feat(ai): normalize Bailian TTS audio formats
This commit is contained in:
+1
-1
Submodule contracts/schema updated: d46d77d278...3158acc009
+2
-1
@@ -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),这里仅记录未完成工作,不维护第二份字段定义。
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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,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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
BIN
Binary file not shown.
Reference in New Issue
Block a user