Resample ASR audio to task sample rate
This commit is contained in:
+1
-1
Submodule contracts/schema updated: bfa3380a5e...d46d77d278
+8
-7
@@ -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` 发起新呼叫。
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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():
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"}}
|
||||
|
||||
@@ -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}
|
||||
})
|
||||
|
||||
+10
-3
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user