diff --git a/contracts/schema b/contracts/schema index bfa3380..d46d77d 160000 --- a/contracts/schema +++ b/contracts/schema @@ -1 +1 @@ -Subproject commit bfa3380a5e01f4bfaea918383fceed81548252d1 +Subproject commit d46d77d278f0511c6f314a5d3001f29a8f12ef14 diff --git a/docs/TODO.md b/docs/TODO.md index 9d7d726..39d18ff 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -1,13 +1,14 @@ # 当前待办 -## ASR 对接边界音频转换(未实现) +## ASR 对接边界音频转换(本地已实现,待真实验收) -- [ ] 将内部媒体格式与 ASR 输入要求分离;内部可继续使用统一格式,不要求服务商支持内部固定的 16000 采样率。 -- [ ] 使用获批任务现有参数确定 ASR 目标输入格式,在发送边界转换音频;实际格式已经匹配时不转换。请求中的采样率必须与实际发送音频一致。 -- [ ] 不忽略或覆盖任务参数,不仅修改采样率声明,不补 SDK 默认值,不因失败换采样率或换服务商。输入要求不能明确、目标不支持或转换失败时明确报错,不发送格式不匹配的音频。 -- [ ] 实现前核查现有音频依赖的转换能力;按 TDD 补充至少 16000→8000、8000→16000、同采样率直通、流式分块与失败测试,核验实际音频而非只检查参数。 -- [ ] 真实供应商兼容性须在另获授权后验证;本地结构校验、模拟测试不代替真实签收。 +- [x] 内部媒体继续使用单声道 16 kHz PCM16;绑定时读取并校验任务现有 `sample_rate`,不补默认值。 +- [x] 在 ASR 发送边界按目标采样率转换实际音频;请求参数原样保留,发送的采样率与音频一致,同采样率时不重采样。 +- [x] 缺失、非整数、超出 8–48 kHz 范围或转换后没有完整 PCM16 样本时明确失败;不改写参数、不换率、不换服务商。 +- [x] 复用已有 PCM16 转换能力;TDD 覆盖 16→8 kHz ASR、8↔16 kHz 媒体转换、同率直通、分块收发和错误输入。 +- [ ] 在获批的隔离 ASR-only 与 full-AI 任务上完成真实 ASR 验证;本地测试不能代替供应商和通话验收。 +- [ ] 修正隔离 full-AI 测试任务的 TTS 输出格式与 Agent PCM16 要求不匹配问题,再验证 TTS、LLM、ASR 全链路。 唯一规则来源是共享合同的 [业务规范](../contracts/schema/saas-dispatcher.md) 与 [任务 Schema](../contracts/schema/http-task-detail.schema.json),这里仅记录未完成工作,不维护第二份字段定义。 -**当前边界:** Agent 仍使用单声道 16 kHz PCM16,尚未实现上述 ASR 转换。Schema 允许 8000、16000 等参数,只表示配置结构有效,不表示当前程序或任意供应商已经支持。此次仅更新待办、合同、示例及测试,不改变运行行为、不部署、不拨号、不调用 AI。 +**当前边界:** 实现位于本地分支 `fix/asr-sample-rate-20261012`,尚未部署。Dispatcher 当前只有 full-AI 任务,没有 ASR-only 测试任务。测试管理页通过 NatApp 隧道访问失败(目标 `192.168.0.3:8888` 不可用),因此尚未创建测试任务,也未对 `15003164745` 或 `15830461047` 发起新呼叫。 diff --git a/internal/ai/asr_sample_rate.go b/internal/ai/asr_sample_rate.go new file mode 100644 index 0000000..af5a354 --- /dev/null +++ b/internal/ai/asr_sample_rate.go @@ -0,0 +1,47 @@ +package ai + +import ( + "encoding/json" + "errors" + "math" + + "git.ipao.vip/rogee/go-sip/internal/audio" +) + +const ( + internalPCM16SampleRate = 16000 + minASRSampleRate = 8000 + maxASRSampleRate = 48000 +) + +func parseASRSampleRate(params map[string]json.RawMessage) (int, error) { + raw, ok := params["sample_rate"] + if !ok { + return 0, errors.New("ASR params.sample_rate is required") + } + var rate float64 + if err := json.Unmarshal(raw, &rate); err != nil { + return 0, errors.New("ASR params.sample_rate must be numeric") + } + if math.IsNaN(rate) || math.IsInf(rate, 0) || math.Trunc(rate) != rate || rate < minASRSampleRate || rate > maxASRSampleRate { + return 0, errors.New("ASR params.sample_rate must be an integer from 8000 to 48000 Hz") + } + return int(rate), nil +} + +func prepareASRPCM16(pcm16 []byte, targetRate int) ([]byte, error) { + if len(pcm16) == 0 || len(pcm16)%2 != 0 { + return nil, errors.New("ASR input must contain nonempty signed PCM16 samples") + } + if targetRate < minASRSampleRate || targetRate > maxASRSampleRate { + return nil, errors.New("ASR target sample rate is outside the supported 8000–48000 Hz range") + } + + // The media layer uses the same resampler when converting the supported + // 8-kHz PCMA wire audio to the agent's 16-kHz PCM16 format. + resampled := audio.ResamplePCM16(pcm16, internalPCM16SampleRate, targetRate) + if len(resampled) == 0 || len(resampled)%2 != 0 { + return nil, errors.New("ASR resampling produced no complete PCM16 samples") + } + return resampled, nil +} diff --git a/internal/ai/asr_sample_rate_test.go b/internal/ai/asr_sample_rate_test.go new file mode 100644 index 0000000..6cbc6c0 --- /dev/null +++ b/internal/ai/asr_sample_rate_test.go @@ -0,0 +1,112 @@ +package ai + +import ( + "bytes" + "strconv" + "testing" +) + +func TestBindStoresConfiguredASRSampleRate(t *testing.T) { + for _, rate := range []int{8000, 16000, 24000, 48000} { + t.Run(strconv.Itoa(rate), func(t *testing.T) { + task, providers := currentFixture(t, "asr_only") + task = changeCurrentAgent(t, task, func(agent map[string]any) { + asr := agent["asr"].(map[string]any) + params := asr["params"].(map[string]any) + params["sample_rate"] = rate + }) + bound, err := Bind(task, providers) + if err != nil { + t.Fatal(err) + } + if bound.ASR.SampleRate != rate { + t.Fatalf("sample rate = %d, want %d", bound.ASR.SampleRate, rate) + } + }) + } +} + +func TestBindRejectsMissingASRSampleRate(t *testing.T) { + task, providers := currentFixture(t, "asr_only") + task = changeCurrentAgent(t, task, func(agent map[string]any) { + asr := agent["asr"].(map[string]any) + params := asr["params"].(map[string]any) + delete(params, "sample_rate") + }) + + if _, err := Bind(task, providers); err == nil { + t.Fatal("Bind accepted ASR parameters without sample_rate") + } +} + +func TestBindRejectsInvalidASRSampleRates(t *testing.T) { + for name, rate := range map[string]any{ + "null": nil, + "string": "8000", + "fractional": 8000.5, + "boolean": true, + "zero": 0, + "below-min": 7999, + "above-max": 48001, + } { + t.Run(name, func(t *testing.T) { + task, providers := currentFixture(t, "asr_only") + task = changeCurrentAgent(t, task, func(agent map[string]any) { + asr := agent["asr"].(map[string]any) + params := asr["params"].(map[string]any) + params["sample_rate"] = rate + }) + if _, err := Bind(task, providers); err == nil { + t.Fatalf("Bind accepted invalid sample_rate %v", rate) + } + }) + } +} + +func TestPrepareASRPCM16ResamplesToConfiguredRate(t *testing.T) { + input := []byte{1, 0, 2, 0, 3, 0, 4, 0} + got, err := prepareASRPCM16(input, 8000) + if err != nil { + t.Fatal(err) + } + if want := []byte{1, 0, 3, 0}; !bytes.Equal(got, want) { + t.Fatalf("resampled PCM16 = %v, want %v", got, want) + } + if want := []byte{1, 0, 2, 0, 3, 0, 4, 0}; !bytes.Equal(input, want) { + t.Fatalf("input PCM16 was modified: %v", input) + } +} + +func TestPrepareASRPCM16CopiesAtInternalRate(t *testing.T) { + input := []byte{1, 0, 2, 0} + got, err := prepareASRPCM16(input, internalPCM16SampleRate) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(got, input) { + t.Fatalf("PCM16 = %v, want %v", got, input) + } + got[0] = 9 + if input[0] != 1 { + t.Fatal("prepared ASR audio aliases the caller's PCM buffer") + } +} + +func TestPrepareASRPCM16RejectsInvalidAudioOrRate(t *testing.T) { + for name, test := range map[string]struct { + pcm []byte + rate int + }{ + "empty": {nil, 8000}, + "odd-length": {[]byte{1}, 8000}, + "too-short-after-downsample": {[]byte{1, 0}, 8000}, + "below-min-rate": {[]byte{1, 0, 2, 0}, 7999}, + "above-max-rate": {[]byte{1, 0, 2, 0}, 48001}, + } { + t.Run(name, func(t *testing.T) { + if _, err := prepareASRPCM16(test.pcm, test.rate); err == nil { + t.Fatal("expected conversion to fail") + } + }) + } +} diff --git a/internal/ai/asr_sdk_test.go b/internal/ai/asr_sdk_test.go index 4cca526..724605f 100644 --- a/internal/ai/asr_sdk_test.go +++ b/internal/ai/asr_sdk_test.go @@ -42,7 +42,7 @@ func TestASRParametersReachDoubaoSDKWebSocket(t *testing.T) { defer server.Close() task, providers := currentFixture(t, tc.mode) task = changeCurrentAgent(t, task, func(a map[string]any) { - a["asr"].(map[string]any)["params"] = map[string]any{"reqid": "ignored-params-reqid", "sequence": 99, "model_name": "ignored-params-model", "result_type": tc.resultType, "enable_nonstream": tc.nonstream, "unknown_flag": false, "nullable": nil, "opaque": "vendor-only", "large_number": json.Number("9007199254740993")} + a["asr"].(map[string]any)["params"] = map[string]any{"reqid": "ignored-params-reqid", "sequence": 99, "model_name": "ignored-params-model", "result_type": tc.resultType, "enable_nonstream": tc.nonstream, "sample_rate": 8000, "unknown_flag": false, "nullable": nil, "opaque": "vendor-only", "large_number": json.Number("9007199254740993")} }) asrProvider := providers["volcengine"] asrProvider.WSEndpoint = "ws" + strings.TrimPrefix(server.URL, "http") @@ -53,7 +53,7 @@ func TestASRParametersReachDoubaoSDKWebSocket(t *testing.T) { } ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() - if _, err := bound.Recognize(ctx, []byte{1, 0}); err == nil { + if _, err := bound.Recognize(ctx, []byte{1, 0, 2, 0}); err == nil { t.Fatal("disconnected mock ASR cannot pretend recognition completed") } select { @@ -82,7 +82,7 @@ func TestASRParametersReachDoubaoSDKWebSocket(t *testing.T) { if string(bound.ASR.Params["model_name"]) != `"ignored-params-model"` || string(bound.ASR.Params["reqid"]) != `"ignored-params-reqid"` || string(bound.ASR.Params["sequence"]) != "99" { t.Fatal("ASR request construction changed the original params") } - if len(audio) != 4 || len(request) != 9 || audio["format"] != "pcm_s16le" || audio["sample_rate"] != float64(16000) || audio["channel"] != float64(1) || audio["bits"] != float64(16) || request["result_type"] != tc.resultType || request["enable_nonstream"] != tc.nonstream || request["unknown_flag"] != false || request["nullable"] != nil || request["opaque"] != "vendor-only" { + if len(audio) != 4 || len(request) != 10 || audio["format"] != "pcm_s16le" || audio["sample_rate"] != float64(8000) || audio["channel"] != float64(1) || audio["bits"] != float64(16) || request["sample_rate"] != float64(8000) || request["result_type"] != tc.resultType || request["enable_nonstream"] != tc.nonstream || request["unknown_flag"] != false || request["nullable"] != nil || request["opaque"] != "vendor-only" { t.Fatalf("SDK ASR request omitted approved input/interim fields: audio=%v request=%v", audio, request) } case <-ctx.Done(): diff --git a/internal/ai/bailian_models_test.go b/internal/ai/bailian_models_test.go index 1540101..59eba33 100644 --- a/internal/ai/bailian_models_test.go +++ b/internal/ai/bailian_models_test.go @@ -12,7 +12,7 @@ func bailianFixture(t *testing.T) Binding { a[role].(map[string]any)["provider_ref"] = "ali_bailian" } a["asr"].(map[string]any)["model"] = "fun-asr-flash-8k-realtime-2026-01-28" - a["asr"].(map[string]any)["params"] = map[string]any{"sample_rate": 16000, "format": "pcm", "language": "zh"} + a["asr"].(map[string]any)["params"] = map[string]any{"sample_rate": 8000, "format": "pcm", "language": "zh"} a["llm"].(map[string]any)["model"] = "Qwen3.8-Flash" a["tts"].(map[string]any)["model"] = "cosyvoice-v3-flash" a["tts"].(map[string]any)["voice"] = "longanyang" diff --git a/internal/ai/bailian_realtime_test.go b/internal/ai/bailian_realtime_test.go index 57d5688..5aee33c 100644 --- a/internal/ai/bailian_realtime_test.go +++ b/internal/ai/bailian_realtime_test.go @@ -98,7 +98,7 @@ func TestFunASRPreservesPCMAndReturnsOnlyConfirmedFinalSentences(t *testing.T) { ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second) defer cancel() cmd := mockRead(t, c, ctx) - if cmd.Payload["model"] != "fun-asr-flash-8k-realtime-2026-01-28" || cmd.Payload["parameters"].(map[string]any)["sample_rate"] != float64(16000) { + if cmd.Payload["model"] != "fun-asr-flash-8k-realtime-2026-01-28" || cmd.Payload["parameters"].(map[string]any)["sample_rate"] != float64(8000) { t.Error("ASR model/sample rate changed") } mockEvent(c, ctx, cmd.Header.TaskID, "task-started", map[string]any{}) @@ -129,7 +129,7 @@ func TestFunASRPreservesPCMAndReturnsOnlyConfirmedFinalSentences(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() text, err := bound.Recognize(ctx, make([]byte, 32000)) - if err != nil || text != "第一句。第二句。" || bytesReceived.Load() != 32000 { + if err != nil || text != "第一句。第二句。" || bytesReceived.Load() != 16000 { t.Fatalf("ASR: text=%q err=%v sent=%d", text, err, bytesReceived.Load()) } } diff --git a/internal/ai/binding.go b/internal/ai/binding.go index af86520..5cb2d77 100644 --- a/internal/ai/binding.go +++ b/internal/ai/binding.go @@ -27,9 +27,10 @@ type Binding struct { } type ASRConfig struct { - Provider configread.Provider - Model string - Params map[string]json.RawMessage + Provider configread.Provider + Model string + SampleRate int + Params map[string]json.RawMessage } type LLMConfig struct { Provider configread.Provider @@ -97,11 +98,15 @@ func Bind(task configread.Task, providers map[string]configread.Provider) (Bindi return Binding{}, errors.New("decode immutable AI settings") } b := Binding{Mode: s.Mode} + sampleRate, err := parseASRSampleRate(s.ASR.Params) + if err != nil { + return Binding{}, fmt.Errorf("ASR settings: %w", err) + } p, err := currentProvider(providers, s.ASR.ProviderRef, "asr") if err != nil { return Binding{}, err } - b.ASR = ASRConfig{Provider: p, Model: s.ASR.Model, Params: s.ASR.Params} + b.ASR = ASRConfig{Provider: p, Model: s.ASR.Model, SampleRate: sampleRate, Params: s.ASR.Params} if s.Mode == "asr_only" { return b, nil } diff --git a/internal/ai/binding_test.go b/internal/ai/binding_test.go index 7163225..451ea3a 100644 --- a/internal/ai/binding_test.go +++ b/internal/ai/binding_test.go @@ -28,7 +28,7 @@ func currentFixture(t *testing.T, mode string) (configread.Task, map[string]conf providers[code] = configread.Provider{ProviderRef: "catalog-" + code, Code: code, Name: "Example only", Endpoint: "https://example.invalid", WSEndpoint: "wss://example.invalid", Credential: "example-only-not-a-real-secret"} } task = changeCurrentAgent(t, task, func(a map[string]any) { - a["asr"] = map[string]any{"provider_ref": "volcengine", "model": "example-asr", "params": map[string]any{"result_type": "full", "enable_nonstream": false, "enable_itn": false}} + a["asr"] = map[string]any{"provider_ref": "volcengine", "model": "example-asr", "params": map[string]any{"result_type": "full", "enable_nonstream": false, "enable_itn": false, "sample_rate": 16000}} if mode == "full_ai" { a["llm"] = map[string]any{"provider_ref": "openai_compatible", "model": "example-chat", "params": map[string]any{"temperature": 0, "max_tokens": 256}} a["tts"] = map[string]any{"provider_ref": "ali_bailian", "model": "qwen3-tts-flash", "voice": "Cherry", "params": map[string]any{"format": "pcm", "sample_rate": 16000, "rate": 1, "language": "Chinese"}} diff --git a/internal/ai/params_precedence_test.go b/internal/ai/params_precedence_test.go index fd32023..51ade71 100644 --- a/internal/ai/params_precedence_test.go +++ b/internal/ai/params_precedence_test.go @@ -20,7 +20,7 @@ func TestTaskFieldsWinOverSameDifferentAndNullParamValues(t *testing.T) { } else if name == "null" { asrModel, llmModel, voice = nil, nil, nil } - agent["asr"].(map[string]any)["params"] = map[string]any{"model_name": asrModel, "reqid": "ignored-id", "sequence": 99, "opaque": false} + agent["asr"].(map[string]any)["params"] = map[string]any{"sample_rate": 16000, "model_name": asrModel, "reqid": "ignored-id", "sequence": 99, "opaque": false} agent["llm"].(map[string]any)["params"] = map[string]any{"model": llmModel, "messages": "ignored-messages", "opaque": false} agent["tts"].(map[string]any)["params"] = map[string]any{"voice": voice, "opaque": false} }) diff --git a/internal/ai/pipeline.go b/internal/ai/pipeline.go index 357d555..75851fe 100644 --- a/internal/ai/pipeline.go +++ b/internal/ai/pipeline.go @@ -138,8 +138,15 @@ func (b Binding) Recognize(ctx context.Context, pcm16 []byte) (string, error) { if len(pcm16) == 0 || len(pcm16)%2 != 0 { return "", errors.New("ASR requires nonempty signed 16-bit PCM") } + asrPCM16, err := prepareASRPCM16(pcm16, b.ASR.SampleRate) + if err != nil { + return "", fmt.Errorf("prepare ASR audio: %w", err) + } + if b.ASR.SampleRate != internalPCM16SampleRate { + slog.Debug("resampled ASR input", "source_sample_rate", internalPCM16SampleRate, "target_sample_rate", b.ASR.SampleRate, "input_bytes", len(pcm16), "output_bytes", len(asrPCM16)) + } if b.ASR.Provider.Code == "ali_bailian" { - return recognizeBailian(ctx, b.ASR, pcm16) + return recognizeBailian(ctx, b.ASR, asrPCM16) } u, err := url.Parse(b.ASR.Provider.Endpoint) if err != nil || u.Host == "" || (u.Scheme != "wss" && u.Scheme != "ws") { @@ -157,7 +164,7 @@ func (b Binding) Recognize(ctx context.Context, pcm16 []byte) (string, error) { delete(parameters, key) } request := doubaospeech.ASRV2Config{ - Format: doubaospeech.FormatPCMS16LE, SampleRate: 16000, Channel: 1, Bits: 16, + Format: doubaospeech.FormatPCMS16LE, SampleRate: doubaospeech.SampleRate(b.ASR.SampleRate), Channel: 1, Bits: 16, Request: &doubaospeech.ASRV2RequestConfig{ModelName: b.ASR.Model}, Parameters: parameters, } @@ -166,7 +173,7 @@ func (b Binding) Recognize(ctx context.Context, pcm16 []byte) (string, error) { return "", err } defer session.Close() - if err := session.SendAudio(ctx, pcm16, true); err != nil { + if err := session.SendAudio(ctx, asrPCM16, true); err != nil { return "", err } var final finalASRAccumulator diff --git a/internal/ai/protocol_runtime_test.go b/internal/ai/protocol_runtime_test.go index 4ded167..29aa0ae 100644 --- a/internal/ai/protocol_runtime_test.go +++ b/internal/ai/protocol_runtime_test.go @@ -64,14 +64,16 @@ func TestTaskTTSProtocolForwardsOpaqueModelVoiceAndParams(t *testing.T) { } } -func TestTaskASRForwardsParamsWithoutRewritingAudio(t *testing.T) { +func TestTaskASRResamplesToConfiguredRateAndForwardsParams(t *testing.T) { task, ps := protocolTask(t, TTSProtocolDashScopeTask) + task = changeCurrentAgent(t, task, func(agent map[string]any) { + agent["asr"].(map[string]any)["params"] = map[string]any{"sample_rate": 8000, "opaque": "unchanged", "nullable": nil} + }) b, e := Bind(task, ps) if e != nil { t.Fatal(e) } - b.ASR.Params = map[string]json.RawMessage{"sample_rate": json.RawMessage("24000"), "opaque": json.RawMessage(`"unchanged"`), "nullable": json.RawMessage("null")} - pcm := []byte{1, 0, 2, 0} + pcm := []byte{1, 0, 2, 0, 3, 0, 4, 0} captured := make(chan []byte, 1) s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { c, e := websocket.Accept(w, r, nil) @@ -84,7 +86,7 @@ func TestTaskASRForwardsParamsWithoutRewritingAudio(t *testing.T) { defer cancel() cmd := mockRead(t, c, ctx) p := cmd.Payload["parameters"].(map[string]any) - if len(p) != 3 || p["sample_rate"] != float64(24000) || p["opaque"] != "unchanged" || p["nullable"] != nil { + if len(p) != 3 || p["sample_rate"] != float64(8000) || p["opaque"] != "unchanged" || p["nullable"] != nil { t.Error("ASR params rewritten") } mockEvent(c, ctx, cmd.Header.TaskID, "task-started", nil) @@ -106,7 +108,7 @@ func TestTaskASRForwardsParamsWithoutRewritingAudio(t *testing.T) { if e != nil || text != "Final." { t.Fatal(e) } - if !bytes.Equal(<-captured, pcm) { - t.Fatal("input media rewritten from opaque params") + if !bytes.Equal(<-captured, []byte{1, 0, 3, 0}) { + t.Fatal("ASR audio was not resampled to the configured sample rate") } } diff --git a/internal/ai/schema_params_test.go b/internal/ai/schema_params_test.go index f325130..e63c43b 100644 --- a/internal/ai/schema_params_test.go +++ b/internal/ai/schema_params_test.go @@ -9,7 +9,7 @@ import ( func TestNewSchemaBindsCodeReferenceAndOpaqueParams(t *testing.T) { task, providers := currentFixture(t, "full_ai") task = changeCurrentAgent(t, task, func(a map[string]any) { - a["asr"].(map[string]any)["params"] = map[string]any{"unknown_flag": false, "nullable": nil, "opaque": "vendor-value"} + a["asr"].(map[string]any)["params"] = map[string]any{"sample_rate": 16000, "unknown_flag": false, "nullable": nil, "opaque": "vendor-value"} a["llm"].(map[string]any)["params"] = map[string]any{"temperature": 0, "unknown_limit": json.Number("9007199254740993")} a["tts"].(map[string]any)["params"] = map[string]any{"rate": 9.5, "language": "vendor-only"} a["prompt"].(map[string]any)["text"] = strings.Repeat("长", 40000) diff --git a/internal/audio/pcm16_test.go b/internal/audio/pcm16_test.go new file mode 100644 index 0000000..e8b4c85 --- /dev/null +++ b/internal/audio/pcm16_test.go @@ -0,0 +1,31 @@ +package audio + +import ( + "bytes" + "testing" +) + +func TestResamplePCM16BetweenTelephonyRates(t *testing.T) { + pcm8k := []byte{1, 0, 3, 0} + pcm16k := ResamplePCM16(pcm8k, 8000, 16000) + if want := []byte{1, 0, 2, 0, 3, 0, 3, 0}; !bytes.Equal(pcm16k, want) { + t.Fatalf("8 kHz to 16 kHz = %v, want %v", pcm16k, want) + } + + downsampled := ResamplePCM16(pcm16k, 16000, 8000) + if !bytes.Equal(downsampled, pcm8k) { + t.Fatalf("16 kHz to 8 kHz = %v, want %v", downsampled, pcm8k) + } +} + +func TestResamplePCM16AtSameRateReturnsIndependentCopy(t *testing.T) { + input := []byte{1, 0, 2, 0} + got := ResamplePCM16(input, 16000, 16000) + if !bytes.Equal(got, input) { + t.Fatalf("same-rate PCM16 = %v, want %v", got, input) + } + got[0] = 9 + if input[0] != 1 { + t.Fatal("same-rate resampling returned the original buffer") + } +} diff --git a/internal/dispatcher/ai_test.go b/internal/dispatcher/ai_test.go index abd1155..4ea643f 100644 --- a/internal/dispatcher/ai_test.go +++ b/internal/dispatcher/ai_test.go @@ -16,7 +16,7 @@ import ( func malformedASRTask(t *testing.T) []byte { t.Helper() original := configExample(t, "config-read-task-asr") - modified := bytes.Replace(original, []byte(`"params":{}`), []byte(`"params":{"nested":{"unexpected":true}}`), 1) + modified := bytes.Replace(original, []byte(`"params":{"sample_rate":16000}`), []byte(`"params":{"sample_rate":16000,"nested":{"unexpected":true}}`), 1) if bytes.Equal(original, modified) { t.Fatal("ASR fixture did not contain params") } diff --git a/internal/rpc/approved_snapshot_test.go b/internal/rpc/approved_snapshot_test.go index eccc739..1813470 100644 --- a/internal/rpc/approved_snapshot_test.go +++ b/internal/rpc/approved_snapshot_test.go @@ -17,7 +17,7 @@ import ( func TestApprovedAgentReceivesFrozenASROnlySnapshot(t *testing.T) { now := time.Date(2026, 9, 20, 10, 0, 0, 0, time.UTC) req := approvedTestRequest(t, now) - req.TaskConfigJson = bytes.Replace(req.TaskConfigJson, []byte(`"params":{}`), []byte(`"params":{"reqid":"ignored-id","sequence":99,"model_name":"ignored-model","opaque":false}`), 1) + req.TaskConfigJson = bytes.Replace(req.TaskConfigJson, []byte(`"params":{"sample_rate":16000}`), []byte(`"params":{"sample_rate":16000,"reqid":"ignored-id","sequence":99,"model_name":"ignored-model","opaque":false}`), 1) hash, err := configread.ExecutionBindingSHA256(req.TaskConfigJson, req.ProvidersJson, req.SipRevision) if err != nil { t.Fatal(err) @@ -50,7 +50,7 @@ func TestApprovedAgentReceivesFrozenASROnlySnapshot(t *testing.T) { func TestApprovedAgentRejectsMalformedParamsWithoutPersistingAttempt(t *testing.T) { now := time.Date(2026, 9, 20, 10, 0, 0, 0, time.UTC) req := approvedTestRequest(t, now) - req.TaskConfigJson = bytes.Replace(req.TaskConfigJson, []byte(`"params":{}`), []byte(`"params":{"nested":{"unexpected":true}}`), 1) + req.TaskConfigJson = bytes.Replace(req.TaskConfigJson, []byte(`"params":{"sample_rate":16000}`), []byte(`"params":{"sample_rate":16000,"nested":{"unexpected":true}}`), 1) if !strings.Contains(string(req.TaskConfigJson), `"nested":{"unexpected":true}`) { t.Fatal("malformed params were not inserted into fixture") } diff --git a/internal/store/snapshot_test.go b/internal/store/snapshot_test.go index f0ed7a5..ad616e5 100644 --- a/internal/store/snapshot_test.go +++ b/internal/store/snapshot_test.go @@ -28,7 +28,7 @@ func TestStoreSnapshotPreservesAgentAndAllowsNewSIPRevision(t *testing.T) { if err != nil { t.Fatal(err) } - if loaded.SIP.Revision != 8 || loaded.Quota.MaxConcurrentCalls != 3 || loaded.Providers["volcengine"].Credential != "sk-******" || !strings.Contains(string(loaded.Task.Agent.Raw), `"params":{}`) { + if loaded.SIP.Revision != 8 || loaded.Quota.MaxConcurrentCalls != 3 || loaded.Providers["volcengine"].Credential != "sk-******" || !strings.Contains(string(loaded.Task.Agent.Raw), `"params":{"sample_rate":16000}`) { t.Fatalf("binding was lost across restart: SIP=%d quota=%d agent=%s", loaded.SIP.Revision, loaded.Quota.MaxConcurrentCalls, loaded.Task.Agent.Raw) } snapshot.SIP.Revision = 9