package main // The capture session: what happens to one 30ms frame, given whether Maven is // currently speaking. Split out of main.go's processFrame so the decision is // testable without a mic, a speaker, or a daemon (Vikunja #287). import ( "context" "log" "github.com/kami/maven/internal/audio" ) // utteranceSender ships one complete utterance to the voice server and // returns the reply audio to play. The real one round-trips over the voice // wire; tests substitute a recorder. type utteranceSender interface { Send(ctx context.Context, utt audio.Audio, lang string) (audio.Audio, error) } // bargeInConfig holds the two numbers barge-in needs. Zero Frames disables // barge-in entirely — the half-duplex gate still runs. type bargeInConfig struct { // RMS is the normalised energy a frame must exceed to count as him // talking over her rather than the mic hearing her. It is deliberately // far above the VAD's own floor: the speaker leaks into the mic at // roughly ambient level, a person talking at the mic does not. RMS float64 // Frames is how many consecutive frames must clear RMS before playback // is cut. One loud frame is a door closing; five in a row is a voice. Frames int } // Enabled reports whether barge-in should be attempted at all. func (c bargeInConfig) Enabled() bool { return c.Frames > 0 && c.RMS > 0 } // session is the per-client capture state machine. type session struct { vad *VAD player player sender utteranceSender lang string barge bargeInConfig // loudFrames counts consecutive over-threshold frames seen while she is // speaking. Reset whenever a frame falls back under the threshold, and // whenever playback ends. loudFrames int // counters, read by tests and logged on the way out. suppressed int // frames dropped because she was speaking bargeIns int // times playback was cut because he spoke over her sent int // utterances shipped to the daemon } func newSession(vad *VAD, p player, s utteranceSender, lang string, barge bargeInConfig) *session { return &session{vad: vad, player: p, sender: s, lang: lang, barge: barge} } // feed processes one 30ms PCM frame. // // While the player is running the capture side is muted: the VAD is not fed // and no utterance can be produced, so Maven's own reply cannot come back in // as a new command. The one thing that gets through is barge-in — sustained // energy well above the speaker's leak level cuts playback, and capture // resumes on the very next frame with a clean VAD. func (s *session) feed(ctx context.Context, frame []byte) error { if s.player.Playing() { s.suppressed++ if !s.barge.Enabled() { return nil } if frameRMS(PCMToI16(frame)) < s.barge.RMS { s.loudFrames = 0 return nil } s.loudFrames++ if s.loudFrames < s.barge.Frames { return nil } // He is talking over her. Cut her off, drop the VAD state that // accumulated from the echo, and start listening for real. s.player.Stop() s.bargeIns++ s.loudFrames = 0 s.vad.Reset() log.Printf("mavwaked: barge-in — stopped playback") return nil } // Not speaking. If we just stopped, make sure no echo-era state leaks // into the next utterance. if s.loudFrames != 0 { s.loudFrames = 0 s.vad.Reset() } utt, state := s.vad.Feed(PCMToI16(frame)) if state == StateSpeech || utt.Bytes == nil { return nil } return s.dispatch(ctx, utt) } // dispatch ships a complete utterance and plays whatever comes back. func (s *session) dispatch(ctx context.Context, utt audio.Audio) error { log.Printf("mavwaked: utterance complete (%.2fs, %d bytes), sending...", utt.Duration(), len(utt.Bytes)) reply, err := s.sender.Send(ctx, utt, s.lang) s.sent++ if err != nil { return err } if len(reply.Bytes) == 0 { log.Printf("mavwaked: empty reply audio (text only)") return nil } // The VAD has been accumulating from the buffered mic stream while the // round-trip blocked. None of it is a command — reset before the // speaker opens, so the first post-reply frame starts clean. s.vad.Reset() s.player.Play(reply) return nil }