111
This commit is contained in:
17
aiclient.code-workspace
Normal file
17
aiclient.code-workspace
Normal 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": {}
|
||||
}
|
||||
@@ -16,10 +16,15 @@ const APP_COMMANDS: &[&str] = &[
|
||||
"run_nodejs_script_source",
|
||||
"list_nodejs_scripts",
|
||||
"read_local_file_base64",
|
||||
"write_local_file_base64",
|
||||
"get_media_duration_seconds",
|
||||
"copy_local_file",
|
||||
"write_local_text_file",
|
||||
"temp_voice_reference_path",
|
||||
"stage_voice_reference_audio",
|
||||
"import_voice_clone_reference",
|
||||
"reveal_local_file_in_folder",
|
||||
"stop_nodejs_script",
|
||||
"list_avatars",
|
||||
"insert_avatar",
|
||||
"update_avatar_name",
|
||||
|
||||
@@ -18,10 +18,15 @@ permissions = [
|
||||
"allow-run-nodejs-script-source",
|
||||
"allow-list-nodejs-scripts",
|
||||
"allow-read-local-file-base64",
|
||||
"allow-write-local-file-base64",
|
||||
"allow-get-media-duration-seconds",
|
||||
"allow-copy-local-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-stop-nodejs-script",
|
||||
"allow-list-avatars",
|
||||
"allow-insert-avatar",
|
||||
"allow-update-avatar-name",
|
||||
|
||||
@@ -14,6 +14,46 @@ fn normalize_path(path: &str) -> PathBuf {
|
||||
.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 {
|
||||
let lower = path.to_string_lossy().to_lowercase();
|
||||
@@ -165,6 +205,34 @@ fn voice_clones_dir(app: &tauri::AppHandle) -> Result<PathBuf, String> {
|
||||
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]
|
||||
pub async fn temp_voice_reference_path(ext: String) -> Result<String, String> {
|
||||
|
||||
@@ -137,6 +137,7 @@ pub fn run() {
|
||||
commands::fs_util::copy_local_file,
|
||||
commands::fs_util::write_local_text_file,
|
||||
commands::fs_util::temp_voice_reference_path,
|
||||
commands::fs_util::stage_voice_reference_audio,
|
||||
commands::fs_util::import_voice_clone_reference,
|
||||
commands::fs_util::reveal_local_file_in_folder,
|
||||
commands::avatar::list_avatars,
|
||||
|
||||
@@ -4,6 +4,8 @@ import { localAudioToPlayableUrl } from "../../services/localAudio.js";
|
||||
import {
|
||||
createTempReferencePath,
|
||||
getAudioDurationSeconds,
|
||||
formatInvokeError,
|
||||
importPickedReferenceAudio,
|
||||
pickReferenceAudioFile,
|
||||
writeBlobToPath,
|
||||
} from "../../services/voiceReferenceAudio.js";
|
||||
@@ -65,10 +67,18 @@ function setPath(path) {
|
||||
|
||||
async function onPickFile() {
|
||||
recordError.value = "";
|
||||
const path = await pickReferenceAudioFile();
|
||||
if (!path) return;
|
||||
try {
|
||||
const picked = await pickReferenceAudioFile();
|
||||
if (!picked) return;
|
||||
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() {
|
||||
@@ -128,9 +138,21 @@ async function exportReferencePath() {
|
||||
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({
|
||||
exportReferencePath,
|
||||
getDurationSeconds: () => durationSec.value,
|
||||
getDurationSeconds,
|
||||
refreshPreview,
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
<script setup>
|
||||
import { ref, computed } from "vue";
|
||||
import { ref, computed, watch } from "vue";
|
||||
import {
|
||||
addCloneVoice,
|
||||
updateCloneVoice,
|
||||
cloneVoiceTitleExists,
|
||||
} from "../../services/soundPromptStorage.js";
|
||||
import {
|
||||
getAudioDurationSeconds,
|
||||
persistVoiceReference,
|
||||
validateReferenceDuration,
|
||||
} from "../../services/voiceReferenceAudio.js";
|
||||
@@ -28,9 +29,22 @@ const saving = ref(false);
|
||||
const enrolling = ref(false);
|
||||
const errorMessage = ref("");
|
||||
const recorderRef = ref(null);
|
||||
/** 打开编辑时已有的参考音频路径(未更换则保存时不校验时长,对齐 Electron) */
|
||||
const initialReferencePath = ref("");
|
||||
/** 一键复刻成功时使用的参考路径;更换音频后需重新校验 */
|
||||
const referencePathAtEnroll = ref("");
|
||||
|
||||
const isEdit = computed(() => Boolean(editingId.value));
|
||||
|
||||
watch(referencePath, (path) => {
|
||||
if (
|
||||
referencePathAtEnroll.value &&
|
||||
path !== referencePathAtEnroll.value
|
||||
) {
|
||||
referencePathAtEnroll.value = "";
|
||||
}
|
||||
});
|
||||
|
||||
const voiceIdModel = computed({
|
||||
get: () => voiceIdInput.value,
|
||||
set: (v) => {
|
||||
@@ -45,6 +59,32 @@ function resetForm() {
|
||||
referencePath.value = "";
|
||||
voiceIdInput.value = "";
|
||||
errorMessage.value = "";
|
||||
initialReferencePath.value = "";
|
||||
referencePathAtEnroll.value = "";
|
||||
}
|
||||
|
||||
/**
|
||||
* 对齐 Electron SoundPromptEditDialog:已有 promptWav 或已完成复刻时保存不再做 6~20s 校验。
|
||||
* @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() {
|
||||
@@ -60,6 +100,8 @@ function openEdit(record) {
|
||||
name.value = record.title || "";
|
||||
promptText.value = record.content?.promptText || "";
|
||||
referencePath.value = record.content?.url || "";
|
||||
initialReferencePath.value = referencePath.value;
|
||||
referencePathAtEnroll.value = "";
|
||||
voiceIdInput.value = stripVoiceIdPrefix(record.content?.aliyunVoiceId || "");
|
||||
errorMessage.value = "";
|
||||
visible.value = true;
|
||||
@@ -77,9 +119,13 @@ async function resolveReferenceForSave() {
|
||||
if (!path) {
|
||||
return { ok: false, message: "请录制声音或选择声音文件" };
|
||||
}
|
||||
const duration = await recorderRef.value?.getDurationSeconds?.();
|
||||
if (!shouldSkipReferenceDurationOnSave(path)) {
|
||||
const duration =
|
||||
(await recorderRef.value?.getDurationSeconds?.()) ??
|
||||
(await getAudioDurationSeconds(path));
|
||||
const check = validateReferenceDuration(duration);
|
||||
if (!check.ok) return check;
|
||||
}
|
||||
|
||||
const storageId =
|
||||
editingId.value ||
|
||||
@@ -112,7 +158,9 @@ async function onEnroll() {
|
||||
return;
|
||||
}
|
||||
|
||||
const duration = await recorderRef.value?.getDurationSeconds?.();
|
||||
const duration =
|
||||
(await recorderRef.value?.getDurationSeconds?.()) ??
|
||||
(await getAudioDurationSeconds(path));
|
||||
const check = validateReferenceDuration(duration, { forEnroll: true });
|
||||
if (!check.ok) {
|
||||
errorMessage.value = check.message;
|
||||
@@ -133,8 +181,8 @@ async function onEnroll() {
|
||||
return;
|
||||
}
|
||||
voiceIdInput.value = stripVoiceIdPrefix(result.voiceId);
|
||||
referencePathAtEnroll.value = path;
|
||||
errorMessage.value = "";
|
||||
// 成功提示由 message 展示
|
||||
successFlash.value = result.message;
|
||||
} finally {
|
||||
enrolling.value = false;
|
||||
|
||||
@@ -4,6 +4,8 @@ export function mimeFromAudioPath(filePath) {
|
||||
const lower = String(filePath || "").toLowerCase();
|
||||
if (lower.endsWith(".mp3")) return "audio/mpeg";
|
||||
if (lower.endsWith(".ogg")) return "audio/ogg";
|
||||
if (lower.endsWith(".webm")) return "audio/webm";
|
||||
if (lower.endsWith(".m4a")) return "audio/mp4";
|
||||
return "audio/wav";
|
||||
}
|
||||
|
||||
|
||||
@@ -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 { isTauri } from "@tauri-apps/api/core";
|
||||
|
||||
const REF_MIN_SEC = 6;
|
||||
const REF_MAX_SEC = 20;
|
||||
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
|
||||
*/
|
||||
@@ -44,6 +86,47 @@ export function validateReferenceDuration(durationSec, opts = {}) {
|
||||
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>}
|
||||
*/
|
||||
@@ -57,9 +140,7 @@ export async function pickReferenceAudioFile() {
|
||||
},
|
||||
],
|
||||
});
|
||||
if (selected == null) return null;
|
||||
if (Array.isArray(selected)) return selected[0] || null;
|
||||
return String(selected);
|
||||
return coerceDialogPath(selected);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user