fix(voice): reconcile imported voice mutations

Signed-off-by: John Tennant <jtennant@squareup.com>
This commit is contained in:
John Tennant
2026-07-29 15:40:02 -04:00
parent 66de7883c9
commit d1d9ffc306
4 changed files with 15 additions and 351 deletions
Generated
-153
View File
@@ -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"
-3
View File
@@ -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"
+2 -183
View File
@@ -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<f32>, 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<Item = cpal::Device>,
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<Mutex<Vec<f32>>>) -> (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<f32> {
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,
}
})
);
}
}
+13 -12
View File
@@ -482,6 +482,14 @@ async fn finish_voice_change(voice_change: Option<VoiceChangeWait>) -> Result<()
.await
}
async fn finish_durable_voice_change(voice_change: Option<VoiceChangeWait>) {
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(
&current.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)?,