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]>
331 lines
8.7 KiB
Go
331 lines
8.7 KiB
Go
package voice
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"context"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// Pipe is the mind that transcribes and then thinks.
|
|
//
|
|
// The transcriber runs while the speaker is still talking, so by the time the
|
|
// floor is yielded the words are already there and only the model's own
|
|
// latency is left. That overlap is the reason this composition beats handing a
|
|
// whole utterance to an audio model after the fact, even though the audio
|
|
// model is one inference instead of two: the transcription was free, it
|
|
// happened during speech.
|
|
//
|
|
// What it costs is tone. "Fine." and "Fine?" reach the model as the same word
|
|
// once prosody is thrown away at the transcript boundary. Omni carries that;
|
|
// this does not.
|
|
type Pipe struct {
|
|
Speech *Speech
|
|
Chat *Chat
|
|
STT string
|
|
Prompt string
|
|
|
|
mu sync.Mutex
|
|
ear *Ear
|
|
said strings.Builder
|
|
frames [][]byte
|
|
turns []line
|
|
waiting []byte // audio set down but not yet carried to the transcriber
|
|
carrying bool // a worker is carrying it
|
|
}
|
|
|
|
type line struct{ Role, Text string }
|
|
|
|
// Hear takes audio and returns immediately.
|
|
//
|
|
// It has to. A microphone does not pause while a decoder catches up, and the
|
|
// caller of this is the loop that also watches for the end of the turn. So the
|
|
// audio is set down here and a single worker carries it to the transcriber.
|
|
//
|
|
// When the transcriber falls behind, the waiting audio coalesces: the worker
|
|
// takes everything set down since it last looked and sends it as one push.
|
|
// Nothing is dropped — dropped audio is a hole in the middle of a sentence,
|
|
// and the transcript reads as if the speaker said something they did not — and
|
|
// the request rate falls to whatever the transcriber can actually sustain,
|
|
// which is the only rate that was ever going to work.
|
|
func (p *Pipe) Hear(ctx context.Context, pcm []byte) {
|
|
p.mu.Lock()
|
|
p.waiting = append(p.waiting, pcm...)
|
|
if p.carrying {
|
|
p.mu.Unlock()
|
|
return
|
|
}
|
|
p.carrying = true
|
|
p.mu.Unlock()
|
|
go p.carry(context.WithoutCancel(ctx))
|
|
}
|
|
|
|
// carry moves waiting audio to the transcriber until there is none left.
|
|
func (p *Pipe) carry(ctx context.Context) {
|
|
for {
|
|
p.mu.Lock()
|
|
batch := p.waiting
|
|
p.waiting = nil
|
|
if len(batch) == 0 {
|
|
p.carrying = false
|
|
p.mu.Unlock()
|
|
return
|
|
}
|
|
if p.ear == nil {
|
|
ear, err := p.Speech.Listen(ctx, p.STT)
|
|
if err != nil {
|
|
p.carrying = false
|
|
p.mu.Unlock()
|
|
return
|
|
}
|
|
p.ear = ear
|
|
}
|
|
ear := p.ear
|
|
p.mu.Unlock()
|
|
|
|
said, err := ear.Push(ctx, batch)
|
|
if err != nil {
|
|
p.mu.Lock()
|
|
p.carrying = false
|
|
p.mu.Unlock()
|
|
return
|
|
}
|
|
p.mu.Lock()
|
|
p.said.Reset()
|
|
p.said.WriteString(said.All())
|
|
p.mu.Unlock()
|
|
}
|
|
}
|
|
|
|
// settled waits for the audio already handed over to come back as words.
|
|
//
|
|
// Waiting for the bytes to be carried is not enough. A transcriber given a
|
|
// quarter second of speech at a time has usually returned nothing at all by
|
|
// the moment the speaker stops — the words are still inside a decode. Asking
|
|
// for the transcript then finds it empty and answers "nothing was said" to
|
|
// someone who just spoke a whole sentence.
|
|
//
|
|
// So the wait is for text, not for bytes, and it is bounded: silence never
|
|
// produces words, and a turn that ends without any is one there is nothing to
|
|
// answer. The bound is what keeps that case fast instead of hanging.
|
|
func (p *Pipe) settled(ctx context.Context) {
|
|
const patience = 4 * time.Second
|
|
give := time.Now().Add(patience)
|
|
for time.Now().Before(give) {
|
|
p.mu.Lock()
|
|
idle := !p.carrying && len(p.waiting) == 0
|
|
words := p.said.Len() > 0
|
|
p.mu.Unlock()
|
|
if idle && words {
|
|
return
|
|
}
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-time.After(20 * time.Millisecond):
|
|
}
|
|
}
|
|
}
|
|
|
|
func (p *Pipe) See(jpeg []byte) {
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
// Only the newest frame is kept. A model call per frame is ruinous and
|
|
// pointless: what matters is what is on screen when the question is asked,
|
|
// not the thirty frames of it that went past while it was being asked.
|
|
p.frames = [][]byte{jpeg}
|
|
}
|
|
|
|
func (p *Pipe) Reply(ctx context.Context) (<-chan string, error) {
|
|
p.settled(ctx)
|
|
p.mu.Lock()
|
|
heard := strings.TrimSpace(p.said.String())
|
|
frames := p.frames
|
|
p.frames = nil
|
|
p.said.Reset()
|
|
if p.ear != nil {
|
|
_ = p.ear.Close()
|
|
p.ear = nil
|
|
}
|
|
if heard == "" {
|
|
p.mu.Unlock()
|
|
return nil, fmt.Errorf("nothing was said")
|
|
}
|
|
p.turns = append(p.turns, line{"user", heard})
|
|
turns := append([]line(nil), p.turns...)
|
|
p.mu.Unlock()
|
|
|
|
out, err := p.Chat.Stream(ctx, p.Prompt, turns, frames)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// Remember what was answered, so the next turn has the conversation.
|
|
kept := make(chan string)
|
|
go func() {
|
|
defer close(kept)
|
|
var whole strings.Builder
|
|
for piece := range out {
|
|
whole.WriteString(piece)
|
|
kept <- piece
|
|
}
|
|
p.mu.Lock()
|
|
p.turns = append(p.turns, line{"assistant", whole.String()})
|
|
p.mu.Unlock()
|
|
}()
|
|
return kept, nil
|
|
}
|
|
|
|
// Chat is the language model, reached the way every other caller reaches it.
|
|
type Chat struct {
|
|
URL string
|
|
Model string
|
|
HTTP *http.Client
|
|
// Token is the CALLER's bearer, not this service's. Audio is expensive and
|
|
// it is billed to whoever is speaking, which requires their credential on
|
|
// the request rather than a service key that pools every tenant onto one
|
|
// account.
|
|
Token string
|
|
Org string
|
|
}
|
|
|
|
func (c *Chat) Stream(ctx context.Context, prompt string, turns []line, frames [][]byte) (<-chan string, error) {
|
|
msgs := []map[string]any{}
|
|
if prompt != "" {
|
|
msgs = append(msgs, map[string]any{"role": "system", "content": prompt})
|
|
}
|
|
for i, t := range turns {
|
|
last := i == len(turns)-1
|
|
if last && t.Role == "user" && len(frames) > 0 {
|
|
parts := []map[string]any{{"type": "text", "text": t.Text}}
|
|
for _, f := range frames {
|
|
parts = append(parts, map[string]any{
|
|
"type": "image_url",
|
|
"image_url": map[string]string{
|
|
"url": "data:image/jpeg;base64," + base64.StdEncoding.EncodeToString(f),
|
|
},
|
|
})
|
|
}
|
|
msgs = append(msgs, map[string]any{"role": t.Role, "content": parts})
|
|
continue
|
|
}
|
|
msgs = append(msgs, map[string]any{"role": t.Role, "content": t.Text})
|
|
}
|
|
body, _ := json.Marshal(map[string]any{
|
|
"model": c.Model, "messages": msgs, "stream": true,
|
|
})
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.URL+"/v1/chat/completions", bytes.NewReader(body))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("Authorization", "Bearer "+c.Token)
|
|
if c.Org != "" {
|
|
req.Header.Set("X-Org-Id", c.Org)
|
|
}
|
|
resp, err := c.HTTP.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if resp.StatusCode != http.StatusOK {
|
|
msg, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
|
resp.Body.Close()
|
|
return nil, fmt.Errorf("chat %d: %s", resp.StatusCode, bytes.TrimSpace(msg))
|
|
}
|
|
|
|
out := make(chan string)
|
|
go func() {
|
|
defer close(out)
|
|
defer resp.Body.Close()
|
|
scan := bufio.NewScanner(resp.Body)
|
|
scan.Buffer(make([]byte, 0, 64<<10), 1<<20)
|
|
for scan.Scan() {
|
|
data, ok := strings.CutPrefix(scan.Text(), "data: ")
|
|
if !ok || data == "[DONE]" {
|
|
continue
|
|
}
|
|
var frame struct {
|
|
Choices []struct {
|
|
Delta struct{ Content string }
|
|
}
|
|
}
|
|
if json.Unmarshal([]byte(data), &frame) != nil || len(frame.Choices) == 0 {
|
|
continue
|
|
}
|
|
if piece := frame.Choices[0].Delta.Content; piece != "" {
|
|
select {
|
|
case out <- piece:
|
|
case <-ctx.Done():
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}()
|
|
return out, nil
|
|
}
|
|
|
|
// Hush is the turn detector of last resort: it ends a turn after a stretch of
|
|
// quiet and nothing else.
|
|
//
|
|
// It is honest about what it is. Silence is a bad proxy for the end of a
|
|
// thought — people pause mid-sentence to think and do not pause at all before
|
|
// a trailing "right?" — so this will cut people off and it will wait through
|
|
// gaps it should have taken. It is here so the surface works end to end while
|
|
// the detector that reads the waveform for intent is built against the Turn
|
|
// interface, and it is the thing that gets replaced, not extended.
|
|
type Hush struct {
|
|
// Quiet is how much silence ends a turn.
|
|
QuietMS int
|
|
// Loud is the amplitude below which a sample counts as silence.
|
|
Loud int16
|
|
|
|
quiet int
|
|
spoke bool
|
|
}
|
|
|
|
func (h *Hush) Hear(pcm []byte) Floor {
|
|
if h.QuietMS == 0 {
|
|
h.QuietMS = 700
|
|
}
|
|
if h.Loud == 0 {
|
|
h.Loud = 700
|
|
}
|
|
ms := len(pcm) * 1000 / (Heard * 2)
|
|
if loudest(pcm) > h.Loud {
|
|
h.spoke = true
|
|
h.quiet = 0
|
|
return Held
|
|
}
|
|
if !h.spoke {
|
|
return Quiet
|
|
}
|
|
if h.quiet += ms; h.quiet >= h.QuietMS {
|
|
h.spoke = false
|
|
h.quiet = 0
|
|
return Yielded
|
|
}
|
|
return Held
|
|
}
|
|
|
|
func (h *Hush) Reset() { h.spoke, h.quiet = false, 0 }
|
|
|
|
func loudest(pcm []byte) int16 {
|
|
var top int16
|
|
for i := 0; i+1 < len(pcm); i += 2 {
|
|
v := int16(uint16(pcm[i]) | uint16(pcm[i+1])<<8)
|
|
if v < 0 {
|
|
v = -v
|
|
}
|
|
if v > top {
|
|
top = v
|
|
}
|
|
}
|
|
return top
|
|
}
|