diff --git a/index.html b/index.html index 173eb1f..83b5b39 100644 --- a/index.html +++ b/index.html @@ -378,6 +378,14 @@ Destroy universes + +
Audio samples
+ + diff --git a/src/API.ts b/src/API.ts index 20f1207..8165903 100644 --- a/src/API.ts +++ b/src/API.ts @@ -2920,7 +2920,7 @@ export class UserAPI { address: address, port: port, args: args, - timetag: Math.round(Date.now() + this.app.clock.deadline), + timetag: Math.round(Date.now() + (this.app.clock.nudge - this.app.clock.deviation)), } as OSCMessage); }; diff --git a/src/Clock.ts b/src/Clock.ts index 4614022..5ab5f62 100644 --- a/src/Clock.ts +++ b/src/Clock.ts @@ -1,11 +1,7 @@ +// @ts-ignore +import { TransportNode } from "./TransportNode"; +import TransportProcessor from "./TransportProcessor?worker&url"; import { Editor } from "./main"; -import { tryEvaluate } from "./Evaluator"; -// @ts-ignore -import { getAudioContext } from "superdough"; -// @ts-ignore -import "zyklus"; -const zeroPad = (num: number, places: number) => - String(num).padStart(places, "0"); export interface TimePosition { /** @@ -22,29 +18,35 @@ export interface TimePosition { export class Clock { /** + * The Clock Class is responsible for keeping track of the current time. + * It is also responsible for starting and stopping the Clock TransportNode. * - * @param app - main application instance - * @param clock - zyklus clock - * @param ctx - current AudioContext used by app - * @param bpm - current beats per minute value - * @param time_signature - time signature - * @param time_position - current time position - * @param ppqn - pulses per quarter note - * @param tick - current tick since origin + * @param app - The main application instance + * @param ctx - The current AudioContext used by app + * @param transportNode - The TransportNode helper + * @param bpm - The current beats per minute value + * @param time_signature - The time signature + * @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 */ - private _bpm: number; - private _ppqn: number; - clock: any; ctx: AudioContext; logicalTime: number; + transportNode: TransportNode | null; + private _bpm: number; time_signature: number[]; time_position: TimePosition; + private _ppqn: number; tick: number; running: boolean; - timeviewer: HTMLElement; - deadline: number; + lastPauseTime: number; + lastPlayPressTime: number; + totalPauseTime: number; constructor( public app: Editor, @@ -56,59 +58,31 @@ export class Clock { this.tick = 0; this._bpm = 120; this._ppqn = 48; + this.transportNode = null; this.ctx = ctx; this.running = true; - this.deadline = 0; - this.timeviewer = document.getElementById("timeviewer")!; - this.clock = getAudioContext().createClock( - this.clockCallback, - this.pulse_duration, - ); + this.lastPauseTime = 0; + this.lastPlayPressTime = 0; + this.totalPauseTime = 0; + ctx.audioWorklet + .addModule(TransportProcessor) + .then((e) => { + this.transportNode = new TransportNode(ctx, {}, this.app); + this.transportNode.connect(ctx.destination); + return e; + }) + .catch((e) => { + console.log("Error loading TransportProcessor.js:", e); + }); } - // @ts-ignore - clockCallback = (time: number, duration: number, tick: number) => { - /** - * Callback function for the zyklus clock. Updates the clock info and sends a - * MIDI clock message if the setting is enabled. Also evaluates the global buffer. - * - * @param time - precise AudioContext time when the tick should happen - * @param duration - seconds between each tick - * @param tick - count of the current tick - */ - let deadline = time - getAudioContext().currentTime; - this.deadline = deadline; - this.tick = tick; - 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; - if (futureTimeStamp.pulse % this.app.clock.ppqn == 0) { - this.timeviewer.innerHTML = `${zeroPad(futureTimeStamp.bar, 2)}:${ - futureTimeStamp.beat + 1 - } / ${this.app.clock.bpm}`; - } - if (this.app.exampleIsPlaying) { - tryEvaluate(this.app, this.app.example_buffer); - } else { - tryEvaluate(this.app, this.app.global_buffer); - } - } - - // Implement TransportNode clock callback and update clock info with it - }; - convertTicksToTimeposition(ticks: number): TimePosition { /** - * Converts ticks to a time position. - * - * @param ticks - ticks to convert - * @returns TimePosition + * Converts ticks to a TimePosition object. + * @param ticks The number of ticks to convert. + * @returns The TimePosition object representing the converted ticks. */ + const beatsPerBar = this.app.clock.time_signature[0]; const ppqnPosition = ticks % this.app.clock.ppqn; const beatNumber = Math.floor(ticks / this.app.clock.ppqn); @@ -119,9 +93,10 @@ export class Clock { get ticks_before_new_bar(): number { /** - * Calculates the number of ticks before the next bar. + * This function returns the number of ticks separating the current moment + * from the beginning of the next bar. * - * @returns number - ticks before the next bar + * @returns number of ticks until next bar */ const ticskMissingFromBeat = this.ppqn - this.time_position.pulse; const beatsMissingFromBar = this.beats_per_bar - this.time_position.beat; @@ -130,9 +105,10 @@ export class Clock { get next_beat_in_ticks(): number { /** - * Calculates the number of ticks before the next beat. + * This function returns the number of ticks separating the current moment + * from the beginning of the next beat. * - * @returns number - ticks before the next beat + * @returns number of ticks until next beat */ return this.app.clock.pulses_since_origin + this.time_position.pulse; } @@ -140,8 +116,6 @@ export class Clock { get beats_per_bar(): number { /** * Returns the number of beats per bar. - * - * @returns number - beats per bar */ return this.time_signature[0]; } @@ -150,7 +124,7 @@ export class Clock { /** * Returns the number of beats since the origin. * - * @returns number - beats since the origin + * @returns number of beats since origin */ return Math.floor(this.tick / this.ppqn); } @@ -159,7 +133,7 @@ export class Clock { /** * Returns the number of pulses since the origin. * - * @returns number - pulses since the origin + * @returns number of pulses since origin */ return this.tick; } @@ -167,112 +141,119 @@ export class Clock { get pulse_duration(): number { /** * Returns the duration of a pulse in seconds. - * @returns number - duration of a pulse in seconds */ 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 given bpm. - * - * @param bpm - bpm to calculate the pulse duration for - * @returns number - duration of a pulse in seconds + * Returns the duration of a pulse in seconds at a specific bpm. */ return 60 / bpm / this.ppqn; } get bpm(): number { - /** - * Returns the current bpm. - * @returns number - current bpm - */ return this._bpm; } - get tickDuration(): number { - /** - * Returns the duration of a tick in seconds. - * @returns number - duration of a tick in seconds - */ - return 1 / this.ppqn; + set nudge(nudge: number) { + this.transportNode?.setNudge(nudge); } set bpm(bpm: number) { - /** - * Sets the bpm. - * @param bpm - bpm to set - */ if (bpm > 0 && this._bpm !== bpm) { + this.transportNode?.setBPM(bpm); this._bpm = bpm; - this.clock.setDuration(() => (this.tickDuration * 60) / this.bpm); + this.logicalTime = this.realTime; } } get ppqn(): number { - /** - * Returns the current ppqn. - * @returns number - current 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) { - /** - * Sets the ppqn. - * @param ppqn - ppqn to set - * @returns number - current ppqn - */ if (ppqn > 0 && this._ppqn !== ppqn) { this._ppqn = ppqn; + this.transportNode?.setPPQN(ppqn); + this.logicalTime = this.realTime; } } + 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. + * @param time - audio context currentTime + * @param nudge - nudge in the future (in seconds) + * @returns remainingTime + */ const pulseDuration = this.pulse_duration; const nudgedTime = time + nudge; const nextTickTime = Math.ceil(nudgedTime / pulseDuration) * pulseDuration; const remainingTime = nextTickTime - nudgedTime; + return remainingTime; } public convertPulseToSecond(n: number): number { + /** + * Converts a pulse to a second. + */ return n * this.pulse_duration; } public start(): void { /** - * Start the clock + * Starts the TransportNode (starts the 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.clock.start(); + this.lastPlayPressTime = this.app.audioContext.currentTime; + this.totalPauseTime += this.lastPlayPressTime - this.lastPauseTime; + this.transportNode?.start(); } public pause(): void { /** - * Pause the clock. + * Pauses the TransportNode (pauses the clock). * * @remark also sends a MIDI message if a port is declared */ this.running = false; + this.transportNode?.pause(); this.app.api.MidiConnection.sendStopMessage(); - this.clock.pause(); + this.lastPauseTime = this.app.audioContext.currentTime; + this.logicalTime = this.realTime; } public stop(): void { /** - * Stops the clock. + * Stops the TransportNode (stops the clock). * * @remark also sends a MIDI message if a port is declared */ 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.clock.stop(); + this.transportNode?.stop(); } -} +} \ No newline at end of file diff --git a/src/DomElements.ts b/src/DomElements.ts index c2f8278..6267e53 100644 --- a/src/DomElements.ts +++ b/src/DomElements.ts @@ -18,6 +18,8 @@ export const singleElements = { load_universe_button: "load-universe-button", download_universe_button: "download-universes", upload_universe_button: "upload-universes", + upload_samples_button: "upload-samples", + sample_indicator: "sample-indicator", destroy_universes_button: "destroy-universes", documentation_button: "doc-button-1", eval_button: "eval-button-1", @@ -81,7 +83,7 @@ export const createDocumentationStyle = (app: Editor) => { p: "lg:text-2xl text-base text-white lg:mx-6 mx-2 my-4 leading-normal", warning: "animate-pulse lg:text-2xl font-bold text-brightred lg:mx-6 mx-2 my-4 leading-normal", - a: "lg:text-2xl text-base text-white", + a: "lg:text-2xl text-base text-brightred", code: `lg:my-4 sm:my-1 text-base lg:text-xl block whitespace-pre overflow-x-hidden`, icode: "lg:my-1 my-1 lg:text-xl sm:text-xs text-brightwhite font-mono bg-brightblack", diff --git a/src/EditorSetup.ts b/src/EditorSetup.ts index 0272013..aff82d7 100644 --- a/src/EditorSetup.ts +++ b/src/EditorSetup.ts @@ -81,8 +81,8 @@ export const getCodeMirrorTheme = (theme: {[key: string]: string}): Extension => }, "&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection": { - backgroundColor: selection_foreground, - border: `0.5px solid ${selection_background}`, + backgroundColor: brightwhite, + border: `1px solid ${brightwhite}`, }, ".cm-panels": { backgroundColor: selection_background, @@ -98,18 +98,15 @@ export const getCodeMirrorTheme = (theme: {[key: string]: string}): Extension => backgroundColor: red, }, ".cm-activeLine": { - // backgroundColor: highlightBackground - backgroundColor: `${selection_foreground}`, + backgroundColor: `rgba(${(parseInt(selection_background.slice(1,3), 16))}, ${(parseInt(selection_background.slice(3,5), 16))}, ${(parseInt(selection_background.slice(5,7), 16))}, 0.25)`, }, ".cm-selectionMatch": { - backgroundColor: yellow, - outline: `1px solid ${red}`, + backgroundColor: `rgba(${(parseInt(selection_background.slice(1,3), 16))}, ${(parseInt(selection_background.slice(3,5), 16))}, ${(parseInt(selection_background.slice(5,7), 16))}, 0.25)`, + outline: `1px solid ${brightwhite}`, }, "&.cm-focused .cm-matchingBracket": { - color: yellow, - // outline: `1px solid ${base02}`, + color: `rgba(${(parseInt(selection_background.slice(1,3), 16))}, ${(parseInt(selection_background.slice(3,5), 16))}, ${(parseInt(selection_background.slice(5,7), 16))}, 0.25)`, }, - "&.cm-focused .cm-nonmatchingBracket": { color: yellow, }, @@ -153,9 +150,9 @@ export const getCodeMirrorTheme = (theme: {[key: string]: string}): Extension => { tag: t.keyword, color: yellow }, { tag: [t.name, t.deleted, t.character, t.macroName], color: red, }, { tag: [t.function(t.variableName)], color: blue }, - { tag: [t.labelName], color: red }, + { tag: [t.labelName], color: brightwhite }, { tag: [t.color, t.constant(t.name), t.standard(t.name)], color: cyan, }, - { tag: [t.definition(t.name), t.separator], color: magenta }, + { tag: [t.definition(t.name), t.separator], color: brightwhite }, { tag: [t.brace], color: white }, { tag: [t.annotation], color: blue, }, { tag: [t.number, t.changed, t.annotation, t.modifier, t.self, t.namespace], color: yellow, }, @@ -229,7 +226,7 @@ export const getCodeMirrorTheme = (theme: {[key: string]: string}): Extension => // pointerEvents: "none", // }, // }); - +// // const debugHighlightStyle = HighlightStyle.define( // // @ts-ignore // Object.entries(t).map(([key, value]) => { diff --git a/src/IO/SampleLoading.ts b/src/IO/SampleLoading.ts new file mode 100644 index 0000000..148838d --- /dev/null +++ b/src/IO/SampleLoading.ts @@ -0,0 +1,155 @@ +/** + * This code is taken from https://github.com/tidalcycles/strudel/pull/839. The logic is written by + * daslyfe (Jade Rose Rowland). I have tweaked it a bit to fit the needs of this project (TypeScript), + * etc... Many thanks for this piece of code! This code is initially part of the Strudel project: + * https://github.com/tidalcycles/strudel. + */ + +// @ts-ignore +import { registerSound, onTriggerSample } from "superdough"; + +export const isAudioFile = (filename: string) => ['wav', 'mp3'].includes(filename.split('.').slice(-1)[0]); + +interface samplesDBConfig { + dbName: string, + table: string, + columns: string[], + version: number +} + +export const samplesDBConfig = { + dbName: 'samples', + table: 'usersamples', + columns: ['data_url', 'title'], + version: 1 +} + +async function bufferToDataUrl(buf: Buffer) { + return new Promise((resolve) => { + var blob = new Blob([buf], { type: 'application/octet-binary' }); + var reader = new FileReader(); + reader.onload = function (event: Event) { + // @ts-ignore + resolve(event.target.result); + }; + reader.readAsDataURL(blob); + }); +} + +const processFilesForIDB = async (files: FileList) => { + return await Promise.all( + Array.from(files) + .map(async (s: File) => { + const title = s.name; + if (!isAudioFile(title)) { + return; + } + //create obscured url to file system that can be fetched + const sUrl = URL.createObjectURL(s); + //fetch the sound and turn it into a buffer array + const buf = await fetch(sUrl).then((res) => res.arrayBuffer()); + //create a url blob containing all of the buffer data + // @ts-ignore + // TODO: conversion to do here, remove ts-ignore + const base64 = await bufferToDataUrl(buf); + return { + title, + blob: base64, + id: s.webkitRelativePath, + }; + }) + .filter(Boolean), + ).catch((error) => { + console.log('Something went wrong while processing uploaded files', error); + }); +}; + + +export const registerSamplesFromDB = (config: samplesDBConfig, onComplete = () => {}) => { + openDB(config, (objectStore: IDBObjectStore) => { + let query = objectStore.getAll(); + query.onsuccess = (event: Event) => { + // @ts-ignore + const soundFiles = event.target.result; + if (!soundFiles?.length) { + return; + } + const sounds = new Map(); + [...soundFiles] + .sort((a, b) => a.title.localeCompare(b.title, undefined, { numeric: true, sensitivity: 'base' })) + .forEach((soundFile) => { + const title = soundFile.title; + if (!isAudioFile(title)) { + return; + } + const splitRelativePath = soundFile.id?.split('/'); + const parentDirectory = splitRelativePath[splitRelativePath.length - 2]; + const soundPath = soundFile.blob; + const soundPaths = sounds.get(parentDirectory) ?? new Set(); + soundPaths.add(soundPath); + sounds.set(parentDirectory, soundPaths); + }); + + sounds.forEach((soundPaths, key) => { + const value = Array.from(soundPaths); + // @ts-ignore + registerSound(key, (t, hapValue, onended) => onTriggerSample(t, hapValue, onended, value), { + type: 'sample', + samples: value, + baseUrl: undefined, + prebake: false, + tag: "user", + }); + }); + onComplete(); + }; + }); +}; + +export const openDB = (config: samplesDBConfig, onOpened: Function) => { + const { dbName, version, table, columns } = config + + if (!('indexedDB' in window)) { + console.log('This browser doesn\'t support IndexedDB') + return + } + const dbOpen = indexedDB.open(dbName, version); + + + dbOpen.onupgradeneeded = (_event) => { + const db = dbOpen.result; + const objectStore = db.createObjectStore(table, { keyPath: 'id', autoIncrement: false }); + columns.forEach((c: any) => { + objectStore.createIndex(c, c, { unique: false }); + }); + }; + dbOpen.onerror = function (err: Event) { + console.log('Error opening DB: ', (err.target as IDBOpenDBRequest).error); + } + dbOpen.onsuccess = function (_event: Event) { + const db = dbOpen.result; + db.onversionchange = function() { + db.close(); + alert("Database is outdated, please reload the page.") + }; + const writeTransaction = db.transaction([table], 'readwrite'), + objectStore = writeTransaction.objectStore(table); + // Writing in the database here! + onOpened(objectStore) + } +} + +export const uploadSamplesToDB = async (config: samplesDBConfig, files: FileList) => { + await processFilesForIDB(files).then((files) => { + const onOpened = (objectStore: IDBObjectStore, _db: IDBDatabase) => { + // @ts-ignore + files.forEach((file: File) => { + if (file == null) { + return; + } + objectStore.put(file); + }); + }; + openDB(config, onOpened); + }); +}; \ No newline at end of file diff --git a/src/InterfaceLogic.ts b/src/InterfaceLogic.ts index 736eab6..23d3070 100644 --- a/src/InterfaceLogic.ts +++ b/src/InterfaceLogic.ts @@ -25,6 +25,7 @@ import { lineNumbers } from "@codemirror/view"; import { jsCompletions } from "./EditorSetup"; import { createDocumentationStyle } from "./DomElements"; import { saveState } from "./WindowBehavior"; +import { registerSamplesFromDB, samplesDBConfig, uploadSamplesToDB } from "./IO/SampleLoading"; export const installInterfaceLogic = (app: Editor) => { // Initialize style @@ -159,6 +160,21 @@ export const installInterfaceLogic = (app: Editor) => { ); }); + app.interface.upload_samples_button.addEventListener("input", async (event) => { + let fileInput = event.target as HTMLInputElement; + if (!fileInput.files?.length) { + return; + } + app.interface.sample_indicator.innerText = "Loading..."; + app.interface.sample_indicator.classList.add("animate-pulse"); + await uploadSamplesToDB(samplesDBConfig, fileInput.files).then(() => { + registerSamplesFromDB(samplesDBConfig, () => { + app.interface.sample_indicator.innerText = "Import samples"; + app.interface.sample_indicator.classList.remove("animate-pulse"); + }); + }); + }); + app.interface.upload_universe_button.addEventListener("click", () => { const fileInput = document.createElement("input"); fileInput.type = "file"; @@ -583,4 +599,4 @@ export const installInterfaceLogic = (app: Editor) => { console.log("Could not find element " + name); } }); -}; +}; \ No newline at end of file diff --git a/src/TransportNode.js b/src/TransportNode.js new file mode 100644 index 0000000..ecea9b0 --- /dev/null +++ b/src/TransportNode.js @@ -0,0 +1,65 @@ +import { tryEvaluate } from "./Evaluator"; +const zeroPad = (num, places) => String(num).padStart(places, "0"); + +export class TransportNode extends AudioWorkletNode { + constructor(context, options, application) { + super(context, "transport", options); + this.app = application; + this.port.addEventListener("message", this.handleMessage); + this.port.start(); + this.timeviewer = document.getElementById("timeviewer"); + } + + /** @type {(this: MessagePort, ev: MessageEvent