Spaces:
Sleeping
Sleeping
| 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; | |
| } | |