Compare commits
24 Commits
clean_beep
...
v1.0.2-rc6
| Author | SHA1 | Date | |
|---|---|---|---|
| ebf9f515f6 | |||
| 788327047c | |||
| b5f7c823c8 | |||
| 1f527dce98 | |||
| cbccb07398 | |||
| b0f07624b0 | |||
| e31fca22c8 | |||
| bee3b98798 | |||
| e4c34f0eec | |||
| 2331d0c73f | |||
| 5ee8e15965 | |||
| ae5f998d88 | |||
| 6cf28217a4 | |||
| e93e99480b | |||
| 619e919fa0 | |||
| 809f123854 | |||
| 46961040b3 | |||
| 4349413887 | |||
| 994023553d | |||
| dd0c7b8feb | |||
| 2d5d3919e2 | |||
| ac0a338b76 | |||
| 24b2b6c199 | |||
| a314a1a0d8 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,6 +1,7 @@
|
|||||||
/logs
|
/logs
|
||||||
/.idea
|
/.idea
|
||||||
/.vscode
|
/.vscode
|
||||||
|
/.qwen
|
||||||
|
|
||||||
*.mp3
|
*.mp3
|
||||||
game-driver*
|
game-driver*
|
||||||
43
.woodpecker.yml
Normal file
43
.woodpecker.yml
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
when:
|
||||||
|
- event: tag
|
||||||
|
|
||||||
|
clone:
|
||||||
|
git:
|
||||||
|
image: docker.m.daocloud.io/woodpeckerci/plugin-git
|
||||||
|
settings:
|
||||||
|
depth: 1
|
||||||
|
|
||||||
|
steps:
|
||||||
|
# 构建多架构二进制文件
|
||||||
|
build:
|
||||||
|
image: docker.m.daocloud.io/golang:1.24-trixie
|
||||||
|
environment:
|
||||||
|
GOPROXY: https://goproxy.cn
|
||||||
|
commands:
|
||||||
|
- sed -i 's|deb.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list.d/debian.sources
|
||||||
|
# 启用多架构支持并安装交叉编译工具
|
||||||
|
- dpkg --add-architecture arm64
|
||||||
|
- apt-get update
|
||||||
|
- apt-get install -y gcc-aarch64-linux-gnu pkg-config
|
||||||
|
- apt-get install -y libasound2-dev libvlc-dev
|
||||||
|
- apt-get install -y libasound2-dev:arm64 libvlc-dev:arm64
|
||||||
|
- mkdir -p release
|
||||||
|
# 构建 amd64 (native)
|
||||||
|
- PKG_CONFIG_PATH=/usr/lib/x86_64-linux-gnu/pkgconfig go build -ldflags="-w -s -X main.Version=${CI_COMMIT_TAG} -X main.Commit=${CI_COMMIT_SHA}" -o release/game-driver-linux-amd64 .
|
||||||
|
# 构建 arm64 (cross-compile)
|
||||||
|
- PKG_CONFIG_PATH=/usr/lib/aarch64-linux-gnu/pkgconfig GOOS=linux GOARCH=arm64 CGO_ENABLED=1 CC=aarch64-linux-gnu-gcc go build -ldflags="-w -s -X main.Version=${CI_COMMIT_TAG} -X main.Commit=${CI_COMMIT_SHA}" -o release/game-driver-linux-arm64 .
|
||||||
|
- ls -lh release/
|
||||||
|
|
||||||
|
# 发布构建产物(可选)
|
||||||
|
release:
|
||||||
|
image: docker.m.daocloud.io/woodpeckerci/plugin-release
|
||||||
|
settings:
|
||||||
|
base-url: https://gitea.tides.top
|
||||||
|
title: ${CI_COMMIT_TAG}
|
||||||
|
api-key:
|
||||||
|
from_secret: gitea_token
|
||||||
|
files:
|
||||||
|
- release/game-driver-linux-amd64
|
||||||
|
- release/game-driver-linux-arm64
|
||||||
|
depends_on:
|
||||||
|
- build
|
||||||
90
cmd/root.go
90
cmd/root.go
@@ -1,90 +0,0 @@
|
|||||||
/*
|
|
||||||
Copyright © 2024 慕枫Go <mapleafgo@163.com>
|
|
||||||
*/
|
|
||||||
package cmd
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"game-driver/config"
|
|
||||||
"game-driver/config/game"
|
|
||||||
"game-driver/config/wait"
|
|
||||||
"game-driver/internal"
|
|
||||||
"io/fs"
|
|
||||||
"log"
|
|
||||||
"os"
|
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
"github.com/spf13/viper"
|
|
||||||
)
|
|
||||||
|
|
||||||
var cfgFile string
|
|
||||||
|
|
||||||
// rootCmd represents the base command when called without any subcommands
|
|
||||||
var rootCmd = &cobra.Command{
|
|
||||||
Use: "game-driver",
|
|
||||||
Version: "1.0.1",
|
|
||||||
Short: "A brief description of your application",
|
|
||||||
Long: `A longer description that spans multiple lines and likely contains
|
|
||||||
examples and usage of using your application. For example:
|
|
||||||
|
|
||||||
Cobra is a CLI library for Go that empowers applications.
|
|
||||||
This application is a tool to generate the needed files
|
|
||||||
to quickly create a Cobra application.`,
|
|
||||||
// Uncomment the following line if your bare application
|
|
||||||
// has an action associated with it:
|
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
|
||||||
internal.Run()
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
// Execute adds all child commands to the root command and sets flags appropriately.
|
|
||||||
// This is called by main.main(). It only needs to happen once to the rootCmd.
|
|
||||||
func Execute() {
|
|
||||||
err := rootCmd.Execute()
|
|
||||||
if err != nil {
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
cobra.OnInitialize(initConfig)
|
|
||||||
|
|
||||||
rootCmd.PersistentFlags().StringVarP(&cfgFile, "config", "c", "config.yml", "默认当前目录下的 config.yml")
|
|
||||||
}
|
|
||||||
|
|
||||||
// initConfig reads in config file and ENV variables if set.
|
|
||||||
func initConfig() {
|
|
||||||
viper.SetConfigFile(cfgFile)
|
|
||||||
viper.AutomaticEnv() // read in environment variables that match
|
|
||||||
// If a config file is found, read it in.
|
|
||||||
if err := viper.ReadInConfig(); err == nil {
|
|
||||||
log.Printf("Using config file: %s", viper.ConfigFileUsed())
|
|
||||||
} else if errors.Is(err, fs.ErrNotExist) {
|
|
||||||
log.Printf("无法找到主配置文件: %s", viper.ConfigFileUsed())
|
|
||||||
os.Exit(1)
|
|
||||||
} else {
|
|
||||||
log.Panicln("read config file error: ", err)
|
|
||||||
}
|
|
||||||
err := viper.Unmarshal(&config.C)
|
|
||||||
if err != nil {
|
|
||||||
log.Panicln("unmarshal config failed: ", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 初始化游戏节点配置
|
|
||||||
game.C = game.NewConfig(config.C.Point)
|
|
||||||
if game.C != nil { // 如果需要游戏配置
|
|
||||||
err = viper.UnmarshalKey("game", &game.C)
|
|
||||||
if err != nil {
|
|
||||||
log.Panicln("unmarshal game config failed: ", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 初始化游戏节点待机时配置
|
|
||||||
wait.C = wait.NewConfig(config.C.Point)
|
|
||||||
if wait.C != nil { // 如果需要游戏配置
|
|
||||||
err = viper.UnmarshalKey("wait", &wait.C)
|
|
||||||
if err != nil {
|
|
||||||
log.Panicln("unmarshal wait config failed: ", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
12
config.yml
12
config.yml
@@ -1,5 +1,5 @@
|
|||||||
location: wushan # 项目名称
|
location: wushan # 项目名称
|
||||||
point: 3 # 点位
|
point: 5 # 点位
|
||||||
relay:
|
relay:
|
||||||
maxTimeout: 60 # 单位 s,必须大于 0
|
maxTimeout: 60 # 单位 s,必须大于 0
|
||||||
standbyCache: # 待机缓存文件路径
|
standbyCache: # 待机缓存文件路径
|
||||||
@@ -17,9 +17,9 @@ log:
|
|||||||
maxAge: 30
|
maxAge: 30
|
||||||
compress: true
|
compress: true
|
||||||
mqtt:
|
mqtt:
|
||||||
url: mqtt://wushan-mqtt.chaoshengshuzi.com:1883
|
url: mqtt://mqtt.wxsxlj.com:1883
|
||||||
clientID: wushan-3
|
clientID: wushan-5
|
||||||
password: wushan@1013
|
password:
|
||||||
aliyun:
|
aliyun:
|
||||||
accessKeyID:
|
accessKeyID:
|
||||||
accessKeySecret:
|
accessKeySecret:
|
||||||
@@ -31,8 +31,8 @@ aliyun:
|
|||||||
|
|
||||||
# 激光秀点位 osc 参数
|
# 激光秀点位 osc 参数
|
||||||
game:
|
game:
|
||||||
host: 192.168.0.167
|
host: 192.168.1.191
|
||||||
port: 3033
|
port: 8000
|
||||||
|
|
||||||
# 待机投影仪控制参数
|
# 待机投影仪控制参数
|
||||||
#wait:
|
#wait:
|
||||||
|
|||||||
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 取消机制正常
|
||||||
|
- ✅ 无资源泄漏
|
||||||
|
- ✅ 树莓派平台运行正常
|
||||||
40
go.mod
40
go.mod
@@ -5,62 +5,60 @@ 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/cobra v1.9.1
|
github.com/spf13/viper v1.21.0
|
||||||
github.com/spf13/viper v1.19.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/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
|
||||||
)
|
)
|
||||||
|
|
||||||
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/ebitengine/oto/v3 v3.3.2 // indirect
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||||
github.com/ebitengine/purego v0.8.2 // indirect
|
github.com/ebitengine/purego v0.8.2 // indirect
|
||||||
github.com/fsnotify/fsnotify v1.8.0 // 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/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/hashicorp/hcl v1.0.0 // indirect
|
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 // 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
|
||||||
github.com/magiconair/properties v1.8.9 // indirect
|
|
||||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||||
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.3 // 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/sagikazarmark/locafero v0.7.0 // indirect
|
github.com/sagikazarmark/locafero v0.11.0 // indirect
|
||||||
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
|
|
||||||
github.com/satori/go.uuid v1.2.0 // indirect
|
github.com/satori/go.uuid v1.2.0 // indirect
|
||||||
github.com/sourcegraph/conc v0.3.0 // indirect
|
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
|
||||||
github.com/spf13/afero v1.12.0 // indirect
|
github.com/spf13/afero v1.15.0 // indirect
|
||||||
github.com/spf13/cast v1.7.1 // indirect
|
github.com/spf13/cast v1.10.0 // indirect
|
||||||
github.com/spf13/pflag v1.0.6 // 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
|
||||||
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect
|
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||||
golang.org/x/net v0.37.0 // indirect
|
golang.org/x/net v0.37.0 // indirect
|
||||||
golang.org/x/sys v0.31.0 // indirect
|
golang.org/x/sys v0.31.0 // indirect
|
||||||
golang.org/x/text v0.23.0 // indirect
|
golang.org/x/text v0.28.0 // indirect
|
||||||
google.golang.org/protobuf v1.36.5 // indirect
|
google.golang.org/protobuf v1.36.5 // indirect
|
||||||
gopkg.in/ini.v1 v1.67.0 // indirect
|
gopkg.in/ini.v1 v1.67.0 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
|
||||||
)
|
)
|
||||||
|
|||||||
88
go.sum
88
go.sum
@@ -5,13 +5,10 @@ github.com/adrg/libvlc-go/v3 v3.1.6 h1:Cm22w6xNMDdzYCW8koHgAvjonYm4xbPP5TrlVTtMd
|
|||||||
github.com/adrg/libvlc-go/v3 v3.1.6/go.mod h1:xJK0YD8cyMDejnrTFQinStE6RYCV1nlfS8KmqTpszSc=
|
github.com/adrg/libvlc-go/v3 v3.1.6/go.mod h1:xJK0YD8cyMDejnrTFQinStE6RYCV1nlfS8KmqTpszSc=
|
||||||
github.com/ajstarks/svgo v0.0.0-20180226025133-644b8db467af/go.mod h1:K08gAheRH3/J6wwsYMMT4xOr94bZjxIelGM0+d/wbFw=
|
github.com/ajstarks/svgo v0.0.0-20180226025133-644b8db467af/go.mod h1:K08gAheRH3/J6wwsYMMT4xOr94bZjxIelGM0+d/wbFw=
|
||||||
github.com/aliyun/alibaba-cloud-sdk-go v1.61.1376/go.mod h1:9CMdKNL3ynIGPpfTcdwTvIm8SGuAZYYC4jFVSSvE1YQ=
|
github.com/aliyun/alibaba-cloud-sdk-go v1.61.1376/go.mod h1:9CMdKNL3ynIGPpfTcdwTvIm8SGuAZYYC4jFVSSvE1YQ=
|
||||||
github.com/aliyun/alibaba-cloud-sdk-go v1.63.92 h1:qespx4b6EexlXkvQUow9x0v1GnWUJYGU5FWYw3a4Wlg=
|
|
||||||
github.com/aliyun/alibaba-cloud-sdk-go v1.63.92/go.mod h1:SOSDHfe1kX91v3W5QiBsWSLqeLxImobbMX1mxrFHsVQ=
|
|
||||||
github.com/aliyun/alibaba-cloud-sdk-go v1.63.93 h1:yHRWq/QmBJ3lC15zy1A1+TkvcAN+6dr1bgHsFghKvmk=
|
github.com/aliyun/alibaba-cloud-sdk-go v1.63.93 h1:yHRWq/QmBJ3lC15zy1A1+TkvcAN+6dr1bgHsFghKvmk=
|
||||||
github.com/aliyun/alibaba-cloud-sdk-go v1.63.93/go.mod h1:SOSDHfe1kX91v3W5QiBsWSLqeLxImobbMX1mxrFHsVQ=
|
github.com/aliyun/alibaba-cloud-sdk-go v1.63.93/go.mod h1:SOSDHfe1kX91v3W5QiBsWSLqeLxImobbMX1mxrFHsVQ=
|
||||||
github.com/aliyun/alibabacloud-nls-go-sdk v1.1.1 h1:LjItoNZuu5xHlsByFo+kr3nGa4LRIESCGWhfurayxBg=
|
github.com/aliyun/alibabacloud-nls-go-sdk v1.1.1 h1:LjItoNZuu5xHlsByFo+kr3nGa4LRIESCGWhfurayxBg=
|
||||||
github.com/aliyun/alibabacloud-nls-go-sdk v1.1.1/go.mod h1:4BDMUKpEaP/Ct79w0ozR0nbnEj49g1k3mrgX/IKG5I4=
|
github.com/aliyun/alibabacloud-nls-go-sdk v1.1.1/go.mod h1:4BDMUKpEaP/Ct79w0ozR0nbnEj49g1k3mrgX/IKG5I4=
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
|
||||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
@@ -27,13 +24,15 @@ github.com/fogleman/gg v1.2.1-0.20190220221249-0403632d5b90/go.mod h1:R/bRT+9gY/
|
|||||||
github.com/frankban/quicktest v1.14.0/go.mod h1:NeW+ay9A/U67EYXNFA1nPE8e/tnQv/09mUdL/ijj8og=
|
github.com/frankban/quicktest v1.14.0/go.mod h1:NeW+ay9A/U67EYXNFA1nPE8e/tnQv/09mUdL/ijj8og=
|
||||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||||
github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M=
|
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||||
github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||||
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
|
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
|
||||||
github.com/go-pkgz/cronrange v0.2.0 h1:FaJ/TB7Ng3xTCfRgblfLecL07RccXVVB6+/hdFaGbBE=
|
github.com/go-pkgz/cronrange v0.2.0 h1:FaJ/TB7Ng3xTCfRgblfLecL07RccXVVB6+/hdFaGbBE=
|
||||||
github.com/go-pkgz/cronrange v0.2.0/go.mod h1:2dPQzEVkSwXsRdcGFXIE6xllAnwELWUusad2MyVskLs=
|
github.com/go-pkgz/cronrange v0.2.0/go.mod h1:2dPQzEVkSwXsRdcGFXIE6xllAnwELWUusad2MyVskLs=
|
||||||
github.com/go-rod/rod v0.116.2 h1:A5t2Ky2A+5eD/ZJQr1EfsQSe5rms5Xof/qj296e+ZqA=
|
github.com/go-rod/rod v0.116.2 h1:A5t2Ky2A+5eD/ZJQr1EfsQSe5rms5Xof/qj296e+ZqA=
|
||||||
github.com/go-rod/rod v0.116.2/go.mod h1:H+CMO9SCNc2TJ2WfrG+pKhITz57uGNYU43qYHh438Mg=
|
github.com/go-rod/rod v0.116.2/go.mod h1:H+CMO9SCNc2TJ2WfrG+pKhITz57uGNYU43qYHh438Mg=
|
||||||
|
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
|
||||||
|
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||||
github.com/goji/httpauth v0.0.0-20160601135302-2da839ab0f4d/go.mod h1:nnjvkQ9ptGaCkuDUx6wNykzzlUixGxvkme+H/lnzb+A=
|
github.com/goji/httpauth v0.0.0-20160601135302-2da839ab0f4d/go.mod h1:nnjvkQ9ptGaCkuDUx6wNykzzlUixGxvkme+H/lnzb+A=
|
||||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
|
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
|
||||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||||
@@ -46,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=
|
||||||
@@ -58,12 +55,8 @@ github.com/grid-x/serial v0.0.0-20211107191517-583c7356b3aa/go.mod h1:kdOd86/VGF
|
|||||||
github.com/hajimehoshi/go-mp3 v0.3.4 h1:NUP7pBYH8OguP4diaTZ9wJbUbk3tC0KlfzsEpWmYj68=
|
github.com/hajimehoshi/go-mp3 v0.3.4 h1:NUP7pBYH8OguP4diaTZ9wJbUbk3tC0KlfzsEpWmYj68=
|
||||||
github.com/hajimehoshi/go-mp3 v0.3.4/go.mod h1:fRtZraRFcWb0pu7ok0LqyFhCUrPeMsGRSVop0eemFmo=
|
github.com/hajimehoshi/go-mp3 v0.3.4/go.mod h1:fRtZraRFcWb0pu7ok0LqyFhCUrPeMsGRSVop0eemFmo=
|
||||||
github.com/hajimehoshi/oto/v2 v2.3.1/go.mod h1:seWLbgHH7AyUMYKfKYT9pg7PhUu9/SisyJvNTT+ASQo=
|
github.com/hajimehoshi/oto/v2 v2.3.1/go.mod h1:seWLbgHH7AyUMYKfKYT9pg7PhUu9/SisyJvNTT+ASQo=
|
||||||
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
|
|
||||||
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
|
|
||||||
github.com/hypebeast/go-osc v0.0.0-20220308234300-cec5a8a1e5f5 h1:fqwINudmUrvGCuw+e3tedZ2UJ0hklSw6t8UPomctKyQ=
|
github.com/hypebeast/go-osc v0.0.0-20220308234300-cec5a8a1e5f5 h1:fqwINudmUrvGCuw+e3tedZ2UJ0hklSw6t8UPomctKyQ=
|
||||||
github.com/hypebeast/go-osc v0.0.0-20220308234300-cec5a8a1e5f5/go.mod h1:lqMjoCs0y0GoRRujSPZRBaGb4c5ER6TfkFKSClxkMbY=
|
github.com/hypebeast/go-osc v0.0.0-20220308234300-cec5a8a1e5f5/go.mod h1:lqMjoCs0y0GoRRujSPZRBaGb4c5ER6TfkFKSClxkMbY=
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
|
||||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
|
||||||
github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k=
|
github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k=
|
||||||
github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg=
|
github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg=
|
||||||
github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
|
github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
|
||||||
@@ -85,10 +78,6 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
|||||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
github.com/magiconair/properties v1.8.9 h1:nWcCbLq1N2v/cpNsy5WvQ37Fb+YElfq20WJ/a8RkpQM=
|
|
||||||
github.com/magiconair/properties v1.8.9/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
|
|
||||||
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
|
|
||||||
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
|
||||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
@@ -99,10 +88,8 @@ 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/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||||
github.com/orcaman/writerseeker v0.0.0-20200621085525-1d3f536ff85e/go.mod h1:nBdnFKj15wFbf94Rwfq4m30eAcyY9V/IyKAGQFtqkW0=
|
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||||
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
|
|
||||||
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
|
|
||||||
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=
|
||||||
github.com/pierrec/lz4 v2.6.1+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY=
|
github.com/pierrec/lz4 v2.6.1+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY=
|
||||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||||
@@ -113,32 +100,28 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH
|
|||||||
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
|
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
|
||||||
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
||||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
|
||||||
github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo=
|
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
|
||||||
github.com/sagikazarmark/locafero v0.7.0/go.mod h1:2za3Cg5rMaTMoG/2Ulr9AwtFaIppKXTRYnozin4aB5k=
|
|
||||||
github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE=
|
|
||||||
github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ=
|
|
||||||
github.com/satori/go.uuid v1.2.0 h1:0uYX9dsZ2yD7q2RtLRtPSdGDWzjeM3TbMJP9utgA0ww=
|
github.com/satori/go.uuid v1.2.0 h1:0uYX9dsZ2yD7q2RtLRtPSdGDWzjeM3TbMJP9utgA0ww=
|
||||||
github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
|
github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
|
||||||
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
|
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
|
||||||
github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
|
github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
|
||||||
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
|
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
|
||||||
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
|
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
|
||||||
github.com/spf13/afero v1.12.0 h1:UcOPyRBYczmFn6yvphxkn9ZEOY65cpwGKb5mL36mrqs=
|
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
|
||||||
github.com/spf13/afero v1.12.0/go.mod h1:ZTlWwG4/ahT8W7T0WQ5uYmjI9duaLQGy3Q2OAl4sk/4=
|
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
|
||||||
github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
|
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
|
||||||
github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
|
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
|
||||||
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
|
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
||||||
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
|
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
|
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
|
||||||
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
|
||||||
github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI=
|
|
||||||
github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg=
|
|
||||||
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.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||||
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
||||||
github.com/tencentcloud/tencentcloud-cls-sdk-go v1.0.11 h1:LJshkcQ14A/7XCgqalheBHv8qLwwOXr/xqttQbjWdHM=
|
github.com/tencentcloud/tencentcloud-cls-sdk-go v1.0.11 h1:LJshkcQ14A/7XCgqalheBHv8qLwwOXr/xqttQbjWdHM=
|
||||||
@@ -147,12 +130,16 @@ github.com/uber/jaeger-client-go v2.30.0+incompatible h1:D6wyKGCecFaSRUpo8lCVbaO
|
|||||||
github.com/uber/jaeger-client-go v2.30.0+incompatible/go.mod h1:WVhlPFC8FDjOFMMWRy2pZqQJSXxYSwNYOkTr/Z6d3Kk=
|
github.com/uber/jaeger-client-go v2.30.0+incompatible/go.mod h1:WVhlPFC8FDjOFMMWRy2pZqQJSXxYSwNYOkTr/Z6d3Kk=
|
||||||
github.com/uber/jaeger-lib v2.4.1+incompatible h1:td4jdvLcExb4cBISKIpHuGoVXh+dVKhn2Um6rjCsSsg=
|
github.com/uber/jaeger-lib v2.4.1+incompatible h1:td4jdvLcExb4cBISKIpHuGoVXh+dVKhn2Um6rjCsSsg=
|
||||||
github.com/uber/jaeger-lib v2.4.1+incompatible/go.mod h1:ComeNDZlWwrWnDv8aPp0Ba6+uUTzImX/AauajbLI56U=
|
github.com/uber/jaeger-lib v2.4.1+incompatible/go.mod h1:ComeNDZlWwrWnDv8aPp0Ba6+uUTzImX/AauajbLI56U=
|
||||||
|
github.com/urfave/cli/v3 v3.8.0 h1:XqKPrm0q4P0q5JpoclYoCAv0/MIvH/jZ2umzuf8pNTI=
|
||||||
|
github.com/urfave/cli/v3 v3.8.0/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso=
|
||||||
github.com/warthog618/go-gpiocdev v0.9.1 h1:pwHPaqjJfhCipIQl78V+O3l9OKHivdRDdmgXYbmhuCI=
|
github.com/warthog618/go-gpiocdev v0.9.1 h1:pwHPaqjJfhCipIQl78V+O3l9OKHivdRDdmgXYbmhuCI=
|
||||||
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/ysmood/fetchup v0.2.4 h1:2kfWr/UrdiHg4KYRrxL2Jcrqx4DZYD+OtWu7WPBZl5o=
|
github.com/youpy/go-riff v0.1.0 h1:vZO/37nI4tIET8tQI0Qn0Y79qQh99aEpponTPiPut7k=
|
||||||
github.com/ysmood/fetchup v0.2.4/go.mod h1:hbysoq65PXL0NQeNzUczNYIKpwpkwFL4LXMDEvIQq9A=
|
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=
|
||||||
@@ -167,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=
|
||||||
@@ -176,6 +166,8 @@ go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
|||||||
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||||
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
|
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
|
||||||
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
|
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
|
||||||
|
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||||
|
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||||
golang.org/x/exp v0.0.0-20180321215751-8460e604b9de/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
golang.org/x/exp v0.0.0-20180321215751-8460e604b9de/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||||
@@ -183,10 +175,6 @@ golang.org/x/exp v0.0.0-20180807140117-3d87b88a115f/go.mod h1:CJ0aWSM057203Lf6IL
|
|||||||
golang.org/x/exp v0.0.0-20190125153040-c74c464bbbf2/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
golang.org/x/exp v0.0.0-20190125153040-c74c464bbbf2/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||||
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||||
golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY=
|
golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY=
|
||||||
golang.org/x/exp v0.0.0-20250228200357-dead58393ab7 h1:aWwlzYV971S4BXRS9AmqwDLAD85ouC6X+pocatKY58c=
|
|
||||||
golang.org/x/exp v0.0.0-20250228200357-dead58393ab7/go.mod h1:BHOTPb3L19zxehTsLoJXVaTktb06DFgmdW6Wb9s8jqk=
|
|
||||||
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw=
|
|
||||||
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM=
|
|
||||||
golang.org/x/image v0.0.0-20180708004352-c73c2afc3b81/go.mod h1:ux5Hcp/YLpHSI86hEcLt0YII63i6oz57MZXIpbrjZUs=
|
golang.org/x/image v0.0.0-20180708004352-c73c2afc3b81/go.mod h1:ux5Hcp/YLpHSI86hEcLt0YII63i6oz57MZXIpbrjZUs=
|
||||||
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
|
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
|
||||||
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||||
@@ -195,8 +183,6 @@ golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY=
|
|||||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
|
|
||||||
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
|
|
||||||
golang.org/x/net v0.37.0 h1:1zLorHbz+LYj7MQlSf1+2tPIIgibq2eL5xkrGk6f+2c=
|
golang.org/x/net v0.37.0 h1:1zLorHbz+LYj7MQlSf1+2tPIIgibq2eL5xkrGk6f+2c=
|
||||||
golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
|
golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
|
||||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
@@ -204,15 +190,11 @@ golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5h
|
|||||||
golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20220712014510-0a85c31ab51e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220712014510-0a85c31ab51e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
|
|
||||||
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
|
||||||
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
|
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
|
||||||
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
|
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
|
||||||
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
|
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
|
||||||
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
|
|
||||||
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
|
|
||||||
golang.org/x/tools v0.0.0-20180525024113-a5b4c53f6e8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180525024113-a5b4c53f6e8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
golang.org/x/tools v0.0.0-20190206041539-40960b6deb8e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20190206041539-40960b6deb8e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||||
@@ -246,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=
|
||||||
|
|||||||
602
init-device.py
Executable file
602
init-device.py
Executable file
@@ -0,0 +1,602 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
设备初始化自动化脚本
|
||||||
|
根据 init_device.md 文档自动执行设备初始化步骤
|
||||||
|
"""
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
import argparse
|
||||||
|
import tempfile
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional, List
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from contextlib import contextmanager
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class InitConfig:
|
||||||
|
"""初始化配置"""
|
||||||
|
install_chromium: bool = False
|
||||||
|
username: Optional[str] = None
|
||||||
|
dry_run: bool = False
|
||||||
|
|
||||||
|
class Logger:
|
||||||
|
"""优雅的日志输出"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def section(title: str):
|
||||||
|
"""输出章节标题"""
|
||||||
|
print(f"\n{'='*60}")
|
||||||
|
print(f" {title}")
|
||||||
|
print(f"{'='*60}")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def step(description: str):
|
||||||
|
"""输出步骤说明"""
|
||||||
|
print(f"\n🔧 {description}")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def success(message: str):
|
||||||
|
"""输出成功信息"""
|
||||||
|
print(f"✅ {message}")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def warning(message: str):
|
||||||
|
"""输出警告信息"""
|
||||||
|
print(f"⚠️ {message}")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def error(message: str):
|
||||||
|
"""输出错误信息"""
|
||||||
|
print(f"❌ {message}")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def info(message: str):
|
||||||
|
"""输出信息"""
|
||||||
|
print(f"ℹ️ {message}")
|
||||||
|
|
||||||
|
class CommandRunner:
|
||||||
|
"""命令执行器"""
|
||||||
|
|
||||||
|
def __init__(self, dry_run: bool = False):
|
||||||
|
self.dry_run = dry_run
|
||||||
|
|
||||||
|
def _execute_command(self, command: str) -> subprocess.CompletedProcess:
|
||||||
|
"""执行命令的核心逻辑"""
|
||||||
|
with subprocess.Popen(
|
||||||
|
command,
|
||||||
|
shell=True,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.STDOUT,
|
||||||
|
text=True,
|
||||||
|
bufsize=1
|
||||||
|
) as process:
|
||||||
|
output_lines = []
|
||||||
|
|
||||||
|
# 实时读取并显示输出
|
||||||
|
for line in iter(process.stdout.readline, ''):
|
||||||
|
print(line.rstrip())
|
||||||
|
output_lines.append(line)
|
||||||
|
|
||||||
|
# 等待进程完成
|
||||||
|
process.wait()
|
||||||
|
|
||||||
|
return subprocess.CompletedProcess(
|
||||||
|
command,
|
||||||
|
process.returncode,
|
||||||
|
''.join(output_lines),
|
||||||
|
""
|
||||||
|
)
|
||||||
|
|
||||||
|
def run(self, command: str, description: str = "", check: bool = True) -> subprocess.CompletedProcess:
|
||||||
|
"""执行命令"""
|
||||||
|
Logger.step(description or command)
|
||||||
|
|
||||||
|
if self.dry_run:
|
||||||
|
Logger.info(f"[预览模式] 命令: {command}")
|
||||||
|
return subprocess.CompletedProcess(command, 0, "", "")
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = self._execute_command(command)
|
||||||
|
|
||||||
|
if check and result.returncode != 0:
|
||||||
|
raise subprocess.CalledProcessError(result.returncode, command, result.stdout)
|
||||||
|
|
||||||
|
Logger.success("执行成功")
|
||||||
|
return result
|
||||||
|
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
Logger.error(f"命令执行失败 (退出码: {e.returncode})")
|
||||||
|
if e.stderr:
|
||||||
|
print(f"错误详情: {e.stderr.strip()}")
|
||||||
|
if check:
|
||||||
|
sys.exit(1)
|
||||||
|
except Exception as e:
|
||||||
|
Logger.error(f"执行异常: {e}")
|
||||||
|
if check:
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def temp_file_with_content(content: str, suffix: str = ""):
|
||||||
|
"""创建临时文件上下文管理器"""
|
||||||
|
with tempfile.NamedTemporaryFile(mode='w', suffix=suffix, delete=False) as f:
|
||||||
|
f.write(content)
|
||||||
|
temp_path = f.name
|
||||||
|
|
||||||
|
try:
|
||||||
|
yield temp_path
|
||||||
|
finally:
|
||||||
|
Path(temp_path).unlink(missing_ok=True)
|
||||||
|
|
||||||
|
class DeviceInitializer:
|
||||||
|
"""设备初始化器"""
|
||||||
|
|
||||||
|
# 常量定义
|
||||||
|
SECTION_OPTIMIZE_BOOT = "优化系统启动时间"
|
||||||
|
SECTION_UPDATE_SYSTEM = "更新系统包列表"
|
||||||
|
SECTION_INSTALL_BASIC = "安装基础依赖"
|
||||||
|
SECTION_ADD_REPOS = "添加第三方软件源"
|
||||||
|
SECTION_INSTALL_PACKAGES = "安装主要软件包"
|
||||||
|
SECTION_CONFIGURE_SYSTEM = "配置系统设置"
|
||||||
|
SECTION_AUTO_STARTX = "配置图形界面自动启动"
|
||||||
|
SECTION_AUTO_LOGIN = "配置自动登录"
|
||||||
|
SECTION_CONFIGURE_I3 = "配置 i3 窗口管理器"
|
||||||
|
SECTION_MANUAL_STEPS = "手动配置步骤"
|
||||||
|
SECTION_COMPLETE = "初始化完成"
|
||||||
|
|
||||||
|
def __init__(self, config: InitConfig):
|
||||||
|
self.config = config
|
||||||
|
# 获取实际用户名(sudo 下运行时从 SUDO_USER 获取)
|
||||||
|
self.username = config.username or os.getenv('SUDO_USER') or os.getenv('USER')
|
||||||
|
self.user_home = Path(f"/home/{self.username}")
|
||||||
|
self.runner = CommandRunner(config.dry_run)
|
||||||
|
|
||||||
|
# 软件包配置
|
||||||
|
self.base_packages = [
|
||||||
|
"fontconfig",
|
||||||
|
"fonts-noto-cjk",
|
||||||
|
"fonts-noto-color-emoji",
|
||||||
|
"unclutter",
|
||||||
|
"xorg",
|
||||||
|
"i3-wm",
|
||||||
|
"libvlc-dev",
|
||||||
|
"vlc-plugin-base",
|
||||||
|
"vlc-plugin-video-output",
|
||||||
|
"libasound2-dev",
|
||||||
|
"alsa-utils",
|
||||||
|
"trzsz",
|
||||||
|
"wireguard",
|
||||||
|
"wireguard-tools"
|
||||||
|
]
|
||||||
|
|
||||||
|
# 基础软件源(始终需要)
|
||||||
|
self.repositories = [
|
||||||
|
"ppa:trzsz/ppa"
|
||||||
|
]
|
||||||
|
# Chromium 仅在需要时添加的源
|
||||||
|
self.chromium_repo = "ppa:xtradeb/apps"
|
||||||
|
|
||||||
|
def _edit_systemd_service(self, service_name: str, override_content: str, description: str):
|
||||||
|
"""兼容地编辑 systemd 服务配置"""
|
||||||
|
# 直接创建 override 目录和文件,兼容所有 systemctl 版本
|
||||||
|
override_dir = f"/etc/systemd/system/{service_name}.d"
|
||||||
|
override_file = f"{override_dir}/override.conf"
|
||||||
|
|
||||||
|
# 创建目录
|
||||||
|
self.runner.run(f"sudo mkdir -p {override_dir}", f"创建服务 override 目录: {override_dir}")
|
||||||
|
|
||||||
|
# 写入配置文件
|
||||||
|
with temp_file_with_content(override_content) as temp_file:
|
||||||
|
self.runner.run(f"sudo cp {temp_file} {override_file}", f"创建服务配置文件: {override_file}")
|
||||||
|
|
||||||
|
# 重新加载 systemd 配置
|
||||||
|
self.runner.run("sudo systemctl daemon-reload", "重新加载 systemd 配置")
|
||||||
|
|
||||||
|
Logger.success(f"{description} - 已完成")
|
||||||
|
|
||||||
|
def optimize_boot_time(self):
|
||||||
|
"""优化 Ubuntu 24 开机时间"""
|
||||||
|
Logger.section(self.SECTION_OPTIMIZE_BOOT)
|
||||||
|
|
||||||
|
override_content = "[Service]\nTimeoutStartSec=2sec\n"
|
||||||
|
self._edit_systemd_service(
|
||||||
|
"systemd-networkd-wait-online.service",
|
||||||
|
override_content,
|
||||||
|
"配置网络等待服务超时时间为2秒"
|
||||||
|
)
|
||||||
|
|
||||||
|
def update_system(self):
|
||||||
|
"""更新系统包"""
|
||||||
|
Logger.section(self.SECTION_UPDATE_SYSTEM)
|
||||||
|
self.runner.run("sudo apt-get update", "刷新软件包索引")
|
||||||
|
|
||||||
|
def install_basic_packages(self):
|
||||||
|
"""安装基础包"""
|
||||||
|
Logger.section(self.SECTION_INSTALL_BASIC)
|
||||||
|
basic_packages = ["curl", "gpg"]
|
||||||
|
packages_str = " ".join(basic_packages)
|
||||||
|
self.runner.run(f"sudo apt-get install -y {packages_str}", f"安装基础包: {', '.join(basic_packages)}")
|
||||||
|
|
||||||
|
def add_repositories(self):
|
||||||
|
"""添加软件源"""
|
||||||
|
Logger.section(self.SECTION_ADD_REPOS)
|
||||||
|
|
||||||
|
# 基础源始终添加;Chromium 源仅在需要安装 Chromium 时添加
|
||||||
|
repos = list(self.repositories)
|
||||||
|
if self.config.install_chromium:
|
||||||
|
repos.append(self.chromium_repo)
|
||||||
|
Logger.info("已选择安装 Chromium,将添加其 PPA 源")
|
||||||
|
else:
|
||||||
|
Logger.info("未选择安装 Chromium,跳过添加 xtradeb/apps PPA")
|
||||||
|
|
||||||
|
for repo in repos:
|
||||||
|
self.runner.run(f"sudo add-apt-repository -y {repo}", f"添加软件源: {repo}")
|
||||||
|
|
||||||
|
self.runner.run("sudo apt-get update", "更新软件包索引")
|
||||||
|
|
||||||
|
def install_packages(self):
|
||||||
|
"""安装主要软件包"""
|
||||||
|
Logger.section(self.SECTION_INSTALL_PACKAGES)
|
||||||
|
|
||||||
|
packages = self.base_packages.copy()
|
||||||
|
if self.config.install_chromium:
|
||||||
|
packages.insert(0, "ungoogled-chromium")
|
||||||
|
Logger.info("已包含 ungoogled-chromium")
|
||||||
|
|
||||||
|
packages_str = " ".join(packages)
|
||||||
|
package_list = ", ".join(packages)
|
||||||
|
# 安装软件包,实时显示进度
|
||||||
|
self.runner.run(f"sudo apt-get install -y {packages_str}", f"安装软件包: {package_list}")
|
||||||
|
|
||||||
|
def configure_system(self):
|
||||||
|
"""配置系统设置"""
|
||||||
|
Logger.section(self.SECTION_CONFIGURE_SYSTEM)
|
||||||
|
|
||||||
|
# 设置时区
|
||||||
|
self.runner.run("sudo timedatectl set-timezone Asia/Shanghai", "设置时区为上海")
|
||||||
|
|
||||||
|
# 添加用户到相关组
|
||||||
|
groups = "audio,video,dialout"
|
||||||
|
self.runner.run(
|
||||||
|
f"sudo usermod -aG {groups} {self.username}",
|
||||||
|
f"将用户 {self.username} 添加到 {groups} 组"
|
||||||
|
)
|
||||||
|
|
||||||
|
def setup_auto_startx(self):
|
||||||
|
"""设置自动启动 Xorg"""
|
||||||
|
Logger.section(self.SECTION_AUTO_STARTX)
|
||||||
|
|
||||||
|
bashrc_path = self.user_home / ".bashrc"
|
||||||
|
startx_config = """
|
||||||
|
# 自动启动 Xorg 和窗口管理器
|
||||||
|
if [ -z "$DISPLAY" ] && [ "$(tty)" = "/dev/tty1" ]; then
|
||||||
|
startx
|
||||||
|
fi
|
||||||
|
"""
|
||||||
|
|
||||||
|
if self.config.dry_run:
|
||||||
|
Logger.step("配置自动启动 Xorg")
|
||||||
|
Logger.info(f"[预览模式] 将添加配置到 {bashrc_path}")
|
||||||
|
return
|
||||||
|
|
||||||
|
# 检查是否已存在配置
|
||||||
|
if bashrc_path.exists():
|
||||||
|
try:
|
||||||
|
content = bashrc_path.read_text(encoding='utf-8')
|
||||||
|
if 'startx' in content and 'tty1' in content:
|
||||||
|
Logger.warning("自动启动 Xorg 配置已存在,跳过")
|
||||||
|
return
|
||||||
|
except Exception as e:
|
||||||
|
Logger.warning(f"读取 .bashrc 文件失败: {e}")
|
||||||
|
|
||||||
|
# 添加配置
|
||||||
|
try:
|
||||||
|
with open(bashrc_path, 'a', encoding='utf-8') as f:
|
||||||
|
f.write(startx_config)
|
||||||
|
|
||||||
|
# 设置文件所有权为实际用户
|
||||||
|
self.runner.run(f"sudo chown {self.username}:{self.username} {bashrc_path}",
|
||||||
|
f"设置 .bashrc 所有权为 {self.username}")
|
||||||
|
|
||||||
|
Logger.success(f"已配置自动启动 Xorg: {bashrc_path}")
|
||||||
|
except Exception as e:
|
||||||
|
Logger.error(f"配置自动启动 Xorg 失败: {e}")
|
||||||
|
|
||||||
|
def setup_auto_login(self):
|
||||||
|
"""设置自动登录"""
|
||||||
|
Logger.section(self.SECTION_AUTO_LOGIN)
|
||||||
|
|
||||||
|
override_content = f"""[Service]
|
||||||
|
ExecStart=
|
||||||
|
ExecStart=-/sbin/agetty --autologin {self.username} --noclear %I $TERM
|
||||||
|
"""
|
||||||
|
|
||||||
|
self._edit_systemd_service(
|
||||||
|
"getty@tty1.service",
|
||||||
|
override_content,
|
||||||
|
f"配置用户 {self.username} 自动登录"
|
||||||
|
)
|
||||||
|
|
||||||
|
def configure_i3(self):
|
||||||
|
"""配置 i3 窗口管理器"""
|
||||||
|
Logger.section(self.SECTION_CONFIGURE_I3)
|
||||||
|
|
||||||
|
i3_config_dir = self.user_home / ".config" / "i3"
|
||||||
|
i3_config_path = i3_config_dir / "config"
|
||||||
|
|
||||||
|
if self.config.dry_run:
|
||||||
|
Logger.step("配置 i3 窗口管理器")
|
||||||
|
Logger.info(f"[预览模式] 将配置 i3 配置文件: {i3_config_path}")
|
||||||
|
return
|
||||||
|
|
||||||
|
# 创建 i3 配置目录
|
||||||
|
i3_config_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# 检查是否已有 i3 配置文件
|
||||||
|
if not i3_config_path.exists():
|
||||||
|
Logger.info("i3 配置文件不存在,从系统默认配置复制")
|
||||||
|
self._copy_system_i3_config(i3_config_path)
|
||||||
|
else:
|
||||||
|
Logger.info("i3 配置文件已存在,直接更新配置")
|
||||||
|
|
||||||
|
# 无论是新生成还是已存在,都更新配置
|
||||||
|
self._update_i3_config(i3_config_path)
|
||||||
|
|
||||||
|
# 设置配置文件所有权为实际用户
|
||||||
|
self.runner.run(f"sudo chown -R {self.username}:{self.username} {i3_config_dir}", f"设置 i3 配置目录所有权为 {self.username}")
|
||||||
|
|
||||||
|
Logger.success(f"已配置 i3 窗口管理器: {i3_config_path}")
|
||||||
|
|
||||||
|
def _copy_system_i3_config(self, config_path: Path):
|
||||||
|
"""从系统默认配置复制 i3 配置文件"""
|
||||||
|
Logger.step("从系统默认配置复制 i3 配置文件")
|
||||||
|
|
||||||
|
system_config = "/etc/i3/config"
|
||||||
|
self.runner.run(f"cp {system_config} {config_path}", f"复制系统默认 i3 配置到 {config_path}")
|
||||||
|
|
||||||
|
# 移除 i3-config-wizard 指令
|
||||||
|
try:
|
||||||
|
content = config_path.read_text(encoding='utf-8')
|
||||||
|
if 'i3-config-wizard' in content:
|
||||||
|
# 移除包含 i3-config-wizard 的行
|
||||||
|
lines = content.splitlines()
|
||||||
|
filtered_lines = [line for line in lines if 'i3-config-wizard' not in line]
|
||||||
|
|
||||||
|
with open(config_path, 'w', encoding='utf-8') as f:
|
||||||
|
f.write('\n'.join(filtered_lines) + '\n')
|
||||||
|
|
||||||
|
Logger.info("已移除系统配置中的 i3-config-wizard 指令")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
Logger.warning(f"移除 i3-config-wizard 指令失败: {e}")
|
||||||
|
|
||||||
|
Logger.success("已从系统默认配置复制 i3 配置文件")
|
||||||
|
|
||||||
|
def _update_i3_config(self, config_path: Path):
|
||||||
|
"""更新现有 i3 配置文件"""
|
||||||
|
Logger.step("更新现有 i3 配置文件")
|
||||||
|
|
||||||
|
try:
|
||||||
|
content = config_path.read_text(encoding='utf-8')
|
||||||
|
except Exception as e:
|
||||||
|
Logger.error(f"读取 i3 配置文件失败: {e}")
|
||||||
|
return
|
||||||
|
|
||||||
|
# 添加启动配置项
|
||||||
|
startup_configs = [
|
||||||
|
"exec --no-startup-id unclutter -root # 隐藏鼠标",
|
||||||
|
"exec --no-startup-id xset dpms 0 0 0 # 关闭屏幕自动关闭",
|
||||||
|
"exec --no-startup-id xset s off # 关闭屏幕保护"
|
||||||
|
]
|
||||||
|
|
||||||
|
config_modified = False
|
||||||
|
for config_line in startup_configs:
|
||||||
|
if config_line not in content:
|
||||||
|
content += f"\n{config_line}\n"
|
||||||
|
Logger.info(f"添加配置: {config_line}")
|
||||||
|
config_modified = True
|
||||||
|
|
||||||
|
# 移除 i3bar 配置块
|
||||||
|
if "bar {" in content:
|
||||||
|
# 使用正则表达式直接移除整个 bar 配置块
|
||||||
|
bar_pattern = r'bar\s*\{[^}]*\}'
|
||||||
|
new_content = re.sub(bar_pattern, '', content, flags=re.DOTALL)
|
||||||
|
if new_content != content:
|
||||||
|
content = new_content
|
||||||
|
Logger.info("已移除 i3bar 状态栏配置")
|
||||||
|
config_modified = True
|
||||||
|
|
||||||
|
# 只在有修改时才写回文件
|
||||||
|
if config_modified:
|
||||||
|
try:
|
||||||
|
with open(config_path, 'w', encoding='utf-8') as f:
|
||||||
|
f.write(content)
|
||||||
|
Logger.success("i3 配置文件更新完成")
|
||||||
|
except Exception as e:
|
||||||
|
Logger.error(f"写入 i3 配置文件失败: {e}")
|
||||||
|
else:
|
||||||
|
Logger.info("i3 配置无需修改")
|
||||||
|
|
||||||
|
def show_manual_steps(self):
|
||||||
|
"""显示需要手动完成的步骤"""
|
||||||
|
Logger.section(self.SECTION_MANUAL_STEPS)
|
||||||
|
|
||||||
|
Logger.info("请手动完成以下配置步骤:")
|
||||||
|
|
||||||
|
print("\n📋 WireGuard 配置:")
|
||||||
|
print(" 1. 从服务器获取 WireGuard 配置文件")
|
||||||
|
print(" 2. 保存到 /etc/wireguard/wg0.conf")
|
||||||
|
print(" 3. 启用并启动 WireGuard:")
|
||||||
|
print(" sudo systemctl enable wg-quick@wg0")
|
||||||
|
print(" sudo systemctl start wg-quick@wg0")
|
||||||
|
|
||||||
|
print("\n📋 系统音量配置:")
|
||||||
|
print(" 1. 运行 alsamixer 进入音量控制界面")
|
||||||
|
print(" 2. 调节 master 音量到合适级别")
|
||||||
|
print(" 3. 按 ESC 退出")
|
||||||
|
print(" 4. 运行 sudo alsactl store 保存音量设置")
|
||||||
|
|
||||||
|
print("\n📋 i3 窗口管理器:")
|
||||||
|
print(" 1. 重启系统后会自动进入 i3")
|
||||||
|
print(" 2. 脚本已自动配置必要的设置(隐藏鼠标、禁用屏保等)")
|
||||||
|
print(" 3. 可以根据需要进一步自定义 ~/.config/i3/config")
|
||||||
|
|
||||||
|
def run_initialization(self):
|
||||||
|
"""运行完整的初始化流程"""
|
||||||
|
Logger.section("设备初始化开始")
|
||||||
|
|
||||||
|
Logger.info(f"目标用户: {self.username}")
|
||||||
|
Logger.info(f"用户主目录: {self.user_home}")
|
||||||
|
Logger.info(f"安装 Chromium: {'是' if self.config.install_chromium else '否'}")
|
||||||
|
Logger.info(f"预览模式: {'是' if self.config.dry_run else '否'}")
|
||||||
|
|
||||||
|
# 初始化步骤配置
|
||||||
|
initialization_steps = [
|
||||||
|
("优化系统启动", self.optimize_boot_time),
|
||||||
|
("更新系统包", self.update_system),
|
||||||
|
("安装基础依赖", self.install_basic_packages),
|
||||||
|
("添加软件源", self.add_repositories),
|
||||||
|
("安装主要软件", self.install_packages),
|
||||||
|
("配置系统设置", self.configure_system),
|
||||||
|
("配置自动启动X", self.setup_auto_startx),
|
||||||
|
("配置自动登录", self.setup_auto_login),
|
||||||
|
("配置i3窗口管理器", self.configure_i3),
|
||||||
|
]
|
||||||
|
|
||||||
|
try:
|
||||||
|
for step_name, step_func in initialization_steps:
|
||||||
|
Logger.info(f"正在执行: {step_name}")
|
||||||
|
step_func()
|
||||||
|
|
||||||
|
self.show_manual_steps()
|
||||||
|
|
||||||
|
Logger.section(self.SECTION_COMPLETE)
|
||||||
|
Logger.success("🎉 所有自动化步骤已完成")
|
||||||
|
Logger.warning("⚠️ 请重启系统以使所有更改生效")
|
||||||
|
Logger.info("💡 重启后请完成 WireGuard 和音量的手动配置")
|
||||||
|
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
Logger.error("❌ 用户中断了初始化过程")
|
||||||
|
sys.exit(1)
|
||||||
|
except Exception as e:
|
||||||
|
Logger.error(f"❌ 初始化过程中发生错误: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
class SystemValidator:
|
||||||
|
"""系统环境验证器"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def validate_environment():
|
||||||
|
"""验证运行环境"""
|
||||||
|
Logger.section("验证系统环境")
|
||||||
|
|
||||||
|
# 检查是否通过 sudo 运行
|
||||||
|
if os.geteuid() != 0:
|
||||||
|
Logger.error("此脚本需要使用 sudo 运行")
|
||||||
|
Logger.info("请使用: sudo python3 init-device.py")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# 检查是否有原始用户信息
|
||||||
|
if not os.getenv('SUDO_USER'):
|
||||||
|
Logger.error("请不要直接以 root 用户运行此脚本")
|
||||||
|
Logger.info("请使用普通用户通过 sudo 运行: sudo python3 init-device.py")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
Logger.info(f"检测到通过 sudo 运行脚本,原用户: {os.getenv('SUDO_USER')}")
|
||||||
|
Logger.success("系统环境验证通过")
|
||||||
|
|
||||||
|
def create_argument_parser() -> argparse.ArgumentParser:
|
||||||
|
"""创建命令行参数解析器"""
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="设备初始化自动化脚本",
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
|
epilog="""
|
||||||
|
使用示例:
|
||||||
|
%(prog)s # 基础安装
|
||||||
|
%(prog)s --chromium # 包含 Chromium 的完整安装
|
||||||
|
%(prog)s --dry-run # 预览模式,不实际执行
|
||||||
|
%(prog)s --username user1 # 指定用户名
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
"--chromium",
|
||||||
|
action="store_true",
|
||||||
|
help="安装 ungoogled-chromium 浏览器"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--username",
|
||||||
|
type=str,
|
||||||
|
help="指定用户名(默认使用当前用户)"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--dry-run",
|
||||||
|
action="store_true",
|
||||||
|
help="预览模式:仅显示将要执行的操作,不实际执行"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--version",
|
||||||
|
action="version",
|
||||||
|
version="%(prog)s 1.0.0"
|
||||||
|
)
|
||||||
|
|
||||||
|
return parser
|
||||||
|
|
||||||
|
def show_preview(config: InitConfig):
|
||||||
|
"""显示预览信息"""
|
||||||
|
Logger.section("预览模式")
|
||||||
|
|
||||||
|
steps = [
|
||||||
|
DeviceInitializer.SECTION_OPTIMIZE_BOOT,
|
||||||
|
DeviceInitializer.SECTION_UPDATE_SYSTEM,
|
||||||
|
f"{DeviceInitializer.SECTION_INSTALL_BASIC} (curl, gpg)",
|
||||||
|
DeviceInitializer.SECTION_ADD_REPOS,
|
||||||
|
f"{DeviceInitializer.SECTION_INSTALL_PACKAGES}" + (" (包含 ungoogled-chromium)" if config.install_chromium else ""),
|
||||||
|
f"{DeviceInitializer.SECTION_CONFIGURE_SYSTEM} (时区、用户组)",
|
||||||
|
DeviceInitializer.SECTION_AUTO_STARTX,
|
||||||
|
DeviceInitializer.SECTION_AUTO_LOGIN,
|
||||||
|
DeviceInitializer.SECTION_CONFIGURE_I3,
|
||||||
|
"显示手动配置步骤"
|
||||||
|
]
|
||||||
|
|
||||||
|
Logger.info("将要执行的操作步骤:")
|
||||||
|
for i, step in enumerate(steps, 1):
|
||||||
|
print(f" {i:2d}. {step}")
|
||||||
|
|
||||||
|
Logger.warning("这是预览模式,不会实际执行任何操作")
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""主函数"""
|
||||||
|
parser = create_argument_parser()
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
# 创建配置
|
||||||
|
config = InitConfig(
|
||||||
|
install_chromium=args.chromium,
|
||||||
|
username=args.username,
|
||||||
|
dry_run=args.dry_run
|
||||||
|
)
|
||||||
|
|
||||||
|
# 预览模式
|
||||||
|
if config.dry_run:
|
||||||
|
show_preview(config)
|
||||||
|
return
|
||||||
|
|
||||||
|
# 验证环境
|
||||||
|
SystemValidator.validate_environment()
|
||||||
|
|
||||||
|
# 运行初始化
|
||||||
|
initializer = DeviceInitializer(config)
|
||||||
|
initializer.run_initialization()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
try:
|
||||||
|
main()
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
Logger.error("用户中断操作")
|
||||||
|
sys.exit(1)
|
||||||
|
except Exception as e:
|
||||||
|
Logger.error(f"程序异常退出: {e}")
|
||||||
|
sys.exit(1)
|
||||||
@@ -2,7 +2,10 @@
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 在 systemd-networkd-wait-online.service Service 加入 TimeoutStartSec=2sec
|
# 在 systemd-networkd-wait-online.service Service 加入 TimeoutStartSec=2sec
|
||||||
sudo vim /etc/systemd/system/network-online.target.wants/systemd-networkd-wait-online.service
|
sudo EDITOR=vim systemctl edit systemd-networkd-wait-online.service
|
||||||
|
# 在打开的编辑器中添加:
|
||||||
|
# [Service]
|
||||||
|
# TimeoutStartSec=2sec
|
||||||
```
|
```
|
||||||
|
|
||||||
### 初始化设备
|
### 初始化设备
|
||||||
@@ -10,9 +13,9 @@ sudo vim /etc/systemd/system/network-online.target.wants/systemd-networkd-wait-o
|
|||||||
```bash
|
```bash
|
||||||
sudo apt update
|
sudo apt update
|
||||||
sudo apt install curl gpg
|
sudo apt install curl gpg
|
||||||
sudo add-apt-repository ppa:xtradeb/apps
|
sudo add-apt-repository ppa:xtradeb/apps # 不安装 ungoogled-chromium 时,不要添加。可能与系统源的库冲突
|
||||||
sudo add-apt-repository ppa:trzsz/ppa
|
sudo add-apt-repository ppa:trzsz/ppa
|
||||||
sudo apt install -y ungoogled-chromium fonts-noto-cjk fonts-noto-color-emoji unclutter xorg i3-wm libvlc-dev libasound2-dev alsa-utils trzsz wireguard wireguard-tools
|
sudo apt install -y ungoogled-chromium fontconfig fonts-noto-cjk fonts-noto-color-emoji unclutter xorg i3-wm libvlc-dev vlc-plugin-base vlc-plugin-video-output libasound2-dev alsa-utils trzsz wireguard wireguard-tools
|
||||||
sudo timedatectl set-timezone Asia/Shanghai
|
sudo timedatectl set-timezone Asia/Shanghai
|
||||||
sudo usermod -aG audio,video,dialout $USER
|
sudo usermod -aG audio,video,dialout $USER
|
||||||
```
|
```
|
||||||
@@ -48,10 +51,14 @@ fi
|
|||||||
|
|
||||||
### 自动登录
|
### 自动登录
|
||||||
|
|
||||||
编辑 `/etc/systemd/system/getty.target.wants/getty@tty1.service` 文件,将 `ExecStart` 行修改为:
|
使用 systemctl edit 修改 getty@tty1 服务:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ExecStart=-/sbin/agetty --autologin <your_username> --noclear %I $TERM
|
sudo EDITOR=vim systemctl edit getty@tty1.service
|
||||||
|
# 在打开的编辑器中添加:
|
||||||
|
# [Service]
|
||||||
|
# ExecStart=
|
||||||
|
# ExecStart=-/sbin/agetty --autologin <your_username> --noclear %I $TERM
|
||||||
```
|
```
|
||||||
|
|
||||||
其中:
|
其中:
|
||||||
|
|||||||
@@ -3,9 +3,11 @@ package middleware
|
|||||||
import (
|
import (
|
||||||
"game-driver/internal/schema"
|
"game-driver/internal/schema"
|
||||||
"game-driver/leaf"
|
"game-driver/leaf"
|
||||||
|
"game-driver/pkg/audio"
|
||||||
"game-driver/pkg/utils"
|
"game-driver/pkg/utils"
|
||||||
"go.uber.org/zap"
|
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
// PlayBgm 播放背景音乐
|
// PlayBgm 播放背景音乐
|
||||||
@@ -19,39 +21,28 @@ 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)
|
||||||
//go func() {
|
go func() {
|
||||||
// defer wait.Done()
|
defer wait.Done()
|
||||||
//
|
|
||||||
// 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()
|
||||||
//
|
|
||||||
// select {
|
<-a
|
||||||
// case <-a:
|
}()
|
||||||
// {
|
|
||||||
// speaker.Lock()
|
|
||||||
// ctrl.Streamer = nil
|
|
||||||
// speaker.Unlock()
|
|
||||||
// return
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
//}()
|
|
||||||
} else {
|
} else {
|
||||||
zap.S().Infoln("未解析到背景音乐")
|
zap.S().Infoln("未解析到背景音乐")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,16 +10,24 @@ import (
|
|||||||
func SoundStart() leaf.HandlerFunc {
|
func SoundStart() leaf.HandlerFunc {
|
||||||
return func(c *leaf.Context) {
|
return func(c *leaf.Context) {
|
||||||
pm := leaf.Value[*schema.PlayModal](c, PayloadJSONKey)
|
pm := leaf.Value[*schema.PlayModal](c, PayloadJSONKey)
|
||||||
tts.DefaultTTS.Sound(pm.TTS.Start)
|
|
||||||
|
// 使用请求的 context,支持取消和超时
|
||||||
|
if pm.TTS.Start != "" {
|
||||||
|
tts.DefaultTTS.Sound(c, pm.TTS.Start)
|
||||||
|
}
|
||||||
|
|
||||||
defer func() {
|
defer func() {
|
||||||
|
var text string
|
||||||
switch leaf.Value[leaf.EndType](c, leaf.EndKey) {
|
switch leaf.Value[leaf.EndType](c, leaf.EndKey) {
|
||||||
case leaf.End:
|
case leaf.End:
|
||||||
tts.DefaultTTS.Sound(pm.TTS.End)
|
text = pm.TTS.End
|
||||||
case leaf.EndTimeout:
|
case leaf.EndTimeout:
|
||||||
tts.DefaultTTS.Sound(pm.TTS.Timeout)
|
text = pm.TTS.Timeout
|
||||||
case leaf.EndStop:
|
case leaf.EndStop:
|
||||||
tts.DefaultTTS.Sound(pm.TTS.Stop)
|
text = pm.TTS.Stop
|
||||||
|
}
|
||||||
|
if text != "" {
|
||||||
|
tts.DefaultTTS.Sound(c, text)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package middleware
|
package middleware
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"game-driver/internal/schema"
|
"game-driver/internal/schema"
|
||||||
"game-driver/leaf"
|
"game-driver/leaf"
|
||||||
"game-driver/pkg/tts"
|
"game-driver/pkg/tts"
|
||||||
@@ -34,7 +35,7 @@ func TickerAction() leaf.HandlerFunc {
|
|||||||
defer close(a)
|
defer close(a)
|
||||||
|
|
||||||
wait.Add(1)
|
wait.Add(1)
|
||||||
go func() {
|
go func(ctx context.Context) {
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
defer wait.Done()
|
defer wait.Done()
|
||||||
// 定时器
|
// 定时器
|
||||||
@@ -48,6 +49,9 @@ func TickerAction() leaf.HandlerFunc {
|
|||||||
select {
|
select {
|
||||||
case <-a:
|
case <-a:
|
||||||
over = true
|
over = true
|
||||||
|
case <-ctx.Done():
|
||||||
|
zap.S().Infoln("Ticker 计时被取消")
|
||||||
|
over = true
|
||||||
case m := <-ticker.C:
|
case m := <-ticker.C:
|
||||||
{
|
{
|
||||||
s := int(m.Sub(start).Seconds())
|
s := int(m.Sub(start).Seconds())
|
||||||
@@ -55,12 +59,12 @@ func TickerAction() leaf.HandlerFunc {
|
|||||||
//TODO: 屏幕打印
|
//TODO: 屏幕打印
|
||||||
}
|
}
|
||||||
if to, ok := ttsMap[s]; ok {
|
if to, ok := ttsMap[s]; ok {
|
||||||
tts.DefaultTTS.Sound(to.Value)
|
tts.DefaultTTS.Sound(ctx, to.Value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}()
|
}(c)
|
||||||
c.Next()
|
c.Next()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,14 +6,15 @@ import (
|
|||||||
"game-driver/leaf"
|
"game-driver/leaf"
|
||||||
"game-driver/pkg/utils"
|
"game-driver/pkg/utils"
|
||||||
"game-driver/pkg/video"
|
"game-driver/pkg/video"
|
||||||
|
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
func OnlyVideo(c *leaf.Context) {
|
func OnlyVideo(c *leaf.Context) {
|
||||||
payload := leaf.Value[*schema.PlayModal](c, middleware.PayloadJSONKey)
|
payload := leaf.Value[*schema.PlayModal](c, middleware.PayloadJSONKey)
|
||||||
|
|
||||||
utils.BlankOpen()
|
// utils.BlankOpen()
|
||||||
defer utils.BlankClose()
|
// defer utils.BlankClose()
|
||||||
|
|
||||||
if url, ok := payload.Game["video"]; ok {
|
if url, ok := payload.Game["video"]; ok {
|
||||||
path, local, err := utils.LinkVideo(url.(string))
|
path, local, err := utils.LinkVideo(url.(string))
|
||||||
@@ -21,6 +22,8 @@ func OnlyVideo(c *leaf.Context) {
|
|||||||
zap.S().Errorln("视频文件获取异常: ", err)
|
zap.S().Errorln("视频文件获取异常: ", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
_ = video.Play(c, path, local)
|
if err := video.Play(c, path, local); err != nil {
|
||||||
|
zap.S().Errorln("视频播放异常: ", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,11 +10,12 @@ import (
|
|||||||
"game-driver/pkg/card_reader"
|
"game-driver/pkg/card_reader"
|
||||||
"game-driver/pkg/channel"
|
"game-driver/pkg/channel"
|
||||||
"game-driver/pkg/tts"
|
"game-driver/pkg/tts"
|
||||||
"go.uber.org/zap"
|
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"os"
|
"os"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
func WaitCard(ctx context.Context) leaf.HandlerFunc {
|
func WaitCard(ctx context.Context) leaf.HandlerFunc {
|
||||||
@@ -79,13 +80,14 @@ func WaitCard(ctx context.Context) leaf.HandlerFunc {
|
|||||||
defer cardInfo.Close()
|
defer cardInfo.Close()
|
||||||
|
|
||||||
// 结束信号通道
|
// 结束信号通道
|
||||||
cc, cancel := context.WithCancel(context.TODO())
|
// 使用独立 context 确保读卡器监听完整执行,不受外部取消影响
|
||||||
|
c2, cancel := context.WithCancel(context.Background())
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
wait.Add(1)
|
wait.Add(1)
|
||||||
go func() {
|
go func() {
|
||||||
defer wait.Done()
|
defer wait.Done()
|
||||||
reader.OnCardInfo(cc, func(info *card_reader.CardInfo) {
|
reader.OnCardInfo(c2, func(info *card_reader.CardInfo) {
|
||||||
cardInfo.Send(info.ID)
|
cardInfo.Send(info.ID)
|
||||||
})
|
})
|
||||||
}()
|
}()
|
||||||
@@ -103,12 +105,12 @@ func WaitCard(ctx context.Context) leaf.HandlerFunc {
|
|||||||
if cardId != id {
|
if cardId != id {
|
||||||
zap.S().Infof("读取到卡片数据%q,与预期卡片数据%q不一致", id, cardId)
|
zap.S().Infof("读取到卡片数据%q,与预期卡片数据%q不一致", id, cardId)
|
||||||
// 播报错误提示
|
// 播报错误提示
|
||||||
tts.DefaultTTS.Sound(cardError)
|
tts.DefaultTTS.Sound(c, cardError)
|
||||||
isNeed = true
|
isNeed = true
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
// 播报恭喜语音
|
// 播报恭喜语音
|
||||||
tts.DefaultTTS.Sound(cardOk)
|
tts.DefaultTTS.Sound(c, cardOk)
|
||||||
//TODO: 打开炫酷光效,屏幕跳转恭喜页面
|
//TODO: 打开炫酷光效,屏幕跳转恭喜页面
|
||||||
zap.S().Infof("读取到卡片数据%q,开始打开炫酷光效", id)
|
zap.S().Infof("读取到卡片数据%q,开始打开炫酷光效", id)
|
||||||
Default(c)
|
Default(c)
|
||||||
|
|||||||
@@ -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,16 +2,18 @@ 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 {
|
||||||
return func(c context.Context) error {
|
return func(c context.Context) error {
|
||||||
reader, err := tts.DefaultTTS.Get(item.Data)
|
reader, err := tts.DefaultTTS.Get(c, item.Data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("语音合成异常: %w", err)
|
return fmt.Errorf("语音合成异常: %w", err)
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"game-driver/internal/schema"
|
"game-driver/internal/schema"
|
||||||
"game-driver/pkg/utils"
|
"game-driver/pkg/utils"
|
||||||
"game-driver/pkg/video"
|
"game-driver/pkg/video"
|
||||||
|
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -19,8 +20,8 @@ func Video(item schema.WaitItemModel) func(c context.Context) error {
|
|||||||
zap.S().Infoln("播放待机视频")
|
zap.S().Infoln("播放待机视频")
|
||||||
defer zap.S().Infoln("结束待机视频")
|
defer zap.S().Infoln("结束待机视频")
|
||||||
|
|
||||||
utils.BlankOpen()
|
// utils.BlankOpen()
|
||||||
defer utils.BlankClose()
|
// defer utils.BlankClose()
|
||||||
|
|
||||||
err = video.Play(c, path, local)
|
err = video.Play(c, path, local)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"game-driver/internal/schema"
|
"game-driver/internal/schema"
|
||||||
"game-driver/pkg/browser"
|
"game-driver/pkg/browser"
|
||||||
"game-driver/pkg/utils"
|
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -13,8 +13,8 @@ func Web(item schema.WaitItemModel) func(c context.Context) error {
|
|||||||
zap.S().Infoln("打开待机网页")
|
zap.S().Infoln("打开待机网页")
|
||||||
|
|
||||||
// 控制背光
|
// 控制背光
|
||||||
utils.BlankOpen()
|
// utils.BlankOpen()
|
||||||
defer utils.BlankClose()
|
// defer utils.BlankClose()
|
||||||
|
|
||||||
browser.OpenApp(c, item.Data)
|
browser.OpenApp(c, item.Data)
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -65,7 +65,8 @@ func Cron(rootRules []cronrange.Rule, cron string, play func(c context.Context)
|
|||||||
case r := <-a:
|
case r := <-a:
|
||||||
if r {
|
if r {
|
||||||
if ok := m.TryLock(); ok {
|
if ok := m.TryLock(); ok {
|
||||||
ctx, cc := context.WithCancel(context.TODO())
|
// 使用独立 context 确保任务完整执行,不受外部取消影响
|
||||||
|
ctx, cc := context.WithCancel(context.Background())
|
||||||
cancel = cc
|
cancel = cc
|
||||||
waitGroup.Add(1)
|
waitGroup.Add(1)
|
||||||
go func() {
|
go func() {
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ func Interval(interval int64, play func(c context.Context) error) func(c context
|
|||||||
case <-c.Done():
|
case <-c.Done():
|
||||||
return nil
|
return nil
|
||||||
default:
|
default:
|
||||||
|
// 避免忙循环,短暂休眠
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"game-driver/internal/common"
|
"game-driver/internal/common"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
"sync"
|
"sync"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Pause 暂停控制器
|
// Pause 暂停控制器
|
||||||
@@ -58,6 +59,8 @@ func Pause(ps *common.PauseSub, isPause bool, play func(c context.Context) error
|
|||||||
zap.S().Infoln("执行后续操作异常: ", err)
|
zap.S().Infoln("执行后续操作异常: ", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 避免忙循环,短暂休眠
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,240 +0,0 @@
|
|||||||
package routes
|
|
||||||
|
|
||||||
import (
|
|
||||||
"game-driver/internal/middleware"
|
|
||||||
"game-driver/internal/schema"
|
|
||||||
"game-driver/leaf"
|
|
||||||
"game-driver/pkg/relay"
|
|
||||||
"game-driver/pkg/tts"
|
|
||||||
"game-driver/pkg/utils"
|
|
||||||
"game-driver/pkg/video"
|
|
||||||
"go.uber.org/zap"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
func timerAction(timestamp int64) <-chan struct{} {
|
|
||||||
a := make(chan struct{})
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
if timestamp == 0 {
|
|
||||||
close(a)
|
|
||||||
} else {
|
|
||||||
<-time.After(time.Until(time.Unix(timestamp, 0)))
|
|
||||||
close(a)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
return a
|
|
||||||
}
|
|
||||||
|
|
||||||
func WaitAction(c *leaf.Context) {
|
|
||||||
payload := leaf.Value[*schema.WaitModel](c, middleware.PayloadJSONKey)
|
|
||||||
|
|
||||||
if payload.Start != 0 && payload.End != 0 && time.Unix(payload.Start, 0).After(time.Unix(payload.End, 0)) {
|
|
||||||
zap.S().Infoln("开始时间大于结束时间")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if payload.End != 0 {
|
|
||||||
cancel := leaf.WithDeadline(c, time.Unix(payload.End, 0))
|
|
||||||
defer cancel()
|
|
||||||
}
|
|
||||||
|
|
||||||
select {
|
|
||||||
case <-c.Done():
|
|
||||||
case <-timerAction(payload.Start):
|
|
||||||
// 等待组
|
|
||||||
var wait sync.WaitGroup
|
|
||||||
defer wait.Wait()
|
|
||||||
for _, item := range payload.Items {
|
|
||||||
switch item.Type {
|
|
||||||
case schema.WaitAudio:
|
|
||||||
// 执行音乐播放
|
|
||||||
wait.Add(1)
|
|
||||||
go func() {
|
|
||||||
defer wait.Done()
|
|
||||||
audioAction(c, item, payload.TimeModel)
|
|
||||||
}()
|
|
||||||
case schema.WaitTTS:
|
|
||||||
// 执行TTS播放
|
|
||||||
wait.Add(1)
|
|
||||||
go func() {
|
|
||||||
defer wait.Done()
|
|
||||||
ttsAction(c, item, payload.TimeModel)
|
|
||||||
}()
|
|
||||||
case schema.WaitRelay:
|
|
||||||
// 执行继电器供电
|
|
||||||
wait.Add(1)
|
|
||||||
go func() {
|
|
||||||
defer wait.Done()
|
|
||||||
relayAction(c, item, payload.TimeModel)
|
|
||||||
}()
|
|
||||||
case schema.WaitVideo:
|
|
||||||
// 执行视频播放
|
|
||||||
wait.Add(1)
|
|
||||||
go func() {
|
|
||||||
defer wait.Done()
|
|
||||||
videoAction(c, item, payload.TimeModel)
|
|
||||||
}()
|
|
||||||
case schema.WaitWeb:
|
|
||||||
default:
|
|
||||||
zap.S().Infof("不支持的类型: %d\n", item.Type)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func audioAction(c *leaf.Context, item schema.WaitItemModel, root schema.TimeModel) {
|
|
||||||
if item.Start != 0 && time.Unix(item.Start, 0).Before(time.Unix(root.Start, 0)) {
|
|
||||||
zap.S().Infoln("开始时间小于根任务开始时间")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if item.End != 0 {
|
|
||||||
cancel := leaf.WithDeadline(c, time.Unix(item.End, 0))
|
|
||||||
defer cancel()
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err := utils.LinkAudio(item.Data)
|
|
||||||
if err != nil {
|
|
||||||
zap.S().Errorln("音频数据获取异常: ", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
select {
|
|
||||||
case <-c.Done():
|
|
||||||
case <-timerAction(item.Start):
|
|
||||||
{
|
|
||||||
zap.S().Infoln("播放待机音乐")
|
|
||||||
defer zap.S().Infoln("结束待机音乐")
|
|
||||||
|
|
||||||
//ctrl, closer, e := audio.PlayBgmMP3(data)
|
|
||||||
//defer closer()
|
|
||||||
//if e != nil {
|
|
||||||
// zap.S().Errorln("播放待机音乐异常", e)
|
|
||||||
// return
|
|
||||||
//}
|
|
||||||
//
|
|
||||||
//select {
|
|
||||||
//case <-c.Done():
|
|
||||||
// {
|
|
||||||
// speaker.Lock()
|
|
||||||
// ctrl.Streamer = nil
|
|
||||||
// speaker.Unlock()
|
|
||||||
// }
|
|
||||||
//}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func ttsAction(c *leaf.Context, item schema.WaitItemModel, root schema.TimeModel) {
|
|
||||||
if item.Start != 0 && time.Unix(item.Start, 0).Before(time.Unix(root.Start, 0)) {
|
|
||||||
zap.S().Infoln("开始时间小于根任务开始时间")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if item.End != 0 {
|
|
||||||
cancel := leaf.WithDeadline(c, time.Unix(item.End, 0))
|
|
||||||
defer cancel()
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err := tts.DefaultTTS.Get(item.Data)
|
|
||||||
if err != nil {
|
|
||||||
zap.S().Errorln("语音合成异常: ", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
select {
|
|
||||||
case <-c.Done():
|
|
||||||
case <-timerAction(item.Start):
|
|
||||||
{
|
|
||||||
zap.S().Infoln("循环播放待机 TTS 语音")
|
|
||||||
defer zap.S().Infoln("结束待机 TTS 语音")
|
|
||||||
|
|
||||||
for {
|
|
||||||
//audio.PlayWav(c, reader)
|
|
||||||
select {
|
|
||||||
case <-c.Done():
|
|
||||||
return
|
|
||||||
case <-time.After(time.Duration(item.Interval) * time.Second):
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func relayAction(c *leaf.Context, item schema.WaitItemModel, root schema.TimeModel) {
|
|
||||||
if item.Start != 0 && time.Unix(item.Start, 0).Before(time.Unix(root.Start, 0)) {
|
|
||||||
zap.S().Infoln("开始时间小于根任务开始时间")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if item.End != 0 {
|
|
||||||
cancel := leaf.WithDeadline(c, time.Unix(item.End, 0))
|
|
||||||
defer cancel()
|
|
||||||
}
|
|
||||||
|
|
||||||
r, err := relay.New(item.Data)
|
|
||||||
if err != nil {
|
|
||||||
zap.S().Errorln("继电器初始化异常: ", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer r.Close()
|
|
||||||
|
|
||||||
select {
|
|
||||||
case <-c.Done():
|
|
||||||
case <-timerAction(item.Start):
|
|
||||||
{
|
|
||||||
zap.S().Infoln("待机继电器供电")
|
|
||||||
defer zap.S().Infoln("待机继电器断电")
|
|
||||||
|
|
||||||
r.On(0)
|
|
||||||
<-c.Done()
|
|
||||||
r.Off(0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func videoAction(c *leaf.Context, item schema.WaitItemModel, root schema.TimeModel) {
|
|
||||||
if item.Start != 0 && time.Unix(item.Start, 0).Before(time.Unix(root.Start, 0)) {
|
|
||||||
zap.S().Infoln("开始时间小于根任务开始时间")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if item.End != 0 {
|
|
||||||
cancel := leaf.WithDeadline(c, time.Unix(item.End, 0))
|
|
||||||
defer cancel()
|
|
||||||
}
|
|
||||||
|
|
||||||
local, err := utils.LinkVideo(item.Data)
|
|
||||||
if err != nil {
|
|
||||||
zap.S().Errorln("视频文件获取异常: ", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
select {
|
|
||||||
case <-c.Done():
|
|
||||||
case <-timerAction(item.Start):
|
|
||||||
{
|
|
||||||
zap.S().Infoln("循环播放待机视频")
|
|
||||||
defer zap.S().Infoln("结束待机视频")
|
|
||||||
|
|
||||||
utils.BlankOpen()
|
|
||||||
defer utils.BlankClose()
|
|
||||||
|
|
||||||
for {
|
|
||||||
err := video.Play(c, local)
|
|
||||||
if err != nil {
|
|
||||||
zap.S().Infof("视频播放异常: %s", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
select {
|
|
||||||
case <-c.Done():
|
|
||||||
return
|
|
||||||
case <-time.After(time.Duration(item.Interval) * time.Second):
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -83,13 +83,13 @@ func Run() {
|
|||||||
cls, err := logger.NewTenCls(fmt.Sprintf("game-driver-%s-%v", config.C.Location, config.C.Point))
|
cls, err := logger.NewTenCls(fmt.Sprintf("game-driver-%s-%v", config.C.Location, config.C.Point))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Println("初始化腾讯云日志服务异常: ", err)
|
log.Println("初始化腾讯云日志服务异常: ", err)
|
||||||
|
logger.InitDevLogger()
|
||||||
|
} else {
|
||||||
|
cls.Start()
|
||||||
|
defer cls.Close()
|
||||||
|
logger.InitProLogger(cls)
|
||||||
}
|
}
|
||||||
cls.Start()
|
|
||||||
defer cls.Close()
|
|
||||||
|
|
||||||
logger.InitProLogger(cls)
|
|
||||||
|
|
||||||
//logger.InitDevLogger()
|
|
||||||
// 应用退出时刷新所有缓冲日志
|
// 应用退出时刷新所有缓冲日志
|
||||||
defer logger.Sync()
|
defer logger.Sync()
|
||||||
|
|
||||||
@@ -101,7 +101,7 @@ func Run() {
|
|||||||
zap.S().Infoln("当前IP: ", addrs)
|
zap.S().Infoln("当前IP: ", addrs)
|
||||||
|
|
||||||
// 启动时关闭屏幕
|
// 启动时关闭屏幕
|
||||||
utils.BlankClose()
|
// utils.BlankClose()
|
||||||
|
|
||||||
topicPrefix := fmt.Sprintf("server/%s/%v/", config.C.Location, config.C.Point)
|
topicPrefix := fmt.Sprintf("server/%s/%v/", config.C.Location, config.C.Point)
|
||||||
publishTopic := fmt.Sprintf("device/%s/%v/status", config.C.Location, config.C.Point)
|
publishTopic := fmt.Sprintf("device/%s/%v/status", config.C.Location, config.C.Point)
|
||||||
@@ -119,7 +119,7 @@ func Run() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// 构建语音合成对象
|
// 构建语音合成对象
|
||||||
tts.DefaultTTS = tts.New(ctx, config.C.Aliyun)
|
tts.DefaultTTS = tts.New(config.C.Aliyun)
|
||||||
|
|
||||||
// 构建继电器对象
|
// 构建继电器对象
|
||||||
var r relay.Relay
|
var r relay.Relay
|
||||||
@@ -143,9 +143,9 @@ func Run() {
|
|||||||
middleware.DeviceLock(device),
|
middleware.DeviceLock(device),
|
||||||
middleware.PauseWait(common.PassCtrl),
|
middleware.PauseWait(common.PassCtrl),
|
||||||
middleware.EmergencyStop(common.GlobalStopper),
|
middleware.EmergencyStop(common.GlobalStopper),
|
||||||
|
middleware.TimeoutOver(config.C.MaxTimeout),
|
||||||
middleware.SoundStart(),
|
middleware.SoundStart(),
|
||||||
middleware.RelayMaster(r),
|
middleware.RelayMaster(r),
|
||||||
middleware.TimeoutOver(config.C.MaxTimeout),
|
|
||||||
middleware.TickerAction(),
|
middleware.TickerAction(),
|
||||||
middleware.PlayBgm(),
|
middleware.PlayBgm(),
|
||||||
routes.PlayRouter(ctx, config.C.Location, config.C.Point),
|
routes.PlayRouter(ctx, config.C.Location, config.C.Point),
|
||||||
|
|||||||
95
main.go
95
main.go
@@ -3,8 +3,99 @@ Copyright © 2024 慕枫Go <mapleafgo@163.com>
|
|||||||
*/
|
*/
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import "game-driver/cmd"
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"game-driver/config"
|
||||||
|
"game-driver/config/game"
|
||||||
|
"game-driver/config/wait"
|
||||||
|
"game-driver/internal"
|
||||||
|
"io/fs"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/spf13/viper"
|
||||||
|
"github.com/urfave/cli/v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 版本信息,编译时通过 ldflags 注入
|
||||||
|
var (
|
||||||
|
Version = "dev"
|
||||||
|
Commit = "unknown"
|
||||||
|
)
|
||||||
|
|
||||||
|
// formatVersion 格式化版本信息
|
||||||
|
func formatVersion() string {
|
||||||
|
if Commit == "unknown" {
|
||||||
|
return Version
|
||||||
|
}
|
||||||
|
if len(Commit) > 7 {
|
||||||
|
return Version + " (" + Commit[:7] + ")"
|
||||||
|
}
|
||||||
|
return Version + " (" + Commit + ")"
|
||||||
|
}
|
||||||
|
|
||||||
|
// initConfig 读取配置文件和环境变量
|
||||||
|
func initConfig(cfgFile string) error {
|
||||||
|
viper.SetConfigFile(cfgFile)
|
||||||
|
viper.AutomaticEnv()
|
||||||
|
|
||||||
|
// 读取配置文件
|
||||||
|
if err := viper.ReadInConfig(); err == nil {
|
||||||
|
log.Printf("使用配置文件: %s", viper.ConfigFileUsed())
|
||||||
|
} else if errors.Is(err, fs.ErrNotExist) {
|
||||||
|
return fmt.Errorf("配置文件不存在: %s", cfgFile)
|
||||||
|
} else {
|
||||||
|
return fmt.Errorf("读取配置文件错误: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析主配置
|
||||||
|
if err := viper.Unmarshal(&config.C); err != nil {
|
||||||
|
return fmt.Errorf("解析主配置失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析游戏配置
|
||||||
|
if game.C = game.NewConfig(config.C.Point); game.C != nil {
|
||||||
|
if err := viper.UnmarshalKey("game", &game.C); err != nil {
|
||||||
|
return fmt.Errorf("解析游戏配置失败: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析待机配置
|
||||||
|
if wait.C = wait.NewConfig(config.C.Point); wait.C != nil {
|
||||||
|
if err := viper.UnmarshalKey("wait", &wait.C); err != nil {
|
||||||
|
return fmt.Errorf("解析待机配置失败: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
cmd.Execute()
|
app := &cli.Command{
|
||||||
|
Name: "game-driver",
|
||||||
|
Usage: "游戏驱动程序",
|
||||||
|
Version: formatVersion(),
|
||||||
|
Flags: []cli.Flag{
|
||||||
|
&cli.StringFlag{
|
||||||
|
Name: "config",
|
||||||
|
Aliases: []string{"c"},
|
||||||
|
Value: "config.yml",
|
||||||
|
Usage: "配置文件路径",
|
||||||
|
Sources: cli.EnvVars("CONFIG_FILE"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Before: func(ctx context.Context, cmd *cli.Command) (context.Context, error) {
|
||||||
|
return ctx, initConfig(cmd.String("config"))
|
||||||
|
},
|
||||||
|
Action: func(ctx context.Context, cmd *cli.Command) error {
|
||||||
|
internal.Run()
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := app.Run(context.Background(), os.Args); err != nil {
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,79 +1,85 @@
|
|||||||
package audio
|
package audio
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"github.com/gopxl/beep/v2"
|
"fmt"
|
||||||
"github.com/gopxl/beep/v2/mp3"
|
|
||||||
"github.com/gopxl/beep/v2/speaker"
|
|
||||||
"github.com/gopxl/beep/v2/wav"
|
|
||||||
"go.uber.org/zap"
|
|
||||||
"io"
|
"io"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/youpy/go-wav"
|
||||||
|
"github.com/hajimehoshi/go-mp3"
|
||||||
)
|
)
|
||||||
|
|
||||||
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 {
|
|
||||||
// panic("扬声器初始化异常: " + err.Error())
|
|
||||||
//}
|
|
||||||
zap.S().Infoln("扬声器初始化完成")
|
|
||||||
}
|
|
||||||
|
|
||||||
func PlayWav(c context.Context, r io.Reader) {
|
|
||||||
streamer, format, err := wav.Decode(r)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return fmt.Errorf("音频上下文初始化失败: %w", err)
|
||||||
}
|
}
|
||||||
defer streamer.Close()
|
|
||||||
|
|
||||||
s := beep.Resample(4, format.SampleRate, DefaultSampleRate, streamer)
|
// Read the entire file into memory since wav.NewReader needs ReadAt
|
||||||
|
data, err := io.ReadAll(r)
|
||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func PlayMP3(c context.Context, r io.ReadCloser) {
|
|
||||||
streamer, format, err := mp3.Decode(r)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
r.Close()
|
||||||
|
return fmt.Errorf("读取 WAV 文件失败: %w", err)
|
||||||
}
|
}
|
||||||
defer streamer.Close()
|
r.Close()
|
||||||
|
|
||||||
s := beep.Resample(4, format.SampleRate, DefaultSampleRate, streamer)
|
// Create a reader from the buffered data
|
||||||
|
dec := wav.NewReader(bytes.NewReader(data))
|
||||||
|
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() {
|
||||||
close(done)
|
for player.IsPlaying() {
|
||||||
})))
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-done:
|
|
||||||
return
|
|
||||||
case <-c.Done():
|
|
||||||
{
|
|
||||||
speaker.Lock()
|
|
||||||
ctrl.Streamer = nil
|
|
||||||
speaker.Unlock()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
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(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.
@@ -1,9 +0,0 @@
|
|||||||
package errorsx
|
|
||||||
|
|
||||||
import "errors"
|
|
||||||
|
|
||||||
var DriverTimeoutErr = errors.New("处理超时")
|
|
||||||
|
|
||||||
var DriverCancelErr = errors.New("系统取消")
|
|
||||||
|
|
||||||
var ThirdPartyErr = errors.New("第三方请求异常")
|
|
||||||
@@ -14,51 +14,61 @@ func New(host string, port int) *Client {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// StartCue 播放节目
|
||||||
func (c *Client) StartCue(data string) error {
|
func (c *Client) StartCue(data string) error {
|
||||||
msg := osc.NewMessage("/beyond/general/StartCue", data)
|
msg := osc.NewMessage("/beyond/general/StartCue", data)
|
||||||
return c.o.Send(msg)
|
return c.o.Send(msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// EnableLaserOutput 打开激光
|
||||||
func (c *Client) EnableLaserOutput() error {
|
func (c *Client) EnableLaserOutput() error {
|
||||||
msg := osc.NewMessage("/beyond/general/EnableLaserOutput")
|
msg := osc.NewMessage("/beyond/general/EnableLaserOutput")
|
||||||
return c.o.Send(msg)
|
return c.o.Send(msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DisableLaserOutput 关闭激光
|
||||||
func (c *Client) DisableLaserOutput() error {
|
func (c *Client) DisableLaserOutput() error {
|
||||||
msg := osc.NewMessage("/beyond/general/DisableLaserOutput")
|
msg := osc.NewMessage("/beyond/general/DisableLaserOutput")
|
||||||
return c.o.Send(msg)
|
return c.o.Send(msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetLaserOutput 设置激光输出
|
||||||
func (c *Client) SetLaserOutput(data string) error {
|
func (c *Client) SetLaserOutput(data string) error {
|
||||||
msg := osc.NewMessage("/beyond/general/SetLaserOutput", data)
|
msg := osc.NewMessage("/beyond/general/SetLaserOutput", data)
|
||||||
return c.o.Send(msg)
|
return c.o.Send(msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetLaserOutputColor 设置激光颜色
|
||||||
func (c *Client) SetLaserOutputColor(data string) error {
|
func (c *Client) SetLaserOutputColor(data string) error {
|
||||||
msg := osc.NewMessage("/beyond/general/SetLaserOutputColor", data)
|
msg := osc.NewMessage("/beyond/general/SetLaserOutputColor", data)
|
||||||
return c.o.Send(msg)
|
return c.o.Send(msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetLaserOutputIntensity 设置激光强度
|
||||||
func (c *Client) SetLaserOutputIntensity(data string) error {
|
func (c *Client) SetLaserOutputIntensity(data string) error {
|
||||||
msg := osc.NewMessage("/beyond/general/SetLaserOutputIntensity", data)
|
msg := osc.NewMessage("/beyond/general/SetLaserOutputIntensity", data)
|
||||||
return c.o.Send(msg)
|
return c.o.Send(msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetLaserOutputPosition 设置激光位置
|
||||||
func (c *Client) SetLaserOutputPosition(data string) error {
|
func (c *Client) SetLaserOutputPosition(data string) error {
|
||||||
msg := osc.NewMessage("/beyond/general/SetLaserOutputPosition", data)
|
msg := osc.NewMessage("/beyond/general/SetLaserOutputPosition", data)
|
||||||
return c.o.Send(msg)
|
return c.o.Send(msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetLaserOutputSize 设置激光尺寸
|
||||||
func (c *Client) SetLaserOutputSize(data string) error {
|
func (c *Client) SetLaserOutputSize(data string) error {
|
||||||
msg := osc.NewMessage("/beyond/general/SetLaserOutputSize", data)
|
msg := osc.NewMessage("/beyond/general/SetLaserOutputSize", data)
|
||||||
return c.o.Send(msg)
|
return c.o.Send(msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetLaserOutputSpeed 设置激光速度
|
||||||
func (c *Client) SetLaserOutputSpeed(data string) error {
|
func (c *Client) SetLaserOutputSpeed(data string) error {
|
||||||
msg := osc.NewMessage("/beyond/general/SetLaserOutputSpeed", data)
|
msg := osc.NewMessage("/beyond/general/SetLaserOutputSpeed", data)
|
||||||
return c.o.Send(msg)
|
return c.o.Send(msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Status 获取状态
|
||||||
func (c *Client) Status() error {
|
func (c *Client) Status() error {
|
||||||
msg := osc.NewMessage("/beyond/general/Status")
|
msg := osc.NewMessage("/beyond/general/Status")
|
||||||
return c.o.Send(msg)
|
return c.o.Send(msg)
|
||||||
|
|||||||
@@ -3,22 +3,26 @@ package tts
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"game-driver/config"
|
"game-driver/config"
|
||||||
"game-driver/leaf"
|
"game-driver/leaf"
|
||||||
"game-driver/pkg/errorsx"
|
"game-driver/pkg/audio"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
nls "github.com/aliyun/alibabacloud-nls-go-sdk"
|
nls "github.com/aliyun/alibabacloud-nls-go-sdk"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// AliTTS 阿里云语音合成
|
||||||
|
// 同一时间只能播放一个 TTS
|
||||||
type AliTTS struct {
|
type AliTTS struct {
|
||||||
config.AliyunConfig
|
config.AliyunConfig
|
||||||
ctx context.Context
|
|
||||||
tokenResult nls.TokenResult
|
tokenResult nls.TokenResult
|
||||||
|
mu sync.Mutex // 互斥锁,确保同时只播放一个
|
||||||
}
|
}
|
||||||
|
|
||||||
type result struct {
|
type result struct {
|
||||||
@@ -28,59 +32,88 @@ type result struct {
|
|||||||
|
|
||||||
var DefaultTTS = &AliTTS{}
|
var DefaultTTS = &AliTTS{}
|
||||||
|
|
||||||
// onTaskFailed 识别过程中的错误处理回调参数
|
// onTaskFailed TTS 合成失败回调
|
||||||
func (tts *AliTTS) onTaskFailed(text string, param interface{}) {
|
func (tts *AliTTS) onTaskFailed(text string, param interface{}) {
|
||||||
p, _ := param.(*result)
|
p, _ := param.(*result)
|
||||||
p.Error = fmt.Errorf("语音合成异常: %v", text)
|
p.Error = fmt.Errorf("语音合成异常: %v", text)
|
||||||
}
|
}
|
||||||
|
|
||||||
// onSynthesisResult 语音合成数据回调参数
|
// onSynthesisResult TTS 合成数据回调
|
||||||
func (tts *AliTTS) onSynthesisResult(data []byte, param interface{}) {
|
func (tts *AliTTS) onSynthesisResult(data []byte, param interface{}) {
|
||||||
p, _ := param.(*result)
|
p, _ := param.(*result)
|
||||||
p.Data.Write(data)
|
p.Data.Write(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (tts *AliTTS) Sound(text string) {
|
// Sound 播放 TTS
|
||||||
|
// 如果已有 TTS 在播放,会等待当前播放完成后再播放新的
|
||||||
|
func (tts *AliTTS) Sound(ctx context.Context, text string) {
|
||||||
if text == "" {
|
if text == "" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
buf, err := tts.Get(text)
|
|
||||||
if err == nil && buf != nil {
|
zap.S().Infof("[TTS] 开始播放: %s", text)
|
||||||
//audio.PlayWav(tts.ctx, buf)
|
|
||||||
} else {
|
buf, err := tts.Get(ctx, text)
|
||||||
zap.S().Errorln("AliTTS 请求异常: ", err)
|
if err != nil {
|
||||||
|
zap.S().Errorw("[TTS] 合成失败", "text", text, "error", err)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
size := buf.(*bytes.Buffer).Len()
|
||||||
|
zap.S().Debugf("[TTS] 合成成功: %s (%d字节)", text, size)
|
||||||
|
|
||||||
|
// 获取锁,阻塞等待直到可以播放
|
||||||
|
zap.S().Debugf("[TTS] 等待播放锁: %s", text)
|
||||||
|
tts.mu.Lock()
|
||||||
|
defer tts.mu.Unlock()
|
||||||
|
|
||||||
|
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 {
|
||||||
|
zap.S().Debugf("[TTS] 播放被取消: %s", text)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
zap.S().Infof("[TTS] 播放完成: %s", text)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// getToken 获取阿里云 TTS Token
|
||||||
func (tts *AliTTS) getToken() error {
|
func (tts *AliTTS) getToken() error {
|
||||||
|
// Token 未过期则复用
|
||||||
if tts.tokenResult.ExpireTime != 0 && time.Unix(tts.tokenResult.ExpireTime, 0).After(time.Now()) {
|
if tts.tokenResult.ExpireTime != 0 && time.Unix(tts.tokenResult.ExpireTime, 0).After(time.Now()) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
tts.tokenResult = nls.TokenResult{}
|
tts.tokenResult = nls.TokenResult{}
|
||||||
resultMessage, err := nls.GetToken("cn-shanghai", "nls-meta.cn-shanghai.aliyuncs.com", tts.AccessKeyID, tts.AccessKeySecret, "2019-02-28")
|
resultMessage, err := nls.GetToken("cn-shanghai", "nls-meta.cn-shanghai.aliyuncs.com", tts.AccessKeyID, tts.AccessKeySecret, "2019-02-28")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
} else if resultMessage.ErrMsg != "" {
|
|
||||||
zap.S().Errorf("获取Token失败: %s", resultMessage.ErrMsg)
|
|
||||||
return errorsx.ThirdPartyErr
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if resultMessage.ErrMsg != "" {
|
||||||
|
return fmt.Errorf("获取Token失败: %s", resultMessage.ErrMsg)
|
||||||
|
}
|
||||||
|
|
||||||
tts.tokenResult = resultMessage.TokenResult
|
tts.tokenResult = resultMessage.TokenResult
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (tts *AliTTS) Get(text string) (io.Reader, error) {
|
// Get 合成语音文本(内部方法)
|
||||||
|
func (tts *AliTTS) Get(ctx context.Context, text string) (io.Reader, error) {
|
||||||
param := nls.DefaultSpeechSynthesisParam()
|
param := nls.DefaultSpeechSynthesisParam()
|
||||||
param.Volume = tts.Volume
|
param.Volume = tts.Volume
|
||||||
param.Voice = tts.Voice
|
param.Voice = tts.Voice
|
||||||
param.SpeechRate = tts.SpeechRate
|
param.SpeechRate = tts.SpeechRate
|
||||||
|
|
||||||
err := tts.getToken()
|
if err := tts.getToken(); err != nil {
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
connectConfig := nls.NewConnectionConfigWithToken(nls.DEFAULT_URL, tts.AppKey, tts.tokenResult.Id)
|
connectConfig := nls.NewConnectionConfigWithToken(nls.DEFAULT_URL, tts.AppKey, tts.tokenResult.Id)
|
||||||
|
|
||||||
logger := nls.NewNlsLogger(leaf.DefaultWriter, "", log.LstdFlags|log.Ltime)
|
logger := nls.NewNlsLogger(leaf.DefaultWriter, "", log.LstdFlags|log.Ltime)
|
||||||
logger.SetLogSil(false)
|
logger.SetLogSil(false)
|
||||||
logger.SetDebug(true)
|
logger.SetDebug(true)
|
||||||
@@ -104,25 +137,29 @@ func (tts *AliTTS) Get(text string) (io.Reader, error) {
|
|||||||
return ttsData.Data, err
|
return ttsData.Data, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// 等待语音合成结束
|
// 等待合成完成
|
||||||
select {
|
select {
|
||||||
case done := <-ch:
|
case done := <-ch:
|
||||||
{
|
if !done {
|
||||||
if !done {
|
return ttsData.Data, fmt.Errorf("TTS合成失败")
|
||||||
return ttsData.Data, errorsx.ThirdPartyErr
|
|
||||||
}
|
|
||||||
return ttsData.Data, nil
|
|
||||||
}
|
}
|
||||||
|
size := ttsData.Data.(*bytes.Buffer).Len()
|
||||||
|
if size == 0 {
|
||||||
|
return ttsData.Data, fmt.Errorf("TTS合成数据为空")
|
||||||
|
}
|
||||||
|
return ttsData.Data, nil
|
||||||
|
|
||||||
case <-time.After(time.Duration(tts.Timeout) * time.Second):
|
case <-time.After(time.Duration(tts.Timeout) * time.Second):
|
||||||
return ttsData.Data, errorsx.DriverTimeoutErr
|
return ttsData.Data, fmt.Errorf("TTS合成超时")
|
||||||
case <-tts.ctx.Done():
|
|
||||||
return ttsData.Data, errorsx.DriverCancelErr
|
case <-ctx.Done():
|
||||||
|
return ttsData.Data, fmt.Errorf("请求被取消")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(ctx context.Context, config config.AliyunConfig) *AliTTS {
|
// New 创建 TTS 实例
|
||||||
|
func New(config config.AliyunConfig) *AliTTS {
|
||||||
return &AliTTS{
|
return &AliTTS{
|
||||||
ctx: ctx,
|
|
||||||
AliyunConfig: config,
|
AliyunConfig: config,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,12 +3,26 @@ package utils
|
|||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"sync"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
xsetBin string
|
||||||
|
once sync.Once
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
once.Do(func() {
|
||||||
|
if found, err := exec.LookPath("xset"); err == nil {
|
||||||
|
xsetBin = found
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// BlankOpen 打开屏幕
|
// BlankOpen 打开屏幕
|
||||||
func BlankOpen() {
|
func BlankOpen() {
|
||||||
if found, err := exec.LookPath("xset"); err == nil {
|
if xsetBin != "" {
|
||||||
exec.Command(found, "dpms", "force", "on").Run()
|
exec.Command(xsetBin, "dpms", "force", "on").Run()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
os.WriteFile("/sys/class/graphics/fb0/blank", []byte("0"), 0644)
|
os.WriteFile("/sys/class/graphics/fb0/blank", []byte("0"), 0644)
|
||||||
@@ -16,8 +30,8 @@ func BlankOpen() {
|
|||||||
|
|
||||||
// BlankClose 关闭屏幕
|
// BlankClose 关闭屏幕
|
||||||
func BlankClose() {
|
func BlankClose() {
|
||||||
if found, err := exec.LookPath("xset"); err == nil {
|
if xsetBin != "" {
|
||||||
exec.Command(found, "dpms", "force", "off").Run()
|
exec.Command(xsetBin, "dpms", "force", "off").Run()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
os.WriteFile("/sys/class/graphics/fb0/blank", []byte("1"), 0644)
|
os.WriteFile("/sys/class/graphics/fb0/blank", []byte("1"), 0644)
|
||||||
|
|||||||
14
todo.md
14
todo.md
@@ -4,10 +4,14 @@
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 在 systemd-networkd-wait-online.service Service 加入 TimeoutStartSec=2sec
|
# 在 systemd-networkd-wait-online.service Service 加入 TimeoutStartSec=2sec
|
||||||
sudo vim /etc/systemd/system/network-online.target.wants/systemd-networkd-wait-online.service
|
sudo EDITOR=vim systemctl edit systemd-networkd-wait-online.service
|
||||||
|
# 在打开的编辑器中添加:
|
||||||
|
# [Service]
|
||||||
|
# TimeoutStartSec=2sec
|
||||||
```
|
```
|
||||||
|
|
||||||
### 配置时区
|
### 配置时区
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo timedatectl set-timezone Asia/Shanghai
|
sudo timedatectl set-timezone Asia/Shanghai
|
||||||
```
|
```
|
||||||
@@ -107,10 +111,14 @@ fi
|
|||||||
|
|
||||||
### 自动登录
|
### 自动登录
|
||||||
|
|
||||||
编辑 `/etc/systemd/system/getty.target.wants/getty@tty1.service` 文件,将 `ExecStart` 行修改为:
|
使用 systemctl edit 修改 getty@tty1 服务:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ExecStart=-/sbin/agetty --autologin <your_username> --noclear %I $TERM
|
sudo EDITOR=vim systemctl edit getty@tty1.service
|
||||||
|
# 在打开的编辑器中添加:
|
||||||
|
# [Service]
|
||||||
|
# ExecStart=
|
||||||
|
# ExecStart=-/sbin/agetty --autologin <your_username> --noclear %I $TERM
|
||||||
```
|
```
|
||||||
|
|
||||||
其中:
|
其中:
|
||||||
|
|||||||
Reference in New Issue
Block a user