# 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 取消机制正常 - ✅ 无资源泄漏 - ✅ 树莓派平台运行正常