import sha1 from "sha1"; // eslint-disable-next-line import {MusicNotation} from "@k-l-lambda/music-widgets"; import DictArray from "./DictArray"; // eslint-disable-next-line import LogRecorder from "./logRecorder"; const GROUP_N_TO_PITCH = [0, 2, 4, 5, 7, 9, 11]; const MIDDLE_C = 60; const mod7 = x => { let y = x % 7; while (y < 0) y += 7; return y; }; const mod12 = x => { let y = x % 12; while (y < 0) y += 12; return y; }; interface NotationNote { track?: number; time?: number; startTick?: number; pitch: number; id?: string; tied?: boolean; contextIndex?: number; staffTrack?: number; type?: number; }; const stringifyNumber = x => Number.isFinite(x) ? x : x.toString(); const PHONETS = "CDEFGAB"; const ALTER_NAMES = { [-2]: "\u266D\u266D", [-1]: "\u266D", [0]: "\u266E", [1]: "\u266F", [2]: "\uD834\uDD2A", }; /* Coordinates: note: zero: the middle C line (maybe altered) positive: high (right on piano keyboard) unit: a step in scales of the current staff key staff Y: zero: the third (middle) line among 5 staff lines positive: down unit: a interval between 2 neighbor staff lines */ class PitchConfig { clef: number = -3; keyAlters: DictArray = new DictArray(); octaveShift: number = 0; alters: DictArray = new DictArray(); get keySignature (): number { return this.keyAlters.filter(a => Number.isInteger(a)).reduce((sum, a) => sum + a, 0); } noteToY (note: number): number { return -note / 2 - this.clef - this.octaveShift * 3.5; } pitchToNote (pitch: number, {preferredAlter = null} = {}): {note: number, alter: number} { if (!preferredAlter) preferredAlter = this.keySignature < 0 ? -1 : 1; const group = Math.floor((pitch - MIDDLE_C) / 12); const gp = mod12(pitch); const alteredGp = GROUP_N_TO_PITCH.includes(gp) ? gp : mod12(gp - preferredAlter); const gn = GROUP_N_TO_PITCH.indexOf(alteredGp); console.assert(gn >= 0, "invalid preferredAlter:", pitch, preferredAlter, alteredGp); const naturalNote = group * 7 + gn; const alterValue = gp - alteredGp; const keyAlterValue = this.keyAlters[gn] || 0; const onAcc = Number.isInteger(this.alters[naturalNote]); const alter = onAcc ? alterValue : (alterValue === keyAlterValue ? null : alterValue); return {note: naturalNote, alter}; } pitchToY (pitch: number, {preferredAlter = null} = {}): {y: number, alter: number} { const {note, alter} = this.pitchToNote(pitch, {preferredAlter}); const y = this.noteToY(note); return {y, alter}; } yToNote (y: number): number { console.assert(Number.isInteger(y * 2), "invalid y:", y); //if (!Number.isInteger(y * 2)) // debugger; return (-y - this.octaveShift * 3.5 - this.clef) * 2; } alterOnNote (note: number): number { if (Number.isInteger(this.alters[note])) return this.alters[note]; const gn = mod7(note); if (Number.isInteger(this.keyAlters[gn])) return this.keyAlters[gn]; return 0; } noteToPitch (note: number): number { const group = Math.floor(note / 7); const gn = mod7(note); const pitch = MIDDLE_C + group * 12 + GROUP_N_TO_PITCH[gn] + this.alterOnNote(note); if (!Number.isFinite(pitch)) { console.warn("invalid pitch value:", pitch, note, group, gn); return -1; } return pitch; } yToPitch (y: number): number { return this.noteToPitch(this.yToNote(y)); } yToPitchName (y: number): string { const note = this.yToNote(y); const group = Math.floor(note / 7); const gn = mod7(note); let alter = this.alterOnNote(note); if (!alter && !Number.isInteger(this.alters[note])) alter = null; return `${ALTER_NAMES[alter] ? ALTER_NAMES[alter] : ""}${PHONETS[gn]}${group + 4}`; } }; class PitchContext extends PitchConfig { tick?: number; constructor (data) { super(); //console.assert(data.keyAlters instanceof DictArray, "unexpected keyAlters:", data); Object.assign(this, data); } toJSON () { return { __prototype: "PitchContext", clef: this.clef, keyAlters: new DictArray(this.keyAlters), octaveShift: this.octaveShift, alters: new DictArray(this.alters), }; } get hash () { return sha1(JSON.stringify(this)); } }; interface PitchContextItem { tick: number; endTick: number; context: PitchContext; }; class PitchContextTable { items: PitchContextItem[]; static createFromNotation (contexts: PitchContext[], notes: NotationNote[], track: number) { const items = []; let index = -1; const trackNotes = notes.filter(note => note.staffTrack === track); for (const note of trackNotes) { while (note.contextIndex > index) { ++index; const context = contexts[index]; console.assert(!!context, "invalid contextIndex:", index, note.contextIndex, contexts.length); items.push({ tick: note.startTick, context, }); } } // assign end ticks items.forEach((item, i) => item.endTick = (i + 1 < items.length ? items[i + 1].tick : Infinity)); // start from 0 if (items[0]) items[0].tick = 0; return new PitchContextTable({items}); } static createPitchContextGroup (contextGroup: PitchContext[][], midiNotation: MusicNotation.NotationData): PitchContextTable[] { return contextGroup.map((contexts, track) => PitchContextTable.createFromNotation(contexts, midiNotation.notes, track)); } // workaround 'Infinity' JSON representation issue. static itemToJSON (item: PitchContextItem) { return { ...item, endTick: stringifyNumber(item.endTick), }; } static itemFromJSON (item: PitchContextItem) { return { ...item, endTick: Number(item.endTick), }; } constructor ({items}: {items: PitchContextItem[]}) { this.items = items.map(PitchContextTable.itemFromJSON); } toJSON () { return { __prototype: "PitchContextTable", items: this.items.map(PitchContextTable.itemToJSON), }; } lookup (tick: number): PitchContext { const item = this.items.find(item => tick >= item.tick && tick < item.endTick); return item && item.context; } }; class NotationTrack { endTime = 0; notes: NotationNote[] = []; contexts: PitchContext[] = []; get lastPitchContext (): PitchContext { if (this.contexts.length) return this.contexts[this.contexts.length - 1]; return null; } appendNote (time: number, data: NotationNote) { this.notes.push({ time: this.endTime + time, ...data, }); } }; class StaffContext extends PitchConfig { logger: LogRecorder; beatsPerMeasure = 4; track = new NotationTrack(); dirty = true; constructor ({logger}) { super(); this.logger = logger; } snapshot ({tick}: {tick?: number} = {}): number { if (this.dirty) { const context = new PitchContext({ clef: this.clef, keyAlters: this.keyAlters.clone(), octaveShift: this.octaveShift, alters: this.alters.clone(), tick, }); if (!this.track.lastPitchContext || context.hash !== this.track.lastPitchContext.hash) this.track.contexts.push(context); this.dirty = false; } return this.track.contexts.length - 1; } resetKeyAlters () { this.logger.append("resetKeyAlters", Object.keys(this.keyAlters).length); if (Object.keys(this.keyAlters).length) { this.keyAlters.clear(); this.dirty = true; } } resetAlters () { this.logger.append("resetAlters", Object.keys(this.alters).length); if (Object.keys(this.alters).length) { this.alters.clear(); this.dirty = true; } } setKeyAlter (y, value) { //console.log("setKeyAlter:", y, value); // reset old key alters in one staff this.keyAlters.forEach((v, n) => { if (v * value < 0) this.keyAlters[n] = 0; }); const n = mod7(this.yToNote(y)); this.keyAlters[n] = value; this.logger.append("setKeyAlter", {n, value}); this.dirty = true; } setAlter (y, value) { //console.log("setAlter:", y, this.yToNote(y), value); const n = this.yToNote(y); this.alters[n] = value; this.logger.append("setAlter", {n, value}); this.dirty = true; } setClef (y, value) { const clef = -y - value / 2; if (clef !== this.clef) { this.clef = clef; this.dirty = true; } } setOctaveShift (value) { if (this.octaveShift !== value) { this.octaveShift = value; this.dirty = true; this.logger.append("octaveShift", value); } } setBeatsPerMeasure (value) { this.beatsPerMeasure = value; // this won't change pitch context } }; export { PitchContext, PitchContextTable, NotationTrack, StaffContext, };