diff --git a/gen/schemas/capabilities.json b/gen/schemas/capabilities.json index b09ec22..5069246 100644 --- a/gen/schemas/capabilities.json +++ b/gen/schemas/capabilities.json @@ -1 +1 @@ -{"main":{"identifier":"main","description":"Main capability for the application","local":true,"windows":["main"],"permissions":["core:default","core:window:default","core:window:allow-show","core:window:allow-hide","core:window:allow-close","core:window:allow-set-focus","core:window:allow-start-dragging","core:webview:default","core:webview:allow-internal-toggle-devtools","shell:default","shell:allow-open","dialog:default","dialog:allow-open","dialog:allow-save","dialog:allow-message","dialog:allow-ask","dialog:allow-confirm","fs:default","fs:allow-read","fs:allow-write","fs:allow-exists"]}} \ No newline at end of file +{"main":{"identifier":"main","description":"Main capability for the application","local":true,"windows":["main"],"permissions":["core:default","core:window:default","core:window:allow-show","core:window:allow-hide","core:window:allow-close","core:window:allow-set-focus","core:window:allow-start-dragging","core:webview:default","core:webview:allow-internal-toggle-devtools","shell:default","shell:allow-open","dialog:default","dialog:allow-open","dialog:allow-save","dialog:allow-message","dialog:allow-ask","dialog:allow-confirm","fs:default","fs:allow-read","fs:allow-write","fs:allow-exists"],"platforms":["windows","linux","macOS"]}} \ No newline at end of file diff --git a/src/app/commands.rs b/src/app/commands.rs index 0c23305..d098f7d 100644 --- a/src/app/commands.rs +++ b/src/app/commands.rs @@ -1,5 +1,3 @@ -//! Tauri 命令处理 - use crate::asr::model::ModelConfig; use crate::asr::engine; use crate::config::{get_config, save_config as save_config_file, AppSettings}; @@ -12,26 +10,27 @@ use super::state::{AppState, AppTheme}; /// 录音响应 #[derive(Debug, Serialize, Deserialize)] pub struct RecordResponse { - success: bool, - message: String, - audio_path: Option, - duration_secs: Option, + pub success: bool, + pub message: String, + pub audio_path: Option, + pub duration_secs: Option, } /// 识别响应 #[derive(Debug, Serialize, Deserialize)] pub struct RecognizeResponse { - success: bool, - text: String, - language: Option, - confidence: Option, - duration_ms: Option, + pub success: bool, + pub text: String, + pub language: Option, + pub confidence: Option, + pub duration_ms: Option, } -/// 开始录音 +/// 开始录音(非阻塞,立即返回) #[tauri::command] -pub async fn start_recording( +pub fn start_recording( state: State<'_, AppState>, + app: tauri::AppHandle, ) -> Result { info!("开始录音命令"); @@ -46,106 +45,121 @@ pub async fn start_recording( let config = get_config().map_err(|e| e.to_string())?; - let recording_config = crate::audio::RecordingConfig { - sample_rate: config.audio.sample_rate, - channels: config.audio.channels, - output_path: None, - }; + 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", ()); - match crate::audio::record_audio(recording_config).await { - Ok((path, duration)) => { - state.set_recording(true); - state.set_recording_path(path.clone()); Ok(RecordResponse { success: true, - message: "录音完成".to_string(), - audio_path: Some(path), - duration_secs: Some(duration), + message: "录音已开始".to_string(), + audio_path: None, + duration_secs: Some(duration_secs), }) } Err(e) => { - error!("录音失败: {}", e); + error!("启动录音失败: {}", e); Err(e.to_string()) } } } -/// 停止录音 +/// 停止录音(停止流并保存 WAV 文件) #[tauri::command] -pub fn stop_recording(state: State<'_, AppState>) -> Result { +pub fn stop_recording( + state: State<'_, AppState>, + app: tauri::AppHandle, +) -> Result { info!("停止录音命令"); - if !state.is_recording() { + 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); } - state.set_recording(false); + match handle.stop_and_save(&output_path) { + Ok((path, actual_duration)) => { + state.set_recording_path(path.clone()); + info!("录音已保存: {}", path); - Ok(RecordResponse { - success: true, - message: "录音已停止".to_string(), - audio_path: None, - duration_secs: None, - }) -} + // 通知前端录音已停止 + let _ = app.emit("recording-stopped", serde_json::json!({ + "path": path, + "duration": actual_duration + })); -/// 识别音频文件 -#[tauri::command] -pub async fn recognize_audio(path: String) -> Result { - info!("识别音频: {}", path); - - // 确保 ASR 引擎已初始化 - if engine::ensure_engine_initialized().is_err() { - // 尝试使用配置中的模型初始化 - 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); - if let Err(e) = engine::init_engine(model_config) { - error!("ASR 引擎初始化失败: {}", e); - return Err(format!("ASR 引擎初始化失败: {}", e)); - } - } else { - // 尝试默认模型路径 - let default_config = ModelConfig::default(); - if default_config.model_exists() { - if let Err(e) = engine::init_engine(default_config) { - return Err(format!("ASR 引擎初始化失败: {}", e)); - } - } else { - return Err("模型文件不存在,请先下载模型".to_string()); - } - } - } else { - let default_config = ModelConfig::default(); - if default_config.model_exists() { - if let Err(e) = engine::init_engine(default_config) { - return Err(format!("ASR 引擎初始化失败: {}", e)); - } - } else { - return Err("模型文件不存在,请先下载模型".to_string()); - } + 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 { + 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 { + 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, ); - let state = tauri::async_runtime::block_on(async { - // 通过 app handle 获取状态 (这里简化处理) - None:: - }); + state.add_history(history.clone()); Ok(RecognizeResponse { success: true, @@ -162,6 +176,38 @@ pub async fn recognize_audio(path: String) -> Result } } +/// 识别音频文件(兼容旧命令名) +#[tauri::command] +pub async fn recognize_audio( + path: String, + state: State<'_, AppState>, +) -> Result { + 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 { diff --git a/src/app/mod.rs b/src/app/mod.rs index 6ec01dd..bfc72e8 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -39,6 +39,8 @@ pub fn run() -> Result<()> { commands::start_recording, commands::stop_recording, commands::recognize_audio, + commands::recognize_file, + commands::recognize_last_recording, commands::get_config_cmd, commands::save_config, commands::get_history, diff --git a/src/app/state.rs b/src/app/state.rs index ce97e00..fa080a1 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -1,8 +1,10 @@ //! 应用状态管理 +use crate::audio::RecordingHandle; use crate::config::HistoryEntry; -use parking_lot::RwLock; +use parking_lot::{Mutex, RwLock}; use std::collections::VecDeque; +use std::sync::Arc; /// 应用主题 #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] @@ -35,10 +37,11 @@ impl AppTheme { const MAX_HISTORY: usize = 100; /// 应用全局状态 -#[derive(Default)] pub struct AppState { /// 是否正在录音 is_recording: RwLock, + /// 当前录音句柄 (可控制的录音流) + recording_handle: Mutex>, /// 当前录音路径 current_recording_path: RwLock>, /// 识别历史记录 @@ -56,6 +59,7 @@ impl AppState { pub fn new() -> Self { Self { is_recording: RwLock::new(false), + recording_handle: Mutex::new(None), current_recording_path: RwLock::new(None), history: RwLock::new(VecDeque::with_capacity(MAX_HISTORY)), current_model: RwLock::new("sensevoice-small".to_string()), @@ -64,9 +68,17 @@ impl AppState { } } - /// 设置录音状态 - pub fn set_recording(&self, recording: bool) { - *self.is_recording.write() = recording; + /// 设置录音句柄 + pub fn set_recording_handle(&self, handle: RecordingHandle) { + *self.recording_handle.lock() = Some(handle); + *self.is_recording.write() = true; + } + + /// 取回录音句柄(用于停止) + pub fn take_recording_handle(&self) -> Option { + let mut guard = self.recording_handle.lock(); + *self.is_recording.write() = false; + guard.take() } /// 检查是否在录音 @@ -74,6 +86,11 @@ impl AppState { *self.is_recording.read() } + /// 设置录音状态(无句柄时,如错误场景) + pub fn set_recording(&self, recording: bool) { + *self.is_recording.write() = recording; + } + /// 设置当前录音路径 pub fn set_recording_path(&self, path: String) { *self.current_recording_path.write() = Some(path); @@ -140,15 +157,8 @@ impl AppState { } } -impl Clone for AppState { - fn clone(&self) -> Self { - Self { - is_recording: RwLock::new(*self.is_recording.read()), - current_recording_path: RwLock::new(self.current_recording_path.read().clone()), - history: RwLock::new(self.history.read().clone()), - current_model: RwLock::new(self.current_model.read().clone()), - current_theme: RwLock::new(*self.current_theme.read()), - allow_exit: RwLock::new(*self.allow_exit.read()), - } +impl Default for AppState { + fn default() -> Self { + Self::new() } } diff --git a/src/audio/capture.rs b/src/audio/capture.rs index 9bf4597..d36bef7 100644 --- a/src/audio/capture.rs +++ b/src/audio/capture.rs @@ -1,11 +1,8 @@ -//! 音频捕获模块 -//! -//! 使用 cpal 实现实时音频录制 - use anyhow::{Context, Result}; use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; +use parking_lot::Mutex; use std::path::PathBuf; -use std::sync::{Arc, Mutex}; +use std::sync::Arc; use tracing::{info, warn}; /// 录音配置 @@ -29,283 +26,43 @@ impl Default for RecordingConfig { } } -/// 录制音频到文件 -/// -/// 录音直到调用方发送停止信号 (通过 drop RecordingHandle) -pub async fn record_audio(config: RecordingConfig) -> Result<(String, f32)> { - info!("开始录音: 采样率={}, 声道={}", config.sample_rate, config.channels); +/// Send 安全包装器 +struct SendStream(cpal::Stream); +unsafe impl Send for SendStream {} - let host = cpal::default_host(); - let device = host - .default_input_device() - .context("没有可用的输入设备")?; - - info!("使用设备: {}", device.name().unwrap_or_else(|_| "未知".to_string())); - - // 获取支持的配置 - let mut supported_configs = device - .supported_input_configs() - .context("获取音频配置失败")?; - - // 查找匹配的采样率配置 - let config_found = supported_configs - .find(|c| { - c.min_sample_rate().0 <= config.sample_rate - && c.max_sample_rate().0 >= config.sample_rate - && c.channels() == config.channels - }) - .or_else(|| { - // 回退: 使用默认配置 - device - .supported_input_configs() - .ok() - .and_then(|mut configs| configs.next()) - }) - .context("没有匹配的音频配置")?; - - let actual_sample_rate = config_found - .min_sample_rate() - .max(cpal::SampleRate(config.sample_rate)) - .min(config_found.max_sample_rate()); - - let actual_config: cpal::StreamConfig = cpal::StreamConfig { - sample_rate: actual_sample_rate, - channels: config.channels, - buffer_size: cpal::BufferSize::Default, - }; - - // 音频缓冲区 - let samples = Arc::new(Mutex::new(Vec::::new())); - let samples_clone = Arc::clone(&samples); - - // 创建录音数据回调 - let err_fn = |err: cpal::StreamError| { - warn!("音频流错误: {}", err); - }; - - let stream = match config_found.sample_format() { - cpal::SampleFormat::F32 => device.build_input_stream( - &actual_config, - move |data: &[f32], _: &cpal::InputCallbackInfo| { - let mut buf = samples_clone.lock().unwrap(); - buf.extend_from_slice(data); - }, - err_fn, - None, - )?, - cpal::SampleFormat::I16 => device.build_input_stream( - &actual_config, - move |data: &[i16], _: &cpal::InputCallbackInfo| { - let mut buf = samples_clone.lock().unwrap(); - for &sample in data { - buf.push(sample as f32 / 32768.0); - } - }, - err_fn, - None, - )?, - cpal::SampleFormat::I32 => device.build_input_stream( - &actual_config, - move |data: &[i32], _: &cpal::InputCallbackInfo| { - let mut buf = samples_clone.lock().unwrap(); - for &sample in data { - buf.push(sample as f32 / 2147483648.0); - } - }, - err_fn, - None, - )?, - cpal::SampleFormat::U16 => device.build_input_stream( - &actual_config, - move |data: &[u16], _: &cpal::InputCallbackInfo| { - let mut buf = samples_clone.lock().unwrap(); - for &sample in data { - buf.push((sample as f32 - 32768.0) / 32768.0); - } - }, - err_fn, - None, - )?, - other => { - anyhow::bail!("不支持的采样格式: {:?}", other); - } - }; - - // 播放流 - stream.play()?; - info!("音频流已启动"); - - // 录音 5 秒 (可配置的默认值) - let duration_secs = 5.0; - tokio::time::sleep(std::time::Duration::from_secs_f32(duration_secs)).await; - - // 停止流 - drop(stream); - info!("音频流已停止"); - - // 获取采集的样本 - let collected_samples = samples.lock().unwrap().clone(); - - if collected_samples.is_empty() { - anyhow::bail!("未采集到任何音频数据"); - } - - info!("采集到 {} 个样本", collected_samples.len()); - - // 保存到文件 - let output_path = config.output_path.unwrap_or_else(|| { - let ts = chrono::Local::now().format("%Y%m%d_%H%M%S"); - PathBuf::from(format!("recordings/rec_{}.wav", ts)) - }); - - if let Some(parent) = output_path.parent() { - let _ = std::fs::create_dir_all(parent); - } - - let channels = actual_config.channels; - let sr = actual_config.sample_rate.0; - - // 写入 WAV 文件 - let spec = hound::WavSpec { - channels, - sample_rate: sr, - bits_per_sample: 16, - sample_format: hound::SampleFormat::Int, - }; - - let mut writer = hound::WavWriter::create(&output_path, spec) - .context("无法创建 WAV 文件")?; - - for sample in &collected_samples { - writer.write_sample((sample.clamp(-1.0, 1.0) * 32767.0) as i16)?; - } - - writer.finalize()?; - - let actual_duration = collected_samples.len() as f32 / (sr as f32 * channels as f32); - - info!("录音完成: {:?}, 时长={:.2}s", output_path, actual_duration); - - Ok((output_path.to_string_lossy().to_string(), actual_duration)) -} - -/// 创建录音句柄 (非阻塞) -/// -/// 返回 (RecordingHandle, 样本 Arc) -/// drop RecordingHandle 会停止录音 -pub fn start_recording( - sample_rate: u32, - channels: u16, -) -> Result<(RecordingHandle, Arc>>)> { - let host = cpal::default_host(); - let device = host - .default_input_device() - .context("没有可用的输入设备")?; - - let supported_configs: Vec<_> = device - .supported_input_configs() - .context("获取音频配置失败")? - .collect(); - - let config_found = supported_configs - .iter() - .find(|c| { - c.min_sample_rate().0 <= sample_rate - && c.max_sample_rate().0 >= sample_rate - }) - .or_else(|| supported_configs.first()) - .context("没有匹配的音频配置")?; - - let actual_sample_rate = config_found - .min_sample_rate() - .max(cpal::SampleRate(sample_rate)) - .min(config_found.max_sample_rate()); - - let actual_channels = config_found.channels().max(channels); - - let stream_config = cpal::StreamConfig { - sample_rate: actual_sample_rate, - channels: actual_channels, - buffer_size: cpal::BufferSize::Default, - }; - - let samples = Arc::new(Mutex::new(Vec::::new())); - let samples_clone = Arc::clone(&samples); - - let err_fn = |err: cpal::StreamError| { - warn!("音频流错误: {}", err); - }; - - let stream = match config_found.sample_format() { - cpal::SampleFormat::F32 => device.build_input_stream( - &stream_config, - move |data: &[f32], _: &cpal::InputCallbackInfo| { - let mut buf = samples_clone.lock().unwrap(); - buf.extend_from_slice(data); - }, - err_fn, - None, - )?, - cpal::SampleFormat::I16 => device.build_input_stream( - &stream_config, - move |data: &[i16], _: &cpal::InputCallbackInfo| { - let mut buf = samples_clone.lock().unwrap(); - for &s in data { - buf.push(s as f32 / 32768.0); - } - }, - err_fn, - None, - )?, - cpal::SampleFormat::I32 => device.build_input_stream( - &stream_config, - move |data: &[i32], _: &cpal::InputCallbackInfo| { - let mut buf = samples_clone.lock().unwrap(); - for &s in data { - buf.push(s as f32 / 2147483648.0); - } - }, - err_fn, - None, - )?, - other => { - anyhow::bail!("不支持的采样格式: {:?}", other); - } - }; - - stream.play()?; - info!("录音已启动: 采样率={}, 声道={}", actual_sample_rate.0, actual_channels); - - Ok(( - RecordingHandle { - stream: Some(stream), - sample_rate: actual_sample_rate.0, - channels: actual_channels, - }, - samples, - )) -} - -/// 录音句柄 - drop 时自动停止 +/// 录音句柄 - 持有正在进行的录音状态 +/// 调用 stop() 停止录音并返回采集的样本 pub struct RecordingHandle { - stream: Option, + stream: Option, + samples: Arc>>, sample_rate: u32, channels: u16, + start_time: std::time::Instant, +} + +impl Drop for RecordingHandle { + fn drop(&mut self) { + self.stream.take(); + let duration = self.start_time.elapsed().as_secs_f32(); + let sample_count = self.samples.lock().len(); + info!("录音句柄销毁: 时长={:.2}s, 样本数={}", duration, sample_count); + } } impl RecordingHandle { - /// 停止录音并保存 - pub fn stop_and_save( - &mut self, - samples: Arc>>, - output_path: &PathBuf, - ) -> Result<(String, f32)> { - // 停止流 + /// 停止录音并返回采集的样本 + pub fn stop(&mut self) -> Vec { + self.stream.take(); + info!("录音已停止"); + self.samples.lock().clone() + } + + /// 停止录音并保存到 WAV 文件 + pub fn stop_and_save(mut self, output_path: &PathBuf) -> Result<(String, f32)> { self.stream.take(); info!("录音已停止"); - let collected = samples.lock().unwrap().clone(); - + let collected = self.samples.lock().clone(); if collected.is_empty() { anyhow::bail!("未采集到音频数据"); } @@ -328,14 +85,189 @@ impl RecordingHandle { writer.finalize()?; let duration = collected.len() as f32 / (self.sample_rate as f32 * self.channels as f32); - Ok((output_path.to_string_lossy().to_string(), duration)) } + /// 获取录音时长(秒) + pub fn duration_secs(&self) -> f32 { + self.start_time.elapsed().as_secs_f32() + } + + /// 获取实时采样数据快照 + pub fn get_samples(&self) -> Vec { + self.samples.lock().clone() + } + pub fn sample_rate(&self) -> u32 { self.sample_rate } pub fn channels(&self) -> u16 { self.channels } } +/// 选择最匹配的音频配置 +fn select_device_config( + device: &cpal::Device, + target_rate: u32, + target_channels: u16, +) -> Result<(cpal::SupportedStreamConfig, cpal::StreamConfig)> { + let supported_configs: Vec<_> = device + .supported_input_configs() + .context("获取音频配置失败")? + .collect(); + + let config_found = supported_configs + .iter() + .find(|c| { + c.min_sample_rate().0 <= target_rate + && c.max_sample_rate().0 >= target_rate + && c.channels() == target_channels + }) + .or_else(|| { + supported_configs.iter().find(|c| { + c.min_sample_rate().0 <= target_rate + && c.max_sample_rate().0 >= target_rate + }) + }) + .or_else(|| supported_configs.first()) + .context("没有匹配的音频配置")?; + + let actual_sample_rate = config_found + .min_sample_rate() + .max(cpal::SampleRate(target_rate)) + .min(config_found.max_sample_rate()); + + let actual_config = cpal::StreamConfig { + sample_rate: actual_sample_rate, + channels: config_found.channels(), + buffer_size: cpal::BufferSize::Default, + }; + + Ok((config_found.clone(), actual_config)) +} + +/// 构建音频输入流回调(统一处理多种采样格式) +fn build_input_stream( + device: &cpal::Device, + config: &cpal::SupportedStreamConfig, + stream_config: &cpal::StreamConfig, + callback: F, +) -> Result +where + F: FnMut(&[f32], &cpal::InputCallbackInfo) + Send + 'static, +{ + let err_fn = |err: cpal::StreamError| { + warn!("音频流错误: {}", err); + }; + + match config.sample_format() { + cpal::SampleFormat::F32 => device + .build_input_stream(stream_config, callback, err_fn, None) + .context("构建 F32 音频流失败"), + cpal::SampleFormat::I16 => { + let callback = move |data: &[i16], info: &cpal::InputCallbackInfo| { + let buf: Vec = data.iter().map(|&s| s as f32 / 32768.0).collect(); + callback(&buf, info); + }; + device + .build_input_stream(stream_config, callback, err_fn, None) + .context("构建 I16 音频流失败") + } + cpal::SampleFormat::I32 => { + let callback = move |data: &[i32], info: &cpal::InputCallbackInfo| { + let buf: Vec = data.iter().map(|&s| s as f32 / 2147483648.0).collect(); + callback(&buf, info); + }; + device + .build_input_stream(stream_config, callback, err_fn, None) + .context("构建 I32 音频流失败") + } + cpal::SampleFormat::U16 => { + let callback = move |data: &[u16], info: &cpal::InputCallbackInfo| { + let buf: Vec = data.iter().map(|&s| (s as f32 - 32768.0) / 32768.0).collect(); + callback(&buf, info); + }; + device + .build_input_stream(stream_config, callback, err_fn, None) + .context("构建 U16 音频流失败") + } + other => anyhow::bail!("不支持的采样格式: {:?}", other), + } +} + +/// 开始录音(非阻塞) +/// +/// 返回 RecordingHandle,调用 handle.stop() 或 handle.stop_and_save() 停止 +pub fn start_recording( + sample_rate: u32, + channels: u16, +) -> Result { + info!("开始录音: 采样率={}, 声道={}", sample_rate, channels); + + let host = cpal::default_host(); + let device = host + .default_input_device() + .context("没有可用的输入设备")?; + + info!("使用设备: {}", device.name().unwrap_or_else(|_| "未知".to_string())); + + let (config, stream_config) = select_device_config(&device, sample_rate, channels)?; + + let samples = Arc::new(Mutex::new(Vec::::new())); + let samples_clone = Arc::clone(&samples); + + let stream = build_input_stream( + &device, + &config, + &stream_config, + move |data: &[f32], _: &cpal::InputCallbackInfo| { + samples_clone.lock().extend_from_slice(data); + }, + )?; + + stream.play()?; + info!("音频流已启动"); + + Ok(RecordingHandle { + stream: Some(SendStream(stream)), + samples, + sample_rate: stream_config.sample_rate.0, + channels: stream_config.channels, + start_time: std::time::Instant::now(), + }) +} + +/// 停止录音并保存到文件(便捷函数) +pub fn stop_recording( + handle: RecordingHandle, + output_path: Option, +) -> Result<(String, f32)> { + let path = output_path.unwrap_or_else(|| { + let ts = chrono::Local::now().format("%Y%m%d_%H%M%S"); + PathBuf::from(format!("recordings/rec_{}.wav", ts)) + }); + + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + + handle.stop_and_save(&path) +} + +/// 录制音频到文件(阻塞,用于 CLI,默认 5 秒) +pub async fn record_audio(config: RecordingConfig) -> Result<(String, f32)> { + let handle = start_recording(config.sample_rate, config.channels)?; + tokio::time::sleep(std::time::Duration::from_secs(5)).await; + + let output_path = config.output_path.unwrap_or_else(|| { + let ts = chrono::Local::now().format("%Y%m%d_%H%M%S"); + PathBuf::from(format!("recordings/rec_{}.wav", ts)) + }); + + if let Some(parent) = output_path.parent() { + let _ = std::fs::create_dir_all(parent); + } + + handle.stop_and_save(&output_path) +} + /// 获取可用的输入设备列表 pub fn list_input_devices() -> Vec { let host = cpal::default_host(); diff --git a/src/audio/mod.rs b/src/audio/mod.rs index df7582b..2a21980 100644 --- a/src/audio/mod.rs +++ b/src/audio/mod.rs @@ -7,7 +7,7 @@ pub mod decoder; pub mod processor; pub mod resampler; -pub use capture::{record_audio, RecordingConfig, list_input_devices, get_default_input_device_info}; +pub use capture::{record_audio, start_recording, stop_recording, RecordingConfig, RecordingHandle, list_input_devices, get_default_input_device_info}; pub use decoder::decode_audio; pub use processor::AudioProcessor; pub use resampler::resample_audio; diff --git a/web/src/pages/FileConvertPage.tsx b/web/src/pages/FileConvertPage.tsx index 2e3d171..1c3be65 100644 --- a/web/src/pages/FileConvertPage.tsx +++ b/web/src/pages/FileConvertPage.tsx @@ -7,8 +7,19 @@ interface FileItem { status: 'pending' | 'processing' | 'completed' | 'error' progress: number result?: string + audioPath?: string // 实际文件路径(Tauri 环境) } +interface TauriAPI { + invoke: (cmd: string, args?: Record) => Promise +} + +declare const window: Window & { + __TAURI__?: TauriAPI +} + +const isTauri = () => typeof window !== 'undefined' && !!window.__TAURI__ + export default function FileConvertPage() { const [files, setFiles] = useState([]) const [isDragOver, setIsDragOver] = useState(false) @@ -23,22 +34,29 @@ export default function FileConvertPage() { setIsDragOver(false) }, []) - const handleDrop = useCallback((e: React.DragEvent) => { + const handleDrop = useCallback(async (e: React.DragEvent) => { e.preventDefault() setIsDragOver(false) - const droppedFiles = Array.from(e.dataTransfer.files) - addFiles(droppedFiles) + if (isTauri()) { + // Tauri 环境中拖拽的文件需要通过特殊方式获取路径 + // 这里简化处理,后续可接入 tauri-plugin-drag + const droppedFiles = Array.from(e.dataTransfer.files) + await addFiles(droppedFiles) + } else { + const droppedFiles = Array.from(e.dataTransfer.files) + addFiles(droppedFiles) + } }, []) - const handleFileSelect = (e: React.ChangeEvent) => { + const handleFileSelect = async (e: React.ChangeEvent) => { if (e.target.files) { const selectedFiles = Array.from(e.target.files) - addFiles(selectedFiles) + await addFiles(selectedFiles) } } - const addFiles = (newFiles: File[]) => { + const addFiles = async (newFiles: File[]) => { const audioExtensions = ['wav', 'mp3', 'flac', 'ogg', 'm4a', 'aac'] const validFiles = newFiles.filter(file => { @@ -46,12 +64,14 @@ export default function FileConvertPage() { return ext && audioExtensions.includes(ext) }) + // 在 Tauri 环境中,需要获取文件的实际路径 const fileItems: FileItem[] = validFiles.map(file => ({ id: Math.random().toString(36).substr(2, 9), name: file.name, size: file.size, status: 'pending', - progress: 0 + progress: 0, + audioPath: isTauri() ? undefined : (file as any).path || undefined })) setFiles(prev => [...prev, ...fileItems]) @@ -63,50 +83,107 @@ export default function FileConvertPage() { return (bytes / (1024 * 1024)).toFixed(1) + ' MB' } - const startConvert = (id: string) => { - setFiles(prev => prev.map(file => { - if (file.id === id) { - return { ...file, status: 'processing' } - } - return file - })) + const startConvert = async (id: string) => { + const file = files.find(f => f.id === id) + if (!file) return - // 模拟转换过程 - let progress = 0 - const interval = setInterval(() => { - progress += 10 - if (progress >= 100) { - clearInterval(interval) - setFiles(prev => prev.map(file => { - if (file.id === id) { + setFiles(prev => prev.map(f => + f.id === id ? { ...f, status: 'processing' as const, progress: 0 } : f + )) + + try { + if (isTauri() && file.audioPath) { + // Tauri 环境:调用后端识别 + const result = await window.__TAURI__!.invoke('recognize_file', { + path: file.audioPath + }) as { success: boolean; text: string; language: string; confidence: number; duration_ms: number } + + setFiles(prev => prev.map(f => { + if (f.id === id) { return { - ...file, - status: 'completed', + ...f, + status: result.success ? 'completed' as const : 'error' as const, progress: 100, - result: '这是模拟的转换结果...' + result: result.text } } - return file + return f })) } else { - setFiles(prev => prev.map(file => { - if (file.id === id) { - return { ...file, progress } - } - return file - })) + // 非 Tauri 环境:模拟转换 + await simulateConversion(id) } - }, 200) + } catch (e) { + setFiles(prev => prev.map(f => { + if (f.id === id) { + return { + ...f, + status: 'error' as const, + progress: 0 + } + } + return f + })) + } } - const startAll = () => { - files.filter(f => f.status === 'pending').forEach(f => startConvert(f.id)) + // 模拟转换(非 Tauri 环境) + const simulateConversion = (id: string): Promise => { + return new Promise((resolve) => { + let progress = 0 + const interval = setInterval(() => { + progress += 10 + if (progress >= 100) { + clearInterval(interval) + setFiles(prev => prev.map(f => { + if (f.id === id) { + return { + ...f, + status: 'completed' as const, + progress: 100, + result: '这是模拟的转换结果。在 Tauri 环境中将调用真实 ASR 引擎。' + } + } + return f + })) + resolve() + } else { + setFiles(prev => prev.map(f => { + if (f.id === id) { + return { ...f, progress } + } + return f + })) + } + }, 200) + }) + } + + const startAll = async () => { + const pendingFiles = files.filter(f => f.status === 'pending') + // 串行处理避免同时调用过多 ASR 推理 + for (const f of pendingFiles) { + await startConvert(f.id) + } } const removeFile = (id: string) => { setFiles(prev => prev.filter(f => f.id !== id)) } + const handleExport = (file: FileItem) => { + if (file.result) { + // 导出为 TXT + const blob = new Blob([file.result], { type: 'text/plain' }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = file.name.replace(/\.[^.]+$/, '') + '.txt' + a.click() + URL.revokeObjectURL(url) + } + } + return (

