1use std::collections::{BTreeSet, HashMap};
4use std::sync::atomic::{AtomicUsize, Ordering};
5use std::sync::{Arc, Mutex};
6
7use forge_primitives::{Message, SessionSlot};
8use forge_tts::{Chunk, Engine, SpeakRequest};
9use tokio::sync::{mpsc, oneshot};
10use tracing::Instrument;
11
12#[derive(Debug, Clone, PartialEq, Eq)]
14pub struct VoiceSettings {
15 pub enabled: bool,
16 pub max_utterance_seconds: u64,
17 pub voice: Option<String>,
19}
20
21impl Default for VoiceSettings {
22 fn default() -> Self {
23 Self { enabled: true, max_utterance_seconds: 120, voice: None }
24 }
25}
26
27pub const PREVIEW_LINE: &str = "This is how this seat's voice sounds.";
29
30#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
32pub enum SayError {
33 #[error("no listener")]
35 NoListener,
36 #[error("voice engine failed: {0}")]
38 Engine(String),
39 #[error("{0}")]
41 TooLong(String),
42 #[error("the voice engine is not ready")]
44 NotReady,
45 #[error("speech is off")]
47 Disabled,
48}
49
50pub trait AudioSink: Send + Sync {
52 fn push(&self, seat: &SessionSlot, chunk: Chunk);
53}
54
55pub trait NoteDispatch: Send + Sync {
57 fn note(&self, seat: &SessionSlot, text: &str);
58}
59
60pub const LISTENING_NOTE: &str = "The user is listening to this seat.\n\nSpeak what they should hear as you would say it in a live conversation: send each message's substance through mcp__forge__voice__say as well as writing it in the chat, keeping the spoken form simpler and shorter so it lands by ear. A reply that is only written is one they will not hear. When the stop note arrives, you may stop speaking.";
62
63pub const NOT_LISTENING_NOTE: &str =
65 "The user has stopped listening to this seat.\n\nYou may stop speaking.";
66
67fn note_envelope(text: &str) -> String {
70 format!("[Voice]\n\n{text}")
71}
72
73type Loader = Arc<dyn Fn() -> Result<Arc<dyn Engine>, String> + Send + Sync>;
74
75#[derive(Clone)]
80pub struct VoiceService {
81 loader: Loader,
82 engine: Arc<tokio::sync::Mutex<Option<Arc<dyn Engine>>>>,
83 settings: VoiceSettings,
84 sink: Arc<dyn AudioSink>,
85 notes: Arc<dyn NoteDispatch>,
86 seats: Arc<Mutex<HashMap<SessionSlot, Seat>>>,
87 previews: Arc<Mutex<HashMap<SessionSlot, PreviewSlot>>>,
90}
91
92#[derive(Default)]
93struct PreviewSlot {
94 latest: Option<String>,
95 running: bool,
96 generation: u64,
99 presses: u64,
102}
103
104struct Seat {
105 listeners: BTreeSet<u64>,
106 queue: mpsc::UnboundedSender<Job>,
107 live: Arc<AtomicUsize>,
111 noted: bool,
115}
116
117struct Job {
118 text: String,
119 voice: Option<String>,
120 engine: Arc<dyn Engine>,
121 first: oneshot::Sender<Result<(), SayError>>,
122 preview: Option<u64>,
125}
126
127impl VoiceService {
128 pub fn new(
129 loader: Loader,
130 settings: VoiceSettings,
131 sink: Arc<dyn AudioSink>,
132 notes: Arc<dyn NoteDispatch>,
133 ) -> Self {
134 Self {
135 loader,
136 engine: Arc::new(tokio::sync::Mutex::new(None)),
137 settings,
138 sink,
139 notes,
140 seats: Arc::new(Mutex::new(HashMap::new())),
141 previews: Arc::new(Mutex::new(HashMap::new())),
142 }
143 }
144
145 pub async fn say(&self, seat: &SessionSlot, text: String) -> Result<(), SayError> {
149 if !self.settings.enabled {
150 return Err(SayError::Disabled);
151 }
152 self.listening_queue(seat)?;
154 forge_tts::within_bound(&text, self.settings.max_utterance_seconds)
155 .map_err(|error| SayError::TooLong(error.to_string()))?;
156
157 let engine = self.engine().await?;
158 let queue = self.listening_queue(seat)?;
161 let (first, outcome) = oneshot::channel();
162 let voice = self.settings.voice.clone();
163 queue
164 .send(Job { text, voice, engine, first, preview: None })
165 .map_err(|_| SayError::NotReady)?;
166 outcome.await.map_err(|_| SayError::NotReady)?
167 }
168
169 pub async fn preview(&self, seat: &SessionSlot, voice: String) -> Result<(), SayError> {
177 if !self.settings.enabled {
178 return Err(SayError::Disabled);
179 }
180 self.listening_queue(seat)?;
182 let mut generation = {
183 let mut previews = self.previews.lock().map_err(|_| SayError::NotReady)?;
184 let slot = previews.entry(seat.clone()).or_default();
185 slot.latest = Some(voice);
186 slot.presses += 1;
187 if slot.running {
188 return Ok(());
189 }
190 slot.running = true;
191 slot.generation += 1;
192 slot.generation
193 };
194 loop {
195 let outcome = self.render_previews(seat).await;
196 let Some(pending) = self.settle_previews(seat, generation) else {
200 return outcome;
201 };
202 let Some(next) = self.restart_previews(seat, pending) else {
203 return outcome;
204 };
205 generation = next;
206 }
207 }
208
209 fn settle_previews(&self, seat: &SessionSlot, generation: u64) -> Option<String> {
213 let mut previews = self.previews.lock().ok()?;
214 let slot = previews.get_mut(seat)?;
215 if slot.generation != generation {
216 return None;
217 }
218 slot.running = false;
219 slot.latest.take()
220 }
221
222 fn restart_previews(&self, seat: &SessionSlot, voice: String) -> Option<u64> {
226 let mut previews = self.previews.lock().ok()?;
227 let slot = previews.get_mut(seat)?;
228 if slot.running {
229 return None;
230 }
231 slot.latest = Some(voice);
232 slot.running = true;
233 slot.generation += 1;
234 Some(slot.generation)
235 }
236
237 async fn render_previews(&self, seat: &SessionSlot) -> Result<(), SayError> {
239 loop {
240 let next = {
244 let mut previews = self.previews.lock().map_err(|_| SayError::NotReady)?;
245 let Some(slot) = previews.get_mut(seat) else {
246 return Ok(());
247 };
248 let next = slot.latest.take().map(|voice| (voice, slot.presses));
249 if next.is_none() {
250 slot.running = false;
251 }
252 next
253 };
254 let Some((voice, presses)) = next else {
255 return Ok(());
256 };
257 let engine = self.engine().await?;
258 let Ok(queue) = self.listening_queue(seat) else {
261 return Ok(());
262 };
263 let (first, outcome) = oneshot::channel();
264 queue
265 .send(Job {
266 text: PREVIEW_LINE.to_owned(),
267 voice: Some(voice),
268 engine,
269 first,
270 preview: Some(presses),
271 })
272 .map_err(|_| SayError::NotReady)?;
273 outcome.await.map_err(|_| SayError::NotReady)??;
274 }
275 }
276
277 fn listening_queue(&self, seat: &SessionSlot) -> Result<mpsc::UnboundedSender<Job>, SayError> {
280 let seats = self.seats.lock().map_err(|_| SayError::NotReady)?;
281 match seats.get(seat) {
282 Some(state) if !state.listeners.is_empty() => Ok(state.queue.clone()),
283 _ => Err(SayError::NoListener),
284 }
285 }
286
287 pub fn set_listening(&self, seat: &SessionSlot, client: u64, on: bool, explicit: bool) {
292 let note = {
293 let Ok(mut seats) = self.seats.lock() else {
294 return;
295 };
296 if on {
297 if let Some(state) = seats.get_mut(seat) {
298 let first = state.listeners.is_empty();
299 state.listeners.insert(client);
300 if first {
301 let live = Arc::new(AtomicUsize::new(1));
304 state.queue = self.spawn_queue(seat, Arc::clone(&live));
305 state.live = live;
306 } else {
307 state.live.fetch_add(1, Ordering::SeqCst);
308 }
309 if first && !state.noted {
310 state.noted = true;
311 Some(LISTENING_NOTE)
312 } else {
313 None
314 }
315 } else {
316 let live = Arc::new(AtomicUsize::new(1));
317 let queue = self.spawn_queue(seat, Arc::clone(&live));
318 seats.insert(
319 seat.clone(),
320 Seat { listeners: BTreeSet::from([client]), queue, live, noted: true },
321 );
322 Some(LISTENING_NOTE)
323 }
324 } else {
325 let Some(state) = seats.get_mut(seat) else {
326 return;
327 };
328 if !state.listeners.remove(&client) {
329 return;
330 }
331 if state.listeners.is_empty() {
332 state.live.store(0, Ordering::SeqCst);
333 if explicit {
334 state.noted = false;
335 seats.remove(seat);
336 Some(NOT_LISTENING_NOTE)
337 } else {
338 None
341 }
342 } else {
343 state.live.fetch_sub(1, Ordering::SeqCst);
344 None
345 }
346 }
347 };
348 if let Some(text) = note {
349 self.notes.note(seat, text);
350 }
351 }
352
353 pub async fn replace_engine(&self, engine: Arc<dyn Engine>) {
356 *self.engine.lock().await = Some(engine);
357 }
358
359 pub fn listeners(&self, seat: &SessionSlot) -> usize {
360 self.seats
361 .lock()
362 .ok()
363 .and_then(|seats| seats.get(seat).map(|state| state.listeners.len()))
364 .unwrap_or(0)
365 }
366
367 pub fn release(&self, client: u64) -> Vec<SessionSlot> {
371 let (changed, emptied) = {
372 let Ok(mut seats) = self.seats.lock() else {
373 return Vec::new();
374 };
375 let mut changed = Vec::new();
376 let mut emptied = Vec::new();
377 for (seat, state) in seats.iter_mut() {
378 if !state.listeners.remove(&client) {
379 continue;
380 }
381 changed.push(seat.clone());
382 if state.listeners.is_empty() {
383 state.live.store(0, Ordering::SeqCst);
384 emptied.push(seat.clone());
385 } else {
386 state.live.fetch_sub(1, Ordering::SeqCst);
387 }
388 }
389 for seat in &emptied {
390 seats.remove(seat);
391 }
392 (changed, emptied)
393 };
394 for seat in &emptied {
395 self.notes.note(seat, NOT_LISTENING_NOTE);
396 }
397 changed
398 }
399
400 pub fn enabled(&self) -> bool {
403 self.settings.enabled
404 }
405
406 pub fn occupant_changed(&self, seat: &SessionSlot) {
410 let listening = self
411 .seats
412 .lock()
413 .ok()
414 .and_then(|seats| seats.get(seat).map(|state| !state.listeners.is_empty()))
415 .unwrap_or(false);
416 if listening {
417 self.notes.note(seat, LISTENING_NOTE);
418 }
419 }
420
421 pub(crate) async fn warm(&self) -> Result<(), SayError> {
424 self.engine().await.map(|_held| ())
425 }
426
427 async fn engine(&self) -> Result<Arc<dyn Engine>, SayError> {
430 let mut slot = self.engine.lock().await;
431 if let Some(engine) = slot.as_ref() {
432 return Ok(Arc::clone(engine));
433 }
434 let loader = Arc::clone(&self.loader);
435 let result = tokio::task::spawn_blocking(move || loader())
436 .await
437 .map_err(|error| SayError::Engine(error.to_string()))?;
438 let engine = result.map_err(SayError::Engine)?;
439 *slot = Some(Arc::clone(&engine));
440 Ok(engine)
441 }
442
443 fn spawn_queue(
446 &self,
447 seat: &SessionSlot,
448 live: Arc<AtomicUsize>,
449 ) -> mpsc::UnboundedSender<Job> {
450 let (queue, mut jobs) = mpsc::unbounded_channel::<Job>();
451 let sink = Arc::clone(&self.sink);
452 let previews = Arc::clone(&self.previews);
453 let seat = seat.clone();
454 tokio::spawn(async move {
455 let mut seq = 0_u32;
456 while live.load(Ordering::SeqCst) > 0 {
457 let Some(job) = jobs.recv().await else {
458 break;
459 };
460 if live.load(Ordering::SeqCst) == 0 {
461 break;
462 }
463 seq = render(job, &seat, sink.as_ref(), &live, seq, previews.as_ref()).await;
464 }
465 });
466 queue
467 }
468}
469
470impl crate::Workspace {
471 pub(crate) fn voice_service(self: &Arc<Self>) -> Arc<VoiceService> {
476 self.voice
477 .get_or_init(|| {
478 let weak = Arc::downgrade(self);
479 let loader: Loader = Arc::new(move || {
480 let workspace =
481 weak.upgrade().ok_or_else(|| "the workspace is gone".to_owned())?;
482 workspace.load_speaking_engine()
483 });
484 Arc::new(self.build_voice_service(loader))
485 })
486 .clone()
487 }
488
489 fn load_speaking_engine(&self) -> Result<Arc<dyn Engine>, String> {
495 let files = self.speaking_files().ok_or_else(|| {
496 "the speaking model is not installed; install it from the models page".to_owned()
497 })?;
498 let installed = files.installed_voices().map_err(|error| error.to_string())?;
499 forge_tts::select_voice(self.config_arc().tts.voice.as_deref(), &installed)
500 .map_err(|error| error.to_string())?;
501 let engine = forge_tts::KokoroTts::load(&files).map_err(|error| error.to_string())?;
502 Ok(Arc::new(engine) as Arc<dyn Engine>)
503 }
504
505 pub fn start_voice_prewarm(self: &Arc<Self>) {
513 self.voice_prewarm_started.store(true, Ordering::Release);
517 let settings = self.config_arc().tts.clone();
518 if !settings.enabled {
519 return;
520 }
521 if self.voice_prewarm_dispatched.swap(true, Ordering::AcqRel) {
522 return;
523 }
524 let files = self.speaking_files();
525 let model = files
526 .as_ref()
527 .map_or_else(|| "unresolved".to_owned(), |files| files.model.display().to_string());
528 let dir = settings
529 .models_dir()
530 .map_or_else(|| "unresolved".to_owned(), |dir| dir.display().to_string());
531 let service = self.voice_service();
532 let started = std::time::Instant::now();
533 let span = tracing::info_span!("voice_prewarm");
534 tracing::info!(
535 event_name = "voice_prewarm_starting",
536 models_dir = %dir,
537 model = %model,
538 "loading the speaking engine"
539 );
540 tokio::spawn(
541 async move {
542 match service.warm().await {
543 Ok(()) => tracing::info!(
544 event_name = "voice_prewarm_ready",
545 elapsed_ms = started.elapsed().as_millis(),
546 model = %model,
547 "the speaking engine is resident; a say pays no load"
548 ),
549 Err(error) => tracing::warn!(
550 event_name = "voice_prewarm_failed",
551 model = %model,
552 failure = %error,
553 "the speaking engine did not load; the first say will retry"
554 ),
555 }
556 }
557 .instrument(span),
558 );
559 }
560
561 pub(crate) fn voice_enabled(&self) -> bool {
565 self.voice.get().map_or_else(|| self.config_arc().tts.enabled, |service| service.enabled())
566 }
567
568 pub fn voice_listeners(&self, seat: &SessionSlot) -> usize {
571 self.voice.get().map_or(0, |service| service.listeners(seat))
572 }
573
574 pub fn voice_occupant_changed(&self, seat: &SessionSlot) {
578 if let Some(service) = self.voice.get() {
579 service.occupant_changed(seat);
580 }
581 }
582
583 pub fn voice_release(&self, client: u64) -> Vec<SessionSlot> {
586 let Some(service) = self.voice.get() else {
587 return Vec::new();
588 };
589 let changed = service.release(client);
590 for seat in &changed {
591 let listeners = u32::try_from(service.listeners(seat)).unwrap_or(u32::MAX);
592 let _ = self
593 .update_sender()
594 .send(crate::SessionUpdate::VoiceModeState { key: seat.clone(), listeners });
595 }
596 changed
597 }
598
599 #[cfg(any(test, feature = "test-helpers"))]
602 pub fn install_voice_engine_for_test(self: &Arc<Self>, engine: Arc<dyn Engine>) {
603 let loader: Loader = Arc::new(move || Ok(Arc::clone(&engine)));
604 let _ = self.voice.set(Arc::new(self.build_voice_service(loader)));
605 }
606
607 #[cfg(any(test, feature = "test-helpers"))]
610 pub async fn voice_say_for_test(
611 self: &Arc<Self>,
612 seat: &SessionSlot,
613 text: String,
614 ) -> Result<(), SayError> {
615 self.voice_service().say(seat, text).await
616 }
617
618 fn build_voice_service(self: &Arc<Self>, loader: Loader) -> VoiceService {
619 let config = self.config_arc();
620 let settings = VoiceSettings {
621 enabled: config.tts.enabled,
622 max_utterance_seconds: config.tts.max_utterance_seconds,
623 voice: config.tts.voice.clone(),
624 };
625 VoiceService::new(
626 loader,
627 settings,
628 Arc::new(WorkspaceSink { workspace: Arc::downgrade(self) }),
629 Arc::new(WorkspaceNotes { workspace: Arc::downgrade(self) }),
630 )
631 }
632}
633
634struct WorkspaceSink {
637 workspace: std::sync::Weak<crate::Workspace>,
638}
639
640impl AudioSink for WorkspaceSink {
641 fn push(&self, seat: &SessionSlot, chunk: Chunk) {
642 let Some(workspace) = self.workspace.upgrade() else {
643 return;
644 };
645 if workspace.domain_session_for(seat).is_none() {
648 return;
649 }
650 let _ = workspace.update_sender().send(crate::SessionUpdate::VoiceAudio {
651 key: seat.clone(),
652 seq: chunk.seq,
653 sample_rate: chunk.sample_rate,
654 pcm: chunk.pcm,
655 });
656 }
657}
658
659struct WorkspaceNotes {
662 workspace: std::sync::Weak<crate::Workspace>,
663}
664
665impl NoteDispatch for WorkspaceNotes {
666 fn note(&self, seat: &SessionSlot, text: &str) {
667 let Some(workspace) = self.workspace.upgrade() else {
668 return;
669 };
670 let uuid = forge_sdk::request_id::next_prompt_id();
671 let prose = note_envelope(text);
672 if let Err(error) = workspace.dispatch_workspace_prompt_under(
673 seat,
674 prose.clone(),
675 crate::PromptSource::Forge,
676 uuid.clone(),
677 ) {
678 tracing::warn!(
679 event_name = "voice_note_not_delivered",
680 seat = %seat.display(),
681 %error,
682 "the listener note did not reach the seat; the agent will not know it is heard"
683 );
684 return;
685 }
686 let mut row = Message::display_only_user(prose, uuid);
689 row.stamp_if_absent(&crate::install::rfc3339_now());
690 let _ = workspace.update_sender().send(crate::SessionUpdate::ChatAppended {
691 key: seat.clone(),
692 msg: row,
693 source: Some(crate::PromptSource::Forge),
694 });
695 }
696}
697
698async fn render(
699 job: Job,
700 seat: &SessionSlot,
701 sink: &dyn AudioSink,
702 live: &AtomicUsize,
703 mut seq: u32,
704 previews: &Mutex<HashMap<SessionSlot, PreviewSlot>>,
705) -> u32 {
706 let Job { text, voice, engine, first, preview } = job;
707 let mut first = Some(first);
708 for sentence in forge_tts::sentences(&text) {
709 if live.load(Ordering::SeqCst) == 0 {
710 return seq;
711 }
712 let mut chunks = engine.speak(SpeakRequest { text: sentence, voice: voice.clone() });
713 while let Some(result) = chunks.recv().await {
714 if live.load(Ordering::SeqCst) == 0 {
715 return seq;
716 }
717 match result {
718 Ok(chunk) => {
719 let stale = preview.is_some_and(|presses| {
723 previews.lock().map_or(true, |map| {
724 map.get(seat).is_none_or(|slot| slot.presses != presses)
725 })
726 });
727 if let Some(sender) = first.take() {
728 let _ = sender.send(Ok(()));
729 }
730 if !stale {
731 sink.push(seat, Chunk { seq, ..chunk });
732 }
733 seq = seq.wrapping_add(1);
734 }
735 Err(error) => {
736 tracing::warn!(
737 event_name = "voice_render_failed",
738 slot = %seat.display(),
739 %error,
740 "a sentence did not render; the rest of the utterance is dropped"
741 );
742 if let Some(sender) = first.take() {
743 let _ = sender.send(Err(SayError::Engine(error.to_string())));
744 }
745 return seq;
746 }
747 }
748 }
749 }
750 if let Some(sender) = first {
751 let _ = sender.send(Ok(()));
752 }
753 seq
754}
755
756#[cfg(test)]
757mod tests {
758 use std::sync::atomic::{AtomicUsize, Ordering};
759 use std::sync::{Arc, Mutex};
760 use std::time::Duration;
761
762 use forge_primitives::SessionSlot;
763 use forge_tts::{Chunk, Engine, Error, SpeakRequest};
764 use tokio::sync::mpsc;
765
766 use super::{
767 AudioSink, LISTENING_NOTE, NOT_LISTENING_NOTE, NoteDispatch, SayError, VoiceService,
768 VoiceSettings, note_envelope,
769 };
770
771 fn seat() -> SessionSlot {
772 SessionSlot::new("Org", "project", "lead")
773 }
774
775 struct RecordingSink {
776 chunks: Mutex<Vec<(SessionSlot, Chunk)>>,
777 }
778
779 impl AudioSink for RecordingSink {
780 fn push(&self, seat: &SessionSlot, chunk: Chunk) {
781 self.chunks.lock().expect("unpoisoned").push((seat.clone(), chunk));
782 }
783 }
784
785 struct RecordingNotes {
786 said: Mutex<Vec<String>>,
787 }
788
789 impl NoteDispatch for RecordingNotes {
790 fn note(&self, _seat: &SessionSlot, text: &str) {
791 self.said.lock().expect("unpoisoned").push(text.to_owned());
792 }
793 }
794
795 struct StubEngine {
799 requests: Arc<Mutex<Vec<String>>>,
800 voices: Arc<Mutex<Vec<Option<String>>>>,
801 gate: Option<Arc<tokio::sync::Semaphore>>,
802 }
803
804 impl StubEngine {
805 fn new(requests: Arc<Mutex<Vec<String>>>) -> Self {
806 Self { requests, voices: Arc::new(Mutex::new(Vec::new())), gate: None }
807 }
808 }
809
810 impl Engine for StubEngine {
811 fn speak(&self, request: SpeakRequest) -> mpsc::Receiver<Result<Chunk, Error>> {
812 self.requests.lock().expect("unpoisoned").push(request.text);
813 self.voices.lock().expect("unpoisoned").push(request.voice);
814 let (sender, receiver) = mpsc::channel(2);
815 let permit = self.gate.as_ref().map(|gate| gate.clone().acquire_owned());
816 tokio::spawn(async move {
817 if let Some(permit) = permit {
818 drop(permit.await);
819 }
820 let _ = sender
821 .send(Ok(Chunk { seq: 0, sample_rate: 24_000, pcm: vec![1, 2, 3] }))
822 .await;
823 });
824 receiver
825 }
826 }
827
828 struct Fixture {
829 service: VoiceService,
830 sink: Arc<RecordingSink>,
831 notes: Arc<RecordingNotes>,
832 requests: Arc<Mutex<Vec<String>>>,
833 loads: Arc<AtomicUsize>,
834 }
835
836 fn service_with(engine: impl Fn() -> StubEngine + Send + Sync + 'static) -> Fixture {
837 let requests = Arc::new(Mutex::new(Vec::new()));
838 let sink = Arc::new(RecordingSink { chunks: Mutex::new(Vec::new()) });
839 let notes = Arc::new(RecordingNotes { said: Mutex::new(Vec::new()) });
840 let loads = Arc::new(AtomicUsize::new(0));
841 let factory = Arc::new(engine);
842 let loader = {
843 let factory = Arc::clone(&factory);
844 let requests = Arc::clone(&requests);
845 let loads = Arc::clone(&loads);
846 move || {
847 loads.fetch_add(1, Ordering::SeqCst);
848 let mut engine = factory();
849 engine.requests = Arc::clone(&requests);
850 Ok(Arc::new(engine) as Arc<dyn Engine>)
851 }
852 };
853 Fixture {
854 service: VoiceService::new(
855 Arc::new(loader),
856 VoiceSettings::default(),
857 Arc::clone(&sink) as Arc<dyn AudioSink>,
858 Arc::clone(¬es) as Arc<dyn NoteDispatch>,
859 ),
860 sink,
861 notes,
862 requests,
863 loads,
864 }
865 }
866
867 async fn wait_for(chunks: &Arc<RecordingSink>, expected: usize) {
868 for _ in 0..200 {
869 if chunks.chunks.lock().expect("unpoisoned").len() >= expected {
870 return;
871 }
872 tokio::time::sleep(Duration::from_millis(5)).await;
873 }
874 panic!("the sink saw fewer than {expected} chunks");
875 }
876
877 #[tokio::test]
878 async fn a_say_with_no_listener_is_refused_without_touching_the_engine() {
879 let fixture = service_with(|| StubEngine::new(Arc::new(Mutex::new(Vec::new()))));
880 let outcome = fixture.service.say(&seat(), "Hello.".to_owned()).await;
881 assert_eq!(outcome, Err(SayError::NoListener));
882 assert!(fixture.requests.lock().expect("unpoisoned").is_empty(), "the engine is untouched");
883 assert_eq!(fixture.loads.load(Ordering::SeqCst), 0, "the 2.3 GB model is never loaded");
884 }
885
886 #[tokio::test]
889 async fn a_preview_with_no_listener_is_refused_without_touching_the_engine() {
890 let fixture = service_with(|| StubEngine::new(Arc::new(Mutex::new(Vec::new()))));
891 let outcome = fixture.service.preview(&seat(), "af_heart".to_owned()).await;
892 assert_eq!(outcome, Err(SayError::NoListener));
893 assert!(fixture.requests.lock().expect("unpoisoned").is_empty());
894 assert_eq!(fixture.loads.load(Ordering::SeqCst), 0);
895 }
896
897 #[tokio::test]
901 async fn previews_coalesce_to_the_newest_voice_per_seat() {
902 struct Gated {
903 voices: Arc<Mutex<Vec<String>>>,
904 permits: Arc<tokio::sync::Semaphore>,
905 }
906
907 impl Engine for Gated {
908 fn speak(&self, request: SpeakRequest) -> mpsc::Receiver<Result<Chunk, Error>> {
909 self.voices.lock().expect("unpoisoned").push(request.voice.unwrap_or_default());
910 let (sender, receiver) = mpsc::channel(2);
911 let permits = Arc::clone(&self.permits);
912 tokio::spawn(async move {
913 let permit = permits.acquire_owned().await;
914 drop(permit);
915 let _ =
916 sender.send(Ok(Chunk { seq: 0, sample_rate: 24_000, pcm: vec![1] })).await;
917 });
918 receiver
919 }
920 }
921
922 let voices = Arc::new(Mutex::new(Vec::new()));
923 let permits = Arc::new(tokio::sync::Semaphore::new(0));
924 let loader: super::Loader = {
925 let voices = Arc::clone(&voices);
926 let permits = Arc::clone(&permits);
927 Arc::new(move || {
928 Ok(Arc::new(Gated { voices: Arc::clone(&voices), permits: Arc::clone(&permits) })
929 as Arc<dyn Engine>)
930 })
931 };
932 let service = VoiceService::new(
933 loader,
934 VoiceSettings::default(),
935 Arc::new(RecordingSink { chunks: Mutex::new(Vec::new()) }) as Arc<dyn AudioSink>,
936 Arc::new(RecordingNotes { said: Mutex::new(Vec::new()) }) as Arc<dyn NoteDispatch>,
937 );
938 let seat = seat();
939 service.set_listening(&seat, 1, true, false);
940
941 let running = tokio::spawn({
942 let service = service.clone();
943 let seat = seat.clone();
944 async move { service.preview(&seat, "af_heart".to_owned()).await }
945 });
946 for _ in 0..200 {
947 if !voices.lock().expect("unpoisoned").is_empty() {
948 break;
949 }
950 tokio::time::sleep(Duration::from_millis(5)).await;
951 }
952
953 service.preview(&seat, "bf_emma".to_owned()).await.expect("queued");
956 service.preview(&seat, "am_adam".to_owned()).await.expect("queued");
957
958 permits.add_permits(2);
959 running.await.expect("the task joins").expect("the first preview renders");
960 for _ in 0..200 {
961 if voices.lock().expect("unpoisoned").len() >= 2 {
962 break;
963 }
964 tokio::time::sleep(Duration::from_millis(5)).await;
965 }
966
967 assert_eq!(
968 voices.lock().expect("unpoisoned").clone(),
969 vec!["af_heart".to_owned(), "am_adam".to_owned()],
970 "the running voice, then the newest press - never the one it replaced",
971 );
972 }
973
974 #[tokio::test]
979 async fn a_listener_that_leaves_while_the_engine_loads_does_not_wedge_previews() {
980 let requests = Arc::new(Mutex::new(Vec::new()));
981 let (started, began) = std::sync::mpsc::channel::<()>();
982 let (release, released) = std::sync::mpsc::channel::<()>();
983 let released = Arc::new(Mutex::new(released));
984 let loader: super::Loader = {
985 let requests = Arc::clone(&requests);
986 Arc::new(move || {
987 let _ = started.send(());
988 released.lock().expect("unpoisoned").recv().ok();
989 Ok(Arc::new(StubEngine::new(Arc::clone(&requests))) as Arc<dyn Engine>)
990 })
991 };
992 let service = VoiceService::new(
993 loader,
994 VoiceSettings::default(),
995 Arc::new(RecordingSink { chunks: Mutex::new(Vec::new()) }) as Arc<dyn AudioSink>,
996 Arc::new(RecordingNotes { said: Mutex::new(Vec::new()) }) as Arc<dyn NoteDispatch>,
997 );
998 let seat = seat();
999 service.set_listening(&seat, 1, true, false);
1000
1001 let preview = tokio::spawn({
1002 let service = service.clone();
1003 let seat = seat.clone();
1004 async move { service.preview(&seat, "af_heart".to_owned()).await }
1005 });
1006 let mut loader_started = false;
1007 for _ in 0..200 {
1008 if began.try_recv().is_ok() {
1009 loader_started = true;
1010 break;
1011 }
1012 tokio::time::sleep(Duration::from_millis(5)).await;
1013 }
1014 assert!(loader_started, "the loader starts");
1015 service.set_listening(&seat, 1, false, false);
1018 release.send(()).expect("the loader is released");
1019
1020 preview.await.expect("the task joins").expect("the preview ends without an error");
1021
1022 service.set_listening(&seat, 1, true, false);
1025 service.preview(&seat, "bf_emma".to_owned()).await.expect("the preview renders");
1026 assert_eq!(requests.lock().expect("unpoisoned").len(), 1);
1027 }
1028
1029 #[tokio::test]
1033 async fn a_stale_loop_does_not_settle_a_newer_press() {
1034 let service = service_with(|| StubEngine::new(Arc::new(Mutex::new(Vec::new())))).service;
1035 let seat = seat();
1036 {
1037 let mut previews = service.previews.lock().expect("unpoisoned");
1038 let slot = previews.entry(seat.clone()).or_default();
1039 slot.generation = 2;
1040 slot.running = true;
1041 slot.latest = Some("bf_emma".to_owned());
1042 }
1043
1044 assert_eq!(service.settle_previews(&seat, 1), None, "a stale loop settles nothing");
1045
1046 let previews = service.previews.lock().expect("unpoisoned");
1047 let slot = previews.get(&seat).expect("the slot");
1048 assert!(slot.running, "a stale loop's settle cleared the newer loop's flag");
1049 assert_eq!(slot.latest.as_deref(), Some("bf_emma"), "the newer press's voice was dropped");
1050 }
1051
1052 #[tokio::test]
1056 async fn a_chunk_from_a_press_the_reader_moved_past_is_not_played() {
1057 struct Gated {
1058 requests: Arc<Mutex<Vec<Option<String>>>>,
1059 release: Arc<tokio::sync::Semaphore>,
1060 }
1061 impl Engine for Gated {
1062 fn speak(&self, request: SpeakRequest) -> mpsc::Receiver<Result<Chunk, Error>> {
1063 self.requests.lock().expect("unpoisoned").push(request.voice.clone());
1064 let (sender, receiver) = mpsc::channel(2);
1065 let release = Arc::clone(&self.release);
1066 let voice = request.voice;
1067 tokio::spawn(async move {
1068 if voice.as_deref() == Some("af_heart") {
1069 release.acquire().await.ok();
1070 }
1071 let pcm = if voice.as_deref() == Some("af_heart") { vec![1] } else { vec![2] };
1072 let _ = sender.send(Ok(Chunk { seq: 0, sample_rate: 24_000, pcm })).await;
1073 });
1074 receiver
1075 }
1076 }
1077
1078 let requests = Arc::new(Mutex::new(Vec::new()));
1079 let release = Arc::new(tokio::sync::Semaphore::new(0));
1080 let sink = Arc::new(RecordingSink { chunks: Mutex::new(Vec::new()) });
1081 let loader: super::Loader = {
1082 let requests = Arc::clone(&requests);
1083 let release = Arc::clone(&release);
1084 Arc::new(move || {
1085 Ok(Arc::new(Gated {
1086 requests: Arc::clone(&requests),
1087 release: Arc::clone(&release),
1088 }) as Arc<dyn Engine>)
1089 })
1090 };
1091 let service = VoiceService::new(
1092 loader,
1093 VoiceSettings::default(),
1094 Arc::clone(&sink) as Arc<dyn AudioSink>,
1095 Arc::new(RecordingNotes { said: Mutex::new(Vec::new()) }) as Arc<dyn NoteDispatch>,
1096 );
1097 let seat = seat();
1098 service.set_listening(&seat, 1, true, false);
1099
1100 let first = tokio::spawn({
1101 let service = service.clone();
1102 let seat = seat.clone();
1103 async move { service.preview(&seat, "af_heart".to_owned()).await }
1104 });
1105 let mut started = false;
1106 for _ in 0..200 {
1107 if requests.lock().expect("unpoisoned").as_slice() == [Some("af_heart".to_owned())] {
1108 started = true;
1109 break;
1110 }
1111 tokio::time::sleep(Duration::from_millis(5)).await;
1112 }
1113 assert!(started, "the first voice renders");
1114 service.preview(&seat, "bf_emma".to_owned()).await.expect("the second press is accepted");
1115 release.add_permits(1);
1116 first.await.expect("the task joins").expect("the preview ends");
1117
1118 wait_for(&sink, 1).await;
1119 assert_eq!(
1120 sink.chunks
1121 .lock()
1122 .expect("unpoisoned")
1123 .iter()
1124 .map(|(_, chunk)| chunk.pcm.clone())
1125 .collect::<Vec<_>>(),
1126 vec![vec![2]],
1127 "only the newest press is heard",
1128 );
1129 assert_eq!(requests.lock().expect("unpoisoned").len(), 2, "both voices rendered");
1130 }
1131
1132 #[tokio::test]
1136 async fn a_press_during_a_failing_load_is_rendered_rather_than_dropped() {
1137 let requests = Arc::new(Mutex::new(Vec::new()));
1138 let voices = Arc::new(Mutex::new(Vec::new()));
1139 let calls = Arc::new(AtomicUsize::new(0));
1140 let (started, began) = std::sync::mpsc::channel::<()>();
1141 let (release, released) = std::sync::mpsc::channel::<()>();
1142 let released = Arc::new(Mutex::new(released));
1143 let loader: super::Loader = {
1144 let requests = Arc::clone(&requests);
1145 let voices = Arc::clone(&voices);
1146 let calls = Arc::clone(&calls);
1147 Arc::new(move || {
1148 if calls.fetch_add(1, Ordering::SeqCst) == 0 {
1149 let _ = started.send(());
1150 released.lock().expect("unpoisoned").recv().ok();
1151 return Err("the first load dies".to_owned());
1152 }
1153 Ok(Arc::new(StubEngine {
1154 requests: Arc::clone(&requests),
1155 voices: Arc::clone(&voices),
1156 gate: None,
1157 }) as Arc<dyn Engine>)
1158 })
1159 };
1160 let service = VoiceService::new(
1161 loader,
1162 VoiceSettings::default(),
1163 Arc::new(RecordingSink { chunks: Mutex::new(Vec::new()) }) as Arc<dyn AudioSink>,
1164 Arc::new(RecordingNotes { said: Mutex::new(Vec::new()) }) as Arc<dyn NoteDispatch>,
1165 );
1166 let seat = seat();
1167 service.set_listening(&seat, 1, true, false);
1168
1169 let first = tokio::spawn({
1170 let service = service.clone();
1171 let seat = seat.clone();
1172 async move { service.preview(&seat, "af_heart".to_owned()).await }
1173 });
1174 let mut loader_started = false;
1175 for _ in 0..200 {
1176 if began.try_recv().is_ok() {
1177 loader_started = true;
1178 break;
1179 }
1180 tokio::time::sleep(Duration::from_millis(5)).await;
1181 }
1182 assert!(loader_started, "the loader starts");
1183 service.preview(&seat, "bf_emma".to_owned()).await.expect("the press is accepted");
1184 release.send(()).expect("the loader is released");
1185
1186 first.await.expect("the task joins").expect("the waiting voice renders");
1187 assert_eq!(
1188 voices.lock().expect("unpoisoned").as_slice(),
1189 &[Some("bf_emma".to_owned())],
1190 "the press during the failing load is the voice rendered",
1191 );
1192 }
1193
1194 #[tokio::test]
1195 async fn a_disabled_service_refuses_a_say() {
1196 let fixture = service_with(|| StubEngine::new(Arc::new(Mutex::new(Vec::new()))));
1197 let service = VoiceService::new(
1198 Arc::new(|| Err("no model loads in this test".to_owned())),
1199 VoiceSettings { enabled: false, ..VoiceSettings::default() },
1200 Arc::clone(&fixture.sink) as Arc<dyn AudioSink>,
1201 Arc::clone(&fixture.notes) as Arc<dyn NoteDispatch>,
1202 );
1203 service.set_listening(&seat(), 1, true, false);
1204 assert_eq!(service.say(&seat(), "Hello.".to_owned()).await, Err(SayError::Disabled));
1205 }
1206
1207 #[tokio::test]
1208 async fn a_chunk_error_reaches_the_caller() {
1209 struct Failing;
1210 impl Engine for Failing {
1211 fn speak(&self, _request: SpeakRequest) -> mpsc::Receiver<Result<Chunk, Error>> {
1212 let (sender, receiver) = mpsc::channel(1);
1213 tokio::spawn(async move {
1214 let _ = sender.send(Err(Error::Render("the render died".to_owned()))).await;
1215 });
1216 receiver
1217 }
1218 }
1219 let sink = Arc::new(RecordingSink { chunks: Mutex::new(Vec::new()) });
1220 let notes = Arc::new(RecordingNotes { said: Mutex::new(Vec::new()) });
1221 let service = VoiceService::new(
1222 Arc::new(|| Ok(Arc::new(Failing) as Arc<dyn Engine>)),
1223 VoiceSettings::default(),
1224 Arc::clone(&sink) as Arc<dyn AudioSink>,
1225 Arc::clone(¬es) as Arc<dyn NoteDispatch>,
1226 );
1227 service.set_listening(&seat(), 1, true, false);
1228 let outcome = service.say(&seat(), "Hello.".to_owned()).await;
1229 assert!(
1230 matches!(&outcome, Err(SayError::Engine(reason)) if reason.contains("the render died")),
1231 "the caller hears the engine's own failure, got: {outcome:?}",
1232 );
1233 }
1234
1235 #[tokio::test]
1236 async fn the_queue_stops_when_the_last_listener_leaves() {
1237 let gate = Arc::new(tokio::sync::Semaphore::new(0));
1238 let requests = Arc::new(Mutex::new(Vec::new()));
1239 let sink = Arc::new(RecordingSink { chunks: Mutex::new(Vec::new()) });
1240 let notes = Arc::new(RecordingNotes { said: Mutex::new(Vec::new()) });
1241 let engine = {
1242 let mut engine = StubEngine::new(Arc::clone(&requests));
1243 engine.gate = Some(Arc::clone(&gate));
1244 Arc::new(engine) as Arc<dyn Engine>
1245 };
1246 let service = VoiceService::new(
1247 Arc::new(move || Ok(Arc::clone(&engine))),
1248 VoiceSettings::default(),
1249 Arc::clone(&sink) as Arc<dyn AudioSink>,
1250 Arc::clone(¬es) as Arc<dyn NoteDispatch>,
1251 );
1252 service.set_listening(&seat(), 1, true, false);
1253 let first = tokio::spawn({
1254 let service = service.clone();
1255 async move { service.say(&seat(), "One.".to_owned()).await }
1256 });
1257 let second = tokio::spawn({
1258 let service = service.clone();
1259 async move { service.say(&seat(), "Two.".to_owned()).await }
1260 });
1261 for _ in 0..20 {
1262 tokio::time::sleep(Duration::from_millis(5)).await;
1263 }
1264 assert_eq!(requests.lock().expect("unpoisoned").clone(), vec!["One.".to_owned()]);
1265
1266 service.set_listening(&seat(), 1, false, false);
1269 gate.add_permits(2);
1270 let _ = first.await.expect("joined");
1271 let _ = second.await.expect("joined");
1272 for _ in 0..20 {
1273 tokio::time::sleep(Duration::from_millis(5)).await;
1274 }
1275 assert_eq!(
1276 requests.lock().expect("unpoisoned").clone(),
1277 vec!["One.".to_owned()],
1278 "the queued utterance never starts",
1279 );
1280 assert!(sink.chunks.lock().expect("unpoisoned").is_empty(), "nothing reaches the sink");
1281 }
1282
1283 #[tokio::test]
1284 async fn an_occupant_change_tells_the_new_session_it_is_heard() {
1285 let fixture = service_with(|| StubEngine::new(Arc::new(Mutex::new(Vec::new()))));
1286 fixture.service.set_listening(&seat(), 1, true, false);
1287 fixture.service.occupant_changed(&seat());
1288 assert_eq!(
1289 fixture.notes.said.lock().expect("unpoisoned").clone(),
1290 vec![LISTENING_NOTE.to_owned(), LISTENING_NOTE.to_owned()],
1291 "the new occupant is told again",
1292 );
1293 }
1294
1295 #[tokio::test]
1296 async fn two_sentences_reach_the_sink_in_order() {
1297 let fixture = service_with(|| StubEngine::new(Arc::new(Mutex::new(Vec::new()))));
1298 fixture.service.set_listening(&seat(), 1, true, false);
1299 fixture.service.say(&seat(), "One. Two.".to_owned()).await.expect("queued");
1300 wait_for(&fixture.sink, 2).await;
1301 let requests = fixture.requests.lock().expect("unpoisoned").clone();
1302 assert_eq!(requests, vec!["One.".to_owned(), "Two.".to_owned()]);
1303 let seqs: Vec<u32> = fixture
1304 .sink
1305 .chunks
1306 .lock()
1307 .expect("unpoisoned")
1308 .iter()
1309 .map(|(_, chunk)| chunk.seq)
1310 .collect();
1311 assert_eq!(seqs, vec![0, 1], "the sink hears the sentences in render order");
1312 }
1313
1314 #[tokio::test]
1315 async fn an_utterance_past_the_bound_is_refused_by_name() {
1316 let fixture = service_with(|| StubEngine::new(Arc::new(Mutex::new(Vec::new()))));
1317 fixture.service.set_listening(&seat(), 1, true, false);
1318 let long = "word ".repeat(2_000);
1319 let outcome = fixture.service.say(&seat(), long).await;
1320 assert!(
1321 matches!(outcome, Err(SayError::TooLong(message)) if message.contains("max_utterance_seconds"))
1322 );
1323 }
1324
1325 #[tokio::test]
1326 async fn the_listener_note_is_injected_once_for_the_first_client_and_withdrawn_for_the_last() {
1327 let fixture = service_with(|| StubEngine::new(Arc::new(Mutex::new(Vec::new()))));
1328 let said = |fixture: &Fixture| fixture.notes.said.lock().expect("unpoisoned").clone();
1329
1330 fixture.service.set_listening(&seat(), 1, true, false);
1331 assert_eq!(said(&fixture), vec![LISTENING_NOTE.to_owned()]);
1332 fixture.service.set_listening(&seat(), 2, true, false);
1333 assert_eq!(
1334 said(&fixture),
1335 vec![LISTENING_NOTE.to_owned()],
1336 "the second client says nothing"
1337 );
1338 fixture.service.set_listening(&seat(), 1, false, false);
1339 assert_eq!(said(&fixture).len(), 1, "one client is still listening");
1340 fixture.service.set_listening(&seat(), 2, false, true);
1341 assert_eq!(said(&fixture), vec![LISTENING_NOTE.to_owned(), NOT_LISTENING_NOTE.to_owned()],);
1342 assert_eq!(fixture.service.listeners(&seat()), 0);
1343 }
1344
1345 #[test]
1346 fn a_note_rides_the_envelope_a_view_draws_as_information() {
1347 assert_eq!(
1348 note_envelope(LISTENING_NOTE),
1349 format!("[Voice]\n\n{LISTENING_NOTE}"),
1350 "the note is information for the reader and a message for the agent",
1351 );
1352 assert_eq!(
1353 note_envelope(NOT_LISTENING_NOTE),
1354 format!("[Voice]\n\n{NOT_LISTENING_NOTE}"),
1355 "the withdrawal rides the same envelope",
1356 );
1357 }
1358
1359 #[test]
1360 fn the_listening_note_carries_the_conversational_register() {
1361 assert!(
1362 LISTENING_NOTE
1363 .contains("Speak what they should hear as you would say it in a live conversation"),
1364 "the note names the register",
1365 );
1366 assert!(
1367 LISTENING_NOTE.contains("send each message's substance through mcp__forge__voice__say"),
1368 "the note names the tool that makes it heard",
1369 );
1370 assert!(
1371 LISTENING_NOTE
1372 .contains("keeping the spoken form simpler and shorter so it lands by ear"),
1373 "the spoken form is shorter than the page",
1374 );
1375 assert!(
1376 LISTENING_NOTE.contains("A reply that is only written is one they will not hear"),
1377 "a written-only reply is not heard",
1378 );
1379 assert!(
1380 LISTENING_NOTE.contains("When the stop note arrives, you may stop speaking."),
1381 "the note releases the agent when listening stops",
1382 );
1383 }
1384
1385 #[test]
1386 fn the_stop_note_keeps_its_title_and_releases_the_agent() {
1387 assert_eq!(
1388 NOT_LISTENING_NOTE,
1389 "The user has stopped listening to this seat.\n\nYou may stop speaking.",
1390 "the reader's row is titled by its first line and the agent is released",
1391 );
1392 }
1393
1394 #[tokio::test]
1397 async fn a_failed_warm_is_retried_by_the_first_say() {
1398 let loads = Arc::new(AtomicUsize::new(0));
1399 let requests = Arc::new(Mutex::new(Vec::new()));
1400 let service = VoiceService::new(
1401 {
1402 let loads = Arc::clone(&loads);
1403 let requests = Arc::clone(&requests);
1404 Arc::new(move || {
1405 if loads.fetch_add(1, Ordering::SeqCst) == 0 {
1406 return Err("the first load failed".to_owned());
1407 }
1408 Ok(Arc::new(StubEngine::new(Arc::clone(&requests))) as Arc<dyn Engine>)
1409 })
1410 },
1411 VoiceSettings::default(),
1412 Arc::new(RecordingSink { chunks: Mutex::new(Vec::new()) }),
1413 Arc::new(RecordingNotes { said: Mutex::new(Vec::new()) }),
1414 );
1415
1416 let warm = service.warm().await;
1417 assert!(
1418 matches!(&warm, Err(SayError::Engine(reason)) if reason.contains("the first load failed")),
1419 "the prewarm's own failure names the load, got: {warm:?}",
1420 );
1421
1422 service.set_listening(&seat(), 1, true, false);
1423 assert_eq!(
1424 service.say(&seat(), "Hello.".to_owned()).await,
1425 Ok(()),
1426 "the first say retries the failed load",
1427 );
1428 assert_eq!(
1429 loads.load(Ordering::SeqCst),
1430 2,
1431 "the load ran twice: the failed warm, then the say",
1432 );
1433 }
1434
1435 #[tokio::test]
1436 async fn a_paused_last_listener_says_nothing() {
1437 let fixture = service_with(|| StubEngine::new(Arc::new(Mutex::new(Vec::new()))));
1438 let said = |fixture: &Fixture| fixture.notes.said.lock().expect("unpoisoned").clone();
1439
1440 fixture.service.set_listening(&seat(), 1, true, false);
1441 assert_eq!(said(&fixture), vec![LISTENING_NOTE.to_owned()]);
1442
1443 fixture.service.set_listening(&seat(), 1, false, false);
1446 assert_eq!(said(&fixture), vec![LISTENING_NOTE.to_owned()], "a pause fires no note");
1447 assert_eq!(fixture.service.listeners(&seat()), 0);
1448
1449 fixture.service.set_listening(&seat(), 1, true, false);
1452 assert_eq!(
1453 said(&fixture),
1454 vec![LISTENING_NOTE.to_owned()],
1455 "a return after a pause says nothing",
1456 );
1457 }
1458
1459 #[tokio::test]
1460 async fn a_dropped_connection_leaves_every_seat_it_listened_to() {
1461 let fixture = service_with(|| StubEngine::new(Arc::new(Mutex::new(Vec::new()))));
1462 let first = seat();
1463 let second = SessionSlot::new("Org", "project", "worker");
1464 fixture.service.set_listening(&first, 1, true, false);
1465 fixture.service.set_listening(&first, 2, true, false);
1466 fixture.service.set_listening(&second, 2, true, false);
1467
1468 let changed = fixture.service.release(2);
1472 assert_eq!(changed.len(), 2, "both seats' counts moved");
1473 assert!(changed.contains(&first) && changed.contains(&second), "got: {changed:?}");
1474 assert_eq!(fixture.service.listeners(&first), 1);
1475 assert_eq!(fixture.service.listeners(&second), 0);
1476 assert_eq!(
1477 fixture.notes.said.lock().expect("unpoisoned").len(),
1478 3,
1479 "two listening notes and one withdrawal: the first seat still has a listener",
1480 );
1481 assert_eq!(
1482 fixture.notes.said.lock().expect("unpoisoned").last().map(String::as_str),
1483 Some(NOT_LISTENING_NOTE),
1484 );
1485
1486 fixture.service.release(1);
1487 assert_eq!(fixture.service.listeners(&first), 0);
1488 assert_eq!(
1489 fixture.notes.said.lock().expect("unpoisoned").last().map(String::as_str),
1490 Some(NOT_LISTENING_NOTE),
1491 );
1492 assert!(fixture.service.release(1).is_empty(), "a second release changes nothing");
1493 }
1494
1495 #[tokio::test]
1496 async fn a_second_say_queues_behind_the_first() {
1497 let gate = Arc::new(tokio::sync::Semaphore::new(0));
1498 let requests = Arc::new(Mutex::new(Vec::new()));
1499 let sink = Arc::new(RecordingSink { chunks: Mutex::new(Vec::new()) });
1500 let notes = Arc::new(RecordingNotes { said: Mutex::new(Vec::new()) });
1501 let engine = {
1502 let mut engine = StubEngine::new(Arc::clone(&requests));
1503 engine.gate = Some(Arc::clone(&gate));
1504 Arc::new(engine) as Arc<dyn Engine>
1505 };
1506 let service = VoiceService::new(
1507 Arc::new(move || Ok(Arc::clone(&engine))),
1508 VoiceSettings::default(),
1509 Arc::clone(&sink) as Arc<dyn AudioSink>,
1510 Arc::clone(¬es) as Arc<dyn NoteDispatch>,
1511 );
1512 service.set_listening(&seat(), 1, true, false);
1513
1514 let first = tokio::spawn({
1515 let service = service.clone();
1516 async move { service.say(&seat(), "One.".to_owned()).await }
1517 });
1518 let second = tokio::spawn({
1519 let service = service.clone();
1520 async move { service.say(&seat(), "Two.".to_owned()).await }
1521 });
1522 for _ in 0..20 {
1524 tokio::time::sleep(Duration::from_millis(5)).await;
1525 }
1526 assert_eq!(
1527 requests.lock().expect("unpoisoned").clone(),
1528 vec!["One.".to_owned()],
1529 "the second utterance has not started while the first renders",
1530 );
1531
1532 gate.add_permits(2);
1533 first.await.expect("joined").expect("queued");
1534 second.await.expect("joined").expect("queued");
1535 wait_for(&sink, 2).await;
1536 assert_eq!(
1537 requests.lock().expect("unpoisoned").clone(),
1538 vec!["One.".to_owned(), "Two.".to_owned()],
1539 );
1540 }
1541
1542 #[tokio::test(start_paused = true)]
1546 async fn the_engine_stays_loaded_after_the_last_listener_leaves() {
1547 struct Dropped(Arc<AtomicUsize>);
1548 impl Drop for Dropped {
1549 fn drop(&mut self) {
1550 self.0.fetch_add(1, Ordering::SeqCst);
1551 }
1552 }
1553 struct WarmEngine {
1554 _dropped: Dropped,
1555 }
1556 impl Engine for WarmEngine {
1557 fn speak(&self, _request: SpeakRequest) -> mpsc::Receiver<Result<Chunk, Error>> {
1558 let (sender, receiver) = mpsc::channel(1);
1559 tokio::spawn(async move {
1560 let _ =
1561 sender.send(Ok(Chunk { seq: 0, sample_rate: 24_000, pcm: vec![1] })).await;
1562 });
1563 receiver
1564 }
1565 }
1566
1567 let loads = Arc::new(AtomicUsize::new(0));
1568 let drops = Arc::new(AtomicUsize::new(0));
1569 let service = VoiceService::new(
1570 {
1571 let loads = Arc::clone(&loads);
1572 let drops = Arc::clone(&drops);
1573 Arc::new(move || {
1574 loads.fetch_add(1, Ordering::SeqCst);
1575 Ok(Arc::new(WarmEngine { _dropped: Dropped(Arc::clone(&drops)) })
1576 as Arc<dyn Engine>)
1577 })
1578 },
1579 VoiceSettings::default(),
1580 Arc::new(RecordingSink { chunks: Mutex::new(Vec::new()) }),
1581 Arc::new(RecordingNotes { said: Mutex::new(Vec::new()) }),
1582 );
1583 service.set_listening(&seat(), 1, true, false);
1584 service.say(&seat(), "One.".to_owned()).await.expect("rendered");
1585 assert_eq!(loads.load(Ordering::SeqCst), 1, "premise: the engine loaded");
1586
1587 service.set_listening(&seat(), 1, false, true);
1588 for _ in 0..5 {
1591 tokio::task::yield_now().await;
1592 }
1593 tokio::time::advance(Duration::from_secs(10 * 60)).await;
1594 for _ in 0..20 {
1595 tokio::task::yield_now().await;
1596 }
1597 assert_eq!(drops.load(Ordering::SeqCst), 0, "the engine outlives the last listener");
1598 assert!(
1599 service.engine.lock().await.is_some(),
1600 "the parked engine stays in the slot after the seat empties",
1601 );
1602
1603 service.set_listening(&seat(), 1, true, false);
1604 service.say(&seat(), "Two.".to_owned()).await.expect("rendered");
1605 assert_eq!(loads.load(Ordering::SeqCst), 1, "the next say reuses the parked engine");
1606 }
1607
1608 fn prewarm_workspace(tts_enabled: bool) -> (Arc<crate::Workspace>, tempfile::TempDir) {
1609 let dir = tempfile::tempdir().expect("a temp dir");
1610 let mut config = crate::config::LoadedConfig::empty_for_test();
1611 config.tts.enabled = tts_enabled;
1612 let (workspace, _updates) =
1613 crate::Workspace::testing_stub_with_config(dir.path().to_path_buf(), config)
1614 .expect("the stub builds");
1615 workspace.install_voice_engine_for_test(Arc::new(StubEngine::new(Arc::new(Mutex::new(
1616 Vec::new(),
1617 )))));
1618 (workspace, dir)
1619 }
1620
1621 #[tokio::test]
1625 async fn the_boot_prewarm_loads_the_engine_when_tts_is_on() {
1626 let (workspace, _dir) = prewarm_workspace(true);
1627 workspace.start_voice_prewarm();
1628 assert!(workspace.test_voice_prewarm_started(), "the wave asked for it");
1629
1630 let service = workspace.voice_service();
1631 for _ in 0..200 {
1632 if service.engine.lock().await.is_some() {
1633 return;
1634 }
1635 tokio::time::sleep(Duration::from_millis(5)).await;
1636 }
1637 panic!("the boot prewarm did not load the engine");
1638 }
1639
1640 #[tokio::test]
1643 async fn a_prewarm_call_after_tts_turns_on_dispatches() {
1644 let (workspace, _dir) = prewarm_workspace(false);
1645 workspace.start_voice_prewarm();
1646 assert!(workspace.test_voice_prewarm_started(), "the wave asked");
1647 assert!(
1648 workspace.voice_service().engine.lock().await.is_none(),
1649 "nothing loads while the section is off",
1650 );
1651
1652 let mut enabled = crate::config::LoadedConfig::empty_for_test();
1653 enabled.tts.enabled = true;
1654 *workspace.config.write() = Arc::new(enabled);
1655
1656 workspace.start_voice_prewarm();
1657 let service = workspace.voice_service();
1658 for _ in 0..200 {
1659 if service.engine.lock().await.is_some() {
1660 return;
1661 }
1662 tokio::time::sleep(Duration::from_millis(5)).await;
1663 }
1664 panic!("the second call did not dispatch the load");
1665 }
1666
1667 #[tokio::test]
1670 async fn a_say_whose_listener_leaves_during_the_load_answers_no_listener() {
1671 let (release, hold) = std::sync::mpsc::channel::<()>();
1672 let hold = Arc::new(Mutex::new(hold));
1673 let loads = Arc::new(AtomicUsize::new(0));
1674 let requests = Arc::new(Mutex::new(Vec::new()));
1675 let service = VoiceService::new(
1676 {
1677 let hold = Arc::clone(&hold);
1678 let loads = Arc::clone(&loads);
1679 let requests = Arc::clone(&requests);
1680 Arc::new(move || {
1681 loads.fetch_add(1, Ordering::SeqCst);
1682 let _ = hold.lock().expect("unpoisoned").recv();
1683 Ok(Arc::new(StubEngine::new(Arc::clone(&requests))) as Arc<dyn Engine>)
1684 })
1685 },
1686 VoiceSettings::default(),
1687 Arc::new(RecordingSink { chunks: Mutex::new(Vec::new()) }),
1688 Arc::new(RecordingNotes { said: Mutex::new(Vec::new()) }),
1689 );
1690 service.set_listening(&seat(), 1, true, false);
1691 let say = tokio::spawn({
1692 let service = service.clone();
1693 async move { service.say(&seat(), "Hello.".to_owned()).await }
1694 });
1695 for _ in 0..200 {
1696 if loads.load(Ordering::SeqCst) == 1 {
1697 break;
1698 }
1699 tokio::time::sleep(Duration::from_millis(5)).await;
1700 }
1701 assert_eq!(loads.load(Ordering::SeqCst), 1, "premise: the say is mid-load");
1702
1703 service.set_listening(&seat(), 1, false, true);
1704 release.send(()).expect("the loader is waiting");
1705 assert_eq!(
1706 say.await.expect("joined"),
1707 Err(SayError::NoListener),
1708 "the seat emptied, so the say answers the seat, not the engine",
1709 );
1710 }
1711
1712 #[tokio::test]
1716 async fn a_say_waits_for_the_prewarm_instead_of_answering_not_ready() {
1717 let (release, hold) = std::sync::mpsc::channel::<()>();
1720 let hold = Arc::new(Mutex::new(hold));
1721 let loads = Arc::new(AtomicUsize::new(0));
1722 let requests = Arc::new(Mutex::new(Vec::new()));
1723 let service = VoiceService::new(
1724 {
1725 let hold = Arc::clone(&hold);
1726 let loads = Arc::clone(&loads);
1727 let requests = Arc::clone(&requests);
1728 Arc::new(move || {
1729 loads.fetch_add(1, Ordering::SeqCst);
1730 let _ = hold.lock().expect("unpoisoned").recv();
1731 Ok(Arc::new(StubEngine::new(Arc::clone(&requests))) as Arc<dyn Engine>)
1732 })
1733 },
1734 VoiceSettings::default(),
1735 Arc::new(RecordingSink { chunks: Mutex::new(Vec::new()) }),
1736 Arc::new(RecordingNotes { said: Mutex::new(Vec::new()) }),
1737 );
1738 service.set_listening(&seat(), 1, true, false);
1739 let warm = tokio::spawn({
1740 let service = service.clone();
1741 async move { service.warm().await }
1742 });
1743 for _ in 0..200 {
1744 if loads.load(Ordering::SeqCst) == 1 {
1745 break;
1746 }
1747 tokio::time::sleep(Duration::from_millis(5)).await;
1748 }
1749 assert_eq!(loads.load(Ordering::SeqCst), 1, "premise: the prewarm is mid-load");
1750
1751 let say = tokio::spawn({
1752 let service = service.clone();
1753 async move { service.say(&seat(), "Hello.".to_owned()).await }
1754 });
1755 for _ in 0..50 {
1756 tokio::time::sleep(Duration::from_millis(2)).await;
1757 }
1758 assert!(!say.is_finished(), "premise: the say is waiting on the load, not answered");
1759
1760 release.send(()).expect("the loader is waiting");
1761 warm.await.expect("joined").expect("warmed");
1762 assert_eq!(
1763 say.await.expect("joined"),
1764 Ok(()),
1765 "the say waits for the prewarm instead of answering NotReady",
1766 );
1767 assert_eq!(loads.load(Ordering::SeqCst), 1, "one load served both");
1768 }
1769
1770 #[tokio::test]
1771 async fn says_that_arrive_during_the_load_queue_behind_it() {
1772 let loads = Arc::new(AtomicUsize::new(0));
1773 let requests = Arc::new(Mutex::new(Vec::new()));
1774 let sink = Arc::new(RecordingSink { chunks: Mutex::new(Vec::new()) });
1775 let notes = Arc::new(RecordingNotes { said: Mutex::new(Vec::new()) });
1776 let service = VoiceService::new(
1777 {
1778 let loads = Arc::clone(&loads);
1779 let requests = Arc::clone(&requests);
1780 Arc::new(move || {
1781 loads.fetch_add(1, Ordering::SeqCst);
1782 std::thread::sleep(Duration::from_millis(50));
1783 Ok(Arc::new(StubEngine::new(Arc::clone(&requests))) as Arc<dyn Engine>)
1784 })
1785 },
1786 VoiceSettings::default(),
1787 Arc::clone(&sink) as Arc<dyn AudioSink>,
1788 Arc::clone(¬es) as Arc<dyn NoteDispatch>,
1789 );
1790 let seat = seat();
1791 service.set_listening(&seat, 1, true, false);
1792
1793 let (first, second) = tokio::join!(
1794 service.say(&seat, "One.".to_owned()),
1795 service.say(&seat, "Two.".to_owned())
1796 );
1797 assert_eq!(first, Ok(()));
1798 assert_eq!(second, Ok(()));
1799 assert_eq!(loads.load(Ordering::SeqCst), 1, "the second say waits for the load");
1800 wait_for(&sink, 2).await;
1801 }
1802}