24 Commits

Author SHA1 Message Date
ebf9f515f6 docs(audio): 添加 oto/v3 重构设计和实施计划文档
All checks were successful
ci/woodpecker/tag/woodpecker Pipeline was successful
- 设计文档:架构设计、API 设计、并发模型
- 实施计划:16 个任务,完整的实施步骤
- 验收标准:测试覆盖、功能验证、性能指标
2026-04-08 19:21:49 +08:00
788327047c feat(audio): 使用 oto/v3 重构音频播放系统,移除 beep/v2 依赖
核心变更:
- 实现全局 oto.Context 单例管理(sync.Once)
- 实现一次性播放:PlayWav/PlayMP3(支持 context 取消)
- 实现 BGM 循环播放:PlayMP3Loop(atomic.Bool + WaitGroup)
- 迁移所有业务层到新 API(TTS/BGM/待机音频)
- 添加完整的单元测试(6/6 通过)

技术栈:
- oto/v3 v3.3.2(低级音频播放)
- hajimehoshi/go-mp3 v0.3.4(MP3 解码)
- youpy/go-wav v0.3.2(WAV 解码)

移除依赖:
- gopxl/beep/v2 及所有相关依赖

优化:
- 流式播放,无需预先加载
- 并发安全,无竞态条件
- 资源管理清晰(defer cleanup)
- Sleep 间隔优化(1ms → 10ms,降低 CPU 占用)
2026-04-08 19:21:48 +08:00
b5f7c823c8 debug(audio): 添加 Resampler 数据读取测试
All checks were successful
ci/woodpecker/tag/woodpecker Pipeline was successful
为诊断 TTS 音频播放停滞问题,添加手动测试代码验证 Resampler 是否能正常读取音频数据:
- 在 speaker.Play() 前尝试从 Resampler 读取 10 个样本
- 打印读取状态和样本数据,验证数据流是否正常
- 重新创建 Resampler 确保测试不影响正常播放

此调试代码用于确认问题是在 Resampler/WAV 解码层,还是在 speaker/mixer 层

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 16:59:13 +08:00
1f527dce98 refactor(middleware): 调整中间件执行顺序和代码格式化
All checks were successful
ci/woodpecker/tag/woodpecker Pipeline was successful
- 调整中间件执行顺序:将 TimeoutOver 移至 SoundStart 之前,确保超时检查在音频播放前生效
- 简化 BGM 停止逻辑:移除 select 语句中的多余花括号
- 修正导入顺序:将 sync 标准库导入置于第三方库之前

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 16:39:36 +08:00
cbccb07398 feat(audio): 添加音频播放进度监控和停滞检测
为诊断 TTS 音频播放卡死问题,在 PlayWav 函数中添加实时播放进度监控:

- 每秒打印当前播放位置、进度百分比和播放时间
- 检测播放停滞(位置不变时打印警告)
- 改进日志输出,显示总样本数和预计时长
- 移除 select case 中的多余花括号

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 16:38:35 +08:00
b0f07624b0 refactor(cli): 将 cobra 替换为 urfave/cli v3 并添加版本号注入
All checks were successful
ci/woodpecker/tag/woodpecker Pipeline was successful
- 移除 cobra 依赖,使用更轻量的 urfave/cli v3
- 删除 cmd/root.go,将 CLI 逻辑整合到 main.go
- 添加编译时版本号注入(Version 和 Commit)
- 适配 .woodpecker.yml 以支持新的版本号路径
- 代码从 147 行减少到 135 行(净减少 12 行)

版本号现在通过 ldflags 在编译时注入,不再硬编码。
CI 构建时会自动从 Git tag 和 commit SHA 注入版本信息。
2026-04-08 14:44:58 +08:00
e31fca22c8 fix: 修复待机控制器的 context 使用和忙循环问题
- interval: 添加 Sleep 避免默认分支的忙循环(CPU 100%)
- cron: 使用 context.Background() 确保定时任务完整执行,不受外部取消影响
- wait_card: 使用 context.Background() 确保读卡器监听完整执行

这些修复确保了关键操作能够完整运行,同时避免 CPU 资源浪费。
2026-04-08 14:25:56 +08:00
bee3b98798 refactor(tts): 删除 errorsx 包,使用标准库错误处理
## 变更内容

### 1. 删除 errorsx 包
- 删除 pkg/errorsx/handler.go(未使用)
- 删除 pkg/errorsx/error.go(无意义的常量)

### 2. 使用标准库错误处理
- 移除 "game-driver/pkg/errorsx" 导入
- 用 fmt.Errorf 替换所有 errorsx.XxxErr
- 错误信息更清晰,保留完整上下文

### 3. 改进前后对比
```go
// 之前
return errorsx.ThirdPartyErr  //  上下文丢失

// 现在
return fmt.Errorf("获取Token失败: %s", resultMessage.ErrMsg)  //  完整上下文
```

## 优势
 错误信息包含完整上下文
 减少一个自定义包
 使用标准库,更优雅
 代码行数减少:166 → 161 行

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 14:10:41 +08:00
e4c34f0eec refactor(tts): TTS 极简重构与代码质量提升
## 核心改进

