package stt import ( "context" "errors" "net/http" "net/url" "strconv" "sync" "time" "ai-operator/internal/config" "ai-operator/internal/media" "github.com/gorilla/websocket" ) type ElevenLabsRealtime struct { cfg config.Config conn *websocket.Conn events chan Event mu sync.Mutex closed bool } func NewElevenLabsRealtime(cfg config.Config) *ElevenLabsRealtime { return &ElevenLabsRealtime{cfg: cfg, events: make(chan Event, 64)} } func (p *ElevenLabsRealtime) Start(ctx context.Context, req StreamRequest) error { if p.cfg.Eleven.APIKey == "" { return errors.New("ELEVENLABS_API_KEY is required for ElevenLabs realtime STT") } u, err := url.Parse(p.cfg.Eleven.STTURL) if err != nil { return err } q := u.Query() q.Set("model_id", nonEmpty(req.Model, p.cfg.STT.Model)) q.Set("sample_rate", strconv.Itoa(nonZero(req.SampleRate, p.cfg.STT.SampleRate))) q.Set("input_format", nonEmpty(req.InputFormat, p.cfg.STT.InputFormat)) if req.LanguageCode != "" { q.Set("language_code", req.LanguageCode) } u.RawQuery = q.Encode() h := http.Header{} h.Set("xi-api-key", p.cfg.Eleven.APIKey) d := websocket.Dialer{HandshakeTimeout: p.cfg.STT.Timeout} conn, _, err := d.DialContext(ctx, u.String(), h) if err != nil { return err } p.mu.Lock() p.conn = conn p.events = make(chan Event, 64) p.closed = false p.mu.Unlock() init := map[string]any{ "type": "start", "model_id": nonEmpty(req.Model, p.cfg.STT.Model), "sample_rate": nonZero(req.SampleRate, p.cfg.STT.SampleRate), "input_format": nonEmpty(req.InputFormat, p.cfg.STT.InputFormat), "partial_transcripts": p.cfg.STT.PartialEnabled, "committed_only_for_llm": p.cfg.STT.CommittedOnlyForLLM, "language_detection_enabled": req.LanguageAuto, } if req.LanguageCode != "" { init["language_code"] = req.LanguageCode } if err := conn.WriteJSON(init); err != nil { _ = conn.Close() return err } go p.readLoop() return nil } func (p *ElevenLabsRealtime) SendAudio(ctx context.Context, chunk media.AudioChunk) error { p.mu.Lock() conn := p.conn p.mu.Unlock() if conn == nil { return errors.New("elevenlabs stt websocket not connected") } return conn.WriteMessage(websocket.BinaryMessage, chunk.Data) } func (p *ElevenLabsRealtime) Events() <-chan Event { return p.events } func (p *ElevenLabsRealtime) Close(ctx context.Context) error { p.mu.Lock() conn := p.conn p.conn = nil p.mu.Unlock() if conn != nil { _ = conn.WriteControl(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""), time.Now().Add(time.Second)) _ = conn.Close() } p.closeEvents() return nil } func (p *ElevenLabsRealtime) readLoop() { for { p.mu.Lock() conn := p.conn p.mu.Unlock() if conn == nil { p.closeEvents() return } _, data, err := conn.ReadMessage() if err != nil { p.emit(Event{Type: EventClosed, Error: sanitizeCloseError(err), At: time.Now().UTC()}) p.closeEvents() return } ev, err := ParseElevenLabsEvent(data) if err != nil { p.emit(Event{Type: EventError, Error: err.Error(), At: time.Now().UTC()}) continue } if ev.Type != "" { p.emit(ev) } } } func sanitizeCloseError(err error) string { if err == nil { return "" } return err.Error() } func (p *ElevenLabsRealtime) emit(ev Event) { p.mu.Lock() defer p.mu.Unlock() if p.closed { return } select { case p.events <- ev: default: } } func (p *ElevenLabsRealtime) closeEvents() { p.mu.Lock() defer p.mu.Unlock() if !p.closed { close(p.events) p.closed = true } } func nonEmpty(v, fallback string) string { if v != "" { return v } return fallback } func nonZero(v, fallback int) int { if v != 0 { return v } return fallback }