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
+218
View File
@@ -0,0 +1,218 @@
package asteriskws
import (
"context"
"errors"
"net/http"
"sync"
"time"
"github.com/gorilla/websocket"
"ai-operator/internal/media"
)
const MaxMessageBytes = 65500
type ClientConfig struct {
URL string
ConnectionID string
Codec media.Codec
DialTimeout time.Duration
ReadLimitBytes int64
BasicAuthUser string
BasicAuthPassword string
}
type Client struct {
cfg ClientConfig
conn *websocket.Conn
events chan ControlEvent
audio chan media.AudioChunk
mu sync.RWMutex
writeMu sync.Mutex
stats media.Stats
paused bool
frameSize int
ptime time.Duration
closed chan struct{}
closeOnce sync.Once
}
func NewClient(cfg ClientConfig) *Client {
if cfg.DialTimeout == 0 {
cfg.DialTimeout = 5 * time.Second
}
if cfg.ReadLimitBytes == 0 {
cfg.ReadLimitBytes = MaxMessageBytes
}
return &Client{cfg: cfg, events: make(chan ControlEvent, 32), audio: make(chan media.AudioChunk, 32), closed: make(chan struct{})}
}
func (c *Client) Connect(ctx context.Context) error {
d := websocket.Dialer{HandshakeTimeout: c.cfg.DialTimeout, Subprotocols: []string{"media"}}
h := http.Header{}
if c.cfg.BasicAuthUser != "" {
req, _ := http.NewRequest(http.MethodGet, c.cfg.URL, nil)
req.SetBasicAuth(c.cfg.BasicAuthUser, c.cfg.BasicAuthPassword)
h = req.Header
}
conn, _, err := d.DialContext(ctx, c.cfg.URL, h)
if err != nil {
return err
}
conn.SetReadLimit(c.cfg.ReadLimitBytes)
c.conn = conn
go c.readLoop()
return nil
}
func (c *Client) Close(ctx context.Context) error {
c.closeOnce.Do(func() {
close(c.closed)
if c.conn != nil {
_ = c.conn.Close()
}
close(c.events)
close(c.audio)
})
return nil
}
func (c *Client) Events() <-chan ControlEvent { return c.events }
func (c *Client) Audio() <-chan media.AudioChunk { return c.audio }
func (c *Client) Stats() media.Stats { c.mu.RLock(); defer c.mu.RUnlock(); return c.stats }
func (c *Client) SendCommand(ctx context.Context, command string) error {
if c.conn == nil {
return errors.New("media websocket not connected")
}
return c.writeMessage(websocket.TextMessage, []byte(command))
}
func (c *Client) GetStatus(ctx context.Context) error { return c.SendCommand(ctx, "GET_STATUS") }
func (c *Client) StartMediaBuffering(ctx context.Context) error {
return c.SendCommand(ctx, "START_MEDIA_BUFFERING")
}
func (c *Client) StopMediaBuffering(ctx context.Context, id string) error {
return c.SendCommand(ctx, "STOP_MEDIA_BUFFERING "+id)
}
func (c *Client) FlushMedia(ctx context.Context) error { return c.SendCommand(ctx, "FLUSH_MEDIA") }
func (c *Client) MarkMedia(ctx context.Context, id string) error {
return c.SendCommand(ctx, "MARK_MEDIA "+id)
}
func (c *Client) Hangup(ctx context.Context) error { return c.SendCommand(ctx, "HANGUP") }
func (c *Client) SendAudio(ctx context.Context, data []byte) error {
if c.conn == nil {
return errors.New("media websocket not connected")
}
for len(data) > 0 {
c.mu.RLock()
paused := c.paused
c.mu.RUnlock()
if paused {
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(10 * time.Millisecond):
continue
}
}
n, ptime := c.nextAudioFrameSize(len(data))
if err := c.writeMessage(websocket.BinaryMessage, data[:n]); err != nil {
return err
}
c.addOutbound(n)
data = data[n:]
if len(data) > 0 && ptime > 0 {
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(ptime):
}
}
}
return nil
}
func (c *Client) writeMessage(messageType int, data []byte) error {
c.writeMu.Lock()
defer c.writeMu.Unlock()
return c.conn.WriteMessage(messageType, data)
}
func (c *Client) nextAudioFrameSize(remaining int) (int, time.Duration) {
c.mu.RLock()
frameSize := c.frameSize
ptime := c.ptime
c.mu.RUnlock()
if frameSize <= 0 || frameSize > MaxMessageBytes {
frameSize = MaxMessageBytes
ptime = 0
}
if remaining < frameSize {
return remaining, ptime
}
return frameSize, ptime
}
func (c *Client) readLoop() {
defer c.Close(context.Background())
for {
mt, data, err := c.conn.ReadMessage()
if err != nil {
return
}
switch mt {
case websocket.TextMessage:
ev := ParseControlEvent(string(data))
c.recordEvent(ev)
select {
case c.events <- ev:
default:
}
case websocket.BinaryMessage:
now := time.Now().UTC()
chunk := media.AudioChunk{Data: data, Codec: c.cfg.Codec, Timestamp: now}
c.recordAudio(len(data), now)
select {
case c.audio <- chunk:
default:
}
}
}
}
func (c *Client) recordEvent(ev ControlEvent) {
c.mu.Lock()
defer c.mu.Unlock()
c.stats.TextEvents++
switch ev.Type {
case ControlMediaXOFF:
c.paused = true
c.stats.XOFFCount++
case ControlMediaXON:
c.paused = false
c.stats.XONCount++
case ControlMediaStart:
ms := ev.MediaStart()
c.stats.MediaStart = ms
if ms != nil && ms.OptimalFrameSize > 0 {
c.frameSize = ms.OptimalFrameSize
}
if ms != nil && ms.PTimeMS > 0 {
c.ptime = time.Duration(ms.PTimeMS) * time.Millisecond
}
}
}
func (c *Client) recordAudio(n int, t time.Time) {
c.mu.Lock()
defer c.mu.Unlock()
c.stats.InboundFrames++
c.stats.InboundBytes += int64(n)
if c.stats.FirstInboundAudioAt == nil {
tt := t
c.stats.FirstInboundAudioAt = &tt
}
tt := t
c.stats.LastInboundAudioAt = &tt
}
func (c *Client) addOutbound(n int) {
c.mu.Lock()
defer c.mu.Unlock()
c.stats.OutboundFrames++
c.stats.OutboundBytes += int64(n)
}
+66
View File
@@ -0,0 +1,66 @@
package asteriskws
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"ai-operator/internal/media"
"github.com/gorilla/websocket"
)
func TestMediaURLToneSilence(t *testing.T) {
u, _, err := BuildMediaWebSocketURL("ws://127.0.0.1:8088/media/", "abc")
if err != nil || u != "ws://127.0.0.1:8088/media/abc" {
t.Fatalf("%s %v", u, err)
}
if _, _, err := BuildMediaWebSocketURL("ws://x/media", ""); err == nil {
t.Fatal("want empty id error")
}
if got := len(GenerateSilence(media.CodecSLIN16, 100*time.Millisecond, 16000)); got != 3200 {
t.Fatalf("silence len=%d", got)
}
if got := len(GenerateSineToneSLIN16(440, 100*time.Millisecond, 16000, 2)); got != 3200 {
t.Fatalf("tone len=%d", got)
}
}
func TestClientStatsAndFlowControl(t *testing.T) {
up := websocket.Upgrader{Subprotocols: []string{"media"}}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
conn, err := up.Upgrade(w, r, nil)
if err != nil {
return
}
defer conn.Close()
_ = conn.WriteMessage(websocket.TextMessage, []byte("MEDIA_START connection_id:abc format:slin16 optimal_frame_size:640 ptime:20"))
_ = conn.WriteMessage(websocket.BinaryMessage, []byte{1, 2, 3})
_ = conn.WriteMessage(websocket.TextMessage, []byte("MEDIA_XOFF"))
time.Sleep(30 * time.Millisecond)
_ = conn.WriteMessage(websocket.TextMessage, []byte("MEDIA_XON"))
for {
if _, _, err := conn.ReadMessage(); err != nil {
return
}
}
}))
defer server.Close()
u := "ws" + server.URL[len("http"):] + "/media/abc"
c := NewClient(ClientConfig{URL: u, ConnectionID: "abc", Codec: media.CodecSLIN16})
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
if err := c.Connect(ctx); err != nil {
t.Fatal(err)
}
defer c.Close(context.Background())
time.Sleep(80 * time.Millisecond)
if err := c.SendAudio(ctx, make([]byte, 1280)); err != nil {
t.Fatal(err)
}
st := c.Stats()
if st.InboundFrames != 1 || st.InboundBytes != 3 || st.XOFFCount != 1 || st.XONCount != 1 || st.OutboundFrames != 2 || st.MediaStart == nil {
t.Fatalf("stats=%+v", st)
}
}
+98
View File
@@ -0,0 +1,98 @@
package asteriskws
import (
"encoding/json"
"strconv"
"strings"
"ai-operator/internal/media"
)
type ControlEventType string
const (
ControlMediaStart ControlEventType = "MEDIA_START"
ControlDTMFEnd ControlEventType = "DTMF_END"
ControlMediaXOFF ControlEventType = "MEDIA_XOFF"
ControlMediaXON ControlEventType = "MEDIA_XON"
ControlStatus ControlEventType = "STATUS"
ControlMediaBufferingCompleted ControlEventType = "MEDIA_BUFFERING_COMPLETED"
ControlMediaMarkProcessed ControlEventType = "MEDIA_MARK_PROCESSED"
ControlQueueDrained ControlEventType = "QUEUE_DRAINED"
ControlUnknown ControlEventType = "UNKNOWN"
)
type ControlEvent struct {
Type ControlEventType
Raw string
Fields map[string]string
}
func ParseControlEvent(text string) ControlEvent {
raw := strings.TrimSpace(text)
if raw == "" {
return ControlEvent{Type: ControlUnknown, Raw: text, Fields: map[string]string{}}
}
if strings.HasPrefix(raw, "{") {
return parseJSONControl(raw)
}
parts := strings.Fields(raw)
fields := map[string]string{}
for _, part := range parts[1:] {
if k, v, ok := strings.Cut(part, ":"); ok {
fields[k] = v
} else if fields["correlation_id"] == "" {
fields["correlation_id"] = part
}
}
return ControlEvent{Type: knownType(parts[0]), Raw: raw, Fields: fields}
}
func parseJSONControl(raw string) ControlEvent {
var data map[string]any
if err := json.Unmarshal([]byte(raw), &data); err != nil {
return ControlEvent{Type: ControlUnknown, Raw: raw, Fields: map[string]string{"error": "invalid_json"}}
}
typeName, _ := data["event"].(string)
if typeName == "" {
typeName, _ = data["type"].(string)
}
fields := map[string]string{}
for k, v := range data {
switch t := v.(type) {
case string:
fields[k] = t
case float64:
fields[k] = strconv.Itoa(int(t))
case bool:
fields[k] = strconv.FormatBool(t)
}
}
return ControlEvent{Type: knownType(typeName), Raw: raw, Fields: fields}
}
func knownType(v string) ControlEventType {
switch ControlEventType(v) {
case ControlMediaStart, ControlDTMFEnd, ControlMediaXOFF, ControlMediaXON, ControlStatus, ControlMediaBufferingCompleted, ControlMediaMarkProcessed, ControlQueueDrained:
return ControlEventType(v)
}
return ControlUnknown
}
func (e ControlEvent) MediaStart() *media.MediaStartInfo {
if e.Type != ControlMediaStart {
return nil
}
opt, _ := strconv.Atoi(value(e.Fields, "optimal_frame_size", "optimalFrameSize"))
ptime, _ := strconv.Atoi(value(e.Fields, "ptime", "ptime_ms"))
return &media.MediaStartInfo{ConnectionID: e.Fields["connection_id"], Channel: e.Fields["channel"], ChannelID: e.Fields["channel_id"], Format: e.Fields["format"], OptimalFrameSize: opt, PTimeMS: ptime}
}
func value(m map[string]string, keys ...string) string {
for _, k := range keys {
if m[k] != "" {
return m[k]
}
}
return ""
}
+24
View File
@@ -0,0 +1,24 @@
package asteriskws
import "testing"
func TestParsePlainTextControlEvents(t *testing.T) {
ev := ParseControlEvent("MEDIA_START connection_id:abc channel:WebSocket/INCOMING-1 channel_id:123 format:slin16 optimal_frame_size:640 ptime:20")
ms := ev.MediaStart()
if ev.Type != ControlMediaStart || ms.ConnectionID != "abc" || ms.OptimalFrameSize != 640 || ms.PTimeMS != 20 {
t.Fatalf("bad media start %+v %+v", ev, ms)
}
cases := map[string]ControlEventType{"DTMF_END channel_id:123 digit:5": ControlDTMFEnd, "MEDIA_XOFF channel_id:123": ControlMediaXOFF, "MEDIA_XON channel_id:123": ControlMediaXON, "STATUS channel_id:123 queue_length:0": ControlStatus, "MEDIA_BUFFERING_COMPLETED test-123": ControlMediaBufferingCompleted, "MEDIA_MARK_PROCESSED mark-123": ControlMediaMarkProcessed, "QUEUE_DRAINED channel_id:123": ControlQueueDrained, "SOMETHING value:1": ControlUnknown, "": ControlUnknown}
for input, want := range cases {
if got := ParseControlEvent(input); got.Type != want {
t.Fatalf("%q got %s want %s", input, got.Type, want)
}
}
}
func TestParseJSONControlEvents(t *testing.T) {
for _, input := range []string{`{"event":"MEDIA_START","connection_id":"abc","channel_id":"123","format":"slin16","optimal_frame_size":640,"ptime":20}`, `{"type":"MEDIA_XOFF","channel_id":"123"}`, `{"event":"NEW_EVENT"}`} {
if ev := ParseControlEvent(input); ev.Type == "" {
t.Fatalf("empty type")
}
}
}
+60
View File
@@ -0,0 +1,60 @@
package asteriskws
import (
"encoding/binary"
"math"
"time"
"ai-operator/internal/audio"
"ai-operator/internal/media"
)
func GenerateSilence(codec media.Codec, duration time.Duration, sampleRate int) []byte {
if duration <= 0 || sampleRate <= 0 {
return nil
}
switch codec {
case media.CodecSLIN16:
case media.CodecULaw:
samples := int(duration.Seconds() * float64(sampleRate))
out := make([]byte, samples)
for i := range out {
out[i] = 0xff
}
return out
default:
return nil
}
samples := int(duration.Seconds() * float64(sampleRate))
return make([]byte, samples*2)
}
func GenerateSineToneSLIN16(frequencyHz float64, duration time.Duration, sampleRate int, amplitude float64) []byte {
if frequencyHz <= 0 || duration <= 0 || sampleRate <= 0 {
return nil
}
if amplitude < 0 {
amplitude = 0
}
if amplitude > 1 {
amplitude = 1
}
samples := int(duration.Seconds() * float64(sampleRate))
out := make([]byte, samples*2)
for i := 0; i < samples; i++ {
v := int16(math.Sin(2*math.Pi*frequencyHz*float64(i)/float64(sampleRate)) * amplitude * math.MaxInt16)
binary.LittleEndian.PutUint16(out[i*2:], uint16(v))
}
return out
}
func GenerateSineTone(codec media.Codec, frequencyHz float64, duration time.Duration, sampleRate int, amplitude float64) []byte {
pcm := GenerateSineToneSLIN16(frequencyHz, duration, sampleRate, amplitude)
if codec == media.CodecULaw {
return audio.EncodeULaw(pcm)
}
if codec == media.CodecSLIN16 {
return pcm
}
return nil
}
+22
View File
@@ -0,0 +1,22 @@
package asteriskws
import (
"errors"
"net/url"
"strings"
)
func BuildMediaWebSocketURL(baseURL, connectionID string) (string, string, error) {
if strings.TrimSpace(connectionID) == "" {
return "", "", errors.New("media websocket connection id is empty")
}
base := strings.TrimRight(baseURL, "/")
parsed, err := url.Parse(base + "/" + url.PathEscape(connectionID))
if err != nil {
return "", "", err
}
if parsed.Scheme != "ws" && parsed.Scheme != "wss" {
return "", "", errors.New("media websocket URL must use ws or wss")
}
return parsed.String(), parsed.String(), nil
}