mroctopus / app /src /utils /midiToNotation.js
Ewan
Add playback progress line and note coloring to sheet music
d4b669a
import { MIDI_SPLIT_POINT } from './colorScheme';
// VexFlow note names for each pitch class
const PITCH_NAMES = ['c', 'c#', 'd', 'd#', 'e', 'f', 'f#', 'g', 'g#', 'a', 'a#', 'b'];
// Duration thresholds in beats → VexFlow duration string
// Ordered from longest to shortest; we snap to nearest
const DURATION_TABLE = [
{ beats: 4, vex: 'w', dots: 0 },
{ beats: 3, vex: 'h', dots: 1 },
{ beats: 2, vex: 'h', dots: 0 },
{ beats: 1.5, vex: 'q', dots: 1 },
{ beats: 1, vex: 'q', dots: 0 },
{ beats: 0.75, vex: '8', dots: 1 },
{ beats: 0.5, vex: '8', dots: 0 },
{ beats: 0.375,vex: '16', dots: 1 },
{ beats: 0.25, vex: '16', dots: 0 },
];
// Rest equivalents
const REST_TABLE = [
{ beats: 4, vex: 'wr', dots: 0 },
{ beats: 3, vex: 'hr', dots: 1 },
{ beats: 2, vex: 'hr', dots: 0 },
{ beats: 1.5, vex: 'qr', dots: 1 },
{ beats: 1, vex: 'qr', dots: 0 },
{ beats: 0.75, vex: '8r', dots: 1 },
{ beats: 0.5, vex: '8r', dots: 0 },
{ beats: 0.25, vex: '16r', dots: 0 },
];
function midiToVexKey(midiNum) {
const pitchClass = midiNum % 12;
const octave = Math.floor(midiNum / 12) - 1;
return `${PITCH_NAMES[pitchClass]}/${octave}`;
}
function snapDuration(beats, table) {
let best = table[table.length - 1];
let bestDist = Infinity;
for (const entry of table) {
const dist = Math.abs(beats - entry.beats);
if (dist < bestDist) {
bestDist = dist;
best = entry;
}
}
return best;
}
// Fill a gap with rests that sum to the given beat count
function fillWithRests(beats, clef) {
if (beats <= 0.125) return [];
const rests = [];
let remaining = beats;
while (remaining > 0.125) {
const snap = snapDuration(remaining, REST_TABLE);
const restKey = clef === 'treble' ? 'b/4' : 'd/3';
rests.push({
keys: [restKey],
duration: snap.vex,
dots: snap.dots,
isRest: true,
});
remaining -= snap.beats;
if (remaining < 0.125) break;
}
return rests;
}
// Group simultaneous notes into chords
function groupIntoChords(notes) {
if (notes.length === 0) return [];
const groups = [];
let current = [notes[0]];
for (let i = 1; i < notes.length; i++) {
if (Math.abs(notes[i].beatPos - current[0].beatPos) < 0.05) {
current.push(notes[i]);
} else {
groups.push(current);
current = [notes[i]];
}
}
groups.push(current);
return groups;
}
export function midiToMeasures(midiObject) {
if (!midiObject || !midiObject.tracks || midiObject.tracks.length === 0) {
return { measures: [], timeSignature: [4, 4], bpm: 120 };
}
const header = midiObject.header;
const bpm = header.tempos?.[0]?.bpm || 120;
const timeSig = header.timeSignatures?.[0]?.timeSignature || [4, 4];
const beatsPerMeasure = timeSig[0];
const ppq = header.ppq || 480;
// Collect all notes with beat positions
const allNotes = [];
for (const track of midiObject.tracks) {
for (const note of track.notes) {
const beatPos = note.ticks / ppq;
const beatDur = note.durationTicks / ppq;
allNotes.push({
midi: note.midi,
beatPos,
beatDur,
velocity: note.velocity,
clef: note.midi < MIDI_SPLIT_POINT ? 'bass' : 'treble',
});
}
}
if (allNotes.length === 0) {
return { measures: [], timeSignature: timeSig, bpm };
}
allNotes.sort((a, b) => a.beatPos - b.beatPos);
// Determine total measures
const lastBeat = Math.max(...allNotes.map(n => n.beatPos + n.beatDur));
const totalMeasures = Math.ceil(lastBeat / beatsPerMeasure);
const measures = [];
for (let m = 0; m < totalMeasures; m++) {
const measureStart = m * beatsPerMeasure;
const measureEnd = measureStart + beatsPerMeasure;
// Get notes that start in this measure
const trebleNotes = allNotes
.filter(n => n.clef === 'treble' && n.beatPos >= measureStart - 0.05 && n.beatPos < measureEnd - 0.05)
.map(n => ({ ...n, beatPos: n.beatPos - measureStart }));
const bassNotes = allNotes
.filter(n => n.clef === 'bass' && n.beatPos >= measureStart - 0.05 && n.beatPos < measureEnd - 0.05)
.map(n => ({ ...n, beatPos: n.beatPos - measureStart }));
measures.push({
treble: buildVoice(trebleNotes, beatsPerMeasure, 'treble'),
bass: buildVoice(bassNotes, beatsPerMeasure, 'bass'),
measureStart,
});
}
return { measures, timeSignature: timeSig, bpm };
}
function buildVoice(notes, beatsPerMeasure, clef) {
if (notes.length === 0) {
// Whole rest for empty measure
const restKey = clef === 'treble' ? 'b/4' : 'd/3';
return [{ keys: [restKey], duration: 'wr', dots: 0, isRest: true }];
}
const chordGroups = groupIntoChords(notes);
const voice = [];
let currentBeat = 0;
for (const group of chordGroups) {
const chordBeat = group[0].beatPos;
// Fill gap before this chord with rests
if (chordBeat - currentBeat > 0.125) {
voice.push(...fillWithRests(chordBeat - currentBeat, clef));
}
// Use shortest note in chord for duration (VexFlow chords share one duration)
const minDur = Math.min(...group.map(n => n.beatDur));
const snapped = snapDuration(minDur, DURATION_TABLE);
// Clamp duration so it doesn't exceed measure boundary
const remainingInMeasure = beatsPerMeasure - chordBeat;
const finalSnap = snapped.beats > remainingInMeasure
? snapDuration(remainingInMeasure, DURATION_TABLE)
: snapped;
voice.push({
keys: group.map(n => midiToVexKey(n.midi)).sort(),
duration: finalSnap.vex,
dots: finalSnap.dots,
isRest: false,
beatOffset: chordBeat,
});
currentBeat = chordBeat + finalSnap.beats;
}
// Fill trailing gap with rests
if (beatsPerMeasure - currentBeat > 0.125) {
voice.push(...fillWithRests(beatsPerMeasure - currentBeat, clef));
}
return voice;
}