sync: migrate ai-operator to Gitea (2026-08-10)

This commit is contained in:
konturai-ops
2026-08-10 15:26:52 +00:00
commit 53652b95ad
173 changed files with 16676 additions and 0 deletions
+45
View File
@@ -0,0 +1,45 @@
package tts
import (
"regexp"
"strings"
"ai-operator/internal/config"
)
var tagPattern = regexp.MustCompile(`\[[^\]]+\]`)
func NaturalizeForVoice(text string, language string, cfg config.NaturalnessConfig) string {
text = strings.TrimSpace(text)
if text == "" || !cfg.Enabled || !cfg.AudioTagsEnabled || !cfg.AllowNonverbalTags || cfg.MaxAudioTagsPerResponse <= 0 {
return text
}
lower := strings.ToLower(text)
if containsAny(lower, []string{"тариф", "оплат", "безопас", "документ", "адрес", "телефон", "газ", "құжат", "төлем", "қауіпсіз", "мекенжай"}) {
return "[calmly] " + text
}
return "[warmly] " + insertBriefPause(text, cfg.MaxAudioTagsPerResponse)
}
func RemoveAudioTags(text string) string {
return strings.TrimSpace(tagPattern.ReplaceAllString(text, ""))
}
func insertBriefPause(text string, maxTags int) string {
if maxTags < 2 {
return text
}
if i := strings.Index(text, ". "); i > 0 {
return text[:i+1] + " [brief pause] " + text[i+2:]
}
return text
}
func containsAny(s string, needles []string) bool {
for _, n := range needles {
if strings.Contains(s, n) {
return true
}
}
return false
}
+168
View File
@@ -0,0 +1,168 @@
package tts
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"net/http"
"net/url"
"strconv"
"strings"
"sync"
"time"
"ai-operator/internal/config"
"github.com/gorilla/websocket"
)
type ElevenLabsWS struct {
cfg config.Config
conn *websocket.Conn
audio chan AudioChunk
mu sync.Mutex
closed bool
}
func NewElevenLabsWS(cfg config.Config) *ElevenLabsWS {
return &ElevenLabsWS{cfg: cfg, audio: make(chan AudioChunk, 64)}
}
func (p *ElevenLabsWS) Start(ctx context.Context, req TTSStreamRequest) error {
if p.cfg.Eleven.APIKey == "" {
return errors.New("ELEVENLABS_API_KEY is required for ElevenLabs streaming TTS")
}
if req.VoiceID == "" {
return errors.New("ElevenLabs voice id is required for streaming TTS")
}
base := strings.TrimRight(p.cfg.Eleven.TTSURL, "/") + "/" + url.PathEscape(req.VoiceID) + "/stream-input"
u, err := url.Parse(base)
if err != nil {
return err
}
q := u.Query()
q.Set("model_id", nonEmpty(req.ModelID, p.cfg.Eleven.TTSModelID))
q.Set("output_format", nonEmpty(req.OutputFormat, p.cfg.Eleven.TTSOutputFormat))
q.Set("optimize_streaming_latency", strconv.Itoa(p.cfg.Eleven.TTSOptimizeStreamingLatency))
u.RawQuery = q.Encode()
h := http.Header{}
h.Set("xi-api-key", p.cfg.Eleven.APIKey)
d := websocket.Dialer{HandshakeTimeout: p.cfg.Eleven.TTSTimeout}
conn, _, err := d.DialContext(ctx, u.String(), h)
if err != nil {
return err
}
p.mu.Lock()
p.conn = conn
p.audio = make(chan AudioChunk, 64)
p.closed = false
p.mu.Unlock()
bos := map[string]any{
"text": " ",
"voice_settings": map[string]any{
"stability": p.cfg.Eleven.TTSStability,
"similarity_boost": p.cfg.Eleven.TTSSimilarityBoost,
"style": p.cfg.Eleven.TTSStyle,
"use_speaker_boost": p.cfg.Eleven.TTSUseSpeakerBoost,
},
}
if err := conn.WriteJSON(bos); err != nil {
return err
}
go p.readLoop()
return nil
}
func (p *ElevenLabsWS) SendText(ctx context.Context, text string, flush bool) error {
p.mu.Lock()
conn := p.conn
p.mu.Unlock()
if conn == nil {
return errors.New("elevenlabs tts websocket not connected")
}
msg := map[string]any{"text": text, "try_trigger_generation": flush}
if flush {
msg["flush"] = true
}
return conn.WriteJSON(msg)
}
func (p *ElevenLabsWS) Audio() <-chan AudioChunk { return p.audio }
func (p *ElevenLabsWS) Close(ctx context.Context) error {
p.mu.Lock()
conn := p.conn
p.conn = nil
p.mu.Unlock()
if conn != nil {
_ = conn.WriteJSON(map[string]any{"text": ""})
_ = conn.WriteControl(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""), time.Now().Add(time.Second))
_ = conn.Close()
}
p.closeAudio()
return nil
}
func (p *ElevenLabsWS) readLoop() {
for {
p.mu.Lock()
conn := p.conn
p.mu.Unlock()
if conn == nil {
p.closeAudio()
return
}
_, data, err := conn.ReadMessage()
if err != nil {
p.closeAudio()
return
}
audio := parseAudio(data)
if len(audio) == 0 {
continue
}
p.emit(AudioChunk{Data: audio, Timestamp: time.Now().UTC()})
}
}
func parseAudio(data []byte) []byte {
var m map[string]any
if json.Unmarshal(data, &m) == nil {
for _, k := range []string{"audio", "audio_base64"} {
if s, ok := m[k].(string); ok && s != "" {
b, _ := base64.StdEncoding.DecodeString(s)
return b
}
}
return nil
}
return data
}
func (p *ElevenLabsWS) emit(ch AudioChunk) {
p.mu.Lock()
defer p.mu.Unlock()
if p.closed {
return
}
select {
case p.audio <- ch:
default:
}
}
func (p *ElevenLabsWS) closeAudio() {
p.mu.Lock()
defer p.mu.Unlock()
if !p.closed {
close(p.audio)
p.closed = true
}
}
func nonEmpty(v, fallback string) string {
if v != "" {
return v
}
return fallback
}
@@ -0,0 +1,15 @@
package tts
import "testing"
func TestParseAudioIgnoresControlJSON(t *testing.T) {
if got := parseAudio([]byte(`{"isFinal":true}`)); len(got) != 0 {
t.Fatalf("control json parsed as audio: %d bytes", len(got))
}
if got := parseAudio([]byte(`{"audio":"AQI="}`)); len(got) != 2 {
t.Fatalf("audio json not decoded: %d bytes", len(got))
}
if got := parseAudio([]byte{0, 1, 0, 1}); len(got) != 4 {
t.Fatalf("binary audio not passed through: %d bytes", len(got))
}
}
+27
View File
@@ -0,0 +1,27 @@
package tts
import (
"context"
"time"
)
type AudioChunk struct {
Data []byte
Timestamp time.Time
}
type TTSStreamRequest struct {
CallID string
Language string
VoiceID string
ModelID string
OutputFormat string
SampleRate int
}
type StreamingTTS interface {
Start(ctx context.Context, req TTSStreamRequest) error
SendText(ctx context.Context, text string, flush bool) error
Audio() <-chan AudioChunk
Close(ctx context.Context) error
}