All checks were successful
ci/woodpecker/tag/woodpecker Pipeline was successful
- 设计文档:架构设计、API 设计、并发模型 - 实施计划:16 个任务,完整的实施步骤 - 验收标准:测试覆盖、功能验证、性能指标
1109 lines
21 KiB
Markdown
1109 lines
21 KiB
Markdown
# 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 依赖
|
||
- 所有测试通过
|
||
- 功能验证通过
|