mirror of
https://github.com/hanzoai/voice.git
synced 2026-10-03 10:14:33 +00:00
344 lines
10 KiB
Go
344 lines
10 KiB
Go
package voice
|
|
|
|
import (
|
|
"context"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
rt "github.com/hanzoai/go-openai-realtime"
|
|
)
|
|
|
|
// Floor is who holds the conversation. A voice agent is a protocol for taking
|
|
// turns, and every hard problem in it is a question about this value.
|
|
type Floor int
|
|
|
|
const (
|
|
Quiet Floor = iota // nobody is speaking
|
|
Held // the speaker has the floor and is still using it
|
|
Yielded // the speaker just finished; a reply is due now
|
|
)
|
|
|
|
// Turn decides when a speaker has finished.
|
|
//
|
|
// This is the seam, and it is deliberately narrow: audio in, floor out. A
|
|
// silence timer satisfies it, and so does a model that hears the difference
|
|
// between a pause for breath and the end of a thought. Nothing else in this
|
|
// package knows which one it is talking to.
|
|
type Turn interface {
|
|
// Hear consumes mono PCM16 at Heard Hz and reports who holds the floor.
|
|
Hear(pcm []byte) Floor
|
|
// Reset forgets the current utterance — called after a reply begins.
|
|
Reset()
|
|
}
|
|
|
|
// Mind hears an utterance and answers in text.
|
|
//
|
|
// Two implementations exist and the surface cannot tell them apart. One
|
|
// transcribes and then thinks; the other hears the audio itself, keeping the
|
|
// tone the transcript throws away. Both answer in text, because no model in
|
|
// the catalogue emits audio — see Voice.Speak for where sound comes from.
|
|
type Mind interface {
|
|
// Hear is fed audio as it arrives, so a mind that transcribes can work
|
|
// while the speaker is still talking instead of after.
|
|
Hear(ctx context.Context, pcm []byte)
|
|
// See offers the most recent video frame, JPEG encoded.
|
|
See(jpeg []byte)
|
|
// Reply closes the utterance and streams the answer as it is composed.
|
|
Reply(ctx context.Context) (<-chan string, error)
|
|
}
|
|
|
|
// Meter records what a session owes. Seconds are the billable quantity in both
|
|
// directions and they must land on a person, so the principal is carried with
|
|
// them rather than looked up later.
|
|
type Meter interface {
|
|
Bill(who Who, heard, spoke time.Duration)
|
|
}
|
|
|
|
// Session is one conversation on one socket.
|
|
type Session struct {
|
|
ID string
|
|
Who Who
|
|
Sock Sock
|
|
Speech *Speech
|
|
Mind Mind
|
|
Turn Turn
|
|
Meter Meter
|
|
Log *slog.Logger
|
|
|
|
Model string // synthesiser
|
|
Voice string
|
|
|
|
// stop cancels the reply in flight. Barge-in is exactly this being called
|
|
// while audio is still queued.
|
|
mu sync.Mutex
|
|
stop context.CancelFunc
|
|
turn int64 // which reply stop belongs to
|
|
item atomic.Int64 // conversation item counter, for truncate
|
|
spoken atomic.Int64 // ms of audio sent for the item in flight
|
|
heard atomic.Int64 // ms of audio taken in
|
|
said atomic.Int64 // ms of audio sent out
|
|
}
|
|
|
|
// Sock is the socket, narrowed to what a session does with it. Narrowing it
|
|
// here is what lets the session be tested without a network.
|
|
type Sock interface {
|
|
Read(ctx context.Context) ([]byte, error)
|
|
Write(ctx context.Context, msg []byte) error
|
|
Close() error
|
|
}
|
|
|
|
// Run drives one conversation until the socket closes.
|
|
func (s *Session) Run(ctx context.Context) error {
|
|
if err := s.send(ctx, rt.ServerEventBase{Type: rt.ServerEventTypeSessionCreated, EventID: s.ID}); err != nil {
|
|
return err
|
|
}
|
|
defer s.settle()
|
|
for {
|
|
msg, err := s.Sock.Read(ctx)
|
|
if err != nil {
|
|
return nil // the client hung up; that is not a failure
|
|
}
|
|
event, err := Read(msg)
|
|
if err != nil {
|
|
s.fail(ctx, err.Error())
|
|
continue
|
|
}
|
|
if err := s.take(ctx, event); err != nil {
|
|
s.fail(ctx, err.Error())
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *Session) take(ctx context.Context, event rt.ClientEvent) error {
|
|
switch e := event.(type) {
|
|
case *rt.InputAudioBufferAppendEvent:
|
|
pcm, err := base64.StdEncoding.DecodeString(e.Audio)
|
|
if err != nil {
|
|
return fmt.Errorf("audio is not base64: %w", err)
|
|
}
|
|
return s.hear(ctx, pcm)
|
|
|
|
case *FrameEvent:
|
|
jpeg, err := base64.StdEncoding.DecodeString(e.Image)
|
|
if err != nil {
|
|
return fmt.Errorf("frame is not base64: %w", err)
|
|
}
|
|
s.Mind.See(jpeg)
|
|
return nil
|
|
|
|
case *rt.InputAudioBufferCommitEvent:
|
|
// An explicit end of turn: the client decided, not the detector.
|
|
go s.reply(context.WithoutCancel(ctx))
|
|
return nil
|
|
|
|
case *rt.ResponseCreateEvent:
|
|
go s.reply(context.WithoutCancel(ctx))
|
|
return nil
|
|
|
|
case *rt.ResponseCancelEvent:
|
|
s.hush()
|
|
return nil
|
|
|
|
case *rt.ConversationItemTruncateEvent:
|
|
// The client says how much of the reply was actually heard. That is
|
|
// the only honest place to cut the record: audio sent is not audio
|
|
// played, and a mind that believes it said what was cut off will
|
|
// answer the next question wrongly.
|
|
s.hush()
|
|
return nil
|
|
|
|
case *rt.SessionUpdateEvent:
|
|
return nil
|
|
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// hear feeds audio to the transcriber and the turn detector, and reacts to
|
|
// whichever of the two things the floor can do.
|
|
func (s *Session) hear(ctx context.Context, pcm []byte) error {
|
|
s.heard.Add(int64(len(pcm)) * 1000 / (Heard * 2))
|
|
|
|
// The floor is decided before the audio is handed on, and never behind it.
|
|
// Deciding after would put a network call between a speaker stopping and
|
|
// anything noticing: when the transcriber falls behind — which at four
|
|
// conversations is when, not if — every chunk waits for the one before it,
|
|
// the backlog grows without bound, and the turn is detected late by
|
|
// however far behind the transcriber has fallen. Turn detection is
|
|
// arithmetic on samples already in hand, so it costs nothing to do first.
|
|
floor := s.Turn.Hear(pcm)
|
|
s.Mind.Hear(ctx, pcm)
|
|
|
|
switch floor {
|
|
case Held:
|
|
// Speech while the agent is talking is an interruption. Cutting on
|
|
// Held rather than on a separate signal keeps one detector answering
|
|
// one question.
|
|
if s.speaking() {
|
|
s.hush()
|
|
_ = s.send(ctx, rt.ServerEventBase{Type: rt.ServerEventTypeInputAudioBufferSpeechStarted})
|
|
}
|
|
case Yielded:
|
|
_ = s.send(ctx, rt.ServerEventBase{Type: rt.ServerEventTypeInputAudioBufferSpeechStopped})
|
|
s.Turn.Reset()
|
|
go s.reply(context.WithoutCancel(ctx))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Session) speaking() bool {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return s.stop != nil
|
|
}
|
|
|
|
// hush stops the reply in flight. Silence has to be immediate — a barge-in
|
|
// that keeps playing for another sentence is worse than no barge-in, because
|
|
// the speaker raises their voice over it.
|
|
func (s *Session) hush() {
|
|
s.mu.Lock()
|
|
stop := s.stop
|
|
s.stop = nil
|
|
s.mu.Unlock()
|
|
if stop != nil {
|
|
stop()
|
|
}
|
|
}
|
|
|
|
// reply composes an answer and speaks it, a sentence at a time.
|
|
//
|
|
// The WORK runs on ctx, which a barge-in cancels; the SOCKET is written on parent,
|
|
// which nothing cancels. coder/websocket closes the whole connection when a write's
|
|
// context is done, so a reply that wrote on its own cancelled context after being
|
|
// spoken over ended the conversation instead of the turn.
|
|
func (s *Session) reply(parent context.Context) {
|
|
ctx, stop := context.WithCancel(parent)
|
|
s.mu.Lock()
|
|
if s.stop != nil { // already answering; the newer utterance wins
|
|
s.mu.Unlock()
|
|
stop()
|
|
return
|
|
}
|
|
s.stop = stop
|
|
s.turn++
|
|
turn := s.turn
|
|
s.mu.Unlock()
|
|
defer s.finish(turn, stop)
|
|
|
|
item := fmt.Sprintf("%s-%d", s.ID, s.item.Add(1))
|
|
s.spoken.Store(0)
|
|
|
|
words, err := s.Mind.Reply(ctx)
|
|
if err != nil {
|
|
if ctx.Err() == nil { // a turn that was spoken over is not an error
|
|
s.fail(parent, err.Error())
|
|
}
|
|
return
|
|
}
|
|
_ = s.send(parent, rt.ServerEventBase{Type: rt.ServerEventTypeResponseCreated, EventID: item})
|
|
|
|
for line := range Sentences(words, 240) {
|
|
if ctx.Err() != nil {
|
|
return // interrupted: stop synthesising what will not be heard
|
|
}
|
|
_ = s.send(parent, transcript{
|
|
ServerEventBase: rt.ServerEventBase{Type: rt.ServerEventTypeResponseAudioTranscriptDelta, EventID: item},
|
|
ItemID: item,
|
|
Delta: line + " ",
|
|
})
|
|
pcm, err := s.Speech.Say(ctx, s.Model, s.Voice, line)
|
|
if err != nil {
|
|
if ctx.Err() == nil {
|
|
s.fail(parent, "speech: "+err.Error())
|
|
}
|
|
return
|
|
}
|
|
if ctx.Err() != nil {
|
|
return
|
|
}
|
|
ms := int64(len(pcm)) * 1000 / (Spoken * 2)
|
|
s.spoken.Add(ms)
|
|
s.said.Add(ms)
|
|
if err := s.send(parent, audio{
|
|
ServerEventBase: rt.ServerEventBase{Type: rt.ServerEventTypeResponseAudioDelta, EventID: item},
|
|
ItemID: item,
|
|
Delta: base64.StdEncoding.EncodeToString(pcm),
|
|
}); err != nil {
|
|
return
|
|
}
|
|
}
|
|
_ = s.send(parent, rt.ServerEventBase{Type: rt.ServerEventTypeResponseDone, EventID: item})
|
|
}
|
|
|
|
// finish ends a reply. It clears the floor only while the floor is still this
|
|
// reply's: one spoken over has already given it up, and the reply that took it
|
|
// next must not be cancelled by the old one leaving.
|
|
func (s *Session) finish(turn int64, stop context.CancelFunc) {
|
|
s.mu.Lock()
|
|
if s.turn == turn {
|
|
s.stop = nil
|
|
}
|
|
s.mu.Unlock()
|
|
stop()
|
|
}
|
|
|
|
// audio and transcript carry the two deltas the protocol defines but the
|
|
// client library, being a client, only ever needs to read.
|
|
type audio struct {
|
|
rt.ServerEventBase
|
|
ItemID string `json:"item_id"`
|
|
Delta string `json:"delta"`
|
|
}
|
|
|
|
type transcript struct {
|
|
rt.ServerEventBase
|
|
ItemID string `json:"item_id"`
|
|
Delta string `json:"delta"`
|
|
}
|
|
|
|
func (s *Session) send(ctx context.Context, event any) error {
|
|
msg, err := json.Marshal(event)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return s.Sock.Write(ctx, msg)
|
|
}
|
|
|
|
func (s *Session) fail(ctx context.Context, why string) {
|
|
// Errors name what the client did wrong and nothing about what is behind
|
|
// the socket. A stack trace on the wire is a map of the inside.
|
|
s.Log.Warn("session error", "id", s.ID, "org", s.Who.Org, "why", why)
|
|
_ = s.send(ctx, map[string]any{
|
|
"type": rt.ServerEventTypeError,
|
|
"error": map[string]string{"type": "invalid_request_error", "message": clean(why)},
|
|
})
|
|
}
|
|
|
|
func clean(why string) string {
|
|
if i := strings.IndexByte(why, '\n'); i >= 0 {
|
|
why = why[:i]
|
|
}
|
|
if len(why) > 200 {
|
|
why = why[:200]
|
|
}
|
|
return why
|
|
}
|
|
|
|
// settle bills the conversation once, at the end. Seconds are counted as they
|
|
// happen but charged together, so a dropped socket cannot lose them.
|
|
func (s *Session) settle() {
|
|
if s.Meter == nil {
|
|
return
|
|
}
|
|
s.Meter.Bill(s.Who,
|
|
time.Duration(s.heard.Load())*time.Millisecond,
|
|
time.Duration(s.said.Load())*time.Millisecond)
|
|
}
|