Spaces:
Sleeping
Sleeping
const MidiSequence = require("./MidiSequence.js"); | |
const PedalControllerTypes = { | |
64: "Sustain", | |
65: "Portamento", | |
66: "Sostenuto", | |
67: "Soft", | |
}; | |
class Notation { | |
static parseMidi (data, {fixOverlap = true} = {}) { | |
const channelStatus = []; | |
const pedalStatus = {}; | |
const pedals = {}; | |
const channels = []; | |
const bars = []; | |
let time = 0; | |
let millisecondsPerBeat = 600000 / 120; | |
let beats = 0; | |
let numerator = 4; | |
let barIndex = 0; | |
const keyRange = {}; | |
let rawTicks = 0; | |
let ticks = 0; | |
let correspondences; | |
const tempos = []; | |
const ticksPerBeat = data.header.ticksPerBeat; | |
let rawEvents = MidiSequence.midiToSequence(data); | |
if (fixOverlap) | |
rawEvents = MidiSequence.trimSequence(MidiSequence.fixOverlapNotes(rawEvents)); | |
const events = rawEvents.map(d => ({ | |
data: d[0].event, | |
track: d[0].track, | |
deltaTime: d[1], | |
deltaTicks: d[0].ticksToEvent, | |
})); | |
let index = 0; | |
const ticksNormal = 1; | |
for (const ev of events) { | |
rawTicks += ev.deltaTicks; | |
ticks = Math.round(rawTicks * ticksNormal); | |
if (ev.deltaTicks > 0) { | |
// append bars | |
const deltaBeats = ev.deltaTicks / ticksPerBeat; | |
for (let b = Math.ceil(beats); b < beats + deltaBeats; ++b) { | |
const t = time + (b - beats) * millisecondsPerBeat; | |
bars.push({time: t, index: barIndex % numerator}); | |
++barIndex; | |
} | |
beats += deltaBeats; | |
} | |
time += ev.deltaTime; | |
//const ticksTime = beats * millisecondsPerBeat; | |
//console.log("time:", time, ticksTime, ticksTime - time); | |
ev.time = time; | |
ev.ticks = ticks; | |
const event = ev.data; | |
switch (event.type) { | |
case "channel": | |
//channelStatus[event.channel] = channelStatus[event.channel] || []; | |
switch (event.subtype) { | |
case "noteOn": | |
{ | |
const pitch = event.noteNumber; | |
//channelStatus[event.channel][pitch] = { | |
channelStatus.push({ | |
channel: event.channel, | |
pitch, | |
startTick: ticks, | |
start: time, | |
velocity: event.velocity, | |
beats: beats, | |
track: ev.track, | |
}); | |
keyRange.low = Math.min(keyRange.low || pitch, pitch); | |
ev.index = index; | |
++index; | |
} | |
break; | |
case "noteOff": | |
{ | |
const pitch = event.noteNumber; | |
channels[event.channel] = channels[event.channel] || []; | |
const statusIndex = channelStatus.findIndex(status => status.channel == event.channel && status.pitch == pitch); | |
if (statusIndex >= 0) { | |
const status = channelStatus.splice(statusIndex, 1)[0]; | |
channels[event.channel].push({ | |
channel: event.channel, | |
startTick: status.startTick, | |
endTick: ticks, | |
pitch, | |
start: status.start, | |
duration: time - status.start, | |
velocity: status.velocity, | |
beats: status.beats, | |
track: status.track, | |
finger: status.finger, | |
}); | |
} | |
else | |
console.debug("unexpected noteOff: ", time, event); | |
keyRange.high = Math.max(keyRange.high || pitch, pitch); | |
} | |
break; | |
case "controller": | |
switch (event.controllerType) { | |
// pedal controllers | |
case 64: | |
case 65: | |
case 66: | |
case 67: | |
const pedalType = PedalControllerTypes[event.controllerType]; | |
pedalStatus[event.channel] = pedalStatus[event.channel] || {}; | |
pedals[event.channel] = pedals[event.channel] || []; | |
const status = pedalStatus[event.channel][pedalType]; | |
if (status) | |
pedals[event.channel].push({type: pedalType, start: status.start, duration: time - status.start, value: status.value}); | |
pedalStatus[event.channel][pedalType] = {start: time, value: event.value}; | |
break; | |
} | |
break; | |
} | |
break; | |
case "meta": | |
switch (event.subtype) { | |
case "setTempo": | |
millisecondsPerBeat = event.microsecondsPerBeat / 1000; | |
//beats = Math.round(beats); | |
//console.assert(Number.isFinite(time), "invalid time:", time); | |
tempos.push({tempo: event.microsecondsPerBeat, tick: ticks, time}); | |
break; | |
case "timeSignature": | |
numerator = event.numerator; | |
barIndex = 0; | |
break; | |
case "text": | |
if (!correspondences && /^find-corres:/.test(event.text)) { | |
const captures = event.text.match(/:([\d\,-]+)/); | |
const str = captures && captures[1] || ""; | |
correspondences = str.split(",").map(s => Number(s)); | |
} | |
else if (/fingering\(.*\)/.test(event.text)) { | |
const [_, fingers] = event.text.match(/\((.+)\)/); | |
const finger = Number(fingers); | |
if (!Number.isNaN(finger)) { | |
const status = channelStatus[channelStatus.length - 1]; | |
if (status) | |
status.finger = finger; | |
const event = events.find(e => e.index == index - 1); | |
if (event) | |
event.data.finger = finger; | |
} | |
} | |
break; | |
case "copyrightNotice": | |
console.log("MIDI copyright:", event.text); | |
break; | |
} | |
break; | |
} | |
} | |
channelStatus.forEach(status => { | |
console.debug("unclosed noteOn event at", status.startTick, status); | |
channels[status.channel].push({ | |
startTick: status.startTick, | |
endTick: ticks, | |
pitch: status.pitch, | |
start: status.start, | |
duration: time - status.start, | |
velocity: status.velocity, | |
beats: status.beats, | |
track: status.track, | |
finger: status.finger, | |
}); | |
}); | |
return new Notation({ | |
channels, | |
keyRange, | |
pedals, | |
bars, | |
endTime: time, | |
endTick: ticks, | |
correspondences, | |
events, | |
tempos, | |
ticksPerBeat, | |
meta: {}, | |
}); | |
} | |
constructor (fields) { | |
Object.assign(this, fields); | |
// channels to notes | |
this.notes = []; | |
for (const channel of this.channels) { | |
if (channel) { | |
for (const note of channel) | |
this.notes.push(note); | |
} | |
} | |
this.notes.sort(function (n1, n2) { | |
return n1.start - n2.start; | |
}); | |
for (const i in this.notes) | |
this.notes[i].index = Number(i); | |
// duration | |
this.duration = this.notes.length > 0 ? (this.endTime - this.notes[0].start) : 0, | |
//this.endSoftIndex = this.notes.length ? this.notes[this.notes.length - 1].softIndex : 0; | |
// pitch map | |
this.pitchMap = []; | |
for (const c in this.channels) { | |
for (const n in this.channels[c]) { | |
const pitch = this.channels[c][n].pitch; | |
this.pitchMap[pitch] = this.pitchMap[pitch] || []; | |
this.pitchMap[pitch].push(this.channels[c][n]); | |
} | |
} | |
this.pitchMap.forEach(notes => notes.sort((n1, n2) => n1.start - n2.start)); | |
/*// setup measure notes index | |
if (this.measures) { | |
const measure_list = []; | |
let last_measure = null; | |
const measure_entries = Object.entries(this.measures).sort((e1, e2) => Number(e1[0]) - Number(e2[0])); | |
for (const [t, measure] of measure_entries) { | |
//console.log("measure time:", Number(t)); | |
measure.startTick = Number(t); | |
measure.notes = []; | |
if (last_measure) | |
last_measure.endTick = measure.startTick; | |
const m = measure.measure; | |
measure_list[m] = measure_list[m] || []; | |
measure_list[m].push(measure); | |
last_measure = measure; | |
} | |
if (last_measure) | |
last_measure.endTick = this.notes[this.notes.length - 1].endTick; | |
for (const i in this.notes) { | |
const note = this.notes[i]; | |
for (const t in this.measures) { | |
const measure = this.measures[t]; | |
if (note.startTick >= measure.startTick && note.startTick < measure.endTick || note.endTick > measure.startTick && note.endTick <= measure.endTick) | |
measure.notes.push(note); | |
} | |
} | |
this.measure_list = measure_list; | |
}*/ | |
// prepare beats info | |
if (this.meta.beatInfos) { | |
for (let i = 0; i < this.meta.beatInfos.length; ++i) { | |
const info = this.meta.beatInfos[i]; | |
if (i > 0) { | |
const lastInfo = this.meta.beatInfos[i - 1]; | |
info.beatIndex = lastInfo.beatIndex + Math.ceil((info.tick - lastInfo.tick) / this.ticksPerBeat); | |
} | |
else | |
info.beatIndex = 0; | |
} | |
} | |
// compute tempos tick -> time | |
{ | |
let time = 0; | |
let ticks = 0; | |
let tempo = 500000; | |
for (const entry of this.tempos) { | |
const deltaTicks = entry.tick - ticks; | |
time += (tempo / 1000) * deltaTicks / this.ticksPerBeat; | |
ticks = entry.tick; | |
tempo = entry.tempo; | |
entry.time = time; | |
} | |
} | |
} | |
findChordBySoftindex (softIndex, radius = 0.8) { | |
return this.notes.filter(note => Math.abs(note.softIndex - softIndex) < radius); | |
} | |
averageTempo (tickRange) { | |
tickRange = tickRange || {from: 0, to: this.endtick}; | |
console.assert(this.tempos, "no tempos."); | |
console.assert(tickRange.to > tickRange.from, "range is invalid:", tickRange); | |
const span = index => { | |
const from = Math.max(tickRange.from, this.tempos[index].tick); | |
const to = (index < this.tempos.length - 1) ? Math.min(this.tempos[index + 1].tick, tickRange.to) : tickRange.to; | |
return Math.max(0, to - from); | |
}; | |
const tempo_sum = this.tempos.reduce((sum, tempo, index) => sum + tempo.tempo * span(index), 0); | |
const average = tempo_sum / (tickRange.to - tickRange.from); | |
// convert microseconds per beat to beats per minute | |
return 60e+6 / average; | |
} | |
ticksToTime (tick) { | |
console.assert(Number.isFinite(tick), "invalid tick value:", tick); | |
console.assert(this.tempos && this.tempos.length, "no tempos."); | |
const next_tempo_index = this.tempos.findIndex(tempo => tempo.tick > tick); | |
const tempo_index = next_tempo_index < 0 ? this.tempos.length - 1 : Math.max(next_tempo_index - 1, 0); | |
const tempo = this.tempos[tempo_index]; | |
return tempo.time + (tick - tempo.tick) * tempo.tempo * 1e-3 / this.ticksPerBeat; | |
} | |
timeToTicks (time) { | |
console.assert(Number.isFinite(time), "invalid time value:", time); | |
console.assert(this.tempos && this.tempos.length, "no tempos."); | |
const next_tempo_index = this.tempos.findIndex(tempo => tempo.time > time); | |
const tempo_index = next_tempo_index < 0 ? this.tempos.length - 1 : Math.max(next_tempo_index - 1, 0); | |
const tempo = this.tempos[tempo_index]; | |
return tempo.tick + (time - tempo.time) * this.ticksPerBeat / (tempo.tempo * 1e-3); | |
} | |
tickRangeToTimeRange (tickRange) { | |
console.assert(tickRange.to >= tickRange.from, "invalid tick range:", tickRange); | |
return { | |
from: this.ticksToTime(tickRange.from), | |
to: this.ticksToTime(tickRange.to), | |
}; | |
} | |
/*getMeasureRange (measureRange) { | |
console.assert(Number.isInteger(measureRange.start) && Number.isInteger(measureRange.end), "invalid measure range:", measureRange); | |
console.assert(this.measure_list && this.measure_list[measureRange.start] && this.measure_list[measureRange.end], "no measure data for specific index:", this.measure_list, measureRange); | |
const startMeasure = this.measure_list[measureRange.start][0]; | |
let endMeasure = null; | |
for (const measure of this.measure_list[measureRange.end]) { | |
if (measure.endTick > startMeasure.startTick) { | |
endMeasure = measure; | |
break; | |
} | |
} | |
// there no path between start measure and end measure. | |
if (!endMeasure) | |
return null; | |
const tickRange = {from: startMeasure.startTick, to: endMeasure.endTick, duration: endMeasure.endTick - startMeasure.startTick}; | |
const timeRange = this.tickRangeToTimeRange(tickRange); | |
timeRange.duration = timeRange.to - timeRange.from; | |
return { | |
tickRange, | |
timeRange, | |
}; | |
}*/ | |
scaleTempo ({factor, headTempo}) { | |
console.assert(this.tempos && this.tempos.length, "[Notation.scaleTempo] tempos is empty."); | |
if (headTempo) | |
factor = headTempo / this.tempos[0].tempo; | |
console.assert(Number.isFinite(factor) && factor > 0, "[Notation.scaleTempo] invalid factor:", factor); | |
this.tempos.forEach(tempo => { | |
tempo.tempo *= factor; | |
tempo.time *= factor; | |
}); | |
this.events.forEach(event => { | |
event.deltaTime *= factor; | |
event.time *= factor; | |
}); | |
this.notes.forEach(note => { | |
note.start *= factor; | |
note.duration *= factor; | |
}); | |
this.endTime *= factor; | |
} | |
}; | |
module.exports = { | |
Notation, | |
}; | |