# oto/v3 音频播放重构实施计划 > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **目标:** 使用 oto/v3 完全重构音频播放功能,移除 beep/v2 依赖,实现高效、简洁、优雅的音频播放 **架构:** 使用单一全局 oto.Context,每次播放创建独立 Player,MP3/WAV 解码器直接传给 oto.Player 实现流式播放 **技术栈:** oto/v3, go-mp3, go-audio/wav, sync/atomic, sync.WaitGroup --- ## 文件结构 **新建文件:** - `pkg/audio/context.go` - 全局状态管理和初始化 - `pkg/audio/loop.go` - BGM 循环播放实现 - `pkg/audio/doc.go` - 包文档说明 - `pkg/audio/context_test.go` - 全局状态测试 - `pkg/audio/play_test.go` - 一次性播放测试 - `pkg/audio/loop_test.go` - 循环播放测试 - `pkg/audio/testdata/test.mp3` - 测试音频文件 - `pkg/audio/testdata/test.wav` - 测试音频文件 **修改文件:** - `pkg/audio/play.go` - 重写一次性播放函数 - `pkg/audio/bgm.go` - 删除(功能合并到 loop.go) - `pkg/tts/aliyun.go` - 更新 PlayWav 调用 - `internal/middleware/bgm.go` - 更新 BGM 调用 - `internal/routes/standby/audio.go` - 更新待机音频调用 --- ## Task 1: 添加 WAV 解码依赖 **Files:** - Modify: `go.mod` - [ ] **Step 1: 添加 go-audio/wav 依赖** ```bash go get github.com/go-audio/wav ``` 运行: `go mod tidy` 预期: 依赖添加成功,go.mod 更新 - [ ] **Step 2: 验证依赖** ```bash grep "github.com/go-audio/wav" go.mod ``` 预期: 输出包含 `github.com/go-audio/wav` 的行 - [ ] **Step 3: 提交** ```bash git add go.mod go.sum git commit -m "chore: add go-audio/wav dependency for WAV decoding" ``` --- ## Task 2: 创建全局状态管理模块 **Files:** - Create: `pkg/audio/context.go` - [ ] **Step 1: 创建文件并实现 initContext** ```go package audio import ( "sync" "github.com/ebitengine/oto/v3" "go.uber.org/zap" ) 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 } ``` 创建文件并写入内容 - [ ] **Step 2: 验证编译** ```bash go build ./pkg/audio ``` 预期: 编译成功,无错误 - [ ] **Step 3: 提交** ```bash git add pkg/audio/context.go git commit -m "feat(audio): add global oto.Context initialization with sync.Once" ``` --- ## Task 3: 实现一次性 WAV 播放 **Files:** - Modify: `pkg/audio/play.go` - Create: `pkg/audio/testdata/test.wav` - [ ] **Step 1: 创建测试音频文件** ```bash mkdir -p pkg/audio/testdata ``` 使用以下命令创建一个简单的 WAV 测试文件(1秒静音): ```bash # 使用 ffmpeg 生成测试文件(如果可用) # 否则从项目其他位置复制一个短的 WAV 文件 ffmpeg -f lavfi -i anullsrc=r=44100:cl=stereo -t 1 -q:a 9 -y pkg/audio/testdata/test.wav 2>/dev/null || echo "请手动准备一个短的 WAV 测试文件" ``` 如果 ffmpeg 不可用,需要手动准备一个短的 WAV 测试文件(约1秒) - [ ] **Step 2: 删除 play.go 旧代码** ```bash rm pkg/audio/play.go ``` - [ ] **Step 3: 创建新的 play.go 实现一次性播放** ```go package audio import ( "context" "fmt" "io" "time" "github.com/go-audio/wav" "github.com/hajimehoshi/go-mp3" ) // 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() } } ``` 创建文件并写入内容 - [ ] **Step 4: 验证编译** ```bash go build ./pkg/audio ``` 预期: 编译成功 - [ ] **Step 5: 提交** ```bash git add pkg/audio/play.go git commit -m "feat(audio): implement PlayWav and PlayMP3 with oto/v3" ``` --- ## Task 4: 实现 BGM 循环播放 **Files:** - Create: `pkg/audio/loop.go` - [ ] **Step 1: 创建 loop.go 实现 PlayMP3Loop** ```go package audio import ( "io" "sync" "sync/atomic" "time" "github.com/ebitengine/oto/v3" "github.com/hajimehoshi/go-mp3" ) // PlayMP3Loop 循环播放 MP3(非阻塞) // 返回 player 和清理函数,调用者负责 defer cleanup() 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) 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 { playing.Store(false) wg.Wait() player.Close() return r.Close() } return player, cleanup, nil } ``` 创建文件并写入内容 - [ ] **Step 2: 验证编译** ```bash go build ./pkg/audio ``` 预期: 编译成功 - [ ] **Step 3: 删除旧的 bgm.go** ```bash rm pkg/audio/bgm.go ``` - [ ] **Step 4: 提交** ```bash git add pkg/audio/loop.go git commit -m "feat(audio): implement PlayMP3Loop with atomic.Bool and WaitGroup" git rm pkg/audio/bgm.go git commit -m "refactor(audio): remove old beep-based BGM implementation" ``` --- ## Task 5: 添加包文档 **Files:** - Create: `pkg/audio/doc.go` - [ ] **Step 1: 创建包文档** ```go // Package audio 提供基于 oto/v3 的音频播放功能。 // // 播放模式: // - 一次性播放: PlayWav(), PlayMP3() - 阻塞直到完成或 context 取消 // - 循环播放: PlayMP3Loop() - 非阻塞,返回 player 和清理函数 // // 使用示例: // // // 一次性播放 WAV // err := audio.PlayWav(ctx, wavReader) // if err != nil && !errors.Is(err, context.Canceled) { // log.Printf("播放失败: %v", err) // } // // // 循环播放 MP3 // player, cleanup, err := audio.PlayMP3Loop(mp3Reader) // if err != nil { // return err // } // defer cleanup() // // ... 播放中 ... // // 资源管理: // - 一次性播放: 函数内部自动管理所有资源 // - 循环播放: 调用者必须调用 defer cleanup() 清理资源 // package audio ``` 创建文件并写入内容 - [ ] **Step 2: 提交** ```bash git add pkg/audio/doc.go git commit -m "docs(audio): add package documentation" ``` --- ## Task 6: 创建全局状态测试 **Files:** - Create: `pkg/audio/context_test.go` - [ ] **Step 1: 创建测试文件** ```go package audio import ( "testing" ) func TestInitContext(t *testing.T) { // 第一次调用应该成功 ctx1, err := initContext() if err != nil { t.Fatalf("第一次 initContext 失败: %v", err) } if ctx1 == nil { t.Fatal("返回的 context 不应为 nil") } // 第二次调用应该返回相同的 context ctx2, err := initContext() if err != nil { t.Fatalf("第二次 initContext 失败: %v", err) } if ctx2 != ctx1 { t.Error("应该返回相同的 context") } } ``` 创建文件并写入内容 - [ ] **Step 2: 运行测试** ```bash go test -v ./pkg/audio -run TestInitContext ``` 预期: 测试通过 - [ ] **Step 3: 提交** ```bash git add pkg/audio/context_test.go git commit -m "test(audio): add TestInitContext for global state" ``` --- ## Task 7: 创建一次性播放测试 **Files:** - Create: `pkg/audio/testdata/test.mp3` - Create: `pkg/audio/play_test.go` - [ ] **Step 1: 创建 MP3 测试文件** ```bash # 使用 ffmpeg 生成测试文件(如果可用) ffmpeg -f lavfi -i anullsrc=r=44100:cl=stereo -t 1 -q:a 9 -y pkg/audio/testdata/test.mp3 2>/dev/null || echo "请手动准备一个短的 MP3 测试文件" ``` 如果 ffmpeg 不可用,需要手动准备一个短的 MP3 测试文件(约1秒) - [ ] **Step 2: 创建播放测试文件** ```go package audio import ( "context" "os" "path/filepath" "testing" "time" ) func TestPlayWav(t *testing.T) { // 跳过测试如果没有测试文件 testFile := "testdata/test.wav" if _, err := os.Stat(testFile); os.IsNotExist(err) { t.Skip("测试文件不存在:", testFile) } f, err := os.Open(testFile) if err != nil { t.Fatalf("打开测试文件失败: %v", err) } defer f.Close() ctx := context.Background() err = PlayWav(ctx, f) if err != nil { t.Fatalf("PlayWav 失败: %v", err) } } func TestPlayMP3(t *testing.T) { testFile := "testdata/test.mp3" if _, err := os.Stat(testFile); os.IsNotExist(err) { t.Skip("测试文件不存在:", testFile) } f, err := os.Open(testFile) if err != nil { t.Fatalf("打开测试文件失败: %v", err) } defer f.Close() ctx := context.Background() err = PlayMP3(ctx, f) if err != nil { t.Fatalf("PlayMP3 失败: %v", err) } } func TestPlayContextCancellation(t *testing.T) { testFile := "testdata/test.mp3" if _, err := os.Stat(testFile); os.IsNotExist(err) { t.Skip("测试文件不存在:", testFile) } f, err := os.Open(testFile) if err != nil { t.Fatalf("打开测试文件失败: %v", err) } defer f.Close() // 创建一个会被快速取消的 context ctx, cancel := context.WithCancel(context.Background()) // 启动播放后立即取消 done := make(chan error, 1) go func() { done <- PlayMP3(ctx, f) }() time.Sleep(10 * time.Millisecond) cancel() err = <-done if err != context.Canceled { t.Errorf("期望 context.Canceled 错误,得到: %v", err) } } ``` 创建文件并写入内容 - [ ] **Step 3: 创建测试数据目录(如果不存在)** ```bash mkdir -p pkg/audio/testdata ``` - [ ] **Step 4: 运行测试** ```bash go test -v ./pkg/audio -run TestPlay ``` 注意: 如果没有测试文件,测试会跳过。这是正常的。 预期: 测试通过或跳过(如果缺少测试文件) - [ ] **Step 5: 提交** ```bash git add pkg/audio/play_test.go git commit -m "test(audio): add PlayWav, PlayMP3 and context cancellation tests" ``` --- ## Task 8: 创建循环播放测试 **Files:** - Create: `pkg/audio/loop_test.go` - [ ] **Step 1: 创建循环播放测试** ```go package audio import ( "os" "testing" "time" ) func TestPlayMP3LoopStop(t *testing.T) { testFile := "testdata/test.mp3" if _, err := os.Stat(testFile); os.IsNotExist(err) { t.Skip("测试文件不存在:", testFile) } f, err := os.Open(testFile) if err != nil { t.Fatalf("打开测试文件失败: %v", err) } player, cleanup, err := PlayMP3Loop(f) if err != nil { t.Fatalf("PlayMP3Loop 失败: %v", err) } if player == nil { t.Fatal("player 不应为 nil") } // 等待一小段时间确保播放开始 time.Sleep(100 * time.Millisecond) // 调用清理函数 if err := cleanup(); err != nil { t.Errorf("cleanup 失败: %v", err) } // 验证文件已关闭 if err := f.Close(); err == nil { t.Error("文件应该已经被 cleanup 关闭") } } func TestConcurrentPlay(t *testing.T) { testFile := "testdata/test.mp3" if _, err := os.Stat(testFile); os.IsNotExist(err) { t.Skip("测试文件不存在:", testFile) } // 启动多个并发播放 const numPlayers = 3 players := make([]interface{}, numPlayers) cleanups := make([]func() error, numPlayers) for i := 0; i < numPlayers; i++ { f, err := os.Open(testFile) if err != nil { t.Fatalf("打开测试文件 %d 失败: %v", i, err) } player, cleanup, err := PlayMP3Loop(f) if err != nil { t.Fatalf("PlayMP3Loop %d 失败: %v", i, err) } players[i] = player cleanups[i] = cleanup } // 等待播放 time.Sleep(200 * time.Millisecond) // 清理所有播放器 for i, cleanup := range cleanups { if err := cleanup(); err != nil { t.Errorf("cleanup %d 失败: %v", i, err) } } } ``` 创建文件并写入内容 - [ ] **Step 2: 运行测试** ```bash go test -v ./pkg/audio -run TestPlayMP3Loop ``` 预期: 测试通过或跳过 - [ ] **Step 3: 提交** ```bash git add pkg/audio/loop_test.go git commit -m "test(audio): add PlayMP3Loop stop and concurrent play tests" ``` --- ## Task 9: 更新 TTS 播放调用 **Files:** - Modify: `pkg/tts/aliyun.go` - [ ] **Step 1: 查看当前调用代码** ```bash grep -A 5 -B 5 "audio.PlayWav" pkg/tts/aliyun.go ``` 记录当前的代码行号和上下文 - [ ] **Step 2: 更新 PlayWav 调用以处理错误** 找到类似这样的代码: ```go audio.PlayWav(ctx, buf) ``` 替换为: ```go err := audio.PlayWav(ctx, buf) if err != nil && !errors.Is(err, context.Canceled) { zap.S().Errorf("TTS 播放失败: %v", err) } ``` 如果文件顶部缺少 `errors` 导入,添加: ```go import ( "errors" // 添加这行 // ... 其他导入 ) ``` - [ ] **Step 3: 验证编译** ```bash go build ./pkg/tts ``` 预期: 编译成功 - [ ] **Step 4: 提交** ```bash git add pkg/tts/aliyun.go git commit -m "refactor(tts): update PlayWav call to handle errors" ``` --- ## Task 10: 更新 BGM 中间件调用 **Files:** - Modify: `internal/middleware/bgm.go` - [ ] **Step 1: 查看当前代码** ```bash cat internal/middleware/bgm.go ``` - [ ] **Step 2: 完全重写 bgm.go** 将文件内容替换为: ```go package middleware import ( "game-driver/internal/schema" "game-driver/leaf" "game-driver/pkg/audio" "game-driver/pkg/utils" "sync" "go.uber.org/zap" ) // PlayBgm 播放背景音乐 func PlayBgm() leaf.HandlerFunc { return func(c *leaf.Context) { pm := leaf.Value[*schema.PlayModal](c, PayloadJSONKey) bgm, err := utils.LinkAudio(pm.BGM) if err != nil { zap.S().Errorln("背景音乐数据解析异常:", err) } if bgm != nil { zap.S().Infoln("背景音乐解析成功") var wait sync.WaitGroup defer wait.Wait() a := make(chan struct{}) defer close(a) wait.Add(1) go func() { defer wait.Done() zap.S().Infoln("开始播放背景音乐") defer zap.S().Infoln("结束背景音乐播放") player, cleanup, err := audio.PlayMP3Loop(bgm) if err != nil { zap.S().Errorln("播放背景音乐异常:", err) return } defer cleanup() <-a }() } else { zap.S().Infoln("未解析到背景音乐") } c.Next() } } ``` - [ ] **Step 3: 验证编译** ```bash go build ./internal/middleware ``` 预期: 编译成功 - [ ] **Step 4: 提交** ```bash git add internal/middleware/bgm.go git commit -m "refactor(middleware): update BGM to use oto/v3 PlayMP3Loop" ``` --- ## Task 11: 更新待机音频调用 **Files:** - Modify: `internal/routes/standby/audio.go` - [ ] **Step 1: 完全重写 audio.go** 将文件内容替换为: ```go package standby import ( "context" "fmt" "game-driver/internal/schema" "game-driver/pkg/audio" "game-driver/pkg/utils" "go.uber.org/zap" ) func Audio(item schema.WaitItemModel) func(c context.Context) error { return func(c context.Context) error { data, err := utils.LinkAudio(item.Data) if err != nil { return fmt.Errorf("音频数据获取异常: %w", err) } if data == nil { return fmt.Errorf("音频数据获取为空") } zap.S().Infoln("播放待机音乐") defer zap.S().Infoln("结束待机音乐") player, cleanup, err := audio.PlayMP3Loop(data) if err != nil { return fmt.Errorf("播放待机音乐异常: %w", err) } defer cleanup() <-c.Done() return nil } } ``` - [ ] **Step 2: 验证编译** ```bash go build ./internal/routes/standby ``` 预期: 编译成功 - [ ] **Step 3: 提交** ```bash git add internal/routes/standby/audio.go git commit -m "refactor(standby): update audio to use oto/v3 PlayMP3Loop" ``` --- ## Task 12: 检查其他使用 beep 的地方 **Files:** - Modify: `internal/routes/standby/tts.go` (如果需要) - [ ] **Step 1: 检查 standby/tts.go** ```bash grep -n "audio\." internal/routes/standby/tts.go ``` 如果有使用 audio 包的函数,需要相应更新 - [ ] **Step 2: 如果有更新,提交** 如果有修改: ```bash git add internal/routes/standby/tts.go git commit -m "refactor(standby): update TTS audio call to use oto/v3" ``` 如果没有修改,跳过此步骤 --- ## Task 13: 移除 beep 依赖 **Files:** - Modify: `go.mod` - Modify: `go.sum` - [ ] **Step 1: 移除 beep 依赖** ```bash go mod edit -droprequire=github.com/gopxl/beep/v2 ``` - [ ] **Step 2: 清理依赖** ```bash go mod tidy ``` 预期: beep 相关依赖从 go.mod 和 go.sum 移除 - [ ] **Step 3: 验证无 beep 引用** ```bash grep -r "gopxl/beep" --include="*.go" . ``` 预期: 无输出(表示代码中没有 beep 引用) - [ ] **Step 4: 验证编译** ```bash go build ./... ``` 预期: 整个项目编译成功 - [ ] **Step 5: 提交** ```bash git add go.mod go.sum git commit -m "chore: remove gopxl/beep/v2 dependency" ``` --- ## Task 14: 运行完整测试套件 **Files:** - All test files - [ ] **Step 1: 运行所有单元测试** ```bash go test -v ./pkg/audio ``` 预期: 所有测试通过(或跳过,如果缺少测试文件) - [ ] **Step 2: 运行整个项目测试** ```bash go test ./... ``` 预期: 所有现有测试仍然通过 - [ ] **Step 3: 编译整个项目** ```bash go build ./... ``` 预期: 编译成功,无错误 - [ ] **Step 4: 提交任何修复** 如果有测试失败或编译错误,修复后提交: ```bash git add . git commit -m "fix: resolve test failures and compilation issues" ``` --- ## Task 15: 手动功能验证 **Files:** - All - [ ] **Step 1: 验证音频播放功能** 启动应用程序并测试: 1. TTS 播放 - 验证 WAV 音频正常播放 2. BGM 播放 - 验证 MP3 循环播放正常 3. 待机音频 - 验证 MP3 循环播放正常 4. 播放停止 - 验证 context 取消时播放能正确停止 - [ ] **Step 2: 检查日志输出** 查找以下日志确认 oto/v3 初始化成功: ``` oto/v3 音频系统就绪 ``` - [ ] **Step 3: 性能检查** 监控: - CPU 使用率应该正常 - 内存使用应该稳定(无内存泄漏) - 多个音频并发播放应该正常工作 - [ ] **Step 4: 提交任何调整** 如果有需要调整的地方: ```bash git add . git commit -m "fix: adjustments based on manual testing" ``` --- ## Task 16: 更新项目文档 **Files:** - Create/Update: 项目相关文档 - [ ] **Step 1: 检查是否有 README 需要更新** ```bash ls README* 2>/dev/null || echo "没有找到 README" ``` - [ ] **Step 2: 更新依赖说明(如果有)** 如果项目有 DEPENDENCIES.md 或类似文件,更新音频播放依赖说明 - [ ] **Step 3: 提交文档更新** ```bash git add README.md DEPENDENCIES.md docs/ 2>/dev/null || true git commit -m "docs: update audio playback dependencies and usage" || true ``` --- ## 验收检查清单 在标记任务完成前,验证以下所有项: - [ ] 所有单元测试通过 - [ ] 整个项目编译成功 - [ ] 代码中没有 beep 相关导入 - [ ] TTS 播放功能正常 - [ ] BGM 循环播放功能正常 - [ ] 待机音频播放功能正常 - [ ] context 取消机制正常工作 - [ ] 无内存泄漏(长时间运行测试) - [ ] 日志输出正常,包含 "oto/v3 音频系统就绪" - [ ] 资源清理正确,无文件句柄泄漏 --- ## 实施注意事项 1. **测试数据准备:** Task 3 和 Task 7 需要准备测试音频文件。如果没有 ffmpeg,可以: - 从项目其他位置复制现有的短音频文件 - 手动生成或下载 1 秒的测试音频 2. **音频设备:** 某些测试(如实际播放)在有音频设备的机器上运行。CI 环境可能需要跳过实际播放测试。 3. **并发测试:** Task 8 的并发测试可能会同时播放多个音频,这是预期行为。 4. **错误处理:** 所有播放函数都返回 error,确保业务层正确处理这些错误。 5. **资源清理:** BGM 循环播放的调用者必须调用 `defer cleanup()`,否则会泄漏资源。 --- ## 完成标志 当所有任务的 checkbox 都选中,并且验收检查清单全部通过后,实施计划即完成。 最终应该有: - 使用 oto/v3 的简洁音频播放实现 - 移除了 beep/v2 依赖 - 所有测试通过 - 功能验证通过