mirror of
https://github.com/hanzoai/voice.git
synced 2026-10-03 10:14:33 +00:00
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]>
73 lines
2.1 KiB
Go
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
|
|
}
|