Spaces:
Sleeping
Sleeping
| 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, | |
| }; | |