From 7d3c9babf1aa738c743ebc7ff7cf7dd9a3feea26 Mon Sep 17 00:00:00 2001 From: haibaraguo Date: Fri, 10 Jun 2022 18:44:04 +0800 Subject: [PATCH 1/2] =?UTF-8?q?:sparkles:=20=E6=B7=BB=E5=8A=A0=E7=AE=80?= =?UTF-8?q?=E6=98=93midi=E5=88=B6=E4=BD=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 16 ++ go.mod | 3 + go.sum | 5 + main.go | 1 + plugin/midicreate/midicreate.go | 393 ++++++++++++++++++++++++++++++++ 5 files changed, 418 insertions(+) create mode 100644 plugin/midicreate/midicreate.go diff --git a/README.md b/README.md index b3c8f70292..fe6fc91363 100644 --- a/README.md +++ b/README.md @@ -1000,6 +1000,22 @@ print("run[CQ:image,file="+j["img"]+"]") - [x] 设置回复模式[青云客 | 小爱] + +
+ 简易midi音乐制作 + + `import _ "github.com/FloatTech/ZeroBot-Plugin/plugin/midicreate"` + + - [x] midi制作 CCGGAAGR FFEEDDCR GGFFEEDR GGFFEEDR CCGGAAGR FFEEDDCR + + - [x] 个人听音练习 + + - [x] 团队听音练习 + + - [x] 注: 该插件需要安装timidity,安装脚本可参考https://gitcode.net/anto_july/midi/-/raw/master/timidity.sh + + - [x] 符号说明: C5是中央C,后面不写数字,默认接5,Cb6<1,b代表降调,#代表升调,6比5高八度,<1代表音长×2,<2代表音长×4,<-1代表音长×0.5,<-2代表音长×0.25 +
TODO... diff --git a/go.mod b/go.mod index 2a705ed095..72f19a01f3 100644 --- a/go.mod +++ b/go.mod @@ -21,12 +21,15 @@ require ( github.com/jozsefsallai/gophersauce v1.0.1 github.com/lucas-clemente/quic-go v0.27.2 github.com/mroth/weightedrand v0.4.1 + github.com/pkg/errors v0.8.1 github.com/pkumza/numcn v1.0.0 github.com/shirou/gopsutil/v3 v3.22.3 github.com/sirupsen/logrus v1.8.1 github.com/tidwall/gjson v1.14.1 github.com/wcharczuk/go-chart/v2 v2.1.0 github.com/wdvxdr1123/ZeroBot v1.5.2-0.20220610070647-9eeffcb277ee + gitlab.com/gomidi/midi v1.23.7 + gitlab.com/gomidi/midi/v2 v2.0.17 golang.org/x/image v0.0.0-20220601225756-64ec528b34cd ) diff --git a/go.sum b/go.sum index 324c63423d..02bda3f23d 100644 --- a/go.sum +++ b/go.sum @@ -189,6 +189,7 @@ github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1y github.com/onsi/gomega v1.13.0 h1:7lLHu94wT9Ij0o6EWWclhu0aOh32VxhkwEJvzuWPeak= github.com/onsi/gomega v1.13.0/go.mod h1:lRk9szgn8TxENtWd0Tp4c3wjlRfMTMH27I+3Je41yGY= github.com/openzipkin/zipkin-go v0.1.1/go.mod h1:NtoC/o8u3JlF1lSlyPNswIbeQH9bJTmOf0Erfk+hxe8= +github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkumza/numcn v1.0.0 h1:ZT5cf9IJkUZgRgEtCiNNykk0RwsrKXSTsvDHOwUTzgE= github.com/pkumza/numcn v1.0.0/go.mod h1:QSeH+al9dWCd8di5HZM/ZqHqhZmUKfph572e9Ev/ETc= @@ -258,6 +259,10 @@ github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9dec github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= github.com/yusufpapurcu/wmi v1.2.2 h1:KBNDSne4vP5mbSWnJbO+51IMOXJB67QiYCSBrubbPRg= github.com/yusufpapurcu/wmi v1.2.2/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +gitlab.com/gomidi/midi v1.23.7 h1:I6qKoIk9s9dcX+pNf0jC+tziCzJFn82bMpuntRkLeik= +gitlab.com/gomidi/midi v1.23.7/go.mod h1:3ohtNOhqoSakkuLG/Li1OI6I3J1c2LErnJF5o/VBq1c= +gitlab.com/gomidi/midi/v2 v2.0.17 h1:kf16wNwFFOskl0trvarOwMuZUQICdIGn37LP9QqIRuo= +gitlab.com/gomidi/midi/v2 v2.0.17/go.mod h1:quTyMKSQ4Klevxu6gY4gy2USbeZra0fV5SalndmPfsY= go.opencensus.io v0.18.0/go.mod h1:vKdFvxhtzZ9onBp9VKHK8z/sRpBMnKAsufL7wlDrCOA= go4.org v0.0.0-20180809161055-417644f6feb5/go.mod h1:MkTOUMDaeVYJUOUsaDXIhWPZYa1yOyC1qaOBpL57BhE= golang.org/x/build v0.0.0-20190111050920-041ab4dc3f9d/go.mod h1:OWs+y06UdEOHN4y+MfF/py+xQ/tYqIWW03b70/CG9Rw= diff --git a/main.go b/main.go index 80b05d80e1..b7dffb4f0d 100644 --- a/main.go +++ b/main.go @@ -87,6 +87,7 @@ import ( _ "github.com/FloatTech/ZeroBot-Plugin/plugin/jandan" // 煎蛋网无聊图 _ "github.com/FloatTech/ZeroBot-Plugin/plugin/juejuezi" // 绝绝子生成器 _ "github.com/FloatTech/ZeroBot-Plugin/plugin/lolicon" // lolicon 随机图片 + _ "github.com/FloatTech/ZeroBot-Plugin/plugin/midicreate" // 简易midi音乐制作 _ "github.com/FloatTech/ZeroBot-Plugin/plugin/moyu" // 摸鱼 _ "github.com/FloatTech/ZeroBot-Plugin/plugin/moyu_calendar" // 摸鱼人日历 _ "github.com/FloatTech/ZeroBot-Plugin/plugin/music" // 点歌 diff --git a/plugin/midicreate/midicreate.go b/plugin/midicreate/midicreate.go new file mode 100644 index 0000000000..c16ff14dcc --- /dev/null +++ b/plugin/midicreate/midicreate.go @@ -0,0 +1,393 @@ +// Package midicreate 简易midi音乐制作 +package midicreate + +import ( + "bytes" + "fmt" + "math/rand" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "time" + + ctrl "github.com/FloatTech/zbpctrl" + "github.com/FloatTech/zbputils/control" + "github.com/FloatTech/zbputils/ctxext" + "github.com/FloatTech/zbputils/file" + "github.com/pkg/errors" + zero "github.com/wdvxdr1123/ZeroBot" + "github.com/wdvxdr1123/ZeroBot/message" + "gitlab.com/gomidi/midi/gm" + "gitlab.com/gomidi/midi/v2" + "gitlab.com/gomidi/midi/v2/smf" +) + +func init() { + engine := control.Register("midicreate", &ctrl.Options[*zero.Ctx]{ + DisableOnDefault: false, + Help: "midi音乐制作,该插件需要安装timidity,安装脚本可参考https://gitcode.net/anto_july/midi/-/raw/master/timidity.sh\n" + + "- midi制作 CCGGAAGR FFEEDDCR GGFFEEDR GGFFEEDR CCGGAAGR FFEEDDCR\n" + + "- 个人听音练习\n" + + "- 团队听音练习", + PrivateDataFolder: "midicreate", + }) + cachePath := engine.DataFolder() + "cache/" + _ = os.RemoveAll(cachePath) + err := os.MkdirAll(cachePath, 0755) + if err != nil { + panic(err) + } + engine.OnRegex(`^midi制作\s?(.{1,1000})$`).SetBlock(true).Limit(ctxext.LimitByUser). + Handle(func(ctx *zero.Ctx) { + uid := ctx.Event.UserID + input := ctx.State["regex_matched"].([]string)[1] + midiFile := cachePath + strconv.FormatInt(uid, 10) + time.Now().Format("20060102150405") + "_midicreate.mid" + cmidiFile, err := str2music(input, midiFile) + if err != nil { + if file.IsExist(midiFile) { + ctx.UploadThisGroupFile(file.BOTPATH+"/"+midiFile, filepath.Base(midiFile), "") + return + } + ctx.SendChain(message.Text("ERROR:无法转换midi文件,", err)) + return + } + ctx.SendChain(message.Record("file:///" + file.BOTPATH + "/" + cmidiFile)) + }) + engine.OnRegex("^(个人|团队)听音练习$", zero.OnlyGroup).SetBlock(true).Limit(ctxext.LimitByUser). + Handle(func(ctx *zero.Ctx) { + uid := ctx.Event.UserID + ctx.SendChain(message.Text("欢迎来到听音练习, 一共有5个问题, 每个问题1分")) + var mode int + var next *zero.FutureEvent + var maxErrorCount int + if ctx.State["regex_matched"].([]string)[1] == "个人" { + mode = 0 + next = zero.NewFutureEvent("message", 999, false, zero.RegexRule(`^[A-G][b|#]?\d{0,2}$`), + zero.OnlyGroup, ctx.CheckSession()) + maxErrorCount = 3 + } else { + mode = 1 + next = zero.NewFutureEvent("message", 999, false, zero.RegexRule(fmt.Sprintf(`^[A-G][b|#]?\d{0,2}$`)), + zero.OnlyGroup, zero.CheckGroup(ctx.Event.GroupID)) + maxErrorCount = 10 + } + recv, cancel := next.Repeat() + defer cancel() + + score := make(map[int64]float64) + round := 1 + maxRound := 6 + errorCount := 0 + target := uint8(55 + rand.Intn(34)) + answer := name(target) + strconv.Itoa(int(target/12)) + midiFile := cachePath + strconv.FormatInt(uid, 10) + time.Now().Format("20060102150405") + "_midicreate.mid" + cmidiFile, err := str2music(answer, midiFile) + if err != nil { + ctx.SendChain(message.Text("ERROR:听音练习结束, 无法转换midi文件, ", err)) + return + } + time.Sleep(time.Millisecond * 500) + ctx.SendChain(message.Record("file:///" + file.BOTPATH + "/" + cmidiFile)) + ctx.Send( + message.ReplyWithMessage(ctx.Event.MessageID, + message.Text("判断上面的音频, 输入音符, 例如C#6"), + ), + ) + tick := time.NewTimer(45 * time.Second) + after := time.NewTimer(60 * time.Second) + for { + select { + case <-tick.C: + ctx.SendChain(message.Text("听音练习, 你还有15s作答时间")) + case <-after.C: + var text string + for k, v := range score { + text += fmt.Sprintf("%s: %.1f\n", ctx.CardOrNickName(k), v) + } + ctx.Send( + message.ReplyWithMessage(ctx.Event.MessageID, + message.Text("听音练习超时, 练习结束...答案是: ", answer, "\n所得分数如下:\n", text), + ), + ) + return + case c := <-recv: + tick.Reset(45 * time.Second) + after.Reset(60 * time.Second) + n := processOne(c.Event.Message.String()) + if n != target { + errorCount++ + } + if errorCount == maxErrorCount || n == target { + if n == target { + ctx.Send( + message.ReplyWithMessage(c.Event.MessageID, + message.Text("恭喜你回答正确, 答案是: ", answer), + ), + ) + } else if errorCount == maxErrorCount { + ctx.Send( + message.ReplyWithMessage(c.Event.MessageID, + message.Text("你的回答是: "), + ), + ) + midiFile = cachePath + strconv.FormatInt(uid, 10) + time.Now().Format("20060102150405") + "_midicreate.mid" + cmidiFile, err = str2music(c.Event.Message.String(), midiFile) + if err != nil { + ctx.SendChain(message.Text("ERROR: can't convert midi file,", err)) + return + } + time.Sleep(time.Millisecond * 500) + ctx.SendChain(message.Record("file:///" + file.BOTPATH + "/" + cmidiFile)) + ctx.Send( + message.ReplyWithMessage(c.Event.MessageID, + message.Text("回答错误, 答案是: ", answer, ", 错误次数已达3次, 进入下一关"), + ), + ) + } + // 统计分数 + if mode == 0 { + switch errorCount { + case 0: + score[c.Event.UserID] += 1.0 + case 1: + score[c.Event.UserID] += 0.5 + case 2: + score[c.Event.UserID] += 0.2 + } + } else if mode == 1 { + if errorCount != maxErrorCount { + score[c.Event.UserID] += 1.0 + } + } + // 下一关 + round++ + if round != maxRound { + errorCount = 0 + target = uint8(55 + rand.Intn(34)) + answer = name(target) + strconv.Itoa(int(target/12)) + midiFile = cachePath + strconv.FormatInt(uid, 10) + time.Now().Format("20060102150405") + "_midicreate.mid" + cmidiFile, err = str2music(answer, midiFile) + if err != nil { + ctx.SendChain(message.Text("ERROR:听音练习结束, 无法转换midi文件, ", err)) + return + } + time.Sleep(time.Millisecond * 500) + ctx.SendChain(message.Record("file:///" + file.BOTPATH + "/" + cmidiFile)) + ctx.Send( + message.ReplyWithMessage(c.Event.MessageID, + message.Text("判断上面的音频, 输入音符, 例如C#6"), + ), + ) + } + } else if n != target { + ctx.Send( + message.ReplyWithMessage(c.Event.MessageID, + message.Text("你的回答是: "), + ), + ) + time.Sleep(time.Millisecond * 500) + midiFile = cachePath + strconv.FormatInt(uid, 10) + time.Now().Format("20060102150405") + "_midicreate.mid" + cmidiFile, err = str2music(c.Event.Message.String(), midiFile) + if err != nil { + ctx.SendChain(message.Text("ERROR: can't convert midi file,", err)) + return + } + time.Sleep(time.Millisecond * 500) + ctx.SendChain(message.Record("file:///" + file.BOTPATH + "/" + cmidiFile)) + ctx.Send( + message.ReplyWithMessage(c.Event.MessageID, + message.Text("回答错误, 错误次数为", errorCount, ", 请继续回答"), + ), + ) + } + if round == maxRound { + var text string + for k, v := range score { + text += fmt.Sprintf("%s: %.1f\n", ctx.CardOrNickName(k), v) + } + ctx.Send( + message.ReplyWithMessage(c.Event.MessageID, + message.Text("回答完毕, 所得分数如下:\n", text), + ), + ) + return + } + } + } + }) +} + +var ( + noteMap = map[string]uint8{ + "C": 60, + "Db": 61, + "D": 62, + "Eb": 63, + "E": 64, + "F": 65, + "Gb": 66, + "G": 67, + "Ab": 68, + "A": 69, + "Bb": 70, + "B": 71, + } +) + +func str2music(input, midiFile string) (cmidiFile string, err error) { + err = mkMidi(midiFile, input) + if err != nil { + return + } + cmidiFile = strings.ReplaceAll(midiFile, ".mid", ".wav") + cmd := exec.Command("timidity", file.BOTPATH+"/"+midiFile, "-Ow", "-o", file.BOTPATH+"/"+cmidiFile) + err = cmd.Run() + return +} + +func mkMidi(filePath, input string) error { + if file.IsExist(filePath) { + return nil + } + var ( + bf bytes.Buffer + clock = smf.MetricTicks(96) + tr smf.Track + ) + + tr.Add(0, smf.MetaMeter(4, 4)) + tr.Add(0, smf.MetaTempo(60)) + tr.Add(0, smf.MetaInstrument("Violin")) + tr.Add(0, midi.ProgramChange(0, gm.Instr_Violin.Value())) + + k := strings.ReplaceAll(input, " ", "") + + var ( + base uint8 + level uint8 + delay uint32 + sleepFlag bool + lengthBytes = make([]byte, 0) + ) + + for i := 0; i < len(k); { + base = 0 + level = 0 + sleepFlag = false + lengthBytes = lengthBytes[:0] + for { + switch { + case k[i] == 'R': + sleepFlag = true + i++ + case k[i] >= 'A' && k[i] <= 'G': + base = noteMap[k[i:i+1]] % 12 + i++ + case k[i] == 'b': + base-- + i++ + case k[i] == '#': + base++ + i++ + case k[i] >= '0' && k[i] <= '9': + level = level*10 + k[i] - '0' + i++ + case k[i] == '<': + i++ + for i < len(k) && (k[i] == '-' || (k[i] >= '0' && k[i] <= '9')) { + lengthBytes = append(lengthBytes, k[i]) + i++ + } + default: + return errors.Errorf("无法解析第%d个位置的%c字符", i, k[i]) + } + if i >= len(k) || (k[i] >= 'A' && k[i] <= 'G') || k[i] == 'R' { + break + } + } + length, _ := strconv.Atoi(string(lengthBytes)) + if sleepFlag { + if length >= 0 { + delay = clock.Ticks4th() * (1 << length) + } else { + delay = clock.Ticks4th() / (1 << -length) + } + continue + } + if level == 0 { + level = 5 + } + tr.Add(delay, midi.NoteOn(0, o(base, level), 120)) + if length >= 0 { + tr.Add(clock.Ticks4th()*(1< 10 { + oct = 10 + } + + if oct == 0 { + return base + } + + res := base + 12*oct + if res > 127 { + res -= 12 + } + + return res +} + +func name(n uint8) string { + for k, v := range noteMap { + if v%12 == n%12 { + return k + } + } + return "" +} + +func processOne(note string) uint8 { + k := strings.ReplaceAll(note, " ", "") + var ( + base uint8 + level uint8 + ) + for i := 0; i < len(k); i++ { + switch { + case k[i] >= 'A' && k[i] <= 'G': + base = noteMap[k[i:i+1]] % 12 + case k[i] == 'b': + base-- + case k[i] == '#': + base++ + case k[i] >= '0' && k[i] <= '9': + level = level*10 + k[i] - '0' + } + } + if level == 0 { + level = 5 + } + return o(base, level) +} From 73c278746105e1ed63c161d12e1ec1b0d032a68b Mon Sep 17 00:00:00 2001 From: haibaraguo Date: Fri, 10 Jun 2022 18:53:29 +0800 Subject: [PATCH 2/2] =?UTF-8?q?:bug:=20=E4=BF=AElint?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- plugin/midicreate/midicreate.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugin/midicreate/midicreate.go b/plugin/midicreate/midicreate.go index c16ff14dcc..e948e2abe1 100644 --- a/plugin/midicreate/midicreate.go +++ b/plugin/midicreate/midicreate.go @@ -69,7 +69,7 @@ func init() { maxErrorCount = 3 } else { mode = 1 - next = zero.NewFutureEvent("message", 999, false, zero.RegexRule(fmt.Sprintf(`^[A-G][b|#]?\d{0,2}$`)), + next = zero.NewFutureEvent("message", 999, false, zero.RegexRule(`^[A-G][b|#]?\d{0,2}$`), zero.OnlyGroup, zero.CheckGroup(ctx.Event.GroupID)) maxErrorCount = 10 }