Files
game-driver/docs/superpowers/plans/2026-04-08-oto-audio-refactor.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

21 KiB
Raw Blame History

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每次播放创建独立 PlayerMP3/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: 验证音频播放功能

启动应用程序并测试:

  1. TTS 播放 - 验证 WAV 音频正常播放
  2. BGM 播放 - 验证 MP3 循环播放正常
  3. 待机音频 - 验证 MP3 循环播放正常
  4. 播放停止 - 验证 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 音频系统就绪"
  • 资源清理正确,无文件句柄泄漏

实施注意事项

  1. 测试数据准备: Task 3 和 Task 7 需要准备测试音频文件。如果没有 ffmpeg可以:

    • 从项目其他位置复制现有的短音频文件
    • 手动生成或下载 1 秒的测试音频
  2. 音频设备: 某些测试如实际播放在有音频设备的机器上运行。CI 环境可能需要跳过实际播放测试。

  3. 并发测试: Task 8 的并发测试可能会同时播放多个音频,这是预期行为。

  4. 错误处理: 所有播放函数都返回 error确保业务层正确处理这些错误。

  5. 资源清理: BGM 循环播放的调用者必须调用 defer cleanup(),否则会泄漏资源。


完成标志

当所有任务的 checkbox 都选中,并且验收检查清单全部通过后,实施计划即完成。

最终应该有:

  • 使用 oto/v3 的简洁音频播放实现
  • 移除了 beep/v2 依赖
  • 所有测试通过
  • 功能验证通过