Add pitch-preserving TTS playback speed

Signed-off-by: John Tennant <jtennant@squareup.com>
This commit is contained in:
John Tennant
2026-07-29 12:41:58 -04:00
committed by John Tennant
parent 6186fc2313
commit 4e6570ac7d
13 changed files with 695 additions and 14 deletions
+133 -14
View File
@@ -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]]
+1
View File
@@ -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"
@@ -0,0 +1,29 @@
use std::time::Instant;
const SAMPLE_RATE: u32 = 24_000;
fn main() {
let input: Vec<f32> = (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(),
);
}
}
+2
View File
@@ -55,6 +55,7 @@ pub struct AppState {
///
/// Set once during `setup()` in `lib.rs`; never cleared.
pub app_handle: Mutex<Option<AppHandle>>,
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(),
+1
View File
@@ -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;
+2
View File
@@ -234,6 +234,7 @@ pub(crate) async fn maybe_start_tts_pipeline(state: &AppState) -> Result<bool, S
super::tts_settings::pocket_voice_name(&settings.voice_preferences).to_string()
})?;
let constructed_voice = initial_voice.clone();
let playback_speed = state.tts_playback_speed.clone();
let constructed = tokio::task::spawn_blocking(move || {
tts::TtsPipeline::new_with_voice(
model_dir,
@@ -241,6 +242,7 @@ pub(crate) async fn maybe_start_tts_pipeline(state: &AppState) -> Result<bool, S
tts_cancel,
&initial_voice,
output_device,
playback_speed,
)
})
.await;
@@ -0,0 +1,382 @@
//! Pitch-preserving playback speed for generated speech.
//!
//! This stage sits between Pocket synthesis and rodio playback. It is
//! deliberately independent of Pocket's model parameters, and it does not use
//! rodio's speed control because rodio changes pitch and speed together.
use std::{
path::{Path, PathBuf},
sync::{
atomic::{AtomicU32, Ordering},
Arc,
},
};
use serde::{Deserialize, Serialize};
use tauri::{AppHandle, Manager, State};
use crate::app_state::AppState;
/// Slowest supported generated-speech playback speed.
pub const MIN_PLAYBACK_SPEED: f32 = 0.75;
/// Fastest supported generated-speech playback speed.
pub const MAX_PLAYBACK_SPEED: f32 = 1.5;
/// Default generated-speech playback speed.
pub const DEFAULT_PLAYBACK_SPEED: f32 = 1.0;
const SETTINGS_FILE: &str = "tts-playback-settings.json";
const UNITY_EPSILON: f32 = 0.000_1;
/// Lock-free shared control read by the TTS worker before each synthesis chunk.
#[derive(Clone, Debug)]
pub struct PlaybackSpeedControl(Arc<AtomicU32>);
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<PathBuf, String> {
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<f32, String> {
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<Self, String> {
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<Vec<f32>, 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<Vec<f32>, 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<Vec<f32>, 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, String> {
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<f32> = (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<f32> = (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
}
}
+18
View File
@@ -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<AtomicBool>,
voice: &str,
output_device: Option<String>,
playback_speed: PlaybackSpeedControl,
) -> Result<Self, String> {
let (text_tx, text_rx) = mpsc::sync_channel::<QueuedText>(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<String>,
startup_tx: mpsc::SyncSender<Result<(), String>>,
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,
@@ -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();
+6
View File
@@ -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::<AppState>();
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,
@@ -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() {
</AnimatePresence>
)}
<SpeechPlaybackSettings />
<ThreadLayoutSetting />
</section>
);
@@ -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<string | null>(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 (
<SettingsOptionGroup className="mt-8">
<SettingsOptionRow className="items-start">
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-4">
<div>
<label
className="text-sm font-medium"
htmlFor="speech-playback-speed"
>
Speech playback speed
</label>
<p className="text-sm font-normal text-muted-foreground">
Changes generated speech playback without changing voice pitch.
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
<span
aria-live="polite"
className="min-w-10 text-right text-sm tabular-nums"
>
{speed.toFixed(2)}x
</span>
<Button
disabled={speed === DEFAULT_SPEED}
onClick={() => commitSpeed(DEFAULT_SPEED)}
size="sm"
type="button"
variant="outline"
>
Reset
</Button>
</div>
</div>
<input
aria-valuetext={`${speed.toFixed(2)} times`}
className="mt-3 w-full accent-primary"
id="speech-playback-speed"
max={MAX_SPEED}
min={MIN_SPEED}
onBlur={(event) => 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 ? (
<p className="mt-1 text-xs text-destructive" role="alert">
Could not save playback speed: {error}
</p>
) : null}
</div>
</SettingsOptionRow>
</SettingsOptionGroup>
);
}
+8
View File
@@ -1197,6 +1197,14 @@ export const setPreventSleepActive = (active: boolean) =>
export const setAgentManagedProfiles = (enabled: boolean) =>
invokeTauri("set_agent_managed_profiles", { enabled });
export function getTtsPlaybackSpeed(): Promise<number> {
return invokeTauri<number>("get_tts_playback_speed");
}
export function setTtsPlaybackSpeed(speed: number): Promise<void> {
return invokeTauri<void>("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. */