-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
6 changed files
with
261 additions
and
18 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -2,21 +2,38 @@ | |
"name": "koishi-plugin-maimai-guess-chart", | ||
"description": "适用于Koishi的舞萌听key音猜谱面插件", | ||
"version": "0.0.1", | ||
"contributors": [ | ||
"TTsdzb <[email protected]>" | ||
], | ||
"homepage": "https://github.com/TTsdzb/koishi-plugin-maimai-guess-chart", | ||
"repository": { | ||
"type": "git", | ||
"url": "git+https://github.com/TTsdzb/koishi-plugin-maimai-guess-chart.git" | ||
}, | ||
"main": "lib/index.js", | ||
"typings": "lib/index.d.ts", | ||
"files": [ | ||
"lib", | ||
"dist" | ||
], | ||
"license": "MIT", | ||
"scripts": {}, | ||
"keywords": [ | ||
"chatbot", | ||
"koishi", | ||
"plugin" | ||
], | ||
"devDependencies": {}, | ||
"peerDependencies": { | ||
"koishi": "^4.17.8" | ||
}, | ||
"koishi": { | ||
"description": { | ||
"zh": "听key音猜舞萌谱面难度!请务必查看文档!" | ||
} | ||
}, | ||
"dependencies": { | ||
"fluent-ffmpeg": "^2.1.3" | ||
}, | ||
"devDependencies": { | ||
"@types/fluent-ffmpeg": "^2" | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,44 @@ | ||
import Ffmpeg from "fluent-ffmpeg"; | ||
|
||
/** | ||
* Probe a media file and get its metadata. | ||
* @param path Path of the input media file | ||
* @returns Metadata of the media | ||
*/ | ||
export function metadata(path: string): Promise<Ffmpeg.FfprobeData> { | ||
return new Promise<Ffmpeg.FfprobeData>((resolve, reject) => { | ||
Ffmpeg(path).ffprobe((err, data) => { | ||
if (err) reject(err); | ||
resolve(data); | ||
}); | ||
}); | ||
} | ||
|
||
/** | ||
* Clip an audio file and return base64 encoded buffer. | ||
* @param path Path of input audio file | ||
* @param startTime Length to seek in the input | ||
* @param duration Duration of clipped audio | ||
* @returns Base64 encoded buffer of audio stream | ||
*/ | ||
export function clipAudio( | ||
path: string, | ||
startTime: number, | ||
duration: number = 10 | ||
): Promise<string> { | ||
return new Promise<string>((resolve, reject) => { | ||
const chunks: Buffer[] = []; | ||
Ffmpeg(path) | ||
.seekInput(startTime) | ||
.audioCodec("copy") | ||
.duration(duration) | ||
.outputFormat("mp3") | ||
.on("error", (err) => reject(err)) | ||
.pipe(undefined, { end: true }) | ||
.on("data", (chunk) => chunks.push(chunk)) | ||
.on("error", (err) => reject(err)) | ||
.on("end", () => { | ||
resolve(Buffer.concat(chunks).toString("base64")); | ||
}); | ||
}); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,11 +1,136 @@ | ||
import { Context, Schema } from 'koishi' | ||
import * as path from "path"; | ||
import * as fs from "fs/promises"; | ||
import { Context, Logger, Schema, Session, h } from "koishi"; | ||
import { clipAudio, metadata } from "./ffmpeg"; | ||
|
||
export const name = 'maimai-guess-chart' | ||
export const name = "maimai-guess-chart"; | ||
|
||
export interface Config {} | ||
export interface Config { | ||
audioPath: string; | ||
answers: string[]; | ||
duration: number; | ||
timeout: number; | ||
} | ||
|
||
export const Config: Schema<Config> = Schema.object({ | ||
audioPath: Schema.path({ | ||
filters: ["directory"], | ||
}).required(), | ||
answers: Schema.tuple([ | ||
Schema.string(), | ||
Schema.string(), | ||
Schema.string(), | ||
Schema.string(), | ||
Schema.string(), | ||
]).default(["绿谱", "黄谱", "红谱", "紫谱", "白谱"]), | ||
duration: Schema.number().min(1).default(10), | ||
timeout: Schema.number().min(1).default(40), | ||
}).i18n({ | ||
"zh-CN": require("./locales/zh-CN")._config, | ||
}); | ||
|
||
interface Song { | ||
id: number; | ||
title: string; | ||
count: number; | ||
} | ||
|
||
interface GameSession { | ||
song: Song; | ||
chart: number; | ||
timeout: NodeJS.Timeout; | ||
} | ||
|
||
export function apply(ctx: Context, config: Config) { | ||
ctx.i18n.define("zh-CN", require("./locales/zh-CN")); | ||
const logger = new Logger("maimai-guess-chart"); | ||
|
||
const reverseAnswers = {}; | ||
config.answers.forEach((value, index) => { | ||
reverseAnswers[value] = index + 1; | ||
}); | ||
|
||
const songs: Song[] = []; | ||
|
||
ctx.on("ready", async () => { | ||
// Load audio data | ||
for (const folder of await fs.readdir(config.audioPath)) { | ||
const index = folder.indexOf("_"); | ||
songs.push({ | ||
id: parseInt(folder.substring(0, index)), | ||
title: folder.substring(index + 1), | ||
count: (await fs.readdir(path.join(config.audioPath, folder))).length, | ||
}); | ||
} | ||
}); | ||
|
||
const gameSessions: Record<string, GameSession> = {}; | ||
|
||
ctx.command("maiGuessChart [id:posint]").action(async ({ session }, id) => { | ||
const gameSessionId = session.guildId ? session.gid : session.uid; | ||
if (gameSessions[gameSessionId]) return session.text(".alreadyStarted"); | ||
|
||
const song = id | ||
? songs.find((song) => song.id === id) | ||
: songs[Math.floor(Math.random() * songs.length)]; | ||
if (!song) return session.text(".songNotFound", [id]); | ||
logger.debug("Selected song: ", song); | ||
|
||
const chart = Math.floor(Math.random() * song.count); | ||
logger.debug("Selected chart: ", chart); | ||
const audioPath = path.join( | ||
config.audioPath, | ||
`${song.id}_${song.title}`, | ||
`${chart + 1}.mp3` | ||
); | ||
|
||
gameSessions[gameSessionId] = { | ||
song, | ||
chart, | ||
timeout: setTimeout(async () => { | ||
delete gameSessions[gameSessionId]; | ||
await session.send( | ||
session.text("commands.maiguesschart.messages.timeout", [ | ||
song.title, | ||
config.answers[chart], | ||
]) | ||
); | ||
}, config.timeout * 1000), | ||
}; | ||
|
||
try { | ||
const audioLength = (await metadata(audioPath)).format.duration; | ||
const seekTime = Math.random() * (audioLength - config.duration); | ||
|
||
await session.send(session.text(".nowPlaying", song)); | ||
return h.audio( | ||
"data:audio/mpeg;base64," + | ||
(await clipAudio(audioPath, seekTime, config.duration)) | ||
); | ||
} catch (e) { | ||
logger.error(e); | ||
clearTimeout(gameSessions[gameSessionId].timeout); | ||
delete gameSessions[gameSessionId]; | ||
return session.text(".errorOccurred"); | ||
} | ||
}); | ||
|
||
ctx.middleware((session, next) => { | ||
const gameSessionId = session.guildId ? session.gid : session.uid; | ||
const gameSession = gameSessions[gameSessionId]; | ||
if (!gameSession) return next(); | ||
|
||
export const Config: Schema<Config> = Schema.object({}) | ||
if (session.event.message?.content !== config.answers[gameSession.chart]) | ||
return next(); | ||
|
||
export function apply(ctx: Context) { | ||
// write your plugin here | ||
clearTimeout(gameSessions[gameSessionId].timeout); | ||
delete gameSessions[gameSessionId]; | ||
return ( | ||
h("quote", { id: session.event.message?.id }) + | ||
session.text("commands.maiguesschart.messages.youWin", [ | ||
gameSession.song.title, | ||
config.answers[gameSession.chart], | ||
]) | ||
); | ||
}); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,24 @@ | ||
commands: | ||
maiguesschart: | ||
description: 听 key 音猜给定歌曲的舞萌谱面难度。 | ||
usage: |- | ||
可以通过参数指定一个 ID(ID 可通过其他 Bot 或插件查询),也可以不指定,随机选一首曲子。 | ||
将会随机截取某一个谱面的一部分 key 音,你需要猜出被截取的是这首歌的绿黄红紫白哪个谱面! | ||
因为是随机截取,所以截到休息段也不一定哦!不要掉以轻心! | ||
messages: | ||
alreadyStarted: 已经有正在进行的猜谱面啦! | ||
songNotFound: 没有找到 ID 为 {0} 的歌曲。 | ||
nowPlaying: |- | ||
<p>当前正在播放</p> | ||
<p>ID: {id}</p> | ||
<p>标题: {title}</p> | ||
<p>请猜出它是哪个谱面!</p> | ||
timeout: 答案是《{0}》的“{1}”!没有人猜对,好可惜! | ||
youWin: 猜对啦!这是《{0}》的“{1}”!恭喜你! | ||
errorOccurred: 处理和发送音频时发生错误,请联系管理员处理。 | ||
|
||
_config: | ||
audioPath: 音频资源路径,请参考文档。 | ||
answers: 五种谱面分别的称呼。 | ||
duration: 发送的谱面音频的时长。 | ||
timeout: 作答的最大时长,超过该时长没人答对则直接结束。 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters