Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions desktop/src-tauri/src/huddle/tts_audio.rs
Original file line number Diff line number Diff line change
Expand Up @@ -165,4 +165,27 @@ mod tests {
.iter()
.all(|sample| *sample == 0.0));
}

#[test]
fn only_the_final_stream_block_receives_a_fade() {
let mut chunk = PlaybackChunkAudio::new();
let mut first_append = true;
let first_samples = vec![0.5; FADE_OUT_SAMPLES * 2];
let final_samples = vec![0.75; FADE_OUT_SAMPLES * 2];

assert!(chunk
.push(first_samples.clone(), 0, &mut first_append, false)
.is_none());
let first = chunk
.push(final_samples.clone(), 1, &mut first_append, false)
.expect("first stream block");
let final_block = chunk
.finish(&mut first_append, false)
.expect("final stream block");

assert_eq!(first.buffer, first_samples);
assert_eq!(final_block.buffer[0], 0.75);
assert_eq!(final_block.buffer.last(), Some(&0.0));
assert_ne!(final_block.buffer, final_samples);
}
}
157 changes: 156 additions & 1 deletion desktop/src-tauri/src/huddle/tts_streaming.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,20 @@ pub(super) fn synthesize_streaming(
signals: (&AtomicBool, &AtomicBool, &AtomicBool),
playback: StreamingPlayback<'_>,
append_audio: &mut dyn FnMut(PreparedModelAudio) -> bool,
) -> Option<&'static str> {
drive_streaming(
|on_audio| engine.synth_chunk_streaming(text, style, emit_frames, on_audio),
signals,
playback,
append_audio,
)
}

fn drive_streaming(
synthesize: impl FnOnce(&mut dyn FnMut(Vec<f32>) -> bool) -> Result<bool, String>,
signals: (&AtomicBool, &AtomicBool, &AtomicBool),
playback: StreamingPlayback<'_>,
append_audio: &mut dyn FnMut(PreparedModelAudio) -> bool,
) -> Option<&'static str> {
let (cancel, voice_cancel, shutdown) = signals;
let StreamingPlayback {
Expand All @@ -59,7 +73,7 @@ pub(super) fn synthesize_streaming(
} = playback;
let mut playback_audio = PlaybackChunkAudio::new();
let mut delta_index = 0usize;
let stream_result = engine.synth_chunk_streaming(text, style, emit_frames, &mut |samples| {
let stream_result = synthesize(&mut |samples| {
if cancel.load(Ordering::Acquire)
|| voice_cancel.load(Ordering::Acquire)
|| shutdown.load(Ordering::Acquire)
Expand Down Expand Up @@ -95,10 +109,151 @@ pub(super) fn synthesize_streaming(
Some("cancelled")
}
Err(_) => {
// Preserve earlier queued speech, but finish the retained tail so
// a partial phrase ends with the same fade as a completed stream.
if let Some(prepared) = playback_audio.finish(first_append, player.empty()) {
if !append_audio(prepared) {
*first_append = true;
return Some("cancelled");
}
}
eprintln!(
"buzz-desktop: tts stage=synthesis status=failed reason=inference route_id={route_id}"
);
Some("failed")
}
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn inference_failure_fades_the_retained_final_block() {
let cancel = AtomicBool::new(false);
let voice_cancel = AtomicBool::new(false);
let shutdown = AtomicBool::new(false);
let mut first_append = true;
let (player, _output) = rodio::Player::new();
let mut appended = Vec::new();

let outcome = drive_streaming(
|on_audio| {
assert!(on_audio(vec![0.4; 16]));
assert!(on_audio(vec![0.5; 16]));
Err("inference failed after output".into())
},
(&cancel, &voice_cancel, &shutdown),
StreamingPlayback {
player: &player,
first_append: &mut first_append,
route_id: 7,
},
&mut |prepared| {
appended.push(prepared);
true
},
);

assert_eq!(outcome, Some("failed"));
assert_eq!(appended.len(), 2);
assert!(appended[0].buffer.ends_with(&[0.4; 16]));
let final_samples = &appended[1].buffer[appended[1].buffer.len() - 16..];
assert_eq!(final_samples.first(), Some(&0.5));
assert_eq!(final_samples.last(), Some(&0.0));
assert!(!first_append, "the partial phrase remains queued");
}

#[test]
fn inference_failure_before_output_preserves_existing_playback() {
let cancel = AtomicBool::new(false);
let voice_cancel = AtomicBool::new(false);
let shutdown = AtomicBool::new(false);
let mut first_append = false;
let (player, _output) = rodio::Player::new();

let outcome = drive_streaming(
|_| Err("inference failed before output".into()),
(&cancel, &voice_cancel, &shutdown),
StreamingPlayback {
player: &player,
first_append: &mut first_append,
route_id: 7,
},
&mut |_| panic!("no audio should be appended"),
);

assert_eq!(outcome, Some("failed"));
assert!(!first_append, "the existing utterance still owns playback");
}

#[test]
fn each_stop_signal_prevents_later_stream_output() {
for signal_index in 0..3 {
let signals = [
AtomicBool::new(false),
AtomicBool::new(false),
AtomicBool::new(false),
];
let mut first_append = true;
let (player, _output) = rodio::Player::new();
let mut appended = 0;

let outcome = drive_streaming(
|on_audio| {
assert!(on_audio(vec![0.4; 16]));
signals[signal_index].store(true, Ordering::Release);
assert!(!on_audio(vec![0.5; 16]));
Ok(false)
},
(&signals[0], &signals[1], &signals[2]),
StreamingPlayback {
player: &player,
first_append: &mut first_append,
route_id: 7,
},
&mut |_| {
appended += 1;
true
},
);

assert_eq!(outcome, Some("cancelled"));
assert_eq!(appended, 0, "no retained block may escape after Stop");
assert!(first_append);
}
}

#[test]
fn rejected_append_never_flushes_the_retained_final_block() {
let cancel = AtomicBool::new(false);
let voice_cancel = AtomicBool::new(false);
let shutdown = AtomicBool::new(false);
let mut first_append = true;
let (player, _output) = rodio::Player::new();
let mut append_attempts = 0;

let outcome = drive_streaming(
|on_audio| {
assert!(on_audio(vec![0.4; 16]));
assert!(!on_audio(vec![0.5; 16]));
Ok(false)
},
(&cancel, &voice_cancel, &shutdown),
StreamingPlayback {
player: &player,
first_append: &mut first_append,
route_id: 7,
},
&mut |_| {
append_attempts += 1;
false
},
);

assert_eq!(outcome, Some("cancelled"));
assert_eq!(append_attempts, 1);
assert!(first_append);
}
}
Loading