CI / Frontend - type-check & test (push) Canceled after 0s
비슷한 임시 연습곡 다섯 개를 교체했다. 부르크뮐러와 슈만 원본 30곡의 출처와 권리 정보를 보존했다. 각 MIDI에 양손 연습용 도입부와 마무리를 넣고 난이도를 음표 밀도로 분류했다. MIDI 구조 검사 30개와 서버 격리 테스트 5개를 통과했다. 운영 백업: /home/syskiller/piano/backups/bach-to-basics/20261009-practice-30-intro-ending. 백엔드만 운영에 반영했고 HTTPS 공개 목록과 30개 다운로드를 확인했다. 회원 업로드 2건은 보존됐다. 실제 건반 연주감 검사는 남아 있다.
114 lines
6.7 KiB
JavaScript
114 lines
6.7 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 root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
const sourceRoot = path.join(root, 'backend', 'tools', 'practice_sources');
|
|
const outputRoot = path.join(root, 'backend', 'sample_midi');
|
|
const catalog = JSON.parse(fs.readFileSync(path.join(sourceRoot, 'catalog.json'), 'utf8'));
|
|
const names = {
|
|
burgmuller: ['순진', '아라베스크', '목가', '작은 모임', '순수', '진보', '맑은 시냇물', '우아함', '사냥', '여린 꽃', '할미새', '이별', '위안', '슈타이어 춤', '발라드', '작은 탄식', '수다쟁이', '불안'],
|
|
schumann: ['멜로디', '군대 행진곡', '흥얼거리는 노래', '코랄', '작은 소품', '가엾은 고아', '사냥꾼의 노래', '거친 기수', '민요', '즐거운 농부', '시칠리아 춤곡', '무서운 아저씨'],
|
|
};
|
|
|
|
const field = (rdf, key) => rdf.match(new RegExp(`<mp:${key}>([^<]*)</mp:${key}>`))?.[1]?.trim() ?? '';
|
|
const clamp = (value, low, high) => Math.max(low, Math.min(high, value));
|
|
|
|
function addNote(track, note, time, duration, velocity) {
|
|
track.addNote({ midi: clamp(note, 21, 108), time, duration: Math.max(0.08, duration), velocity });
|
|
}
|
|
|
|
function onsets(notes) {
|
|
const grouped = new Map();
|
|
for (const note of notes) {
|
|
const key = Math.round(note.time * 50);
|
|
const list = grouped.get(key) ?? [];
|
|
list.push(note);
|
|
grouped.set(key, list);
|
|
}
|
|
return [...grouped.entries()].sort(([a], [b]) => a - b).map(([key, group]) => ({ time: key / 50, notes: group }));
|
|
}
|
|
|
|
const manifest = [];
|
|
for (const item of catalog) {
|
|
const name = item.name;
|
|
const [series, numberText] = name.split('-');
|
|
const number = Number(numberText);
|
|
const source = new Midi(fs.readFileSync(path.join(sourceRoot, `${name}.mid`)));
|
|
const rdf = fs.readFileSync(path.join(sourceRoot, `${name}.rdf`), 'utf8');
|
|
const license = field(rdf, 'licence');
|
|
if (license !== 'Public Domain' && !/^Creative Commons Attribution-ShareAlike (2\.5|3\.0)$/.test(license)) {
|
|
throw new Error(`${name}: 권리 표시를 확인해야 합니다: ${license}`);
|
|
}
|
|
const nonempty = source.tracks.filter((track) => track.notes.length);
|
|
if (nonempty.length < 2) throw new Error(`${name}: 양손 원본이 없습니다`);
|
|
const rightSource = onsets(nonempty[0].notes);
|
|
const leftSource = onsets(nonempty.at(-1).notes);
|
|
const speed = Math.max(1, 58 / source.duration);
|
|
const introSeconds = 5.4;
|
|
const target = new Midi();
|
|
target.header.setTempo(120);
|
|
target.header.timeSignatures.push({ ticks: 0, timeSignature: source.header.timeSignatures[0]?.timeSignature ?? [4, 4] });
|
|
const right = target.addTrack();
|
|
right.name = 'Right Hand';
|
|
const left = target.addTrack();
|
|
left.name = 'Left Hand';
|
|
|
|
// 원곡의 첫 선율 네 음을 먼저 천천히 들려준다. 본곡과 자연스럽게 이어지는 도입부다.
|
|
const introPitches = rightSource.slice(0, 4).map((group) => Math.max(...group.notes.map((note) => note.midi)));
|
|
const firstBass = Math.min(...leftSource[0].notes.map((note) => note.midi));
|
|
addNote(left, firstBass, 0, 4.5, 0.48);
|
|
introPitches.forEach((pitch, index) => addNote(right, pitch, 0.55 + index * 1.05, 0.83, 0.55 + index * 0.04));
|
|
|
|
// 오른손은 원곡의 각 시작점에서 가장 높은 성부를 택한다. 선율의 순서와 리듬을 보존한다.
|
|
for (const group of rightSource) {
|
|
const note = group.notes.reduce((highest, current) => current.midi > highest.midi ? current : highest);
|
|
addNote(right, note.midi, introSeconds + group.time * speed, Math.min(2.4, note.duration * speed * 0.92), clamp(note.velocity, 0.48, 0.87));
|
|
}
|
|
// 왼손은 저음을 따라가되 가까운 시작점을 정리해 초보자가 양손을 맞출 여유를 둔다.
|
|
let previousBassTime = -10;
|
|
for (const group of leftSource) {
|
|
const time = introSeconds + group.time * speed;
|
|
if (time - previousBassTime < 0.48) continue;
|
|
const note = group.notes.reduce((lowest, current) => current.midi < lowest.midi ? current : lowest);
|
|
addNote(left, note.midi, time, clamp(note.duration * speed * 0.9, 0.38, 2.5), 0.53);
|
|
previousBassTime = time;
|
|
}
|
|
|
|
// 원곡 마지막 선율을 늦추어 다시 들려준 뒤 종지음을 충분히 유지한다.
|
|
const endingStart = introSeconds + source.duration * speed + 0.5;
|
|
const endingPitches = rightSource.slice(-3).map((group) => Math.max(...group.notes.map((note) => note.midi)));
|
|
const lastBass = Math.min(...leftSource.at(-1).notes.map((note) => note.midi));
|
|
addNote(left, lastBass, endingStart, 5.1, 0.52);
|
|
endingPitches.forEach((pitch, index) => addNote(right, pitch, endingStart + index * 1.05, index === 2 ? 3.0 : 0.85, 0.68 - index * 0.06));
|
|
|
|
const file = `practice-${name}.mid`;
|
|
const duration = new Midi(target.toArray()).duration;
|
|
if (duration < 60 || right.notes.length < 12 || left.notes.length < 6) throw new Error(`${name}: 연습곡 검증 실패`);
|
|
const notesPerSecond = right.notes.length / duration;
|
|
const difficulty = notesPerSecond < 2.1 ? 'beginner' : notesPerSecond < 3.5 ? 'easy' : 'intermediate';
|
|
fs.writeFileSync(path.join(outputRoot, file), Buffer.from(target.toArray()));
|
|
const title = `${series === 'burgmuller' ? '부르크뮐러 25곡' : '슈만 어린이를 위한 앨범'} ${number}번 ${names[series][number - 1]}`;
|
|
manifest.push({
|
|
storage_name: file,
|
|
title,
|
|
artist: series === 'burgmuller' ? '요한 프리드리히 부르크뮐러' : '로베르트 슈만',
|
|
description: `${field(rdf, 'title')} 원곡을 양손 연습용으로 단순화했습니다. 도입부와 마무리가 포함된 전체 곡입니다. 원본: Mutopia Project ${field(rdf, 'id')}. 조판: ${field(rdf, 'maintainer')}. ${license}${license === 'Public Domain' ? '' : ' 동일 조건 편곡'}.`,
|
|
difficulty,
|
|
genre: series === 'burgmuller' ? '낭만주의 연습곡' : '낭만주의 소품',
|
|
arrangement: 'piano_solo',
|
|
tags: ['양손', '도입부', '마무리', series === 'burgmuller' ? '부르크뮐러' : '슈만', difficulty === 'beginner' ? '입문' : difficulty === 'easy' ? '쉬움' : '중급', license === 'Public Domain' ? '공개 도메인' : 'CC BY-SA'],
|
|
source_url: item.midi_url,
|
|
rdf_url: item.rdf_url,
|
|
source_title: field(rdf, 'title'),
|
|
source_id: field(rdf, 'id'),
|
|
maintainer: field(rdf, 'maintainer'),
|
|
license,
|
|
duration_seconds: Math.round(duration * 10) / 10,
|
|
});
|
|
}
|
|
fs.writeFileSync(path.join(outputRoot, 'practice_collection.json'), `${JSON.stringify(manifest, null, 2)}\n`);
|
|
console.log(`연습곡 ${manifest.length}개 생성. 재생 길이 ${Math.min(...manifest.map((item) => item.duration_seconds))}~${Math.max(...manifest.map((item) => item.duration_seconds))}초`);
|