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>
This commit is contained in:
parent
b5b7930304
commit
da5d0d8ad2
@ -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"]}}
|
{"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"]}}
|
||||||
@ -1,5 +1,3 @@
|
|||||||
//! Tauri 命令处理
|
|
||||||
|
|
||||||
use crate::asr::model::ModelConfig;
|
use crate::asr::model::ModelConfig;
|
||||||
use crate::asr::engine;
|
use crate::asr::engine;
|
||||||
use crate::config::{get_config, save_config as save_config_file, AppSettings};
|
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)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
pub struct RecordResponse {
|
pub struct RecordResponse {
|
||||||
success: bool,
|
pub success: bool,
|
||||||
message: String,
|
pub message: String,
|
||||||
audio_path: Option<String>,
|
pub audio_path: Option<String>,
|
||||||
duration_secs: Option<f32>,
|
pub duration_secs: Option<f32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 识别响应
|
/// 识别响应
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
pub struct RecognizeResponse {
|
pub struct RecognizeResponse {
|
||||||
success: bool,
|
pub success: bool,
|
||||||
text: String,
|
pub text: String,
|
||||||
language: Option<String>,
|
pub language: Option<String>,
|
||||||
confidence: Option<f32>,
|
pub confidence: Option<f32>,
|
||||||
duration_ms: Option<u64>,
|
pub duration_ms: Option<u64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 开始录音
|
/// 开始录音(非阻塞,立即返回)
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn start_recording(
|
pub fn start_recording(
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
|
app: tauri::AppHandle,
|
||||||
) -> Result<RecordResponse, String> {
|
) -> Result<RecordResponse, String> {
|
||||||
info!("开始录音命令");
|
info!("开始录音命令");
|
||||||
|
|
||||||
@ -46,106 +45,121 @@ pub async fn start_recording(
|
|||||||
|
|
||||||
let config = get_config().map_err(|e| e.to_string())?;
|
let config = get_config().map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
let recording_config = crate::audio::RecordingConfig {
|
match crate::audio::start_recording(config.audio.sample_rate, config.audio.channels) {
|
||||||
sample_rate: config.audio.sample_rate,
|
Ok(handle) => {
|
||||||
channels: config.audio.channels,
|
let duration_secs = handle.duration_secs();
|
||||||
output_path: None,
|
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 {
|
Ok(RecordResponse {
|
||||||
success: true,
|
success: true,
|
||||||
message: "录音完成".to_string(),
|
message: "录音已开始".to_string(),
|
||||||
audio_path: Some(path),
|
audio_path: None,
|
||||||
duration_secs: Some(duration),
|
duration_secs: Some(duration_secs),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
error!("录音失败: {}", e);
|
error!("启动录音失败: {}", e);
|
||||||
Err(e.to_string())
|
Err(e.to_string())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 停止录音
|
/// 停止录音(停止流并保存 WAV 文件)
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn stop_recording(state: State<'_, AppState>) -> Result<RecordResponse, String> {
|
pub fn stop_recording(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
app: tauri::AppHandle,
|
||||||
|
) -> Result<RecordResponse, String> {
|
||||||
info!("停止录音命令");
|
info!("停止录音命令");
|
||||||
|
|
||||||
if !state.is_recording() {
|
let handle = state.take_recording_handle();
|
||||||
|
let Some(handle) = handle else {
|
||||||
return Ok(RecordResponse {
|
return Ok(RecordResponse {
|
||||||
success: false,
|
success: false,
|
||||||
message: "未在录音".to_string(),
|
message: "未在录音".to_string(),
|
||||||
audio_path: None,
|
audio_path: None,
|
||||||
duration_secs: 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);
|
||||||
|
|
||||||
|
// 通知前端录音已停止
|
||||||
|
let _ = app.emit("recording-stopped", serde_json::json!({
|
||||||
|
"path": path,
|
||||||
|
"duration": actual_duration
|
||||||
|
}));
|
||||||
|
|
||||||
Ok(RecordResponse {
|
Ok(RecordResponse {
|
||||||
success: true,
|
success: true,
|
||||||
message: "录音已停止".to_string(),
|
message: "录音已保存".to_string(),
|
||||||
audio_path: None,
|
audio_path: Some(path),
|
||||||
duration_secs: None,
|
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]
|
#[tauri::command]
|
||||||
pub async fn recognize_audio(path: String) -> Result<RecognizeResponse, String> {
|
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);
|
info!("识别音频: {}", path);
|
||||||
|
|
||||||
// 确保 ASR 引擎已初始化
|
ensure_engine_initialized()?;
|
||||||
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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
match engine::recognize(&path).await {
|
match engine::recognize(&path).await {
|
||||||
Ok(result) => {
|
Ok(result) => {
|
||||||
// 添加到历史记录
|
|
||||||
let history = crate::config::HistoryEntry::new(
|
let history = crate::config::HistoryEntry::new(
|
||||||
result.text.clone(),
|
result.text.clone(),
|
||||||
result.language.clone(),
|
result.language.clone(),
|
||||||
result.confidence,
|
result.confidence,
|
||||||
result.duration_ms as f32 / 1000.0,
|
result.duration_ms as f32 / 1000.0,
|
||||||
);
|
);
|
||||||
let state = tauri::async_runtime::block_on(async {
|
state.add_history(history.clone());
|
||||||
// 通过 app handle 获取状态 (这里简化处理)
|
|
||||||
None::<String>
|
|
||||||
});
|
|
||||||
|
|
||||||
Ok(RecognizeResponse {
|
Ok(RecognizeResponse {
|
||||||
success: true,
|
success: true,
|
||||||
@ -162,6 +176,38 @@ pub async fn recognize_audio(path: String) -> Result<RecognizeResponse, String>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 识别音频文件(兼容旧命令名)
|
||||||
|
#[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]
|
#[tauri::command]
|
||||||
pub fn get_config_cmd() -> Result<AppSettings, String> {
|
pub fn get_config_cmd() -> Result<AppSettings, String> {
|
||||||
|
|||||||
@ -39,6 +39,8 @@ pub fn run() -> Result<()> {
|
|||||||
commands::start_recording,
|
commands::start_recording,
|
||||||
commands::stop_recording,
|
commands::stop_recording,
|
||||||
commands::recognize_audio,
|
commands::recognize_audio,
|
||||||
|
commands::recognize_file,
|
||||||
|
commands::recognize_last_recording,
|
||||||
commands::get_config_cmd,
|
commands::get_config_cmd,
|
||||||
commands::save_config,
|
commands::save_config,
|
||||||
commands::get_history,
|
commands::get_history,
|
||||||
|
|||||||
@ -1,8 +1,10 @@
|
|||||||
//! 应用状态管理
|
//! 应用状态管理
|
||||||
|
|
||||||
|
use crate::audio::RecordingHandle;
|
||||||
use crate::config::HistoryEntry;
|
use crate::config::HistoryEntry;
|
||||||
use parking_lot::RwLock;
|
use parking_lot::{Mutex, RwLock};
|
||||||
use std::collections::VecDeque;
|
use std::collections::VecDeque;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
/// 应用主题
|
/// 应用主题
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||||
@ -35,10 +37,11 @@ impl AppTheme {
|
|||||||
const MAX_HISTORY: usize = 100;
|
const MAX_HISTORY: usize = 100;
|
||||||
|
|
||||||
/// 应用全局状态
|
/// 应用全局状态
|
||||||
#[derive(Default)]
|
|
||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
/// 是否正在录音
|
/// 是否正在录音
|
||||||
is_recording: RwLock<bool>,
|
is_recording: RwLock<bool>,
|
||||||
|
/// 当前录音句柄 (可控制的录音流)
|
||||||
|
recording_handle: Mutex<Option<RecordingHandle>>,
|
||||||
/// 当前录音路径
|
/// 当前录音路径
|
||||||
current_recording_path: RwLock<Option<String>>,
|
current_recording_path: RwLock<Option<String>>,
|
||||||
/// 识别历史记录
|
/// 识别历史记录
|
||||||
@ -56,6 +59,7 @@ impl AppState {
|
|||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self {
|
Self {
|
||||||
is_recording: RwLock::new(false),
|
is_recording: RwLock::new(false),
|
||||||
|
recording_handle: Mutex::new(None),
|
||||||
current_recording_path: RwLock::new(None),
|
current_recording_path: RwLock::new(None),
|
||||||
history: RwLock::new(VecDeque::with_capacity(MAX_HISTORY)),
|
history: RwLock::new(VecDeque::with_capacity(MAX_HISTORY)),
|
||||||
current_model: RwLock::new("sensevoice-small".to_string()),
|
current_model: RwLock::new("sensevoice-small".to_string()),
|
||||||
@ -64,9 +68,17 @@ impl AppState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 设置录音状态
|
/// 设置录音句柄
|
||||||
pub fn set_recording(&self, recording: bool) {
|
pub fn set_recording_handle(&self, handle: RecordingHandle) {
|
||||||
*self.is_recording.write() = recording;
|
*self.recording_handle.lock() = Some(handle);
|
||||||
|
*self.is_recording.write() = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 取回录音句柄(用于停止)
|
||||||
|
pub fn take_recording_handle(&self) -> Option<RecordingHandle> {
|
||||||
|
let mut guard = self.recording_handle.lock();
|
||||||
|
*self.is_recording.write() = false;
|
||||||
|
guard.take()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 检查是否在录音
|
/// 检查是否在录音
|
||||||
@ -74,6 +86,11 @@ impl AppState {
|
|||||||
*self.is_recording.read()
|
*self.is_recording.read()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 设置录音状态(无句柄时,如错误场景)
|
||||||
|
pub fn set_recording(&self, recording: bool) {
|
||||||
|
*self.is_recording.write() = recording;
|
||||||
|
}
|
||||||
|
|
||||||
/// 设置当前录音路径
|
/// 设置当前录音路径
|
||||||
pub fn set_recording_path(&self, path: String) {
|
pub fn set_recording_path(&self, path: String) {
|
||||||
*self.current_recording_path.write() = Some(path);
|
*self.current_recording_path.write() = Some(path);
|
||||||
@ -140,15 +157,8 @@ impl AppState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Clone for AppState {
|
impl Default for AppState {
|
||||||
fn clone(&self) -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self::new()
|
||||||
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()),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,11 +1,8 @@
|
|||||||
//! 音频捕获模块
|
|
||||||
//!
|
|
||||||
//! 使用 cpal 实现实时音频录制
|
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
|
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
|
||||||
|
use parking_lot::Mutex;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::Arc;
|
||||||
use tracing::{info, warn};
|
use tracing::{info, warn};
|
||||||
|
|
||||||
/// 录音配置
|
/// 录音配置
|
||||||
@ -29,283 +26,43 @@ impl Default for RecordingConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 录制音频到文件
|
/// Send 安全包装器
|
||||||
///
|
struct SendStream(cpal::Stream);
|
||||||
/// 录音直到调用方发送停止信号 (通过 drop RecordingHandle)
|
unsafe impl Send for SendStream {}
|
||||||
pub async fn record_audio(config: RecordingConfig) -> Result<(String, f32)> {
|
|
||||||
info!("开始录音: 采样率={}, 声道={}", config.sample_rate, config.channels);
|
|
||||||
|
|
||||||
let host = cpal::default_host();
|
/// 录音句柄 - 持有正在进行的录音状态
|
||||||
let device = host
|
/// 调用 stop() 停止录音并返回采集的样本
|
||||||
.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::<f32>::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<Mutex<Vec<f32>>>)> {
|
|
||||||
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::<f32>::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 时自动停止
|
|
||||||
pub struct RecordingHandle {
|
pub struct RecordingHandle {
|
||||||
stream: Option<cpal::Stream>,
|
stream: Option<SendStream>,
|
||||||
|
samples: Arc<Mutex<Vec<f32>>>,
|
||||||
sample_rate: u32,
|
sample_rate: u32,
|
||||||
channels: u16,
|
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 {
|
impl RecordingHandle {
|
||||||
/// 停止录音并保存
|
/// 停止录音并返回采集的样本
|
||||||
pub fn stop_and_save(
|
pub fn stop(&mut self) -> Vec<f32> {
|
||||||
&mut self,
|
self.stream.take();
|
||||||
samples: Arc<Mutex<Vec<f32>>>,
|
info!("录音已停止");
|
||||||
output_path: &PathBuf,
|
self.samples.lock().clone()
|
||||||
) -> Result<(String, f32)> {
|
}
|
||||||
// 停止流
|
|
||||||
|
/// 停止录音并保存到 WAV 文件
|
||||||
|
pub fn stop_and_save(mut self, output_path: &PathBuf) -> Result<(String, f32)> {
|
||||||
self.stream.take();
|
self.stream.take();
|
||||||
info!("录音已停止");
|
info!("录音已停止");
|
||||||
|
|
||||||
let collected = samples.lock().unwrap().clone();
|
let collected = self.samples.lock().clone();
|
||||||
|
|
||||||
if collected.is_empty() {
|
if collected.is_empty() {
|
||||||
anyhow::bail!("未采集到音频数据");
|
anyhow::bail!("未采集到音频数据");
|
||||||
}
|
}
|
||||||
@ -328,14 +85,189 @@ impl RecordingHandle {
|
|||||||
writer.finalize()?;
|
writer.finalize()?;
|
||||||
|
|
||||||
let duration = collected.len() as f32 / (self.sample_rate as f32 * self.channels as f32);
|
let duration = collected.len() as f32 / (self.sample_rate as f32 * self.channels as f32);
|
||||||
|
|
||||||
Ok((output_path.to_string_lossy().to_string(), duration))
|
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<f32> {
|
||||||
|
self.samples.lock().clone()
|
||||||
|
}
|
||||||
|
|
||||||
pub fn sample_rate(&self) -> u32 { self.sample_rate }
|
pub fn sample_rate(&self) -> u32 { self.sample_rate }
|
||||||
pub fn channels(&self) -> u16 { self.channels }
|
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<F>(
|
||||||
|
device: &cpal::Device,
|
||||||
|
config: &cpal::SupportedStreamConfig,
|
||||||
|
stream_config: &cpal::StreamConfig,
|
||||||
|
callback: F,
|
||||||
|
) -> Result<cpal::Stream>
|
||||||
|
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<f32> = 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<f32> = 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<f32> = 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<RecordingHandle> {
|
||||||
|
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::<f32>::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<PathBuf>,
|
||||||
|
) -> 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<String> {
|
pub fn list_input_devices() -> Vec<String> {
|
||||||
let host = cpal::default_host();
|
let host = cpal::default_host();
|
||||||
|
|||||||
@ -7,7 +7,7 @@ pub mod decoder;
|
|||||||
pub mod processor;
|
pub mod processor;
|
||||||
pub mod resampler;
|
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 decoder::decode_audio;
|
||||||
pub use processor::AudioProcessor;
|
pub use processor::AudioProcessor;
|
||||||
pub use resampler::resample_audio;
|
pub use resampler::resample_audio;
|
||||||
|
|||||||
@ -7,8 +7,19 @@ interface FileItem {
|
|||||||
status: 'pending' | 'processing' | 'completed' | 'error'
|
status: 'pending' | 'processing' | 'completed' | 'error'
|
||||||
progress: number
|
progress: number
|
||||||
result?: string
|
result?: string
|
||||||
|
audioPath?: string // 实际文件路径(Tauri 环境)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface TauriAPI {
|
||||||
|
invoke: (cmd: string, args?: Record<string, unknown>) => Promise<unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
declare const window: Window & {
|
||||||
|
__TAURI__?: TauriAPI
|
||||||
|
}
|
||||||
|
|
||||||
|
const isTauri = () => typeof window !== 'undefined' && !!window.__TAURI__
|
||||||
|
|
||||||
export default function FileConvertPage() {
|
export default function FileConvertPage() {
|
||||||
const [files, setFiles] = useState<FileItem[]>([])
|
const [files, setFiles] = useState<FileItem[]>([])
|
||||||
const [isDragOver, setIsDragOver] = useState(false)
|
const [isDragOver, setIsDragOver] = useState(false)
|
||||||
@ -23,22 +34,29 @@ export default function FileConvertPage() {
|
|||||||
setIsDragOver(false)
|
setIsDragOver(false)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const handleDrop = useCallback((e: React.DragEvent) => {
|
const handleDrop = useCallback(async (e: React.DragEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setIsDragOver(false)
|
setIsDragOver(false)
|
||||||
|
|
||||||
|
if (isTauri()) {
|
||||||
|
// Tauri 环境中拖拽的文件需要通过特殊方式获取路径
|
||||||
|
// 这里简化处理,后续可接入 tauri-plugin-drag
|
||||||
|
const droppedFiles = Array.from(e.dataTransfer.files)
|
||||||
|
await addFiles(droppedFiles)
|
||||||
|
} else {
|
||||||
const droppedFiles = Array.from(e.dataTransfer.files)
|
const droppedFiles = Array.from(e.dataTransfer.files)
|
||||||
addFiles(droppedFiles)
|
addFiles(droppedFiles)
|
||||||
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
if (e.target.files) {
|
if (e.target.files) {
|
||||||
const selectedFiles = Array.from(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 audioExtensions = ['wav', 'mp3', 'flac', 'ogg', 'm4a', 'aac']
|
||||||
|
|
||||||
const validFiles = newFiles.filter(file => {
|
const validFiles = newFiles.filter(file => {
|
||||||
@ -46,12 +64,14 @@ export default function FileConvertPage() {
|
|||||||
return ext && audioExtensions.includes(ext)
|
return ext && audioExtensions.includes(ext)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 在 Tauri 环境中,需要获取文件的实际路径
|
||||||
const fileItems: FileItem[] = validFiles.map(file => ({
|
const fileItems: FileItem[] = validFiles.map(file => ({
|
||||||
id: Math.random().toString(36).substr(2, 9),
|
id: Math.random().toString(36).substr(2, 9),
|
||||||
name: file.name,
|
name: file.name,
|
||||||
size: file.size,
|
size: file.size,
|
||||||
status: 'pending',
|
status: 'pending',
|
||||||
progress: 0
|
progress: 0,
|
||||||
|
audioPath: isTauri() ? undefined : (file as any).path || undefined
|
||||||
}))
|
}))
|
||||||
|
|
||||||
setFiles(prev => [...prev, ...fileItems])
|
setFiles(prev => [...prev, ...fileItems])
|
||||||
@ -63,50 +83,107 @@ export default function FileConvertPage() {
|
|||||||
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
|
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
|
||||||
}
|
}
|
||||||
|
|
||||||
const startConvert = (id: string) => {
|
const startConvert = async (id: string) => {
|
||||||
setFiles(prev => prev.map(file => {
|
const file = files.find(f => f.id === id)
|
||||||
if (file.id === id) {
|
if (!file) return
|
||||||
return { ...file, status: 'processing' }
|
|
||||||
}
|
|
||||||
return file
|
|
||||||
}))
|
|
||||||
|
|
||||||
// 模拟转换过程
|
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 {
|
||||||
|
...f,
|
||||||
|
status: result.success ? 'completed' as const : 'error' as const,
|
||||||
|
progress: 100,
|
||||||
|
result: result.text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return f
|
||||||
|
}))
|
||||||
|
} else {
|
||||||
|
// 非 Tauri 环境:模拟转换
|
||||||
|
await simulateConversion(id)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setFiles(prev => prev.map(f => {
|
||||||
|
if (f.id === id) {
|
||||||
|
return {
|
||||||
|
...f,
|
||||||
|
status: 'error' as const,
|
||||||
|
progress: 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return f
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 模拟转换(非 Tauri 环境)
|
||||||
|
const simulateConversion = (id: string): Promise<void> => {
|
||||||
|
return new Promise((resolve) => {
|
||||||
let progress = 0
|
let progress = 0
|
||||||
const interval = setInterval(() => {
|
const interval = setInterval(() => {
|
||||||
progress += 10
|
progress += 10
|
||||||
if (progress >= 100) {
|
if (progress >= 100) {
|
||||||
clearInterval(interval)
|
clearInterval(interval)
|
||||||
setFiles(prev => prev.map(file => {
|
setFiles(prev => prev.map(f => {
|
||||||
if (file.id === id) {
|
if (f.id === id) {
|
||||||
return {
|
return {
|
||||||
...file,
|
...f,
|
||||||
status: 'completed',
|
status: 'completed' as const,
|
||||||
progress: 100,
|
progress: 100,
|
||||||
result: '这是模拟的转换结果...'
|
result: '这是模拟的转换结果。在 Tauri 环境中将调用真实 ASR 引擎。'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return file
|
return f
|
||||||
}))
|
}))
|
||||||
|
resolve()
|
||||||
} else {
|
} else {
|
||||||
setFiles(prev => prev.map(file => {
|
setFiles(prev => prev.map(f => {
|
||||||
if (file.id === id) {
|
if (f.id === id) {
|
||||||
return { ...file, progress }
|
return { ...f, progress }
|
||||||
}
|
}
|
||||||
return file
|
return f
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
}, 200)
|
}, 200)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const startAll = () => {
|
const startAll = async () => {
|
||||||
files.filter(f => f.status === 'pending').forEach(f => startConvert(f.id))
|
const pendingFiles = files.filter(f => f.status === 'pending')
|
||||||
|
// 串行处理避免同时调用过多 ASR 推理
|
||||||
|
for (const f of pendingFiles) {
|
||||||
|
await startConvert(f.id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const removeFile = (id: string) => {
|
const removeFile = (id: string) => {
|
||||||
setFiles(prev => prev.filter(f => f.id !== id))
|
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 (
|
return (
|
||||||
<div className="file-page">
|
<div className="file-page">
|
||||||
<h1 className="page-title">文件识别转文字</h1>
|
<h1 className="page-title">文件识别转文字</h1>
|
||||||
@ -156,6 +233,19 @@ export default function FileConvertPage() {
|
|||||||
)}
|
)}
|
||||||
{file.status === 'error' && '处理失败'}
|
{file.status === 'error' && '处理失败'}
|
||||||
</div>
|
</div>
|
||||||
|
{file.status === 'completed' && file.result && (
|
||||||
|
<div className="file-result-preview" style={{
|
||||||
|
fontSize: '12px',
|
||||||
|
color: 'var(--text-secondary)',
|
||||||
|
marginTop: '4px',
|
||||||
|
maxWidth: '400px',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap'
|
||||||
|
}}>
|
||||||
|
{file.result.substring(0, 80)}...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{file.status === 'pending' && (
|
{file.status === 'pending' && (
|
||||||
<div style={{ fontSize: '12px', color: 'var(--text-secondary)' }}>
|
<div style={{ fontSize: '12px', color: 'var(--text-secondary)' }}>
|
||||||
{formatSize(file.size)}
|
{formatSize(file.size)}
|
||||||
@ -173,8 +263,7 @@ export default function FileConvertPage() {
|
|||||||
)}
|
)}
|
||||||
{file.status === 'completed' && (
|
{file.status === 'completed' && (
|
||||||
<>
|
<>
|
||||||
<button className="btn btn-secondary">查看</button>
|
<button className="btn btn-secondary" onClick={() => handleExport(file)}>导出</button>
|
||||||
<button className="btn btn-secondary">导出</button>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
|
|||||||
@ -1,30 +1,95 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||||
|
|
||||||
interface RecordResult {
|
interface RecordResult {
|
||||||
text: string
|
text: string
|
||||||
language: string
|
language: string
|
||||||
confidence: number
|
confidence: number
|
||||||
duration_ms: number
|
duration_ms: number
|
||||||
|
audio_path: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface TauriAPI {
|
||||||
|
invoke: (cmd: string, args?: Record<string, unknown>) => Promise<unknown>
|
||||||
|
listen: (event: string, handler: (e: { payload: unknown }) => void) => Promise<() => void>
|
||||||
|
}
|
||||||
|
|
||||||
|
declare const window: Window & {
|
||||||
|
__TAURI__?: TauriAPI
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检测是否在 Tauri 环境中
|
||||||
|
const isTauri = () => typeof window !== 'undefined' && !!window.__TAURI__
|
||||||
|
|
||||||
export default function RecordPage() {
|
export default function RecordPage() {
|
||||||
const [isRecording, setIsRecording] = useState(false)
|
const [isRecording, setIsRecording] = useState(false)
|
||||||
const [recordingTime, setRecordingTime] = useState(0)
|
const [recordingTime, setRecordingTime] = useState(0)
|
||||||
const [result, setResult] = useState<RecordResult | null>(null)
|
const [result, setResult] = useState<RecordResult | null>(null)
|
||||||
const [isProcessing, setIsProcessing] = useState(false)
|
const [isProcessing, setIsProcessing] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||||
|
|
||||||
|
// 启动计时器
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let interval: number
|
|
||||||
if (isRecording) {
|
if (isRecording) {
|
||||||
interval = setInterval(() => {
|
|
||||||
setRecordingTime(prev => prev + 0.1)
|
|
||||||
}, 100) as unknown as number
|
|
||||||
} else {
|
|
||||||
setRecordingTime(0)
|
setRecordingTime(0)
|
||||||
|
timerRef.current = setInterval(() => {
|
||||||
|
setRecordingTime(prev => prev + 0.1)
|
||||||
|
}, 100)
|
||||||
|
} else {
|
||||||
|
if (timerRef.current) {
|
||||||
|
clearInterval(timerRef.current)
|
||||||
|
timerRef.current = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
if (timerRef.current) {
|
||||||
|
clearInterval(timerRef.current)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return () => clearInterval(interval)
|
|
||||||
}, [isRecording])
|
}, [isRecording])
|
||||||
|
|
||||||
|
// 监听后端事件
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isTauri()) return
|
||||||
|
|
||||||
|
const setupListeners = async () => {
|
||||||
|
try {
|
||||||
|
await window.__TAURI__!.listen('recording-stopped', async (event) => {
|
||||||
|
const payload = event.payload as { path: string; duration: number }
|
||||||
|
if (payload?.path) {
|
||||||
|
// 自动识别刚录制的文件
|
||||||
|
setIsProcessing(true)
|
||||||
|
try {
|
||||||
|
const recognizeResult = await window.__TAURI__!.invoke('recognize_file', {
|
||||||
|
path: payload.path
|
||||||
|
}) as { success: boolean; text: string; language: string; confidence: number; duration_ms: number }
|
||||||
|
|
||||||
|
if (recognizeResult.success) {
|
||||||
|
setResult({
|
||||||
|
text: recognizeResult.text,
|
||||||
|
language: recognizeResult.language,
|
||||||
|
confidence: recognizeResult.confidence,
|
||||||
|
duration_ms: recognizeResult.duration_ms,
|
||||||
|
audio_path: payload.path
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
setError('识别失败')
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setError(`识别失败: ${e instanceof Error ? e.message : String(e)}`)
|
||||||
|
} finally {
|
||||||
|
setIsProcessing(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to setup recording listener:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setupListeners()
|
||||||
|
}, [])
|
||||||
|
|
||||||
const formatTime = (seconds: number) => {
|
const formatTime = (seconds: number) => {
|
||||||
const mins = Math.floor(seconds / 60)
|
const mins = Math.floor(seconds / 60)
|
||||||
const secs = Math.floor(seconds % 60)
|
const secs = Math.floor(seconds % 60)
|
||||||
@ -32,28 +97,50 @@ export default function RecordPage() {
|
|||||||
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}.${ms}`
|
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}.${ms}`
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleRecordClick = async () => {
|
const handleRecordClick = useCallback(async () => {
|
||||||
|
setError(null)
|
||||||
|
|
||||||
if (isRecording) {
|
if (isRecording) {
|
||||||
// 停止录音
|
// 停止录音
|
||||||
setIsRecording(false)
|
setIsRecording(false)
|
||||||
setIsProcessing(true)
|
setIsProcessing(true)
|
||||||
|
|
||||||
// 模拟识别过程
|
if (isTauri()) {
|
||||||
|
try {
|
||||||
|
await window.__TAURI__!.invoke('stop_recording')
|
||||||
|
} catch (e) {
|
||||||
|
setError(`停止录音失败: ${e instanceof Error ? e.message : String(e)}`)
|
||||||
|
setIsProcessing(false)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 开发环境:模拟识别
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setResult({
|
setResult({
|
||||||
text: '这是一个模拟的语音识别结果。实际使用时会调用 ONNX 模型进行识别。',
|
text: '开发环境:这是一个模拟的语音识别结果。实际使用时请在 Tauri 环境中运行。',
|
||||||
language: 'zh',
|
language: 'zh',
|
||||||
confidence: 0.95,
|
confidence: 0.95,
|
||||||
duration_ms: 350
|
duration_ms: 350,
|
||||||
|
audio_path: ''
|
||||||
})
|
})
|
||||||
setIsProcessing(false)
|
setIsProcessing(false)
|
||||||
}, 1000)
|
}, 1000)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// 开始录音
|
// 开始录音
|
||||||
setIsRecording(true)
|
setIsRecording(true)
|
||||||
setResult(null)
|
setResult(null)
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
if (isTauri()) {
|
||||||
|
try {
|
||||||
|
await window.__TAURI__!.invoke('start_recording')
|
||||||
|
} catch (e) {
|
||||||
|
setError(`启动录音失败: ${e instanceof Error ? e.message : String(e)}`)
|
||||||
|
setIsRecording(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}, [isRecording])
|
||||||
|
|
||||||
const handleCopy = async () => {
|
const handleCopy = async () => {
|
||||||
if (result?.text) {
|
if (result?.text) {
|
||||||
@ -61,6 +148,11 @@ export default function RecordPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleClear = () => {
|
||||||
|
setResult(null)
|
||||||
|
setError(null)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="record-page">
|
<div className="record-page">
|
||||||
<h1 className="page-title">录音识别</h1>
|
<h1 className="page-title">录音识别</h1>
|
||||||
@ -93,8 +185,9 @@ export default function RecordPage() {
|
|||||||
<button
|
<button
|
||||||
className={`record-button ${isRecording ? 'recording' : 'idle'}`}
|
className={`record-button ${isRecording ? 'recording' : 'idle'}`}
|
||||||
onClick={handleRecordClick}
|
onClick={handleRecordClick}
|
||||||
|
disabled={isProcessing}
|
||||||
>
|
>
|
||||||
{isRecording ? '⏹' : '🎤'}
|
{isProcessing ? '⏳' : isRecording ? '⏹' : '🎤'}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className="record-timer">
|
<div className="record-timer">
|
||||||
@ -103,7 +196,7 @@ export default function RecordPage() {
|
|||||||
|
|
||||||
<div className="record-status">
|
<div className="record-status">
|
||||||
<span className={`status-indicator ${isRecording ? 'status-recording' : 'status-ready'}`}>
|
<span className={`status-indicator ${isRecording ? 'status-recording' : 'status-ready'}`}>
|
||||||
{isRecording ? '● 录音中' : '○ 就绪'}
|
{isRecording ? '● 录音中' : isProcessing ? '⏳ 识别中' : '○ 就绪'}
|
||||||
</span>
|
</span>
|
||||||
{result && (
|
{result && (
|
||||||
<span>
|
<span>
|
||||||
@ -111,6 +204,12 @@ export default function RecordPage() {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="error-message" style={{ color: 'var(--error-color, #ef4444)', marginTop: '8px' }}>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -119,13 +218,13 @@ export default function RecordPage() {
|
|||||||
<div className="result-header">
|
<div className="result-header">
|
||||||
<h2 className="card-title">识别结果</h2>
|
<h2 className="card-title">识别结果</h2>
|
||||||
<div className="result-actions">
|
<div className="result-actions">
|
||||||
<button className="btn btn-secondary" onClick={handleCopy}>
|
<button className="btn btn-secondary" onClick={handleCopy} disabled={!result?.text}>
|
||||||
📋 复制
|
📋 复制
|
||||||
</button>
|
</button>
|
||||||
<button className="btn btn-secondary">
|
<button className="btn btn-secondary">
|
||||||
💾 保存
|
💾 保存
|
||||||
</button>
|
</button>
|
||||||
<button className="btn btn-secondary">
|
<button className="btn btn-secondary" onClick={handleClear}>
|
||||||
🗑️ 清除
|
🗑️ 清除
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
|
|
||||||
interface Settings {
|
interface Settings {
|
||||||
model: string
|
model: string
|
||||||
@ -21,14 +21,17 @@ interface SettingsPageProps {
|
|||||||
onThemeChange: (theme: 'light' | 'dark' | 'system') => void
|
onThemeChange: (theme: 'light' | 'dark' | 'system') => void
|
||||||
}
|
}
|
||||||
|
|
||||||
declare const window: Window & {
|
interface TauriAPI {
|
||||||
__TAURI__: {
|
|
||||||
invoke: (cmd: string, args?: Record<string, unknown>) => Promise<unknown>
|
invoke: (cmd: string, args?: Record<string, unknown>) => Promise<unknown>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
declare const window: Window & {
|
||||||
|
__TAURI__?: TauriAPI
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SettingsPage({ theme, onThemeChange }: SettingsPageProps) {
|
const isTauri = () => typeof window !== 'undefined' && !!window.__TAURI__
|
||||||
const [settings, setSettings] = useState<Settings>({
|
|
||||||
|
const defaultSettings: Settings = {
|
||||||
model: 'sensevoice-small',
|
model: 'sensevoice-small',
|
||||||
language: 'zh',
|
language: 'zh',
|
||||||
sampleRate: 16000,
|
sampleRate: 16000,
|
||||||
@ -41,56 +44,113 @@ export default function SettingsPage({ theme, onThemeChange }: SettingsPageProps
|
|||||||
hotkeyRecord: 'Ctrl+Shift+R',
|
hotkeyRecord: 'Ctrl+Shift+R',
|
||||||
hotkeyCopy: 'Ctrl+Shift+C',
|
hotkeyCopy: 'Ctrl+Shift+C',
|
||||||
hotkeyToggle: 'Ctrl+Shift+H'
|
hotkeyToggle: 'Ctrl+Shift+H'
|
||||||
})
|
}
|
||||||
|
|
||||||
|
export default function SettingsPage({ theme, onThemeChange }: SettingsPageProps) {
|
||||||
|
const [settings, setSettings] = useState<Settings>(defaultSettings)
|
||||||
const [modified, setModified] = useState(false)
|
const [modified, setModified] = useState(false)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [saveMessage, setSaveMessage] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// 加载保存的配置
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isTauri()) return
|
||||||
|
|
||||||
|
const loadConfig = async () => {
|
||||||
|
try {
|
||||||
|
const config = await window.__TAURI__!.invoke('get_config_cmd') as Record<string, unknown>
|
||||||
|
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]) => {
|
const handleChange = (key: keyof Settings, value: Settings[typeof key]) => {
|
||||||
setSettings(prev => ({ ...prev, [key]: value }))
|
setSettings(prev => ({ ...prev, [key]: value }))
|
||||||
setModified(true)
|
setModified(true)
|
||||||
|
setSaveMessage(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSave = () => {
|
const handleSave = async () => {
|
||||||
// 保存设置
|
setSaving(true)
|
||||||
console.log('保存设置:', settings)
|
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)
|
setModified(false)
|
||||||
alert('设置已保存')
|
} catch (e) {
|
||||||
|
setSaveMessage(`✗ 保存失败: ${e instanceof Error ? e.message : String(e)}`)
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleReset = () => {
|
const handleReset = () => {
|
||||||
setSettings({
|
setSettings({ ...defaultSettings })
|
||||||
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
|
|
||||||
})
|
|
||||||
setModified(true)
|
setModified(true)
|
||||||
|
setSaveMessage(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSelectModel = async () => {
|
const handleSelectModel = async () => {
|
||||||
try {
|
try {
|
||||||
console.log('开始调用 select_model_file 命令...')
|
if (!isTauri()) {
|
||||||
const modelPath = await window.__TAURI__.invoke('select_model_file')
|
alert('仅在 Tauri 环境中可用')
|
||||||
console.log('模型文件选择结果:', modelPath)
|
return
|
||||||
|
}
|
||||||
|
const modelPath = await window.__TAURI__!.invoke('select_model_file')
|
||||||
if (modelPath) {
|
if (modelPath) {
|
||||||
const path = modelPath as string
|
const path = modelPath as string
|
||||||
setSettings(prev => ({ ...prev, modelPath: path }))
|
setSettings(prev => ({ ...prev, modelPath: path }))
|
||||||
setModified(true)
|
setModified(true)
|
||||||
console.log('模型路径已更新:', path)
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('选择模型文件失败,错误详情:', error)
|
|
||||||
const errorMessage = error instanceof Error ? error.message : String(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
|
|||||||
<div className="settings-page">
|
<div className="settings-page">
|
||||||
<h1 className="page-title">设置</h1>
|
<h1 className="page-title">设置</h1>
|
||||||
|
|
||||||
|
{saveMessage && (
|
||||||
|
<div className={`save-message ${saveMessage.startsWith('✓') ? 'success' : 'error'}`}>
|
||||||
|
{saveMessage}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="card">
|
<div className="card">
|
||||||
{/* 识别模型 */}
|
{/* 识别模型 */}
|
||||||
<div className="setting-section">
|
<div className="setting-section">
|
||||||
@ -327,9 +393,9 @@ export default function SettingsPage({ theme, onThemeChange }: SettingsPageProps
|
|||||||
<button
|
<button
|
||||||
className="btn btn-primary"
|
className="btn btn-primary"
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
disabled={!modified}
|
disabled={!modified || saving}
|
||||||
>
|
>
|
||||||
保存设置
|
{saving ? '保存中...' : '保存设置'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user