Skip to main content

flowcat_core/agent/
wakeword.rs

1// SPDX-License-Identifier: Apache-2.0
2//
3//! Wake-word gating (pure logic, no network).
4//!
5//! Ports pipecat's wake filters (`processors/filters/wake_check_filter.py`,
6//! `wake_notifier_filter.py`) to the flowcat frame stream:
7//!
8//! - [`WakeCheckFilter`] — buffers per-user transcription text and only lets a
9//!   [`Frame::Transcription`] through once a wake phrase has been seen; a
10//!   keepalive window keeps the gate open for follow-up turns.
11//! - [`WakeNotifierProcessor`] — fires a [`WakeNotifier`] callback when a matching
12//!   frame passes a predicate, without otherwise altering the stream (used to wake
13//!   another component, e.g. arm the brain).
14//!
15//! Detection is plain substring/word matching over transcripts — no audio model,
16//! no network. (Wake detection straight off the audio is a VAD/turn concern.)
17
18use std::collections::HashMap;
19use std::sync::Arc;
20use std::time::Duration;
21
22use async_trait::async_trait;
23
24use crate::error::Result;
25use crate::processor::frame::{Frame, FrameKind};
26use crate::processor::{Envelope, FrameProcessor, Link};
27
28/// A monotonic clock seam so the keepalive window is testable without real time.
29pub trait WakeClock: Send {
30    /// Current time as a [`Duration`] since some fixed, monotonic epoch.
31    fn now(&self) -> Duration;
32}
33
34/// The default wall-clock implementation (`Instant`-backed).
35pub struct SystemWakeClock {
36    base: std::time::Instant,
37}
38
39impl Default for SystemWakeClock {
40    fn default() -> Self {
41        Self {
42            base: std::time::Instant::now(),
43        }
44    }
45}
46
47impl WakeClock for SystemWakeClock {
48    fn now(&self) -> Duration {
49        self.base.elapsed()
50    }
51}
52
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54enum WakeState {
55    Idle,
56    Awake,
57}
58
59struct ParticipantState {
60    state: WakeState,
61    wake_timer: Duration,
62    accumulator: String,
63}
64
65impl ParticipantState {
66    fn new() -> Self {
67        Self {
68            state: WakeState::Idle,
69            wake_timer: Duration::ZERO,
70            accumulator: String::new(),
71        }
72    }
73}
74
75/// Gates [`Frame::Transcription`] frames behind a spoken wake phrase. Until a wake
76/// phrase appears in a user's accumulated transcript, that user's transcriptions
77/// are withheld; once seen, the gate opens and a keepalive window keeps it open for
78/// follow-up turns (mirrors pipecat `WakeCheckFilter`). All other frames pass
79/// through unchanged.
80///
81/// Matching is case-insensitive whitespace-tolerant: each wake phrase's words must
82/// appear in order separated only by whitespace (pipecat builds the same
83/// `\b…\s*…\b` regex). On a match, the accumulator is trimmed to start at the wake
84/// phrase so the gated transcript carries the command, not the wake word's prefix.
85pub struct WakeCheckFilter {
86    name: &'static str,
87    wake_phrases: Vec<Vec<String>>,
88    keepalive: Duration,
89    clock: Box<dyn WakeClock>,
90    participants: HashMap<String, ParticipantState>,
91}
92
93impl WakeCheckFilter {
94    /// A filter that wakes on any of `wake_phrases`, with a `keepalive` window
95    /// (seconds) after each accepted transcript during which the gate stays open.
96    pub fn new(wake_phrases: Vec<String>, keepalive: Duration) -> Self {
97        Self::with_clock(
98            wake_phrases,
99            keepalive,
100            Box::new(SystemWakeClock::default()),
101        )
102    }
103
104    /// As [`WakeCheckFilter::new`] but with an injected clock (for tests).
105    pub fn with_clock(
106        wake_phrases: Vec<String>,
107        keepalive: Duration,
108        clock: Box<dyn WakeClock>,
109    ) -> Self {
110        let phrases = wake_phrases
111            .into_iter()
112            .map(|p| p.split_whitespace().map(|w| w.to_lowercase()).collect())
113            .collect();
114        Self {
115            name: "wake-check",
116            wake_phrases: phrases,
117            keepalive,
118            clock,
119            participants: HashMap::new(),
120        }
121    }
122}
123
124/// Find the byte offset in `haystack` where a wake phrase begins, matched on
125/// **whole-word** boundaries (so "computer" does not match "computers"), tolerant
126/// of any whitespace between the phrase's words. Mirrors pipecat's
127/// `\bword\s*word\b` regex. A free function so it borrows only the phrase list,
128/// not all of `self` (the per-participant accumulator is borrowed mutably at the
129/// call site).
130fn match_wake(wake_phrases: &[Vec<String>], haystack: &str) -> Option<usize> {
131    let lower = haystack.to_lowercase();
132    let words: Vec<(usize, &str)> = word_offsets(&lower);
133    for phrase in wake_phrases {
134        if phrase.is_empty() {
135            continue;
136        }
137        // Slide over the token list looking for the phrase's words in order, each
138        // matched as a whole token (whole-word boundary).
139        'start: for start in 0..words.len() {
140            if words.len() - start < phrase.len() {
141                break;
142            }
143            for (i, pw) in phrase.iter().enumerate() {
144                if words[start + i].1 != pw.as_str() {
145                    continue 'start;
146                }
147            }
148            return Some(words[start].0);
149        }
150    }
151    None
152}
153
154/// Tokenize into `(byte_offset, word)` pairs split on whitespace.
155fn word_offsets(s: &str) -> Vec<(usize, &str)> {
156    let mut out = Vec::new();
157    let mut idx = 0;
158    for word in s.split_whitespace() {
159        // Find the word starting at/after idx (split_whitespace collapses runs).
160        if let Some(rel) = s[idx..].find(word) {
161            let at = idx + rel;
162            out.push((at, word));
163            idx = at + word.len();
164        }
165    }
166    out
167}
168
169#[async_trait]
170impl FrameProcessor for WakeCheckFilter {
171    fn name(&self) -> &str {
172        self.name
173    }
174
175    async fn process_frame(&mut self, env: Envelope, link: &Link) -> Result<()> {
176        let Frame::Transcription {
177            ref text,
178            ref user_id,
179            ..
180        } = env.frame
181        else {
182            // Non-transcription frames always pass through.
183            link.push(env.meta, env.frame, env.direction).await;
184            return Ok(());
185        };
186
187        let now = self.clock.now();
188        let keepalive = self.keepalive;
189        let user_key = user_id.to_string();
190        // Reborrow split: take the matcher input (phrases) by shared ref before
191        // mutably borrowing the participant entry.
192        let wake_phrases = &self.wake_phrases;
193        let p = self
194            .participants
195            .entry(user_key)
196            .or_insert_with(ParticipantState::new);
197
198        // Already awake within the keepalive window → pass straight through.
199        if p.state == WakeState::Awake {
200            if now.saturating_sub(p.wake_timer) < keepalive {
201                p.wake_timer = now;
202                link.push(env.meta, env.frame, env.direction).await;
203                return Ok(());
204            }
205            p.state = WakeState::Idle;
206        }
207
208        // Separate fragments with a space so two transcripts never fuse into a
209        // single token (e.g. "…it" + "hey…" must not become "ithey").
210        if !p.accumulator.is_empty() && !p.accumulator.ends_with(char::is_whitespace) {
211            p.accumulator.push(' ');
212        }
213        p.accumulator.push_str(text);
214        if let Some(start) = match_wake(wake_phrases, &p.accumulator) {
215            p.state = WakeState::Awake;
216            p.wake_timer = now;
217            // Trim to the wake phrase so the command (not the wake prefix) flows on.
218            let gated = p.accumulator[start..].to_string();
219            p.accumulator.clear();
220            let (uid, lang, final_) = match env.frame {
221                Frame::Transcription {
222                    user_id,
223                    language,
224                    final_,
225                    ..
226                } => (user_id, language, final_),
227                _ => unreachable!(),
228            };
229            link.push(
230                env.meta,
231                Frame::Transcription {
232                    text: gated,
233                    user_id: uid,
234                    language: lang,
235                    final_,
236                },
237                env.direction,
238            )
239            .await;
240        }
241        // Not yet woken → withhold this transcription.
242        Ok(())
243    }
244}
245
246/// A callback fired when a wake condition is met. Cheaply cloneable.
247pub type WakeNotifier = Arc<dyn Fn() + Send + Sync>;
248
249/// A predicate over a frame deciding whether to fire the notifier.
250pub type WakePredicate = Arc<dyn Fn(&Frame) -> bool + Send + Sync>;
251
252/// Fires a [`WakeNotifier`] when a frame of one of the watched [`FrameKind`]s
253/// satisfies a predicate, then forwards the frame unchanged. Port of pipecat
254/// `WakeNotifierFilter` — used to wake another component (e.g. arm the brain on
255/// the first user-stopped-speaking) without consuming the frame.
256pub struct WakeNotifierProcessor {
257    name: &'static str,
258    kinds: Vec<FrameKind>,
259    predicate: WakePredicate,
260    notifier: WakeNotifier,
261}
262
263impl WakeNotifierProcessor {
264    /// Watch `kinds`; when one matches `predicate`, call `notifier`.
265    pub fn new(kinds: Vec<FrameKind>, predicate: WakePredicate, notifier: WakeNotifier) -> Self {
266        Self {
267            name: "wake-notifier",
268            kinds,
269            predicate,
270            notifier,
271        }
272    }
273}
274
275#[async_trait]
276impl FrameProcessor for WakeNotifierProcessor {
277    fn name(&self) -> &str {
278        self.name
279    }
280
281    async fn process_frame(&mut self, env: Envelope, link: &Link) -> Result<()> {
282        if self.kinds.contains(&env.frame.kind()) && (self.predicate)(&env.frame) {
283            (self.notifier)();
284        }
285        link.push(env.meta, env.frame, env.direction).await;
286        Ok(())
287    }
288}
289
290#[cfg(test)]
291mod tests {
292    use super::*;
293    use crate::agent::test_harness::drive;
294    use crate::processor::frame::Direction;
295    use std::sync::atomic::{AtomicU64, Ordering};
296
297    /// A test clock the test advances manually.
298    #[derive(Clone, Default)]
299    struct FakeClock(Arc<AtomicU64>);
300    impl FakeClock {
301        fn advance(&self, ms: u64) {
302            self.0.fetch_add(ms, Ordering::SeqCst);
303        }
304    }
305    impl WakeClock for FakeClock {
306        fn now(&self) -> Duration {
307            Duration::from_millis(self.0.load(Ordering::SeqCst))
308        }
309    }
310
311    fn transcription(text: &str, user: &str) -> Frame {
312        Frame::Transcription {
313            text: text.into(),
314            user_id: Arc::from(user),
315            language: None,
316            final_: true,
317        }
318    }
319
320    #[tokio::test]
321    async fn gates_until_wake_phrase_then_opens() {
322        let filter = WakeCheckFilter::new(vec!["hey assistant".into()], Duration::from_secs(3));
323        let out = drive(
324            Box::new(filter),
325            vec![
326                transcription("what time is it", "u1"), // withheld (no wake)
327                transcription("hey assistant set a timer", "u1"), // wakes + passes
328                transcription("for ten minutes", "u1"), // keepalive → passes
329            ],
330            Direction::Downstream,
331        )
332        .await;
333        let texts: Vec<String> = out
334            .into_iter()
335            .filter_map(|f| match f {
336                Frame::Transcription { text, .. } => Some(text),
337                _ => None,
338            })
339            .collect();
340        // First is withheld; the woken transcript is trimmed to the wake phrase;
341        // the follow-up rides the keepalive.
342        assert_eq!(texts, vec!["hey assistant set a timer", "for ten minutes"]);
343    }
344
345    #[tokio::test]
346    async fn wake_only_fires_on_the_phrase() {
347        let filter = WakeCheckFilter::new(vec!["computer".into()], Duration::from_secs(3));
348        let out = drive(
349            Box::new(filter),
350            vec![
351                transcription("hello world", "u1"),
352                transcription("the computers are noisy", "u1"),
353            ],
354            Direction::Downstream,
355        )
356        .await;
357        // "computers" is not the standalone word "computer" → nothing passes.
358        let count = out
359            .iter()
360            .filter(|f| matches!(f, Frame::Transcription { .. }))
361            .count();
362        assert_eq!(count, 0, "fired on a non-wake word: {out:?}");
363    }
364
365    #[tokio::test]
366    async fn keepalive_expires_and_regates() {
367        let clock = FakeClock::default();
368        let filter = WakeCheckFilter::with_clock(
369            vec!["wake".into()],
370            Duration::from_millis(1000),
371            Box::new(clock.clone()),
372        );
373        // We need to drive frames with time advancing between them, so run the
374        // processor directly rather than via the batch harness.
375        use crate::processor::runtime::{channel, NORMAL_CHAN_CAP};
376        use crate::processor::{Envelope, Link};
377        use std::sync::atomic::AtomicI64;
378
379        let (cap_tx, mut cap_rx) = channel(Arc::from("cap"), NORMAL_CHAN_CAP);
380        let link = Link {
381            next: Some(cap_tx.clone()),
382            prev: Some(cap_tx),
383            name: Arc::from("wake-check"),
384            clock: crate::processor::Clock::new(),
385            observer: None,
386            enable_metrics: false,
387            enable_usage_metrics: false,
388            ttfb_start: Arc::new(AtomicI64::new(0)),
389            processing_start: Arc::new(AtomicI64::new(0)),
390        };
391        let mut filter: Box<dyn FrameProcessor> = Box::new(filter);
392
393        let drain = |rx: &mut crate::processor::runtime::ProcessorRx| -> Vec<Frame> {
394            let mut v = Vec::new();
395            while let Ok(e) = rx.normal.try_recv() {
396                v.push(e.frame);
397            }
398            v
399        };
400
401        // Wake.
402        filter
403            .process_frame(
404                Envelope::new(transcription("wake up", "u"), Direction::Downstream),
405                &link,
406            )
407            .await
408            .unwrap();
409        assert_eq!(drain(&mut cap_rx).len(), 1);
410
411        // Within keepalive → passes.
412        clock.advance(500);
413        filter
414            .process_frame(
415                Envelope::new(transcription("still here", "u"), Direction::Downstream),
416                &link,
417            )
418            .await
419            .unwrap();
420        assert_eq!(drain(&mut cap_rx).len(), 1);
421
422        // After keepalive expires (relative to the last accepted frame) → re-gated.
423        clock.advance(2000);
424        filter
425            .process_frame(
426                Envelope::new(transcription("ignored now", "u"), Direction::Downstream),
427                &link,
428            )
429            .await
430            .unwrap();
431        assert_eq!(
432            drain(&mut cap_rx).len(),
433            0,
434            "should re-gate after keepalive"
435        );
436    }
437
438    #[tokio::test]
439    async fn notifier_fires_on_matching_kind_and_predicate() {
440        let hits = Arc::new(AtomicU64::new(0));
441        let h2 = hits.clone();
442        let proc = WakeNotifierProcessor::new(
443            vec![FrameKind::Transcription],
444            Arc::new(|f| matches!(f, Frame::Transcription { text, .. } if text.contains("now"))),
445            Arc::new(move || {
446                h2.fetch_add(1, Ordering::SeqCst);
447            }),
448        );
449        let out = drive(
450            Box::new(proc),
451            vec![
452                transcription("later", "u"),
453                transcription("do it now", "u"),
454                Frame::Text("now".into()), // wrong kind → must not fire
455            ],
456            Direction::Downstream,
457        )
458        .await;
459        // Fires exactly once (the matching transcription); all frames forwarded.
460        assert_eq!(hits.load(Ordering::SeqCst), 1);
461        assert_eq!(out.len(), 3, "notifier must forward every frame");
462    }
463}