### TTS 模块重构
- 统一 API,仅保留 Sound(ctx, text) 方法
- 优化日志,添加 [TTS] 前缀和结构化字段
- 实现互斥等待:同时只播放一个,新请求等待旧播放完成
- 响应 context 取消:超时或断开时立即停止播放
- 移除全局 context 存储,改为参数传递
- 简化实例化:New(config) 无需传入 context

### 代码质量提升
- 修复 PlayWav/PlayMP3 的死循环 bug(context 取消时缺少 return)
- 修复 standby_ctrl/pause.go 的忙循环(添加 Sleep 避免CPU 100%)
- 添加关键路径错误传播(only_video.go 不再忽略播放错误)
- 新增 pkg/errorsx/handler.go 统一错误处理工具

## 代码优化
- TTS 代码从 234 行精简到 166 行(减少 29%)
- 移除冗余状态管理(playing 标志、等待循环)
- 利用互斥锁的阻塞特性实现优雅等待
- 保持简洁易读的代码风格

## 行为说明
 同时只能播放一个 TTS(互斥)
 新请求等待当前播放完成(不打断)
 响应 context 取消(超时停止)
 日志完善,便于排查问题

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 14:05:16 +08:00
2331d0c73f fix(tts): 修复 TTS 播放卡死问题并增强日志
All checks were successful
ci/woodpecker/tag/woodpecker Pipeline was successful
- 添加 SoundWithContext 方法,使用请求 context 而非全局 context
- 修复 TTS 使用服务器全局 context 导致无法取消的问题
- 添加详细的诊断日志(解码、播放、TTS 合成各阶段)
- 检测并记录 TTS 合成数据为空的情况

修复前 TTS 播放使用全局 context,当播放卡住时无法通过超时
或取消机制中断,导致后续任务永远无法执行。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 12:07:39 +08:00
5ee8e15965 fix(audio): 修复音频播放死循环并增强错误日志
All checks were successful
ci/woodpecker/tag/woodpecker Pipeline was successful
- 修复 PlayWav 和 PlayMP3 在 context 取消时的死循环 bug
- 添加 WAV/MP3 解码失败的错误日志
- 添加 TTS 播放开始/完成的日志,便于排查问题

修复前 context 取消会导致无限循环,阻塞后续任务执行。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 11:34:27 +08:00
ae5f998d88 ci: 优化构建配置
All checks were successful
ci/woodpecker/tag/woodpecker Pipeline was successful
- amd64 原生构建移除冗余的 GOOS/GOARCH/CGO/CC 设置
- 删除不需要的 gcc-x86-64-linux-gnu 安装
- 添加构建产物列表便于调试
- 修复 release 步骤的 YAML 缩进

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-04-07 18:35:55 +08:00
6cf28217a4 ci: 增强多架构构建支持
Some checks failed
ci/woodpecker/tag/woodpecker Pipeline was canceled
- 启用 arm64 多架构和安装 libasound2/libvlc 开发库
- 设置 PKG_CONFIG_PATH 确保交叉编译时依赖正确链接
- 分别配置 amd64 和 arm64 的 pkg-config 路径

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-04-07 18:22:07 +08:00
e93e99480b ci: 修复 amd64 交叉编译器变量名
Some checks failed
ci/woodpecker/tag/woodpecker Pipeline was canceled
CC 应从 x86-64-linux-gnu-gcc 改为 x86_64-linux-gnu-gcc

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-04-07 18:13:24 +08:00
619e919fa0 ci: 优化构建速度,添加 Go 代理和 APT 镜像
Some checks failed
ci/woodpecker/tag/woodpecker Pipeline was canceled
- 设置 GOPROXY 为 goproxy.cn 加速依赖下载
- 替换 Debian 源为阿里云镜像加速 apt 安装

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-04-07 18:07:29 +08:00
809f123854 ci: 升级构建环境至 Go 1.24
Some checks failed
ci/woodpecker/tag/woodpecker Pipeline was canceled
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-04-07 17:54:23 +08:00
46961040b3 ci: 添加 Woodpecker CI 配置并更新 .gitignore
Some checks failed
ci/woodpecker/tag/woodpecker Pipeline was canceled
- 新增 tag 触发的 amd64/arm64 多架构构建与发布流水线
- .gitignore 忽略 .qwen 目录
- 修复 .gitignore 文件末尾换行符

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-04-07 17:48:38 +08:00
4349413887 refactor(utils): 禁用屏幕开关并优化 xset 查找逻辑
- 注释掉所有 BlankOpen/BlankClose 调用,启动不再关屏
- 将 xset 路径查找改为 init + sync.Once 缓存,避免重复执行
- 清理未使用的 utils 导入

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-04-07 16:52:33 +08:00
994023553d chore(deps): 清理未使用的 go 依赖
移除旧版本的 aliyun-sdk、fetchup、x/exp、x/net、x/sys、x/tools 等

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-04-07 16:52:20 +08:00
dd0c7b8feb chore(config): 更新 wushan-5 点位配置
- 点位从 3 调整为 5
- 更新 MQTT 服务器地址和客户端 ID
- 更新游戏服务器 host 和端口
- 修复文件末尾换行符

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-04-07 16:52:03 +08:00
2d5d3919e2 降级日志 2025-09-26 16:48:11 +08:00
ac0a338b76 脚本优化 2025-09-25 18:07:59 +08:00
24b2b6c199 增加 fontconfig 包 2025-09-25 11:48:51 +08:00
a314a1a0d8 增加初始化脚本 2025-07-11 17:41:36 +08:00
38 changed files with 2890 additions and 364 deletions