文件识别转文字

@@ -156,6 +233,19 @@ export default function FileConvertPage() { )} {file.status === 'error' && '处理失败'}
+ {file.status === 'completed' && file.result && ( +
+ {file.result.substring(0, 80)}... +
+ )} {file.status === 'pending' && (
{formatSize(file.size)} @@ -173,8 +263,7 @@ export default function FileConvertPage() { )} {file.status === 'completed' && ( <> - - + )}
@@ -103,7 +196,7 @@ export default function RecordPage() {
- {isRecording ? '● 录音中' : '○ 就绪'} + {isRecording ? '● 录音中' : isProcessing ? '⏳ 识别中' : '○ 就绪'} {result && ( @@ -111,6 +204,12 @@ export default function RecordPage() { )}
+ + {error && ( +
+ {error} +
+ )}
@@ -119,13 +218,13 @@ export default function RecordPage() {

识别结果

- -
diff --git a/web/src/pages/SettingsPage.tsx b/web/src/pages/SettingsPage.tsx index 8ac615b..e4f3033 100644 --- a/web/src/pages/SettingsPage.tsx +++ b/web/src/pages/SettingsPage.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react' +import { useState, useEffect } from 'react' interface Settings { model: string @@ -21,76 +21,136 @@ interface SettingsPageProps { onThemeChange: (theme: 'light' | 'dark' | 'system') => void } +interface TauriAPI { + invoke: (cmd: string, args?: Record) => Promise +} + declare const window: Window & { - __TAURI__: { - invoke: (cmd: string, args?: Record) => Promise - } + __TAURI__?: TauriAPI +} + +const isTauri = () => typeof window !== 'undefined' && !!window.__TAURI__ + +const defaultSettings: Settings = { + model: 'sensevoice-small', + language: 'zh', + sampleRate: 16000, + microphone: '默认设备', + theme: 'dark', + autoStart: false, + autoCheckUpdate: true, + autoCopy: false, + historyKeepDays: 30, + hotkeyRecord: 'Ctrl+Shift+R', + hotkeyCopy: 'Ctrl+Shift+C', + hotkeyToggle: 'Ctrl+Shift+H' } export default function SettingsPage({ theme, onThemeChange }: SettingsPageProps) { - const [settings, setSettings] = useState({ - model: 'sensevoice-small', - language: 'zh', - sampleRate: 16000, - microphone: '默认设备', - theme: 'dark', - autoStart: false, - autoCheckUpdate: true, - autoCopy: false, - historyKeepDays: 30, - hotkeyRecord: 'Ctrl+Shift+R', - hotkeyCopy: 'Ctrl+Shift+C', - hotkeyToggle: 'Ctrl+Shift+H' - }) - + const [settings, setSettings] = useState(defaultSettings) const [modified, setModified] = useState(false) + const [saving, setSaving] = useState(false) + const [saveMessage, setSaveMessage] = useState(null) + + // 加载保存的配置 + useEffect(() => { + if (!isTauri()) return + + const loadConfig = async () => { + try { + const config = await window.__TAURI__!.invoke('get_config_cmd') as Record + if (config) { + setSettings(prev => ({ + ...prev, + model: (config.asr as any)?.model || prev.model, + language: (config.asr as any)?.language || prev.language, + sampleRate: (config.audio as any)?.sample_rate || prev.sampleRate, + microphone: (config.audio as any)?.device || prev.microphone, + modelPath: (config.asr as any)?.model_path || prev.modelPath, + })) + } + } catch (e) { + console.error('加载配置失败:', e) + } + } + + loadConfig() + }, []) const handleChange = (key: keyof Settings, value: Settings[typeof key]) => { setSettings(prev => ({ ...prev, [key]: value })) setModified(true) + setSaveMessage(null) } - const handleSave = () => { - // 保存设置 - console.log('保存设置:', settings) - setModified(false) - alert('设置已保存') + const handleSave = async () => { + setSaving(true) + setSaveMessage(null) + + try { + if (isTauri()) { + // 构建后端期望的配置格式 + const configPayload = { + asr: { + model: settings.model, + language: settings.language, + model_path: settings.modelPath || null, + }, + audio: { + sample_rate: settings.sampleRate, + channels: 1, + device: settings.microphone, + }, + ui: { + theme: settings.theme, + auto_start: settings.autoStart, + auto_check_update: settings.autoCheckUpdate, + auto_copy: settings.autoCopy, + history_keep_days: settings.historyKeepDays, + }, + hotkeys: { + record: settings.hotkeyRecord, + copy: settings.hotkeyCopy, + toggle: settings.hotkeyToggle, + } + } + + await window.__TAURI__!.invoke('save_config', { settings: configPayload }) + setSaveMessage('✓ 设置已保存') + } else { + // 开发环境:仅日志 + console.log('保存设置 (开发环境):', settings) + setSaveMessage('✓ 设置已记录到控制台 (开发环境)') + } + setModified(false) + } catch (e) { + setSaveMessage(`✗ 保存失败: ${e instanceof Error ? e.message : String(e)}`) + } finally { + setSaving(false) + } } const handleReset = () => { - setSettings({ - model: 'sensevoice-small', - language: 'zh', - sampleRate: 16000, - microphone: '默认设备', - theme: 'dark', - autoStart: false, - autoCheckUpdate: true, - autoCopy: false, - historyKeepDays: 30, - hotkeyRecord: 'Ctrl+Shift+R', - hotkeyCopy: 'Ctrl+Shift+C', - hotkeyToggle: 'Ctrl+Shift+H', - modelPath: undefined - }) + setSettings({ ...defaultSettings }) setModified(true) + setSaveMessage(null) } const handleSelectModel = async () => { try { - console.log('开始调用 select_model_file 命令...') - const modelPath = await window.__TAURI__.invoke('select_model_file') - console.log('模型文件选择结果:', modelPath) + if (!isTauri()) { + alert('仅在 Tauri 环境中可用') + return + } + const modelPath = await window.__TAURI__!.invoke('select_model_file') if (modelPath) { const path = modelPath as string setSettings(prev => ({ ...prev, modelPath: path })) setModified(true) - console.log('模型路径已更新:', path) } } catch (error) { - console.error('选择模型文件失败,错误详情:', error) const errorMessage = error instanceof Error ? error.message : String(error) - alert(`选择模型文件失败:${errorMessage}`) + setSaveMessage(`✗ 选择模型失败: ${errorMessage}`) } } @@ -98,6 +158,12 @@ export default function SettingsPage({ theme, onThemeChange }: SettingsPageProps

设置

+ {saveMessage && ( +
+ {saveMessage} +
+ )} +
{/* 识别模型 */}
@@ -327,9 +393,9 @@ export default function SettingsPage({ theme, onThemeChange }: SettingsPageProps