From 788327047c931a476a687b338d0146f7ef799806 Mon Sep 17 00:00:00 2001 From: mapleafgo Date: Wed, 8 Apr 2026 19:21:48 +0800 Subject: [PATCH] =?UTF-8?q?feat(audio):=20=E4=BD=BF=E7=94=A8=20oto/v3=20?= =?UTF-8?q?=E9=87=8D=E6=9E=84=E9=9F=B3=E9=A2=91=E6=92=AD=E6=94=BE=E7=B3=BB?= =?UTF-8?q?=E7=BB=9F=EF=BC=8C=E7=A7=BB=E9=99=A4=20beep/v2=20=E4=BE=9D?= =?UTF-8?q?=E8=B5=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 核心变更: - 实现全局 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 占用) --- go.mod | 9 +- go.sum | 14 ++- internal/middleware/bgm.go | 16 +--- internal/routes/standby/audio.go | 14 ++- internal/routes/standby/tts.go | 7 +- pkg/audio/bgm.go | 31 ------- pkg/audio/context.go | 37 ++++++++ pkg/audio/context_test.go | 25 ++++++ pkg/audio/doc.go | 27 ++++++ pkg/audio/loop.go | 64 ++++++++++++++ pkg/audio/loop_test.go | 76 +++++++++++++++++ pkg/audio/play.go | 142 ++++++++++++------------------- pkg/audio/play_test.go | 77 +++++++++++++++++ pkg/audio/testdata/test.wav | Bin 0 -> 176478 bytes pkg/tts/aliyun.go | 6 +- 15 files changed, 396 insertions(+), 149 deletions(-) delete mode 100644 pkg/audio/bgm.go create mode 100644 pkg/audio/context.go create mode 100644 pkg/audio/context_test.go create mode 100644 pkg/audio/doc.go create mode 100644 pkg/audio/loop.go create mode 100644 pkg/audio/loop_test.go create mode 100644 pkg/audio/play_test.go create mode 100644 pkg/audio/testdata/test.wav diff --git a/go.mod b/go.mod index d76fd4e..3e52fca 100644 --- a/go.mod +++ b/go.mod @@ -5,16 +5,18 @@ go 1.23.2 require ( github.com/adrg/libvlc-go/v3 v3.1.6 github.com/aliyun/alibabacloud-nls-go-sdk v1.1.1 + github.com/ebitengine/oto/v3 v3.3.2 github.com/eclipse/paho.golang v0.22.0 github.com/go-pkgz/cronrange v0.2.0 github.com/go-rod/rod v0.116.2 - github.com/gopxl/beep/v2 v2.1.1 github.com/grid-x/modbus v0.0.0-20250219144522-2b18d136199f + github.com/hajimehoshi/go-mp3 v0.3.4 github.com/hypebeast/go-osc v0.0.0-20220308234300-cec5a8a1e5f5 github.com/spf13/viper v1.21.0 github.com/tencentcloud/tencentcloud-cls-sdk-go v1.0.11 github.com/urfave/cli/v3 v3.8.0 github.com/warthog618/go-gpiocdev v0.9.1 + github.com/youpy/go-wav v0.3.2 go.uber.org/zap v1.27.0 gopkg.in/natefinch/lumberjack.v2 v2.2.1 ) @@ -22,14 +24,12 @@ require ( require ( github.com/aliyun/alibaba-cloud-sdk-go v1.63.93 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect - github.com/ebitengine/oto/v3 v3.3.2 // indirect github.com/ebitengine/purego v0.8.2 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/golang/protobuf v1.5.4 // indirect github.com/gorilla/websocket v1.5.3 // indirect github.com/grid-x/serial v0.0.0-20211107191517-583c7356b3aa // indirect - github.com/hajimehoshi/go-mp3 v0.3.4 // indirect github.com/jmespath/go-jmespath v0.4.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/compress v1.18.0 // indirect @@ -38,7 +38,6 @@ require ( github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pierrec/lz4 v2.6.1+incompatible // indirect - github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/sagikazarmark/locafero v0.11.0 // indirect github.com/satori/go.uuid v1.2.0 // indirect @@ -47,11 +46,13 @@ require ( github.com/spf13/cast v1.10.0 // indirect github.com/spf13/pflag v1.0.10 // indirect github.com/subosito/gotenv v1.6.0 // indirect + github.com/youpy/go-riff v0.1.0 // indirect github.com/ysmood/fetchup v0.3.0 // indirect github.com/ysmood/goob v0.4.0 // indirect github.com/ysmood/got v0.40.0 // indirect github.com/ysmood/gson v0.7.3 // indirect github.com/ysmood/leakless v0.9.0 // indirect + github.com/zaf/g711 v1.4.0 // indirect go.uber.org/atomic v1.11.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect diff --git a/go.sum b/go.sum index c8e9b60..e397670 100644 --- a/go.sum +++ b/go.sum @@ -45,8 +45,6 @@ github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= -github.com/gopxl/beep/v2 v2.1.1 h1:6FYIYMm2qPAdWkjX+7xwKrViS1x0Po5kDMdRkq8NVbU= -github.com/gopxl/beep/v2 v2.1.1/go.mod h1:ZAm9TGQ9lvpoiFLd4zf5B1IuyxZhgRACMId1XJbaW0E= github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= @@ -90,8 +88,6 @@ github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWb github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b h1:FfH+VrHHk6Lxt9HdVS0PXzSXFyS2NbZKXv33FYPol0A= github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b/go.mod h1:AC62GU6hc0BrNm+9RK9VSiwa/EUe1bkIeFORAMcHvJU= -github.com/orcaman/writerseeker v0.0.0-20200621085525-1d3f536ff85e h1:s2RNOM/IGdY0Y6qfTeUKhDawdHDpK9RGBdx80qN4Ttw= -github.com/orcaman/writerseeker v0.0.0-20200621085525-1d3f536ff85e/go.mod h1:nBdnFKj15wFbf94Rwfq4m30eAcyY9V/IyKAGQFtqkW0= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pierrec/lz4 v2.6.1+incompatible h1:9UY3+iC23yxF0UfGaYrGplQ+79Rg+h/q9FV9ix19jjM= @@ -122,6 +118,7 @@ github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= @@ -139,6 +136,10 @@ github.com/warthog618/go-gpiocdev v0.9.1 h1:pwHPaqjJfhCipIQl78V+O3l9OKHivdRDdmgX github.com/warthog618/go-gpiocdev v0.9.1/go.mod h1:dN3e3t/S2aSNC+hgigGE/dBW8jE1ONk9bDSEYfoPyl8= github.com/warthog618/go-gpiosim v0.1.1 h1:MRAEv+T+itmw+3GeIGpQJBfanUVyg0l3JCTwHtwdre4= github.com/warthog618/go-gpiosim v0.1.1/go.mod h1:YXsnB+I9jdCMY4YAlMSRrlts25ltjmuIsrnoUrBLdqU= +github.com/youpy/go-riff v0.1.0 h1:vZO/37nI4tIET8tQI0Qn0Y79qQh99aEpponTPiPut7k= +github.com/youpy/go-riff v0.1.0/go.mod h1:83nxdDV4Z9RzrTut9losK7ve4hUnxUR8ASSz4BsKXwQ= +github.com/youpy/go-wav v0.3.2 h1:NLM8L/7yZ0Bntadw/0h95OyUsen+DQIVf9gay+SUsMU= +github.com/youpy/go-wav v0.3.2/go.mod h1:0FCieAXAeSdcxFfwLpRuEo0PFmAoc+8NU34h7TUvk50= github.com/ysmood/fetchup v0.3.0 h1:UhYz9xnLEVn2ukSuK3KCgcznWpHMdrmbsPpllcylyu8= github.com/ysmood/fetchup v0.3.0/go.mod h1:hbysoq65PXL0NQeNzUczNYIKpwpkwFL4LXMDEvIQq9A= github.com/ysmood/goob v0.4.0 h1:HsxXhyLBeGzWXnqVKtmT9qM7EuVs/XOgkX7T6r1o1AQ= @@ -153,6 +154,9 @@ github.com/ysmood/gson v0.7.3 h1:QFkWbTH8MxyUTKPkVWAENJhxqdBa4lYTQWqZCiLG6kE= github.com/ysmood/gson v0.7.3/go.mod h1:3Kzs5zDl21g5F/BlLTNcuAGAYLKt2lV5G8D1zF3RNmg= github.com/ysmood/leakless v0.9.0 h1:qxCG5VirSBvmi3uynXFkcnLMzkphdh3xx5FtrORwDCU= github.com/ysmood/leakless v0.9.0/go.mod h1:R8iAXPRaG97QJwqxs74RdwzcRHT1SWCGTNqY8q0JvMQ= +github.com/zaf/g711 v0.0.0-20190814101024-76a4a538f52b/go.mod h1:T2h1zV50R/q0CVYnsQOQ6L7P4a2ZxH47ixWcMXFGyx8= +github.com/zaf/g711 v1.4.0 h1:XZYkjjiAg9QTBnHqEg37m2I9q3IIDv5JRYXs2N8ma7c= +github.com/zaf/g711 v1.4.0/go.mod h1:eCDXt3dSp/kYYAoooba7ukD/Q75jvAaS4WOMr0l1Roo= go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= @@ -224,6 +228,8 @@ gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo= +gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw= pgregory.net/rapid v1.1.0 h1:CMa0sjHSru3puNx+J0MIAuiiEV4N0qj8/cMWGBBCsjw= pgregory.net/rapid v1.1.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04= rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= diff --git a/internal/middleware/bgm.go b/internal/middleware/bgm.go index f6f1b0f..f331974 100644 --- a/internal/middleware/bgm.go +++ b/internal/middleware/bgm.go @@ -7,7 +7,6 @@ import ( "game-driver/pkg/utils" "sync" - "github.com/gopxl/beep/v2/speaker" "go.uber.org/zap" ) @@ -22,13 +21,10 @@ func PlayBgm() leaf.HandlerFunc { } if bgm != nil { zap.S().Infoln("背景音乐解析成功") - // 等待组 var wait sync.WaitGroup defer wait.Wait() - // 结束信号通道 a := make(chan struct{}) - // 发送结束信号 defer close(a) wait.Add(1) @@ -38,18 +34,14 @@ func PlayBgm() leaf.HandlerFunc { zap.S().Infoln("开始播放背景音乐") defer zap.S().Infoln("结束背景音乐播放") - ctrl, closer, e := audio.PlayBgmMP3(bgm) - defer closer() - if e != nil { - zap.S().Errorln("播放背景音乐异常:", e) + _, cleanup, err := audio.PlayMP3Loop(bgm) + if err != nil { + zap.S().Errorln("播放背景音乐异常:", err) return } + defer cleanup() <-a - - speaker.Lock() - ctrl.Streamer = nil - speaker.Unlock() }() } else { zap.S().Infoln("未解析到背景音乐") diff --git a/internal/routes/standby/audio.go b/internal/routes/standby/audio.go index 44c6178..0c18865 100644 --- a/internal/routes/standby/audio.go +++ b/internal/routes/standby/audio.go @@ -6,7 +6,7 @@ import ( "game-driver/internal/schema" "game-driver/pkg/audio" "game-driver/pkg/utils" - "github.com/gopxl/beep/v2/speaker" + "go.uber.org/zap" ) @@ -23,18 +23,14 @@ func Audio(item schema.WaitItemModel) func(c context.Context) error { zap.S().Infoln("播放待机音乐") defer zap.S().Infoln("结束待机音乐") - ctrl, closer, e := audio.PlayBgmMP3(data) - defer closer() - if e != nil { - return fmt.Errorf("播放待机音乐异常: %w", e) + _, cleanup, err := audio.PlayMP3Loop(data) + if err != nil { + return fmt.Errorf("播放待机音乐异常: %w", err) } + defer cleanup() <-c.Done() - speaker.Lock() - ctrl.Streamer = nil - speaker.Unlock() - return nil } } diff --git a/internal/routes/standby/tts.go b/internal/routes/standby/tts.go index df59dba..49fd8a0 100644 --- a/internal/routes/standby/tts.go +++ b/internal/routes/standby/tts.go @@ -2,11 +2,13 @@ package standby import ( "context" + "errors" "fmt" "game-driver/internal/schema" "game-driver/pkg/audio" "game-driver/pkg/tts" "go.uber.org/zap" + "io" ) func TTS(item schema.WaitItemModel) func(c context.Context) error { @@ -19,7 +21,10 @@ func TTS(item schema.WaitItemModel) func(c context.Context) error { zap.S().Infoln("播放待机 TTS 语音") defer zap.S().Infoln("结束待机 TTS 语音") - audio.PlayWav(c, reader) + err = audio.PlayWav(c, io.NopCloser(reader)) + if err != nil && !errors.Is(err, context.Canceled) { + zap.S().Errorf("TTS 播放失败: %v", err) + } return nil } diff --git a/pkg/audio/bgm.go b/pkg/audio/bgm.go deleted file mode 100644 index 10c32ae..0000000 --- a/pkg/audio/bgm.go +++ /dev/null @@ -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 -} diff --git a/pkg/audio/context.go b/pkg/audio/context.go new file mode 100644 index 0000000..77fc54e --- /dev/null +++ b/pkg/audio/context.go @@ -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 +} diff --git a/pkg/audio/context_test.go b/pkg/audio/context_test.go new file mode 100644 index 0000000..5fa0a22 --- /dev/null +++ b/pkg/audio/context_test.go @@ -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") + } +} diff --git a/pkg/audio/doc.go b/pkg/audio/doc.go new file mode 100644 index 0000000..c799627 --- /dev/null +++ b/pkg/audio/doc.go @@ -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 diff --git a/pkg/audio/loop.go b/pkg/audio/loop.go new file mode 100644 index 0000000..1fb11ca --- /dev/null +++ b/pkg/audio/loop.go @@ -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 +} diff --git a/pkg/audio/loop_test.go b/pkg/audio/loop_test.go new file mode 100644 index 0000000..3e0daa5 --- /dev/null +++ b/pkg/audio/loop_test.go @@ -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) + } + } +} diff --git a/pkg/audio/play.go b/pkg/audio/play.go index 7d4a89e..c85cc12 100644 --- a/pkg/audio/play.go +++ b/pkg/audio/play.go @@ -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() } } diff --git a/pkg/audio/play_test.go b/pkg/audio/play_test.go new file mode 100644 index 0000000..e6eaf80 --- /dev/null +++ b/pkg/audio/play_test.go @@ -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) + } +} diff --git a/pkg/audio/testdata/test.wav b/pkg/audio/testdata/test.wav new file mode 100644 index 0000000000000000000000000000000000000000..dbd6fbf13197dcd18196a46ca86223683b253949 GIT binary patch literal 176478 zcmeIuF$%&!5CzaNSW9XvPax64UeJYwpkP9*1Cq`zcq$uj=z0v{4Zj#Zzk0Wv((tL` zejLuzbKcYuv4|>8?})ne%c!HxUB51UzNBmJQokyHJHDpFe!DAans|)!_***y1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U pAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0tEh7;0rba5D5SP literal 0 HcmV?d00001 diff --git a/pkg/tts/aliyun.go b/pkg/tts/aliyun.go index 87a5655..4a54de2 100644 --- a/pkg/tts/aliyun.go +++ b/pkg/tts/aliyun.go @@ -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 {