fix: normalize Bailian TTS streaming audio
This commit is contained in:
+11
-22
@@ -107,6 +107,12 @@ func rejected(r *report, err error) {
|
||||
visit(validation)
|
||||
}
|
||||
}
|
||||
|
||||
func readProbeTasks(ctx context.Context, client *configread.Client) ([]configread.DiscoveredTask, error) {
|
||||
tasks, _, err := client.ReadAllTasks(ctx)
|
||||
return tasks, err
|
||||
}
|
||||
|
||||
func check(in input) (r report) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
|
||||
defer cancel()
|
||||
@@ -171,28 +177,11 @@ func check(in input) (r report) {
|
||||
return
|
||||
}
|
||||
r.Providers = len(providers)
|
||||
var tasks []configread.DiscoveredTask
|
||||
cursor := ""
|
||||
seen := map[string]bool{}
|
||||
for {
|
||||
r.Phase = "tasks"
|
||||
page, err := client.ReadTasks(ctx, cursor)
|
||||
if err != nil {
|
||||
rejected(&r, err)
|
||||
return
|
||||
}
|
||||
for _, task := range page.Tasks {
|
||||
if seen[task.TaskID] {
|
||||
rejected(&r, errors.New("duplicate discovery task"))
|
||||
return
|
||||
}
|
||||
seen[task.TaskID] = true
|
||||
tasks = append(tasks, task)
|
||||
}
|
||||
if page.Cursor == "" {
|
||||
break
|
||||
}
|
||||
cursor = page.Cursor
|
||||
r.Phase = "tasks"
|
||||
tasks, err := readProbeTasks(ctx, client)
|
||||
if err != nil {
|
||||
rejected(&r, err)
|
||||
return
|
||||
}
|
||||
r.Tasks = len(tasks)
|
||||
for _, task := range tasks {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
@@ -8,13 +9,52 @@ import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.ipao.vip/rogee/go-sip/internal/configread"
|
||||
"git.ipao.vip/rogee/go-sip/internal/contract"
|
||||
)
|
||||
|
||||
func TestReadProbeTasksStopsOnEmptyPageWithUnchangedCursor(t *testing.T) {
|
||||
const dispatcherID = "c046b893-8628-4589-ae50-619d049248a6"
|
||||
var requests atomic.Int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/internal/v1/dispatcher/tasks" {
|
||||
t.Errorf("unexpected path %q", r.URL.Path)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch r.URL.Query().Get("after") {
|
||||
case "":
|
||||
requests.Add(1)
|
||||
_, _ = w.Write([]byte(`{"dispatcher_id":"` + dispatcherID + `","cursor":"115","tasks":[{"task_id":"6","tenant_id":1001,"status":"running","task_revision":1}]}`))
|
||||
case "115":
|
||||
requests.Add(1)
|
||||
_, _ = w.Write([]byte(`{"dispatcher_id":"` + dispatcherID + `","cursor":"115","tasks":[]}`))
|
||||
default:
|
||||
t.Errorf("unexpected task cursor %q", r.URL.RawQuery)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
client, err := configread.NewClient(server.URL, dispatcherID, "test-secret", server.Client())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
tasks, err := readProbeTasks(ctx, client)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(tasks) != 1 || tasks[0].TaskID != "6" || requests.Load() != 2 {
|
||||
t.Fatalf("tasks=%+v request_count=%d, want one task and two page requests", tasks, requests.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnvironmentRejectsMissingConfigurationWithoutNetworking(t *testing.T) {
|
||||
t.Setenv("DISPATCHER_ID", "")
|
||||
t.Setenv("AGENT_ID", "")
|
||||
|
||||
+6
-3
@@ -7,9 +7,12 @@
|
||||
- [x] 缺失、非整数、超出 8–48 kHz 范围或转换后没有完整 PCM16 样本时明确失败;不改写参数、不换率、不换服务商。
|
||||
- [x] 复用已有 PCM16 转换能力;TDD 覆盖 16→8 kHz ASR、8↔16 kHz 媒体转换、同率直通、分块收发和错误输入。
|
||||
- [ ] 在获批的隔离 ASR-only 与 full-AI 任务上完成真实 ASR 验证;本地测试不能代替供应商和通话验收。
|
||||
- [x] Bailian task TTS 在 format 缺省时默认原始 PCM16/16000 Hz,并显式发送到供应商;支持 PCM16 归一化、MP3 解码及 PCM WAV 解码/下混/重采样为内部单声道 16 kHz PCM16。未知格式、损坏音频或不支持的 WAV 子格式仍明确失败。
|
||||
- [ ] 在隔离 full-AI 任务上核对真实 provider 返回格式/采样率,并完成 TTS、LLM、ASR 全链路验证。
|
||||
- [x] Bailian task TTS 在 format 缺省时默认原始 PCM16/16000 Hz,并显式发送到供应商;支持 PCM16 归一化、有效 MP3 解码及 PCM WAV 解码/下混/重采样为内部单声道 16 kHz PCM16。MP3 解码器已换为支持 MPEG 2.5/8 kHz 的 `github.com/hujm2023/go-mp3/v2`;本地含 ID3 的 8 kHz 回归样本通过。解码初始化错误保留底层原因。
|
||||
- [x] WAV RIFF 声明长度异常时报告 RIFF/data 声明与实收长度。仅在完整 Bailian task 响应带有已观测到的精确流式占位长度、标准 44-byte `fmt `/`data` 头时,按实际收到字节修正长度,再交给严格 WAV 解码器验证;其它长度或头结构仍失败关闭。
|
||||
- [x] 新 MP3 decoder 已用一次真实 8 kHz MPEG 2.5 Bailian 响应验证,成功解码并归一化为内部单声道 16 kHz PCM16;不代表真实 SIP 通话或完整 AI 链路已验收。
|
||||
- [x] 修复后以 task 6/revision 1 获批执行一次真实 WAV TTS 验证:RIFF 声明 2,147,483,591 bytes / 实收 19,884 bytes,data 声明 2,147,483,547 bytes / 可用 19,840 bytes;精确占位长度修正通过,实际源采样率 8 kHz,归一化为内部 16 kHz PCM16(39,680 bytes,1,240 ms)。SQLite 任务快照 SHA-256 前后相同;覆盖只在进程内,没有拨号或 OSS 写入;音频未留存。
|
||||
- [ ] 在获批的隔离 ASR-only/full-AI 任务上完成真实 ASR、LLM 与通话链路验证;本地音频和 Mock 检查不能替代供应商、录音或 OSS 验收。
|
||||
|
||||
唯一规则来源是共享合同的 [业务规范](../contracts/schema/saas-dispatcher.md) 与 [任务 Schema](../contracts/schema/http-task-detail.schema.json),这里仅记录未完成工作,不维护第二份字段定义。
|
||||
|
||||
**当前边界:** ASR/TTS 版本 `05f3724` 与共享合同 `940d922` 已部署到 `server.sip`,`--debug` 开启,未重置数据。当前 Asterisk、Agent、Dispatcher 均 enabled+active,活动通话为 0;状态仍为 `business_ready=false`、`dependency_state=saas_pending`,但最新 `waiting_reason` 为空。部署配置的 SaaS 基础地址与使用者提供的地址一致。Dispatcher 启动时读取 SIP 配置曾收到 HTTP 502(2026-10-12 17:11:36 +08:00);随后 17:11:47–17:11:49 的配置 GET 响应为 HTTP 200。尚无证据说明当前仍是同一 HTTP 故障,需 SaaS 方核对该时段的 `/internal/v1/dispatcher/sip` 服务/网关日志。任务快照、WAV 供应商响应及真实 AI 链路仍未核验。管理页面浏览器连接超时,未创建/修改任务,未请求真实 AI,未对 `15003164745` 或 `15830461047` 拨号;asr-only 测试任务仍待确认。
|
||||
**当前边界:** ASR/TTS 版本 `05f3724` 与共享合同 `940d922` 已部署到 `server.sip`,`--debug` 开启,未重置数据。当前 Asterisk、Agent、Dispatcher 均 enabled+active,活动通话为 0;状态仍为 `business_ready=false`、`dependency_state=saas_pending`,但最新 `waiting_reason` 为空。部署配置的 SaaS 基础地址与使用者提供的地址一致。Dispatcher 启动时读取 SIP 配置曾收到 HTTP 502(2026-10-12 17:11:36 +08:00);随后 17:11:47–17:11:49 的配置 GET 响应为 HTTP 200。尚无证据说明当前仍是同一 HTTP 故障,需 SaaS 方核对该时段的 `/internal/v1/dispatcher/sip` 服务/网关日志。任务快照未改。真实 PCM TTS/ASR 已按既有授权验证;MP3 与 WAV 新解码路径均各有一次真实 provider 响应通过,WAV 流式占位长度修正与 8 kHz→16 kHz PCM16 归一化已实测。部署探针曾因继续读取末尾空任务页而重复请求;现复用 `ReadAllTasks` 并以回归测试覆盖。修复后实机只读预检确认 MQ 登录/关闭、3 trunks、1 provider、6 tasks 均通过。诊断音频未留存。未拨号、未写 OSS。此次 MP3/WAV 改动仍仅在本地未提交、未推送、未部署;ASR-only/full-AI 通话链路仍待另行验收。管理页面浏览器连接超时,未创建/修改任务,管理页面浏览器连接超时,未创建/修改任务;此前按授权完成的真实 PCM/MP3/WAV TTS 请求仅用于格式诊断,当前不再发起真实 AI 请求。未对 `15003164745` 或 `15830461047` 拨号;ASR-only 测试任务仍待确认。
|
||||
|
||||
@@ -13,7 +13,7 @@ require (
|
||||
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
|
||||
github.com/hujm2023/go-mp3/v2 v2.0.1
|
||||
github.com/openai/openai-go/v3 v3.62.0
|
||||
github.com/pion/rtp v1.10.5
|
||||
github.com/pion/rtp/v2 v2.0.0
|
||||
|
||||
@@ -56,11 +56,10 @@ 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/hujm2023/go-mp3/v2 v2.0.1 h1:xZn+v7cbi9DWIRN4aJ2hbXeiqjUx76w08EjlIgnw3jo=
|
||||
github.com/hujm2023/go-mp3/v2 v2.0.1/go.mod h1:XCnQpP8pNjvtJFPHFf73FPJwE+680k8fDCPsw4bVCK8=
|
||||
github.com/inconshreveable/log15 v0.0.0-20201112154412-8562bdadbbac h1:n1DqxAo4oWPMvH1+v+DLYlMCecgumhhgnxAPdqDIFHI=
|
||||
github.com/inconshreveable/log15 v0.0.0-20201112154412-8562bdadbbac/go.mod h1:cOaXtrgN4ScfRrD9Bre7U1thNq5RtJ8ZoP4iXVGRj6o=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
@@ -185,7 +184,6 @@ 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=
|
||||
|
||||
@@ -18,5 +18,9 @@ func bailianFailureSummary(err error) (string, int) {
|
||||
if errors.Is(err, context.Canceled) {
|
||||
return "canceled", 0
|
||||
}
|
||||
var decodeErr *bailianTTSAudioDecodeError
|
||||
if errors.As(err, &decodeErr) {
|
||||
return "audio_decode", 0
|
||||
}
|
||||
return "task_websocket", 0
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
@@ -47,6 +48,37 @@ func TestCosyVoiceWAVTaskReturnsNormalizedPCM16(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCosyVoiceWAVRepairsObservedStreamingLengthSentinels(t *testing.T) {
|
||||
samples := []int16{1000, -1000, 2000, -2000}
|
||||
encoded := testTTSWAV(8000, samples)
|
||||
binary.LittleEndian.PutUint32(encoded[4:8], 0x7fffffbf)
|
||||
binary.LittleEndian.PutUint32(encoded[40:44], 0x7fffff9b)
|
||||
|
||||
got, sourceRate, err := normalizeBailianTTSOutput(TTSConfig{OutputFormat: "wav", OutputSampleRate: 8000}, encoded)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if sourceRate != 8000 {
|
||||
t.Fatalf("WAV source sample rate = %d, want 8000", sourceRate)
|
||||
}
|
||||
want := audio.ResamplePCM16(testTTSPCM16(samples), 8000, internalPCM16SampleRate)
|
||||
if !bytes.Equal(got, want) {
|
||||
t.Fatalf("normalized streaming WAV = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCosyVoiceWAVDoesNotRepairUnknownLengthMismatch(t *testing.T) {
|
||||
encoded := testTTSWAV(8000, []int16{1000, -1000})
|
||||
binary.LittleEndian.PutUint32(encoded[4:8], uint32(len(encoded)-8+1024))
|
||||
_, _, err := normalizeBailianTTSOutput(TTSConfig{OutputFormat: "wav", OutputSampleRate: 8000}, encoded)
|
||||
if err == nil {
|
||||
t.Fatal("unknown WAV length mismatch was repaired")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "RIFF declared") {
|
||||
t.Fatalf("WAV length diagnostics were lost: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCosyVoiceWAVDerivesRateFromContainerWhenUnset(t *testing.T) {
|
||||
binding := bailianFixtureWithTTSParams(t, map[string]any{"format": "wav"})
|
||||
samples := []int16{1000, -1000}
|
||||
@@ -70,6 +102,17 @@ func TestNormalizeBailianWAVRejectsConfiguredSampleRateMismatch(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBailianFailureSummaryClassifiesTTSDecodeErrors(t *testing.T) {
|
||||
for _, format := range []string{"MP3", "WAV"} {
|
||||
decodeErr := &bailianTTSAudioDecodeError{format: strings.ToLower(format), err: fmt.Errorf("decoder rejected audio")}
|
||||
err := fmt.Errorf("approved TTS synthesis failed: %w", decodeErr)
|
||||
category, status := bailianFailureSummary(err)
|
||||
if category != "audio_decode" || status != 0 {
|
||||
t.Errorf("%s decode failure summary = (%q, %d), want (audio_decode, 0)", format, category, status)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"git.ipao.vip/rogee/go-sip/internal/audio"
|
||||
)
|
||||
@@ -12,8 +14,22 @@ const (
|
||||
ttsOutputFormatPCM = "pcm"
|
||||
ttsOutputFormatMP3 = "mp3"
|
||||
ttsOutputFormatWAV = "wav"
|
||||
|
||||
bailianStreamingWAVRIFFSizeSentinel = uint32(0x7fffffbf)
|
||||
bailianStreamingWAVDataSizeSentinel = uint32(0x7fffff9b)
|
||||
)
|
||||
|
||||
type bailianTTSAudioDecodeError struct {
|
||||
format string
|
||||
err error
|
||||
}
|
||||
|
||||
func (e *bailianTTSAudioDecodeError) Error() string {
|
||||
return fmt.Sprintf("decode TTS %s: %v", e.format, e.err)
|
||||
}
|
||||
|
||||
func (e *bailianTTSAudioDecodeError) Unwrap() error { return e.err }
|
||||
|
||||
func parseBailianTTSOutputSettings(params map[string]json.RawMessage) (string, int, error) {
|
||||
format := ttsOutputFormatPCM
|
||||
if rawFormat, configured := params["format"]; configured {
|
||||
@@ -39,6 +55,32 @@ func parseBailianTTSOutputSettings(params map[string]json.RawMessage) (string, i
|
||||
return format, rate, nil
|
||||
}
|
||||
|
||||
// repairObservedBailianStreamingWAVLengths corrects only the exact RIFF/data
|
||||
// size sentinels observed in a completed Bailian streaming WAV response. The
|
||||
// existing WAV decoder still validates the resulting PCM container; all other
|
||||
// length mismatches remain errors.
|
||||
func repairObservedBailianStreamingWAVLengths(encoded []byte) ([]byte, bool) {
|
||||
const canonicalWAVHeaderBytes = 44
|
||||
if len(encoded) < canonicalWAVHeaderBytes ||
|
||||
string(encoded[0:4]) != "RIFF" ||
|
||||
binary.LittleEndian.Uint32(encoded[4:8]) != bailianStreamingWAVRIFFSizeSentinel ||
|
||||
string(encoded[8:12]) != "WAVE" ||
|
||||
string(encoded[12:16]) != "fmt " ||
|
||||
binary.LittleEndian.Uint32(encoded[16:20]) != 16 ||
|
||||
string(encoded[36:40]) != "data" ||
|
||||
binary.LittleEndian.Uint32(encoded[40:44]) != bailianStreamingWAVDataSizeSentinel {
|
||||
return nil, false
|
||||
}
|
||||
actualDataBytes := len(encoded) - canonicalWAVHeaderBytes
|
||||
if actualDataBytes <= 0 {
|
||||
return nil, false
|
||||
}
|
||||
corrected := append([]byte(nil), encoded...)
|
||||
binary.LittleEndian.PutUint32(corrected[4:8], uint32(len(corrected)-8))
|
||||
binary.LittleEndian.PutUint32(corrected[40:44], uint32(actualDataBytes))
|
||||
return corrected, true
|
||||
}
|
||||
|
||||
func normalizeBailianTTSOutput(approved TTSConfig, encoded []byte) ([]byte, int, error) {
|
||||
if len(encoded) == 0 {
|
||||
return nil, 0, errors.New("TTS provider returned empty audio")
|
||||
@@ -65,7 +107,7 @@ func normalizeBailianTTSOutput(approved TTSConfig, encoded []byte) ([]byte, int,
|
||||
case ttsOutputFormatMP3:
|
||||
pcm16, sourceRate, err := audio.DecodeMP3ToPCM16Mono(encoded, internalPCM16SampleRate, maxBailianPCMBytes)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("decode TTS MP3: %w", err)
|
||||
return nil, 0, &bailianTTSAudioDecodeError{format: ttsOutputFormatMP3, err: 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)
|
||||
@@ -73,9 +115,19 @@ func normalizeBailianTTSOutput(approved TTSConfig, encoded []byte) ([]byte, int,
|
||||
return pcm16, sourceRate, nil
|
||||
|
||||
case ttsOutputFormatWAV:
|
||||
pcm16, sourceRate, err := audio.DecodeWAVToPCM16Mono(encoded, internalPCM16SampleRate, maxBailianPCMBytes)
|
||||
wavInput := encoded
|
||||
if repaired, ok := repairObservedBailianStreamingWAVLengths(encoded); ok {
|
||||
wavInput = repaired
|
||||
slog.Info("Bailian TTS streaming WAV length sentinels normalized",
|
||||
"original_riff_declared_bytes", uint64(bailianStreamingWAVRIFFSizeSentinel)+8,
|
||||
"riff_received_bytes", len(encoded),
|
||||
"original_data_declared_bytes", bailianStreamingWAVDataSizeSentinel,
|
||||
"data_available_bytes", len(encoded)-44,
|
||||
)
|
||||
}
|
||||
pcm16, sourceRate, err := audio.DecodeWAVToPCM16Mono(wavInput, internalPCM16SampleRate, maxBailianPCMBytes)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("decode TTS WAV: %w", err)
|
||||
return nil, 0, &bailianTTSAudioDecodeError{format: ttsOutputFormatWAV, err: 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)
|
||||
|
||||
@@ -4,9 +4,10 @@ import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"github.com/hajimehoshi/go-mp3"
|
||||
"github.com/hujm2023/go-mp3/v2"
|
||||
)
|
||||
|
||||
// DecodeMP3ToPCM16Mono decodes MP3 to signed little-endian mono PCM16 at the
|
||||
@@ -25,7 +26,7 @@ func DecodeMP3ToPCM16Mono(encoded []byte, targetSampleRate, maxOutputBytes int)
|
||||
|
||||
decoder, err := mp3.NewDecoder(bytes.NewReader(encoded))
|
||||
if err != nil {
|
||||
return nil, 0, errors.New("MP3 stream could not be decoded")
|
||||
return nil, 0, fmt.Errorf("MP3 stream could not be decoded: %w", err)
|
||||
}
|
||||
sourceSampleRate := decoder.SampleRate()
|
||||
if sourceSampleRate < 8000 || sourceSampleRate > 48000 {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package audio
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
@@ -34,9 +36,33 @@ func TestDecodeMP3ToPCM16Mono(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeMP3ToPCM16MonoSupportsMPEG25At8k(t *testing.T) {
|
||||
// Fixture is a 1-second 440-Hz mono MP3 generated with FFmpeg/libmp3lame at 8 kHz, including ID3v2 metadata.
|
||||
encoded, err := os.ReadFile("testdata/sine_8k_mono_mpeg25.mp3")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.HasPrefix(encoded, []byte("ID3")) {
|
||||
t.Fatal("fixture must include the provider-style ID3v2 header")
|
||||
}
|
||||
|
||||
pcm16, sourceRate, err := DecodeMP3ToPCM16Mono(encoded, 16000, 1<<20)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if sourceRate != 8000 {
|
||||
t.Fatalf("source sample rate = %d, want 8000", sourceRate)
|
||||
}
|
||||
if len(pcm16) == 0 || len(pcm16)%2 != 0 {
|
||||
t.Fatalf("decoded PCM16 length = %d, want nonempty complete samples", len(pcm16))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeMP3ToPCM16MonoRejectsInvalidAndOversizedAudio(t *testing.T) {
|
||||
if _, _, err := DecodeMP3ToPCM16Mono([]byte("not an MP3"), 16000, 1<<20); err == nil {
|
||||
t.Fatal("invalid MP3 was accepted")
|
||||
} else if errors.Unwrap(err) == nil {
|
||||
t.Fatalf("decoder failure cause was hidden: %v", err)
|
||||
}
|
||||
encoded, err := os.ReadFile("testdata/sine_16k_mono.mp3")
|
||||
if err != nil {
|
||||
|
||||
BIN
Binary file not shown.
+51
-1
@@ -158,14 +158,64 @@ func DecodeWAVToPCM16Mono(encoded []byte, targetRate, maxAudioBytes int) ([]byte
|
||||
return pcm16, int(layout.sampleRate), nil
|
||||
}
|
||||
|
||||
type wavLengthMismatchError struct {
|
||||
riffDeclaredBytes uint64
|
||||
receivedBytes uint64
|
||||
dataChunkDeclaredBytes uint64
|
||||
dataChunkAvailableBytes uint64
|
||||
hasDataChunk bool
|
||||
}
|
||||
|
||||
func (e *wavLengthMismatchError) Error() string {
|
||||
if e.hasDataChunk {
|
||||
return fmt.Sprintf("RIFF/WAVE length invalid or truncated: RIFF declared %d bytes, received %d bytes; data chunk declared %d bytes, available %d bytes", e.riffDeclaredBytes, e.receivedBytes, e.dataChunkDeclaredBytes, e.dataChunkAvailableBytes)
|
||||
}
|
||||
return fmt.Sprintf("RIFF/WAVE length invalid or truncated: RIFF declared %d bytes, received %d bytes; data chunk size unavailable", e.riffDeclaredBytes, e.receivedBytes)
|
||||
}
|
||||
|
||||
func wavLengthMismatch(encoded []byte, riffDeclaredBytes uint64) error {
|
||||
mismatch := &wavLengthMismatchError{
|
||||
riffDeclaredBytes: riffDeclaredBytes,
|
||||
receivedBytes: uint64(len(encoded)),
|
||||
}
|
||||
for offset := 12; offset+8 <= len(encoded); {
|
||||
chunkID := string(encoded[offset : offset+4])
|
||||
chunkSize := uint64(binary.LittleEndian.Uint32(encoded[offset+4 : offset+8]))
|
||||
chunkStart := uint64(offset + 8)
|
||||
if chunkID == "data" {
|
||||
mismatch.hasDataChunk = true
|
||||
mismatch.dataChunkDeclaredBytes = chunkSize
|
||||
available := uint64(len(encoded)) - chunkStart
|
||||
if available > chunkSize {
|
||||
available = chunkSize
|
||||
}
|
||||
mismatch.dataChunkAvailableBytes = available
|
||||
return mismatch
|
||||
}
|
||||
chunkEnd := chunkStart + chunkSize
|
||||
if chunkEnd < chunkStart || chunkEnd > uint64(len(encoded)) {
|
||||
return mismatch
|
||||
}
|
||||
paddedEnd := chunkEnd + (chunkSize & 1)
|
||||
if paddedEnd > uint64(len(encoded)) {
|
||||
return mismatch
|
||||
}
|
||||
offset = int(paddedEnd)
|
||||
}
|
||||
return mismatch
|
||||
}
|
||||
|
||||
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)) {
|
||||
if riffEnd64 < 12 {
|
||||
return wavPCMLayout{}, errors.New("RIFF/WAVE length is invalid or truncated")
|
||||
}
|
||||
if riffEnd64 > uint64(len(encoded)) {
|
||||
return wavPCMLayout{}, wavLengthMismatch(encoded, riffEnd64)
|
||||
}
|
||||
layout := wavPCMLayout{dataOffset: -1, riffEnd: int(riffEnd64)}
|
||||
var sawFormat bool
|
||||
for offset := 12; offset < layout.riffEnd; {
|
||||
|
||||
@@ -62,6 +62,21 @@ func TestDecodeWAVRejectsMalformedOrUnsupportedAudio(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeWAVReportsDeclaredRIFFAndDataLengths(t *testing.T) {
|
||||
encoded := testPCM16WAV(8000, 1, []int16{1000, -1000})
|
||||
binary.LittleEndian.PutUint32(encoded[4:8], uint32(len(encoded)+32-8))
|
||||
binary.LittleEndian.PutUint32(encoded[40:44], uint32(len(encoded)-44+16))
|
||||
|
||||
_, _, err := DecodeWAVToPCM16Mono(encoded, 16000, 1<<20)
|
||||
if err == nil {
|
||||
t.Fatal("truncated WAV was accepted")
|
||||
}
|
||||
const want = "RIFF/WAVE length invalid or truncated: RIFF declared 80 bytes, received 48 bytes; data chunk declared 20 bytes, available 4 bytes"
|
||||
if err.Error() != want {
|
||||
t.Fatalf("error = %q, want %q", err, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeWAVPCMIntegerDepthsConvertToPCM16(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
Reference in New Issue
Block a user