sync: migrate ai-operator to Gitea (2026-08-10)
This commit is contained in:
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package media
|
||||
|
||||
import "context"
|
||||
|
||||
type Gateway interface {
|
||||
Start(ctx context.Context) error
|
||||
Stop(ctx context.Context) error
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"ai-operator/internal/asterisk/ari"
|
||||
"ai-operator/internal/call"
|
||||
"ai-operator/internal/config"
|
||||
"ai-operator/internal/media"
|
||||
"ai-operator/internal/media/asteriskws"
|
||||
)
|
||||
|
||||
type TestMode string
|
||||
|
||||
const (
|
||||
TestModeStats TestMode = "stats"
|
||||
TestModeSilence TestMode = "silence"
|
||||
TestModeTone TestMode = "tone"
|
||||
TestModeEcho TestMode = "echo"
|
||||
)
|
||||
|
||||
type SelfTestConfig struct {
|
||||
Duration time.Duration
|
||||
Mode TestMode
|
||||
}
|
||||
type SelfTestResult struct {
|
||||
OK bool
|
||||
ExternalMediaChannelCreated bool
|
||||
ConnectionIDRetrieved bool
|
||||
MediaWebSocketConnected bool
|
||||
MediaStartReceived bool
|
||||
Format string
|
||||
OptimalFrameSize int
|
||||
PTimeMS int
|
||||
GetStatusSent bool
|
||||
TestPayloadSent bool
|
||||
CleanupOK bool
|
||||
Error string
|
||||
Stats media.Stats
|
||||
}
|
||||
|
||||
type Gateway struct {
|
||||
cfg config.Config
|
||||
actions ari.MediaActionClient
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func New(cfg config.Config, actions ari.MediaActionClient, logger *slog.Logger) *Gateway {
|
||||
return &Gateway{cfg: cfg, actions: actions, logger: logger}
|
||||
}
|
||||
|
||||
func (g *Gateway) SelfTest(ctx context.Context, cfg SelfTestConfig) (res SelfTestResult) {
|
||||
if cfg.Duration == 0 {
|
||||
cfg.Duration = 3 * time.Second
|
||||
}
|
||||
if cfg.Mode == "" {
|
||||
cfg.Mode = TestModeSilence
|
||||
}
|
||||
id := shortID("aiop-selftest")
|
||||
bridgeID := "aiop-bridge-" + id
|
||||
_ = g.actions.CreateBridge(ctx, bridgeID, "mixing,dtmf_events")
|
||||
defer g.actions.DeleteBridge(context.Background(), bridgeID)
|
||||
codec := g.mediaCodec()
|
||||
ch, err := g.actions.CreateExternalMediaChannel(ctx, ari.ExternalMediaRequest{ChannelID: id, App: g.cfg.Asterisk.ARIApp, ExternalHost: "INCOMING", Encapsulation: "none", Transport: "websocket", ConnectionType: "server", Format: string(codec), Direction: "both", Data: "selftest"})
|
||||
if err != nil {
|
||||
res.Error = err.Error()
|
||||
return res
|
||||
}
|
||||
res.ExternalMediaChannelCreated = true
|
||||
channelID := ch.ID
|
||||
if channelID == "" {
|
||||
channelID = id
|
||||
}
|
||||
defer func() {
|
||||
cleanupErr := g.actions.HangupChannel(context.Background(), channelID)
|
||||
res.CleanupOK = cleanupErr == nil
|
||||
if cleanupErr != nil && res.Error == "" {
|
||||
res.Error = cleanupErr.Error()
|
||||
}
|
||||
}()
|
||||
connID, err := g.waitConnectionID(ctx, channelID)
|
||||
if err != nil {
|
||||
res.Error = err.Error()
|
||||
return res
|
||||
}
|
||||
res.ConnectionIDRetrieved = true
|
||||
_ = g.actions.AddChannelToBridge(ctx, bridgeID, channelID)
|
||||
url, _, err := asteriskws.BuildMediaWebSocketURL(g.cfg.Asterisk.MediaWSBaseURL, connID)
|
||||
if err != nil {
|
||||
res.Error = err.Error()
|
||||
return res
|
||||
}
|
||||
client := asteriskws.NewClient(asteriskws.ClientConfig{URL: url, ConnectionID: connID, Codec: codec, BasicAuthUser: g.cfg.Asterisk.ARIUser, BasicAuthPassword: g.cfg.Asterisk.ARIPassword})
|
||||
if err := client.Connect(ctx); err != nil {
|
||||
res.Error = err.Error()
|
||||
return res
|
||||
}
|
||||
defer client.Close(context.Background())
|
||||
res.MediaWebSocketConnected = true
|
||||
if err := client.GetStatus(ctx); err == nil {
|
||||
res.GetStatusSent = true
|
||||
}
|
||||
deadline := time.After(5 * time.Second)
|
||||
for !res.MediaStartReceived {
|
||||
select {
|
||||
case ev := <-client.Events():
|
||||
if ev.Type == asteriskws.ControlMediaStart {
|
||||
ms := ev.MediaStart()
|
||||
if ms != nil {
|
||||
res.MediaStartReceived = true
|
||||
res.Format = ms.Format
|
||||
res.OptimalFrameSize = ms.OptimalFrameSize
|
||||
res.PTimeMS = ms.PTimeMS
|
||||
}
|
||||
}
|
||||
case <-deadline:
|
||||
res.Error = "timeout waiting for MEDIA_START"
|
||||
return res
|
||||
case <-ctx.Done():
|
||||
res.Error = ctx.Err().Error()
|
||||
return res
|
||||
}
|
||||
}
|
||||
_ = client.StartMediaBuffering(ctx)
|
||||
payload := asteriskws.GenerateSilence(codec, 100*time.Millisecond, g.mediaSampleRate())
|
||||
if cfg.Mode == TestModeTone {
|
||||
payload = asteriskws.GenerateSineTone(codec, 440, 100*time.Millisecond, g.mediaSampleRate(), 0.05)
|
||||
}
|
||||
if len(payload) > 0 {
|
||||
if err := client.SendAudio(ctx, payload); err != nil {
|
||||
res.Error = "test payload not sent: " + err.Error()
|
||||
} else {
|
||||
res.TestPayloadSent = true
|
||||
}
|
||||
}
|
||||
_ = client.StopMediaBuffering(ctx, "selftest")
|
||||
select {
|
||||
case <-time.After(cfg.Duration):
|
||||
case <-ctx.Done():
|
||||
}
|
||||
res.Stats = client.Stats()
|
||||
res.OK = res.ExternalMediaChannelCreated && res.ConnectionIDRetrieved && res.MediaWebSocketConnected && res.MediaStartReceived
|
||||
return res
|
||||
}
|
||||
|
||||
func (g *Gateway) waitConnectionID(ctx context.Context, channelID string) (string, error) {
|
||||
var last error
|
||||
for i := 0; i < 25; i++ {
|
||||
v, err := g.actions.GetChannelVariable(ctx, channelID, "MEDIA_WEBSOCKET_CONNECTION_ID")
|
||||
if err == nil && v != "" {
|
||||
return v, nil
|
||||
}
|
||||
last = err
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return "", ctx.Err()
|
||||
case <-time.After(200 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
if last != nil {
|
||||
return "", fmt.Errorf("MEDIA_WEBSOCKET_CONNECTION_ID not available: %w", last)
|
||||
}
|
||||
return "", fmt.Errorf("MEDIA_WEBSOCKET_CONNECTION_ID not available")
|
||||
}
|
||||
func shortID(prefix string) string { return fmt.Sprintf("%s-%d", prefix, time.Now().UnixNano()) }
|
||||
|
||||
func ValidTestMode(v string) bool {
|
||||
switch TestMode(strings.ToLower(v)) {
|
||||
case TestModeStats, TestModeSilence, TestModeTone, TestModeEcho:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (g *Gateway) mediaCodec() media.Codec {
|
||||
switch media.Codec(g.cfg.Asterisk.MediaCodec) {
|
||||
case media.CodecULaw:
|
||||
return media.CodecULaw
|
||||
case media.CodecALaw:
|
||||
return media.CodecALaw
|
||||
default:
|
||||
return media.CodecSLIN16
|
||||
}
|
||||
}
|
||||
|
||||
func (g *Gateway) mediaSampleRate() int {
|
||||
switch g.mediaCodec() {
|
||||
case media.CodecULaw, media.CodecALaw:
|
||||
return 8000
|
||||
default:
|
||||
return 16000
|
||||
}
|
||||
}
|
||||
|
||||
func (g *Gateway) StartCallMedia(ctx context.Context, session *call.CallSession) (call.MediaClient, error) {
|
||||
bridgeID := "aiop-bridge-" + safeID(session.CallID)
|
||||
mediaID := "aiop-media-" + safeID(session.CallID)
|
||||
session.BridgeID = bridgeID
|
||||
session.MediaChannelID = mediaID
|
||||
if err := g.actions.CreateBridge(ctx, bridgeID, "mixing,proxy_media,dtmf_events"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := g.actions.AddChannelToBridge(ctx, bridgeID, session.AsteriskChannelID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
codec := g.mediaCodec()
|
||||
ch, err := g.actions.CreateExternalMediaChannel(ctx, ari.ExternalMediaRequest{ChannelID: mediaID, App: g.cfg.Asterisk.ARIApp, ExternalHost: "INCOMING", Encapsulation: "none", Transport: "websocket", ConnectionType: "server", Format: string(codec), Direction: "both", Data: session.CallID})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if ch.ID != "" {
|
||||
session.MediaChannelID = ch.ID
|
||||
}
|
||||
connID, err := g.waitConnectionID(ctx, session.MediaChannelID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
session.MediaConnectionID = connID
|
||||
url, _, err := asteriskws.BuildMediaWebSocketURL(g.cfg.Asterisk.MediaWSBaseURL, connID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
client := asteriskws.NewClient(asteriskws.ClientConfig{URL: url, ConnectionID: connID, Codec: codec, BasicAuthUser: g.cfg.Asterisk.ARIUser, BasicAuthPassword: g.cfg.Asterisk.ARIPassword})
|
||||
if err := client.Connect(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
session.MediaConnected = true
|
||||
if _, err := g.waitMediaStart(ctx, client, 5*time.Second); err != nil {
|
||||
_ = client.Close(context.Background())
|
||||
return nil, err
|
||||
}
|
||||
if err := g.addMediaChannelToBridge(ctx, bridgeID, session.MediaChannelID); err != nil {
|
||||
_ = client.Close(context.Background())
|
||||
return nil, err
|
||||
}
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
_ = client.Close(context.Background())
|
||||
_ = g.actions.HangupChannel(context.Background(), session.MediaChannelID)
|
||||
_ = g.actions.RemoveChannelFromBridge(context.Background(), bridgeID, session.AsteriskChannelID)
|
||||
_ = g.actions.DeleteBridge(context.Background(), bridgeID)
|
||||
}()
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func (g *Gateway) waitMediaStart(ctx context.Context, client *asteriskws.Client, timeout time.Duration) (*media.MediaStartInfo, error) {
|
||||
if timeout <= 0 {
|
||||
timeout = 5 * time.Second
|
||||
}
|
||||
deadline := time.NewTimer(timeout)
|
||||
defer deadline.Stop()
|
||||
for {
|
||||
select {
|
||||
case ev, ok := <-client.Events():
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("media websocket closed before MEDIA_START")
|
||||
}
|
||||
if ev.Type == asteriskws.ControlMediaStart {
|
||||
ms := ev.MediaStart()
|
||||
if ms != nil {
|
||||
return ms, nil
|
||||
}
|
||||
}
|
||||
case <-deadline.C:
|
||||
return nil, fmt.Errorf("timeout waiting for MEDIA_START")
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (g *Gateway) addMediaChannelToBridge(ctx context.Context, bridgeID, channelID string) error {
|
||||
var lastErr error
|
||||
deadline := time.NewTimer(2 * time.Second)
|
||||
defer deadline.Stop()
|
||||
ticker := time.NewTicker(100 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
err := g.actions.AddChannelToBridge(ctx, bridgeID, channelID)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
lastErr = err
|
||||
if !strings.Contains(err.Error(), "HTTP 422") {
|
||||
return err
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-deadline.C:
|
||||
return lastErr
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func safeID(v string) string {
|
||||
r := strings.NewReplacer("/", "-", ":", "-", ".", "-")
|
||||
out := r.Replace(v)
|
||||
if len(out) > 32 {
|
||||
return out[:32]
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"ai-operator/internal/asterisk/ari"
|
||||
"ai-operator/internal/call"
|
||||
"ai-operator/internal/config"
|
||||
)
|
||||
|
||||
type fakeARI struct{ bridgeCreated, added, removed, bridgeDeleted, mediaCreated, hungup int }
|
||||
|
||||
func (f *fakeARI) AnswerChannel(ctx context.Context, channelID string) error { return nil }
|
||||
func (f *fakeARI) HangupChannel(ctx context.Context, channelID string) error { f.hungup++; return nil }
|
||||
func (f *fakeARI) GetChannel(ctx context.Context, channelID string) (*ari.ARIChannel, error) {
|
||||
return &ari.ARIChannel{ID: channelID}, nil
|
||||
}
|
||||
func (f *fakeARI) CreateBridge(ctx context.Context, bridgeID string, bridgeType string) error {
|
||||
f.bridgeCreated++
|
||||
return nil
|
||||
}
|
||||
func (f *fakeARI) AddChannelToBridge(ctx context.Context, bridgeID string, channelID string) error {
|
||||
f.added++
|
||||
return nil
|
||||
}
|
||||
func (f *fakeARI) RemoveChannelFromBridge(ctx context.Context, bridgeID string, channelID string) error {
|
||||
f.removed++
|
||||
return nil
|
||||
}
|
||||
func (f *fakeARI) DeleteBridge(ctx context.Context, bridgeID string) error {
|
||||
f.bridgeDeleted++
|
||||
return nil
|
||||
}
|
||||
func (f *fakeARI) CreateExternalMediaChannel(ctx context.Context, req ari.ExternalMediaRequest) (*ari.ARIChannel, error) {
|
||||
f.mediaCreated++
|
||||
return &ari.ARIChannel{ID: req.ChannelID}, nil
|
||||
}
|
||||
func (f *fakeARI) GetChannelVariable(ctx context.Context, channelID string, variable string) (string, error) {
|
||||
return "conn-1", nil
|
||||
}
|
||||
|
||||
func TestGatewayStartCallMediaAttemptsSetup(t *testing.T) {
|
||||
// Uses an unreachable media URL, so setup should fail after bridge/media ARI setup without panic.
|
||||
fake := &fakeARI{}
|
||||
gw := New(config.Config{Asterisk: config.AsteriskConfig{ARIApp: "ai-operator", MediaWSBaseURL: "ws://127.0.0.1:1/media"}}, fake, nil)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
|
||||
defer cancel()
|
||||
s := &call.CallSession{CallID: "call/1", AsteriskChannelID: "caller-1"}
|
||||
_, _ = gw.StartCallMedia(ctx, s)
|
||||
if fake.bridgeCreated != 1 || fake.added != 1 || fake.mediaCreated != 1 {
|
||||
t.Fatalf("fake=%+v", fake)
|
||||
}
|
||||
if s.BridgeID == "" || s.MediaChannelID == "" || s.MediaConnectionID != "conn-1" {
|
||||
t.Fatalf("session=%+v", s)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package media
|
||||
|
||||
import "time"
|
||||
|
||||
type Mode string
|
||||
|
||||
const (
|
||||
ModeChanWebsocket Mode = "chan_websocket"
|
||||
ModeExternalMediaRTP Mode = "external_media_rtp"
|
||||
)
|
||||
|
||||
type Codec string
|
||||
|
||||
const (
|
||||
CodecSLIN16 Codec = "slin16"
|
||||
CodecALaw Codec = "alaw"
|
||||
CodecULaw Codec = "ulaw"
|
||||
)
|
||||
|
||||
type AudioChunk struct {
|
||||
CallID string
|
||||
Data []byte
|
||||
Codec Codec
|
||||
Timestamp time.Time
|
||||
}
|
||||
|
||||
type MediaStartInfo struct {
|
||||
ConnectionID string
|
||||
Channel string
|
||||
ChannelID string
|
||||
Format string
|
||||
OptimalFrameSize int
|
||||
PTimeMS int
|
||||
}
|
||||
|
||||
type Stats struct {
|
||||
InboundFrames int64
|
||||
InboundBytes int64
|
||||
OutboundFrames int64
|
||||
OutboundBytes int64
|
||||
TextEvents int64
|
||||
XOFFCount int64
|
||||
XONCount int64
|
||||
FirstInboundAudioAt *time.Time
|
||||
LastInboundAudioAt *time.Time
|
||||
MediaStart *MediaStartInfo
|
||||
}
|
||||
Reference in New Issue
Block a user