package call import ( "context" "testing" "time" "ai-operator/internal/ai" "ai-operator/internal/media" ) type pumpMedia struct { audio chan media.AudioChunk sent [][]byte flushed int } func newPumpMedia() *pumpMedia { return &pumpMedia{audio: make(chan media.AudioChunk, 4)} } func (m *pumpMedia) Audio() <-chan media.AudioChunk { return m.audio } func (m *pumpMedia) SendAudio(ctx context.Context, data []byte) error { m.sent = append(m.sent, append([]byte(nil), data...)) return nil } func (m *pumpMedia) FlushMedia(ctx context.Context) error { m.flushed++; return nil } func (m *pumpMedia) Close(ctx context.Context) error { close(m.audio); return nil } type pumpProvider struct { sent []media.AudioChunk } func (p *pumpProvider) StartSession(ctx context.Context, config ai.VoiceSessionConfig) error { return nil } func (p *pumpProvider) SendAudio(ctx context.Context, chunk media.AudioChunk) error { p.sent = append(p.sent, media.AudioChunk{CallID: chunk.CallID, Data: append([]byte(nil), chunk.Data...), Codec: chunk.Codec, Timestamp: chunk.Timestamp}) return nil } func (p *pumpProvider) SendToolResult(ctx context.Context, result ai.ToolResult) error { return nil } func (p *pumpProvider) Close(ctx context.Context) error { return nil } func (p *pumpProvider) Events() <-chan ai.VoiceEvent { return nil } func (p *pumpProvider) Stats() ai.VoiceProviderStats { return ai.VoiceProviderStats{} } func TestAudioPumpForwardsAsteriskAudioToProvider(t *testing.T) { pm := newPumpMedia() pp := &pumpProvider{} pump := &AudioPump{CallID: "c1", Media: pm, Provider: pp, AsteriskSampleRate: 16000, ProviderInputSampleRate: 24000, ProviderOutputSampleRate: 24000} ctx, cancel := context.WithCancel(context.Background()) defer cancel() if err := pump.Start(ctx); err != nil { t.Fatal(err) } pm.audio <- media.AudioChunk{Data: []byte{0, 0, 1, 0, 2, 0, 3, 0}, Codec: media.CodecSLIN16, Timestamp: time.Now()} deadline := time.Now().Add(time.Second) for len(pp.sent) == 0 && time.Now().Before(deadline) { time.Sleep(10 * time.Millisecond) } if len(pp.sent) != 1 { t.Fatalf("provider chunks=%d", len(pp.sent)) } if len(pp.sent[0].Data) <= 8 { t.Fatalf("expected 16k->24k resample to increase bytes, got %d", len(pp.sent[0].Data)) } } func TestAudioPumpForwardsProviderAudioToAsterisk(t *testing.T) { pm := newPumpMedia() pp := &pumpProvider{} pump := &AudioPump{CallID: "c1", Media: pm, Provider: pp, AsteriskSampleRate: 16000, ProviderInputSampleRate: 24000, ProviderOutputSampleRate: 24000} in := []byte{0, 0, 1, 0, 2, 0, 3, 0, 4, 0, 5, 0} if err := pump.HandleProviderEvent(context.Background(), ai.VoiceEvent{Type: ai.VoiceEventAssistantAudioDelta, Audio: in}); err != nil { t.Fatal(err) } if len(pm.sent) != 1 { t.Fatalf("media sent chunks=%d", len(pm.sent)) } if len(pm.sent[0]) >= len(in) { t.Fatalf("expected 24k->16k resample to reduce bytes, got %d from %d", len(pm.sent[0]), len(in)) } pump.mu.Lock() pump.suppressInputUntil = time.Now().Add(-time.Second) pump.mu.Unlock() if err := pump.HandleProviderEvent(context.Background(), ai.VoiceEvent{Type: ai.VoiceEventInterruption}); err != nil { t.Fatal(err) } if pm.flushed != 1 { t.Fatalf("flush count=%d", pm.flushed) } } func TestAudioPumpSuppressesInputDuringAssistantPlayback(t *testing.T) { pm := newPumpMedia() pp := &pumpProvider{} pump := &AudioPump{CallID: "c1", Media: pm, Provider: pp, AsteriskSampleRate: 16000, ProviderInputSampleRate: 24000, ProviderOutputSampleRate: 24000} ctx, cancel := context.WithCancel(context.Background()) defer cancel() if err := pump.Start(ctx); err != nil { t.Fatal(err) } if err := pump.HandleProviderEvent(context.Background(), ai.VoiceEvent{Type: ai.VoiceEventAssistantAudioDelta, Audio: []byte{0, 0, 1, 0, 2, 0, 3, 0}}); err != nil { t.Fatal(err) } pm.audio <- media.AudioChunk{Data: []byte{0, 0, 1, 0, 2, 0, 3, 0}, Codec: media.CodecSLIN16, Timestamp: time.Now()} time.Sleep(50 * time.Millisecond) if len(pp.sent) != 0 { t.Fatalf("provider received echo audio chunks=%d", len(pp.sent)) } } func TestAudioPumpIgnoresEchoInterruptionDuringAssistantPlayback(t *testing.T) { pm := newPumpMedia() pp := &pumpProvider{} pump := &AudioPump{CallID: "c1", Media: pm, Provider: pp, AsteriskSampleRate: 16000, ProviderInputSampleRate: 24000, ProviderOutputSampleRate: 24000} if err := pump.HandleProviderEvent(context.Background(), ai.VoiceEvent{Type: ai.VoiceEventAssistantAudioDelta, Audio: []byte{0, 0, 1, 0, 2, 0, 3, 0}}); err != nil { t.Fatal(err) } if err := pump.HandleProviderEvent(context.Background(), ai.VoiceEvent{Type: ai.VoiceEventInterruption}); err != nil { t.Fatal(err) } if pm.flushed != 0 { t.Fatalf("unexpected flush during assistant echo suppression: %d", pm.flushed) } pump.mu.Lock() pump.suppressInputUntil = time.Now().Add(-time.Second) pump.mu.Unlock() if err := pump.HandleProviderEvent(context.Background(), ai.VoiceEvent{Type: ai.VoiceEventInterruption}); err != nil { t.Fatal(err) } if pm.flushed != 1 { t.Fatalf("flush count=%d", pm.flushed) } }