Skip to content

fix: OpenRouter 语音合成流式输出逐块转发 - #126

Open
sheepbox8646 wants to merge 1 commit into
mainfrom
fix/twi-16-openrouter-speech-streaming
Open

sheepbox8646 wants to merge 1 commit into
mainfrom
fix/twi-16-openrouter-speech-streaming

Conversation

@sheepbox8646

Copy link
Copy Markdown
Member

概要

provider/openrouter/speech 的 DoStream 原来先读完整个 SSE 流,收集全部 base64 PCM-16 分块,拼成完整 WAV 后作为单个分块交给调用方。StreamSpeech 因此要等合成全部结束才拿到数据,与 GenerateSpeech 没有区别。

  • 逐块转发:每收到一个 SSE 音频 delta 就解码并发给调用方。本 provider 不额外缓冲,分块节奏由 OpenRouter 与上游模型决定。
  • 输出格式:仍为 audio/wav。
    • 第一块是 44 字节 WAV 头加第一段 PCM。头中 RIFF 与 data 的大小写为 0xFFFFFFFF,这是长度未知的流式 WAV 的通用写法。
    • 之后各块是裸 PCM-16(24 kHz、单声道、小端)。
    • 所有分块按顺序拼接后是可播放的 WAV,PCM 与 GenerateSpeech 逐字节一致。
    • 没有改用裸 PCM,因为仓库里没有对应的 ContentType 约定,而 audio/L16 表示大端,会标错。
  • 样本对齐与 base64:奇数字节留到下一块,保证每块都是整样本。每个 delta 仍按原逻辑单独解码;单独解不出来的片段才按跨 delta 切开的 base64 处理。
  • 并发与错误:发送时监听 ctx.Done(),取消时报告 ctx.Err(),不泄漏 goroutine。非 2xx 响应仍在 DoStream 中同步返回,并走 DecodeOpenRouter。流中途的 OpenRouter 错误事件转成 *sdk.APIError,通过 Err() 报告。
  • GenerateSpeech 的行为变化:
    • 输出形态不变。
    • 流中途的错误事件现在会返回错误,原来会被忽略。
    • 单行 SSE 的上限从 64KB 提到 10MB,与 utils.FetchSSE 一致。
    • 请求增加 Accept: text/event-stream。
  • 注释与文档:删除了“OpenRouter 在音频可用前就发完全部 PCM,因此无法真正流式”这一未经证实的说法。docs/speech.md 新增 OpenRouter TTS 一节。

关联

验证

  • 新增 stream_test.go,用 httptest 覆盖以下情况:
    • 服务端发出第一个 delta 后阻塞,直到测试收到第一块才继续,以此证明首块先于上游结束到达。
    • 奇数长度 delta、奇数总长度。
    • 拼接结果等于 WAV 头加原始 PCM,也与 DoSynthesize 的 PCM 一致。
    • 被切开的 base64、非法 base64。
    • 两条路径上的中途错误事件、流开始前的 403、没有音频。
    • 两种 ctx 取消:卡在读取上游时,以及卡在向调用方发送时。
  • 变异测试:临时把 DoStream 改回“读完再发”,首块测试会超时失败,确认它能抓到这类回归。
  • 本地运行 go build ./...、go vet ./...、go vet -tags integration ./...、go test ./... -short -count=1 -race、golangci-lint run ./...,全部通过。go mod tidy 无变化。

未验证

  • 没有做真实接口验证。经 /chat/completions 输出音频的模型只有 openai/gpt-audio* 和音乐生成模型 google/lyria-3-*。测试环境调用这些模型全部返回 403(服务条款限制,与本改动无关)。
  • 因此首包时间、分块数,以及 OpenRouter 是否会先缓冲再集中下发,都还没有实测。如果上游本身就集中下发,本改动不会带来延迟收益,但也不会变差。
  • provider/mimo/speech 的 DoStream 有同样的单分块问题,不在本 PR 范围内。

🤖 Generated with Claude Code

DoStream 原来先读完整个 SSE 流、拼成完整 WAV,再作为单个分块交给调用方,流式接口没有降低首包延迟。现在每收到一个音频 delta 就解码并转发:第一块是大小字段为 0xFFFFFFFF 的流式 WAV 头加第一段 PCM,之后是按样本对齐的 PCM-16,ContentType 仍为 audio/wav。DoSynthesize 与 DoStream 共用 SSE 解析,GenerateSpeech 的输出形态不变;流中途的 OpenRouter 错误事件现在会作为 APIError 返回。

Linear: TWI-16

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant