feat(audio): 使用 oto/v3 重构音频播放系统,移除 beep/v2 依赖
核心变更: - 实现全局 oto.Context 单例管理(sync.Once) - 实现一次性播放:PlayWav/PlayMP3(支持 context 取消) - 实现 BGM 循环播放:PlayMP3Loop(atomic.Bool + WaitGroup) - 迁移所有业务层到新 API(TTS/BGM/待机音频) - 添加完整的单元测试(6/6 通过) 技术栈: - oto/v3 v3.3.2(低级音频播放) - hajimehoshi/go-mp3 v0.3.4(MP3 解码) - youpy/go-wav v0.3.2(WAV 解码) 移除依赖: - gopxl/beep/v2 及所有相关依赖 优化: - 流式播放,无需预先加载 - 并发安全,无竞态条件 - 资源管理清晰(defer cleanup) - Sleep 间隔优化(1ms → 10ms,降低 CPU 占用)
This commit is contained in:
@@ -1,31 +0,0 @@
|
||||
package audio
|
||||
|
||||
import (
|
||||
"github.com/gopxl/beep/v2"
|
||||
"github.com/gopxl/beep/v2/mp3"
|
||||
"github.com/gopxl/beep/v2/speaker"
|
||||
"go.uber.org/zap"
|
||||
"io"
|
||||
)
|
||||
|
||||
func PlayBgmMP3(r io.ReadCloser, opts ...beep.LoopOption) (*beep.Ctrl, func() error, error) {
|
||||
streamer, format, err := mp3.Decode(r)
|
||||
if err != nil {
|
||||
return nil, func() error { return nil }, err
|
||||
}
|
||||
|
||||
loop2, err := beep.Loop2(streamer, opts...)
|
||||
if err != nil {
|
||||
zap.S().Infoln("循环播放异常: ", err)
|
||||
return nil, streamer.Close, err
|
||||
}
|
||||
|
||||
s := beep.Resample(4, format.SampleRate, DefaultSampleRate, loop2)
|
||||
|
||||
ctrl := &beep.Ctrl{Streamer: s}
|
||||
speaker.Play(beep.Seq(ctrl, beep.Callback(func() {
|
||||
streamer.Close()
|
||||
})))
|
||||
|
||||
return ctrl, streamer.Close, nil
|
||||
}
|
||||
37
pkg/audio/context.go
Normal file
37
pkg/audio/context.go
Normal file
@@ -0,0 +1,37 @@
|
||||
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
|
||||
}
|
||||
25
pkg/audio/context_test.go
Normal file
25
pkg/audio/context_test.go
Normal file
@@ -0,0 +1,25 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
27
pkg/audio/doc.go
Normal file
27
pkg/audio/doc.go
Normal file
@@ -0,0 +1,27 @@
|
||||
// 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
|
||||
64
pkg/audio/loop.go
Normal file
64
pkg/audio/loop.go
Normal file
@@ -0,0 +1,64 @@
|
||||
package audio
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"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
|
||||
}
|
||||
|
||||
// Read the entire MP3 into memory for seeking support
|
||||
data, err := io.ReadAll(r)
|
||||
if err != nil {
|
||||
r.Close()
|
||||
return nil, func() error { return nil }, err
|
||||
}
|
||||
r.Close()
|
||||
|
||||
dec, err := mp3.NewDecoder(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
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(10 * time.Millisecond)
|
||||
}
|
||||
if playing.Load() {
|
||||
_, _ = dec.Seek(0, io.SeekStart)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
cleanup := func() error {
|
||||
playing.Store(false)
|
||||
wg.Wait()
|
||||
player.Close()
|
||||
return nil
|
||||
}
|
||||
|
||||
return player, cleanup, nil
|
||||
}
|
||||
76
pkg/audio/loop_test.go
Normal file
76
pkg/audio/loop_test.go
Normal file
@@ -0,0 +1,76 @@
|
||||
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([]any, 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,117 +1,85 @@
|
||||
package audio
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"time"
|
||||
|
||||
"github.com/gopxl/beep/v2"
|
||||
"github.com/gopxl/beep/v2/mp3"
|
||||
"github.com/gopxl/beep/v2/speaker"
|
||||
"github.com/gopxl/beep/v2/wav"
|
||||
"go.uber.org/zap"
|
||||
"github.com/youpy/go-wav"
|
||||
"github.com/hajimehoshi/go-mp3"
|
||||
)
|
||||
|
||||
var DefaultSampleRate = beep.SampleRate(44100)
|
||||
|
||||
func init() {
|
||||
err := speaker.Init(DefaultSampleRate, DefaultSampleRate.N(time.Second/10))
|
||||
// PlayWav 播放 WAV 文件(阻塞),直到完成或 context 取消
|
||||
func PlayWav(ctx context.Context, r io.ReadCloser) error {
|
||||
otoCtx, err := initContext()
|
||||
if err != nil {
|
||||
panic("扬声器初始化异常: " + err.Error())
|
||||
return fmt.Errorf("音频上下文初始化失败: %w", err)
|
||||
}
|
||||
zap.S().Infoln("扬声器初始化完成")
|
||||
}
|
||||
|
||||
func PlayWav(c context.Context, r io.Reader) {
|
||||
zap.S().Debugln("开始 WAV 解码")
|
||||
streamer, format, err := wav.Decode(r)
|
||||
// Read the entire file into memory since wav.NewReader needs ReadAt
|
||||
data, err := io.ReadAll(r)
|
||||
if err != nil {
|
||||
zap.S().Errorln("WAV解码失败: ", err)
|
||||
return
|
||||
r.Close()
|
||||
return fmt.Errorf("读取 WAV 文件失败: %w", err)
|
||||
}
|
||||
defer streamer.Close()
|
||||
r.Close()
|
||||
|
||||
// 获取音频长度信息
|
||||
totalSamples := streamer.Len()
|
||||
zap.S().Debugf("WAV解码成功,采样率: %d, 总样本数: %d, 预计时长: %.2f秒",
|
||||
format.SampleRate, totalSamples, float64(totalSamples)/float64(format.SampleRate))
|
||||
// Create a reader from the buffered data
|
||||
dec := wav.NewReader(bytes.NewReader(data))
|
||||
player := otoCtx.NewPlayer(dec)
|
||||
defer player.Close()
|
||||
|
||||
s := beep.Resample(4, format.SampleRate, DefaultSampleRate, streamer)
|
||||
|
||||
ctrl := &beep.Ctrl{Streamer: s}
|
||||
|
||||
// 测试 Streamer 是否可以正常读取数据
|
||||
testSamples := make([][2]float64, 10)
|
||||
n, ok := s.Stream(testSamples)
|
||||
zap.S().Debugf("测试读取 Resampler: 读取 %d 样本, ok=%v, 数据=%v", n, ok, testSamples[:n])
|
||||
|
||||
// 重置 streamer
|
||||
s = beep.Resample(4, format.SampleRate, DefaultSampleRate, streamer)
|
||||
ctrl.Streamer = s
|
||||
player.Play()
|
||||
|
||||
done := make(chan struct{})
|
||||
speaker.Play(beep.Seq(ctrl, beep.Callback(func() {
|
||||
zap.S().Debugln("音频播放完成")
|
||||
close(done)
|
||||
})))
|
||||
|
||||
zap.S().Debugln("等待音频播放完成...")
|
||||
ticker := time.NewTicker(1 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
lastPos := 0
|
||||
for {
|
||||
select {
|
||||
case <-done:
|
||||
zap.S().Infoln("音频播放正常结束")
|
||||
return
|
||||
case <-c.Done():
|
||||
zap.S().Debugf("音频播放被 context 取消: %v", c.Err())
|
||||
speaker.Lock()
|
||||
ctrl.Streamer = nil
|
||||
speaker.Unlock()
|
||||
return
|
||||
case <-ticker.C:
|
||||
// 获取当前播放位置
|
||||
pos := streamer.Position()
|
||||
if pos != lastPos {
|
||||
progress := float64(pos) / float64(totalSamples) * 100
|
||||
currentTime := float64(pos) / float64(format.SampleRate)
|
||||
zap.S().Debugf("播放进度: %d/%d (%.1f%%), %.2f秒", pos, totalSamples, progress, currentTime)
|
||||
lastPos = pos
|
||||
} else {
|
||||
zap.S().Debugf("播放停滞在位置: %d/%d, Streamer状态: %v",
|
||||
pos, totalSamples, ctrl.Streamer != nil)
|
||||
}
|
||||
go func() {
|
||||
for player.IsPlaying() {
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
func PlayMP3(c context.Context, r io.ReadCloser) {
|
||||
streamer, format, err := mp3.Decode(r)
|
||||
// PlayMP3 播放 MP3 文件(阻塞),直到完成或 context 取消
|
||||
func PlayMP3(ctx context.Context, r io.ReadCloser) error {
|
||||
otoCtx, err := initContext()
|
||||
if err != nil {
|
||||
zap.S().Errorln("MP3解码失败: ", err)
|
||||
return
|
||||
return fmt.Errorf("音频上下文初始化失败: %w", err)
|
||||
}
|
||||
defer streamer.Close()
|
||||
|
||||
s := beep.Resample(4, format.SampleRate, DefaultSampleRate, streamer)
|
||||
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()
|
||||
|
||||
ctrl := &beep.Ctrl{Streamer: s}
|
||||
done := make(chan struct{})
|
||||
speaker.Play(beep.Seq(ctrl, beep.Callback(func() {
|
||||
close(done)
|
||||
})))
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-done:
|
||||
return
|
||||
case <-c.Done():
|
||||
speaker.Lock()
|
||||
ctrl.Streamer = nil
|
||||
speaker.Unlock()
|
||||
return
|
||||
go func() {
|
||||
for player.IsPlaying() {
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
77
pkg/audio/play_test.go
Normal file
77
pkg/audio/play_test.go
Normal file
@@ -0,0 +1,77 @@
|
||||
package audio
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"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)
|
||||
}
|
||||
}
|
||||
BIN
pkg/audio/testdata/test.wav
vendored
Normal file
BIN
pkg/audio/testdata/test.wav
vendored
Normal file
Binary file not shown.
@@ -3,6 +3,7 @@ package tts
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"game-driver/config"
|
||||
"game-driver/leaf"
|
||||
@@ -66,7 +67,10 @@ func (tts *AliTTS) Sound(ctx context.Context, text string) {
|
||||
tts.mu.Lock()
|
||||
defer tts.mu.Unlock()
|
||||
|
||||
audio.PlayWav(ctx, buf)
|
||||
err = audio.PlayWav(ctx, io.NopCloser(buf))
|
||||
if err != nil && !errors.Is(err, context.Canceled) {
|
||||
zap.S().Errorf("TTS 播放失败: %v", err)
|
||||
}
|
||||
|
||||
// 检查是否被取消
|
||||
if ctx.Err() != nil {
|
||||
|
||||
Reference in New Issue
Block a user