Files
attendant/answer_test.go
zeekayandHanzo Dev 3032e96f35 answer the room in the gap the floor leaves
The library had both directions and no service: attend.go ended in an empty
main. This closes the loop — a turn is transcribed, our model answers it, and
hanzoai/speech gives that answer a voice the room hears.

The floor decides when. It frees itself `rest` after the holder stops, which is
the pause a person waits out before speaking, and it fills again within `notice`
of anyone starting — so an answer still playing when somebody talks is cut off
there and the track comes down. Nothing here holds a second notion of turns.

Say takes a context and the bytes rather than a path: it returns when the room
has heard the audio or the moment it is cancelled, and unpublishes either way. A
track left up is a microphone nobody takes down, and every answer added another.

A session is now addressed where the open says it lives. A growing transcript is
a window in one process's memory and speech runs two replicas behind a Service
that round-robins, so pushing to the Service address is a 404 for about half of
every turn.

The attendant holds no LiveKit signing key: it asks meet for one room's token
the way a browser does, carrying one IAM identity to meet, the model and the
voice alike.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-13 19:27:06 -07:00

296 lines
8.6 KiB
Go

package main
import (
"bytes"
"context"
"io"
"strings"
"sync"
"testing"
"time"
)
// The attendant's own side of a meeting, driven on a clock the test moves: a
// model that answers, a voice that returns the repo's own Ogg/Opus, and a floor
// with real speakers on it. What is measured is WHEN it speaks, because an
// attendant that talks over people is not a participant.
// room is where an answer goes when there is no SFU. It takes what would have
// been published and, like a real room, holds the attendant for as long as the
// audio would have taken — so being cut off is something that can happen here.
type room struct {
hold time.Duration // how long the audio plays for
done chan struct{} // one signal per answer said
mu sync.Mutex
said [][]byte
cut []bool
}
func (r *room) say(ctx context.Context, ogg io.ReadCloser) error {
heard, err := io.ReadAll(ogg)
if err != nil {
return err
}
cut := false
if r.hold > 0 {
select {
case <-time.After(r.hold):
case <-ctx.Done():
cut = true
}
}
r.mu.Lock()
r.said = append(r.said, heard)
r.cut = append(r.cut, cut)
r.mu.Unlock()
select {
case r.done <- struct{}{}:
default:
}
return nil
}
func (r *room) heard() ([][]byte, []bool) {
r.mu.Lock()
defer r.mu.Unlock()
return append([][]byte(nil), r.said...), append([]bool(nil), r.cut...)
}
// answering is the attendant's voice against the fake: one URL for the model
// and for the voice, because the estate is one and it is reached one way.
func answering(notes *Notes, url string) *Answer {
return &Answer{
Notes: notes, ai: url, model: "best", speech: url, voice: "af_heart",
prompt: "answer", who: "attendant", wake: make(chan struct{}, 1),
}
}
// spoke drives one whole turn: somebody talks, the room names them, and then
// the room falls quiet — which is what closes their turn and frees the floor.
func spoke(f *Floor, c *clock, who string) {
talk(f, c, time.Second, voice{who, 1})
f.Speaking([]string{who})
talk(f, c, 500*time.Millisecond, voice{who, 1})
c.pass(rest + time.Second)
f.Speaking(nil)
}
// waited reports whether an answer arrived inside d.
func waited(r *room, d time.Duration) bool {
select {
case <-r.done:
return true
case <-time.After(d):
return false
}
}
// The floor moves from ana to ben, which ends ana's turn — and the attendant
// now has something to answer while somebody else is talking. It waits.
//
// This is the whole difference between a participant and a nuisance: a turn
// ending is not an invitation, an empty floor is.
func TestTheAttendantWaitsForTheFloor(t *testing.T) {
s, url := serve(t, 0)
s.says("The rebuild finished at four.")
notes := Record(url, "whisper", "en")
say := answering(notes, url)
c := started()
f := chair(c, say)
r := &room{done: make(chan struct{}, 4)}
ctx, stop := context.WithCancel(context.Background())
defer stop()
go say.tend(ctx, f, r.say)
// ana holds the floor, then ben takes it off her: her turn closes and asks
// for an answer, and ben is mid-sentence.
talk(f, c, time.Second, voice{"ana", 1})
f.Speaking([]string{"ana"})
both := []voice{{"ana", 1}, {"ben", 2}}
talk(f, c, notice, both...)
f.Speaking([]string{"ben", "ana"})
talk(f, c, grab, both...)
f.Speaking([]string{"ben", "ana"})
if f.Holder() != "ben" {
t.Fatalf("ben holds the floor here, not %q", f.Holder())
}
if waited(r, time.Second) {
t.Fatal("the attendant answered ana while ben was talking")
}
// ben stops. Now there is a gap, and a gap is what an answer is for.
c.pass(rest + time.Second)
f.Speaking(nil)
if !waited(r, 10*time.Second) {
t.Fatal("the floor was free and the attendant never answered")
}
said, _ := r.heard()
t.Logf("held its answer through ben's turn, then said %d bytes of audio into the gap", len(said[0]))
}
// Somebody starts talking while the attendant is answering. It stops.
//
// The floor is empty while the attendant talks — it is not on the ranking and
// subscribes to nothing of its own — so the first person to make a sound fills
// it, and that is the signal. Being interrupted costs a syllable.
func TestSomebodyTalkingCutsTheAnswerShort(t *testing.T) {
s, url := serve(t, 0)
s.says("I can pull those numbers up.")
notes := Record(url, "whisper", "en")
say := answering(notes, url)
c := started()
f := chair(c, say)
// A minute of audio: far longer than this test will wait, so an answer that
// finishes here finished because it was cut off.
r := &room{hold: time.Minute, done: make(chan struct{}, 4)}
ctx, stop := context.WithCancel(context.Background())
defer stop()
go say.tend(ctx, f, r.say)
spoke(f, c, "ana")
// Wait for the attendant to be talking: the model has answered and the audio
// is playing when the service has been asked.
for at := time.Now(); len(s.asks()) == 0; {
if time.Since(at) > 10*time.Second {
t.Fatal("the attendant never started answering")
}
time.Sleep(20 * time.Millisecond)
}
// ben talks over it.
talk(f, c, notice, voice{"ben", 2})
f.Speaking([]string{"ben"})
if f.Holder() != "ben" {
t.Fatalf("ben holds the floor here, not %q", f.Holder())
}
if !waited(r, 5*time.Second) {
t.Fatal("ben started talking and the attendant kept going")
}
_, cut := r.heard()
if !cut[0] {
t.Fatal("the answer played to the end while ben was talking")
}
t.Log("stopped mid-answer when ben took the floor")
}
// A turn that ends while the attendant is talking asks for an answer, and by
// the time that ask is read the attendant has already spoken. Nobody answers
// themselves — and an attendant that did would then answer THAT, forever.
func TestTheAttendantDoesNotAnswerItself(t *testing.T) {
s, url := serve(t, 0)
s.says("Green across both regions.")
notes := Record(url, "whisper", "en")
say := answering(notes, url)
c := started()
f := chair(c, say)
r := &room{done: make(chan struct{}, 4)}
ctx, stop := context.WithCancel(context.Background())
defer stop()
go say.tend(ctx, f, r.say)
spoke(f, c, "ana")
if !waited(r, 10*time.Second) {
t.Fatal("ana spoke and the attendant did not answer")
}
// The leftover ask, with nothing said since.
say.nudge()
if waited(r, time.Second) {
t.Fatal("the attendant answered its own answer")
}
if n := len(s.asks()); n != 1 {
t.Fatalf("the model was asked %d times for one thing said", n)
}
t.Log("one turn, one answer, and the leftover ask went nowhere")
}
// What the attendant said is in the record under its own name, and it is in
// what the model reads next. A participant that cannot hear itself repeats
// itself.
func TestWhatTheAttendantSaidIsInTheRecord(t *testing.T) {
s, url := serve(t, 0)
const reply = "Both regions are green."
s.says(reply)
notes := Record(url, "whisper", "en")
say := answering(notes, url)
c := started()
f := chair(c, say)
r := &room{done: make(chan struct{}, 4)}
ctx, stop := context.WithCancel(context.Background())
defer stop()
go say.tend(ctx, f, r.say)
spoke(f, c, "ana")
if !waited(r, 10*time.Second) {
t.Fatal("ana spoke and the attendant did not answer")
}
spoke(f, c, "ben")
if !waited(r, 10*time.Second) {
t.Fatal("ben spoke and the attendant did not answer")
}
kept := notes.Text()
if !strings.Contains(kept, "attendant: "+reply) {
t.Fatalf("the record does not say what the attendant said:\n%s", kept)
}
asked := s.asks()
if len(asked) < 2 {
t.Fatalf("the model was asked %d times across two turns", len(asked))
}
if !strings.Contains(asked[1], reply) {
t.Fatalf("the second thing the model read does not contain what it already said:\n%s", asked[1])
}
t.Logf("the record the model reads on the second turn:\n%s", asked[1])
}
// What is published is the audio the voice made, and it is audio a room can
// play: real Ogg/Opus that decodes to speech at the rate the estate speaks in.
func TestTheAnswerIsAudioTheRoomCanPlay(t *testing.T) {
s, url := serve(t, 0)
s.says("Say something back.")
notes := Record(url, "whisper", "en")
say := answering(notes, url)
c := started()
f := chair(c, say)
r := &room{done: make(chan struct{}, 4)}
ctx, stop := context.WithCancel(context.Background())
defer stop()
go say.tend(ctx, f, r.say)
spoke(f, c, "ana")
if !waited(r, 10*time.Second) {
t.Fatal("ana spoke and the attendant did not answer")
}
said, _ := r.heard()
pcm := decode(t, bytes.NewReader(said[0]))
seconds := float64(len(pcm)) / rate
if seconds < 3.5 {
t.Fatalf("published %d bytes that decode to %.2fs; the voice answered with 3.9s of speech",
len(said[0]), seconds)
}
var sum float64
for _, v := range pcm {
sum += float64(v) * float64(v)
}
if rms := sum / float64(len(pcm)); rms < 200*200 {
t.Fatalf("what was published decodes to silence, not speech")
}
t.Logf("published %d bytes of Ogg/Opus: %.2fs of speech", len(said[0]), seconds)
}