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

1109 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 依赖**
```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 依赖
- 所有测试通过
- 功能验证通过