diff --git a/index.html b/index.html index 2d3c60c..1d47cc7 100644 --- a/index.html +++ b/index.html @@ -22,7 +22,12 @@ padding: 0; } - #hydra-bg { + .fluid-bg-transition { + transition: background-color 0.05s ease-in-out; + } + + + .fullscreencanvas { position: fixed; /* ignore margins */ top: 0px; left: 0px; @@ -34,7 +39,6 @@ display: block; } - details br { display: none; } @@ -160,6 +164,7 @@

More

+ Oscilloscope Bonus/Trivia About Topos
@@ -447,7 +452,9 @@
- + + +
diff --git a/src/API.ts b/src/API.ts index 1c604b6..d621df5 100644 --- a/src/API.ts +++ b/src/API.ts @@ -26,6 +26,7 @@ import { } from "superdough"; import { Speaker } from "./StringExtensions"; import { getScaleNotes } from "zifferjs"; +import { OscilloscopeConfig, blinkScript } from "./AudioVisualisation"; interface ControlChange { channel: number; @@ -269,10 +270,13 @@ export class UserAPI { * @returns The result of the evaluation */ args.forEach((arg) => { - tryEvaluate( - this.app, - this.app.universes[this.app.selected_universe].locals[arg] - ); + if (arg >= 1 && arg <= 9) { + blinkScript(this.app, "local", arg); + tryEvaluate( + this.app, + this.app.universes[this.app.selected_universe].locals[arg] + ); + } }); }; s = this.script; @@ -1964,4 +1968,19 @@ export class UserAPI { */ return array.concat(array.slice(0, array.length - 1).reverse()); }; + + // ============================================================= + // Oscilloscope Configuration + // ============================================================= + + public scope = (config: OscilloscopeConfig): void => { + /** + * Configures the oscilloscope. + * @param config - The configuration object + */ + this.app.osc = { + ...this.app.osc, + ...config, + }; + }; } diff --git a/src/AudioVisualisation.ts b/src/AudioVisualisation.ts new file mode 100644 index 0000000..5ce3192 --- /dev/null +++ b/src/AudioVisualisation.ts @@ -0,0 +1,207 @@ +// @ts-ignore +import { getAnalyser } from "superdough"; +import { type Editor } from "./main"; + +/** + * Draw a circle at a specific position on the canvas. + * @param {number} x - The x-coordinate of the circle's center. + * @param {number} y - The y-coordinate of the circle's center. + * @param {number} radius - The radius of the circle. + * @param {string} color - The fill color of the circle. + */ +export const drawCircle = ( + app: Editor, + x: number, + y: number, + radius: number, + color: string +): void => { + // @ts-ignore + const canvas: HTMLCanvasElement = app.interface.feedback; + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + ctx.beginPath(); + ctx.arc(x, y, radius, 0, Math.PI * 2); + ctx.fillStyle = color; + ctx.fill(); + ctx.closePath(); +}; + +/** + * Blinks a script indicator circle. + * @param script - The type of script. + * @param no - The shift amount multiplier. + */ +export const blinkScript = ( + app: Editor, + script: "local" | "global" | "init", + no?: number +) => { + if (no !== undefined && no < 1 && no > 9) return; + const blinkDuration = + (app.clock.bpm / 60 / app.clock.time_signature[1]) * 200; + // @ts-ignore + const ctx = app.interface.feedback.getContext("2d"); // Assuming a canvas context + + /** + * Draws a circle at a given shift. + * @param shift - The pixel distance from the origin. + */ + const _drawBlinker = (shift: number) => { + const horizontalOffset = 50; + drawCircle( + app, + horizontalOffset + shift, + app.interface.feedback.clientHeight - 15, + 8, + "#fdba74" + ); + }; + + /** + * Clears the circle at a given shift. + * @param shift - The pixel distance from the origin. + */ + const _clearBlinker = (shift: number) => { + const x = 50 + shift; + const y = app.interface.feedback.clientHeight - 15; + const radius = 8; + ctx.clearRect(x - radius, y - radius, radius * 2, radius * 2); + }; + + if (script === "local" && no !== undefined) { + const shiftAmount = no * 25; + + // Clear existing timeout if any + if (app.blinkTimeouts[shiftAmount]) { + clearTimeout(app.blinkTimeouts[shiftAmount]); + } + + _drawBlinker(shiftAmount); + + // Save timeout ID for later clearing + app.blinkTimeouts[shiftAmount] = setTimeout(() => { + _clearBlinker(shiftAmount); + // Clear the canvas before drawing new blinkers + (app.interface.feedback as HTMLCanvasElement) + .getContext("2d")! + .clearRect( + 0, + 0, + (app.interface.feedback as HTMLCanvasElement).width, + (app.interface.feedback as HTMLCanvasElement).height + ); + drawEmptyBlinkers(app); + }, blinkDuration); + } +}; + +/** + * Draws a series of 9 white circles. + * @param app - The Editor application context. + */ +export const drawEmptyBlinkers = (app: Editor) => { + for (let no = 1; no <= 9; no++) { + const shiftAmount = no * 25; + drawCircle( + app, + 50 + shiftAmount, + app.interface.feedback.clientHeight - 15, + 8, + "white" + ); + } +}; + +export interface OscilloscopeConfig { + enabled: boolean; + color: string; + thickness: number; + fftSize: number; // multiples of 256 + orientation: "horizontal" | "vertical"; + is3D: boolean; + size: number; +} + +/** + * Initializes and runs an oscilloscope using an AnalyzerNode. + * @param {HTMLCanvasElement} canvas - The canvas element to draw the oscilloscope. + * @param {OscilloscopeConfig} config - Configuration for the oscilloscope's appearance and behavior. + */ +export const runOscilloscope = ( + canvas: HTMLCanvasElement, + app: Editor +): void => { + let config = app.osc; + let analyzer = getAnalyser(config.fftSize); + let dataArray = new Float32Array(analyzer.frequencyBinCount); + const canvasCtx = canvas.getContext("2d")!; + const WIDTH = canvas.width; + const HEIGHT = canvas.height; + + function draw() { + requestAnimationFrame(draw); + if (!app.osc.enabled) { + canvasCtx.clearRect(0, 0, WIDTH, HEIGHT); + return; + } + + if (analyzer.fftSize !== app.osc.fftSize) { + analyzer = getAnalyser(app.osc.fftSize); + dataArray = new Float32Array(analyzer.frequencyBinCount); + } + + analyzer.getFloatTimeDomainData(dataArray); + + canvasCtx.fillStyle = "rgba(0, 0, 0, 0)"; + canvasCtx.fillRect(0, 0, WIDTH, HEIGHT); + canvasCtx.clearRect(0, 0, WIDTH, HEIGHT); + + canvasCtx.lineWidth = app.osc.thickness; + + if (app.osc.color === "random") { + if (app.clock.time_position.pulse % 16 === 0) { + canvasCtx.strokeStyle = `hsl(${Math.random() * 360}, 100%, 50%)`; + } + } else { + canvasCtx.strokeStyle = app.osc.color; + } + canvasCtx.beginPath(); + + // Drawing logic varies based on orientation and 3D setting + if (app.osc.is3D) { + for (let i = 0; i < dataArray.length; i += 2) { + const x = (dataArray[i] * WIDTH * app.osc.size) / 2 + WIDTH / 4; + const y = (dataArray[i + 1] * HEIGHT * app.osc.size) / 2 + HEIGHT / 4; + i === 0 ? canvasCtx.moveTo(x, y) : canvasCtx.lineTo(x, y); + } + } else if (app.osc.orientation === "horizontal") { + let x = 0; + const sliceWidth = (WIDTH * 1.0) / dataArray.length; + const yOffset = HEIGHT / 4; + for (let i = 0; i < dataArray.length; i++) { + const v = dataArray[i] * 0.5 * HEIGHT * app.osc.size; + const y = v + yOffset; + i === 0 ? canvasCtx.moveTo(x, y) : canvasCtx.lineTo(x, y); + x += sliceWidth; + } + canvasCtx.lineTo(WIDTH, yOffset); + } else { + let y = 0; + const sliceHeight = (HEIGHT * 1.0) / dataArray.length; + const xOffset = WIDTH / 4; + for (let i = 0; i < dataArray.length; i++) { + const v = dataArray[i] * 0.5 * WIDTH * app.osc.size; + const x = v + xOffset; + i === 0 ? canvasCtx.moveTo(x, y) : canvasCtx.lineTo(x, y); + y += sliceHeight; + } + canvasCtx.lineTo(xOffset, HEIGHT); + } + + canvasCtx.stroke(); + } + + draw(); +}; diff --git a/src/Documentation.ts b/src/Documentation.ts index 114d9e3..a2dea88 100644 --- a/src/Documentation.ts +++ b/src/Documentation.ts @@ -1,5 +1,6 @@ import { type Editor } from "./main"; import { introduction } from "./documentation/introduction"; +import { oscilloscope } from "./documentation/oscilloscope"; import { samples } from "./documentation/samples"; import { chaining } from "./documentation/chaining"; import { software_interface } from "./documentation/interface"; @@ -85,6 +86,7 @@ export const documentation_factory = (application: Editor) => { functions: functions(application), reference: reference(), shortcuts: shortcuts(), + oscilloscope: oscilloscope(application), bonus: bonus(application), about: about(), }; diff --git a/src/DomElements.ts b/src/DomElements.ts index c592c35..de5b0ce 100644 --- a/src/DomElements.ts +++ b/src/DomElements.ts @@ -47,6 +47,8 @@ export const singleElements = { dough_nudge_range: "dough_nudge", error_line: "error_line", hydra_canvas: "hydra-bg", + feedback: "feedback", + scope: "scope", }; export const buttonGroups = { diff --git a/src/InterfaceLogic.ts b/src/InterfaceLogic.ts index 867020b..85e2eac 100644 --- a/src/InterfaceLogic.ts +++ b/src/InterfaceLogic.ts @@ -451,6 +451,7 @@ export const installInterfaceLogic = (app: Editor) => { "shortcuts", "about", "bonus", + "oscilloscope", ].forEach((e) => { let name = `docs_` + e; document.getElementById(name)!.addEventListener("click", async () => { diff --git a/src/TransportNode.js b/src/TransportNode.js index 12d9e66..32e6939 100644 --- a/src/TransportNode.js +++ b/src/TransportNode.js @@ -13,14 +13,16 @@ 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(); + 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}`; + 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 { diff --git a/src/WindowBehavior.ts b/src/WindowBehavior.ts index fe3a6bd..2ddba53 100644 --- a/src/WindowBehavior.ts +++ b/src/WindowBehavior.ts @@ -1,10 +1,32 @@ import { type Editor } from "./main"; +const handleResize = (canvas: HTMLCanvasElement) => { + if (!canvas) return; + canvas.width = window.innerWidth; + canvas.height = window.innerHeight; + const ctx = canvas.getContext("2d"); + const dpr = window.devicePixelRatio || 1; + + // Assuming the canvas takes up the whole window + canvas.width = window.innerWidth * dpr; + canvas.height = window.innerHeight * dpr; + + if (ctx) { + ctx.scale(dpr, dpr); + } +}; + export const installWindowBehaviors = ( app: Editor, window: Window, preventMultipleTabs: boolean = false ) => { + window.addEventListener("resize", () => + handleResize(app.interface.scope as HTMLCanvasElement) + ); + window.addEventListener("resize", () => + handleResize(app.interface.feedback as HTMLCanvasElement) + ); window.addEventListener("beforeunload", () => { // @ts-ignore event.preventDefault(); diff --git a/src/classes/SoundEvent.ts b/src/classes/SoundEvent.ts index 28968fc..213ea1e 100644 --- a/src/classes/SoundEvent.ts +++ b/src/classes/SoundEvent.ts @@ -1,6 +1,11 @@ import { type Editor } from "../main"; import { AudibleEvent } from "./AbstractEvents"; -import { chord as parseChord, midiToFreq, noteFromPc, noteNameToMidi } from "zifferjs"; +import { + chord as parseChord, + midiToFreq, + noteFromPc, + noteNameToMidi, +} from "zifferjs"; import { superdough, @@ -10,10 +15,9 @@ import { export type SoundParams = { dur: number; s?: string; -} +}; export class SoundEvent extends AudibleEvent { - nudge: number; constructor(sound: string | object, public app: Editor) { @@ -25,9 +29,10 @@ export class SoundEvent extends AudibleEvent { s: sound.split(":")[0], n: sound.split(":")[1], dur: app.clock.convertPulseToSecond(app.clock.ppqn), + analyze: true, }; } else { - this.values = { s: sound, dur: 0.5 }; + this.values = { s: sound, dur: 0.5, analyze: true }; } } else { this.values = sound; @@ -116,7 +121,7 @@ export class SoundEvent extends AudibleEvent { this.sustain(0.0); this.release(0.0); return this; - } + }; // Lowpass filter public lpenv = (value: number) => this.updateValue("lpenv", value); @@ -247,30 +252,43 @@ export class SoundEvent extends AudibleEvent { // Frequency management public sound = (value: string) => this.updateValue("s", value); - public chord = (value: string | object[] | number[] | number, ...kwargs: number[]) => { + public chord = ( + value: string | object[] | number[] | number, + ...kwargs: number[] + ) => { if (typeof value === "string") { const chord = parseChord(value); - value = chord.map((note: number) => { return { note: note, freq: midiToFreq(note) } }); + value = chord.map((note: number) => { + return { note: note, freq: midiToFreq(note) }; + }); } else if (value instanceof Array && typeof value[0] === "number") { - value = (value as number[]).map((note: number) => { return { note: note, freq: midiToFreq(note) } }); + value = (value as number[]).map((note: number) => { + return { note: note, freq: midiToFreq(note) }; + }); } else if (typeof value === "number" && kwargs.length > 0) { - value = [value, ...kwargs].map((note: number) => { return { note: note, freq: midiToFreq(note) } }); + value = [value, ...kwargs].map((note: number) => { + return { note: note, freq: midiToFreq(note) }; + }); } return this.updateValue("chord", value); - } + }; public invert = (howMany: number = 0) => { if (this.values.chord) { - let notes = this.values.chord.map((obj: { [key: string]: number }) => obj.note); + let notes = this.values.chord.map( + (obj: { [key: string]: number }) => obj.note + ); notes = howMany < 0 ? [...notes].reverse() : notes; for (let i = 0; i < Math.abs(howMany); i++) { notes[i % notes.length] += howMany <= 0 ? -12 : 12; } - const chord = notes.map((note: number) => { return { note: note, freq: midiToFreq(note) } }); + const chord = notes.map((note: number) => { + return { note: note, freq: midiToFreq(note) }; + }); return this.updateValue("chord", chord); } else { return this; } - } + }; public snd = this.sound; public cut = (value: number) => this.updateValue("cut", value); public clip = (value: number) => this.updateValue("clip", value); @@ -309,7 +327,7 @@ export class SoundEvent extends AudibleEvent { // Reverb management public room = (value: number) => this.updateValue("room", value); - public rm = this.room + public rm = this.room; public roomfade = (value: number) => this.updateValue("roomfade", value); public rfade = this.roomfade; public roomlp = (value: number) => this.updateValue("roomlp", value); @@ -320,25 +338,26 @@ export class SoundEvent extends AudibleEvent { public sz = this.size; // Compressor - public comp = (value: number) => this.updateValue('compressor', value); + public comp = (value: number) => this.updateValue("compressor", value); public cmp = this.comp; - public ratio = (value: number) => this.updateValue('compressorRatio', value); + public ratio = (value: number) => this.updateValue("compressorRatio", value); public rt = this.ratio; - public knee = (value: number) => this.updateValue('compressorKnee', value); + public knee = (value: number) => this.updateValue("compressorKnee", value); public kn = this.knee; - public compAttack = (value: number) => this.updateValue('compressorAttack', value); + public compAttack = (value: number) => + this.updateValue("compressorAttack", value); public cmpa = this.compAttack; - public compRelease = (value: number) => this.updateValue('compressorRelease', value); + public compRelease = (value: number) => + this.updateValue("compressorRelease", value); public cmpr = this.compRelease; - // Unit public stretch = (beat: number) => { this.updateValue("unit", "c"); - this.updateValue("speed", 1 / beat) - this.updateValue("cut", beat) + this.updateValue("speed", 1 / beat); + this.updateValue("cut", beat); return this; - } + }; // ================================================================================ // AbstactEvent overrides @@ -368,7 +387,7 @@ export class SoundEvent extends AudibleEvent { if (this.values.chord) { this.values.chord.forEach((obj: { [key: string]: number }) => { const copy = { ...this.values }; - copy.freq = obj.freq + copy.freq = obj.freq; superdough(copy, this.nudge, this.values.dur); }); } else { diff --git a/src/documentation/oscilloscope.ts b/src/documentation/oscilloscope.ts new file mode 100644 index 0000000..4d1f371 --- /dev/null +++ b/src/documentation/oscilloscope.ts @@ -0,0 +1,29 @@ +import { type Editor } from "../main"; +import { makeExampleFactory } from "../Documentation"; + +export const oscilloscope = (application: Editor): string => { + const makeExample = makeExampleFactory(application); + return `# Oscilloscope + +You can turn on the oscilloscope to generate interesting visuals or to inspect audio. Use the scope() function to turn it on and off. The oscilloscope is off by default. + +${makeExample( + "Oscilloscope configuration", + ` +scope({ + enabled: true, // off by default + color: "#fdba74", // any valid CSS color or "random" + thickness: 4, // stroke thickness + fftSize: 256, // multiples of 128 + orientation: "horizontal", // "vertical" or "horizontal" + is3D: false, // 3D oscilloscope + size: 1, // size of the oscilloscope +}) + `, + true +)} + +Note that these values can be patterned as well! You can transform the oscilloscope into its own light show if you want. The picture is not stable anyway so you won't have much use of it for precision work :) + + `; +}; diff --git a/src/main.ts b/src/main.ts index a9064b1..c7a9ac8 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,3 +1,4 @@ +import { OscilloscopeConfig, runOscilloscope } from "./AudioVisualisation"; import { EditorState, Compartment } from "@codemirror/state"; import { javascript } from "@codemirror/lang-javascript"; import { markdown } from "@codemirror/lang-markdown"; @@ -24,6 +25,7 @@ import showdown from "showdown"; import { makeStringExtensions } from "./StringExtensions"; import { installInterfaceLogic } from "./InterfaceLogic"; import { installWindowBehaviors } from "./WindowBehavior"; +import { drawEmptyBlinkers } from "./AudioVisualisation"; export class Editor { // Universes and settings @@ -56,6 +58,16 @@ export class Editor { show_error: boolean = false; buttonElements: Record = {}; interface: ElementMap = {}; + blinkTimeouts: Record = {}; + osc: OscilloscopeConfig = { + enabled: false, + color: "#fdba74", + thickness: 4, + fftSize: 256, + orientation: "horizontal", + is3D: false, + size: 1, + }; // UserAPI api: UserAPI; @@ -79,6 +91,8 @@ export class Editor { this.initializeElements(); this.initializeButtonGroups(); this.initializeHydra(); + this.setCanvas(this.interface.feedback as HTMLCanvasElement); + this.setCanvas(this.interface.scope as HTMLCanvasElement); // ================================================================================ // Loading the universe from local storage @@ -127,12 +141,14 @@ export class Editor { registerFillKeys(this); registerOnKeyDown(this); installInterfaceLogic(this); + drawEmptyBlinkers(this); // ================================================================================ // Building CodeMirror Editor // ================================================================================ installEditor(this); + runOscilloscope(this.interface.scope as HTMLCanvasElement, this); // First evaluation of the init file tryEvaluate(this, this.universes[this.selected_universe.toString()].init); @@ -381,25 +397,36 @@ export class Editor { } /** - * @param color the color to flash the background - * @param duration the duration of the flash + * Flashes the background of the view and its gutters. + * @param {string} color - The color to set. + * @param {number} duration - Duration in milliseconds to maintain the color. */ flashBackground(color: string, duration: number): void { - // Set the flashing color - this.view.dom.style.backgroundColor = color; - const gutters = this.view.dom.getElementsByClassName( + const domElement = this.view.dom; + const gutters = domElement.getElementsByClassName( "cm-gutter" ) as HTMLCollectionOf; + + domElement.classList.add("fluid-bg-transition"); + Array.from(gutters).forEach((gutter) => + gutter.classList.add("fluid-bg-transition") + ); + + domElement.style.backgroundColor = color; Array.from(gutters).forEach( (gutter) => (gutter.style.backgroundColor = color) ); - // Reset to original color after duration setTimeout(() => { - this.view.dom.style.backgroundColor = ""; + domElement.style.backgroundColor = ""; Array.from(gutters).forEach( (gutter) => (gutter.style.backgroundColor = "") ); + + domElement.classList.remove("fluid-bg-transition"); + Array.from(gutters).forEach((gutter) => + gutter.classList.remove("fluid-bg-transition") + ); }, duration); } @@ -428,6 +455,21 @@ export class Editor { }); this.hydra = this.hydra_backend.synth; } + + private setCanvas(canvas: HTMLCanvasElement): void { + if (!canvas) return; + const ctx = canvas.getContext("2d"); + + const dpr = window.devicePixelRatio || 1; + + // Assuming the canvas takes up the whole window + canvas.width = window.innerWidth * dpr; + canvas.height = window.innerHeight * dpr; + + if (ctx) { + ctx.scale(dpr, dpr); + } + } } let app = new Editor();