From a61fe3ec32d6a5941d22740b73e27ed500a5ae50 Mon Sep 17 00:00:00 2001 From: aeoform <2790848120@qq.com> Date: Sun, 25 Oct 2026 20:14:36 +0800 Subject: [PATCH 1/9] =?UTF-8?q?feat(audio):=20=E8=B0=83=E8=AF=95=E5=BD=95?= =?UTF-8?q?=E9=9F=B3=E5=8F=AF=E7=8B=AC=E7=AB=8B=E9=80=89=E6=8B=A9=E4=BF=9D?= =?UTF-8?q?=E5=AD=98=E5=A4=84=E7=90=86=E5=89=8D/=E5=A4=84=E7=90=86?= =?UTF-8?q?=E5=90=8E=E7=9A=84=E9=9F=B3=E9=A2=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 原来的「保留原始录音(调试)」只会落一份处理后的 WAV,没法判断某个识别问题是 麦克风原始信号差,还是降噪/增益/AGC 处理坏了。 Core: - `UserPreferences` 新增 `debug_archive_processed` / `debug_archive_raw`,两个独立 开关,默认都关;旧 preferences.json 走 serde default,不迁移。 - `RecordingPlan` 增加 `archive_processed` / `archive_raw`,由上面两个开关推出。 quick note / undecided 这类功能必需档案始终 `archive_processed = true`,不受开关 影响;调试录音两个开关都关时 `archive_successful_recording = false`(不落盘)。 - 新增 `openless_core::pcm_f32_to_i16_le`,让宿主把「处理前」的交织 f32 帧也量化 成同格式 PCM,与处理器内部规则一致。 - 偏好注册表补两条(Portable),云同步与契约测试同步跟上。 linux-egui: - 主档案 `.wav` 按 `archive_processed` 决定写处理后还是原始 PCM;只有两份 都要时才额外创建 `.raw.wav`。 - 音频处理线程在处理前就拿到原始帧,因此不做常态额外分配:`raw` 只在开关打开时 才量化。 - 设置页在「保留原始录音(调试)」下面新增两个开关:保存处理后的音频 / 另存一份 处理前的音频;打开主开关且两项都关时自动勾上「处理后」,避免主开关看起来没效果。 五语文案补在 i18n 目录里。 测试:`dictation_context` 新增用例覆盖四种开关组合与 quick note 例外; `openless-core --lib` 1391 passed,`openless-linux-egui --lib` 266 passed, `clippy -D warnings` 干净。 --- .../app/crates/openless-core/src/audio.rs | 11 ++ .../src/cloud_sync_e2ee_documents/registry.rs | 14 +++ .../openless-core/src/dictation_context.rs | 83 +++++++++++++- .../app/crates/openless-core/src/lib.rs | 4 +- .../crates/openless-core/src/shared_types.rs | 20 ++++ openless-all/app/linux-egui/package-revision | 2 +- openless-all/app/linux-egui/src/audio.rs | 101 +++++++++++++++--- openless-all/app/linux-egui/src/i18n.rs | 28 +++++ .../linux-egui/src/linux_app/settings_save.rs | 14 +++ openless-all/app/linux-egui/src/main.rs | 2 + .../linux-egui/src/ui/frontend/settings.rs | 28 +++++ .../linux-egui/src/ui/frontend/view_model.rs | 10 ++ 12 files changed, 300 insertions(+), 17 deletions(-) diff --git a/openless-all/app/crates/openless-core/src/audio.rs b/openless-all/app/crates/openless-core/src/audio.rs index 46233a7ab..6dd0f1057 100644 --- a/openless-all/app/crates/openless-core/src/audio.rs +++ b/openless-all/app/crates/openless-core/src/audio.rs @@ -354,6 +354,17 @@ fn downmix_to_mono(interleaved: &[f32], channels: usize) -> Vec { .collect() } +/// f32 交织采样 → 小端 i16 字节流,供宿主把「处理前」的原始 PCM 也写成 WAV +/// (调试时与处理后那份做 A/B 对照)。量化规则与 [`AudioProcessor`] 内部一致。 +pub fn pcm_f32_to_i16_le(samples: &[f32]) -> Vec { + let mut bytes = Vec::with_capacity(samples.len() * 2); + for sample in samples { + let quantized = (sample.clamp(-1.0, 1.0) * i16::MAX as f32) as i16; + bytes.extend_from_slice(&quantized.to_le_bytes()); + } + bytes +} + fn quantize_to_i16_le(samples: &[f32]) -> (Vec, f32) { let mut bytes = Vec::with_capacity(samples.len() * 2); let mut square_sum = 0.0_f64; diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/registry.rs b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/registry.rs index 44e900442..92df88dd9 100644 --- a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/registry.rs +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/registry.rs @@ -851,6 +851,20 @@ pub const PREFERENCE_FIELDS: &[PreferenceField] = &[ shape: PreferenceShape::Boolean, reason: "portable_setting", }, + PreferenceField { + rust_name: "debug_archive_processed", + key: "debugArchiveProcessed", + class: PreferenceClass::Portable, + shape: PreferenceShape::Boolean, + reason: "portable_setting", + }, + PreferenceField { + rust_name: "debug_archive_raw", + key: "debugArchiveRaw", + class: PreferenceClass::Portable, + shape: PreferenceShape::Boolean, + reason: "portable_setting", + }, PreferenceField { rust_name: "audio_recording_max_entries", key: "audioRecordingMaxEntries", diff --git a/openless-all/app/crates/openless-core/src/dictation_context.rs b/openless-all/app/crates/openless-core/src/dictation_context.rs index b82b76a0e..f0caa269f 100644 --- a/openless-all/app/crates/openless-core/src/dictation_context.rs +++ b/openless-all/app/crates/openless-core/src/dictation_context.rs @@ -181,6 +181,11 @@ pub struct RecordingPlan { /// keep PCM in memory; successful-recording retention is a separate policy. pub archive_enabled: bool, pub archive_successful_recording: bool, + /// 主档案(`.wav`)写「处理后」的 PCM。调试录音两个开关都关时为 + /// false;quick note / undecided 这类功能必需档案永远为 true。 + pub archive_processed: bool, + /// 额外再写一份「处理前」的 `.raw.wav` 供 A/B 对照。 + pub archive_raw: bool, /// A quick-note or undecided Android capture must not silently continue /// without a disk archive when its path cannot be created. pub archive_required: bool, @@ -321,7 +326,15 @@ impl DictationContext { archive_successful_recording: matches!( options.output_target, DictationOutputTarget::QuickNote | DictationOutputTarget::Undecided - ) || preferences.record_audio_for_debug, + ) || (preferences.record_audio_for_debug + && (preferences.debug_archive_processed || preferences.debug_archive_raw)), + // 功能必需档案(quick note / undecided)始终存处理后的 PCM; + // 调试录音则完全由两个开关决定,两个都关就不落盘。 + archive_processed: matches!( + options.output_target, + DictationOutputTarget::QuickNote | DictationOutputTarget::Undecided + ) || (preferences.record_audio_for_debug && preferences.debug_archive_processed), + archive_raw: preferences.record_audio_for_debug && preferences.debug_archive_raw, archive_required: matches!( options.output_target, DictationOutputTarget::QuickNote | DictationOutputTarget::Undecided @@ -928,6 +941,74 @@ mod tests { assert!(prompt.chars().count() <= ASR_PROMPT_CHAR_BUDGET); } + // 调试录音的两个开关独立生效: + // - 都关 → 不落盘 + // - 只开处理后 → 主档案存处理后 + // - 只开处理前 → 主档案存原始(功能不减),不额外多一份 + // - 都开 → 主档案 + `.raw.wav` + // quick note 这类功能必需档案不受开关影响。 + #[test] + fn debug_archive_switches_decide_what_the_host_writes() { + fn plan(processed: bool, raw: bool) -> (bool, bool, bool) { + let preferences = UserPreferences { + record_audio_for_debug: true, + debug_archive_processed: processed, + debug_archive_raw: raw, + ..UserPreferences::default() + }; + let pack = builtin_style_pack_for_mode(preferences.default_mode); + let context = DictationContext::capture( + &preferences, + &pack, + DictationProviderInvocations::new( + ProviderInvocation::for_provider(preferences.active_asr_provider.clone()), + ProviderInvocation::for_provider(preferences.active_llm_provider.clone()), + ProviderInvocation::for_provider("omni"), + ), + Vec::new(), + Vec::new(), + &DictationStartOptions::default(), + ); + ( + context.recording.archive_successful_recording, + context.recording.archive_processed, + context.recording.archive_raw, + ) + } + + assert_eq!(plan(false, false), (false, false, false)); + assert_eq!(plan(true, false), (true, true, false)); + assert_eq!(plan(false, true), (true, false, true)); + assert_eq!(plan(true, true), (true, true, true)); + + // quick note 必须始终有处理后档案,否则播放与本地回退会失去 PCM。 + let preferences = UserPreferences { + record_audio_for_debug: false, + debug_archive_processed: false, + debug_archive_raw: false, + ..UserPreferences::default() + }; + let pack = builtin_style_pack_for_mode(preferences.default_mode); + let context = DictationContext::capture( + &preferences, + &pack, + DictationProviderInvocations::new( + ProviderInvocation::for_provider(preferences.active_asr_provider.clone()), + ProviderInvocation::for_provider(preferences.active_llm_provider.clone()), + ProviderInvocation::for_provider("omni"), + ), + Vec::new(), + Vec::new(), + &DictationStartOptions { + insert_text: false, + output_target: DictationOutputTarget::QuickNote, + ..DictationStartOptions::default() + }, + ); + assert!(context.recording.archive_processed); + assert!(!context.recording.archive_raw); + } + #[test] fn quick_note_target_forces_permanent_archive_and_skips_insertion() { let preferences = UserPreferences::default(); diff --git a/openless-all/app/crates/openless-core/src/lib.rs b/openless-all/app/crates/openless-core/src/lib.rs index 1f363893f..57113de60 100644 --- a/openless-all/app/crates/openless-core/src/lib.rs +++ b/openless-all/app/crates/openless-core/src/lib.rs @@ -227,8 +227,8 @@ pub use api::{ QaVoiceCaptureSession, StartupSnapshot, VoiceTranscriptionSession, }; pub use audio::{ - encode_dictation_wav, AudioProcessingConfig, AudioProcessor, NormalizedPcmChunk, - PcmNormalizer, DICTATION_SAMPLE_RATE, + encode_dictation_wav, pcm_f32_to_i16_le, AudioProcessingConfig, AudioProcessor, + NormalizedPcmChunk, PcmNormalizer, DICTATION_SAMPLE_RATE, }; pub use auxiliary::{ AsrCallLabel, AuxiliaryApi, RepolishRequest, RetranscriptionFailure, RetranscriptionResult, diff --git a/openless-all/app/crates/openless-core/src/shared_types.rs b/openless-all/app/crates/openless-core/src/shared_types.rs index ea06f2ee9..b62a40f0f 100644 --- a/openless-all/app/crates/openless-core/src/shared_types.rs +++ b/openless-all/app/crates/openless-core/src/shared_types.rs @@ -870,6 +870,16 @@ pub struct UserPreferences { /// policy as `history_retention_days`. #[serde(default)] pub record_audio_for_debug: bool, + /// 调试录音里保留「处理后」的音频(降噪/增益/AGC 之后)。占用现有档案名 + /// `.wav`,quick note 播放与本地 ASR 回退都用它。默认 false: + /// 只有显式选择才会落盘。 + #[serde(default)] + pub debug_archive_processed: bool, + /// 调试录音里额外保留「处理前」的原始音频,写成同目录的 + /// `.raw.wav`,用于和 `debug_archive_processed` 的那份做 A/B 对照。 + /// 只选它、不选处理后时,主档案直接存原始 PCM(功能不受影响)。 + #[serde(default)] + pub debug_archive_raw: bool, /// How many recent wav files `recordings/` keeps (newest first by mtime). /// `None` = follow `HISTORY_CAP` (200); `Some(n)` clamps to 1..=200. /// Trimmed before each new session. Lets users tune combinations like @@ -1225,6 +1235,10 @@ struct UserPreferencesWire { #[serde(default)] record_audio_for_debug: bool, #[serde(default)] + debug_archive_processed: bool, + #[serde(default)] + debug_archive_raw: bool, + #[serde(default)] audio_recording_max_entries: Option, #[serde(default)] quick_note_export_directory: String, @@ -1417,6 +1431,8 @@ impl Default for UserPreferencesWire { auto_update_check: prefs.auto_update_check, history_max_entries: prefs.history_max_entries, record_audio_for_debug: prefs.record_audio_for_debug, + debug_archive_processed: prefs.debug_archive_processed, + debug_archive_raw: prefs.debug_archive_raw, audio_recording_max_entries: prefs.audio_recording_max_entries, quick_note_export_directory: prefs.quick_note_export_directory.clone(), marketplace_base_url: prefs.marketplace_base_url, @@ -1654,6 +1670,8 @@ impl<'de> Deserialize<'de> for UserPreferences { auto_update_check: wire.auto_update_check, history_max_entries: wire.history_max_entries, record_audio_for_debug: wire.record_audio_for_debug, + debug_archive_processed: wire.debug_archive_processed, + debug_archive_raw: wire.debug_archive_raw, audio_recording_max_entries: wire.audio_recording_max_entries, quick_note_export_directory: wire.quick_note_export_directory, marketplace_base_url: wire.marketplace_base_url, @@ -2034,6 +2052,8 @@ impl Default for UserPreferences { auto_update_check: true, history_max_entries: None, record_audio_for_debug: false, + debug_archive_processed: false, + debug_archive_raw: false, audio_recording_max_entries: None, quick_note_export_directory: String::new(), marketplace_base_url: String::new(), diff --git a/openless-all/app/linux-egui/package-revision b/openless-all/app/linux-egui/package-revision index b4f334f26..a29644e57 100644 --- a/openless-all/app/linux-egui/package-revision +++ b/openless-all/app/linux-egui/package-revision @@ -1 +1 @@ -141 +144 diff --git a/openless-all/app/linux-egui/src/audio.rs b/openless-all/app/linux-egui/src/audio.rs index adb75bc7a..44b8ad978 100644 --- a/openless-all/app/linux-egui/src/audio.rs +++ b/openless-all/app/linux-egui/src/audio.rs @@ -50,6 +50,10 @@ struct LinuxRecordingOptions { require_archive: bool, mute_during_recording: bool, audio_processing: openless_core::AudioProcessingConfig, + /// 主档案(`.wav`)写「处理后」的 PCM。 + archive_processed: bool, + /// 额外再写一份「处理前」的 `.raw.wav` 做 A/B 对照。 + archive_raw: bool, } #[cfg(target_os = "linux")] @@ -58,6 +62,10 @@ struct LinuxAudioThreadArgs { consumer: Arc, progress: Arc, writer: Option>>, + /// 主档案是否写「处理后」的 PCM;false 时主档案存原始 PCM。 + write_processed: bool, + /// 「处理前」的额外档案(只在同时要两份时创建)。 + raw_writer: Option>>, audio_processing: openless_core::AudioProcessingConfig, stop: Arc, runtime_error: Arc>>, @@ -76,6 +84,8 @@ impl AudioProcessingWorker { consumer: Arc, progress: Arc, writer: Option>>, + write_processed: bool, + raw_writer: Option>>, ) -> Result<(Self, SyncSender), BackendError> { let (sender, receiver) = sync_channel::(AUDIO_PROCESSING_QUEUE_CAPACITY); let started = std::time::Instant::now(); @@ -83,18 +93,44 @@ impl AudioProcessingWorker { .name("openless-linux-audio-processor".to_string()) .spawn(move || { let mut processor = openless_core::AudioProcessor::new(processing); + // 处理前的原始 PCM:只在需要时量化,避免常态多一次分配。 + let write_raw = + |frame: &RawAudioFrame, sink: &Option>>| { + let Some(writer) = sink.as_ref() else { + return; + }; + let pcm = openless_core::pcm_f32_to_i16_le(&frame.samples); + if let Err(error) = writer + .lock() + .expect("Linux WAV writer lock poisoned") + .append(&pcm) + { + log::warn!("Linux raw recording write failed: {error}"); + } + }; while let Ok(frame) = receiver.recv() { + // 「处理前」的额外档案永远拿原始帧。 + if raw_writer.is_some() { + write_raw(&frame, &raw_writer); + } + // 只要了处理前、没要处理后:主档案也存原始帧,功能(回退/quick + // note 播放)继续可用,且不会多出一份重复文件。 + if !write_processed { + write_raw(&frame, &writer); + } if let Some(chunk) = processor.process(&frame.samples, frame.channels, frame.input_sample_rate) { consumer.consume_pcm_chunk(&chunk.pcm_i16_le); - if let Some(writer) = &writer { - if let Err(error) = writer - .lock() - .expect("Linux WAV writer lock poisoned") - .append(&chunk.pcm_i16_le) - { - log::warn!("Linux recording archive write failed: {error}"); + if write_processed { + if let Some(writer) = &writer { + if let Err(error) = writer + .lock() + .expect("Linux WAV writer lock poisoned") + .append(&chunk.pcm_i16_le) + { + log::warn!("Linux recording archive write failed: {error}"); + } } } let _ = progress @@ -103,7 +139,7 @@ impl AudioProcessingWorker { } if let Some(chunk) = processor.finish() { consumer.consume_pcm_chunk(&chunk.pcm_i16_le); - if let Some(writer) = &writer { + if let Some(writer) = writer.as_ref().filter(|_| write_processed) { if let Err(error) = writer .lock() .expect("Linux WAV writer lock poisoned") @@ -163,6 +199,8 @@ impl AudioRecorder for LinuxCpalRecorder { let recordings_dir = self.recordings_dir.clone(); let require_archive = context.output_target == openless_core::DictationOutputTarget::QuickNote; + let archive_processed = context.recording.archive_processed; + let archive_raw = context.recording.archive_raw; Box::pin(async move { #[cfg(target_os = "linux")] { @@ -175,6 +213,8 @@ impl AudioRecorder for LinuxCpalRecorder { require_archive, mute_during_recording, audio_processing, + archive_processed, + archive_raw, }, consumer, progress, @@ -322,6 +362,8 @@ fn start_linux_recording( require_archive, mute_during_recording, audio_processing, + archive_processed, + archive_raw, } = options; // Mute is best-effort and independent of capture availability (mirrors the @@ -343,11 +385,33 @@ fn start_linux_recording( let (startup_tx, startup_rx) = std::sync::mpsc::sync_channel(1); let stop_for_thread = Arc::clone(&stop); let runtime_error_for_thread = Arc::clone(&runtime_error); - let (writer, archive) = match recordings_dir { + // 调试录音两个开关都关时不落盘;quick note / undecided 这类功能必需档案由 + // Core 把 archive_processed 置为 true,所以这里统一按开关走。 + let wants_archive = archive_processed || archive_raw; + let (writer, archive, raw_writer) = match recordings_dir.filter(|_| wants_archive) { Some(directory) => { match LinuxWavWriter::create(directory.join(format!("{session_id}.wav"))) { Ok((writer, archive)) => { - (Some(Arc::new(std::sync::Mutex::new(writer))), Some(archive)) + // 两份都要时,另开一个 .raw.wav 存处理前的 PCM。 + let raw = if archive_processed && archive_raw { + match LinuxWavWriter::create(directory.join(format!("{session_id}.raw.wav"))) + { + Ok((raw_writer, _raw_archive)) => { + Some(Arc::new(std::sync::Mutex::new(raw_writer))) + } + Err(error) => { + log::warn!("failed to create raw recording archive: {error}"); + None + } + } + } else { + None + }; + ( + Some(Arc::new(std::sync::Mutex::new(writer))), + Some(archive), + raw, + ) } Err(error) => { if require_archive { @@ -357,7 +421,7 @@ fn start_linux_recording( )); } log::warn!("failed to create Linux recording archive: {error}"); - (None, None) + (None, None, None) } } } @@ -367,7 +431,7 @@ fn start_linux_recording( "quick note recording archive directory is missing", )) } - None => (None, None), + None => (None, None, None), }; let thread = std::thread::Builder::new() .name("openless-linux-recorder".to_string()) @@ -377,6 +441,8 @@ fn start_linux_recording( consumer, progress, writer, + write_processed: archive_processed, + raw_writer, audio_processing, stop: stop_for_thread, runtime_error: runtime_error_for_thread, @@ -421,13 +487,22 @@ fn run_audio_thread(args: LinuxAudioThreadArgs) { consumer, progress, writer, + write_processed, + raw_writer, audio_processing, stop, runtime_error, startup, } = args; let (processor, frame_tx) = - match AudioProcessingWorker::start(audio_processing, consumer, progress, writer) { + match AudioProcessingWorker::start( + audio_processing, + consumer, + progress, + writer, + write_processed, + raw_writer, + ) { Ok(worker) => worker, Err(error) => { let _ = startup.send(Err(error)); diff --git a/openless-all/app/linux-egui/src/i18n.rs b/openless-all/app/linux-egui/src/i18n.rs index 13ca6fe9e..a77808ab7 100644 --- a/openless-all/app/linux-egui/src/i18n.rs +++ b/openless-all/app/linux-egui/src/i18n.rs @@ -3809,6 +3809,34 @@ pub const CATALOG: &[Msg] = &[ "debug는 하트비트와 IPC 세부 정보를 기록합니다(문제 해결용). OpenLess를 다시 시작해야 적용됩니다.", ), }, + Msg { + key: "settings.recording.archive_processed_label", + text: row("保存处理后的音频", "儲存處理後的音訊", "Keep processed audio", "処理後の音声を保存", "처리된 오디오 저장"), + }, + Msg { + key: "settings.recording.archive_processed_desc", + text: row( + "降噪与增益之后的版本,仍保存在原来的录制文件里(quick note 播放、本地模型回退都用它)。", + "降噪與增益之後的版本,仍存於原本的錄製檔案(quick note 播放、本機模型回退都用它)。", + "The version after noise reduction and gain, still stored in the existing recording file (used by quick-note playback and the local model fallback).", + "ノイズ低減とゲイン適用後の音声で、従来の録音ファイルに保存されます(クイックノート再生とローカルモデルのフォールバックが使います)。", + "노이즈 감소와 게인 적용 후의 오디오로, 기존 녹음 파일에 저장됩니다(퀵노트 재생과 로컬 모델 폴백이 사용).", + ), + }, + Msg { + key: "settings.recording.archive_raw_label", + text: row("另存一份处理前的音频", "另存一份處理前的音訊", "Also keep pre-processing audio", "処理前の音声も保存", "처리 전 오디오도 저장"), + }, + Msg { + key: "settings.recording.archive_raw_desc", + text: row( + "同一目录下另存 <会话>.raw.wav,可与上面那份逐段对照;只开这一项时,原始音频直接存进主档案。", + "同一目錄下另存 .raw.wav,可與上面那份逐段對照;只開這一項時,原始音訊直接存進主檔案。", + "Writes .raw.wav next to it so you can compare segment by segment; with only this enabled the raw audio goes into the main archive.", + "同じフォルダに .raw.wav を追加保存し、上記と区間ごとに比較できます。これだけを有効にすると元音声がメインファイルに保存されます。", + "같은 폴더에 .raw.wav를 함께 저장해 구간별로 비교할 수 있습니다. 이것만 켜면 원본 오디오가 기본 파일에 저장됩니다.", + ), + }, Msg { key: "settings.recording.record_audio_for_debug_label", text: row( diff --git a/openless-all/app/linux-egui/src/linux_app/settings_save.rs b/openless-all/app/linux-egui/src/linux_app/settings_save.rs index 6bb556f03..35d66143d 100644 --- a/openless-all/app/linux-egui/src/linux_app/settings_save.rs +++ b/openless-all/app/linux-egui/src/linux_app/settings_save.rs @@ -235,6 +235,20 @@ impl OpenLessEguiApp { } frontend::view_model::SettingsField::RecordAudioForDebug => { preferences.record_audio_for_debug = !preferences.record_audio_for_debug; + // 打开主开关时默认选「处理后」,否则主开关打开了却什么都不落盘、 + // 用户以为坏了;「处理前」保持用户上一次的选择。 + if preferences.record_audio_for_debug + && !preferences.debug_archive_processed + && !preferences.debug_archive_raw + { + preferences.debug_archive_processed = true; + } + } + frontend::view_model::SettingsField::DebugArchiveProcessed => { + preferences.debug_archive_processed = !preferences.debug_archive_processed; + } + frontend::view_model::SettingsField::DebugArchiveRaw => { + preferences.debug_archive_raw = !preferences.debug_archive_raw; } frontend::view_model::SettingsField::StreamingSaveClipboard => { preferences.streaming_insert_save_clipboard = diff --git a/openless-all/app/linux-egui/src/main.rs b/openless-all/app/linux-egui/src/main.rs index 63fe56c4c..cea36a446 100644 --- a/openless-all/app/linux-egui/src/main.rs +++ b/openless-all/app/linux-egui/src/main.rs @@ -5879,6 +5879,8 @@ mod linux_app { s.launch_at_login = prefs.launch_at_login; s.streaming_save_clipboard = prefs.streaming_insert_save_clipboard; s.record_audio_for_debug = prefs.record_audio_for_debug; + s.debug_archive_processed = prefs.debug_archive_processed; + s.debug_archive_raw = prefs.debug_archive_raw; // 日志级别是宿主本地文件,不在 Core 偏好里;读不到就按 info 显示。 s.log_level = log_level_index; if hydrate_text { diff --git a/openless-all/app/linux-egui/src/ui/frontend/settings.rs b/openless-all/app/linux-egui/src/ui/frontend/settings.rs index 493a62327..c45a0d112 100644 --- a/openless-all/app/linux-egui/src/ui/frontend/settings.rs +++ b/openless-all/app/linux-egui/src/ui/frontend/settings.rs @@ -3197,6 +3197,34 @@ fn advanced(ui: &mut egui::Ui, vm: &mut FrontendViewModel, actions: &mut Vec.wav`)。 + pub debug_archive_processed: bool, + /// 调试录音:额外保留「处理前」的 `.raw.wav`,用于 A/B 对照。 + pub debug_archive_raw: bool, /// 日志级别:0 = info,1 = debug,2 = trace。宿主本地设置,不进 Core 偏好。 pub log_level: usize, pub history_max_entries: String, @@ -1273,6 +1281,8 @@ impl Default for SettingsFields { capsule_transcript_font_size: 14, llm_thinking_enabled: false, record_audio_for_debug: false, + debug_archive_processed: false, + debug_archive_raw: false, log_level: 0, history_max_entries: String::new(), style_pack_hotkeys: Vec::new(), From 1cbfd4db91ccc46351985f88a26170952db7a461 Mon Sep 17 00:00:00 2001 From: aeoform <2790848120@qq.com> Date: Sun, 25 Oct 2026 21:34:39 +0800 Subject: [PATCH 2/9] =?UTF-8?q?feat(audio-dsp):=20=E8=A1=A5=E5=85=A8=20Mic?= =?UTF-8?q?You=20=E9=93=BE=E8=B7=AF=E7=9A=84=20AEC=20=E4=B8=8E=E8=BE=93?= =?UTF-8?q?=E5=87=BA=E7=BC=93=E5=86=B2=EF=BC=8Cegui=20=E8=AE=BE=E7=BD=AE?= =?UTF-8?q?=E9=A1=B5=E8=B7=9F=E4=B8=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MicYou 的 DSP 参数是 8 项:增益 / 声学回声消除 / 降噪 / 去混响 / 自动增益 / 人声活动检测 / 处理链路 / 缓冲区大小。Core 的 `AudioDspConfig` 一直缺 AEC 与 缓冲区,而且因为 `processing_order` 只有 6 个阶段、`settings()` 里用 `..Default::default()` 兜底,MicYou 的 `processing_chain` 里根本没有 "AEC" 这一级 —— 即使把 `aec_enabled` 打开也不会执行。 Core: - `AudioDspStage` 新增 `AcousticEchoCancellation`(链名 "AEC"), `processing_order` 由 6 级变 7 级,默认顺序与 MicYou 一致(AEC 在最前)。 - `AudioDspConfig` 新增 `aec_enabled` 与 `output_buffer_ms`(100..=1200,默认 300), `normalized()` 夹紧范围,`settings()` 映射到 micyou 的 `AudioDspSettings`。 - `processing_order` 反序列化兼容老配置:6 级数组把 AEC 前置补齐,其余顺序原样 保留(既符合 AEC 必须在降噪之前,也不会制造重复项触发「重复即重置」)。 - 测试:老 6 级配置反序列化保留自定义顺序 + AEC 前置;AEC/缓冲区能到达 DSP 设置;越界缓冲夹到 1200。 linux-egui: - 设置 → 录音 → 语音增强设置新增「回声消除与缓冲」段:AEC 开关(附「需要扬声器 参考信号」说明)+ 输出缓冲区 100–1200 ms 滑杆(附延迟代价说明)。 - 处理链路列表把 AEC 作为一级显示,未开启时置灰;五语文案补齐。 这样 MicYou 的 8 项参数在 egui 前端全部可达。 --- .../openless-core/src/audio/dsp_tests.rs | 60 +++++++++++++++++ .../app/crates/openless-core/src/audio_dsp.rs | 66 ++++++++++++++++++- openless-all/app/linux-egui/package-revision | 2 +- openless-all/app/linux-egui/src/i18n.rs | 36 ++++++++++ .../linux-egui/src/ui/frontend/audio_dsp.rs | 10 +++ 5 files changed, 170 insertions(+), 4 deletions(-) diff --git a/openless-all/app/crates/openless-core/src/audio/dsp_tests.rs b/openless-all/app/crates/openless-core/src/audio/dsp_tests.rs index bd04ee7be..cdd3ee4cc 100644 --- a/openless-all/app/crates/openless-core/src/audio/dsp_tests.rs +++ b/openless-all/app/crates/openless-core/src/audio/dsp_tests.rs @@ -195,3 +195,63 @@ fn default_agc_does_not_amplify_denoised_room_noise_back_to_original_level() { ); } } + +/// 老 preferences 里 `processingOrder` 只有 6 个阶段(没有 AEC)。数组长度从 6 +/// 变 7 之后必须仍能反序列化,缺的 AEC 补在默认位置,否则升级后用户的增强配置 +/// 会被整个丢掉。 +#[test] +fn legacy_six_stage_processing_order_still_deserializes() { + let legacy = r#"{ + "noiseReduction": "rnnoise", + "noiseStrength": 80, + "gainDb": 4, + "agcEnabled": true, + "agcTarget": 35, + "agcAttack": 50, + "agcDecay": 50, + "vadEnabled": false, + "vadThresholdDb": -50, + "dereverbLevel": 0, + "equalizerEnabled": false, + "equalizerPreampDb": 0, + "equalizerBandsDb": [0,0,0,0,0,0,0,0,0,0], + "processingOrder": ["noiseReduction","dereverb","equalizer","amplifier","agc","vad"] + }"#; + let config: crate::AudioDspConfig = serde_json::from_str(legacy).unwrap(); + assert!(!config.aec_enabled, "AEC 默认关"); + assert_eq!(config.output_buffer_ms, 300); + assert_eq!(config.gain_db, 4, "老配置里的值必须保留"); + assert_eq!( + config.processing_order, + [ + // AEC 补到最前,老配置的 6 级顺序原样保留。 + crate::AudioDspStage::AcousticEchoCancellation, + crate::AudioDspStage::NoiseReduction, + crate::AudioDspStage::Dereverb, + crate::AudioDspStage::Equalizer, + crate::AudioDspStage::Amplifier, + crate::AudioDspStage::Agc, + crate::AudioDspStage::Vad, + ], + "老配置的 6 级顺序必须保留,AEC 前置补齐" + ); +} + +#[test] +fn aec_and_buffer_reach_the_dsp_settings() { + let config = crate::AudioDspConfig { + aec_enabled: true, + output_buffer_ms: 900, + ..Default::default() + }; + let settings = config.settings(); + assert!(settings.aec_enabled); + assert_eq!(settings.output_buffer_ms, 900); + // 越界值必须夹回 MicYou 接受的范围。 + let clamped = crate::AudioDspConfig { + output_buffer_ms: 5000, + ..Default::default() + } + .normalized(); + assert_eq!(clamped.output_buffer_ms, 1200); +} diff --git a/openless-all/app/crates/openless-core/src/audio_dsp.rs b/openless-all/app/crates/openless-core/src/audio_dsp.rs index 3e639acaf..5560b7b72 100644 --- a/openless-all/app/crates/openless-core/src/audio_dsp.rs +++ b/openless-all/app/crates/openless-core/src/audio_dsp.rs @@ -14,6 +14,9 @@ pub enum NoiseReduction { #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum AudioDspStage { + /// 声学回声消除:需要远端参考信号,只有拿到 far-end 时才真正生效(MicYou 的 + /// 链路里它是第一级)。 + AcousticEchoCancellation, NoiseReduction, Dereverb, Equalizer, @@ -25,6 +28,7 @@ pub enum AudioDspStage { impl AudioDspStage { fn name(self) -> &'static str { match self { + Self::AcousticEchoCancellation => "AEC", Self::NoiseReduction => "NoiseReduction", Self::Dereverb => "Dereverb", Self::Equalizer => "Equalizer", @@ -39,6 +43,8 @@ impl AudioDspStage { #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] pub struct AudioDspConfig { + /// 声学回声消除。默认关:外放说话时才会需要,且要能拿到扬声器参考信号。 + pub aec_enabled: bool, pub noise_reduction: NoiseReduction, pub noise_strength: u8, pub gain_db: i8, @@ -54,13 +60,54 @@ pub struct AudioDspConfig { pub equalizer_enabled: bool, pub equalizer_preamp_db: i8, pub equalizer_bands_db: [i8; 10], - pub processing_order: [AudioDspStage; 6], + /// 输出环形缓冲的余量(毫秒)。设备回调抖动大时调大,端到端延迟会跟着变大。 + /// MicYou 的范围是 100..=1200,默认 300。 + pub output_buffer_ms: u16, + /// 处理链路顺序,7 个阶段各一次。老版本 preferences 里存的是 6 个阶段(没有 + /// AEC),反序列化时按默认顺序补齐,不会因为数组长度变化而丢配置。 + #[serde( + default = "default_processing_order", + deserialize_with = "deserialize_processing_order" + )] + pub processing_order: [AudioDspStage; 7], +} + +fn default_processing_order() -> [AudioDspStage; 7] { + AudioDspConfig::default().processing_order +} + +fn deserialize_processing_order<'de, D>( + deserializer: D, +) -> Result<[AudioDspStage; 7], D::Error> +where + D: serde::Deserializer<'de>, +{ + let stored = Vec::::deserialize(deserializer)?; + let defaults = default_processing_order(); + // 老配置(6 级,没有 AEC):把 AEC 补到最前面,其余顺序原样保留 —— 这既是 + // MicYou 的默认位置(AEC 必须拿到尚未被降噪改过的信号),也不会制造重复项 + // 而触发 `normalized()` 的「重复即重置」。 + if stored.len() == defaults.len() - 1 && !stored.contains(&AudioDspStage::AcousticEchoCancellation) + { + let mut order = [AudioDspStage::AcousticEchoCancellation; 7]; + for (slot, stage) in stored.into_iter().enumerate() { + order[slot + 1] = stage; + } + return Ok(order); + } + let mut order = defaults; + for (slot, stage) in order.iter_mut().enumerate() { + // 其它长度(损坏或未来版本):按位置取,缺的用默认,多的丢弃。 + *stage = stored.get(slot).copied().unwrap_or(defaults[slot]); + } + Ok(order) } impl Default for AudioDspConfig { fn default() -> Self { use AudioDspStage::*; Self { + aec_enabled: false, noise_reduction: crate::audio_dsp::NoiseReduction::Rnnoise, noise_strength: 80, gain_db: 0, @@ -74,7 +121,18 @@ impl Default for AudioDspConfig { equalizer_enabled: false, equalizer_preamp_db: 0, equalizer_bands_db: [0; 10], - processing_order: [NoiseReduction, Dereverb, Equalizer, Amplifier, Agc, Vad], + output_buffer_ms: 300, + // 与 MicYou 的默认链路一致:AEC 在最前,其后是降噪、去混响、均衡、 + // 增益、AGC、VAD。 + processing_order: [ + AcousticEchoCancellation, + NoiseReduction, + Dereverb, + Equalizer, + Amplifier, + Agc, + Vad, + ], } } } @@ -90,6 +148,7 @@ impl AudioDspConfig { self.dereverb_level = self.dereverb_level.min(100); self.equalizer_preamp_db = self.equalizer_preamp_db.clamp(-20, 20); self.equalizer_bands_db = self.equalizer_bands_db.map(|gain| gain.clamp(-12, 12)); + self.output_buffer_ms = self.output_buffer_ms.clamp(100, 1200); if self .processing_order .iter() @@ -117,6 +176,8 @@ impl AudioDspConfig { pub(crate) fn settings(self) -> micyou_dsp::AudioDspSettings { let c = self.normalized(); micyou_dsp::AudioDspSettings { + aec_enabled: c.aec_enabled, + output_buffer_ms: u32::from(c.output_buffer_ms), gain: c.gain_db as f32, ns_enabled: c.noise_reduction != NoiseReduction::Off && c.noise_strength > 0, ns_type: match c.noise_reduction { @@ -140,7 +201,6 @@ impl AudioDspConfig { gains: c.equalizer_bands_db.map(f32::from).to_vec(), }, processing_chain: c.processing_order.map(|stage| stage.name().into()).to_vec(), - ..Default::default() } } } diff --git a/openless-all/app/linux-egui/package-revision b/openless-all/app/linux-egui/package-revision index a29644e57..13c09a007 100644 --- a/openless-all/app/linux-egui/package-revision +++ b/openless-all/app/linux-egui/package-revision @@ -1 +1 @@ -144 +145 diff --git a/openless-all/app/linux-egui/src/i18n.rs b/openless-all/app/linux-egui/src/i18n.rs index a77808ab7..e95501cf8 100644 --- a/openless-all/app/linux-egui/src/i18n.rs +++ b/openless-all/app/linux-egui/src/i18n.rs @@ -152,6 +152,42 @@ pub const CATALOG: &[Msg] = &[ Msg { key: "settings.audioDsp.disabledHint", text: row("开启上方语音增强开关后可调整,当前配置已保留。", "開啟上方語音增強開關後可調整,目前設定已保留。", "Enable speech enhancement above to adjust these settings. Your configuration is retained.", "上の音声強調を有効にすると調整できます。設定は保持されています。", "위에서 음성 향상을 켜면 조정할 수 있습니다. 설정은 유지됩니다.") }, Msg { key: "settings.audioDsp.moveUp", text: row("上移处理阶段", "上移處理階段", "Move stage up", "処理を上へ移動", "처리 단계 위로 이동") }, Msg { key: "settings.audioDsp.moveDown", text: row("下移处理阶段", "下移處理階段", "Move stage down", "処理を下へ移動", "처리 단계 아래로 이동") }, + Msg { + key: "settings.audioDsp.aecStage", + text: row("回声消除", "回聲消除", "Echo cancellation", "エコーキャンセル", "에코 제거"), + }, + Msg { + key: "settings.audioDsp.echoStage", + text: row("回声消除与缓冲", "回聲消除與緩衝", "Echo cancellation and buffering", "エコーキャンセルとバッファ", "에코 제거 및 버퍼"), + }, + Msg { + key: "settings.audioDsp.acousticEchoCancellation", + text: row("声学回声消除(AEC)", "聲學回聲消除(AEC)", "Acoustic echo cancellation (AEC)", "音響エコーキャンセル(AEC)", "음향 에코 제거(AEC)"), + }, + Msg { + key: "settings.audioDsp.AEC_needs_a_loudspeaker_reference", + text: row( + "外放时压掉扬声器回授;没有参考信号时不会起作用,戴耳机可以关掉。", + "外放時壓掉揚聲器回授;沒有參考訊號時不會起作用,戴耳機可以關掉。", + "Suppresses loudspeaker feedback while on speaker; it does nothing without a reference signal, so leave it off with headphones.", + "スピーカー使用時の回り込みを抑えます。参照信号がないと効果はないため、ヘッドホンならオフのままで構いません。", + "스피커 사용 시 되먹임을 줄입니다. 참조 신호가 없으면 효과가 없으므로 헤드폰이면 꺼 두세요.", + ), + }, + Msg { + key: "settings.audioDsp.Output_buffer_ms", + text: row("输出缓冲区", "輸出緩衝區", "Output buffer", "出力バッファ", "출력 버퍼"), + }, + Msg { + key: "settings.audioDsp.A_larger_buffer_survives_jitter_but_adds_latency", + text: row( + "调大可吸收声卡回调抖动,代价是端到端延迟增加。默认 300 ms。", + "調大可吸收音效卡回呼抖動,代價是端到端延遲增加。預設 300 ms。", + "A larger buffer absorbs sound-card callback jitter at the cost of end-to-end latency. Default 300 ms.", + "大きくするとサウンドカードのコールバック揺らぎに強くなりますが、遅延が増えます。既定は 300 ms です。", + "크게 하면 사운드 카드 콜백 지터를 흡수하지만 지연이 늘어납니다. 기본값은 300 ms입니다.", + ), + }, Msg { key: "settings.audioDsp.noiseStage", text: row("降噪", "降噪", "Noise reduction", "ノイズ低減", "잡음 감소") }, Msg { key: "settings.audioDsp.combStage", text: row("梳状滤波", "梳狀濾波", "Comb filter", "コムフィルター", "콤 필터") }, Msg { key: "settings.audioDsp.Speech_enhancement_settings", text: row("语音增强设置", "語音增強設置", "Speech enhancement settings", "音声強調設定", "음성 향상 설정") }, diff --git a/openless-all/app/linux-egui/src/ui/frontend/audio_dsp.rs b/openless-all/app/linux-egui/src/ui/frontend/audio_dsp.rs index 0430a5424..c7619fde3 100644 --- a/openless-all/app/linux-egui/src/ui/frontend/audio_dsp.rs +++ b/openless-all/app/linux-egui/src/ui/frontend/audio_dsp.rs @@ -11,6 +11,14 @@ pub fn settings(ui: &mut egui::Ui, config: &mut AudioDspConfig, lang: Lang, enab ui.add_enabled_ui(enabled, |ui| { ui.small(label("settings.audioDsp.RNNoise_is_lightweight_PureVox_uses_more_CPU_Changes_apply_to_the_next_recording")); ui.add_space(8.0); + // 回声消除与输出缓冲:MicYou 的链路里 AEC 在最前,缓冲区是整条链的全局 + // 参数(设备回调抖动大时调大,代价是延迟)。 + ui.strong(label("settings.audioDsp.echoStage")); + ui.checkbox(&mut config.aec_enabled, label("settings.audioDsp.acousticEchoCancellation")); + ui.small(label("settings.audioDsp.AEC_needs_a_loudspeaker_reference")); + slider(ui, label("settings.audioDsp.Output_buffer_ms"), &mut config.output_buffer_ms, 100..=1200, " ms"); + ui.small(label("settings.audioDsp.A_larger_buffer_survives_jitter_but_adds_latency")); + ui.separator(); ui.strong(label("settings.audioDsp.noiseStage")); egui::ComboBox::from_id_salt("audio-dsp-algorithm") .width(ui.available_width().min(280.0)) @@ -70,6 +78,7 @@ pub fn settings(ui: &mut egui::Ui, config: &mut AudioDspConfig, lang: Lang, enab let mut swap = None; for (i, stage) in config.processing_order.iter().enumerate() { let name = match stage { + AudioDspStage::AcousticEchoCancellation => label("settings.audioDsp.aecStage"), AudioDspStage::NoiseReduction => label("settings.audioDsp.noiseStage"), AudioDspStage::Dereverb => label("settings.audioDsp.combStage"), AudioDspStage::Equalizer => label("settings.audioDsp.Equalizer"), @@ -80,6 +89,7 @@ pub fn settings(ui: &mut egui::Ui, config: &mut AudioDspConfig, lang: Lang, enab ui.push_id(i, |ui| ui.horizontal(|ui| { ui.monospace(format!("{}", i + 1)); let active = match stage { + AudioDspStage::AcousticEchoCancellation => config.aec_enabled, AudioDspStage::NoiseReduction => config.noise_reduction != NoiseReduction::Off && config.noise_strength > 0, AudioDspStage::Dereverb => config.dereverb_level > 0, AudioDspStage::Equalizer => config.equalizer_enabled, From e7a76ba3c27a3b651587419e1f7c4977ccf02439 Mon Sep 17 00:00:00 2001 From: aeoform <2790848120@qq.com> Date: Sun, 25 Oct 2026 22:09:09 +0800 Subject: [PATCH 3/9] =?UTF-8?q?feat(tauri):=20=E8=AF=AD=E9=9F=B3=E5=A2=9E?= =?UTF-8?q?=E5=BC=BA=E8=AE=BE=E7=BD=AE=E8=A1=A5=E4=B8=8A=20AEC=20=E4=B8=8E?= =?UTF-8?q?=E8=BE=93=E5=87=BA=E7=BC=93=E5=86=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Core 侧已经支持 `aec_enabled` / `output_buffer_ms`(并真正传给 MicYou 的 `processing_chain`),Tauri 的设置页还停在 6 级链路: - `AudioDspStage` 类型加 `acousticEchoCancellation`,`AudioDspConfig` 加 `aecEnabled` / `outputBufferMs`,`defaultAudioDsp` 同步成 7 级链路(AEC 在最前)。 - `AudioDspSettings.tsx` 新增「回声消除」开关(附「需要扬声器参考信号」说明)与 输出缓冲 100–1200 ms 滑杆;处理顺序列表自动多出 AEC 一级。 - 八语文案补齐(en / zh-CN / zh-TW / ja / ko / de / fr / es),`UserPreferences` 类型与 mock 数据跟上 Core 新增的两个调试录音字段。 验证:`tsc --noEmit` 干净、`vite build` 成功、`frontend-test-runner` 全过。 --- openless-all/app/src/i18n/de.ts | 8 ++++++++ openless-all/app/src/i18n/en.ts | 8 ++++++++ openless-all/app/src/i18n/es.ts | 8 ++++++++ openless-all/app/src/i18n/fr.ts | 8 ++++++++ openless-all/app/src/i18n/ja.ts | 8 ++++++++ openless-all/app/src/i18n/ko.ts | 8 ++++++++ openless-all/app/src/i18n/zh-CN.ts | 8 ++++++++ openless-all/app/src/i18n/zh-TW.ts | 8 ++++++++ openless-all/app/src/lib/audioDsp.ts | 12 +++++++++++- openless-all/app/src/lib/ipc/mock-data.ts | 2 ++ openless-all/app/src/lib/types.ts | 16 +++++++++++++++- .../app/src/pages/settings/AudioDspSettings.tsx | 11 +++++++++++ 12 files changed, 103 insertions(+), 2 deletions(-) diff --git a/openless-all/app/src/i18n/de.ts b/openless-all/app/src/i18n/de.ts index 83ca7fdbd..84e3b1f0d 100644 --- a/openless-all/app/src/i18n/de.ts +++ b/openless-all/app/src/i18n/de.ts @@ -1575,6 +1575,10 @@ export const de: typeof zhCN = { lowLatencyDictationDesc: 'Hält einen Mikrofonstream bereit und bewahrt bis zu 500 ms im Speicher, damit Sprache direkt nach dem Kurzbefehl seltener verloren geht. Vor der Bestätigung wird Audio weder gespeichert noch hochgeladen; der Stromverbrauch steigt und die Mikrofonanzeige bleibt aktiv.', dsp: { + aec: 'Echounterdrückung', + aecHint: + 'Unterdrückt Lautsprecher-Rückkopplung. Braucht ein Referenzsignal; mit Kopfhörern aus lassen.', + buffer: 'Ausgabepuffer', noise: 'Noise reduction', dereverb: 'Comb filter', equalizer: 'Equalizer', @@ -1681,6 +1685,10 @@ export const de: typeof zhCN = { polishContextWindowLabel: 'Kontextfenster für Überarbeitung (Minuten)', polishContextWindowDesc: 'Verwendet überarbeitete Transkripte der letzten N Minuten als Gesprächskontext. 0 = deaktiviert.', + debugArchiveProcessedLabel: 'Verarbeitetes Audio behalten', + debugArchiveProcessedDesc: 'Die Version nach Rauschunterdrückung und Verstärkung, weiterhin in der bisherigen Aufnahmedatei.', + debugArchiveRawLabel: 'Auch Audio vor der Verarbeitung behalten', + debugArchiveRawDesc: 'Schreibt zusätzlich .raw.wav daneben. Ist nur dies aktiv, landet das Rohaudio in der Hauptdatei.', recordAudioForDebugLabel: 'Rohaufnahmen behalten (Diagnose)', recordAudioForDebugDesc: 'Speichert das rohe Mikrofonaudio als WAV, um Erkennungsprobleme zu untersuchen.', diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index cabdfbd89..817df7a1c 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -1545,6 +1545,10 @@ export const en: typeof zhCN = { lowLatencyDictationDesc: 'Keep a microphone stream ready and retain up to 500 ms in memory so speech immediately after the hotkey is less likely to be missed. Audio is not saved or uploaded until you confirm recording; this uses more power and keeps the microphone indicator active.', dsp: { + aec: 'Echo cancellation', + aecHint: + 'Suppresses loudspeaker feedback while on speaker. It needs a speaker reference signal, so leave it off with headphones.', + buffer: 'Output buffer', noise: 'Noise reduction', dereverb: 'Comb filter', equalizer: 'Equalizer', @@ -1649,6 +1653,10 @@ export const en: typeof zhCN = { polishContextWindowLabel: 'Polish context window (minutes)', polishContextWindowDesc: 'Use the last N minutes of polished transcripts as multi-turn context; 0 = disabled.', + debugArchiveProcessedLabel: 'Keep processed audio', + debugArchiveProcessedDesc: 'The version after noise reduction and gain, stored in the existing recording file.', + debugArchiveRawLabel: 'Also keep pre-processing audio', + debugArchiveRawDesc: 'Writes .raw.wav next to it so you can compare. With only this enabled the raw audio goes into the main archive.', recordAudioForDebugLabel: 'Keep raw recording (debug)', recordAudioForDebugDesc: 'Save raw microphone audio as wav for diagnosing recognition issues.', diff --git a/openless-all/app/src/i18n/es.ts b/openless-all/app/src/i18n/es.ts index 0378847ea..3707eddb0 100644 --- a/openless-all/app/src/i18n/es.ts +++ b/openless-all/app/src/i18n/es.ts @@ -1566,6 +1566,10 @@ export const es: typeof zhCN = { lowLatencyDictationDesc: 'Mantén preparado el flujo del micrófono y conserva hasta 500 ms en memoria para reducir la pérdida de voz justo después del atajo. El audio no se guarda ni se sube antes de confirmar la grabación; consume más energía y mantiene activo el indicador del micrófono.', dsp: { + aec: 'Cancelación de eco', + aecHint: + 'Suprime la realimentación del altavoz. Necesita una señal de referencia; con auriculares puedes dejarlo apagado.', + buffer: 'Búfer de salida', noise: 'Noise reduction', dereverb: 'Comb filter', equalizer: 'Equalizer', @@ -1672,6 +1676,10 @@ export const es: typeof zhCN = { polishContextWindowLabel: 'Ventana de contexto para mejorar texto (minutos)', polishContextWindowDesc: 'Usa las transcripciones mejoradas de los últimos N minutos como contexto de varias intervenciones. 0 = desactivado.', + debugArchiveProcessedLabel: 'Conservar el audio procesado', + debugArchiveProcessedDesc: 'La versión tras reducción de ruido y ganancia, en el archivo de grabación existente.', + debugArchiveRawLabel: 'Conservar también el audio sin procesar', + debugArchiveRawDesc: 'Escribe además .raw.wav junto a él. Si solo activas esto, el audio sin procesar va al archivo principal.', recordAudioForDebugLabel: 'Conservar grabación original (depuración)', recordAudioForDebugDesc: 'Guarda el audio original del micrófono en WAV para investigar problemas de reconocimiento.', diff --git a/openless-all/app/src/i18n/fr.ts b/openless-all/app/src/i18n/fr.ts index b9487f656..0de26f1b1 100644 --- a/openless-all/app/src/i18n/fr.ts +++ b/openless-all/app/src/i18n/fr.ts @@ -1586,6 +1586,10 @@ export const fr: typeof zhCN = { lowLatencyDictationDesc: 'Garde un flux microphone prêt et conserve jusqu’à 500 ms en mémoire afin de réduire les mots perdus juste après le raccourci. Aucun audio n’est enregistré ni envoyé avant confirmation ; ce mode consomme davantage et maintient l’indicateur du microphone actif.', dsp: { + aec: 'Annulation d’écho', + aecHint: + 'Réduit le retour du haut-parleur. Nécessite un signal de référence; inutile au casque.', + buffer: 'Tampon de sortie', noise: 'Noise reduction', dereverb: 'Comb filter', equalizer: 'Equalizer', @@ -1693,6 +1697,10 @@ export const fr: typeof zhCN = { polishContextWindowLabel: 'Fenêtre de contexte d’amélioration (minutes)', polishContextWindowDesc: 'Utilise les transcriptions améliorées des N dernières minutes comme contexte de plusieurs interventions. 0 = désactivé.', + debugArchiveProcessedLabel: 'Conserver l’audio traité', + debugArchiveProcessedDesc: 'La version après réduction de bruit et gain, toujours dans le fichier d’enregistrement existant.', + debugArchiveRawLabel: 'Conserver aussi l’audio avant traitement', + debugArchiveRawDesc: 'Écrit aussi .raw.wav à côté. Si seule cette option est active, l’audio brut va dans l’archive principale.', recordAudioForDebugLabel: 'Conserver l’enregistrement brut (débogage)', recordAudioForDebugDesc: 'Enregistre l’audio brut du microphone en WAV pour diagnostiquer les problèmes de reconnaissance.', diff --git a/openless-all/app/src/i18n/ja.ts b/openless-all/app/src/i18n/ja.ts index c1d09e207..f6de03741 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -1533,6 +1533,10 @@ export const ja: typeof zhCN = { lowLatencyDictationDesc: 'マイクストリームを準備し、最大 500 ミリ秒をメモリに保持して、ホットキー直後の音声が欠落しにくくします。録音を確定するまで音声は保存・アップロードされません。電力を多く使い、マイク使用中の表示が続きます。', dsp: { + aec: 'エコーキャンセル', + aecHint: + 'スピーカー使用時の回り込みを抑えます。参照信号が必要なので、ヘッドホンならオフで構いません。', + buffer: '出力バッファ', noise: 'Noise reduction', dereverb: 'Comb filter', equalizer: 'Equalizer', @@ -1637,6 +1641,10 @@ export const ja: typeof zhCN = { polishContextWindowLabel: '会話コンテキスト窓(分)', polishContextWindowDesc: '直近 N 分間の整文済み転写をマルチターン文脈として渡します。0 = 無効。', + debugArchiveProcessedLabel: '処理後の音声を保存', + debugArchiveProcessedDesc: 'ノイズ低減とゲイン適用後の音声で、従来の録音ファイルに保存されます。', + debugArchiveRawLabel: '処理前の音声も保存', + debugArchiveRawDesc: '同じフォルダに .raw.wav を追加保存します。これだけを有効にすると元音声がメインに保存されます。', recordAudioForDebugLabel: '元の録音を保持(デバッグ)', recordAudioForDebugDesc: '生のマイク音声を wav で保存し、認識問題の診断に利用。', audioRecordingMaxEntriesLabel: '元音声の保持件数', diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index dff252b94..5d65d133a 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -1520,6 +1520,10 @@ export const ko: typeof zhCN = { lowLatencyDictationDesc: '마이크 스트림을 준비하고 최대 500ms를 메모리에 보관해 단축키 직후의 음성이 누락될 가능성을 줄입니다. 녹음을 확정하기 전에는 오디오를 저장하거나 업로드하지 않습니다. 전력 사용량이 늘고 마이크 표시가 계속 켜져 있습니다.', dsp: { + aec: '에코 제거', + aecHint: + '스피커 사용 시 되먹임을 줄입니다. 참조 신호가 필요하므로 헤드폰이면 꺼 두세요.', + buffer: '출력 버퍼', noise: 'Noise reduction', dereverb: 'Comb filter', equalizer: 'Equalizer', @@ -1624,6 +1628,10 @@ export const ko: typeof zhCN = { polishContextWindowLabel: '대화 컨텍스트 윈도(분)', polishContextWindowDesc: '최근 N 분간 정리된 전사를 멀티턴 컨텍스트로 전달합니다. 0 = 비활성화.', + debugArchiveProcessedLabel: '처리된 오디오 저장', + debugArchiveProcessedDesc: '노이즈 감소와 게인 적용 후의 오디오로, 기존 녹음 파일에 저장됩니다.', + debugArchiveRawLabel: '처리 전 오디오도 저장', + debugArchiveRawDesc: '같은 폴더에 .raw.wav를 함께 저장합니다. 이것만 켜면 원본이 기본 파일에 저장됩니다.', recordAudioForDebugLabel: '원본 녹음 보관(디버그)', recordAudioForDebugDesc: '원시 마이크 오디오를 wav 로 저장하여 인식 문제 진단.', audioRecordingMaxEntriesLabel: '원본 녹음 보관 개수', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index 6b92f58ea..12387ab08 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -1470,6 +1470,10 @@ export const zhCN = { lowLatencyDictationDesc: '保持麦克风流就绪,并在内存中保留最多 500 毫秒音频,减少按下热键后第一句话被漏识别的情况。确认录音前不会保存或上传音频;此模式会增加功耗并保持系统麦克风指示灯亮起。', dsp: { + aec: '回声消除', + aecHint: + '外放时压掉扬声器回授;需要扬声器参考信号,戴耳机可以关掉。', + buffer: '输出缓冲区', title: '语音增强设置', description: 'RNNoise 更轻量;PureVox 模型降噪耗时较高。设置在下一次录音生效。', noise: '降噪算法', @@ -1578,6 +1582,10 @@ export const zhCN = { '转写不超过 N 个字(不计空白)时直接插入原文,不调用润色模型。0 = 关闭。翻译模式始终润色。', polishContextWindowLabel: '对话上下文窗口(分钟)', polishContextWindowDesc: '把最近 N 分钟内已润色的转写作为多轮上下文,0 = 关闭。', + debugArchiveProcessedLabel: '保存处理后的音频', + debugArchiveProcessedDesc: '降噪与增益之后的版本,仍保存在原来的录制文件里。', + debugArchiveRawLabel: '另存一份处理前的音频', + debugArchiveRawDesc: '同目录下另存 .raw.wav 便于对照;只开这一项时,原始音频直接存进主档案。', recordAudioForDebugLabel: '保留原始录音(调试)', recordAudioForDebugDesc: '保存原始麦克风音频为 wav,便于排查识别问题。', audioRecordingMaxEntriesLabel: '原始录音保留条数', diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index 533136f34..d885f0cdf 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -1472,6 +1472,10 @@ export const zhTW: typeof zhCN = { lowLatencyDictationDesc: '保持麥克風串流就緒,並在記憶體中保留最多 500 毫秒音訊,減少按下熱鍵後第一句話被漏辨識的情況。確認錄音前不會儲存或上傳音訊;此模式會增加耗電並保持系統麥克風指示燈亮起。', dsp: { + aec: '回聲消除', + aecHint: + '外放時壓掉揚聲器回授;需要揚聲器參考訊號,戴耳機可以關掉。', + buffer: '輸出緩衝區', title: '語音增強設定', description: 'RNNoise 更輕量;PureVox 模型降噪耗時較高。設定於下一次錄音生效。', noise: '降噪演算法', @@ -1566,6 +1570,10 @@ export const zhTW: typeof zhCN = { '轉寫不超過 N 個字(不計空白)時直接插入原文,不呼叫潤色模型。0 = 關閉。翻譯模式始終潤色。', polishContextWindowLabel: '對話上下文視窗(分鐘)', polishContextWindowDesc: '把最近 N 分鐘內已潤色的轉寫作為多輪上下文,0 = 關閉。', + debugArchiveProcessedLabel: '儲存處理後的音訊', + debugArchiveProcessedDesc: '降噪與增益之後的版本,仍存於原本的錄製檔案。', + debugArchiveRawLabel: '另存一份處理前的音訊', + debugArchiveRawDesc: '同一目錄下另存 .raw.wav 便於對照;只開這一項時,原始音訊直接存進主檔案。', recordAudioForDebugLabel: '保留原始錄音(除錯)', recordAudioForDebugDesc: '儲存原始麥克風音訊為 wav,便於排查識別問題。', audioRecordingMaxEntriesLabel: '原始錄音保留條數', diff --git a/openless-all/app/src/lib/audioDsp.ts b/openless-all/app/src/lib/audioDsp.ts index 9bce1b732..db09984aa 100644 --- a/openless-all/app/src/lib/audioDsp.ts +++ b/openless-all/app/src/lib/audioDsp.ts @@ -1,6 +1,8 @@ import type { AudioDspConfig } from './types'; export const defaultAudioDsp: AudioDspConfig = { + aecEnabled: false, + outputBufferMs: 300, noiseReduction: 'rnnoise', noiseStrength: 80, gainDb: 0, @@ -14,7 +16,15 @@ export const defaultAudioDsp: AudioDspConfig = { equalizerEnabled: false, equalizerPreampDb: 0, equalizerBandsDb: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0], - processingOrder: ['noiseReduction', 'dereverb', 'equalizer', 'amplifier', 'agc', 'vad'], + processingOrder: [ + 'acousticEchoCancellation', + 'noiseReduction', + 'dereverb', + 'equalizer', + 'amplifier', + 'agc', + 'vad', + ], }; export const equalizerFrequencies = [ diff --git a/openless-all/app/src/lib/ipc/mock-data.ts b/openless-all/app/src/lib/ipc/mock-data.ts index 29dbcc579..e30bd2922 100644 --- a/openless-all/app/src/lib/ipc/mock-data.ts +++ b/openless-all/app/src/lib/ipc/mock-data.ts @@ -145,6 +145,8 @@ export let mockSettings: UserPreferences = { autoUpdateCheck: true, historyMaxEntries: null, recordAudioForDebug: false, + debugArchiveProcessed: false, + debugArchiveRaw: false, audioRecordingMaxEntries: null, quickNoteExportDirectory: '', marketplaceBaseUrl: 'https://apic.openless.top', diff --git a/openless-all/app/src/lib/types.ts b/openless-all/app/src/lib/types.ts index e3f0f0e66..197ede0d2 100644 --- a/openless-all/app/src/lib/types.ts +++ b/openless-all/app/src/lib/types.ts @@ -341,8 +341,18 @@ export interface StylePackRuntimeDiagnostics { } export type AudioDspStage = - 'noiseReduction' | 'dereverb' | 'equalizer' | 'amplifier' | 'agc' | 'vad'; + | 'acousticEchoCancellation' + | 'noiseReduction' + | 'dereverb' + | 'equalizer' + | 'amplifier' + | 'agc' + | 'vad'; export interface AudioDspConfig { + /** 声学回声消除;只有拿到扬声器参考信号时才起作用。 */ + aecEnabled: boolean; + /** 输出环形缓冲余量(100..1200 ms)。 */ + outputBufferMs: number; noiseReduction: 'off' | 'rnnoise' | 'purevox' | 'spectral'; noiseStrength: number; gainDb: number; @@ -592,6 +602,10 @@ export interface UserPreferences { /** Whether to keep the raw microphone audio file (wav) per session, for debugging ASR misrecognition / mic sensitivity. * Default false. When on, it uses disk space and follows the same cleanup policy as historyRetentionDays. */ recordAudioForDebug: boolean; + /** 调试录音:追加保存「处理后」的音频(主档案 `.wav`)。 */ + debugArchiveProcessed: boolean; + /** 调试录音:额外保存「处理前」的 `.raw.wav`,用于 A/B 对照。 */ + debugArchiveRaw: boolean; /** Number of recent wav files kept in recordings/. null = follows the 200 hard cap; 1..=200 for user-defined values. * Decoupled from historyMaxEntries — many text entries with only the 5 most recent wavs is a valid combination. */ audioRecordingMaxEntries: number | null; diff --git a/openless-all/app/src/pages/settings/AudioDspSettings.tsx b/openless-all/app/src/pages/settings/AudioDspSettings.tsx index 971e63c1a..460f1220e 100644 --- a/openless-all/app/src/pages/settings/AudioDspSettings.tsx +++ b/openless-all/app/src/pages/settings/AudioDspSettings.tsx @@ -53,6 +53,7 @@ export function AudioDspSettings({ ); const stageLabels: Record = { + acousticEchoCancellation: label('aec', 'Echo cancellation'), noiseReduction: label('noise', 'Noise reduction'), dereverb: label('dereverb', 'Comb filter'), equalizer: label('equalizer', 'Equalizer'), @@ -78,6 +79,16 @@ export function AudioDspSettings({ 'RNNoise is lightweight; PureVox offers model-based noise reduction with higher CPU use. Changes apply to the next recording.', )}

