From d3af2e11245c1ce11c2d5cd0c6c652ebe1721018 Mon Sep 17 00:00:00 2001 From: "fengchuanhn@gmail.com" Date: Fri, 22 May 2026 18:42:26 +0800 Subject: [PATCH] 11 --- src-tauri/src/asr_db.rs | 170 +++++ src-tauri/src/asr_store.rs | 83 +++ src-tauri/src/audio_db.rs | 47 +- src-tauri/src/audio_store.rs | 17 +- src-tauri/src/commands/audio.rs | 14 +- src-tauri/src/commands/mod.rs | 1 + src-tauri/src/commands/sound_asr.rs | 162 +++++ src-tauri/src/lib.rs | 10 + .../voice/BatchTextSynthesizeDialog.vue | 119 ++++ src/components/workflow/VoiceManageDialog.vue | 64 +- src/config/ttsModels.js | 8 + src/router/index.js | 2 +- src/services/asrDb.js | 47 ++ src/services/audioDb.js | 20 +- src/services/soundMedia.js | 24 + src/services/soundSynthesize.js | 82 +++ src/services/voiceGenerate.js | 190 ++--- src/stores/voicePage.js | 247 +++++++ src/views/VoiceView.vue | 665 ++++++++++++++++++ 19 files changed, 1856 insertions(+), 116 deletions(-) create mode 100644 src-tauri/src/asr_db.rs create mode 100644 src-tauri/src/asr_store.rs create mode 100644 src-tauri/src/commands/sound_asr.rs create mode 100644 src/components/voice/BatchTextSynthesizeDialog.vue create mode 100644 src/config/ttsModels.js create mode 100644 src/services/asrDb.js create mode 100644 src/services/soundMedia.js create mode 100644 src/services/soundSynthesize.js create mode 100644 src/stores/voicePage.js create mode 100644 src/views/VoiceView.vue diff --git a/src-tauri/src/asr_db.rs b/src-tauri/src/asr_db.rs new file mode 100644 index 0000000..0d0ddf1 --- /dev/null +++ b/src-tauri/src/asr_db.rs @@ -0,0 +1,170 @@ +//! 语音识别历史 SQLite + +use std::path::PathBuf; +use std::sync::Mutex; + +use chrono::Utc; +use rusqlite::{params, Connection}; +use serde::Serialize; +use thiserror::Error; + +const MAX_RECORDS: i64 = 50; + +#[derive(Debug, Error)] +pub enum AsrDbError { + #[error("{0}")] + Db(#[from] rusqlite::Error), + #[error("{0}")] + Message(String), +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AsrHistoryRecord { + pub id: i64, + pub name: String, + pub source_path: String, + pub result_text: String, + pub created_at: String, +} + +#[derive(Debug)] +pub struct AsrDb { + conn: Mutex, +} + +impl AsrDb { + pub fn open(path: PathBuf) -> Result { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|e| { + AsrDbError::Message(format!("创建数据库目录失败: {e}")) + })?; + } + let conn = Connection::open(path)?; + conn.execute_batch( + r#" + PRAGMA journal_mode = WAL; + CREATE TABLE IF NOT EXISTS asr_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL DEFAULT '', + source_path TEXT NOT NULL, + result_text TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL + ); + "#, + )?; + Ok(Self { + conn: Mutex::new(conn), + }) + } + + pub fn list(&self) -> Result, AsrDbError> { + let conn = self.conn.lock().map_err(|_| { + AsrDbError::Message("ASR 数据库锁异常".into()) + })?; + let mut stmt = conn.prepare( + "SELECT id, name, source_path, result_text, created_at + FROM asr_history ORDER BY id DESC LIMIT ?1", + )?; + let rows = stmt.query_map(params![MAX_RECORDS], |row| { + Ok(AsrHistoryRecord { + id: row.get(0)?, + name: row.get(1)?, + source_path: row.get(2)?, + result_text: row.get(3)?, + created_at: row.get(4)?, + }) + })?; + let mut items = Vec::new(); + for row in rows { + items.push(row?); + } + Ok(items) + } + + pub fn insert( + &self, + name: &str, + source_path: &str, + result_text: &str, + ) -> Result { + let source_path = source_path.trim(); + if source_path.is_empty() { + return Err(AsrDbError::Message("源文件路径不能为空".into())); + } + let name = name.trim(); + let name = if name.is_empty() { + source_path + .replace('\\', "/") + .rsplit('/') + .next() + .unwrap_or("语音识别") + .to_string() + } else { + name.to_string() + }; + let created_at = Utc::now().to_rfc3339(); + let conn = self.conn.lock().map_err(|_| { + AsrDbError::Message("ASR 数据库锁异常".into()) + })?; + conn.execute( + "INSERT INTO asr_history (name, source_path, result_text, created_at) + VALUES (?1, ?2, ?3, ?4)", + params![name, source_path, result_text.trim(), created_at], + )?; + let id = conn.last_insert_rowid(); + self.prune_excess(&conn)?; + Ok(AsrHistoryRecord { + id, + name, + source_path: source_path.to_string(), + result_text: result_text.trim().to_string(), + created_at, + }) + } + + pub fn update_name(&self, id: i64, name: &str) -> Result<(), AsrDbError> { + let name = name.trim(); + if name.is_empty() { + return Err(AsrDbError::Message("名称不能为空".into())); + } + let conn = self.conn.lock().map_err(|_| { + AsrDbError::Message("ASR 数据库锁异常".into()) + })?; + let n = conn.execute( + "UPDATE asr_history SET name = ?1 WHERE id = ?2", + params![name, id], + )?; + if n == 0 { + return Err(AsrDbError::Message("记录不存在".into())); + } + Ok(()) + } + + fn prune_excess(&self, conn: &Connection) -> Result<(), AsrDbError> { + let count: i64 = conn.query_row( + "SELECT COUNT(1) FROM asr_history", + [], + |row| row.get(0), + )?; + if count <= MAX_RECORDS { + return Ok(()); + } + let excess = count - MAX_RECORDS; + conn.execute( + "DELETE FROM asr_history WHERE id IN ( + SELECT id FROM asr_history ORDER BY id ASC LIMIT ?1 + )", + params![excess], + )?; + Ok(()) + } + + pub fn delete(&self, id: i64) -> Result { + let conn = self.conn.lock().map_err(|_| { + AsrDbError::Message("ASR 数据库锁异常".into()) + })?; + let n = conn.execute("DELETE FROM asr_history WHERE id = ?1", params![id])?; + Ok(n > 0) + } +} diff --git a/src-tauri/src/asr_store.rs b/src-tauri/src/asr_store.rs new file mode 100644 index 0000000..c2654b5 --- /dev/null +++ b/src-tauri/src/asr_store.rs @@ -0,0 +1,83 @@ +//! 语音识别历史:SQLite 访问与 Tauri 状态 + +use std::path::PathBuf; +use std::sync::Arc; + +use tokio::sync::RwLock; + +use crate::asr_db::{AsrDb, AsrHistoryRecord}; + +#[derive(Debug, Default)] +pub struct AsrStore { + inner: Arc>>>, +} + +impl AsrStore { + pub fn new() -> Self { + Self::default() + } + + pub async fn init(&self, db_path: PathBuf) -> Result<(), String> { + let db = tokio::task::spawn_blocking(move || { + AsrDb::open(db_path).map_err(|e| e.to_string()) + }) + .await + .map_err(|e| e.to_string())??; + + let count = tokio::task::spawn_blocking({ + let db = Arc::new(db); + let db2 = db.clone(); + move || db2.list().map(|l| (db, l.len())).map_err(|e| e.to_string()) + }) + .await + .map_err(|e| e.to_string())??; + + let (db, n) = count; + *self.inner.write().await = Some(db); + log::info!(target: "asr", "ASR 历史数据库已初始化,共 {n} 条记录"); + Ok(()) + } + + async fn with_db(&self, f: F) -> Result + where + F: FnOnce(Arc) -> Result + Send + 'static, + T: Send + 'static, + { + let db = self + .inner + .read() + .await + .clone() + .ok_or_else(|| "ASR 数据库未初始化".to_string())?; + tokio::task::spawn_blocking(move || f(db)) + .await + .map_err(|e| e.to_string())? + } + + pub async fn list(&self) -> Result, String> { + self.with_db(|db| db.list().map_err(|e| e.to_string())).await + } + + pub async fn insert( + &self, + name: String, + source_path: String, + result_text: String, + ) -> Result { + self.with_db(move |db| { + db.insert(&name, &source_path, &result_text) + .map_err(|e| e.to_string()) + }) + .await + } + + pub async fn update_name(&self, id: i64, name: String) -> Result<(), String> { + self.with_db(move |db| db.update_name(id, &name).map_err(|e| e.to_string())) + .await + } + + pub async fn delete(&self, id: i64) -> Result { + self.with_db(move |db| db.delete(id).map_err(|e| e.to_string())) + .await + } +} diff --git a/src-tauri/src/audio_db.rs b/src-tauri/src/audio_db.rs index 482f9eb..6ad2eff 100644 --- a/src-tauri/src/audio_db.rs +++ b/src-tauri/src/audio_db.rs @@ -24,6 +24,7 @@ pub struct GeneratedAudioRecord { pub id: i64, pub name: String, pub file_path: String, + pub source_text: String, pub created_at: String, } @@ -47,10 +48,15 @@ impl AudioDb { id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL DEFAULT '', file_path TEXT NOT NULL, + source_text TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL ); "#, )?; + let _ = conn.execute( + "ALTER TABLE generated_audios ADD COLUMN source_text TEXT NOT NULL DEFAULT ''", + [], + ); Ok(Self { conn: Mutex::new(conn), }) @@ -72,14 +78,15 @@ impl AudioDb { AudioDbError::Message("语音数据库锁异常".into()) })?; let mut stmt = conn.prepare( - "SELECT id, name, file_path, created_at FROM generated_audios ORDER BY id DESC LIMIT ?1", + "SELECT id, name, file_path, source_text, created_at FROM generated_audios ORDER BY id DESC LIMIT ?1", )?; let rows = stmt.query_map(params![MAX_RECORDS], |row| { Ok(GeneratedAudioRecord { id: row.get(0)?, name: row.get(1)?, file_path: row.get(2)?, - created_at: row.get(3)?, + source_text: row.get(3)?, + created_at: row.get(4)?, }) })?; let mut items = Vec::new(); @@ -94,14 +101,15 @@ impl AudioDb { AudioDbError::Message("语音数据库锁异常".into()) })?; let mut stmt = conn.prepare( - "SELECT id, name, file_path, created_at FROM generated_audios WHERE id = ?1", + "SELECT id, name, file_path, source_text, created_at FROM generated_audios WHERE id = ?1", )?; let mut rows = stmt.query_map(params![id], |row| { Ok(GeneratedAudioRecord { id: row.get(0)?, name: row.get(1)?, file_path: row.get(2)?, - created_at: row.get(3)?, + source_text: row.get(3)?, + created_at: row.get(4)?, }) })?; match rows.next() { @@ -110,7 +118,30 @@ impl AudioDb { } } - pub fn insert(&self, name: &str, file_path: &str) -> Result { + pub fn update_name(&self, id: i64, name: &str) -> Result<(), AudioDbError> { + let name = name.trim(); + if name.is_empty() { + return Err(AudioDbError::Message("名称不能为空".into())); + } + let conn = self.conn.lock().map_err(|_| { + AudioDbError::Message("语音数据库锁异常".into()) + })?; + let n = conn.execute( + "UPDATE generated_audios SET name = ?1 WHERE id = ?2", + params![name, id], + )?; + if n == 0 { + return Err(AudioDbError::Message("记录不存在".into())); + } + Ok(()) + } + + pub fn insert( + &self, + name: &str, + file_path: &str, + source_text: &str, + ) -> Result { let file_path = file_path.trim(); if file_path.is_empty() { return Err(AudioDbError::Message("文件路径不能为空".into())); @@ -125,9 +156,10 @@ impl AudioDb { let conn = self.conn.lock().map_err(|_| { AudioDbError::Message("语音数据库锁异常".into()) })?; + let source_text = source_text.trim().to_string(); conn.execute( - "INSERT INTO generated_audios (name, file_path, created_at) VALUES (?1, ?2, ?3)", - params![name, file_path, created_at], + "INSERT INTO generated_audios (name, file_path, source_text, created_at) VALUES (?1, ?2, ?3, ?4)", + params![name, file_path, source_text, created_at], )?; let id = conn.last_insert_rowid(); self.prune_excess(&conn)?; @@ -135,6 +167,7 @@ impl AudioDb { id, name, file_path: file_path.to_string(), + source_text, created_at, }) } diff --git a/src-tauri/src/audio_store.rs b/src-tauri/src/audio_store.rs index ea1cac7..3f74a6f 100644 --- a/src-tauri/src/audio_store.rs +++ b/src-tauri/src/audio_store.rs @@ -58,8 +58,21 @@ impl AudioStore { self.with_db(|db| db.list().map_err(|e| e.to_string())).await } - pub async fn insert(&self, name: String, file_path: String) -> Result { - self.with_db(move |db| db.insert(&name, &file_path).map_err(|e| e.to_string())) + pub async fn insert( + &self, + name: String, + file_path: String, + source_text: String, + ) -> Result { + self.with_db(move |db| { + db.insert(&name, &file_path, &source_text) + .map_err(|e| e.to_string()) + }) + .await + } + + pub async fn update_name(&self, id: i64, name: String) -> Result<(), String> { + self.with_db(move |db| db.update_name(id, &name).map_err(|e| e.to_string())) .await } diff --git a/src-tauri/src/commands/audio.rs b/src-tauri/src/commands/audio.rs index 241d67d..d4253eb 100644 --- a/src-tauri/src/commands/audio.rs +++ b/src-tauri/src/commands/audio.rs @@ -17,8 +17,20 @@ pub async fn insert_generated_audio( store: State<'_, AudioStore>, name: String, file_path: String, + #[allow(unused)] source_text: Option, ) -> Result { - store.insert(name, file_path).await + store + .insert(name, file_path, source_text.unwrap_or_default()) + .await +} + +#[tauri::command] +pub async fn update_generated_audio_name( + store: State<'_, AudioStore>, + id: i64, + name: String, +) -> Result<(), String> { + store.update_name(id, name).await } #[tauri::command] diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 4d2ee79..e42cb16 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -4,6 +4,7 @@ pub mod auth; pub mod audio; pub mod avatar; pub mod material; +pub mod sound_asr; pub mod video; pub mod fs_util; pub mod nodejs; diff --git a/src-tauri/src/commands/sound_asr.rs b/src-tauri/src/commands/sound_asr.rs new file mode 100644 index 0000000..8d57a02 --- /dev/null +++ b/src-tauri/src/commands/sound_asr.rs @@ -0,0 +1,162 @@ +//! 本地媒体文件语音识别(DashScope + OSS) + +use std::collections::HashMap; +use std::path::{Component, Path, PathBuf}; + +use tauri::State; +use uuid::Uuid; + +use crate::app_config::AppConfig; +use crate::asr; +use crate::asr_store::AsrStore; +use crate::ffmpeg; +use crate::oss::{self, OssConfig}; + +const VIDEO_EXTS: &[&str] = &["mp4", "avi", "mov", "mkv", "webm", "m4v", "flv"]; + +fn normalize_path(path: &str) -> PathBuf { + Path::new(path) + .components() + .filter(|c| !matches!(c, Component::ParentDir)) + .collect() +} + +fn config_get(map: &HashMap, keys: &[&str]) -> Option { + for key in keys { + if let Some(v) = map.get(*key) { + let t = v.trim(); + if !t.is_empty() { + return Some(t.to_string()); + } + } + } + None +} + +fn build_oss_config(map: &HashMap) -> Result { + let access_key_id = config_get( + map, + &[ + "ALIYUN_OSS_ACCESS_KEY_ID", + "OSS_ACCESS_KEY_ID", + "ALIYUN_ACCESS_KEY_ID", + ], + ) + .ok_or_else(|| "缺少 OSS AccessKeyId,请在本地配置中设置".to_string())?; + let access_key_secret = config_get( + map, + &[ + "ALIYUN_OSS_ACCESS_KEY_SECRET", + "OSS_ACCESS_KEY_SECRET", + "ALIYUN_ACCESS_KEY_SECRET", + ], + ) + .ok_or_else(|| "缺少 OSS AccessKeySecret".to_string())?; + let bucket = config_get(map, &["ALIYUN_OSS_BUCKET", "OSS_BUCKET"]) + .ok_or_else(|| "缺少 OSS Bucket".to_string())?; + let region = config_get(map, &["ALIYUN_OSS_REGION", "OSS_REGION"]) + .ok_or_else(|| "缺少 OSS Region".to_string())?; + let endpoint = config_get(map, &["ALIYUN_OSS_ENDPOINT", "OSS_ENDPOINT"]); + Ok(OssConfig { + access_key_id, + access_key_secret, + bucket, + region, + endpoint, + }) +} + +fn is_video_path(path: &Path) -> bool { + path.extension() + .and_then(|e| e.to_str()) + .map(|e| VIDEO_EXTS.contains(&e.to_lowercase().as_str())) + .unwrap_or(false) +} + +#[tauri::command] +pub async fn list_asr_history(store: State<'_, AsrStore>) -> Result, String> { + store.list().await +} + +#[tauri::command] +pub async fn delete_asr_history(store: State<'_, AsrStore>, id: i64) -> Result<(), String> { + let ok = store.delete(id).await?; + if !ok { + return Err("记录不存在".into()); + } + Ok(()) +} + +#[tauri::command] +pub async fn update_asr_history_name( + store: State<'_, AsrStore>, + id: i64, + name: String, +) -> Result<(), String> { + store.update_name(id, name).await +} + +#[tauri::command] +pub async fn transcribe_media_file( + app_config: State<'_, AppConfig>, + asr_store: State<'_, AsrStore>, + file_path: String, + model: Option, +) -> Result { + let src = normalize_path(file_path.trim()); + if !src.is_file() { + return Err(format!("文件不存在: {}", src.display())); + } + + let map = app_config.entries().await; + let api_key = config_get( + &map, + &["BAILIAN_API_KEY", "DASHSCOPE_API_KEY", "ALIYUN_BAILIAN_API_KEY"], + ) + .ok_or_else(|| { + "缺少 BAILIAN_API_KEY(或 DASHSCOPE_API_KEY),请在本地配置中设置".to_string() + })?; + let oss_cfg = build_oss_config(&map)?; + let model = model + .filter(|m| !m.trim().is_empty()) + .unwrap_or_else(|| "qwen3-asr-flash-filetrans".to_string()); + + let temp_dir = std::env::temp_dir().join("aiclient-asr"); + tokio::fs::create_dir_all(&temp_dir) + .await + .map_err(|e| format!("创建临时目录失败: {e}"))?; + + let upload_path = if is_video_path(&src) { + let wav = temp_dir.join(format!("{}.wav", Uuid::new_v4())); + ffmpeg::extract_audio(&src, &wav) + .await + .map_err(|e| format!("提取音频失败: {e}"))?; + wav + } else { + src.clone() + }; + + let key = format!("audio/{}.wav", Uuid::new_v4()); + let audio_url = oss::put_object(&oss_cfg, &upload_path, &key, "audio/wav") + .await + .map_err(|e| format!("上传音频到 OSS 失败: {e}"))?; + + let result = asr::transcribe(&audio_url, &api_key, &model) + .await + .map_err(|e| format!("语音识别失败: {e}"))?; + + if !result.success { + return Err(result + .error + .unwrap_or_else(|| "语音识别失败".into())); + } + + let text = result + .text + .filter(|t| !t.trim().is_empty()) + .ok_or_else(|| "未识别到有效文本".to_string())?; + + asr_store + .insert(String::new(), src.to_string_lossy().to_string(), text) + .await +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 8a7bc85..3ce3a1a 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -7,6 +7,8 @@ pub mod avatar_db; pub mod avatar_store; pub mod material_db; pub mod material_store; +pub mod asr_db; +pub mod asr_store; pub mod video_db; pub mod video_store; pub mod local_config; @@ -38,6 +40,7 @@ pub fn run() { .manage(app_config::AppConfig::new()) .manage(avatar_store::AvatarStore::new()) .manage(material_store::MaterialStore::new()) + .manage(asr_store::AsrStore::new()) .manage(audio_store::AudioStore::new()) .manage(video_store::VideoStore::new()) .manage(publish_store::PublishStore::new()) @@ -54,6 +57,7 @@ pub fn run() { let app_config = handle.state::(); let avatar_store = handle.state::(); let material_store = handle.state::(); + let asr_store = handle.state::(); let audio_store = handle.state::(); let video_store = handle.state::(); let publish_store = handle.state::(); @@ -65,6 +69,7 @@ pub fn run() { app_config.init_local_store(db_path).await?; avatar_store.init(dir.join("avatars.db")).await?; material_store.init(dir.join("video_materials.db")).await?; + asr_store.init(dir.join("asr_history.db")).await?; audio_store.init(dir.join("generated_audios.db")).await?; video_store.init(dir.join("generated_videos.db")).await?; publish_store.init(dir.join("publish_accounts.db")).await?; @@ -113,6 +118,11 @@ pub fn run() { commands::material::update_material_name, commands::material::delete_material, commands::material::import_material_files, + commands::audio::update_generated_audio_name, + commands::sound_asr::list_asr_history, + commands::sound_asr::delete_asr_history, + commands::sound_asr::update_asr_history_name, + commands::sound_asr::transcribe_media_file, commands::audio::list_generated_audios, commands::audio::insert_generated_audio, commands::audio::delete_generated_audio, diff --git a/src/components/voice/BatchTextSynthesizeDialog.vue b/src/components/voice/BatchTextSynthesizeDialog.vue new file mode 100644 index 0000000..ea8decb --- /dev/null +++ b/src/components/voice/BatchTextSynthesizeDialog.vue @@ -0,0 +1,119 @@ + + +