From ebf9f515f623ca77d3109e72e5e6570cd8e1b5eb Mon Sep 17 00:00:00 2001 From: mapleafgo Date: Wed, 8 Apr 2026 19:21:49 +0800 Subject: [PATCH] =?UTF-8?q?docs(audio):=20=E6=B7=BB=E5=8A=A0=20oto/v3=20?= =?UTF-8?q?=E9=87=8D=E6=9E=84=E8=AE=BE=E8=AE=A1=E5=92=8C=E5=AE=9E=E6=96=BD?= =?UTF-8?q?=E8=AE=A1=E5=88=92=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 设计文档:架构设计、API 设计、并发模型 - 实施计划:16 个任务,完整的实施步骤 - 验收标准:测试覆盖、功能验证、性能指标 --- .../plans/2026-04-08-oto-audio-refactor.md | 1108 +++++++++++++++++ .../2026-04-08-oto-audio-refactor-design.md | 429 +++++++ 2 files changed, 1537 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-08-oto-audio-refactor.md create mode 100644 docs/superpowers/specs/2026-04-08-oto-audio-refactor-design.md diff --git a/docs/superpowers/plans/2026-04-08-oto-audio-refactor.md b/docs/superpowers/plans/2026-04-08-oto-audio-refactor.md new file mode 100644 index 0000000..122fb08 --- /dev/null +++ b/docs/superpowers/plans/2026-04-08-oto-audio-refactor.md @@ -0,0 +1,1108 @@ +# 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 依赖 +- 所有测试通过 +- 功能验证通过 diff --git a/docs/superpowers/specs/2026-04-08-oto-audio-refactor-design.md b/docs/superpowers/specs/2026-04-08-oto-audio-refactor-design.md new file mode 100644 index 0000000..f38197a --- /dev/null +++ b/docs/superpowers/specs/2026-04-08-oto-audio-refactor-design.md @@ -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 取消机制正常 +- ✅ 无资源泄漏 +- ✅ 树莓派平台运行正常