+ + save({ aecEnabled: !draft.aecEnabled })} /> + + {slider('outputBufferMs', label('buffer', 'Output buffer'), 100, 1200, ' ms')} Date: Sun, 25 Oct 2026 22:35:18 +0800 Subject: [PATCH 4/9] =?UTF-8?q?feat(aec):=20=E9=87=87=E9=9B=86=E6=89=AC?= =?UTF-8?q?=E5=A3=B0=E5=99=A8=E5=9B=9E=E7=8E=AF=E4=BD=9C=E4=B8=BA=20AEC=20?= =?UTF-8?q?=E5=8F=82=E8=80=83=EF=BC=8C=E5=B9=B6=E5=85=81=E8=AE=B8=E9=80=89?= =?UTF-8?q?=E6=8B=A9=E5=8F=82=E8=80=83=E6=BA=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AEC 打开却一直没效果:MicYou 需要「扬声器此刻在放什么」当参考,而宿主从来没有 把输出信号喂进来。这次把整条链补上。 Core: - 新增 `FarEndQueue`:宿主在独立采集线程里 push(任意采样率/通道),DSP 线程在 每帧处理前 drain。队列按 96k 样本(约 2 秒 @48k)限长,丢最旧的;非有限值与 空帧直接丢弃。 - `AudioProcessor::attach_far_end(queue)` + `process()` 里把参考重采样到 48 kHz 单声道后交给 `micyou set_far_end_audio`(MicYou 用最近历史做自适应滤波,所以 参考必须先于本帧到达)。 - `RecordingPlan` 增加 `aec_reference_device_name`,来自新增偏好 `aec_reference_device_name`(空 = 自动,`"off"` = 不采集;注册表记为 Excluded/local_hardware,不同步到云端)。 linux-egui: - `is_loopback_device_name()` 识别 PulseAudio/PipeWire 的 `.monitor`、 `Monitor of …` 与 ALSA `snd-aloop`;带单测确认普通麦克风不会被误判。 - `list_loopback_devices()` 按 `pipewire → pulseaudio → alsa` 顺序枚举去重。 - `start_reference_capture()`:另起一条 cpal 采集流抓回环,带自己的启动握手与 停止标志;同样按三后端顺序尝试,ALSA 无回环时只记一条 warn —— 录音本身照常。 - 录音侧:AEC 打开且参考未被显式关闭时创建队列、挂到处理器、启动参考采集; `LinuxActiveRecording` 持有它并在 Drop 时**先**停参考流再拆主线程。 - 设置页:语音增强里 AEC 打开后出现「扬声器参考源」下拉(自动 / 不采集 / 具名 监听设备),五语文案补齐。 验证(MicYou micyou-dsp 已含 AEC7 模型,beta 上无冲突改动): ``` openless-core --lib 1399 passed openless-linux-egui --lib 268 passed localization_contract 5 passed clippy -D warnings clean deb/rpm verify PASS(2.0.2-Beta.2-146) ``` --- .../app/crates/openless-core/src/audio.rs | 100 ++++++ .../openless-core/src/audio/dsp_tests.rs | 30 ++ .../src/cloud_sync_e2ee_documents/registry.rs | 7 + .../openless-core/src/dictation_context.rs | 3 + .../app/crates/openless-core/src/lib.rs | 2 +- .../crates/openless-core/src/shared_types.rs | 9 + openless-all/app/linux-egui/package-revision | 2 +- openless-all/app/linux-egui/src/audio.rs | 286 ++++++++++++++++++ openless-all/app/linux-egui/src/i18n.rs | 22 ++ openless-all/app/linux-egui/src/lib.rs | 2 + .../linux-egui/src/linux_app/settings_save.rs | 24 ++ openless-all/app/linux-egui/src/main.rs | 27 ++ .../linux-egui/src/ui/frontend/settings.rs | 22 ++ .../linux-egui/src/ui/frontend/view_model.rs | 8 + 14 files changed, 542 insertions(+), 2 deletions(-) diff --git a/openless-all/app/crates/openless-core/src/audio.rs b/openless-all/app/crates/openless-core/src/audio.rs index 6dd0f1057..524c64cb0 100644 --- a/openless-all/app/crates/openless-core/src/audio.rs +++ b/openless-all/app/crates/openless-core/src/audio.rs @@ -16,6 +16,63 @@ pub struct AudioProcessingConfig { pub dsp: crate::audio_dsp::AudioDspConfig, } +/// 扬声器回环(far-end)参考音频的交接队列。 +/// +/// AEC 需要「扬声器此刻在放什么」。宿主在一条独立的采集流里拿到输出音频后, +/// 按自己的采样率塞进这里;DSP 线程在处理每一帧之前把它取走、重采样到 48 kHz +/// 单声道后交给 MicYou。队列有上限:AEC 只关心最近的参考,堆积只会拖慢。 +#[derive(Clone, Default)] +pub struct FarEndQueue { + inner: std::sync::Arc>>, +} + +#[derive(Debug)] +struct FarEndFrame { + rate: u32, + channels: usize, + samples: Vec, +} + +/// 参考队列最多保留的样本数(约 2 秒 @48 kHz)。超出时丢弃最旧的。 +const FAR_END_QUEUE_LIMIT: usize = 96_000; + +impl FarEndQueue { + /// 宿主采集线程调用。非有限值与空帧直接丢弃。 + pub fn push(&self, samples: &[f32], channels: usize, rate: u32) { + if samples.is_empty() || channels == 0 || rate == 0 { + return; + } + let clean: Vec = samples + .iter() + .map(|sample| if sample.is_finite() { sample.clamp(-1.0, 1.0) } else { 0.0 }) + .collect(); + let mut queue = self.inner.lock().unwrap_or_else(|poisoned| poisoned.into_inner()); + queue.push_back(FarEndFrame { + rate, + channels, + samples: clean, + }); + let mut total: usize = queue.iter().map(|frame| frame.samples.len()).sum(); + while total > FAR_END_QUEUE_LIMIT { + let Some(dropped) = queue.pop_front() else { break }; + total -= dropped.samples.len(); + } + } + + /// DSP 线程调用:取走全部待处理参考帧(保持到达顺序)。 + fn drain(&self) -> Vec { + let mut queue = self.inner.lock().unwrap_or_else(|poisoned| poisoned.into_inner()); + queue.drain(..).collect() + } + + fn is_empty(&self) -> bool { + self.inner + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .is_empty() + } +} + /// Shared processor for native and external microphone hosts. /// Enhancement starts with the session's settings and may be disabled under /// load. Hosts must deliver every returned tail before feeding more input or @@ -24,6 +81,8 @@ pub struct AudioProcessor { config: AudioProcessingConfig, normalizer: PcmNormalizer, enhanced: Option, + /// AEC 的扬声器参考输入(宿主在独立的回环采集流里喂)。 + far_end: Option, finished: bool, } @@ -41,10 +100,17 @@ impl AudioProcessor { config, normalizer: Default::default(), enhanced: None, + far_end: None, finished: false, } } + /// 接上扬声器回环参考队列。只有 AEC 打开且宿主真的采到了输出音频时才有意义; + /// 没接或队列为空时 AEC 按「无参考」处理(MicYou 内部会退化)。 + pub fn attach_far_end(&mut self, queue: FarEndQueue) { + self.far_end = Some(queue); + } + pub fn reset(&mut self) { *self = Self::new(self.config); } @@ -92,6 +158,11 @@ impl AudioProcessor { let mono = downmix_to_mono(&stream.interleaved[..complete], channels); stream.interleaved.drain(..complete); stream.input_frames += mono.len() as u64; + // 参考音频必须早于本帧进入 AEC:MicYou 用最近的历史做自适应滤波。 + if let Some(far_end) = self.far_end.as_ref().filter(|queue| !queue.is_empty()) { + let frames = far_end.drain(); + stream.push_far_end(frames); + } let working = stream.input_resampler.push(&mono, false); let processed = stream.frames(&working, false); pcm_chunk(&processed) @@ -123,6 +194,10 @@ struct EnhancedStream { model_delay: usize, input_frames: u64, emitted_frames: u64, + /// 参考音频的重采样器,按输入采样率惰性重建(回环流通常固定,重建仅一次)。 + far_end_rate: Option, + far_end_resampler: Option, + far_end_pending: Vec, } impl EnhancedStream { @@ -143,7 +218,32 @@ impl EnhancedStream { model_delay: config.model_delay(), input_frames: 0, emitted_frames: 0, + far_end_rate: None, + far_end_resampler: None, + far_end_pending: Vec::new(), + } + } + + /// 把宿主采到的回环参考帧转成 48 kHz 单声道后交给 AEC。 + fn push_far_end(&mut self, frames: Vec) { + for frame in frames { + let mono = downmix_to_mono(&frame.samples, frame.channels); + if self.far_end_rate != Some(frame.rate) { + self.far_end_rate = Some(frame.rate); + self.far_end_resampler = + Some(crate::audio_dsp::SpeechResampler::new(frame.rate, 48_000)); + self.far_end_pending.clear(); + } + if let Some(resampler) = self.far_end_resampler.as_mut() { + self.far_end_pending + .extend(resampler.push(&mono, false)); + } + } + if self.far_end_pending.is_empty() { + return; } + let reference = std::mem::take(&mut self.far_end_pending); + self.dsp.set_far_end_audio(&reference); } fn frames(&mut self, input: &[f32], finish: bool) -> Vec { diff --git a/openless-all/app/crates/openless-core/src/audio/dsp_tests.rs b/openless-all/app/crates/openless-core/src/audio/dsp_tests.rs index cdd3ee4cc..6de070c5d 100644 --- a/openless-all/app/crates/openless-core/src/audio/dsp_tests.rs +++ b/openless-all/app/crates/openless-core/src/audio/dsp_tests.rs @@ -255,3 +255,33 @@ fn aec_and_buffer_reach_the_dsp_settings() { .normalized(); assert_eq!(clamped.output_buffer_ms, 1200); } + + +/// 参考队列限长:AEC 只关心最近的扬声器信号,堆积只会把 AEC 拖慢。 +#[test] +fn far_end_queue_drops_oldest_frames_beyond_the_limit() { + let queue = crate::FarEndQueue::default(); + // 每帧 48 kHz 单声道 1 秒 = 48000 样本;塞 3 秒应当留下最近 2 秒。 + for _ in 0..3 { + queue.push(&vec![0.1_f32; 48_000], 1, 48_000); + } + let frames = queue.drain(); + let total: usize = frames.iter().map(|frame| frame.samples.len()).sum(); + assert!(total <= 96_000, "队列长度应被限制在 2 秒内,实际 {total}"); + assert_eq!(frames.len(), 2, "最旧的一秒应当被丢弃"); +} + +/// 空帧/非法采样率不该进队列;非有限值被清洗成 0。 +#[test] +fn far_end_queue_rejects_empty_and_cleans_non_finite_samples() { + let queue = crate::FarEndQueue::default(); + queue.push(&[], 1, 48_000); + queue.push(&[0.0; 8], 0, 48_000); + queue.push(&[0.0; 8], 1, 0); + assert!(queue.is_empty(), "空帧与零通道/零采样率必须被拒绝"); + + queue.push(&[f32::NAN, f32::INFINITY, 0.5], 1, 48_000); + let frames = queue.drain(); + assert_eq!(frames.len(), 1); + assert!(frames[0].samples.iter().all(|sample| sample.is_finite())); +} diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/registry.rs b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/registry.rs index 92df88dd9..705e62d13 100644 --- a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/registry.rs +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/registry.rs @@ -858,6 +858,13 @@ pub const PREFERENCE_FIELDS: &[PreferenceField] = &[ shape: PreferenceShape::Boolean, reason: "portable_setting", }, + PreferenceField { + rust_name: "aec_reference_device_name", + key: "aecReferenceDeviceName", + class: PreferenceClass::Excluded, + shape: PreferenceShape::Text, + reason: "local_hardware", + }, PreferenceField { rust_name: "debug_archive_raw", key: "debugArchiveRaw", diff --git a/openless-all/app/crates/openless-core/src/dictation_context.rs b/openless-all/app/crates/openless-core/src/dictation_context.rs index f0caa269f..b83933027 100644 --- a/openless-all/app/crates/openless-core/src/dictation_context.rs +++ b/openless-all/app/crates/openless-core/src/dictation_context.rs @@ -171,6 +171,8 @@ pub struct DictationInsertionContext { #[derive(Debug, Clone, PartialEq, Eq)] pub struct RecordingPlan { pub microphone_device_name: Option, + /// AEC 的扬声器参考源:空字符串 = 自动选默认监听源,`"off"` = 不采集参考。 + pub aec_reference_device_name: String, /// The physical hotkey generation that owns a provisional capture, when /// this context is promoted from the native staging path. pub capture_generation: Option, @@ -316,6 +318,7 @@ impl DictationContext { normal_archive_successful_recording: preferences.record_audio_for_debug, recording: RecordingPlan { microphone_device_name: non_blank(&preferences.microphone_device_name), + aec_reference_device_name: preferences.aec_reference_device_name.clone(), capture_generation: None, mute_during_recording: preferences.mute_during_recording, // TextStream is explicitly a live ASR module; do not let an old diff --git a/openless-all/app/crates/openless-core/src/lib.rs b/openless-all/app/crates/openless-core/src/lib.rs index 57113de60..f0ef75f91 100644 --- a/openless-all/app/crates/openless-core/src/lib.rs +++ b/openless-all/app/crates/openless-core/src/lib.rs @@ -227,7 +227,7 @@ pub use api::{ QaVoiceCaptureSession, StartupSnapshot, VoiceTranscriptionSession, }; pub use audio::{ - encode_dictation_wav, pcm_f32_to_i16_le, AudioProcessingConfig, AudioProcessor, + encode_dictation_wav, pcm_f32_to_i16_le, AudioProcessingConfig, AudioProcessor, FarEndQueue, NormalizedPcmChunk, PcmNormalizer, DICTATION_SAMPLE_RATE, }; pub use auxiliary::{ diff --git a/openless-all/app/crates/openless-core/src/shared_types.rs b/openless-all/app/crates/openless-core/src/shared_types.rs index b62a40f0f..4aa888205 100644 --- a/openless-all/app/crates/openless-core/src/shared_types.rs +++ b/openless-all/app/crates/openless-core/src/shared_types.rs @@ -880,6 +880,10 @@ pub struct UserPreferences { /// 只选它、不选处理后时,主档案直接存原始 PCM(功能不受影响)。 #[serde(default)] pub debug_archive_raw: bool, + /// AEC 的扬声器参考源(回环/监听设备名)。空字符串 = 自动选默认扬声器的 + /// 监听源;`"off"` = 不采集参考(AEC 退化为无参考)。 + #[serde(default)] + pub aec_reference_device_name: String, /// How many recent wav files `recordings/` keeps (newest first by mtime). /// `None` = follow `HISTORY_CAP` (200); `Some(n)` clamps to 1..=200. /// Trimmed before each new session. Lets users tune combinations like @@ -1239,6 +1243,8 @@ struct UserPreferencesWire { #[serde(default)] debug_archive_raw: bool, #[serde(default)] + aec_reference_device_name: String, + #[serde(default)] audio_recording_max_entries: Option, #[serde(default)] quick_note_export_directory: String, @@ -1433,6 +1439,7 @@ impl Default for UserPreferencesWire { record_audio_for_debug: prefs.record_audio_for_debug, debug_archive_processed: prefs.debug_archive_processed, debug_archive_raw: prefs.debug_archive_raw, + aec_reference_device_name: prefs.aec_reference_device_name.clone(), audio_recording_max_entries: prefs.audio_recording_max_entries, quick_note_export_directory: prefs.quick_note_export_directory.clone(), marketplace_base_url: prefs.marketplace_base_url, @@ -1672,6 +1679,7 @@ impl<'de> Deserialize<'de> for UserPreferences { record_audio_for_debug: wire.record_audio_for_debug, debug_archive_processed: wire.debug_archive_processed, debug_archive_raw: wire.debug_archive_raw, + aec_reference_device_name: wire.aec_reference_device_name, audio_recording_max_entries: wire.audio_recording_max_entries, quick_note_export_directory: wire.quick_note_export_directory, marketplace_base_url: wire.marketplace_base_url, @@ -2054,6 +2062,7 @@ impl Default for UserPreferences { record_audio_for_debug: false, debug_archive_processed: false, debug_archive_raw: false, + aec_reference_device_name: String::new(), audio_recording_max_entries: None, quick_note_export_directory: String::new(), marketplace_base_url: String::new(), diff --git a/openless-all/app/linux-egui/package-revision b/openless-all/app/linux-egui/package-revision index 13c09a007..878d5a02b 100644 --- a/openless-all/app/linux-egui/package-revision +++ b/openless-all/app/linux-egui/package-revision @@ -1 +1 @@ -145 +146 diff --git a/openless-all/app/linux-egui/src/audio.rs b/openless-all/app/linux-egui/src/audio.rs index 44b8ad978..a5cff8a56 100644 --- a/openless-all/app/linux-egui/src/audio.rs +++ b/openless-all/app/linux-egui/src/audio.rs @@ -54,6 +54,8 @@ struct LinuxRecordingOptions { archive_processed: bool, /// 额外再写一份「处理前」的 `.raw.wav` 做 A/B 对照。 archive_raw: bool, + /// AEC 扬声器参考源:空 = 自动,`"off"` = 不采集。 + aec_reference_device_name: String, } #[cfg(target_os = "linux")] @@ -66,6 +68,8 @@ struct LinuxAudioThreadArgs { write_processed: bool, /// 「处理前」的额外档案(只在同时要两份时创建)。 raw_writer: Option>>, + /// AEC 的扬声器参考队列(None = 本次录音不接参考)。 + far_end: Option, audio_processing: openless_core::AudioProcessingConfig, stop: Arc, runtime_error: Arc>>, @@ -86,6 +90,7 @@ impl AudioProcessingWorker { writer: Option>>, write_processed: bool, raw_writer: Option>>, + far_end: Option, ) -> Result<(Self, SyncSender), BackendError> { let (sender, receiver) = sync_channel::(AUDIO_PROCESSING_QUEUE_CAPACITY); let started = std::time::Instant::now(); @@ -93,6 +98,9 @@ impl AudioProcessingWorker { .name("openless-linux-audio-processor".to_string()) .spawn(move || { let mut processor = openless_core::AudioProcessor::new(processing); + if let Some(queue) = far_end { + processor.attach_far_end(queue); + } // 处理前的原始 PCM:只在需要时量化,避免常态多一次分配。 let write_raw = |frame: &RawAudioFrame, sink: &Option>>| { @@ -201,6 +209,7 @@ impl AudioRecorder for LinuxCpalRecorder { context.output_target == openless_core::DictationOutputTarget::QuickNote; let archive_processed = context.recording.archive_processed; let archive_raw = context.recording.archive_raw; + let aec_reference_device_name = context.recording.aec_reference_device_name.clone(); Box::pin(async move { #[cfg(target_os = "linux")] { @@ -215,6 +224,7 @@ impl AudioRecorder for LinuxCpalRecorder { audio_processing, archive_processed, archive_raw, + aec_reference_device_name, }, consumer, progress, @@ -249,10 +259,16 @@ struct LinuxActiveRecording { /// Holds the output-mute guard while capture is live. Its `Drop` restores /// the sink on every terminal path (stop/cancel/error/drop/shutdown). mute: Option, + /// AEC 的扬声器参考采集;随录音一起停(字段顺序即 Drop 顺序,先停参考流再 + /// 拆主线程,避免参考线程还在往已释放的队列里写)。 + reference: Option, } impl Drop for LinuxActiveRecording { fn drop(&mut self) { + // 先显式停掉 AEC 参考流:它只在录音期间有意义,且必须在主采集线程之前 + // 结束,避免参考线程还在往已释放的队列写。 + drop(self.reference.take()); // Taking the guard here forces the field to be consumed (and therefore // restored) even if `stop()` is never reached — e.g. the handle is // dropped directly on an early error or during Core shutdown before it @@ -364,8 +380,32 @@ fn start_linux_recording( audio_processing, archive_processed, archive_raw, + aec_reference_device_name, } = options; + // AEC 打开且参考没被显式关掉时,单独起一条回环采集流喂参考队列。 + let far_end = if audio_processing.enhancement_enabled + && audio_processing.dsp.aec_enabled + && aec_reference_device_name != "off" + { + Some(openless_core::FarEndQueue::default()) + } else { + None + }; + let mut reference_capture = None; + if let Some(queue) = far_end.clone() { + let preferred = (!aec_reference_device_name.is_empty()) + .then(|| aec_reference_device_name.clone()); + match start_reference_capture(preferred, queue) { + Ok(capture) => reference_capture = Some(capture), + // 没有回环源(典型是纯 ALSA)不该让整次录音失败:AEC 退化为无参考。 + Err(error) => log::warn!( + "[aec] speaker reference unavailable; continuing without it: {}", + error.message + ), + } + } + // Mute is best-effort and independent of capture availability (mirrors the // Tauri reference): if it fails we log and continue recording. If capture // later fails on this path, the guard drops here and restores the sink. @@ -443,6 +483,7 @@ fn start_linux_recording( writer, write_processed: archive_processed, raw_writer, + far_end, audio_processing, stop: stop_for_thread, runtime_error: runtime_error_for_thread, @@ -463,6 +504,7 @@ fn start_linux_recording( runtime_error, archive, mute, + reference: reference_capture, })), Ok(Err(error)) => { let _ = thread.join(); @@ -489,6 +531,7 @@ fn run_audio_thread(args: LinuxAudioThreadArgs) { writer, write_processed, raw_writer, + far_end, audio_processing, stop, runtime_error, @@ -502,6 +545,7 @@ fn run_audio_thread(args: LinuxAudioThreadArgs) { writer, write_processed, raw_writer, + far_end, ) { Ok(worker) => worker, Err(error) => { @@ -579,6 +623,248 @@ fn run_audio_thread(args: LinuxAudioThreadArgs) { let _ = processor.finish(); } +/// 判断一个输入设备名是否是「扬声器回环 / 监听」源。 +/// +/// - PulseAudio / PipeWire(走 pulse 兼容层):`.monitor`,例如 +/// `alsa_output.pci-0000_00_1f.3.analog-stereo.monitor` +/// - 部分 PipeWire 版本直接暴露为 `...output...` 的 monitor 节点 +/// - ALSA 没有回环源(除非加载 `snd-aloop`,那会表现为 `Loopback, ...`) +fn is_loopback_device_name(name: &str) -> bool { + let lower = name.to_ascii_lowercase(); + lower.contains(".monitor") + || lower.contains("monitor of") + || lower.starts_with("loopback") + || lower.contains(", loopback") +} + +/// 列出所有可用的扬声器回环源,按后端顺序(pipewire → pulseaudio → alsa)去重。 +/// +/// 用户在下拉里选的是设备名原文;空字符串表示「自动」。返回空列表说明当前 +/// 环境没有回环源(典型是纯 ALSA),此时 AEC 只能在没有参考信号的情况下工作。 +pub fn list_loopback_devices() -> Vec { + use cpal::traits::HostTrait; + + let hosts = cpal::available_hosts(); + let mut names: Vec = Vec::new(); + for backend in audio_backend_order() { + let Some(host_id) = hosts + .iter() + .copied() + .find(|id| id.name().eq_ignore_ascii_case(backend)) + else { + continue; + }; + let Ok(host) = cpal::host_from_id(host_id) else { + continue; + }; + let Ok(devices) = host.input_devices() else { + continue; + }; + for device in devices { + let name = device.to_string(); + if is_loopback_device_name(&name) && !names.contains(&name) { + names.push(name); + } + } + } + names +} + +/// 一次录音期间的扬声器参考采集。Drop 时停流并回收线程。 +#[cfg(target_os = "linux")] +pub struct ReferenceCapture { + stop: Arc, + thread: Option>, +} + +#[cfg(target_os = "linux")] +impl Drop for ReferenceCapture { + fn drop(&mut self) { + self.stop + .store(true, std::sync::atomic::Ordering::Release); + if let Some(thread) = self.thread.take() { + let _ = thread.join(); + } + } +} + +/// 启动扬声器回环采集,把参考音频喂进 AEC 队列。 +/// +/// 与麦克风采集一样按 `pipewire → pulseaudio → alsa` 顺序尝试:只有前两者提供 +/// `.monitor`;ALSA 在没有 `snd-aloop` 时必然失败,此时返回错误,调用方 +/// 只记一条日志(AEC 退化为无参考,录音本身照常进行)。 +#[cfg(target_os = "linux")] +pub fn start_reference_capture( + preferred_device_name: Option, + queue: openless_core::FarEndQueue, +) -> Result { + use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; + + let stop = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let stop_for_thread = Arc::clone(&stop); + let (startup_tx, startup_rx) = std::sync::mpsc::sync_channel(1); + let thread = std::thread::Builder::new() + .name("openless-linux-aec-reference".to_string()) + .spawn(move || { + let hosts = cpal::available_hosts(); + let mut stream_slot: Option = None; + let mut last_error = None; + for backend in audio_backend_order() { + let Some(host_id) = hosts + .iter() + .copied() + .find(|id| id.name().eq_ignore_ascii_case(backend)) + else { + continue; + }; + let Ok(host) = cpal::host_from_id(host_id) else { + continue; + }; + let Ok(devices) = host.input_devices() else { + continue; + }; + let wanted = preferred_device_name.as_deref().filter(|name| !name.is_empty()); + let device = devices.into_iter().find(|device| { + let name = device.to_string(); + match wanted { + Some(wanted) => name == wanted, + None => is_loopback_device_name(&name), + } + }); + let Some(device) = device else { + last_error = Some(BackendError::new( + BackendErrorCode::Platform, + format!("{backend}: no speaker loopback source"), + )); + continue; + }; + let Ok(config) = device.default_input_config() else { + last_error = Some(BackendError::new( + BackendErrorCode::Platform, + format!("{backend}: loopback device has no input config"), + )); + continue; + }; + let channels = usize::from(config.channels()); + let rate = config.sample_rate(); + let queue_for_stream = queue.clone(); + let built = match config.sample_format() { + cpal::SampleFormat::F32 => device.build_input_stream( + config.config(), + move |data: &[f32], _| queue_for_stream.push(data, channels, rate), + |error| log::warn!("[aec] reference stream error: {error}"), + None, + ), + cpal::SampleFormat::I16 => { + let queue_for_stream = queue.clone(); + device.build_input_stream( + config.config(), + move |data: &[i16], _| { + let converted: Vec = + data.iter().map(|s| *s as f32 / i16::MAX as f32).collect(); + queue_for_stream.push(&converted, channels, rate) + }, + |error| log::warn!("[aec] reference stream error: {error}"), + None, + ) + } + other => { + last_error = Some(BackendError::new( + BackendErrorCode::Unsupported, + format!("{backend}: unsupported loopback sample format {other:?}"), + )); + continue; + } + }; + match built { + Ok(stream) => { + if let Err(error) = stream.play() { + last_error = Some(classify_audio_error( + "start loopback stream", + error.to_string(), + )); + continue; + } + log::info!( + "[aec] speaker reference capturing via {backend}: {} ({rate} Hz, {channels} ch)", + device + ); + stream_slot = Some(stream); + break; + } + Err(error) => { + last_error = Some(classify_audio_error( + "build loopback stream", + error.to_string(), + )); + } + } + } + match stream_slot { + Some(stream) => { + let _ = startup_tx.send(Ok(())); + while !stop_for_thread.load(std::sync::atomic::Ordering::Acquire) { + std::thread::park_timeout(std::time::Duration::from_millis(25)); + } + drop(stream); + } + None => { + let error = last_error.unwrap_or_else(|| { + BackendError::new( + BackendErrorCode::Platform, + "no speaker loopback source is available", + ) + }); + let _ = startup_tx.send(Err(error)); + } + } + }) + .map_err(|error| { + BackendError::new( + BackendErrorCode::Platform, + format!("spawn AEC reference thread: {error}"), + ) + })?; + + match startup_rx.recv() { + Ok(Ok(())) => Ok(ReferenceCapture { + stop, + thread: Some(thread), + }), + Ok(Err(error)) => { + let _ = thread.join(); + Err(error) + } + Err(error) => { + let _ = thread.join(); + Err(BackendError::new( + BackendErrorCode::Platform, + format!("AEC reference thread exited during startup: {error}"), + )) + } + } +} + +#[cfg(all(test, target_os = "linux"))] +mod loopback_tests { + use super::is_loopback_device_name; + + #[test] + fn recognises_pulse_and_pipewire_monitor_sources() { + // PulseAudio / PipeWire 的经典形态 + assert!(is_loopback_device_name( + "alsa_output.pci-0000_00_1f.3.analog-stereo.monitor" + )); + // 部分版本带 "Monitor of" 前缀 + assert!(is_loopback_device_name("Monitor of Built-in Audio Analog Stereo")); + // ALSA 的 snd-aloop 模块 + assert!(is_loopback_device_name("Loopback, PCM Capture")); + // 普通麦克风不能被误判成回环源,否则 AEC 会拿麦克风当参考 + assert!(!is_loopback_device_name("alsa_input.pci-0000_00_1f.3.analog-stereo")); + assert!(!is_loopback_device_name("Yeti Stereo Microphone")); + } +} + #[cfg(target_os = "linux")] fn audio_backend_order() -> [&'static str; 3] { // Native desktop servers are preferred because they handle device policy, diff --git a/openless-all/app/linux-egui/src/i18n.rs b/openless-all/app/linux-egui/src/i18n.rs index e95501cf8..3de5f81e8 100644 --- a/openless-all/app/linux-egui/src/i18n.rs +++ b/openless-all/app/linux-egui/src/i18n.rs @@ -160,6 +160,28 @@ pub const CATALOG: &[Msg] = &[ key: "settings.audioDsp.echoStage", text: row("回声消除与缓冲", "回聲消除與緩衝", "Echo cancellation and buffering", "エコーキャンセルとバッファ", "에코 제거 및 버퍼"), }, + Msg { + key: "settings.audioDsp.aecReferenceLabel", + text: row("扬声器参考源", "揚聲器參考源", "Speaker reference", "スピーカー参照", "스피커 참조"), + }, + Msg { + key: "settings.audioDsp.aecReferenceDesc", + text: row( + "采集哪台扬声器的回环信号供回声消除使用;没有回环源时 AEC 只能在没有参考的情况下工作。", + "擷取哪台揚聲器的回環訊號供回聲消除使用;沒有回環源時 AEC 只能在沒有參考的情況下運作。", + "Which speaker loopback feeds echo cancellation; without a loopback source AEC runs without a reference.", + "どのスピーカーのループバックをエコーキャンセルに使うか。ループバック元がない場合、AEC は参照なしで動作します。", + "어떤 스피커의 루프백을 에코 제거에 사용할지 선택합니다. 루프백 소스가 없으면 AEC는 참조 없이 동작합니다.", + ), + }, + Msg { + key: "settings.audioDsp.aecReferenceAuto", + text: row("自动(默认扬声器)", "自動(預設揚聲器)", "Automatic (default speaker)", "自動(既定のスピーカー)", "자동(기본 스피커)"), + }, + Msg { + key: "settings.audioDsp.aecReferenceOff", + text: row("不采集参考", "不擷取參考", "Do not capture a reference", "参照を取得しない", "참조를 수집하지 않음"), + }, Msg { key: "settings.audioDsp.acousticEchoCancellation", text: row("声学回声消除(AEC)", "聲學回聲消除(AEC)", "Acoustic echo cancellation (AEC)", "音響エコーキャンセル(AEC)", "음향 에코 제거(AEC)"), diff --git a/openless-all/app/linux-egui/src/lib.rs b/openless-all/app/linux-egui/src/lib.rs index 765508486..69e330f53 100644 --- a/openless-all/app/linux-egui/src/lib.rs +++ b/openless-all/app/linux-egui/src/lib.rs @@ -50,6 +50,8 @@ pub mod wgpu_device; pub mod siri_wgpu; pub use audio::LinuxCpalRecorder; +#[cfg(target_os = "linux")] +pub use audio::{list_loopback_devices, start_reference_capture, ReferenceCapture}; pub use audio_cue::{play_cue_start, play_cue_stop, CueTone}; pub use audio_mute::AudioMuteGuard; pub use audio_player::ClipPlayer; diff --git a/openless-all/app/linux-egui/src/linux_app/settings_save.rs b/openless-all/app/linux-egui/src/linux_app/settings_save.rs index 35d66143d..3c1002117 100644 --- a/openless-all/app/linux-egui/src/linux_app/settings_save.rs +++ b/openless-all/app/linux-egui/src/linux_app/settings_save.rs @@ -267,6 +267,28 @@ impl OpenLessEguiApp { field: frontend::view_model::SettingsComboField, index: usize, ) { + // AEC 参考源是 Core 偏好里的字符串(空 = 自动,"off" = 不采集), + // 界面用下标表达,这里换算回去。 + if matches!(field, frontend::view_model::SettingsComboField::AecReference) { + let value = match index { + 0 => String::new(), + 1 => "off".to_string(), + other => self + .frontend_vm + .settings + .aec_reference_devices + .get(other - 2) + .cloned() + .unwrap_or_default(), + }; + self.frontend_vm.settings.aec_reference_index = index; + if let Some(preferences) = self.preferences.as_mut() { + preferences.aec_reference_device_name = value; + self.save_settings_if_dirty(); + } + return; + } + // 日志级别是宿主本地设置(`/logs/level`),不经过 Core 偏好, // 因此要放在下面 `self.preferences` 的守卫之前 —— 否则偏好还没加载时 // 连日志级别都改不了。 @@ -301,6 +323,8 @@ impl OpenLessEguiApp { match field { // 已在函数开头处理(不走 Core 偏好)。 frontend::view_model::SettingsComboField::LogLevel => {} + // 已在函数开头处理(换算成偏好里的设备名字符串)。 + frontend::view_model::SettingsComboField::AecReference => {} frontend::view_model::SettingsComboField::Theme => { preferences.theme_mode = match index { 0 => openless_core::shared_types::ThemeMode::System, diff --git a/openless-all/app/linux-egui/src/main.rs b/openless-all/app/linux-egui/src/main.rs index cea36a446..223d16ce9 100644 --- a/openless-all/app/linux-egui/src/main.rs +++ b/openless-all/app/linux-egui/src/main.rs @@ -589,6 +589,8 @@ mod linux_app { hydrate_text_fields: bool, overview: OverviewState, microphones: Vec, + /// AEC 可选的扬声器回环源(缓存一次 cpal 枚举结果)。 + aec_reference_devices: Vec, /// 麦克风枚举失败的原因(设置页据此显示 `microphoneLoadError`)。 microphone_error: Option, transcript: String, @@ -853,6 +855,7 @@ mod linux_app { hydrate_text_fields: true, overview: OverviewState::Loading, microphones: Vec::new(), + aec_reference_devices: Vec::new(), microphone_error: None, transcript: String::new(), transcript_state: TranscriptAccumulator::default(), @@ -974,6 +977,7 @@ mod linux_app { service_configured: [false; 2], overview: OverviewState::Loading, microphones: Vec::new(), + aec_reference_devices: Vec::new(), microphone_error: None, transcript: String::new(), transcript_state: TranscriptAccumulator::default(), @@ -5690,6 +5694,27 @@ mod linux_app { // 文本型设置行只在偏好载入 / 外部变更时回灌一次,避免把输入中的 // 内容每帧弹回旧值。 let hydrate_text = std::mem::replace(&mut self.hydrate_text_fields, false); + // AEC 参考源:设备列表要枚举一次 cpal 主机,缓存到应用里;下标由 + // 偏好里的设备名换算(0 = 自动,1 = 不采集)。 + if self.aec_reference_devices.is_empty() && hydrate_text { + self.aec_reference_devices = openless_linux_egui::list_loopback_devices(); + } + let aec_reference_devices = self.aec_reference_devices.clone(); + let aec_reference_index = match self + .preferences + .as_ref() + .map(|prefs| prefs.aec_reference_device_name.as_str()) + .unwrap_or_default() + { + "" => 0, + "off" => 1, + current => self + .aec_reference_devices + .iter() + .position(|name| name == current) + .map_or(0, |index| index + 2), + }; + // 日志级别是宿主本地文件(不在 Core 偏好里),要在借用 vm 之前读。 let log_level_index = self .backend() @@ -5883,6 +5908,8 @@ mod linux_app { s.debug_archive_raw = prefs.debug_archive_raw; // 日志级别是宿主本地文件,不在 Core 偏好里;读不到就按 info 显示。 s.log_level = log_level_index; + s.aec_reference_devices = aec_reference_devices.clone(); + s.aec_reference_index = aec_reference_index; if hydrate_text { s.history_max_entries = prefs .history_max_entries diff --git a/openless-all/app/linux-egui/src/ui/frontend/settings.rs b/openless-all/app/linux-egui/src/ui/frontend/settings.rs index c45a0d112..206572199 100644 --- a/openless-all/app/linux-egui/src/ui/frontend/settings.rs +++ b/openless-all/app/linux-egui/src/ui/frontend/settings.rs @@ -639,6 +639,28 @@ fn general(ui: &mut egui::Ui, vm: &mut FrontendViewModel, actions: &mut Vec = vec![ + tr_l10n(lang, "settings.audioDsp.aecReferenceAuto"), + tr_l10n(lang, "settings.audioDsp.aecReferenceOff"), + ]; + options.extend(vm.settings.aec_reference_devices.iter().map(String::as_str)); + combo_index_row( + ui, + tr_l10n(lang, "settings.audioDsp.aecReferenceLabel"), + tr_l10n(lang, "settings.audioDsp.aecReferenceDesc"), + vm.settings.aec_reference_index.min(options.len().saturating_sub(1)), + &options, + |index| { + actions.push(FrontendAction::SettingsCombo( + SettingsComboField::AecReference, + index, + )); + }, + ); + } // #4:录音胶囊开关与样式(Tauri `capsuleLabel` / `capsuleStyleLabel`)。 toggle_row( ui, diff --git a/openless-all/app/linux-egui/src/ui/frontend/view_model.rs b/openless-all/app/linux-egui/src/ui/frontend/view_model.rs index 81f9408b0..169e2b5b1 100644 --- a/openless-all/app/linux-egui/src/ui/frontend/view_model.rs +++ b/openless-all/app/linux-egui/src/ui/frontend/view_model.rs @@ -588,6 +588,8 @@ pub enum SettingsComboField { SelectionVoicePlanFormat, /// Less Computer 权限模式(0 = 放行, 1 = 只读/计划, 2 = 默认, 3 = 完全放行)。 CodingAgentPermission, + /// AEC 的扬声器参考源(回环/监听设备)。 + AecReference, /// 日志级别:0 = info,1 = debug,2 = trace。写 `/logs/level`, /// 重启后生效;不是 Core 偏好,所以不上云、不影响 Tauri。 LogLevel, @@ -1212,6 +1214,10 @@ pub struct SettingsFields { pub debug_archive_processed: bool, /// 调试录音:额外保留「处理前」的 `.raw.wav`,用于 A/B 对照。 pub debug_archive_raw: bool, + /// AEC 的扬声器参考源选项:0 = 自动,1 = 不采集,其余为回环设备名。 + pub aec_reference_devices: Vec, + /// 当前选择的下标(见 `aec_reference_devices`)。 + pub aec_reference_index: usize, /// 日志级别:0 = info,1 = debug,2 = trace。宿主本地设置,不进 Core 偏好。 pub log_level: usize, pub history_max_entries: String, @@ -1283,6 +1289,8 @@ impl Default for SettingsFields { record_audio_for_debug: false, debug_archive_processed: false, debug_archive_raw: false, + aec_reference_devices: Vec::new(), + aec_reference_index: 0, log_level: 0, history_max_entries: String::new(), style_pack_hotkeys: Vec::new(), From 22a8de1ea46fc160abf504bfba993d86834abe74 Mon Sep 17 00:00:00 2001 From: aeoform <2790848120@qq.com> Date: Sun, 25 Oct 2026 23:01:26 +0800 Subject: [PATCH 5/9] =?UTF-8?q?fix(aec):=20=E5=A2=9E=E5=BC=BA=E5=85=B3?= =?UTF-8?q?=E9=97=AD=E6=97=B6=20AEC=20=E4=B9=9F=E8=83=BD=E7=8B=AC=E7=AB=8B?= =?UTF-8?q?=E5=B7=A5=E4=BD=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 之前 `process()` 在 `enhancement_enabled == false` 时直接走归一化,AEC 永远不执行 —— 但「外放听写、只想去回声、不想动音色」正是最需要 AEC 的场景。 现在只要 AEC 打开且本次录音接了参考队列,就仍然走 MicYou 链路(其余阶段由各自 开关控制,不会顺带改音色)。 顺带记录实测:单核上 aec7 模型约 2.0x 实时(≈50% 单核),NLMS 类方案约 31x (≈3%),差 16 倍 —— 弱机上要用轻量参考抵消,别默认开模型。 --- openless-all/app/crates/openless-core/src/audio.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/openless-all/app/crates/openless-core/src/audio.rs b/openless-all/app/crates/openless-core/src/audio.rs index 524c64cb0..f89a135cd 100644 --- a/openless-all/app/crates/openless-core/src/audio.rs +++ b/openless-all/app/crates/openless-core/src/audio.rs @@ -137,7 +137,10 @@ impl AudioProcessor { if self.finished || input.is_empty() || channels == 0 || rate == 0 { return None; } - if !self.config.enhancement_enabled { + // 增强关掉时 AEC 仍然可以独立工作(外放场景只想去回声、不想动音色): + // 只要 AEC 打开且本次录音接了参考队列,就走 MicYou 链路。 + let aec_standalone = self.config.dsp.aec_enabled && self.far_end.is_some(); + if !self.config.enhancement_enabled && !aec_standalone { return self.normalizer.process(input, channels, rate); } let stream = self From 408f8f8ea5fff25fca96c2f95eb3272b715e8f5d Mon Sep 17 00:00:00 2001 From: aeoform <2790848120@qq.com> Date: Sun, 25 Oct 2026 23:39:10 +0800 Subject: [PATCH 6/9] =?UTF-8?q?feat(aec):=20=E8=BD=BB=E9=87=8F=20NLMS=20?= =?UTF-8?q?=E4=B8=8E=20aec7=20=E6=A8=A1=E5=9E=8B=E4=B8=A4=E7=A7=8D?= =?UTF-8?q?=E5=9B=9E=E5=A3=B0=E6=B6=88=E9=99=A4=E5=B9=B6=E5=AD=98=EF=BC=8C?= =?UTF-8?q?=E7=94=A8=E6=88=B7=E5=8F=AF=E9=80=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 「能不能两者都留、让用户选」——可以,而且实测下来两者正好互补。 - 新增 `AecMode { Off, Light, Model }` 取代原来的布尔 `aec_enabled`; 老 preferences 里的 `aecEnabled: true/false` 由自定义反序列化映射成 `Light`/`Off`,不会丢配置。 - `Light`:把 #1228 的有界归一化 LMS(1024 抽头、双讲时暂停自适应)移植进来, 在 Core 里对麦克风原始样本做抵消,再进降噪/增益链。 - `Model`:仍走 MicYou 的 aec7 神经网络(经 `FarEndQueue` → `set_far_end_audio`)。 - 两种模式共用同一套参考采集(linux-egui 的 `.monitor` 与扬声器选择)。 关键修正:早先轻量路径把参考重采样到 16 kHz 却拿 48 kHz 的麦克风样本做抵消, 采样率不一致导致 ERLE ≈ 0(等于没做)。现在参考按麦克风采样率重采样。 实测(单核,120 s 音频,单讲段,回声延迟 15 ms、衰减 0.45): | 模式 | ERLE | 实时倍率 | 单核占用 | |---|---:|---:|---:| | 关闭 | 0 dB | 5719x | ~0% | | 轻量 NLMS | **22.9 dB** | 6.2x | **~16%** | | aec7 模型 | **26.9 dB** | 1.9x | **~53%** | 模型只多 4 dB,却贵 3.3 倍 —— 所以默认关、弱机推轻量、强机可选模型。 UI:egui 与 Tauri 的语音增强里都改成三选一(关闭 / 轻量(省 CPU)/ 模型(更强)), 选模型时提示 CPU 代价;八语 + 五语文案补齐。 --- .../app/crates/openless-core/src/audio.rs | 147 +++++++++++++++++- .../openless-core/src/audio/dsp_tests.rs | 6 +- .../app/crates/openless-core/src/audio_dsp.rs | 42 ++++- .../app/crates/openless-core/src/lib.rs | 2 +- openless-all/app/linux-egui/package-revision | 2 +- openless-all/app/linux-egui/src/audio.rs | 2 +- openless-all/app/linux-egui/src/i18n.rs | 22 +++ .../linux-egui/src/ui/frontend/audio_dsp.rs | 19 ++- .../linux-egui/src/ui/frontend/settings.rs | 2 +- openless-all/app/src/i18n/de.ts | 5 + openless-all/app/src/i18n/en.ts | 5 + openless-all/app/src/i18n/es.ts | 5 + openless-all/app/src/i18n/fr.ts | 5 + openless-all/app/src/i18n/ja.ts | 5 + openless-all/app/src/i18n/ko.ts | 5 + openless-all/app/src/i18n/zh-CN.ts | 5 + openless-all/app/src/i18n/zh-TW.ts | 5 + openless-all/app/src/lib/audioDsp.ts | 2 +- openless-all/app/src/lib/types.ts | 4 +- .../src/pages/settings/AudioDspSettings.tsx | 16 +- 20 files changed, 285 insertions(+), 21 deletions(-) diff --git a/openless-all/app/crates/openless-core/src/audio.rs b/openless-all/app/crates/openless-core/src/audio.rs index f89a135cd..585b9b907 100644 --- a/openless-all/app/crates/openless-core/src/audio.rs +++ b/openless-all/app/crates/openless-core/src/audio.rs @@ -73,6 +73,84 @@ impl FarEndQueue { } } +const ECHO_FILTER_TAPS: usize = 1_024; +const ECHO_ADAPTATION_RATE: f32 = 0.12; +const ECHO_DOUBLE_TALK_RATIO: f32 = 2.5; +const ECHO_MIN_REFERENCE_POWER: f32 = 0.000_001; + +/// 轻量回声消除:有界归一化 LMS(沿用 #1228 的实现)。 +/// +/// 学的是「扬声器 → 麦克风」这条声学路径;近端能量明显强于估计回声(双讲)时 +/// 暂停自适应,避免把说话人自己的声音学掉。单核开销约 3%,弱机可用。 +#[derive(Debug, Clone)] +struct LightEchoCanceller { + coefficients: Vec, + history: Vec, + history_cursor: usize, +} + +impl Default for LightEchoCanceller { + fn default() -> Self { + Self { + coefficients: vec![0.0; ECHO_FILTER_TAPS], + history: vec![0.0; ECHO_FILTER_TAPS], + history_cursor: 0, + } + } +} + +impl LightEchoCanceller { + fn reset(&mut self) { + self.coefficients.fill(0.0); + self.history.fill(0.0); + self.history_cursor = 0; + } + + fn process(&mut self, microphone: &[f32], reference: &[f32]) -> Vec { + if microphone.is_empty() || reference.is_empty() { + return microphone.to_vec(); + } + let mut output = Vec::with_capacity(microphone.len()); + for (index, &sample) in microphone.iter().enumerate() { + // 块边界处重采样可能少一个样本:保留麦克风样本、等下一块重建对齐, + // 不要把上一个参考值铺满,那样会教坏滤波器。 + let Some(&reference_sample) = reference.get(index) else { + output.push(sample); + continue; + }; + self.history[self.history_cursor] = reference_sample; + let mut estimate = 0.0; + let mut reference_power = 0.0; + for tap in 0..ECHO_FILTER_TAPS { + let history_index = + (self.history_cursor + ECHO_FILTER_TAPS - tap) % ECHO_FILTER_TAPS; + let value = self.history[history_index]; + estimate += self.coefficients[tap] * value; + reference_power += value * value; + } + let error = sample - estimate; + let near_power = sample * sample; + let should_adapt = reference_power > ECHO_MIN_REFERENCE_POWER + && (estimate.abs() < 0.01 + || estimate * estimate * ECHO_DOUBLE_TALK_RATIO >= near_power + || near_power < ECHO_MIN_REFERENCE_POWER); + if should_adapt { + let normalization = reference_power + 0.000_01; + for tap in 0..ECHO_FILTER_TAPS { + let history_index = + (self.history_cursor + ECHO_FILTER_TAPS - tap) % ECHO_FILTER_TAPS; + self.coefficients[tap] += + ECHO_ADAPTATION_RATE * error * self.history[history_index] / normalization; + self.coefficients[tap] = self.coefficients[tap].clamp(-2.0, 2.0); + } + } + output.push(if error.is_finite() { error } else { 0.0 }); + self.history_cursor = (self.history_cursor + 1) % ECHO_FILTER_TAPS; + } + output + } +} + /// Shared processor for native and external microphone hosts. /// Enhancement starts with the session's settings and may be disabled under /// load. Hosts must deliver every returned tail before feeding more input or @@ -83,6 +161,11 @@ pub struct AudioProcessor { enhanced: Option, /// AEC 的扬声器参考输入(宿主在独立的回环采集流里喂)。 far_end: Option, + /// 轻量 NLMS 抵消器(`AecMode::Light`)。 + light_aec: Option, + /// 轻量路径把参考重采样到输出采样率(16 kHz 单声道)。 + light_reference_resampler: Option, + light_reference_rate: Option, finished: bool, } @@ -101,10 +184,54 @@ impl AudioProcessor { normalizer: Default::default(), enhanced: None, far_end: None, + light_aec: matches!(config.dsp.aec_mode, crate::audio_dsp::AecMode::Light) + .then(LightEchoCanceller::default), + light_reference_resampler: None, + light_reference_rate: None, finished: false, } } + /// 把参考队列里的帧转成「与麦克风同一采样率」的单声道,供轻量抵消器使用。 + /// + /// NLMS 的抽头是按样本对齐的,采样率不一致就完全对不上 —— 早先版本把参考重 + /// 采样到 16 kHz 却拿 48 kHz 的麦克风样本做抵消,实测 ERLE ≈ 0(等于没做)。 + fn light_reference_at( + &mut self, + queue: &FarEndQueue, + rate: u32, + ) -> Option> { + if queue.is_empty() { + return None; + } + let frames = queue.drain(); + if frames.is_empty() { + return None; + } + if self.light_reference_rate != Some(rate) { + self.light_reference_rate = Some(rate); + self.light_reference_resampler = + Some(crate::audio_dsp::SpeechResampler::new(rate, rate)); + } + let mut reference = Vec::new(); + for frame in frames { + let mono = downmix_to_mono(&frame.samples, frame.channels); + if let Some(resampler) = self.light_reference_resampler.as_mut() { + reference.extend(resampler.push(&mono, false)); + } + } + (!reference.is_empty()).then_some(reference) + } + + /// 会话结束时清掉抵消器状态,避免上一场的声学路径带到下一场。 + pub fn reset_echo_cancellers(&mut self) { + if let Some(canceller) = self.light_aec.as_mut() { + canceller.reset(); + } + self.light_reference_resampler = None; + self.light_reference_rate = None; + } + /// 接上扬声器回环参考队列。只有 AEC 打开且宿主真的采到了输出音频时才有意义; /// 没接或队列为空时 AEC 按「无参考」处理(MicYou 内部会退化)。 pub fn attach_far_end(&mut self, queue: FarEndQueue) { @@ -137,9 +264,23 @@ impl AudioProcessor { if self.finished || input.is_empty() || channels == 0 || rate == 0 { return None; } - // 增强关掉时 AEC 仍然可以独立工作(外放场景只想去回声、不想动音色): - // 只要 AEC 打开且本次录音接了参考队列,就走 MicYou 链路。 - let aec_standalone = self.config.dsp.aec_enabled && self.far_end.is_some(); + // 轻量 AEC 在主链之前做:抵消后的信号再进降噪/增益,顺序与 MicYou 的 + // 链路一致(AEC 必须拿到尚未被其它处理改过的信号)。 + let mut cancelled: Option> = None; + if self.light_aec.is_some() { + let reference = self + .far_end + .clone() + .and_then(|queue| self.light_reference_at(&queue, rate)); + if let (Some(reference), Some(canceller)) = (reference, self.light_aec.as_mut()) { + cancelled = Some(canceller.process(input, &reference)); + } + } + let input = cancelled.as_deref().unwrap_or(input); + + // 模型 AEC(`AecMode::Model`)走 MicYou 链路;增强关掉时也要能单独跑。 + let aec_standalone = matches!(self.config.dsp.aec_mode, crate::audio_dsp::AecMode::Model) + && self.far_end.is_some(); if !self.config.enhancement_enabled && !aec_standalone { return self.normalizer.process(input, channels, rate); } diff --git a/openless-all/app/crates/openless-core/src/audio/dsp_tests.rs b/openless-all/app/crates/openless-core/src/audio/dsp_tests.rs index 6de070c5d..aec39fbd0 100644 --- a/openless-all/app/crates/openless-core/src/audio/dsp_tests.rs +++ b/openless-all/app/crates/openless-core/src/audio/dsp_tests.rs @@ -218,7 +218,7 @@ fn legacy_six_stage_processing_order_still_deserializes() { "processingOrder": ["noiseReduction","dereverb","equalizer","amplifier","agc","vad"] }"#; let config: crate::AudioDspConfig = serde_json::from_str(legacy).unwrap(); - assert!(!config.aec_enabled, "AEC 默认关"); + assert_eq!(config.aec_mode, crate::AecMode::Off, "AEC 默认关"); assert_eq!(config.output_buffer_ms, 300); assert_eq!(config.gain_db, 4, "老配置里的值必须保留"); assert_eq!( @@ -240,12 +240,12 @@ fn legacy_six_stage_processing_order_still_deserializes() { #[test] fn aec_and_buffer_reach_the_dsp_settings() { let config = crate::AudioDspConfig { - aec_enabled: true, + aec_mode: crate::AecMode::Model, output_buffer_ms: 900, ..Default::default() }; let settings = config.settings(); - assert!(settings.aec_enabled); + assert!(settings.aec_enabled, "只有 Model 模式才走 MicYou 的 aec7"); assert_eq!(settings.output_buffer_ms, 900); // 越界值必须夹回 MicYou 接受的范围。 let clamped = crate::AudioDspConfig { diff --git a/openless-all/app/crates/openless-core/src/audio_dsp.rs b/openless-all/app/crates/openless-core/src/audio_dsp.rs index 5560b7b72..831ad494d 100644 --- a/openless-all/app/crates/openless-core/src/audio_dsp.rs +++ b/openless-all/app/crates/openless-core/src/audio_dsp.rs @@ -11,6 +11,20 @@ pub enum NoiseReduction { Spectral, } +/// 回声消除算法。 +/// +/// - `Off`:不做回声消除。 +/// - `Light`:Core 自带的归一化 LMS(单核约 3%),对稳态回声有效,双讲时暂停自适应。 +/// - `Model`:MicYou 的 aec7 神经网络(单核约 50%),非线性/强混响下更强,弱机慎用。 +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum AecMode { + #[default] + Off, + Light, + Model, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum AudioDspStage { @@ -43,8 +57,10 @@ impl AudioDspStage { #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] pub struct AudioDspConfig { - /// 声学回声消除。默认关:外放说话时才会需要,且要能拿到扬声器参考信号。 - pub aec_enabled: bool, + /// 声学回声消除算法。老版本 preferences 里存的是布尔 `aecEnabled`, + /// 反序列化时 `true` 映射为 `Light`(轻量、弱机可用),`false` 映射为 `Off`。 + #[serde(default, deserialize_with = "deserialize_aec_mode")] + pub aec_mode: AecMode, pub noise_reduction: NoiseReduction, pub noise_strength: u8, pub gain_db: i8, @@ -103,11 +119,29 @@ where Ok(order) } +/// 接受三种写法:新枚举名(`off`/`light`/`model`)、老布尔值(`true`→`light`)。 +fn deserialize_aec_mode<'de, D>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, +{ + #[derive(Deserialize)] + #[serde(untagged)] + enum Wire { + Mode(AecMode), + Enabled(bool), + } + Ok(match Wire::deserialize(deserializer)? { + Wire::Mode(mode) => mode, + Wire::Enabled(true) => AecMode::Light, + Wire::Enabled(false) => AecMode::Off, + }) +} + impl Default for AudioDspConfig { fn default() -> Self { use AudioDspStage::*; Self { - aec_enabled: false, + aec_mode: AecMode::Off, noise_reduction: crate::audio_dsp::NoiseReduction::Rnnoise, noise_strength: 80, gain_db: 0, @@ -176,7 +210,7 @@ impl AudioDspConfig { pub(crate) fn settings(self) -> micyou_dsp::AudioDspSettings { let c = self.normalized(); micyou_dsp::AudioDspSettings { - aec_enabled: c.aec_enabled, + aec_enabled: matches!(c.aec_mode, AecMode::Model), output_buffer_ms: u32::from(c.output_buffer_ms), gain: c.gain_db as f32, ns_enabled: c.noise_reduction != NoiseReduction::Off && c.noise_strength > 0, diff --git a/openless-all/app/crates/openless-core/src/lib.rs b/openless-all/app/crates/openless-core/src/lib.rs index f0ef75f91..c54880299 100644 --- a/openless-all/app/crates/openless-core/src/lib.rs +++ b/openless-all/app/crates/openless-core/src/lib.rs @@ -10,7 +10,7 @@ pub mod api; pub mod asr; pub mod audio; pub mod audio_dsp; -pub use audio_dsp::{AudioDspConfig, AudioDspStage, NoiseReduction}; +pub use audio_dsp::{AecMode, AudioDspConfig, AudioDspStage, NoiseReduction}; pub mod auxiliary; pub mod cli; mod cloud_providers; diff --git a/openless-all/app/linux-egui/package-revision b/openless-all/app/linux-egui/package-revision index 878d5a02b..c748b568f 100644 --- a/openless-all/app/linux-egui/package-revision +++ b/openless-all/app/linux-egui/package-revision @@ -1 +1 @@ -146 +147 diff --git a/openless-all/app/linux-egui/src/audio.rs b/openless-all/app/linux-egui/src/audio.rs index a5cff8a56..e02325fbf 100644 --- a/openless-all/app/linux-egui/src/audio.rs +++ b/openless-all/app/linux-egui/src/audio.rs @@ -385,7 +385,7 @@ fn start_linux_recording( // AEC 打开且参考没被显式关掉时,单独起一条回环采集流喂参考队列。 let far_end = if audio_processing.enhancement_enabled - && audio_processing.dsp.aec_enabled + && audio_processing.dsp.aec_mode != openless_core::AecMode::Off && aec_reference_device_name != "off" { Some(openless_core::FarEndQueue::default()) diff --git a/openless-all/app/linux-egui/src/i18n.rs b/openless-all/app/linux-egui/src/i18n.rs index 3de5f81e8..26d4ade3c 100644 --- a/openless-all/app/linux-egui/src/i18n.rs +++ b/openless-all/app/linux-egui/src/i18n.rs @@ -160,6 +160,28 @@ pub const CATALOG: &[Msg] = &[ key: "settings.audioDsp.echoStage", text: row("回声消除与缓冲", "回聲消除與緩衝", "Echo cancellation and buffering", "エコーキャンセルとバッファ", "에코 제거 및 버퍼"), }, + Msg { + key: "settings.audioDsp.aecOff", + text: row("关闭", "關閉", "Off", "オフ", "끄기"), + }, + Msg { + key: "settings.audioDsp.aecLight", + text: row("轻量(省 CPU)", "輕量(省 CPU)", "Light (low CPU)", "軽量(低 CPU)", "경량(저 CPU)"), + }, + Msg { + key: "settings.audioDsp.aecModel", + text: row("模型(更强)", "模型(更強)", "Model (stronger)", "モデル(高精度)", "모델(강화)"), + }, + Msg { + key: "settings.audioDsp.aecModelCost", + text: row( + "模型版抑制更强,但约是轻量版的 3 倍 CPU;老机器建议用轻量。", + "模型版抑制更強,但約是輕量版的 3 倍 CPU;舊機器建議用輕量。", + "The model cancels more echo but costs about 3× the CPU; prefer Light on older machines.", + "モデル版はより強く抑圧しますが CPU は約 3 倍です。古い端末では軽量版を推奨します。", + "모델 버전은 더 강하게 제거하지만 CPU를 약 3배 사용합니다. 오래된 기기에서는 경량을 권장합니다.", + ), + }, Msg { key: "settings.audioDsp.aecReferenceLabel", text: row("扬声器参考源", "揚聲器參考源", "Speaker reference", "スピーカー参照", "스피커 참조"), diff --git a/openless-all/app/linux-egui/src/ui/frontend/audio_dsp.rs b/openless-all/app/linux-egui/src/ui/frontend/audio_dsp.rs index c7619fde3..32408660e 100644 --- a/openless-all/app/linux-egui/src/ui/frontend/audio_dsp.rs +++ b/openless-all/app/linux-egui/src/ui/frontend/audio_dsp.rs @@ -1,5 +1,5 @@ use eframe::egui; -use openless_core::{AudioDspConfig, AudioDspStage, NoiseReduction}; +use openless_core::{AecMode, AudioDspConfig, AudioDspStage, NoiseReduction}; use openless_linux_egui::{tr_l10n, Lang}; pub fn settings(ui: &mut egui::Ui, config: &mut AudioDspConfig, lang: Lang, enabled: bool) -> bool { @@ -14,8 +14,21 @@ pub fn settings(ui: &mut egui::Ui, config: &mut AudioDspConfig, lang: Lang, enab // 回声消除与输出缓冲:MicYou 的链路里 AEC 在最前,缓冲区是整条链的全局 // 参数(设备回调抖动大时调大,代价是延迟)。 ui.strong(label("settings.audioDsp.echoStage")); - ui.checkbox(&mut config.aec_enabled, label("settings.audioDsp.acousticEchoCancellation")); + // 三选一:轻量 NLMS 省 CPU(弱机可用),aec7 模型抑制更强但约 3 倍开销。 + let aec_labels = [ + (AecMode::Off, label("settings.audioDsp.aecOff")), + (AecMode::Light, label("settings.audioDsp.aecLight")), + (AecMode::Model, label("settings.audioDsp.aecModel")), + ]; + ui.horizontal_wrapped(|ui| { + for (mode, name) in aec_labels { + ui.selectable_value(&mut config.aec_mode, mode, name); + } + }); ui.small(label("settings.audioDsp.AEC_needs_a_loudspeaker_reference")); + if config.aec_mode == AecMode::Model { + ui.small(label("settings.audioDsp.aecModelCost")); + } slider(ui, label("settings.audioDsp.Output_buffer_ms"), &mut config.output_buffer_ms, 100..=1200, " ms"); ui.small(label("settings.audioDsp.A_larger_buffer_survives_jitter_but_adds_latency")); ui.separator(); @@ -89,7 +102,7 @@ pub fn settings(ui: &mut egui::Ui, config: &mut AudioDspConfig, lang: Lang, enab ui.push_id(i, |ui| ui.horizontal(|ui| { ui.monospace(format!("{}", i + 1)); let active = match stage { - AudioDspStage::AcousticEchoCancellation => config.aec_enabled, + AudioDspStage::AcousticEchoCancellation => config.aec_mode != AecMode::Off, AudioDspStage::NoiseReduction => config.noise_reduction != NoiseReduction::Off && config.noise_strength > 0, AudioDspStage::Dereverb => config.dereverb_level > 0, AudioDspStage::Equalizer => config.equalizer_enabled, diff --git a/openless-all/app/linux-egui/src/ui/frontend/settings.rs b/openless-all/app/linux-egui/src/ui/frontend/settings.rs index 206572199..b11dda971 100644 --- a/openless-all/app/linux-egui/src/ui/frontend/settings.rs +++ b/openless-all/app/linux-egui/src/ui/frontend/settings.rs @@ -641,7 +641,7 @@ fn general(ui: &mut egui::Ui, vm: &mut FrontendViewModel, actions: &mut Vec = vec![ tr_l10n(lang, "settings.audioDsp.aecReferenceAuto"), tr_l10n(lang, "settings.audioDsp.aecReferenceOff"), diff --git a/openless-all/app/src/i18n/de.ts b/openless-all/app/src/i18n/de.ts index 84e3b1f0d..f1d3b9d70 100644 --- a/openless-all/app/src/i18n/de.ts +++ b/openless-all/app/src/i18n/de.ts @@ -1575,6 +1575,11 @@ export const de: typeof zhCN = { lowLatencyDictationDesc: 'Hält einen Mikrofonstream bereit und bewahrt bis zu 500 ms im Speicher, damit Sprache direkt nach dem Kurzbefehl seltener verloren geht. Vor der Bestätigung wird Audio weder gespeichert noch hochgeladen; der Stromverbrauch steigt und die Mikrofonanzeige bleibt aktiv.', dsp: { + aecOff: 'Aus', + aecLight: 'Leicht (wenig CPU)', + aecModel: 'Modell (stärker)', + aecModelHint: + 'Das Modell unterdrückt mehr Echo, kostet aber rund 3x CPU; auf älteren Geräten besser Leicht.', aec: 'Echounterdrückung', aecHint: 'Unterdrückt Lautsprecher-Rückkopplung. Braucht ein Referenzsignal; mit Kopfhörern aus lassen.', diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index 817df7a1c..1005dd767 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -1545,6 +1545,11 @@ export const en: typeof zhCN = { lowLatencyDictationDesc: 'Keep a microphone stream ready and retain up to 500 ms in memory so speech immediately after the hotkey is less likely to be missed. Audio is not saved or uploaded until you confirm recording; this uses more power and keeps the microphone indicator active.', dsp: { + aecOff: 'Off', + aecLight: 'Light (low CPU)', + aecModel: 'Model (stronger)', + aecModelHint: + 'The model cancels more echo but costs about 3x the CPU; prefer Light on older machines.', aec: 'Echo cancellation', aecHint: 'Suppresses loudspeaker feedback while on speaker. It needs a speaker reference signal, so leave it off with headphones.', diff --git a/openless-all/app/src/i18n/es.ts b/openless-all/app/src/i18n/es.ts index 3707eddb0..7872ae806 100644 --- a/openless-all/app/src/i18n/es.ts +++ b/openless-all/app/src/i18n/es.ts @@ -1566,6 +1566,11 @@ export const es: typeof zhCN = { lowLatencyDictationDesc: 'Mantén preparado el flujo del micrófono y conserva hasta 500 ms en memoria para reducir la pérdida de voz justo después del atajo. El audio no se guarda ni se sube antes de confirmar la grabación; consume más energía y mantiene activo el indicador del micrófono.', dsp: { + aecOff: 'Desactivado', + aecLight: 'Ligero (poca CPU)', + aecModel: 'Modelo (más potente)', + aecModelHint: + 'El modelo cancela más pero cuesta unas 3x más CPU; en equipos antiguos usa Ligero.', aec: 'Cancelación de eco', aecHint: 'Suprime la realimentación del altavoz. Necesita una señal de referencia; con auriculares puedes dejarlo apagado.', diff --git a/openless-all/app/src/i18n/fr.ts b/openless-all/app/src/i18n/fr.ts index 0de26f1b1..e1a2b43ae 100644 --- a/openless-all/app/src/i18n/fr.ts +++ b/openless-all/app/src/i18n/fr.ts @@ -1586,6 +1586,11 @@ export const fr: typeof zhCN = { lowLatencyDictationDesc: 'Garde un flux microphone prêt et conserve jusqu’à 500 ms en mémoire afin de réduire les mots perdus juste après le raccourci. Aucun audio n’est enregistré ni envoyé avant confirmation ; ce mode consomme davantage et maintient l’indicateur du microphone actif.', dsp: { + aecOff: 'Désactivé', + aecLight: 'Léger (CPU réduit)', + aecModel: 'Modèle (plus fort)', + aecModelHint: + 'Le modèle annule davantage mais coûte environ 3x plus de CPU ; préférez Léger sur les machines anciennes.', aec: 'Annulation d’écho', aecHint: 'Réduit le retour du haut-parleur. Nécessite un signal de référence; inutile au casque.', diff --git a/openless-all/app/src/i18n/ja.ts b/openless-all/app/src/i18n/ja.ts index f6de03741..7f96a530d 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -1533,6 +1533,11 @@ export const ja: typeof zhCN = { lowLatencyDictationDesc: 'マイクストリームを準備し、最大 500 ミリ秒をメモリに保持して、ホットキー直後の音声が欠落しにくくします。録音を確定するまで音声は保存・アップロードされません。電力を多く使い、マイク使用中の表示が続きます。', dsp: { + aecOff: 'オフ', + aecLight: '軽量(低 CPU)', + aecModel: 'モデル(高精度)', + aecModelHint: + 'モデル版はより強く抑圧しますが CPU は約 3 倍です。古い端末では軽量版を推奨します。', aec: 'エコーキャンセル', aecHint: 'スピーカー使用時の回り込みを抑えます。参照信号が必要なので、ヘッドホンならオフで構いません。', diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index 5d65d133a..62f334bb0 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -1520,6 +1520,11 @@ export const ko: typeof zhCN = { lowLatencyDictationDesc: '마이크 스트림을 준비하고 최대 500ms를 메모리에 보관해 단축키 직후의 음성이 누락될 가능성을 줄입니다. 녹음을 확정하기 전에는 오디오를 저장하거나 업로드하지 않습니다. 전력 사용량이 늘고 마이크 표시가 계속 켜져 있습니다.', dsp: { + aecOff: '끄기', + aecLight: '경량(저 CPU)', + aecModel: '모델(강화)', + aecModelHint: + '모델 버전은 더 강하게 제거하지만 CPU를 약 3배 사용합니다. 오래된 기기에서는 경량을 권장합니다.', aec: '에코 제거', aecHint: '스피커 사용 시 되먹임을 줄입니다. 참조 신호가 필요하므로 헤드폰이면 꺼 두세요.', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index 12387ab08..faf6da2e2 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -1470,6 +1470,11 @@ export const zhCN = { lowLatencyDictationDesc: '保持麦克风流就绪,并在内存中保留最多 500 毫秒音频,减少按下热键后第一句话被漏识别的情况。确认录音前不会保存或上传音频;此模式会增加功耗并保持系统麦克风指示灯亮起。', dsp: { + aecOff: '关闭', + aecLight: '轻量(省 CPU)', + aecModel: '模型(更强)', + aecModelHint: + '模型版抑制更强,但约是轻量版的 3 倍 CPU;老机器建议用轻量。', aec: '回声消除', aecHint: '外放时压掉扬声器回授;需要扬声器参考信号,戴耳机可以关掉。', diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index d885f0cdf..b49fc8261 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -1472,6 +1472,11 @@ export const zhTW: typeof zhCN = { lowLatencyDictationDesc: '保持麥克風串流就緒,並在記憶體中保留最多 500 毫秒音訊,減少按下熱鍵後第一句話被漏辨識的情況。確認錄音前不會儲存或上傳音訊;此模式會增加耗電並保持系統麥克風指示燈亮起。', dsp: { + aecOff: '關閉', + aecLight: '輕量(省 CPU)', + aecModel: '模型(更強)', + aecModelHint: + '模型版抑制更強,但約是輕量版的 3 倍 CPU;舊機器建議用輕量。', aec: '回聲消除', aecHint: '外放時壓掉揚聲器回授;需要揚聲器參考訊號,戴耳機可以關掉。', diff --git a/openless-all/app/src/lib/audioDsp.ts b/openless-all/app/src/lib/audioDsp.ts index db09984aa..511735481 100644 --- a/openless-all/app/src/lib/audioDsp.ts +++ b/openless-all/app/src/lib/audioDsp.ts @@ -1,7 +1,7 @@ import type { AudioDspConfig } from './types'; export const defaultAudioDsp: AudioDspConfig = { - aecEnabled: false, + aecMode: 'off', outputBufferMs: 300, noiseReduction: 'rnnoise', noiseStrength: 80, diff --git a/openless-all/app/src/lib/types.ts b/openless-all/app/src/lib/types.ts index 197ede0d2..5d9999a5e 100644 --- a/openless-all/app/src/lib/types.ts +++ b/openless-all/app/src/lib/types.ts @@ -349,8 +349,8 @@ export type AudioDspStage = | 'agc' | 'vad'; export interface AudioDspConfig { - /** 声学回声消除;只有拿到扬声器参考信号时才起作用。 */ - aecEnabled: boolean; + /** 回声消除算法:轻量 NLMS 省 CPU,aec7 模型抑制更强但约 3 倍开销。 */ + aecMode: 'off' | 'light' | 'model'; /** 输出环形缓冲余量(100..1200 ms)。 */ outputBufferMs: number; noiseReduction: 'off' | 'rnnoise' | 'purevox' | 'spectral'; diff --git a/openless-all/app/src/pages/settings/AudioDspSettings.tsx b/openless-all/app/src/pages/settings/AudioDspSettings.tsx index 460f1220e..0f742aefc 100644 --- a/openless-all/app/src/pages/settings/AudioDspSettings.tsx +++ b/openless-all/app/src/pages/settings/AudioDspSettings.tsx @@ -86,8 +86,22 @@ export function AudioDspSettings({ 'Suppresses loudspeaker feedback while on speaker. It needs a speaker reference signal, so leave it off with headphones.', )} > - save({ aecEnabled: !draft.aecEnabled })} /> + save({ aecMode: v as AudioDspConfig['aecMode'] })} + options={[ + { value: 'off', label: label('aecOff', 'Off') }, + { value: 'light', label: label('aecLight', 'Light (low CPU)') }, + { value: 'model', label: label('aecModel', 'Model (stronger)') }, + ]} + /> + {draft.aecMode === 'model' && + label( + 'aecModelHint', + 'The model cancels more echo but costs about 3x the CPU; prefer Light on older machines.', + )} {slider('outputBufferMs', label('buffer', 'Output buffer'), 100, 1200, ' ms')} Date: Sun, 25 Oct 2026 23:52:06 +0800 Subject: [PATCH 7/9] =?UTF-8?q?refactor(audio-dsp):=20=E5=8E=BB=E6=8E=89?= =?UTF-8?q?=E5=8D=81=E6=AE=B5=E5=9D=87=E8=A1=A1=E5=99=A8=EF=BC=8C=E6=8A=8A?= =?UTF-8?q?=E3=80=8C=E5=8E=BB=E6=B7=B7=E5=93=8D=20/=20=E4=BA=BA=E5=A3=B0?= =?UTF-8?q?=E6=A3=80=E6=B5=8B=E3=80=8D=E7=9A=84=E7=9C=9F=E5=AE=9E=E8=83=BD?= =?UTF-8?q?=E5=8A=9B=E5=86=99=E6=B8=85=E6=A5=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 对照 MicYou 的实现逐个核对后,界面上三处名不副实: 1. **均衡器**:用户不需要,整块从 egui 设置里移除(`equalizer_*` 配置字段保留, 旧 preferences 仍能反序列化;处理链路里的 Equalizer 阶段默认不启用)。 2. **去混响**:MicYou 的 `apply_dereverb` 其实是 `x[n] - mix·x[n-10ms]` —— 一个 固定延迟的**梳状滤波**,不是去混响。界面改名为「梳状滤波(简易去混响)」, 提示里写清它会给音色染金属感、真正去混响需要频谱或模型方法(当前没做)。 3. **人声检测**:MicYou 的 `apply_vad` 是 RMS 门限 + 淡入淡出**噪声门**,不输出 语音/非语音判定。界面改名为「噪声门限(不是人声检测)」,并说明低于门限只是 衰减、不会停止录音。 结果:设置页现在只暴露真实存在且自解释的开关,不再有「看起来有、其实没做」的项。 --- openless-all/app/linux-egui/package-revision | 2 +- openless-all/app/linux-egui/src/i18n.rs | 9 +- .../linux-egui/src/ui/frontend/audio_dsp.rs | 27 +-- openless-all/app/src-tauri/src/recorder.rs | 220 +++++++++++++++++- 4 files changed, 227 insertions(+), 31 deletions(-) diff --git a/openless-all/app/linux-egui/package-revision b/openless-all/app/linux-egui/package-revision index c748b568f..0d667b5e3 100644 --- a/openless-all/app/linux-egui/package-revision +++ b/openless-all/app/linux-egui/package-revision @@ -1 +1 @@ -147 +148 diff --git a/openless-all/app/linux-egui/src/i18n.rs b/openless-all/app/linux-egui/src/i18n.rs index 26d4ade3c..9bc77737e 100644 --- a/openless-all/app/linux-egui/src/i18n.rs +++ b/openless-all/app/linux-egui/src/i18n.rs @@ -233,7 +233,8 @@ pub const CATALOG: &[Msg] = &[ ), }, Msg { key: "settings.audioDsp.noiseStage", text: row("降噪", "降噪", "Noise reduction", "ノイズ低減", "잡음 감소") }, - Msg { key: "settings.audioDsp.combStage", text: row("梳状滤波", "梳狀濾波", "Comb filter", "コムフィルター", "콤 필터") }, + Msg { key: "settings.audioDsp.roomStage", text: row("音色与房间", "音色與房間", "Tone and room", "音色と残響", "음색과 잔향") }, + Msg { key: "settings.audioDsp.combStage", text: row("梳状滤波(简易去混响)", "梳狀濾波(簡易去混響)", "Comb filter (simple dereverb)", "コムフィルター(簡易デリバーブ)", "콤 필터(간이 디버브)") }, Msg { key: "settings.audioDsp.Speech_enhancement_settings", text: row("语音增强设置", "語音增強設置", "Speech enhancement settings", "音声強調設定", "음성 향상 설정") }, Msg { key: "settings.audioDsp.RNNoise_is_lightweight_PureVox_uses_more_CPU_Changes_apply_to_the_next_recording", text: row("RNNoise 更轻量,PureVox 模型降噪耗时较高。设置在下一次录音生效。", "RNNoise 更輕量,PureVox 模型降噪耗時較高。設置在下一次錄音生效。", "RNNoise is lightweight; PureVox uses more CPU. Changes apply to the next recording.", "RNNoise は軽量、PureVox は CPU 使用量が多めです。設定は次の録音に適用されます。", "RNNoise는 가볍고 PureVox는 CPU를 더 사용합니다. 설정은 다음 녹음에 적용됩니다.") }, Msg { key: "settings.audioDsp.Noise_reduction_off", text: row("关闭降噪", "關闭降噪", "Noise reduction off", "ノイズ低減オフ", "잡음 감소 끄기") }, @@ -245,18 +246,18 @@ pub const CATALOG: &[Msg] = &[ Msg { key: "settings.audioDsp.Target_level", text: row("目标电平 %", "目標電平 %", "Target level %", "目標レベル %", "목표 레벨 %") }, Msg { key: "settings.audioDsp.Attack_speed", text: row("启动速度", "啟動速度", "Attack speed", "アタック速度", "어택 속도") }, Msg { key: "settings.audioDsp.Release_speed", text: row("释放速度", "釋放速度", "Release speed", "リリース速度", "릴리스 속도") }, - Msg { key: "settings.audioDsp.Speech_gate_does_not_stop_recording", text: row("语音门限(不自动停止录音)", "語音門限(不自動停止錄音)", "Speech gate (does not stop recording)", "音声ゲート(録音は停止しません)", "음성 게이트 (녹음을 중지하지 않음)") }, + Msg { key: "settings.audioDsp.Speech_gate_does_not_stop_recording", text: row("低于门限的片段被衰减,录音不会因此停止。", "低於門檻的片段被衰減,錄音不會因此停止。", "Passages below the threshold are attenuated; recording never stops for them.", "しきい値を下回る区間は減衰されますが、録音は停止しません。", "임계값 미만 구간은 감쇠되지만 녹음은 중단되지 않습니다.") }, Msg { key: "settings.audioDsp.Threshold_dB", text: row("门限 dB", "門限 dB", "Threshold dB", "しきい値 dB", "임계값 dB") }, Msg { key: "settings.audioDsp.A_high_threshold_can_remove_quiet_speech", text: row("门限过高可能丢失轻声。", "門限過高可能丟失輕聲。", "A high threshold can remove quiet speech.", "しきい値が高いと小さな声が失われる場合があります。", "임계값이 높으면 작은 목소리가 사라질 수 있습니다.") }, Msg { key: "settings.audioDsp.Tone_and_room_adjustments", text: row("音色与房间调整", "音色與房間調整", "Tone and room adjustments", "音色と室内音響の調整", "음색 및 실내 음향 조정") }, - Msg { key: "settings.audioDsp.The_MicYou_comb_filter_can_color_speech_Start_at_0", text: row("梳状滤波沿用 MicYou 的延迟相减方式,可能改变音色,建议从 0% 开始。", "梳狀濾波沿用 MicYou 的延遲相減方式,可能改變音色,建議從 0% 开始。", "The MicYou comb filter can color speech. Start at 0%.", "MicYou のコムフィルターは音色を変える場合があります。0% から始めてください。", "MicYou 콤 필터는 음색을 바꿀 수 있습니다. 0%부터 시작하세요.") }, + Msg { key: "settings.audioDsp.The_MicYou_comb_filter_can_color_speech_Start_at_0", text: row("把 10 ms 前的信号按比例减掉,能压掉一点早期反射,但会给音色染上金属感;从 0 开始试。真正的去混响需要频谱或模型方法,这里没做。", "把 10 ms 前的訊號按比例減掉,能壓掉一點早期反射,但會讓音色帶金屬感;從 0 開始試。真正的去混響需要頻譜或模型方法,這裡沒做。", "Subtracts the signal from 10 ms ago, trimming some early reflections at the cost of a metallic color; start at 0. Real dereverberation needs spectral or model-based methods, which this is not.", "10 ms 前の信号を差し引いて初期反射を少し抑えますが、金属的な音色になります。0 から試してください。本格的なデリバーブは周波数領域かモデル方式が必要で、ここでは未実装です。", "10 ms 전 신호를 비율로 빼서 초기 반사를 조금 줄이지만 금속성 음색이 생깁니다. 0부터 시작하세요. 실제 디버브는 주파수 영역이나 모델 방식이 필요하며 여기서는 구현되지 않았습니다.") }, Msg { key: "settings.audioDsp.Comb_filter", text: row("梳状滤波 %", "梳狀濾波 %", "Comb filter %", "コムフィルター %", "콤 필터 %") }, Msg { key: "settings.audioDsp.10_band_equalizer", text: row("十段均衡器", "十段均衡器", "10-band equalizer", "10 バンドイコライザー", "10밴드 이퀄라이저") }, Msg { key: "settings.audioDsp.Preamp_dB", text: row("前置增益 dB", "前置增益 dB", "Preamp dB", "プリアンプ dB", "프리앰프 dB") }, Msg { key: "settings.audioDsp.Processing_order", text: row("处理顺序", "處理順序", "Processing order", "処理順序", "처리 순서") }, Msg { key: "settings.audioDsp.Equalizer", text: row("均衡器", "均衡器", "Equalizer", "イコライザー", "이퀄라이저") }, Msg { key: "settings.audioDsp.Input_gain", text: row("输入增益", "輸入增益", "Input gain", "入力ゲイン", "입력 게인") }, - Msg { key: "settings.audioDsp.Speech_gate", text: row("语音门限", "語音門限", "Speech gate", "音声ゲート", "음성 게이트") }, + Msg { key: "settings.audioDsp.Speech_gate", text: row("噪声门限(不是人声检测)", "雜訊門檻(不是人聲偵測)", "Noise gate (not speech detection)", "ノイズゲート(音声検出ではありません)", "노이즈 게이트(음성 검출 아님)") }, Msg { key: "settings.audioDsp.Reset_enhancement_settings", text: row("恢复增强默认设置", "恢復增強默認設置", "Reset enhancement settings", "強調設定を初期化", "향상 설정 초기화") }, Msg { key: "settings.providers.presets.chatgptSiwc", text: row("ChatGPT Plan", "ChatGPT Plan", "ChatGPT Plan", "ChatGPT Plan", "ChatGPT Plan") }, Msg { key: "siwc.description", text: row("符合条件的文本润色和翻译使用你的 ChatGPT 计划或积分余额。ASR 仍使用原渠道;额度不足时不会自动切换到 API Key。", "符合條件的文字潤飾和翻譯使用你的 ChatGPT 方案或點數。ASR 仍使用原管道;額度不足時不會自動切換至 API Key。", "Eligible text polishing and translation use your ChatGPT plan or credits. ASR keeps its existing provider. Usage limits never switch you to an API key automatically.", "対象の文章校正と翻訳には ChatGPT プランまたはクレジットを使用します。音声認識は従来のプロバイダーを使い、上限到達時に API キーへ自動切り替えしません。", "지원되는 텍스트 다듬기와 번역에 ChatGPT 요금제 또는 크레딧을 사용합니다. 음성 인식은 기존 서비스를 사용하며 한도에 도달해도 API 키로 자동 전환하지 않습니다.") }, diff --git a/openless-all/app/linux-egui/src/ui/frontend/audio_dsp.rs b/openless-all/app/linux-egui/src/ui/frontend/audio_dsp.rs index 32408660e..ad5f5f816 100644 --- a/openless-all/app/linux-egui/src/ui/frontend/audio_dsp.rs +++ b/openless-all/app/linux-egui/src/ui/frontend/audio_dsp.rs @@ -66,27 +66,12 @@ pub fn settings(ui: &mut egui::Ui, config: &mut AudioDspConfig, lang: Lang, enab ui.small(label("settings.audioDsp.A_high_threshold_can_remove_quiet_speech")); } ui.add_space(8.0); - ui.collapsing(label("settings.audioDsp.Tone_and_room_adjustments"), |ui| { - ui.small(label("settings.audioDsp.The_MicYou_comb_filter_can_color_speech_Start_at_0")); - slider(ui, label("settings.audioDsp.Comb_filter"), &mut config.dereverb_level, 0..=100, "%"); - ui.checkbox(&mut config.equalizer_enabled, label("settings.audioDsp.10_band_equalizer")); - if config.equalizer_enabled { - slider(ui, label("settings.audioDsp.Preamp_dB"), &mut config.equalizer_preamp_db, -20..=20, " dB"); - let columns = if ui.available_width() >= 540.0 { 2 } else { 1 }; - egui::Grid::new("audio-dsp-eq").num_columns(columns).spacing([16.0, 8.0]).show(ui, |ui| { - let width = ((ui.available_width() - 16.0 * (columns - 1) as f32) / columns as f32).max(100.0); - for (index, (band, name)) in config.equalizer_bands_db.iter_mut().zip(["31 Hz", "63 Hz", "125 Hz", "250 Hz", "500 Hz", "1 kHz", "2 kHz", "4 kHz", "8 kHz", "16 kHz"]).enumerate() { - ui.push_id(index, |ui| ui.vertical(|ui| { - ui.set_width(width); - ui.label(name); - ui.spacing_mut().slider_width = (width - 80.0).clamp(60.0, 220.0); - ui.add(egui::Slider::new(band, -12..=12).suffix(" dB")); - })); - if (index + 1) % columns == 0 { ui.end_row(); } - } - }); - } - }); + // 房间/音色只剩「梳状滤波」一项:十段均衡器按需求移除(配置字段保留, + // 旧 preferences 仍能反序列化),界面上不再暴露。 + ui.add_space(8.0); + ui.strong(label("settings.audioDsp.roomStage")); + ui.small(label("settings.audioDsp.The_MicYou_comb_filter_can_color_speech_Start_at_0")); + slider(ui, label("settings.audioDsp.Comb_filter"), &mut config.dereverb_level, 0..=100, "%"); ui.collapsing(label("settings.audioDsp.Processing_order"), |ui| { let mut swap = None; for (i, stage) in config.processing_order.iter().enumerate() { diff --git a/openless-all/app/src-tauri/src/recorder.rs b/openless-all/app/src-tauri/src/recorder.rs index 40cf087d1..9525cdc11 100644 --- a/openless-all/app/src-tauri/src/recorder.rs +++ b/openless-all/app/src-tauri/src/recorder.rs @@ -262,6 +262,32 @@ impl WavArchiveWriter { } } +/// 调试录音要落哪几份盘(与 egui 侧同名同义)。 +/// +/// - `processed`:主档案 `.wav`,`write_processed` 时写降噪/增益之后的 +/// PCM,否则写原始 PCM(只勾「处理前」时用,回退/播放功能不变)。 +/// - `raw`:额外的 `.raw.wav`,只在同时要两份时创建,用于 A/B 对照。 +#[derive(Clone, Default)] +pub(crate) struct ArchiveTargets { + pub processed: Option, + pub raw: Option, + pub write_processed: bool, +} + +impl ArchiveTargets { + pub(crate) fn processed_only(path: Option) -> Self { + Self { + processed: path, + raw: None, + write_processed: true, + } + } + + fn canonical(&self) -> Option { + self.processed.clone().or_else(|| self.raw.clone()) + } +} + #[derive(Clone)] struct ForwardTarget { consumer: Arc, @@ -1514,6 +1540,8 @@ pub struct Recorder { stop_flag: Arc, join_handle: Mutex>>, archive_writer: Option>, + /// AEC 的扬声器参考采集流;与录音同生命周期,Drop 时自动停。 + reference_stream: Option, state: Arc, } @@ -1559,6 +1587,24 @@ impl Recorder { level_handler: Arc, audio_archive_path: Option, processing: openless_core::AudioProcessingConfig, + ) -> Result<(Self, Receiver, bool), RecorderError> { + Self::start_with_archive_targets( + microphone_device_name, + consumer, + level_handler, + ArchiveTargets::processed_only(audio_archive_path), + processing, + ) + } + + /// 由调用方决定主档案写哪份 PCM、是否额外写一份处理前的 `.raw.wav`, + /// 以及 AEC 的扬声器参考来源。 + pub fn start_with_archive_targets( + microphone_device_name: Option, + consumer: Arc, + level_handler: Arc, + archives: ArchiveTargets, + processing: openless_core::AudioProcessingConfig, ) -> Result<(Self, Receiver, bool), RecorderError> { // Startup signal: the child thread reports the result via startup_tx once the Stream // is constructed. @@ -1574,16 +1620,46 @@ impl Recorder { // Try creating WavArchiveWriter on the synchronous path — success/failure is known // immediately and passed to the caller to decide whether history marks // has_audio_recording. Failure only logs a warning, no error; the main path continues. - let archive_writer = - audio_archive_path.and_then(|path| match WavArchiveWriter::create(&path) { + let archive_writer = archives.canonical().and_then(|path| { + match WavArchiveWriter::create(&path) { Ok(writer) => Some(Arc::new(writer)), Err(err) => { log::warn!("[recorder] wav archive create failed at {path:?}: {err}"); None } + } + }); + // 只有两份都要时才创建第二份档案。 + let raw_writer = archives + .raw + .as_ref() + .filter(|_| archives.processed.is_some()) + .and_then(|path| match WavArchiveWriter::create(path) { + Ok(writer) => Some(Arc::new(writer)), + Err(err) => { + log::warn!("[recorder] raw archive create failed at {path:?}: {err}"); + None + } }); + let write_processed = archives.write_processed; let archive_active = archive_writer.is_some(); let archive_for_thread = archive_writer.clone(); + let raw_for_thread = raw_writer.clone(); + // AEC 的扬声器参考:与录音同生命周期,Drop 时停流。 + let far_end = (processing.dsp.aec_mode != openless_core::AecMode::Off) + .then(openless_core::FarEndQueue::default); + let far_end_for_thread = far_end.clone(); + let reference_stream = far_end.as_ref().and_then(|queue| { + match start_reference_stream(&stop_for_thread, queue) { + Ok(stream) => Some(stream), + Err(error) => { + log::warn!( + "[recorder] speaker reference unavailable; continuing without AEC: {error}" + ); + None + } + } + }); let join_handle = match thread::Builder::new() .name("openless-recorder".into()) @@ -1593,6 +1669,9 @@ impl Recorder { consumer, level_handler, archive_for_thread, + raw_for_thread, + write_processed, + far_end_for_thread, processing, state_for_thread, stop_for_thread, @@ -1637,6 +1716,7 @@ impl Recorder { stop_flag, join_handle: Mutex::new(Some(join_handle)), archive_writer, + reference_stream, state, }, runtime_error_rx, @@ -1813,6 +1893,86 @@ pub fn list_input_devices() -> Result, RecorderError> { Ok(result) } +/// 打开「扬声器回环」参考采集,供 AEC 使用。 +/// +/// 走的是宿主默认**输出**设备:Windows 的 WASAPI 允许在 render 设备上开 capture +/// 流(loopback),Linux 的 PulseAudio/PipeWire 会把输出映射到 monitor 源,macOS +/// 走 CoreAudio 的同一设备。没有可用回环源时返回错误,调用方记一条 warn 后继续 +/// 录音(AEC 退化为无参考),绝不让整次录音失败。 +#[cfg(not(any(target_os = "android", target_os = "ios")))] +fn start_reference_stream( + stop_flag: &Arc, + queue: &openless_core::FarEndQueue, +) -> Result { + use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; + + let host = cpal::default_host(); + let device = host + .default_output_device() + .ok_or_else(|| RecorderError::EngineFailed("no default output device".into()))?; + let supported = device + .default_output_config() + .map_err(|error| RecorderError::EngineFailed(format!("default output config: {error}")))?; + let config = supported.config(); + let channels = usize::from(config.channels); + let rate = config.sample_rate; + let device_name = device.to_string(); + let queue = queue.clone(); + let stop_for_callback = Arc::clone(stop_flag); + + macro_rules! make_reference_stream { + ($t:ty, $to_f32:expr) => {{ + let queue = queue.clone(); + let stop_for_callback = Arc::clone(&stop_for_callback); + device.build_input_stream( + &config, + move |data: &[$t], _: &cpal::InputCallbackInfo| { + if stop_for_callback.load(Ordering::Acquire) { + return; + } + let samples: Vec = data.iter().copied().map($to_f32).collect(); + queue.push(&samples, channels, rate); + }, + |error| log::warn!("[recorder] speaker reference stream error: {error}"), + None, + ) + }}; + } + + let stream = match supported.sample_format() { + SampleFormat::F32 => make_reference_stream!(f32, |sample: f32| sample), + SampleFormat::I16 => make_reference_stream!(i16, |sample: i16| sample as f32 / i16::MAX as f32), + SampleFormat::I32 => make_reference_stream!(i32, |sample: i32| sample as f32 / i32::MAX as f32), + SampleFormat::I8 => make_reference_stream!(i8, |sample: i8| sample as f32 / i8::MAX as f32), + SampleFormat::U8 => make_reference_stream!(u8, |sample: u8| (sample as f32 - 128.0) / 128.0), + other => { + return Err(RecorderError::EngineFailed(format!( + "unsupported speaker reference sample format: {other:?}" + ))) + } + } + .map_err(|error| RecorderError::EngineFailed(format!("build speaker reference stream: {error}")))?; + stream + .play() + .map_err(|error| RecorderError::EngineFailed(format!("play speaker reference: {error}")))?; + log::info!( + "[recorder] speaker reference capturing outputDevice={} rate={} channels={channels}", + device_name, + rate + ); + Ok(stream) +} + +#[cfg(any(target_os = "android", target_os = "ios"))] +fn start_reference_stream( + _stop_flag: &Arc, + _queue: &openless_core::FarEndQueue, +) -> Result { + Err(RecorderError::EngineFailed( + "speaker loopback is not available on mobile".into(), + )) +} + /// Audio thread body: build Stream -> report via startup_tx -> loop until stop_flag. /// `archiver` was already attempted by the caller on the synchronous path (Ok -> Some / /// failed -> None); this only threads it through to build_input_stream for the cpal callback. @@ -1821,18 +1981,24 @@ fn run_audio_thread( consumer: Arc, level_handler: Arc, archiver: Option>, + raw_archiver: Option>, + write_processed: bool, + far_end: Option, processing: openless_core::AudioProcessingConfig, state: Arc, stop_flag: Arc, startup_tx: Sender>, runtime_error_tx: Sender, ) { - let (processor, frame_tx) = match AudioProcessingWorker::start( + let (processor, frame_tx) = match AudioProcessingWorker::start_with_targets( processing, consumer, level_handler, archiver, + raw_archiver, Arc::clone(&state), + write_processed, + far_end, ) { Ok(worker) => worker, Err(error) => { @@ -2454,6 +2620,31 @@ impl AudioProcessingWorker { level_handler: Arc, archiver: Option>, state: Arc, + ) -> Result<(Self, AudioProcessingSender), RecorderError> { + Self::start_with_targets( + processing, + consumer, + level_handler, + archiver, + None, + state, + true, + None, + ) + } + + /// `write_processed = false` 时主档案写原始 PCM;`raw_archiver` 是额外的 + /// 「处理前」档案(只在两份都要时给出)。`far_end` 是 AEC 的扬声器参考队列。 + #[allow(clippy::too_many_arguments)] + fn start_with_targets( + processing: openless_core::AudioProcessingConfig, + consumer: Arc, + level_handler: Arc, + archiver: Option>, + raw_archiver: Option>, + state: Arc, + write_processed: bool, + far_end: Option, ) -> Result<(Self, AudioProcessingSender), RecorderError> { let (sender, receiver) = sync_channel::(AUDIO_PROCESSING_QUEUE_CAPACITY); @@ -2466,12 +2657,17 @@ impl AudioProcessingWorker { .name("openless-audio-processor".into()) .spawn(move || { let mut processor = openless_core::AudioProcessor::new(processing); + if let Some(queue) = far_end.clone() { + processor.attach_far_end(queue); + } let mut enhancement_enabled = processing.enhancement_enabled; let deliver = |chunk: openless_core::audio::NormalizedPcmChunk| { state.note_first_pcm(true); consumer.consume_pcm_chunk(&chunk.pcm_i16_le); - if let Some(archiver) = &archiver { - archiver.append(&chunk.pcm_i16_le); + if write_processed { + if let Some(archiver) = &archiver { + archiver.append(&chunk.pcm_i16_le); + } } level_handler(chunk.level); update_peak(&state.peak_output_rms_milli, chunk.level); @@ -2481,6 +2677,20 @@ impl AudioProcessingWorker { let QueuedAudioProcessingMessage { message, _reservation } = queued; match message { AudioProcessingMessage::Frame(frame) => { + // 「处理前」的原始帧:额外 raw 档案永远拿原始 PCM; + // 只要处理前、不要处理后时,主档案也写原始 PCM,回退与 + // 播放继续可用,且不会多出一份重复文件。 + if raw_archiver.is_some() || !write_processed { + let pcm = openless_core::pcm_f32_to_i16_le(&frame.samples); + if let Some(raw) = &raw_archiver { + raw.append(&pcm); + } + if !write_processed { + if let Some(canonical) = &archiver { + canonical.append(&pcm); + } + } + } let queued_us = backlog.micros.load(Ordering::Acquire); if enhancement_enabled && queued_us >= AUDIO_PROCESSING_SHED_ENHANCEMENT_US { log::warn!("[recorder] disabling enhancement for this capture: processing backlog {} ms", queued_us / 1_000); From 0e78d6d945b1b50ed74630fea0289bbab07a5ceb Mon Sep 17 00:00:00 2001 From: aeoform <2790848120@qq.com> Date: Mon, 26 Oct 2026 00:15:24 +0800 Subject: [PATCH 8/9] =?UTF-8?q?feat(tauri):=20=E6=89=AC=E5=A3=B0=E5=99=A8?= =?UTF-8?q?=E5=9B=9E=E7=8E=AF=E5=8F=82=E8=80=83=20+=20=E5=A4=84=E7=90=86?= =?UTF-8?q?=E5=89=8D/=E5=A4=84=E7=90=86=E5=90=8E=E6=A1=A3=E6=A1=88?= =?UTF-8?q?=E8=90=BD=E7=9B=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 把 egui 侧已经跑通的两件事在 Tauri 侧补齐(Linux 上 Tauri crate 被 `compile_error!` 拦住,只能靠 CI 的 macOS/Windows job 真编,这里做了 rustfmt 语法校验 + 前端 tsc/vite/144 个前端测试)。 一、AEC 的扬声器参考(`start_reference_stream`) - 与 egui 同样的思路:拿宿主**默认输出设备**开一条 capture 流 —— Windows 的 WASAPI 允许在 render 设备上做 loopback,Linux 的 PulseAudio/PipeWire 把输出 映射到 monitor 源,macOS 走 CoreAudio 同一设备。 - 参考帧按设备原生采样率/通道推入 `openless_core::FarEndQueue`,由 Core 重采样 后交给 AEC(轻量 NLMS 与 aec7 模型都吃这一路)。 - 没有回环源时只记一条 warn,录音照常进行;移动端 stub 直接返回不可用。 - 参考流挂在 `Recorder` 上,跟录音同生命周期,Drop 即停。 二、处理前/处理后档案 - 新增 `ArchiveTargets { processed, raw, write_processed }` 与 `Recorder::start_with_archive_targets`。 - 关键约束:原始 PCM 只存在于 DSP 工作线程里,而 staged 路径的档案是 `commit` 时才创建的。所以引入 `RawTap`:`CaptureSink::promote` 把 tap 写进 共享槽,DSP 线程每帧读一次槽。 - 两份都要 → 主档案写处理后,另存 `.raw.wav` 写原始; - 只要处理前 → 主档案直接写原始 PCM(回退/播放功能不变,且不多出文件); - 都不要 → 不落盘。 - `ForwardTarget.archive` 在 `write_processed == false` 时为 `None`,避免同一 段 PCM 被写两次。 三、Tauri 设置页 - 调试页在「保留原始录音(调试)」下面恢复两个开关(保存处理后的音频 / 另存 一份处理前的音频),打开主开关且两项都空时自动勾上「处理后」。 --- .../app/src-tauri/src/core_adapters.rs | 21 ++- openless-all/app/src-tauri/src/recorder.rs | 138 ++++++++++++++++-- .../src/pages/settings/DebugToolsSection.tsx | 38 ++++- 3 files changed, 174 insertions(+), 23 deletions(-) diff --git a/openless-all/app/src-tauri/src/core_adapters.rs b/openless-all/app/src-tauri/src/core_adapters.rs index d3b383201..6d1b63783 100644 --- a/openless-all/app/src-tauri/src/core_adapters.rs +++ b/openless-all/app/src-tauri/src/core_adapters.rs @@ -2964,13 +2964,13 @@ impl TauriStagedCapture { self, consumer: Arc, level_handler: Arc, - archive_path: Option, + archives: crate::recorder::ArchiveTargets, processing: openless_core::AudioProcessingConfig, ) -> Result { match self { Self::Cold(staged) => { let (recorder, runtime_errors, archive_active) = - staged.commit(consumer, level_handler, archive_path)?; + staged.commit(consumer, level_handler, archives)?; Ok(TauriCommittedCapture::Cold { recorder, runtime_errors, @@ -2983,8 +2983,8 @@ impl TauriStagedCapture { let fallback_hub = staged.capture_hub(); let fallback_consumer = Arc::clone(&consumer); let fallback_level_handler = Arc::clone(&level_handler); - let fallback_archive_path = archive_path.clone(); - match staged.commit(consumer, level_handler, archive_path) { + let fallback_archives = archives.clone(); + match staged.commit(consumer, level_handler, archives) { Ok((capture, runtime_errors, archive_active)) => { Ok(TauriCommittedCapture::Warm { capture, @@ -3022,7 +3022,7 @@ impl TauriStagedCapture { }) .unwrap_or(fallback_consumer), fallback_level_handler, - fallback_archive_path, + fallback_archives, ) { Ok(committed) => committed, Err(error) => { @@ -3128,6 +3128,15 @@ impl PendingAudioCapture for TauriPendingAudioCapture { }) .ok() .flatten(); + // 调试录音:主档案写处理后(或只勾处理前时写原始),需要对照时 + // 同目录再存一份 `.raw.wav`。 + let archives = crate::recorder::ArchiveTargets { + processed: archive_path.clone(), + raw: (recording_plan.archive_raw && archive_path.is_some()) + .then(|| archive_path.as_ref().map(|path| path.with_extension("raw.wav"))) + .flatten(), + write_processed: recording_plan.archive_processed, + }; let started_at = Instant::now(); let level_progress = Arc::clone(&progress); let level_handler: Arc = Arc::new(move |level| { @@ -3154,7 +3163,7 @@ impl PendingAudioCapture for TauriPendingAudioCapture { .commit( consumer, level_handler, - archive_path.clone(), + archives.clone(), recording_plan.audio_processing, ) .map_err(map_recorder_error) diff --git a/openless-all/app/src-tauri/src/recorder.rs b/openless-all/app/src-tauri/src/recorder.rs index 9525cdc11..1ec2179f3 100644 --- a/openless-all/app/src-tauri/src/recorder.rs +++ b/openless-all/app/src-tauri/src/recorder.rs @@ -288,6 +288,45 @@ impl ArchiveTargets { } } +/// 「处理前」音频的落盘通道:DSP 线程在处理每一帧之前先按这里落原始 PCM。 +#[derive(Clone)] +pub(crate) struct RawTap { + /// 额外的 `.raw.wav`(只在两份都要时存在)。 + raw_writer: Option>, + /// 主档案;`write_processed == false` 时原始 PCM 直接写进这里。 + canonical: Option>, + write_processed: bool, +} + +impl RawTap { + pub(crate) fn new( + canonical: Option>, + raw_writer: Option>, + write_processed: bool, + ) -> Self { + Self { + raw_writer, + canonical, + write_processed, + } + } + + fn append_raw(&self, samples: &[f32]) { + if self.raw_writer.is_none() && self.write_processed { + return; + } + let pcm = openless_core::pcm_f32_to_i16_le(samples); + if let Some(raw) = self.raw_writer.as_ref() { + raw.append(&pcm); + } + if !self.write_processed { + if let Some(canonical) = self.canonical.as_ref() { + canonical.append(&pcm); + } + } + } +} + #[derive(Clone)] struct ForwardTarget { consumer: Arc, @@ -307,9 +346,16 @@ struct CaptureSink { state: Mutex, delivery_gate: Mutex<()>, max_bytes: usize, + /// 处理前音频的落盘通道,由 `promote` 设置;DSP 线程每帧读一次。 + raw_tap: Arc>>>, } impl CaptureSink { + /// DSP 线程用它读「处理前」落盘通道;`promote` 时写入。 + fn raw_tap_slot(&self) -> Arc>>> { + Arc::clone(&self.raw_tap) + } + fn new(max_bytes: usize) -> Arc { let max_bytes = (max_bytes.max(2) & !1).min(STAGED_CAPTURE_MAX_BYTES.max(2)); Arc::new(Self { @@ -318,6 +364,7 @@ impl CaptureSink { }), delivery_gate: Mutex::new(()), max_bytes, + raw_tap: Arc::new(Mutex::new(None)), }) } @@ -333,7 +380,11 @@ impl CaptureSink { consumer: Arc, level_handler: Arc, archive: Option>, + raw_tap: Option>, ) -> Result<(), RecorderError> { + // 只要处理前时,主档案由 DSP 线程写原始 PCM,这里就不能再写处理后。 + let write_processed = raw_tap.as_ref().map_or(true, |tap| tap.write_processed); + *self.raw_tap.lock().unwrap_or_else(|poisoned| poisoned.into_inner()) = raw_tap; let _delivery = self.delivery_gate.lock(); let prefix = { let mut state = self.state.lock(); @@ -352,7 +403,7 @@ impl CaptureSink { *state = CaptureSinkState::Promoted(ForwardTarget { consumer, level_handler, - archive, + archive: if write_processed { archive } else { None }, }); prefix }; @@ -916,9 +967,20 @@ impl WarmStagedRecorder { mut self, consumer: Arc, level_handler: Arc, - audio_archive_path: Option, + archives: ArchiveTargets, ) -> Result<(WarmActiveCapture, Receiver, bool), RecorderError> { - let archive_path = audio_archive_path; + let archive_path = archives.canonical(); + let raw_writer = archives + .raw + .as_ref() + .filter(|_| archives.processed.is_some()) + .and_then(|path| match WavArchiveWriter::create(path) { + Ok(writer) => Some(Arc::new(writer)), + Err(error) => { + log::warn!("[recorder] raw archive create failed at {path:?}: {error}"); + None + } + }); let archive_writer = archive_path .as_ref() .and_then(|path| match WavArchiveWriter::create(path) { @@ -928,12 +990,18 @@ impl WarmStagedRecorder { None } }); + let raw_tap = Some(Arc::new(RawTap::new( + archive_writer.clone(), + raw_writer, + archives.write_processed, + ))); let archive_active = archive_writer.is_some(); let result = self.hub.promote( self.press_id, consumer, level_handler, archive_writer.clone(), + raw_tap.clone(), ); let runtime_errors = match result { Ok(runtime_errors) => runtime_errors, @@ -1263,7 +1331,7 @@ impl WarmCaptureHub { let (runtime_error_tx, runtime_error_rx) = channel(); if let Err(error) = self .sink - .promote(press_id, consumer, level_handler, archive) + .promote(press_id, consumer, level_handler, archive, None) { let fallback_allowed = self.runtime_fallback_claims.lock().remove(&press_id); if !fallback_allowed @@ -1594,6 +1662,7 @@ impl Recorder { level_handler, ArchiveTargets::processed_only(audio_archive_path), processing, + None, ) } @@ -1605,6 +1674,7 @@ impl Recorder { level_handler: Arc, archives: ArchiveTargets, processing: openless_core::AudioProcessingConfig, + raw_tap_slot: Option>>>>, ) -> Result<(Self, Receiver, bool), RecorderError> { // Startup signal: the child thread reports the result via startup_tx once the Stream // is constructed. @@ -1649,6 +1719,7 @@ impl Recorder { let far_end = (processing.dsp.aec_mode != openless_core::AecMode::Off) .then(openless_core::FarEndQueue::default); let far_end_for_thread = far_end.clone(); + let raw_tap_slot_for_thread = raw_tap_slot.clone(); let reference_stream = far_end.as_ref().and_then(|queue| { match start_reference_stream(&stop_for_thread, queue) { Ok(stream) => Some(stream), @@ -1672,6 +1743,7 @@ impl Recorder { raw_for_thread, write_processed, far_end_for_thread, + raw_tap_slot_for_thread, processing, state_for_thread, stop_for_thread, @@ -1781,12 +1853,14 @@ impl Recorder { let level_handler: Arc = Arc::new(move |level| { sink_for_level.publish_level(level); }); - let (recorder, runtime_errors, _) = Self::start_with_processing( + let raw_tap_slot = sink.raw_tap_slot(); + let (recorder, runtime_errors, _) = Recorder::start_with_archive_targets( microphone_device_name, consumer, level_handler, - None, + ArchiveTargets::default(), processing, + Some(raw_tap_slot), )?; Ok(StagedRecorder { recorder: Some(recorder), @@ -1808,9 +1882,9 @@ impl StagedRecorder { mut self, consumer: Arc, level_handler: Arc, - audio_archive_path: Option, + archives: ArchiveTargets, ) -> Result<(Recorder, Receiver, bool), RecorderError> { - let archive_path = audio_archive_path; + let archive_path = archives.canonical(); let archive_writer = archive_path .as_ref() .and_then(|path| match WavArchiveWriter::create(path) { @@ -1820,10 +1894,26 @@ impl StagedRecorder { None } }); + let raw_writer = archives + .raw + .as_ref() + .filter(|_| archives.processed.is_some()) + .and_then(|path| match WavArchiveWriter::create(path) { + Ok(writer) => Some(Arc::new(writer)), + Err(error) => { + log::warn!("[recorder] raw archive create failed at {path:?}: {error}"); + None + } + }); + let raw_tap = Some(Arc::new(RawTap::new( + archive_writer.clone(), + raw_writer, + archives.write_processed, + ))); let archive_active = archive_writer.is_some(); if let Err(error) = self .sink - .promote(consumer, level_handler, archive_writer.clone()) + .promote(consumer, level_handler, archive_writer.clone(), raw_tap) { if let Some(archive) = archive_writer { archive.finish(); @@ -1984,6 +2074,7 @@ fn run_audio_thread( raw_archiver: Option>, write_processed: bool, far_end: Option, + raw_tap_slot: Option>>>>, processing: openless_core::AudioProcessingConfig, state: Arc, stop_flag: Arc, @@ -1999,6 +2090,7 @@ fn run_audio_thread( Arc::clone(&state), write_processed, far_end, + raw_tap_slot, ) { Ok(worker) => worker, Err(error) => { @@ -2630,6 +2722,7 @@ impl AudioProcessingWorker { state, true, None, + None, ) } @@ -2645,6 +2738,7 @@ impl AudioProcessingWorker { state: Arc, write_processed: bool, far_end: Option, + raw_tap_slot: Option>>>>, ) -> Result<(Self, AudioProcessingSender), RecorderError> { let (sender, receiver) = sync_channel::(AUDIO_PROCESSING_QUEUE_CAPACITY); @@ -2691,6 +2785,15 @@ impl AudioProcessingWorker { } } } + if let Some(slot) = raw_tap_slot.as_ref() { + let tap = slot + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .clone(); + if let Some(tap) = tap { + tap.append_raw(&frame.samples); + } + } let queued_us = backlog.micros.load(Ordering::Acquire); if enhancement_enabled && queued_us >= AUDIO_PROCESSING_SHED_ENHANCEMENT_US { log::warn!("[recorder] disabling enhancement for this capture: processing backlog {} ms", queued_us / 1_000); @@ -3420,6 +3523,7 @@ mod tests { Arc::clone(&formal) as Arc, Arc::new(|_| {}), None, + None, ) .unwrap(); @@ -3443,6 +3547,7 @@ mod tests { Arc::clone(&formal) as Arc, Arc::new(|_| {}), None, + None, ) .unwrap(); @@ -3772,6 +3877,7 @@ mod tests { Arc::clone(&consumer) as Arc, Arc::new(move |level| levels_for_handler.lock().unwrap().push(level)), None, + None, ) .unwrap(); sink.consume_pcm_chunk(&[5, 6]); @@ -3794,7 +3900,7 @@ mod tests { sink.consume_pcm_chunk(&[1, 2]); sink.discard(); assert!(sink - .promote(consumer as Arc, Arc::new(|_| {}), None) + .promote(consumer as Arc, Arc::new(|_| {}), None, None) .is_err()); sink.consume_pcm_chunk(&[3, 4]); let is_discarded = matches!(&*sink.state.lock(), CaptureSinkState::Discarded); @@ -3817,6 +3923,7 @@ mod tests { consumer.clone(), Arc::new(move |level| levels_for_handler.lock().unwrap().push(level)), None, + None, ) .unwrap(); sink.consume_pcm_chunk(&[11, 12]); @@ -3849,7 +3956,7 @@ mod tests { sink.arm(19, false).unwrap(); sink.consume_pcm_chunk(&[5, 6]); let consumer = Arc::new(RecordingConsumer::default()); - sink.promote(19, consumer.clone(), Arc::new(|_| {}), None) + sink.promote(19, consumer.clone(), Arc::new(|_| {}), None, None) .unwrap(); assert_eq!(consumer.chunks.lock().unwrap().as_slice(), &[vec![5, 6]]); } @@ -3862,7 +3969,7 @@ mod tests { sink.consume_pcm_chunk(&[17, 18, 19, 20, 21, 22]); let consumer = Arc::new(RecordingConsumer::default()); - sink.promote(17, consumer.clone(), Arc::new(|_| {}), None) + sink.promote(17, consumer.clone(), Arc::new(|_| {}), None, None) .unwrap(); assert_eq!( @@ -3880,7 +3987,7 @@ mod tests { sink.consume_pcm_chunk(&[17, 18, 19, 20, 21, 22]); let consumer = Arc::new(RecordingConsumer::default()); - sink.promote(17, consumer.clone(), Arc::new(|_| {}), None) + sink.promote(17, consumer.clone(), Arc::new(|_| {}), None, None) .unwrap(); assert_eq!( @@ -3900,7 +4007,7 @@ mod tests { let first = Arc::new(RecordingConsumer::default()); let second = Arc::new(RecordingConsumer::default()); - sink.promote(18, second.clone(), Arc::new(|_| {}), None) + sink.promote(18, second.clone(), Arc::new(|_| {}), None, None) .unwrap(); sink.consume_pcm_chunk(&[5, 6]); @@ -3978,7 +4085,8 @@ mod tests { Arc::new(RecordingConsumer::default()), Arc::new(|_| {}), None, - ) + None, + ) .unwrap(); } hub.configure( diff --git a/openless-all/app/src/pages/settings/DebugToolsSection.tsx b/openless-all/app/src/pages/settings/DebugToolsSection.tsx index 480c5d4da..9dd63db3b 100644 --- a/openless-all/app/src/pages/settings/DebugToolsSection.tsx +++ b/openless-all/app/src/pages/settings/DebugToolsSection.tsx @@ -93,8 +93,20 @@ export function DebugToolsSection() { ); } - const onRecordAudioForDebugChange = (recordAudioForDebug: boolean) => - savePrefs({ ...prefs, recordAudioForDebug }); + const onRecordAudioForDebugChange = (recordAudioForDebug: boolean) => { + // 打开主开关时如果两项都没勾,默认勾上「处理后」,否则开了开关却什么都不写。 + const keepSomething = + prefs.debugArchiveProcessed || prefs.debugArchiveRaw || !recordAudioForDebug; + void savePrefs({ + ...prefs, + recordAudioForDebug, + debugArchiveProcessed: keepSomething ? prefs.debugArchiveProcessed : true, + }); + }; + const onDebugArchiveProcessedChange = (debugArchiveProcessed: boolean) => + void savePrefs({ ...prefs, debugArchiveProcessed }); + const onDebugArchiveRawChange = (debugArchiveRaw: boolean) => + void savePrefs({ ...prefs, debugArchiveRaw }); // Empty means unlimited, falling back to null → the backend applies its 200 default. const onAudioRecordingMaxEntriesChange = (raw: string) => { const trimmed = raw.trim(); @@ -112,6 +124,28 @@ export function DebugToolsSection() { + {prefs.recordAudioForDebug && ( + <> + + onDebugArchiveProcessedChange(!prefs.debugArchiveProcessed)} + /> + + + onDebugArchiveRawChange(!prefs.debugArchiveRaw)} + /> + + + )}
Date: Mon, 26 Oct 2026 22:06:24 +0800 Subject: [PATCH 9/9] chore(linux-egui): 2.0.2-Beta.3-149 --- openless-all/app/linux-egui/package-revision | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openless-all/app/linux-egui/package-revision b/openless-all/app/linux-egui/package-revision index 0d667b5e3..15c44e939 100644 --- a/openless-all/app/linux-egui/package-revision +++ b/openless-all/app/linux-egui/package-revision @@ -1 +1 @@ -148 +149