Merge pull request #75 from Bubobubobubobubo/visualisation

Adding some visualisations
This commit is contained in:
Raphaël Forment
2023-10-22 23:26:58 +01:00
committed by GitHub
11 changed files with 393 additions and 41 deletions

View File

@ -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 @@
<div class="space-y-2">
<h2 class="font-semibold lg:text-xl text-gray-400">More</h2>
<div class="flex flex-col">
<a rel="noopener noreferrer" id="docs_oscilloscope" class="pl-2 pr-2 lg:text-xl text-sm hover:bg-neutral-800 py-1 my-1 rounded-lg">Oscilloscope</a>
<a rel="noopener noreferrer" id="docs_bonus" class="pl-2 pr-2 lg:text-xl text-sm hover:bg-neutral-800 py-1 my-1 rounded-lg">Bonus/Trivia</a>
<a rel="noopener noreferrer" id="docs_about" class="pl-2 pr-2 lg:text-xl text-sm hover:bg-neutral-800 py-1 my-1 rounded-lg">About Topos</a>
</div>
@ -447,7 +452,9 @@
</ul>
<!-- Here comes the editor itself -->
<div id="editor" class="relative flex flex-row h-screen overflow-y-hidden">
<canvas id="hydra-bg"></canvas>
<canvas id="hydra-bg" class="fullscreencanvas"></canvas>
<canvas id="scope" class="fullscreencanvas"></canvas>
<canvas id="feedback" class="fullscreencanvas"></canvas>
</div>
<p id="error_line" class="hidden text-red-400 w-screen bg-neutral-900 font-mono absolute bottom-0 pl-2 py-2">Hello kids</p>
</div>

View File

@ -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,
};
};
}

207
src/AudioVisualisation.ts Normal file
View File

@ -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();
};

View File

@ -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(),
};

View File

@ -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 = {

View File

@ -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 () => {

View File

@ -13,14 +13,16 @@ export class TransportNode extends AudioWorkletNode {
/** @type {(this: MessagePort, ev: MessageEvent<any>) => 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 {

View File

@ -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();

View File

@ -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 {

View File

@ -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 <ic>scope()</ic> 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 :)
`;
};

View File

@ -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<string, HTMLButtonElement[]> = {};
interface: ElementMap = {};
blinkTimeouts: Record<number, number> = {};
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<HTMLElement>;
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();