Compare commits
3 Commits
v1.0.2-rc3
...
v1.0.2-rc6
| Author | SHA1 | Date | |
|---|---|---|---|
| ebf9f515f6 | |||
| 788327047c | |||
| b5f7c823c8 |
1108
docs/superpowers/plans/2026-04-08-oto-audio-refactor.md
Normal file
1108
docs/superpowers/plans/2026-04-08-oto-audio-refactor.md
Normal file
File diff suppressed because it is too large
Load Diff
429
docs/superpowers/specs/2026-04-08-oto-audio-refactor-design.md
Normal file
429
docs/superpowers/specs/2026-04-08-oto-audio-refactor-design.md
Normal file
@@ -0,0 +1,429 @@
|
|||||||
|
# oto/v3 音频播放重构设计
|
||||||
|
|
||||||
|
**日期:** 2026-04-08
|
||||||
|
**作者:** Claude
|
||||||
|
**状态:** 设计阶段
|
||||||
|
|
||||||
|
## 1. 概述
|
||||||
|
|
||||||
|
### 1.1 重构目标
|
||||||
|
|
||||||
|
使用 `oto/v3` 完全重构项目中的音频播放功能,移除 `beep/v2` 依赖,解决音频播放问题。
|
||||||
|
|
||||||
|
### 1.2 重构范围
|
||||||
|
|
||||||
|
- ✅ 所有音频播放功能: WAV/MP3 一次性播放、BGM 循环播放、待机音频
|
||||||
|
- ✅ 保持现有业务逻辑不变
|
||||||
|
- ✅ 遵循 oto/v3 官方最佳实践
|
||||||
|
- ✅ 高效、简洁、优雅的实现
|
||||||
|
|
||||||
|
### 1.3 当前问题
|
||||||
|
|
||||||
|
- 音频未正确播放(具体表现待重构后验证)
|
||||||
|
- 依赖 `beep/v2` 中间层,增加复杂度
|
||||||
|
- 代码不够简洁优雅
|
||||||
|
|
||||||
|
## 2. 架构设计
|
||||||
|
|
||||||
|
### 2.1 整体结构
|
||||||
|
|
||||||
|
```
|
||||||
|
业务层 (middleware/routes)
|
||||||
|
↓
|
||||||
|
音频播放层 (pkg/audio)
|
||||||
|
↓
|
||||||
|
oto/v3 核心层
|
||||||
|
```
|
||||||
|
|
||||||
|
**设计原则:**
|
||||||
|
- 单一 oto.Context: 全局初始化一次,所有播放共享
|
||||||
|
- 独立 Player: 每次播放创建新的 Player,互不干扰
|
||||||
|
- 格式解码器: MP3/WAV 解码器直接传给 oto.Player
|
||||||
|
- 直接流式播放: 无需预先转 PCM,oto 自动处理
|
||||||
|
- 自动重采样: oto 会自动处理采样率不匹配的情况
|
||||||
|
|
||||||
|
### 2.2 依赖管理
|
||||||
|
|
||||||
|
**移除:**
|
||||||
|
- `github.com/gopxl/beep/v2`
|
||||||
|
|
||||||
|
**保留并改为直接依赖:**
|
||||||
|
- `github.com/ebitengine/oto/v3`
|
||||||
|
|
||||||
|
**保留:**
|
||||||
|
- `github.com/hajimehoshi/go-mp3`
|
||||||
|
|
||||||
|
**新增:**
|
||||||
|
- `github.com/go-audio/wav` - WAV 解码
|
||||||
|
|
||||||
|
## 3. 核心组件设计
|
||||||
|
|
||||||
|
### 3.1 全局状态管理
|
||||||
|
|
||||||
|
```go
|
||||||
|
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
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**设计说明:**
|
||||||
|
- ✅ 使用 `sync.Once` 确保全局 Context 只初始化一次
|
||||||
|
- ✅ 使用 `oto.NewContext()` 返回的 ready channel,而非包级变量
|
||||||
|
- ✅ 返回 error 给调用者处理,而非 panic
|
||||||
|
|
||||||
|
### 3.2 一次性播放(阻塞式)
|
||||||
|
|
||||||
|
**关键实现点:**
|
||||||
|
- oto.Context 配置为 44100Hz,16bit,立体声
|
||||||
|
- MP3/WAV 解码器直接传给 oto.NewPlayer()
|
||||||
|
- 播放完成后会自动停止,IsPlaying() 返回 false
|
||||||
|
- context 取消时,defer player.Close() 确保资源释放
|
||||||
|
- goroutine 会在 player.Close() 后自然退出,无泄漏风险
|
||||||
|
|
||||||
|
**PlayWav:** 播放 WAV 文件,阻塞直到完成或 context 取消
|
||||||
|
|
||||||
|
```go
|
||||||
|
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 取消
|
||||||
|
|
||||||
|
```go
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 BGM 循环播放(非阻塞)
|
||||||
|
|
||||||
|
**PlayMP3Loop:** 循环播放 MP3,立即返回 player 和清理函数
|
||||||
|
|
||||||
|
**资源管理:** 使用 atomic.Bool 和 sync.WaitGroup 控制 goroutine 生命周期,确保安全退出
|
||||||
|
|
||||||
|
```go
|
||||||
|
import "sync/atomic"
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
// 使用 atomic.Bool 和 WaitGroup 控制 goroutine 生命周期
|
||||||
|
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 {
|
||||||
|
// 1. 通知 goroutine 退出
|
||||||
|
playing.Store(false)
|
||||||
|
|
||||||
|
// 2. 等待 goroutine 完全退出
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
// 3. 按照 oto 最佳实践的顺序释放资源
|
||||||
|
player.Close()
|
||||||
|
return r.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
return player, cleanup, nil
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**设计说明:**
|
||||||
|
- ✅ 使用 `atomic.Bool` 明确控制 goroutine 生命周期
|
||||||
|
- ✅ 使用 `sync.WaitGroup` 明确等待 goroutine 退出
|
||||||
|
- ✅ goroutine 在每毫秒都检查退出条件,确保快速响应
|
||||||
|
- ✅ 清理函数先通知退出,等待 goroutine 完全停止,再释放资源
|
||||||
|
- ✅ 符合 oto 官方要求:"Do NOT close [reader] before you finish playing"
|
||||||
|
- ✅ 无竞态条件,无资源泄漏,并发安全
|
||||||
|
|
||||||
|
### 3.4 业务层使用示例
|
||||||
|
|
||||||
|
**场景1: TTS 播放 (pkg/tts/aliyun.go)**
|
||||||
|
|
||||||
|
改动前(beep):
|
||||||
|
```go
|
||||||
|
audio.PlayWav(ctx, buf)
|
||||||
|
```
|
||||||
|
|
||||||
|
改动后(oto):
|
||||||
|
```go
|
||||||
|
err := audio.PlayWav(ctx, buf)
|
||||||
|
if err != nil && !errors.Is(err, context.Canceled) {
|
||||||
|
zap.S().Errorf("TTS 播放失败: %v", err)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**场景2: BGM 播放 (internal/middleware/bgm.go)**
|
||||||
|
|
||||||
|
改动前(beep):
|
||||||
|
```go
|
||||||
|
ctrl, closer, e := audio.PlayBgmMP3(bgm)
|
||||||
|
defer closer()
|
||||||
|
<-a
|
||||||
|
speaker.Lock()
|
||||||
|
ctrl.Streamer = nil
|
||||||
|
speaker.Unlock()
|
||||||
|
```
|
||||||
|
|
||||||
|
改动后(oto):
|
||||||
|
```go
|
||||||
|
player, cleanup, err := audio.PlayMP3Loop(bgm)
|
||||||
|
if err != nil {
|
||||||
|
zap.S().Errorf("BGM 启动失败: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer cleanup() // ✅ 一次性清理所有资源,顺序正确
|
||||||
|
|
||||||
|
<-a
|
||||||
|
// cleanup() 会先 player.Close() 停止播放,再 r.Close() 清理 reader
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. API 设计
|
||||||
|
|
||||||
|
### 4.1 公开接口
|
||||||
|
|
||||||
|
```go
|
||||||
|
package audio
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/ebitengine/oto/v3"
|
||||||
|
"github.com/go-audio/wav"
|
||||||
|
"github.com/hajimehoshi/go-mp3"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PlayMP3 播放 MP3 文件(阻塞),直到完成或 context 取消
|
||||||
|
func PlayMP3(ctx context.Context, r io.ReadCloser) error
|
||||||
|
|
||||||
|
// PlayWav 播放 WAV 文件(阻塞),直到完成或 context 取消
|
||||||
|
func PlayWav(ctx context.Context, r io.ReadCloser) error
|
||||||
|
|
||||||
|
// PlayMP3Loop 循环播放 MP3(非阻塞)
|
||||||
|
// 返回 player 和清理函数,调用者负责 defer cleanup()
|
||||||
|
func PlayMP3Loop(r io.ReadCloser) (*oto.Player, func() error, error)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 功能覆盖
|
||||||
|
|
||||||
|
| 当前功能 | 当前 API | oto API | 说明 |
|
||||||
|
|---------|---------|---------|------|
|
||||||
|
| TTS 播放 | `PlayWav(ctx, r)` | `PlayWav(ctx, r)` | 新增 error 返回 |
|
||||||
|
| BGM 循环 | `PlayBgmMP3(r, ...)` | `PlayMP3Loop(r)` | 返回 player 和清理函数 |
|
||||||
|
|
||||||
|
## 5. 错误处理和资源管理
|
||||||
|
|
||||||
|
### 5.1 错误处理策略
|
||||||
|
|
||||||
|
- ✅ 一次性播放: 忽略 `context.Canceled`,记录其他错误
|
||||||
|
- ✅ BGM 播放: 所有错误都需要返回(影响启动)
|
||||||
|
- ✅ 统一使用 `fmt.Errorf` 包装错误信息
|
||||||
|
|
||||||
|
### 5.2 资源管理
|
||||||
|
|
||||||
|
**一次性播放:**
|
||||||
|
- ✅ `defer r.Close()` 确保资源释放
|
||||||
|
- ✅ `defer player.Close()` 确保播放器释放
|
||||||
|
- ✅ 函数内部完整管理资源生命周期
|
||||||
|
|
||||||
|
**BGM 循环播放:**
|
||||||
|
- ✅ 返回清理函数 `func() error`,内部保证正确的释放顺序
|
||||||
|
- ✅ 先 `player.Close()` 停止播放,再 `r.Close()` 清理 reader
|
||||||
|
- ✅ 符合 oto 官方要求:"Do NOT close [reader] before you finish playing"
|
||||||
|
- ✅ 业务层 `defer cleanup()` 一次性清理所有资源
|
||||||
|
- ✅ 责任边界清晰,资源管理可靠
|
||||||
|
|
||||||
|
### 5.3 并发安全
|
||||||
|
|
||||||
|
- ✅ `sync.Once` 保证全局 Context 线程安全
|
||||||
|
- ✅ 每个 Player 独立,无共享状态
|
||||||
|
- ✅ `player.Close()` 是线程安全的
|
||||||
|
- ✅ `atomic.Bool` 控制 goroutine 生命周期,无竞态条件
|
||||||
|
- ✅ `sync.WaitGroup` 明确等待 goroutine 退出,确保资源安全释放
|
||||||
|
|
||||||
|
## 6. 测试策略
|
||||||
|
|
||||||
|
### 6.1 单元测试
|
||||||
|
|
||||||
|
- TestInitContext - 测试音频上下文初始化
|
||||||
|
- TestPlayMP3 - 测试 MP3 播放
|
||||||
|
- TestPlayWav - 测试 WAV 播放
|
||||||
|
- TestPlayContextCancellation - 测试 Context 取消
|
||||||
|
- TestPlayMP3LoopStop - 测试 BGM 停止
|
||||||
|
- TestConcurrentPlay - 测试并发播放
|
||||||
|
|
||||||
|
### 6.2 测试数据
|
||||||
|
|
||||||
|
```
|
||||||
|
pkg/audio/testdata/
|
||||||
|
├── test.mp3
|
||||||
|
├── test.wav
|
||||||
|
└── corrupt.mp3
|
||||||
|
```
|
||||||
|
|
||||||
|
## 7. 实施计划
|
||||||
|
|
||||||
|
### 7.1 实施步骤
|
||||||
|
|
||||||
|
1. **添加依赖**
|
||||||
|
```bash
|
||||||
|
go get github.com/go-audio/wav
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **实现核心组件**
|
||||||
|
- initContext()
|
||||||
|
- PlayWav()
|
||||||
|
- PlayMP3()
|
||||||
|
- PlayMP3Loop()
|
||||||
|
|
||||||
|
3. **更新业务层**
|
||||||
|
- pkg/tts/aliyun.go
|
||||||
|
- internal/middleware/bgm.go
|
||||||
|
- internal/routes/standby/audio.go
|
||||||
|
|
||||||
|
4. **移除旧依赖**
|
||||||
|
```bash
|
||||||
|
go mod tidy
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **测试**
|
||||||
|
- 单元测试
|
||||||
|
- 集成测试
|
||||||
|
|
||||||
|
### 7.2 风险评估
|
||||||
|
|
||||||
|
**低风险:**
|
||||||
|
- API 简单清晰
|
||||||
|
- 依赖成熟稳定
|
||||||
|
- 业务层改动最小
|
||||||
|
|
||||||
|
**需验证:**
|
||||||
|
- 树莓派 ARM64 平台兼容性
|
||||||
|
- 循环播放性能
|
||||||
|
- 并发播放稳定性
|
||||||
|
|
||||||
|
## 8. 验收标准
|
||||||
|
|
||||||
|
- ✅ 所有单元测试通过
|
||||||
|
- ✅ 实际播放测试通过
|
||||||
|
- ✅ BGM 循环播放正常
|
||||||
|
- ✅ Context 取消机制正常
|
||||||
|
- ✅ 无资源泄漏
|
||||||
|
- ✅ 树莓派平台运行正常
|
||||||
9
go.mod
9
go.mod
@@ -5,16 +5,18 @@ go 1.23.2
|
|||||||
require (
|
require (
|
||||||
github.com/adrg/libvlc-go/v3 v3.1.6
|
github.com/adrg/libvlc-go/v3 v3.1.6
|
||||||
github.com/aliyun/alibabacloud-nls-go-sdk v1.1.1
|
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/eclipse/paho.golang v0.22.0
|
||||||
github.com/go-pkgz/cronrange v0.2.0
|
github.com/go-pkgz/cronrange v0.2.0
|
||||||
github.com/go-rod/rod v0.116.2
|
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/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/hypebeast/go-osc v0.0.0-20220308234300-cec5a8a1e5f5
|
||||||
github.com/spf13/viper v1.21.0
|
github.com/spf13/viper v1.21.0
|
||||||
github.com/tencentcloud/tencentcloud-cls-sdk-go v1.0.11
|
github.com/tencentcloud/tencentcloud-cls-sdk-go v1.0.11
|
||||||
github.com/urfave/cli/v3 v3.8.0
|
github.com/urfave/cli/v3 v3.8.0
|
||||||
github.com/warthog618/go-gpiocdev v0.9.1
|
github.com/warthog618/go-gpiocdev v0.9.1
|
||||||
|
github.com/youpy/go-wav v0.3.2
|
||||||
go.uber.org/zap v1.27.0
|
go.uber.org/zap v1.27.0
|
||||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1
|
gopkg.in/natefinch/lumberjack.v2 v2.2.1
|
||||||
)
|
)
|
||||||
@@ -22,14 +24,12 @@ require (
|
|||||||
require (
|
require (
|
||||||
github.com/aliyun/alibaba-cloud-sdk-go v1.63.93 // indirect
|
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/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/ebitengine/purego v0.8.2 // indirect
|
||||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||||
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
||||||
github.com/golang/protobuf v1.5.4 // indirect
|
github.com/golang/protobuf v1.5.4 // indirect
|
||||||
github.com/gorilla/websocket v1.5.3 // indirect
|
github.com/gorilla/websocket v1.5.3 // indirect
|
||||||
github.com/grid-x/serial v0.0.0-20211107191517-583c7356b3aa // 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/jmespath/go-jmespath v0.4.0 // indirect
|
||||||
github.com/json-iterator/go v1.1.12 // indirect
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
github.com/klauspost/compress v1.18.0 // 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/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b // indirect
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||||
github.com/pierrec/lz4 v2.6.1+incompatible // 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/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||||
github.com/sagikazarmark/locafero v0.11.0 // indirect
|
github.com/sagikazarmark/locafero v0.11.0 // indirect
|
||||||
github.com/satori/go.uuid v1.2.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/cast v1.10.0 // indirect
|
||||||
github.com/spf13/pflag v1.0.10 // indirect
|
github.com/spf13/pflag v1.0.10 // indirect
|
||||||
github.com/subosito/gotenv v1.6.0 // 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/fetchup v0.3.0 // indirect
|
||||||
github.com/ysmood/goob v0.4.0 // indirect
|
github.com/ysmood/goob v0.4.0 // indirect
|
||||||
github.com/ysmood/got v0.40.0 // indirect
|
github.com/ysmood/got v0.40.0 // indirect
|
||||||
github.com/ysmood/gson v0.7.3 // indirect
|
github.com/ysmood/gson v0.7.3 // indirect
|
||||||
github.com/ysmood/leakless v0.9.0 // 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/atomic v1.11.0 // indirect
|
||||||
go.uber.org/multierr v1.11.0 // indirect
|
go.uber.org/multierr v1.11.0 // indirect
|
||||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||||
|
|||||||
14
go.sum
14
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/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/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/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.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 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
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/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 h1:FfH+VrHHk6Lxt9HdVS0PXzSXFyS2NbZKXv33FYPol0A=
|
||||||
github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b/go.mod h1:AC62GU6hc0BrNm+9RK9VSiwa/EUe1bkIeFORAMcHvJU=
|
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 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
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=
|
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/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/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.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.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 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
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-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 h1:MRAEv+T+itmw+3GeIGpQJBfanUVyg0l3JCTwHtwdre4=
|
||||||
github.com/warthog618/go-gpiosim v0.1.1/go.mod h1:YXsnB+I9jdCMY4YAlMSRrlts25ltjmuIsrnoUrBLdqU=
|
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 h1:UhYz9xnLEVn2ukSuK3KCgcznWpHMdrmbsPpllcylyu8=
|
||||||
github.com/ysmood/fetchup v0.3.0/go.mod h1:hbysoq65PXL0NQeNzUczNYIKpwpkwFL4LXMDEvIQq9A=
|
github.com/ysmood/fetchup v0.3.0/go.mod h1:hbysoq65PXL0NQeNzUczNYIKpwpkwFL4LXMDEvIQq9A=
|
||||||
github.com/ysmood/goob v0.4.0 h1:HsxXhyLBeGzWXnqVKtmT9qM7EuVs/XOgkX7T6r1o1AQ=
|
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/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 h1:qxCG5VirSBvmi3uynXFkcnLMzkphdh3xx5FtrORwDCU=
|
||||||
github.com/ysmood/leakless v0.9.0/go.mod h1:R8iAXPRaG97QJwqxs74RdwzcRHT1SWCGTNqY8q0JvMQ=
|
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.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 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
||||||
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
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.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
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 h1:CMa0sjHSru3puNx+J0MIAuiiEV4N0qj8/cMWGBBCsjw=
|
||||||
pgregory.net/rapid v1.1.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04=
|
pgregory.net/rapid v1.1.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04=
|
||||||
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
|
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import (
|
|||||||
"game-driver/pkg/utils"
|
"game-driver/pkg/utils"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"github.com/gopxl/beep/v2/speaker"
|
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -22,13 +21,10 @@ func PlayBgm() leaf.HandlerFunc {
|
|||||||
}
|
}
|
||||||
if bgm != nil {
|
if bgm != nil {
|
||||||
zap.S().Infoln("背景音乐解析成功")
|
zap.S().Infoln("背景音乐解析成功")
|
||||||
// 等待组
|
|
||||||
var wait sync.WaitGroup
|
var wait sync.WaitGroup
|
||||||
defer wait.Wait()
|
defer wait.Wait()
|
||||||
|
|
||||||
// 结束信号通道
|
|
||||||
a := make(chan struct{})
|
a := make(chan struct{})
|
||||||
// 发送结束信号
|
|
||||||
defer close(a)
|
defer close(a)
|
||||||
|
|
||||||
wait.Add(1)
|
wait.Add(1)
|
||||||
@@ -38,18 +34,14 @@ func PlayBgm() leaf.HandlerFunc {
|
|||||||
zap.S().Infoln("开始播放背景音乐")
|
zap.S().Infoln("开始播放背景音乐")
|
||||||
defer zap.S().Infoln("结束背景音乐播放")
|
defer zap.S().Infoln("结束背景音乐播放")
|
||||||
|
|
||||||
ctrl, closer, e := audio.PlayBgmMP3(bgm)
|
_, cleanup, err := audio.PlayMP3Loop(bgm)
|
||||||
defer closer()
|
if err != nil {
|
||||||
if e != nil {
|
zap.S().Errorln("播放背景音乐异常:", err)
|
||||||
zap.S().Errorln("播放背景音乐异常:", e)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
<-a
|
<-a
|
||||||
|
|
||||||
speaker.Lock()
|
|
||||||
ctrl.Streamer = nil
|
|
||||||
speaker.Unlock()
|
|
||||||
}()
|
}()
|
||||||
} else {
|
} else {
|
||||||
zap.S().Infoln("未解析到背景音乐")
|
zap.S().Infoln("未解析到背景音乐")
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
"game-driver/internal/schema"
|
"game-driver/internal/schema"
|
||||||
"game-driver/pkg/audio"
|
"game-driver/pkg/audio"
|
||||||
"game-driver/pkg/utils"
|
"game-driver/pkg/utils"
|
||||||
"github.com/gopxl/beep/v2/speaker"
|
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -23,18 +23,14 @@ func Audio(item schema.WaitItemModel) func(c context.Context) error {
|
|||||||
zap.S().Infoln("播放待机音乐")
|
zap.S().Infoln("播放待机音乐")
|
||||||
defer zap.S().Infoln("结束待机音乐")
|
defer zap.S().Infoln("结束待机音乐")
|
||||||
|
|
||||||
ctrl, closer, e := audio.PlayBgmMP3(data)
|
_, cleanup, err := audio.PlayMP3Loop(data)
|
||||||
defer closer()
|
if err != nil {
|
||||||
if e != nil {
|
return fmt.Errorf("播放待机音乐异常: %w", err)
|
||||||
return fmt.Errorf("播放待机音乐异常: %w", e)
|
|
||||||
}
|
}
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
<-c.Done()
|
<-c.Done()
|
||||||
|
|
||||||
speaker.Lock()
|
|
||||||
ctrl.Streamer = nil
|
|
||||||
speaker.Unlock()
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,11 +2,13 @@ package standby
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"game-driver/internal/schema"
|
"game-driver/internal/schema"
|
||||||
"game-driver/pkg/audio"
|
"game-driver/pkg/audio"
|
||||||
"game-driver/pkg/tts"
|
"game-driver/pkg/tts"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
|
"io"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TTS(item schema.WaitItemModel) func(c context.Context) error {
|
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 语音")
|
zap.S().Infoln("播放待机 TTS 语音")
|
||||||
defer 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
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,107 +1,85 @@
|
|||||||
package audio
|
package audio
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gopxl/beep/v2"
|
"github.com/youpy/go-wav"
|
||||||
"github.com/gopxl/beep/v2/mp3"
|
"github.com/hajimehoshi/go-mp3"
|
||||||
"github.com/gopxl/beep/v2/speaker"
|
|
||||||
"github.com/gopxl/beep/v2/wav"
|
|
||||||
"go.uber.org/zap"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var DefaultSampleRate = beep.SampleRate(44100)
|
// PlayWav 播放 WAV 文件(阻塞),直到完成或 context 取消
|
||||||
|
func PlayWav(ctx context.Context, r io.ReadCloser) error {
|
||||||
func init() {
|
otoCtx, err := initContext()
|
||||||
err := speaker.Init(DefaultSampleRate, DefaultSampleRate.N(time.Second/10))
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic("扬声器初始化异常: " + err.Error())
|
return fmt.Errorf("音频上下文初始化失败: %w", err)
|
||||||
}
|
|
||||||
zap.S().Infoln("扬声器初始化完成")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func PlayWav(c context.Context, r io.Reader) {
|
// Read the entire file into memory since wav.NewReader needs ReadAt
|
||||||
zap.S().Debugln("开始 WAV 解码")
|
data, err := io.ReadAll(r)
|
||||||
streamer, format, err := wav.Decode(r)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
zap.S().Errorln("WAV解码失败: ", err)
|
r.Close()
|
||||||
return
|
return fmt.Errorf("读取 WAV 文件失败: %w", err)
|
||||||
}
|
}
|
||||||
defer streamer.Close()
|
r.Close()
|
||||||
|
|
||||||
// 获取音频长度信息
|
// Create a reader from the buffered data
|
||||||
totalSamples := streamer.Len()
|
dec := wav.NewReader(bytes.NewReader(data))
|
||||||
zap.S().Debugf("WAV解码成功,采样率: %d, 总样本数: %d, 预计时长: %.2f秒",
|
player := otoCtx.NewPlayer(dec)
|
||||||
format.SampleRate, totalSamples, float64(totalSamples)/float64(format.SampleRate))
|
defer player.Close()
|
||||||
|
|
||||||
s := beep.Resample(4, format.SampleRate, DefaultSampleRate, streamer)
|
player.Play()
|
||||||
|
|
||||||
ctrl := &beep.Ctrl{Streamer: s}
|
|
||||||
done := make(chan struct{})
|
done := make(chan struct{})
|
||||||
speaker.Play(beep.Seq(ctrl, beep.Callback(func() {
|
go func() {
|
||||||
zap.S().Debugln("音频播放完成")
|
for player.IsPlaying() {
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
}
|
||||||
close(done)
|
close(done)
|
||||||
})))
|
}()
|
||||||
|
|
||||||
zap.S().Debugln("等待音频播放完成...")
|
|
||||||
ticker := time.NewTicker(1 * time.Second)
|
|
||||||
defer ticker.Stop()
|
|
||||||
|
|
||||||
lastPos := 0
|
|
||||||
for {
|
|
||||||
select {
|
select {
|
||||||
case <-done:
|
case <-done:
|
||||||
zap.S().Infoln("音频播放正常结束")
|
return nil
|
||||||
return
|
case <-ctx.Done():
|
||||||
case <-c.Done():
|
return ctx.Err()
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func PlayMP3(c context.Context, r io.ReadCloser) {
|
// PlayMP3 播放 MP3 文件(阻塞),直到完成或 context 取消
|
||||||
streamer, format, err := mp3.Decode(r)
|
func PlayMP3(ctx context.Context, r io.ReadCloser) error {
|
||||||
|
otoCtx, err := initContext()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
zap.S().Errorln("MP3解码失败: ", err)
|
return fmt.Errorf("音频上下文初始化失败: %w", err)
|
||||||
return
|
|
||||||
}
|
}
|
||||||
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{})
|
done := make(chan struct{})
|
||||||
speaker.Play(beep.Seq(ctrl, beep.Callback(func() {
|
go func() {
|
||||||
|
for player.IsPlaying() {
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
}
|
||||||
close(done)
|
close(done)
|
||||||
})))
|
}()
|
||||||
|
|
||||||
for {
|
|
||||||
select {
|
select {
|
||||||
case <-done:
|
case <-done:
|
||||||
return
|
return nil
|
||||||
case <-c.Done():
|
case <-ctx.Done():
|
||||||
speaker.Lock()
|
return ctx.Err()
|
||||||
ctrl.Streamer = nil
|
|
||||||
speaker.Unlock()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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 (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"game-driver/config"
|
"game-driver/config"
|
||||||
"game-driver/leaf"
|
"game-driver/leaf"
|
||||||
@@ -66,7 +67,10 @@ func (tts *AliTTS) Sound(ctx context.Context, text string) {
|
|||||||
tts.mu.Lock()
|
tts.mu.Lock()
|
||||||
defer tts.mu.Unlock()
|
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 {
|
if ctx.Err() != nil {
|
||||||
|
|||||||
Reference in New Issue
Block a user