125 lines
3.6 KiB
Go
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
|
|
}
|