UNPKG

musicvis-lib

Version:

Music analysis and visualization library

532 lines (519 loc) 18 kB
import { group, max } from 'd3' import Note from '../types/Note.js' import { bpmToSecondsPerBeat } from '../utils/MusicUtils.js' import { roundToNDecimals } from '../utils/MathUtils.js' /** * @module fileFormats/MidiParser * @todo parse pitch bends * @todo after tempo changes notes and measure time do not align, * see "[Test] Tempo change.mid" */ // Precision in number of digits when rounding seconds const ROUNDING_PRECISION = 5 /** * Parses a MIDI JSON file to get Note objects with absolute time in seconds. * * @see https://github.com/colxi/midi-parser-js/wiki/MIDI-File-Format-Specifications * @param {object} data MIDI data in JSON format * @param {boolean} splitFormat0IntoTracks split MIDI format 0 data into tracks * instead of using channels? * @param {boolean} log set to true to log results etc. to the console * @returns {object} including an array of note objects and meta information */ export function preprocessMidiFileData (data, splitFormat0IntoTracks = true, log = false) { if (data === null || data === undefined) { return } if (!data.track) { console.warn('[MidiParser] MIDI data has no track') return } if (log) { console.groupCollapsed('[MidiParser] Preprocessing MIDI file data') } // Parse notes let parsedTracks = [] const { tempoChanges, beatTypeChanges, keySignatureChanges } = getSignatureChanges(data.track) for (const track of data.track) { const t = parseMidiTrack( track, data.timeDivision, tempoChanges, beatTypeChanges, keySignatureChanges, log ) if (t !== null) { parsedTracks.push(t) } } // Split MIDI format 0 data into tracks instead of having channels if (data.formatType === 0 && splitFormat0IntoTracks && parsedTracks.length === 1) { parsedTracks = splitFormat0(parsedTracks) } // Generate measure lines from tempo and beat type changes const totalTime = max(parsedTracks, d => d?.totalTime ?? 0) const measureLinePositions = getMeasureLines(tempoChanges, beatTypeChanges, totalTime) // Get measure indices, the note's index where a new measure starts, for each track for (const track of parsedTracks) { track.measureIndices = getMeasureIndices(track.noteObjs, measureLinePositions) } // Resulting track const result = { tracks: parsedTracks, totalTime, tempoChanges, beatTypeChanges, keySignatureChanges, measureLinePositions } if (log) { console.log(`Got ${parsedTracks.length} MIDI tracks`, result) console.groupEnd() } return result } /** * Parses a single MIDI track and pushes it to parsedTracks if it contains notes * * @private * @param {object} track a MIDI track * @param {number} timeDivision MIDI time division * @param {object[]} tempoChanges array with tempo change events * @param {object[]} beatTypeChanges array with beat type change events * @param {object[]} keySignatureChanges array with key signature change events * @param {boolean} log if true, debug info will be logged to the console * @returns {object} parsed track */ function parseMidiTrack (track, timeDivision, tempoChanges, beatTypeChanges, keySignatureChanges, log) { const notes = [] let tempo = tempoChanges[0]?.tempo ?? 120 let currentTick = 0 let currentTime let milliSecondsPerTick = getMillisecondsPerTick(tempo, timeDivision) let tickOfLastTempoChange = 0 let timeOfLastTempoChange = 0 // This map stores note-on note that have not yet been finished by a note-off const unfinishedNotes = new Map() for (const event of track.event) { const type = event.type // Ignore delta time if it is a meta event (fixes some parsing issues) if (type === EVENT_TYPES.meta) { continue } currentTick += event.deltaTime // Update beat type change times for (const btc of beatTypeChanges) { if (btc.time === undefined && btc.tick <= currentTick) { const t = (btc.tick - tickOfLastTempoChange) * milliSecondsPerTick / 1000 + timeOfLastTempoChange btc.time = roundToNDecimals(t, ROUNDING_PRECISION) } } // Update key signature change times for (const ksc of keySignatureChanges) { if (ksc.time === undefined && ksc.tick <= currentTick) { const t = (ksc.tick - tickOfLastTempoChange) * milliSecondsPerTick / 1000 + timeOfLastTempoChange ksc.time = roundToNDecimals(t, ROUNDING_PRECISION) } } // Handle last tempo change in track differently let mostRecentTempoChange if (tempoChanges.length > 0 && currentTick > tempoChanges[tempoChanges.length - 1].tick) { mostRecentTempoChange = tempoChanges[tempoChanges.length - 1] } // Get current tempo, as soon as we are too far, 1 step back for (let index = 1; index < tempoChanges.length; index++) { const tick = tempoChanges[index].tick if (tick > currentTick) { const change = tempoChanges[index - 1] mostRecentTempoChange = change break } } // React to tempo changes if (mostRecentTempoChange && mostRecentTempoChange.tempo !== tempo) { const tick = mostRecentTempoChange.tick timeOfLastTempoChange = (tick - tickOfLastTempoChange) * milliSecondsPerTick / 1000 + timeOfLastTempoChange tickOfLastTempoChange = tick mostRecentTempoChange.time = roundToNDecimals(timeOfLastTempoChange, ROUNDING_PRECISION) tempo = mostRecentTempoChange.tempo milliSecondsPerTick = getMillisecondsPerTick(tempo, timeDivision) } // Update current time in seconds currentTime = (currentTick - tickOfLastTempoChange) * milliSecondsPerTick / 1000 + timeOfLastTempoChange // Skip events that are neither note-on nor note-off if (type !== EVENT_TYPES.noteOn && type !== EVENT_TYPES.noteOff) { continue } const [pitch, velocity] = event.data const channel = event.channel const key = `${pitch} ${channel}` if (type === EVENT_TYPES.noteOff || (type === EVENT_TYPES.noteOn && velocity === 0)) { // Handle note-off if (unfinishedNotes.has(key)) { unfinishedNotes.get(key).end = roundToNDecimals(currentTime, ROUNDING_PRECISION) unfinishedNotes.delete(key) } else { if (log) { console.warn('Did not find an unfinished note for note-off event!') console.log(event) } } } else if (type === EVENT_TYPES.noteOn) { // Handle note-on const newNote = new Note( pitch, roundToNDecimals(currentTime, ROUNDING_PRECISION), velocity, channel ) notes.push(newNote) unfinishedNotes.set(key, newNote) } else { continue } } // Fix missing note ends const neededToFix = [] for (const note of notes) { if (note.end === -1) { note.end = roundToNDecimals(currentTime, ROUNDING_PRECISION) neededToFix.push(note) } } if (neededToFix.length > 0) { console.warn(`had to fix ${neededToFix.length} notes`) console.log(neededToFix) } // Save parsed track with meta information const { trackName, instrument, instrumentName } = getInstrumentAndTrackName(track) if (notes.length > 0) { const parsedTrack = { noteObjs: notes, totalTime: currentTime, trackName: trackName ?? 'Track', instrument, instrumentName: instrumentName ?? 'Unknown instrument' } return parsedTrack } else { return null } } /** * Finds out the name of the track and the instrument, if this data is available * * @private * @param {object} track MIDI track * @returns {{trackName, instrument, instrumentName}} meta data with either * values or null when no information found */ function getInstrumentAndTrackName (track) { let trackName = null let instrument = null let instrumentName = null for (const event of track.event) { if (event.type === EVENT_TYPES.meta && event.metaType === META_TYPES.trackName) { trackName = event.data } if (event.type === EVENT_TYPES.programChange) { instrument = event.data } if (event.type === EVENT_TYPES.meta && event.metaType === META_TYPES.instrumentName) { instrumentName = event.data } } return { trackName, instrument, instrumentName } } /** * Caclulates the time positions of measure lines by looking at tempo and beat * type change events * * @private * @param {object[]} tempoChanges tempo change events * @param {object[]} beatTypeChanges beat type change events * @param {number} totalTime total time * @returns {number[]} measure line times in seconds */ function getMeasureLines (tempoChanges, beatTypeChanges, totalTime) { const measureLines = [] // Generate measure lines from tempo and beat type changes let tempo = 120 let beats = 4 let beatType = 4 let currentTime = 0 let currentBeatsInMeasure = 0 let timeOfLastTempoChange = 0 let timeSinceLastTempoChange = 0 while (currentTime < totalTime) { // Get current tempo and beat type let mostRecentTempoChange for (const t of tempoChanges) { if (t.time <= currentTime) { mostRecentTempoChange = t.tempo } } if (mostRecentTempoChange && mostRecentTempoChange !== tempo) { timeOfLastTempoChange = currentTime timeSinceLastTempoChange = 0 tempo = mostRecentTempoChange } for (const b of beatTypeChanges) { if (b.time <= currentTime) { beats = b.beats beatType = b.beatType } } // Update time and beats currentBeatsInMeasure++ const secondsPerBeat = bpmToSecondsPerBeat(tempo) / (beatType / 4) timeSinceLastTempoChange += secondsPerBeat currentTime = timeOfLastTempoChange + timeSinceLastTempoChange if (currentBeatsInMeasure >= beats) { // Measure is full currentBeatsInMeasure = 0 measureLines.push(roundToNDecimals(currentTime, ROUNDING_PRECISION)) } } return measureLines } /** * For the notes of one track, computes the notes' indices where new measures * start. * * @param {Note[]} notes notes of a track * @param {numer[]} measureTimes times in seconds where new measures start * @returns {number[]} measure indices */ function getMeasureIndices (notes, measureTimes) { const measureIndices = [] const todo = [...measureTimes] for (const [index, note] of notes.entries()) { if (note.start >= todo[0]) { todo.shift() measureIndices.push(index) } } return measureIndices } /** * Split MIDI format 0 data into tracks instead of having channels, * creates one track for each channel * * @private * @param {Note[][]} tracks parsed MIDI tracks * @returns {Note[][]} splitted tracks */ function splitFormat0 (tracks) { if (tracks.length > 1) { console.warn('Splitting a format 0 file with more than 1 track will result in all but the first beeing lost!') } // console.log('Splitting format 0 file into tracks based on channel'); const grouped = group(tracks[0].noteObjs, d => d.channel) // All tracks will share the meta infomation of the 0th track // Assign the splitted-by-channel notes to their new tracks const splittedTracks = [] for (const notes of grouped.values()) { splittedTracks.push({ ...tracks[0], noteObjs: notes }) } return splittedTracks } /** * Given the tempo and time division, returns the number of milliseconds * each MIDI time tick corresponds to * * @private * @param {number} tempo tempo * @param {number} timeDivision time division * @returns {number} milli seconds per tick */ function getMillisecondsPerTick (tempo, timeDivision) { const milliSecondsPerBeat = 1 / tempo * 60_000 const milliSecondsPerTick = milliSecondsPerBeat / timeDivision return milliSecondsPerTick } /** * Retrieves all tempo and beat type changes from a MIDI file's tracks. * * @see https://github.com/colxi/midi-parser-js/wiki/MIDI-File-Format-Specifications * @private * @param {Array} tracks MIDI JSON file tracks * @returns {object} {tempoChanges, beatTypeChanges} as arrays of objects, which * contain the MIDI tick and the new information */ function getSignatureChanges (tracks) { const tempoChanges = [] const beatTypeChanges = [] const keySignatureChanges = [] let currentTick = 0 let lastTempo = null for (const track of tracks) { for (const event of track.event) { // Get timing of events currentTick += event.deltaTime // Tempo change if (event.type === EVENT_TYPES.meta && event.metaType === META_TYPES.setTempo) { const milliSecondsPerQuarter = event.data / 1000 const tempo = Math.round(1 / (milliSecondsPerQuarter / 60_000)) // Ignore tempo changes that don't change the tempo if (tempo !== lastTempo) { tempoChanges.push({ tick: currentTick, tempo, time: currentTick === 0 ? 0 : undefined }) lastTempo = tempo } } // Beat type change if (event.type === EVENT_TYPES.meta && event.metaType === META_TYPES.timeSignature) { const d = event.data const beats = d[0] const beatType = 2 ** d[1] const newEntry = { tick: currentTick, beats, beatType } if (beatTypeChanges.length === 0) { beatTypeChanges.push(newEntry) } else { // If it id not change, do not add const last = beatTypeChanges[beatTypeChanges.length - 1] if (last.beats !== beats || last.beatType !== beatType) { beatTypeChanges.push(newEntry) } } // console.log(`Metro: ${d[2]}`); // console.log(`32nds: ${d[3]}`); } // Key change if (event.type === EVENT_TYPES.meta && event.metaType === META_TYPES.keySignature) { // console.log('keychange', event); const d = event.data if (!KEY_SIG_MAP.has(d)) { console.warn('[MidiParser] Invalid key signature', d) } else { const { key, scale } = KEY_SIG_MAP.get(d) const newEntry = { tick: currentTick, key, scale } if (keySignatureChanges.length === 0) { keySignatureChanges.push(newEntry) } else { // If it id not change, do not add const last = keySignatureChanges[keySignatureChanges.length - 1] if (last.key !== key || last.scale !== scale) { keySignatureChanges.push(newEntry) } } } } } } // Default values if (tempoChanges.length === 0 || tempoChanges[0].time > 0) { tempoChanges.unshift({ tempo: 120, time: 0 }) } if (beatTypeChanges.length === 0 || beatTypeChanges[0].time > 0) { beatTypeChanges.unshift({ beats: 4, beatType: 4, time: 0 }) } if (keySignatureChanges.length === 0 || keySignatureChanges[0].time > 0) { keySignatureChanges.unshift({ key: 'C', scale: 'major', time: 0 }) } return { tempoChanges, beatTypeChanges, keySignatureChanges } } /** * MIDI event types and meta types and their codes * * @see https://github.com/colxi/midi-parser-js/wiki/MIDI-File-Format-Specifications * Event Type Value Value decimal Parameter 1 Parameter 2 * Note Off 0x8 8 note number velocity * Note On 0x9 9 note number velocity * Note Aftertouch 0xA 10 note number aftertouch value * Controller 0xB 11 controller number controller value * Program Change 0xC 12 program number not used * Channel Aftertouch 0xD 13 aftertouch value not used * Pitch Bend 0xE 14 pitch value (LSB) pitch value (MSB) * Meta 0xFF 255 parameters depend on meta type * @type {object} */ const EVENT_TYPES = { noteOff: 0x8, noteOn: 0x9, noteAftertouch: 0xA, controller: 0xB, programChange: 0xC, channelAftertouch: 0xD, pitchBend: 0xE, meta: 0xFF } /** * @type {object} */ const META_TYPES = { sequenceNumber: 0x0, textEvent: 0x1, copyright: 0x2, trackName: 0x3, instrumentName: 0x4, lyrics: 0x5, marker: 0x6, cuePoint: 0x7, channelPrefix: 0x20, endOfTrack: 0x2F, setTempo: 0x51, smpteOffset: 0x54, timeSignature: 0x58, keySignature: 0x59, sequencerSpecific: 0x7F } /** * Maps needed for key signature detection from number of sharps / flats * * @type {Map<number,object>} * @see https://www.recordingblogs.com/wiki/midi-key-signature-meta-message */ const KEY_SIG_MAP = new Map([ // major [0xF9_00, { key: 'Cb', scale: 'major' }], [0xFA_00, { key: 'Gb', scale: 'major' }], [0xFB_00, { key: 'Db', scale: 'major' }], [0xFC_00, { key: 'Ab', scale: 'major' }], [0xFD_00, { key: 'Eb', scale: 'major' }], [0xFE_00, { key: 'Bb', scale: 'major' }], [0xFF_00, { key: 'F', scale: 'major' }], [0x00_00, { key: 'C', scale: 'major' }], [0x01_00, { key: 'G', scale: 'major' }], [0x02_00, { key: 'D', scale: 'major' }], [0x03_00, { key: 'A', scale: 'major' }], [0x04_00, { key: 'E', scale: 'major' }], [0x05_00, { key: 'B', scale: 'major' }], [0x06_00, { key: 'F#', scale: 'major' }], [0x07_00, { key: 'C#', scale: 'major' }], // minor [0xF9_01, { key: 'Ab', scale: 'minor' }], [0xFA_01, { key: 'Eb', scale: 'minor' }], [0xFB_01, { key: 'Bb', scale: 'minor' }], [0xFC_01, { key: 'F', scale: 'minor' }], [0xFD_01, { key: 'C', scale: 'minor' }], [0xFE_01, { key: 'G', scale: 'minor' }], [0xFF_01, { key: 'D', scale: 'minor' }], [0x00_01, { key: 'A', scale: 'minor' }], [0x01_01, { key: 'E', scale: 'minor' }], [0x02_01, { key: 'B', scale: 'minor' }], [0x03_01, { key: 'F#', scale: 'minor' }], [0x04_01, { key: 'C#', scale: 'minor' }], [0x05_01, { key: 'G#', scale: 'minor' }], [0x06_01, { key: 'D#', scale: 'minor' }], [0x07_01, { key: 'A#', scale: 'minor' }] ])