JS脚本:YuanQinAssistant (#2347)

* Add score for '银月之庭(蓝花)C大调'

* Create tutorial file(empty now)

Add placeholder for video tutorial link

* Delete repo/js/YuanQinAssistant/assets/toos directory

* Create tutorial placeholder file

Add placeholder for tutorial video link

* 添加转谱工具

* 发布 原琴助手 v1.0.0

* Fixed some incorrect descriptions
This commit is contained in:
himno
2025-11-15 01:22:57 +08:00
committed by GitHub
parent d2070ed287
commit 58d543fb8d
7 changed files with 479 additions and 0 deletions

View File

@@ -0,0 +1,55 @@
# YuanQinAssistant
原琴助手BGI版是用于游戏中自动弹奏的脚本使用gs2格式的曲谱并提供方便的midi转谱工具。
## 内容清单
- [快速开始](#快速开始)
- [曲谱格式说明](#曲谱格式说明)
- [MIDI转谱](#midi转谱)
## 快速开始
1. 在“全自动-调度器”中新建配置组,并添加此脚本。
2. 右键-修改JS脚本自定义配置选择曲目后点击运行即可在游戏中自动演奏请先确保角色处于演奏状态。
3. 曲谱文件保存在 `assets/score/` 文件夹中,如果此文件夹中的内容发生变动,脚本会自动更新曲谱列表并退出,再次运行后方可选择曲谱进行演奏。
## 曲谱格式说明
脚本使用指令式的文本曲谱,每一行都代表一个指令,以“!xx/something”的形式出现。不符合此格式的行会被忽略因此可以利用此特性在曲谱中插入注释文本。
1. 文件头
```text
!v/2
```
- 曲谱第一行固定为此内容。
2. 说明性指令
```text
!ti/标题
!au/发布人
!cp/作曲人
!ar/制谙人
```
- 说明曲谱文件的版权信息,非必须。这些指令不存在时会使用缺省值。
3. 演奏参数指令
```text
!gi/乐器编号
!di/四分音符的tick数
```
- 乐器编号用于说明曲谱适用的乐器从0开始非必须缺省值为0表示风物之诗琴。此指令在脚本中并无实际作用是为手机端适配不同乐器而预留的。
- di指令用于指定MIDI规范中的division值非必需缺省值为480。改变此值相当于改变BPM但需要和轨道事件中的变速事件结合才能最终确定每个音符的时长。
4. 轨道指令
```text
!tr/事件序列
```
- 可以同时存在多个轨道指令,本脚本通过多个轨道事件的同步,来实现和弦功能。
- 事件序列由一串形如 **1000n60** 的文本组成,并以竖线 **`|`** 分隔。其中1000表示此事件之前经过的时间以tick数计算具体可查询MIDI文件规范此处不作讲解。n是事件类型60是此事件的参数。
- 目前支持三种事件,分别为:
- n 按下音符参数表示音符的音高60即为C4
- u 松开音符,参数含义同 n
- s 变速参数表示接下来的microtempo值单位是微秒
## MIDI转谱
您完全可以通过文本编辑器来手动编辑一个gs2格式的曲谱文件但并不建议这么做。本质上gs2格式是因为BGI不支持二进制文件的读取而不得不做的一个折中方案。使用现有的专业工具制作出mid格式文件再使用我提供的转谱工具生成对应的gs2文件工具放置于 `assets/tool/` 文件夹下),是更直观且更安全的做法。
您需要注意的是为了简化处理逻辑转谱工具对mid文件的内容作了一些限制比如不能使用异步多轨的格式必须是C大调导出midi文件时不要包含RPN和NRPN相关指令等等更详细的会在之后用视频教程进行演示普通用户完全不了解这些内容也不影响使用。

View File

@@ -0,0 +1,9 @@
!v/2
!gi/0
!ti/银月之庭蓝花C大调
!au/天空光芒
!cp/HoyoMix
!ar/Gideon Chamberlain
!di/480
!tr/0s731707|0n60|43n64|44n71|151u64|0u71|1u60|1n57|239u57|1n59|239u59|1n60|239u60|1n67|959u67|1n60|43n64|44n71|151u64|0u71|1u60|1n57|239u57|1n59|239u59|1n60|239u60|1n67|239u67|1n59|719u59|1n60|43n64|44n71|151u64|0u71|1u60|1n57|239u57|1n59|239u59|1n60|239u60|1n67|959u67|1n60|43n64|44n71|151u64|0u71|1u60|1n57|239u57|1n59|239u59|1n60|119u60|1n60|119u60|1n62|959u62|1n76|479u76|1n81|479u81|1n81|719u81|1n79|239u79|1n81|479u81|1n79|239u79|1n78|239u78|1n74|959u74|1n76|479u76|1n72|479u72|1n74|239u74|1n76|239u76|1n79|479u79|1n76|179u76|1n77|59u77|1n76|119u76|1n74|119u74|1n76|959u76|481n76|479u76|1n81|479u81|1n81|239u81|481n79|239u79|1n81|479u81|1n79|239u79|1n78|239u78|1n74|959u74|1n76|479u76|1n72|479u72|1n74|239u74|1n72|239u72|1n71|239u71|1n67|239u67|1n72|119u72|1n71|119u71|1n69|479u69|241n72|239u72|241n74|239u74|241n76|359u76|1n76|39u76|1n77|39u77|1n76|39u76|1n74|239u74|1n72|239u72|1n74|359u74|1n74|39u74|1n76|39u76|1n74|39u74|1n72|239u72|1n71|239u71|1n72|239u72|1n71|239u71|1n69|479u69|1n72|239u72|1n71|239u71|1n72|239u72|1n74|239u74|1n76|359u76|1n76|39u76|1n77|39u77|1n76|39u76|1n74|239u74|1n72|239u72|1n74|239u74|241n72|239u72|1n71|239u71|1n76|959u76|1n72|479u72|1n74|479u74|1n76|359u76|1n76|39u76|1n77|39u77|1n76|39u76|1n74|239u74|1n72|239u72|1n74|239u74|241n72|239u72|1n71|239u71|1n72|239u72|1n71|239u71|1n69|479u69|1n69|239u69|1n71|239u71|1n72|239u72|1n71|239u71|1n72|119u72|1n71|119u71|1n69|479u69|1n76|239u76|1n74|239u74|1n72|239u72|1n71|239u71|1n67|239u67|1n72|119u72|1n71|119u71|1n69|959u69|1681n64|0n69|959u64|0u69|1n64|0n76|959u64|0u76|1n76|0n88|719u76|0u88|1n74|0n86|119u74|0u86|1n72|0n84|119u72|0u84|1n71|0n83|959u71|0u83|1n67|0n79|479u67|0u79|1n69|0n81|479u69|0u81|1n76|479u76|1n81|479u81|1n81|719u81|1n79|239u79|1n81|479u81|1n79|239u79|1n78|239u78|1n74|959u74|1n76|479u76|1n72|479u72|1n74|239u74|1n76|239u76|1n79|479u79|1n76|179u76|1n77|59u77|1n76|119u76|1n74|119u74|1n76|959u76|481n76|479u76|1n81|479u81|1n81|239u81|481n79|239u79|1n81|479u81|1n79|239u79|1n78|239u78|1n74|959u74|1n76|479u76|1n72|479u72|1n74|239u74|1n72|239u72|1n71|239u71|1n67|239u67|1n72|119u72|1n71|119u71|1n69|479u69|241n72|239u72|241n74|239u74|241n76|359u76|1n76|39u76|1n77|39u77|1n76|39u76|1n74|239u74|1n72|239u72|1n74|359u74|1n74|39u74|1n76|39u76|1n74|39u74|1n72|239u72|1n71|239u71|1n72|239u72|1n71|239u71|1n69|479u69|1n72|239u72|1n71|239u71|1n72|239u72|1n74|239u74|1n76|359u76|1n76|39u76|1n77|39u77|1n76|39u76|1n74|239u74|1n72|239u72|1n74|239u74|241n72|239u72|1n71|239u71|1n76|959u76|1n72|479u72|1n74|479u74|1n76|359u76|1n76|39u76|1n77|39u77|1n76|39u76|1n74|239u74|1n72|239u72|1n74|239u74|241n72|239u72|1n71|239u71|1n72|239u72|1n71|239u71|1n69|479u69|1n69|239u69|1n71|239u71|1n72|239u72|1n71|239u71|1s731707|0n72|119u72|1n71|119u71|1s755311|0n69|240s780488|239u69|1s807401|0n76|239u76|1s836237|0n74|239u74|1s867208|0n72|239u72|1s900563|0n71|239u71|1s936585|0n67|239u67|1s975610|0n69|1919u69|961n69|239u69|1n71|239u71|1n72|239u72|1n71|239u71|1n72|119u72|1n71|119u71|1n69|479u69|1n76|239u76|1n74|239u74|1n72|239u72|1n71|239u71|1n67|239u67|1n69|1920s1463414|1919s731707|0u69
!tr/0n52|0n57|239u52|0u57|1681n52|0n57|239u52|0u57|1681n52|0n57|239u52|0u57|1681n57|239u57|1681n57|42n64|1876u64|1u57|1n55|42n64|1876u64|1u55|1n60|42n64|1876u64|1u60|1n57|42n64|1876u64|1u57|1n57|43n64|915u64|1u57|1n55|43n62|915u62|1u55|1n53|43n60|915u60|1u53|1n52|43n60|915u60|1u52|1n57|43n64|915u64|1u57|1n55|43n62|915u62|1u55|1n57|43n60|195u60|1u57|1n52|239u52|1n57|239u57|1n59|239u59|1n60|479u60|1n59|479u59|1n57|43n64|915u64|1u57|1n55|43n62|915u62|1u55|1n57|43n60|195u60|1u57|1n52|239u52|1n57|239u57|1n59|239u59|1n60|479u60|1n59|479u59|1n57|43n64|915u64|1u57|1n55|43n62|915u62|1u55|1n57|43n60|675u60|1u57|1n52|239u52|1n57|0n60|479u57|0u60|1n55|0n62|239u55|0u62|1n55|239u55|1n57|43n64|915u64|1u57|1n55|43n62|435u62|1u55|1n55|479u55|1n57|43n60|195u60|1u57|1n52|239u52|1n57|239u57|1n59|239u59|1n60|959u60|1n55|43n64|195u64|1u55|1n52|239u52|1n57|239u57|1n59|239u59|1n55|43n62|915u62|1u55|1n57|43n60|195u60|1u57|1n52|239u52|1n57|239u57|1n59|239u59|1n64|239u64|1n66|239u66|1n67|479u67|1n45|239u45|1n52|239u52|1n57|239u57|1n59|1199u59|1n45|239u45|1n52|239u52|1n57|239u57|1n59|239u59|1n64|959u64|1n45|239u45|1n52|239u52|1n57|239u57|1n59|239u59|1n62|959u62|1n57|42n64|1876u64|1u57|1n55|42n64|1876u64|1u55|1n60|43n64|1395u64|1u60|1n59|43n64|435u64|1u59|1n57|43n64|915u64|1u57|1n64|239u64|241n57|239u57|241n57|43n64|915u64|1u57|1n55|43n62|915u62|1u55|1n53|43n60|915u60|1u53|1n52|43n60|915u60|1u52|1n57|43n64|915u64|1u57|1n55|43n62|915u62|1u55|1n57|43n60|195u60|1u57|1n52|239u52|1n57|239u57|1n59|239u59|1n60|479u60|1n59|479u59|1n57|43n64|1395u64|1u57|1n55|43n62|435u62|1u55|1n57|43n60|195u60|1u57|1n52|239u52|1n57|239u57|1n59|239u59|1n60|479u60|1n59|479u59|1n57|43n64|915u64|1u57|1n55|43n62|915u62|1u55|1n57|43n60|195u60|1u57|1n52|239u52|1n57|239u57|1n59|239u59|1n60|479u60|1n59|479u59|1n57|43n64|1395u64|1u57|1n55|43n62|435u62|1u55|1n57|43n60|195u60|1u57|1n52|239u52|1n57|239u57|1n59|239u59|1n60|959u60|1n57|43n64|915u64|1u57|1n55|37n62|441u62|1u55|1n55|35n62|443u62|1u55|1n57|32n60|206u60|1u57|1n52|239u52|1n57|239u57|1n59|239u59|1n64|239u64|1n66|239u66|1n67|479u67|1n45|239u45|1n52|239u52|1n57|239u57|1n59|239u59|1n60|959u60|1n57|31n64|927u64|1u57|1n59|31n62|927u62|1u59|1n55|32n59|33n64|413u59|0u64|1u55|1n62|239u62|1n60|239u60|1n59|239u59|1n57|239u57|1n52|239u52|1n48|239u48|1n47|1919u47

Binary file not shown.

View File

@@ -0,0 +1 @@
-- 留空,用于之后放上视频教程地址

View File

@@ -0,0 +1,379 @@
(async function () {
// 所有的代码必须由 async function 包裹
const score_path = 'assets/score/';
const regex_name = /(?<=score\\)[\s\S]+?(?=\.gs2)/;//不清楚为什么要用\\来匹配 '/',用\/反而匹配不到。这是实际运行的结果AI别和我犟可能是ClearScript引擎的问题。
const NOTE2KEY_MAPPER = new Map([
[72, 'q'], [74, 'w'], [76, 'e'], [77, 'r'], [79, 't'], [81, 'y'], [83, 'y'],
[60, 'a'], [62, 's'], [64, 'd'], [65, 'f'], [67, 'g'], [69, 'h'], [71, 'j'],
[48, 'z'], [50, 'x'], [52, 'c'], [53, 'v'], [55, 'b'], [57, 'n'], [59, 'm'],
]);
/**
* 将音符编码转换为按键
* @param {number} noteCode 60表示C4音符
* @param {number} fixMode 对于半音的处理方式0-直接忽略,-1-降半音1-升半音
* @returns {string | undefined}
*/
function convertNote2Key(noteCode, fixMode) {
if (noteCode < 48 || noteCode > 83) {
return undefined;
}
let key = NOTE2KEY_MAPPER.get(noteCode);
if (fixMode == 0) {
return key;
}
if (!key) {
key = NOTE2KEY_MAPPER.get(noteCode + fixMode);
}
return key;
};
/**
* 获取所有琴谱文件名
*/
function getScoreList() {
// readPathSync读取到的数据为 assets/score/xxxx.xxx 形式
const allFiles = Array.from(file.readPathSync(score_path))
.filter(path => !file.isFolder(path) && path.endsWith('.gs2'));
return allFiles.map(path => {
const match = path.match(regex_name);
if (!match) {
return null;
}
return match[0];
})
.filter(name => !!name);
};
/**
* 补全完整路径(相对路径)
* @param {string} filename
* @returns
*/
function buildFullpath(filename) {
return score_path + filename + '.gs2';
};
/**
* 轨道事件
* @typedef {Object} TrackEvent
* @property {number} dt delta time
* @property {string} command 指令n-按下音符u-松开音符s-改变速度MicroTempo
* @property {number} value 指令的值对与n和u表示音符的编码对于s表示一个四分音符对应的微秒数MicroTempo
*/
/**
* 曲谱信息
* @typedef {Object} ScoreInfo
* @property {number} instrument 乐器编号
* @property {number} division 每个四分音符的tick数
* @property {string} title 标题
* @property {string} author 曲谱文件的作者
* @property {string} composer 作曲者
* @property {string} arranger 制谱者(通常指扒谱的人)
* @property {Array<TrackEvent>[]} tracks 音符轨道
*/
/**
* 从指定文件中加载琴谱
* @param {string} filename 琴谱文件名
* @returns {ScoreInfo} 返回一个ScoreInfo对象
*/
function loadScoreInfo(filename) {
const filepath = buildFullpath(filename);
try {
/** @type {string} */
const content = file.readTextSync(filepath);
const lines = content.split(/\r?\n/);
if (lines[0] !== '!v/2') {
log.error('错误的文件头!');
return null;
}
/** @type {ScoreInfo} */
const info = {
instrument: 0,
division: 480,
title: filename,
author: '未知',
arranger: '未知',
composer: '未知',
tracks: [],
};
const lineReg = /^!(\w+?)\/(.+)$/;
lines.forEach(line => {
const match = line.match(lineReg);
if (match) {
switch(match[1]) {
case 'gi': // 乐器编号
info.instrument = parseInt(match[2]);
break;
case 'di': // division
info.division = parseInt(match[2]);
break;
case 'ti': // title
info.title = match[2];
break;
case 'au': // author 曲谱发布人
info.author = match[2];
break;
case 'cp': // composer 作曲人
info.composer = match[2];
break;
case 'ar': // arranger 制谱人
info.arranger = match[2];
break;
case 'tr': // 事件轨道
info.tracks.push(parseTrackEvents(match[2]));
break;
}
}
});
return info;
} catch (error) {
log.error(`加载曲谱文件 ${filename} 时发生错误`, error);
return null;
}
};
/**
* 从文本中解析出轨道事件
* @param {string} text 事件的字符串
* @returns {TrackEvent[]}
*/
function parseTrackEvents(text) {
const regex = /^(\d+)([nus])(\d+)$/;
return text.split('|')
.map(t => {
const match = t.match(regex);
if (!match) {
return null;
}
const dt = parseInt(match[1]);
if (isNaN(dt) || dt < 0 || dt > Number.MAX_SAFE_INTEGER) {
return null;
}
return {
dt,
command: match[2],
value: parseInt(match[3]),
};
})
.filter(v => !!v);
};
/**
* 检查本地的文件列表和设置中的选项是否一致
* @returns {boolean}
*/
function checkScoreSheet() {
try {
// 获取本地琴谱文件名列表
const localMusicList = getScoreList();
// 读取配置文件
/** @type {Array} */
const settingsList = JSON.parse(file.readTextSync('settings.json'));
/** @type {string[]} */
let configMusicList = undefined; // 配置文件中的gs2文件名列表
let selectorIndex = -1; // 音乐选择器在配置列表中的序号
for(let i = 0; i < settingsList.length; i++) {
if(settingsList[i].name === 'music_selector') {
selectorIndex = i;
configMusicList = settingsList[i].options;
break;
}
}
// 核对两个列表是否相同
let totallySame = true;
if (localMusicList.length !== configMusicList.length) {
totallySame = false;
}
else {
for (let i = 0; i < localMusicList.length; i++) {
if (localMusicList[i] !== configMusicList[i]) {
totallySame = false;
break;
}
}
}
// 如果不相同,则以本地列表为准,更新到配置文件中去
if (!totallySame) {
const updatedSettings = [...settingsList];
updatedSettings[selectorIndex].options = localMusicList;
file.writeTextSync("settings.json", JSON.stringify(updatedSettings, null, 4));
log.warn("检测到曲谱文件不一致, 已自动修改settings(以本地曲谱文件为基准)...");
log.warn("JS脚本配置已更新, 请重新运行脚本!");
return false;
}
return true;
} catch (error) {
log.error('检查曲谱文件时发生错误:', error);
return false;
}
};
//---------------------------------------------------------
/** 事件源 */
const eventSource = {
indices: [], // 各轨道当前索引
deltas: [], // 各轨道剩余delta time
division: 480, // 一个四分音符的tick数
microtempo: 50_0000, // 一个四分音符的微秒数
/**
* 初始化事件源
* @param {ScoreInfo} scoreInfo
*/
init(scoreInfo) {
this.division = scoreInfo.division;
this.microtempo = 50_0000;
this.indices = new Array(scoreInfo.tracks.length).fill(0);
this.deltas = scoreInfo.tracks.map(l => l[0].dt);
},
/**
* 开始依次将轨道事件发射到接收器
* @param {*} receiver 接收器
* @param {ScoreInfo} scoreInfo
*/
async produce(receiver, scoreInfo) {
while(true) {
// 选择delta time最小的轨道
let targetTrackIndex = -1;
let minDelta = Number.MAX_SAFE_INTEGER;
for (let i = 0; i < this.deltas.length; i++) {
if (this.indices[i] >= scoreInfo.tracks[i].length) {
continue;
}
const dt = this.deltas[i];
if (dt < minDelta) {
targetTrackIndex = i;
minDelta = dt;
}
}
// 所有轨道都处理完
if (targetTrackIndex < 0) {
break;
}
// 选定事件
let targetEvent = scoreInfo.tracks[targetTrackIndex][this.indices[targetTrackIndex]];
// 将选定轨道的索引往后移动一位并更新其对应的delta time
this.indices[targetTrackIndex] += 1;
if (this.indices[targetTrackIndex] < scoreInfo.tracks[targetTrackIndex].length) {
this.deltas[targetTrackIndex] = scoreInfo.tracks[targetTrackIndex][this.indices[targetTrackIndex]].dt;
}
else {
this.deltas[targetTrackIndex] = Number.MAX_SAFE_INTEGER;
}
// 其他轨道对应的delta time相应减少
for (let i = 0; i < this.deltas.length; i++) {
const dt = this.deltas[i];
if (i !== targetTrackIndex) {
this.deltas[i] = Math.max(dt - minDelta, 0);
}
}
// 等待
if (minDelta > 0) {
let waitMilliseconds = ((this.microtempo * minDelta / this.division) / 1000) | 0;
await sleep(waitMilliseconds);
}
// 发送事件到接收器
receiver.receive(targetEvent, this);
}
},
};
/**
* 事件接收器
*/
const eventReceiver = {
keyStates: new Map(),
fixMode: 0,
init(fixMode) {
this.fixMode = fixMode;
this.keyStates.clear();
},
/**
*
* @param {TrackEvent} event
* @param {*} source
*/
receive(event, source) {
// log.info(event.command + ':' + event.value);
let k;
switch (event.command) {
case 's': // 改变microtempo
source.microtempo = event.value;
break;
case 'n': // 按下音符
k = convertNote2Key(event.value, this.fixMode);
if (k && (!this.keyStates.get(k))) {
this.keyStates.set(k, true);
keyDown(k);
}
break;
case 'u': // 松开音符
k = convertNote2Key(event.value, this.fixMode);
if (k && this.keyStates.get(k)) {
this.keyStates.set(k, false);
keyUp(k);
}
break;
default:
break;
}
},
};
function getFixMode() {
const modeDesc = settings.fix_mode;
if (modeDesc == '降半音') {
return -1;
}
else if (modeDesc == '升半音') {
return 1;
}
else {
return 0;
}
};
function getInstrumentName(instrumentCode) {
const list = ["风物之诗琴", "老旧的诗琴", "镜花之琴", "盛世豪鼓", "绮庭之鼓", "晚风圆号", "余音", "悠可琴", "跃律琴"];
return list[instrumentCode] || "未知乐器";
}
//----------------------------------------------------------
async function main() {
if (!checkScoreSheet()) return;
const scoreFilename = settings.music_selector;
if (!scoreFilename) {
log.warn('未选择曲谱请在js配置中选择后再次运行脚本');
return;
}
const scoreInfo = loadScoreInfo(scoreFilename);
const instrumentName = getInstrumentName(scoreInfo.instrument);
log.info('当前演奏:' + scoreInfo.title);
log.info(`作曲人:${scoreInfo.composer},制谱人:${scoreInfo.arranger}`);
log.info(`推荐乐器:${instrumentName},发布人:${scoreInfo.author}`);
eventSource.init(scoreInfo);
eventReceiver.init(getFixMode());
// 开始演奏
await eventSource.produce(eventReceiver, scoreInfo);
};
await main();
})();

View File

@@ -0,0 +1,15 @@
{
"manifest_version": 1,
"name": "原琴助手",
"version": "1.0.0",
"bgi_version": "0.43.1",
"description": "BetterGI版本的原琴助手使用gs2格式的琴谱",
"authors": [
{
"name": "himno",
"link": "https://github.com/himno"
}
],
"settings_ui": "settings.json",
"main": "main.js"
}

View File

@@ -0,0 +1,20 @@
[
{
"name": "music_selector",
"type": "select",
"label": "选择一首曲子",
"options": [
"银月之庭蓝花C大调"
]
},
{
"name": "fix_mode",
"type": "select",
"label": "半音修正",
"options": [
"忽略",
"降半音",
"升半音"
]
}
]