docs(audio): 添加 oto/v3 重构设计和实施计划文档
All checks were successful
ci/woodpecker/tag/woodpecker Pipeline was successful
All checks were successful
ci/woodpecker/tag/woodpecker Pipeline was successful
- 设计文档:架构设计、API 设计、并发模型 - 实施计划:16 个任务,完整的实施步骤 - 验收标准:测试覆盖、功能验证、性能指标
This commit is contained in:
429
docs/superpowers/specs/2026-04-08-oto-audio-refactor-design.md
Normal file
429
docs/superpowers/specs/2026-04-08-oto-audio-refactor-design.md
Normal file
@@ -0,0 +1,429 @@
|
||||
# oto/v3 音频播放重构设计
|
||||
|
||||
**日期:** 2026-04-08
|
||||
**作者:** Claude
|
||||
**状态:** 设计阶段
|
||||
|
||||
## 1. 概述
|
||||
|
||||
### 1.1 重构目标
|
||||
|
||||
使用 `oto/v3` 完全重构项目中的音频播放功能,移除 `beep/v2` 依赖,解决音频播放问题。
|
||||
|
||||
### 1.2 重构范围
|
||||
|
||||
- ✅ 所有音频播放功能: WAV/MP3 一次性播放、BGM 循环播放、待机音频
|
||||
- ✅ 保持现有业务逻辑不变
|
||||
- ✅ 遵循 oto/v3 官方最佳实践
|
||||
- ✅ 高效、简洁、优雅的实现
|
||||
|
||||
### 1.3 当前问题
|
||||
|
||||
- 音频未正确播放(具体表现待重构后验证)
|
||||
- 依赖 `beep/v2` 中间层,增加复杂度
|
||||
- 代码不够简洁优雅
|
||||
|
||||
## 2. 架构设计
|
||||
|
||||
### 2.1 整体结构
|
||||
|
||||
```
|
||||
业务层 (middleware/routes)
|
||||
↓
|
||||
音频播放层 (pkg/audio)
|
||||
↓
|
||||
oto/v3 核心层
|
||||
```
|
||||
|
||||
**设计原则:**
|
||||
- 单一 oto.Context: 全局初始化一次,所有播放共享
|
||||
- 独立 Player: 每次播放创建新的 Player,互不干扰
|
||||
- 格式解码器: MP3/WAV 解码器直接传给 oto.Player
|
||||
- 直接流式播放: 无需预先转 PCM,oto 自动处理
|
||||
- 自动重采样: oto 会自动处理采样率不匹配的情况
|
||||
|
||||
### 2.2 依赖管理
|
||||
|
||||
**移除:**
|
||||
- `github.com/gopxl/beep/v2`
|
||||
|
||||
**保留并改为直接依赖:**
|
||||
- `github.com/ebitengine/oto/v3`
|
||||
|
||||
**保留:**
|
||||
- `github.com/hajimehoshi/go-mp3`
|
||||
|
||||
**新增:**
|
||||
- `github.com/go-audio/wav` - WAV 解码
|
||||
|
||||
## 3. 核心组件设计
|
||||
|
||||
### 3.1 全局状态管理
|
||||
|
||||
```go
|
||||
var (
|
||||
otoCtx *oto.Context
|
||||
otoOnce sync.Once
|
||||
)
|
||||
|
||||
// 音频配置常量
|
||||
const (
|
||||
DefaultSampleRate = 44100 // 采样率
|
||||
DefaultChannelCount = 2 // 声道数(立体声)
|
||||
)
|
||||
|
||||
func initContext() (*oto.Context, error) {
|
||||
var initErr error
|
||||
otoOnce.Do(func() {
|
||||
op := &oto.NewContextOptions{}
|
||||
op.SampleRate = DefaultSampleRate
|
||||
op.ChannelCount = DefaultChannelCount
|
||||
op.Format = oto.FormatSignedInt16LE
|
||||
|
||||
var ready <-chan struct{}
|
||||
otoCtx, ready, initErr = oto.NewContext(op)
|
||||
if initErr != nil {
|
||||
return
|
||||
}
|
||||
<-ready // 等待音频设备就绪
|
||||
zap.S().Infoln("oto/v3 音频系统就绪")
|
||||
})
|
||||
return otoCtx, initErr
|
||||
}
|
||||
```
|
||||
|
||||
**设计说明:**
|
||||
- ✅ 使用 `sync.Once` 确保全局 Context 只初始化一次
|
||||
- ✅ 使用 `oto.NewContext()` 返回的 ready channel,而非包级变量
|
||||
- ✅ 返回 error 给调用者处理,而非 panic
|
||||
|
||||
### 3.2 一次性播放(阻塞式)
|
||||
|
||||
**关键实现点:**
|
||||
- oto.Context 配置为 44100Hz,16bit,立体声
|
||||
- MP3/WAV 解码器直接传给 oto.NewPlayer()
|
||||
- 播放完成后会自动停止,IsPlaying() 返回 false
|
||||
- context 取消时,defer player.Close() 确保资源释放
|
||||
- goroutine 会在 player.Close() 后自然退出,无泄漏风险
|
||||
|
||||
**PlayWav:** 播放 WAV 文件,阻塞直到完成或 context 取消
|
||||
|
||||
```go
|
||||
func PlayWav(ctx context.Context, r io.ReadCloser) error {
|
||||
otoCtx, err := initContext()
|
||||
if err != nil {
|
||||
return fmt.Errorf("音频上下文初始化失败: %w", err)
|
||||
}
|
||||
|
||||
dec, err := wav.NewDecoder(r)
|
||||
if err != nil {
|
||||
r.Close()
|
||||
return fmt.Errorf("WAV 解码失败: %w", err)
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
player := otoCtx.NewPlayer(dec)
|
||||
defer player.Close()
|
||||
|
||||
player.Play()
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
for player.IsPlaying() {
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**PlayMP3:** 播放 MP3 文件,阻塞直到完成或 context 取消
|
||||
|
||||
```go
|
||||
func PlayMP3(ctx context.Context, r io.ReadCloser) error {
|
||||
otoCtx, err := initContext()
|
||||
if err != nil {
|
||||
return fmt.Errorf("音频上下文初始化失败: %w", err)
|
||||
}
|
||||
|
||||
dec, err := mp3.NewDecoder(r)
|
||||
if err != nil {
|
||||
r.Close()
|
||||
return fmt.Errorf("MP3 解码失败: %w", err)
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
player := otoCtx.NewPlayer(dec)
|
||||
defer player.Close()
|
||||
|
||||
player.Play()
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
for player.IsPlaying() {
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3 BGM 循环播放(非阻塞)
|
||||
|
||||
**PlayMP3Loop:** 循环播放 MP3,立即返回 player 和清理函数
|
||||
|
||||
**资源管理:** 使用 atomic.Bool 和 sync.WaitGroup 控制 goroutine 生命周期,确保安全退出
|
||||
|
||||
```go
|
||||
import "sync/atomic"
|
||||
|
||||
func PlayMP3Loop(r io.ReadCloser) (*oto.Player, func() error, error) {
|
||||
otoCtx, err := initContext()
|
||||
if err != nil {
|
||||
r.Close()
|
||||
return nil, func() error { return nil }, err
|
||||
}
|
||||
|
||||
dec, err := mp3.NewDecoder(r)
|
||||
if err != nil {
|
||||
r.Close()
|
||||
return nil, func() error { return nil }, err
|
||||
}
|
||||
|
||||
player := otoCtx.NewPlayer(dec)
|
||||
|
||||
// 使用 atomic.Bool 和 WaitGroup 控制 goroutine 生命周期
|
||||
playing := atomic.Bool{}
|
||||
playing.Store(true)
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(1)
|
||||
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for playing.Load() {
|
||||
player.Play()
|
||||
for playing.Load() && player.IsPlaying() {
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
if playing.Load() {
|
||||
player.Seek(0)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
cleanup := func() error {
|
||||
// 1. 通知 goroutine 退出
|
||||
playing.Store(false)
|
||||
|
||||
// 2. 等待 goroutine 完全退出
|
||||
wg.Wait()
|
||||
|
||||
// 3. 按照 oto 最佳实践的顺序释放资源
|
||||
player.Close()
|
||||
return r.Close()
|
||||
}
|
||||
|
||||
return player, cleanup, nil
|
||||
}
|
||||
```
|
||||
|
||||
**设计说明:**
|
||||
- ✅ 使用 `atomic.Bool` 明确控制 goroutine 生命周期
|
||||
- ✅ 使用 `sync.WaitGroup` 明确等待 goroutine 退出
|
||||
- ✅ goroutine 在每毫秒都检查退出条件,确保快速响应
|
||||
- ✅ 清理函数先通知退出,等待 goroutine 完全停止,再释放资源
|
||||
- ✅ 符合 oto 官方要求:"Do NOT close [reader] before you finish playing"
|
||||
- ✅ 无竞态条件,无资源泄漏,并发安全
|
||||
|
||||
### 3.4 业务层使用示例
|
||||
|
||||
**场景1: TTS 播放 (pkg/tts/aliyun.go)**
|
||||
|
||||
改动前(beep):
|
||||
```go
|
||||
audio.PlayWav(ctx, buf)
|
||||
```
|
||||
|
||||
改动后(oto):
|
||||
```go
|
||||
err := audio.PlayWav(ctx, buf)
|
||||
if err != nil && !errors.Is(err, context.Canceled) {
|
||||
zap.S().Errorf("TTS 播放失败: %v", err)
|
||||
}
|
||||
```
|
||||
|
||||
**场景2: BGM 播放 (internal/middleware/bgm.go)**
|
||||
|
||||
改动前(beep):
|
||||
```go
|
||||
ctrl, closer, e := audio.PlayBgmMP3(bgm)
|
||||
defer closer()
|
||||
<-a
|
||||
speaker.Lock()
|
||||
ctrl.Streamer = nil
|
||||
speaker.Unlock()
|
||||
```
|
||||
|
||||
改动后(oto):
|
||||
```go
|
||||
player, cleanup, err := audio.PlayMP3Loop(bgm)
|
||||
if err != nil {
|
||||
zap.S().Errorf("BGM 启动失败: %v", err)
|
||||
return err
|
||||
}
|
||||
defer cleanup() // ✅ 一次性清理所有资源,顺序正确
|
||||
|
||||
<-a
|
||||
// cleanup() 会先 player.Close() 停止播放,再 r.Close() 清理 reader
|
||||
```
|
||||
|
||||
## 4. API 设计
|
||||
|
||||
### 4.1 公开接口
|
||||
|
||||
```go
|
||||
package audio
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/ebitengine/oto/v3"
|
||||
"github.com/go-audio/wav"
|
||||
"github.com/hajimehoshi/go-mp3"
|
||||
)
|
||||
|
||||
// PlayMP3 播放 MP3 文件(阻塞),直到完成或 context 取消
|
||||
func PlayMP3(ctx context.Context, r io.ReadCloser) error
|
||||
|
||||
// PlayWav 播放 WAV 文件(阻塞),直到完成或 context 取消
|
||||
func PlayWav(ctx context.Context, r io.ReadCloser) error
|
||||
|
||||
// PlayMP3Loop 循环播放 MP3(非阻塞)
|
||||
// 返回 player 和清理函数,调用者负责 defer cleanup()
|
||||
func PlayMP3Loop(r io.ReadCloser) (*oto.Player, func() error, error)
|
||||
```
|
||||
|
||||
### 4.2 功能覆盖
|
||||
|
||||
| 当前功能 | 当前 API | oto API | 说明 |
|
||||
|---------|---------|---------|------|
|
||||
| TTS 播放 | `PlayWav(ctx, r)` | `PlayWav(ctx, r)` | 新增 error 返回 |
|
||||
| BGM 循环 | `PlayBgmMP3(r, ...)` | `PlayMP3Loop(r)` | 返回 player 和清理函数 |
|
||||
|
||||
## 5. 错误处理和资源管理
|
||||
|
||||
### 5.1 错误处理策略
|
||||
|
||||
- ✅ 一次性播放: 忽略 `context.Canceled`,记录其他错误
|
||||
- ✅ BGM 播放: 所有错误都需要返回(影响启动)
|
||||
- ✅ 统一使用 `fmt.Errorf` 包装错误信息
|
||||
|
||||
### 5.2 资源管理
|
||||
|
||||
**一次性播放:**
|
||||
- ✅ `defer r.Close()` 确保资源释放
|
||||
- ✅ `defer player.Close()` 确保播放器释放
|
||||
- ✅ 函数内部完整管理资源生命周期
|
||||
|
||||
**BGM 循环播放:**
|
||||
- ✅ 返回清理函数 `func() error`,内部保证正确的释放顺序
|
||||
- ✅ 先 `player.Close()` 停止播放,再 `r.Close()` 清理 reader
|
||||
- ✅ 符合 oto 官方要求:"Do NOT close [reader] before you finish playing"
|
||||
- ✅ 业务层 `defer cleanup()` 一次性清理所有资源
|
||||
- ✅ 责任边界清晰,资源管理可靠
|
||||
|
||||
### 5.3 并发安全
|
||||
|
||||
- ✅ `sync.Once` 保证全局 Context 线程安全
|
||||
- ✅ 每个 Player 独立,无共享状态
|
||||
- ✅ `player.Close()` 是线程安全的
|
||||
- ✅ `atomic.Bool` 控制 goroutine 生命周期,无竞态条件
|
||||
- ✅ `sync.WaitGroup` 明确等待 goroutine 退出,确保资源安全释放
|
||||
|
||||
## 6. 测试策略
|
||||
|
||||
### 6.1 单元测试
|
||||
|
||||
- TestInitContext - 测试音频上下文初始化
|
||||
- TestPlayMP3 - 测试 MP3 播放
|
||||
- TestPlayWav - 测试 WAV 播放
|
||||
- TestPlayContextCancellation - 测试 Context 取消
|
||||
- TestPlayMP3LoopStop - 测试 BGM 停止
|
||||
- TestConcurrentPlay - 测试并发播放
|
||||
|
||||
### 6.2 测试数据
|
||||
|
||||
```
|
||||
pkg/audio/testdata/
|
||||
├── test.mp3
|
||||
├── test.wav
|
||||
└── corrupt.mp3
|
||||
```
|
||||
|
||||
## 7. 实施计划
|
||||
|
||||
### 7.1 实施步骤
|
||||
|
||||
1. **添加依赖**
|
||||
```bash
|
||||
go get github.com/go-audio/wav
|
||||
```
|
||||
|
||||
2. **实现核心组件**
|
||||
- initContext()
|
||||
- PlayWav()
|
||||
- PlayMP3()
|
||||
- PlayMP3Loop()
|
||||
|
||||
3. **更新业务层**
|
||||
- pkg/tts/aliyun.go
|
||||
- internal/middleware/bgm.go
|
||||
- internal/routes/standby/audio.go
|
||||
|
||||
4. **移除旧依赖**
|
||||
```bash
|
||||
go mod tidy
|
||||
```
|
||||
|
||||
5. **测试**
|
||||
- 单元测试
|
||||
- 集成测试
|
||||
|
||||
### 7.2 风险评估
|
||||
|
||||
**低风险:**
|
||||
- API 简单清晰
|
||||
- 依赖成熟稳定
|
||||
- 业务层改动最小
|
||||
|
||||
**需验证:**
|
||||
- 树莓派 ARM64 平台兼容性
|
||||
- 循环播放性能
|
||||
- 并发播放稳定性
|
||||
|
||||
## 8. 验收标准
|
||||
|
||||
- ✅ 所有单元测试通过
|
||||
- ✅ 实际播放测试通过
|
||||
- ✅ BGM 循环播放正常
|
||||
- ✅ Context 取消机制正常
|
||||
- ✅ 无资源泄漏
|
||||
- ✅ 树莓派平台运行正常
|
||||
Reference in New Issue
Block a user