Spaces:
Sleeping
Sleeping
import {MusicNotation, MIDI} from "@k-l-lambda/music-widgets"; | |
import {WHOLE_DURATION_MAGNITUDE} from "../lilyParser/utils"; | |
import {PitchContextTable} from "../pitchContext"; | |
import {MatcherResult} from "./matcher"; | |
import {MeasureLayout, LayoutType} from "../measureLayout"; | |
import ImplicitType from "./implicitType"; | |
import npmPackage from "../../package.json"; | |
import pick from "../pick"; | |
const TICKS_PER_BEAT = WHOLE_DURATION_MAGNITUDE / 4; | |
const ARPEGGIO_REFERENCE_DURATION = 240; | |
// import WHOLE_DURATION_MAGNITUDE from "../lilyParser" may result in null error in nodejs | |
console.assert(Number.isFinite(TICKS_PER_BEAT), "TICKS_PER_BEAT is invalid:", TICKS_PER_BEAT); | |
export interface ChordPosition { | |
index: number; | |
count: number; | |
}; | |
interface StaffNoteProperties { | |
rest: boolean; | |
tied: boolean; | |
overlapped: boolean; | |
implicitType: ImplicitType; | |
afterGrace: boolean; | |
chordPosition: ChordPosition; | |
division: number; | |
contextIndex: number; | |
staffTrack: number; | |
}; | |
export interface Note extends MusicNotation.Note, Partial<StaffNoteProperties> { | |
id: string; | |
measure: number; | |
endTick: number; | |
outMeasure?: boolean; | |
}; | |
interface SubNote { | |
startTick: number; | |
endTick: number; | |
pitch: number; | |
velocity?: number; | |
}; | |
interface MeasureNote extends Partial<StaffNoteProperties> { | |
tick: number; | |
pitch: number; | |
duration: number; | |
chordPosition: ChordPosition; | |
track: number; | |
channel: number; | |
id: string; | |
ids: string[]; | |
subNotes: SubNote[]; | |
}; | |
interface MeasureEvent { | |
data: any; | |
track: number; | |
ticks?: number; | |
}; | |
interface Measure { | |
tick: number; | |
duration: number; | |
notes: MeasureNote[]; | |
events?: MeasureEvent[]; | |
}; | |
interface PerformOptions { | |
withRestTied?: boolean; | |
}; | |
/*interface NotationData { | |
pitchContextGroup: PitchContextTable[]; | |
measures: Measure[]; | |
};*/ | |
const EXTRA_NOTE_FIELDS = ["rest", "tied", "overlapped", "implicitType", "afterGrace", "contextIndex", "staffTrack", "chordPosition", "division"]; | |
const COMMON_NOTE_FIELDS = ["id", "ids", "pitch", "velocity", "track", "channel", ...EXTRA_NOTE_FIELDS]; | |
export class Notation { | |
pitchContextGroup: PitchContextTable[]; | |
measureLayout: MeasureLayout; | |
measures: Measure[]; | |
trackNames: string[]; | |
idTrackMap: {[key: string]: number}; | |
ripe: boolean = false; | |
static fromAbsoluteNotes (notes: Note[], measureHeads: number[], data?: Partial<Notation>): Notation { | |
const notation = new Notation(data); | |
notation.measures = Array(measureHeads.length).fill(null).map((__, i) => { | |
const tick = measureHeads[i]; | |
const duration = measureHeads[i + 1] ? (measureHeads[i + 1] - tick) : 0; | |
const mnotes = notes.filter(note => note.measure === i + 1).map(note => ({ | |
tick: note.startTick - tick, | |
duration: note.endTick - note.startTick, | |
...pick(note, COMMON_NOTE_FIELDS), | |
subNotes: [], | |
} as MeasureNote)); | |
// reduce note data size | |
mnotes.forEach(mn => ["rest", "tied", "implicitType", "afterGrace"].forEach(field => { | |
if (!mn[field]) | |
delete mn[field]; | |
})); | |
return { | |
tick, | |
duration, | |
notes: mnotes, | |
}; | |
}); | |
notation.idTrackMap = notes.reduce((map, note) => { | |
if (note.id) | |
map[note.id] = note.track; | |
return map; | |
}, {}); | |
return notation; | |
} | |
static performAbsoluteNotes (abNotes: Note[], {withRestTied = false}: PerformOptions = {}): MusicNotation.Note[] { | |
const notes = abNotes.filter(note => (withRestTied || (!note.rest && !note.tied)) && !note.overlapped).map(note => ({ | |
measure: note.measure, | |
channel: note.channel, | |
track: note.track, | |
start: note.start, | |
startTick: note.startTick, | |
endTick: note.endTick, | |
pitch: note.pitch, | |
duration: note.duration, | |
velocity: note.velocity || 127, | |
id: note.id, | |
ids: note.ids, | |
staffTrack: note.staffTrack, | |
contextIndex: note.contextIndex, | |
implicitType: note.implicitType, | |
chordPosition: note.chordPosition, | |
outMeasure: note.outMeasure, | |
})); | |
const noteMap = notes.reduce((map, note) => { | |
const key = `${note.channel}|${note.start}|${note.pitch}`; | |
const priorNote = map[key]; | |
if (priorNote) | |
priorNote.ids.push(...note.ids); | |
else | |
map[key] = note; | |
return map; | |
}, {}); | |
return Object.values(noteMap); | |
} | |
constructor (data?: Partial<Notation>) { | |
if (data) | |
Object.assign(this, data); | |
} | |
get ordinaryMeasureIndices (): number[] { | |
if (this.measureLayout) | |
return this.measureLayout.serialize(LayoutType.Ordinary); | |
return Array(this.measures.length).fill(null).map((_, i) => i + 1); | |
} | |
// In Lilypond 2.20.0, minus tick value at the head of a track result in MIDI event time bias, | |
// So store the bias values to correct MIDI time from lilyond. | |
get trackTickBias (): {[key: string]: number} { | |
const headMeasure = this.measures[0]; | |
return this.trackNames.reduce((map, name, track) => { | |
map[name] = 0; | |
if (headMeasure) { | |
const note = headMeasure.notes.find(note => note.track === track); | |
if (note) | |
map[name] = Math.min(note.tick, 0); | |
} | |
return map; | |
}, {}); | |
} | |
get idSet (): Set<string> { | |
return this.measures.reduce((set, measure) => | |
(measure.notes.filter(note => !note.rest).forEach(note => note.ids.forEach(id => set.add(id))), set), new Set<string>()); | |
} | |
toJSON () { | |
return { | |
__prototype: "LilyNotation", | |
pitchContextGroup: this.pitchContextGroup, | |
measureLayout: this.measureLayout, | |
measures: this.measures, | |
idTrackMap: this.idTrackMap, | |
trackNames: this.trackNames, | |
ripe: this.ripe, | |
}; | |
} | |
toAbsoluteNotes (measureIndices: number[] = this.ordinaryMeasureIndices): Note[] { | |
let measureTick = 0; | |
const measureNotes: Note[][] = measureIndices.map(index => { | |
const measure = this.measures[index - 1]; | |
console.assert(!!measure, "invalid measure index:", index, this.measures.length); | |
const notes = measure.notes.map(mnote => { | |
const outMeasure = mnote.tick < 0 || mnote.tick >= measure.duration; | |
return { | |
startTick: measureTick + mnote.tick, | |
endTick: measureTick + mnote.tick + mnote.duration, | |
start: measureTick + mnote.tick, | |
duration: mnote.duration, | |
measure: index, | |
outMeasure, | |
...pick(mnote, COMMON_NOTE_FIELDS), | |
} as Note; | |
}); | |
measureTick += measure.duration; | |
return notes; | |
}); | |
return [].concat(...measureNotes); | |
} | |
getMeasureIndices (type: LayoutType) { | |
return this.measureLayout.serialize(type); | |
} | |
toPerformingNotation (measureIndices: number[] = this.ordinaryMeasureIndices, options: PerformOptions = {}): MusicNotation.Notation { | |
//console.debug("toPerformingNotation:", this, measureIndices); | |
const abNotes = this.toAbsoluteNotes(measureIndices); | |
const notes = Notation.performAbsoluteNotes(abNotes, options); | |
//const lastNote = notes[notes.length - 1]; | |
const endTime = Math.max(...notes.map(note => note.start + note.duration)); | |
const endTick = measureIndices.reduce((tick, index) => tick + this.measures[index - 1].duration, 0); | |
const notation = new MusicNotation.Notation({ | |
ticksPerBeat: TICKS_PER_BEAT, | |
meta: {}, | |
tempos: [], // TODO | |
channels: [notes], | |
endTime, | |
endTick, | |
}); | |
return notation; | |
} | |
toPerformingMIDI (measureIndices: number[], {trackList}: {trackList?: boolean[]} = {}): MIDI.MidiData & {zeroTick: number} { | |
if (!measureIndices.length) | |
return null; | |
// to avoid begin minus tick | |
const zeroTick = -Math.min(0, | |
...(this.measures[0] ? this.measures[0].events.map((e) => e.ticks) : []), | |
...(this.measures[0] ? this.measures[0].notes.map((note) => note.tick) : [])); | |
let measureTick = zeroTick; | |
const measureEvents: MeasureEvent[][] = measureIndices.map(index => { | |
const measure = this.measures[index - 1]; | |
console.assert(!!measure, "invalid measure index:", index, this.measures.length); | |
const events = measure.events.map(mevent => ({ | |
ticks: measureTick + mevent.ticks, | |
track: mevent.track, | |
data: { | |
...mevent.data, | |
measure: index, | |
}, | |
})); | |
measureTick += measure.duration; | |
return events; | |
}); | |
interface MidiEvent extends MIDI.MidiEvent { | |
ticks?: number; | |
measure?: number; | |
ids?: string[]; | |
staffTrack?: number; | |
}; | |
type MidiTrack = MidiEvent[]; | |
const eventPriority = (event: MidiEvent): number => event.ticks + (event.subtype === "noteOff" ? -1e-4 : 0); | |
const tracks: MidiTrack[] = [].concat(...measureEvents).reduce((tracks, mevent) => { | |
tracks[mevent.track] = tracks[mevent.track] || []; | |
tracks[mevent.track].push({ | |
ticks: mevent.ticks, | |
...mevent.data, | |
}); | |
return tracks; | |
}, []); | |
tracks[0] = tracks[0] || []; | |
tracks[0].push({ | |
ticks: 0, | |
type: "meta", | |
subtype: "text", | |
text: `${npmPackage.name} ${npmPackage.version}`, | |
}); | |
// append note events | |
measureTick = zeroTick; | |
measureIndices.map(index => { | |
const measure = this.measures[index - 1]; | |
console.assert(!!measure, "invalid measure index:", index, this.measures.length); | |
measure.notes.forEach(note => { | |
if (trackList && !trackList[note.track]) | |
return; | |
if (note.rest) | |
return; | |
const tick = measureTick + note.tick; | |
const track = tracks[note.track] = tracks[note.track] || []; | |
note.subNotes.forEach(subnote => { | |
track.push({ | |
ticks: tick + subnote.startTick, | |
measure: index, | |
ids: note.ids, | |
type: "channel", | |
subtype: "noteOn", | |
channel: note.channel, | |
noteNumber: subnote.pitch, | |
velocity: subnote.velocity, | |
staffTrack: note.staffTrack, | |
}); | |
track.push({ | |
ticks: tick + subnote.endTick, | |
measure: index, | |
ids: note.ids, | |
type: "channel", | |
subtype: "noteOff", | |
channel: note.channel, | |
noteNumber: subnote.pitch, | |
velocity: 0, | |
staffTrack: note.staffTrack, | |
}); | |
}); | |
}); | |
measureTick += measure.duration; | |
}); | |
const finalTick = measureTick; | |
// ensure no empty track | |
for (let t = 0; t < tracks.length; ++t) | |
tracks[t] = tracks[t] || []; | |
// sort & make deltaTime | |
tracks.forEach(events => { | |
events.sort((e1, e2) => eventPriority(e1) - eventPriority(e2)); | |
let ticks = 0; | |
events.forEach(event => { | |
event.deltaTime = event.ticks - ticks; | |
ticks = event.ticks; | |
}); | |
events.push({deltaTime: Math.max(finalTick - ticks, 0), type: "meta", subtype: "endOfTrack"}); | |
}); | |
return { | |
header: { | |
formatType: 0, | |
ticksPerBeat: TICKS_PER_BEAT, | |
}, | |
tracks, | |
zeroTick, | |
}; | |
} | |
toPerformingNotationWithEvents (measureIndices: number[], options: {trackList?: boolean[]} = {}): MusicNotation.Notation { | |
if (!measureIndices.length) | |
return null; | |
const {zeroTick, ...midi} = this.toPerformingMIDI(measureIndices, options); | |
const notation = MusicNotation.Notation.parseMidi(midi); | |
assignNotationNoteDataFromEvents(notation); | |
let tick = zeroTick; | |
notation.measures = measureIndices.map(index => { | |
const startTick = tick; | |
tick += this.measures[index - 1].duration; | |
return { | |
index, | |
startTick, | |
endTick: tick, | |
}; | |
}); | |
return notation; | |
} | |
getContextGroup (measureIndices: number[]): PitchContextTable[] { | |
const contextGroup = this.pitchContextGroup.map(table => table.items.map(item => item.context)); | |
const midiNotation = this.toPerformingNotation(measureIndices); | |
return PitchContextTable.createPitchContextGroup(contextGroup, midiNotation); | |
} | |
assignMatcher (matcher: MatcherResult): this { | |
const tickFactor = TICKS_PER_BEAT / matcher.sample.ticksPerBeat; | |
matcher.path.forEach((ci, si) => { | |
if (ci >= 0) { | |
const cn = matcher.criterion.notes[ci] as Note; | |
const sn = matcher.sample.notes[si] as Note; | |
sn.ids = cn.ids || [cn.id]; | |
sn.measure = cn.measure; | |
} | |
}); | |
assignNotationEventsIds(matcher.sample, ["ids", "measure"]); | |
// assign sub notes | |
const c2sIndices = Array(matcher.criterion.notes.length).fill(null).map(() => []); | |
matcher.path.forEach((ci, si) => ci >= 0 && c2sIndices[ci].push(si)); | |
const velocities: {[key: number]: number} = {}; | |
c2sIndices.forEach((indices, ci) => { | |
const cn = matcher.criterion.notes[ci] as any; | |
const measure = this.measures[cn.measure - 1]; | |
console.assert(!!measure, "cannot find measure for c note:", cn, this.measures.length); | |
//const mn = measure.notes.find(note => note.tick === cn.startTick - measure.tick && note.pitch === cn.pitch); | |
const mn = measure.notes.find(note => note.id === cn.id && note.pitch === cn.pitch); | |
console.assert(!!mn, "cannot find measure note for c note:", cn, measure); | |
let bias = 0; | |
// arpeggio time bias | |
if (mn.implicitType === "arpeggio" && mn.chordPosition) { | |
const referenceDuration = Math.min(mn.duration * 0.5, ARPEGGIO_REFERENCE_DURATION); | |
bias = Math.round(mn.chordPosition.index * referenceDuration / mn.chordPosition.count); | |
} | |
const snotes = indices | |
.map(si => matcher.sample.notes[si]) | |
.filter((sn, i) => Math.abs(sn.startTick - cn.startTick) < WHOLE_DURATION_MAGNITUDE | |
&& (!mn.afterGrace || i < 1)); // lilypond's afterGrace MIDI parsing is ill, clip notes to avoid error matching | |
if (!snotes.length) { | |
//const track = matcher.trackMap[mn.track]; | |
mn.subNotes = [{ | |
//track, | |
startTick: bias, | |
endTick: mn.duration, | |
pitch: mn.pitch, | |
velocity: velocities[mn.track] || 90, | |
}]; | |
} | |
else { | |
const referenceTick = measure.tick + mn.tick; | |
//const referenceTick = snotes[0].startTick; | |
//console.log("mn bias:", mn.id, measure.tick + mn.tick - snotes[0].startTick); | |
mn.subNotes = snotes.map(sn => { | |
velocities[sn.track] = sn.velocity; | |
console.assert(sn.endTick > sn.startTick, "midi note duration is zero or negative:", sn); | |
// fix bad subnote duration | |
const duration = sn.endTick - sn.startTick; | |
if (duration > mn.duration * 2) | |
sn.endTick = sn.startTick + mn.duration; | |
return { | |
//track: sn.track, | |
startTick: Math.round(sn.startTick - referenceTick + bias), | |
endTick: Math.round(sn.endTick - referenceTick), | |
pitch: sn.pitch, | |
velocity: sn.velocity, | |
}; | |
}); | |
} | |
}); | |
// assign events | |
this.measures.forEach(measure => measure.events = []); | |
//console.debug("matcher.trackMap:", matcher.trackMap); | |
(matcher.sample.events as MeasureEvent[]).forEach(event => { | |
// exclude note events | |
if (["noteOn", "noteOff"].includes(event.data.subtype)) | |
return; | |
let track = matcher.trackMap[event.track]; | |
if (!Number.isFinite(track)) { | |
const id = event.data.ids && event.data.ids[0]; | |
track = id ? (this.idTrackMap[id] || 0) : 0; | |
} | |
if (Number.isInteger(event.data.measure)) { | |
const measure = this.measures[event.data.measure - 1]; | |
console.assert(!!measure, "measure index is invalid:", event.data.measure, this.measures.length); | |
measure.events.push({ | |
data: event.data, | |
track, | |
ticks: Math.round(event.ticks * tickFactor - measure.tick), | |
}); | |
} | |
else { | |
switch (event.data.subtype) { | |
case "setTempo": | |
case "timeSignature": | |
case "keySignature": { | |
// find container measure by tick range | |
const tick = event.ticks * tickFactor; | |
const measure = this.measures.find(measure => tick >= measure.tick && tick < measure.tick + measure.duration); | |
if (measure) { | |
measure.events.push({ | |
data: event.data, | |
track, | |
ticks: Math.round(tick - measure.tick), | |
}); | |
} | |
} | |
break; | |
} | |
} | |
}); | |
this.ripe = true; | |
return this; | |
} | |
// find the MIDI event of setTempo in measures data, and change the value of microsecondsPerBeat | |
setTempo (bpm: number): boolean { | |
let found = false; | |
for (const measure of this.measures) { | |
for (const event of measure.events) { | |
if (event.data.subtype === "setTempo") { | |
event.data.microsecondsPerBeat = 60e+6 / bpm; | |
found = true; | |
} | |
} | |
} | |
return found; | |
} | |
}; | |
export const assignNotationEventsIds = (midiNotation: MusicNotation.NotationData, fields = ["ids"]) => { | |
const events = midiNotation.notes.reduce((events, note) => { | |
const dict = pick(note, fields); | |
events.push({ticks: note.startTick, subtype: "noteOn", channel: note.channel, pitch: note.pitch, dict}); | |
events.push({ticks: note.endTick, subtype: "noteOff", channel: note.channel, pitch: note.pitch, dict}); | |
["setTempo", "timeSignature", "keySignature"].forEach(subtype => events.push({ticks: note.startTick, subtype, dict})); | |
return events; | |
}, []).sort((e1, e2) => e1.ticks - e2.ticks); | |
let index = -1; | |
let ticks = -Infinity; | |
for (const event of midiNotation.events) { | |
console.assert(Number.isFinite(event.ticks), "invalid event ticks:", event); | |
while (event.ticks > ticks && index < events.length) { | |
++index; | |
ticks = events[index] && events[index].ticks; | |
} | |
if (index >= events.length) | |
break; | |
if (event.ticks < ticks) | |
continue; | |
for (let i = index; i < events.length; ++i) { | |
const ne = events[i]; | |
if (!ne) { | |
console.warn("null event:", i, events.length); | |
break; | |
} | |
if (ne.ticks > ticks) | |
break; | |
else { | |
if (event.data.subtype === ne.subtype && event.data.channel === ne.channel && event.data.noteNumber === ne.pitch) { | |
Object.assign(event.data, ne.dict); | |
break; | |
} | |
} | |
} | |
} | |
}; | |
const assignNotationNoteDataFromEvents = (midiNotation: MusicNotation.NotationData, fields = ["ids", "measure", "staffTrack"]) => { | |
const noteId = (channel: number, pitch: number, tick: number): string => `${channel}|${pitch}|${tick}`; | |
const noteMap = midiNotation.notes.reduce((map, note) => { | |
map[noteId(note.channel, note.pitch, note.startTick)] = note; | |
return map; | |
}, {}); | |
midiNotation.events.forEach(event => { | |
if (event.data.subtype === "noteOn") { | |
const id = noteId(event.data.channel, event.data.noteNumber, event.ticks); | |
const note = noteMap[id]; | |
console.assert(!!note, "cannot find note of", id); | |
if (note) | |
Object.assign(note, pick(event.data, fields)); | |
} | |
}); | |
}; | |