CI / Frontend - type-check & test (push) Canceled after 0s
양손 악보를 피아노 독주로 통일하고 난이도 정렬과 곡 목록 통신 복구를 보완했다. 재생 위치에 맞춰 악보 커서와 화면이 이동하도록 고쳤으며 높은음과 낮은음에는 8va와 8vb 표기를 적용했다. 상단 브랜드와 저장소 링크를 ITOSM 피아노 학습실 기준으로 바꿨다. 검사: 프런트엔드 테스트 67건 통과, 백엔드 테스트 4건 통과, 타입 검사와 빌드 통과, Docker 재빌드와 운영 화면 확인 운영 반영: 완료 백업: /home/syskiller/piano/backups/bach-to-basics/20261003-1635-classification-scroll
111 lines
3.2 KiB
JavaScript
111 lines
3.2 KiB
JavaScript
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import midiPackage from "../frontend/node_modules/@tonejs/midi/build/Midi.js";
|
|
|
|
const { Midi } = midiPackage;
|
|
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
const sampleRoot = path.join(projectRoot, "backend", "sample_midi");
|
|
|
|
function load(name) {
|
|
return new Midi(fs.readFileSync(path.join(sampleRoot, name)));
|
|
}
|
|
|
|
function createVariant(source, options) {
|
|
const result = new Midi();
|
|
const header = source.header.toJSON();
|
|
result.header.fromJSON({
|
|
...header,
|
|
name: options.name,
|
|
tempos: [{ ticks: 0, bpm: options.tempo }],
|
|
meta: [],
|
|
});
|
|
|
|
const originalTempo = source.header.tempos[0]?.bpm ?? options.tempo;
|
|
const timeScale = originalTempo / options.tempo;
|
|
for (const trackIndex of options.trackIndexes) {
|
|
const sourceTrack = source.tracks[trackIndex];
|
|
if (!sourceTrack) continue;
|
|
const targetTrack = result.addTrack();
|
|
targetTrack.name = sourceTrack.name;
|
|
targetTrack.channel = sourceTrack.channel;
|
|
targetTrack.instrument.number = sourceTrack.instrument.number;
|
|
|
|
let notes = sourceTrack.notes.filter((note) => note.time < options.endTime);
|
|
if (options.leftHandRoots && trackIndex === 1) {
|
|
const measureSeconds = (60 / originalTempo) * (header.timeSignatures[0]?.timeSignature[0] ?? 4);
|
|
const roots = new Map();
|
|
for (const note of notes) {
|
|
const measure = Math.floor(note.time / measureSeconds);
|
|
const current = roots.get(measure);
|
|
if (!current || note.midi < current.midi) roots.set(measure, note);
|
|
}
|
|
notes = [...roots.values()];
|
|
}
|
|
|
|
for (const note of notes) {
|
|
targetTrack.addNote({
|
|
midi: note.midi,
|
|
time: note.time * timeScale,
|
|
duration: options.leftHandRoots && trackIndex === 1
|
|
? Math.max(note.duration * timeScale, 0.8)
|
|
: note.duration * timeScale,
|
|
velocity: note.velocity,
|
|
});
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function write(name, midi) {
|
|
fs.writeFileSync(path.join(sampleRoot, name), Buffer.from(midi.toArray()));
|
|
}
|
|
|
|
const furElise = load("fur-elise.mid");
|
|
write(
|
|
"fur-elise-beginner.mid",
|
|
createVariant(furElise, {
|
|
name: "엘리제를 위하여 입문 양손 연습",
|
|
tempo: 60,
|
|
endTime: 32,
|
|
trackIndexes: [0, 1],
|
|
leftHandRoots: true,
|
|
}),
|
|
);
|
|
write(
|
|
"fur-elise-easy.mid",
|
|
createVariant(furElise, {
|
|
name: "엘리제를 위하여 쉬운 양손 연습",
|
|
tempo: 66,
|
|
endTime: 45,
|
|
trackIndexes: [0, 1],
|
|
leftHandRoots: true,
|
|
}),
|
|
);
|
|
|
|
const bachMinuet = load("minuet-bwv-anh-114.mid");
|
|
write(
|
|
"minuet-bwv-anh-114-beginner.mid",
|
|
createVariant(bachMinuet, {
|
|
name: "미뉴에트 사장조 입문 양손 연습",
|
|
tempo: 100,
|
|
endTime: bachMinuet.duration + 1,
|
|
trackIndexes: [0, 1],
|
|
leftHandRoots: true,
|
|
}),
|
|
);
|
|
|
|
const beethovenMinuet = load("menuett-woo-82.mid");
|
|
write(
|
|
"menuett-woo-82-easy.mid",
|
|
createVariant(beethovenMinuet, {
|
|
name: "미뉴에트 내림마장조 쉬운 양손 연습",
|
|
tempo: 88,
|
|
endTime: beethovenMinuet.duration + 1,
|
|
trackIndexes: [0, 1],
|
|
leftHandRoots: true,
|
|
}),
|
|
);
|
|
|
|
console.log("양손이 포함된 난이도별 연습 MIDI 4개를 만들었습니다.");
|