chore: initialize go-sip repository
This commit is contained in:
@@ -0,0 +1,111 @@
|
||||
package callflow
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
// CaptureConfig mirrors the real Cell turn boundary: wait for speech, keep
|
||||
// collecting until bounded duration or end-of-speech silence, then send one
|
||||
// canonical PCM16 turn to the AI adapter.
|
||||
type CaptureConfig struct {
|
||||
FirstSpeechTimeout time.Duration
|
||||
MaxDuration time.Duration
|
||||
EndSilence time.Duration
|
||||
VoiceThreshold int
|
||||
MaxTurns int
|
||||
}
|
||||
|
||||
func captureTurn(ctx context.Context, session MediaSession, cfg CaptureConfig) ([]byte, error) {
|
||||
if cfg.FirstSpeechTimeout <= 0 {
|
||||
cfg.FirstSpeechTimeout = 5 * time.Second
|
||||
}
|
||||
if cfg.MaxDuration <= 0 {
|
||||
cfg.MaxDuration = cfg.FirstSpeechTimeout
|
||||
}
|
||||
if cfg.EndSilence < 0 {
|
||||
cfg.EndSilence = 0
|
||||
}
|
||||
startedAt := time.Now()
|
||||
firstDeadline := startedAt.Add(cfg.FirstSpeechTimeout)
|
||||
maxDeadline := startedAt.Add(cfg.MaxDuration)
|
||||
started := cfg.VoiceThreshold <= 0
|
||||
lastVoice := time.Time{}
|
||||
var frames []byte
|
||||
|
||||
for {
|
||||
now := time.Now()
|
||||
if !started && !now.Before(firstDeadline) {
|
||||
return nil, errors.New("no speech detected before capture timeout")
|
||||
}
|
||||
if !maxDeadline.After(now) {
|
||||
break
|
||||
}
|
||||
readDeadline := maxDeadline
|
||||
if !started && firstDeadline.Before(readDeadline) {
|
||||
readDeadline = firstDeadline
|
||||
}
|
||||
if started && cfg.EndSilence > 0 && !lastVoice.IsZero() {
|
||||
silenceDeadline := lastVoice.Add(cfg.EndSilence)
|
||||
if silenceDeadline.Before(readDeadline) {
|
||||
readDeadline = silenceDeadline
|
||||
}
|
||||
}
|
||||
readCtx, cancel := context.WithDeadline(ctx, readDeadline)
|
||||
payload, err := session.ReadPayload(readCtx)
|
||||
cancel()
|
||||
if err != nil {
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
if started && cfg.EndSilence > 0 && !lastVoice.IsZero() && !time.Now().Before(lastVoice.Add(cfg.EndSilence)) {
|
||||
break
|
||||
}
|
||||
continue
|
||||
}
|
||||
if errors.Is(err, context.Canceled) && ctx.Err() == nil {
|
||||
continue
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if len(payload) == 0 {
|
||||
continue
|
||||
}
|
||||
if cfg.VoiceThreshold > 0 {
|
||||
if pcm16VoiceLevel(payload) >= cfg.VoiceThreshold {
|
||||
started = true
|
||||
lastVoice = time.Now()
|
||||
}
|
||||
if started {
|
||||
frames = append(frames, payload...)
|
||||
}
|
||||
} else {
|
||||
started = true
|
||||
lastVoice = time.Now()
|
||||
frames = append(frames, payload...)
|
||||
}
|
||||
if started && cfg.EndSilence > 0 && !lastVoice.IsZero() && !time.Now().Before(lastVoice.Add(cfg.EndSilence)) {
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(frames) == 0 {
|
||||
return nil, errors.New("captured audio is empty")
|
||||
}
|
||||
return frames, nil
|
||||
}
|
||||
|
||||
func pcm16VoiceLevel(pcm []byte) int {
|
||||
if len(pcm) < 2 {
|
||||
return 0
|
||||
}
|
||||
var sum uint64
|
||||
count := len(pcm) / 2
|
||||
for i := 0; i < count; i++ {
|
||||
value := int64(int16(binary.LittleEndian.Uint16(pcm[i*2 : i*2+2])))
|
||||
if value < 0 {
|
||||
value = -value
|
||||
}
|
||||
sum += uint64(value)
|
||||
}
|
||||
return int(sum / uint64(count))
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package callflow
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestCaptureTurnWaitsForSpeechAndStopsOnSilence(t *testing.T) {
|
||||
silence := make([]byte, 640)
|
||||
voice := make([]byte, 640)
|
||||
for i := 0; i < len(voice); i += 2 {
|
||||
voice[i] = 0xE8
|
||||
voice[i+1] = 0x03 // 1000, little-endian PCM16
|
||||
}
|
||||
session := NewMemorySession(append(append(append([]byte{}, silence...), voice...), silence...))
|
||||
captured, err := captureTurn(context.Background(), session, CaptureConfig{
|
||||
FirstSpeechTimeout: 20 * time.Millisecond,
|
||||
MaxDuration: 20 * time.Millisecond,
|
||||
EndSilence: time.Millisecond,
|
||||
VoiceThreshold: 100,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(captured) < len(voice) || len(captured) >= len(silence)+len(voice)+len(silence) {
|
||||
t.Fatalf("captured=%d want speech and bounded trailing silence", len(captured))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package callflow
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.ipao.vip/rogee/go-sip/internal/ai"
|
||||
"git.ipao.vip/rogee/go-sip/internal/media"
|
||||
)
|
||||
|
||||
// MediaSession is the only transport boundary of the shared call flow.
|
||||
// Real mode uses Asterisk ExternalMedia/RTP; mock mode uses MemorySession; the
|
||||
// sequencing is shared while ASR-only deliberately omits opening/reply TTS.
|
||||
type MediaSession interface {
|
||||
ReadPayload(context.Context) ([]byte, error)
|
||||
SendPCM16(context.Context, []byte, int) error
|
||||
Stats() media.RTPStats
|
||||
}
|
||||
|
||||
type Result struct {
|
||||
Turn ai.TurnResult
|
||||
Turns []ai.TurnResult
|
||||
Inbound []byte
|
||||
InboundTurns [][]byte
|
||||
OutboundTurns [][]byte
|
||||
RTP media.RTPStats
|
||||
}
|
||||
|
||||
func Execute(ctx context.Context, session MediaSession, pipeline ai.Pipeline, snapshot ai.Snapshot, opening string, turnWindow time.Duration) (Result, error) {
|
||||
if turnWindow <= 0 {
|
||||
turnWindow = 5 * time.Second
|
||||
}
|
||||
return ExecuteWithCapture(ctx, session, pipeline, snapshot, opening, CaptureConfig{
|
||||
FirstSpeechTimeout: turnWindow,
|
||||
MaxDuration: turnWindow,
|
||||
MaxTurns: 1,
|
||||
})
|
||||
}
|
||||
|
||||
func ExecuteWithCapture(ctx context.Context, session MediaSession, pipeline ai.Pipeline, snapshot ai.Snapshot, opening string, capture CaptureConfig) (Result, error) {
|
||||
if session == nil || pipeline == nil {
|
||||
return Result{}, errors.New("media session and AI pipeline are required")
|
||||
}
|
||||
if snapshot.Mode != ai.ModeFullAI && snapshot.Mode != ai.ModeASROnly {
|
||||
return Result{}, fmt.Errorf("unsupported callflow AI mode %q", snapshot.Mode)
|
||||
}
|
||||
maxTurns := capture.MaxTurns
|
||||
if maxTurns <= 0 {
|
||||
maxTurns = 1
|
||||
}
|
||||
result := Result{}
|
||||
if snapshot.Mode == ai.ModeFullAI {
|
||||
openingPCM, err := pipeline.Synthesize(ctx, snapshot, opening)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
result.OutboundTurns = append(result.OutboundTurns, clonePCM(openingPCM))
|
||||
if err := session.SendPCM16(ctx, openingPCM, 16000); err != nil {
|
||||
return result, err
|
||||
}
|
||||
}
|
||||
for turnIndex := 0; turnIndex < maxTurns; turnIndex++ {
|
||||
inbound, err := captureTurn(ctx, session, capture)
|
||||
if err != nil {
|
||||
return result, fmt.Errorf("capturing RTP turn %d after opening/reply prompt: %w", turnIndex+1, err)
|
||||
}
|
||||
result.Inbound = inbound
|
||||
result.InboundTurns = append(result.InboundTurns, clonePCM(inbound))
|
||||
if len(inbound) < 3200 {
|
||||
return result, fmt.Errorf("captured audio turn %d is too short", turnIndex+1)
|
||||
}
|
||||
turn, err := pipeline.RunTurn(ctx, snapshot, inbound)
|
||||
if err != nil {
|
||||
return result, fmt.Errorf("run AI turn %d: %w", turnIndex+1, err)
|
||||
}
|
||||
turn.InvalidCall, turn.InvalidReason = invalidCallReason(turn.Transcript, turn.Reply)
|
||||
result.Turn = turn
|
||||
result.Turns = append(result.Turns, turn)
|
||||
if turn.InvalidCall {
|
||||
result.RTP = session.Stats()
|
||||
return result, nil
|
||||
}
|
||||
if snapshot.Mode == ai.ModeASROnly {
|
||||
result.RTP = session.Stats()
|
||||
continue
|
||||
}
|
||||
if err := session.SendPCM16(ctx, turn.AudioPCM16, 16000); err != nil {
|
||||
return result, fmt.Errorf("send AI reply turn %d: %w", turnIndex+1, err)
|
||||
}
|
||||
result.OutboundTurns = append(result.OutboundTurns, clonePCM(turn.AudioPCM16))
|
||||
result.RTP = session.Stats()
|
||||
}
|
||||
result.RTP = session.Stats()
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func clonePCM(pcm []byte) []byte {
|
||||
return append([]byte(nil), pcm...)
|
||||
}
|
||||
|
||||
func invalidCallReason(transcript, reply string) (bool, string) {
|
||||
if strings.Contains(reply, ai.InvalidCallMarker) {
|
||||
return true, "llm_invalid_call_marker"
|
||||
}
|
||||
normalized := strings.NewReplacer(" ", "", " ", "", "。", "", ",", "", ",", "", ".", "").Replace(strings.TrimSpace(transcript))
|
||||
for _, marker := range []string{"打错", "不需要", "不用", "没兴趣", "不考虑", "不方便", "别打", "拒绝", "骚扰", "语音信箱", "自动语音", "请按键", "空号"} {
|
||||
if strings.Contains(normalized, marker) {
|
||||
return true, "transcript_invalid_intent"
|
||||
}
|
||||
}
|
||||
return false, ""
|
||||
}
|
||||
|
||||
// MemorySession is a bounded transport adapter for mock/mixed-flow tests. It
|
||||
// has no SIP semantics and never bypasses the shared call flow.
|
||||
type MemorySession struct {
|
||||
inbound [][]byte
|
||||
index int
|
||||
outbound []byte
|
||||
stats media.RTPStats
|
||||
}
|
||||
|
||||
func NewMemorySession(pcm []byte) *MemorySession {
|
||||
const frameBytes = 640
|
||||
frames := make([][]byte, 0, (len(pcm)+frameBytes-1)/frameBytes)
|
||||
for offset := 0; offset < len(pcm); offset += frameBytes {
|
||||
end := offset + frameBytes
|
||||
if end > len(pcm) {
|
||||
end = len(pcm)
|
||||
}
|
||||
frames = append(frames, append([]byte(nil), pcm[offset:end]...))
|
||||
}
|
||||
return &MemorySession{inbound: frames}
|
||||
}
|
||||
|
||||
func (m *MemorySession) ReadPayload(ctx context.Context) ([]byte, error) {
|
||||
if m.index < len(m.inbound) {
|
||||
payload := m.inbound[m.index]
|
||||
m.index++
|
||||
m.stats.ReceivedPackets++
|
||||
m.stats.ReceivedBytes += uint64(len(payload))
|
||||
return append([]byte(nil), payload...), nil
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
default:
|
||||
return nil, context.DeadlineExceeded
|
||||
}
|
||||
}
|
||||
|
||||
func (m *MemorySession) SendPCM16(ctx context.Context, pcm []byte, _ int) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
m.outbound = append(m.outbound, pcm...)
|
||||
m.stats.SentBytes += uint64(len(pcm))
|
||||
m.stats.SentPackets += uint64((len(pcm) + 639) / 640)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MemorySession) Stats() media.RTPStats { return m.stats }
|
||||
func (m *MemorySession) OutboundPCM() []byte { return append([]byte(nil), m.outbound...) }
|
||||
@@ -0,0 +1,187 @@
|
||||
package callflow
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.ipao.vip/rogee/go-sip/contracts"
|
||||
"git.ipao.vip/rogee/go-sip/internal/ai"
|
||||
"git.ipao.vip/rogee/go-sip/internal/media"
|
||||
)
|
||||
|
||||
func TestInitialMediaWaitIsBounded(t *testing.T) {
|
||||
raw, err := contracts.Read("examples/agent-version-full-explicit.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
snapshot, err := ai.ValidateForMode(raw, ai.ModeFullAI)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = Execute(context.Background(), NewMemorySession(nil), ai.MockPipeline{}, snapshot, "测试开场", time.Millisecond)
|
||||
if err == nil {
|
||||
t.Fatal("expected bounded initial media wait to fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSharedFlowRunsThreeConversationTurns(t *testing.T) {
|
||||
raw, err := contracts.Read("examples/agent-version-full-explicit.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
snapshot, err := ai.ValidateForMode(raw, ai.ModeFullAI)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
session := &scriptedTurnSession{turns: [][]byte{make([]byte, 6400), make([]byte, 6400), make([]byte, 6400)}}
|
||||
result, err := ExecuteWithCapture(context.Background(), session, ai.MockPipeline{MaxAudioBytes: 16 << 20}, snapshot, "测试开场", CaptureConfig{
|
||||
FirstSpeechTimeout: time.Second,
|
||||
MaxDuration: 2 * time.Millisecond,
|
||||
MaxTurns: 3,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(result.Turns) != 3 || len(result.InboundTurns) != 3 || len(result.OutboundTurns) != 4 {
|
||||
t.Fatalf("expected opening plus three shared turns, got turns=%d inbound=%d outbound=%d", len(result.Turns), len(result.InboundTurns), len(result.OutboundTurns))
|
||||
}
|
||||
if result.Turn.Transcript == "" || result.Turn.Reply == "" || result.RTP.ReceivedBytes != 19200 {
|
||||
t.Fatalf("three-turn flow facts are incomplete: %+v", result)
|
||||
}
|
||||
}
|
||||
|
||||
type scriptedTurnSession struct {
|
||||
turns [][]byte
|
||||
index int
|
||||
served bool
|
||||
stats media.RTPStats
|
||||
}
|
||||
|
||||
func (s *scriptedTurnSession) ReadPayload(ctx context.Context) ([]byte, error) {
|
||||
if !s.served && s.index < len(s.turns) {
|
||||
s.served = true
|
||||
payload := append([]byte(nil), s.turns[s.index]...)
|
||||
s.index++
|
||||
s.stats.ReceivedPackets++
|
||||
s.stats.ReceivedBytes += uint64(len(payload))
|
||||
return payload, nil
|
||||
}
|
||||
<-ctx.Done()
|
||||
s.served = false
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
|
||||
func (s *scriptedTurnSession) SendPCM16(ctx context.Context, pcm []byte, _ int) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
s.stats.SentPackets++
|
||||
s.stats.SentBytes += uint64(len(pcm))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *scriptedTurnSession) Stats() media.RTPStats { return s.stats }
|
||||
|
||||
type invalidCallPipeline struct{}
|
||||
|
||||
func (invalidCallPipeline) Synthesize(context.Context, ai.Snapshot, string) ([]byte, error) {
|
||||
return make([]byte, 6400), nil
|
||||
}
|
||||
|
||||
func (invalidCallPipeline) RunTurn(context.Context, ai.Snapshot, []byte) (ai.TurnResult, error) {
|
||||
return ai.TurnResult{Transcript: "打错了", Reply: ai.InvalidCallMarker, AudioPCM16: make([]byte, 6400)}, nil
|
||||
}
|
||||
|
||||
func TestInvalidCallStopsBeforeReply(t *testing.T) {
|
||||
raw, err := contracts.Read("examples/agent-version-full-explicit.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
snapshot, err := ai.ValidateForMode(raw, ai.ModeFullAI)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
session := &scriptedTurnSession{turns: [][]byte{make([]byte, 6400)}}
|
||||
result, err := ExecuteWithCapture(context.Background(), session, invalidCallPipeline{}, snapshot, "开场", CaptureConfig{
|
||||
FirstSpeechTimeout: time.Second,
|
||||
MaxDuration: 2 * time.Millisecond,
|
||||
MaxTurns: 3,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !result.Turn.InvalidCall || result.Turn.InvalidReason != "llm_invalid_call_marker" || len(result.Turns) != 1 {
|
||||
t.Fatalf("invalid call was not classified: %+v", result)
|
||||
}
|
||||
if session.stats.SentPackets != 1 {
|
||||
t.Fatalf("invalid call must not send a reply, sent packets=%d", session.stats.SentPackets)
|
||||
}
|
||||
}
|
||||
|
||||
func TestASROnlySkipsOpeningLLMAndTTS(t *testing.T) {
|
||||
raw, err := contracts.Read("examples/agent-version-asr-only.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
snapshot, err := ai.ValidateForMode(raw, ai.ModeASROnly)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pipeline := &asrOnlyPipeline{}
|
||||
session := &scriptedTurnSession{turns: [][]byte{make([]byte, 6400)}}
|
||||
result, err := ExecuteWithCapture(context.Background(), session, pipeline, snapshot, "不得播放", CaptureConfig{
|
||||
FirstSpeechTimeout: time.Second,
|
||||
MaxDuration: time.Millisecond,
|
||||
MaxTurns: 1,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if pipeline.synthesizeCalls != 0 || pipeline.turnCalls != 1 {
|
||||
t.Fatalf("ASR-only provider calls = synthesize:%d turn:%d", pipeline.synthesizeCalls, pipeline.turnCalls)
|
||||
}
|
||||
if result.Turn.Transcript != "asr-only transcript" || result.Turn.Reply != "" || len(result.OutboundTurns) != 0 {
|
||||
t.Fatalf("unexpected ASR-only result: %+v", result)
|
||||
}
|
||||
if session.stats.SentPackets != 0 {
|
||||
t.Fatalf("ASR-only flow must not send opening/reply audio, sent packets=%d", session.stats.SentPackets)
|
||||
}
|
||||
}
|
||||
|
||||
type asrOnlyPipeline struct {
|
||||
synthesizeCalls int
|
||||
turnCalls int
|
||||
}
|
||||
|
||||
func (p *asrOnlyPipeline) Synthesize(context.Context, ai.Snapshot, string) ([]byte, error) {
|
||||
p.synthesizeCalls++
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (p *asrOnlyPipeline) RunTurn(context.Context, ai.Snapshot, []byte) (ai.TurnResult, error) {
|
||||
p.turnCalls++
|
||||
return ai.TurnResult{Transcript: "asr-only transcript"}, nil
|
||||
}
|
||||
|
||||
func TestMockModeUsesTheSharedConversationFlow(t *testing.T) {
|
||||
raw, err := contracts.Read("examples/agent-version-full-explicit.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
snapshot, err := ai.ValidateForMode(raw, ai.ModeFullAI)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
session := NewMemorySession(make([]byte, 6400))
|
||||
result, err := Execute(context.Background(), session, ai.MockPipeline{MaxAudioBytes: 16 << 20}, snapshot, "测试开场", 10*time.Millisecond)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Turn.Transcript == "" || result.Turn.Reply == "" || len(result.Turn.AudioPCM16) == 0 {
|
||||
t.Fatalf("shared flow did not complete mock ASR/LLM/TTS: %+v", result.Turn)
|
||||
}
|
||||
if result.RTP.ReceivedBytes != 6400 || result.RTP.SentPackets == 0 || len(session.OutboundPCM()) == 0 {
|
||||
t.Fatalf("shared media boundary was not exercised: %+v", result.RTP)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user