All checks were successful
Build and Deploy Backend / build-and-deploy (push) Successful in 5m41s
194 lines
5.9 KiB
Markdown
194 lines
5.9 KiB
Markdown
# 故事音频预转码方案 — 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)
|
||
|
||
---
|
||
|
||
## 改动概览
|
||
|
||
| 改动范围 | 文件 | 改动大小 |
|
||
|---------|------|---------|
|
||
| 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`
|
||
|
||
```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 转为 PCM(16kHz, 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. 压测对比:相同并发下首帧延迟应从秒级降到百毫秒级
|