sync: migrate ai-operator to Gitea (2026-08-10)
This commit is contained in:
@@ -0,0 +1,58 @@
|
||||
package stt
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func ParseElevenLabsEvent(data []byte) (Event, error) {
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal(data, &m); err != nil {
|
||||
return Event{}, err
|
||||
}
|
||||
typ := strings.ToLower(toString(m["type"]))
|
||||
text := firstString(m, "text", "transcript", "partial", "final")
|
||||
lang := firstString(m, "language", "language_code")
|
||||
ev := Event{Text: text, Language: lang, At: time.Now().UTC(), Metadata: map[string]any{}}
|
||||
switch typ {
|
||||
case "partial_transcript", "partial", "transcript.partial":
|
||||
ev.Type = EventPartialTranscript
|
||||
case "final_transcript", "final", "transcript.final":
|
||||
ev.Type = EventFinalTranscript
|
||||
case "committed_transcript", "committed", "transcript.committed":
|
||||
ev.Type = EventCommittedTranscript
|
||||
case "speech_started", "speech.start", "vad.speech_started":
|
||||
ev.Type = EventSpeechStarted
|
||||
case "speech_ended", "speech.end", "vad.speech_ended":
|
||||
ev.Type = EventSpeechEnded
|
||||
case "error":
|
||||
ev.Type = EventError
|
||||
ev.Error = firstString(m, "error", "message")
|
||||
case "closed":
|
||||
ev.Type = EventClosed
|
||||
default:
|
||||
if final, _ := m["is_final"].(bool); final && text != "" {
|
||||
ev.Type = EventCommittedTranscript
|
||||
} else if text != "" {
|
||||
ev.Type = EventPartialTranscript
|
||||
}
|
||||
}
|
||||
return ev, nil
|
||||
}
|
||||
|
||||
func firstString(m map[string]any, keys ...string) string {
|
||||
for _, k := range keys {
|
||||
if s := toString(m[k]); s != "" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func toString(v any) string {
|
||||
if s, ok := v.(string); ok {
|
||||
return s
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package stt
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseElevenLabsEvent(t *testing.T) {
|
||||
ev, err := ParseElevenLabsEvent([]byte(`{"type":"partial_transcript","text":"Сколько стоит"}`))
|
||||
if err != nil || ev.Type != EventPartialTranscript || ev.Text == "" {
|
||||
t.Fatalf("ev=%+v err=%v", ev, err)
|
||||
}
|
||||
ev, err = ParseElevenLabsEvent([]byte(`{"type":"transcript.final","text":"готово"}`))
|
||||
if err != nil || ev.Type != EventFinalTranscript {
|
||||
t.Fatalf("ev=%+v err=%v", ev, err)
|
||||
}
|
||||
ev, err = ParseElevenLabsEvent([]byte(`{"is_final":true,"transcript":"готово"}`))
|
||||
if err != nil || ev.Type != EventCommittedTranscript {
|
||||
t.Fatalf("ev=%+v err=%v", ev, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package stt
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"ai-operator/internal/media"
|
||||
)
|
||||
|
||||
type EventType string
|
||||
|
||||
const (
|
||||
EventPartialTranscript EventType = "partial_transcript"
|
||||
EventFinalTranscript EventType = "final_transcript"
|
||||
EventCommittedTranscript EventType = "committed_transcript"
|
||||
EventSpeechStarted EventType = "speech_started"
|
||||
EventSpeechEnded EventType = "speech_ended"
|
||||
EventError EventType = "error"
|
||||
EventClosed EventType = "closed"
|
||||
)
|
||||
|
||||
type StreamRequest struct {
|
||||
CallID string
|
||||
Model string
|
||||
LanguageCode string
|
||||
LanguageAuto bool
|
||||
SampleRate int
|
||||
InputFormat string
|
||||
}
|
||||
|
||||
type Event struct {
|
||||
Type EventType
|
||||
Text string
|
||||
Language string
|
||||
Error string
|
||||
At time.Time
|
||||
Metadata map[string]any
|
||||
}
|
||||
|
||||
type StreamingProvider interface {
|
||||
Start(ctx context.Context, req StreamRequest) error
|
||||
SendAudio(ctx context.Context, chunk media.AudioChunk) error
|
||||
Events() <-chan Event
|
||||
Close(ctx context.Context) error
|
||||
}
|
||||
Reference in New Issue
Block a user