This commit is contained in:
fengchuanhn@gmail.com
2026-05-18 10:18:49 +08:00
parent beb5eaeb42
commit 6cc305f5e8
5 changed files with 253 additions and 5 deletions

View File

@@ -0,0 +1,46 @@
//! 本地文件读取(供前端播放 Node 生成的临时音频等)
use base64::{engine::general_purpose::STANDARD, Engine as _};
use std::path::{Component, Path, PathBuf};
fn normalize_path(path: &str) -> PathBuf {
Path::new(path)
.components()
.filter(|c| !matches!(c, Component::ParentDir))
.collect()
}
/// 仅允许读取临时目录下的试听/流水线产物,避免任意路径读取。
fn is_allowed_local_media(path: &Path) -> bool {
let lower = path.to_string_lossy().to_lowercase();
if lower.contains("aiclient-voice-preview-cache")
|| lower.contains("aiclient-node-pipeline")
{
return true;
}
if let Ok(temp) = std::env::temp_dir().canonicalize() {
if let Ok(canonical) = path.canonicalize() {
return canonical.starts_with(&temp);
}
}
false
}
/// 读取本地文件为 base64用于 `<audio src="data:audio/wav;base64,...">`
#[tauri::command]
pub async fn read_local_file_base64(path: String) -> Result<String, String> {
let normalized = normalize_path(path.trim());
if normalized.as_os_str().is_empty() {
return Err("路径为空".into());
}
if !normalized.is_file() {
return Err(format!("文件不存在: {}", normalized.display()));
}
if !is_allowed_local_media(&normalized) {
return Err("不允许读取该路径下的文件".into());
}
let bytes = tokio::fs::read(&normalized)
.await
.map_err(|e| format!("读取文件失败: {e}"))?;
Ok(STANDARD.encode(bytes))
}

View File

@@ -1,5 +1,6 @@
pub mod api;
pub mod app_config;
pub mod auth;
pub mod fs_util;
pub mod nodejs;
pub mod quickjs;

View File

