JS脚本:自动原琴(五线谱版)【更新】 (#1838)

* Delete repo/js/AutoYuanQin directory

* 更新了MIDI直接转换为JSON曲谱(HTML+JS纯代码实现)
This commit is contained in:
提瓦特钓鱼玳师
2025-09-08 19:43:38 +08:00
committed by GitHub
parent 8b67ff5868
commit 18a98b61a5
8 changed files with 7176 additions and 145 deletions

View File

@@ -1,6 +1,6 @@
# 曲谱 JSON 文件说明
* **注意**
- 制谱优先使用AutoYuanQin\assets\tutorial_file目录下的制谱软件(index.html),有任何疑问请来看这个使用说明
- 制谱优先使用AutoYuanQin\assets\tutorial_file目录下的制谱软件(五线谱制谱器.html),有任何疑问请来看这个使用说明
此文档供曲谱制作人阅读,本文档详细说明了一个标准格式的曲谱.json文件格式包括各个字段的解释以及曲谱内容的格式要求。
重要即使制作了曲谱的JSON文件放到了正确的路径下在调度器的JS脚本配置里也不会出现你制作的曲谱上传方法如下
@@ -12,8 +12,14 @@
3.发送邮件到hijiwos@hotmail.com并说明你的谱子将会在一段时间内更新到仓库
## MIDI翻谱器使用方法
**MIDI翻谱器: AutoYuanQin\assets\tutorial_file\MIDI翻谱器.html(请确保 五线谱注解.png与制谱器位于同一目录下)**
**声明本制谱器生成的曲谱文件为标准格式区别于五线谱制谱器生成的是MIDI版本的JSON标准格式**
使用浏览器打开```MIDI翻谱器.html```即可,注意**千万不要手动修改生成的JSON文件中的author**
## 曲谱制作器使用方法
**制谱器路径: AutoYuanQin\assets\tutorial_file\index.html(请确保 五线谱注解.png与制谱器位于同一目录下)**
**制谱器路径: AutoYuanQin\assets\tutorial_file\五线谱制谱器.html(请确保 五线谱注解.png与制谱器位于同一目录下)**
**声明:本制谱器生成的曲谱文件为标准格式**
* 使用步骤如下(顺序)

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,250 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>MIDI 解析器纯JS版</title>
<style>
body { font-family: sans-serif; padding: 20px; }
pre { background: #f4f4f4; padding: 10px; border-radius: 5px; white-space: pre-wrap; word-break: break-word; }
button { margin-left: 10px; }
</style>
</head>
<body>
<h1>MIDI 解析器纯JS版</h1>
<input type="file" id="midiFile" accept=".mid">
<button id="exportBtn" disabled>导出 JSON</button>
<pre id="output"></pre>
<script>
// ===== JS 版 best_three_octave_natural_dict =====
function bestThreeOctaveNaturalDict(midiList) {
const noteNames = ["C","C#","D","D#","E","F","F#","G","G#","A","A#","B"];
const naturalMap = {
"C":"C","C#":"C",
"D":"D","D#":"D",
"E":"E",
"F":"F","F#":"F",
"G":"G","G#":"G",
"A":"A","A#":"A",
"B":"B"
};
const naturalOrder = ["C","D","E","F","G","A","B"];
const labels = "ZXCVBNMASDFGHJQWERTYU".split("");
const midiInts = Array.from(new Set(midiList.map(n=>parseInt(n)))).sort((a,b)=>a-b);
if (!midiInts.length) return {};
const midiToNatOct = m => {
const nName = noteNames[m % 12];
const naturalName = naturalMap[nName];
const octave = Math.floor(m / 12) - 1;
return [naturalName, octave];
};
const inputNotes = midiInts.map(midiToNatOct);
const minOct = Math.min(...inputNotes.map(n=>n[1]));
const maxOct = Math.max(...inputNotes.map(n=>n[1]));
let bestStart = null, bestCover = -1, bestCenterDiff = Infinity;
for (let startOct = minOct - 3; startOct <= maxOct; startOct++) {
for (let startNat of naturalOrder) {
let seq = [];
let noteIdx = naturalOrder.indexOf(startNat);
let octv = startOct;
for (let i=0; i<21; i++) {
seq.push([naturalOrder[noteIdx], octv]);
noteIdx++;
if (noteIdx >= naturalOrder.length) { noteIdx = 0; octv++; }
}
const coverCount = inputNotes.filter(n => seq.some(s => s[0]===n[0] && s[1]===n[1])).length;
const seqMidOct = seq[Math.floor(seq.length/2)][1];
const inputMidOct = inputNotes.sort((a,b)=>a[1]-b[1])[Math.floor(inputNotes.length/2)][1];
const centerDiff = Math.abs(seqMidOct - inputMidOct);
if (coverCount > bestCover || (coverCount === bestCover && centerDiff < bestCenterDiff)) {
bestCover = coverCount;
bestStart = seq[0];
bestCenterDiff = centerDiff;
}
}
}
const mappingDict = {};
let [startNat, startOct] = bestStart;
let noteIdx = naturalOrder.indexOf(startNat);
let octv = startOct;
let labelIdx = 0;
for (let i=0; i<21; i++) {
const natName = naturalOrder[noteIdx];
const midiBase = noteNames.indexOf(natName) + (octv + 1) * 12;
for (let m = midiBase; m < midiBase + 2; m++) {
if (noteNames[m % 12] === natName || naturalMap[noteNames[m % 12]] === natName) {
mappingDict[m.toString()] = labels[labelIdx];
}
}
noteIdx++;
labelIdx++;
if (noteIdx >= naturalOrder.length) { noteIdx = 0; octv++; }
}
return mappingDict;
}
// ===== MIDI 解析函数 =====
function parseMidi(arrayBuffer) {
const data = new DataView(arrayBuffer);
let pos = 0;
function readStr(len) {
let s = "";
for (let i=0; i<len; i++) s += String.fromCharCode(data.getUint8(pos++));
return s;
}
function readUint32() {
const v = data.getUint32(pos);
pos += 4;
return v;
}
function readUint16() {
const v = data.getUint16(pos);
pos += 2;
return v;
}
function readVarLen() {
let value = 0;
while (true) {
let b = data.getUint8(pos++);
if (b & 0x80) {
value += (b & 0x7F);
value <<= 7;
} else {
value += b;
break;
}
}
return value;
}
// Header
const headerChunkType = readStr(4);
const headerLength = readUint32();
const formatType = readUint16();
const numTracks = readUint16();
const division = readUint16();
pos = 8 + headerLength; // 跳到第一个轨道块
let allNotes = [];
let notesList = [];
let tempo = 500000; // 默认 120bpm
const ticksPerBeat = division;
for (let t=0; t<numTracks; t++) {
const trackType = readStr(4);
const trackLength = readUint32();
const trackEnd = pos + trackLength;
let lastStatus = null;
while (pos < trackEnd) {
const deltaTime = readVarLen();
let statusByte = data.getUint8(pos++);
if (statusByte < 0x80) {
pos--;
statusByte = lastStatus;
} else {
lastStatus = statusByte;
}
if (statusByte === 0xFF) {
const metaType = data.getUint8(pos++);
const len = readVarLen();
if (metaType === 0x51) {
tempo = (data.getUint8(pos)<<16) | (data.getUint8(pos+1)<<8) | data.getUint8(pos+2);
}
pos += len;
} else if ((statusByte & 0xF0) === 0x90 || (statusByte & 0xF0) === 0x80) {
const note = data.getUint8(pos++);
const velocity = data.getUint8(pos++);
allNotes.push(note.toString());
notesList.push({
type: ((statusByte & 0xF0) === 0x90 && velocity > 0) ? "on" : "off",
note: note,
time: deltaTime
});
} else {
let paramLen = 2;
if ((statusByte & 0xF0) === 0xC0 || (statusByte & 0xF0) === 0xD0) paramLen = 1;
pos += paramLen;
}
}
}
const mappingDict = bestThreeOctaveNaturalDict(allNotes);
const msPerTick = tempo / ticksPerBeat / 1000.0;
const mappedNotes = notesList.map(n => ({
type: n.type,
note: mappingDict[n.note.toString()] || "K",
time: +(n.time * msPerTick).toFixed(3)
}));
return {
name: "示例曲谱",
author: "MidiTrans",
bpm: Math.round(60000000 / tempo).toString(),
description: "曲谱信息",
time_signature: "4/4",
composer: "曲师",
arranger: "谱师",
notes: mappedNotes
};
}
// ===== 全局变量保存解析结果和文件名 =====
let lastScoreJson = null;
let lastMidiFileName = "";
// ===== 文件选择事件 =====
document.getElementById('midiFile').addEventListener('change', function(e) {
const file = e.target.files[0];
if (!file) return;
lastMidiFileName = file.name; // 保存原始文件名
const reader = new FileReader();
reader.onload = function(evt) {
try {
const arrayBuffer = evt.target.result;
lastScoreJson = parseMidi(arrayBuffer);
document.getElementById('output').textContent = JSON.stringify(lastScoreJson, null, 2);
// 解析成功后启用导出按钮
document.getElementById('exportBtn').disabled = false;
} catch (err) {
document.getElementById('output').textContent = "解析出错: " + err.message;
lastScoreJson = null;
document.getElementById('exportBtn').disabled = true;
}
};
reader.readAsArrayBuffer(file);
});
// ===== 导出 JSON 按钮事件 =====
document.getElementById('exportBtn').addEventListener('click', function() {
if (!lastScoreJson) return;
const blob = new Blob([JSON.stringify(lastScoreJson, null, 2)], {type: "application/json"});
const url = URL.createObjectURL(blob);
// 用原 MIDI 文件名生成 JSON 文件名
let jsonFileName = lastMidiFileName.replace(/\.[^/.]+$/, "") + ".json";
const a = document.createElement('a');
a.href = url;
a.download = jsonFileName;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
});
</script>
</body>
</html>

View File

@@ -9,7 +9,9 @@
"5.Flower Dance",
"6.起风了",
"7.千本樱 (Eric Chen)",
"8.春よ、来い(春天,来吧)"
"8.春よ、来い(春天,来吧)",
"9.One Last Kiss",
"10.卡农(MIDI转谱)"
]
const base_path = "assets/score_file/"
@@ -225,7 +227,11 @@
// 谱师
music_msg_dic["arranger"] = file_text.match(regex_arranger)[0];
// 曲谱内容(删除换行符)
music_msg_dic["notes"] = file_text.match(regex_notes)[0].replace(regex_blank, '');
if (music_msg_dic["author"] !== "MidiTrans") {
music_msg_dic["notes"] = file_text.match(regex_notes)[0].replace(regex_blank, '');
} else {
music_msg_dic["notes"] = JSON.parse(file_text)["notes"];
}
} catch(error) {
log.info(`曲谱解析错误:${error}\n请检查曲谱文件格式是否正确`);
return null;
@@ -246,69 +252,74 @@
* 附:
* 中括号(-前表示音符类型-后用于区分特殊音符):[填4表示4分音符填16表示16分音符...-填#表示装饰音填3表示三连音] 例:[16-#]
*
* @param sheet {string} 乐谱
* @param sheet {string} 乐谱 [DEBUG]更新midi后这里也会是一个字典
* @returns {Object[][]}
*/
function parseMusicSheet(sheet) {
// 将输入字符串按照小节分割
let bars = sheet.split('|');
let result = [];
// 遍历每个小节
bars.forEach(bar => {
let i = 0;
if (typeof(sheet) === "object") {
result = sheet;
} else {
// 将输入字符串按照小节分割
let bars = sheet.split('|');
// 逐个字符解析小节中的音符及其属性
while (i < bar.length) {
let note = ''; // 存储音符
let type = ''; // 存储音符类型
let chord = false; // 判断是否为和弦
let spl = 'none'; // 存储特殊音符属性,默认值为 "none"
// 遍历每个小节
bars.forEach(bar => {
let i = 0;
// 检查是否为和弦(和弦用圆括号包裹)
if (bar[i] === '(') {
chord = true;
i++;
while (bar[i] !== ')') {
note += bar[i];
// 逐个字符解析小节中的音符及其属性
while (i < bar.length) {
let note = ''; // 存储音符
let type = ''; // 存储音符类型
let chord = false; // 判断是否为和弦
let spl = 'none'; // 存储特殊音符属性,默认值为 "none"
// 检查是否为和弦(和弦用圆括号包裹)
if (bar[i] === '(') {
chord = true;
i++;
while (bar[i] !== ')') {
note += bar[i];
i++;
}
i++; // 跳过闭合圆括号
} else if (bar[i] === '@') {
// 处理休止符
note = '@';
i++;
} else {
note = bar[i];
i++;
}
i++; // 跳过闭合圆括号
} else if (bar[i] === '@') {
// 处理休止符
note = '@';
i++;
} else {
note = bar[i];
i++;
}
// 解析音符类型(用方括号包裹)
if (bar[i] === '[') {
i++;
while (bar[i] !== ']') {
type += bar[i];
// 解析音符类型(用方括号包裹)
if (bar[i] === '[') {
i++;
while (bar[i] !== ']') {
type += bar[i];
i++;
}
i++; // 跳过闭合方括号
}
i++; // 跳过闭合方括号
}
// 解析特殊音符属性如果type中包含'-'
if (type.includes('-')) {
let splIndex = type.indexOf('-');
spl = type.slice(splIndex + 1);
type = parseInt(type.slice(0, splIndex), 10);
}
// 解析特殊音符属性如果type中包含'-'
if (type.includes('-')) {
let splIndex = type.indexOf('-');
spl = type.slice(splIndex + 1);
type = parseInt(type.slice(0, splIndex), 10);
}
// 将解析结果添加到parsedNotes数组中
result.push({
"note": note,
"type": type,
"chord": chord,
"spl": spl
});
}
});
// 将解析结果添加到parsedNotes数组中
result.push({
"note": note,
"type": type,
"chord": chord,
"spl": spl
});
}
});
}
return result;
}
@@ -323,101 +334,112 @@
* @returns {Promise<void>}
*/
async function play_sheet(sheet_list, bpm, ts) {
// 确定是以几分音符为一拍
let symbol = parseInt(ts.split("/")[1], 10);
// 每拍所需的时间
let symbol_time = Math.round(60000 / bpm);
// 装饰音时长
let ornament_time = Math.round(symbol_time / 16)
// 存储连音
let temp_legato = [];
// test 需要额外计算装饰音时值的影响
for (let i = 0; i < sheet_list.length; i++) {
// 显示正在演奏的音符
log.info(`${sheet_list[i]["note"]}[${sheet_list[i]["type"]}-${sheet_list[i]["spl"]}]`);
if (sheet_list[i]["spl"] === 'none') { // 单音、休止符或和弦
if (sheet_list[i]["chord"]) {
await play_chord(sheet_list[i]["note"]); // 和弦
if (Object.keys(sheet_list[0]).length === 3) {
for (let i = 0; i < sheet_list.length; i++) {
await sleep(Math.round(sheet_list[i]["time"]));
if (sheet_list[i]["type"] === "on") {
keyDown(sheet_list[i]["note"]);
} else {
if (sheet_list[i]["note"] === '@') { // 休止符
// pass
keyUp(sheet_list[i]["note"]);
}
}
} else {
// 确定是以几分音符为一拍
let symbol = parseInt(ts.split("/")[1], 10);
// 每拍所需的时间
let symbol_time = Math.round(60000 / bpm);
// 装饰音时长
let ornament_time = Math.round(symbol_time / 16)
// 存储连音
let temp_legato = [];
// test 需要额外计算装饰音时值的影响
for (let i = 0; i < sheet_list.length; i++) {
// 显示正在演奏的音符
log.info(`${sheet_list[i]["note"]}[${sheet_list[i]["type"]}-${sheet_list[i]["spl"]}]`);
if (sheet_list[i]["spl"] === 'none') { // 单音、休止符或和弦
if (sheet_list[i]["chord"]) {
await play_chord(sheet_list[i]["note"]); // 和弦
} else {
await play_note(sheet_list[i]["note"]); // 单音
}
}
if (i !== sheet_list.length - 1) {
await sleep(cal_time_ornament(sheet_list, symbol_time, symbol, sheet_list[i]["type"], i));
}
} else if (sheet_list[i]["spl"] === '#') { // 装饰音不会包含休止符时值为symbol的时值的1/16
if (sheet_list[i]["chord"]) {
await play_chord(sheet_list[i]["note"]); // 和弦
} else {
await play_note(sheet_list[i]["note"]); // 单音
}
if (i !== sheet_list.length - 1) {
await sleep(ornament_time);
}
} else if (/\.3|\.6|\.\$/.test(sheet_list[i]["spl"])) { // 三连音/六连音(可能包含休止符)
temp_legato.push({
"note": sheet_list[i]["note"],
"chord": sheet_list[i]["chord"],
"type": sheet_list[i]["type"]
});
// 演奏连音
if ("$".includes(sheet_list[i]["spl"])) {
// 连音的总时长
let time_legato = Math.round(symbol_time * (symbol / sheet_list[i]["type"]));
// 当前音符类型
let current_type = parseInt(sheet_list[i]["spl"].split(".")[0])
// 连音的音符数值总和(用于计算当前音符时长)
let time_all = temp_legato.reduce((sum, each) => sum + 1 / parseInt(each["spl"].split(".")[0]), 0);
// 当前音符时长
let time_current = Math.round(time_legato * (1 / current_type) / time_all);
// 计数
let count = undefined;
for (const note_legato of temp_legato) {
if (sheet_list[i]["chord"]) {
await play_chord(sheet_list[i]["note"]); // 和弦
if (sheet_list[i]["note"] === '@') { // 休止符
// pass
} else {
if (sheet_list[i]["note"] === '@') { // 休止符
// pass
} else {
await play_note(sheet_list[i]["note"]); // 单音
}
await play_note(sheet_list[i]["note"]); // 单音
}
if (count === temp_legato.length - 1 && i !== sheet_list.length - 1) {
// 计算连音的最后一个音的时值(计算装饰音)
await sleep(cal_time_ornament(sheet_list, symbol_time, symbol, sheet_list[i]["type"], i, time_current));
// 重置连音缓存区
temp_legato = [];
} else if (i !== sheet_list.length - 1) {
await sleep(time_current);
}
count += 1;
}
}
} else if (sheet_list[i]["spl"] === '*') { // 附点音符
if (sheet_list[i]["chord"]) {
await play_chord(sheet_list[i]["note"]); // 和弦
} else {
if (sheet_list[i]["note"] === '@') { // 休止符
// pass
if (i !== sheet_list.length - 1) {
await sleep(cal_time_ornament(sheet_list, symbol_time, symbol, sheet_list[i]["type"], i));
}
} else if (sheet_list[i]["spl"] === '#') { // 装饰音不会包含休止符时值为symbol的时值的1/16
if (sheet_list[i]["chord"]) {
await play_chord(sheet_list[i]["note"]); // 和弦
} else {
await play_note(sheet_list[i]["note"]); // 单音
}
if (i !== sheet_list.length - 1) {
await sleep(ornament_time);
}
} else if (/\.3|\.6|\.\$/.test(sheet_list[i]["spl"])) { // 三连音/六连音(可能包含休止符)
temp_legato.push({
"note": sheet_list[i]["note"],
"chord": sheet_list[i]["chord"],
"type": sheet_list[i]["type"]
});
// 演奏连音
if ("$".includes(sheet_list[i]["spl"])) {
// 连音的总时长
let time_legato = Math.round(symbol_time * (symbol / sheet_list[i]["type"]));
// 当前音符类型
let current_type = parseInt(sheet_list[i]["spl"].split(".")[0])
// 连音的音符数值总和(用于计算当前音符时长)
let time_all = temp_legato.reduce((sum, each) => sum + 1 / parseInt(each["spl"].split(".")[0]), 0);
// 当前音符时长
let time_current = Math.round(time_legato * (1 / current_type) / time_all);
// 计数
let count = undefined;
for (const note_legato of temp_legato) {
if (sheet_list[i]["chord"]) {
await play_chord(sheet_list[i]["note"]); // 和弦
} else {
if (sheet_list[i]["note"] === '@') { // 休止符
// pass
} else {
await play_note(sheet_list[i]["note"]); // 单音
}
}
if (count === temp_legato.length - 1 && i !== sheet_list.length - 1) {
// 计算连音的最后一个音的时值(计算装饰音)
await sleep(cal_time_ornament(sheet_list, symbol_time, symbol, sheet_list[i]["type"], i, time_current));
// 重置连音缓存区
temp_legato = [];
} else if (i !== sheet_list.length - 1) {
await sleep(time_current);
}
count += 1;
}
}
} else if (sheet_list[i]["spl"] === '*') { // 附点音符
if (sheet_list[i]["chord"]) {
await play_chord(sheet_list[i]["note"]); // 和弦
} else {
if (sheet_list[i]["note"] === '@') { // 休止符
// pass
} else {
await play_note(sheet_list[i]["note"]); // 单音
}
}
// 排除尾音
if (i !== sheet_list.length - 1) {
await sleep(cal_time_ornament(sheet_list, symbol_time * 1.5, symbol, sheet_list[i]["type"], i));
}
} else {
log.info(`错误: ${sheet_list[i]["spl"]}`);
return null;
}
// 排除尾音
if (i !== sheet_list.length - 1) {
await sleep(cal_time_ornament(sheet_list, symbol_time * 1.5, symbol, sheet_list[i]["type"], i));
}
} else {
log.info(`错误: ${sheet_list[i]["spl"]}`);
return null;
}
}
}
@@ -427,7 +449,7 @@
if (settings_msg == null) {
return null
}
try {
// try {
if (settings_msg["type"] === "single") { // 单曲
// 读取乐谱
const music_msg = await get_music_msg(settings_msg["music"]);
@@ -475,9 +497,9 @@
}
}
}
} catch (error) {
log.error(`出现错误: ${error}`);
}
// } catch (error) {
// log.error(`出现错误: ${error}`);
// }
}

View File

@@ -1,7 +1,7 @@
{
"manifest_version": 1,
"name": "原琴·五线谱版",
"version": "2.1.1",
"version": "3.0",
"bgi_version": "0.43.1",
"description": "功能描述:功能及其强大的原琴脚本\n核心功能------------------------------>\n1.轻松实现根据五线谱翻版琴谱,支持单音、和弦\n2.曲谱支持录入BPM、拍号\n3.特殊音符支持休止符、浮点音符、(三/六)连音、(三/六)连音标记线、装饰音·倚音\n4.含有制谱器,方便制作曲谱\n注意事项------------------------------>\n1.使用前请装备原琴\n2.音域只有3个八度受原琴音域限制本脚本的上限取决于翻谱的大佬卑微\n3.实际上装饰音·倚音的时长视为基础时值单位(比如拍号2/4的基础时值单位就是4分音符)的1/16\n4.制铺说明曲谱JSON文件的notes必须保证为一行且不能包括空白符换行符除外小节之间用|隔开,|不是必要的,作用是方便曲谱维护\n---------------------------------------->\n作者提瓦特钓鱼玳师\n脚本反馈邮箱hijiwos@hotmail.com",
"authors": [

View File

@@ -11,7 +11,9 @@
"5.Flower Dance-[5:20]",
"6.起风了-[3:22]",
"7.千本樱 (Eric Chen)-[4:03]",
"8.春よ、来い(春天,来吧)-[4:02]"
"8.春よ、来い(春天,来吧)-[4:02]",
"9.One Last Kiss-[4:12]",
"10.卡农(MIDI转谱).json"
]
},
{