From 8e2bf7f59b3c5a633b8a90d9f5d2061723ee059f Mon Sep 17 00:00:00 2001 From: mapleafgo Date: Tue, 5 Nov 2024 18:39:03 +0800 Subject: [PATCH] =?UTF-8?q?=E5=BE=85=E6=9C=BA=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/common/link_audio.go | 54 ++++++++++++++ internal/common/stopper.go | 3 + internal/middleware/bgm.go | 54 ++------------ internal/middleware/json.go | 6 +- internal/middleware/stop.go | 6 +- internal/middleware/ticker.go | 61 ++++++++++++++++ internal/middleware/timer.go | 45 ++---------- internal/routes/background.go | 93 ++++++++++++++++++++++++ internal/routes/command.go | 2 + internal/schema/bg.go | 29 ++++++++ internal/schema/common.go | 4 + internal/schema/play.go | 1 + internal/server.go | 18 +++-- leaf/context.go | 7 ++ readme.md | 133 +++++++++++++++++++++++++--------- 15 files changed, 384 insertions(+), 132 deletions(-) create mode 100644 internal/common/link_audio.go create mode 100644 internal/middleware/ticker.go create mode 100644 internal/routes/background.go create mode 100644 internal/schema/bg.go create mode 100644 internal/schema/common.go diff --git a/internal/common/link_audio.go b/internal/common/link_audio.go new file mode 100644 index 0000000..8af793d --- /dev/null +++ b/internal/common/link_audio.go @@ -0,0 +1,54 @@ +package common + +import ( + "bytes" + "io" + "log" + "net/http" + "net/url" + "os" + "strings" +) + +func open(u string) io.ReadCloser { + p, _ := strings.CutPrefix(u, "file://") + f, e := os.Open(p) + if e != nil { + log.Printf("音频文件 [%v] 打开错误: %v\n", u, e) + return nil + } + return f +} + +func get(u string) io.ReadCloser { + resp, e := http.Get(u) + if e != nil { + log.Printf("音频文件 [%v] 下载失败: %v\n", u, e) + return nil + } + return resp.Body +} + +func toBuffer(b io.ReadCloser) io.ReadCloser { + data := &bytes.Buffer{} + _, _ = data.ReadFrom(b) + defer b.Close() + return io.NopCloser(data) +} + +func LinkAudio(link string) (bgm io.ReadCloser) { + u, err := url.Parse(link) + if err != nil { + log.Println("音频 URL 解析错误: ", err) + } else { + if u.Scheme == "file" { + bgm = open(u.String()) + } else if u.Scheme == "http" || u.Scheme == "https" { + bgm = get(u.String()) + } else { + log.Printf("不支持的音频文件协议: %v\n", u.String()) + } + bgm = toBuffer(bgm) + } + return +} diff --git a/internal/common/stopper.go b/internal/common/stopper.go index 5fde2a7..909fe61 100644 --- a/internal/common/stopper.go +++ b/internal/common/stopper.go @@ -56,3 +56,6 @@ func (g *simpleStopper) Done() <-chan struct{} { // GlobalStopper 全局停止器 var GlobalStopper Stopper = &simpleStopper{} + +// GlobalBgStopper 全局后台停止器 +var GlobalBgStopper Stopper = &simpleStopper{} diff --git a/internal/middleware/bgm.go b/internal/middleware/bgm.go index cab233d..ee95c01 100644 --- a/internal/middleware/bgm.go +++ b/internal/middleware/bgm.go @@ -1,63 +1,19 @@ package middleware import ( - "bytes" + "game-driver/internal/common" "game-driver/internal/schema" "game-driver/leaf" "game-driver/pkg/audio" - "io" - "log" - "net/http" - "net/url" - "os" - "strings" ) -func open(u string) io.ReadCloser { - p, _ := strings.CutPrefix(u, "file://") - f, e := os.Open(p) - if e != nil { - log.Printf("BGM 文件 [%v] 打开错误: %v\n", u, e) - return nil - } - return f -} - -func get(u string) io.ReadCloser { - resp, e := http.Get(u) - if e != nil { - log.Printf("BGM 文件 [%v] 下载失败: %v\n", u, e) - return nil - } - return resp.Body -} - -func toBuffer(b io.ReadCloser) io.ReadCloser { - data := &bytes.Buffer{} - _, _ = data.ReadFrom(b) - defer b.Close() - return io.NopCloser(data) -} - func PlayBgm() leaf.HandlerFunc { return func(c *leaf.Context) { pm := leaf.Value[*schema.PlayModal](c, PayloadJSONKey) - u, err := url.Parse(pm.BGM) - if err != nil { - log.Println("BGM URL 解析错误: ", err) - } else { - var bgm io.ReadCloser - if u.Scheme == "file" { - bgm = open(u.String()) - } else if u.Scheme == "http" || u.Scheme == "https" { - bgm = get(u.String()) - } else { - log.Printf("不支持的BGM文件协议: %v\n", u.String()) - } - // 获取到了资源进行播放 - if bgm != nil { - go audio.PlayMP3(c, toBuffer(bgm)) - } + + bgm := common.LinkAudio(pm.BGM) + if bgm != nil { + go audio.PlayMP3(c, bgm) } c.Next() diff --git a/internal/middleware/json.go b/internal/middleware/json.go index ac040c6..dd01485 100644 --- a/internal/middleware/json.go +++ b/internal/middleware/json.go @@ -12,14 +12,14 @@ type JSONKey string const PayloadJSONKey JSONKey = "payload_json" // PayloadJSON 解析报文 -func PayloadJSON() leaf.HandlerFunc { +func PayloadJSON[T schema.JsonModel]() leaf.HandlerFunc { return func(c *leaf.Context) { - pm := &schema.PlayModal{} + pm := new(T) err := json.Unmarshal(c.Payload, pm) if err != nil { log.Panicf("报文解析错误: %v\n", err) } - leaf.WithValue[*schema.PlayModal](c, PayloadJSONKey, pm) + leaf.WithValue[*T](c, PayloadJSONKey, pm) c.Next() } } diff --git a/internal/middleware/stop.go b/internal/middleware/stop.go index 40ac2e0..84f37bb 100644 --- a/internal/middleware/stop.go +++ b/internal/middleware/stop.go @@ -6,10 +6,10 @@ import ( ) // EmergencyStop 紧急停止中间件 -func EmergencyStop() leaf.HandlerFunc { +func EmergencyStop(stopper common.Stopper) leaf.HandlerFunc { return func(c *leaf.Context) { cancel := leaf.WithCancel(c) - defer common.GlobalStopper.Reset() + defer stopper.Reset() // 结束信号通道 a := make(chan struct{}) @@ -19,7 +19,7 @@ func EmergencyStop() leaf.HandlerFunc { go func() { select { case <-a: - case <-common.GlobalStopper.Done(): + case <-stopper.Done(): { cancel() leaf.WithValue[leaf.EndType](c, leaf.EndKey, leaf.EndStop) diff --git a/internal/middleware/ticker.go b/internal/middleware/ticker.go new file mode 100644 index 0000000..bfcecb6 --- /dev/null +++ b/internal/middleware/ticker.go @@ -0,0 +1,61 @@ +package middleware + +import ( + "game-driver/internal/schema" + "game-driver/leaf" + "game-driver/pkg/tts" + "sync" + "time" +) + +func TickerAction(t *tts.AliTTS) leaf.HandlerFunc { + return func(c *leaf.Context) { + pm := leaf.Value[*schema.PlayModal](c, PayloadJSONKey) + + // 构建打印和语音播报的时间映射 + printMap := make(map[int]schema.PrintModal, len(pm.Print)) + for _, p := range pm.Print { + printMap[p.Time] = p + } + ttsMap := make(map[int]schema.TTSTimer, len(pm.TTS.Timer)) + for _, t := range pm.TTS.Timer { + ttsMap[t.Time] = t + } + + // 等待组 + var wait sync.WaitGroup + defer wait.Wait() + + // 结束信号通道 + a := make(chan struct{}) + // 发送结束信号 + defer close(a) + + go func() { + start := time.Now() + // 等待结束 + wait.Add(1) + defer wait.Done() + // 定时器 + ticker := time.NewTicker(time.Second) + defer ticker.Stop() + for over := false; !over; { + select { + case <-a: + over = true + case m := <-ticker.C: + { + s := int(m.Sub(start).Seconds()) + if _, ok := printMap[s]; ok { + //TODO: 屏幕打印 + } + if to, ok := ttsMap[s]; ok { + t.Sound(to.Value) + } + } + } + } + }() + c.Next() + } +} diff --git a/internal/middleware/timer.go b/internal/middleware/timer.go index bc0d0e0..341ef42 100644 --- a/internal/middleware/timer.go +++ b/internal/middleware/timer.go @@ -3,26 +3,15 @@ package middleware import ( "game-driver/internal/schema" "game-driver/leaf" - "game-driver/pkg/tts" "sync" "time" ) -// TimerAction 定时器中间件,用于定时触发屏幕打印和语音播报。 t 是语音播报实例 -func TimerAction(t *tts.AliTTS, maxTimeout int) leaf.HandlerFunc { +// TimeoutOver 定时器中间件,用于定时触发屏幕打印和语音播报。 t 是语音播报实例 +func TimeoutOver(maxTimeout int) leaf.HandlerFunc { return func(c *leaf.Context) { pm := leaf.Value[*schema.PlayModal](c, PayloadJSONKey) - // 构建打印和语音播报的时间映射 - printMap := make(map[int]schema.PrintModal, len(pm.Print)) - for _, p := range pm.Print { - printMap[p.Time] = p - } - ttsMap := make(map[int]schema.TTSTimer, len(pm.TTS.Timer)) - for _, t := range pm.TTS.Timer { - ttsMap[t.Time] = t - } - // 定时器 var timer *time.Timer if pm.Timeout != 0 { @@ -43,34 +32,16 @@ func TimerAction(t *tts.AliTTS, maxTimeout int) leaf.HandlerFunc { cancel := leaf.WithCancel(c) go func() { - start := time.Now() // 等待结束 wait.Add(1) defer wait.Done() - // 定时器 - ticker := time.NewTicker(time.Second) - defer ticker.Stop() // 结束标志 - for over := false; !over; { - select { - case <-a: - over = true - case <-timer.C: // 定时器结束 - { - cancel() - over = true - leaf.WithValue[leaf.EndType](c, leaf.EndKey, leaf.EndTimer) - } - case m := <-ticker.C: - { - s := int(m.Sub(start).Seconds()) - if _, ok := printMap[s]; ok { - //TODO: 屏幕打印 - } - if to, ok := ttsMap[s]; ok { - t.Sound(to.Value) - } - } + select { + case <-a: + case <-timer.C: // 定时器结束 + { + cancel() + leaf.WithValue[leaf.EndType](c, leaf.EndKey, leaf.EndTimer) } } }() diff --git a/internal/routes/background.go b/internal/routes/background.go new file mode 100644 index 0000000..063df56 --- /dev/null +++ b/internal/routes/background.go @@ -0,0 +1,93 @@ +package routes + +import ( + "game-driver/internal/common" + "game-driver/internal/middleware" + "game-driver/internal/schema" + "game-driver/leaf" + "game-driver/pkg/audio" + "github.com/gopxl/beep/v2" + "github.com/gopxl/beep/v2/mp3" + "github.com/gopxl/beep/v2/speaker" + "log" + "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 BackgroundAction(c *leaf.Context) { + payload := leaf.Value[*schema.BackgroundModel](c, middleware.PayloadJSONKey) + + if payload.Start != 0 && payload.End != 0 && time.Unix(payload.Start, 0).After(time.Unix(payload.End, 0)) { + log.Println("开始时间大于结束时间") + return + } + + if payload.End != 0 { + cancel := leaf.WithDeadline(c, time.Unix(payload.End, 0)) + defer cancel() + } + + select { + case <-c.Done(): + case <-timerAction(payload.Start): + go audioAction(c, payload.Items[0], payload.TimeModel) + } +} + +func audioAction(c *leaf.Context, item schema.BackgroundItemModel, root schema.TimeModel) { + if item.Start != 0 && time.Unix(item.Start, 0).Before(time.Unix(root.Start, 0)) { + log.Println("开始时间小于根任务开始时间") + return + } + + select { + case <-c.Done(): + case <-timerAction(item.Start): + { + log.Println("开始执行后台任务") + data := common.LinkAudio(item.Data) + + streamer, format, err := mp3.Decode(data) + if err != nil { + return + } + defer streamer.Close() + + s := beep.Resample(4, format.SampleRate, audio.DefaultSampleRate, streamer) + + 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() + } + } + } + + } + } +} diff --git a/internal/routes/command.go b/internal/routes/command.go index 652babf..b93e4cd 100644 --- a/internal/routes/command.go +++ b/internal/routes/command.go @@ -12,6 +12,8 @@ func Command(d *common.Device) leaf.HandlerFunc { switch cmd { case "stop": common.GlobalStopper.Stop() + case "stop-bg": + common.GlobalBgStopper.Stop() case "status": d.PublishStatus() default: diff --git a/internal/schema/bg.go b/internal/schema/bg.go new file mode 100644 index 0000000..ca56664 --- /dev/null +++ b/internal/schema/bg.go @@ -0,0 +1,29 @@ +package schema + +type ItemType int + +const ( + TYPE_AUDIO ItemType = iota + TYPE_VIDEO + TYPE_WEB + TYPE_TTS + TYPE_RELAY +) + +type TimeModel struct { + Start int64 + End int64 +} + +type BackgroundItemModel struct { + TimeModel + Interval int64 + Type ItemType + Data string +} + +type BackgroundModel struct { + JsonModel + TimeModel + Items []BackgroundItemModel +} diff --git a/internal/schema/common.go b/internal/schema/common.go new file mode 100644 index 0000000..181330b --- /dev/null +++ b/internal/schema/common.go @@ -0,0 +1,4 @@ +package schema + +type JsonModel interface { +} diff --git a/internal/schema/play.go b/internal/schema/play.go index 1462385..592bb3e 100644 --- a/internal/schema/play.go +++ b/internal/schema/play.go @@ -19,6 +19,7 @@ type PrintModal struct { } type PlayModal struct { + JsonModel Timeout int `json:"timeout"` Power bool `json:"power"` BGM string `json:"bgm"` diff --git a/internal/server.go b/internal/server.go index 45edb00..1fcdc29 100644 --- a/internal/server.go +++ b/internal/server.go @@ -7,6 +7,7 @@ import ( "game-driver/internal/common" "game-driver/internal/middleware" "game-driver/internal/routes" + "game-driver/internal/schema" "game-driver/leaf" "game-driver/pkg/tts" "github.com/eclipse/paho.golang/autopaho" @@ -113,23 +114,28 @@ func Run() { // 处理启动报文 router.RegisterHandler(topicPrefix+"play", - middleware.PayloadJSON(), + middleware.PayloadJSON[schema.PlayModal](), middleware.DeviceLock(device), - middleware.EmergencyStop(), + middleware.EmergencyStop(common.GlobalStopper), middleware.SoundStart(t), middleware.RelayMaster(nil), - middleware.TimerAction(t, config.C.Game.MaxTimeout), + middleware.TimeoutOver(config.C.Game.MaxTimeout), + middleware.TickerAction(t), middleware.PlayBgm(), func(c *leaf.Context) { log.Println("接收到启动消息: ", string(c.Payload)) select { case <-c.Done(): - log.Println("程序已关闭") - case <-time.After(10 * time.Second): - log.Println("10s 结束") + log.Println("执行结束") } }, ) + // 处理后台线程报文 + router.RegisterHandler(topicPrefix+"bg", + middleware.PayloadJSON[schema.BackgroundModel](), + middleware.EmergencyStop(common.GlobalBgStopper), + routes.BackgroundAction, + ) // 处理指令 router.RegisterHandler(topicPrefix+"command", routes.Command(device)) diff --git a/leaf/context.go b/leaf/context.go index 0d2e728..8967dd8 100644 --- a/leaf/context.go +++ b/leaf/context.go @@ -4,6 +4,7 @@ import ( "context" "github.com/eclipse/paho.golang/paho" "math" + "time" ) // abortIndex represents a typical value used in abort functions. @@ -132,3 +133,9 @@ func WithCancel(ctx *Context) context.CancelFunc { ctx.Context = c return cancel } + +func WithDeadline(ctx *Context, t time.Time) context.CancelFunc { + c, cancel := context.WithDeadline(ctx.Context, t) + ctx.Context = c + return cancel +} diff --git a/readme.md b/readme.md index a2509c3..cabe838 100644 --- a/readme.md +++ b/readme.md @@ -8,44 +8,64 @@ Topic: `server/${location}/${point}/play` Payload: -```json +```json lines { - "timeout": 30, // 设备超时时长(s) - "power": true, // 是否需要整体电源控制 - "bgm": "", // 游戏中背景音乐(file://本地文件地址、http://远程文件地址) - "volume": 0.5, // 整体设备音量(0-1) - "default-print": "", // 屏幕默认打印(设备待机时展示文本) - "tts": { // 文本转语音整体控制 - "start": "", // 开始语音 - "end": "", // 结束语音 - "stop": "", // 终止语音 - "timer": [ // 固定节点语音 - { - "time": 10, // 时间节点(s) - "value": "" // 语音文字 - } - ] + // 设备超时时长(s) + "timeout": 30, + // 是否需要整体电源控制 + "power": true, + // 游戏中背景音乐(支持 file:// 本地文件地址、 http(s):// 远程文件地址) + "bgm": "", + // 整体设备音量(0-1) + "volume": 0.5, + // 屏幕默认打印(设备待机时展示文本) + "default-print": "", + // 文本转语音整体控制 + "tts": { + // 开始语音 + "start": "", + // 结束语音 + "end": "", + // 终止语音 + "stop": "", + // 固定节点语音 + "timer": [ + { + // 时间节点(s) + "time": 10, + // 语音文字 + "value": "" + }, + ... + ] + }, + // 屏幕打印控制 + "print": [ + { + // 时间节点(s) + "time": 10, + // 展示文字 + "text": "", + // 持续时长(s) + "duration": 10 }, - "print": [ // 屏幕打印控制 - { - "time": 10, // 时间节点(s) - "text": "", // 展示文字 - "duration": 10 // 持续时长(s) - } - ], - "game": {} // 根据具体游戏特定 + ... + ], + // 根据具体游戏特定 + "game": {} } ``` [游戏节点报文](./game.md) #### 例: 入口欢迎播报 -```json + +```json lines { - "volume": 1, - "tts": { - "start": "欢迎前来挑战!" - } + "volume": 1, + "tts": { + "start": "欢迎前来挑战!" + } } ``` @@ -57,10 +77,12 @@ Topic: `device/${location}/${point}/status` Payload: -```bash -0 # 0 待机; 1 使用中; -1 状态异常 +```text +0 ``` +> 0 待机; 1 使用中; -1 状态异常 + ## 3. 接收指令 ### 终止 @@ -69,18 +91,61 @@ Topic: `server/${location}/${point}/command` Payload: -```bash +```text stop ``` +### 终止后台 + +Topic: `server/${location}/${point}/command` + +Payload: + +```text +stop-bg +``` + ### 查询状态 Topic: `server/${location}/${point}/command` Payload: -```bash +```text status ``` -设备接收到该指令会立即向 `device/${location}/${point}/status` 发送一次当前状态 +> 设备接收到该指令会立即向 `device/${location}/${point}/status` 发送一次当前状态 + +## 4. 后台执行 + +Topic: `server/${location}/${point}/bg` + +Payload: + +```json lines +{ + // 开始时间戳(s), default 0, 0表示立即执行 + "start": 1730793361, + // 结束时间戳(s), default 0, 0表示无限执行 + "end": 1730793368, + // 执行项 + "items": [ + { + // 开始时间戳(s), 默认根的时间戳, 只有在根执行时间内才会执行 + "start": 1730793361, + // 结束时间戳(s), 默认根的时间戳, 只有在根执行时间内才会执行 + "end": 1730793368, + // 间隔时间(s), default 0 + "interval": 0, + // 事件类型(0: 音频; 1: 视频; 2: 网页; 3: TTS; 4: 继电器), default 0 + "type": 3, + // 事件数据(TTS为文字, 继电器为端口号, 其他都为地址链接。支持 file:// 本地文件地址、 http(s):// 远程文件地址) + "data": "", + }, + ... + ] +} +``` + +> 同一个类型的后台任务只能有一个,当有新的任务到达时会覆盖之前的任务