lotus / inc /lilyNotation /notation.ts
k-l-lambda's picture
commit lotus dist.
d605f27
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));
}
});
};