From 05f37242cab3c5e10c4b6c300c14120344b09613 Mon Sep 17 00:00:00 2001 From: Rogee Date: Mon, 12 Oct 2026 17:06:08 +0800 Subject: [PATCH] feat(ai): default Bailian TTS to PCM and decode WAV --- contracts/schema | 2 +- docs/TODO.md | 4 +- go.mod | 3 + go.sum | 6 + internal/ai/bailian_models_test.go | 19 +- internal/ai/bailian_realtime.go | 14 +- internal/ai/bailian_tts_formats_test.go | 152 ++++++++++++++++ internal/ai/binding_test.go | 11 +- internal/ai/tts_audio.go | 27 ++- internal/ai/tts_audio_defaults_test.go | 63 +++++++ internal/audio/wav.go | 226 ++++++++++++++++++++++++ internal/audio/wav_test.go | 132 ++++++++++++++ 12 files changed, 641 insertions(+), 18 deletions(-) create mode 100644 internal/ai/bailian_tts_formats_test.go create mode 100644 internal/ai/tts_audio_defaults_test.go create mode 100644 internal/audio/wav.go create mode 100644 internal/audio/wav_test.go diff --git a/contracts/schema b/contracts/schema index 3158acc..940d922 160000 --- a/contracts/schema +++ b/contracts/schema @@ -1 +1 @@ -Subproject commit 3158acc009ef260f1a9c5219db52a14a5f73565b +Subproject commit 940d9224fd2270791bf2be8d28046f65f7a853f3 diff --git a/docs/TODO.md b/docs/TODO.md index 7a5594c..f0507c5 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -7,9 +7,9 @@ - [x] 缺失、非整数、超出 8–48 kHz 范围或转换后没有完整 PCM16 样本时明确失败;不改写参数、不换率、不换服务商。 - [x] 复用已有 PCM16 转换能力;TDD 覆盖 16→8 kHz ASR、8↔16 kHz 媒体转换、同率直通、分块收发和错误输入。 - [ ] 在获批的隔离 ASR-only 与 full-AI 任务上完成真实 ASR 验证;本地测试不能代替供应商和通话验收。 -- [x] Bailian task TTS 要求明确输出格式;支持 PCM16 采样率转换和 MP3 解码/下混/重采样为内部 PCM16,未知格式或无法解码的数据明确失败。 +- [x] Bailian task TTS 在 format 缺省时默认原始 PCM16/16000 Hz,并显式发送到供应商;支持 PCM16 归一化、MP3 解码及 PCM WAV 解码/下混/重采样为内部单声道 16 kHz PCM16。未知格式、损坏音频或不支持的 WAV 子格式仍明确失败。 - [ ] 在隔离 full-AI 任务上核对真实 provider 返回格式/采样率,并完成 TTS、LLM、ASR 全链路验证。 唯一规则来源是共享合同的 [业务规范](../contracts/schema/saas-dispatcher.md) 与 [任务 Schema](../contracts/schema/http-task-detail.schema.json),这里仅记录未完成工作,不维护第二份字段定义。 -**当前边界:** 实现位于本地分支 `fix/asr-sample-rate-20261012`,尚未部署。Dispatcher 当前只有 full-AI 任务,没有 ASR-only 测试任务。测试管理页通过 NatApp 隧道访问失败(目标 `192.168.0.3:8888` 不可用),因此尚未创建测试任务,也未对 `15003164745` 或 `15830461047` 发起新呼叫。 +**当前边界:** ASR 采样率和 PCM/MP3 版本 `e522037` 已合入 main 并部署到 `server.sip`,但 Dispatcher 未就绪:SaaS 任务 6 显式配置 `TTS format=wav`,旧版本因不支持 WAV 而自动重启。当前 WAV/默认值修复仍在 `feat/bailian-wav-default-20261012`,尚未合入或部署。未重置数据、创建任务或拨号;ASR-only 测试任务及管理隧道状态仍待重新核验。 diff --git a/go.mod b/go.mod index dfa91be..69e2132 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,8 @@ require ( github.com/coder/websocket v1.8.15 github.com/cyberphone/json-canonicalization v0.0.0-20241213102144-19d51d7fe467 github.com/emiago/sipgo v1.6.0 + github.com/go-audio/audio v1.0.0 + github.com/go-audio/wav v1.1.0 github.com/google/uuid v1.6.0 github.com/gorilla/websocket v1.5.3 github.com/hajimehoshi/go-mp3 v0.3.4 @@ -32,6 +34,7 @@ require ( require ( github.com/dustin/go-humanize v1.0.1 // indirect github.com/ebitengine/purego v0.10.2 // indirect + github.com/go-audio/riff v1.0.0 // indirect github.com/go-ole/go-ole v1.2.6 // indirect github.com/go-stack/stack v1.8.0 // indirect github.com/gobwas/httphead v0.1.0 // indirect diff --git a/go.sum b/go.sum index bbdbf92..be2d51b 100644 --- a/go.sum +++ b/go.sum @@ -23,6 +23,12 @@ github.com/ebitengine/purego v0.10.2 h1:W809HbnvzAxgdm+aOvlSekrM16wGCdT/e76+9tS7 github.com/ebitengine/purego v0.10.2/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/emiago/sipgo v1.6.0 h1:6EuOP7c6f0VRatKYTPEYNezt4hslBEsaCzZZOhT2n3s= github.com/emiago/sipgo v1.6.0/go.mod h1:DuwAxBZhKMqIzQFPGZb1MVAGU6Wuxj64oTOhd5dx/FY= +github.com/go-audio/audio v1.0.0 h1:zS9vebldgbQqktK4H0lUqWrG8P0NxCJVqcj7ZpNnwd4= +github.com/go-audio/audio v1.0.0/go.mod h1:6uAu0+H2lHkwdGsAY+j2wHPNPpPoeg5AaEFh9FlA+Zs= +github.com/go-audio/riff v1.0.0 h1:d8iCGbDvox9BfLagY94fBynxSPHO80LmZCaOsmKxokA= +github.com/go-audio/riff v1.0.0/go.mod h1:l3cQwc85y79NQFCRB7TiPoNiaijp6q8Z0Uv38rVG498= +github.com/go-audio/wav v1.1.0 h1:jQgLtbqBzY7G+BM8fXF7AHUk1uHUviWS4X39d5rsL2g= +github.com/go-audio/wav v1.1.0/go.mod h1:mpe9qfwbScEbkd8uybLuIpTgHyrISw/OTuvjUW2iGtE= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= diff --git a/internal/ai/bailian_models_test.go b/internal/ai/bailian_models_test.go index 59eba33..a782758 100644 --- a/internal/ai/bailian_models_test.go +++ b/internal/ai/bailian_models_test.go @@ -5,6 +5,10 @@ import ( ) func bailianFixture(t *testing.T) Binding { + return bailianFixtureWithTTSParams(t, map[string]any{"rate": 1.25, "sample_rate": 16000, "format": "pcm"}) +} + +func bailianFixtureWithTTSParams(t *testing.T, params map[string]any) Binding { t.Helper() task, ps := currentFixture(t, "full_ai") task = changeCurrentAgent(t, task, func(a map[string]any) { @@ -16,7 +20,7 @@ func bailianFixture(t *testing.T) Binding { 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" - a["tts"].(map[string]any)["params"] = map[string]any{"rate": 1.25, "sample_rate": 16000, "format": "pcm"} + a["tts"].(map[string]any)["params"] = params }) p := ps["ali_bailian"] p.Credential = "mock-key" @@ -33,3 +37,16 @@ func TestTaskModelsShareConnectionOnlyProvider(t *testing.T) { t.Fatal("shared connection or opaque params lost") } } + +func TestBailianTTSDefaultsMissingOutputSettings(t *testing.T) { + b := bailianFixtureWithTTSParams(t, map[string]any{"rate": 1.25}) + if b.TTS.OutputFormat != ttsOutputFormatPCM || b.TTS.OutputSampleRate != internalPCM16SampleRate { + t.Fatalf("TTS defaults = (%q, %d), want (%q, %d)", b.TTS.OutputFormat, b.TTS.OutputSampleRate, ttsOutputFormatPCM, internalPCM16SampleRate) + } + if _, ok := b.TTS.Params["format"]; ok { + t.Fatal("default format must not rewrite the approved params snapshot") + } + if _, ok := b.TTS.Params["sample_rate"]; ok { + t.Fatal("default sample rate must not rewrite the approved params snapshot") + } +} diff --git a/internal/ai/bailian_realtime.go b/internal/ai/bailian_realtime.go index 447f6b3..0c6dbc9 100644 --- a/internal/ai/bailian_realtime.go +++ b/internal/ai/bailian_realtime.go @@ -206,11 +206,23 @@ func synthesizeBailianTaskTTS(ctx context.Context, approved TTSConfig, text stri return nil, errors.New("unapproved Bailian task TTS settings or empty text") } parameters := cloneParams(approved.Params) + format, err := json.Marshal(approved.OutputFormat) + if err != nil { + return nil, errors.New("encode approved Bailian TTS output format") + } + parameters["format"] = format + if approved.OutputSampleRate > 0 { + sampleRate, err := json.Marshal(approved.OutputSampleRate) + if err != nil { + return nil, errors.New("encode approved Bailian TTS sample rate") + } + parameters["sample_rate"] = sampleRate + } parameters["voice"], _ = json.Marshal(approved.Voice) payload := map[string]any{"task_group": "audio", "task": "tts", "function": "SpeechSynthesizer", "model": approved.Model, "parameters": parameters, "input": map[string]any{}} var audio []byte binaryFrames := 0 - err := runBailianTask(ctx, approved.Provider.WSEndpoint, approved.Provider.Credential, payload, func(t bailianTask) error { + err = runBailianTask(ctx, approved.Provider.WSEndpoint, approved.Provider.Credential, payload, func(t bailianTask) error { if err := t.command(ctx, "continue-task", map[string]any{"input": map[string]any{"text": text}}); err != nil { return err } diff --git a/internal/ai/bailian_tts_formats_test.go b/internal/ai/bailian_tts_formats_test.go new file mode 100644 index 0000000..b6d6c2e --- /dev/null +++ b/internal/ai/bailian_tts_formats_test.go @@ -0,0 +1,152 @@ +package ai + +import ( + "bytes" + "context" + "encoding/binary" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/coder/websocket" + + "git.ipao.vip/rogee/go-sip/internal/audio" +) + +func TestCosyVoiceDefaultsMissingFormatToPCM16AtInternalRate(t *testing.T) { + binding := bailianFixtureWithTTSParams(t, map[string]any{"rate": 1.25}) + pcm := []byte{0x10, 0x00, 0x20, 0x00, 0x30, 0x00, 0x40, 0x00} + + got := runMockCosyVoiceTTS(t, binding, pcm, map[string]any{ + "rate": float64(1.25), + "format": ttsOutputFormatPCM, + "sample_rate": float64(internalPCM16SampleRate), + "voice": "longanyang", + }) + if !bytes.Equal(got, pcm) { + t.Fatalf("default PCM output = %v, want unchanged %v", got, pcm) + } +} + +func TestCosyVoiceWAVTaskReturnsNormalizedPCM16(t *testing.T) { + binding := bailianFixtureWithTTSParams(t, map[string]any{"format": "wav", "sample_rate": 8000}) + samples := []int16{1000, -1000, 2000, -2000} + encoded := testTTSWAV(8000, samples) + want := audio.ResamplePCM16(testTTSPCM16(samples), 8000, internalPCM16SampleRate) + + got := runMockCosyVoiceTTS(t, binding, encoded, map[string]any{ + "format": ttsOutputFormatWAV, + "sample_rate": float64(8000), + "voice": "longanyang", + }) + if !bytes.Equal(got, want) { + t.Fatalf("WAV output = %v, want normalized PCM16 %v", got, want) + } +} + +func TestCosyVoiceWAVDerivesRateFromContainerWhenUnset(t *testing.T) { + binding := bailianFixtureWithTTSParams(t, map[string]any{"format": "wav"}) + samples := []int16{1000, -1000} + encoded := testTTSWAV(8000, samples) + want := audio.ResamplePCM16(testTTSPCM16(samples), 8000, internalPCM16SampleRate) + + got := runMockCosyVoiceTTS(t, binding, encoded, map[string]any{ + "format": ttsOutputFormatWAV, + "voice": "longanyang", + }) + if !bytes.Equal(got, want) { + t.Fatalf("WAV output = %v, want normalized PCM16 %v", got, want) + } +} + +func TestNormalizeBailianWAVRejectsConfiguredSampleRateMismatch(t *testing.T) { + binding := bailianFixtureWithTTSParams(t, map[string]any{"format": "wav", "sample_rate": 16000}) + encoded := testTTSWAV(8000, []int16{1000, -1000}) + if _, _, err := normalizeBailianTTSOutput(*binding.TTS, encoded); err == nil { + t.Fatal("WAV sample rate mismatch was accepted") + } +} + +func runMockCosyVoiceTTS(t *testing.T, binding Binding, encoded []byte, wantParameters map[string]any) []byte { + t.Helper() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + conn, err := websocket.Accept(w, r, nil) + if err != nil { + t.Error(err) + return + } + defer conn.CloseNow() + ctx, cancel := context.WithTimeout(r.Context(), time.Second) + defer cancel() + + kind, raw, err := conn.Read(ctx) + if err != nil || kind != websocket.MessageText { + t.Errorf("missing TTS start payload: kind=%v err=%v", kind, err) + return + } + var command mockCommand + if err := json.Unmarshal(raw, &command); err != nil { + t.Error(err) + return + } + parameters, ok := command.Payload["parameters"].(map[string]any) + if !ok { + t.Errorf("TTS parameters missing: %#v", command.Payload["parameters"]) + return + } + for key, want := range wantParameters { + if got := parameters[key]; got != want { + t.Errorf("TTS parameter %q = %#v, want %#v", key, got, want) + } + } + mockEvent(conn, ctx, command.Header.TaskID, "task-started", nil) + mockRead(t, conn, ctx) + mockRead(t, conn, ctx) + if err := conn.Write(ctx, websocket.MessageBinary, encoded); err != nil { + t.Error(err) + return + } + mockEvent(conn, ctx, command.Header.TaskID, "task-finished", nil) + })) + defer server.Close() + binding.TTS.Provider.WSEndpoint = "ws" + strings.TrimPrefix(server.URL, "http") + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + got, err := binding.Synthesize(ctx, "TTS format normalization fixture.") + if err != nil { + t.Fatal(err) + } + return got +} + +func testTTSWAV(sampleRate uint32, samples []int16) []byte { + pcm := testTTSPCM16(samples) + data := make([]byte, 44+len(pcm)) + copy(data[0:4], "RIFF") + binary.LittleEndian.PutUint32(data[4:8], uint32(len(data)-8)) + copy(data[8:12], "WAVE") + copy(data[12:16], "fmt ") + binary.LittleEndian.PutUint32(data[16:20], 16) + binary.LittleEndian.PutUint16(data[20:22], 1) + binary.LittleEndian.PutUint16(data[22:24], 1) + binary.LittleEndian.PutUint32(data[24:28], sampleRate) + binary.LittleEndian.PutUint32(data[28:32], sampleRate*2) + binary.LittleEndian.PutUint16(data[32:34], 2) + binary.LittleEndian.PutUint16(data[34:36], 16) + copy(data[36:40], "data") + binary.LittleEndian.PutUint32(data[40:44], uint32(len(pcm))) + copy(data[44:], pcm) + return data +} + +func testTTSPCM16(samples []int16) []byte { + pcm := make([]byte, len(samples)*2) + for i, sample := range samples { + binary.LittleEndian.PutUint16(pcm[i*2:], uint16(sample)) + } + return pcm +} diff --git a/internal/ai/binding_test.go b/internal/ai/binding_test.go index 9f895f7..4371834 100644 --- a/internal/ai/binding_test.go +++ b/internal/ai/binding_test.go @@ -76,7 +76,7 @@ func TestBindCurrentASROnlyDoesNotBindOtherProviders(t *testing.T) { t.Fatal("ASR-only inherited another provider", err) } } -func TestBindRequiresSupportedBailianTTSOutputFormat(t *testing.T) { +func TestBindBailianTTSOutputSettings(t *testing.T) { tests := []struct { name string params map[string]any @@ -84,13 +84,16 @@ func TestBindRequiresSupportedBailianTTSOutputFormat(t *testing.T) { wantFormat string wantRate int }{ - {name: "missing format", params: map[string]any{"sample_rate": 16000}, wantErr: true}, - {name: "PCM missing sample rate", params: map[string]any{"format": "pcm"}, wantErr: true}, - {name: "unsupported format", params: map[string]any{"format": "wav", "sample_rate": 16000}, wantErr: true}, + {name: "missing format defaults to PCM at configured rate", params: map[string]any{"sample_rate": 16000}, wantFormat: "pcm", wantRate: 16000}, + {name: "missing settings defaults to internal PCM16", params: map[string]any{}, wantFormat: "pcm", wantRate: internalPCM16SampleRate}, + {name: "PCM missing sample rate defaults to internal rate", params: map[string]any{"format": "pcm"}, wantFormat: "pcm", wantRate: internalPCM16SampleRate}, + {name: "unsupported format", params: map[string]any{"format": "ogg"}, wantErr: true}, {name: "invalid PCM rate", params: map[string]any{"format": "pcm", "sample_rate": 1600}, wantErr: true}, {name: "PCM", params: map[string]any{"format": "pcm", "sample_rate": 16000}, wantFormat: "pcm", wantRate: 16000}, {name: "MP3", params: map[string]any{"format": "mp3", "sample_rate": 16000}, wantFormat: "mp3", wantRate: 16000}, {name: "MP3 rate from bitstream", params: map[string]any{"format": "mp3"}, wantFormat: "mp3"}, + {name: "WAV", params: map[string]any{"format": "wav", "sample_rate": 8000}, wantFormat: "wav", wantRate: 8000}, + {name: "WAV rate from container", params: map[string]any{"format": "wav"}, wantFormat: "wav"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/internal/ai/tts_audio.go b/internal/ai/tts_audio.go index 6827d03..3c7159d 100644 --- a/internal/ai/tts_audio.go +++ b/internal/ai/tts_audio.go @@ -11,25 +11,24 @@ import ( const ( ttsOutputFormatPCM = "pcm" ttsOutputFormatMP3 = "mp3" + ttsOutputFormatWAV = "wav" ) func parseBailianTTSOutputSettings(params map[string]json.RawMessage) (string, int, error) { - rawFormat, ok := params["format"] - if !ok { - return "", 0, errors.New("TTS params.format is required") + format := ttsOutputFormatPCM + if rawFormat, configured := params["format"]; configured { + if err := json.Unmarshal(rawFormat, &format); err != nil || format == "" { + return "", 0, errors.New("TTS params.format must be a nonempty string") + } } - var format string - if err := json.Unmarshal(rawFormat, &format); err != nil || format == "" { - return "", 0, errors.New("TTS params.format must be a nonempty string") - } - if format != ttsOutputFormatPCM && format != ttsOutputFormatMP3 { + if format != ttsOutputFormatPCM && format != ttsOutputFormatMP3 && format != ttsOutputFormatWAV { return "", 0, fmt.Errorf("unsupported Bailian TTS output format %q", format) } rawRate, hasRate := params["sample_rate"] if !hasRate { if format == ttsOutputFormatPCM { - return "", 0, errors.New("TTS PCM output requires params.sample_rate") + return format, internalPCM16SampleRate, nil } return format, 0, nil } @@ -73,6 +72,16 @@ func normalizeBailianTTSOutput(approved TTSConfig, encoded []byte) ([]byte, int, } return pcm16, sourceRate, nil + case ttsOutputFormatWAV: + pcm16, sourceRate, err := audio.DecodeWAVToPCM16Mono(encoded, internalPCM16SampleRate, maxBailianPCMBytes) + if err != nil { + return nil, 0, fmt.Errorf("decode TTS WAV: %w", err) + } + if approved.OutputSampleRate != 0 && approved.OutputSampleRate != sourceRate { + return nil, 0, fmt.Errorf("TTS WAV sample rate %d does not match requested %d Hz", sourceRate, approved.OutputSampleRate) + } + return pcm16, sourceRate, nil + default: return nil, 0, fmt.Errorf("unsupported Bailian TTS output format %q", approved.OutputFormat) } diff --git a/internal/ai/tts_audio_defaults_test.go b/internal/ai/tts_audio_defaults_test.go new file mode 100644 index 0000000..6b89968 --- /dev/null +++ b/internal/ai/tts_audio_defaults_test.go @@ -0,0 +1,63 @@ +package ai + +import ( + "encoding/json" + "testing" +) + +func TestParseBailianTTSOutputSettingsDefaults(t *testing.T) { + tests := []struct { + name string + params map[string]json.RawMessage + wantFormat string + wantRate int + wantErr bool + }{ + {name: "missing settings defaults to internal PCM16", wantFormat: ttsOutputFormatPCM, wantRate: internalPCM16SampleRate}, + { + name: "explicit PCM without rate defaults to internal rate", + params: map[string]json.RawMessage{"format": json.RawMessage(`"pcm"`)}, + wantFormat: ttsOutputFormatPCM, + wantRate: internalPCM16SampleRate, + }, + { + name: "missing format preserves configured PCM rate", + params: map[string]json.RawMessage{"sample_rate": json.RawMessage(`8000`)}, + wantFormat: ttsOutputFormatPCM, + wantRate: 8000, + }, + { + name: "explicit WAV derives source rate from container", + params: map[string]json.RawMessage{"format": json.RawMessage(`"wav"`)}, + wantFormat: ttsOutputFormatWAV, + }, + { + name: "explicit MP3 derives source rate from bitstream", + params: map[string]json.RawMessage{"format": json.RawMessage(`"mp3"`)}, + wantFormat: ttsOutputFormatMP3, + }, + { + name: "explicit unknown format remains invalid", + params: map[string]json.RawMessage{"format": json.RawMessage(`"ogg"`)}, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + format, rate, err := parseBailianTTSOutputSettings(tt.params) + if tt.wantErr { + if err == nil { + t.Fatal("invalid TTS settings were accepted") + } + return + } + if err != nil { + t.Fatal(err) + } + if format != tt.wantFormat || rate != tt.wantRate { + t.Fatalf("settings = (%q, %d), want (%q, %d)", format, rate, tt.wantFormat, tt.wantRate) + } + }) + } +} diff --git a/internal/audio/wav.go b/internal/audio/wav.go new file mode 100644 index 0000000..dcb9b7b --- /dev/null +++ b/internal/audio/wav.go @@ -0,0 +1,226 @@ +package audio + +import ( + "bytes" + "encoding/binary" + "errors" + "fmt" + "io" + + goaudio "github.com/go-audio/audio" + wavcodec "github.com/go-audio/wav" +) + +const ( + minWAVSampleRate = 8000 + maxWAVSampleRate = 48000 + wavPCMFormat = 1 +) + +type wavPCMLayout struct { + format uint16 + channels uint16 + sampleRate uint32 + byteRate uint32 + blockAlign uint16 + bitDepth uint16 + dataOffset int + dataSize int + riffEnd int +} + +// DecodeWAVToPCM16Mono decodes integer PCM RIFF/WAVE audio and normalizes it to +// mono PCM16 at targetRate. WAV metadata is validated before the decoder reads +// sample data so truncated chunks cannot be mistaken for completed audio. +func DecodeWAVToPCM16Mono(encoded []byte, targetRate, maxAudioBytes int) ([]byte, int, error) { + if len(encoded) == 0 { + return nil, 0, errors.New("WAV audio is empty") + } + if maxAudioBytes <= 0 || len(encoded) > maxAudioBytes { + return nil, 0, errors.New("WAV audio exceeds the configured bound") + } + if targetRate < minWAVSampleRate || targetRate > maxWAVSampleRate { + return nil, 0, errors.New("WAV target sample rate is outside the supported 8000–48000 Hz range") + } + + layout, err := inspectWAVPCMLayout(encoded) + if err != nil { + return nil, 0, err + } + decoder := wavcodec.NewDecoder(bytes.NewReader(encoded)) + decoder.ReadInfo() + if err := decoder.Err(); err != nil { + return nil, 0, fmt.Errorf("read WAV format: %w", err) + } + if decoder.WavAudioFormat != layout.format || decoder.NumChans != layout.channels || decoder.SampleRate != layout.sampleRate || decoder.BitDepth != layout.bitDepth { + return nil, 0, errors.New("WAV decoder metadata does not match the RIFF header") + } + if layout.format != wavPCMFormat { + return nil, 0, fmt.Errorf("unsupported WAV audio format %d", layout.format) + } + if layout.channels < 1 || layout.channels > 2 { + return nil, 0, fmt.Errorf("unsupported WAV channel count %d", layout.channels) + } + if layout.sampleRate < minWAVSampleRate || layout.sampleRate > maxWAVSampleRate { + return nil, 0, fmt.Errorf("WAV sample rate %d is outside the supported 8000–48000 Hz range", layout.sampleRate) + } + bytesPerSample := int(layout.bitDepth / 8) + if bytesPerSample == 0 || layout.blockAlign != layout.channels*uint16(bytesPerSample) || layout.byteRate != layout.sampleRate*uint32(layout.blockAlign) { + return nil, 0, errors.New("WAV block alignment does not match its PCM format") + } + if layout.bitDepth != 8 && layout.bitDepth != 16 && layout.bitDepth != 24 && layout.bitDepth != 32 { + return nil, 0, fmt.Errorf("unsupported WAV PCM bit depth %d", layout.bitDepth) + } + if layout.dataSize == 0 || layout.dataSize%int(layout.blockAlign) != 0 { + return nil, 0, errors.New("WAV data chunk contains no complete PCM frames") + } + + if err := decoder.FwdToPCM(); err != nil { + return nil, 0, fmt.Errorf("locate WAV data chunk: %w", err) + } + if err := decoder.Err(); err != nil { + return nil, 0, fmt.Errorf("read WAV data chunk: %w", err) + } + if decoder.PCMChunk == nil { + return nil, 0, errors.New("WAV data chunk is unavailable") + } + dataOffset, err := decoder.Seek(0, io.SeekCurrent) + if err != nil { + return nil, 0, fmt.Errorf("read WAV data offset: %w", err) + } + paddedDataSize := layout.dataSize + (layout.dataSize & 1) + if dataOffset != int64(layout.dataOffset) || decoder.PCMSize != paddedDataSize || int64(layout.dataOffset)+int64(layout.dataSize) > int64(layout.riffEnd) { + return nil, 0, errors.New("WAV decoder data boundaries do not match the RIFF chunks") + } + + expectedSamples := layout.dataSize / bytesPerSample + frames := expectedSamples / int(layout.channels) + outputSamples := int64(frames) * int64(targetRate) / int64(layout.sampleRate) + if outputSamples <= 0 { + return nil, 0, errors.New("WAV conversion produced no complete PCM16 samples") + } + if outputSamples > int64(maxAudioBytes/2) { + return nil, 0, errors.New("converted WAV PCM exceeds the configured audio bound") + } + + buffer := &goaudio.IntBuffer{Data: make([]int, 4096)} + mono := make([]byte, 0, frames*2) + decodedSamples := 0 + for { + n, err := decoder.PCMBuffer(buffer) + if err != nil { + return nil, 0, fmt.Errorf("decode WAV PCM samples: %w", err) + } + if n == 0 { + break + } + if n < 0 || n > len(buffer.Data) { + return nil, 0, errors.New("WAV decoder returned an invalid sample count") + } + remaining := expectedSamples - decodedSamples + if n > remaining { + extra := n - remaining + if extra != 1 || layout.dataSize%2 == 0 { + return nil, 0, errors.New("WAV decoder returned samples outside the data chunk") + } + n = remaining + } + if n%int(layout.channels) != 0 { + return nil, 0, errors.New("WAV data ends with an incomplete audio frame") + } + for i := 0; i < n; i += int(layout.channels) { + left, err := wavSampleToPCM16(buffer.Data[i], layout.bitDepth) + if err != nil { + return nil, 0, err + } + monoSample := left + if layout.channels == 2 { + right, err := wavSampleToPCM16(buffer.Data[i+1], layout.bitDepth) + if err != nil { + return nil, 0, err + } + monoSample = int16((int32(left) + int32(right)) / 2) + } + var sample [2]byte + binary.LittleEndian.PutUint16(sample[:], uint16(monoSample)) + mono = append(mono, sample[:]...) + } + decodedSamples += n + } + if decodedSamples != expectedSamples { + return nil, 0, fmt.Errorf("WAV data is incomplete: decoded %d of %d samples", decodedSamples, expectedSamples) + } + + pcm16 := ResamplePCM16(mono, int(layout.sampleRate), targetRate) + if len(pcm16) == 0 || len(pcm16)%2 != 0 || len(pcm16) > maxAudioBytes { + return nil, 0, errors.New("converted WAV PCM is empty, incomplete or oversized") + } + return pcm16, int(layout.sampleRate), nil +} + +func inspectWAVPCMLayout(encoded []byte) (wavPCMLayout, error) { + if len(encoded) < 12 || string(encoded[:4]) != "RIFF" || string(encoded[8:12]) != "WAVE" { + return wavPCMLayout{}, errors.New("audio is not a RIFF/WAVE file") + } + riffEnd64 := uint64(binary.LittleEndian.Uint32(encoded[4:8])) + 8 + if riffEnd64 < 12 || riffEnd64 > uint64(len(encoded)) { + return wavPCMLayout{}, errors.New("RIFF/WAVE length is invalid or truncated") + } + layout := wavPCMLayout{dataOffset: -1, riffEnd: int(riffEnd64)} + var sawFormat bool + for offset := 12; offset < layout.riffEnd; { + if layout.riffEnd-offset < 8 { + return wavPCMLayout{}, errors.New("RIFF/WAVE contains a truncated chunk header") + } + chunkID := string(encoded[offset : offset+4]) + chunkSize := uint64(binary.LittleEndian.Uint32(encoded[offset+4 : offset+8])) + chunkStart := uint64(offset + 8) + chunkEnd := chunkStart + chunkSize + if chunkEnd > uint64(layout.riffEnd) { + return wavPCMLayout{}, errors.New("RIFF/WAVE chunk exceeds the declared file length") + } + switch chunkID { + case "fmt ": + if sawFormat || chunkSize < 16 { + return wavPCMLayout{}, errors.New("RIFF/WAVE format chunk is missing or invalid") + } + layout.format = binary.LittleEndian.Uint16(encoded[chunkStart : chunkStart+2]) + layout.channels = binary.LittleEndian.Uint16(encoded[chunkStart+2 : chunkStart+4]) + layout.sampleRate = binary.LittleEndian.Uint32(encoded[chunkStart+4 : chunkStart+8]) + layout.byteRate = binary.LittleEndian.Uint32(encoded[chunkStart+8 : chunkStart+12]) + layout.blockAlign = binary.LittleEndian.Uint16(encoded[chunkStart+12 : chunkStart+14]) + layout.bitDepth = binary.LittleEndian.Uint16(encoded[chunkStart+14 : chunkStart+16]) + sawFormat = true + case "data": + if !sawFormat || layout.dataOffset >= 0 { + return wavPCMLayout{}, errors.New("RIFF/WAVE data chunk is missing, duplicated or invalid") + } + layout.dataOffset = int(chunkStart) + layout.dataSize = int(chunkSize) + } + paddedEnd := chunkEnd + (chunkSize & 1) + if paddedEnd > uint64(layout.riffEnd) { + return wavPCMLayout{}, errors.New("RIFF/WAVE chunk padding is truncated") + } + offset = int(paddedEnd) + } + if !sawFormat || layout.dataOffset < 0 { + return wavPCMLayout{}, errors.New("RIFF/WAVE is missing its format or data chunk") + } + return layout, nil +} + +func wavSampleToPCM16(sample int, bitDepth uint16) (int16, error) { + switch bitDepth { + case 8: + return int16((sample - 128) << 8), nil + case 16: + return int16(sample), nil + case 24: + return int16(sample >> 8), nil + case 32: + return int16(sample >> 16), nil + default: + return 0, fmt.Errorf("unsupported WAV PCM bit depth %d", bitDepth) + } +} diff --git a/internal/audio/wav_test.go b/internal/audio/wav_test.go new file mode 100644 index 0000000..efad50d --- /dev/null +++ b/internal/audio/wav_test.go @@ -0,0 +1,132 @@ +package audio + +import ( + "bytes" + "encoding/binary" + "testing" +) + +func TestDecodeWAVPCM16MonoToInternalFormat(t *testing.T) { + want := []int16{1000, -1000, 32767, -32768} + encoded := testPCM16WAV(16000, 1, want) + + got, rate, err := DecodeWAVToPCM16Mono(encoded, 16000, 1<<20) + if err != nil { + t.Fatal(err) + } + if rate != 16000 { + t.Fatalf("source rate = %d, want 16000", rate) + } + if !bytes.Equal(got, pcm16Bytes(want)) { + t.Fatalf("decoded PCM16 = %v, want %v", got, pcm16Bytes(want)) + } +} + +func TestDecodeWAVPCM16StereoDownmixesAndResamples(t *testing.T) { + encoded := testPCM16WAV(8000, 2, []int16{1000, 3000, 2000, 4000}) + want := []int16{2000, 2500, 3000, 3000} + + got, rate, err := DecodeWAVToPCM16Mono(encoded, 16000, 1<<20) + if err != nil { + t.Fatal(err) + } + if rate != 8000 { + t.Fatalf("source rate = %d, want 8000", rate) + } + if !bytes.Equal(got, pcm16Bytes(want)) { + t.Fatalf("decoded PCM16 = %v, want %v", got, pcm16Bytes(want)) + } +} + +func TestDecodeWAVRejectsMalformedOrUnsupportedAudio(t *testing.T) { + valid := testPCM16WAV(16000, 1, []int16{1000, -1000}) + truncated := append([]byte(nil), valid[:len(valid)-1]...) + wrongCodec := append([]byte(nil), valid...) + binary.LittleEndian.PutUint16(wrongCodec[20:22], 3) + wrongRate := testPCM16WAV(96000, 1, []int16{1000, -1000}) + badAlignment := testWAV(1, 16000, 1, 16, []byte{0x01}) + + for name, input := range map[string][]byte{ + "empty": nil, + "invalid header": []byte("not a wave file"), + "truncated data": truncated, + "unsupported codec": wrongCodec, + "unsupported rate": wrongRate, + "incomplete sample": badAlignment, + } { + t.Run(name, func(t *testing.T) { + if _, _, err := DecodeWAVToPCM16Mono(input, 16000, 1<<20); err == nil { + t.Fatal("invalid WAV was accepted") + } + }) + } +} + +func TestDecodeWAVPCMIntegerDepthsConvertToPCM16(t *testing.T) { + tests := []struct { + name string + bits uint16 + data []byte + want []int16 + }{ + {name: "8-bit unsigned", bits: 8, data: []byte{0, 128, 255}, want: []int16{-32768, 0, 32512}}, + {name: "24-bit signed", bits: 24, data: []byte{0, 0, 0x80, 0xff, 0xff, 0x7f}, want: []int16{-32768, 32767}}, + {name: "32-bit signed", bits: 32, data: []byte{0, 0, 0, 0x80, 0xff, 0xff, 0xff, 0x7f}, want: []int16{-32768, 32767}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + encoded := testWAV(1, 16000, 1, tt.bits, tt.data) + got, rate, err := DecodeWAVToPCM16Mono(encoded, 16000, 1<<20) + if err != nil { + t.Fatal(err) + } + if rate != 16000 || !bytes.Equal(got, pcm16Bytes(tt.want)) { + t.Fatalf("decoded rate=%d PCM16=%v, want rate=16000 PCM16=%v", rate, got, pcm16Bytes(tt.want)) + } + }) + } +} + +func TestDecodeWAVRejectsOutputOverBound(t *testing.T) { + samples := make([]int16, 100) + encoded := testPCM16WAV(8000, 1, samples) + if len(encoded) >= 300 { + t.Fatalf("test WAV is unexpectedly large: %d", len(encoded)) + } + if _, _, err := DecodeWAVToPCM16Mono(encoded, 16000, 300); err == nil { + t.Fatal("WAV output over the configured bound was accepted") + } +} + +func testPCM16WAV(sampleRate uint32, channels uint16, samples []int16) []byte { + pcm := pcm16Bytes(samples) + return testWAV(1, sampleRate, channels, 16, pcm) +} + +func pcm16Bytes(samples []int16) []byte { + data := make([]byte, len(samples)*2) + for i, sample := range samples { + binary.LittleEndian.PutUint16(data[i*2:], uint16(sample)) + } + return data +} + +func testWAV(format, sampleRate uint32, channels uint16, bits uint16, pcm []byte) []byte { + blockAlign := channels * (bits / 8) + data := make([]byte, 44+len(pcm)+(len(pcm)&1)) + copy(data[0:4], "RIFF") + binary.LittleEndian.PutUint32(data[4:8], uint32(len(data)-8)) + copy(data[8:12], "WAVE") + copy(data[12:16], "fmt ") + binary.LittleEndian.PutUint32(data[16:20], 16) + binary.LittleEndian.PutUint16(data[20:22], uint16(format)) + binary.LittleEndian.PutUint16(data[22:24], channels) + binary.LittleEndian.PutUint32(data[24:28], sampleRate) + binary.LittleEndian.PutUint32(data[28:32], sampleRate*uint32(blockAlign)) + binary.LittleEndian.PutUint16(data[32:34], blockAlign) + binary.LittleEndian.PutUint16(data[34:36], bits) + copy(data[36:40], "data") + binary.LittleEndian.PutUint32(data[40:44], uint32(len(pcm))) + copy(data[44:], pcm) + return data +}