# 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 依赖
- 所有测试通过
- 功能验证通过