3
.gitignore vendored
View File

@@ -1,6 +1,7 @@
/logs
/.idea
/.vscode
/.qwen
*.mp3
game-driver*
game-driver*

43
.woodpecker.yml Normal file
View 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

View File

@@ -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)
}
}
}

View File

@@ -1,5 +1,5 @@
location: wushan # 项目名称
point: 3 # 点位
point: 5 # 点位
relay:
maxTimeout: 60 # 单位 s必须大于 0
standbyCache: # 待机缓存文件路径
@@ -17,9 +17,9 @@ log:
maxAge: 30
compress: true
mqtt:
url: mqtt://wushan-mqtt.chaoshengshuzi.com:1883
clientID: wushan-3
password: wushan@1013
url: mqtt://mqtt.wxsxlj.com:1883
clientID: wushan-5
password:
aliyun:
accessKeyID:
accessKeySecret:
@@ -31,8 +31,8 @@ aliyun:
# 激光秀点位 osc 参数
game:
host: 192.168.0.167
port: 3033
host: 192.168.1.191
port: 8000
# 待机投影仪控制参数
#wait:
@@ -65,4 +65,4 @@ game:
# empty: 24
# push: 10
# reset: 9
# pull: 11
# pull: 11

File diff suppressed because it is too large Load Diff

View 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
View File

