rtc_backend/docs/opus-preconvert-plan.md
repair-agent 134ccb70f3
All checks were successful
Build and Deploy Backend / build-and-deploy (push) Successful in 5m41s
fix 音频并发优化
2026-03-03 17:21:46 +08:00

194 lines
5.9 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 故事音频预转码方案 — 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 |
详见 [压测报告](../rtc_backend/hw_service_go/test/stress/REPORT.md)
---
## 改动概览
| 改动范围 | 文件 | 改动大小 |
|---------|------|---------|
| DjangoStory 模型 | `apps/stories/models.py` | 小(加 1 个字段) |
| DjangoTTS 服务 | `apps/stories/services/tts_service.py` | 中(加预转码逻辑) |
| Django故事 API | `apps/devices/views.py` | 小(返回新字段) |
| Django迁移文件 | `apps/stories/migrations/` | 自动生成 |
| GoAPI 响应结构体 | `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`
```python
# 在 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` 之前),增加:
1. 调用 ffmpeg 将 MP3 bytes 转为 PCM16kHz, mono, s16le
2. 用 Python opuslib或 subprocess 调 ffmpeg 直出 opus编码为 60ms 帧
3. 将帧列表序列化为紧凑格式上传 OSS
4. 保存 `story.opus_url`
**Opus 数据格式JSON + base64**
```json
{
"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
```python
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` 方法)
```python
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`
```go
type StoryInfo struct {
Title string `json:"title"`
AudioURL string `json:"audio_url"`
OpusURL string `json:"opus_url"` // 新增
}
```
**文件**`hw_service_go/internal/audio/` — 新增函数
```go
// FetchOpusFrames 从 OSS 下载预转码的 Opus JSON 文件,解析为帧列表
func FetchOpusFrames(ctx context.Context, opusURL string) ([][]byte, error)
```
**文件**`hw_service_go/internal/handler/story.go` — 修改播放逻辑
```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 批量转码已有故事:
```bash
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 分帧
---
## 验证方法
1. 本地创建一个故事 + TTS → 检查 `opus_url` 是否生成
2. `curl /api/v1/devices/stories/?mac_address=...` 确认返回含 `opus_url`
3. hw_service_go 本地启动,连接测试页面触发故事 → 确认跳过 ffmpeg
4. 压测对比:相同并发下首帧延迟应从秒级降到百毫秒级