Files
attendant/scribe_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

474 lines
14 KiB
Go

package main
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"strings"
"sync"
"testing"
"time"
)
// service is the estate the attendant talks to, answering the way it answers:
// hanzoai/speech's transcript API — a session per open, a limit past which a
// push is refused, and the text on close — and beside it the two routes an
// answer is made of, the model and the voice.
type service struct {
mu sync.Mutex
next int
got map[string][]int // bytes of each push, per open session
kept map[string][]int // and per session already closed
refused int // pushes answered 409
hold chan struct{} // when non-nil, pushes wait on it
limit float64 // seconds a session accepts before 409, 0 for no limit
at string // where an open says its session lives, empty for here
sent []string // the host each push was addressed to
reply string // what the model answers
asked []string // and what it was asked, last message first
}
func serve(t *testing.T, limit float64) (*service, string) {
t.Helper()
s := &service{got: map[string][]int{}, kept: map[string][]int{}, limit: limit}
srv := httptest.NewServer(http.HandlerFunc(s.handle))
t.Cleanup(srv.Close)
return s, srv.URL
}
func (s *service) handle(w http.ResponseWriter, r *http.Request) {
id := strings.TrimPrefix(r.URL.Path, "/v1/audio/transcript")
id = strings.TrimPrefix(id, "/")
switch {
// The model, and the voice. One service stands in for the whole estate
// because the attendant reaches all of it the same way.
case r.URL.Path == "/v1/chat/completions":
var heard struct {
Messages []struct{ Role, Content string } `json:"messages"`
}
if err := json.NewDecoder(r.Body).Decode(&heard); err != nil {
http.Error(w, err.Error(), 400)
return
}
s.mu.Lock()
if n := len(heard.Messages); n > 0 {
s.asked = append(s.asked, heard.Messages[n-1].Content)
}
said := s.reply
s.mu.Unlock()
json.NewEncoder(w).Encode(map[string]any{
"choices": []map[string]any{{"message": map[string]string{"content": said}}},
})
// Real kokoro output, so what the attendant publishes is the audio a room
// would actually be given rather than a stand-in that decodes to nothing.
case r.URL.Path == "/v1/audio/speech":
var want struct {
Format string `json:"response_format"`
}
if err := json.NewDecoder(r.Body).Decode(&want); err != nil {
http.Error(w, err.Error(), 400)
return
}
// The service refuses a format it cannot make rather than answering in
// another one, so asking for the wrong container is an error and not an
// MP3 in an Opus track, which a room carries as nothing at all.
if want.Format != "opus" {
http.Error(w, "unsupported response_format "+want.Format, 400)
return
}
ogg, err := os.ReadFile("spoken.opus")
if err != nil {
http.Error(w, err.Error(), 500)
return
}
w.Header().Set("Content-Type", "audio/ogg")
w.Write(ogg)
case r.Method == http.MethodPost && id == "":
var open map[string]any
if err := json.NewDecoder(r.Body).Decode(&open); err != nil {
http.Error(w, err.Error(), 400)
return
}
if open["format"] != "pcm16" || open["rate"] != float64(rate) || open["channels"] != float64(1) {
http.Error(w, fmt.Sprintf("wrong shape: %v", open), 400)
return
}
s.mu.Lock()
s.next++
id = fmt.Sprintf("atr_%d", s.next)
s.got[id] = nil
at := s.at
s.mu.Unlock()
w.WriteHeader(201)
json.NewEncoder(w).Encode(state{ID: id, At: at})
case r.Method == http.MethodPost:
body, _ := io.ReadAll(r.Body)
s.mu.Lock()
s.sent = append(s.sent, r.Host)
hold, seen := s.hold, s.got[id]
if seen == nil {
if _, live := s.got[id]; !live {
s.mu.Unlock()
http.Error(w, "no transcript "+id, 404)
return
}
}
var sent int
for _, n := range seen {
sent += n
}
s.mu.Unlock()
if hold != nil {
<-hold
}
if s.limit > 0 && float64(sent+len(body))/(rate*2) > s.limit {
s.mu.Lock()
s.refused++
s.mu.Unlock()
http.Error(w, "at its limit", 409)
return
}
s.mu.Lock()
s.got[id] = append(s.got[id], len(body))
s.mu.Unlock()
json.NewEncoder(w).Encode(state{ID: id})
case r.Method == http.MethodDelete:
s.mu.Lock()
s.sent = append(s.sent, r.Host)
pushes, live := s.got[id]
if !live {
s.mu.Unlock()
http.Error(w, "no transcript "+id, 404)
return
}
var sent int
for _, n := range pushes {
sent += n
}
s.kept[id] = pushes
delete(s.got, id)
s.mu.Unlock()
json.NewEncoder(w).Encode(state{ID: id, Text: id, Seconds: float64(sent) / (rate * 2)})
default:
http.Error(w, "no", 405)
}
}
func (s *service) pushes(id string) []int {
s.mu.Lock()
defer s.mu.Unlock()
if open, live := s.got[id]; live {
return append([]int(nil), open...)
}
return append([]int(nil), s.kept[id]...)
}
// says is what the model will answer. Set through the service's own lock, like
// everything else it holds, so the race detector has the whole picture.
func (s *service) says(text string) {
s.mu.Lock()
defer s.mu.Unlock()
s.reply = text
}
// asks is what the model was asked, one entry per ask.
func (s *service) asks() []string {
s.mu.Lock()
defer s.mu.Unlock()
return append([]string(nil), s.asked...)
}
// lives is where an open will say its session lives.
func (s *service) lives(url string) {
s.mu.Lock()
defer s.mu.Unlock()
s.at = url
}
// addressed is the host each push and close was sent to.
func (s *service) addressed() []string {
s.mu.Lock()
defer s.mu.Unlock()
return append([]string(nil), s.sent...)
}
// taken is the audio the service has actually accepted, in seconds.
func (s *service) taken() float64 {
s.mu.Lock()
defer s.mu.Unlock()
sent := 0
for _, all := range []map[string][]int{s.got, s.kept} {
for _, pushes := range all {
for _, n := range pushes {
sent += n
}
}
}
return float64(sent) / (rate * 2)
}
// The service refuses a body larger than its ceiling and one that is not whole
// int16 frames, rather than truncating either. A turn that pushes the wrong
// shape gets a 400 for every chunk and transcribes nothing.
func TestAPushIsWholeFramesInsideTheCeiling(t *testing.T) {
s, url := serve(t, 0)
notes := Record(url, "whisper", "en")
turn, err := notes.Turn("ana", time.Now())
if err != nil {
t.Fatalf("open: %v", err)
}
// Two and a half chunks, so both the full pushes and the remainder are seen.
for i := 0; i < 5; i++ {
turn.Hear(make([]int16, chunk/2/2))
}
if err := turn.Close(); err != nil {
t.Fatalf("close: %v", err)
}
sent := s.pushes("atr_1")
if len(sent) != 3 {
t.Fatalf("%d pushes for 2.5 chunks of audio: %v", len(sent), sent)
}
for i, n := range sent {
if n%2 != 0 {
t.Fatalf("push %d is %d bytes, not whole int16 frames", i, n)
}
if n > chunk {
t.Fatalf("push %d is %d bytes, over the %d ceiling", i, n, chunk)
}
}
if sent[0] != chunk || sent[1] != chunk || sent[2] != chunk/2 {
t.Fatalf("pushes %v; wanted two full chunks and the remainder", sent)
}
}
// A session takes 600 s of audio and then answers 409. A keynote is longer than
// that, so the turn rolls into a fresh session and keeps what the last one
// settled — otherwise the second half of a long turn is transcribed by nobody
// and every push after the limit is an error nobody reads.
func TestALongTurnRollsOverRatherThanBeingRefused(t *testing.T) {
s, url := serve(t, span.Seconds())
notes := Record(url, "whisper", "en")
turn, err := notes.Turn("ana", time.Now())
if err != nil {
t.Fatalf("open: %v", err)
}
// Ten seconds past the limit, a second at a time, never more than a minute
// ahead of the service — which is what a room does, since audio arrives on a
// clock and cannot arrive faster than it is spoken.
const over = 10
for fed := 0.0; fed < span.Seconds()+over; fed++ {
turn.Hear(make([]int16, rate))
for s.taken() < fed-60 {
time.Sleep(time.Millisecond)
}
}
if err := turn.Close(); err != nil {
t.Fatalf("close: %v", err)
}
said := notes.Said()
if len(said) != 1 {
t.Fatalf("%d turns; a rollover is one turn, not two", len(said))
}
if s.next < 2 {
t.Fatalf("%d sessions opened; %v of audio does not fit in one", s.next, span)
}
// Every second submitted is accounted for, across both sessions.
if s.refused > 0 {
t.Fatalf("%d pushes answered 409; the turn rolled over too late", s.refused)
}
if notes.Lost() > 0 {
t.Fatalf("%d frames dropped feeding the turn", notes.Lost())
}
if got := said[0].Seconds; got < span.Seconds()+over-1 {
t.Fatalf("turn reports %.1fs of %.1fs submitted; the rest went nowhere",
got, span.Seconds()+over)
}
// And the text of the session that was closed on the way is still there.
if !strings.Contains(said[0].Text, "atr_1") || !strings.Contains(said[0].Text, "atr_2") {
t.Fatalf("text %q lost a session", said[0].Text)
}
}
// A growing transcript is a window in ONE process's memory, and the Service
// address in front of it round-robins per connection. speech runs two replicas,
// so a session opened through the Service and pushed through it again lands
// about half the time on a pod that has never heard of it and answers 404. The
// open says where the session lives; the rest of the turn goes THERE.
//
// Two addresses onto one service, which is what two replicas behind a Service
// is: the open goes in the front door and names the pod, and every push after
// it has to be addressed to that pod.
func TestASessionIsAddressedWhereItLives(t *testing.T) {
s, door := serve(t, 0)
pod := httptest.NewServer(http.HandlerFunc(s.handle))
t.Cleanup(pod.Close)
s.lives(pod.URL)
notes := Record(door, "whisper", "en")
turn, err := notes.Turn("ana", time.Now())
if err != nil {
t.Fatalf("open: %v", err)
}
turn.Hear(make([]int16, chunk))
if err := turn.Close(); err != nil {
t.Fatalf("close: %v", err)
}
want := strings.TrimPrefix(pod.URL, "http://")
sent := s.addressed()
if len(sent) == 0 {
t.Fatal("the turn pushed nothing at all")
}
for _, host := range sent {
if host != want {
t.Fatalf("a push went to %s; the open said the session lives at %s, "+
"and behind two replicas that address is a 404 for half the turn",
host, want)
}
}
t.Logf("%d pushes, all addressed to %s", len(sent), want)
}
// A turn that runs long settles after the short one that followed it. Notes read
// in the order things were SAID, so they are ordered by when a turn began.
func TestNotesReadInTheOrderThingsWereSaid(t *testing.T) {
_, url := serve(t, 0)
notes := Record(url, "whisper", "en")
began := time.Now()
first, err := notes.Turn("ana", began)
if err != nil {
t.Fatalf("open: %v", err)
}
second, err := notes.Turn("ben", began.Add(time.Second))
if err != nil {
t.Fatalf("open: %v", err)
}
first.Hear(make([]int16, rate))
second.Hear(make([]int16, rate))
if err := second.Close(); err != nil { // ben settles first
t.Fatalf("close ben: %v", err)
}
if err := first.Close(); err != nil {
t.Fatalf("close ana: %v", err)
}
said := notes.Said()
if len(said) != 2 || said[0].Speaker != "ana" || said[1].Speaker != "ben" {
t.Fatalf("notes read %v; ana spoke first", said)
}
}
// A service too slow to keep up must slow the record, never the room. Frames
// that cannot be queued are dropped and COUNTED: audio discarded quietly is a
// transcript that reads complete and is not.
func TestAServiceThatCannotKeepUpLosesAudioOutLoud(t *testing.T) {
s, url := serve(t, 0)
s.hold = make(chan struct{})
notes := Record(url, "whisper", "en")
turn, err := notes.Turn("ana", time.Now())
if err != nil {
t.Fatalf("open: %v", err)
}
for i := 0; i < queue*3; i++ {
turn.Hear(make([]int16, chunk)) // one chunk of frames each, well past the queue
}
lost := notes.Lost()
// Let the service go before asserting anything. A push still waiting is a
// request the test server will not shut down without, so an assertion that
// failed here would hang the run instead of reporting itself.
close(s.hold)
if err := turn.Close(); err != nil {
t.Fatalf("close: %v", err)
}
if lost == 0 {
t.Fatal("a stalled service dropped no frames and reported none")
}
t.Logf("%d frames dropped while the service was held", lost)
}
// Hear must not block on the network. Every speaker's track is behind that call,
// so a turn that waits for a push stalls the room, and audio arriving on a
// real-time clock while nothing reads it is audio gone.
func TestHearDoesNotWaitForTheService(t *testing.T) {
s, url := serve(t, 0)
s.hold = make(chan struct{})
defer close(s.hold)
notes := Record(url, "whisper", "en")
turn, err := notes.Turn("ana", time.Now())
if err != nil {
t.Fatalf("open: %v", err)
}
done := make(chan struct{})
go func() {
defer close(done)
for i := 0; i < queue*3; i++ {
turn.Hear(make([]int16, chunk))
}
}()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("Hear blocked on a service that is not answering")
}
}
// A turn opens with everything its speaker said before the floor was theirs —
// `lead` seconds arriving at once, which is not a rate any room delivers at. A
// queue sized for a room drops what it cannot hold, and what it cannot hold is
// the front of the turn: measured live, cleo's notes began "owe you the
// capacity numbers".
func TestTheOpeningBurstDoesNotOverrunTheTurn(t *testing.T) {
_, url := serve(t, 0)
notes := Record(url, "whisper", "en")
c := started()
f := chair(c, notes)
talk(f, c, 200*time.Millisecond, voice{"ana", 1})
f.Speaking([]string{"ana"})
// ben talks under ana for the whole of the delay plus the wait, so his turn
// opens holding every one of those seconds.
both := []voice{{"ana", 1}, {"ben", 2}}
talk(f, c, notice, both...)
f.Speaking([]string{"ben", "ana"})
said := talk(f, c, grab, both...) / 2
// Measured across the handover alone. This replay runs the meeting far
// faster than a room does, so ordinary frames outrun the pusher here in a
// way they cannot in a room; the burst is what a room DOES do, and it is
// what this is about.
before := notes.Lost()
f.Speaking([]string{"ben", "ana"})
if got := notes.Lost() - before; got != 0 {
t.Fatalf("%d frames dropped opening a turn on %.1fs of held audio; "+
"the first thing lost is the first thing said", got, float64(said)/rate)
}
if err := f.Close(); err != nil {
t.Fatalf("close: %v", err)
}
for _, s := range notes.Said() {
if s.Speaker == "ben" && s.Seconds < float64(said)/rate {
t.Fatalf("ben's turn received %.2fs of the %.2fs he had already spoken",
s.Seconds, float64(said)/rate)
}
}
}