impress_asr_input_rust/src/app/commands.rs
impressionyang da5d0d8ad2
Some checks failed
Build Windows GUI / build-windows (push) Has been cancelled
Build Windows GUI / release (push) Has been cancelled
feat: 打通录音核心链路,连接前后端命令
- 重构 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>
2026-06-04 20:19:44 +08:00

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)
}