|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
import { Midi } from '@tonejs/midi'; |
|
|
import type { Score, Part, Measure, Note } from '../store/notation'; |
|
|
|
|
|
export interface MidiParseOptions { |
|
|
tempo?: number; |
|
|
timeSignature?: { numerator: number; denominator: number }; |
|
|
keySignature?: string; |
|
|
splitAtMiddleC?: boolean; |
|
|
middleCNote?: number; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export async function parseMidiFile( |
|
|
midiData: ArrayBuffer, |
|
|
options: MidiParseOptions = {} |
|
|
): Promise<Score> { |
|
|
const midi = new Midi(midiData); |
|
|
|
|
|
|
|
|
const tempo = options.tempo || midi.header.tempos[0]?.bpm || 120; |
|
|
const timeSignature = options.timeSignature || { |
|
|
numerator: midi.header.timeSignatures[0]?.timeSignature[0] || 4, |
|
|
denominator: midi.header.timeSignatures[0]?.timeSignature[1] || 4, |
|
|
}; |
|
|
const keySignature = options.keySignature || 'C'; |
|
|
|
|
|
|
|
|
const allNotes = extractNotesFromMidi(midi); |
|
|
|
|
|
|
|
|
const measureDuration = (timeSignature.numerator / timeSignature.denominator) * 4; |
|
|
const parts = createPartsFromNotes( |
|
|
allNotes, |
|
|
measureDuration, |
|
|
options.splitAtMiddleC ?? true, |
|
|
options.middleCNote ?? 60, |
|
|
tempo |
|
|
); |
|
|
|
|
|
return { |
|
|
id: 'score-1', |
|
|
title: midi.name || 'Transcribed Score', |
|
|
composer: 'YourMT3+', |
|
|
key: keySignature, |
|
|
timeSignature: `${timeSignature.numerator}/${timeSignature.denominator}`, |
|
|
tempo, |
|
|
parts, |
|
|
measures: parts[0]?.measures || [], |
|
|
}; |
|
|
} |
|
|
|
|
|
interface MidiNote { |
|
|
midi: number; |
|
|
time: number; |
|
|
duration: number; |
|
|
velocity: number; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function extractNotesFromMidi(midi: Midi): MidiNote[] { |
|
|
const notes: MidiNote[] = []; |
|
|
|
|
|
for (const track of midi.tracks) { |
|
|
for (const note of track.notes) { |
|
|
notes.push({ |
|
|
midi: note.midi, |
|
|
time: note.time, |
|
|
duration: note.duration, |
|
|
velocity: note.velocity, |
|
|
}); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
notes.sort((a, b) => a.time - b.time); |
|
|
|
|
|
return notes; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function createPartsFromNotes( |
|
|
notes: MidiNote[], |
|
|
measureDuration: number, |
|
|
splitStaff: boolean, |
|
|
middleCNote: number, |
|
|
tempo: number |
|
|
): Part[] { |
|
|
if (splitStaff) { |
|
|
|
|
|
const trebleNotes = notes.filter((n) => n.midi >= middleCNote); |
|
|
const bassNotes = notes.filter((n) => n.midi < middleCNote); |
|
|
|
|
|
return [ |
|
|
{ |
|
|
id: 'part-treble', |
|
|
name: 'Piano Right Hand', |
|
|
clef: 'treble', |
|
|
measures: createMeasures(trebleNotes, measureDuration, tempo), |
|
|
}, |
|
|
{ |
|
|
id: 'part-bass', |
|
|
name: 'Piano Left Hand', |
|
|
clef: 'bass', |
|
|
measures: createMeasures(bassNotes, measureDuration, tempo), |
|
|
}, |
|
|
]; |
|
|
} else { |
|
|
|
|
|
return [ |
|
|
{ |
|
|
id: 'part-1', |
|
|
name: 'Piano', |
|
|
clef: 'treble', |
|
|
measures: createMeasures(notes, measureDuration, tempo), |
|
|
}, |
|
|
]; |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function createMeasures(notes: MidiNote[], measureDuration: number, tempo: number = 120): Measure[] { |
|
|
if (notes.length === 0) { |
|
|
return [ |
|
|
{ |
|
|
id: 'measure-1', |
|
|
number: 1, |
|
|
notes: [], |
|
|
}, |
|
|
]; |
|
|
} |
|
|
|
|
|
|
|
|
const maxTime = Math.max(...notes.map((n) => n.time + n.duration)); |
|
|
const numMeasures = Math.ceil(maxTime / measureDuration); |
|
|
|
|
|
const measures: Measure[] = []; |
|
|
|
|
|
for (let i = 0; i < numMeasures; i++) { |
|
|
const measureStart = i * measureDuration; |
|
|
const measureEnd = (i + 1) * measureDuration; |
|
|
|
|
|
|
|
|
const measureNotes = notes |
|
|
.filter((n) => n.time >= measureStart && n.time < measureEnd) |
|
|
.map((midiNote, idx) => convertMidiNoteToNote(midiNote, `m${i + 1}-n${idx}`, measureStart, tempo)); |
|
|
|
|
|
measures.push({ |
|
|
id: `measure-${i + 1}`, |
|
|
number: i + 1, |
|
|
notes: measureNotes, |
|
|
}); |
|
|
} |
|
|
|
|
|
return measures; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function convertMidiNoteToNote(midiNote: MidiNote, id: string, measureStart: number, tempo: number): Note { |
|
|
const { pitch, octave, accidental } = midiNumberToPitch(midiNote.midi); |
|
|
const { duration, dotted } = durationToNoteName(midiNote.duration, tempo); |
|
|
|
|
|
return { |
|
|
id, |
|
|
pitch: `${pitch}${octave}`, |
|
|
duration, |
|
|
octave, |
|
|
startTime: midiNote.time - measureStart, |
|
|
dotted, |
|
|
accidental, |
|
|
isRest: false, |
|
|
|
|
|
}; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function midiNumberToPitch(midiNumber: number): { |
|
|
pitch: string; |
|
|
octave: number; |
|
|
accidental?: 'sharp' | 'flat' | 'natural'; |
|
|
} { |
|
|
const pitchClasses = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B']; |
|
|
const pitchClass = midiNumber % 12; |
|
|
const octave = Math.floor(midiNumber / 12) - 1; |
|
|
const pitchName = pitchClasses[pitchClass]; |
|
|
|
|
|
let accidental: 'sharp' | 'flat' | 'natural' | undefined; |
|
|
if (pitchName.includes('#')) { |
|
|
accidental = 'sharp'; |
|
|
} |
|
|
|
|
|
return { |
|
|
pitch: pitchName.replace('#', ''), |
|
|
octave, |
|
|
accidental, |
|
|
}; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function durationToNoteName(duration: number, tempo: number): { duration: string; dotted: boolean } { |
|
|
|
|
|
|
|
|
const quarterNoteDuration = 60 / tempo; |
|
|
|
|
|
const durationInQuarters = duration / quarterNoteDuration; |
|
|
|
|
|
|
|
|
const durations: [number, string, boolean][] = [ |
|
|
[4, 'whole', false], |
|
|
[3, 'half', true], |
|
|
[2, 'half', false], |
|
|
[1.5, 'quarter', true], |
|
|
[1, 'quarter', false], |
|
|
[0.75, 'eighth', true], |
|
|
[0.5, 'eighth', false], |
|
|
[0.375, '16th', true], |
|
|
[0.25, '16th', false], |
|
|
[0.125, '32nd', false], |
|
|
]; |
|
|
|
|
|
let closestDuration = durations[0]; |
|
|
let minDiff = Math.abs(durationInQuarters - durations[0][0]); |
|
|
|
|
|
for (const [value, name, dotted] of durations) { |
|
|
const diff = Math.abs(durationInQuarters - value); |
|
|
if (diff < minDiff) { |
|
|
minDiff = diff; |
|
|
closestDuration = [value, name, dotted]; |
|
|
} |
|
|
} |
|
|
|
|
|
return { |
|
|
duration: closestDuration[1], |
|
|
dotted: closestDuration[2], |
|
|
}; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export function assignChordIds(score: Score): Score { |
|
|
const CHORD_TOLERANCE = 0.05; |
|
|
|
|
|
for (const part of score.parts) { |
|
|
for (const measure of part.measures) { |
|
|
const notes = measure.notes; |
|
|
|
|
|
|
|
|
const groups: Record<string, Note[]> = {}; |
|
|
|
|
|
for (const note of notes) { |
|
|
const timeKey = Math.round(note.startTime / CHORD_TOLERANCE).toString(); |
|
|
if (!groups[timeKey]) { |
|
|
groups[timeKey] = []; |
|
|
} |
|
|
groups[timeKey].push(note); |
|
|
} |
|
|
|
|
|
|
|
|
for (const [timeKey, groupNotes] of Object.entries(groups)) { |
|
|
if (groupNotes.length > 1) { |
|
|
const chordId = `chord-${measure.id}-${timeKey}`; |
|
|
groupNotes.forEach((note) => { |
|
|
note.chordId = chordId; |
|
|
}); |
|
|
} |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
return score; |
|
|
} |
|
|
|