@@ -5,62 +5,60 @@ go 1.23.2
require (
github.com/adrg/libvlc-go/v3 v3.1.6
github.com/aliyun/alibabacloud-nls-go-sdk v1.1.1
github.com/ebitengine/oto/v3 v3.3.2
github.com/eclipse/paho.golang v0.22.0
github.com/go-pkgz/cronrange v0.2.0
github.com/go-rod/rod v0.116.2
github.com/gopxl/beep/v2 v2.1.1
github.com/grid-x/modbus v0.0.0-20250219144522-2b18d136199f
github.com/hajimehoshi/go-mp3 v0.3.4
github.com/hypebeast/go-osc v0.0.0-20220308234300-cec5a8a1e5f5
github.com/spf13/cobra v1.9.1
github.com/spf13/viper v1.19.0
github.com/spf13/viper v1.21.0
github.com/tencentcloud/tencentcloud-cls-sdk-go v1.0.11
github.com/urfave/cli/v3 v3.8.0
github.com/warthog618/go-gpiocdev v0.9.1
github.com/youpy/go-wav v0.3.2
go.uber.org/zap v1.27.0
gopkg.in/natefinch/lumberjack.v2 v2.2.1
)
require (
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/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/gorilla/websocket v1.5.3 // indirect
github.com/grid-x/serial v0.0.0-20211107191517-583c7356b3aa // indirect
github.com/hajimehoshi/go-mp3 v0.3.4 // indirect
github.com/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/json-iterator/go v1.1.12 // 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/reflect2 v1.0.2 // 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/pkg/errors v0.9.1 // indirect
github.com/sagikazarmark/locafero v0.7.0 // indirect
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/sagikazarmark/locafero v0.11.0 // indirect
github.com/satori/go.uuid v1.2.0 // indirect
github.com/sourcegraph/conc v0.3.0 // indirect
github.com/spf13/afero v1.12.0 // indirect
github.com/spf13/cast v1.7.1 // indirect
github.com/spf13/pflag v1.0.6 // indirect
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
github.com/spf13/afero v1.15.0 // indirect
github.com/spf13/cast v1.10.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/youpy/go-riff v0.1.0 // indirect
github.com/ysmood/fetchup v0.3.0 // indirect
github.com/ysmood/goob v0.4.0 // indirect
github.com/ysmood/got v0.40.0 // indirect
github.com/ysmood/gson v0.7.3 // indirect
github.com/ysmood/leakless v0.9.0 // indirect
github.com/zaf/g711 v1.4.0 // indirect
go.uber.org/atomic v1.11.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
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/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
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

88
go.sum
View File

@@ -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/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.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/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/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/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=
@@ -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.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
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.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
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-pkgz/cronrange v0.2.0 h1:FaJ/TB7Ng3xTCfRgblfLecL07RccXVVB6+/hdFaGbBE=
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/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/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=
@@ -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/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/gopxl/beep/v2 v2.1.1 h1:6FYIYMm2qPAdWkjX+7xwKrViS1x0Po5kDMdRkq8NVbU=
github.com/gopxl/beep/v2 v2.1.1/go.mod h1:ZAm9TGQ9lvpoiFLd4zf5B1IuyxZhgRACMId1XJbaW0E=
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
@@ -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/go.mod h1:fRtZraRFcWb0pu7ok0LqyFhCUrPeMsGRSVop0eemFmo=
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/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.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg=
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.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
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-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
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/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b h1:FfH+VrHHk6Lxt9HdVS0PXzSXFyS2NbZKXv33FYPol0A=
github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b/go.mod h1:AC62GU6hc0BrNm+9RK9VSiwa/EUe1bkIeFORAMcHvJU=
github.com/orcaman/writerseeker v0.0.0-20200621085525-1d3f536ff85e h1:s2RNOM/IGdY0Y6qfTeUKhDawdHDpK9RGBdx80qN4Ttw=
github.com/orcaman/writerseeker v0.0.0-20200621085525-1d3f536ff85e/go.mod h1:nBdnFKj15wFbf94Rwfq4m30eAcyY9V/IyKAGQFtqkW0=
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pierrec/lz4 v2.6.1+incompatible h1:9UY3+iC23yxF0UfGaYrGplQ+79Rg+h/q9FV9ix19jjM=
github.com/pierrec/lz4 v2.6.1+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY=
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.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
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.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo=
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/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
github.com/satori/go.uuid v1.2.0 h1:0uYX9dsZ2yD7q2RtLRtPSdGDWzjeM3TbMJP9utgA0ww=
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/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.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
github.com/spf13/afero v1.12.0 h1:UcOPyRBYczmFn6yvphxkn9ZEOY65cpwGKb5mL36mrqs=
github.com/spf13/afero v1.12.0/go.mod h1:ZTlWwG4/ahT8W7T0WQ5uYmjI9duaLQGy3Q2OAl4sk/4=
github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
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/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
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=
@@ -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-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/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/go.mod h1:dN3e3t/S2aSNC+hgigGE/dBW8jE1ONk9bDSEYfoPyl8=
github.com/warthog618/go-gpiosim v0.1.1 h1:MRAEv+T+itmw+3GeIGpQJBfanUVyg0l3JCTwHtwdre4=
github.com/warthog618/go-gpiosim v0.1.1/go.mod h1:YXsnB+I9jdCMY4YAlMSRrlts25ltjmuIsrnoUrBLdqU=
github.com/ysmood/fetchup v0.2.4 h1:2kfWr/UrdiHg4KYRrxL2Jcrqx4DZYD+OtWu7WPBZl5o=
github.com/ysmood/fetchup v0.2.4/go.mod h1:hbysoq65PXL0NQeNzUczNYIKpwpkwFL4LXMDEvIQq9A=
github.com/youpy/go-riff v0.1.0 h1:vZO/37nI4tIET8tQI0Qn0Y79qQh99aEpponTPiPut7k=
github.com/youpy/go-riff v0.1.0/go.mod h1:83nxdDV4Z9RzrTut9losK7ve4hUnxUR8ASSz4BsKXwQ=
github.com/youpy/go-wav v0.3.2 h1:NLM8L/7yZ0Bntadw/0h95OyUsen+DQIVf9gay+SUsMU=
github.com/youpy/go-wav v0.3.2/go.mod h1:0FCieAXAeSdcxFfwLpRuEo0PFmAoc+8NU34h7TUvk50=
github.com/ysmood/fetchup v0.3.0 h1:UhYz9xnLEVn2ukSuK3KCgcznWpHMdrmbsPpllcylyu8=
github.com/ysmood/fetchup v0.3.0/go.mod h1:hbysoq65PXL0NQeNzUczNYIKpwpkwFL4LXMDEvIQq9A=
github.com/ysmood/goob v0.4.0 h1:HsxXhyLBeGzWXnqVKtmT9qM7EuVs/XOgkX7T6r1o1AQ=
@@ -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/leakless v0.9.0 h1:qxCG5VirSBvmi3uynXFkcnLMzkphdh3xx5FtrORwDCU=
github.com/ysmood/leakless v0.9.0/go.mod h1:R8iAXPRaG97QJwqxs74RdwzcRHT1SWCGTNqY8q0JvMQ=
github.com/zaf/g711 v0.0.0-20190814101024-76a4a538f52b/go.mod h1:T2h1zV50R/q0CVYnsQOQ6L7P4a2ZxH47ixWcMXFGyx8=
github.com/zaf/g711 v1.4.0 h1:XZYkjjiAg9QTBnHqEg37m2I9q3IIDv5JRYXs2N8ma7c=
github.com/zaf/g711 v1.4.0/go.mod h1:eCDXt3dSp/kYYAoooba7ukD/Q75jvAaS4WOMr0l1Roo=
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
@@ -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/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
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-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
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-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-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-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
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-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.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/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
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-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.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/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
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.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
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/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
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-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.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo=
gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw=
pgregory.net/rapid v1.1.0 h1:CMa0sjHSru3puNx+J0MIAuiiEV4N0qj8/cMWGBBCsjw=
pgregory.net/rapid v1.1.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=

602
init-device.py Executable file
View 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)

View File

@@ -2,7 +2,10 @@
```bash
# 在 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
sudo apt update
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 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 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
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
```
其中:

View File

