- 设计文档:架构设计、API 设计、并发模型 - 实施计划:16 个任务,完整的实施步骤 - 验收标准:测试覆盖、功能验证、性能指标
21 KiB
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 依赖
go get github.com/go-audio/wav
运行: go mod tidy
预期: 依赖添加成功,go.mod 更新
- Step 2: 验证依赖
grep "github.com/go-audio/wav" go.mod
预期: 输出包含 github.com/go-audio/wav 的行
- Step 3: 提交
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
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: 验证编译
go build ./pkg/audio
预期: 编译成功,无错误
- Step 3: 提交
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: 创建测试音频文件
mkdir -p pkg/audio/testdata
使用以下命令创建一个简单的 WAV 测试文件(1秒静音):
# 使用 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 旧代码
rm pkg/audio/play.go
- Step 3: 创建新的 play.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: 验证编译
go build ./pkg/audio
预期: 编译成功
- Step 5: 提交
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
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: 验证编译
go build ./pkg/audio
预期: 编译成功
- Step 3: 删除旧的 bgm.go
rm pkg/audio/bgm.go
- Step 4: 提交
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: 创建包文档
// 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: 提交
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: 创建测试文件
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: 运行测试
go test -v ./pkg/audio -run TestInitContext
预期: 测试通过
- Step 3: 提交
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 测试文件
# 使用 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: 创建播放测试文件
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: 创建测试数据目录(如果不存在)
mkdir -p pkg/audio/testdata
- Step 4: 运行测试
go test -v ./pkg/audio -run TestPlay
注意: 如果没有测试文件,测试会跳过。这是正常的。
预期: 测试通过或跳过(如果缺少测试文件)
- Step 5: 提交
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: 创建循环播放测试
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: 运行测试
go test -v ./pkg/audio -run TestPlayMP3Loop
预期: 测试通过或跳过
- Step 3: 提交
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: 查看当前调用代码
grep -A 5 -B 5 "audio.PlayWav" pkg/tts/aliyun.go
记录当前的代码行号和上下文
- Step 2: 更新 PlayWav 调用以处理错误
找到类似这样的代码:
audio.PlayWav(ctx, buf)
替换为:
err := audio.PlayWav(ctx, buf)
if err != nil && !errors.Is(err, context.Canceled) {
zap.S().Errorf("TTS 播放失败: %v", err)
}
如果文件顶部缺少 errors 导入,添加:
import (
"errors" // 添加这行
// ... 其他导入
)
- Step 3: 验证编译
go build ./pkg/tts
预期: 编译成功
- Step 4: 提交
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: 查看当前代码
cat internal/middleware/bgm.go
- Step 2: 完全重写 bgm.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: 验证编译
go build ./internal/middleware
预期: 编译成功
- Step 4: 提交
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
将文件内容替换为:
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: 验证编译
go build ./internal/routes/standby
预期: 编译成功
- Step 3: 提交
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
grep -n "audio\." internal/routes/standby/tts.go
如果有使用 audio 包的函数,需要相应更新
- Step 2: 如果有更新,提交
如果有修改:
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 依赖
go mod edit -droprequire=github.com/gopxl/beep/v2
- Step 2: 清理依赖
go mod tidy
预期: beep 相关依赖从 go.mod 和 go.sum 移除
- Step 3: 验证无 beep 引用
grep -r "gopxl/beep" --include="*.go" .
预期: 无输出(表示代码中没有 beep 引用)
- Step 4: 验证编译
go build ./...
预期: 整个项目编译成功
- Step 5: 提交
git add go.mod go.sum
git commit -m "chore: remove gopxl/beep/v2 dependency"
Task 14: 运行完整测试套件
Files:
-
All test files
-
Step 1: 运行所有单元测试
go test -v ./pkg/audio
预期: 所有测试通过(或跳过,如果缺少测试文件)
- Step 2: 运行整个项目测试
go test ./...
预期: 所有现有测试仍然通过
- Step 3: 编译整个项目
go build ./...
预期: 编译成功,无错误
- Step 4: 提交任何修复
如果有测试失败或编译错误,修复后提交:
git add .
git commit -m "fix: resolve test failures and compilation issues"
Task 15: 手动功能验证
Files:
-
All
-
Step 1: 验证音频播放功能
启动应用程序并测试:
- TTS 播放 - 验证 WAV 音频正常播放
- BGM 播放 - 验证 MP3 循环播放正常
- 待机音频 - 验证 MP3 循环播放正常
- 播放停止 - 验证 context 取消时播放能正确停止
- Step 2: 检查日志输出
查找以下日志确认 oto/v3 初始化成功:
oto/v3 音频系统就绪
- Step 3: 性能检查
监控:
-
CPU 使用率应该正常
-
内存使用应该稳定(无内存泄漏)
-
多个音频并发播放应该正常工作
-
Step 4: 提交任何调整
如果有需要调整的地方:
git add .
git commit -m "fix: adjustments based on manual testing"
Task 16: 更新项目文档
Files:
-
Create/Update: 项目相关文档
-
Step 1: 检查是否有 README 需要更新
ls README* 2>/dev/null || echo "没有找到 README"
- Step 2: 更新依赖说明(如果有)
如果项目有 DEPENDENCIES.md 或类似文件,更新音频播放依赖说明
- Step 3: 提交文档更新
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 音频系统就绪"
- 资源清理正确,无文件句柄泄漏
实施注意事项
-
测试数据准备: Task 3 和 Task 7 需要准备测试音频文件。如果没有 ffmpeg,可以:
- 从项目其他位置复制现有的短音频文件
- 手动生成或下载 1 秒的测试音频
-
音频设备: 某些测试(如实际播放)在有音频设备的机器上运行。CI 环境可能需要跳过实际播放测试。
-
并发测试: Task 8 的并发测试可能会同时播放多个音频,这是预期行为。
-
错误处理: 所有播放函数都返回 error,确保业务层正确处理这些错误。
-
资源清理: BGM 循环播放的调用者必须调用
defer cleanup(),否则会泄漏资源。
完成标志
当所有任务的 checkbox 都选中,并且验收检查清单全部通过后,实施计划即完成。
最终应该有:
- 使用 oto/v3 的简洁音频播放实现
- 移除了 beep/v2 依赖
- 所有测试通过
- 功能验证通过