Files

125 lines
3.6 KiB
Go

package ai
import (
"context"
"crypto/sha256"
"encoding/binary"
"encoding/hex"
"encoding/json"
"fmt"
)
// MockTurnInput is a test-only media boundary. TranscriptHint is explicit test
// input; the mock never claims to have recognized real audio.
type MockTurnInput struct {
Audio []byte
TranscriptHint string
}
type MockTurnResult struct {
Mode Mode
Transcript string
ResponseText string
Audio []byte
Calls []string
}
type mockAIConfig struct {
Prompt struct {
Text string `json:"text"`
} `json:"prompt"`
ASR struct {
ProviderRef string `json:"provider_ref"`
} `json:"asr"`
LLM struct {
ProviderRef string `json:"provider_ref"`
Model string `json:"model"`
} `json:"llm"`
TTS struct {
ProviderRef string `json:"provider_ref"`
Model string `json:"model"`
} `json:"tts"`
}
// MockPipeline is a bounded provider adapter for the same callflow used by
// mixed and real modes. It never reaches a provider or accepts production
// credentials; only the adapter changes, not the business sequence.
type MockPipeline struct {
MaxAudioBytes int
}
func (p MockPipeline) Synthesize(ctx context.Context, snapshot Snapshot, text string) ([]byte, error) {
result, err := p.Run(ctx, snapshot, MockTurnInput{Audio: []byte(text), TranscriptHint: text})
if err != nil {
return nil, err
}
return result.Audio, nil
}
func (p MockPipeline) RunTurn(ctx context.Context, snapshot Snapshot, pcm16 []byte) (TurnResult, error) {
result, err := p.Run(ctx, snapshot, MockTurnInput{Audio: pcm16})
if err != nil {
return TurnResult{}, err
}
return TurnResult{Transcript: result.Transcript, Reply: result.ResponseText, AudioPCM16: result.Audio}, nil
}
func (p MockPipeline) Run(ctx context.Context, snapshot Snapshot, input MockTurnInput) (MockTurnResult, error) {
if err := ctx.Err(); err != nil {
return MockTurnResult{}, err
}
if p.MaxAudioBytes > 0 && len(input.Audio) > p.MaxAudioBytes {
return MockTurnResult{}, fmt.Errorf("mock audio exceeds limit: %d > %d", len(input.Audio), p.MaxAudioBytes)
}
var config mockAIConfig
if err := json.Unmarshal(snapshot.Raw, &config); err != nil {
return MockTurnResult{}, fmt.Errorf("decode immutable AI snapshot: %w", err)
}
transcript := input.TranscriptHint
if transcript == "" {
digest := sha256.Sum256(input.Audio)
transcript = "mock transcript " + hex.EncodeToString(digest[:4])
}
result := MockTurnResult{Mode: snapshot.Mode, Transcript: transcript, Calls: []string{"asr"}}
if snapshot.Mode == ModeASROnly {
return result, nil
}
if snapshot.Mode != ModeFullAI {
return MockTurnResult{}, fmt.Errorf("unsupported mock mode %q", snapshot.Mode)
}
if config.LLM.ProviderRef == "" || config.TTS.ProviderRef == "" || config.Prompt.Text == "" {
return MockTurnResult{}, fmt.Errorf("full-AI mock snapshot is missing provider or prompt parameters")
}
if err := ctx.Err(); err != nil {
return MockTurnResult{}, err
}
result.ResponseText = "mock response: " + transcript
result.Calls = append(result.Calls, "llm")
if err := ctx.Err(); err != nil {
return MockTurnResult{}, err
}
result.Audio = mockTonePCM16()
result.Calls = append(result.Calls, "tts")
return result, nil
}
// mockTonePCM16 is a deterministic, non-silent fixture so the isolated SIP
// callee can advance its scripted conversation after the opening prompt.
func mockTonePCM16() []byte {
const (
sampleRate = 16000
samples = sampleRate / 5
amplitude = int16(6000)
period = 36
)
pcm := make([]byte, samples*2)
for i := 0; i < samples; i++ {
value := amplitude
if (i/period)%2 == 1 {
value = -amplitude
}
binary.LittleEndian.PutUint16(pcm[i*2:i*2+2], uint16(value))
}
return pcm
}