fed33a4e16
Playback was `go playAudio(reply)` — fire and forget, nobody holding the process handle. Two audible consequences fell out of that. She answered herself. The capture loop kept feeding the VAD while the speaker was running, so her own reply came back in through the mic, tripped the VAD, and was shipped to the daemon as a fresh command. There is no acoustic echo canceller in this pipeline, so the fix is half-duplex: while she is speaking, the capture side is muted. That part is unconditional — it repairs a defect, it is not a new capability. And talking over her did nothing, because there was no handle to cancel. -barge-in now cuts playback when sustained energy clears a room-tuned threshold (-barge-in-rms, default 0.12 normalised, over -barge-in-frames consecutive frames, default 5). It is off by default: without an echo canceller the only way to tell "he is talking over her" from "the mic is hearing her" is that he is much louder, and how much louder depends on where the mic sits. The frame decision moved out of main.go into session.feed, behind a player and an utteranceSender interface, so all of it is testable with no mic, no speaker and no daemon. Nine tests cover the self-hearing case, the off-by-default case, the consecutive-frame requirement, speaker-leak-level audio not triggering, capturing the interrupting utterance after a cut, and failed round-trips not starting playback. The other seven items on #287 (partial STT, per-segment retry, mic profiles, noise-floor calibration, short-response-while-speaking) are untouched and stay on the task.
140 lines
3.5 KiB
Go
140 lines
3.5 KiB
Go
package main
|
|
|
|
// Reply playback, and the half-duplex gate around it (Vikunja #287).
|
|
//
|
|
// Before this, playback was `go playAudio(reply)` — fire and forget, with no
|
|
// handle on the running aplay. Two things fell out of that, and both are
|
|
// audible:
|
|
//
|
|
// 1. Self-trigger. The capture loop keeps feeding the VAD while the speaker
|
|
// is playing, so Maven's own reply comes back in through the mic, trips
|
|
// the VAD, and is sent to the daemon as a fresh utterance. She answers
|
|
// herself. There is no acoustic echo canceller in this pipeline, so the
|
|
// only correct fix is half-duplex: while she is speaking, the capture
|
|
// side is muted.
|
|
//
|
|
// 2. No barge-in. Talking over her did nothing — there was nothing to
|
|
// cancel, because nobody held the process handle.
|
|
//
|
|
// The two are the same mechanism seen from opposite sides, so they live
|
|
// together here. Echo suppression is unconditional (it fixes a bug). Barge-in
|
|
// is off unless -barge-in is passed, because it needs a room-specific energy
|
|
// threshold: with no echo canceller, the only way to tell "he is talking over
|
|
// her" from "the mic is hearing her" is that he is louder, and how much
|
|
// louder depends on where the mic sits relative to the speaker.
|
|
|
|
import (
|
|
"log"
|
|
"os/exec"
|
|
"strconv"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/kami/maven/internal/audio"
|
|
)
|
|
|
|
// player plays one reply at a time and can be cut off mid-utterance.
|
|
type player interface {
|
|
// Play starts playback of a, replacing anything already playing, and
|
|
// returns immediately.
|
|
Play(a audio.Audio)
|
|
// Stop ends playback now. A no-op when nothing is playing.
|
|
Stop()
|
|
// Playing reports whether audio is currently going out of the speaker.
|
|
Playing() bool
|
|
}
|
|
|
|
// aplayPlayer pipes raw PCM to aplay(1). Stop kills the child, which is what
|
|
// makes barge-in instant rather than "instant at the end of the sentence".
|
|
type aplayPlayer struct {
|
|
mu sync.Mutex
|
|
cmd *exec.Cmd
|
|
playing bool
|
|
// gen rises on every Play/Stop so a finishing playback cannot clear the
|
|
// playing flag of the one that replaced it.
|
|
gen uint64
|
|
}
|
|
|
|
func newAplayPlayer() *aplayPlayer { return &aplayPlayer{} }
|
|
|
|
func (p *aplayPlayer) Play(a audio.Audio) {
|
|
if len(a.Bytes) == 0 {
|
|
return
|
|
}
|
|
p.Stop()
|
|
|
|
cmd := exec.Command("aplay",
|
|
"-f", "S16_LE",
|
|
"-r", strconv.Itoa(a.Format.SampleRate),
|
|
"-c", strconv.Itoa(a.Format.Channels),
|
|
"-t", "raw",
|
|
)
|
|
stdin, err := cmd.StdinPipe()
|
|
if err != nil {
|
|
log.Printf("mavwaked: aplay stdin pipe: %v", err)
|
|
return
|
|
}
|
|
if err := cmd.Start(); err != nil {
|
|
log.Printf("mavwaked: start aplay: %v", err)
|
|
_ = stdin.Close()
|
|
return
|
|
}
|
|
|
|
p.mu.Lock()
|
|
p.gen++
|
|
gen := p.gen
|
|
p.cmd = cmd
|
|
p.playing = true
|
|
p.mu.Unlock()
|
|
|
|
go func() {
|
|
if _, err := stdin.Write(a.Bytes); err != nil {
|
|
// Broken pipe is the expected outcome of Stop().
|
|
log.Printf("mavwaked: write to aplay: %v", err)
|
|
}
|
|
_ = stdin.Close()
|
|
|
|
done := make(chan error, 1)
|
|
go func() { done <- cmd.Wait() }()
|
|
select {
|
|
case err := <-done:
|
|
if err != nil {
|
|
log.Printf("mavwaked: aplay: %v", err)
|
|
}
|
|
case <-time.After(30 * time.Second):
|
|
log.Printf("mavwaked: aplay timeout, killing")
|
|
if pr := cmd.Process; pr != nil {
|
|
_ = pr.Kill()
|
|
}
|
|
<-done
|
|
}
|
|
|
|
p.mu.Lock()
|
|
if p.gen == gen {
|
|
p.playing = false
|
|
p.cmd = nil
|
|
}
|
|
p.mu.Unlock()
|
|
}()
|
|
}
|
|
|
|
func (p *aplayPlayer) Stop() {
|
|
p.mu.Lock()
|
|
cmd := p.cmd
|
|
if cmd != nil {
|
|
p.gen++
|
|
p.playing = false
|
|
p.cmd = nil
|
|
}
|
|
p.mu.Unlock()
|
|
if cmd != nil && cmd.Process != nil {
|
|
_ = cmd.Process.Kill()
|
|
}
|
|
}
|
|
|
|
func (p *aplayPlayer) Playing() bool {
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
return p.playing
|
|
}
|