@@ -5,9 +5,9 @@ import (
"game-driver/leaf"
"game-driver/pkg/audio"
"game-driver/pkg/utils"
"github.com/gopxl/beep/v2/speaker"
"go.uber.org/zap"
"sync"
"go.uber.org/zap"
)
// PlayBgm 播放背景音乐
@@ -21,13 +21,10 @@ func PlayBgm() leaf.HandlerFunc {
}
if bgm != nil {
zap.S().Infoln("背景音乐解析成功")
// 等待组
var wait sync.WaitGroup
defer wait.Wait()
// 结束信号通道
a := make(chan struct{})
// 发送结束信号
defer close(a)
wait.Add(1)
@@ -37,22 +34,14 @@ func PlayBgm() leaf.HandlerFunc {
zap.S().Infoln("开始播放背景音乐")
defer zap.S().Infoln("结束背景音乐播放")
ctrl, closer, e := audio.PlayBgmMP3(bgm)
defer closer()
if e != nil {
zap.S().Errorln("播放背景音乐异常:", e)
_, cleanup, err := audio.PlayMP3Loop(bgm)
if err != nil {
zap.S().Errorln("播放背景音乐异常:", err)
return
}
defer cleanup()
select {
case <-a:
{
speaker.Lock()
ctrl.Streamer = nil
speaker.Unlock()
return
}
}
<-a
}()
} else {
zap.S().Infoln("未解析到背景音乐")

View File

@@ -10,16 +10,24 @@ import (
func SoundStart() leaf.HandlerFunc {
return func(c *leaf.Context) {
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() {
var text string
switch leaf.Value[leaf.EndType](c, leaf.EndKey) {
case leaf.End:
tts.DefaultTTS.Sound(pm.TTS.End)
text = pm.TTS.End
case leaf.EndTimeout:
tts.DefaultTTS.Sound(pm.TTS.Timeout)
text = pm.TTS.Timeout
case leaf.EndStop:
tts.DefaultTTS.Sound(pm.TTS.Stop)
text = pm.TTS.Stop
}
if text != "" {
tts.DefaultTTS.Sound(c, text)
}
}()

View File

@@ -1,6 +1,7 @@
package middleware
import (
"context"
"game-driver/internal/schema"
"game-driver/leaf"
"game-driver/pkg/tts"
@@ -34,7 +35,7 @@ func TickerAction() leaf.HandlerFunc {
defer close(a)
wait.Add(1)
go func() {
go func(ctx context.Context) {
start := time.Now()
defer wait.Done()
// 定时器
@@ -48,6 +49,9 @@ func TickerAction() leaf.HandlerFunc {
select {
case <-a:
over = true
case <-ctx.Done():
zap.S().Infoln("Ticker 计时被取消")
over = true
case m := <-ticker.C:
{
s := int(m.Sub(start).Seconds())
@@ -55,12 +59,12 @@ func TickerAction() leaf.HandlerFunc {
//TODO: 屏幕打印
}
if to, ok := ttsMap[s]; ok {
tts.DefaultTTS.Sound(to.Value)
tts.DefaultTTS.Sound(ctx, to.Value)
}
}
}
}
}()
}(c)
c.Next()
}
}

View File

@@ -6,14 +6,15 @@ import (
"game-driver/leaf"
"game-driver/pkg/utils"
"game-driver/pkg/video"
"go.uber.org/zap"
)
func OnlyVideo(c *leaf.Context) {
payload := leaf.Value[*schema.PlayModal](c, middleware.PayloadJSONKey)
utils.BlankOpen()
defer utils.BlankClose()
// utils.BlankOpen()
// defer utils.BlankClose()
if url, ok := payload.Game["video"]; ok {
path, local, err := utils.LinkVideo(url.(string))
@@ -21,6 +22,8 @@ func OnlyVideo(c *leaf.Context) {
zap.S().Errorln("视频文件获取异常: ", err)
return
}
_ = video.Play(c, path, local)
if err := video.Play(c, path, local); err != nil {
zap.S().Errorln("视频播放异常: ", err)
}
}
}

View File

@@ -10,11 +10,12 @@ import (
"game-driver/pkg/card_reader"
"game-driver/pkg/channel"
"game-driver/pkg/tts"
"go.uber.org/zap"
"io/fs"
"os"
"sync"
"time"
"go.uber.org/zap"
)
func WaitCard(ctx context.Context) leaf.HandlerFunc {
@@ -79,13 +80,14 @@ func WaitCard(ctx context.Context) leaf.HandlerFunc {
defer cardInfo.Close()
// 结束信号通道
cc, cancel := context.WithCancel(context.TODO())
// 使用独立 context 确保读卡器监听完整执行,不受外部取消影响
c2, cancel := context.WithCancel(context.Background())
defer cancel()
wait.Add(1)
go func() {
defer wait.Done()
reader.OnCardInfo(cc, func(info *card_reader.CardInfo) {
reader.OnCardInfo(c2, func(info *card_reader.CardInfo) {
cardInfo.Send(info.ID)
})
}()
@@ -103,12 +105,12 @@ func WaitCard(ctx context.Context) leaf.HandlerFunc {
if cardId != id {
zap.S().Infof("读取到卡片数据%q与预期卡片数据%q不一致", id, cardId)
// 播报错误提示
tts.DefaultTTS.Sound(cardError)
tts.DefaultTTS.Sound(c, cardError)
isNeed = true
break
}
// 播报恭喜语音
tts.DefaultTTS.Sound(cardOk)
tts.DefaultTTS.Sound(c, cardOk)
//TODO: 打开炫酷光效,屏幕跳转恭喜页面
zap.S().Infof("读取到卡片数据%q开始打开炫酷光效", id)
Default(c)

View File

@@ -6,7 +6,7 @@ import (
"game-driver/internal/schema"
"game-driver/pkg/audio"
"game-driver/pkg/utils"
"github.com/gopxl/beep/v2/speaker"
"go.uber.org/zap"
)
@@ -23,18 +23,14 @@ func Audio(item schema.WaitItemModel) func(c context.Context) error {
zap.S().Infoln("播放待机音乐")
defer zap.S().Infoln("结束待机音乐")
ctrl, closer, e := audio.PlayBgmMP3(data)
defer closer()
if e != nil {
return fmt.Errorf("播放待机音乐异常: %w", e)
_, cleanup, err := audio.PlayMP3Loop(data)
if err != nil {
return fmt.Errorf("播放待机音乐异常: %w", err)
}
defer cleanup()
<-c.Done()
speaker.Lock()
ctrl.Streamer = nil
speaker.Unlock()
return nil
}
}

View File

@@ -2,16 +2,18 @@ package standby
import (
"context"
"errors"
"fmt"
"game-driver/internal/schema"
"game-driver/pkg/audio"
"game-driver/pkg/tts"
"go.uber.org/zap"
"io"
)
func TTS(item schema.WaitItemModel) func(c context.Context) error {
return func(c context.Context) error {
reader, err := tts.DefaultTTS.Get(item.Data)
reader, err := tts.DefaultTTS.Get(c, item.Data)
if err != nil {
return fmt.Errorf("语音合成异常: %w", err)
}
@@ -19,7 +21,10 @@ func TTS(item schema.WaitItemModel) func(c context.Context) error {
zap.S().Infoln("播放待机 TTS 语音")
defer zap.S().Infoln("结束待机 TTS 语音")
audio.PlayWav(c, reader)
err = audio.PlayWav(c, io.NopCloser(reader))
if err != nil && !errors.Is(err, context.Canceled) {
zap.S().Errorf("TTS 播放失败: %v", err)
}
return nil
}

View File

@@ -6,6 +6,7 @@ import (
"game-driver/internal/schema"
"game-driver/pkg/utils"
"game-driver/pkg/video"
"go.uber.org/zap"
)
@@ -19,8 +20,8 @@ func Video(item schema.WaitItemModel) func(c context.Context) error {
zap.S().Infoln("播放待机视频")
defer zap.S().Infoln("结束待机视频")
utils.BlankOpen()
defer utils.BlankClose()
// utils.BlankOpen()
// defer utils.BlankClose()
err = video.Play(c, path, local)
if err != nil {

View File

@@ -4,7 +4,7 @@ import (
"context"
"game-driver/internal/schema"
"game-driver/pkg/browser"
"game-driver/pkg/utils"
"go.uber.org/zap"
)
@@ -13,8 +13,8 @@ func Web(item schema.WaitItemModel) func(c context.Context) error {
zap.S().Infoln("打开待机网页")
// 控制背光
utils.BlankOpen()
defer utils.BlankClose()
// utils.BlankOpen()
// defer utils.BlankClose()
browser.OpenApp(c, item.Data)
return nil

View File

@@ -65,7 +65,8 @@ func Cron(rootRules []cronrange.Rule, cron string, play func(c context.Context)
case r := <-a:
if r {
if ok := m.TryLock(); ok {
ctx, cc := context.WithCancel(context.TODO())
// 使用独立 context 确保任务完整执行,不受外部取消影响
ctx, cc := context.WithCancel(context.Background())
cancel = cc
waitGroup.Add(1)
go func() {

View File

@@ -28,6 +28,8 @@ func Interval(interval int64, play func(c context.Context) error) func(c context
case <-c.Done():
return nil
default:
// 避免忙循环,短暂休眠
time.Sleep(10 * time.Millisecond)
}
}
}

View File

@@ -5,6 +5,7 @@ import (
"game-driver/internal/common"
"go.uber.org/zap"
"sync"
"time"
)
// Pause 暂停控制器
@@ -58,6 +59,8 @@ func Pause(ps *common.PauseSub, isPause bool, play func(c context.Context) error
zap.S().Infoln("执行后续操作异常: ", err)
}
}
// 避免忙循环,短暂休眠
time.Sleep(10 * time.Millisecond)
}
}
}

View File

@@ -83,13 +83,13 @@ func Run() {
cls, err := logger.NewTenCls(fmt.Sprintf("game-driver-%s-%v", config.C.Location, config.C.Point))
if err != nil {
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()
@@ -101,7 +101,7 @@ func Run() {
zap.S().Infoln("当前IP: ", addrs)
// 启动时关闭屏幕
utils.BlankClose()
// utils.BlankClose()
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)
@@ -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
@@ -143,9 +143,9 @@ func Run() {
middleware.DeviceLock(device),
middleware.PauseWait(common.PassCtrl),
middleware.EmergencyStop(common.GlobalStopper),
middleware.TimeoutOver(config.C.MaxTimeout),
middleware.SoundStart(),
middleware.RelayMaster(r),
middleware.TimeoutOver(config.C.MaxTimeout),
middleware.TickerAction(),
middleware.PlayBgm(),
routes.PlayRouter(ctx, config.C.Location, config.C.Point),

95
main.go
View File

@@ -3,8 +3,99 @@ Copyright © 2024 慕枫Go <mapleafgo@163.com>
*/
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() {
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)
}
}

View File

@@ -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
View 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
View 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
View 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
View 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
View 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)
}
}
}

View File

@@ -1,80 +1,85 @@
package audio
import (
"bytes"
"context"
"github.com/gopxl/beep/v2"
"github.com/gopxl/beep/v2/mp3"
"github.com/gopxl/beep/v2/speaker"
"github.com/gopxl/beep/v2/wav"
"go.uber.org/zap"
"fmt"
"io"
"time"
"github.com/youpy/go-wav"
"github.com/hajimehoshi/go-mp3"
)
var DefaultSampleRate = beep.SampleRate(44100)
func init() {
err := speaker.Init(DefaultSampleRate, DefaultSampleRate.N(time.Second/10))
// PlayWav 播放 WAV 文件(阻塞),直到完成或 context 取消
func PlayWav(ctx context.Context, r io.ReadCloser) error {
otoCtx, err := initContext()
if err != nil {
panic("扬声器初始化异常: " + err.Error())
return fmt.Errorf("音频上下文初始化失败: %w", err)
}
zap.S().Infoln("扬声器初始化完成")
}
func PlayWav(c context.Context, r io.Reader) {
streamer, format, err := wav.Decode(r)
// Read the entire file into memory since wav.NewReader needs ReadAt
data, err := io.ReadAll(r)
if err != nil {
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{})
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()
}
go func() {
for player.IsPlaying() {
time.Sleep(10 * time.Millisecond)
}
close(done)
}()
select {
case <-done:
return nil
case <-ctx.Done():
return ctx.Err()
}
}
func PlayMP3(c context.Context, r io.ReadCloser) {
streamer, format, err := mp3.Decode(r)
// PlayMP3 播放 MP3 文件(阻塞),直到完成或 context 取消
func PlayMP3(ctx context.Context, r io.ReadCloser) error {
otoCtx, err := initContext()
if err != nil {
return
return fmt.Errorf("音频上下文初始化失败: %w", err)
}
defer streamer.Close()
s := beep.Resample(4, format.SampleRate, DefaultSampleRate, streamer)
dec, err := mp3.NewDecoder(r)
if err != nil {
r.Close()
return fmt.Errorf("MP3 解码失败: %w", err)
}
defer r.Close()
player := otoCtx.NewPlayer(dec)
defer player.Close()
player.Play()
ctrl := &beep.Ctrl{Streamer: s}
done := make(chan struct{})
speaker.Play(beep.Seq(ctrl, beep.Callback(func() {
close(done)
})))
for {
select {
case <-done:
return
case <-c.Done():
{
speaker.Lock()
ctrl.Streamer = nil
speaker.Unlock()
}
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
View 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

Binary file not shown.

View File

@@ -1,9 +0,0 @@
package errorsx
import "errors"
var DriverTimeoutErr = errors.New("处理超时")
var DriverCancelErr = errors.New("系统取消")
var ThirdPartyErr = errors.New("第三方请求异常")

View File

@@ -14,51 +14,61 @@ func New(host string, port int) *Client {
}
}
// StartCue 播放节目
func (c *Client) StartCue(data string) error {
msg := osc.NewMessage("/beyond/general/StartCue", data)
return c.o.Send(msg)
}
// EnableLaserOutput 打开激光
func (c *Client) EnableLaserOutput() error {
msg := osc.NewMessage("/beyond/general/EnableLaserOutput")
return c.o.Send(msg)
}
// DisableLaserOutput 关闭激光
func (c *Client) DisableLaserOutput() error {
msg := osc.NewMessage("/beyond/general/DisableLaserOutput")
return c.o.Send(msg)
}
// SetLaserOutput 设置激光输出
func (c *Client) SetLaserOutput(data string) error {
msg := osc.NewMessage("/beyond/general/SetLaserOutput", data)
return c.o.Send(msg)
}
// SetLaserOutputColor 设置激光颜色
func (c *Client) SetLaserOutputColor(data string) error {
msg := osc.NewMessage("/beyond/general/SetLaserOutputColor", data)
return c.o.Send(msg)
}
// SetLaserOutputIntensity 设置激光强度
func (c *Client) SetLaserOutputIntensity(data string) error {
msg := osc.NewMessage("/beyond/general/SetLaserOutputIntensity", data)
return c.o.Send(msg)
}
// SetLaserOutputPosition 设置激光位置
func (c *Client) SetLaserOutputPosition(data string) error {
msg := osc.NewMessage("/beyond/general/SetLaserOutputPosition", data)
return c.o.Send(msg)
}
// SetLaserOutputSize 设置激光尺寸
func (c *Client) SetLaserOutputSize(data string) error {
msg := osc.NewMessage("/beyond/general/SetLaserOutputSize", data)
return c.o.Send(msg)
}
// SetLaserOutputSpeed 设置激光速度
func (c *Client) SetLaserOutputSpeed(data string) error {
msg := osc.NewMessage("/beyond/general/SetLaserOutputSpeed", data)
return c.o.Send(msg)
}
// Status 获取状态
func (c *Client) Status() error {
msg := osc.NewMessage("/beyond/general/Status")
return c.o.Send(msg)

View File

@@ -3,23 +3,26 @@ package tts
import (
"bytes"
"context"
"errors"
"fmt"
"game-driver/config"
"game-driver/leaf"
"game-driver/pkg/audio"
"game-driver/pkg/errorsx"
"go.uber.org/zap"
"io"
"log"
"sync"
"time"
nls "github.com/aliyun/alibabacloud-nls-go-sdk"
)
// AliTTS 阿里云语音合成
// 同一时间只能播放一个 TTS
type AliTTS struct {
config.AliyunConfig
ctx context.Context
tokenResult nls.TokenResult
mu sync.Mutex // 互斥锁,确保同时只播放一个
}
type result struct {
@@ -29,59 +32,88 @@ type result struct {
var DefaultTTS = &AliTTS{}
// onTaskFailed 识别过程中的错误处理回调参数
// onTaskFailed TTS 合成失败回调
func (tts *AliTTS) onTaskFailed(text string, param interface{}) {
p, _ := param.(*result)
p.Error = fmt.Errorf("语音合成异常: %v", text)
}
// onSynthesisResult 语音合成数据回调参数
// onSynthesisResult TTS 合成数据回调
func (tts *AliTTS) onSynthesisResult(data []byte, param interface{}) {
p, _ := param.(*result)
p.Data.Write(data)
}
func (tts *AliTTS) Sound(text string) {
// Sound 播放 TTS
// 如果已有 TTS 在播放,会等待当前播放完成后再播放新的
func (tts *AliTTS) Sound(ctx context.Context, text string) {
if text == "" {
return
}
buf, err := tts.Get(text)
if err == nil && buf != nil {
audio.PlayWav(tts.ctx, buf)
} else {
zap.S().Errorln("AliTTS 请求异常: ", err)
zap.S().Infof("[TTS] 开始播放: %s", text)
buf, err := tts.Get(ctx, text)
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 {
// Token 未过期则复用
if tts.tokenResult.ExpireTime != 0 && time.Unix(tts.tokenResult.ExpireTime, 0).After(time.Now()) {
return nil
}
tts.tokenResult = nls.TokenResult{}
resultMessage, err := nls.GetToken("cn-shanghai", "nls-meta.cn-shanghai.aliyuncs.com", tts.AccessKeyID, tts.AccessKeySecret, "2019-02-28")
if err != nil {
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
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.Volume = tts.Volume
param.Voice = tts.Voice
param.SpeechRate = tts.SpeechRate
err := tts.getToken()
if err != nil {
if err := tts.getToken(); err != nil {
return nil, err
}
connectConfig := nls.NewConnectionConfigWithToken(nls.DEFAULT_URL, tts.AppKey, tts.tokenResult.Id)
logger := nls.NewNlsLogger(leaf.DefaultWriter, "", log.LstdFlags|log.Ltime)
logger.SetLogSil(false)
logger.SetDebug(true)
@@ -105,25 +137,29 @@ func (tts *AliTTS) Get(text string) (io.Reader, error) {
return ttsData.Data, err
}
// 等待语音合成结束
// 等待合成完成
select {
case done := <-ch:
{
if !done {
return ttsData.Data, errorsx.ThirdPartyErr
}
return ttsData.Data, nil
if !done {
return ttsData.Data, fmt.Errorf("TTS合成失败")
}
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):
return ttsData.Data, errorsx.DriverTimeoutErr
case <-tts.ctx.Done():
return ttsData.Data, errorsx.DriverCancelErr
return ttsData.Data, fmt.Errorf("TTS合成超时")
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{
ctx: ctx,
AliyunConfig: config,
}
}

View File

@@ -3,12 +3,26 @@ package utils
import (
"os"
"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 打开屏幕
func BlankOpen() {
if found, err := exec.LookPath("xset"); err == nil {
exec.Command(found, "dpms", "force", "on").Run()
if xsetBin != "" {
exec.Command(xsetBin, "dpms", "force", "on").Run()
return
}
os.WriteFile("/sys/class/graphics/fb0/blank", []byte("0"), 0644)
@@ -16,8 +30,8 @@ func BlankOpen() {
// BlankClose 关闭屏幕
func BlankClose() {
if found, err := exec.LookPath("xset"); err == nil {
exec.Command(found, "dpms", "force", "off").Run()
if xsetBin != "" {
exec.Command(xsetBin, "dpms", "force", "off").Run()
return
}
os.WriteFile("/sys/class/graphics/fb0/blank", []byte("1"), 0644)

16
todo.md
View File

@@ -4,17 +4,21 @@
```bash
# 在 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
sudo timedatectl set-timezone Asia/Shanghai
```
## linux 下播放音频
```bash
```bash
sudo apt install libasound2-dev alsa-utils
```
@@ -107,10 +111,14 @@ fi
### 自动登录
编辑 `/etc/systemd/system/getty.target.wants/getty@tty1.service` 文件,将 `ExecStart` 行修改为
使用 systemctl edit 修改 getty@tty1 服务
```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
```
其中: