From a09e3a76be64d47a825dc6c1982cba7a24e4150b Mon Sep 17 00:00:00 2001 From: Miika Alonen Date: Fri, 3 Nov 2023 16:07:39 +0200 Subject: [PATCH] Refactor object mangling and add midi support --- src/API.ts | 30 +++----- src/Utils/Generic.ts | 70 ++++++++++++++++++ src/classes/AbstractEvents.ts | 46 ++++++++++-- src/classes/MidiEvent.ts | 99 +++++++++++++++----------- src/classes/RestEvent.ts | 4 +- src/classes/SoundEvent.ts | 130 +++++++++++++--------------------- src/classes/ZPlayer.ts | 24 ++++--- 7 files changed, 242 insertions(+), 161 deletions(-) create mode 100644 src/Utils/Generic.ts diff --git a/src/API.ts b/src/API.ts index c6c4008..7854c10 100644 --- a/src/API.ts +++ b/src/API.ts @@ -9,7 +9,7 @@ import { tryEvaluate, evaluateOnce } from "./Evaluator"; import { DrunkWalk } from "./Utils/Drunk"; import { Editor } from "./main"; import { SoundEvent } from "./classes/SoundEvent"; -import { MidiEvent } from "./classes/MidiEvent"; +import { MidiEvent, MidiParams } from "./classes/MidiEvent"; import { LRUCache } from "lru-cache"; import { InputOptions, Player } from "./classes/ZPlayer"; import { @@ -390,9 +390,10 @@ export class UserAPI { }; public midi = ( - value: number | object = 60, - velocity?: number, - channel?: number + value: number | number[] = 60, + velocity?: number | number[], + channel?: number | number[], + port?: number | string | number[] | string[] ): MidiEvent => { /** * Sends a MIDI note to the current MIDI output. @@ -402,24 +403,9 @@ export class UserAPI { * { channel: 0, velocity: 100, duration: 0.5 } */ - if (velocity !== undefined) { - // Check if value is of type number - if (typeof value === "number") { - value = { note: value }; - } - // @ts-ignore - value["velocity"] = velocity; - } + const event = {note: value, velocity, channel, port} as MidiParams - if (channel !== undefined) { - if (typeof value === "number") { - value = { note: value }; - } - // @ts-ignore - value["channel"] = channel; - } - - return new MidiEvent(value, this.app); + return new MidiEvent(event, this.app); }; public sysex = (data: Array): void => { @@ -1893,7 +1879,7 @@ export class UserAPI { // Trivial functions // ============================================================= - sound = (sound: string | object) => { + sound = (sound: string | string[]) => { return new SoundEvent(sound, this.app); }; diff --git a/src/Utils/Generic.ts b/src/Utils/Generic.ts new file mode 100644 index 0000000..15578d1 --- /dev/null +++ b/src/Utils/Generic.ts @@ -0,0 +1,70 @@ +/* + * Transforms object with arrays into array of objects + * + * @param {Record} input - Object with arrays + * @param {string[]} ignoredKeys - Keys to ignore + * @returns {Record[]} Array of objects + * + */ +export function objectWithArraysToArrayOfObjects(input: Record, ignoredKeys: string[]): Record[] { + const keys = Object.keys(input).filter((k) => !ignoredKeys.includes(k)); + const maxLength = Math.max( + ...keys.map((k) => + Array.isArray(input[k]) ? (input[k] as any[]).length : 1 + ) + ); + + const output: Record[] = []; + + for (let i = 0; i < maxLength; i++) { + const event: Record = {}; + for (const k of keys) { + if (ignoredKeys.includes(k)) { + event[k] = input[k]; + } else { + if (Array.isArray(input[k])) { + event[k] = (input[k] as any[])[i % (input[k] as any[]).length]; + } else { + event[k] = input[k]; + } + } + } + output.push(event); + } + return output; + }; + +/* + * Transforms array of objects into object with arrays + * + * @param {Record[]} array - Array of objects + * @param {Record} mergeObject - Object that is merged to each object in the array + * @returns {object} Merged object with arrays + * + */ +export function arrayOfObjectsToObjectWithArrays>(array: T[], mergeObject: Record = {}): Record { + return array.reduce((acc, obj) => { + Object.keys(mergeObject).forEach((key) => { + obj[key as keyof T] = mergeObject[key]; + }); + Object.keys(obj).forEach((key) => { + if (!acc[key as keyof T]) { + acc[key as keyof T] = []; + } + (acc[key as keyof T] as unknown[]).push(obj[key]); + }); + return acc; + }, {} as Record); + } + + /* + * Filter certain keys from object + * + * @param {Record} obj - Object to filter + * @param {string[]} filter - Keys to filter + * @returns {object} Filtered object + * + */ + export function filterObject(obj: Record, filter: string[]): Record { + return Object.fromEntries(Object.entries(obj).filter(([key]) => filter.includes(key))); + } \ No newline at end of file diff --git a/src/classes/AbstractEvents.ts b/src/classes/AbstractEvents.ts index 6eab352..29d80dd 100644 --- a/src/classes/AbstractEvents.ts +++ b/src/classes/AbstractEvents.ts @@ -204,8 +204,11 @@ export abstract class Event { return this.modify(func); }; - length = (value: number): Event => { - this.values["length"] = value; + noteLength = (value: number): Event => { + /** + * This function is used to set the note length of the Event. + */ + this.values["noteLength"] = value; return this; }; } @@ -215,30 +218,63 @@ export abstract class AudibleEvent extends Event { super(app); } + pitch = (value: number): this => { + /* + * This function is used to set the pitch of the Event. + * @param value - The pitch value + * @returns The Event + */ + this.values["pitch"] = value; + if(this.values.key && this.values.parsedScale) this.update(); + return this; + } + + pc = this.pitch; + octave = (value: number): this => { + /* + * This function is used to set the octave of the Event. + * @param value - The octave value + * @returns The Event + */ this.values["octave"] = value; - this.update(); + if(this.values.key && this.values.pitch && this.values.parsedScale) this.update(); return this; }; key = (value: string): this => { + /* + * This function is used to set the key of the Event. + * @param value - The key value + * @returns The Event + */ this.values["key"] = value; - this.update(); + if(this.values.pitch && this.values.parsedScale) this.update(); return this; }; scale = (value: string): this => { + /* + * This function is used to set the scale of the Event. + * @param value - The scale value + * @returns The Event + */ if (!isScale(value)) { this.values.parsedScale = parseScala(value) as number[]; } else { this.values.scaleName = value; this.values.parsedScale = getScale(value) as number[]; } - this.update(); + if(this.values.key && this.values.pitch) this.update(); return this; }; freq = (value: number): this => { + /* + * This function is used to set the frequency of the Event. + * @param value - The frequency value + * @returns The Event + */ this.values["freq"] = value; const midiNote = freqToMidi(value); if (midiNote % 1 !== 0) { diff --git a/src/classes/MidiEvent.ts b/src/classes/MidiEvent.ts index 1294d57..5fd0c5b 100644 --- a/src/classes/MidiEvent.ts +++ b/src/classes/MidiEvent.ts @@ -1,7 +1,8 @@ import { AudibleEvent } from "./AbstractEvents"; import { type Editor } from "../main"; import { MidiConnection } from "../IO/MidiConnection"; -import { midiToFreq, noteFromPc } from "zifferjs"; +import { noteFromPc, chord as parseChord } from "zifferjs"; +import { filterObject, arrayOfObjectsToObjectWithArrays, objectWithArraysToArrayOfObjects } from "../Utils/Generic"; export type MidiParams = { note: number; @@ -9,40 +10,49 @@ export type MidiParams = { channel?: number; port?: number; sustain?: number; + velocity?: number; } export class MidiEvent extends AudibleEvent { midiConnection: MidiConnection; - constructor(input: number | object, public app: Editor) { + constructor(input: MidiParams, public app: Editor) { super(app); - if (typeof input === "number") this.values["note"] = input; - else this.values = input; + this.values = input; this.midiConnection = app.api.MidiConnection; } - chord = (value: MidiParams[]): this => { - this.values["chord"] = value; - return this; + public chord = (value: string) => { + this.values.note = parseChord(value); + return this; }; - note = (value: number): this => { + note = (value: number | number[]): this => { this.values["note"] = value; return this; }; - sustain = (value: number): this => { + sustain = (value: number | number[]): this => { this.values["sustain"] = value; return this; }; - channel = (value: number): this => { + velocity = (value: number | number[]): this => { + this.values["velocity"] = value; + return this; + } + + channel = (value: number | number[]): this => { this.values["channel"] = value; return this; }; - port = (value: number | string): this => { - this.values["port"] = this.midiConnection.getMidiOutputIndex(value); + port = (value: number | string | number[] | string[]): this => { + if(typeof value === "string"){ + this.values["port"] = this.midiConnection.getMidiOutputIndex(value); + } else if(Array.isArray(value)){ + this.values["port"] = value.map((v) => typeof v === "string" ? this.midiConnection.getMidiOutputIndex(v) : v); + } return this; }; @@ -75,37 +85,46 @@ export class MidiEvent extends AudibleEvent { }; update = (): void => { - const [note, bend] = noteFromPc( - this.values.key || "C4", - this.values.pitch || 0, - this.values.parsedScale || "MAJOR", - this.values.octave || 0 - ); - this.values.note = note; - this.values.freq = midiToFreq(note); - if (bend) this.values.bend = bend; + // Get key, pitch, parsedScale and octave from this.values object + const filteredValues = filterObject(this.values, ["key", "pitch", "parsedScale", "octave"]); + + const events = objectWithArraysToArrayOfObjects(filteredValues,["parsedScale"]); + + events.forEach((event) => { + const [note, bend] = noteFromPc( + event.key as number || "C4", + event.pitch as number || 0, + event.parsedScale as number[] || event.scale || "MAJOR", + event.octave as number || 0 + ); + event.note = note; + if(bend) event.bend = bend; + }); + + const newArrays = arrayOfObjectsToObjectWithArrays(events) as MidiParams; + + this.values.note = newArrays.note; + if(newArrays.bend) this.values.bend = newArrays.bend; }; out = (): void => { - function play(event: MidiEvent, params?: MidiParams): void { - const paramChannel = params && params.channel ? params.channel : 0; - const channel = event.values.channel ? event.values.channel : paramChannel; - const velocity = event.values.velocity ? event.values.velocity : 100; - const paramNote = params && params.note ? params.note : 60; - const note = event.values.note ? event.values.note : paramNote; + function play(event: MidiEvent, params: MidiParams): void { + const channel = params.channel ? params.channel : 0; + const velocity = params.velocity ? params.velocity : 100; + const note = params.note ? params.note : 60; - const sustain = event.values.sustain - ? event.values.sustain * + const sustain = params.sustain + ? params.sustain * event.app.clock.pulse_duration * event.app.api.ppqn() : event.app.clock.pulse_duration * event.app.api.ppqn(); - const bend = event.values.bend ? event.values.bend : undefined; - - const port = event.values.port - ? event.midiConnection.getMidiOutputIndex(event.values.port) - : event.midiConnection.getCurrentMidiPortIndex(); + const bend = params.bend ? params.bend : undefined; + const port = params.port + ? event.midiConnection.getMidiOutputIndex(params.port) + : event.midiConnection.getCurrentMidiPortIndex() || 0; + event.midiConnection.sendMidiNote( note, channel, @@ -116,13 +135,11 @@ export class MidiEvent extends AudibleEvent { ); } - if(this.values.chord) { - this.values.chord.forEach((p: MidiParams) => { - play(this, p); - }); - } else { - play(this); - } + const events = objectWithArraysToArrayOfObjects(this.values,["parsedScale"]) as MidiParams[]; + + events.forEach((p: MidiParams) => { + play(this,p); + }); }; } diff --git a/src/classes/RestEvent.ts b/src/classes/RestEvent.ts index 4f4ec53..862d2d5 100644 --- a/src/classes/RestEvent.ts +++ b/src/classes/RestEvent.ts @@ -4,11 +4,11 @@ import { Event } from "./AbstractEvents"; export class RestEvent extends Event { constructor(length: number, app: Editor) { super(app); - this.values["length"] = length; + this.values["noteLength"] = length; } _fallbackMethod = (): Event => { - return RestEvent.createRestProxy(this.values["length"], this.app); + return RestEvent.createRestProxy(this.values["noteLength"], this.app); }; public static createRestProxy = ( diff --git a/src/classes/SoundEvent.ts b/src/classes/SoundEvent.ts index 9f19a85..4c753d3 100644 --- a/src/classes/SoundEvent.ts +++ b/src/classes/SoundEvent.ts @@ -1,5 +1,6 @@ import { type Editor } from "../main"; import { AudibleEvent } from "./AbstractEvents"; +import { filterObject, arrayOfObjectsToObjectWithArrays, objectWithArraysToArrayOfObjects } from "../Utils/Generic"; import { chord as parseChord, midiToFreq, @@ -12,22 +13,23 @@ import { // @ts-ignore } from "superdough"; -type EventObj = { [key: string]: number | number[] }; - export type SoundParams = { - dur: number; - s?: string; -}; - -type ValuesType = { - s: string | string[]; - n?: string | string[]; - dur: number; - analyze: boolean; + dur: number | number[]; + s?: undefined | string | string[]; + n?: undefined | number | number[]; + analyze?: boolean; + note?: number | number[]; + freq?: number | number[]; + pitch?: number | number[]; + key?: string; + scale?: string; + parsedScale?: number[]; + octave?: number | number[]; }; export class SoundEvent extends AudibleEvent { nudge: number; + sound: any; private methodMap = { volume: ["volume", "vol"], @@ -276,7 +278,7 @@ export class SoundEvent extends AudibleEvent { }; - constructor(sound: string | string[] | object, public app: Editor) { + constructor(sound: string | string[] | SoundParams, public app: Editor) { super(app); this.nudge = app.dough_nudge / 100; @@ -294,15 +296,15 @@ export class SoundEvent extends AudibleEvent { this.values = this.processSound(sound); } - private processSound = (sound: string | string[] | object): ValuesType => { - if (Array.isArray(sound)) { + private processSound = (sound: string | string[] | SoundParams | SoundParams[]): SoundParams => { + if (Array.isArray(sound) && typeof sound[0] === 'string') { const s: string[] = []; - const n: string[] = []; + const n: number[] = []; sound.forEach(str => { - const parts = str.split(":"); + const parts = (str as string).split(":"); s.push(parts[0]); if (parts[1]) { - n.push(parts[1]); + n.push(parseInt(parts[1])); } }); return { @@ -312,16 +314,17 @@ export class SoundEvent extends AudibleEvent { analyze: true }; } else if (typeof sound === 'object') { - console.log(sound) - const validatedObj: ValuesType = { + const validatedObj: SoundParams = { dur: this.app.clock.convertPulseToSecond(this.app.clock.ppqn), analyze: true, - ...sound as Partial + ...sound as Partial }; return validatedObj; } else { if (sound.includes(":")) { - const [s, n] = sound.split(":"); + const vals = sound.split(":"); + const s = vals[0]; + const n = parseInt(vals[1]); return { s, n, @@ -334,8 +337,7 @@ export class SoundEvent extends AudibleEvent { } } - - private updateValue(key: string, value: T | T[] | null): this { + private updateValue(key: string, value: T | T[] | SoundParams[] | null): this { if (value == null) return this; this.values[key] = value; return this; @@ -356,61 +358,31 @@ export class SoundEvent extends AudibleEvent { }; update = (): void => { - const [note, _] = noteFromPc( - this.values.key || "C4", - this.values.pitch || 0, - this.values.parsedScale || "MAJOR", - this.values.octave || 0 - ); - this.values.freq = midiToFreq(note); + const filteredValues = filterObject(this.values, ["key", "pitch", "parsedScale", "octave"]); + const events = objectWithArraysToArrayOfObjects(filteredValues,["parsedScale"]); + + events.forEach((event) => { + const [note, _] = noteFromPc( + event.key as number || "C4", + event.pitch as number || 0, + event.parsedScale as number[] || event.scale || "MAJOR", + event.octave as number || 0 + ); + event.note = note; + event.freq = midiToFreq(note); + }); + + const newArrays = arrayOfObjectsToObjectWithArrays(events) as SoundParams; + + this.values.note = newArrays.note; + this.values.freq = newArrays.freq; }; - generateEvents = (input: EventObj): EventObj[] => { - const keys = Object.keys(input); - const maxLength = Math.max( - ...keys.map((k) => - Array.isArray(input[k]) ? (input[k] as number[]).length : 1 - ) - ); - - const output: EventObj[] = []; - - for (let i = 0; i < maxLength; i++) { - const event: EventObj = {}; - for (const k of keys) { - if (Array.isArray(input[k])) { - // @ts-ignore - event[k] = input[k][i % (input[k] as number[]).length]; - } else { - event[k] = input[k]; - } - } - output.push(event); - } - - return output; - }; - - public chord = ( - value: string | object[] | number[] | number, - ...kwargs: number[] - ) => { - if (typeof value === "string") { + public chord = (value: string) => { const chord = parseChord(value); - value = chord.map((note: number) => { - return { note: note, freq: midiToFreq(note) }; - }); - } else if (value instanceof Array && typeof value[0] === "number") { - value = (value as number[]).map((note: number) => { - return { note: note, freq: midiToFreq(note) }; - }); - } else if (typeof value === "number" && kwargs.length > 0) { - value = [value, ...kwargs].map((note: number) => { - return { note: note, freq: midiToFreq(note) }; - }); - } - return this.updateValue("chord", value); + return this.updateValue("note", chord); }; + public invert = (howMany: number = 0) => { if (this.values.chord) { let notes = this.values.chord.map( @@ -438,17 +410,13 @@ export class SoundEvent extends AudibleEvent { } }; - - out = (): void => { - const input = this.values.chord || this.values; - const events = this.generateEvents(input); - console.log(events); + const events = objectWithArraysToArrayOfObjects(this.values,["parsedScale"]); for (const event of events) { superdough( - { ...event, ...{ freq: event.freq, dur: this.values.dur } }, + event, this.nudge, - this.values.dur + event.dur ); } }; diff --git a/src/classes/ZPlayer.ts b/src/classes/ZPlayer.ts index 85fb3ba..57a87fb 100644 --- a/src/classes/ZPlayer.ts +++ b/src/classes/ZPlayer.ts @@ -5,6 +5,7 @@ import { SkipEvent } from "./SkipEvent"; import { SoundEvent, SoundParams } from "./SoundEvent"; import { MidiEvent, MidiParams } from "./MidiEvent"; import { RestEvent } from "./RestEvent"; +import { arrayOfObjectsToObjectWithArrays } from "../Utils/Generic"; export type InputOptions = { [key: string]: string | number }; @@ -139,37 +140,39 @@ export class Player extends Event { }; sound(name?: string) { - if (this.areWeThereYet()) { const event = this.next() as Pitch | Chord | ZRest; const noteLengthInSeconds = this.app.clock.convertPulseToSecond(event.duration*4*this.app.clock.ppqn); if (event instanceof Pitch) { const obj = event.getExisting( "freq", + "note", "pitch", "key", "scale", "octave", "parsedScale" - ); + ) as SoundParams; if(event.sound) name = event.sound as string; - if(event.soundIndex) obj.n = event.soundIndex; + if(event.soundIndex) obj.n = event.soundIndex as number; obj.dur = noteLengthInSeconds; return new SoundEvent(obj, this.app).sound(name || "sine"); } else if (event instanceof Chord) { const pitches = event.pitches.map((p) => { return p.getExisting( "freq", + "note", "pitch", "key", "scale", "octave", "parsedScale" ); - }); - const sound: SoundParams = {dur: noteLengthInSeconds}; - if(name) sound.s = name; - return new SoundEvent(sound, this.app).chord(pitches); + }) as SoundParams[]; + const add = { dur: noteLengthInSeconds } as SoundParams; + if(name) add.s = name; + let sound = arrayOfObjectsToObjectWithArrays(pitches,add) as SoundParams; + return new SoundEvent(sound, this.app); } else if (event instanceof ZRest) { return RestEvent.createRestProxy(event.duration, this.app); } @@ -189,16 +192,17 @@ export class Player extends Event { "scale", "octave", "parsedScale", - ); + ) as MidiParams; if (event instanceof Pitch) { - if(event.soundIndex) obj.channel = event.soundIndex; + if(event.soundIndex) obj.channel = event.soundIndex as number; const note = new MidiEvent(obj, this.app); return value ? note.note(value) : note; } else if (event instanceof ZRest) { return RestEvent.createRestProxy(event.duration, this.app); } else if (event instanceof Chord) { const pitches = event.midiChord() as MidiParams[]; - return new MidiEvent(obj, this.app).chord(pitches); + const obj = arrayOfObjectsToObjectWithArrays(pitches) as MidiParams; + return new MidiEvent(obj, this.app); } } else { return SkipEvent.createSkipProxy();