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 0000000..d3fc5a9 Binary files /dev/null and b/internal/audio/testdata/sine_8k_mono_mpeg25.mp3 differ diff --git a/internal/audio/wav.go b/internal/audio/wav.go index dcb9b7b..ffd8e05 100644 --- a/internal/audio/wav.go +++ b/internal/audio/wav.go @@ -158,14 +158,64 @@ func DecodeWAVToPCM16Mono(encoded []byte, targetRate, maxAudioBytes int) ([]byte return pcm16, int(layout.sampleRate), nil } +type wavLengthMismatchError struct { + riffDeclaredBytes uint64 + receivedBytes uint64 + dataChunkDeclaredBytes uint64 + dataChunkAvailableBytes uint64 + hasDataChunk bool +} + +func (e *wavLengthMismatchError) Error() string { + if e.hasDataChunk { + return fmt.Sprintf("RIFF/WAVE length invalid or truncated: RIFF declared %d bytes, received %d bytes; data chunk declared %d bytes, available %d bytes", e.riffDeclaredBytes, e.receivedBytes, e.dataChunkDeclaredBytes, e.dataChunkAvailableBytes) + } + return fmt.Sprintf("RIFF/WAVE length invalid or truncated: RIFF declared %d bytes, received %d bytes; data chunk size unavailable", e.riffDeclaredBytes, e.receivedBytes) +} + +func wavLengthMismatch(encoded []byte, riffDeclaredBytes uint64) error { + mismatch := &wavLengthMismatchError{ + riffDeclaredBytes: riffDeclaredBytes, + receivedBytes: uint64(len(encoded)), + } + for offset := 12; offset+8 <= len(encoded); { + chunkID := string(encoded[offset : offset+4]) + chunkSize := uint64(binary.LittleEndian.Uint32(encoded[offset+4 : offset+8])) + chunkStart := uint64(offset + 8) + if chunkID == "data" { + mismatch.hasDataChunk = true + mismatch.dataChunkDeclaredBytes = chunkSize + available := uint64(len(encoded)) - chunkStart + if available > 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