- 重构 capture.rs:添加 start_recording/stop_recording 支持真正的开始/停止控制 - 更新 AppState:引入 RecordingHandle 字段管理录音状态 - 重构 commands.rs:start_recording 立即返回,stop_recording 停止并保存文件 - 新增 recognize_file/recognize_last_recording 命令 - 前端 RecordPage:调用真实后端命令,监听 recording-stopped 事件自动识别 - 前端 FileConvertPage:连接 recognize_file 命令,支持导出识别结果 - 前端 SettingsPage:通过 save_config 持久化配置,支持配置加载 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
276 lines
7.9 KiB
Rust
276 lines
7.9 KiB
Rust
use crate::asr::model::ModelConfig;
|
|
use crate::asr::engine;
|
|
use crate::config::{get_config, save_config as save_config_file, AppSettings};
|
|
use serde::{Deserialize, Serialize};
|
|
use tauri::{Emitter, State};
|
|
use tracing::{error, info};
|
|
|
|
use super::state::{AppState, AppTheme};
|
|
|
|
/// 录音响应
|
|
#[derive(Debug, Serialize, Deserialize)]
|
|
pub struct RecordResponse {
|
|
pub success: bool,
|
|
pub message: String,
|
|
pub audio_path: Option<String>,
|
|
pub duration_secs: Option<f32>,
|
|
}
|
|
|
|
/// 识别响应
|
|
#[derive(Debug, Serialize, Deserialize)]
|
|
pub struct RecognizeResponse {
|
|
pub success: bool,
|
|
pub text: String,
|
|
pub language: Option<String>,
|
|
pub confidence: Option<f32>,
|
|
pub duration_ms: Option<u64>,
|
|
}
|
|
|
|
/// 开始录音(非阻塞,立即返回)
|
|
#[tauri::command]
|
|
pub fn start_recording(
|
|
state: State<'_, AppState>,
|
|
app: tauri::AppHandle,
|
|
) -> Result<RecordResponse, String> {
|
|
info!("开始录音命令");
|
|
|
|
if state.is_recording() {
|
|
return Ok(RecordResponse {
|
|
success: false,
|
|
message: "正在录音中".to_string(),
|
|
audio_path: None,
|
|
duration_secs: None,
|
|
});
|
|
}
|
|
|
|
let config = get_config().map_err(|e| e.to_string())?;
|
|
|
|
match crate::audio::start_recording(config.audio.sample_rate, config.audio.channels) {
|
|
Ok(handle) => {
|
|
let duration_secs = handle.duration_secs();
|
|
state.set_recording_handle(handle);
|
|
info!("录音已启动");
|
|
|
|
// 通知前端录音已开始
|
|
let _ = app.emit("recording-started", ());
|
|
|
|
Ok(RecordResponse {
|
|
success: true,
|
|
message: "录音已开始".to_string(),
|
|
audio_path: None,
|
|
duration_secs: Some(duration_secs),
|
|
})
|
|
}
|
|
Err(e) => {
|
|
error!("启动录音失败: {}", e);
|
|
Err(e.to_string())
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 停止录音(停止流并保存 WAV 文件)
|
|
#[tauri::command]
|
|
pub fn stop_recording(
|
|
state: State<'_, AppState>,
|
|
app: tauri::AppHandle,
|
|
) -> Result<RecordResponse, String> {
|
|
info!("停止录音命令");
|
|
|
|
let handle = state.take_recording_handle();
|
|
let Some(handle) = handle else {
|
|
return Ok(RecordResponse {
|
|
success: false,
|
|
message: "未在录音".to_string(),
|
|
audio_path: None,
|
|
duration_secs: None,
|
|
});
|
|
};
|
|
|
|
let duration_secs = handle.duration_secs();
|
|
info!("录音时长: {:.2}s", duration_secs);
|
|
|
|
// 保存 WAV 文件
|
|
let output_path = chrono::Local::now().format("recordings/rec_%Y%m%d_%H%M%S.wav");
|
|
let output_path = std::path::PathBuf::from(output_path.to_string());
|
|
|
|
if let Some(parent) = output_path.parent() {
|
|
let _ = std::fs::create_dir_all(parent);
|
|
}
|
|
|
|
match handle.stop_and_save(&output_path) {
|
|
Ok((path, actual_duration)) => {
|
|
state.set_recording_path(path.clone());
|
|
info!("录音已保存: {}", path);
|
|
|
|
// 通知前端录音已停止
|
|
let _ = app.emit("recording-stopped", serde_json::json!({
|
|
"path": path,
|
|
"duration": actual_duration
|
|
}));
|
|
|
|
Ok(RecordResponse {
|
|
success: true,
|
|
message: "录音已保存".to_string(),
|
|
audio_path: Some(path),
|
|
duration_secs: Some(actual_duration),
|
|
})
|
|
}
|
|
Err(e) => {
|
|
// 即使保存失败也返回样本数量信息
|
|
let samples = handle.get_samples();
|
|
error!("保存录音失败: {}", e);
|
|
Ok(RecordResponse {
|
|
success: false,
|
|
message: format!("保存失败: {}", e),
|
|
audio_path: None,
|
|
duration_secs: Some(duration_secs),
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 识别录音文件(停止录音后自动识别)
|
|
#[tauri::command]
|
|
pub async fn recognize_last_recording(
|
|
state: State<'_, AppState>,
|
|
) -> Result<RecognizeResponse, String> {
|
|
let path = state
|
|
.get_recording_path()
|
|
.ok_or_else(|| "没有最近的录音文件".to_string())?;
|
|
|
|
recognize_file(path, state).await
|
|
}
|
|
|
|
/// 识别指定音频文件
|
|
#[tauri::command]
|
|
pub async fn recognize_file(
|
|
path: String,
|
|
state: State<'_, AppState>,
|
|
) -> Result<RecognizeResponse, String> {
|
|
info!("识别音频: {}", path);
|
|
|
|
ensure_engine_initialized()?;
|
|
|
|
match engine::recognize(&path).await {
|
|
Ok(result) => {
|
|
let history = crate::config::HistoryEntry::new(
|
|
result.text.clone(),
|
|
result.language.clone(),
|
|
result.confidence,
|
|
result.duration_ms as f32 / 1000.0,
|
|
);
|
|
state.add_history(history.clone());
|
|
|
|
Ok(RecognizeResponse {
|
|
success: true,
|
|
text: result.text,
|
|
language: Some(result.language),
|
|
confidence: Some(result.confidence),
|
|
duration_ms: Some(result.duration_ms),
|
|
})
|
|
}
|
|
Err(e) => {
|
|
error!("识别失败: {}", e);
|
|
Err(format!("识别失败: {}", e))
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 识别音频文件(兼容旧命令名)
|
|
#[tauri::command]
|
|
pub async fn recognize_audio(
|
|
path: String,
|
|
state: State<'_, AppState>,
|
|
) -> Result<RecognizeResponse, String> {
|
|
recognize_file(path, state).await
|
|
}
|
|
|
|
/// 确保 ASR 引擎已初始化
|
|
fn ensure_engine_initialized() -> Result<(), String> {
|
|
if engine::ensure_engine_initialized().is_ok() {
|
|
return Ok(());
|
|
}
|
|
|
|
let config = get_config().map_err(|e| e.to_string())?;
|
|
|
|
if let Some(model_path) = &config.asr.model_path {
|
|
if model_path.exists() {
|
|
let model_config = ModelConfig::new(model_path, &config.asr.model);
|
|
return engine::init_engine(model_config).map_err(|e| format!("ASR 引擎初始化失败: {}", e));
|
|
}
|
|
}
|
|
|
|
let default_config = ModelConfig::default();
|
|
if default_config.model_exists() {
|
|
engine::init_engine(default_config).map_err(|e| format!("ASR 引擎初始化失败: {}", e))
|
|
} else {
|
|
Err("模型文件不存在,请先下载模型".to_string())
|
|
}
|
|
}
|
|
|
|
/// 获取配置
|
|
#[tauri::command]
|
|
pub fn get_config_cmd() -> Result<AppSettings, String> {
|
|
get_config().map_err(|e| e.to_string())
|
|
}
|
|
|
|
/// 保存配置
|
|
#[tauri::command]
|
|
pub fn save_config(settings: AppSettings) -> Result<(), String> {
|
|
save_config_file(&settings).map_err(|e| e.to_string())
|
|
}
|
|
|
|
/// 获取历史记录
|
|
#[tauri::command]
|
|
pub fn get_history(
|
|
state: State<'_, AppState>,
|
|
limit: Option<usize>,
|
|
) -> Vec<crate::config::HistoryEntry> {
|
|
state.get_history(limit.unwrap_or(20))
|
|
}
|
|
|
|
/// 清空历史记录
|
|
#[tauri::command]
|
|
pub fn clear_history(state: State<'_, AppState>) {
|
|
state.clear_history();
|
|
}
|
|
|
|
/// 获取当前主题
|
|
#[tauri::command]
|
|
pub fn get_theme(state: State<'_, AppState>) -> String {
|
|
state.get_theme().as_str().to_string()
|
|
}
|
|
|
|
/// 设置主题
|
|
#[tauri::command]
|
|
pub fn set_theme(theme: String, state: State<'_, AppState>, app: tauri::AppHandle) {
|
|
let app_theme = AppTheme::from_str(&theme);
|
|
state.set_theme(app_theme);
|
|
let _ = app.emit("theme-change", theme);
|
|
}
|
|
|
|
/// 选择模型文件
|
|
#[tauri::command]
|
|
pub async fn select_model_file(app: tauri::AppHandle) -> Result<String, String> {
|
|
use tauri_plugin_dialog::DialogExt;
|
|
|
|
let (tx, rx) = std::sync::mpsc::channel();
|
|
|
|
app.dialog()
|
|
.file()
|
|
.add_filter("ONNX Model", &["onnx"])
|
|
.pick_file(move |file_path| {
|
|
let result = match file_path {
|
|
Some(path) => path.into_path()
|
|
.map(|p| p.to_string_lossy().to_string())
|
|
.map_err(|e| format!("转换路径失败: {}", e)),
|
|
None => Err("用户取消选择".to_string()),
|
|
};
|
|
let _ = tx.send(result);
|
|
});
|
|
|
|
rx.recv()
|
|
.map_err(|e| e.to_string())
|
|
.and_then(|r| r)
|
|
}
|