diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 46a16cbd8..a2c913292 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -1090,6 +1090,7 @@ dependencies = [ "serde_yaml", "sha2 0.11.0", "sherpa-onnx", + "ssstretch", "strip-ansi-escapes", "tar", "tauri", @@ -1531,6 +1532,17 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "codespan-reporting" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af491d569909a7e4dee0ad7db7f5341fef5c614d5b8ec8cf765732aba3cff681" +dependencies = [ + "serde", + "termcolor", + "unicode-width", +] + [[package]] name = "color_quant" version = "1.1.0" @@ -1549,7 +1561,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] @@ -1787,7 +1799,7 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "windows 0.61.3", + "windows 0.62.2", ] [[package]] @@ -2103,6 +2115,68 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "cxx" +version = "1.0.198" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fe442a792c7c736eea18b32a7f8a3b63cf8aafabda6760042dc2fdeda456291" +dependencies = [ + "cc", + "cxx-build", + "cxxbridge-cmd", + "cxxbridge-flags", + "cxxbridge-macro", + "foldhash 0.2.0", + "link-cplusplus", +] + +[[package]] +name = "cxx-build" +version = "1.0.198" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3184a94384c663718698311a78a51ac00c484c10b4eeac06fb0a068c5f64fa2" +dependencies = [ + "cc", + "codespan-reporting", + "indexmap 2.14.0", + "proc-macro2", + "quote", + "scratch", + "syn 3.0.3", +] + +[[package]] +name = "cxxbridge-cmd" +version = "1.0.198" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0148d8fd1199329ddf1d157a5e134e51ceff37c6a7ddd38615c399d81cb05d8d" +dependencies = [ + "clap", + "codespan-reporting", + "indexmap 2.14.0", + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "cxxbridge-flags" +version = "1.0.198" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52850339faed2eaadd24e286dc1d8268cc6f8a7bd9524d713adc9099566b4c89" + +[[package]] +name = "cxxbridge-macro" +version = "1.0.198" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c77c856545d886c9bd5215409ebb63b925e262135248b50c79e5a5f194ee47c" +dependencies = [ + "indexmap 2.14.0", + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "darling" version = "0.20.11" @@ -2210,7 +2284,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccc2776f0c61eca1ca32528f85548abd1a4be8fb53d1b21c013e4f18da1e7090" dependencies = [ "data-encoding", - "syn 1.0.109", + "syn 2.0.118", ] [[package]] @@ -3177,8 +3251,8 @@ dependencies = [ "libc", "log", "rustversion", - "windows-link 0.1.3", - "windows-result 0.3.4", + "windows-link 0.2.1", + "windows-result 0.4.1", ] [[package]] @@ -3923,7 +3997,7 @@ dependencies = [ "tokio", "tower-service", "tracing", - "windows-registry 0.5.3", + "windows-registry 0.6.1", ] [[package]] @@ -3938,7 +4012,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.61.2", + "windows-core 0.62.2", ] [[package]] @@ -4773,6 +4847,15 @@ dependencies = [ "bitflags 2.13.0", ] +[[package]] +name = "link-cplusplus" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f78c730aaa7d0b9336a299029ea49f9ee53b0ed06e9202e8cb7db9bae7b8c82" +dependencies = [ + "cc", +] + [[package]] name = "link-section" version = "0.19.0" @@ -6327,7 +6410,7 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" dependencies = [ - "proc-macro-crate 1.3.1", + "proc-macro-crate 3.5.0", "proc-macro2", "quote", "syn 2.0.118", @@ -6913,7 +6996,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.45.0", + "windows-sys 0.61.2", ] [[package]] @@ -7672,7 +7755,7 @@ version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "03da047801ff44bb6a4d407d4860c05fd70bb81714e6b2f3812603d5b145b042" dependencies = [ - "heck 0.4.1", + "heck 0.5.0", "itertools", "log", "multimap", @@ -8870,6 +8953,12 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "scratch" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d68f2ec51b097e4c1a75b681a8bec621909b5e91f15bb7b840c4f2f7b01148b2" + [[package]] name = "scrypt" version = "0.11.0" @@ -9651,6 +9740,16 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "ssstretch" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ee31a0a494b76c8d3047aac804c5f4eb4b6e96c75414e3043b2212301bb8c6" +dependencies = [ + "cxx", + "cxx-build", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -9931,6 +10030,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "sync_wrapper" version = "1.0.2" @@ -10536,7 +10646,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.3", "once_cell", "rustix 1.1.4", "windows-sys 0.61.2", @@ -10551,6 +10661,15 @@ dependencies = [ "new_debug_unreachable", ] +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + [[package]] name = "termina" version = "0.3.3" @@ -12226,7 +12345,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] @@ -12855,8 +12974,8 @@ dependencies = [ "log", "serde", "thiserror 2.0.18", - "windows 0.61.3", - "windows-core 0.61.2", + "windows 0.62.2", + "windows-core 0.62.2", ] [[package]] diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index aed6ab7bf..aea8c2642 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -121,6 +121,7 @@ regex = "1" rusqlite = { version = "0.37", features = ["bundled"] } axum = "0.8" rodio = "0.22" +ssstretch = "0.1.0" earshot = "1.0" rubato = "3.0" audioadapter-buffers = "3.0" diff --git a/desktop/src-tauri/examples/tts_speed_probe.rs b/desktop/src-tauri/examples/tts_speed_probe.rs new file mode 100644 index 000000000..f2a3aba8b --- /dev/null +++ b/desktop/src-tauri/examples/tts_speed_probe.rs @@ -0,0 +1,29 @@ +use std::time::Instant; + +const SAMPLE_RATE: u32 = 24_000; + +fn main() { + let input: Vec = (0..SAMPLE_RATE * 10) + .map(|index| { + (2.0 * std::f32::consts::PI * 220.0 * index as f32 / SAMPLE_RATE as f32).sin() * 0.25 + }) + .collect(); + + for speed in [0.75_f32, 1.25, 1.5] { + let mut processor = ssstretch::Stretch::new(); + processor.preset_default(1, SAMPLE_RATE as f32); + let output_len = (input.len() as f32 / speed).round() as usize; + let inputs = [input.clone()]; + let mut outputs = [Vec::with_capacity(output_len)]; + let latency_ms = processor.output_latency() as f64 * 1_000.0 / SAMPLE_RATE as f64; + let started = Instant::now(); + processor.process_vec(&inputs, input.len() as i32, &mut outputs, output_len as i32); + let elapsed = started.elapsed(); + println!( + "{speed:.2}x: latency={latency_ms:.1}ms, CPU={:.2}ms for 10s ({:.3}% realtime), output={}", + elapsed.as_secs_f64() * 1_000.0, + elapsed.as_secs_f64() / 10.0 * 100.0, + outputs[0].len(), + ); + } +} diff --git a/desktop/src-tauri/src/app_state.rs b/desktop/src-tauri/src/app_state.rs index 3efe04ea6..6f7d29083 100644 --- a/desktop/src-tauri/src/app_state.rs +++ b/desktop/src-tauri/src/app_state.rs @@ -55,6 +55,7 @@ pub struct AppState { /// /// Set once during `setup()` in `lib.rs`; never cleared. pub app_handle: Mutex>, + pub tts_playback_speed: crate::huddle::playback_speed::PlaybackSpeedControl, /// Port of the localhost media streaming proxy (set during setup). pub media_proxy_port: AtomicU16, /// Set when identity resolution detected a "keyring-locked" state: the @@ -213,6 +214,7 @@ pub fn build_app_state() -> AppState { huddle_state: Mutex::new(HuddleState::default()), huddle_audio: Default::default(), app_handle: Mutex::new(None), + tts_playback_speed: crate::huddle::playback_speed::PlaybackSpeedControl::default(), media_proxy_port: AtomicU16::new(0), prevent_sleep: Arc::new(Mutex::new( crate::prevent_sleep::PreventSleepState::default(), diff --git a/desktop/src-tauri/src/huddle/mod.rs b/desktop/src-tauri/src/huddle/mod.rs index 04c675c99..f21d8f33f 100644 --- a/desktop/src-tauri/src/huddle/mod.rs +++ b/desktop/src-tauri/src/huddle/mod.rs @@ -29,6 +29,7 @@ pub mod audio_output; pub mod jitter; pub mod models; pub mod pipeline; +pub mod playback_speed; pub mod playout; pub mod pocket; pub mod preprocessing; diff --git a/desktop/src-tauri/src/huddle/pipeline.rs b/desktop/src-tauri/src/huddle/pipeline.rs index 852e84287..4bba74edb 100644 --- a/desktop/src-tauri/src/huddle/pipeline.rs +++ b/desktop/src-tauri/src/huddle/pipeline.rs @@ -234,6 +234,7 @@ pub(crate) async fn maybe_start_tts_pipeline(state: &AppState) -> Result Result); + +impl Default for PlaybackSpeedControl { + fn default() -> Self { + Self(Arc::new(AtomicU32::new(DEFAULT_PLAYBACK_SPEED.to_bits()))) + } +} + +impl PlaybackSpeedControl { + /// Return the current generated-speech playback speed. + pub fn get(&self) -> f32 { + f32::from_bits(self.0.load(Ordering::Acquire)) + } + + /// Update the in-memory speed after validation. + pub fn set(&self, speed: f32) -> Result<(), String> { + validate_speed(speed)?; + self.0.store(speed.to_bits(), Ordering::Release); + Ok(()) + } +} + +#[derive(Debug, Deserialize, Serialize)] +struct PersistedPlaybackSettings { + speed: f32, +} + +/// Load the global playback speed during app setup. +pub fn load_playback_speed(app: &AppHandle, control: &PlaybackSpeedControl) -> Result<(), String> { + let path = settings_path(app)?; + let speed = load_from_path(&path)?; + control.set(speed) +} + +/// Return the globally configured generated-speech playback speed. +#[tauri::command] +pub fn get_tts_playback_speed(state: State<'_, AppState>) -> f32 { + state.tts_playback_speed.get() +} + +/// Persist and apply the global generated-speech playback speed. +#[tauri::command] +pub fn set_tts_playback_speed( + speed: f32, + app: AppHandle, + state: State<'_, AppState>, +) -> Result<(), String> { + validate_speed(speed)?; + save_to_path(&settings_path(&app)?, speed)?; + state.tts_playback_speed.set(speed) +} + +fn settings_path(app: &AppHandle) -> Result { + app.path() + .app_data_dir() + .map(|directory| directory.join(SETTINGS_FILE)) + .map_err(|error| format!("resolve TTS playback settings directory: {error}")) +} + +fn load_from_path(path: &Path) -> Result { + let bytes = match std::fs::read(path) { + Ok(bytes) => bytes, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Ok(DEFAULT_PLAYBACK_SPEED); + } + Err(error) => return Err(format!("read {}: {error}", path.display())), + }; + let settings: PersistedPlaybackSettings = serde_json::from_slice(&bytes) + .map_err(|error| format!("parse {}: {error}", path.display()))?; + validate_speed(settings.speed)?; + Ok(settings.speed) +} + +fn save_to_path(path: &Path, speed: f32) -> Result<(), String> { + validate_speed(speed)?; + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .map_err(|error| format!("create {}: {error}", parent.display()))?; + } + let payload = serde_json::to_vec_pretty(&PersistedPlaybackSettings { speed }) + .map_err(|error| format!("serialize TTS playback settings: {error}"))?; + crate::managed_agents::storage::atomic_write_json(path, &payload) +} + +#[cfg(test)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ProcessorKind { + Bypass, + Signalsmith, +} + +/// Stateful streaming pitch-preserving processor. +/// +/// Calls to [`Self::process`] preserve input order. The current Pocket path +/// uses [`Self::process_complete_chunk`] because Pocket already hands the +/// player one complete synthesis chunk; a future progressive synthesizer can +/// feed deltas through `process` and pay only the one-time reported latency. +pub struct PlaybackSpeedProcessor { + inner: Processor, + speed: f32, + input_samples: usize, + output_samples: usize, + cancelled: bool, +} + +enum Processor { + Bypass, + Signalsmith(ssstretch::Stretch), +} + +impl PlaybackSpeedProcessor { + /// Select bypass at 1x or Signalsmith Stretch at a non-unity speed. + pub fn new(speed: f32, sample_rate: u32) -> Result { + validate_speed(speed)?; + let inner = if (speed - DEFAULT_PLAYBACK_SPEED).abs() <= UNITY_EPSILON { + Processor::Bypass + } else { + let mut stretch = ssstretch::Stretch::new(); + stretch.preset_default(1, sample_rate as f32); + Processor::Signalsmith(stretch) + }; + Ok(Self { + inner, + speed, + input_samples: 0, + output_samples: 0, + cancelled: false, + }) + } + + #[cfg(test)] + fn kind(&self) -> ProcessorKind { + match self.inner { + Processor::Bypass => ProcessorKind::Bypass, + Processor::Signalsmith(_) => ProcessorKind::Signalsmith, + } + } + + /// One-time output-side algorithmic latency in samples. + pub fn output_latency(&self) -> usize { + match &self.inner { + Processor::Bypass => 0, + Processor::Signalsmith(stretch) => stretch.output_latency().max(0) as usize, + } + } + + /// Process a progressive input delta without reordering prior deltas. + pub fn process(&mut self, input: &[f32]) -> Result, String> { + if input.is_empty() || self.cancelled { + return Ok(Vec::new()); + } + match &mut self.inner { + Processor::Bypass => Ok(input.to_vec()), + Processor::Signalsmith(stretch) => { + self.input_samples = self.input_samples.saturating_add(input.len()); + let target_output = + (self.input_samples as f64 / self.speed as f64).round() as usize; + let output_len = target_output.saturating_sub(self.output_samples); + let input_len = i32_len(input.len())?; + let output_len_i32 = i32_len(output_len)?; + let inputs = [input.to_vec()]; + let mut outputs = [Vec::with_capacity(output_len)]; + stretch.process_vec(&inputs, input_len, &mut outputs, output_len_i32); + self.output_samples = target_output; + Ok(std::mem::take(&mut outputs[0])) + } + } + } + + /// Process one already-buffered Pocket chunk and compensate DSP pre-roll. + /// + /// This returns exactly `input.len() / speed` samples and does not buffer + /// any later Pocket chunk or the remainder of the response. + pub fn process_complete_chunk(&mut self, input: &[f32]) -> Result, String> { + if matches!(self.inner, Processor::Bypass) { + return self.process(input); + } + + let expected = (input.len() as f64 / self.speed as f64).round() as usize; + let latency = self.output_latency(); + let mut output = self.process(input)?; + output.extend(self.drain()?); + + let end = latency.saturating_add(expected); + if output.len() < end { + return Err(format!( + "time stretcher produced {} samples, need {end}", + output.len() + )); + } + Ok(output[latency..end].to_vec()) + } + + /// Discard all pending processor output after barge-in. + #[allow(dead_code)] // Used by progressive synthesis integrations; current Pocket chunks are atomic. + pub fn cancel(&mut self) { + self.cancelled = true; + if let Processor::Signalsmith(stretch) = &mut self.inner { + stretch.reset(); + } + } + + fn drain(&mut self) -> Result, String> { + if self.cancelled { + return Ok(Vec::new()); + } + let Processor::Signalsmith(stretch) = &mut self.inner else { + return Ok(Vec::new()); + }; + let input_latency = stretch.input_latency().max(0) as usize; + let output_latency = stretch.output_latency().max(0) as usize; + let drain_output = (input_latency as f64 / self.speed as f64).ceil() as usize; + + let inputs = [vec![0.0; input_latency]]; + let mut processed = [Vec::with_capacity(drain_output)]; + stretch.process_vec( + &inputs, + i32_len(input_latency)?, + &mut processed, + i32_len(drain_output)?, + ); + let mut flushed = [Vec::with_capacity(output_latency)]; + stretch.flush_vec(&mut flushed, i32_len(output_latency)?); + processed[0].extend_from_slice(&flushed[0]); + Ok(std::mem::take(&mut processed[0])) + } +} + +/// Validate a generated-speech playback speed. +pub fn validate_speed(speed: f32) -> Result<(), String> { + if speed.is_finite() && (MIN_PLAYBACK_SPEED..=MAX_PLAYBACK_SPEED).contains(&speed) { + Ok(()) + } else { + Err(format!( + "Speech playback speed must be between {MIN_PLAYBACK_SPEED} and {MAX_PLAYBACK_SPEED}" + )) + } +} + +fn i32_len(length: usize) -> Result { + i32::try_from(length).map_err(|_| "audio chunk is too large to process".to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + const SAMPLE_RATE: u32 = 24_000; + + #[test] + fn selects_bypass_only_at_unity() { + assert_eq!( + PlaybackSpeedProcessor::new(1.0, SAMPLE_RATE) + .expect("bypass") + .kind(), + ProcessorKind::Bypass + ); + assert_eq!( + PlaybackSpeedProcessor::new(1.25, SAMPLE_RATE) + .expect("stretcher") + .kind(), + ProcessorKind::Signalsmith + ); + } + + #[test] + fn chunked_processing_preserves_length_pitch_and_order() { + let input: Vec = (0..24_000) + .map(|sample| { + let frequency = if sample < 12_000 { 220.0 } else { 440.0 }; + (2.0 * std::f32::consts::PI * frequency * sample as f32 / SAMPLE_RATE as f32).sin() + }) + .collect(); + let mut processor = PlaybackSpeedProcessor::new(1.25, SAMPLE_RATE).expect("processor"); + let output = processor + .process_complete_chunk(&input) + .expect("complete output"); + + assert_eq!(output.len(), 19_200); + let first_frequency = zero_crossing_frequency(&output[2_000..7_000], SAMPLE_RATE); + let second_frequency = zero_crossing_frequency(&output[12_000..17_000], SAMPLE_RATE); + assert!( + (first_frequency - 220.0).abs() < 8.0, + "first segment measured {first_frequency} Hz" + ); + assert!( + (second_frequency - 440.0).abs() <= 10.0, + "second segment measured {second_frequency} Hz" + ); + } + + #[test] + fn cancellation_discards_subsequent_chunks_and_tail() { + let mut processor = PlaybackSpeedProcessor::new(1.25, SAMPLE_RATE).expect("processor"); + assert!(!processor + .process(&vec![0.25; 4_800]) + .expect("first chunk") + .is_empty()); + processor.cancel(); + assert!(processor + .process(&vec![0.5; 4_800]) + .expect("cancelled chunk") + .is_empty()); + assert!(processor.drain().expect("cancelled tail").is_empty()); + } + + #[test] + fn processor_preserves_sine_pitch() { + let frequency = 220.0_f32; + let input: Vec = (0..SAMPLE_RATE * 2) + .map(|sample| { + (2.0 * std::f32::consts::PI * frequency * sample as f32 / SAMPLE_RATE as f32).sin() + }) + .collect(); + let mut processor = PlaybackSpeedProcessor::new(1.5, SAMPLE_RATE).expect("processor"); + let output = processor + .process_complete_chunk(&input) + .expect("complete output"); + let measured = zero_crossing_frequency(&output[2_000..], SAMPLE_RATE); + assert!( + (measured - frequency).abs() < 3.0, + "expected {frequency} Hz, measured {measured} Hz" + ); + } + + #[test] + fn non_unity_latency_stays_below_75_ms() { + let processor = PlaybackSpeedProcessor::new(1.25, SAMPLE_RATE).expect("processor"); + let latency_ms = processor.output_latency() as f64 * 1_000.0 / SAMPLE_RATE as f64; + assert!(latency_ms <= 75.0, "algorithmic latency was {latency_ms}ms"); + } + + #[test] + fn persisted_speed_round_trips_and_rejects_invalid_values() { + let directory = tempfile::tempdir().expect("tempdir"); + let path = directory.path().join(SETTINGS_FILE); + save_to_path(&path, 1.25).expect("save"); + assert_eq!(load_from_path(&path).expect("load"), 1.25); + + std::fs::write(&path, br#"{"speed":2.0}"#).expect("invalid fixture"); + assert!(load_from_path(&path).is_err()); + } + + fn zero_crossing_frequency(samples: &[f32], sample_rate: u32) -> f32 { + let crossings = samples + .windows(2) + .filter(|pair| pair[0] <= 0.0 && pair[1] > 0.0) + .count(); + crossings as f32 * sample_rate as f32 / samples.len() as f32 + } +} diff --git a/desktop/src-tauri/src/huddle/tts.rs b/desktop/src-tauri/src/huddle/tts.rs index c03589f9f..64cda0822 100644 --- a/desktop/src-tauri/src/huddle/tts.rs +++ b/desktop/src-tauri/src/huddle/tts.rs @@ -50,6 +50,7 @@ use std::{ use super::pocket::{ load_text_to_speech, load_voice_style, DEFAULT_VOICE, SAMPLE_RATE, VOICE_FILE_EXT, }; +use super::playback_speed::{PlaybackSpeedControl, PlaybackSpeedProcessor}; use super::preprocessing::{preprocess_for_tts, split_sentences}; #[path = "tts_voice_transition.rs"] @@ -167,6 +168,7 @@ impl TtsPipeline { cancel: Arc, voice: &str, output_device: Option, + playback_speed: PlaybackSpeedControl, ) -> Result { let (text_tx, text_rx) = mpsc::sync_channel::(TEXT_QUEUE_DEPTH); let shutdown = Arc::new(AtomicBool::new(false)); @@ -204,6 +206,7 @@ impl TtsPipeline { ), output_device, startup_tx, + playback_speed, ) }) .map_err(|e| format!("failed to spawn tts-worker thread: {e}"))?; @@ -310,6 +313,7 @@ fn tts_worker( control_state: WorkerControlState, output_device: Option, startup_tx: mpsc::SyncSender>, + playback_speed: PlaybackSpeedControl, ) { let (selected_voice, voice_generation, voice_change_ack) = voice_state; let (tts_active, shutdown, cancel_signals) = control_state; @@ -754,6 +758,20 @@ fn tts_worker( } match synthesis { Ok(samples) if !samples.is_empty() => { + let speed = playback_speed.get(); + let samples = match PlaybackSpeedProcessor::new(speed, SAMPLE_RATE) + .and_then(|mut processor| { + processor.process_complete_chunk(&samples) + }) { + Ok(processed) => processed, + Err(error) => { + eprintln!( + "buzz-desktop: TTS playback-speed processing failed at \ + {speed:.2}x: {error}; using 1x playback" + ); + samples + } + }; if let Some(prepared) = playback_audio.push( samples, chunk_index, diff --git a/desktop/src-tauri/src/huddle/tts_settings.rs b/desktop/src-tauri/src/huddle/tts_settings.rs index a3e931dff..116d4396a 100644 --- a/desktop/src-tauri/src/huddle/tts_settings.rs +++ b/desktop/src-tauri/src/huddle/tts_settings.rs @@ -548,6 +548,7 @@ pub async fn preview_pocket_voice( .lock() .unwrap_or_else(|error| error.into_inner()) .clone(); + let playback_speed = state.tts_playback_speed.clone(); let voice_name = voice .reference_file .and_then(|file| file.strip_suffix(".wav").map(str::to_string)) @@ -561,6 +562,7 @@ pub async fn preview_pocket_voice( cancel, &voice_name, output_device, + playback_speed, )?; pipeline.speak("Hello! This is how I’ll read agent responses.".to_string())?; let started = std::time::Instant::now(); diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 88f4cb7a0..c2f7764ee 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -41,6 +41,7 @@ use deep_link::{ use huddle::audio_output::{ get_audio_output_device, list_audio_output_devices, set_audio_output_device, }; +use huddle::playback_speed::{get_tts_playback_speed, load_playback_speed, set_tts_playback_speed}; use huddle::reconnect::reconnect_huddle_audio; use huddle::{ add_agent_to_huddle, check_pipeline_hotstart, confirm_huddle_active, download_voice_models, @@ -402,6 +403,9 @@ pub fn run() { // that will be lost on restart, as that silently breaks channel // memberships, DMs, and relay identity. let state = app_handle.state::(); + if let Err(error) = load_playback_speed(&app_handle, &state.tts_playback_speed) { + eprintln!("buzz-desktop: failed to load TTS playback speed; using 1x: {error}"); + } if let Err(e) = resolve_persisted_identity(&app_handle, &state) { eprintln!("buzz-desktop: fatal: identity resolution failed: {e}"); std::process::exit(1); @@ -885,6 +889,8 @@ pub fn run() { list_audio_output_devices, set_audio_output_device, get_audio_output_device, + get_tts_playback_speed, + set_tts_playback_speed, start_pairing, confirm_pairing_sas, cancel_pairing, diff --git a/desktop/src/features/settings/ui/SettingsPanels.tsx b/desktop/src/features/settings/ui/SettingsPanels.tsx index 5c997efbc..fd153ca6d 100644 --- a/desktop/src/features/settings/ui/SettingsPanels.tsx +++ b/desktop/src/features/settings/ui/SettingsPanels.tsx @@ -81,6 +81,7 @@ import { PreventSleepSettingsCard } from "./PreventSleepSettingsCard"; import { AgentDefaultsSettingsCard } from "./AgentDefaultsSettingsCard"; import { HostedCommunitiesSettingsCard } from "./HostedCommunitiesSettingsCard"; import { SettingsOptionGroup, SettingsOptionRow } from "./SettingsOptionGroup"; +import { SpeechPlaybackSettings } from "./SpeechPlaybackSettings"; import { ProfileSettingsCard } from "./ProfileSettingsCard"; import { UpdateChecker } from "../UpdateChecker"; import { SettingsSectionHeader } from "./SettingsSectionHeader"; @@ -655,6 +656,7 @@ function ThemeSettingsCard() { )} + ); diff --git a/desktop/src/features/settings/ui/SpeechPlaybackSettings.tsx b/desktop/src/features/settings/ui/SpeechPlaybackSettings.tsx new file mode 100644 index 000000000..0b60a4675 --- /dev/null +++ b/desktop/src/features/settings/ui/SpeechPlaybackSettings.tsx @@ -0,0 +1,109 @@ +import { useEffect, useRef, useState } from "react"; +import { getTtsPlaybackSpeed, setTtsPlaybackSpeed } from "@/shared/api/tauri"; +import { Button } from "@/shared/ui/button"; +import { SettingsOptionGroup, SettingsOptionRow } from "./SettingsOptionGroup"; + +const DEFAULT_SPEED = 1; +const MIN_SPEED = 0.75; +const MAX_SPEED = 1.5; +const SPEED_STEP = 0.05; + +export function SpeechPlaybackSettings() { + const [speed, setSpeed] = useState(DEFAULT_SPEED); + const [error, setError] = useState(null); + const committedSpeed = useRef(DEFAULT_SPEED); + + useEffect(() => { + let active = true; + getTtsPlaybackSpeed() + .then((savedSpeed) => { + if (active) { + committedSpeed.current = savedSpeed; + setSpeed(savedSpeed); + } + }) + .catch((cause) => { + if (active) setError(String(cause)); + }); + return () => { + active = false; + }; + }, []); + + const commitSpeed = async (nextSpeed: number) => { + const previous = committedSpeed.current; + if (nextSpeed === previous) return; + committedSpeed.current = nextSpeed; + setSpeed(nextSpeed); + setError(null); + try { + await setTtsPlaybackSpeed(nextSpeed); + } catch (cause) { + if (committedSpeed.current === nextSpeed) { + committedSpeed.current = previous; + setSpeed(previous); + setError(String(cause)); + } + } + }; + + return ( + + +
+
+
+ +

+ Changes generated speech playback without changing voice pitch. +

+
+
+ + {speed.toFixed(2)}x + + +
+
+ commitSpeed(Number(event.currentTarget.value))} + onChange={(event) => setSpeed(Number(event.currentTarget.value))} + onKeyUp={(event) => commitSpeed(Number(event.currentTarget.value))} + onPointerUp={(event) => + commitSpeed(Number(event.currentTarget.value)) + } + step={SPEED_STEP} + type="range" + value={speed} + /> + {error ? ( +

+ Could not save playback speed: {error} +

+ ) : null} +
+
+
+ ); +} diff --git a/desktop/src/shared/api/tauri.ts b/desktop/src/shared/api/tauri.ts index c57525480..6eb43c619 100644 --- a/desktop/src/shared/api/tauri.ts +++ b/desktop/src/shared/api/tauri.ts @@ -1197,6 +1197,14 @@ export const setPreventSleepActive = (active: boolean) => export const setAgentManagedProfiles = (enabled: boolean) => invokeTauri("set_agent_managed_profiles", { enabled }); +export function getTtsPlaybackSpeed(): Promise { + return invokeTauri("get_tts_playback_speed"); +} + +export function setTtsPlaybackSpeed(speed: number): Promise { + return invokeTauri("set_tts_playback_speed", { speed }); +} + /** Returns true on macOS, Windows, and Linux AppImage installs. * Returns false on Linux non-AppImage packages (e.g. .deb) where * Tauri's updater cannot swap the binary. */