152 lines
3.0 KiB
Go
152 lines
3.0 KiB
Go
package stt
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"net/http"
|
|
"net/url"
|
|
"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("audio_format", nonEmpty(req.InputFormat, p.cfg.STT.InputFormat))
|
|
q.Set("commit_strategy", "manual")
|
|
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()
|
|
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
|
|
}
|