Skip to content

Commit

Permalink
feat: initial commit
Browse files Browse the repository at this point in the history
  • Loading branch information
TTsdzb committed Jul 4, 2024
1 parent 7e52da6 commit 2c6ba2f
Show file tree
Hide file tree
Showing 6 changed files with 261 additions and 18 deletions.
21 changes: 19 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
39 changes: 38 additions & 1 deletion readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,41 @@

[![npm](https://img.shields.io/npm/v/koishi-plugin-maimai-guess-chart?style=flat-square)](https://www.npmjs.com/package/koishi-plugin-maimai-guess-chart)

适用于Koishi的舞萌听key音猜谱面插件
适用于 Koishi 的舞萌听 key 音猜谱面插件。

## 前置条件

1. 安装 ffmpeg 和 ffprobe,并将其路径添加到 `PATH` 环境变量中
2. 下载音频资源并解压,得到一个包含许多子文件夹的文件夹

## 使用方法

1. 在 Koishi 中安装该插件
2.`audioPath` 设为解压音频资源得到的文件夹路径
3. 保存配置,启用插件

## 自定义资源

插件完全从资源读取歌曲,因此可以通过修改资源的方式自定义歌曲。以下是资源文件夹的结构示例:

```
audioPath
├── 10070_シンクルヘル_DX
│   ├── 1.mp3
│   ├── 2.mp3
│   ├── 3.mp3
│   └── 4.mp3
├── 100_TELLYOURWORLD
│   ├── 1.mp3
│   ├── 2.mp3
│   ├── 3.mp3
│   ├── 4.mp3
│   └── 5.mp3
├── ...
```

`audioPath` 路径下的每个子文件夹代表一首歌曲。其中文件夹名的前半部分为一整数,表示歌曲 ID,后半部分为歌曲标题。ID 与标题由且仅由文件名中的第一个 `_` 字符分割。

每首歌曲包含以数字 1~5 命名的 mp3 文件,分别为歌曲的绿、黄、红、紫、白谱的完整 key 音。若没有白谱,可以忽略。

插件只会在加载时扫描一遍歌曲,因此修改后请务必重载插件。为了避免找不到文件等问题,如果需要移除歌曲,请尽量先禁用插件或提前通知用户(群友)再操作。
44 changes: 44 additions & 0 deletions src/ffmpeg.ts
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"));
});
});
}
137 changes: 131 additions & 6 deletions src/index.ts
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],
])
);
});
}
24 changes: 24 additions & 0 deletions src/locales/zh-CN.yml
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: 作答的最大时长,超过该时长没人答对则直接结束。
14 changes: 5 additions & 9 deletions tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,17 @@
"rootDir": "src",
"outDir": "lib",
"target": "es2022",
"module": "commonjs",
"module": "esnext",
"declaration": true,
"emitDeclarationOnly": true,
"composite": true,
"incremental": true,
"skipLibCheck": true,
"esModuleInterop": true,
"moduleResolution": "node",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"jsxImportSource": "@satorijs/element",
"types": [
"node",
"yml-register/types"
]
"types": ["node", "yml-register/types"]
},
"include": [
"src"
]
"include": ["src"]
}

0 comments on commit 2c6ba2f

Please sign in to comment.