Add one-line score visualizer with 3D orb and MP4 export
- Verovio single-system engraving, MuseScore audio/MIDI from the same source - React + Remotion player with live camera, colour and orb controls - Express pipeline, render queue and local H.264/AAC export - MIT license plus third-party notices for the reference skill
This commit is contained in:
commit
a237a7c604
38 files changed
+13958
No files matched your search
@@ -0,0 +1,71 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import {parseMusicXml, buildPerformanceOrder} from '../src/server/musicxml.js';
|
||||
import {engraveOneLine} from '../src/server/engrave.js';
|
||||
import {exportAudioAndMidi} from '../src/server/musescore.js';
|
||||
import {parseMidiFile} from '../src/server/midi.js';
|
||||
import {buildEvents} from '../src/server/events.js';
|
||||
import {readAudioDuration} from '../src/server/audio.js';
|
||||
import {DEFAULT_SETTINGS} from '../src/shared/types.js';
|
||||
|
||||
const demo = path.resolve('public/demo/demo.musicxml');
|
||||
const outDir = path.resolve('data/check');
|
||||
fs.mkdirSync(outDir, {recursive: true});
|
||||
|
||||
const parsed = parseMusicXml(fs.readFileSync(demo));
|
||||
console.log('title', parsed.title, '| composer', parsed.composer);
|
||||
console.log('parts', parsed.parts.length, 'measures', parsed.measures.length, 'notes', parsed.allNotes.length);
|
||||
console.log('unsupported', parsed.unsupported);
|
||||
console.log('warnings', parsed.warnings.slice(0, 5));
|
||||
|
||||
const performance = buildPerformanceOrder(parsed.measures, []);
|
||||
console.log('performance order length', performance.order.length);
|
||||
console.log('order', performance.order.map((i) => i + 1).join(', '));
|
||||
|
||||
const engraved = await engraveOneLine(parsed.annotatedXml);
|
||||
fs.writeFileSync(path.join(outDir, 'score.svg'), engraved.svg);
|
||||
console.log('svg', engraved.geometry.width, 'x', engraved.geometry.height, 'geometry notes', engraved.geometry.noteCount);
|
||||
console.log('measure times', engraved.measureStartMs.size, 'note times', engraved.noteTimes.size);
|
||||
|
||||
console.time('musescore');
|
||||
const {wav, midi} = await exportAudioAndMidi(demo, outDir);
|
||||
console.timeEnd('musescore');
|
||||
console.log('wav', fs.statSync(wav).size, 'midi', fs.statSync(midi).size);
|
||||
|
||||
const audioDuration = await readAudioDuration(wav);
|
||||
const parsedMidi = parseMidiFile(midi);
|
||||
console.log('audioDuration', audioDuration, 'midi notes', parsedMidi.notes.length, 'midi duration', parsedMidi.duration);
|
||||
|
||||
const built = buildEvents({
|
||||
parsed,
|
||||
engrave: engraved,
|
||||
midi: parsedMidi,
|
||||
performanceOrder: performance.order,
|
||||
audioDuration,
|
||||
settings: DEFAULT_SETTINGS,
|
||||
});
|
||||
console.log('events', built.events.length, 'scroll keys', built.scroll.length);
|
||||
console.log('meta', JSON.stringify({...built.meta, warnings: built.meta.warnings.slice(0, 8)}, null, 2));
|
||||
console.log('first events', built.events.slice(0, 8));
|
||||
console.log('last events', built.events.slice(-5));
|
||||
console.log('scroll head', built.scroll.slice(0, 6));
|
||||
console.log('scroll tail', built.scroll.slice(-4));
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(outDir, 'score.json'),
|
||||
JSON.stringify(
|
||||
{
|
||||
version: 1,
|
||||
meta: built.meta,
|
||||
events: built.events,
|
||||
activations: built.activations,
|
||||
scroll: built.scroll,
|
||||
svgUrl: '/jobs/check/score.svg',
|
||||
audioUrl: '/jobs/check/audio.wav',
|
||||
settings: DEFAULT_SETTINGS,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
console.log('wrote', path.join(outDir, 'score.json'));
|
||||
@@ -0,0 +1,292 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const divisions = 4;
|
||||
const typeFor = (d) =>
|
||||
({16: 'whole', 8: 'half', 4: 'quarter', 2: 'eighth', 1: '16th'})[d] ?? 'quarter';
|
||||
|
||||
let xml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE score-partwise PUBLIC "-//Recordare//DTD MusicXML 4.0 Partwise//EN" "http://www.musicxml.org/dtds/partwise.dtd">
|
||||
<score-partwise version="4.0">
|
||||
<work><work-title>Morning Window</work-title></work>
|
||||
<identification>
|
||||
<creator type="composer">Demo · Original</creator>
|
||||
<encoding><software>music-visualizer demo generator</software></encoding>
|
||||
</identification>
|
||||
<part-list><score-part id="P1"><part-name>Piano</part-name></score-part></part-list>
|
||||
<part id="P1">
|
||||
`;
|
||||
|
||||
function note(step, octave, duration, opts = {}) {
|
||||
const {chord = false, staff = 1, voice = '1', tie, grace = false} = opts;
|
||||
const parts = [];
|
||||
if (grace) parts.push('<grace/>');
|
||||
if (chord) parts.push('<chord/>');
|
||||
parts.push(`<pitch><step>${step}</step><octave>${octave}</octave></pitch>`);
|
||||
if (!grace) parts.push(`<duration>${duration}</duration>`);
|
||||
if (tie === 'start') parts.push('<tie type="start"/>');
|
||||
if (tie === 'stop') parts.push('<tie type="stop"/>');
|
||||
parts.push(`<voice>${voice}</voice>`);
|
||||
if (!grace) parts.push(`<type>${typeFor(duration)}</type>`);
|
||||
else parts.push('<type>eighth</type>');
|
||||
if (tie) {
|
||||
parts.push(`<notations><tied type="${tie}"/></notations>`);
|
||||
}
|
||||
parts.push(`<staff>${staff}</staff>`);
|
||||
return ` <note>${parts.join('')}</note>\n`;
|
||||
}
|
||||
|
||||
function rest(duration, opts = {}) {
|
||||
const {staff = 1, voice = '1'} = opts;
|
||||
return ` <note><rest/><duration>${duration}</duration><voice>${voice}</voice><type>${typeFor(duration)}</type><staff>${staff}</staff></note>\n`;
|
||||
}
|
||||
|
||||
function leftArpeggio(notes) {
|
||||
// 8 eighth notes = 16 divisions
|
||||
let out = '';
|
||||
for (const [step, octave] of notes) {
|
||||
out += note(step, octave, 2, {staff: 2, voice: '5'});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const measures = [
|
||||
// 1
|
||||
{
|
||||
right:
|
||||
note('C', 5, 4) +
|
||||
note('E', 5, 2) +
|
||||
note('G', 5, 2) +
|
||||
note('A', 5, 4) +
|
||||
note('G', 5, 4),
|
||||
left: leftArpeggio([
|
||||
['C', 3], ['G', 3], ['E', 4], ['G', 3],
|
||||
['C', 3], ['G', 3], ['E', 4], ['G', 3],
|
||||
]),
|
||||
},
|
||||
// 2
|
||||
{
|
||||
right:
|
||||
note('F', 5, 4) +
|
||||
note('E', 5, 2) +
|
||||
note('D', 5, 2) +
|
||||
note('C', 5, 8) +
|
||||
note('E', 5, 8, {chord: true}) +
|
||||
note('G', 5, 8, {chord: true}),
|
||||
left: leftArpeggio([
|
||||
['F', 3], ['C', 4], ['A', 3], ['C', 4],
|
||||
['F', 3], ['C', 4], ['A', 3], ['C', 4],
|
||||
]),
|
||||
},
|
||||
// 3
|
||||
{
|
||||
right:
|
||||
rest(4) +
|
||||
note('G', 5, 2) +
|
||||
note('A', 5, 2) +
|
||||
note('G', 5, 4) +
|
||||
note('E', 5, 4),
|
||||
left: leftArpeggio([
|
||||
['G', 3], ['D', 4], ['B', 3], ['D', 4],
|
||||
['G', 3], ['D', 4], ['B', 3], ['D', 4],
|
||||
]),
|
||||
},
|
||||
// 4
|
||||
{
|
||||
right:
|
||||
note('D', 5, 4) +
|
||||
note('E', 5, 4) +
|
||||
note('C', 5, 8),
|
||||
left: leftArpeggio([
|
||||
['C', 3], ['G', 3], ['E', 4], ['G', 3],
|
||||
['C', 3], ['G', 3], ['E', 4], ['G', 3],
|
||||
]),
|
||||
},
|
||||
// 5
|
||||
{
|
||||
right:
|
||||
note('E', 5, 4) +
|
||||
note('G', 5, 2) +
|
||||
note('A', 5, 2) +
|
||||
note('C', 6, 4) +
|
||||
note('B', 5, 4),
|
||||
left: leftArpeggio([
|
||||
['A', 3], ['E', 4], ['C', 4], ['E', 4],
|
||||
['A', 3], ['E', 4], ['C', 4], ['E', 4],
|
||||
]),
|
||||
},
|
||||
// 6
|
||||
{
|
||||
right:
|
||||
note('A', 5, 4) +
|
||||
note('G', 5, 2) +
|
||||
note('E', 5, 2) +
|
||||
note('D', 5, 4) +
|
||||
note('E', 5, 4),
|
||||
left: leftArpeggio([
|
||||
['F', 3], ['C', 4], ['A', 3], ['C', 4],
|
||||
['F', 3], ['C', 4], ['A', 3], ['C', 4],
|
||||
]),
|
||||
},
|
||||
// 7
|
||||
{
|
||||
right:
|
||||
note('G', 5, 4) +
|
||||
note('E', 5, 2) +
|
||||
note('D', 5, 2) +
|
||||
note('C', 5, 8),
|
||||
left: leftArpeggio([
|
||||
['G', 3], ['D', 4], ['B', 3], ['D', 4],
|
||||
['G', 3], ['D', 4], ['B', 3], ['D', 4],
|
||||
]),
|
||||
},
|
||||
// 8
|
||||
{
|
||||
right:
|
||||
note('D', 5, 4) +
|
||||
note('E', 5, 2) +
|
||||
note('F', 5, 2) +
|
||||
note('G', 5, 8, {tie: 'start'}),
|
||||
left: leftArpeggio([
|
||||
['C', 3], ['G', 3], ['E', 4], ['G', 3],
|
||||
['C', 3], ['G', 3], ['E', 4], ['G', 3],
|
||||
]),
|
||||
},
|
||||
// 9
|
||||
{
|
||||
right:
|
||||
note('G', 5, 8, {tie: 'stop'}) +
|
||||
note('A', 5, 4) +
|
||||
note('G', 5, 4),
|
||||
left: leftArpeggio([
|
||||
['A', 3], ['E', 4], ['C', 4], ['E', 4],
|
||||
['A', 3], ['E', 4], ['C', 4], ['E', 4],
|
||||
]),
|
||||
},
|
||||
// 10
|
||||
{
|
||||
right:
|
||||
note('A', 5, 2, {grace: true}) +
|
||||
note('G', 5, 4) +
|
||||
note('E', 5, 4) +
|
||||
note('D', 5, 8),
|
||||
left: leftArpeggio([
|
||||
['F', 3], ['C', 4], ['A', 3], ['C', 4],
|
||||
['F', 3], ['C', 4], ['A', 3], ['C', 4],
|
||||
]),
|
||||
},
|
||||
// 11
|
||||
{
|
||||
right:
|
||||
note('E', 5, 4) +
|
||||
note('G', 5, 2) +
|
||||
note('A', 5, 2) +
|
||||
note('C', 6, 8),
|
||||
left: leftArpeggio([
|
||||
['G', 3], ['D', 4], ['B', 3], ['D', 4],
|
||||
['G', 3], ['D', 4], ['B', 3], ['D', 4],
|
||||
]),
|
||||
},
|
||||
// 12
|
||||
{
|
||||
right:
|
||||
note('B', 5, 4) +
|
||||
note('A', 5, 4) +
|
||||
note('G', 5, 8),
|
||||
left: leftArpeggio([
|
||||
['C', 3], ['G', 3], ['E', 4], ['G', 3],
|
||||
['C', 3], ['G', 3], ['E', 4], ['G', 3],
|
||||
]),
|
||||
},
|
||||
// 13
|
||||
{
|
||||
right:
|
||||
note('C', 6, 4) +
|
||||
note('B', 5, 2) +
|
||||
note('A', 5, 2) +
|
||||
note('G', 5, 4) +
|
||||
note('E', 5, 4),
|
||||
left: leftArpeggio([
|
||||
['A', 3], ['E', 4], ['C', 4], ['E', 4],
|
||||
['A', 3], ['E', 4], ['C', 4], ['E', 4],
|
||||
]),
|
||||
},
|
||||
// 14
|
||||
{
|
||||
right:
|
||||
note('F', 5, 4) +
|
||||
note('E', 5, 2) +
|
||||
note('D', 5, 2) +
|
||||
note('C', 5, 8),
|
||||
left: leftArpeggio([
|
||||
['F', 3], ['C', 4], ['A', 3], ['C', 4],
|
||||
['F', 3], ['C', 4], ['A', 3], ['C', 4],
|
||||
]),
|
||||
},
|
||||
// 15
|
||||
{
|
||||
right:
|
||||
note('D', 5, 4) +
|
||||
note('E', 5, 4) +
|
||||
note('G', 5, 8),
|
||||
left: leftArpeggio([
|
||||
['G', 3], ['D', 4], ['B', 3], ['D', 4],
|
||||
['G', 3], ['D', 4], ['B', 3], ['D', 4],
|
||||
]),
|
||||
},
|
||||
// 16
|
||||
{
|
||||
right:
|
||||
note('C', 5, 16) +
|
||||
note('E', 5, 16, {chord: true}) +
|
||||
note('G', 5, 16, {chord: true}),
|
||||
left:
|
||||
note('C', 3, 16, {staff: 2, voice: '5'}) +
|
||||
note('E', 3, 16, {staff: 2, voice: '5', chord: true}) +
|
||||
note('G', 3, 16, {staff: 2, voice: '5', chord: true}),
|
||||
},
|
||||
];
|
||||
|
||||
measures.forEach((measure, index) => {
|
||||
const number = index + 1;
|
||||
xml += ` <measure number="${number}">\n`;
|
||||
if (number === 1) {
|
||||
xml += ` <attributes>
|
||||
<divisions>${divisions}</divisions>
|
||||
<key><fifths>0</fifths></key>
|
||||
<time><beats>4</beats><beat-type>4</beat-type></time>
|
||||
<staves>2</staves>
|
||||
<clef number="1"><sign>G</sign><line>2</line></clef>
|
||||
<clef number="2"><sign>F</sign><line>4</line></clef>
|
||||
</attributes>
|
||||
<direction placement="above">
|
||||
<direction-type><metronome><beat-unit>quarter</beat-unit><per-minute>96</per-minute></metronome></direction-type>
|
||||
<sound tempo="96"/>
|
||||
</direction>
|
||||
<barline location="left"><repeat direction="forward"/></barline>
|
||||
`;
|
||||
}
|
||||
if (number === 9) {
|
||||
xml += ` <direction placement="above">
|
||||
<direction-type><metronome><beat-unit>quarter</beat-unit><per-minute>104</per-minute></metronome></direction-type>
|
||||
<sound tempo="104"/>
|
||||
</direction>
|
||||
`;
|
||||
}
|
||||
xml += measure.right;
|
||||
xml += ` <backup><duration>16</duration></backup>\n`;
|
||||
xml += measure.left;
|
||||
if (number === 4) {
|
||||
xml += ` <barline location="right"><repeat direction="backward" times="2"/></barline>\n`;
|
||||
}
|
||||
xml += ` </measure>\n`;
|
||||
});
|
||||
|
||||
xml += ` </part>
|
||||
</score-partwise>
|
||||
`;
|
||||
|
||||
const out = path.resolve('public/demo/demo.musicxml');
|
||||
fs.mkdirSync(path.dirname(out), {recursive: true});
|
||||
fs.writeFileSync(out, xml, 'utf8');
|
||||
console.log(`Wrote ${out} (${xml.length} bytes, ${measures.length} measures)`);
|
||||
@@ -0,0 +1,44 @@
|
||||
import fs from 'node:fs';
|
||||
import {buildScrollKeys} from '../src/shared/scroll.js';
|
||||
import type {RenderData, VisualSettings} from '../src/shared/types.js';
|
||||
|
||||
/** Builds Remotion still props for a finished job, using exactly its saved settings. */
|
||||
const id = process.argv[2];
|
||||
if (!id) throw new Error('usage: tsx scripts/props-from-job.ts <jobId> [outFile]');
|
||||
const out = process.argv[3] ?? 'data/check/p-job.json';
|
||||
|
||||
const data = JSON.parse(fs.readFileSync(`data/jobs/${id}/score.json`, 'utf8')) as RenderData;
|
||||
const job = JSON.parse(fs.readFileSync(`data/jobs/${id}/job.json`, 'utf8')) as {
|
||||
settings?: Partial<VisualSettings>;
|
||||
};
|
||||
const settings: VisualSettings = {...data.settings, ...(job.settings ?? {})};
|
||||
|
||||
const displayHeight = settings.height * settings.scoreHeightRatio;
|
||||
const displayScale = displayHeight / Math.max(1, data.meta.svgHeight);
|
||||
const viewWidthScore = settings.width / displayScale;
|
||||
const maxLeft = Math.max(0, data.meta.svgWidth - viewWidthScore);
|
||||
const scroll = buildScrollKeys(
|
||||
data.events,
|
||||
settings.scrollLead,
|
||||
viewWidthScore,
|
||||
maxLeft,
|
||||
data.meta.musicDuration,
|
||||
);
|
||||
|
||||
fs.writeFileSync(
|
||||
out,
|
||||
JSON.stringify(
|
||||
{
|
||||
data: {
|
||||
...data,
|
||||
settings,
|
||||
scroll,
|
||||
svgUrl: `http://127.0.0.1:3001/jobs/${id}/score.svg`,
|
||||
audioUrl: `http://127.0.0.1:3001/jobs/${id}/audio.wav`,
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
console.log(`${out}: ${settings.width}x${settings.height} tiltX=${settings.tiltX} tiltZ=${settings.tiltZ}`);
|
||||
Reference in new issue
Block a user