From 9bf20cfbba14c2aed7d24762987b4d16ef52d637 Mon Sep 17 00:00:00 2001 From: Rogee Date: Tue, 13 Oct 2026 07:32:51 +0800 Subject: [PATCH] fix: normalize Bailian TTS streaming audio --- deploys/production/probe.go | 33 ++++------ deploys/production/probe_test.go | 40 ++++++++++++ docs/TODO.md | 9 ++- go.mod | 2 +- go.sum | 6 +- internal/ai/bailian_tts.go | 4 ++ internal/ai/bailian_tts_formats_test.go | 43 +++++++++++++ internal/ai/tts_audio.go | 58 +++++++++++++++++- internal/audio/mp3.go | 5 +- internal/audio/mp3_test.go | 26 ++++++++ .../audio/testdata/sine_8k_mono_mpeg25.mp3 | Bin 0 -> 3563 bytes internal/audio/wav.go | 52 +++++++++++++++- internal/audio/wav_test.go | 15 +++++ 13 files changed, 257 insertions(+), 36 deletions(-) create mode 100644 internal/audio/testdata/sine_8k_mono_mpeg25.mp3 diff --git a/deploys/production/probe.go b/deploys/production/probe.go index d6ac7dd..627b569 100644 --- a/deploys/production/probe.go +++ b/deploys/production/probe.go @@ -107,6 +107,12 @@ func rejected(r *report, err error) { visit(validation) } } + +func readProbeTasks(ctx context.Context, client *configread.Client) ([]configread.DiscoveredTask, error) { + tasks, _, err := client.ReadAllTasks(ctx) + return tasks, err +} + func check(in input) (r report) { ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) defer cancel() @@ -171,28 +177,11 @@ func check(in input) (r report) { return } r.Providers = len(providers) - var tasks []configread.DiscoveredTask - cursor := "" - seen := map[string]bool{} - for { - r.Phase = "tasks" - page, err := client.ReadTasks(ctx, cursor) - if err != nil { - rejected(&r, err) - return - } - for _, task := range page.Tasks { - if seen[task.TaskID] { - rejected(&r, errors.New("duplicate discovery task")) - return - } - seen[task.TaskID] = true - tasks = append(tasks, task) - } - if page.Cursor == "" { - break - } - cursor = page.Cursor + r.Phase = "tasks" + tasks, err := readProbeTasks(ctx, client) + if err != nil { + rejected(&r, err) + return } r.Tasks = len(tasks) for _, task := range tasks { diff --git a/deploys/production/probe_test.go b/deploys/production/probe_test.go index 4cc726e..b3db73e 100644 --- a/deploys/production/probe_test.go +++ b/deploys/production/probe_test.go @@ -1,6 +1,7 @@ package main import ( + "context" "crypto/tls" "crypto/x509" "encoding/json" @@ -8,13 +9,52 @@ import ( "fmt" "net" "net/http" + "net/http/httptest" "strings" + "sync/atomic" "syscall" "testing" + "time" + "git.ipao.vip/rogee/go-sip/internal/configread" "git.ipao.vip/rogee/go-sip/internal/contract" ) +func TestReadProbeTasksStopsOnEmptyPageWithUnchangedCursor(t *testing.T) { + const dispatcherID = "c046b893-8628-4589-ae50-619d049248a6" + var requests atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/internal/v1/dispatcher/tasks" { + t.Errorf("unexpected path %q", r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + switch r.URL.Query().Get("after") { + case "": + requests.Add(1) + _, _ = w.Write([]byte(`{"dispatcher_id":"` + dispatcherID + `","cursor":"115","tasks":[{"task_id":"6","tenant_id":1001,"status":"running","task_revision":1}]}`)) + case "115": + requests.Add(1) + _, _ = w.Write([]byte(`{"dispatcher_id":"` + dispatcherID + `","cursor":"115","tasks":[]}`)) + default: + t.Errorf("unexpected task cursor %q", r.URL.RawQuery) + } + })) + defer server.Close() + client, err := configread.NewClient(server.URL, dispatcherID, "test-secret", server.Client()) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + tasks, err := readProbeTasks(ctx, client) + if err != nil { + t.Fatal(err) + } + if len(tasks) != 1 || tasks[0].TaskID != "6" || requests.Load() != 2 { + t.Fatalf("tasks=%+v request_count=%d, want one task and two page requests", tasks, requests.Load()) + } +} + func TestEnvironmentRejectsMissingConfigurationWithoutNetworking(t *testing.T) { t.Setenv("DISPATCHER_ID", "") t.Setenv("AGENT_ID", "") diff --git a/docs/TODO.md b/docs/TODO.md index 2edefff..c63d6d5 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -7,9 +7,12 @@ - [x] 缺失、非整数、超出 8–48 kHz 范围或转换后没有完整 PCM16 样本时明确失败;不改写参数、不换率、不换服务商。 - [x] 复用已有 PCM16 转换能力;TDD 覆盖 16→8 kHz ASR、8↔16 kHz 媒体转换、同率直通、分块收发和错误输入。 - [ ] 在获批的隔离 ASR-only 与 full-AI 任务上完成真实 ASR 验证;本地测试不能代替供应商和通话验收。 -- [x] Bailian task TTS 在 format 缺省时默认原始 PCM16/16000 Hz,并显式发送到供应商;支持 PCM16 归一化、MP3 解码及 PCM WAV 解码/下混/重采样为内部单声道 16 kHz PCM16。未知格式、损坏音频或不支持的 WAV 子格式仍明确失败。 -- [ ] 在隔离 full-AI 任务上核对真实 provider 返回格式/采样率,并完成 TTS、LLM、ASR 全链路验证。 +- [x] Bailian task TTS 在 format 缺省时默认原始 PCM16/16000 Hz,并显式发送到供应商;支持 PCM16 归一化、有效 MP3 解码及 PCM WAV 解码/下混/重采样为内部单声道 16 kHz PCM16。MP3 解码器已换为支持 MPEG 2.5/8 kHz 的 `github.com/hujm2023/go-mp3/v2`;本地含 ID3 的 8 kHz 回归样本通过。解码初始化错误保留底层原因。 +- [x] WAV RIFF 声明长度异常时报告 RIFF/data 声明与实收长度。仅在完整 Bailian task 响应带有已观测到的精确流式占位长度、标准 44-byte `fmt `/`data` 头时,按实际收到字节修正长度,再交给严格 WAV 解码器验证;其它长度或头结构仍失败关闭。 +- [x] 新 MP3 decoder 已用一次真实 8 kHz MPEG 2.5 Bailian 响应验证,成功解码并归一化为内部单声道 16 kHz PCM16;不代表真实 SIP 通话或完整 AI 链路已验收。 +- [x] 修复后以 task 6/revision 1 获批执行一次真实 WAV TTS 验证:RIFF 声明 2,147,483,591 bytes / 实收 19,884 bytes,data 声明 2,147,483,547 bytes / 可用 19,840 bytes;精确占位长度修正通过,实际源采样率 8 kHz,归一化为内部 16 kHz PCM16(39,680 bytes,1,240 ms)。SQLite 任务快照 SHA-256 前后相同;覆盖只在进程内,没有拨号或 OSS 写入;音频未留存。 +- [ ] 在获批的隔离 ASR-only/full-AI 任务上完成真实 ASR、LLM 与通话链路验证;本地音频和 Mock 检查不能替代供应商、录音或 OSS 验收。 唯一规则来源是共享合同的 [业务规范](../contracts/schema/saas-dispatcher.md) 与 [任务 Schema](../contracts/schema/http-task-detail.schema.json),这里仅记录未完成工作,不维护第二份字段定义。 -**当前边界:** ASR/TTS 版本 `05f3724` 与共享合同 `940d922` 已部署到 `server.sip`,`--debug` 开启,未重置数据。当前 Asterisk、Agent、Dispatcher 均 enabled+active,活动通话为 0;状态仍为 `business_ready=false`、`dependency_state=saas_pending`,但最新 `waiting_reason` 为空。部署配置的 SaaS 基础地址与使用者提供的地址一致。Dispatcher 启动时读取 SIP 配置曾收到 HTTP 502(2026-10-12 17:11:36 +08:00);随后 17:11:47–17:11:49 的配置 GET 响应为 HTTP 200。尚无证据说明当前仍是同一 HTTP 故障,需 SaaS 方核对该时段的 `/internal/v1/dispatcher/sip` 服务/网关日志。任务快照、WAV 供应商响应及真实 AI 链路仍未核验。管理页面浏览器连接超时,未创建/修改任务,未请求真实 AI,未对 `15003164745` 或 `15830461047` 拨号;asr-only 测试任务仍待确认。 +**当前边界:** ASR/TTS 版本 `05f3724` 与共享合同 `940d922` 已部署到 `server.sip`,`--debug` 开启,未重置数据。当前 Asterisk、Agent、Dispatcher 均 enabled+active,活动通话为 0;状态仍为 `business_ready=false`、`dependency_state=saas_pending`,但最新 `waiting_reason` 为空。部署配置的 SaaS 基础地址与使用者提供的地址一致。Dispatcher 启动时读取 SIP 配置曾收到 HTTP 502(2026-10-12 17:11:36 +08:00);随后 17:11:47–17:11:49 的配置 GET 响应为 HTTP 200。尚无证据说明当前仍是同一 HTTP 故障,需 SaaS 方核对该时段的 `/internal/v1/dispatcher/sip` 服务/网关日志。任务快照未改。真实 PCM TTS/ASR 已按既有授权验证;MP3 与 WAV 新解码路径均各有一次真实 provider 响应通过,WAV 流式占位长度修正与 8 kHz→16 kHz PCM16 归一化已实测。部署探针曾因继续读取末尾空任务页而重复请求;现复用 `ReadAllTasks` 并以回归测试覆盖。修复后实机只读预检确认 MQ 登录/关闭、3 trunks、1 provider、6 tasks 均通过。诊断音频未留存。未拨号、未写 OSS。此次 MP3/WAV 改动仍仅在本地未提交、未推送、未部署;ASR-only/full-AI 通话链路仍待另行验收。管理页面浏览器连接超时,未创建/修改任务,管理页面浏览器连接超时,未创建/修改任务;此前按授权完成的真实 PCM/MP3/WAV TTS 请求仅用于格式诊断,当前不再发起真实 AI 请求。未对 `15003164745` 或 `15830461047` 拨号;ASR-only 测试任务仍待确认。 diff --git a/go.mod b/go.mod index 69e2132..5f400f8 100644 --- a/go.mod +++ b/go.mod @@ -13,7 +13,7 @@ require ( 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 + github.com/hujm2023/go-mp3/v2 v2.0.1 github.com/openai/openai-go/v3 v3.62.0 github.com/pion/rtp v1.10.5 github.com/pion/rtp/v2 v2.0.0 diff --git a/go.sum b/go.sum index be2d51b..fc3c2e5 100644 --- a/go.sum +++ b/go.sum @@ -56,11 +56,10 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= -github.com/hajimehoshi/go-mp3 v0.3.4 h1:NUP7pBYH8OguP4diaTZ9wJbUbk3tC0KlfzsEpWmYj68= -github.com/hajimehoshi/go-mp3 v0.3.4/go.mod h1:fRtZraRFcWb0pu7ok0LqyFhCUrPeMsGRSVop0eemFmo= -github.com/hajimehoshi/oto/v2 v2.3.1/go.mod h1:seWLbgHH7AyUMYKfKYT9pg7PhUu9/SisyJvNTT+ASQo= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/hujm2023/go-mp3/v2 v2.0.1 h1:xZn+v7cbi9DWIRN4aJ2hbXeiqjUx76w08EjlIgnw3jo= +github.com/hujm2023/go-mp3/v2 v2.0.1/go.mod h1:XCnQpP8pNjvtJFPHFf73FPJwE+680k8fDCPsw4bVCK8= github.com/inconshreveable/log15 v0.0.0-20201112154412-8562bdadbbac h1:n1DqxAo4oWPMvH1+v+DLYlMCecgumhhgnxAPdqDIFHI= github.com/inconshreveable/log15 v0.0.0-20201112154412-8562bdadbbac/go.mod h1:cOaXtrgN4ScfRrD9Bre7U1thNq5RtJ8ZoP4iXVGRj6o= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= @@ -185,7 +184,6 @@ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220712014510-0a85c31ab51e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= diff --git a/internal/ai/bailian_tts.go b/internal/ai/bailian_tts.go index 7911548..03a9943 100644 --- a/internal/ai/bailian_tts.go +++ b/internal/ai/bailian_tts.go @@ -18,5 +18,9 @@ func bailianFailureSummary(err error) (string, int) { if errors.Is(err, context.Canceled) { return "canceled", 0 } + var decodeErr *bailianTTSAudioDecodeError + if errors.As(err, &decodeErr) { + return "audio_decode", 0 + } return "task_websocket", 0 } diff --git a/internal/ai/bailian_tts_formats_test.go b/internal/ai/bailian_tts_formats_test.go index b6d6c2e..9540bf6 100644 --- a/internal/ai/bailian_tts_formats_test.go +++ b/internal/ai/bailian_tts_formats_test.go @@ -5,6 +5,7 @@ import ( "context" "encoding/binary" "encoding/json" + "fmt" "net/http" "net/http/httptest" "strings" @@ -47,6 +48,37 @@ func TestCosyVoiceWAVTaskReturnsNormalizedPCM16(t *testing.T) { } } +func TestCosyVoiceWAVRepairsObservedStreamingLengthSentinels(t *testing.T) { + samples := []int16{1000, -1000, 2000, -2000} + encoded := testTTSWAV(8000, samples) + binary.LittleEndian.PutUint32(encoded[4:8], 0x7fffffbf) + binary.LittleEndian.PutUint32(encoded[40:44], 0x7fffff9b) + + got, sourceRate, err := normalizeBailianTTSOutput(TTSConfig{OutputFormat: "wav", OutputSampleRate: 8000}, encoded) + if err != nil { + t.Fatal(err) + } + if sourceRate != 8000 { + t.Fatalf("WAV source sample rate = %d, want 8000", sourceRate) + } + want := audio.ResamplePCM16(testTTSPCM16(samples), 8000, internalPCM16SampleRate) + if !bytes.Equal(got, want) { + t.Fatalf("normalized streaming WAV = %v, want %v", got, want) + } +} + +func TestCosyVoiceWAVDoesNotRepairUnknownLengthMismatch(t *testing.T) { + encoded := testTTSWAV(8000, []int16{1000, -1000}) + binary.LittleEndian.PutUint32(encoded[4:8], uint32(len(encoded)-8+1024)) + _, _, err := normalizeBailianTTSOutput(TTSConfig{OutputFormat: "wav", OutputSampleRate: 8000}, encoded) + if err == nil { + t.Fatal("unknown WAV length mismatch was repaired") + } + if !strings.Contains(err.Error(), "RIFF declared") { + t.Fatalf("WAV length diagnostics were lost: %v", err) + } +} + func TestCosyVoiceWAVDerivesRateFromContainerWhenUnset(t *testing.T) { binding := bailianFixtureWithTTSParams(t, map[string]any{"format": "wav"}) samples := []int16{1000, -1000} @@ -70,6 +102,17 @@ func TestNormalizeBailianWAVRejectsConfiguredSampleRateMismatch(t *testing.T) { } } +func TestBailianFailureSummaryClassifiesTTSDecodeErrors(t *testing.T) { + for _, format := range []string{"MP3", "WAV"} { + decodeErr := &bailianTTSAudioDecodeError{format: strings.ToLower(format), err: fmt.Errorf("decoder rejected audio")} + err := fmt.Errorf("approved TTS synthesis failed: %w", decodeErr) + category, status := bailianFailureSummary(err) + if category != "audio_decode" || status != 0 { + t.Errorf("%s decode failure summary = (%q, %d), want (audio_decode, 0)", format, category, status) + } + } +} + 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) { diff --git a/internal/ai/tts_audio.go b/internal/ai/tts_audio.go index 3c7159d..a702c1a 100644 --- a/internal/ai/tts_audio.go +++ b/internal/ai/tts_audio.go @@ -1,9 +1,11 @@ package ai import ( + "encoding/binary" "encoding/json" "errors" "fmt" + "log/slog" "git.ipao.vip/rogee/go-sip/internal/audio" ) @@ -12,8 +14,22 @@ const ( ttsOutputFormatPCM = "pcm" ttsOutputFormatMP3 = "mp3" ttsOutputFormatWAV = "wav" + + bailianStreamingWAVRIFFSizeSentinel = uint32(0x7fffffbf) + bailianStreamingWAVDataSizeSentinel = uint32(0x7fffff9b) ) +type bailianTTSAudioDecodeError struct { + format string + err error +} + +func (e *bailianTTSAudioDecodeError) Error() string { + return fmt.Sprintf("decode TTS %s: %v", e.format, e.err) +} + +func (e *bailianTTSAudioDecodeError) Unwrap() error { return e.err } + func parseBailianTTSOutputSettings(params map[string]json.RawMessage) (string, int, error) { format := ttsOutputFormatPCM if rawFormat, configured := params["format"]; configured { @@ -39,6 +55,32 @@ func parseBailianTTSOutputSettings(params map[string]json.RawMessage) (string, i return format, rate, nil } +// repairObservedBailianStreamingWAVLengths corrects only the exact RIFF/data +// size sentinels observed in a completed Bailian streaming WAV response. The +// existing WAV decoder still validates the resulting PCM container; all other +// length mismatches remain errors. +func repairObservedBailianStreamingWAVLengths(encoded []byte) ([]byte, bool) { + const canonicalWAVHeaderBytes = 44 + if len(encoded) < canonicalWAVHeaderBytes || + string(encoded[0:4]) != "RIFF" || + binary.LittleEndian.Uint32(encoded[4:8]) != bailianStreamingWAVRIFFSizeSentinel || + string(encoded[8:12]) != "WAVE" || + string(encoded[12:16]) != "fmt " || + binary.LittleEndian.Uint32(encoded[16:20]) != 16 || + string(encoded[36:40]) != "data" || + binary.LittleEndian.Uint32(encoded[40:44]) != bailianStreamingWAVDataSizeSentinel { + return nil, false + } + actualDataBytes := len(encoded) - canonicalWAVHeaderBytes + if actualDataBytes <= 0 { + return nil, false + } + corrected := append([]byte(nil), encoded...) + binary.LittleEndian.PutUint32(corrected[4:8], uint32(len(corrected)-8)) + binary.LittleEndian.PutUint32(corrected[40:44], uint32(actualDataBytes)) + return corrected, true +} + func normalizeBailianTTSOutput(approved TTSConfig, encoded []byte) ([]byte, int, error) { if len(encoded) == 0 { return nil, 0, errors.New("TTS provider returned empty audio") @@ -65,7 +107,7 @@ func normalizeBailianTTSOutput(approved TTSConfig, encoded []byte) ([]byte, int, case ttsOutputFormatMP3: pcm16, sourceRate, err := audio.DecodeMP3ToPCM16Mono(encoded, internalPCM16SampleRate, maxBailianPCMBytes) if err != nil { - return nil, 0, fmt.Errorf("decode TTS MP3: %w", err) + return nil, 0, &bailianTTSAudioDecodeError{format: ttsOutputFormatMP3, err: err} } if approved.OutputSampleRate != 0 && approved.OutputSampleRate != sourceRate { return nil, 0, fmt.Errorf("TTS MP3 sample rate %d does not match requested %d Hz", sourceRate, approved.OutputSampleRate) @@ -73,9 +115,19 @@ func normalizeBailianTTSOutput(approved TTSConfig, encoded []byte) ([]byte, int, return pcm16, sourceRate, nil case ttsOutputFormatWAV: - pcm16, sourceRate, err := audio.DecodeWAVToPCM16Mono(encoded, internalPCM16SampleRate, maxBailianPCMBytes) + wavInput := encoded + if repaired, ok := repairObservedBailianStreamingWAVLengths(encoded); ok { + wavInput = repaired + slog.Info("Bailian TTS streaming WAV length sentinels normalized", + "original_riff_declared_bytes", uint64(bailianStreamingWAVRIFFSizeSentinel)+8, + "riff_received_bytes", len(encoded), + "original_data_declared_bytes", bailianStreamingWAVDataSizeSentinel, + "data_available_bytes", len(encoded)-44, + ) + } + pcm16, sourceRate, err := audio.DecodeWAVToPCM16Mono(wavInput, internalPCM16SampleRate, maxBailianPCMBytes) if err != nil { - return nil, 0, fmt.Errorf("decode TTS WAV: %w", err) + return nil, 0, &bailianTTSAudioDecodeError{format: ttsOutputFormatWAV, err: 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) diff --git a/internal/audio/mp3.go b/internal/audio/mp3.go index 7acb180..071c24d 100644 --- a/internal/audio/mp3.go +++ b/internal/audio/mp3.go @@ -4,9 +4,10 @@ import ( "bytes" "encoding/binary" "errors" + "fmt" "io" - "github.com/hajimehoshi/go-mp3" + "github.com/hujm2023/go-mp3/v2" ) // DecodeMP3ToPCM16Mono decodes MP3 to signed little-endian mono PCM16 at the @@ -25,7 +26,7 @@ func DecodeMP3ToPCM16Mono(encoded []byte, targetSampleRate, maxOutputBytes int) decoder, err := mp3.NewDecoder(bytes.NewReader(encoded)) if err != nil { - return nil, 0, errors.New("MP3 stream could not be decoded") + return nil, 0, fmt.Errorf("MP3 stream could not be decoded: %w", err) } sourceSampleRate := decoder.SampleRate() if sourceSampleRate < 8000 || sourceSampleRate > 48000 { diff --git a/internal/audio/mp3_test.go b/internal/audio/mp3_test.go index 3113ee7..5182eea 100644 --- a/internal/audio/mp3_test.go +++ b/internal/audio/mp3_test.go @@ -1,6 +1,8 @@ package audio import ( + "bytes" + "errors" "os" "testing" ) @@ -34,9 +36,33 @@ func TestDecodeMP3ToPCM16Mono(t *testing.T) { } } +func TestDecodeMP3ToPCM16MonoSupportsMPEG25At8k(t *testing.T) { + // Fixture is a 1-second 440-Hz mono MP3 generated with FFmpeg/libmp3lame at 8 kHz, including ID3v2 metadata. + encoded, err := os.ReadFile("testdata/sine_8k_mono_mpeg25.mp3") + if err != nil { + t.Fatal(err) + } + if !bytes.HasPrefix(encoded, []byte("ID3")) { + t.Fatal("fixture must include the provider-style ID3v2 header") + } + + pcm16, sourceRate, err := DecodeMP3ToPCM16Mono(encoded, 16000, 1<<20) + if err != nil { + t.Fatal(err) + } + if sourceRate != 8000 { + t.Fatalf("source sample rate = %d, want 8000", sourceRate) + } + if len(pcm16) == 0 || len(pcm16)%2 != 0 { + t.Fatalf("decoded PCM16 length = %d, want nonempty complete samples", len(pcm16)) + } +} + func TestDecodeMP3ToPCM16MonoRejectsInvalidAndOversizedAudio(t *testing.T) { if _, _, err := DecodeMP3ToPCM16Mono([]byte("not an MP3"), 16000, 1<<20); err == nil { t.Fatal("invalid MP3 was accepted") + } else if errors.Unwrap(err) == nil { + t.Fatalf("decoder failure cause was hidden: %v", err) } encoded, err := os.ReadFile("testdata/sine_16k_mono.mp3") if err != nil { diff --git a/internal/audio/testdata/sine_8k_mono_mpeg25.mp3 b/internal/audio/testdata/sine_8k_mono_mpeg25.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..d3fc5a9ab85c3faabd12d48fb46c6b9d05880ed1 GIT binary patch literal 3563 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 chunkSize { + available = chunkSize + } + mismatch.dataChunkAvailableBytes = available + return mismatch + } + chunkEnd := chunkStart + chunkSize + if chunkEnd < chunkStart || chunkEnd > uint64(len(encoded)) { + return mismatch + } + paddedEnd := chunkEnd + (chunkSize & 1) + if paddedEnd > uint64(len(encoded)) { + return mismatch + } + offset = int(paddedEnd) + } + return mismatch +} + 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)) { + if riffEnd64 < 12 { return wavPCMLayout{}, errors.New("RIFF/WAVE length is invalid or truncated") } + if riffEnd64 > uint64(len(encoded)) { + return wavPCMLayout{}, wavLengthMismatch(encoded, riffEnd64) + } layout := wavPCMLayout{dataOffset: -1, riffEnd: int(riffEnd64)} var sawFormat bool for offset := 12; offset < layout.riffEnd; { diff --git a/internal/audio/wav_test.go b/internal/audio/wav_test.go index efad50d..3f576ad 100644 --- a/internal/audio/wav_test.go +++ b/internal/audio/wav_test.go @@ -62,6 +62,21 @@ func TestDecodeWAVRejectsMalformedOrUnsupportedAudio(t *testing.T) { } } +func TestDecodeWAVReportsDeclaredRIFFAndDataLengths(t *testing.T) { + encoded := testPCM16WAV(8000, 1, []int16{1000, -1000}) + binary.LittleEndian.PutUint32(encoded[4:8], uint32(len(encoded)+32-8)) + binary.LittleEndian.PutUint32(encoded[40:44], uint32(len(encoded)-44+16)) + + _, _, err := DecodeWAVToPCM16Mono(encoded, 16000, 1<<20) + if err == nil { + t.Fatal("truncated WAV was accepted") + } + const want = "RIFF/WAVE length invalid or truncated: RIFF declared 80 bytes, received 48 bytes; data chunk declared 20 bytes, available 4 bytes" + if err.Error() != want { + t.Fatalf("error = %q, want %q", err, want) + } +} + func TestDecodeWAVPCMIntegerDepthsConvertToPCM16(t *testing.T) { tests := []struct { name string