This commit is contained in:
949036910@qq.com
2026-05-30 11:52:25 +08:00
parent 30e019d345
commit 58240c3d76
9 changed files with 265 additions and 16 deletions

17
aiclient.code-workspace Normal file
View File

@@ -0,0 +1,17 @@
{
"folders": [
{
"path": "."
},
{
"path": "../pythonbackend"
},
{
"path": "../../Users/12996/AppData/Local/Programs/zhenqianba-v2/resources/app_debug"
},
{
"path": "src-tauri/resources/resources-bundles/python-runtimebackup"
}
],
"settings": {}
}

View File

@@ -16,10 +16,15 @@ const APP_COMMANDS: &[&str] = &[
"run_nodejs_script_source", "run_nodejs_script_source",
"list_nodejs_scripts", "list_nodejs_scripts",
"read_local_file_base64", "read_local_file_base64",
"write_local_file_base64",
"get_media_duration_seconds", "get_media_duration_seconds",
"copy_local_file", "copy_local_file",
"write_local_text_file", "write_local_text_file",
"temp_voice_reference_path",
"stage_voice_reference_audio",
"import_voice_clone_reference",
"reveal_local_file_in_folder", "reveal_local_file_in_folder",
"stop_nodejs_script",
"list_avatars", "list_avatars",
"insert_avatar", "insert_avatar",
"update_avatar_name", "update_avatar_name",

View File

@@ -18,10 +18,15 @@ permissions = [
"allow-run-nodejs-script-source", "allow-run-nodejs-script-source",
"allow-list-nodejs-scripts", "allow-list-nodejs-scripts",
"allow-read-local-file-base64", "allow-read-local-file-base64",
"allow-write-local-file-base64",
"allow-get-media-duration-seconds", "allow-get-media-duration-seconds",
"allow-copy-local-file", "allow-copy-local-file",
"allow-write-local-text-file", "allow-write-local-text-file",
"allow-temp-voice-reference-path",
"allow-stage-voice-reference-audio",
"allow-import-voice-clone-reference",
"allow-reveal-local-file-in-folder", "allow-reveal-local-file-in-folder",
"allow-stop-nodejs-script",
"allow-list-avatars", "allow-list-avatars",
"allow-insert-avatar", "allow-insert-avatar",
"allow-update-avatar-name", "allow-update-avatar-name",

View File

@@ -14,6 +14,46 @@ fn normalize_path(path: &str) -> PathBuf {
.collect() .collect()
} }
/// 解析用户通过系统对话框选择的文件路径(兼容 file:// 与 /C:/ 形式)。
fn resolve_user_selected_file(path: &str) -> Result<PathBuf, String> {
let mut trimmed = path.trim();
if trimmed.is_empty() {
return Err("路径为空".into());
}
if let Some(rest) = trimmed.strip_prefix("file:///") {
trimmed = rest;
} else if let Some(rest) = trimmed.strip_prefix("file://") {
trimmed = rest;
}
// `/C:/Users/...` → `C:/Users/...`
if trimmed.len() >= 3 {
let bytes = trimmed.as_bytes();
if bytes[0] == b'/' && bytes[2] == b':' {
trimmed = &trimmed[1..];
}
}
let direct = PathBuf::from(trimmed);
if direct.is_file() {
return Ok(direct);
}
let normalized = normalize_path(trimmed);
if normalized.is_file() {
return Ok(normalized);
}
if let Ok(canonical) = dunce::canonicalize(&direct) {
if canonical.is_file() {
return Ok(canonical);
}
}
if let Ok(canonical) = dunce::canonicalize(&normalized) {
if canonical.is_file() {
return Ok(canonical);
}
}
Err(format!("文件不存在: {trimmed}"))
}
/// 仅允许读取临时目录下的试听/流水线产物,避免任意路径读取。 /// 仅允许读取临时目录下的试听/流水线产物,避免任意路径读取。
fn is_allowed_local_media(path: &Path) -> bool { fn is_allowed_local_media(path: &Path) -> bool {
let lower = path.to_string_lossy().to_lowercase(); let lower = path.to_string_lossy().to_lowercase();
@@ -165,6 +205,34 @@ fn voice_clones_dir(app: &tauri::AppHandle) -> Result<PathBuf, String> {
Ok(dir) Ok(dir)
} }
/// 将用户通过文件对话框选择的音频复制到临时目录,供时长探测与试听。
#[tauri::command]
pub async fn stage_voice_reference_audio(source_path: String) -> Result<String, String> {
let source = resolve_user_selected_file(&source_path)?;
let ext = source
.extension()
.and_then(|e| e.to_str())
.unwrap_or("wav");
let ext = if ext.is_empty() { "wav" } 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 dest = dir.join(format!(
"upload_{}_{}.{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis())
.unwrap_or(0),
rand_simple(),
ext
));
tokio::fs::copy(&source, &dest)
.await
.map_err(|e| format!("复制参考音频失败: {e}"))?;
Ok(dest.to_string_lossy().into_owned())
}
/// 生成临时参考音频路径(录音/上传中转)。 /// 生成临时参考音频路径(录音/上传中转)。
#[tauri::command] #[tauri::command]
pub async fn temp_voice_reference_path(ext: String) -> Result<String, String> { pub async fn temp_voice_reference_path(ext: String) -> Result<String, String> {

View File

@@ -137,6 +137,7 @@ pub fn run() {
commands::fs_util::copy_local_file, commands::fs_util::copy_local_file,
commands::fs_util::write_local_text_file, commands::fs_util::write_local_text_file,
commands::fs_util::temp_voice_reference_path, commands::fs_util::temp_voice_reference_path,
commands::fs_util::stage_voice_reference_audio,
commands::fs_util::import_voice_clone_reference, commands::fs_util::import_voice_clone_reference,
commands::fs_util::reveal_local_file_in_folder, commands::fs_util::reveal_local_file_in_folder,
commands::avatar::list_avatars, commands::avatar::list_avatars,

View File

@@ -4,6 +4,8 @@ import { localAudioToPlayableUrl } from "../../services/localAudio.js";
import { import {
createTempReferencePath, createTempReferencePath,
getAudioDurationSeconds, getAudioDurationSeconds,
formatInvokeError,
importPickedReferenceAudio,
pickReferenceAudioFile, pickReferenceAudioFile,
writeBlobToPath, writeBlobToPath,
} from "../../services/voiceReferenceAudio.js"; } from "../../services/voiceReferenceAudio.js";
@@ -65,10 +67,18 @@ function setPath(path) {
async function onPickFile() { async function onPickFile() {
recordError.value = ""; recordError.value = "";
const path = await pickReferenceAudioFile(); try {
if (!path) return; const picked = await pickReferenceAudioFile();
setPath(path); if (!picked) return;
await refreshPreview(); const path = await importPickedReferenceAudio(picked);
setPath(path);
await refreshPreview();
if (!durationSec.value) {
recordError.value = "无法读取音频时长,请换用 wav/mp3 文件重试";
}
} catch (err) {
recordError.value = formatInvokeError(err, "导入参考音频失败");
}
} }
async function startRecording() { async function startRecording() {
@@ -128,9 +138,21 @@ async function exportReferencePath() {
return referencePath.value; return referencePath.value;
} }
async function getDurationSeconds() {
const p = referencePath.value;
if (!p) return 0;
if (durationSec.value > 0) return durationSec.value;
try {
durationSec.value = await getAudioDurationSeconds(p);
} catch {
durationSec.value = 0;
}
return durationSec.value;
}
defineExpose({ defineExpose({
exportReferencePath, exportReferencePath,
getDurationSeconds: () => durationSec.value, getDurationSeconds,
refreshPreview, refreshPreview,
}); });
</script> </script>

View File

@@ -1,11 +1,12 @@
<script setup> <script setup>
import { ref, computed } from "vue"; import { ref, computed, watch } from "vue";
import { import {
addCloneVoice, addCloneVoice,
updateCloneVoice, updateCloneVoice,
cloneVoiceTitleExists, cloneVoiceTitleExists,
} from "../../services/soundPromptStorage.js"; } from "../../services/soundPromptStorage.js";
import { import {
getAudioDurationSeconds,
persistVoiceReference, persistVoiceReference,
validateReferenceDuration, validateReferenceDuration,
} from "../../services/voiceReferenceAudio.js"; } from "../../services/voiceReferenceAudio.js";
@@ -28,9 +29,22 @@ const saving = ref(false);
const enrolling = ref(false); const enrolling = ref(false);
const errorMessage = ref(""); const errorMessage = ref("");
const recorderRef = ref(null); const recorderRef = ref(null);
/** 打开编辑时已有的参考音频路径(未更换则保存时不校验时长,对齐 Electron */
const initialReferencePath = ref("");
/** 一键复刻成功时使用的参考路径;更换音频后需重新校验 */
const referencePathAtEnroll = ref("");
const isEdit = computed(() => Boolean(editingId.value)); const isEdit = computed(() => Boolean(editingId.value));
watch(referencePath, (path) => {
if (
referencePathAtEnroll.value &&
path !== referencePathAtEnroll.value
) {
referencePathAtEnroll.value = "";
}
});
const voiceIdModel = computed({ const voiceIdModel = computed({
get: () => voiceIdInput.value, get: () => voiceIdInput.value,
set: (v) => { set: (v) => {
@@ -45,6 +59,32 @@ function resetForm() {
referencePath.value = ""; referencePath.value = "";
voiceIdInput.value = ""; voiceIdInput.value = "";
errorMessage.value = ""; errorMessage.value = "";
initialReferencePath.value = "";
referencePathAtEnroll.value = "";
}
/**
* 对齐 Electron SoundPromptEditDialog已有 promptWav 或已完成复刻时保存不再做 620s 校验。
* @param {string} path
*/
function shouldSkipReferenceDurationOnSave(path) {
const p = String(path || "").trim();
if (!p) return false;
if (
voiceIdInput.value.trim() &&
referencePathAtEnroll.value &&
p === referencePathAtEnroll.value
) {
return true;
}
if (
isEdit.value &&
initialReferencePath.value &&
p === initialReferencePath.value
) {
return true;
}
return false;
} }
function openAdd() { function openAdd() {
@@ -60,6 +100,8 @@ function openEdit(record) {
name.value = record.title || ""; name.value = record.title || "";
promptText.value = record.content?.promptText || ""; promptText.value = record.content?.promptText || "";
referencePath.value = record.content?.url || ""; referencePath.value = record.content?.url || "";
initialReferencePath.value = referencePath.value;
referencePathAtEnroll.value = "";
voiceIdInput.value = stripVoiceIdPrefix(record.content?.aliyunVoiceId || ""); voiceIdInput.value = stripVoiceIdPrefix(record.content?.aliyunVoiceId || "");
errorMessage.value = ""; errorMessage.value = "";
visible.value = true; visible.value = true;
@@ -77,9 +119,13 @@ async function resolveReferenceForSave() {
if (!path) { if (!path) {
return { ok: false, message: "请录制声音或选择声音文件" }; return { ok: false, message: "请录制声音或选择声音文件" };
} }
const duration = await recorderRef.value?.getDurationSeconds?.(); if (!shouldSkipReferenceDurationOnSave(path)) {
const check = validateReferenceDuration(duration); const duration =
if (!check.ok) return check; (await recorderRef.value?.getDurationSeconds?.()) ??
(await getAudioDurationSeconds(path));
const check = validateReferenceDuration(duration);
if (!check.ok) return check;
}
const storageId = const storageId =
editingId.value || editingId.value ||
@@ -112,7 +158,9 @@ async function onEnroll() {
return; return;
} }
const duration = await recorderRef.value?.getDurationSeconds?.(); const duration =
(await recorderRef.value?.getDurationSeconds?.()) ??
(await getAudioDurationSeconds(path));
const check = validateReferenceDuration(duration, { forEnroll: true }); const check = validateReferenceDuration(duration, { forEnroll: true });
if (!check.ok) { if (!check.ok) {
errorMessage.value = check.message; errorMessage.value = check.message;
@@ -133,8 +181,8 @@ async function onEnroll() {
return; return;
} }
voiceIdInput.value = stripVoiceIdPrefix(result.voiceId); voiceIdInput.value = stripVoiceIdPrefix(result.voiceId);
referencePathAtEnroll.value = path;
errorMessage.value = ""; errorMessage.value = "";
// 成功提示由 message 展示
successFlash.value = result.message; successFlash.value = result.message;
} finally { } finally {
enrolling.value = false; enrolling.value = false;

View File

@@ -4,6 +4,8 @@ export function mimeFromAudioPath(filePath) {
const lower = String(filePath || "").toLowerCase(); const lower = String(filePath || "").toLowerCase();
if (lower.endsWith(".mp3")) return "audio/mpeg"; if (lower.endsWith(".mp3")) return "audio/mpeg";
if (lower.endsWith(".ogg")) return "audio/ogg"; if (lower.endsWith(".ogg")) return "audio/ogg";
if (lower.endsWith(".webm")) return "audio/webm";
if (lower.endsWith(".m4a")) return "audio/mp4";
return "audio/wav"; return "audio/wav";
} }

View File

@@ -1,11 +1,53 @@
import { invoke } from "@tauri-apps/api/core"; import { convertFileSrc, invoke, isTauri } from "@tauri-apps/api/core";
import { open } from "@tauri-apps/plugin-dialog"; import { open } from "@tauri-apps/plugin-dialog";
import { isTauri } from "@tauri-apps/api/core";
const REF_MIN_SEC = 6; const REF_MIN_SEC = 6;
const REF_MAX_SEC = 20; const REF_MAX_SEC = 20;
const ENROLL_MAX_SEC = 60; const ENROLL_MAX_SEC = 60;
/**
* @param {unknown} err
* @param {string} [fallback]
*/
export function formatInvokeError(err, fallback = "操作失败") {
if (err instanceof Error && err.message.trim()) return err.message;
if (typeof err === "string" && err.trim()) return err.trim();
return fallback;
}
/**
* @param {unknown} selected
* @returns {string | null}
*/
export function coerceDialogPath(selected) {
if (selected == null) return null;
if (typeof selected === "string") {
const s = selected.trim();
return s || null;
}
if (Array.isArray(selected)) {
return coerceDialogPath(selected[0]);
}
if (typeof selected === "object") {
const obj = /** @type {Record<string, unknown>} */ (selected);
if (typeof obj.path === "string") return obj.path.trim() || null;
if (typeof obj.uri === "string") return obj.uri.trim() || null;
}
const s = String(selected).trim();
if (!s || s === "[object Object]") return null;
return s;
}
/**
* @param {string} filePath
*/
function extFromPath(filePath) {
const m = String(filePath || "")
.toLowerCase()
.match(/\.([a-z0-9]+)$/);
return m?.[1] || "wav";
}
/** /**
* @param {string} filePath * @param {string} filePath
*/ */
@@ -44,6 +86,47 @@ export function validateReferenceDuration(durationSec, opts = {}) {
return { ok: true, message: "" }; return { ok: true, message: "" };
} }
/**
* 通过 asset 协议读取对话框授权路径并写入临时目录Rust 复制失败时的兜底)。
* @param {string} sourcePath
*/
async function importReferenceViaAssetFetch(sourcePath) {
const ext = extFromPath(sourcePath);
const tempPath = await createTempReferencePath(ext);
const url = convertFileSrc(sourcePath);
const resp = await fetch(url);
if (!resp.ok) {
throw new Error(`读取音频失败 (${resp.status})`);
}
const blob = await resp.blob();
await writeBlobToPath(tempPath, blob);
return tempPath;
}
/**
* 将用户选择的参考音频导入到受信任临时目录。
* @param {string} sourcePath
*/
export async function importPickedReferenceAudio(sourcePath) {
if (!sourcePath || !isTauri()) return sourcePath;
try {
return await invoke("stage_voice_reference_audio", { sourcePath });
} catch (rustErr) {
try {
return await importReferenceViaAssetFetch(sourcePath);
} catch (webErr) {
const rustMsg = formatInvokeError(rustErr, "");
const webMsg = formatInvokeError(webErr, "");
const detail = [rustMsg, webMsg].filter(Boolean).join("");
throw new Error(detail || "导入参考音频失败");
}
}
}
/** @deprecated 使用 importPickedReferenceAudio */
export const stagePickedReferenceAudio = importPickedReferenceAudio;
/** /**
* @returns {Promise<string | null>} * @returns {Promise<string | null>}
*/ */
@@ -57,9 +140,7 @@ export async function pickReferenceAudioFile() {
}, },
], ],
}); });
if (selected == null) return null; return coerceDialogPath(selected);
if (Array.isArray(selected)) return selected[0] || null;
return String(selected);
} }
/** /**