feat(ai): default Bailian TTS to PCM and decode WAV

This commit is contained in:
2026-10-12 17:06:08 +08:00
parent e522037cb6
commit 05f37242ca
12 changed files with 641 additions and 18 deletions
+2 -2
View File
@@ -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 测试任务及管理隧道状态仍待重新核验。
+3
View File
@@ -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
+6
View File
@@ -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=
+18 -1
View File
@@ -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")
}
}
+13 -1
View File
@@ -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
}
+152
View File
@@ -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
}
+7 -4
View File
@@ -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) {
+18 -9
View File
@@ -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)
}
+63
View File
@@ -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)
}
})
}
}
+226
View File
@@ -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)
}
}
+132
View File
@@ -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
}