서로 다른 연습곡 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))}초`);
-32
View File
@@ -1,32 +0,0 @@
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)), "..", "backend", "sample_midi");
const expected = [
["starlight-steps-beginner.mid", 4],
["starlight-steps-easy.mid", 4],
["evening-waltz-beginner.mid", 3],
["sunrise-arpeggio-easy.mid", 4],
["black-key-pentatonic-beginner.mid", 4],
];
for (const [name, meter] of expected) {
const midi = new Midi(fs.readFileSync(path.join(root, name)));
const [right, left] = midi.tracks;
if (midi.tracks.length !== 2 || !right?.notes.length || !left?.notes.length) {
throw new Error(`${name}: 오른손과 왼손 트랙이 각각 필요합니다`);
}
if (midi.duration < 60 || midi.duration > 90) {
throw new Error(`${name}: 곡 길이가 1분에서 1분 30초 사이여야 합니다 (${midi.duration.toFixed(1)}초)`);
}
if (midi.header.timeSignatures[0]?.timeSignature[0] !== meter) {
throw new Error(`${name}: 박자가 일치하지 않습니다`);
}
if ([...right.notes, ...left.notes].some((note) => note.midi < 21 || note.midi > 108)) {
throw new Error(`${name}: 피아노 음역을 벗어났습니다`);
}
console.log(`${name}: ${midi.duration.toFixed(1)}초, 오른손 ${right.notes.length}음, 왼손 ${left.notes.length}음`);
}
+35
View File
@@ -0,0 +1,35 @@
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)), '..', 'backend', 'sample_midi');
const collection = JSON.parse(fs.readFileSync(path.join(root, 'practice_collection.json'), 'utf8'));
if (collection.length !== 30 || new Set(collection.map((item) => item.title)).size !== 30) {
throw new Error('서로 다른 제목의 연습곡 30개가 필요합니다');
}
const motifs = new Set();
for (const item of collection) {
const midi = new Midi(fs.readFileSync(path.join(root, item.storage_name)));
const [right, left] = midi.tracks;
if (midi.tracks.length !== 2 || right.notes.length < 12 || left.notes.length < 6) {
throw new Error(`${item.storage_name}: 양손 음표가 부족합니다`);
}
if (midi.duration < 60 || midi.duration > 180) {
throw new Error(`${item.storage_name}: 곡 길이가 기준을 벗어납니다`);
}
if (right.notes[0].time < 0.4 || right.notes[4].time < 5 || left.notes[0].time !== 0) {
throw new Error(`${item.storage_name}: 도입부를 확인해야 합니다`);
}
if (midi.duration - right.notes.at(-1).time < 2.5 || midi.duration - left.notes.at(-1).time < 4) {
throw new Error(`${item.storage_name}: 마무리 지속 시간이 부족합니다`);
}
const motif = right.notes.slice(4, 20).map((note) => note.midi).join('-');
if (motifs.has(motif)) throw new Error(`${item.storage_name}: 다른 곡과 선율이 같습니다`);
motifs.add(motif);
if (![...right.notes, ...left.notes].every((note) => note.midi >= 21 && note.midi <= 108)) {
throw new Error(`${item.storage_name}: 피아노 음역을 벗어났습니다`);
}
console.log(`${item.storage_name}: ${midi.duration.toFixed(1)}초, 오른손 ${right.notes.length}음, 왼손 ${left.notes.length}음`);
}