diff --git a/src/API.ts b/src/API.ts index 62f2cb4..5da3daa 100644 --- a/src/API.ts +++ b/src/API.ts @@ -116,6 +116,7 @@ export class UserAPI { ? code : (this.app.selectedExample as string); } + this.stop(); this.play(); }; @@ -126,7 +127,7 @@ export class UserAPI { current_universe.example.candidate! = ""; current_universe.example.committed! = ""; } - this.pause(); + this.stop(); }; _playDocExampleOnce = (code?: string) => { @@ -135,6 +136,7 @@ export class UserAPI { current_universe.example.candidate! = ""; current_universe.example.committed! = ""; } + this.stop(); this.play(); this.app.exampleIsPlaying = true; evaluateOnce(this.app, code as string); @@ -207,13 +209,11 @@ export class UserAPI { public pause = (): void => { this.app.setButtonHighlighting("pause", true); - this.MidiConnection.sendStopMessage(); this.app.clock.pause(); }; public stop = (): void => { this.app.setButtonHighlighting("stop", true); - this.MidiConnection.sendStopMessage(); this.app.clock.stop(); }; silence = this.stop; diff --git a/src/Clock.ts b/src/Clock.ts index a2ed80b..428dae8 100644 --- a/src/Clock.ts +++ b/src/Clock.ts @@ -29,24 +29,38 @@ export class Clock { * @param time_position - The current time position * @param ppqn - The pulses per quarter note * @param tick - The current tick since origin + * @param running - Is the clock running? + * @param lastPauseTime - The last time the clock was paused + * @param lastPlayPressTime - The last time the clock was started + * @param totalPauseTime - The total time the clock has been paused / stopped */ ctx: AudioContext; + logicalTime: number; transportNode: TransportNode | null; private _bpm: number; time_signature: number[]; time_position: TimePosition; private _ppqn: number; tick: number; + running: boolean; + lastPauseTime: number; + lastPlayPressTime: number; + totalPauseTime: number; constructor(public app: Editor, ctx: AudioContext) { - this.time_position = { bar: -1, beat: -1, pulse: -1 }; + this.time_position = { bar: 0, beat: 0, pulse: 0 }; this.time_signature = [4, 4]; - this.tick = -1; + this.logicalTime = 0; + this.tick = 0; this._bpm = 120; this._ppqn = 48; this.transportNode = null; this.ctx = ctx; + this.running = true; + this.lastPauseTime = 0; + this.lastPlayPressTime = 0; + this.totalPauseTime = 0; ctx.audioWorklet .addModule(TransportProcessor) .then((e) => { @@ -122,6 +136,13 @@ export class Clock { return 60 / this.bpm / this.ppqn; } + public pulse_duration_at_bpm(bpm: number = this.bpm): number { + /** + * Returns the duration of a pulse in seconds at a specific bpm. + */ + return 60 / bpm / this.ppqn; + } + get bpm(): number { return this._bpm; } @@ -132,8 +153,9 @@ export class Clock { set bpm(bpm: number) { if (bpm > 0 && this._bpm !== bpm) { - this._bpm = bpm; this.transportNode?.setBPM(bpm); + this._bpm = bpm; + this.logicalTime = this.realTime; } } @@ -141,6 +163,14 @@ export class Clock { return this._ppqn; } + get realTime(): number { + return this.app.audioContext.currentTime - this.totalPauseTime; + } + + get deviation(): number { + return Math.abs(this.logicalTime - this.realTime); + } + set ppqn(ppqn: number) { if (ppqn > 0 && this._ppqn !== ppqn) { this._ppqn = ppqn; @@ -148,6 +178,11 @@ export class Clock { } } + public incrementTick(bpm: number) { + this.tick++; + this.logicalTime += this.pulse_duration_at_bpm(bpm); + } + public nextTickFrom(time: number, nudge: number): number { /** * Compute the time remaining before the next clock tick. @@ -180,7 +215,10 @@ export class Clock { * @remark also sends a MIDI message if a port is declared */ this.app.audioContext.resume(); + this.running = true; this.app.api.MidiConnection.sendStartMessage(); + this.lastPlayPressTime = this.app.audioContext.currentTime; + this.totalPauseTime += (this.lastPlayPressTime - this.lastPauseTime); this.transportNode?.start(); } @@ -190,8 +228,11 @@ export class Clock { * * @remark also sends a MIDI message if a port is declared */ + this.running = false; this.transportNode?.pause(); this.app.api.MidiConnection.sendStopMessage(); + this.lastPauseTime = this.app.audioContext.currentTime; + this.logicalTime = this.realTime; } public stop(): void { @@ -200,8 +241,11 @@ export class Clock { * * @remark also sends a MIDI message if a port is declared */ - this.app.clock.tick = -1; - this.time_position = { bar: -1, beat: -1, pulse: -1 }; + this.running = false; + this.tick = 0; + this.lastPauseTime = this.app.audioContext.currentTime; + this.logicalTime = this.realTime; + this.time_position = { bar: 0, beat: 0, pulse: 0 }; this.app.api.MidiConnection.sendStopMessage(); this.transportNode?.stop(); } diff --git a/src/TransportNode.js b/src/TransportNode.js index 32e6939..220072f 100644 --- a/src/TransportNode.js +++ b/src/TransportNode.js @@ -12,31 +12,39 @@ export class TransportNode extends AudioWorkletNode { /** @type {(this: MessagePort, ev: MessageEvent) => any} */ handleMessage = (message) => { - if (message.data && message.data.type === "bang") { - if (this.app.settings.send_clock) - this.app.api.MidiConnection.sendMidiClock(); - this.app.clock.tick++; - const futureTimeStamp = this.app.clock.convertTicksToTimeposition( - this.app.clock.tick - ); - this.app.clock.time_position = futureTimeStamp; - this.timeviewer.innerHTML = `${zeroPad(futureTimeStamp.bar, 2)}:${ - futureTimeStamp.beat + 1 - }:${zeroPad(futureTimeStamp.pulse, 2)} / ${this.app.clock.bpm}`; - if (this.app.exampleIsPlaying) { - tryEvaluate(this.app, this.app.example_buffer); - } else { - tryEvaluate(this.app, this.app.global_buffer); + if(message.data) { + if (message.data.type === "bang") { + if(this.app.clock.running) { + if (this.app.settings.send_clock) { + this.app.api.MidiConnection.sendMidiClock(); + } + const futureTimeStamp = this.app.clock.convertTicksToTimeposition( + this.app.clock.tick + ); + this.app.clock.time_position = futureTimeStamp; + this.timeviewer.innerHTML = `${zeroPad(futureTimeStamp.bar, 2)}:${futureTimeStamp.beat + 1 + }:${zeroPad(futureTimeStamp.pulse, 2)} / ${this.app.clock.bpm}`; + if (this.app.exampleIsPlaying) { + tryEvaluate(this.app, this.app.example_buffer); + } else { + tryEvaluate(this.app, this.app.global_buffer); + } + this.app.clock.incrementTick(message.data.bpm); + } } } }; start() { - this.port.postMessage("start"); + this.port.postMessage({ type: "start" }); } pause() { - this.port.postMessage("pause"); + this.port.postMessage({ type: "pause" }); + } + + resume() { + this.port.postMessage({ type: "resume" }); } setBPM(bpm) { @@ -52,6 +60,6 @@ export class TransportNode extends AudioWorkletNode { } stop() { - this.port.postMessage("stop"); + this.port.postMessage({type: "stop" }); } } diff --git a/src/TransportProcessor.js b/src/TransportProcessor.js index 6e593d8..17507f2 100644 --- a/src/TransportProcessor.js +++ b/src/TransportProcessor.js @@ -14,20 +14,19 @@ class TransportProcessor extends AudioWorkletProcessor { handleMessage = (message) => { if (message.data && message.data.type === "ping") { this.port.postMessage(message.data); - } else if (message.data === "start") { + } else if (message.data.type === "start") { this.started = true; - } else if (message.data === "pause") { + } else if (message.data.type === "pause") { this.started = false; - } else if (message.data === "stop") { + } else if (message.data.type === "stop") { this.started = false; } else if (message.data.type === 'bpm') { this.bpm = message.data.value; - this.currentPulsePosition = 0; + this.currentPulsePosition = currentTime; } else if (message.data.type === 'ppqn') { this.ppqn = message.data.value; - this.currentPulsePosition = 0; } else if (message.data.type === 'nudge') { - this.nudge = message.data.value + this.nudge = message.data.value; } } @@ -38,7 +37,7 @@ class TransportProcessor extends AudioWorkletProcessor { const currentPulsePosition = Math.ceil(beatNumber * this.ppqn); if (currentPulsePosition > this.currentPulsePosition) { this.currentPulsePosition = currentPulsePosition; - this.port.postMessage({ type: "bang" }); + this.port.postMessage({ type: "bang", bpm: this.bpm }); } } return true; diff --git a/src/Utils/Generic.ts b/src/Utils/Generic.ts index d6e91e6..69e5da1 100644 --- a/src/Utils/Generic.ts +++ b/src/Utils/Generic.ts @@ -6,9 +6,15 @@ * @returns {Record[]} Array of objects * */ -export function objectWithArraysToArrayOfObjects(input: Record, ignoredKeys: string[]): Record[] { - ignoredKeys = ignoredKeys.map((k) => Array.isArray(input[k]) ? undefined : k).filter((k) => k !== undefined) as string[]; - const keys = Object.keys(input).filter((k) => !ignoredKeys.includes(k)); +export function objectWithArraysToArrayOfObjects(input: Record, arraysToArrays: string[]): Record[] { + arraysToArrays.forEach((k) => { + // Transform single array to array of arrays and keep array of arrays as is + if (Array.isArray(input[k]) && !Array.isArray(input[k][0])) { + input[k] = [input[k]]; + } + }); + const keys = Object.keys(input); + const maxLength = Math.max( ...keys.map((k) => Array.isArray(input[k]) ? (input[k] as any[]).length : 1 @@ -20,14 +26,10 @@ export function objectWithArraysToArrayOfObjects(input: Record, ign 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])) { + if (Array.isArray(input[k])) { event[k] = (input[k] as any[])[i % (input[k] as any[]).length]; - } else { + } else { event[k] = input[k]; - } } } output.push(event); diff --git a/src/classes/SoundEvent.ts b/src/classes/SoundEvent.ts index 4c753d3..2d34c12 100644 --- a/src/classes/SoundEvent.ts +++ b/src/classes/SoundEvent.ts @@ -1,6 +1,10 @@ import { type Editor } from "../main"; import { AudibleEvent } from "./AbstractEvents"; -import { filterObject, arrayOfObjectsToObjectWithArrays, objectWithArraysToArrayOfObjects } from "../Utils/Generic"; +import { + filterObject, + arrayOfObjectsToObjectWithArrays, + objectWithArraysToArrayOfObjects, +} from "../Utils/Generic"; import { chord as parseChord, midiToFreq, @@ -277,7 +281,6 @@ export class SoundEvent extends AudibleEvent { }, }; - constructor(sound: string | string[] | SoundParams, public app: Editor) { super(app); this.nudge = app.dough_nudge / 100; @@ -296,11 +299,13 @@ export class SoundEvent extends AudibleEvent { this.values = this.processSound(sound); } - private processSound = (sound: string | string[] | SoundParams | SoundParams[]): SoundParams => { - if (Array.isArray(sound) && typeof sound[0] === 'string') { + private processSound = ( + sound: string | string[] | SoundParams | SoundParams[] + ): SoundParams => { + if (Array.isArray(sound) && typeof sound[0] === "string") { const s: string[] = []; const n: number[] = []; - sound.forEach(str => { + sound.forEach((str) => { const parts = (str as string).split(":"); s.push(parts[0]); if (parts[1]) { @@ -311,13 +316,13 @@ export class SoundEvent extends AudibleEvent { s, n: n.length > 0 ? n : undefined, dur: this.app.clock.convertPulseToSecond(this.app.clock.ppqn), - analyze: true + analyze: true, }; - } else if (typeof sound === 'object') { + } else if (typeof sound === "object") { const validatedObj: SoundParams = { dur: this.app.clock.convertPulseToSecond(this.app.clock.ppqn), analyze: true, - ...sound as Partial + ...(sound as Partial), }; return validatedObj; } else { @@ -329,15 +334,18 @@ export class SoundEvent extends AudibleEvent { s, n, dur: this.app.clock.convertPulseToSecond(this.app.clock.ppqn), - analyze: true + analyze: true, }; } else { return { s: sound, dur: 0.5, analyze: true }; } } - } + }; - private updateValue(key: string, value: T | T[] | SoundParams[] | null): this { + private updateValue( + key: string, + value: T | T[] | SoundParams[] | null + ): this { if (value == null) return this; this.values[key] = value; return this; @@ -358,15 +366,21 @@ export class SoundEvent extends AudibleEvent { }; update = (): void => { - const filteredValues = filterObject(this.values, ["key", "pitch", "parsedScale", "octave"]); - const events = objectWithArraysToArrayOfObjects(filteredValues,["parsedScale"]); - + 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.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); @@ -379,8 +393,8 @@ export class SoundEvent extends AudibleEvent { }; public chord = (value: string) => { - const chord = parseChord(value); - return this.updateValue("note", chord); + const chord = parseChord(value); + return this.updateValue("note", chord); }; public invert = (howMany: number = 0) => { @@ -411,13 +425,11 @@ export class SoundEvent extends AudibleEvent { }; out = (): void => { - const events = objectWithArraysToArrayOfObjects(this.values,["parsedScale"]); + const events = objectWithArraysToArrayOfObjects(this.values, [ + "parsedScale", + ]); for (const event of events) { - superdough( - event, - this.nudge, - event.dur - ); + superdough(event, this.nudge + this.app.clock.deviation, event.dur); } }; } diff --git a/src/classes/ZPlayer.ts b/src/classes/ZPlayer.ts index 57a87fb..43ea493 100644 --- a/src/classes/ZPlayer.ts +++ b/src/classes/ZPlayer.ts @@ -24,7 +24,12 @@ export class Player extends Event { options: InputOptions = {}; skipIndex = 0; - constructor(input: string, options: InputOptions, public app: Editor, zid: string = "") { + constructor( + input: string, + options: InputOptions, + public app: Editor, + zid: string = "" + ) { super(app); this.input = input; this.options = options; @@ -109,19 +114,21 @@ export class Player extends Event { this.app.api.resetAllFromCache(); } - const patternIsStarting = (this.notStarted() && - (this.pulse() === 0 || this.origin() >= this.nextBeatInTicks()) && - this.origin() >= this.waitTime); + const patternIsStarting = + this.notStarted() && + (this.pulse() === 0 || this.origin() >= this.nextBeatInTicks()) && + this.origin() >= this.waitTime; - const timeToPlayNext = (this.current && + const timeToPlayNext = + this.current && this.pulseToSecond(this.origin()) >= - this.pulseToSecond(this.lastCallTime) + - this.pulseToSecond(this.current.duration*4*this.app.clock.ppqn) && - this.origin() >= this.waitTime); + this.pulseToSecond(this.lastCallTime) + + this.pulseToSecond(this.current.duration * 4 * this.app.clock.ppqn) && + this.origin() >= this.waitTime; // If pattern is starting or it's time to play next event const areWeThereYet = patternIsStarting || timeToPlayNext; - + // Increment index of how many times call is skipped this.skipIndex = areWeThereYet ? 0 : this.skipIndex + 1; @@ -142,7 +149,9 @@ 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); + const noteLengthInSeconds = this.app.clock.convertPulseToSecond( + event.duration * 4 * this.app.clock.ppqn + ); if (event instanceof Pitch) { const obj = event.getExisting( "freq", @@ -153,8 +162,8 @@ export class Player extends Event { "octave", "parsedScale" ) as SoundParams; - if(event.sound) name = event.sound as string; - if(event.soundIndex) obj.n = event.soundIndex as number; + if (event.sound) name = event.sound as string; + 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) { @@ -170,8 +179,11 @@ export class Player extends Event { ); }) as SoundParams[]; const add = { dur: noteLengthInSeconds } as SoundParams; - if(name) add.s = name; - let sound = arrayOfObjectsToObjectWithArrays(pitches,add) 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); @@ -191,10 +203,10 @@ export class Player extends Event { "key", "scale", "octave", - "parsedScale", + "parsedScale" ) as MidiParams; if (event instanceof Pitch) { - if(event.soundIndex) obj.channel = event.soundIndex as number; + 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) { @@ -236,7 +248,7 @@ export class Player extends Event { this.ziffers.invert(n); } return this; - } + }; retrograde() { if (this.atTheBeginning()) this.ziffers.retrograde();