@@ -60,6 +60,7 @@ pub fn run() {
commands::nodejs::run_nodejs_script,
commands::nodejs::run_nodejs_script_source,
commands::nodejs::list_nodejs_scripts,
commands::fs_util::read_local_file_base64,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");

View File

@@ -10,6 +10,7 @@ import {
listCloneVoices,
deleteCloneVoice,
} from "../../services/soundPromptStorage.js";
import { previewVoice } from "../../services/voicePreview.js";
import VoiceCloneEditDialog from "./VoiceCloneEditDialog.vue";
const workflow = useWorkflowStore();
@@ -21,6 +22,12 @@ const cloneVoices = ref([]);
const cloneLoading = ref(false);
const editDialogRef = ref(null);
const previewingKey = ref(null);
const previewPanelKey = ref(null);
const previewAudioSrc = ref("");
const previewError = ref("");
const audioRef = ref(null);
const filteredSystemVoices = computed(() =>
filterSystemVoices(voiceCategory.value),
);
@@ -39,9 +46,22 @@ watch(voiceManageModalVisible, (open) => {
activeTab.value = "system";
voiceCategory.value = "putonghua";
loadCloneVoices();
} else {
stopPreview();
}
});
function stopPreview() {
previewingKey.value = null;
previewPanelKey.value = null;
previewAudioSrc.value = "";
previewError.value = "";
if (audioRef.value) {
audioRef.value.pause();
audioRef.value.removeAttribute("src");
}
}
function onClose() {
workflow.closeVoiceManageDialog();
}
@@ -64,12 +84,62 @@ function onDeleteClone(record) {
if (selectedVoiceId.value === record.id) {
workflow.selectVoice("sys_Cherry");
}
if (previewPanelKey.value === record.id) {
stopPreview();
}
loadCloneVoices();
}
function stripVoiceIdPrefix(id) {
return String(id || "").replace(/^cosyvoice-v3-flash-/, "");
}
async function runPreview(opts) {
previewError.value = "";
previewingKey.value = opts.listKey;
const result = await previewVoice(opts);
previewingKey.value = null;
if (!result.ok) {
previewError.value = result.message;
return;
}
previewPanelKey.value = opts.listKey;
previewAudioSrc.value = result.audioSrc;
requestAnimationFrame(() => {
audioRef.value?.play()?.catch(() => {});
});
}
function onPreviewSystem(voice) {
runPreview({
listKey: voice.id,
title: voice.title,
aliyunVoiceId: voice.aliyunVoiceId,
});
}
function onPreviewClone(item) {
const voiceId = String(item.content?.aliyunVoiceId || "").trim();
if (!voiceId) {
previewError.value = "请先配置音色 ID 后再试听";
return;
}
runPreview({
listKey: item.id,
title: item.title,
aliyunVoiceId: voiceId,
});
}
function isPreviewing(key) {
return previewingKey.value === key;
}
function showPreviewPlayer(key) {
return previewPanelKey.value === key && previewAudioSrc.value;
}
</script>
<template>
@@ -82,6 +152,8 @@ function stripVoiceIdPrefix(id) {
class="voice-manage-dialog"
@hide="onClose"
>
<audio ref="audioRef" class="hidden" :src="previewAudioSrc || undefined" />
<Tabs v-model:value="activeTab">
<TabList>
<Tab value="system">系统音色</Tab>
@@ -113,6 +185,16 @@ function stripVoiceIdPrefix(id) {
</div>
<p class="mt-2 text-xs text-slate-500">音色ID: {{ voice.aliyunVoiceId }}</p>
</div>
<div class="flex shrink-0 gap-1">
<Button
label="试听"
size="small"
severity="secondary"
icon="pi pi-volume-up"
:loading="isPreviewing(voice.id)"
:disabled="Boolean(previewingKey && !isPreviewing(voice.id))"
@click="onPreviewSystem(voice)"
/>
<Button
label="选择"
size="small"
@@ -121,6 +203,18 @@ function stripVoiceIdPrefix(id) {
/>
</div>
</div>
<div
v-if="showPreviewPlayer(voice.id)"
class="mt-2 rounded-lg border border-blue-500/30 bg-blue-500/10 px-2 py-2"
>
<audio
:src="previewAudioSrc"
controls
class="h-8 w-full"
autoplay
/>
</div>
</div>
<p
v-if="filteredSystemVoices.length === 0"
class="py-8 text-center text-sm text-slate-500"
@@ -170,6 +264,18 @@ function stripVoiceIdPrefix(id) {
</p>
</div>
<div class="flex shrink-0 gap-1">
<Button
label="试听"
size="small"
severity="secondary"
icon="pi pi-volume-up"
:loading="isPreviewing(item.id)"
:disabled="
!item.content?.aliyunVoiceId ||
Boolean(previewingKey && !isPreviewing(item.id))
"
@click="onPreviewClone(item)"
/>
<Button label="选择" size="small" @click="onSelectVoice(item.id)" />
<Button
icon="pi pi-pencil"
@@ -187,11 +293,20 @@ function stripVoiceIdPrefix(id) {
/>
</div>
</div>
<div
v-if="showPreviewPlayer(item.id)"
class="mt-2 rounded-lg border border-blue-500/30 bg-blue-500/10 px-2 py-2"
>
<audio :src="previewAudioSrc" controls class="h-8 w-full" autoplay />
</div>
</div>
</div>
</TabPanel>
</TabPanels>
</Tabs>
<p v-if="previewError" class="mt-2 text-sm text-red-400">{{ previewError }}</p>
<VoiceCloneEditDialog ref="editDialogRef" @saved="loadCloneVoices" />
</Dialog>
</template>

View File

@@ -0,0 +1,85 @@
import { invoke, isTauri } from "@tauri-apps/api/core";
import { ensureAppConfigReady } from "../config/videoPipeline.js";
import { resolveAliyunVoiceId } from "./soundPromptStorage.js";
const VOICE_PREVIEW_SCRIPT = "voice_preview.js";
/** @type {Map<string, string>} cacheKey → data:audio URL */
const previewUrlCache = new Map();
function mimeFromPath(filePath) {
const lower = String(filePath || "").toLowerCase();
if (lower.endsWith(".mp3")) return "audio/mpeg";
if (lower.endsWith(".ogg")) return "audio/ogg";
return "audio/wav";
}
/**
* 将 Node 写入的本地音频转为可在 WebView 播放的 URL避免 asset.localhost 不可用)
* @param {string} filePath
*/
async function localAudioToPlayableUrl(filePath) {
if (!isTauri()) {
throw new Error("请在 Tauri 桌面端使用试听功能");
}
const base64 = await invoke("read_local_file_base64", { path: filePath });
const mime = mimeFromPath(filePath);
return `data:${mime};base64,${base64}`;
}
/**
* 音色试听
* @param {{ listKey: string, title: string, aliyunVoiceId?: string, selectedVoiceId?: string }} opts
*/
export async function previewVoice(opts) {
const cfg = await ensureAppConfigReady();
if (!cfg.ok) {
return { ok: false, message: cfg.message };
}
const voice = String(
opts.aliyunVoiceId ||
resolveAliyunVoiceId(opts.selectedVoiceId || ""),
).trim();
if (!voice) {
return { ok: false, message: "音色 ID 无效" };
}
const cacheKey = opts.listKey || "";
const memCached = cacheKey ? previewUrlCache.get(cacheKey) : null;
if (memCached) {
return { ok: true, audioSrc: memCached, cached: true };
}
try {
const result = await invoke("run_nodejs_script", {
scriptName: VOICE_PREVIEW_SCRIPT,
params: {
voice,
title: opts.title || "",
cacheKey: cacheKey || undefined,
},
});
if (!result?.success || !result?.audioPath) {
return {
ok: false,
message: String(result?.error || "试听失败,请重试"),
};
}
const audioSrc = await localAudioToPlayableUrl(result.audioPath);
if (cacheKey) {
previewUrlCache.set(cacheKey, audioSrc);
}
return {
ok: true,
audioSrc,
audioPath: result.audioPath,
cached: Boolean(result.cached),
};
} catch (err) {
const msg = err instanceof Error ? err.message : String(err || "未知错误");
return { ok: false, message: `试听失败: ${msg}` };
}
}