diff --git a/index.html b/index.html index 4f1c05e..83b5b39 100644 --- a/index.html +++ b/index.html @@ -237,6 +237,7 @@
@@ -377,6 +378,14 @@ Destroy universes + +Audio samples
+ + diff --git a/src/API.ts b/src/API.ts index f5bba41..8165903 100644 --- a/src/API.ts +++ b/src/API.ts @@ -73,6 +73,36 @@ export async function loadSamples() { ]); } +export type ShapeObject = { + x: number, + y: number, + x1: number, + y1: number, + x2: number, + y2: number, + radius: number, + width: number, + height: number, + fillStyle: string, + secondary: string, + strokeStyle: string, + rotate: number, + points: number, + outerRadius: number, + rotation: number, + eyeSize: number, + happiness: number, + slices: number, + gap: number, + font: string, + fontSize: number, + text: string, + filter: string, + url: string, + curve: number, + curves: number, +} + export class UserAPI { /** * The UserAPI class is the interface between the user's code and the backend. It provides @@ -2191,7 +2221,7 @@ export class UserAPI { // Canvas Functions // ============================================================= - public clear = (): void => { + public clear = (): boolean => { /** * Clears the canvas after a given timeout. * @param timeout - The timeout in seconds @@ -2199,27 +2229,44 @@ export class UserAPI { const canvas: HTMLCanvasElement = this.app.interface.drawings as HTMLCanvasElement; const ctx = canvas.getContext("2d")!; ctx.clearRect(0, 0, canvas.width, canvas.height); + return true; } - public width = (): number => { + public w = (): number => { /** * Returns the width of the canvas. * @returns The width of the canvas */ const canvas: HTMLCanvasElement = this.app.interface.drawings as HTMLCanvasElement; - return canvas.width; + return canvas.clientWidth; } - public height = (): number => { + public h = (): number => { /** * Returns the height of the canvas. * @returns The height of the canvas */ const canvas: HTMLCanvasElement = this.app.interface.drawings as HTMLCanvasElement; - return canvas.height; + return canvas.clientHeight; } - public background = (color: string|number, ...gb:number[]): void => { + public hc = (): number => { + /** + * Returns the center y-coordinate of the canvas. + * @returns The center y-coordinate of the canvas + */ + return this.h() / 2; + } + + public wc = (): number => { + /** + * Returns the center x-coordinate of the canvas. + * @returns The center x-coordinate of the canvas + */ + return this.w() / 2; + } + + public background = (color: string|number, ...gb:number[]): boolean => { /** * Set background color of the canvas. * @param color - The color to set. String or 3 numbers representing RGB values. @@ -2229,7 +2276,9 @@ export class UserAPI { if(typeof color === "number") color = `rgb(${color},${gb[0]},${gb[1]})`; ctx.fillStyle = color; ctx.fillRect(0, 0, canvas.width, canvas.height); + return true; } + bg = this.background; public linearGradient = (x1: number, y1: number, x2: number, y2: number, ...stops: (number|string)[]) => { /** @@ -2293,37 +2342,112 @@ export class UserAPI { return gradient; } - public draw = (func: Function): void => { + public draw = (func: Function): boolean => { /** * Draws on the canvas. * @param func - The function to execute */ - const canvas: HTMLCanvasElement = this.app.interface.drawings as HTMLCanvasElement; - const ctx = canvas.getContext("2d")!; - func(ctx); + if(typeof func === "string") { + this.drawText (func); + } else { + const canvas: HTMLCanvasElement = this.app.interface.drawings as HTMLCanvasElement; + const ctx = canvas.getContext("2d")!; + func(ctx); + } + return true; } - public circle = ( - x: number, - y: number, - radius: number, - fillStyle: string, - ): void => { + public balloid = ( + curves: number|ShapeObject = 6, + radius: number = this.hc()/2, + curve: number = 1.5, + fillStyle: string = "white", + secondary: string = "black", + x: number = this.wc(), + y: number = this.hc(), + ): boolean => { + if(typeof curves === "object") { + fillStyle = curves.fillStyle || "white"; + x = curves.x || this.wc(); + y = curves.y || this.hc(); + curve = curves.curve || 1.5; + radius = curves.radius || this.hc()/2; + curves = curves.curves || 6; + } const canvas: HTMLCanvasElement = this.app.interface.drawings as HTMLCanvasElement; const ctx = canvas.getContext("2d")!; + + // Draw the shape using quadratic Bézier curves ctx.beginPath(); - ctx.arc(x, y, radius, 0, 2 * Math.PI); ctx.fillStyle = fillStyle; - ctx.fill(); - }; + + if (curves === 0) { + // Draw a circle if curves = 0 + ctx.arc(x, y, radius, 0, 2 * Math.PI); + ctx.closePath(); + ctx.fill(); + } else if (curves === 1) { + // Draw a single curve (ellipse) if curves = 1 + ctx.ellipse(x, y, radius*0.8, (radius* curve)*0.7, 0, 0, 2 * Math.PI); + ctx.closePath(); + ctx.fill(); + } else if (curves === 2) { + // Draw a shape with two symmetric curves starting from the top and meeting at the bottom + ctx.moveTo(x, y - radius); - public triangular = ( - x: number, - y: number, - radius: number, - fillStyle: string, - rotate: number - ): void => { + // First curve + ctx.quadraticCurveTo(x + radius * curve, y, x, y + radius); + + // Second symmetric curve + ctx.quadraticCurveTo(x - radius * curve, y, x, y - radius); + + ctx.closePath(); + ctx.fill(); + } else { + // Draw the curved shape with the specified number of curves + ctx.moveTo(x, y - radius); + let points = []; + for (let i = 0; i < curves; i++) { + const startAngle = (i / curves) * 2 * Math.PI; + const endAngle = startAngle + (2 * Math.PI) / curves; + + const controlX = x + radius * curve * Math.cos(startAngle + Math.PI / curves); + const controlY = y + radius * curve * Math.sin(startAngle + Math.PI / curves); + points.push([x + radius * Math.cos(startAngle), y + radius * Math.sin(startAngle)]); + ctx.moveTo(x + radius * Math.cos(startAngle), y + radius * Math.sin(startAngle)); + ctx.quadraticCurveTo(controlX, controlY, x + radius * Math.cos(endAngle), y + radius * Math.sin(endAngle)); + } + ctx.closePath(); + ctx.closePath(); + ctx.fill(); + + ctx.beginPath(); + ctx.fillStyle = secondary; + // Form the shape from points with straight lines and fill it + ctx.moveTo(points[0][0], points[0][1]); + for(let point of points) ctx.lineTo(point[0], point[1]); + // Close and fill + + ctx.closePath(); + ctx.fill(); + } + return true; + }; + + public equilateral = ( + radius: number|ShapeObject = this.hc()/3, + fillStyle: string = "white", + rotate: number = 0, + x: number = this.wc(), + y: number = this.hc(), + ): boolean => { + if(typeof radius === "object") { + fillStyle = radius.fillStyle || "white"; + x = radius.x || this.wc(); + y = radius.y || this.hc(); + rotate = radius.rotate || 0; + radius = radius.radius || this.hc()/3; + } const canvas: HTMLCanvasElement = this.app.interface.drawings as HTMLCanvasElement; const ctx = canvas.getContext("2d")!; ctx.save(); @@ -2337,20 +2461,205 @@ export class UserAPI { ctx.fillStyle = fillStyle; ctx.fill(); ctx.restore(); + return true; } - public star = ( - x: number, - y: number, - radius: number, - points: number = 5, + public triangular = ( + width: number|ShapeObject = this.hc()/3, + height: number = this.hc()/3, fillStyle: string = "white", - outerRadius: number = 1.0, rotate: number = 0, - ): void => { + x: number = this.wc(), + y: number = this.hc(), + ): boolean => { + if(typeof width === "object") { + fillStyle = width.fillStyle || "white"; + x = width.x || this.wc(); + y = width.y || this.hc(); + rotate = width.rotate || 0; + height = width.height || this.hc()/3; + width = width.width || this.hc()/3; + } + const canvas: HTMLCanvasElement = this.app.interface.drawings as HTMLCanvasElement; + const ctx = canvas.getContext("2d")!; + ctx.save(); + ctx.translate(x, y); + ctx.rotate((rotate * Math.PI) / 180); + ctx.beginPath(); + ctx.moveTo(0, -height); + ctx.lineTo(width, height); + ctx.lineTo(-width, height); + ctx.closePath(); + ctx.fillStyle = fillStyle; + ctx.fill(); + ctx.restore(); + return true; + } + pointy = this.triangular; + + public ball = ( + radius: number|ShapeObject = this.hc()/3, + fillStyle: string = "white", + x: number = this.wc(), + y: number = this.hc(), + ): boolean => { + if(typeof radius === "object") { + fillStyle = radius.fillStyle || "white"; + x = radius.x || this.wc(); + y = radius.y || this.hc(); + radius = radius.radius || this.hc()/3; + } + const canvas: HTMLCanvasElement = this.app.interface.drawings as HTMLCanvasElement; + const ctx = canvas.getContext("2d")!; + ctx.beginPath(); + ctx.arc(x, y, radius, 0, 2 * Math.PI); + ctx.fillStyle = fillStyle; + ctx.fill(); + return true; + } + circle = this.ball; + + public donut = ( + slices: number = 3, + eaten: number = 0, + radius: number | ShapeObject = this.hc() / 3, + hole: number = this.hc() / 12, + fillStyle: string = "white", + secondary: string = "black", + stroke: string = "black", + rotate: number = 0, + x: number = this.wc(), + y: number = this.hc(), + ): boolean => { + if (typeof radius === "object") { + fillStyle = radius.fillStyle || "white"; + x = radius.x || this.wc(); + y = radius.y || this.hc(); + rotate = radius.rotate || 0; + slices = radius.slices || 3; + radius = radius.radius || this.hc() / 3; + } + + const canvas: HTMLCanvasElement = this.app.interface.drawings as HTMLCanvasElement; + const ctx = canvas.getContext("2d")!; + ctx.save(); + ctx.translate(x, y); + ctx.rotate((rotate * Math.PI) / 180); + + // Draw slices as arcs + const totalSlices = slices; + const sliceAngle = (2 * Math.PI) / totalSlices; + for (let i = 0; i < totalSlices; i++) { + const startAngle = i * sliceAngle; + const endAngle = (i + 1) * sliceAngle; + + // Calculate the position of the outer arc + const outerStartX = hole * Math.cos(startAngle); + const outerStartY = hole * Math.sin(startAngle); + + ctx.beginPath(); + ctx.moveTo(outerStartX, outerStartY); + ctx.arc(0, 0, radius, startAngle, endAngle); + ctx.arc(0, 0, hole, endAngle, startAngle, true); + ctx.closePath(); + + // Fill and stroke the slices with the specified fill style + if (i < slices - eaten) { + // Regular slices are white + ctx.fillStyle = fillStyle; + } else { + // Missing slices are black + ctx.fillStyle = secondary; + } + ctx.lineWidth = 2; + ctx.fill(); + ctx.strokeStyle = stroke; + ctx.stroke(); + } + + ctx.restore(); + return true; + }; + + public pie = ( + slices: number = 3, + eaten: number = 0, + radius: number | ShapeObject = this.hc() / 3, + fillStyle: string = "white", + secondary: string = "black", + stroke: string = "black", + rotate: number = 0, + x: number = this.wc(), + y: number = this.hc(), + ): boolean => { + if (typeof radius === "object") { + fillStyle = radius.fillStyle || "white"; + x = radius.x || this.wc(); + y = radius.y || this.hc(); + rotate = radius.rotate || 0; + slices = radius.slices || 3; + radius = radius.radius || this.hc() / 3; + } + + const canvas: HTMLCanvasElement = this.app.interface.drawings as HTMLCanvasElement; + const ctx = canvas.getContext("2d")!; + ctx.save(); + ctx.translate(x, y); + ctx.rotate((rotate * Math.PI) / 180); + + // Draw slices as arcs + const totalSlices = slices; + const sliceAngle = ((2 * Math.PI) / totalSlices); + for (let i = 0; i < totalSlices; i++) { + const startAngle = i * sliceAngle; + const endAngle = (i + 1) * sliceAngle; + + ctx.beginPath(); + ctx.moveTo(0, 0); + ctx.arc(0, 0, radius, startAngle, endAngle); + ctx.closePath(); + + // Fill and stroke the slices with the specified fill style + if (i < slices - eaten) { + // Regular slices are white + ctx.fillStyle = fillStyle; + } else { + // Missing slices are black + ctx.fillStyle = secondary; + } + ctx.lineWidth = 2; + ctx.strokeStyle = stroke; + ctx.fill(); + ctx.stroke(); + + } + + ctx.restore(); + return true; + }; + + + public star = ( + points: number|ShapeObject = 5, + radius: number = this.hc()/3, + fillStyle: string = "white", + rotate: number = 0, + outerRadius: number = radius/100, + x: number = this.wc(), + y: number = this.hc(), + ): boolean => { + if(typeof points === "object") { + radius = points.radius || this.hc()/3; + fillStyle = points.fillStyle || "white"; + x = points.x || this.wc(); + y = points.y || this.hc(); + rotate = points.rotate || 0; + outerRadius = points.outerRadius || radius/100; + points = points.points || 5; + } const canvas: HTMLCanvasElement = this.app.interface.drawings as HTMLCanvasElement; - if(points<1) return this.circle(x, y, radius+outerRadius, fillStyle); - if(points==1) return this.triangular(x, y, radius, fillStyle, 0); + if(points<1) return this.ball(radius, fillStyle, x, y); + if(points==1) return this.equilateral(radius, fillStyle, 0, x, y); const ctx = canvas.getContext("2d")!; ctx.save(); ctx.translate(x, y); @@ -2367,34 +2676,59 @@ export class UserAPI { ctx.fillStyle = fillStyle; ctx.fill(); ctx.restore(); + return true; }; public stroke = ( - x1: number, - y1: number, - x2: number, - y2: number, - fillStyle: string, - width: number = 1, - ): void => { + width: number|ShapeObject = 1, + strokeStyle: string = "white", + rotate: number = 0, + x1: number = this.wc()-this.wc()/10, + y1: number = this.hc(), + x2: number = this.wc()+this.wc()/5, + y2: number = this.hc(), + ): boolean => { + if(typeof width === "object") { + strokeStyle = width.strokeStyle || "white"; + x1 = width.x1 || this.wc()-this.wc()/10; + y1 = width.y1 || this.hc(); + x2 = width.x2 || this.wc()+this.wc()/5; + y2 = width.y2 || this.hc(); + rotate = width.rotate || 0; + width = width.width || 1; + } const canvas: HTMLCanvasElement = this.app.interface.drawings as HTMLCanvasElement; const ctx = canvas.getContext("2d")!; + ctx.save(); + ctx.translate(x1, y1); + ctx.rotate((rotate * Math.PI) / 180); ctx.beginPath(); - ctx.moveTo(x1, y1); - ctx.lineTo(x2, y2); - ctx.strokeStyle = fillStyle; + ctx.moveTo(0, 0); + ctx.lineTo(x2-x1, y2-y1); ctx.lineWidth = width; + ctx.strokeStyle = strokeStyle; ctx.stroke(); + ctx.restore(); + return true; }; - public rectangle = ( - x: number, - y: number, - width: number, - height: number, - fillStyle: string, + public box = ( + width: number|ShapeObject = this.wc()/4, + height: number = this.wc()/4, + fillStyle: string = "white", rotate: number = 0, - ): void => { + x: number = this.wc()-this.wc()/8, + y: number = this.hc()-this.hc()/8, + ): boolean => { + if(typeof width === "object") { + fillStyle = width.fillStyle || "white"; + x = width.x || this.wc()-this.wc()/4; + y = width.y || this.hc()-this.hc()/2; + rotate = width.rotate || 0; + height = width.height || this.wc()/4; + width = width.width || this.wc()/4; + + } const canvas: HTMLCanvasElement = this.app.interface.drawings as HTMLCanvasElement; const ctx = canvas.getContext("2d")!; ctx.save(); @@ -2403,17 +2737,27 @@ export class UserAPI { ctx.fillStyle = fillStyle; ctx.fillRect(0, 0, width, height); ctx.restore(); + return true; } public smiley = ( - x: number, - y: number, - radius: number, - fillStyle: string, - eyeSize: number = 1.0, - happiness: number = 0.0, - rotation: number = 0 - ): void => { + happiness: number|ShapeObject = 2.0, + radius: number = this.hc()/3, + eyeSize: number = 3.0, + fillStyle: string = "yellow", + rotation: number = 0, + x: number = this.wc(), + y: number = this.hc(), + ): boolean => { + if(typeof happiness === "object") { + fillStyle = happiness.fillStyle || "yellow"; + x = happiness.x || this.wc(); + y = happiness.y || this.hc(); + rotation = happiness.rotation || 0; + eyeSize = happiness.eyeSize || 3.0; + radius = happiness.radius || this.hc()/3; + happiness = happiness.happiness || 2.0; + } const canvas: HTMLCanvasElement = this.app.interface.drawings as HTMLCanvasElement; const ctx = canvas.getContext("2d")!; // Map the rotation value to an angle within the range of -PI to PI @@ -2468,9 +2812,104 @@ export class UserAPI { ctx.strokeStyle = "black"; ctx.stroke(); ctx.restore(); - + return true; } + drawText = ( + text: string|ShapeObject, + fontSize: number = 24, + rotation: number = 0, + font: string = "Arial", + x: number = this.wc(), + y: number = this.hc(), + fillStyle: string = "white", + filter: string = "none", + ): boolean => { + if(typeof text === "object") { + fillStyle = text.fillStyle || "white"; + x = text.x || this.wc(); + y = text.y || this.hc(); + rotation = text.rotation || 0; + font = text.font || "Arial"; + fontSize = text.fontSize || 24; + filter = text.filter || "none"; + text = text.text || ""; + } + const canvas: HTMLCanvasElement = this.app.interface.drawings as HTMLCanvasElement; + const ctx = canvas.getContext("2d")!; + ctx.save(); + ctx.translate(x, y); + ctx.rotate((rotation * Math.PI) / 180); + ctx.filter = filter; + ctx.font = `${fontSize}px ${font}`; + ctx.fillStyle = fillStyle; + ctx.fillText(text, 0, 0); + ctx.restore(); + return true; + } + + image = ( + url: string|ShapeObject, + width: number = this.wc()/2, + height: number = this.hc()/2, + rotation: number = 0, + x: number = this.wc(), + y: number = this.hc(), + filter: string = "none", + ): boolean => { + if(typeof url === "object") { + if(!url.url) return true; + x = url.x || this.wc(); + y = url.y || this.hc(); + rotation = url.rotation || 0; + width = url.width || 100; + height = url.height || 100; + filter = url.filter || "none"; + url = url.url || ""; + } + const canvas: HTMLCanvasElement = this.app.interface.drawings as HTMLCanvasElement; + const ctx = canvas.getContext("2d")!; + ctx.save(); + ctx.translate(x, y); + ctx.rotate((rotation * Math.PI) / 180); + ctx.filter = filter; + const image = new Image(); + image.src = url; + ctx.drawImage(image, -width/2, -height/2, width, height); + ctx.restore(); + return true; + } + + randomChar = (length: number= 1, min: number = 0, max: number = 65536): string => { + return Array.from( + + { length }, () => String.fromCodePoint(Math.floor(Math.random() * (max - min) + min)) + ).join(''); + } + + randomFromRange = (min: number, max: number): string => { + const codePoint = Math.floor(Math.random() * (max - min) + min); + return String.fromCodePoint(codePoint); + }; + + emoji = (n: number = 1): string => { + return this.randomChar(n, 0x1f600, 0x1f64f); + }; + + food = (n: number = 1): string => { + return this.randomChar(n, 0x1f32d, 0x1f37f); + }; + + animals = (n: number = 1): string => { + return this.randomChar(n, 0x1f400, 0x1f4d3); + }; + + expressions = (n: number = 1): string => { + return this.randomChar(n, 0x1f910, 0x1f92f); + }; + + + // ============================================================= // OSC Functions @@ -2481,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/Documentation.ts b/src/Documentation.ts index d190d38..d5f1428 100644 --- a/src/Documentation.ts +++ b/src/Documentation.ts @@ -17,6 +17,7 @@ import { oscilloscope } from "./documentation/more/oscilloscope"; import { synchronisation } from "./documentation/more/synchronisation"; import { about } from "./documentation/more/about"; import { bonus } from "./documentation/more/bonus"; +import { visualization } from "./documentation/more/visualization"; import { chaining } from "./documentation/patterns/chaining"; import { interaction } from "./documentation/basics/interaction"; import { time } from "./documentation/learning/time/time"; @@ -157,6 +158,7 @@ export const documentation_factory = (application: Editor) => { audio_basics: audio_basics(application), synchronisation: synchronisation(application), bonus: bonus(application), + visualization: visualization(application), sample_list: sample_list(application), sample_banks: sample_banks(application), loading_samples: loading_samples(application), 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 efd4270..a252077 100644 --- a/src/InterfaceLogic.ts +++ b/src/InterfaceLogic.ts @@ -25,6 +25,7 @@ import { inlineHoveringTips } from "./documentation/inlineHelp"; import { lineNumbers } from "@codemirror/view"; import { jsCompletions } from "./EditorSetup"; import { saveState } from "./WindowBehavior"; +import { registerSamplesFromDB, samplesDBConfig, uploadSamplesToDB } from "./IO/SampleLoading"; export const installInterfaceLogic = (app: Editor) => { // Initialize style @@ -152,6 +153,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"; @@ -541,4 +557,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