color-synth

a synth that generates colors instead of sounds
Log | Files | Refs | README

commit a1608ff5dd04a4e657e7a0ddb3b0df371b7e7d6e
parent 43d0c885326b39c79356904c7cfd99cb247d1889
Author: massi <mdsiboldi@gmail.com>
Date:   Wed,  5 Jul 2023 13:06:05 -0700

better sliders and a webworker

Diffstat:
Asrc/lib/Slider.svelte | 90+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/lib/UnitMap.ts | 0
Asrc/lib/engine.worker.ts | 137+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/lib/types.ts | 74++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Rsrc/routes/+page.js -> src/routes/+layout.server.js | 0
Msrc/routes/+page.svelte | 240++++++++++++++++++++-----------------------------------------------------------
6 files changed, 362 insertions(+), 179 deletions(-)

diff --git a/src/lib/Slider.svelte b/src/lib/Slider.svelte @@ -0,0 +1,90 @@ +<script lang="ts"> + import type { Unit, UnitId, UnitMap } from '$lib/types'; + import { INPUT_RANGE, getUnit as _getUnit } from '$lib/types'; + export let id: UnitId; + export let units: UnitMap; + export let handleInput: (id: string, coarseVal: number | null, fineVal: number | null) => void; + export let handleChange: () => void; + + $: props = { units, handleInput, handleChange }; + $: unit = getUnit(id); + + const _handleInput = (...args) => { + handleInput(...args); + unit = unit; + }; + + $: getUnit = _getUnit.bind(null, units); + $: coarseUnit = Math.floor(unit.value / 100_000) * 100_000; + $: fineUnit = unit.value % 100_000; +</script> + +<div class="unit-container"> + {#if unit.kind === 'const'} + <h3>{unit.kind}</h3> + <div class="val"> + {unit.value} + </div> + <input + name={id} + type="range" + min="0" + max={INPUT_RANGE} + step={100_000} + value={coarseUnit} + on:input={(e) => _handleInput(id, Number(e.target.value), null)} + on:change={handleChange} + /> + <input + type="range" + min={0} + max={100_000} + step={1} + value={fineUnit} + on:input={(e) => _handleInput(id, null, Number(e.target.value))} + on:change={handleChange} + /> + {:else if unit.kind === 'combinator'} + <h3>{unit.kind}</h3> + <div class="combinator-container"> + {#each unit.sources as sourceId} + <svelte:self id={sourceId} {...props} /> + {/each} + </div> + {:else if unit.kind === 'rescale'} + <svelte:self id={unit.input} {...props} /> + {:else if unit.kind === 'noise'} + <h3>{unit.kind}</h3> + <svelte:self id={unit.amount} {...props} /> + {:else if unit.kind === 'osc'} + <h3>{unit.kind}</h3> + <h4>rate</h4> + <svelte:self id={unit.rate} {...props} /> + <h4>amount</h4> + <svelte:self id={unit.amount} {...props} /> + {:else} + <h3>{unit.kind} nyi</h3> + {/if} +</div> + +<style> + .combinator-container { + display: flex; + flex-direction: row; + } + .unit-container { + background: rgba(255, 255, 255, 0.3); + padding: 10px; + display: flexbox; + } + h1, + h2, + h3, + h4 { + padding: 0; + margin: 0; + } + .val { + height: 16px; + } +</style> diff --git a/src/lib/UnitMap.ts b/src/lib/UnitMap.ts diff --git a/src/lib/engine.worker.ts b/src/lib/engine.worker.ts @@ -0,0 +1,137 @@ +import type { + EngineMessage, + SynthConfig, + Unit, + UnitId, + UnitState, + UnitStateMap, +} from "$lib/types"; +import { getUnit, INPUT_RANGE, LOOP_CYCLES } from "$lib/types"; + +let config: SynthConfig | undefined = undefined; + +let unitState: UnitStateMap = new Map(); + +let canvas: OffscreenCanvas | undefined; + +onmessage = (message: { data: EngineMessage }) => { + const { data, data: { kind, content } } = message; + switch (kind) { + case "canvas": { + canvas = content; + break; + } + case "config": { + config = content; + break; + } + case "window": { + if (canvas) { + canvas.height = content.height; + canvas.width = content.width; + } + break; + } + } +}; + +function getUnitState(id: UnitId): UnitState { + const unit = getUnit(config.units, id); + const theState = unitState.get(id); + switch (unit.kind) { + case "osc": { + if (theState === undefined) { + return 0; + } + if (typeof theState !== "number" || Number.isNaN(theState)) { + throw new Error(`invalid state for osc unit ${id}: ${theState}`); + } + break; + } + default: { + throw new Error("state for this invalid or NYI"); + } + } + return theState; +} + +function setUnitState(id: UnitId, state: UnitState) { + // TODO: type safety + unitState.set(id, state); +} + +function v(id: UnitId): number { + const unit: Unit = getUnit(config.units, id); + switch (unit.kind) { + case "osc": { + const position = getUnitState(id); + return (Math.sin(2 * Math.PI * (position / LOOP_CYCLES)) - 0.5) * + v(unit.amount); + } + case "const": { + return unit.value; + } + case "combinator": { + return unit.sources.reduce((a, b) => a + v(b), 0); + } + case "rescale": { + const min = v(unit.min); + const max = v(unit.max); + const input = v(unit.input); + + return (max - min) * (input / INPUT_RANGE) + min; + } + case "noise": { + return Math.random() * v(unit.amount); + } + default: { + throw new Error(`${unit.kind} unsupported`); + } + } +} + +function update() { + // update all unit states + const ids = config.units.keys(); + for (let id of ids) { + const unit = getUnit(config.units, id); + switch (unit.kind) { + case "osc": { + const position = getUnitState(id); + const rate = v(unit.rate); + setUnitState(id, (position + rate) % LOOP_CYCLES); + break; + } + } + } +} + +function drawSquares() { + if (config && canvas) { + const ctx = canvas.getContext("2d"); + const cols = 300; + const rows = 100; + let color = [0, 0, 0]; + let width = canvas.width; + let height = canvas.height; + let paneWidth = Math.ceil(width / cols); + let paneHeight = Math.ceil(height / rows); + + for (let row = 0; row < rows; row++) { + for (let col = 0; col < cols; col++) { + color[0] = v(config.sinks.red); + color[1] = v(config.sinks.green); + color[2] = v(config.sinks.blue); + ctx.fillStyle = `rgb(${color.join(", ")})`; + let x = col * paneWidth; + let y = row * paneHeight; + ctx.fillRect(x, y, paneWidth, paneHeight); + update(); + } + } + } + requestAnimationFrame(drawSquares); +} +drawSquares(); + +export {}; diff --git a/src/lib/types.ts b/src/lib/types.ts @@ -0,0 +1,74 @@ +export type SynthConfig = { + sinks: Sinks; + units: UnitMap; +}; +export type EngineMessage = { + kind: "config"; + content: SynthConfig; +} | { + kind: "window"; + content: { + height: number; + width: number; + }; +} | { + kind: "canvas"; + content: OffscreenCanvas; +}; + +export type Unit = + | OscUnit + | ConstUnit + | CombinatorUnit + | RescaleUnit + | NoiseUnit; + +export type UnitId = string; + +export type RescaleUnit = { + kind: "rescale"; + input: UnitId; + min: UnitId; + max: UnitId; +}; + +// uses rate and amount to output a sine wave going that fast and loud. TBD: what units the values are, etc. +export type OscUnit = { + kind: "osc"; + rate: UnitId; + amount: UnitId; +}; + +export type NoiseUnit = { + kind: "noise"; + amount: UnitId; +}; + +// outputs number as it is +export type ConstUnit = { + kind: "const"; + value: number; +}; + +export type CombinatorUnit = { + kind: "combinator"; + sources: UnitId[]; +}; + +export type UnitMap = Map<UnitId, Unit>; +export type UnitStateMap = Map<UnitId, UnitState>; +export type UnitState = any; +export type Sinks = { + red?: UnitId; + green?: UnitId; + blue?: UnitId; +}; + +export function getUnit(units: UnitMap, id: UnitId): Unit { + const result = units.get(id); + if (!result) throw new Error("invalid id for unit: " + id); + return result; +} + +export const LOOP_CYCLES = 100_000_000; +export const INPUT_RANGE = 100_000_000; diff --git a/src/routes/+page.js b/src/routes/+layout.server.js diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte @@ -11,33 +11,57 @@ NoiseUnit, ConstUnit, CombinatorUnit, + PageMessage, UnitMap, UnitStateMap, UnitState, Sinks } from '$lib/types'; - let cvs: HTMLCanvasElement; + + let cvs: HTMLCanvasElement | undefined; + let offscreenCanvas: OffscreenCanvas | undefined; + + // initialize worker + let engineWorker: Worker | undefined = undefined; + const loadWorker = async () => { + const EngineWorker = await import('$lib/engine.worker?worker'); + engineWorker = new EngineWorker.default(); + engineWorker.postMessage({ kind: 'config', content: { units, sinks } }); + }; + + onMount(loadWorker); + $: engineWorker && engineWorker.postMessage({ kind: 'config', content: { units, sinks } }); + $: { + if (cvs && engineWorker && !offscreenCanvas) { + const { height, width } = cvs; + console.log('sending canvas'); + offscreenCanvas = cvs.transferControlToOffscreen(); + engineWorker.postMessage({ kind: 'window', content: { width, height } }); + engineWorker.postMessage({ kind: 'canvas', content: offscreenCanvas }, [offscreenCanvas]); + } + } onMount(() => { const resizeObserver = new ResizeObserver((entries) => { const entry = entries.find((entry) => entry.target === cvs); - if (entry) { - cvs.width = entry.devicePixelContentBoxSize[0].inlineSize; - cvs.height = entry.devicePixelContentBoxSize[0].blockSize; + if (entry && cvs) { + const width = entry.devicePixelContentBoxSize[0].inlineSize; + const height = entry.devicePixelContentBoxSize[0].blockSize; + engineWorker && engineWorker.postMessage({ kind: 'window', content: { width, height } }); } }); - resizeObserver.observe(cvs); if (cvs) resizeObserver.observe(cvs, { box: 'device-pixel-content-box' }); // This callback cleans up the observer - return () => resizeObserver.unobserve(cvs); + return () => { + if (cvs) resizeObserver.unobserve(cvs); + }; }); $: getUnit = _getUnit.bind(null, units); let id = 0; let units: UnitMap = new Map(); - let unitState: UnitStateMap = new Map(); let sinks: Sinks = {}; onMount(() => { @@ -77,115 +101,13 @@ console.log(fromUrl()); } - function initUnits() { - const doc = decodeURIComponent( - new URL(document.location.toString()).searchParams.get('z') || '' - ); - if (doc) { - } else { - return; - } - } - function addUnit(unit: Unit) { const _id = String(id++); units.set(_id, unit); - switch (unit.kind) { - case 'osc': { - unitState.set(_id, 0); - } - } units = units; return _id; } - function getUnitState(id: UnitId): UnitState { - let unit = getUnit(id); - let goal = unitState.get(id); - switch (unit.kind) { - case 'osc': { - if (typeof goal !== 'number') { - throw new Error('invalid state for osc unit: ' + id); - } - break; - } - default: { - throw new Error('state for this invalid or NYI'); - } - } - return goal; - } - - function setUnitState(id: UnitId, state: UnitState) { - // TODO: type safety - unitState.set(id, state); - } - - function v(id: UnitId): number { - const unit: Unit = getUnit(id); - switch (unit.kind) { - case 'osc': { - const position = getUnitState(id); - return (Math.sin(2 * Math.PI * (position / LOOP_CYCLES)) - 0.5) * v(unit.amount); - } - case 'const': { - return unit.value; - } - case 'combinator': { - return unit.sources.reduce((a, b) => a + v(b), 0); - } - case 'rescale': { - const min = v(unit.min); - const max = v(unit.max); - const input = v(unit.input); - - return (max - min) * (input / INPUT_RANGE) + min; - } - case 'noise': { - return Math.random() * v(unit.amount); - } - } - } - - function saveState() { - const saved = {}; - const ids = units.keys(); - for (let id of ids) { - const unit = getUnit(id); - switch (unit.kind) { - case 'osc': - const position = getUnitState(id); - saved[id] = position; - } - } - return saved; - } - function restoreState(saved) { - const ids = units.keys(); - for (let id of ids) { - const unit = getUnit(id); - switch (unit.kind) { - case 'osc': - setUnitState(id, saved[id]); - } - } - } - - function update() { - // update all unit states - const ids = units.keys(); - for (let id of ids) { - const unit = getUnit(id); - switch (unit.kind) { - case 'osc': - const position = getUnitState(id); - const rate = v(unit.rate); - // console.log({ id, units, position, rate }); - setUnitState(id, (position + rate) % LOOP_CYCLES); - } - } - } - type EzUnit = UnitId | number | undefined; // make a const unit or use the supplied one function ez(input: EzUnit): UnitId { @@ -266,71 +188,37 @@ } }; + const CELLS = 5000; + const red = mk.re_color( mk.add( mk.c(INPUT_RANGE / 2), // - mk.n(INPUT_RANGE / 3), - mk.osc(mk.add(10, mk.osc()), INPUT_RANGE / 2) // - ) - ); - const green = mk.re_color( - mk.add( - mk.c(100), // - mk.osc(mk.add(mk.osc(0, 1), mk.osc(0, 1)), 10) // - ) - ); - const blue = mk.re_color( - mk.add( - mk.c(100), // - mk.osc(mk.add(mk.osc(0, 1), mk.osc(0, 1)), 10) // + mk.n(INPUT_RANGE / 2), + mk.osc( + mk.add(mk.c(0), mk.osc(CELLS * 4 * 10), mk.osc(CELLS * 2 * 10, INPUT_RANGE / 2)), + INPUT_RANGE / 2 + ) // ) ); + const green = mk.re_color(); + const blue = mk.re_color(); sinks = { red, green, blue }; - function drawSquares(ctx: CanvasRenderingContext2D) { - if (!cvs) { - return; + function updateUnit(k: UnitId, vCoarse: number | null, vFine: number | null) { + const unit = get.constUnit(k); + const oldVal = unit.value; + if (typeof vCoarse === typeof vFine) { + throw new Error('nyi: coarse and fine simul adjust'); } - const cols = 50; - const rows = 50; - let color = [50, 0, 100]; - let width = cvs.width; - let height = cvs.height; - let paneWidth = Math.ceil(width / cols); - let paneHeight = Math.ceil(height / rows); + console.log({ oldVal, vCoarse, vFine }); - update(); - let prevState = saveState(); - for (let row = 0; row < rows; row++) { - for (let col = 0; col < cols; col++) { - color[0] = v(red); - color[1] = v(green); - color[2] = v(blue); - update(); - ctx.fillStyle = `rgb(${color.join(', ')})`; - let x = col * paneWidth; - let y = row * paneHeight; - ctx.fillRect(x, y, paneWidth, paneHeight); - } + if (vCoarse !== null) { + unit.value = (oldVal % 100_000) + vCoarse; + } else if (vFine !== null) { + unit.value = Math.floor(oldVal / 100_000) * 100_000 + vFine; } - //restoreState(prevState); - } - function run() { - requestAnimationFrame((t) => { - drawSquares(cvs.getContext('2d')); - run(); - }); - } - - function updateUnit(k: UnitId, v: number) { - const unit = get.constUnit(k); - unit.value = Number(v); - } - - console.log(new Set(Object.values(sinks))); - - $: if (cvs) { - run(); + console.log({ value: unit.value }); + units = units; } </script> @@ -338,24 +226,18 @@ <div id="sliders"> {#each [...Object.entries(sinks)] as [label, unitId]} - <div> - <h2>{label}</h2> - <Slider - id={unitId} - units={units} - handleInput={updateUnit} - handleChange={updateUrl} - /> - </div> + <div> + <h2>{label}</h2> + <Slider id={unitId} {units} handleInput={updateUnit} handleChange={updateUrl} /> + </div> {/each} </div> -<button on:click={run}>step </button> <style> - h2 { - margin: 0 5px; - background: white; - } + h2 { + margin: 0 5px; + background: white; + } canvas { width: 100vw; height: 100vh; @@ -365,6 +247,6 @@ position: absolute; left: 10px; top: 10px; - display: flex; + display: flex; } </style>