diff --git a/contracts/schema b/contracts/schema index d46d77d..3158acc 160000 --- a/contracts/schema +++ b/contracts/schema @@ -1 +1 @@ -Subproject commit d46d77d278f0511c6f314a5d3001f29a8f12ef14 +Subproject commit 3158acc009ef260f1a9c5219db52a14a5f73565b diff --git a/docs/TODO.md b/docs/TODO.md index 39d18ff..7a5594c 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -7,7 +7,8 @@ - [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 全链路。 +- [x] Bailian task TTS 要求明确输出格式;支持 PCM16 采样率转换和 MP3 解码/下混/重采样为内部 PCM16,未知格式或无法解码的数据明确失败。 +- [ ] 在隔离 full-AI 任务上核对真实 provider 返回格式/采样率,并完成 TTS、LLM、ASR 全链路验证。 唯一规则来源是共享合同的 [业务规范](../contracts/schema/saas-dispatcher.md) 与 [任务 Schema](../contracts/schema/http-task-detail.schema.json),这里仅记录未完成工作,不维护第二份字段定义。 diff --git a/go.mod b/go.mod index 73026ce..dfa91be 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,7 @@ require ( github.com/emiago/sipgo v1.6.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/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 cf51258..bbdbf92 100644 --- a/go.sum +++ b/go.sum @@ -50,6 +50,9 @@ 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/inconshreveable/log15 v0.0.0-20201112154412-8562bdadbbac h1:n1DqxAo4oWPMvH1+v+DLYlMCecgumhhgnxAPdqDIFHI= @@ -176,6 +179,7 @@ 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/asr_sample_rate.go b/internal/ai/asr_sample_rate.go index af5a354..a928723 100644 --- a/internal/ai/asr_sample_rate.go +++ b/internal/ai/asr_sample_rate.go @@ -3,6 +3,7 @@ package ai import ( "encoding/json" "errors" + "fmt" "math" "git.ipao.vip/rogee/go-sip/internal/audio" @@ -19,12 +20,16 @@ func parseASRSampleRate(params map[string]json.RawMessage) (int, error) { if !ok { return 0, errors.New("ASR params.sample_rate is required") } + return parseSampleRateValue(raw, "ASR params.sample_rate") +} + +func parseSampleRateValue(raw json.RawMessage, field string) (int, error) { var rate float64 if err := json.Unmarshal(raw, &rate); err != nil { - return 0, errors.New("ASR params.sample_rate must be numeric") + return 0, fmt.Errorf("%s must be numeric", field) } 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 0, fmt.Errorf("%s must be an integer from 8000 to 48000 Hz", field) } return int(rate), nil } diff --git a/internal/ai/bailian_realtime.go b/internal/ai/bailian_realtime.go index c0dab41..447f6b3 100644 --- a/internal/ai/bailian_realtime.go +++ b/internal/ai/bailian_realtime.go @@ -209,6 +209,7 @@ func synthesizeBailianTaskTTS(ctx context.Context, approved TTSConfig, text stri 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 { if err := t.command(ctx, "continue-task", map[string]any{"input": map[string]any{"text": text}}); err != nil { return err @@ -216,19 +217,42 @@ func synthesizeBailianTaskTTS(ctx context.Context, approved TTSConfig, text stri return t.command(ctx, "finish-task", map[string]any{"input": map[string]any{}}) }, func(kind websocket.MessageType, body []byte, _ bailianEvent) error { if kind == websocket.MessageBinary { + binaryFrames++ if len(body) > maxBailianPCMBytes-len(audio) { - return errors.New("Bailian task TTS audio exceeds approved PCM bound") + return errors.New("Bailian task TTS encoded audio exceeds approved bound") } audio = append(audio, body...) } return nil }) if err != nil { + slog.Error("Bailian task TTS WebSocket failed", + "format", approved.OutputFormat, + "sample_rate", approved.OutputSampleRate, + "binary_frames", binaryFrames, + "encoded_audio_bytes", len(audio), + "cause_type", fmt.Sprintf("%T", err), + ) return nil, err } - if len(audio) == 0 || len(audio)%2 != 0 { - return nil, errors.New("Bailian task TTS finished without complete PCM16 audio") + pcm16, sourceRate, err := normalizeBailianTTSOutput(approved, audio) + if err != nil { + slog.Error("Bailian task TTS output conversion failed", + "format", approved.OutputFormat, + "sample_rate", approved.OutputSampleRate, + "binary_frames", binaryFrames, + "encoded_audio_bytes", len(audio), + "reason", err.Error(), + ) + return nil, err } - slog.Debug("Bailian task TTS complete PCM16 received", "bytes", len(audio)) - return audio, nil + slog.Debug("Bailian task TTS audio normalized", + "format", approved.OutputFormat, + "source_sample_rate", sourceRate, + "output_sample_rate", internalPCM16SampleRate, + "binary_frames", binaryFrames, + "encoded_audio_bytes", len(audio), + "pcm16_bytes", len(pcm16), + ) + return pcm16, nil } diff --git a/internal/ai/bailian_tts_mp3_test.go b/internal/ai/bailian_tts_mp3_test.go new file mode 100644 index 0000000..cba819c --- /dev/null +++ b/internal/ai/bailian_tts_mp3_test.go @@ -0,0 +1,77 @@ +package ai + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + "time" + + "github.com/coder/websocket" +) + +func TestCosyVoiceMP3TaskReturnsNormalizedPCM16(t *testing.T) { + binding := bailianFixture(t) + binding.TTS.OutputFormat = ttsOutputFormatMP3 + binding.TTS.OutputSampleRate = 16000 + binding.TTS.Params["format"] = json.RawMessage(`"mp3"`) + + encoded, err := os.ReadFile("../audio/testdata/sine_16k_mono.mp3") + if err != nil { + t.Fatal(err) + } + want, _, err := normalizeBailianTTSOutput(*binding.TTS, encoded) + if err != nil { + t.Fatal(err) + } + + 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 + } + params := command.Payload["parameters"].(map[string]any) + if params["format"] != "mp3" || params["sample_rate"] != float64(16000) { + t.Errorf("TTS format parameters changed: %v", params) + } + 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 MP3 normalization fixture.") + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(got, want) { + t.Fatalf("TTS returned %d bytes, want normalized PCM16 (%d bytes)", len(got), len(want)) + } +} diff --git a/internal/ai/binding.go b/internal/ai/binding.go index 5cb2d77..1faff1b 100644 --- a/internal/ai/binding.go +++ b/internal/ai/binding.go @@ -38,11 +38,13 @@ type LLMConfig struct { Params map[string]json.RawMessage } type TTSConfig struct { - Provider configread.Provider - Protocol string - Model string - Voice string - Params map[string]json.RawMessage + Provider configread.Provider + Protocol string + Model string + Voice string + OutputFormat string + OutputSampleRate int + Params map[string]json.RawMessage } type ConversationConfig struct { AllowInterrupt *bool @@ -122,7 +124,19 @@ func Bind(task configread.Task, providers map[string]configread.Provider) (Bindi if err != nil { return Binding{}, err } - b.TTS = &TTSConfig{Provider: p, Protocol: TTSProtocolDashScopeTask, Model: s.TTS.Model, Voice: s.TTS.Voice, Params: s.TTS.Params} + outputFormat, outputSampleRate, err := parseBailianTTSOutputSettings(s.TTS.Params) + if err != nil { + return Binding{}, fmt.Errorf("TTS settings: %w", err) + } + b.TTS = &TTSConfig{ + Provider: p, + Protocol: TTSProtocolDashScopeTask, + Model: s.TTS.Model, + Voice: s.TTS.Voice, + OutputFormat: outputFormat, + OutputSampleRate: outputSampleRate, + Params: s.TTS.Params, + } b.Prompt = s.Prompt.Text b.AllowedVariables = append([]string(nil), s.Prompt.AllowedVariables...) if s.Conversation != nil { diff --git a/internal/ai/binding_test.go b/internal/ai/binding_test.go index 451ea3a..9f895f7 100644 --- a/internal/ai/binding_test.go +++ b/internal/ai/binding_test.go @@ -76,6 +76,42 @@ func TestBindCurrentASROnlyDoesNotBindOtherProviders(t *testing.T) { t.Fatal("ASR-only inherited another provider", err) } } +func TestBindRequiresSupportedBailianTTSOutputFormat(t *testing.T) { + tests := []struct { + name string + params map[string]any + wantErr bool + 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: "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"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + task, providers := currentFixture(t, "full_ai") + task = changeCurrentAgent(t, task, func(agent map[string]any) { + agent["tts"].(map[string]any)["params"] = tt.params + }) + binding, err := Bind(task, providers) + if tt.wantErr && err == nil { + t.Fatal("invalid TTS output settings were accepted") + } + if !tt.wantErr && err != nil { + t.Fatalf("valid TTS output settings were rejected: %v", err) + } + if !tt.wantErr && (binding.TTS.OutputFormat != tt.wantFormat || binding.TTS.OutputSampleRate != tt.wantRate) { + t.Fatalf("TTS output settings = %q/%d, want %q/%d", binding.TTS.OutputFormat, binding.TTS.OutputSampleRate, tt.wantFormat, tt.wantRate) + } + }) + } +} + func TestBindCurrentRejectsUnauthorizedProviderBeforeCall(t *testing.T) { for _, kind := range []string{"missing", "code", "endpoint", "credential"} { t.Run(kind, func(t *testing.T) { diff --git a/internal/ai/media_profile_test.go b/internal/ai/media_profile_test.go index dd98bae..125b625 100644 --- a/internal/ai/media_profile_test.go +++ b/internal/ai/media_profile_test.go @@ -6,7 +6,12 @@ func TestVendorParameterValuesAreNotLocallyValidated(t *testing.T) { for _, role := range []string{"asr", "tts"} { task, ps := currentFixture(t, "full_ai") task = changeCurrentAgent(t, task, func(a map[string]any) { - a[role].(map[string]any)["params"] = map[string]any{"sample_rate": 24000, "format": "vendor-format", "speed": 9.5} + params := map[string]any{"sample_rate": 24000, "format": "vendor-format", "speed": 9.5} + if role == "tts" { + params["format"] = "pcm" + params["vendor_codec_hint"] = "vendor-format" + } + a[role].(map[string]any)["params"] = params }) if _, err := Bind(task, ps); err != nil { t.Fatal("vendor parameter was locally rejected", err) diff --git a/internal/ai/params_precedence_test.go b/internal/ai/params_precedence_test.go index 51ade71..2567a68 100644 --- a/internal/ai/params_precedence_test.go +++ b/internal/ai/params_precedence_test.go @@ -22,7 +22,7 @@ func TestTaskFieldsWinOverSameDifferentAndNullParamValues(t *testing.T) { } 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} + agent["tts"].(map[string]any)["params"] = map[string]any{"format": "pcm", "sample_rate": 16000, "voice": voice, "opaque": false} }) original := append([]byte(nil), task.Raw...) bound, err := Bind(task, providers) diff --git a/internal/ai/protocol_runtime_test.go b/internal/ai/protocol_runtime_test.go index 29aa0ae..049f41a 100644 --- a/internal/ai/protocol_runtime_test.go +++ b/internal/ai/protocol_runtime_test.go @@ -15,7 +15,7 @@ import ( func TestTaskTTSProtocolForwardsOpaqueModelVoiceAndParams(t *testing.T) { task, ps := protocolTask(t, TTSProtocolDashScopeTask) task = changeCurrentAgent(t, task, func(agent map[string]any) { - agent["tts"].(map[string]any)["params"] = map[string]any{"voice": "ignored-params-voice", "opaque": "vendor-only", "rate": 9.5, "unknown_flag": false, "nullable": nil, "large_number": json.Number("9007199254740993")} + agent["tts"].(map[string]any)["params"] = map[string]any{"format": "pcm", "sample_rate": 16000, "voice": "ignored-params-voice", "opaque": "vendor-only", "rate": 9.5, "unknown_flag": false, "nullable": nil, "large_number": json.Number("9007199254740993")} }) b, err := Bind(task, ps) if err != nil { @@ -45,7 +45,7 @@ func TestTaskTTSProtocolForwardsOpaqueModelVoiceAndParams(t *testing.T) { return } p := cmd.Payload["parameters"].(map[string]any) - if cmd.Payload["model"] != "unlisted-tts-model-2029" || p["voice"] != "unlisted-voice" || p["opaque"] != "vendor-only" || p["rate"] != 9.5 || p["unknown_flag"] != false || p["nullable"] != nil || len(p) != 6 { + if cmd.Payload["model"] != "unlisted-tts-model-2029" || p["voice"] != "unlisted-voice" || p["opaque"] != "vendor-only" || p["rate"] != 9.5 || p["unknown_flag"] != false || p["nullable"] != nil || len(p) != 8 { t.Error("opaque parameters changed") } mockEvent(c, ctx, cmd.Header.TaskID, "task-started", nil) diff --git a/internal/ai/schema_params_test.go b/internal/ai/schema_params_test.go index e63c43b..a5a4cd6 100644 --- a/internal/ai/schema_params_test.go +++ b/internal/ai/schema_params_test.go @@ -11,7 +11,7 @@ func TestNewSchemaBindsCodeReferenceAndOpaqueParams(t *testing.T) { task = changeCurrentAgent(t, task, func(a map[string]any) { 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["tts"].(map[string]any)["params"] = map[string]any{"format": "pcm", "sample_rate": 16000, "rate": 9.5, "language": "vendor-only"} a["prompt"].(map[string]any)["text"] = strings.Repeat("长", 40000) }) b, err := Bind(task, providers) diff --git a/internal/ai/tts_audio.go b/internal/ai/tts_audio.go new file mode 100644 index 0000000..6827d03 --- /dev/null +++ b/internal/ai/tts_audio.go @@ -0,0 +1,79 @@ +package ai + +import ( + "encoding/json" + "errors" + "fmt" + + "git.ipao.vip/rogee/go-sip/internal/audio" +) + +const ( + ttsOutputFormatPCM = "pcm" + ttsOutputFormatMP3 = "mp3" +) + +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") + } + 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 { + 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, 0, nil + } + rate, err := parseSampleRateValue(rawRate, "TTS params.sample_rate") + if err != nil { + return "", 0, err + } + return format, rate, nil +} + +func normalizeBailianTTSOutput(approved TTSConfig, encoded []byte) ([]byte, int, error) { + if len(encoded) == 0 { + return nil, 0, errors.New("TTS provider returned empty audio") + } + + switch approved.OutputFormat { + case ttsOutputFormatPCM: + sourceRate := approved.OutputSampleRate + if sourceRate < minASRSampleRate || sourceRate > maxASRSampleRate { + return nil, 0, errors.New("TTS PCM sample rate is outside the supported 8000–48000 Hz range") + } + if len(encoded)%2 != 0 { + return nil, 0, errors.New("TTS PCM output contains an incomplete PCM16 sample") + } + pcm16 := audio.ResamplePCM16(encoded, sourceRate, internalPCM16SampleRate) + if len(pcm16) == 0 || len(pcm16)%2 != 0 { + return nil, 0, errors.New("TTS PCM conversion produced no complete PCM16 samples") + } + if len(pcm16) > maxBailianPCMBytes { + return nil, 0, errors.New("converted TTS PCM exceeds the approved audio bound") + } + return pcm16, sourceRate, nil + + case ttsOutputFormatMP3: + pcm16, sourceRate, err := audio.DecodeMP3ToPCM16Mono(encoded, internalPCM16SampleRate, maxBailianPCMBytes) + if err != nil { + return nil, 0, fmt.Errorf("decode TTS MP3: %w", 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) + } + 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_test.go b/internal/ai/tts_audio_test.go new file mode 100644 index 0000000..0655f1d --- /dev/null +++ b/internal/ai/tts_audio_test.go @@ -0,0 +1,69 @@ +package ai + +import ( + "os" + "testing" +) + +func TestNormalizeBailianTTSOutputMP3ToInternalPCM16(t *testing.T) { + encoded, err := os.ReadFile("../audio/testdata/sine_16k_mono.mp3") + if err != nil { + t.Fatal(err) + } + + pcm16, sourceRate, err := normalizeBailianTTSOutput(TTSConfig{OutputFormat: "mp3", OutputSampleRate: 16000}, encoded) + if err != nil { + t.Fatal(err) + } + if sourceRate != 16000 { + t.Fatalf("MP3 source sample rate = %d, want 16000", sourceRate) + } + if len(pcm16) == 0 || len(pcm16)%2 != 0 { + t.Fatalf("normalized PCM16 length = %d, want nonempty complete samples", len(pcm16)) + } + if _, _, err := normalizeBailianTTSOutput(TTSConfig{OutputFormat: "mp3", OutputSampleRate: 8000}, encoded); err == nil { + t.Fatal("MP3 sample-rate mismatch was accepted") + } + if _, sourceRate, err := normalizeBailianTTSOutput(TTSConfig{OutputFormat: "mp3"}, encoded); err != nil || sourceRate != 16000 { + t.Fatalf("MP3 rate should be read from the bitstream: sample_rate=%d err=%v", sourceRate, err) + } +} + +func TestNormalizeBailianTTSOutputPCMResamplesToInternalRate(t *testing.T) { + input := []byte{1, 0, 3, 0} + got, sourceRate, err := normalizeBailianTTSOutput(TTSConfig{OutputFormat: "pcm", OutputSampleRate: 8000}, input) + if err != nil { + t.Fatal(err) + } + if sourceRate != 8000 { + t.Fatalf("PCM source sample rate = %d, want 8000", sourceRate) + } + want := []byte{1, 0, 2, 0, 3, 0, 3, 0} + if len(got) != len(want) { + t.Fatalf("normalized PCM length = %d, want %d", len(got), len(want)) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("normalized PCM = %v, want %v", got, want) + } + } +} + +func TestNormalizeBailianTTSOutputRejectsInvalidAudio(t *testing.T) { + for _, tc := range []struct { + name string + cfg TTSConfig + data []byte + }{ + {name: "unsupported format", cfg: TTSConfig{OutputFormat: "wav", OutputSampleRate: 16000}, data: []byte{1, 2}}, + {name: "empty PCM", cfg: TTSConfig{OutputFormat: "pcm", OutputSampleRate: 16000}}, + {name: "incomplete PCM sample", cfg: TTSConfig{OutputFormat: "pcm", OutputSampleRate: 16000}, data: []byte{1}}, + {name: "invalid MP3", cfg: TTSConfig{OutputFormat: "mp3", OutputSampleRate: 16000}, data: []byte("not an MP3")}, + } { + t.Run(tc.name, func(t *testing.T) { + if _, _, err := normalizeBailianTTSOutput(tc.cfg, tc.data); err == nil { + t.Fatal("invalid TTS output was accepted") + } + }) + } +} diff --git a/internal/audio/mp3.go b/internal/audio/mp3.go new file mode 100644 index 0000000..7acb180 --- /dev/null +++ b/internal/audio/mp3.go @@ -0,0 +1,65 @@ +package audio + +import ( + "bytes" + "encoding/binary" + "errors" + "io" + + "github.com/hajimehoshi/go-mp3" +) + +// DecodeMP3ToPCM16Mono decodes MP3 to signed little-endian mono PCM16 at the +// requested rate. The decoder emits stereo PCM16 for every MP3 frame, so this +// function downmixes before resampling. maxOutputBytes bounds the final buffer. +func DecodeMP3ToPCM16Mono(encoded []byte, targetSampleRate, maxOutputBytes int) ([]byte, int, error) { + if len(encoded) == 0 || maxOutputBytes <= 0 { + return nil, 0, errors.New("MP3 input or output limit is invalid") + } + if targetSampleRate < 8000 || targetSampleRate > 48000 { + return nil, 0, errors.New("MP3 target sample rate is outside 8–48 kHz") + } + if len(encoded) > maxOutputBytes { + return nil, 0, errors.New("encoded MP3 exceeds the approved audio bound") + } + + decoder, err := mp3.NewDecoder(bytes.NewReader(encoded)) + if err != nil { + return nil, 0, errors.New("MP3 stream could not be decoded") + } + sourceSampleRate := decoder.SampleRate() + if sourceSampleRate < 8000 || sourceSampleRate > 48000 { + return nil, 0, errors.New("MP3 source sample rate is outside 8–48 kHz") + } + + maxDecodedBytes := int64(maxOutputBytes) * 2 * int64(sourceSampleRate) / int64(targetSampleRate) + if maxDecodedBytes < 4 { + return nil, 0, errors.New("MP3 output limit is too small for a PCM16 sample") + } + decoded, err := io.ReadAll(io.LimitReader(decoder, maxDecodedBytes+1)) + if err != nil { + return nil, 0, errors.New("MP3 decode failed while reading audio") + } + if int64(len(decoded)) > maxDecodedBytes { + return nil, 0, errors.New("decoded MP3 exceeds the approved PCM16 bound") + } + if len(decoded) == 0 || len(decoded)%4 != 0 { + return nil, 0, errors.New("MP3 decoder returned incomplete stereo PCM16") + } + + mono := make([]byte, len(decoded)/2) + for source, target := 0, 0; source < len(decoded); source, target = source+4, target+2 { + left := int32(int16(binary.LittleEndian.Uint16(decoded[source : source+2]))) + right := int32(int16(binary.LittleEndian.Uint16(decoded[source+2 : source+4]))) + binary.LittleEndian.PutUint16(mono[target:target+2], uint16(int16((left+right)/2))) + } + + pcm16 := ResamplePCM16(mono, sourceSampleRate, targetSampleRate) + if len(pcm16) == 0 || len(pcm16)%2 != 0 { + return nil, 0, errors.New("MP3 conversion produced no complete PCM16 samples") + } + if len(pcm16) > maxOutputBytes { + return nil, 0, errors.New("converted MP3 exceeds the approved PCM16 bound") + } + return pcm16, sourceSampleRate, nil +} diff --git a/internal/audio/mp3_test.go b/internal/audio/mp3_test.go new file mode 100644 index 0000000..3113ee7 --- /dev/null +++ b/internal/audio/mp3_test.go @@ -0,0 +1,48 @@ +package audio + +import ( + "os" + "testing" +) + +func TestDecodeMP3ToPCM16Mono(t *testing.T) { + encoded, err := os.ReadFile("testdata/sine_16k_mono.mp3") + if err != nil { + t.Fatal(err) + } + + pcm16k, sourceRate, err := DecodeMP3ToPCM16Mono(encoded, 16000, 1<<20) + if err != nil { + t.Fatal(err) + } + if sourceRate != 16000 { + t.Fatalf("source sample rate = %d, want 16000", sourceRate) + } + if len(pcm16k) == 0 || len(pcm16k)%2 != 0 { + t.Fatalf("decoded PCM16 length = %d, want nonempty complete samples", len(pcm16k)) + } + + pcm8k, decodedRate, err := DecodeMP3ToPCM16Mono(encoded, 8000, 1<<20) + if err != nil { + t.Fatal(err) + } + if decodedRate != sourceRate { + t.Fatalf("source sample rate changed between conversions: %d != %d", decodedRate, sourceRate) + } + if len(pcm16k) != 2*len(pcm8k) { + t.Fatalf("16 kHz output has %d bytes, 8 kHz output has %d; want 2:1", len(pcm16k), len(pcm8k)) + } +} + +func TestDecodeMP3ToPCM16MonoRejectsInvalidAndOversizedAudio(t *testing.T) { + if _, _, err := DecodeMP3ToPCM16Mono([]byte("not an MP3"), 16000, 1<<20); err == nil { + t.Fatal("invalid MP3 was accepted") + } + encoded, err := os.ReadFile("testdata/sine_16k_mono.mp3") + if err != nil { + t.Fatal(err) + } + if _, _, err := DecodeMP3ToPCM16Mono(encoded, 16000, 2048); err == nil { + t.Fatal("decoded output beyond the limit was accepted") + } +} diff --git a/internal/audio/testdata/sine_16k_mono.mp3 b/internal/audio/testdata/sine_16k_mono.mp3 new file mode 100644 index 0000000..598e2ba Binary files /dev/null and b/internal/audio/testdata/sine_16k_mono.mp3 differ