diff --git a/crates/buzz-voice/Cargo.toml b/crates/buzz-voice/Cargo.toml index 3574c3291..14646477f 100644 --- a/crates/buzz-voice/Cargo.toml +++ b/crates/buzz-voice/Cargo.toml @@ -8,11 +8,20 @@ repository.workspace = true description = "Reusable local voice primitives for Buzz" [dependencies] +atomic-write-file = "0.3" +hex = { workspace = true } ort = { version = "=2.0.0-rc.12", default-features = false, features = ["api-24", "ndarray", "std"] } ort-sys = { version = "=2.0.0-rc.12", features = ["disable-linking"] } rand = "0.10" sentencepiece-model = "0.1" serde = { version = "1", features = ["derive"] } serde_json = "1" +sha2 = { workspace = true } sherpa-onnx = "1.12" tokenizers = { version = "0.22", default-features = false, features = ["fancy-regex"] } + +[dev-dependencies] +tempfile = "3" + +[target.'cfg(target_os = "macos")'.dev-dependencies] +cpal = "0.17" diff --git a/crates/buzz-voice/src/imported.rs b/crates/buzz-voice/src/imported.rs new file mode 100644 index 000000000..4c494e226 --- /dev/null +++ b/crates/buzz-voice/src/imported.rs @@ -0,0 +1,549 @@ +//! Device-local Pocket reference voice validation, canonicalization, and storage. + +use std::{ + fs, + io::Write, + path::{Path, PathBuf}, +}; + +use atomic_write_file::AtomicWriteFile; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +const MAX_SOURCE_BYTES: u64 = 25 * 1024 * 1024; +const MIN_SAMPLE_RATE: u32 = 8_000; +const MAX_SAMPLE_RATE: u32 = 96_000; +const MIN_DURATION_SECONDS: f64 = 2.0; +const MAX_DURATION_SECONDS: f64 = 30.0; +pub const CANONICAL_SAMPLE_RATE: u32 = 32_000; +const REGISTRY_VERSION: u32 = 1; +const REGISTRY_FILE: &str = "registry.json"; + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ImportedVoice { + pub key: String, + pub display_name: String, + pub content_hash: String, + pub file_name: String, +} + +#[derive(Default, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +struct ImportedVoiceRegistry { + version: u32, + voices: Vec, +} + +#[derive(Clone, Debug)] +pub struct PocketVoiceLibrary { + root: PathBuf, +} + +impl PocketVoiceLibrary { + pub fn new(root: impl Into) -> Self { + Self { root: root.into() } + } + + pub fn root(&self) -> &Path { + &self.root + } + + fn registry_path(&self) -> PathBuf { + self.root.join(REGISTRY_FILE) + } + + pub fn load(&self) -> Result, String> { + let path = self.registry_path(); + if !path.exists() { + return Ok(Vec::new()); + } + let bytes = + fs::read(&path).map_err(|error| format!("could not read imported voices: {error}"))?; + let registry: ImportedVoiceRegistry = serde_json::from_slice(&bytes) + .map_err(|error| format!("imported voice registry is invalid: {error}"))?; + if registry.version > REGISTRY_VERSION { + return Err(format!( + "imported voice registry version {} is newer than this Buzz build supports", + registry.version + )); + } + Ok(registry + .voices + .into_iter() + .filter(valid_identity) + .filter(|voice| self.resolve_file(voice).is_ok()) + .collect()) + } + + fn save(&self, voices: &[ImportedVoice]) -> Result<(), String> { + ensure_storage_dir(&self.root)?; + let payload = serde_json::to_vec_pretty(&ImportedVoiceRegistry { + version: REGISTRY_VERSION, + voices: voices.to_vec(), + }) + .map_err(|error| format!("could not encode imported voice registry: {error}"))?; + atomic_write_restricted(&self.registry_path(), &payload) + .map_err(|error| format!("could not save imported voice registry: {error}")) + } + + pub fn resolve_file(&self, voice: &ImportedVoice) -> Result { + if !valid_identity(voice) { + return Err("Imported voice registry contains an invalid file identity".to_string()); + } + let path = self.root.join(&voice.file_name); + if !is_regular_file_without_symlink(&path) { + return Err(format!("Imported voice {} is missing", voice.display_name)); + } + let bytes = + fs::read(&path).map_err(|error| format!("could not verify imported voice: {error}"))?; + if hex::encode(Sha256::digest(bytes)) != voice.content_hash { + return Err(format!( + "Imported voice {} does not match its content identity", + voice.display_name + )); + } + Ok(path) + } + + pub fn find(&self, key: &str) -> Result, String> { + Ok(self.load()?.into_iter().find(|voice| voice.key == key)) + } + + pub fn import_path(&self, source: &Path) -> Result { + let metadata = fs::metadata(source) + .map_err(|error| format!("could not inspect selected WAV: {error}"))?; + if metadata.len() > MAX_SOURCE_BYTES { + return Err("Voice WAV must be 25 MB or smaller".to_string()); + } + let source_bytes = + fs::read(source).map_err(|error| format!("could not read selected WAV: {error}"))?; + let samples = decode_wav(&source_bytes)?; + let canonical_samples = resample_linear(&samples.samples, samples.sample_rate); + let canonical = encode_pcm16_wav(&canonical_samples, CANONICAL_SAMPLE_RATE); + let hash = hex::encode(Sha256::digest(&canonical)); + let key = format!("pocket:imported:{hash}"); + let file_name = format!("{hash}.wav"); + let display_name = source + .file_stem() + .and_then(|name| name.to_str()) + .map(str::trim) + .filter(|name| !name.is_empty()) + .unwrap_or("Imported voice") + .chars() + .take(80) + .collect::(); + + ensure_storage_dir(&self.root)?; + let file_path = self.root.join(&file_name); + let file_created = !file_path.exists(); + if file_created { + atomic_write_restricted(&file_path, &canonical) + .map_err(|error| format!("could not save imported voice audio: {error}"))?; + } else { + if !is_regular_file_without_symlink(&file_path) { + return Err("Imported voice storage contains an unsafe file entry".to_string()); + } + let existing = fs::read(&file_path) + .map_err(|error| format!("could not verify imported voice audio: {error}"))?; + if hex::encode(Sha256::digest(&existing)) != hash { + return Err("Imported voice storage contains mismatched audio data".to_string()); + } + } + + let mut imported = ImportedVoice { + key, + display_name, + content_hash: hash, + file_name, + }; + let mut voices = self.load()?; + if let Some(existing) = voices + .iter() + .find(|voice| voice.content_hash == imported.content_hash) + { + imported = existing.clone(); + } else { + voices.push(imported.clone()); + } + if let Err(error) = self.save(&voices) { + if file_created { + let _ = fs::remove_file(&file_path); + } + return Err(error); + } + Ok(imported) + } + + pub fn delete(&self, key: &str) -> Result<(), String> { + let mut voices = self.load()?; + let index = voices + .iter() + .position(|voice| voice.key == key) + .ok_or_else(|| format!("Unknown imported voice: {key}"))?; + let previous_voices = voices.clone(); + let removed = voices.remove(index); + self.save(&voices)?; + let path = self.root.join(removed.file_name); + match fs::remove_file(path) { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => { + self.save(&previous_voices).map_err(|rollback_error| { + format!( + "Imported voice audio could not be deleted ({error}), and its registry \ + entry could not be restored ({rollback_error})" + ) + })?; + Err(format!( + "Imported voice audio could not be deleted: {error}" + )) + } + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct PcmStats { + pub sample_count: usize, + pub sample_rate: u32, + pub duration_seconds: f64, + pub peak: f32, + pub rms: f32, + pub non_silent_samples: usize, +} + +impl PcmStats { + pub fn analyze(samples: &[f32], sample_rate: u32) -> Self { + let peak = samples + .iter() + .filter(|sample| sample.is_finite()) + .fold(0.0_f32, |peak, sample| peak.max(sample.abs())); + let square_sum = samples + .iter() + .filter(|sample| sample.is_finite()) + .map(|sample| sample * sample) + .sum::(); + let rms = if samples.is_empty() { + 0.0 + } else { + (square_sum / samples.len() as f32).sqrt() + }; + Self { + sample_count: samples.len(), + sample_rate, + duration_seconds: if sample_rate == 0 { + 0.0 + } else { + samples.len() as f64 / f64::from(sample_rate) + }, + peak, + rms, + non_silent_samples: samples + .iter() + .filter(|sample| sample.is_finite() && sample.abs() >= 0.001) + .count(), + } + } + + pub fn is_non_silent(self) -> bool { + self.peak >= 0.001 && self.rms >= 0.0001 && self.non_silent_samples > 0 + } +} + +pub fn write_pcm16_wav(path: &Path, samples: &[f32], sample_rate: u32) -> Result<(), String> { + let bytes = encode_pcm16_wav(samples, sample_rate); + fs::write(path, bytes).map_err(|error| format!("could not write PCM evidence: {error}")) +} + +fn ensure_storage_dir(path: &Path) -> Result<(), String> { + fs::create_dir_all(path) + .map_err(|error| format!("could not create local voice storage: {error}"))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(path, fs::Permissions::from_mode(0o700)) + .map_err(|error| format!("could not restrict local voice storage: {error}"))?; + } + Ok(()) +} + +fn atomic_write_restricted(path: &Path, payload: &[u8]) -> Result<(), String> { + let resolved = fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()); + let mut file = AtomicWriteFile::open(&resolved) + .map_err(|error| format!("open {} for atomic write: {error}", resolved.display()))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + file.set_permissions(fs::Permissions::from_mode(0o600)) + .map_err(|error| format!("set {} permissions: {error}", resolved.display()))?; + } + file.write_all(payload) + .map_err(|error| format!("write {}: {error}", resolved.display()))?; + file.commit() + .map_err(|error| format!("commit {}: {error}", resolved.display())) +} + +fn valid_hash(hash: &str) -> bool { + hash.len() == 64 && hash.bytes().all(|byte| byte.is_ascii_hexdigit()) +} + +fn valid_identity(voice: &ImportedVoice) -> bool { + valid_hash(&voice.content_hash) + && voice.key == format!("pocket:imported:{}", voice.content_hash) + && voice.file_name == format!("{}.wav", voice.content_hash) +} + +fn is_regular_file_without_symlink(path: &Path) -> bool { + fs::symlink_metadata(path) + .is_ok_and(|metadata| metadata.file_type().is_file() && !metadata.file_type().is_symlink()) +} + +#[derive(Debug)] +struct DecodedWav { + sample_rate: u32, + samples: Vec, +} + +fn decode_wav(bytes: &[u8]) -> Result { + if bytes.len() < 12 || &bytes[..4] != b"RIFF" || &bytes[8..12] != b"WAVE" { + return Err("Selected file is not a valid RIFF/WAVE file".to_string()); + } + let mut offset = 12usize; + let mut format = None; + let mut data = None; + while offset.checked_add(8).is_some_and(|end| end <= bytes.len()) { + let id = &bytes[offset..offset + 4]; + let size = + u32::from_le_bytes(bytes[offset + 4..offset + 8].try_into().unwrap_or([0; 4])) as usize; + let start = offset + 8; + let end = start.checked_add(size).ok_or("WAV chunk size overflow")?; + if end > bytes.len() { + return Err("Selected WAV contains a truncated chunk".to_string()); + } + if id == b"fmt " { + format = Some(&bytes[start..end]); + } else if id == b"data" { + data = Some(&bytes[start..end]); + } + offset = end + (size & 1); + } + let format = format.ok_or("Selected WAV has no format chunk")?; + let data = data.ok_or("Selected WAV has no audio data")?; + if format.len() < 16 { + return Err("Selected WAV has an invalid format chunk".to_string()); + } + let encoding = u16::from_le_bytes(format[0..2].try_into().unwrap_or([0; 2])); + let encoding = if encoding == 0xfffe && format.len() >= 40 { + u16::from_le_bytes(format[24..26].try_into().unwrap_or([0; 2])) + } else { + encoding + }; + let channels = u16::from_le_bytes(format[2..4].try_into().unwrap_or([0; 2])); + let sample_rate = u32::from_le_bytes(format[4..8].try_into().unwrap_or([0; 4])); + let block_align = u16::from_le_bytes(format[12..14].try_into().unwrap_or([0; 2])) as usize; + let bits = u16::from_le_bytes(format[14..16].try_into().unwrap_or([0; 2])); + if channels != 1 { + return Err("Voice WAV must be mono".to_string()); + } + if !(MIN_SAMPLE_RATE..=MAX_SAMPLE_RATE).contains(&sample_rate) { + return Err("Voice WAV sample rate must be between 8 and 96 kHz".to_string()); + } + let bytes_per_sample = usize::from(bits.div_ceil(8)); + if block_align != bytes_per_sample || block_align == 0 || data.len() % block_align != 0 { + return Err("Voice WAV has invalid sample alignment".to_string()); + } + if !matches!((encoding, bits), (1, 8 | 16 | 24 | 32) | (3, 32)) { + return Err("Voice WAV must contain PCM or 32-bit float audio".to_string()); + } + let frames = data.len() / block_align; + let duration = frames as f64 / f64::from(sample_rate); + if !(MIN_DURATION_SECONDS..=MAX_DURATION_SECONDS).contains(&duration) { + return Err("Voice WAV must be between 2 and 30 seconds long".to_string()); + } + + let mut samples = Vec::with_capacity(frames); + for chunk in data.chunks_exact(block_align) { + let sample = match (encoding, bits) { + (1, 8) => (f32::from(chunk[0]) - 128.0) / 128.0, + (1, 16) => f32::from(i16::from_le_bytes([chunk[0], chunk[1]])) / 32768.0, + (1, 24) => { + let raw = i32::from_le_bytes([ + chunk[0], + chunk[1], + chunk[2], + if chunk[2] & 0x80 == 0 { 0 } else { 0xff }, + ]); + raw as f32 / 8_388_608.0 + } + (1, 32) => { + i32::from_le_bytes(chunk.try_into().map_err(|_| "invalid PCM sample")?) as f32 + / 2_147_483_648.0 + } + (3, 32) => f32::from_le_bytes( + chunk + .try_into() + .map_err(|_| "invalid floating-point sample")?, + ), + _ => unreachable!(), + }; + if !sample.is_finite() { + return Err("Voice WAV contains non-finite samples".to_string()); + } + samples.push(sample.clamp(-1.0, 1.0)); + } + let stats = PcmStats::analyze(&samples, sample_rate); + if !stats.is_non_silent() { + return Err("Voice WAV is silent or too quiet to clone".to_string()); + } + Ok(DecodedWav { + sample_rate, + samples, + }) +} + +fn resample_linear(samples: &[f32], source_rate: u32) -> Vec { + if source_rate == CANONICAL_SAMPLE_RATE { + return samples.to_vec(); + } + let output_len = ((samples.len() as u64 * u64::from(CANONICAL_SAMPLE_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(CANONICAL_SAMPLE_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() +} + +fn encode_pcm16_wav(samples: &[f32], sample_rate: u32) -> Vec { + let data_len = (samples.len() * 2) as u32; + let mut bytes = Vec::with_capacity(44 + data_len as usize); + bytes.extend_from_slice(b"RIFF"); + bytes.extend_from_slice(&(36 + data_len).to_le_bytes()); + bytes.extend_from_slice(b"WAVEfmt "); + bytes.extend_from_slice(&16_u32.to_le_bytes()); + bytes.extend_from_slice(&1_u16.to_le_bytes()); + bytes.extend_from_slice(&1_u16.to_le_bytes()); + bytes.extend_from_slice(&sample_rate.to_le_bytes()); + bytes.extend_from_slice(&(sample_rate * 2).to_le_bytes()); + bytes.extend_from_slice(&2_u16.to_le_bytes()); + bytes.extend_from_slice(&16_u16.to_le_bytes()); + bytes.extend_from_slice(b"data"); + bytes.extend_from_slice(&data_len.to_le_bytes()); + for sample in samples { + let value = (sample.clamp(-1.0, 1.0) * f32::from(i16::MAX)).round() as i16; + bytes.extend_from_slice(&value.to_le_bytes()); + } + bytes +} + +#[cfg(test)] +mod tests { + use super::*; + + fn fixture(sample_rate: u32, seconds: usize, amplitude: f32) -> Vec { + let samples = (0..sample_rate as usize * seconds) + .map(|index| { + amplitude + * (std::f32::consts::TAU * 220.0 * index as f32 / sample_rate as f32).sin() + }) + .collect::>(); + encode_pcm16_wav(&samples, sample_rate) + } + + #[test] + fn imports_persists_reloads_and_deletes_canonical_voice() { + let temp = tempfile::tempdir().expect("temp voice workspace"); + let source = temp.path().join("My voice.wav"); + fs::write(&source, fixture(44_100, 2, 0.5)).expect("write source"); + let library = PocketVoiceLibrary::new(temp.path().join("library")); + + let imported = library.import_path(&source).expect("import voice"); + assert!(imported.key.starts_with("pocket:imported:")); + assert_eq!(imported.display_name, "My voice"); + + let relaunched = PocketVoiceLibrary::new(library.root()); + assert_eq!( + relaunched.load().expect("reload registry"), + vec![imported.clone()] + ); + let stored = relaunched + .resolve_file(&imported) + .expect("resolve stored voice"); + let decoded = decode_wav(&fs::read(&stored).expect("read stored voice")) + .expect("decode canonical voice"); + assert_eq!(decoded.sample_rate, CANONICAL_SAMPLE_RATE); + assert_eq!(decoded.samples.len(), CANONICAL_SAMPLE_RATE as usize * 2); + + assert_eq!( + relaunched.import_path(&source).expect("idempotent import"), + imported + ); + assert_eq!(relaunched.load().expect("deduplicated registry").len(), 1); + + relaunched.delete(&imported.key).expect("delete voice"); + assert!(relaunched.load().expect("empty registry").is_empty()); + assert!(!stored.exists()); + } + + #[test] + fn invalid_unsupported_and_silent_files_do_not_mutate_registry() { + let temp = tempfile::tempdir().expect("temp voice workspace"); + let library = PocketVoiceLibrary::new(temp.path().join("library")); + + let garbage = temp.path().join("garbage.wav"); + fs::write(&garbage, b"not a wave").expect("write garbage"); + assert!(library + .import_path(&garbage) + .expect_err("garbage rejected") + .contains("RIFF/WAVE")); + + let silent = temp.path().join("silent.wav"); + fs::write(&silent, fixture(32_000, 2, 0.0)).expect("write silence"); + assert!(library + .import_path(&silent) + .expect_err("silence rejected") + .contains("silent")); + + let mut stereo = fixture(32_000, 2, 0.5); + stereo[22..24].copy_from_slice(&2_u16.to_le_bytes()); + let stereo_path = temp.path().join("stereo.wav"); + fs::write(&stereo_path, stereo).expect("write stereo"); + assert!(library + .import_path(&stereo_path) + .expect_err("stereo rejected") + .contains("mono")); + + let mut unsupported = fixture(32_000, 2, 0.5); + unsupported[20..22].copy_from_slice(&6_u16.to_le_bytes()); + let unsupported_path = temp.path().join("unsupported.wav"); + fs::write(&unsupported_path, unsupported).expect("write unsupported"); + assert!(library + .import_path(&unsupported_path) + .expect_err("unsupported rejected") + .contains("PCM or 32-bit float")); + + assert!(library.load().expect("unchanged registry").is_empty()); + } + + #[test] + fn pcm_analysis_distinguishes_signal_from_silence() { + let signal = (0..24_000) + .map(|index| (std::f32::consts::TAU * 440.0 * index as f32 / 24_000.0).sin() * 0.5) + .collect::>(); + let signal_stats = PcmStats::analyze(&signal, 24_000); + assert!(signal_stats.is_non_silent()); + assert_eq!(signal_stats.duration_seconds, 1.0); + assert!(signal_stats.peak > 0.49); + assert!(signal_stats.rms > 0.3); + + let silence = vec![0.0; 24_000]; + assert!(!PcmStats::analyze(&silence, 24_000).is_non_silent()); + } +} diff --git a/crates/buzz-voice/src/lib.rs b/crates/buzz-voice/src/lib.rs index c0d4a05c5..829fc5d29 100644 --- a/crates/buzz-voice/src/lib.rs +++ b/crates/buzz-voice/src/lib.rs @@ -1,5 +1,6 @@ //! Reusable local voice primitives for Buzz. +pub mod imported; pub mod pocket; pub use pocket::{ diff --git a/crates/buzz-voice/tests/pocket_import_audio.rs b/crates/buzz-voice/tests/pocket_import_audio.rs new file mode 100644 index 000000000..5936ee8d2 --- /dev/null +++ b/crates/buzz-voice/tests/pocket_import_audio.rs @@ -0,0 +1,360 @@ +use std::{ + fs, + path::{Path, PathBuf}, + sync::{ + atomic::{AtomicBool, Ordering}, + Arc, + }, + thread, + time::Duration, +}; + +use buzz_voice::{ + imported::{write_pcm16_wav, PcmStats, PocketVoiceLibrary}, + pocket::{ + load_text_to_speech, load_voice_style, SynthesisOutcome, DEFAULT_VOICE, SAMPLE_RATE, + VOICE_FILE_EXT, + }, +}; + +const PREVIEW_TEXT: &str = "This is an objective Pocket voice preview."; +const INTERRUPT_TEXT: &str = + "This longer Pocket voice sentence should stop before it produces audio that could be played."; + +fn required_path(name: &str) -> PathBuf { + std::env::var_os(name) + .map(PathBuf::from) + .unwrap_or_else(|| panic!("{name} must point to the required local test path")) +} + +fn checked_in_voice() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../desktop/src-tauri/resources/pocket-voices/marius.wav") +} + +fn evidence_dir() -> PathBuf { + std::env::var_os("BUZZ_VOICE_EVIDENCE_DIR") + .map(PathBuf::from) + .unwrap_or_else(|| { + Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/buzz-voice-evidence") + }) +} + +fn synthesize(model_dir: &Path, voice_path: &Path, text: &str) -> (Vec, PcmStats) { + let engine = load_text_to_speech( + model_dir + .to_str() + .expect("Pocket model path must be valid UTF-8"), + ) + .expect("load Pocket model"); + let style = load_voice_style(voice_path).expect("load selected voice"); + let samples = engine + .synth_chunk(text, "en", &style, 1) + .expect("synthesize preview"); + let stats = PcmStats::analyze(&samples, SAMPLE_RATE); + assert!( + stats.is_non_silent(), + "generated PCM must be non-silent: {stats:?}" + ); + assert!( + stats.duration_seconds > 0.2, + "generated PCM is unexpectedly short: {stats:?}" + ); + (samples, stats) +} + +#[test] +#[ignore = "requires BUZZ_POCKET_MODEL_DIR and runs the installed Pocket ONNX model"] +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"); + fs::copy(checked_in_voice(), &source).expect("copy checked-in voice fixture"); + + let library_root = temp.path().join("library"); + let library = PocketVoiceLibrary::new(&library_root); + let imported = library.import_path(&source).expect("import valid WAV"); + assert_eq!( + library.find(&imported.key).expect("read selection"), + Some(imported.clone()) + ); + + drop(library); + let relaunched = PocketVoiceLibrary::new(&library_root); + let selected = relaunched + .find(&imported.key) + .expect("reload persisted selection") + .expect("selected imported voice survived relaunch"); + let imported_path = relaunched + .resolve_file(&selected) + .expect("resolve persisted imported voice"); + let (imported_pcm, imported_stats) = synthesize(&model_dir, &imported_path, PREVIEW_TEXT); + + let evidence = evidence_dir(); + fs::create_dir_all(&evidence).expect("create evidence directory"); + let imported_wav = evidence.join("imported-preview.wav"); + write_pcm16_wav(&imported_wav, &imported_pcm, SAMPLE_RATE) + .expect("write imported preview evidence"); + + let engine = load_text_to_speech( + model_dir + .to_str() + .expect("Pocket model path must be valid UTF-8"), + ) + .expect("load Pocket model for interruption"); + let style = load_voice_style(&imported_path).expect("load imported style for interruption"); + let interrupted = Arc::new(AtomicBool::new(false)); + let interrupt_worker = Arc::clone(&interrupted); + let interrupter = thread::spawn(move || { + thread::sleep(Duration::from_millis(25)); + interrupt_worker.store(true, Ordering::Release); + }); + let interrupt_check = Arc::clone(&interrupted); + let outcome = engine + .synth_chunk_interruptible(INTERRUPT_TEXT, "en", &style, 1, move || { + interrupt_check.load(Ordering::Acquire) + }) + .expect("interrupt synthesis"); + interrupter.join().expect("join interrupter"); + assert_eq!( + outcome, + SynthesisOutcome::Interrupted, + "interrupted synthesis must discard partial PCM" + ); + + assert!( + engine + .synth_chunk("", "en", &style, 1) + .expect("empty synthesis") + .is_empty(), + "empty input must not emit unintended audio" + ); + + relaunched + .delete(&imported.key) + .expect("delete imported voice"); + assert_eq!( + relaunched.find(&imported.key).expect("reload after delete"), + None + ); + + let mary_path = model_dir.join(format!("{DEFAULT_VOICE}.{VOICE_FILE_EXT}")); + assert_eq!( + mary_path.file_name().and_then(|name| name.to_str()), + Some("reference_sample.wav"), + "fallback must remain the deterministic Mary reference" + ); + let (mary_pcm, mary_stats) = synthesize(&model_dir, &mary_path, PREVIEW_TEXT); + let mary_wav = evidence.join("mary-fallback-preview.wav"); + write_pcm16_wav(&mary_wav, &mary_pcm, SAMPLE_RATE) + .expect("write Mary fallback preview evidence"); + + println!( + "{}", + serde_json::json!({ + "importedKey": imported.key, + "persistence": "reloaded", + "interruption": "partial PCM discarded", + "emptyInputSamples": 0, + "afterDelete": "pocket:mary", + "importedPreview": { + "path": imported_wav, + "samples": imported_stats.sample_count, + "sampleRate": imported_stats.sample_rate, + "durationSeconds": imported_stats.duration_seconds, + "peak": imported_stats.peak, + "rms": imported_stats.rms, + "nonSilentSamples": imported_stats.non_silent_samples, + }, + "maryFallbackPreview": { + "path": mary_wav, + "samples": mary_stats.sample_count, + "sampleRate": mary_stats.sample_rate, + "durationSeconds": mary_stats.duration_seconds, + "peak": mary_stats.peak, + "rms": mary_stats.rms, + "nonSilentSamples": mary_stats.non_silent_samples, + } + }) + ); +} + +#[cfg(target_os = "macos")] +mod blackhole { + use std::{ + sync::{Arc, Mutex}, + time::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_voice_import.rs b/desktop/src-tauri/src/huddle/tts_voice_import.rs index 91beafec7..1813b19ab 100644 --- a/desktop/src-tauri/src/huddle/tts_voice_import.rs +++ b/desktop/src-tauri/src/huddle/tts_voice_import.rs @@ -1,41 +1,10 @@ -//! Local Pocket reference-voice import storage and WAV canonicalization. +//! Tauri native-picker adapter for the reusable local Pocket voice library. -use std::{ - fs, - path::{Path, PathBuf}, -}; +use std::path::PathBuf; -use serde::{Deserialize, Serialize}; -use sha2::{Digest, Sha256}; +use buzz_voice_pkg::imported::{ImportedVoice, PocketVoiceLibrary}; use tauri::{AppHandle, Manager}; -use crate::managed_agents::storage::atomic_write_json_restricted; - -const MAX_SOURCE_BYTES: u64 = 25 * 1024 * 1024; -const MIN_SAMPLE_RATE: u32 = 8_000; -const MAX_SAMPLE_RATE: u32 = 96_000; -const MIN_DURATION_SECONDS: f64 = 2.0; -const MAX_DURATION_SECONDS: f64 = 30.0; -const CANONICAL_SAMPLE_RATE: u32 = 32_000; -const REGISTRY_VERSION: u32 = 1; -const REGISTRY_FILE: &str = "registry.json"; - -#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct ImportedVoice { - pub key: String, - pub display_name: String, - pub content_hash: String, - pub file_name: String, -} - -#[derive(Default, Deserialize, Serialize)] -#[serde(rename_all = "camelCase")] -struct ImportedVoiceRegistry { - version: u32, - voices: Vec, -} - pub fn voices_dir(app: &AppHandle) -> Result { app.path() .app_data_dir() @@ -43,79 +12,16 @@ pub fn voices_dir(app: &AppHandle) -> Result { .map_err(|error| format!("could not locate local voice storage: {error}")) } -fn registry_path(app: &AppHandle) -> Result { - Ok(voices_dir(app)?.join(REGISTRY_FILE)) -} - -fn ensure_storage_dir(path: &Path) -> Result<(), String> { - fs::create_dir_all(path) - .map_err(|error| format!("could not create local voice storage: {error}"))?; - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - fs::set_permissions(path, fs::Permissions::from_mode(0o700)) - .map_err(|error| format!("could not restrict local voice storage: {error}"))?; - } - Ok(()) +fn library(app: &AppHandle) -> Result { + voices_dir(app).map(PocketVoiceLibrary::new) } pub fn load_registry(app: &AppHandle) -> Result, String> { - let path = registry_path(app)?; - if !path.exists() { - return Ok(Vec::new()); - } - let bytes = - fs::read(&path).map_err(|error| format!("could not read imported voices: {error}"))?; - let registry: ImportedVoiceRegistry = serde_json::from_slice(&bytes) - .map_err(|error| format!("imported voice registry is invalid: {error}"))?; - if registry.version > REGISTRY_VERSION { - return Err(format!( - "imported voice registry version {} is newer than this Buzz build supports", - registry.version - )); - } - Ok(registry - .voices - .into_iter() - .filter(|voice| { - valid_hash(&voice.content_hash) - && voice.key == format!("pocket:imported:{}", voice.content_hash) - && voice.file_name == format!("{}.wav", voice.content_hash) - }) - .filter(|voice| resolve_file(app, voice).is_ok()) - .collect()) -} - -fn save_registry(app: &AppHandle, voices: &[ImportedVoice]) -> Result<(), String> { - let dir = voices_dir(app)?; - ensure_storage_dir(&dir)?; - let payload = serde_json::to_vec_pretty(&ImportedVoiceRegistry { - version: REGISTRY_VERSION, - voices: voices.to_vec(), - }) - .map_err(|error| format!("could not encode imported voice registry: {error}"))?; - atomic_write_json_restricted(&dir.join(REGISTRY_FILE), &payload) - .map_err(|error| format!("could not save imported voice registry: {error}")) + library(app)?.load() } pub fn resolve_file(app: &AppHandle, voice: &ImportedVoice) -> Result { - if !valid_hash(&voice.content_hash) || voice.file_name != format!("{}.wav", voice.content_hash) - { - return Err("Imported voice registry contains an invalid file identity".to_string()); - } - let path = voices_dir(app)?.join(&voice.file_name); - if !is_regular_file_without_symlink(&path) { - return Err(format!("Imported voice {} is missing", voice.display_name)); - } - let bytes = - fs::read(&path).map_err(|error| format!("could not verify imported voice: {error}"))?; - if hex::encode(Sha256::digest(bytes)) != voice.content_hash { - return Err(format!( - "Imported voice {} does not match its content identity", - voice.display_name - )); - } - Ok(path) + library(app)?.resolve_file(voice) } pub async fn pick_and_import(app: &AppHandle) -> Result, String> { @@ -138,314 +44,13 @@ pub async fn pick_and_import(app: &AppHandle) -> Result, S .as_path() .ok_or("the selected voice path is invalid")? .to_path_buf(); - let app = app.clone(); - tokio::task::spawn_blocking(move || import_path(&app, &path)) + let voice_library = library(app)?; + tokio::task::spawn_blocking(move || voice_library.import_path(&path)) .await .map_err(|error| format!("voice import task failed: {error}"))? .map(Some) } -pub fn import_path(app: &AppHandle, source: &Path) -> Result { - let metadata = - fs::metadata(source).map_err(|error| format!("could not inspect selected WAV: {error}"))?; - if metadata.len() > MAX_SOURCE_BYTES { - return Err("Voice WAV must be 25 MB or smaller".to_string()); - } - let source_bytes = - fs::read(source).map_err(|error| format!("could not read selected WAV: {error}"))?; - let samples = decode_wav(&source_bytes)?; - let canonical_samples = resample_linear(&samples.samples, samples.sample_rate); - let canonical = encode_pcm16_wav(&canonical_samples, CANONICAL_SAMPLE_RATE); - let hash = hex::encode(Sha256::digest(&canonical)); - let key = format!("pocket:imported:{hash}"); - let file_name = format!("{hash}.wav"); - let display_name = source - .file_stem() - .and_then(|name| name.to_str()) - .map(str::trim) - .filter(|name| !name.is_empty()) - .unwrap_or("Imported voice") - .chars() - .take(80) - .collect::(); - - let dir = voices_dir(app)?; - ensure_storage_dir(&dir)?; - let file_path = dir.join(&file_name); - let file_created = !file_path.exists(); - if file_created { - atomic_write_json_restricted(&file_path, &canonical) - .map_err(|error| format!("could not save imported voice audio: {error}"))?; - } else { - if !is_regular_file_without_symlink(&file_path) { - return Err("Imported voice storage contains an unsafe file entry".to_string()); - } - let existing = fs::read(&file_path) - .map_err(|error| format!("could not verify imported voice audio: {error}"))?; - if hex::encode(Sha256::digest(&existing)) != hash { - return Err("Imported voice storage contains mismatched audio data".to_string()); - } - } - - let mut imported = ImportedVoice { - key, - display_name, - content_hash: hash, - file_name, - }; - let mut voices = load_registry(app)?; - if let Some(existing) = voices - .iter() - .find(|voice| voice.content_hash == imported.content_hash) - { - imported = existing.clone(); - } else { - voices.push(imported.clone()); - } - if let Err(error) = save_registry(app, &voices) { - if file_created { - let _ = fs::remove_file(&file_path); - } - return Err(error); - } - Ok(imported) -} - pub fn delete(app: &AppHandle, key: &str) -> Result<(), String> { - let mut voices = load_registry(app)?; - let index = voices - .iter() - .position(|voice| voice.key == key) - .ok_or_else(|| format!("Unknown imported voice: {key}"))?; - let previous_voices = voices.clone(); - let removed = voices.remove(index); - save_registry(app, &voices)?; - let path = voices_dir(app)?.join(removed.file_name); - match fs::remove_file(path) { - Ok(()) => Ok(()), - Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), - Err(error) => { - save_registry(app, &previous_voices).map_err(|rollback_error| { - format!( - "Imported voice audio could not be deleted ({error}), and its registry entry \ - could not be restored ({rollback_error})" - ) - })?; - Err(format!( - "Imported voice audio could not be deleted: {error}" - )) - } - } -} - -fn valid_hash(hash: &str) -> bool { - hash.len() == 64 && hash.bytes().all(|byte| byte.is_ascii_hexdigit()) -} - -fn is_regular_file_without_symlink(path: &Path) -> bool { - fs::symlink_metadata(path) - .is_ok_and(|metadata| metadata.file_type().is_file() && !metadata.file_type().is_symlink()) -} - -#[derive(Debug)] -struct DecodedWav { - sample_rate: u32, - samples: Vec, -} - -fn decode_wav(bytes: &[u8]) -> Result { - if bytes.len() < 12 || &bytes[..4] != b"RIFF" || &bytes[8..12] != b"WAVE" { - return Err("Selected file is not a valid RIFF/WAVE file".to_string()); - } - let mut offset = 12usize; - let mut format = None; - let mut data = None; - while offset.checked_add(8).is_some_and(|end| end <= bytes.len()) { - let id = &bytes[offset..offset + 4]; - let size = - u32::from_le_bytes(bytes[offset + 4..offset + 8].try_into().unwrap_or([0; 4])) as usize; - let start = offset + 8; - let end = start.checked_add(size).ok_or("WAV chunk size overflow")?; - if end > bytes.len() { - return Err("Selected WAV contains a truncated chunk".to_string()); - } - if id == b"fmt " { - format = Some(&bytes[start..end]); - } else if id == b"data" { - data = Some(&bytes[start..end]); - } - offset = end + (size & 1); - } - let format = format.ok_or("Selected WAV has no format chunk")?; - let data = data.ok_or("Selected WAV has no audio data")?; - if format.len() < 16 { - return Err("Selected WAV has an invalid format chunk".to_string()); - } - let encoding = u16::from_le_bytes(format[0..2].try_into().unwrap_or([0; 2])); - let encoding = if encoding == 0xfffe && format.len() >= 40 { - u16::from_le_bytes(format[24..26].try_into().unwrap_or([0; 2])) - } else { - encoding - }; - let channels = u16::from_le_bytes(format[2..4].try_into().unwrap_or([0; 2])); - let sample_rate = u32::from_le_bytes(format[4..8].try_into().unwrap_or([0; 4])); - let block_align = u16::from_le_bytes(format[12..14].try_into().unwrap_or([0; 2])) as usize; - let bits = u16::from_le_bytes(format[14..16].try_into().unwrap_or([0; 2])); - if channels != 1 { - return Err("Voice WAV must be mono".to_string()); - } - if !(MIN_SAMPLE_RATE..=MAX_SAMPLE_RATE).contains(&sample_rate) { - return Err("Voice WAV sample rate must be between 8 and 96 kHz".to_string()); - } - let bytes_per_sample = usize::from(bits.div_ceil(8)); - if block_align != bytes_per_sample || block_align == 0 || data.len() % block_align != 0 { - return Err("Voice WAV has invalid sample alignment".to_string()); - } - if !matches!((encoding, bits), (1, 8 | 16 | 24 | 32) | (3, 32)) { - return Err("Voice WAV must contain PCM or 32-bit float audio".to_string()); - } - let frames = data.len() / block_align; - let duration = frames as f64 / f64::from(sample_rate); - if !(MIN_DURATION_SECONDS..=MAX_DURATION_SECONDS).contains(&duration) { - return Err("Voice WAV must be between 2 and 30 seconds long".to_string()); - } - - let mut samples = Vec::with_capacity(frames); - for chunk in data.chunks_exact(block_align) { - let sample = match (encoding, bits) { - (1, 8) => (f32::from(chunk[0]) - 128.0) / 128.0, - (1, 16) => f32::from(i16::from_le_bytes([chunk[0], chunk[1]])) / 32768.0, - (1, 24) => { - let raw = i32::from_le_bytes([ - chunk[0], - chunk[1], - chunk[2], - if chunk[2] & 0x80 == 0 { 0 } else { 0xff }, - ]); - raw as f32 / 8_388_608.0 - } - (1, 32) => { - i32::from_le_bytes(chunk.try_into().map_err(|_| "invalid PCM sample")?) as f32 - / 2_147_483_648.0 - } - (3, 32) => f32::from_le_bytes( - chunk - .try_into() - .map_err(|_| "invalid floating-point sample")?, - ), - _ => unreachable!(), - }; - if !sample.is_finite() { - return Err("Voice WAV contains non-finite samples".to_string()); - } - samples.push(sample.clamp(-1.0, 1.0)); - } - let peak = samples - .iter() - .fold(0.0_f32, |peak, sample| peak.max(sample.abs())); - let rms = - (samples.iter().map(|sample| sample * sample).sum::() / samples.len() as f32).sqrt(); - if peak < 0.001 || rms < 0.0001 { - return Err("Voice WAV is silent or too quiet to clone".to_string()); - } - Ok(DecodedWav { - sample_rate, - samples, - }) -} - -fn resample_linear(samples: &[f32], source_rate: u32) -> Vec { - if source_rate == CANONICAL_SAMPLE_RATE { - return samples.to_vec(); - } - let output_len = ((samples.len() as u64 * u64::from(CANONICAL_SAMPLE_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(CANONICAL_SAMPLE_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() -} - -fn encode_pcm16_wav(samples: &[f32], sample_rate: u32) -> Vec { - let data_len = (samples.len() * 2) as u32; - let mut bytes = Vec::with_capacity(44 + data_len as usize); - bytes.extend_from_slice(b"RIFF"); - bytes.extend_from_slice(&(36 + data_len).to_le_bytes()); - bytes.extend_from_slice(b"WAVEfmt "); - bytes.extend_from_slice(&16_u32.to_le_bytes()); - bytes.extend_from_slice(&1_u16.to_le_bytes()); - bytes.extend_from_slice(&1_u16.to_le_bytes()); - bytes.extend_from_slice(&sample_rate.to_le_bytes()); - bytes.extend_from_slice(&(sample_rate * 2).to_le_bytes()); - bytes.extend_from_slice(&2_u16.to_le_bytes()); - bytes.extend_from_slice(&16_u16.to_le_bytes()); - bytes.extend_from_slice(b"data"); - bytes.extend_from_slice(&data_len.to_le_bytes()); - for sample in samples { - let value = (sample.clamp(-1.0, 1.0) * f32::from(i16::MAX)).round() as i16; - bytes.extend_from_slice(&value.to_le_bytes()); - } - bytes -} - -#[cfg(test)] -mod tests { - use super::*; - - fn fixture(sample_rate: u32, seconds: usize, amplitude: f32) -> Vec { - let samples = (0..sample_rate as usize * seconds) - .map(|index| { - amplitude - * (std::f32::consts::TAU * 220.0 * index as f32 / sample_rate as f32).sin() - }) - .collect::>(); - encode_pcm16_wav(&samples, sample_rate) - } - - #[test] - fn validates_and_canonicalizes_supported_wav() { - let decoded = decode_wav(&fixture(44_100, 2, 0.5)).expect("valid WAV"); - let canonical = resample_linear(&decoded.samples, decoded.sample_rate); - assert_eq!(canonical.len(), 64_000); - let encoded = encode_pcm16_wav(&canonical, CANONICAL_SAMPLE_RATE); - let reparsed = decode_wav(&encoded).expect("canonical WAV"); - assert_eq!(reparsed.sample_rate, 32_000); - assert_eq!(reparsed.samples.len(), 64_000); - } - - #[test] - fn rejects_silent_and_short_wav() { - assert!(decode_wav(&fixture(32_000, 2, 0.0)) - .expect_err("silence rejected") - .contains("silent")); - assert!(decode_wav(&fixture(32_000, 1, 0.5)) - .expect_err("short rejected") - .contains("between 2 and 30")); - } - - #[test] - fn rejects_stereo_out_of_range_and_overlong_wav() { - let mut stereo_header = fixture(32_000, 2, 0.5); - stereo_header[22..24].copy_from_slice(&2_u16.to_le_bytes()); - assert!(decode_wav(&stereo_header) - .expect_err("stereo rejected") - .contains("mono")); - - let mut low_rate_header = fixture(32_000, 2, 0.5); - low_rate_header[24..28].copy_from_slice(&4_000_u32.to_le_bytes()); - assert!(decode_wav(&low_rate_header) - .expect_err("low sample rate rejected") - .contains("between 8 and 96")); - - assert!(decode_wav(&fixture(8_000, 31, 0.5)) - .expect_err("overlong rejected") - .contains("between 2 and 30")); - } + library(app)?.delete(key) } diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 13334827a..6a2b12d3e 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -151,6 +151,8 @@ type E2eConfig = { agentTextToSpeech: boolean; voicePreferences: string[]; }; + /** Native picker boundary result for Pocket voice import tests. */ + pocketVoiceImportResult?: "success" | "cancel" | "invalid"; /** Advertised HEAD for the first mock project without adding that branch. */ projectHeadBranch?: string; /** Builderlab account returned by hosted-community onboarding. Null/omitted = signed out. */ @@ -9860,6 +9862,12 @@ export function maybeInstallE2eTauriMocks() { case "preview_pocket_voice": return null; case "import_pocket_voice": { + const importResult = + activeConfig?.mock?.pocketVoiceImportResult ?? "success"; + if (importResult === "cancel") return null; + if (importResult === "invalid") { + throw new Error("Voice WAV must contain PCM or 32-bit float audio"); + } const contentHash = "1".repeat(64); const imported = { key: `pocket:imported:${contentHash}`, diff --git a/desktop/tests/e2e/voice-settings.spec.ts b/desktop/tests/e2e/voice-settings.spec.ts index 78756fb58..9bd8742ed 100644 --- a/desktop/tests/e2e/voice-settings.spec.ts +++ b/desktop/tests/e2e/voice-settings.spec.ts @@ -129,6 +129,7 @@ test.describe("Pocket voice settings", () => { "My voice", ); await expect(page.getByTestId("pocket-voice-delete")).toBeVisible(); + await page.getByRole("button", { name: "Preview" }).click(); await page.getByTestId("pocket-voice-delete").click(); await expect(page.getByText("Delete imported voice?")).toBeVisible(); @@ -137,22 +138,75 @@ test.describe("Pocket voice settings", () => { "Mary", ); await expect(page.getByTestId("pocket-voice-delete")).toBeHidden(); + await page.getByRole("button", { name: "Preview" }).click(); const mutations = await page.evaluate(() => (window.__BUZZ_E2E_COMMAND_LOG__ ?? []) .filter((entry) => - ["import_pocket_voice", "delete_pocket_voice"].includes( - entry.command, - ), + [ + "import_pocket_voice", + "preview_pocket_voice", + "delete_pocket_voice", + ].includes(entry.command), ) .map((entry) => ({ command: entry.command, payload: entry.payload })), ); expect(mutations).toEqual([ { command: "import_pocket_voice", payload: {} }, + { + command: "preview_pocket_voice", + payload: { voiceKey: `pocket:imported:${"1".repeat(64)}` }, + }, { command: "delete_pocket_voice", payload: { voiceKey: `pocket:imported:${"1".repeat(64)}` }, }, + { + command: "preview_pocket_voice", + payload: { voiceKey: "pocket:mary" }, + }, ]); }); + + test("keeps the selected voice unchanged when the native picker is cancelled", async ({ + page, + }) => { + await installMockBridge(page, { pocketVoiceImportResult: "cancel" }); + await page.goto("/", { waitUntil: "domcontentloaded" }); + await openSettings(page, "voice"); + + await expect(page.getByTestId("pocket-voice-selector")).toContainText( + "Mary", + ); + await page.getByTestId("pocket-voice-import").click(); + await expect(page.getByTestId("pocket-voice-selector")).toContainText( + "Mary", + ); + await expect(page.getByTestId("pocket-voice-delete")).toBeHidden(); + await expect(page.getByTestId("voice-settings-error")).toBeHidden(); + + const audioCommands = await page.evaluate(() => + (window.__BUZZ_E2E_COMMAND_LOG__ ?? []).filter((entry) => + ["preview_pocket_voice", "delete_pocket_voice"].includes(entry.command), + ), + ); + expect(audioCommands).toEqual([]); + }); + + test("surfaces invalid or unsupported WAV errors without changing selection", async ({ + page, + }) => { + await installMockBridge(page, { pocketVoiceImportResult: "invalid" }); + await page.goto("/", { waitUntil: "domcontentloaded" }); + await openSettings(page, "voice"); + + await page.getByTestId("pocket-voice-import").click(); + await expect(page.getByTestId("voice-settings-error")).toContainText( + "Voice WAV must contain PCM or 32-bit float audio", + ); + await expect(page.getByTestId("pocket-voice-selector")).toContainText( + "Mary", + ); + await expect(page.getByTestId("pocket-voice-delete")).toBeHidden(); + }); }); diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts index 53de38c46..8f9e9ad11 100644 --- a/desktop/tests/helpers/bridge.ts +++ b/desktop/tests/helpers/bridge.ts @@ -137,6 +137,8 @@ type MockBridgeOptions = { agentTextToSpeech: boolean; voicePreferences: string[]; }; + /** Native picker boundary result for Pocket voice import tests. */ + pocketVoiceImportResult?: "success" | "cancel" | "invalid"; /** Advertised HEAD for the first mock project without adding that branch. */ projectHeadBranch?: string; /** Relay NIP-11 identity used to sign authoritative repository state. */