feat(ai): normalize Bailian TTS audio formats

This commit is contained in:
2026-10-12 15:11:06 +08:00
parent 969e9639ec
commit e522037cb6
18 changed files with 448 additions and 20 deletions
+2 -1
View File
@@ -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),这里仅记录未完成工作,不维护第二份字段定义。
+1
View File
@@ -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
+4
View File
@@ -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=
+7 -2
View File
@@ -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
}
+29 -5
View File
@@ -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
}
+77
View File
@@ -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))
}
}
+20 -6
View File
@@ -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 {
+36
View File
@@ -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) {
+6 -1
View File
@@ -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)
+1 -1
View File
@@ -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)
+2 -2
View File
@@ -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)
+1 -1
View File
@@ -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)
+79
View File
@@ -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)
}
}
+69
View File
@@ -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")
}
})
}
}
+65
View File
@@ -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
}
+48
View File
@@ -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")
}
}
Binary file not shown.