5.9 KiB
故事音频预转码方案 — MP3 → Opus 预处理
创建时间:2026-03-03 状态:待实施
Context
问题:当前 hw_service_go 每次播放故事都实时执行 MP3下载 → ffmpeg转码 → Opus编码,ffmpeg 是 CPU 密集型操作,压测显示 0.5 核 CPU 下 5 个并发就首帧延迟 4.5s。
方案:在 TTS 生成 MP3 后,立即预转码为 Opus 帧数据(JSON 格式)并上传 OSS。hw_service_go 播放时直接下载预处理好的 Opus 数据,跳过 ffmpeg,首帧延迟从秒级降到毫秒级。
预期效果:
- hw_service_go 播放时 零 CPU 转码开销
- 首帧延迟从 ~2s 降到 ~200ms
- 并发播放容量从 5-10 个提升到 100+(瓶颈变为网络/内存)
压测数据参考(单 Pod, 0.5 核 CPU, 512Mi):
| 并发故事数 | 首帧延迟 | 帧数/故事 | 错误 |
|---|---|---|---|
| 2 | 2.0s | 796 | 0 |
| 5 | 4.5s | 796 | 0 |
| 10 | 8.7s | 796 | 0 |
| 20 | 17.4s | 796 | 0 |
详见 压测报告
改动概览
| 改动范围 | 文件 | 改动大小 |
|---|---|---|
| Django:Story 模型 | apps/stories/models.py |
小(加 1 个字段) |
| Django:TTS 服务 | apps/stories/services/tts_service.py |
中(加预转码逻辑) |
| Django:故事 API | apps/devices/views.py |
小(返回新字段) |
| Django:迁移文件 | apps/stories/migrations/ |
自动生成 |
| Go:API 响应结构体 | hw_service_go/internal/rtcclient/client.go |
小 |
| Go:播放处理器 | hw_service_go/internal/handler/story.go |
中(分支逻辑) |
| Go:新增 Opus 下载 | hw_service_go/internal/audio/ |
中(新函数) |
总改动量:中等偏小,核心改动集中在 3 个文件。
详细方案
Step 1: Story 模型加字段
文件:apps/stories/models.py
# 在 Story 模型中新增
opus_url = models.URLField('Opus音频URL', max_length=500, blank=True, default='')
opus_url 存储预转码后的 Opus JSON 文件地址。为空表示未转码(兼容旧数据)。
然后 makemigrations + migrate。
Step 2: TTS 服务中增加预转码
文件:apps/stories/services/tts_service.py
在 MP3 上传 OSS 成功后(第 88 行 story.save 之前),增加:
- 调用 ffmpeg 将 MP3 bytes 转为 PCM(16kHz, mono, s16le)
- 用 Python opuslib(或 subprocess 调 ffmpeg 直出 opus)编码为 60ms 帧
- 将帧列表序列化为紧凑格式上传 OSS
- 保存
story.opus_url
Opus 数据格式(JSON + base64):
{
"sample_rate": 16000,
"channels": 1,
"frame_duration_ms": 60,
"frames": ["<base64帧1>", "<base64帧2>", ...]
}
一个 5 分钟故事约 5000 帧 × ~300 bytes/帧 ≈ 1.5MB JSON,压缩后 ~1MB,对 OSS 存储无压力。
转码实现(subprocess 调 ffmpeg + opuslib):
import subprocess, base64, json, opuslib
def convert_mp3_to_opus_frames(mp3_bytes):
"""MP3 → PCM → Opus 帧列表"""
# ffmpeg: MP3 → PCM
proc = subprocess.run(
['ffmpeg', '-i', 'pipe:0', '-ar', '16000', '-ac', '1', '-f', 's16le', 'pipe:1'],
input=mp3_bytes, capture_output=True
)
pcm = proc.stdout
# Opus 编码:每帧 960 samples (60ms @ 16kHz)
encoder = opuslib.Encoder(16000, 1, opuslib.APPLICATION_AUDIO)
frame_size = 960
frames = []
for i in range(0, len(pcm) // 2 - frame_size + 1, frame_size):
chunk = pcm[i*2 : (i+frame_size)*2]
opus_frame = encoder.encode(chunk, frame_size)
frames.append(base64.b64encode(opus_frame).decode())
return json.dumps({
"sample_rate": 16000,
"channels": 1,
"frame_duration_ms": 60,
"frames": frames
})
上传路径:stories/audio-opus/YYYYMMDD/{uuid}.json
Step 3: Django API 返回 opus_url
文件:apps/devices/views.py(stories_by_mac 方法)
return success(data={
'title': story.title,
'audio_url': story.audio_url,
'opus_url': story.opus_url, # 新增
})
Step 4: Go 服务适配
文件:hw_service_go/internal/rtcclient/client.go
type StoryInfo struct {
Title string `json:"title"`
AudioURL string `json:"audio_url"`
OpusURL string `json:"opus_url"` // 新增
}
文件:hw_service_go/internal/audio/ — 新增函数
// FetchOpusFrames 从 OSS 下载预转码的 Opus JSON 文件,解析为帧列表
func FetchOpusFrames(ctx context.Context, opusURL string) ([][]byte, error)
文件:hw_service_go/internal/handler/story.go — 修改播放逻辑
// 优先使用预转码 Opus
var frames [][]byte
if story.OpusURL != "" {
frames, err = audio.FetchOpusFrames(ctx, story.OpusURL)
} else {
// 兜底:旧数据无预转码,走实时转码
frames, err = audio.MP3URLToOpusFrames(ctx, story.AudioURL)
}
Step 5: 历史数据迁移(可选)
写一个 management command 批量转码已有故事:
python manage.py convert_stories_to_opus
兼容性
- 旧故事(
opus_url为空):hw_service_go 自动 fallback 到实时 ffmpeg 转码,无影响 - 新故事:TTS 生成时自动预转码,hw_service_go 直接下载 Opus 数据
- App 端:无任何改动,
audio_url(MP3)仍然存在供 App 播放器使用
依赖
- Django 端需安装
opuslib(Python Opus 绑定):pip install opuslib - Django 服务器需有
ffmpeg(已有,用于 TTS 后处理等) - 如果不想引入 opuslib 依赖,可以用
ffmpeg -c:a libopus直接输出 opus,但需要自行按 60ms 分帧
验证方法
- 本地创建一个故事 + TTS → 检查
opus_url是否生成 curl /api/v1/devices/stories/?mac_address=...确认返回含opus_url- hw_service_go 本地启动,连接测试页面触发故事 → 确认跳过 ffmpeg
- 压测对比:相同并发下首帧延迟应从秒级降到百毫秒级