mirror of
https://github.com/block/buzz.git
synced 2026-11-07 09:52:03 +00:00
## Summary - add foreground mobile Huddles on Android and iOS with native Opus capture/playback, mute, speaker routing, participants, lifecycle, and minimized drawer UI - keep mobile Huddle cards and roster state live, including ended rooms, relay-resolved profiles, and agents - broadcast desktop agent TTS through the existing Huddle audio protocol ## Scope Foreground human-to-human voice MVP only. Agent setup/transcripts, background calling, recording, and advanced device controls remain out of scope. ## Validation - `just mobile-check` - `just mobile-test` — 1,500 passed - `just desktop-check` and `just desktop-test` — 4,957 passed - desktop typecheck, strict Clippy, and Tauri tests — 2,445 passed, 15 ignored - mobile worktree identity contract checks - physical Pixel/iPhone behavior reviewed during development --------- Signed-off-by: kenny lopez <klopez4212@gmail.com> Signed-off-by: Kenny Lopez <klopez4212@gmail.com> Signed-off-by: Princess Donut <b238ea756dee4d98afa5883fc7f1de61eeabe65bf700e3a5a5a80db5e42e2c2b@buzz.block.builderlab.xyz> Signed-off-by: Tom Brow <tomb@block.xyz> Co-authored-by: Carl <3c4caeafb646d23867f1c4832e68211d77e2561946171625f75c3ce1a3f2670f@buzz.block.builderlab.xyz> Co-authored-by: Princess Donut <b238ea756dee4d98afa5883fc7f1de61eeabe65bf700e3a5a5a80db5e42e2c2b@buzz.block.builderlab.xyz> Co-authored-by: leader <71e9f2c44a6932b6772caaaccda1911d010463c3e2c6c40410b8329956046801@buzz.block.builderlab.xyz> Co-authored-by: Tom Brow <tomb@block.xyz> Co-authored-by: Mongo <9cfd347903944d5b85aa6c93d2ab67381b978a92a31914bca69998968752a1d7@buzz.block.builderlab.xyz>
975 lines
35 KiB
Rust
975 lines
35 KiB
Rust
//! Tests for huddle/tts.rs — split into a sibling file to keep tts.rs
|
||
//! focused. These exercise the pure helpers and the lifecycle contracts:
|
||
//! remote-interrupt frame counting, cancel consumption, the worker idle
|
||
//! branch, buffer building, fades, and playback gain.
|
||
|
||
use super::*;
|
||
use std::collections::HashMap;
|
||
use std::sync::atomic::{AtomicBool, Ordering};
|
||
use std::sync::mpsc;
|
||
use std::sync::{Arc, Mutex};
|
||
|
||
#[path = "tts_tests/token_split.rs"]
|
||
mod token_split;
|
||
|
||
// ── Human-floor queue authorization ───────────────────────────────────────
|
||
|
||
fn queued_text(route_id: u64, floor_epoch: u64) -> QueuedText {
|
||
QueuedText {
|
||
generation: 1,
|
||
floor_epoch,
|
||
route_id,
|
||
speaker_pubkey: None,
|
||
speaker_generation: 0,
|
||
voice_reference: None,
|
||
text: "queued while a human is speaking".to_string(),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn production_worker_append_authorization_completes() {
|
||
let (completed_tx, completed_rx) = mpsc::sync_channel(1);
|
||
let worker = std::thread::spawn(move || {
|
||
let human_floor = HumanFloor::new();
|
||
let playback = human_floor.playback();
|
||
let channels = NonZero::new(1).expect("nonzero channels");
|
||
let rate = NonZero::new(SAMPLE_RATE).expect("nonzero rate");
|
||
let (mixer, _unpulled_source) = rodio::mixer::mixer(channels, rate);
|
||
playback.bind_mixer(&mixer);
|
||
let floor_epoch = human_floor.epoch();
|
||
let cancel = AtomicBool::new(false);
|
||
let voice_cancel = AtomicBool::new(false);
|
||
let shutdown = AtomicBool::new(false);
|
||
let tts_active = AtomicBool::new(false);
|
||
let speaker_generations = Arc::new(Mutex::new(HashMap::new()));
|
||
let active_speaker = Arc::new(Mutex::new(None));
|
||
let activity_frames = Mutex::new(VecDeque::new());
|
||
let context = TtsAppendContext {
|
||
playback: &playback,
|
||
human_floor: &human_floor,
|
||
cancel: &cancel,
|
||
voice_cancel: &voice_cancel,
|
||
shutdown: &shutdown,
|
||
tts_active: &tts_active,
|
||
speaker_generations: &speaker_generations,
|
||
active_speaker: &active_speaker,
|
||
activity_frames: &activity_frames,
|
||
broadcasters: &TtsBroadcasters::default(),
|
||
channels,
|
||
rate,
|
||
};
|
||
|
||
let accepted = append_worker_audio(
|
||
&context,
|
||
PreparedModelAudio {
|
||
buffer: vec![0.25; SAMPLE_RATE as usize],
|
||
sample_count: SAMPLE_RATE as usize,
|
||
chunk_index: 0,
|
||
},
|
||
40,
|
||
None,
|
||
0,
|
||
floor_epoch,
|
||
|| {},
|
||
);
|
||
completed_tx
|
||
.send((accepted, tts_active.load(Ordering::Acquire)))
|
||
.expect("completion receiver");
|
||
});
|
||
|
||
assert_eq!(
|
||
completed_rx
|
||
.recv_timeout(std::time::Duration::from_secs(1))
|
||
.expect("production worker append authorization must not deadlock"),
|
||
(true, true)
|
||
);
|
||
worker.join().expect("append worker");
|
||
}
|
||
|
||
#[test]
|
||
fn worker_queue_defers_text_while_floor_is_held_then_releases_it() {
|
||
let human_floor = HumanFloor::new();
|
||
assert!(human_floor.enter_local(true, false));
|
||
let floor_epoch = human_floor.epoch();
|
||
let mut deferred = VecDeque::new();
|
||
|
||
assert!(matches!(
|
||
authorize_or_defer_queued_text(&human_floor, &mut deferred, queued_text(41, floor_epoch),),
|
||
Err(HumanFloorAuthorization::Blocked)
|
||
));
|
||
assert_eq!(deferred.len(), 1, "held-floor text must stay queued");
|
||
|
||
human_floor.leave_local();
|
||
let queued = deferred.pop_front().expect("deferred text");
|
||
let released = authorize_or_defer_queued_text(&human_floor, &mut deferred, queued)
|
||
.expect("the same queue item is eligible after floor release");
|
||
|
||
assert_eq!(released.route_id, 41);
|
||
assert!(deferred.is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn worker_queue_drops_text_from_before_human_onset() {
|
||
let human_floor = HumanFloor::new();
|
||
let stale_epoch = human_floor.epoch();
|
||
assert!(human_floor.enter_local(true, false));
|
||
human_floor.leave_local();
|
||
let mut deferred = VecDeque::new();
|
||
|
||
assert!(matches!(
|
||
authorize_or_defer_queued_text(&human_floor, &mut deferred, queued_text(42, stale_epoch),),
|
||
Err(HumanFloorAuthorization::Stale)
|
||
));
|
||
assert!(deferred.is_empty(), "pre-barge-in text must not replay");
|
||
}
|
||
|
||
// ── Remote interrupt tracker ──────────────────────────────────────────────
|
||
//
|
||
// Models the per-peer frame counting logic in the recv task of
|
||
// relay_api.rs. The contract (must match the production implementation):
|
||
//
|
||
// - Frame counting is GATED on tts_active — counters only increment
|
||
// while TTS is playing.
|
||
// - On TTS session start (false→true transition), all counters and
|
||
// the window timer are reset. Prevents stale pre-playback speech
|
||
// from tripping a cancel.
|
||
// - In production, counting happens after successful Opus decode
|
||
// (Ok(n) if n > 0). We can't model decode in unit tests, so
|
||
// on_frame() represents a successfully-decoded audio frame.
|
||
// - Each remote peer has an independent frame counter.
|
||
// - Counters use saturating_add (overflow-safe).
|
||
// - When a peer's counter crosses REMOTE_SPEECH_THRESHOLD, set
|
||
// tts_cancel = true.
|
||
// - Counters reset on the 500ms window (Instant-based in production,
|
||
// on_tick() in tests — logically equivalent).
|
||
// - Uses Acquire for tts_active reads, Release for tts_cancel writes.
|
||
|
||
#[test]
|
||
fn tts_speaker_activity_uses_the_playback_waveform() {
|
||
let mut samples = vec![0.0; 1_200];
|
||
samples.extend(vec![0.25; 1_200]);
|
||
|
||
let frames = build_tts_speaker_activity_frames(&samples, "agent-pubkey", 24_000);
|
||
|
||
assert_eq!(frames.len(), 2);
|
||
assert_eq!(frames[0].pubkey, "agent-pubkey");
|
||
assert_eq!(frames[0].level, 0.0);
|
||
assert!(frames[1].level > 0.5);
|
||
}
|
||
//
|
||
use crate::huddle::relay_api::REMOTE_SPEECH_THRESHOLD;
|
||
|
||
/// Test-side model of the per-peer frame counting logic in the recv task.
|
||
struct RemoteInterruptTracker {
|
||
frame_counts: HashMap<u8, u16>,
|
||
tts_active: Arc<AtomicBool>,
|
||
tts_cancel: Arc<AtomicBool>,
|
||
/// Tracks the previous tts_active state to detect false→true transitions.
|
||
/// Mirrors `tts_was_active` in the production recv task.
|
||
tts_was_active: bool,
|
||
}
|
||
|
||
impl RemoteInterruptTracker {
|
||
fn new(tts_active: Arc<AtomicBool>, tts_cancel: Arc<AtomicBool>) -> Self {
|
||
Self {
|
||
frame_counts: HashMap::new(),
|
||
tts_active,
|
||
tts_cancel,
|
||
tts_was_active: false,
|
||
}
|
||
}
|
||
|
||
/// Called when a successfully-decoded audio frame arrives from a
|
||
/// remote peer. Mirrors the production logic in relay_api.rs:
|
||
/// 1. Check tts_active — skip if inactive
|
||
/// 2. On false→true transition, clear all counters (new TTS session)
|
||
/// 3. Increment peer counter (saturating)
|
||
/// 4. Fire cancel if threshold crossed
|
||
fn on_frame(&mut self, peer_idx: u8) {
|
||
let tts_now = self.tts_active.load(Ordering::Acquire);
|
||
|
||
// Detect TTS session start — clear stale counters.
|
||
if tts_now && !self.tts_was_active {
|
||
self.frame_counts.clear();
|
||
}
|
||
self.tts_was_active = tts_now;
|
||
|
||
if !tts_now {
|
||
return; // Not counting while TTS is inactive.
|
||
}
|
||
|
||
let count = self.frame_counts.entry(peer_idx).or_insert(0);
|
||
*count = count.saturating_add(1);
|
||
if *count >= REMOTE_SPEECH_THRESHOLD {
|
||
self.tts_cancel.store(true, Ordering::Release);
|
||
}
|
||
}
|
||
|
||
/// Called on the 500ms window boundary — resets all frame counters.
|
||
/// In production this is Instant-based (starvation-proof); in tests
|
||
/// we call it explicitly since there's no async event loop.
|
||
fn on_tick(&mut self) {
|
||
self.frame_counts.clear();
|
||
}
|
||
|
||
fn count_for(&self, peer_idx: u8) -> u16 {
|
||
*self.frame_counts.get(&peer_idx).unwrap_or(&0)
|
||
}
|
||
}
|
||
|
||
/// Simulate the TTS worker's cancel-handling logic (from handle_cancel_or_shutdown).
|
||
/// Returns true if cancel was processed (mirrors the real function's return value).
|
||
fn simulate_cancel_consumption(
|
||
cancel: &AtomicBool,
|
||
shutdown: &AtomicBool,
|
||
tts_active: &AtomicBool,
|
||
text_rx: &mpsc::Receiver<String>,
|
||
) -> bool {
|
||
if shutdown.load(Ordering::Acquire) {
|
||
tts_active.store(false, Ordering::Release);
|
||
return true;
|
||
}
|
||
if cancel.load(Ordering::Acquire) {
|
||
while text_rx.try_recv().is_ok() {}
|
||
cancel.store(false, Ordering::Release);
|
||
tts_active.store(false, Ordering::Release);
|
||
return true;
|
||
}
|
||
false
|
||
}
|
||
|
||
/// Simulate the drained-player check in the TTS worker's main loop. It
|
||
/// runs in TWO places — the recv-timeout (idle) arm, and on item receipt
|
||
/// before synthesis begins: `tts_active` is released and the lead-in
|
||
/// re-armed only when the player has fully drained AND audio was actually
|
||
/// queued since the last idle period. Must match the production logic in
|
||
/// `tts_worker`.
|
||
fn simulate_idle_check(player_empty: bool, first_append: &mut bool, tts_active: &AtomicBool) {
|
||
if player_empty && !*first_append {
|
||
tts_active.store(false, Ordering::Release);
|
||
*first_append = true;
|
||
}
|
||
}
|
||
|
||
// ── Threshold tests ───────────────────────────────────────────────────────
|
||
|
||
/// Real speech above threshold during TTS → cancel fires.
|
||
#[test]
|
||
fn speech_above_threshold_sets_cancel() {
|
||
let tts_active = Arc::new(AtomicBool::new(true));
|
||
let tts_cancel = Arc::new(AtomicBool::new(false));
|
||
let mut tracker = RemoteInterruptTracker::new(Arc::clone(&tts_active), Arc::clone(&tts_cancel));
|
||
|
||
// Send exactly REMOTE_SPEECH_THRESHOLD frames from peer 1.
|
||
for _ in 0..REMOTE_SPEECH_THRESHOLD {
|
||
tracker.on_frame(1);
|
||
}
|
||
|
||
assert!(
|
||
tts_cancel.load(Ordering::Acquire),
|
||
"tts_cancel should be true after {} frames from a peer during active TTS",
|
||
REMOTE_SPEECH_THRESHOLD,
|
||
);
|
||
}
|
||
|
||
/// DTX comfort noise below threshold during TTS → cancel does NOT fire.
|
||
#[test]
|
||
fn comfort_noise_below_threshold_no_cancel() {
|
||
let tts_active = Arc::new(AtomicBool::new(true));
|
||
let tts_cancel = Arc::new(AtomicBool::new(false));
|
||
let mut tracker = RemoteInterruptTracker::new(Arc::clone(&tts_active), Arc::clone(&tts_cancel));
|
||
|
||
// DTX comfort noise: ~2-3 frames per 500ms tick. Send fewer than threshold.
|
||
for _ in 0..(REMOTE_SPEECH_THRESHOLD - 1) {
|
||
tracker.on_frame(1);
|
||
}
|
||
|
||
assert!(
|
||
!tts_cancel.load(Ordering::Acquire),
|
||
"tts_cancel should remain false with only {} frames (below threshold of {})",
|
||
REMOTE_SPEECH_THRESHOLD - 1,
|
||
REMOTE_SPEECH_THRESHOLD,
|
||
);
|
||
}
|
||
|
||
/// Frames arrive while tts_active=false → no cancel regardless of count.
|
||
#[test]
|
||
fn frames_without_tts_active_no_cancel() {
|
||
let tts_active = Arc::new(AtomicBool::new(false));
|
||
let tts_cancel = Arc::new(AtomicBool::new(false));
|
||
let mut tracker = RemoteInterruptTracker::new(Arc::clone(&tts_active), Arc::clone(&tts_cancel));
|
||
|
||
// Send many frames — well above threshold.
|
||
for _ in 0..50 {
|
||
tracker.on_frame(1);
|
||
}
|
||
|
||
assert!(
|
||
!tts_cancel.load(Ordering::Acquire),
|
||
"tts_cancel should remain false when TTS is not active",
|
||
);
|
||
}
|
||
|
||
/// Counter reset on tick → peer must re-accumulate frames to trigger cancel.
|
||
#[test]
|
||
fn tick_resets_counters() {
|
||
let tts_active = Arc::new(AtomicBool::new(true));
|
||
let tts_cancel = Arc::new(AtomicBool::new(false));
|
||
let mut tracker = RemoteInterruptTracker::new(Arc::clone(&tts_active), Arc::clone(&tts_cancel));
|
||
|
||
// Send frames just below threshold.
|
||
for _ in 0..(REMOTE_SPEECH_THRESHOLD - 1) {
|
||
tracker.on_frame(1);
|
||
}
|
||
assert!(!tts_cancel.load(Ordering::Acquire));
|
||
|
||
// Tick resets counters.
|
||
tracker.on_tick();
|
||
assert_eq!(tracker.count_for(1), 0, "counter should be zero after tick");
|
||
|
||
// Send same number again — still below threshold because counter was reset.
|
||
for _ in 0..(REMOTE_SPEECH_THRESHOLD - 1) {
|
||
tracker.on_frame(1);
|
||
}
|
||
assert!(
|
||
!tts_cancel.load(Ordering::Acquire),
|
||
"cancel should not fire: counter was reset by tick, frames still below threshold",
|
||
);
|
||
}
|
||
|
||
/// Per-peer isolation: peer A's silence does not reset peer B's accumulation.
|
||
#[test]
|
||
fn per_peer_counters_are_independent() {
|
||
let tts_active = Arc::new(AtomicBool::new(true));
|
||
let tts_cancel = Arc::new(AtomicBool::new(false));
|
||
let mut tracker = RemoteInterruptTracker::new(Arc::clone(&tts_active), Arc::clone(&tts_cancel));
|
||
|
||
// Peer 1 sends frames below threshold (DTX comfort noise).
|
||
for _ in 0..2 {
|
||
tracker.on_frame(1);
|
||
}
|
||
|
||
// Peer 2 sends frames above threshold (real speech).
|
||
for _ in 0..REMOTE_SPEECH_THRESHOLD {
|
||
tracker.on_frame(2);
|
||
}
|
||
|
||
assert!(
|
||
tts_cancel.load(Ordering::Acquire),
|
||
"peer 2 should trigger cancel independently of peer 1's low count",
|
||
);
|
||
assert_eq!(
|
||
tracker.count_for(1),
|
||
2,
|
||
"peer 1 counter should be untouched"
|
||
);
|
||
assert_eq!(
|
||
tracker.count_for(2),
|
||
REMOTE_SPEECH_THRESHOLD,
|
||
"peer 2 counter should be at threshold",
|
||
);
|
||
}
|
||
|
||
/// Multiple peers both above threshold → cancel fires (idempotent).
|
||
#[test]
|
||
fn multiple_peers_above_threshold_idempotent() {
|
||
let tts_active = Arc::new(AtomicBool::new(true));
|
||
let tts_cancel = Arc::new(AtomicBool::new(false));
|
||
let mut tracker = RemoteInterruptTracker::new(Arc::clone(&tts_active), Arc::clone(&tts_cancel));
|
||
|
||
// Both peers send above threshold.
|
||
for _ in 0..REMOTE_SPEECH_THRESHOLD {
|
||
tracker.on_frame(1);
|
||
tracker.on_frame(2);
|
||
}
|
||
|
||
assert!(
|
||
tts_cancel.load(Ordering::Acquire),
|
||
"cancel should be set when multiple peers exceed threshold",
|
||
);
|
||
// tts_active should still be true — only the TTS worker resets it.
|
||
assert!(
|
||
tts_active.load(Ordering::Acquire),
|
||
"tts_active should remain true (only TTS worker clears it)",
|
||
);
|
||
}
|
||
|
||
/// tts_cancel already true → setting again is harmless (AtomicBool store).
|
||
#[test]
|
||
fn cancel_already_true_is_harmless() {
|
||
let tts_active = Arc::new(AtomicBool::new(true));
|
||
let tts_cancel = Arc::new(AtomicBool::new(true)); // Already cancelled.
|
||
let mut tracker = RemoteInterruptTracker::new(Arc::clone(&tts_active), Arc::clone(&tts_cancel));
|
||
|
||
for _ in 0..REMOTE_SPEECH_THRESHOLD {
|
||
tracker.on_frame(1);
|
||
}
|
||
|
||
assert!(
|
||
tts_cancel.load(Ordering::Acquire),
|
||
"tts_cancel should still be true (idempotent store)",
|
||
);
|
||
}
|
||
|
||
// ── Cancel consumption tests (TTS worker side) ────────────────────────────
|
||
|
||
/// TTS worker correctly resets both tts_cancel and tts_active after cancel.
|
||
#[test]
|
||
fn cancel_consumption_resets_flags() {
|
||
let cancel = AtomicBool::new(true);
|
||
let shutdown = AtomicBool::new(false);
|
||
let tts_active = AtomicBool::new(true);
|
||
let (_tx, rx) = mpsc::channel::<String>();
|
||
|
||
let handled = simulate_cancel_consumption(&cancel, &shutdown, &tts_active, &rx);
|
||
|
||
assert!(handled, "should return true when cancel is set");
|
||
assert!(
|
||
!cancel.load(Ordering::Acquire),
|
||
"cancel should be reset to false after consumption",
|
||
);
|
||
assert!(
|
||
!tts_active.load(Ordering::Acquire),
|
||
"tts_active should be reset to false after cancel",
|
||
);
|
||
}
|
||
|
||
/// TTS worker drains the text queue on cancel.
|
||
#[test]
|
||
fn cancel_consumption_drains_queue() {
|
||
let cancel = AtomicBool::new(true);
|
||
let shutdown = AtomicBool::new(false);
|
||
let tts_active = AtomicBool::new(true);
|
||
let (tx, rx) = mpsc::channel::<String>();
|
||
|
||
tx.send("sentence one".to_string()).unwrap();
|
||
tx.send("sentence two".to_string()).unwrap();
|
||
tx.send("sentence three".to_string()).unwrap();
|
||
|
||
let handled = simulate_cancel_consumption(&cancel, &shutdown, &tts_active, &rx);
|
||
|
||
assert!(handled);
|
||
assert!(
|
||
rx.try_recv().is_err(),
|
||
"text queue should be drained after cancel",
|
||
);
|
||
}
|
||
|
||
/// No cancel, no shutdown → TTS worker continues (returns false).
|
||
#[test]
|
||
fn no_cancel_no_shutdown_returns_false() {
|
||
let cancel = AtomicBool::new(false);
|
||
let shutdown = AtomicBool::new(false);
|
||
let tts_active = AtomicBool::new(true);
|
||
let (_tx, rx) = mpsc::channel::<String>();
|
||
|
||
let handled = simulate_cancel_consumption(&cancel, &shutdown, &tts_active, &rx);
|
||
|
||
assert!(
|
||
!handled,
|
||
"should return false when neither cancel nor shutdown is set",
|
||
);
|
||
assert!(
|
||
tts_active.load(Ordering::Acquire),
|
||
"tts_active should remain true",
|
||
);
|
||
}
|
||
|
||
/// Shutdown takes priority over cancel — cancel flag is NOT reset.
|
||
#[test]
|
||
fn shutdown_takes_priority_over_cancel() {
|
||
let cancel = AtomicBool::new(true);
|
||
let shutdown = AtomicBool::new(true);
|
||
let tts_active = AtomicBool::new(true);
|
||
let (_tx, rx) = mpsc::channel::<String>();
|
||
|
||
let handled = simulate_cancel_consumption(&cancel, &shutdown, &tts_active, &rx);
|
||
|
||
assert!(handled, "should return true on shutdown");
|
||
assert!(
|
||
!tts_active.load(Ordering::Acquire),
|
||
"tts_active should be false after shutdown",
|
||
);
|
||
assert!(
|
||
cancel.load(Ordering::Acquire),
|
||
"cancel should remain true (shutdown path doesn't reset it)",
|
||
);
|
||
}
|
||
|
||
// ── Full lifecycle tests ──────────────────────────────────────────────────
|
||
|
||
/// Idle branch: nothing queued + player drained + audio was played →
|
||
/// release `tts_active` and re-arm the lead-in for the next utterance.
|
||
#[test]
|
||
fn idle_check_releases_mic_gate_after_drain() {
|
||
let tts_active = AtomicBool::new(true);
|
||
let mut first_append = false; // audio was appended this utterance
|
||
|
||
simulate_idle_check(true, &mut first_append, &tts_active);
|
||
|
||
assert!(
|
||
!tts_active.load(Ordering::Acquire),
|
||
"tts_active should be released once playback drains",
|
||
);
|
||
assert!(
|
||
first_append,
|
||
"lead-in should be re-armed for next utterance"
|
||
);
|
||
}
|
||
|
||
/// Idle branch: nothing queued but audio is STILL DRAINING → `tts_active`
|
||
/// stays set. This is the cross-item pipelining contract: the per-item
|
||
/// drain barrier is gone, so the mic gate must hold across the gap
|
||
/// between item N finishing synthesis and its audio finishing playback.
|
||
#[test]
|
||
fn idle_check_holds_mic_gate_while_audio_drains() {
|
||
let tts_active = AtomicBool::new(true);
|
||
let mut first_append = false;
|
||
|
||
simulate_idle_check(false, &mut first_append, &tts_active);
|
||
|
||
assert!(
|
||
tts_active.load(Ordering::Acquire),
|
||
"tts_active must stay set while queued audio is still playing",
|
||
);
|
||
assert!(!first_append, "lead-in must not re-arm mid-playback");
|
||
}
|
||
|
||
/// Idle branch: player empty but nothing was ever appended (e.g. worker
|
||
/// just started, or every item preprocessed to empty) → no spurious
|
||
/// store, lead-in stays armed.
|
||
#[test]
|
||
fn idle_check_noop_when_nothing_was_queued() {
|
||
let tts_active = AtomicBool::new(false);
|
||
let mut first_append = true;
|
||
|
||
simulate_idle_check(true, &mut first_append, &tts_active);
|
||
|
||
assert!(!tts_active.load(Ordering::Acquire));
|
||
assert!(first_append, "lead-in should remain armed");
|
||
}
|
||
|
||
/// On-receipt check (PR #997 review blocker): item N's audio drains, then
|
||
/// item N+1 arrives BEFORE the recv timeout fires. The same drained-player
|
||
/// check must run on receipt so `tts_active` is released before the
|
||
/// synthesis pass — otherwise STT discards human speech as "echo" for the
|
||
/// whole synthesis window while the agent is actually silent.
|
||
#[test]
|
||
fn on_receipt_check_releases_mic_gate_before_synthesis() {
|
||
let tts_active = AtomicBool::new(true);
|
||
let mut first_append = false; // item N appended audio, now fully drained
|
||
|
||
// Item N+1 received with the player already empty: release + re-arm.
|
||
simulate_idle_check(true, &mut first_append, &tts_active);
|
||
|
||
assert!(
|
||
!tts_active.load(Ordering::Acquire),
|
||
"tts_active must be released before synthesizing the next item \
|
||
when playback has already drained",
|
||
);
|
||
assert!(
|
||
first_append,
|
||
"lead-in must re-arm so the next utterance gets a fresh cushion"
|
||
);
|
||
}
|
||
|
||
// ── Barge-in monitor ──────────────────────────────────────────────────────
|
||
//
|
||
// Models one tick of the tts-barge-in-monitor thread in `tts_worker`. The
|
||
// contract (must match production):
|
||
// - cancel set → take `player_ops`, RE-CHECK cancel under the lock; if
|
||
// still set: silence player + release tts_active, flag NOT consumed
|
||
// (the worker owns consumption: queue drain + lead-in reset)
|
||
// - cancel cleared by the time the lock is held → no-op (stale branch)
|
||
// - cancel clear → no-op
|
||
// Not consuming the flag is what makes the monitor idempotent across ticks
|
||
// and closes the race where the worker appends a sentence after the
|
||
// monitor's clear but before consuming the flag. The under-lock re-check
|
||
// closes the converse race (PR #997 review blocker): worker consumes the
|
||
// cancel and appends a fresh post-cancel utterance between the monitor's
|
||
// initial load and its clear — the stale branch must not delete that audio.
|
||
|
||
/// Test-side model of one monitor tick. `player_cleared` stands in for the
|
||
/// `clear()+play()` pair on the real Player; `player_ops` is the mutex
|
||
/// serializing monitor clears with worker player mutations.
|
||
fn simulate_monitor_tick(
|
||
cancel: &AtomicBool,
|
||
tts_active: &AtomicBool,
|
||
player_ops: &Mutex<()>,
|
||
player_cleared: &mut bool,
|
||
) {
|
||
if cancel.load(Ordering::Acquire) {
|
||
let _ops = player_ops.lock().unwrap();
|
||
if cancel.load(Ordering::Acquire) {
|
||
*player_cleared = true;
|
||
tts_active.store(false, Ordering::Release);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Cancel set → monitor silences playback and releases the mic gate, but
|
||
/// leaves the flag for the worker to consume.
|
||
#[test]
|
||
fn monitor_tick_silences_and_releases_without_consuming_cancel() {
|
||
let cancel = AtomicBool::new(true);
|
||
let tts_active = AtomicBool::new(true);
|
||
let player_ops = Mutex::new(());
|
||
let mut player_cleared = false;
|
||
|
||
simulate_monitor_tick(&cancel, &tts_active, &player_ops, &mut player_cleared);
|
||
|
||
assert!(player_cleared, "monitor must silence in-flight audio");
|
||
assert!(
|
||
!tts_active.load(Ordering::Acquire),
|
||
"monitor must release the mic gate immediately on barge-in",
|
||
);
|
||
assert!(
|
||
cancel.load(Ordering::Acquire),
|
||
"monitor must NOT consume the cancel flag — the worker owns \
|
||
queue drain and lead-in reset",
|
||
);
|
||
}
|
||
|
||
/// No cancel → monitor is a pure no-op.
|
||
#[test]
|
||
fn monitor_tick_noop_without_cancel() {
|
||
let cancel = AtomicBool::new(false);
|
||
let tts_active = AtomicBool::new(true);
|
||
let player_ops = Mutex::new(());
|
||
let mut player_cleared = false;
|
||
|
||
simulate_monitor_tick(&cancel, &tts_active, &player_ops, &mut player_cleared);
|
||
|
||
assert!(!player_cleared);
|
||
assert!(
|
||
tts_active.load(Ordering::Acquire),
|
||
"monitor must not touch the mic gate while no barge-in is pending",
|
||
);
|
||
}
|
||
|
||
/// Stale-branch race (PR #997 review blocker): monitor observes
|
||
/// `cancel == true`, then the worker — under `player_ops` — consumes the
|
||
/// cancel and appends a fresh post-cancel utterance before the monitor
|
||
/// reaches its clear. The monitor's under-lock re-check must see
|
||
/// `cancel == false` and no-op, leaving the fresh audio and its mic gate
|
||
/// intact.
|
||
#[test]
|
||
fn monitor_stale_cancel_branch_must_not_clear_fresh_audio() {
|
||
let cancel = AtomicBool::new(true);
|
||
let tts_active = AtomicBool::new(true);
|
||
let player_ops = Mutex::new(());
|
||
let mut player_cleared = false;
|
||
|
||
// Monitor's initial (pre-lock) load observes the cancel…
|
||
let stale_observation = cancel.load(Ordering::Acquire);
|
||
assert!(stale_observation);
|
||
|
||
// …then the worker wins the lock: consumes the cancel, appends a fresh
|
||
// utterance, sets tts_active (mirrors handle_cancel_or_shutdown +
|
||
// the locked append in the sentence loop).
|
||
{
|
||
let _ops = player_ops.lock().unwrap();
|
||
cancel.store(false, Ordering::Release);
|
||
tts_active.store(true, Ordering::Release);
|
||
}
|
||
|
||
// Monitor resumes from its stale branch — the re-check under the lock
|
||
// must turn it into a no-op.
|
||
if stale_observation {
|
||
let _ops = player_ops.lock().unwrap();
|
||
if cancel.load(Ordering::Acquire) {
|
||
player_cleared = true;
|
||
tts_active.store(false, Ordering::Release);
|
||
}
|
||
}
|
||
|
||
assert!(
|
||
!player_cleared,
|
||
"stale monitor branch must not clear a fresh post-cancel utterance",
|
||
);
|
||
assert!(
|
||
tts_active.load(Ordering::Acquire),
|
||
"stale monitor branch must not release the mic gate while fresh \
|
||
audio is playing",
|
||
);
|
||
}
|
||
|
||
/// Full cycle: remote speech → cancel → TTS consumption → new TTS → cancel again.
|
||
/// Validates the cancel mechanism is reusable across TTS sessions.
|
||
/// The false→true transition on tts_active auto-clears counters — no
|
||
/// explicit on_tick() needed between sessions.
|
||
#[test]
|
||
fn full_cancel_cycle_is_reusable() {
|
||
let tts_active = Arc::new(AtomicBool::new(false));
|
||
let tts_cancel = Arc::new(AtomicBool::new(false));
|
||
let shutdown = AtomicBool::new(false);
|
||
let (_tx, rx) = mpsc::channel::<String>();
|
||
|
||
let mut tracker = RemoteInterruptTracker::new(Arc::clone(&tts_active), Arc::clone(&tts_cancel));
|
||
|
||
// Cycle 1: TTS starts, remote speech triggers cancel, TTS consumes.
|
||
tts_active.store(true, Ordering::Release);
|
||
for _ in 0..REMOTE_SPEECH_THRESHOLD {
|
||
tracker.on_frame(1);
|
||
}
|
||
assert!(tts_cancel.load(Ordering::Acquire));
|
||
|
||
let handled = simulate_cancel_consumption(&tts_cancel, &shutdown, &tts_active, &rx);
|
||
assert!(handled);
|
||
assert!(!tts_cancel.load(Ordering::Acquire));
|
||
assert!(!tts_active.load(Ordering::Acquire));
|
||
|
||
// No on_tick() needed — the false→true transition auto-clears counters.
|
||
|
||
// Cycle 2: New TTS starts, another remote speech triggers cancel.
|
||
tts_active.store(true, Ordering::Release);
|
||
for _ in 0..REMOTE_SPEECH_THRESHOLD {
|
||
tracker.on_frame(1);
|
||
}
|
||
assert!(tts_cancel.load(Ordering::Acquire));
|
||
|
||
let handled = simulate_cancel_consumption(&tts_cancel, &shutdown, &tts_active, &rx);
|
||
assert!(handled);
|
||
assert!(!tts_cancel.load(Ordering::Acquire));
|
||
assert!(!tts_active.load(Ordering::Acquire));
|
||
}
|
||
|
||
/// TTS session transition (false→true) clears stale counters.
|
||
/// Prevents pre-existing speech from tripping a cancel on TTS restart.
|
||
#[test]
|
||
fn tts_session_transition_clears_counters() {
|
||
let tts_active = Arc::new(AtomicBool::new(true));
|
||
let tts_cancel = Arc::new(AtomicBool::new(false));
|
||
let mut tracker = RemoteInterruptTracker::new(Arc::clone(&tts_active), Arc::clone(&tts_cancel));
|
||
|
||
// Accumulate frames just below threshold during first TTS session.
|
||
for _ in 0..(REMOTE_SPEECH_THRESHOLD - 1) {
|
||
tracker.on_frame(1);
|
||
}
|
||
assert_eq!(tracker.count_for(1), REMOTE_SPEECH_THRESHOLD - 1);
|
||
assert!(!tts_cancel.load(Ordering::Acquire));
|
||
|
||
// TTS stops (cancel consumed).
|
||
tts_active.store(false, Ordering::Release);
|
||
// Send a frame while inactive — triggers tts_was_active transition tracking.
|
||
tracker.on_frame(1);
|
||
|
||
// TTS restarts — false→true transition should clear counters.
|
||
tts_active.store(true, Ordering::Release);
|
||
tracker.on_frame(1); // First frame of new session triggers clear + count.
|
||
assert_eq!(
|
||
tracker.count_for(1),
|
||
1,
|
||
"counter should be 1 (cleared on session transition, then incremented)",
|
||
);
|
||
|
||
// Need full threshold again from scratch.
|
||
for _ in 1..REMOTE_SPEECH_THRESHOLD {
|
||
tracker.on_frame(1);
|
||
}
|
||
assert!(
|
||
tts_cancel.load(Ordering::Acquire),
|
||
"cancel should fire after full threshold in new session",
|
||
);
|
||
}
|
||
|
||
/// Concurrent remote cancel + local barge-in → both safe, one cancel processed.
|
||
#[test]
|
||
fn concurrent_remote_and_local_cancel() {
|
||
let tts_active = Arc::new(AtomicBool::new(true));
|
||
let tts_cancel = Arc::new(AtomicBool::new(false));
|
||
|
||
// Remote path: frame counting above threshold.
|
||
let cancel_remote = Arc::clone(&tts_cancel);
|
||
let active_remote = Arc::clone(&tts_active);
|
||
let remote = std::thread::spawn(move || {
|
||
// Simulate threshold crossing — directly set cancel (the tracker
|
||
// would do this after REMOTE_SPEECH_THRESHOLD frames).
|
||
if active_remote.load(Ordering::Acquire) {
|
||
cancel_remote.store(true, Ordering::Release);
|
||
}
|
||
});
|
||
|
||
// Local path: STT barge-in.
|
||
let cancel_local = Arc::clone(&tts_cancel);
|
||
let local = std::thread::spawn(move || {
|
||
cancel_local.store(true, Ordering::Release);
|
||
});
|
||
|
||
remote.join().unwrap();
|
||
local.join().unwrap();
|
||
|
||
assert!(
|
||
tts_cancel.load(Ordering::Acquire),
|
||
"tts_cancel should be true regardless of which path set it",
|
||
);
|
||
}
|
||
|
||
/// Frames while TTS is inactive are NOT counted. The peer must accumulate
|
||
/// the full threshold AFTER tts_active becomes true.
|
||
#[test]
|
||
fn frames_while_tts_inactive_are_not_counted() {
|
||
let tts_active = Arc::new(AtomicBool::new(false));
|
||
let tts_cancel = Arc::new(AtomicBool::new(false));
|
||
let mut tracker = RemoteInterruptTracker::new(Arc::clone(&tts_active), Arc::clone(&tts_cancel));
|
||
|
||
// Send frames while TTS is inactive — should be ignored entirely.
|
||
for _ in 0..50 {
|
||
tracker.on_frame(1);
|
||
}
|
||
assert_eq!(
|
||
tracker.count_for(1),
|
||
0,
|
||
"no frames should accumulate while TTS inactive"
|
||
);
|
||
assert!(!tts_cancel.load(Ordering::Acquire));
|
||
|
||
// TTS starts. Peer must now accumulate from zero.
|
||
tts_active.store(true, Ordering::Release);
|
||
|
||
// Send frames below threshold — not enough yet.
|
||
for _ in 0..(REMOTE_SPEECH_THRESHOLD - 1) {
|
||
tracker.on_frame(1);
|
||
}
|
||
assert!(
|
||
!tts_cancel.load(Ordering::Acquire),
|
||
"cancel should not fire: only {} frames since TTS became active",
|
||
REMOTE_SPEECH_THRESHOLD - 1,
|
||
);
|
||
|
||
// One more frame crosses the threshold.
|
||
tracker.on_frame(1);
|
||
assert!(
|
||
tts_cancel.load(Ordering::Acquire),
|
||
"cancel should fire after full threshold accumulated post-activation",
|
||
);
|
||
}
|
||
|
||
// ── apply_fade_out tests ──────────────────────────────────────────────────
|
||
|
||
/// The fade-out half of the old `apply_fades`: last sample is silenced
|
||
/// and the ramp is monotonic. Mid-buffer must be untouched.
|
||
#[test]
|
||
fn apply_fade_out_short_buffer() {
|
||
let mut samples = vec![1.0f32; 10];
|
||
apply_fade_out(&mut samples);
|
||
assert_eq!(samples[9], 0.0, "last sample should be silenced");
|
||
assert!(samples[5] > 0.5, "mid-buffer should be near-untouched");
|
||
}
|
||
|
||
/// REGRESSION (2026-05-18): the *first* samples must NOT be attenuated.
|
||
/// An earlier `apply_fades` symmetrically faded in over 8 ms which
|
||
/// swallowed the consonant onset of every sentence.
|
||
/// Lock in: samples[0..FADE_OUT_SAMPLES] are byte-equal to input.
|
||
#[test]
|
||
fn apply_fade_out_does_not_touch_leading_samples() {
|
||
// Input long enough that fade window doesn't overlap (≫ 2× fade).
|
||
let n = FADE_OUT_SAMPLES * 4;
|
||
let input: Vec<f32> = (0..n).map(|i| 0.5 + (i as f32) * 1e-4).collect();
|
||
let mut samples = input.clone();
|
||
apply_fade_out(&mut samples);
|
||
for i in 0..FADE_OUT_SAMPLES {
|
||
assert_eq!(
|
||
samples[i], input[i],
|
||
"leading sample {i} must not be attenuated (was {} → {})",
|
||
input[i], samples[i]
|
||
);
|
||
}
|
||
// And the trailing fade still works.
|
||
assert_eq!(samples[n - 1], 0.0);
|
||
}
|
||
|
||
#[test]
|
||
fn apply_fade_out_empty_buffer() {
|
||
let mut samples: Vec<f32> = vec![];
|
||
apply_fade_out(&mut samples);
|
||
assert!(samples.is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn apply_fade_out_single_sample() {
|
||
// fade = min(FADE_OUT_SAMPLES, len/2) = 0, so nothing changes.
|
||
let mut samples = vec![1.0f32];
|
||
apply_fade_out(&mut samples);
|
||
assert_eq!(samples[0], 1.0);
|
||
}
|
||
|
||
// ── build_sentence_append_buffer tests ───────────────────────────────────
|
||
|
||
/// Playback chunks are contiguous: Pocket's generated pause is not extended
|
||
/// with a fixed inter-sentence silence budget.
|
||
#[test]
|
||
fn sentence_append_buffer_does_not_inject_silence() {
|
||
let first_buf = build_sentence_append_buffer(vec![0.5; 100], false);
|
||
let second_buf = build_sentence_append_buffer(vec![0.25; 100], false);
|
||
|
||
assert_eq!(first_buf, vec![0.5; 100]);
|
||
assert_eq!(second_buf, vec![0.25; 100]);
|
||
}
|
||
|
||
/// If generation falls behind playback, retain the onset cushion that protects
|
||
/// the first phoneme while the output path wakes back up.
|
||
#[test]
|
||
fn idle_playback_gets_an_onset_cushion() {
|
||
let buf = build_sentence_append_buffer(vec![0.5; 100], true);
|
||
|
||
assert_eq!(buf.len(), SENTENCE_LEAD_IN_SAMPLES + 100);
|
||
assert!(buf[..SENTENCE_LEAD_IN_SAMPLES].iter().all(|&s| s == 0.0));
|
||
assert_eq!(buf[SENTENCE_LEAD_IN_SAMPLES], 0.5);
|
||
}
|
||
|
||
#[test]
|
||
fn tts_worker_uses_distinct_playback_and_model_splitters() {
|
||
let source = include_str!("tts.rs");
|
||
let playback_calls = source.matches("engine.split_text_for_playback(").count();
|
||
let model_calls = source.matches("engine.split_text_into_chunks(").count();
|
||
|
||
assert_eq!(
|
||
(playback_calls, model_calls),
|
||
(1, 1),
|
||
"the worker must isolate sentence one only in the outer playback split"
|
||
);
|
||
|
||
// Counts alone are order-blind: swapping the two call sites keeps them at
|
||
// (1, 1) while the outer split stops isolating sentence one, which delays
|
||
// first audio by a whole generation. Pin the ORDER too.
|
||
let playback_at = source
|
||
.find("engine.split_text_for_playback(")
|
||
.expect("outer playback split exists");
|
||
let model_at = source
|
||
.find("engine.split_text_into_chunks(")
|
||
.expect("inner model split exists");
|
||
assert!(
|
||
playback_at < model_at,
|
||
"the playback split must be the OUTER pass; swapping the two delays first audio"
|
||
);
|
||
}
|
||
|
||
// ── clamp_to_full_scale tests ─────────────────────────────────────────────
|
||
|
||
/// In-range speech audio passes through bit-exact — no gain is applied.
|
||
/// (Pocket output is already at speech level; see the fn doc-comment for
|
||
/// the history of the two gain-stage regressions this replaced.)
|
||
#[test]
|
||
fn clamp_to_full_scale_passes_speech_audio_unchanged() {
|
||
let input = vec![0.42_f32, -0.97, 0.076, 0.0];
|
||
let out = clamp_to_full_scale(input.clone());
|
||
assert_eq!(out, input);
|
||
}
|
||
|
||
/// Outlier transients beyond full scale are hard-clamped to ±1.0 rather
|
||
/// than wrapping.
|
||
#[test]
|
||
fn clamp_to_full_scale_clamps_outliers() {
|
||
let input = vec![1.5_f32, -2.0, 0.5];
|
||
let out = clamp_to_full_scale(input);
|
||
assert_eq!(out, vec![1.0, -1.0, 0.5]);
|
||
}
|
||
|
||
/// Empty input round-trips to empty output.
|
||
#[test]
|
||
fn clamp_to_full_scale_empty_buffer() {
|
||
let out = clamp_to_full_scale(Vec::new());
|
||
assert!(out.is_empty());
|
||
}
|