Files
game-driver/docs/superpowers/specs/2026-04-08-oto-audio-refactor-design.md
mapleafgo ebf9f515f6
All checks were successful
ci/woodpecker/tag/woodpecker Pipeline was successful
docs(audio): 添加 oto/v3 重构设计和实施计划文档
- 设计文档:架构设计、API 设计、并发模型
- 实施计划:16 个任务,完整的实施步骤
- 验收标准:测试覆盖、功能验证、性能指标
2026-04-08 19:21:49 +08:00

9.9 KiB

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 全局状态管理

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 取消

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 取消

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 生命周期,确保安全退出

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):

audio.PlayWav(ctx, buf)

改动后(oto):

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):

ctrl, closer, e := audio.PlayBgmMP3(bgm)
defer closer()
<-a
speaker.Lock()
ctrl.Streamer = nil
speaker.Unlock()

改动后(oto):

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 公开接口

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. 添加依赖

    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. 移除旧依赖

    go mod tidy
    
  5. 测试

    • 单元测试
    • 集成测试

7.2 风险评估

低风险:

  • API 简单清晰
  • 依赖成熟稳定
  • 业务层改动最小

需验证:

  • 树莓派 ARM64 平台兼容性
  • 循环播放性能
  • 并发播放稳定性

8. 验收标准

  • 所有单元测试通过
  • 实际播放测试通过
  • BGM 循环播放正常
  • Context 取消机制正常
  • 无资源泄漏
  • 树莓派平台运行正常