Merge pull request #83 from Bubobubobubobubo/clockwork

Ahead-of-time scheduling (part I)
This commit is contained in:
Raphaël Forment
2023-11-04 23:04:01 +00:00
committed by GitHub
7 changed files with 162 additions and 85 deletions

View File

@ -116,6 +116,7 @@ export class UserAPI {
? code ? code
: (this.app.selectedExample as string); : (this.app.selectedExample as string);
} }
this.stop();
this.play(); this.play();
}; };
@ -126,7 +127,7 @@ export class UserAPI {
current_universe.example.candidate! = ""; current_universe.example.candidate! = "";
current_universe.example.committed! = ""; current_universe.example.committed! = "";
} }
this.pause(); this.stop();
}; };
_playDocExampleOnce = (code?: string) => { _playDocExampleOnce = (code?: string) => {
@ -135,6 +136,7 @@ export class UserAPI {
current_universe.example.candidate! = ""; current_universe.example.candidate! = "";
current_universe.example.committed! = ""; current_universe.example.committed! = "";
} }
this.stop();
this.play(); this.play();
this.app.exampleIsPlaying = true; this.app.exampleIsPlaying = true;
evaluateOnce(this.app, code as string); evaluateOnce(this.app, code as string);
@ -207,13 +209,11 @@ export class UserAPI {
public pause = (): void => { public pause = (): void => {
this.app.setButtonHighlighting("pause", true); this.app.setButtonHighlighting("pause", true);
this.MidiConnection.sendStopMessage();
this.app.clock.pause(); this.app.clock.pause();
}; };
public stop = (): void => { public stop = (): void => {
this.app.setButtonHighlighting("stop", true); this.app.setButtonHighlighting("stop", true);
this.MidiConnection.sendStopMessage();
this.app.clock.stop(); this.app.clock.stop();
}; };
silence = this.stop; silence = this.stop;

View File

@ -29,24 +29,38 @@ export class Clock {
* @param time_position - The current time position * @param time_position - The current time position
* @param ppqn - The pulses per quarter note * @param ppqn - The pulses per quarter note
* @param tick - The current tick since origin * @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; ctx: AudioContext;
logicalTime: number;
transportNode: TransportNode | null; transportNode: TransportNode | null;
private _bpm: number; private _bpm: number;
time_signature: number[]; time_signature: number[];
time_position: TimePosition; time_position: TimePosition;
private _ppqn: number; private _ppqn: number;
tick: number; tick: number;
running: boolean;
lastPauseTime: number;
lastPlayPressTime: number;
totalPauseTime: number;
constructor(public app: Editor, ctx: AudioContext) { 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.time_signature = [4, 4];
this.tick = -1; this.logicalTime = 0;
this.tick = 0;
this._bpm = 120; this._bpm = 120;
this._ppqn = 48; this._ppqn = 48;
this.transportNode = null; this.transportNode = null;
this.ctx = ctx; this.ctx = ctx;
this.running = true;
this.lastPauseTime = 0;
this.lastPlayPressTime = 0;
this.totalPauseTime = 0;
ctx.audioWorklet ctx.audioWorklet
.addModule(TransportProcessor) .addModule(TransportProcessor)
.then((e) => { .then((e) => {
@ -122,6 +136,13 @@ export class Clock {
return 60 / this.bpm / this.ppqn; 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 { get bpm(): number {
return this._bpm; return this._bpm;
} }
@ -132,8 +153,9 @@ export class Clock {
set bpm(bpm: number) { set bpm(bpm: number) {
if (bpm > 0 && this._bpm !== bpm) { if (bpm > 0 && this._bpm !== bpm) {
this._bpm = bpm;
this.transportNode?.setBPM(bpm); this.transportNode?.setBPM(bpm);
this._bpm = bpm;
this.logicalTime = this.realTime;
} }
} }
@ -141,6 +163,14 @@ export class Clock {
return this._ppqn; 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) { set ppqn(ppqn: number) {
if (ppqn > 0 && this._ppqn !== ppqn) { if (ppqn > 0 && this._ppqn !== ppqn) {
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 { public nextTickFrom(time: number, nudge: number): number {
/** /**
* Compute the time remaining before the next clock tick. * 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 * @remark also sends a MIDI message if a port is declared
*/ */
this.app.audioContext.resume(); this.app.audioContext.resume();
this.running = true;
this.app.api.MidiConnection.sendStartMessage(); this.app.api.MidiConnection.sendStartMessage();
this.lastPlayPressTime = this.app.audioContext.currentTime;
this.totalPauseTime += (this.lastPlayPressTime - this.lastPauseTime);
this.transportNode?.start(); this.transportNode?.start();
} }
@ -190,8 +228,11 @@ export class Clock {
* *
* @remark also sends a MIDI message if a port is declared * @remark also sends a MIDI message if a port is declared
*/ */
this.running = false;
this.transportNode?.pause(); this.transportNode?.pause();
this.app.api.MidiConnection.sendStopMessage(); this.app.api.MidiConnection.sendStopMessage();
this.lastPauseTime = this.app.audioContext.currentTime;
this.logicalTime = this.realTime;
} }
public stop(): void { public stop(): void {
@ -200,8 +241,11 @@ export class Clock {
* *
* @remark also sends a MIDI message if a port is declared * @remark also sends a MIDI message if a port is declared
*/ */
this.app.clock.tick = -1; this.running = false;
this.time_position = { bar: -1, beat: -1, pulse: -1 }; 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.app.api.MidiConnection.sendStopMessage();
this.transportNode?.stop(); this.transportNode?.stop();
} }

View File

@ -12,31 +12,39 @@ export class TransportNode extends AudioWorkletNode {
/** @type {(this: MessagePort, ev: MessageEvent<any>) => any} */ /** @type {(this: MessagePort, ev: MessageEvent<any>) => any} */
handleMessage = (message) => { handleMessage = (message) => {
if (message.data && message.data.type === "bang") { if(message.data) {
if (this.app.settings.send_clock) if (message.data.type === "bang") {
this.app.api.MidiConnection.sendMidiClock(); if(this.app.clock.running) {
this.app.clock.tick++; if (this.app.settings.send_clock) {
const futureTimeStamp = this.app.clock.convertTicksToTimeposition( this.app.api.MidiConnection.sendMidiClock();
this.app.clock.tick }
); const futureTimeStamp = this.app.clock.convertTicksToTimeposition(
this.app.clock.time_position = futureTimeStamp; this.app.clock.tick
this.timeviewer.innerHTML = `${zeroPad(futureTimeStamp.bar, 2)}:${ );
futureTimeStamp.beat + 1 this.app.clock.time_position = futureTimeStamp;
}:${zeroPad(futureTimeStamp.pulse, 2)} / ${this.app.clock.bpm}`; this.timeviewer.innerHTML = `${zeroPad(futureTimeStamp.bar, 2)}:${futureTimeStamp.beat + 1
if (this.app.exampleIsPlaying) { }:${zeroPad(futureTimeStamp.pulse, 2)} / ${this.app.clock.bpm}`;
tryEvaluate(this.app, this.app.example_buffer); if (this.app.exampleIsPlaying) {
} else { tryEvaluate(this.app, this.app.example_buffer);
tryEvaluate(this.app, this.app.global_buffer); } else {
tryEvaluate(this.app, this.app.global_buffer);
}
this.app.clock.incrementTick(message.data.bpm);
}
} }
} }
}; };
start() { start() {
this.port.postMessage("start"); this.port.postMessage({ type: "start" });
} }
pause() { pause() {
this.port.postMessage("pause"); this.port.postMessage({ type: "pause" });
}
resume() {
this.port.postMessage({ type: "resume" });
} }
setBPM(bpm) { setBPM(bpm) {
@ -52,6 +60,6 @@ export class TransportNode extends AudioWorkletNode {
} }
stop() { stop() {
this.port.postMessage("stop"); this.port.postMessage({type: "stop" });
} }
} }

View File

@ -14,20 +14,19 @@ class TransportProcessor extends AudioWorkletProcessor {
handleMessage = (message) => { handleMessage = (message) => {
if (message.data && message.data.type === "ping") { if (message.data && message.data.type === "ping") {
this.port.postMessage(message.data); this.port.postMessage(message.data);
} else if (message.data === "start") { } else if (message.data.type === "start") {
this.started = true; this.started = true;
} else if (message.data === "pause") { } else if (message.data.type === "pause") {
this.started = false; this.started = false;
} else if (message.data === "stop") { } else if (message.data.type === "stop") {
this.started = false; this.started = false;
} else if (message.data.type === 'bpm') { } else if (message.data.type === 'bpm') {
this.bpm = message.data.value; this.bpm = message.data.value;
this.currentPulsePosition = 0; this.currentPulsePosition = currentTime;
} else if (message.data.type === 'ppqn') { } else if (message.data.type === 'ppqn') {
this.ppqn = message.data.value; this.ppqn = message.data.value;
this.currentPulsePosition = 0;
} else if (message.data.type === 'nudge') { } 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); const currentPulsePosition = Math.ceil(beatNumber * this.ppqn);
if (currentPulsePosition > this.currentPulsePosition) { if (currentPulsePosition > this.currentPulsePosition) {
this.currentPulsePosition = currentPulsePosition; this.currentPulsePosition = currentPulsePosition;
this.port.postMessage({ type: "bang" }); this.port.postMessage({ type: "bang", bpm: this.bpm });
} }
} }
return true; return true;

View File

@ -6,9 +6,15 @@
* @returns {Record<string, any>[]} Array of objects * @returns {Record<string, any>[]} Array of objects
* *
*/ */
export function objectWithArraysToArrayOfObjects(input: Record<string, any>, ignoredKeys: string[]): Record<string, any>[] { export function objectWithArraysToArrayOfObjects(input: Record<string, any>, arraysToArrays: string[]): Record<string, any>[] {
ignoredKeys = ignoredKeys.map((k) => Array.isArray(input[k]) ? undefined : k).filter((k) => k !== undefined) as string[]; arraysToArrays.forEach((k) => {
const keys = Object.keys(input).filter((k) => !ignoredKeys.includes(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( const maxLength = Math.max(
...keys.map((k) => ...keys.map((k) =>
Array.isArray(input[k]) ? (input[k] as any[]).length : 1 Array.isArray(input[k]) ? (input[k] as any[]).length : 1
@ -20,14 +26,10 @@ export function objectWithArraysToArrayOfObjects(input: Record<string, any>, ign
for (let i = 0; i < maxLength; i++) { for (let i = 0; i < maxLength; i++) {
const event: Record<string, any> = {}; const event: Record<string, any> = {};
for (const k of keys) { for (const k of keys) {
if (ignoredKeys.includes(k)) { if (Array.isArray(input[k])) {
event[k] = input[k];
} else {
if (Array.isArray(input[k])) {
event[k] = (input[k] as any[])[i % (input[k] as any[]).length]; event[k] = (input[k] as any[])[i % (input[k] as any[]).length];
} else { } else {
event[k] = input[k]; event[k] = input[k];
}
} }
} }
output.push(event); output.push(event);

View File

@ -1,6 +1,10 @@
import { type Editor } from "../main"; import { type Editor } from "../main";
import { AudibleEvent } from "./AbstractEvents"; import { AudibleEvent } from "./AbstractEvents";
import { filterObject, arrayOfObjectsToObjectWithArrays, objectWithArraysToArrayOfObjects } from "../Utils/Generic"; import {
filterObject,
arrayOfObjectsToObjectWithArrays,
objectWithArraysToArrayOfObjects,
} from "../Utils/Generic";
import { import {
chord as parseChord, chord as parseChord,
midiToFreq, midiToFreq,
@ -277,7 +281,6 @@ export class SoundEvent extends AudibleEvent {
}, },
}; };
constructor(sound: string | string[] | SoundParams, public app: Editor) { constructor(sound: string | string[] | SoundParams, public app: Editor) {
super(app); super(app);
this.nudge = app.dough_nudge / 100; this.nudge = app.dough_nudge / 100;
@ -296,11 +299,13 @@ export class SoundEvent extends AudibleEvent {
this.values = this.processSound(sound); this.values = this.processSound(sound);
} }
private processSound = (sound: string | string[] | SoundParams | SoundParams[]): SoundParams => { private processSound = (
if (Array.isArray(sound) && typeof sound[0] === 'string') { sound: string | string[] | SoundParams | SoundParams[]
): SoundParams => {
if (Array.isArray(sound) && typeof sound[0] === "string") {
const s: string[] = []; const s: string[] = [];
const n: number[] = []; const n: number[] = [];
sound.forEach(str => { sound.forEach((str) => {
const parts = (str as string).split(":"); const parts = (str as string).split(":");
s.push(parts[0]); s.push(parts[0]);
if (parts[1]) { if (parts[1]) {
@ -311,13 +316,13 @@ export class SoundEvent extends AudibleEvent {
s, s,
n: n.length > 0 ? n : undefined, n: n.length > 0 ? n : undefined,
dur: this.app.clock.convertPulseToSecond(this.app.clock.ppqn), 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 = { const validatedObj: SoundParams = {
dur: this.app.clock.convertPulseToSecond(this.app.clock.ppqn), dur: this.app.clock.convertPulseToSecond(this.app.clock.ppqn),
analyze: true, analyze: true,
...sound as Partial<SoundParams> ...(sound as Partial<SoundParams>),
}; };
return validatedObj; return validatedObj;
} else { } else {
@ -329,15 +334,18 @@ export class SoundEvent extends AudibleEvent {
s, s,
n, n,
dur: this.app.clock.convertPulseToSecond(this.app.clock.ppqn), dur: this.app.clock.convertPulseToSecond(this.app.clock.ppqn),
analyze: true analyze: true,
}; };
} else { } else {
return { s: sound, dur: 0.5, analyze: true }; return { s: sound, dur: 0.5, analyze: true };
} }
} }
} };
private updateValue<T>(key: string, value: T | T[] | SoundParams[] | null): this { private updateValue<T>(
key: string,
value: T | T[] | SoundParams[] | null
): this {
if (value == null) return this; if (value == null) return this;
this.values[key] = value; this.values[key] = value;
return this; return this;
@ -358,15 +366,21 @@ export class SoundEvent extends AudibleEvent {
}; };
update = (): void => { update = (): void => {
const filteredValues = filterObject(this.values, ["key", "pitch", "parsedScale", "octave"]); const filteredValues = filterObject(this.values, [
const events = objectWithArraysToArrayOfObjects(filteredValues,["parsedScale"]); "key",
"pitch",
"parsedScale",
"octave",
]);
const events = objectWithArraysToArrayOfObjects(filteredValues, [
"parsedScale",
]);
events.forEach((event) => { events.forEach((event) => {
const [note, _] = noteFromPc( const [note, _] = noteFromPc(
event.key as number || "C4", (event.key as number) || "C4",
event.pitch as number || 0, (event.pitch as number) || 0,
event.parsedScale as number[] || event.scale || "MAJOR", (event.parsedScale as number[]) || event.scale || "MAJOR",
event.octave as number || 0 (event.octave as number) || 0
); );
event.note = note; event.note = note;
event.freq = midiToFreq(note); event.freq = midiToFreq(note);
@ -379,8 +393,8 @@ export class SoundEvent extends AudibleEvent {
}; };
public chord = (value: string) => { public chord = (value: string) => {
const chord = parseChord(value); const chord = parseChord(value);
return this.updateValue("note", chord); return this.updateValue("note", chord);
}; };
public invert = (howMany: number = 0) => { public invert = (howMany: number = 0) => {
@ -411,13 +425,11 @@ export class SoundEvent extends AudibleEvent {
}; };
out = (): void => { out = (): void => {
const events = objectWithArraysToArrayOfObjects(this.values,["parsedScale"]); const events = objectWithArraysToArrayOfObjects(this.values, [
"parsedScale",
]);
for (const event of events) { for (const event of events) {
superdough( superdough(event, this.nudge + this.app.clock.deviation, event.dur);
event,
this.nudge,
event.dur
);
} }
}; };
} }

View File

@ -24,7 +24,12 @@ export class Player extends Event {
options: InputOptions = {}; options: InputOptions = {};
skipIndex = 0; skipIndex = 0;
constructor(input: string, options: InputOptions, public app: Editor, zid: string = "") { constructor(
input: string,
options: InputOptions,
public app: Editor,
zid: string = ""
) {
super(app); super(app);
this.input = input; this.input = input;
this.options = options; this.options = options;
@ -109,15 +114,17 @@ export class Player extends Event {
this.app.api.resetAllFromCache(); this.app.api.resetAllFromCache();
} }
const patternIsStarting = (this.notStarted() && const patternIsStarting =
(this.pulse() === 0 || this.origin() >= this.nextBeatInTicks()) && this.notStarted() &&
this.origin() >= this.waitTime); (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.origin()) >=
this.pulseToSecond(this.lastCallTime) + this.pulseToSecond(this.lastCallTime) +
this.pulseToSecond(this.current.duration*4*this.app.clock.ppqn) && this.pulseToSecond(this.current.duration * 4 * this.app.clock.ppqn) &&
this.origin() >= this.waitTime); this.origin() >= this.waitTime;
// If pattern is starting or it's time to play next event // If pattern is starting or it's time to play next event
const areWeThereYet = patternIsStarting || timeToPlayNext; const areWeThereYet = patternIsStarting || timeToPlayNext;
@ -142,7 +149,9 @@ export class Player extends Event {
sound(name?: string) { sound(name?: string) {
if (this.areWeThereYet()) { if (this.areWeThereYet()) {
const event = this.next() as Pitch | Chord | ZRest; 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) { if (event instanceof Pitch) {
const obj = event.getExisting( const obj = event.getExisting(
"freq", "freq",
@ -153,8 +162,8 @@ export class Player extends Event {
"octave", "octave",
"parsedScale" "parsedScale"
) as SoundParams; ) as SoundParams;
if(event.sound) name = event.sound as string; if (event.sound) name = event.sound as string;
if(event.soundIndex) obj.n = event.soundIndex as number; if (event.soundIndex) obj.n = event.soundIndex as number;
obj.dur = noteLengthInSeconds; obj.dur = noteLengthInSeconds;
return new SoundEvent(obj, this.app).sound(name || "sine"); return new SoundEvent(obj, this.app).sound(name || "sine");
} else if (event instanceof Chord) { } else if (event instanceof Chord) {
@ -170,8 +179,11 @@ export class Player extends Event {
); );
}) as SoundParams[]; }) as SoundParams[];
const add = { dur: noteLengthInSeconds } as SoundParams; const add = { dur: noteLengthInSeconds } as SoundParams;
if(name) add.s = name; if (name) add.s = name;
let sound = arrayOfObjectsToObjectWithArrays(pitches,add) as SoundParams; let sound = arrayOfObjectsToObjectWithArrays(
pitches,
add
) as SoundParams;
return new SoundEvent(sound, this.app); return new SoundEvent(sound, this.app);
} else if (event instanceof ZRest) { } else if (event instanceof ZRest) {
return RestEvent.createRestProxy(event.duration, this.app); return RestEvent.createRestProxy(event.duration, this.app);
@ -191,10 +203,10 @@ export class Player extends Event {
"key", "key",
"scale", "scale",
"octave", "octave",
"parsedScale", "parsedScale"
) as MidiParams; ) as MidiParams;
if (event instanceof Pitch) { 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); const note = new MidiEvent(obj, this.app);
return value ? note.note(value) : note; return value ? note.note(value) : note;
} else if (event instanceof ZRest) { } else if (event instanceof ZRest) {
@ -236,7 +248,7 @@ export class Player extends Event {
this.ziffers.invert(n); this.ziffers.invert(n);
} }
return this; return this;
} };
retrograde() { retrograde() {
if (this.atTheBeginning()) this.ziffers.retrograde(); if (this.atTheBeginning()) this.ziffers.retrograde();