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(`([^<]*)`))?.[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))}초`);