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; }