비슷한 임시 연습곡 다섯 개를 교체했다. 부르크뮐러와 슈만 원본 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:
@@ -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))}초`);
|
||||
@@ -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}음`);
|
||||
}
|
||||
@@ -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}음`);
|
||||
}
|
||||
Reference in New Issue
Block a user