# 故事音频预转码方案 — MP3 → Opus 预处理

> 创建时间：2026-03-07
> 状态：已实施

## 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. 压测对比：相同并发下首帧延迟应从秒级降到百毫秒级
