# 故事音频预转码方案 — 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": ["", "", ...] } ``` > 一个 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. 压测对比:相同并发下首帧延迟应从秒级降到百毫秒级