From d1d9ffc30635f93724d9a87ad082abcc8eb3fd57 Mon Sep 17 00:00:00 2001 From: John Tennant Date: Wed, 29 Jul 2026 15:40:02 -0400 Subject: [PATCH] fix(voice): reconcile imported voice mutations Signed-off-by: John Tennant --- Cargo.lock | 153 --------------- crates/buzz-voice/Cargo.toml | 3 - .../buzz-voice/tests/pocket_import_audio.rs | 185 +----------------- desktop/src-tauri/src/huddle/tts_settings.rs | 25 +-- 4 files changed, 15 insertions(+), 351 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 578db415c..7e134ec30 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -72,28 +72,6 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" -[[package]] -name = "alsa" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "812947049edcd670a82cd5c73c3661d2e58468577ba8489de58e1a73c04cbd5d" -dependencies = [ - "alsa-sys", - "bitflags 2.13.0", - "cfg-if 1.0.4", - "libc", -] - -[[package]] -name = "alsa-sys" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad7569085a265dd3f607ebecce7458eaab2132a84393534c95b18dcbc3f31e04" -dependencies = [ - "libc", - "pkg-config", -] - [[package]] name = "android_system_properties" version = "0.1.5" @@ -1325,7 +1303,6 @@ name = "buzz-voice" version = "0.1.0" dependencies = [ "atomic-write-file", - "cpal", "hex", "ort", "ort-sys", @@ -1808,20 +1785,6 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" -[[package]] -name = "coreaudio-rs" -version = "0.14.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d5d7dca3ebcf65a035582c9ad4385371a9d9ee6537474d2a278f4e1e475bb58" -dependencies = [ - "bitflags 2.13.0", - "libc", - "objc2-audio-toolbox", - "objc2-core-audio", - "objc2-core-audio-types", - "objc2-core-foundation", -] - [[package]] name = "countdown-bot" version = "0.1.0" @@ -1845,36 +1808,6 @@ dependencies = [ "futures-io", ] -[[package]] -name = "cpal" -version = "0.17.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8942da362c0f0d895d7cac616263f2f9424edc5687364dfd1d25ef7eba506d7" -dependencies = [ - "alsa", - "coreaudio-rs", - "dasp_sample", - "jni 0.21.1", - "js-sys", - "libc", - "mach2", - "ndk", - "ndk-context", - "num-derive", - "num-traits", - "objc2", - "objc2-audio-toolbox", - "objc2-avf-audio", - "objc2-core-audio", - "objc2-core-audio-types", - "objc2-core-foundation", - "objc2-foundation", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", - "windows 0.62.2", -] - [[package]] name = "cpubits" version = "0.1.1" @@ -2302,12 +2235,6 @@ dependencies = [ "parking_lot_core", ] -[[package]] -name = "dasp_sample" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f" - [[package]] name = "data-encoding" version = "2.11.0" @@ -4625,15 +4552,6 @@ dependencies = [ "winapi", ] -[[package]] -name = "mach2" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a1b95cd5421ec55b445b5ae102f5ea0e768de1f82bd3001e11f426c269c3aea" -dependencies = [ - "libc", -] - [[package]] name = "macro_rules_attribute" version = "0.2.2" @@ -5526,35 +5444,12 @@ dependencies = [ "rawpointer", ] -[[package]] -name = "ndk" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" -dependencies = [ - "bitflags 2.13.0", - "jni-sys 0.3.1", - "log", - "ndk-sys", - "num_enum", - "thiserror 1.0.69", -] - [[package]] name = "ndk-context" version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" -[[package]] -name = "ndk-sys" -version = "0.6.0+11769913" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" -dependencies = [ - "jni-sys 0.3.1", -] - [[package]] name = "negentropy" version = "0.5.0" @@ -6027,54 +5922,6 @@ dependencies = [ "objc2-encode", ] -[[package]] -name = "objc2-audio-toolbox" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6948501a91121d6399b79abaa33a8aa4ea7857fe019f341b8c23ad6e81b79b08" -dependencies = [ - "bitflags 2.13.0", - "libc", - "objc2", - "objc2-core-audio", - "objc2-core-audio-types", - "objc2-core-foundation", - "objc2-foundation", -] - -[[package]] -name = "objc2-avf-audio" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13a380031deed8e99db00065c45937da434ca987c034e13b87e4441f9e4090be" -dependencies = [ - "objc2", - "objc2-foundation", -] - -[[package]] -name = "objc2-core-audio" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1eebcea8b0dbff5f7c8504f3107c68fc061a3eb44932051c8cf8a68d969c3b2" -dependencies = [ - "dispatch2", - "objc2", - "objc2-core-audio-types", - "objc2-core-foundation", - "objc2-foundation", -] - -[[package]] -name = "objc2-core-audio-types" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a89f2ec274a0cf4a32642b2991e8b351a404d290da87bb6a9a9d8632490bd1c" -dependencies = [ - "bitflags 2.13.0", - "objc2", -] - [[package]] name = "objc2-core-foundation" version = "0.3.2" diff --git a/crates/buzz-voice/Cargo.toml b/crates/buzz-voice/Cargo.toml index 14646477f..f45fdff8a 100644 --- a/crates/buzz-voice/Cargo.toml +++ b/crates/buzz-voice/Cargo.toml @@ -22,6 +22,3 @@ tokenizers = { version = "0.22", default-features = false, features = ["fancy-re [dev-dependencies] tempfile = "3" - -[target.'cfg(target_os = "macos")'.dev-dependencies] -cpal = "0.17" diff --git a/crates/buzz-voice/tests/pocket_import_audio.rs b/crates/buzz-voice/tests/pocket_import_audio.rs index f2a56ad9d..8578c368d 100644 --- a/crates/buzz-voice/tests/pocket_import_audio.rs +++ b/crates/buzz-voice/tests/pocket_import_audio.rs @@ -18,7 +18,7 @@ fn required_path(name: &str) -> PathBuf { fn checked_in_voice() -> PathBuf { Path::new(env!("CARGO_MANIFEST_DIR")) - .join("../../desktop/src-tauri/resources/pocket-voices/marius.wav") + .join("../../desktop/src-tauri/resources/pocket-voices/eve.wav") } fn evidence_dir() -> PathBuf { @@ -57,7 +57,7 @@ fn synthesize(model_dir: &Path, voice_path: &Path, text: &str) -> (Vec, Pcm fn objective_import_synthesis_delete_and_mary_fallback() { let model_dir = required_path("BUZZ_POCKET_MODEL_DIR"); let temp = tempfile::tempdir().expect("temporary voice workspace"); - let source = temp.path().join("Imported Marius.wav"); + let source = temp.path().join("Imported Eve.wav"); fs::copy(checked_in_voice(), &source).expect("copy checked-in voice fixture"); let library_root = temp.path().join("library"); @@ -131,184 +131,3 @@ fn objective_import_synthesis_delete_and_mary_fallback() { }) ); } - -#[cfg(target_os = "macos")] -mod blackhole { - use std::{ - sync::{Arc, Mutex}, - thread, - time::{Duration, Instant}, - }; - - use cpal::{ - self, - traits::{DeviceTrait, HostTrait, StreamTrait}, - SampleFormat, - }; - - use super::*; - - fn named_device( - devices: impl Iterator, - expected_name: &str, - ) -> cpal::Device { - devices - .filter_map(|device| { - device - .description() - .ok() - .map(|description| (device, description.name().to_string())) - }) - .find(|(_, name)| name == expected_name) - .map(|(device, _)| device) - .unwrap_or_else(|| panic!("audio device {expected_name:?} is not available")) - } - - fn capture_f32(device: &cpal::Device, captured: Arc>>) -> (cpal::Stream, u32) { - let supported = device - .default_input_config() - .expect("read BlackHole input configuration"); - assert_eq!( - supported.sample_format(), - SampleFormat::F32, - "BlackHole test expects Float32 input" - ); - let sample_rate = supported.sample_rate(); - let config: cpal::StreamConfig = supported.into(); - let stream = device - .build_input_stream( - &config, - move |data: &[f32], _| { - captured - .lock() - .unwrap_or_else(|error| error.into_inner()) - .extend_from_slice(data); - }, - |error| panic!("BlackHole capture failed: {error}"), - None, - ) - .expect("build BlackHole input stream"); - (stream, sample_rate) - } - - fn resample_linear(samples: &[f32], source_rate: u32, output_rate: u32) -> Vec { - let output_len = ((samples.len() as u64 * u64::from(output_rate) - + u64::from(source_rate) / 2) - / u64::from(source_rate)) as usize; - (0..output_len) - .map(|index| { - let source = index as f64 * f64::from(source_rate) / f64::from(output_rate); - let left = source.floor() as usize; - let fraction = (source - left as f64) as f32; - let a = samples[left.min(samples.len() - 1)]; - let b = samples[(left + 1).min(samples.len() - 1)]; - a + (b - a) * fraction - }) - .collect() - } - - #[test] - #[ignore = "requires BUZZ_POCKET_MODEL_DIR and BUZZ_VOICE_AUDIO_DEVICE=BlackHole 2ch"] - fn blackhole_playback_captures_generated_non_silent_pcm() { - let model_dir = required_path("BUZZ_POCKET_MODEL_DIR"); - let device_name = std::env::var("BUZZ_VOICE_AUDIO_DEVICE") - .expect("BUZZ_VOICE_AUDIO_DEVICE must name a BlackHole loopback device"); - let temp = tempfile::tempdir().expect("temporary voice workspace"); - let source = temp.path().join("Imported Marius.wav"); - fs::copy(checked_in_voice(), &source).expect("copy checked-in voice fixture"); - let library = PocketVoiceLibrary::new(temp.path().join("library")); - let imported = library.import_path(&source).expect("import voice"); - let selected = library - .find(&imported.key) - .expect("read selection") - .expect("selected imported voice"); - let voice_path = library - .resolve_file(&selected) - .expect("resolve imported voice"); - let (samples, generated_stats) = synthesize(&model_dir, &voice_path, PREVIEW_TEXT); - - let host = cpal::default_host(); - let input = named_device( - host.input_devices().expect("enumerate input devices"), - &device_name, - ); - let output = named_device( - host.output_devices().expect("enumerate output devices"), - &device_name, - ); - let captured = Arc::new(Mutex::new(Vec::new())); - let (input_stream, input_sample_rate) = capture_f32(&input, Arc::clone(&captured)); - input_stream.play().expect("start BlackHole capture"); - - let supported_output = output - .default_output_config() - .expect("read BlackHole output configuration"); - assert_eq!( - supported_output.sample_format(), - SampleFormat::F32, - "BlackHole test expects Float32 output" - ); - let output_sample_rate = supported_output.sample_rate(); - let output_config: cpal::StreamConfig = supported_output.into(); - let output_channels = usize::from(output_config.channels); - let playback = resample_linear(&samples, SAMPLE_RATE, output_sample_rate); - let playback_len = playback.len(); - let mut playback_index = 0usize; - let output_stream = output - .build_output_stream( - &output_config, - move |data: &mut [f32], _| { - for frame in data.chunks_mut(output_channels) { - let sample = playback.get(playback_index).copied().unwrap_or(0.0); - frame.fill(sample); - playback_index = playback_index.saturating_add(1); - } - }, - |error| panic!("BlackHole playback failed: {error}"), - None, - ) - .expect("build BlackHole output stream"); - output_stream.play().expect("start BlackHole playback"); - let started = Instant::now(); - let playback_duration = - Duration::from_secs_f64(playback_len as f64 / f64::from(output_sample_rate)); - while started.elapsed() < playback_duration { - thread::sleep(Duration::from_millis(20)); - } - thread::sleep(Duration::from_millis(250)); - drop(output_stream); - drop(input_stream); - - let captured_samples = captured - .lock() - .unwrap_or_else(|error| error.into_inner()) - .clone(); - let captured_stats = PcmStats::analyze(&captured_samples, input_sample_rate); - assert!( - captured_stats.is_non_silent(), - "BlackHole must capture non-silent generated PCM: {captured_stats:?}" - ); - assert!( - captured_stats.non_silent_samples >= generated_stats.non_silent_samples / 4, - "captured signal is unexpectedly sparse: generated={generated_stats:?}, captured={captured_stats:?}" - ); - println!( - "{}", - serde_json::json!({ - "device": device_name, - "generated": { - "samples": generated_stats.sample_count, - "peak": generated_stats.peak, - "rms": generated_stats.rms, - }, - "captured": { - "samples": captured_stats.sample_count, - "sampleRate": captured_stats.sample_rate, - "peak": captured_stats.peak, - "rms": captured_stats.rms, - "nonSilentSamples": captured_stats.non_silent_samples, - } - }) - ); - } -} diff --git a/desktop/src-tauri/src/huddle/tts_settings.rs b/desktop/src-tauri/src/huddle/tts_settings.rs index fa80b563e..1b378af82 100644 --- a/desktop/src-tauri/src/huddle/tts_settings.rs +++ b/desktop/src-tauri/src/huddle/tts_settings.rs @@ -482,6 +482,14 @@ async fn finish_voice_change(voice_change: Option) -> Result<() .await } +async fn finish_durable_voice_change(voice_change: Option) { + if let Err(error) = finish_voice_change(voice_change).await { + eprintln!( + "buzz-desktop: tts stage=voice_switch status=delayed reason=ack_timeout error={error}" + ); + } +} + async fn wait_for_voice_change_ack( mut acknowledged: tokio::sync::oneshot::Receiver<()>, timeout: Duration, @@ -581,14 +589,7 @@ pub async fn set_pocket_voice( let settings = settings_with_pocket_voice(settings, &voice_key, &app)?; let voice_change = apply_tts_settings(settings, &app, &state).await?; drop(transition); - if let Err(error) = finish_voice_change(voice_change).await { - // The preference is already durable. Report the delayed live - // transition diagnostically without telling the UI that saving failed; - // the next pipeline start resolves the persisted voice normally. - eprintln!( - "buzz-desktop: tts stage=voice_switch status=delayed reason=ack_timeout error={error}" - ); - } + finish_durable_voice_change(voice_change).await; current_settings(&state) } @@ -656,12 +657,12 @@ pub async fn import_pocket_voice( let Some(imported) = super::tts_voice_import::pick_and_import(&app).await? else { return Ok(None); }; - let transition = state.tts_settings_transition.lock().await; + let transition = state.huddle_audio.tts_transition.lock().await; let settings = current_settings(&state)?; let settings = settings_with_pocket_voice(settings, &imported.key, &app)?; let voice_change = apply_tts_settings(settings, &app, &state).await?; drop(transition); - finish_voice_change(voice_change).await?; + finish_durable_voice_change(voice_change).await; Ok(Some(TtsVoiceMutation { settings: current_settings(&state)?, registry: voice_registry(&app), @@ -681,7 +682,7 @@ pub async fn delete_pocket_voice( return Err(format!("Unknown imported voice: {voice_key}")); } - let transition = state.tts_settings_transition.lock().await; + let transition = state.huddle_audio.tts_transition.lock().await; let current = current_settings(&state)?; let selected = resolve_voice_for_backend_in_registry( ¤t.voice_preferences, @@ -696,7 +697,7 @@ pub async fn delete_pocket_voice( None }; drop(transition); - finish_voice_change(voice_change).await?; + finish_durable_voice_change(voice_change).await; super::tts_voice_import::delete(&app, &voice_key)?; Ok(TtsVoiceMutation { settings: current_settings(&state)?,