11
This commit is contained in:
@@ -3,6 +3,8 @@
|
||||
use base64::{engine::general_purpose::STANDARD, Engine as _};
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
|
||||
use tauri::Manager;
|
||||
|
||||
use crate::ffmpeg;
|
||||
|
||||
fn normalize_path(path: &str) -> PathBuf {
|
||||
@@ -28,6 +30,8 @@ fn is_allowed_local_media(path: &Path) -> bool {
|
||||
|| lower.contains("aiclient-cover-previews")
|
||||
|| lower.contains("video-material")
|
||||
|| lower.contains("avatars")
|
||||
|| lower.contains("voice_clones")
|
||||
|| lower.contains("aiclient-voice-ref")
|
||||
{
|
||||
return true;
|
||||
}
|
||||
@@ -126,6 +130,119 @@ pub async fn copy_local_file(source_path: String, dest_path: String) -> Result<(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 将 base64 写入受信任目录下的二进制文件(如录音 wav)。
|
||||
#[tauri::command]
|
||||
pub async fn write_local_file_base64(path: String, base64_data: String) -> Result<(), String> {
|
||||
let dest = normalize_path(path.trim());
|
||||
if dest.as_os_str().is_empty() {
|
||||
return Err("路径为空".into());
|
||||
}
|
||||
if !is_allowed_local_media(&dest) {
|
||||
return Err("不允许写入该路径".into());
|
||||
}
|
||||
if let Some(parent) = dest.parent() {
|
||||
if !parent.as_os_str().is_empty() {
|
||||
tokio::fs::create_dir_all(parent)
|
||||
.await
|
||||
.map_err(|e| format!("创建目录失败: {e}"))?;
|
||||
}
|
||||
}
|
||||
let bytes = STANDARD
|
||||
.decode(base64_data.trim())
|
||||
.map_err(|e| format!("base64 解码失败: {e}"))?;
|
||||
tokio::fs::write(&dest, &bytes)
|
||||
.await
|
||||
.map_err(|e| format!("保存文件失败: {e}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn voice_clones_dir(app: &tauri::AppHandle) -> Result<PathBuf, String> {
|
||||
let dir = app
|
||||
.path()
|
||||
.app_data_dir()
|
||||
.map_err(|e| format!("无法获取应用数据目录: {e}"))?
|
||||
.join("voice_clones");
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
/// 生成临时参考音频路径(录音/上传中转)。
|
||||
#[tauri::command]
|
||||
pub async fn temp_voice_reference_path(ext: String) -> Result<String, String> {
|
||||
let ext = ext.trim().trim_start_matches('.');
|
||||
let ext = if ext.is_empty() { "webm" } else { ext };
|
||||
let dir = std::env::temp_dir().join("aiclient-voice-ref");
|
||||
tokio::fs::create_dir_all(&dir)
|
||||
.await
|
||||
.map_err(|e| format!("创建临时目录失败: {e}"))?;
|
||||
let name = format!(
|
||||
"ref_{}_{}.{}",
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_millis())
|
||||
.unwrap_or(0),
|
||||
rand_simple(),
|
||||
ext
|
||||
);
|
||||
Ok(dir.join(name).to_string_lossy().into_owned())
|
||||
}
|
||||
|
||||
fn rand_simple() -> u32 {
|
||||
use std::hash::{Hash, Hasher};
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
std::time::SystemTime::now().hash(&mut h);
|
||||
(h.finish() & 0xffff) as u32
|
||||
}
|
||||
|
||||
/// 将参考音频复制到应用数据目录 `voice_clones/{voice_id}{ext}`。
|
||||
#[tauri::command]
|
||||
pub async fn import_voice_clone_reference(
|
||||
app: tauri::AppHandle,
|
||||
source_path: String,
|
||||
voice_id: String,
|
||||
) -> Result<String, String> {
|
||||
let source = normalize_path(source_path.trim());
|
||||
if !source.is_file() {
|
||||
return Err(format!("源文件不存在: {}", source.display()));
|
||||
}
|
||||
let allowed_source = is_allowed_local_media(&source)
|
||||
|| source
|
||||
.to_string_lossy()
|
||||
.to_lowercase()
|
||||
.contains("voice_clones");
|
||||
if !allowed_source {
|
||||
if let Ok(temp) = std::env::temp_dir().canonicalize() {
|
||||
if let Ok(canonical) = source.canonicalize() {
|
||||
if !canonical.starts_with(&temp) {
|
||||
return Err("不允许导入该路径下的文件".into());
|
||||
}
|
||||
} else {
|
||||
return Err("不允许导入该路径下的文件".into());
|
||||
}
|
||||
} else {
|
||||
return Err("不允许导入该路径下的文件".into());
|
||||
}
|
||||
}
|
||||
|
||||
let id = voice_id.trim();
|
||||
if id.is_empty() {
|
||||
return Err("voice_id 为空".into());
|
||||
}
|
||||
let ext = source
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.unwrap_or("wav");
|
||||
let ext = if ext.is_empty() { "wav" } else { ext };
|
||||
let dir = voice_clones_dir(&app)?;
|
||||
tokio::fs::create_dir_all(&dir)
|
||||
.await
|
||||
.map_err(|e| format!("创建目录失败: {e}"))?;
|
||||
let dest = dir.join(format!("{id}.{ext}"));
|
||||
tokio::fs::copy(&source, &dest)
|
||||
.await
|
||||
.map_err(|e| format!("保存参考音频失败: {e}"))?;
|
||||
Ok(dest.to_string_lossy().into_owned())
|
||||
}
|
||||
|
||||
/// 将文本写入用户指定路径(供 ASR 结果「另存为」)。
|
||||
#[tauri::command]
|
||||
pub async fn write_local_text_file(path: String, content: String) -> Result<(), String> {
|
||||
|
||||
@@ -132,9 +132,12 @@ pub fn run() {
|
||||
commands::nodejs::list_nodejs_scripts,
|
||||
commands::nodejs::stop_nodejs_script,
|
||||
commands::fs_util::read_local_file_base64,
|
||||
commands::fs_util::write_local_file_base64,
|
||||
commands::fs_util::get_media_duration_seconds,
|
||||
commands::fs_util::copy_local_file,
|
||||
commands::fs_util::write_local_text_file,
|
||||
commands::fs_util::temp_voice_reference_path,
|
||||
commands::fs_util::import_voice_clone_reference,
|
||||
commands::fs_util::reveal_local_file_in_folder,
|
||||
commands::avatar::list_avatars,
|
||||
commands::avatar::insert_avatar,
|
||||
|
||||
181
src/components/voice/VoiceReferenceRecorder.vue
Normal file
181
src/components/voice/VoiceReferenceRecorder.vue
Normal file
@@ -0,0 +1,181 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch, onBeforeUnmount } from "vue";
|
||||
import { localAudioToPlayableUrl } from "../../services/localAudio.js";
|
||||
import {
|
||||
createTempReferencePath,
|
||||
getAudioDurationSeconds,
|
||||
pickReferenceAudioFile,
|
||||
writeBlobToPath,
|
||||
} from "../../services/voiceReferenceAudio.js";
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: String, default: "" },
|
||||
});
|
||||
|
||||
const emit = defineEmits(["update:modelValue"]);
|
||||
|
||||
const referencePath = ref(props.modelValue || "");
|
||||
const previewSrc = ref("");
|
||||
const recording = ref(false);
|
||||
const recordError = ref("");
|
||||
const durationSec = ref(0);
|
||||
|
||||
let mediaStream = null;
|
||||
let mediaRecorder = null;
|
||||
let recordChunks = [];
|
||||
|
||||
const durationLabel = computed(() => {
|
||||
if (!durationSec.value) return "";
|
||||
return `${durationSec.value.toFixed(1)} 秒`;
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
async (path) => {
|
||||
referencePath.value = path || "";
|
||||
await refreshPreview();
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stopRecording();
|
||||
});
|
||||
|
||||
async function refreshPreview() {
|
||||
const p = referencePath.value;
|
||||
if (!p) {
|
||||
previewSrc.value = "";
|
||||
durationSec.value = 0;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
previewSrc.value = await localAudioToPlayableUrl(p);
|
||||
durationSec.value = await getAudioDurationSeconds(p);
|
||||
} catch {
|
||||
previewSrc.value = "";
|
||||
durationSec.value = 0;
|
||||
}
|
||||
}
|
||||
|
||||
function setPath(path) {
|
||||
referencePath.value = path || "";
|
||||
emit("update:modelValue", referencePath.value);
|
||||
}
|
||||
|
||||
async function onPickFile() {
|
||||
recordError.value = "";
|
||||
const path = await pickReferenceAudioFile();
|
||||
if (!path) return;
|
||||
setPath(path);
|
||||
await refreshPreview();
|
||||
}
|
||||
|
||||
async function startRecording() {
|
||||
recordError.value = "";
|
||||
try {
|
||||
mediaStream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
recordChunks = [];
|
||||
const mimeType = MediaRecorder.isTypeSupported("audio/webm;codecs=opus")
|
||||
? "audio/webm;codecs=opus"
|
||||
: "audio/webm";
|
||||
mediaRecorder = new MediaRecorder(mediaStream, { mimeType });
|
||||
mediaRecorder.ondataavailable = (e) => {
|
||||
if (e.data?.size) recordChunks.push(e.data);
|
||||
};
|
||||
mediaRecorder.onstop = async () => {
|
||||
try {
|
||||
const blob = new Blob(recordChunks, { type: mimeType });
|
||||
const ext = mimeType.includes("webm") ? "webm" : "wav";
|
||||
const tempPath = await createTempReferencePath(ext);
|
||||
await writeBlobToPath(tempPath, blob);
|
||||
setPath(tempPath);
|
||||
await refreshPreview();
|
||||
} catch (err) {
|
||||
recordError.value =
|
||||
err instanceof Error ? err.message : "保存录音失败";
|
||||
}
|
||||
};
|
||||
mediaRecorder.start();
|
||||
recording.value = true;
|
||||
} catch (err) {
|
||||
recordError.value =
|
||||
err instanceof Error ? err.message : "无法访问麦克风,请检查权限";
|
||||
}
|
||||
}
|
||||
|
||||
function stopRecording() {
|
||||
if (mediaRecorder && mediaRecorder.state !== "inactive") {
|
||||
mediaRecorder.stop();
|
||||
}
|
||||
if (mediaStream) {
|
||||
mediaStream.getTracks().forEach((t) => t.stop());
|
||||
mediaStream = null;
|
||||
}
|
||||
mediaRecorder = null;
|
||||
recording.value = false;
|
||||
}
|
||||
|
||||
function toggleRecord() {
|
||||
if (recording.value) stopRecording();
|
||||
else startRecording();
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
async function exportReferencePath() {
|
||||
return referencePath.value;
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
exportReferencePath,
|
||||
getDurationSeconds: () => durationSec.value,
|
||||
refreshPreview,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="voice-reference-recorder space-y-3">
|
||||
<Message severity="info" :closable="false" class="text-sm">
|
||||
参考声音控制在 6~20s,保证声音清晰可见
|
||||
</Message>
|
||||
|
||||
<div
|
||||
class="rounded-lg border border-blue-500/40 bg-slate-900/40 p-3"
|
||||
>
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<Button
|
||||
:label="recording ? '停止录音' : '开始录音'"
|
||||
size="small"
|
||||
:severity="recording ? 'danger' : 'secondary'"
|
||||
:icon="recording ? 'pi pi-stop-circle' : 'pi pi-circle'"
|
||||
@click="toggleRecord"
|
||||
/>
|
||||
<span v-if="durationLabel" class="text-xs text-slate-400">
|
||||
时长 {{ durationLabel }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="previewSrc" class="mt-3">
|
||||
<audio :src="previewSrc" controls class="h-9 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center justify-between gap-2 text-sm text-slate-400">
|
||||
<span class="flex items-center gap-1">
|
||||
<i class="pi pi-info-circle text-xs" />
|
||||
支持 wav/mp3 格式
|
||||
</span>
|
||||
<Button
|
||||
label="选择声音文件"
|
||||
size="small"
|
||||
severity="secondary"
|
||||
icon="pi pi-upload"
|
||||
@click="onPickFile"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p v-if="recordError" class="text-xs text-red-400">{{ recordError }}</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -3,6 +3,7 @@ import { computed, ref } from "vue";
|
||||
import { storeToRefs } from "pinia";
|
||||
import { useWorkflowStore } from "../../stores/workflow.js";
|
||||
import SubtitleTemplateDialog from "../subtitle/SubtitleTemplateDialog.vue";
|
||||
import { revealLocalFileInFolder } from "../../services/fileExport.js";
|
||||
|
||||
const workflow = useWorkflowStore();
|
||||
const {
|
||||
@@ -13,6 +14,8 @@ const {
|
||||
subtitleTemplateId,
|
||||
subtitleBgmGenerating,
|
||||
generatedVideoPath,
|
||||
subtitlePreviewVideoPath,
|
||||
subtitlePreviewVideoSrc,
|
||||
bgmPreviewSrc,
|
||||
} = storeToRefs(workflow);
|
||||
|
||||
@@ -20,6 +23,9 @@ const feedback = ref({ severity: "", message: "" });
|
||||
const templateDialogVisible = ref(false);
|
||||
|
||||
const hasSourceVideo = computed(() => Boolean(generatedVideoPath.value));
|
||||
const hasSubtitleVideoPreview = computed(() =>
|
||||
Boolean(subtitlePreviewVideoSrc.value),
|
||||
);
|
||||
|
||||
const canGenerate = computed(
|
||||
() =>
|
||||
@@ -57,6 +63,21 @@ async function onPreviewBgm() {
|
||||
const result = await workflow.previewBgm();
|
||||
if (result?.message) showFeedback(result);
|
||||
}
|
||||
|
||||
async function onOpenVideoInExplorer() {
|
||||
if (!subtitlePreviewVideoPath.value) {
|
||||
feedback.value = { severity: "error", message: "暂无字幕预览视频" };
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await revealLocalFileInFolder(subtitlePreviewVideoPath.value);
|
||||
} catch (err) {
|
||||
feedback.value = {
|
||||
severity: "error",
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -154,6 +175,33 @@ async function onPreviewBgm() {
|
||||
@click="onGenerate"
|
||||
/>
|
||||
|
||||
<div class="dashboard-preview-box mt-3">
|
||||
<div class="mb-2 flex items-center gap-0.5">
|
||||
<span class="text-xs text-slate-400">字幕视频预览</span>
|
||||
<Button
|
||||
icon="pi pi-folder-open"
|
||||
size="small"
|
||||
severity="secondary"
|
||||
text
|
||||
:disabled="!subtitlePreviewVideoPath"
|
||||
aria-label="在资源管理器中打开"
|
||||
@click="onOpenVideoInExplorer"
|
||||
/>
|
||||
</div>
|
||||
<div class="dashboard-aspect-video">
|
||||
<video
|
||||
v-if="hasSubtitleVideoPreview"
|
||||
:key="subtitlePreviewVideoSrc"
|
||||
:src="subtitlePreviewVideoSrc"
|
||||
controls
|
||||
class="h-full w-full rounded object-contain"
|
||||
/>
|
||||
<span v-else class="text-xs text-gray-400">
|
||||
{{ hasSourceVideo ? "生成字幕后将在此预览" : "请先在步骤 02 生成口播视频" }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SubtitleTemplateDialog
|
||||
v-model:visible="templateDialogVisible"
|
||||
v-model:selected-id="subtitleTemplateId"
|
||||
|
||||
@@ -5,6 +5,16 @@ import {
|
||||
updateCloneVoice,
|
||||
cloneVoiceTitleExists,
|
||||
} from "../../services/soundPromptStorage.js";
|
||||
import {
|
||||
persistVoiceReference,
|
||||
validateReferenceDuration,
|
||||
} from "../../services/voiceReferenceAudio.js";
|
||||
import { enrollCloneVoice } from "../../services/voiceCloneEnroll.js";
|
||||
import {
|
||||
stripVoiceIdPrefix,
|
||||
formatVoiceIdWithPrefix,
|
||||
} from "../../utils/voiceIdPrefix.js";
|
||||
import VoiceReferenceRecorder from "../voice/VoiceReferenceRecorder.vue";
|
||||
|
||||
const emit = defineEmits(["saved"]);
|
||||
|
||||
@@ -12,17 +22,28 @@ const visible = ref(false);
|
||||
const editingId = ref(null);
|
||||
const name = ref("");
|
||||
const promptText = ref("");
|
||||
const aliyunVoiceId = ref("");
|
||||
const referencePath = ref("");
|
||||
const voiceIdInput = ref("");
|
||||
const saving = ref(false);
|
||||
const enrolling = ref(false);
|
||||
const errorMessage = ref("");
|
||||
const recorderRef = ref(null);
|
||||
|
||||
const isEdit = computed(() => Boolean(editingId.value));
|
||||
|
||||
const voiceIdModel = computed({
|
||||
get: () => voiceIdInput.value,
|
||||
set: (v) => {
|
||||
voiceIdInput.value = v;
|
||||
},
|
||||
});
|
||||
|
||||
function resetForm() {
|
||||
editingId.value = null;
|
||||
name.value = "";
|
||||
promptText.value = "";
|
||||
aliyunVoiceId.value = "";
|
||||
referencePath.value = "";
|
||||
voiceIdInput.value = "";
|
||||
errorMessage.value = "";
|
||||
}
|
||||
|
||||
@@ -32,13 +53,14 @@ function openAdd() {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ id: string, title: string, content?: { promptText?: string, aliyunVoiceId?: string } }} record
|
||||
* @param {{ id: string, title: string, content?: { promptText?: string, aliyunVoiceId?: string, url?: string } }} record
|
||||
*/
|
||||
function openEdit(record) {
|
||||
editingId.value = record.id;
|
||||
name.value = record.title || "";
|
||||
promptText.value = record.content?.promptText || "";
|
||||
aliyunVoiceId.value = record.content?.aliyunVoiceId || "";
|
||||
referencePath.value = record.content?.url || "";
|
||||
voiceIdInput.value = stripVoiceIdPrefix(record.content?.aliyunVoiceId || "");
|
||||
errorMessage.value = "";
|
||||
visible.value = true;
|
||||
}
|
||||
@@ -47,11 +69,86 @@ function onCancel() {
|
||||
visible.value = false;
|
||||
}
|
||||
|
||||
async function onSave() {
|
||||
async function resolveReferenceForSave() {
|
||||
const path =
|
||||
referencePath.value ||
|
||||
(await recorderRef.value?.exportReferencePath?.()) ||
|
||||
"";
|
||||
if (!path) {
|
||||
return { ok: false, message: "请录制声音或选择声音文件" };
|
||||
}
|
||||
const duration = await recorderRef.value?.getDurationSeconds?.();
|
||||
const check = validateReferenceDuration(duration);
|
||||
if (!check.ok) return check;
|
||||
|
||||
const storageId =
|
||||
editingId.value ||
|
||||
`clone_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
try {
|
||||
const persisted = await persistVoiceReference(path, storageId);
|
||||
return { ok: true, path: persisted, storageId };
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
message: err instanceof Error ? err.message : "保存参考音频失败",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function onEnroll() {
|
||||
errorMessage.value = "";
|
||||
const title = name.value.trim();
|
||||
if (!title) {
|
||||
errorMessage.value = "请输入音色名称";
|
||||
errorMessage.value = "请先输入音色名称";
|
||||
return;
|
||||
}
|
||||
|
||||
const path =
|
||||
referencePath.value ||
|
||||
(await recorderRef.value?.exportReferencePath?.()) ||
|
||||
"";
|
||||
if (!path) {
|
||||
errorMessage.value = "请先录制或上传音频";
|
||||
return;
|
||||
}
|
||||
|
||||
const duration = await recorderRef.value?.getDurationSeconds?.();
|
||||
const check = validateReferenceDuration(duration, { forEnroll: true });
|
||||
if (!check.ok) {
|
||||
errorMessage.value = check.message;
|
||||
return;
|
||||
}
|
||||
|
||||
enrolling.value = true;
|
||||
try {
|
||||
const result = await enrollCloneVoice({
|
||||
name: title,
|
||||
audioPath: path,
|
||||
existingVoiceId: voiceIdInput.value
|
||||
? formatVoiceIdWithPrefix(voiceIdInput.value)
|
||||
: "",
|
||||
});
|
||||
if (!result.ok) {
|
||||
errorMessage.value = result.message;
|
||||
return;
|
||||
}
|
||||
voiceIdInput.value = stripVoiceIdPrefix(result.voiceId);
|
||||
errorMessage.value = "";
|
||||
// 成功提示由 message 展示
|
||||
successFlash.value = result.message;
|
||||
} finally {
|
||||
enrolling.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const successFlash = ref("");
|
||||
|
||||
async function onSave() {
|
||||
errorMessage.value = "";
|
||||
successFlash.value = "";
|
||||
const title = name.value.trim();
|
||||
if (!title) {
|
||||
errorMessage.value = "请输入名称";
|
||||
return;
|
||||
}
|
||||
if (cloneVoiceTitleExists(title, editingId.value)) {
|
||||
@@ -61,19 +158,32 @@ async function onSave() {
|
||||
|
||||
saving.value = true;
|
||||
try {
|
||||
const refResult = await resolveReferenceForSave();
|
||||
if (!refResult.ok) {
|
||||
errorMessage.value = refResult.message;
|
||||
return;
|
||||
}
|
||||
|
||||
const aliyunVoiceId = formatVoiceIdWithPrefix(
|
||||
voiceIdInput.value.trim(),
|
||||
voiceIdInput.value,
|
||||
);
|
||||
|
||||
const payload = {
|
||||
title,
|
||||
content: {
|
||||
promptText: promptText.value.trim(),
|
||||
aliyunVoiceId: aliyunVoiceId.value.trim(),
|
||||
url: "",
|
||||
aliyunVoiceId,
|
||||
url: refResult.path,
|
||||
},
|
||||
};
|
||||
|
||||
if (editingId.value) {
|
||||
updateCloneVoice(editingId.value, payload);
|
||||
} else {
|
||||
addCloneVoice(payload);
|
||||
addCloneVoice(payload, refResult.storageId);
|
||||
}
|
||||
|
||||
visible.value = false;
|
||||
emit("saved");
|
||||
} catch (err) {
|
||||
@@ -92,42 +202,103 @@ defineExpose({ openAdd, openEdit });
|
||||
v-model:visible="visible"
|
||||
modal
|
||||
:header="isEdit ? '编辑音色' : '添加音色'"
|
||||
:style="{ width: '520px' }"
|
||||
:style="{ width: '800px' }"
|
||||
:draggable="false"
|
||||
class="voice-clone-edit-dialog"
|
||||
>
|
||||
<div class="space-y-3">
|
||||
<div class="max-h-[60vh] overflow-y-auto">
|
||||
<div class="flex flex-col gap-4 lg:flex-row">
|
||||
<div class="w-full shrink-0 space-y-4 lg:w-1/2 lg:pr-4">
|
||||
<div>
|
||||
<div class="dashboard-field-label mb-1">名称 <span class="text-red-400">*</span></div>
|
||||
<InputText v-model="name" class="w-full" size="small" placeholder="例如:我的克隆音色" />
|
||||
<div class="dashboard-field-label mb-1">
|
||||
名称 <span class="text-red-400">*</span>
|
||||
</div>
|
||||
<InputText
|
||||
v-model="name"
|
||||
class="w-full"
|
||||
size="small"
|
||||
placeholder="例如:我的克隆音色"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="dashboard-field-label mb-1">
|
||||
参考声音 <span class="text-red-400">*</span>
|
||||
</div>
|
||||
<VoiceReferenceRecorder
|
||||
ref="recorderRef"
|
||||
v-model="referencePath"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="dashboard-field-label mb-1">参考文字</div>
|
||||
<InputText
|
||||
v-model="promptText"
|
||||
class="w-full"
|
||||
size="small"
|
||||
placeholder="可选,部分模型需要参考文字"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-slate-500">部分模型需要使用,可选填写参考声音的文字内容</p>
|
||||
</div>
|
||||
<div>
|
||||
<div class="dashboard-field-label mb-1">音色 ID(可选)</div>
|
||||
<InputText
|
||||
v-model="aliyunVoiceId"
|
||||
class="w-full"
|
||||
size="small"
|
||||
placeholder="复刻成功后填入,留空则使用默认音色"
|
||||
placeholder="可选"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-slate-500">
|
||||
仅限复刻音色。参考音频录制与一键复刻功能后续版本支持。
|
||||
部分模型需要使用,可选填写参考声音的文字内容
|
||||
</p>
|
||||
</div>
|
||||
<p v-if="errorMessage" class="text-sm text-red-400">{{ errorMessage }}</p>
|
||||
|
||||
<div>
|
||||
<div class="dashboard-field-label mb-1">音色 ID(可选)</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<InputText
|
||||
v-model="voiceIdModel"
|
||||
class="min-w-0 flex-1"
|
||||
size="small"
|
||||
placeholder="1111-voice-202603232"
|
||||
/>
|
||||
<Button
|
||||
label="一键复刻"
|
||||
size="small"
|
||||
severity="success"
|
||||
icon="pi pi-bolt"
|
||||
:loading="enrolling"
|
||||
:disabled="enrolling || saving"
|
||||
@click="onEnroll"
|
||||
/>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-slate-500">
|
||||
仅限复刻音色。如果没有复刻 ID,请留空(将使用默认音色)。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="text-lg font-bold text-slate-100">音色说明</div>
|
||||
<div
|
||||
class="mt-2 rounded-lg bg-slate-800/60 p-3 text-xs leading-6 text-slate-300"
|
||||
>
|
||||
<div>1. 请在安静的环境下进行录音,避免噪音干扰</div>
|
||||
<div>2. 请使用标准普通话,吐字清晰,语速适当</div>
|
||||
<div>3. 录音时长控制在 6~20秒 最佳,最多不超过 20 秒</div>
|
||||
<div>4. 录制完成后先试听看是否达到要求再提交</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p
|
||||
v-if="successFlash"
|
||||
class="mt-3 text-sm text-emerald-400"
|
||||
>
|
||||
{{ successFlash }}
|
||||
</p>
|
||||
<p
|
||||
v-if="errorMessage"
|
||||
class="mt-3 text-sm text-red-400"
|
||||
>
|
||||
{{ errorMessage }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<Button label="取消" severity="secondary" @click="onCancel" />
|
||||
<Button label="保存" :loading="saving" @click="onSave" />
|
||||
<Button label="保存" :loading="saving" :disabled="enrolling" @click="onSave" />
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
deleteCloneVoice,
|
||||
} from "../../services/soundPromptStorage.js";
|
||||
import { previewVoice } from "../../services/voicePreview.js";
|
||||
import { stripVoiceIdPrefix } from "../../utils/voiceIdPrefix.js";
|
||||
import VoiceCloneEditDialog from "./VoiceCloneEditDialog.vue";
|
||||
|
||||
const props = defineProps({
|
||||
@@ -140,10 +141,6 @@ function onDeleteClone(record) {
|
||||
loadCloneVoices();
|
||||
}
|
||||
|
||||
function stripVoiceIdPrefix(id) {
|
||||
return String(id || "").replace(/^cosyvoice-v3-flash-/, "");
|
||||
}
|
||||
|
||||
async function runPreview(opts) {
|
||||
previewError.value = "";
|
||||
previewingKey.value = opts.listKey;
|
||||
|
||||
@@ -27,12 +27,15 @@ function saveCloneVoices(list) {
|
||||
|
||||
/**
|
||||
* @param {{ title: string, content?: SoundPromptRecord['content'] }} payload
|
||||
* @param {string} [presetId] 可选,与参考音频文件名一致
|
||||
* @returns {SoundPromptRecord}
|
||||
*/
|
||||
export function addCloneVoice(payload) {
|
||||
export function addCloneVoice(payload, presetId = null) {
|
||||
const list = listCloneVoices();
|
||||
const record = {
|
||||
id: `clone_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
|
||||
id:
|
||||
presetId ||
|
||||
`clone_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
|
||||
title: String(payload.title || "").trim(),
|
||||
content: {
|
||||
url: payload.content?.url || "",
|
||||
|
||||
@@ -2,16 +2,18 @@ import { invoke } from "@tauri-apps/api/core";
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
|
||||
import { ensureAppConfigReady } from "../config/videoPipeline.js";
|
||||
import {
|
||||
buildFfmpegForceStyle,
|
||||
getSubtitleTemplate,
|
||||
} from "../config/subtitleTemplates.js";
|
||||
import { getSubtitleTemplate } from "../config/subtitleTemplates.js";
|
||||
import { getSubtitleTemplateById } from "./subtitleTemplateCatalog.js";
|
||||
import { templateToFfmpegStyle } from "../utils/subtitlePreviewStyle.js";
|
||||
import { importGeneratedVideo } from "./videoDb.js";
|
||||
import { buildSubtitleBurnStyle } from "../utils/subtitlePreviewStyle.js";
|
||||
import { localAudioToPlayableUrl } from "./localAudio.js";
|
||||
import { localVideoToPlayableUrl } from "./localVideo.js";
|
||||
|
||||
/** @param {ReturnType<typeof import('../stores/workflow.js').useWorkflowStore>} store */
|
||||
export function clearSubtitlePreview(store) {
|
||||
store.subtitlePreviewVideoPath = "";
|
||||
store.subtitlePreviewVideoSrc = "";
|
||||
}
|
||||
|
||||
const SUBTITLE_BGM_SCRIPT = "subtitle_bgm_generate.js";
|
||||
|
||||
/**
|
||||
@@ -32,12 +34,6 @@ export async function pickBgmFile() {
|
||||
return String(selected);
|
||||
}
|
||||
|
||||
function formatHistoryTime() {
|
||||
const d = new Date();
|
||||
const p = (n) => String(n).padStart(2, "0");
|
||||
return `${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ReturnType<typeof import('../stores/workflow.js').useWorkflowStore>} store
|
||||
*/
|
||||
@@ -63,16 +59,29 @@ export async function executeGenerateSubtitleAndBgm(store) {
|
||||
}
|
||||
|
||||
const templateId = store.subtitleTemplateId || "template_system_11";
|
||||
let tpl = getSubtitleTemplate(templateId);
|
||||
const fullTemplate =
|
||||
store.subtitleTemplate?.id === templateId
|
||||
? store.subtitleTemplate
|
||||
: await getSubtitleTemplateById(templateId);
|
||||
const fromSystem = fullTemplate ? templateToFfmpegStyle(fullTemplate) : null;
|
||||
if (fromSystem) tpl = fromSystem;
|
||||
const subtitleForceStyle = buildFfmpegForceStyle(tpl);
|
||||
let subtitleStyle = fullTemplate ? buildSubtitleBurnStyle(fullTemplate) : null;
|
||||
if (!subtitleStyle) {
|
||||
const fallback = getSubtitleTemplate(templateId);
|
||||
subtitleStyle = {
|
||||
fontName: fallback.fontName,
|
||||
fontSize: fallback.fontSize,
|
||||
fontColor: fallback.primaryColor,
|
||||
outlineColor: fallback.outlineColor,
|
||||
outlineWidth: fallback.outline,
|
||||
backgroundColor: "transparent",
|
||||
backgroundOpacity: 0,
|
||||
position: "bottom",
|
||||
fontSizeScale: 100,
|
||||
maxCharsPerLine: 20,
|
||||
};
|
||||
}
|
||||
|
||||
store.subtitleBgmGenerating = true;
|
||||
clearSubtitlePreview(store);
|
||||
|
||||
try {
|
||||
const result = await invoke("run_nodejs_script", {
|
||||
@@ -82,7 +91,7 @@ export async function executeGenerateSubtitleAndBgm(store) {
|
||||
autoSubtitle: store.autoSubtitle,
|
||||
smartSubtitle: store.smartSubtitle,
|
||||
scriptContent: store.scriptContent || "",
|
||||
subtitleForceStyle,
|
||||
subtitleStyle,
|
||||
bgmEnabled: store.bgmEnabled,
|
||||
bgmPath: store.bgmPath || null,
|
||||
bgmVolume: store.bgmVolume,
|
||||
@@ -100,24 +109,11 @@ export async function executeGenerateSubtitleAndBgm(store) {
|
||||
store.subtitleSrtPath = result.srtPath;
|
||||
}
|
||||
|
||||
const baseName =
|
||||
store.videoHistory.find((v) => v.id === store.currentVideoId)?.name ||
|
||||
"口播视频";
|
||||
const displayName = `${baseName} · 字幕BGM · ${formatHistoryTime()}`;
|
||||
const record = await importGeneratedVideo(
|
||||
result.videoPath,
|
||||
displayName,
|
||||
store.avatarSelect ?? null,
|
||||
store.currentAudioId ?? null,
|
||||
);
|
||||
const videoSrc = localVideoToPlayableUrl(record.filePath);
|
||||
|
||||
store.generatedVideoPath = record.filePath;
|
||||
store.generatedVideoSrc = videoSrc;
|
||||
store.processedVideoPath = record.filePath;
|
||||
store.processedVideoSrc = videoSrc;
|
||||
store.currentVideoId = record.id;
|
||||
await store.refreshVideoHistory();
|
||||
const previewPath = String(result.videoPath || "").trim();
|
||||
store.subtitlePreviewVideoPath = previewPath;
|
||||
store.subtitlePreviewVideoSrc = previewPath
|
||||
? localVideoToPlayableUrl(previewPath)
|
||||
: "";
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
import { loadAppConfigMap } from "../config/videoPipeline.js";
|
||||
import { useAvatarStore } from "../stores/avatar.js";
|
||||
import { clearSubtitlePreview } from "./subtitleBgmGenerate.js";
|
||||
import { importGeneratedVideo } from "./videoDb.js";
|
||||
import { localVideoToPlayableUrl } from "./localVideo.js";
|
||||
import { getMediaDurationSeconds } from "./mediaDuration.js";
|
||||
@@ -92,6 +93,7 @@ export async function executeGenerateTalkingVideo(store) {
|
||||
store.generatedVideoPath = record.filePath;
|
||||
store.generatedVideoSrc = videoSrc;
|
||||
store.currentVideoId = record.id;
|
||||
clearSubtitlePreview(store);
|
||||
await store.refreshVideoHistory();
|
||||
|
||||
return { ok: true, message: "口播视频生成完成" };
|
||||
@@ -184,4 +186,5 @@ export async function selectVideoHistory(store, videoId) {
|
||||
item.videoSrc || localVideoToPlayableUrl(item.filePath);
|
||||
store.processedVideoPath = "";
|
||||
store.processedVideoSrc = "";
|
||||
clearSubtitlePreview(store);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
|
||||
import { clearSubtitlePreview } from "./subtitleBgmGenerate.js";
|
||||
import { importGeneratedVideo } from "./videoDb.js";
|
||||
import { localVideoToPlayableUrl } from "./localVideo.js";
|
||||
|
||||
@@ -162,6 +163,7 @@ export async function executeAutoProcessVideo(store) {
|
||||
store.processedVideoPath = record.filePath;
|
||||
store.processedVideoSrc = videoSrc;
|
||||
store.currentVideoId = record.id;
|
||||
clearSubtitlePreview(store);
|
||||
await store.refreshVideoHistory();
|
||||
|
||||
return {
|
||||
|
||||
46
src/services/voiceCloneEnroll.js
Normal file
46
src/services/voiceCloneEnroll.js
Normal file
@@ -0,0 +1,46 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
import { ensureAppConfigReady } from "../config/videoPipeline.js";
|
||||
import { formatVoiceIdWithPrefix } from "../utils/voiceIdPrefix.js";
|
||||
|
||||
const VOICE_CLONE_ENROLL_SCRIPT = "voice_clone_enroll.js";
|
||||
|
||||
/**
|
||||
* @param {{ name: string, audioPath: string, existingVoiceId?: string }} opts
|
||||
*/
|
||||
export async function enrollCloneVoice(opts) {
|
||||
const name = String(opts.name || "").trim();
|
||||
const audioPath = String(opts.audioPath || "").trim();
|
||||
if (!name) return { ok: false, message: "请先输入音色名称" };
|
||||
if (!audioPath) return { ok: false, message: "请先录制或上传参考声音" };
|
||||
|
||||
const cfg = await ensureAppConfigReady();
|
||||
if (!cfg.ok) return { ok: false, message: cfg.message };
|
||||
|
||||
try {
|
||||
const result = await invoke("run_nodejs_script", {
|
||||
scriptName: VOICE_CLONE_ENROLL_SCRIPT,
|
||||
params: { audioPath, name },
|
||||
});
|
||||
|
||||
if (!result?.success || !result?.voiceId) {
|
||||
return {
|
||||
ok: false,
|
||||
message: String(result?.error || "复刻失败,请重试"),
|
||||
};
|
||||
}
|
||||
|
||||
const voiceId = formatVoiceIdWithPrefix(
|
||||
result.voiceId,
|
||||
opts.existingVoiceId || result.voiceId,
|
||||
);
|
||||
return {
|
||||
ok: true,
|
||||
voiceId,
|
||||
message: "复刻成功!音色 ID 已自动填入",
|
||||
};
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err || "未知错误");
|
||||
return { ok: false, message: `调用复刻服务异常:${msg}` };
|
||||
}
|
||||
}
|
||||
102
src/services/voiceReferenceAudio.js
Normal file
102
src/services/voiceReferenceAudio.js
Normal file
@@ -0,0 +1,102 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
import { isTauri } from "@tauri-apps/api/core";
|
||||
|
||||
const REF_MIN_SEC = 6;
|
||||
const REF_MAX_SEC = 20;
|
||||
const ENROLL_MAX_SEC = 60;
|
||||
|
||||
/**
|
||||
* @param {string} filePath
|
||||
*/
|
||||
export async function getAudioDurationSeconds(filePath) {
|
||||
if (!filePath || !isTauri()) return 0;
|
||||
try {
|
||||
return await invoke("get_media_duration_seconds", { path: filePath });
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} durationSec
|
||||
* @param {{ forEnroll?: boolean }} [opts]
|
||||
*/
|
||||
export function validateReferenceDuration(durationSec, opts = {}) {
|
||||
const d = Number(durationSec) || 0;
|
||||
const max = opts.forEnroll ? ENROLL_MAX_SEC : REF_MAX_SEC;
|
||||
if (d < REF_MIN_SEC) {
|
||||
return {
|
||||
ok: false,
|
||||
message: opts.forEnroll
|
||||
? `复刻音频时长需在 ${REF_MIN_SEC}~${ENROLL_MAX_SEC} 秒之间`
|
||||
: `参考声音需要大于 ${REF_MIN_SEC} 秒小于 ${REF_MAX_SEC} 秒,保证声音清晰可见`,
|
||||
};
|
||||
}
|
||||
if (d > max) {
|
||||
return {
|
||||
ok: false,
|
||||
message: opts.forEnroll
|
||||
? `复刻音频时长需在 ${REF_MIN_SEC}~${ENROLL_MAX_SEC} 秒之间`
|
||||
: `参考声音需要大于 ${REF_MIN_SEC} 秒小于 ${REF_MAX_SEC} 秒,保证声音清晰可见`,
|
||||
};
|
||||
}
|
||||
return { ok: true, message: "" };
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Promise<string | null>}
|
||||
*/
|
||||
export async function pickReferenceAudioFile() {
|
||||
const selected = await open({
|
||||
multiple: false,
|
||||
filters: [
|
||||
{
|
||||
name: "音频",
|
||||
extensions: ["wav", "mp3", "m4a", "flac", "ogg", "webm"],
|
||||
},
|
||||
],
|
||||
});
|
||||
if (selected == null) return null;
|
||||
if (Array.isArray(selected)) return selected[0] || null;
|
||||
return String(selected);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将临时录音/上传文件持久化到 app_data/voice_clones
|
||||
* @param {string} sourcePath
|
||||
* @param {string} voiceId
|
||||
*/
|
||||
export async function persistVoiceReference(sourcePath, voiceId) {
|
||||
return invoke("import_voice_clone_reference", {
|
||||
sourcePath,
|
||||
voiceId,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} destPath
|
||||
* @param {Blob} blob
|
||||
*/
|
||||
export async function writeBlobToPath(destPath, blob) {
|
||||
const buf = await blob.arrayBuffer();
|
||||
const bytes = new Uint8Array(buf);
|
||||
let binary = "";
|
||||
const chunk = 0x8000;
|
||||
for (let i = 0; i < bytes.length; i += chunk) {
|
||||
binary += String.fromCharCode(...bytes.subarray(i, i + chunk));
|
||||
}
|
||||
const base64 = btoa(binary);
|
||||
await invoke("write_local_file_base64", {
|
||||
path: destPath,
|
||||
base64Data: base64,
|
||||
});
|
||||
return destPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} [ext]
|
||||
*/
|
||||
export async function createTempReferencePath(ext = "webm") {
|
||||
return invoke("temp_voice_reference_path", { ext });
|
||||
}
|
||||
@@ -276,6 +276,11 @@ export const useWorkflowStore = defineStore("workflow", {
|
||||
|
||||
subtitleSrtPath: "",
|
||||
|
||||
/** 步骤 05 字幕/BGM 预览(临时目录,不入库) */
|
||||
subtitlePreviewVideoPath: "",
|
||||
|
||||
subtitlePreviewVideoSrc: "",
|
||||
|
||||
bgmPath: "",
|
||||
|
||||
bgmPreviewSrc: "",
|
||||
|
||||
@@ -253,14 +253,37 @@ export function resolveSubtitlePreviewImage(path) {
|
||||
* @param {import('../services/subtitleTemplateCatalog.js').SubtitleTemplateItem} template
|
||||
*/
|
||||
export function templateToFfmpegStyle(template) {
|
||||
const style = template?.config?.subtitleStyle;
|
||||
if (!style) return null;
|
||||
const burn = buildSubtitleBurnStyle(template);
|
||||
if (!burn) return null;
|
||||
return {
|
||||
fontName: String(style.fontName || "Microsoft YaHei"),
|
||||
fontSize: Number(style.fontSize) || 24,
|
||||
primaryColor: String(style.fontColor || "#FFFFFF"),
|
||||
outlineColor: String(style.outlineColor || "#000000"),
|
||||
outline: Number(style.outlineWidth) || 2,
|
||||
fontName: burn.fontName,
|
||||
fontSize: burn.fontSize,
|
||||
primaryColor: burn.fontColor,
|
||||
outlineColor: burn.outlineColor,
|
||||
outline: burn.outlineWidth,
|
||||
marginV: 40,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 烧录字幕参数(对齐 Electron ZimuShengcheng:PlayRes=视频尺寸、每行字数限制)
|
||||
* @param {import('../services/subtitleTemplateCatalog.js').SubtitleTemplateItem | null | undefined} template
|
||||
*/
|
||||
export function buildSubtitleBurnStyle(template) {
|
||||
const style = template?.config?.subtitleStyle;
|
||||
if (!style) return null;
|
||||
const outlineWidth = Number(style.outlineWidth);
|
||||
return {
|
||||
fontName: String(style.fontName || "Microsoft YaHei"),
|
||||
fontSize: Number(style.fontSize) || 35,
|
||||
fontColor: String(style.fontColor || "#FFFFFF"),
|
||||
outlineColor: String(style.outlineColor || "#000000"),
|
||||
outlineWidth: Number.isFinite(outlineWidth) ? outlineWidth : 2,
|
||||
backgroundColor: String(style.backgroundColor || "transparent"),
|
||||
backgroundOpacity: Number(style.backgroundOpacity) ?? 0,
|
||||
position:
|
||||
template?.config?.subtitlePosition || style.position || "bottom",
|
||||
fontSizeScale: Number(template?.config?.subtitleFontSizeScale) || 100,
|
||||
maxCharsPerLine: 20,
|
||||
};
|
||||
}
|
||||
|
||||
25
src/utils/voiceIdPrefix.js
Normal file
25
src/utils/voiceIdPrefix.js
Normal file
@@ -0,0 +1,25 @@
|
||||
/** 克隆音色 ID 前缀(对齐 Electron SoundPromptEditDialog) */
|
||||
const VOICE_ID_PREFIXES = ["qwen-tts-vc-", "cosyvoice-v3-flash-"];
|
||||
|
||||
/**
|
||||
* @param {string} voiceId
|
||||
*/
|
||||
export function stripVoiceIdPrefix(voiceId) {
|
||||
const raw = String(voiceId || "");
|
||||
const prefix = VOICE_ID_PREFIXES.find((p) => raw.startsWith(p));
|
||||
return prefix ? raw.slice(prefix.length) : raw;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} value
|
||||
* @param {string} [existingFullId]
|
||||
*/
|
||||
export function formatVoiceIdWithPrefix(value, existingFullId = "") {
|
||||
const raw = String(value || "").trim();
|
||||
if (!raw) return "";
|
||||
if (VOICE_ID_PREFIXES.some((p) => raw.startsWith(p))) return raw;
|
||||
const existing = String(existingFullId || "");
|
||||
const prefix =
|
||||
VOICE_ID_PREFIXES.find((p) => existing.startsWith(p)) || "qwen-tts-vc-";
|
||||
return `${prefix}${raw}`;
|
||||
}
|
||||
Reference in New Issue
Block a user