서로 다른 연습곡 30개에 도입부와 마무리를 추가함
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건은 보존됐다. 실제 건반 연주감 검사는 남아 있다.
This commit is contained in:
이창현
2026-10-09 23:25:37 +09:00
parent 89f66c3bee
commit 8298f5af26
108 changed files with 2181 additions and 282 deletions
+113
View File
@@ -0,0 +1,113 @@
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))}초`);