feat(ai): default Bailian TTS to PCM and decode WAV
This commit is contained in:
+1
-1
Submodule contracts/schema updated: 3158acc009...940d9224fd
+2
-2
@@ -7,9 +7,9 @@
|
||||
- [x] 缺失、非整数、超出 8–48 kHz 范围或转换后没有完整 PCM16 样本时明确失败;不改写参数、不换率、不换服务商。
|
||||
- [x] 复用已有 PCM16 转换能力;TDD 覆盖 16→8 kHz ASR、8↔16 kHz 媒体转换、同率直通、分块收发和错误输入。
|
||||
- [ ] 在获批的隔离 ASR-only 与 full-AI 任务上完成真实 ASR 验证;本地测试不能代替供应商和通话验收。
|
||||
- [x] Bailian task TTS 要求明确输出格式;支持 PCM16 采样率转换和 MP3 解码/下混/重采样为内部 PCM16,未知格式或无法解码的数据明确失败。
|
||||
- [x] Bailian task TTS 在 format 缺省时默认原始 PCM16/16000 Hz,并显式发送到供应商;支持 PCM16 归一化、MP3 解码及 PCM WAV 解码/下混/重采样为内部单声道 16 kHz PCM16。未知格式、损坏音频或不支持的 WAV 子格式仍明确失败。
|
||||
- [ ] 在隔离 full-AI 任务上核对真实 provider 返回格式/采样率,并完成 TTS、LLM、ASR 全链路验证。
|
||||
|
||||
唯一规则来源是共享合同的 [业务规范](../contracts/schema/saas-dispatcher.md) 与 [任务 Schema](../contracts/schema/http-task-detail.schema.json),这里仅记录未完成工作,不维护第二份字段定义。
|
||||
|
||||
**当前边界:** 实现位于本地分支 `fix/asr-sample-rate-20261012`,尚未部署。Dispatcher 当前只有 full-AI 任务,没有 ASR-only 测试任务。测试管理页通过 NatApp 隧道访问失败(目标 `192.168.0.3:8888` 不可用),因此尚未创建测试任务,也未对 `15003164745` 或 `15830461047` 发起新呼叫。
|
||||
**当前边界:** ASR 采样率和 PCM/MP3 版本 `e522037` 已合入 main 并部署到 `server.sip`,但 Dispatcher 未就绪:SaaS 任务 6 显式配置 `TTS format=wav`,旧版本因不支持 WAV 而自动重启。当前 WAV/默认值修复仍在 `feat/bailian-wav-default-20261012`,尚未合入或部署。未重置数据、创建任务或拨号;ASR-only 测试任务及管理隧道状态仍待重新核验。
|
||||
|
||||
@@ -9,6 +9,8 @@ require (
|
||||
github.com/coder/websocket v1.8.15
|
||||
github.com/cyberphone/json-canonicalization v0.0.0-20241213102144-19d51d7fe467
|
||||
github.com/emiago/sipgo v1.6.0
|
||||
github.com/go-audio/audio v1.0.0
|
||||
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
|
||||
@@ -32,6 +34,7 @@ require (
|
||||
require (
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/ebitengine/purego v0.10.2 // indirect
|
||||
github.com/go-audio/riff v1.0.0 // indirect
|
||||
github.com/go-ole/go-ole v1.2.6 // indirect
|
||||
github.com/go-stack/stack v1.8.0 // indirect
|
||||
github.com/gobwas/httphead v0.1.0 // indirect
|
||||
|
||||
@@ -23,6 +23,12 @@ github.com/ebitengine/purego v0.10.2 h1:W809HbnvzAxgdm+aOvlSekrM16wGCdT/e76+9tS7
|
||||
github.com/ebitengine/purego v0.10.2/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||
github.com/emiago/sipgo v1.6.0 h1:6EuOP7c6f0VRatKYTPEYNezt4hslBEsaCzZZOhT2n3s=
|
||||
github.com/emiago/sipgo v1.6.0/go.mod h1:DuwAxBZhKMqIzQFPGZb1MVAGU6Wuxj64oTOhd5dx/FY=
|
||||
github.com/go-audio/audio v1.0.0 h1:zS9vebldgbQqktK4H0lUqWrG8P0NxCJVqcj7ZpNnwd4=
|
||||
github.com/go-audio/audio v1.0.0/go.mod h1:6uAu0+H2lHkwdGsAY+j2wHPNPpPoeg5AaEFh9FlA+Zs=
|
||||
github.com/go-audio/riff v1.0.0 h1:d8iCGbDvox9BfLagY94fBynxSPHO80LmZCaOsmKxokA=
|
||||
github.com/go-audio/riff v1.0.0/go.mod h1:l3cQwc85y79NQFCRB7TiPoNiaijp6q8Z0Uv38rVG498=
|
||||
github.com/go-audio/wav v1.1.0 h1:jQgLtbqBzY7G+BM8fXF7AHUk1uHUviWS4X39d5rsL2g=
|
||||
github.com/go-audio/wav v1.1.0/go.mod h1:mpe9qfwbScEbkd8uybLuIpTgHyrISw/OTuvjUW2iGtE=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
|
||||
@@ -5,6 +5,10 @@ import (
|
||||
)
|
||||
|
||||
func bailianFixture(t *testing.T) Binding {
|
||||
return bailianFixtureWithTTSParams(t, map[string]any{"rate": 1.25, "sample_rate": 16000, "format": "pcm"})
|
||||
}
|
||||
|
||||
func bailianFixtureWithTTSParams(t *testing.T, params map[string]any) Binding {
|
||||
t.Helper()
|
||||
task, ps := currentFixture(t, "full_ai")
|
||||
task = changeCurrentAgent(t, task, func(a map[string]any) {
|
||||
@@ -16,7 +20,7 @@ func bailianFixture(t *testing.T) Binding {
|
||||
a["llm"].(map[string]any)["model"] = "Qwen3.8-Flash"
|
||||
a["tts"].(map[string]any)["model"] = "cosyvoice-v3-flash"
|
||||
a["tts"].(map[string]any)["voice"] = "longanyang"
|
||||
a["tts"].(map[string]any)["params"] = map[string]any{"rate": 1.25, "sample_rate": 16000, "format": "pcm"}
|
||||
a["tts"].(map[string]any)["params"] = params
|
||||
})
|
||||
p := ps["ali_bailian"]
|
||||
p.Credential = "mock-key"
|
||||
@@ -33,3 +37,16 @@ func TestTaskModelsShareConnectionOnlyProvider(t *testing.T) {
|
||||
t.Fatal("shared connection or opaque params lost")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBailianTTSDefaultsMissingOutputSettings(t *testing.T) {
|
||||
b := bailianFixtureWithTTSParams(t, map[string]any{"rate": 1.25})
|
||||
if b.TTS.OutputFormat != ttsOutputFormatPCM || b.TTS.OutputSampleRate != internalPCM16SampleRate {
|
||||
t.Fatalf("TTS defaults = (%q, %d), want (%q, %d)", b.TTS.OutputFormat, b.TTS.OutputSampleRate, ttsOutputFormatPCM, internalPCM16SampleRate)
|
||||
}
|
||||
if _, ok := b.TTS.Params["format"]; ok {
|
||||
t.Fatal("default format must not rewrite the approved params snapshot")
|
||||
}
|
||||
if _, ok := b.TTS.Params["sample_rate"]; ok {
|
||||
t.Fatal("default sample rate must not rewrite the approved params snapshot")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -206,11 +206,23 @@ func synthesizeBailianTaskTTS(ctx context.Context, approved TTSConfig, text stri
|
||||
return nil, errors.New("unapproved Bailian task TTS settings or empty text")
|
||||
}
|
||||
parameters := cloneParams(approved.Params)
|
||||
format, err := json.Marshal(approved.OutputFormat)
|
||||
if err != nil {
|
||||
return nil, errors.New("encode approved Bailian TTS output format")
|
||||
}
|
||||
parameters["format"] = format
|
||||
if approved.OutputSampleRate > 0 {
|
||||
sampleRate, err := json.Marshal(approved.OutputSampleRate)
|
||||
if err != nil {
|
||||
return nil, errors.New("encode approved Bailian TTS sample rate")
|
||||
}
|
||||
parameters["sample_rate"] = sampleRate
|
||||
}
|
||||
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 {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
|
||||
"git.ipao.vip/rogee/go-sip/internal/audio"
|
||||
)
|
||||
|
||||
func TestCosyVoiceDefaultsMissingFormatToPCM16AtInternalRate(t *testing.T) {
|
||||
binding := bailianFixtureWithTTSParams(t, map[string]any{"rate": 1.25})
|
||||
pcm := []byte{0x10, 0x00, 0x20, 0x00, 0x30, 0x00, 0x40, 0x00}
|
||||
|
||||
got := runMockCosyVoiceTTS(t, binding, pcm, map[string]any{
|
||||
"rate": float64(1.25),
|
||||
"format": ttsOutputFormatPCM,
|
||||
"sample_rate": float64(internalPCM16SampleRate),
|
||||
"voice": "longanyang",
|
||||
})
|
||||
if !bytes.Equal(got, pcm) {
|
||||
t.Fatalf("default PCM output = %v, want unchanged %v", got, pcm)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCosyVoiceWAVTaskReturnsNormalizedPCM16(t *testing.T) {
|
||||
binding := bailianFixtureWithTTSParams(t, map[string]any{"format": "wav", "sample_rate": 8000})
|
||||
samples := []int16{1000, -1000, 2000, -2000}
|
||||
encoded := testTTSWAV(8000, samples)
|
||||
want := audio.ResamplePCM16(testTTSPCM16(samples), 8000, internalPCM16SampleRate)
|
||||
|
||||
got := runMockCosyVoiceTTS(t, binding, encoded, map[string]any{
|
||||
"format": ttsOutputFormatWAV,
|
||||
"sample_rate": float64(8000),
|
||||
"voice": "longanyang",
|
||||
})
|
||||
if !bytes.Equal(got, want) {
|
||||
t.Fatalf("WAV output = %v, want normalized PCM16 %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCosyVoiceWAVDerivesRateFromContainerWhenUnset(t *testing.T) {
|
||||
binding := bailianFixtureWithTTSParams(t, map[string]any{"format": "wav"})
|
||||
samples := []int16{1000, -1000}
|
||||
encoded := testTTSWAV(8000, samples)
|
||||
want := audio.ResamplePCM16(testTTSPCM16(samples), 8000, internalPCM16SampleRate)
|
||||
|
||||
got := runMockCosyVoiceTTS(t, binding, encoded, map[string]any{
|
||||
"format": ttsOutputFormatWAV,
|
||||
"voice": "longanyang",
|
||||
})
|
||||
if !bytes.Equal(got, want) {
|
||||
t.Fatalf("WAV output = %v, want normalized PCM16 %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeBailianWAVRejectsConfiguredSampleRateMismatch(t *testing.T) {
|
||||
binding := bailianFixtureWithTTSParams(t, map[string]any{"format": "wav", "sample_rate": 16000})
|
||||
encoded := testTTSWAV(8000, []int16{1000, -1000})
|
||||
if _, _, err := normalizeBailianTTSOutput(*binding.TTS, encoded); err == nil {
|
||||
t.Fatal("WAV sample rate mismatch was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
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
|
||||
}
|
||||
parameters, ok := command.Payload["parameters"].(map[string]any)
|
||||
if !ok {
|
||||
t.Errorf("TTS parameters missing: %#v", command.Payload["parameters"])
|
||||
return
|
||||
}
|
||||
for key, want := range wantParameters {
|
||||
if got := parameters[key]; got != want {
|
||||
t.Errorf("TTS parameter %q = %#v, want %#v", key, got, want)
|
||||
}
|
||||
}
|
||||
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 format normalization fixture.")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return got
|
||||
}
|
||||
|
||||
func testTTSWAV(sampleRate uint32, samples []int16) []byte {
|
||||
pcm := testTTSPCM16(samples)
|
||||
data := make([]byte, 44+len(pcm))
|
||||
copy(data[0:4], "RIFF")
|
||||
binary.LittleEndian.PutUint32(data[4:8], uint32(len(data)-8))
|
||||
copy(data[8:12], "WAVE")
|
||||
copy(data[12:16], "fmt ")
|
||||
binary.LittleEndian.PutUint32(data[16:20], 16)
|
||||
binary.LittleEndian.PutUint16(data[20:22], 1)
|
||||
binary.LittleEndian.PutUint16(data[22:24], 1)
|
||||
binary.LittleEndian.PutUint32(data[24:28], sampleRate)
|
||||
binary.LittleEndian.PutUint32(data[28:32], sampleRate*2)
|
||||
binary.LittleEndian.PutUint16(data[32:34], 2)
|
||||
binary.LittleEndian.PutUint16(data[34:36], 16)
|
||||
copy(data[36:40], "data")
|
||||
binary.LittleEndian.PutUint32(data[40:44], uint32(len(pcm)))
|
||||
copy(data[44:], pcm)
|
||||
return data
|
||||
}
|
||||
|
||||
func testTTSPCM16(samples []int16) []byte {
|
||||
pcm := make([]byte, len(samples)*2)
|
||||
for i, sample := range samples {
|
||||
binary.LittleEndian.PutUint16(pcm[i*2:], uint16(sample))
|
||||
}
|
||||
return pcm
|
||||
}
|
||||
@@ -76,7 +76,7 @@ func TestBindCurrentASROnlyDoesNotBindOtherProviders(t *testing.T) {
|
||||
t.Fatal("ASR-only inherited another provider", err)
|
||||
}
|
||||
}
|
||||
func TestBindRequiresSupportedBailianTTSOutputFormat(t *testing.T) {
|
||||
func TestBindBailianTTSOutputSettings(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
params map[string]any
|
||||
@@ -84,13 +84,16 @@ func TestBindRequiresSupportedBailianTTSOutputFormat(t *testing.T) {
|
||||
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: "missing format defaults to PCM at configured rate", params: map[string]any{"sample_rate": 16000}, wantFormat: "pcm", wantRate: 16000},
|
||||
{name: "missing settings defaults to internal PCM16", params: map[string]any{}, wantFormat: "pcm", wantRate: internalPCM16SampleRate},
|
||||
{name: "PCM missing sample rate defaults to internal rate", params: map[string]any{"format": "pcm"}, wantFormat: "pcm", wantRate: internalPCM16SampleRate},
|
||||
{name: "unsupported format", params: map[string]any{"format": "ogg"}, 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"},
|
||||
{name: "WAV", params: map[string]any{"format": "wav", "sample_rate": 8000}, wantFormat: "wav", wantRate: 8000},
|
||||
{name: "WAV rate from container", params: map[string]any{"format": "wav"}, wantFormat: "wav"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
|
||||
@@ -11,25 +11,24 @@ import (
|
||||
const (
|
||||
ttsOutputFormatPCM = "pcm"
|
||||
ttsOutputFormatMP3 = "mp3"
|
||||
ttsOutputFormatWAV = "wav"
|
||||
)
|
||||
|
||||
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")
|
||||
format := ttsOutputFormatPCM
|
||||
if rawFormat, configured := params["format"]; configured {
|
||||
if err := json.Unmarshal(rawFormat, &format); err != nil || format == "" {
|
||||
return "", 0, errors.New("TTS params.format must be a nonempty string")
|
||||
}
|
||||
}
|
||||
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 {
|
||||
if format != ttsOutputFormatPCM && format != ttsOutputFormatMP3 && format != ttsOutputFormatWAV {
|
||||
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, internalPCM16SampleRate, nil
|
||||
}
|
||||
return format, 0, nil
|
||||
}
|
||||
@@ -73,6 +72,16 @@ func normalizeBailianTTSOutput(approved TTSConfig, encoded []byte) ([]byte, int,
|
||||
}
|
||||
return pcm16, sourceRate, nil
|
||||
|
||||
case ttsOutputFormatWAV:
|
||||
pcm16, sourceRate, err := audio.DecodeWAVToPCM16Mono(encoded, internalPCM16SampleRate, maxBailianPCMBytes)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("decode TTS WAV: %w", 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)
|
||||
}
|
||||
return pcm16, sourceRate, nil
|
||||
|
||||
default:
|
||||
return nil, 0, fmt.Errorf("unsupported Bailian TTS output format %q", approved.OutputFormat)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseBailianTTSOutputSettingsDefaults(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
params map[string]json.RawMessage
|
||||
wantFormat string
|
||||
wantRate int
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "missing settings defaults to internal PCM16", wantFormat: ttsOutputFormatPCM, wantRate: internalPCM16SampleRate},
|
||||
{
|
||||
name: "explicit PCM without rate defaults to internal rate",
|
||||
params: map[string]json.RawMessage{"format": json.RawMessage(`"pcm"`)},
|
||||
wantFormat: ttsOutputFormatPCM,
|
||||
wantRate: internalPCM16SampleRate,
|
||||
},
|
||||
{
|
||||
name: "missing format preserves configured PCM rate",
|
||||
params: map[string]json.RawMessage{"sample_rate": json.RawMessage(`8000`)},
|
||||
wantFormat: ttsOutputFormatPCM,
|
||||
wantRate: 8000,
|
||||
},
|
||||
{
|
||||
name: "explicit WAV derives source rate from container",
|
||||
params: map[string]json.RawMessage{"format": json.RawMessage(`"wav"`)},
|
||||
wantFormat: ttsOutputFormatWAV,
|
||||
},
|
||||
{
|
||||
name: "explicit MP3 derives source rate from bitstream",
|
||||
params: map[string]json.RawMessage{"format": json.RawMessage(`"mp3"`)},
|
||||
wantFormat: ttsOutputFormatMP3,
|
||||
},
|
||||
{
|
||||
name: "explicit unknown format remains invalid",
|
||||
params: map[string]json.RawMessage{"format": json.RawMessage(`"ogg"`)},
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
format, rate, err := parseBailianTTSOutputSettings(tt.params)
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Fatal("invalid TTS settings were accepted")
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if format != tt.wantFormat || rate != tt.wantRate {
|
||||
t.Fatalf("settings = (%q, %d), want (%q, %d)", format, rate, tt.wantFormat, tt.wantRate)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
package audio
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
goaudio "github.com/go-audio/audio"
|
||||
wavcodec "github.com/go-audio/wav"
|
||||
)
|
||||
|
||||
const (
|
||||
minWAVSampleRate = 8000
|
||||
maxWAVSampleRate = 48000
|
||||
wavPCMFormat = 1
|
||||
)
|
||||
|
||||
type wavPCMLayout struct {
|
||||
format uint16
|
||||
channels uint16
|
||||
sampleRate uint32
|
||||
byteRate uint32
|
||||
blockAlign uint16
|
||||
bitDepth uint16
|
||||
dataOffset int
|
||||
dataSize int
|
||||
riffEnd int
|
||||
}
|
||||
|
||||
// DecodeWAVToPCM16Mono decodes integer PCM RIFF/WAVE audio and normalizes it to
|
||||
// mono PCM16 at targetRate. WAV metadata is validated before the decoder reads
|
||||
// sample data so truncated chunks cannot be mistaken for completed audio.
|
||||
func DecodeWAVToPCM16Mono(encoded []byte, targetRate, maxAudioBytes int) ([]byte, int, error) {
|
||||
if len(encoded) == 0 {
|
||||
return nil, 0, errors.New("WAV audio is empty")
|
||||
}
|
||||
if maxAudioBytes <= 0 || len(encoded) > maxAudioBytes {
|
||||
return nil, 0, errors.New("WAV audio exceeds the configured bound")
|
||||
}
|
||||
if targetRate < minWAVSampleRate || targetRate > maxWAVSampleRate {
|
||||
return nil, 0, errors.New("WAV target sample rate is outside the supported 8000–48000 Hz range")
|
||||
}
|
||||
|
||||
layout, err := inspectWAVPCMLayout(encoded)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
decoder := wavcodec.NewDecoder(bytes.NewReader(encoded))
|
||||
decoder.ReadInfo()
|
||||
if err := decoder.Err(); err != nil {
|
||||
return nil, 0, fmt.Errorf("read WAV format: %w", err)
|
||||
}
|
||||
if decoder.WavAudioFormat != layout.format || decoder.NumChans != layout.channels || decoder.SampleRate != layout.sampleRate || decoder.BitDepth != layout.bitDepth {
|
||||
return nil, 0, errors.New("WAV decoder metadata does not match the RIFF header")
|
||||
}
|
||||
if layout.format != wavPCMFormat {
|
||||
return nil, 0, fmt.Errorf("unsupported WAV audio format %d", layout.format)
|
||||
}
|
||||
if layout.channels < 1 || layout.channels > 2 {
|
||||
return nil, 0, fmt.Errorf("unsupported WAV channel count %d", layout.channels)
|
||||
}
|
||||
if layout.sampleRate < minWAVSampleRate || layout.sampleRate > maxWAVSampleRate {
|
||||
return nil, 0, fmt.Errorf("WAV sample rate %d is outside the supported 8000–48000 Hz range", layout.sampleRate)
|
||||
}
|
||||
bytesPerSample := int(layout.bitDepth / 8)
|
||||
if bytesPerSample == 0 || layout.blockAlign != layout.channels*uint16(bytesPerSample) || layout.byteRate != layout.sampleRate*uint32(layout.blockAlign) {
|
||||
return nil, 0, errors.New("WAV block alignment does not match its PCM format")
|
||||
}
|
||||
if layout.bitDepth != 8 && layout.bitDepth != 16 && layout.bitDepth != 24 && layout.bitDepth != 32 {
|
||||
return nil, 0, fmt.Errorf("unsupported WAV PCM bit depth %d", layout.bitDepth)
|
||||
}
|
||||
if layout.dataSize == 0 || layout.dataSize%int(layout.blockAlign) != 0 {
|
||||
return nil, 0, errors.New("WAV data chunk contains no complete PCM frames")
|
||||
}
|
||||
|
||||
if err := decoder.FwdToPCM(); err != nil {
|
||||
return nil, 0, fmt.Errorf("locate WAV data chunk: %w", err)
|
||||
}
|
||||
if err := decoder.Err(); err != nil {
|
||||
return nil, 0, fmt.Errorf("read WAV data chunk: %w", err)
|
||||
}
|
||||
if decoder.PCMChunk == nil {
|
||||
return nil, 0, errors.New("WAV data chunk is unavailable")
|
||||
}
|
||||
dataOffset, err := decoder.Seek(0, io.SeekCurrent)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("read WAV data offset: %w", err)
|
||||
}
|
||||
paddedDataSize := layout.dataSize + (layout.dataSize & 1)
|
||||
if dataOffset != int64(layout.dataOffset) || decoder.PCMSize != paddedDataSize || int64(layout.dataOffset)+int64(layout.dataSize) > int64(layout.riffEnd) {
|
||||
return nil, 0, errors.New("WAV decoder data boundaries do not match the RIFF chunks")
|
||||
}
|
||||
|
||||
expectedSamples := layout.dataSize / bytesPerSample
|
||||
frames := expectedSamples / int(layout.channels)
|
||||
outputSamples := int64(frames) * int64(targetRate) / int64(layout.sampleRate)
|
||||
if outputSamples <= 0 {
|
||||
return nil, 0, errors.New("WAV conversion produced no complete PCM16 samples")
|
||||
}
|
||||
if outputSamples > int64(maxAudioBytes/2) {
|
||||
return nil, 0, errors.New("converted WAV PCM exceeds the configured audio bound")
|
||||
}
|
||||
|
||||
buffer := &goaudio.IntBuffer{Data: make([]int, 4096)}
|
||||
mono := make([]byte, 0, frames*2)
|
||||
decodedSamples := 0
|
||||
for {
|
||||
n, err := decoder.PCMBuffer(buffer)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("decode WAV PCM samples: %w", err)
|
||||
}
|
||||
if n == 0 {
|
||||
break
|
||||
}
|
||||
if n < 0 || n > len(buffer.Data) {
|
||||
return nil, 0, errors.New("WAV decoder returned an invalid sample count")
|
||||
}
|
||||
remaining := expectedSamples - decodedSamples
|
||||
if n > remaining {
|
||||
extra := n - remaining
|
||||
if extra != 1 || layout.dataSize%2 == 0 {
|
||||
return nil, 0, errors.New("WAV decoder returned samples outside the data chunk")
|
||||
}
|
||||
n = remaining
|
||||
}
|
||||
if n%int(layout.channels) != 0 {
|
||||
return nil, 0, errors.New("WAV data ends with an incomplete audio frame")
|
||||
}
|
||||
for i := 0; i < n; i += int(layout.channels) {
|
||||
left, err := wavSampleToPCM16(buffer.Data[i], layout.bitDepth)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
monoSample := left
|
||||
if layout.channels == 2 {
|
||||
right, err := wavSampleToPCM16(buffer.Data[i+1], layout.bitDepth)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
monoSample = int16((int32(left) + int32(right)) / 2)
|
||||
}
|
||||
var sample [2]byte
|
||||
binary.LittleEndian.PutUint16(sample[:], uint16(monoSample))
|
||||
mono = append(mono, sample[:]...)
|
||||
}
|
||||
decodedSamples += n
|
||||
}
|
||||
if decodedSamples != expectedSamples {
|
||||
return nil, 0, fmt.Errorf("WAV data is incomplete: decoded %d of %d samples", decodedSamples, expectedSamples)
|
||||
}
|
||||
|
||||
pcm16 := ResamplePCM16(mono, int(layout.sampleRate), targetRate)
|
||||
if len(pcm16) == 0 || len(pcm16)%2 != 0 || len(pcm16) > maxAudioBytes {
|
||||
return nil, 0, errors.New("converted WAV PCM is empty, incomplete or oversized")
|
||||
}
|
||||
return pcm16, int(layout.sampleRate), nil
|
||||
}
|
||||
|
||||
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)) {
|
||||
return wavPCMLayout{}, errors.New("RIFF/WAVE length is invalid or truncated")
|
||||
}
|
||||
layout := wavPCMLayout{dataOffset: -1, riffEnd: int(riffEnd64)}
|
||||
var sawFormat bool
|
||||
for offset := 12; offset < layout.riffEnd; {
|
||||
if layout.riffEnd-offset < 8 {
|
||||
return wavPCMLayout{}, errors.New("RIFF/WAVE contains a truncated chunk header")
|
||||
}
|
||||
chunkID := string(encoded[offset : offset+4])
|
||||
chunkSize := uint64(binary.LittleEndian.Uint32(encoded[offset+4 : offset+8]))
|
||||
chunkStart := uint64(offset + 8)
|
||||
chunkEnd := chunkStart + chunkSize
|
||||
if chunkEnd > uint64(layout.riffEnd) {
|
||||
return wavPCMLayout{}, errors.New("RIFF/WAVE chunk exceeds the declared file length")
|
||||
}
|
||||
switch chunkID {
|
||||
case "fmt ":
|
||||
if sawFormat || chunkSize < 16 {
|
||||
return wavPCMLayout{}, errors.New("RIFF/WAVE format chunk is missing or invalid")
|
||||
}
|
||||
layout.format = binary.LittleEndian.Uint16(encoded[chunkStart : chunkStart+2])
|
||||
layout.channels = binary.LittleEndian.Uint16(encoded[chunkStart+2 : chunkStart+4])
|
||||
layout.sampleRate = binary.LittleEndian.Uint32(encoded[chunkStart+4 : chunkStart+8])
|
||||
layout.byteRate = binary.LittleEndian.Uint32(encoded[chunkStart+8 : chunkStart+12])
|
||||
layout.blockAlign = binary.LittleEndian.Uint16(encoded[chunkStart+12 : chunkStart+14])
|
||||
layout.bitDepth = binary.LittleEndian.Uint16(encoded[chunkStart+14 : chunkStart+16])
|
||||
sawFormat = true
|
||||
case "data":
|
||||
if !sawFormat || layout.dataOffset >= 0 {
|
||||
return wavPCMLayout{}, errors.New("RIFF/WAVE data chunk is missing, duplicated or invalid")
|
||||
}
|
||||
layout.dataOffset = int(chunkStart)
|
||||
layout.dataSize = int(chunkSize)
|
||||
}
|
||||
paddedEnd := chunkEnd + (chunkSize & 1)
|
||||
if paddedEnd > uint64(layout.riffEnd) {
|
||||
return wavPCMLayout{}, errors.New("RIFF/WAVE chunk padding is truncated")
|
||||
}
|
||||
offset = int(paddedEnd)
|
||||
}
|
||||
if !sawFormat || layout.dataOffset < 0 {
|
||||
return wavPCMLayout{}, errors.New("RIFF/WAVE is missing its format or data chunk")
|
||||
}
|
||||
return layout, nil
|
||||
}
|
||||
|
||||
func wavSampleToPCM16(sample int, bitDepth uint16) (int16, error) {
|
||||
switch bitDepth {
|
||||
case 8:
|
||||
return int16((sample - 128) << 8), nil
|
||||
case 16:
|
||||
return int16(sample), nil
|
||||
case 24:
|
||||
return int16(sample >> 8), nil
|
||||
case 32:
|
||||
return int16(sample >> 16), nil
|
||||
default:
|
||||
return 0, fmt.Errorf("unsupported WAV PCM bit depth %d", bitDepth)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package audio
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDecodeWAVPCM16MonoToInternalFormat(t *testing.T) {
|
||||
want := []int16{1000, -1000, 32767, -32768}
|
||||
encoded := testPCM16WAV(16000, 1, want)
|
||||
|
||||
got, rate, err := DecodeWAVToPCM16Mono(encoded, 16000, 1<<20)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rate != 16000 {
|
||||
t.Fatalf("source rate = %d, want 16000", rate)
|
||||
}
|
||||
if !bytes.Equal(got, pcm16Bytes(want)) {
|
||||
t.Fatalf("decoded PCM16 = %v, want %v", got, pcm16Bytes(want))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeWAVPCM16StereoDownmixesAndResamples(t *testing.T) {
|
||||
encoded := testPCM16WAV(8000, 2, []int16{1000, 3000, 2000, 4000})
|
||||
want := []int16{2000, 2500, 3000, 3000}
|
||||
|
||||
got, rate, err := DecodeWAVToPCM16Mono(encoded, 16000, 1<<20)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rate != 8000 {
|
||||
t.Fatalf("source rate = %d, want 8000", rate)
|
||||
}
|
||||
if !bytes.Equal(got, pcm16Bytes(want)) {
|
||||
t.Fatalf("decoded PCM16 = %v, want %v", got, pcm16Bytes(want))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeWAVRejectsMalformedOrUnsupportedAudio(t *testing.T) {
|
||||
valid := testPCM16WAV(16000, 1, []int16{1000, -1000})
|
||||
truncated := append([]byte(nil), valid[:len(valid)-1]...)
|
||||
wrongCodec := append([]byte(nil), valid...)
|
||||
binary.LittleEndian.PutUint16(wrongCodec[20:22], 3)
|
||||
wrongRate := testPCM16WAV(96000, 1, []int16{1000, -1000})
|
||||
badAlignment := testWAV(1, 16000, 1, 16, []byte{0x01})
|
||||
|
||||
for name, input := range map[string][]byte{
|
||||
"empty": nil,
|
||||
"invalid header": []byte("not a wave file"),
|
||||
"truncated data": truncated,
|
||||
"unsupported codec": wrongCodec,
|
||||
"unsupported rate": wrongRate,
|
||||
"incomplete sample": badAlignment,
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if _, _, err := DecodeWAVToPCM16Mono(input, 16000, 1<<20); err == nil {
|
||||
t.Fatal("invalid WAV was accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeWAVPCMIntegerDepthsConvertToPCM16(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
bits uint16
|
||||
data []byte
|
||||
want []int16
|
||||
}{
|
||||
{name: "8-bit unsigned", bits: 8, data: []byte{0, 128, 255}, want: []int16{-32768, 0, 32512}},
|
||||
{name: "24-bit signed", bits: 24, data: []byte{0, 0, 0x80, 0xff, 0xff, 0x7f}, want: []int16{-32768, 32767}},
|
||||
{name: "32-bit signed", bits: 32, data: []byte{0, 0, 0, 0x80, 0xff, 0xff, 0xff, 0x7f}, want: []int16{-32768, 32767}},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
encoded := testWAV(1, 16000, 1, tt.bits, tt.data)
|
||||
got, rate, err := DecodeWAVToPCM16Mono(encoded, 16000, 1<<20)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rate != 16000 || !bytes.Equal(got, pcm16Bytes(tt.want)) {
|
||||
t.Fatalf("decoded rate=%d PCM16=%v, want rate=16000 PCM16=%v", rate, got, pcm16Bytes(tt.want))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeWAVRejectsOutputOverBound(t *testing.T) {
|
||||
samples := make([]int16, 100)
|
||||
encoded := testPCM16WAV(8000, 1, samples)
|
||||
if len(encoded) >= 300 {
|
||||
t.Fatalf("test WAV is unexpectedly large: %d", len(encoded))
|
||||
}
|
||||
if _, _, err := DecodeWAVToPCM16Mono(encoded, 16000, 300); err == nil {
|
||||
t.Fatal("WAV output over the configured bound was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func testPCM16WAV(sampleRate uint32, channels uint16, samples []int16) []byte {
|
||||
pcm := pcm16Bytes(samples)
|
||||
return testWAV(1, sampleRate, channels, 16, pcm)
|
||||
}
|
||||
|
||||
func pcm16Bytes(samples []int16) []byte {
|
||||
data := make([]byte, len(samples)*2)
|
||||
for i, sample := range samples {
|
||||
binary.LittleEndian.PutUint16(data[i*2:], uint16(sample))
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func testWAV(format, sampleRate uint32, channels uint16, bits uint16, pcm []byte) []byte {
|
||||
blockAlign := channels * (bits / 8)
|
||||
data := make([]byte, 44+len(pcm)+(len(pcm)&1))
|
||||
copy(data[0:4], "RIFF")
|
||||
binary.LittleEndian.PutUint32(data[4:8], uint32(len(data)-8))
|
||||
copy(data[8:12], "WAVE")
|
||||
copy(data[12:16], "fmt ")
|
||||
binary.LittleEndian.PutUint32(data[16:20], 16)
|
||||
binary.LittleEndian.PutUint16(data[20:22], uint16(format))
|
||||
binary.LittleEndian.PutUint16(data[22:24], channels)
|
||||
binary.LittleEndian.PutUint32(data[24:28], sampleRate)
|
||||
binary.LittleEndian.PutUint32(data[28:32], sampleRate*uint32(blockAlign))
|
||||
binary.LittleEndian.PutUint16(data[32:34], blockAlign)
|
||||
binary.LittleEndian.PutUint16(data[34:36], bits)
|
||||
copy(data[36:40], "data")
|
||||
binary.LittleEndian.PutUint32(data[40:44], uint32(len(pcm)))
|
||||
copy(data[44:], pcm)
|
||||
return data
|
||||
}
|
||||
Reference in New Issue
Block a user