Files
zeekayandHanzo Dev 03a9f42e10 Serve a spoken conversation over one socket
Audio in, audio out, on an IAM bearer, billed to whoever spoke. speech holds
the models; this holds the conversation.

The wire is the OpenAI realtime protocol so existing clients work unchanged.
hanzoai/go-openai-realtime already carries the event types but is a client, so
it can only unmarshal the server's half; Read supplies the other direction.

A browser cannot put a header on a WebSocket, so the bearer is spent on a POST
and the URL carries a single-use thirty-second ticket naming a principal
already verified here. It has no claims and cannot be verified elsewhere, so a
copy lifted from a log buys nothing.

Replies are synthesised a sentence at a time: 452 ms to first sound against
2267 ms for a whole reply, and every sentence after the first is free because
synthesis outruns speech. Measured end to end at 1.15-1.55 s from the speaker
stopping, of which 500 ms is the silence detector that Turn exists to replace.

Turn, Mind and Meter are the seams. Hush ends a turn on silence alone, which
cuts people off mid-thought and waits through gaps it should have taken; it is
there so the surface works while a detector that reads intent is built.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-13 17:37:04 -07:00

73 lines
2.1 KiB
Go

package voice
import (
"fmt"
"sync"
)
// Capacity is how many conversations the speech service sustains at once.
//
// It is four. That is measured, not chosen: the transcriber runs a pool of
// four decode workers, and past four the backlog grows without bound rather
// than the acks merely slowing down. The service itself enforces nothing — it
// has no notion of a caller and refuses nobody — so a queue there becomes
// latency here, and latency is the one thing a conversation cannot absorb.
// Refusing a fifth session is kinder than degrading five.
const Capacity = 4
// Share is the most of that capacity any one tenant may hold. Without it the
// first org to arrive takes every slot and every other tenant is refused by a
// neighbour rather than by its own usage.
const Share = 2
// Floorspace admits conversations while there is room and refuses them when
// there is not.
//
// This is a doorway, not a queue. A voice session that waits has already
// failed: the speaker is holding a live microphone. So the answer is
// immediate and it is either yes or no.
type Floorspace struct {
mu sync.Mutex
held int
byOrg map[string]int
Total int
Each int
}
func NewFloorspace(total, each int) *Floorspace {
return &Floorspace{byOrg: map[string]int{}, Total: total, Each: each}
}
// Enter takes a slot for an org. The returned func gives it back and is safe
// to call more than once, because a session can end in more ways than it began.
func (f *Floorspace) Enter(org string) (leave func(), err error) {
f.mu.Lock()
defer f.mu.Unlock()
if f.held >= f.Total {
return nil, fmt.Errorf("all %d conversation slots are busy", f.Total)
}
if f.byOrg[org] >= f.Each {
return nil, fmt.Errorf("this org already holds its %d conversations", f.Each)
}
f.held++
f.byOrg[org]++
var once sync.Once
return func() {
once.Do(func() {
f.mu.Lock()
defer f.mu.Unlock()
f.held--
if f.byOrg[org]--; f.byOrg[org] <= 0 {
delete(f.byOrg, org)
}
})
}, nil
}
// Busy is how many conversations are live, for the health endpoint.
func (f *Floorspace) Busy() int {
f.mu.Lock()
defer f.mu.Unlock()
return f.held
}