color-synth

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

commit 9a1882ee9f108571b74e38371612467f7492a3f7
parent 80180c90b4fe7cb7f5870cc65e9c818ace445ebc
Author: massi <mdsiboldi@gmail.com>
Date:   Fri, 15 Mar 2024 11:12:23 -0700

lots of things

Diffstat:
Mpackage.json | 2+-
Msrc/lib/ConstUnit.svelte | 6+++---
Msrc/lib/Control.svelte | 68++++++++++++--------------------------------------------------------
Msrc/lib/DumbSlider.svelte | 1+
Asrc/lib/ImgUnit.svelte | 21+++++++++++++++++++++
Msrc/lib/InputDragger.svelte | 7+++----
Asrc/lib/LagUnit.svelte | 26++++++++++++++++++++++++++
Asrc/lib/MathUnit.svelte | 27+++++++++++++++++++++++++++
Asrc/lib/MathUnit.ts | 22++++++++++++++++++++++
Msrc/lib/NoiseUnit.svelte | 6+++---
Msrc/lib/OscUnit.svelte | 13+++++++------
Msrc/lib/Sink.svelte | 11+++++++----
Msrc/lib/SmoothUnit.svelte | 8++++----
Asrc/lib/UnitControl.svelte | 73+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/lib/Wires.svelte | 112+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/lib/color.ts | 2+-
Msrc/lib/engine.worker.ts | 88+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------
Msrc/lib/stores.ts | 61+++++++++++++++++++++++++++++++++++++++++++++++++++++--------
Msrc/lib/types.ts | 149++++++++++++++++++++++++++++++++++++++++++++-----------------------------------
Msrc/routes/+page.svelte | 43+++++++++++++++++++++++++++++++------------
Astatic/turtle-by-olga-tsai.jpg | 0
Mtsconfig.json | 2+-
22 files changed, 560 insertions(+), 188 deletions(-)

diff --git a/package.json b/package.json @@ -3,7 +3,7 @@ "version": "0.0.1", "private": true, "scripts": { - "dev": "vite dev", + "dev": "vite dev && docker run -p 6379:6379 -it redis/redis-stack-server:latest", "build": "vite build", "preview": "vite preview", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", diff --git a/src/lib/ConstUnit.svelte b/src/lib/ConstUnit.svelte @@ -1,18 +1,18 @@ <script lang="ts"> - import Control from '$lib/Control.svelte'; + import { default as _UnitControl } from '$lib/UnitControl.svelte'; import type { UnitId } from '$lib/types'; export let id: UnitId; const kind = 'const'; - const KControl = Control<typeof kind>; + const UnitControl = _UnitControl<typeof kind>; $: common = { kind, id } as const; </script> <div> <h1>const</h1> - <KControl {...common} controlName="value" /> + <UnitControl {...common} controlName="value" /> </div> <style> diff --git a/src/lib/Control.svelte b/src/lib/Control.svelte @@ -1,56 +1,27 @@ -<script lang="ts" generics="TKind extends UnitKind"> - import { unitStore, unitToConnect } from '$lib/stores'; - import { - unitRange, - getUnit, - inp, - RANGE, - rescale, - type ControlName, - type Controls, - type UnitId, - type UnitKind, - wrangle - } from '$lib/types'; +<script lang="ts"> + import { RANGE, inp, rescale, wrangle, type Input, type NumberRange } from '$lib/types'; import DumbSlider from './DumbSlider.svelte'; - import InputDragger from './InputDragger.svelte'; - export let id: UnitId; - export let kind: TKind; - export let controlName: ControlName<TKind>; + export let input: Input; + export let controlRange: NumberRange; + export let setInput: (i: Input) => void; - $: unit = getUnit(kind, $unitStore, id); - - const controlRange = unitRange[kind][controlName]; - $: input = (unit.controls as Controls<TKind>)[controlName]; const updateValue = (controlValue: number) => { const n = Math.round(rescale(controlValue, controlRange, RANGE.signal)); - unitStore.setControl(kind, id, controlName, n); + setInput(inp.setValue(input, n)); }; - let connection: { connected: boolean; onConnect: () => void } | null; - $: { - if ($unitToConnect) { - connection = { - connected: Array.isArray(input) && inp.connected(input, $unitToConnect), - onConnect: () => { - if (!$unitToConnect) return; - unitStore.setControl(kind, id, controlName, inp.toggle(input, $unitToConnect)); - } - }; - } else { - connection = null; - } - } + let value: number | null; - $: value = typeof input === 'number' ? wrangle(input, RANGE.signal, controlRange) : null; + $: value = Object.hasOwn(input, 'value') + ? Math.round(wrangle(input.value!, RANGE.signal, controlRange)) + : null; $: slotProps = { range: controlRange, value: value || 0, update: updateValue }; </script> -<div class="control-wrapper"> - <h3>{controlName}</h3> - <InputDragger {connection} /> +<div> {#if value !== null} + <input type="number" {value} on:input={(e) => updateValue(Number(e.currentTarget?.value))} /> {#if $$slots.default} <slot props={{ range: controlRange, value: value || 0, update: updateValue }} /> {:else} @@ -58,18 +29,3 @@ {/if} {/if} </div> - -<style> - h3 { - margin: 0; - padding: 3px 3px 3px 24px; - } - .control-wrapper { - position: relative; - width: 100%; - height: 80px; - background: rgba(255, 255, 255, 0.3); - padding: 10px; - display: flexbox; - } -</style> diff --git a/src/lib/DumbSlider.svelte b/src/lib/DumbSlider.svelte @@ -13,4 +13,5 @@ step={1} {value} on:input={(e) => update(Number(e.currentTarget?.value))} + on:dblclick={(e) => update(0)} /> diff --git a/src/lib/ImgUnit.svelte b/src/lib/ImgUnit.svelte @@ -0,0 +1,21 @@ +<script lang="ts"> + import { default as _UnitControl } from '$lib/UnitControl.svelte'; + import type { UnitId } from '$lib/types'; + + export let id: UnitId; + + const kind = 'img'; + const UnitControl = _UnitControl<typeof kind>; + + $: common = { kind, id } as const; +</script> + +<div> + <h1>image of a turtle</h1> +</div> + +<style> + h1 { + color: blue; + } +</style> diff --git a/src/lib/InputDragger.svelte b/src/lib/InputDragger.svelte @@ -1,15 +1,14 @@ <script lang="ts"> export let connection: { connected: Boolean; onConnect: () => void } | null; + export let controlName: string; </script> -<div class="input-dragger"> +<div class="input-dragger" data-control-name={controlName}> {#if connection} <div class={['connect', connection.connected && 'connected'].filter(Boolean).join(' ')} on:mouseup={connection.onConnect} - > - hi - </div> + /> {/if} </div> diff --git a/src/lib/LagUnit.svelte b/src/lib/LagUnit.svelte @@ -0,0 +1,26 @@ +<script lang="ts"> + import { default as _UnitControl } from '$lib/UnitControl.svelte'; + import type { UnitId } from '$lib/types'; + + export let id: UnitId; + + const kind = 'lag'; + const UnitControl = _UnitControl<typeof kind>; + + $: common = { kind, id } as const; +</script> + +<div> + <h1>lag</h1> + <UnitControl {...common} controlName="signal" /> + <UnitControl {...common} controlName="amount" /> +</div> + +<style> + h1 { + padding: 0; + font-size: 24px; + margin: 0; + color: pink; + } +</style> diff --git a/src/lib/MathUnit.svelte b/src/lib/MathUnit.svelte @@ -0,0 +1,27 @@ +<script lang="ts"> + import { default as _UnitControl } from '$lib/UnitControl.svelte'; + import type { UnitId } from '$lib/types'; + + export let id: UnitId; + + const kind = 'math'; + const UnitControl = _UnitControl<typeof kind>; + + $: common = { kind, id } as const; +</script> + +<div> + <h1>math</h1> + <UnitControl {...common} controlName="a" /> + <UnitControl {...common} controlName="op" /> + <UnitControl {...common} controlName="b" /> +</div> + +<style> + h1 { + padding: 0; + font-size: 24px; + margin: 0; + color: grey; + } +</style> diff --git a/src/lib/MathUnit.ts b/src/lib/MathUnit.ts @@ -0,0 +1,22 @@ +<script lang="ts"> + import { default as _UnitControl } from '$lib/UnitControl.svelte'; + import type { UnitId } from '$lib/types'; + + export let id: UnitId; + + const kind = 'const'; + const UnitControl = _UnitControl<typeof kind>; + + $: common = { kind, id } as const; +</script> + +<div> + <h1>const</h1> + <UnitControl {...common} controlName="value" /> +</div> + +<style> + h1 { + color: blue; + } +</style> diff --git a/src/lib/NoiseUnit.svelte b/src/lib/NoiseUnit.svelte @@ -1,18 +1,18 @@ <script lang="ts"> - import Control from '$lib/Control.svelte'; + import { default as _UnitControl } from '$lib/UnitControl.svelte'; import type { UnitId } from '$lib/types'; export let id: UnitId; const kind = 'noise'; - const KControl = Control<typeof kind>; + const UnitControl = _UnitControl<typeof kind>; $: common = { kind, id } as const; </script> <div> <h1>noiseyboi</h1> - <KControl {...common} controlName="amount" /> + <UnitControl {...common} controlName="amount" /> </div> <style> diff --git a/src/lib/OscUnit.svelte b/src/lib/OscUnit.svelte @@ -1,21 +1,22 @@ <script lang="ts"> - import Control from '$lib/Control.svelte'; + import { default as _UnitControl } from '$lib/UnitControl.svelte'; import type { UnitId } from '$lib/types'; export let id: UnitId; const kind = 'osc'; - const KControl = Control<typeof kind>; + const UnitControl = _UnitControl<typeof kind>; $: common = { kind, id } as const; </script> <div> <h1>os-kill-8r</h1> - <KControl {...common} controlName="coarse" /> - <KControl {...common} controlName="fine" /> - <KControl {...common} controlName="superfine" /> - <KControl {...common} controlName="amount" /> + <UnitControl {...common} controlName="coarse" /> + <UnitControl {...common} controlName="fine" /> + <UnitControl {...common} controlName="superfine" /> + <UnitControl {...common} controlName="amount" /> + <UnitControl {...common} controlName="waveshape" /> </div> <style> diff --git a/src/lib/Sink.svelte b/src/lib/Sink.svelte @@ -1,9 +1,11 @@ <script lang="ts"> - import type { Input } from '$lib/types'; + import { RANGE, type Input } from '$lib/types'; + import Control from '$lib/Control.svelte'; export let channel: 'l' | 'c' | 'h'; export let onSinkConnect: null | ((ch: 'l' | 'c' | 'h') => void); + export let setInput: (input: Input) => void; export let connected: boolean; - export let input: Input | null; + export let input: Input; let _onSinkConnect: null | (() => void) = null; $: _onSinkConnect = onSinkConnect ? onSinkConnect.bind(null, channel) : null; @@ -12,12 +14,13 @@ .join(' '); </script> -<div class="sink"> +<div class="sink" data-sink-ch={channel}> {#if onSinkConnect} <div class={classes} on:mouseup={_onSinkConnect} /> {/if} <button class={Boolean(_onSinkConnect) ? 'hl' : ''}> - {channel}: {Array.isArray(input) ? input.map((o) => o.id).join(' + ') : 'none'} + {channel}: {Array.isArray(input) ? input.map((o) => o.id.substring(0, 5)).join(' + ') : 'none'} + <Control {input} controlRange={RANGE.signal} {setInput} /> </button> </div> diff --git a/src/lib/SmoothUnit.svelte b/src/lib/SmoothUnit.svelte @@ -1,19 +1,19 @@ <script lang="ts"> - import Control from '$lib/Control.svelte'; + import { default as _UnitControl } from '$lib/UnitControl.svelte'; import type { UnitId } from '$lib/types'; export let id: UnitId; const kind = 'smooth'; - const KControl = Control<typeof kind>; + const UnitControl = _UnitControl<typeof kind>; $: common = { kind, id } as const; </script> <div> <h1>smoother</h1> - <KControl {...common} controlName="signal"><div>cant control this one</div></KControl> - <KControl {...common} controlName="frames" /> + <UnitControl {...common} controlName="signal" /> + <UnitControl {...common} controlName="frames" /> </div> <style> diff --git a/src/lib/UnitControl.svelte b/src/lib/UnitControl.svelte @@ -0,0 +1,73 @@ +<script lang="ts" generics="TKind extends UnitKind"> + import { unitStore, unitToConnect } from '$lib/stores'; + import { + RANGE, + getUnit, + inp, + rescale, + unitRange, + type ControlName, + type Controls, + type Input, + type UnitId, + type UnitKind + } from '$lib/types'; + import Control from './Control.svelte'; + import InputDragger from './InputDragger.svelte'; + + export let id: UnitId; + export let kind: TKind; + export let controlName: ControlName<TKind>; + + const controlRange = unitRange[kind][controlName]; + + $: unit = getUnit(kind, $unitStore, id); + + $: input = (unit.controls as Controls<TKind>)[controlName]; + const updateValue = (controlValue: number) => { + const n = Math.round(rescale(controlValue, controlRange, RANGE.signal)); + unitStore.setControl(kind, id, controlName, inp.setValue(input, n)); + }; + let connection: { connected: boolean; onConnect: () => void } | null; + $: { + if ($unitToConnect) { + connection = { + connected: inp.connected(input, $unitToConnect), + onConnect: () => { + if (!$unitToConnect) return; + unitStore.setControl(kind, id, controlName, inp.toggle(input, $unitToConnect)); + } + }; + } else { + connection = null; + } + } + + const setInput = (i: Input) => { + unitStore.setControl(kind, id, controlName, i); + }; +</script> + +<div class="control-wrapper"> + <h3>{controlName}</h3> + <InputDragger {controlName} {connection} /> + <Control {input} {controlRange} {setInput} /> +</div> + +<style> + input[type='number'] { + width: 80px; + } + h3 { + margin: 0; + padding: 3px 3px 3px 24px; + } + .control-wrapper { + position: relative; + width: 100%; + height: 80px; + background: rgba(255, 255, 255, 0.3); + padding: 10px; + display: flexbox; + } +</style> diff --git a/src/lib/Wires.svelte b/src/lib/Wires.svelte @@ -0,0 +1,112 @@ +<script lang="ts"> + import { onMount } from 'svelte'; + import { sinksStore, unitStore } from './stores'; + import type { Input, NumberRange, Pos } from './types'; + + let cvs: HTMLCanvasElement | null = null; + + type VisitCb = (start: Pos, end: Pos) => void; + const visitInput = (ownerQuery: string, input: Input, cb: VisitCb) => { + const ownerEl = document.querySelector(ownerQuery); + if (ownerEl === null) { + return; + } else { + for (let src of input.sources) { + const id = src.id; + const el = document.querySelector(`div[data-unit-id='${id}'] .output-dragger`); + + if (el) { + cb(ownerEl.getBoundingClientRect(), el.getBoundingClientRect()); + } + } + } + }; + + const visitConnections = (cb: VisitCb) => { + // just use query selectors to find the elements to connect. + + // controls + for (let [id, unit] of Object.entries($unitStore)) { + const unitQuery = `div[data-unit-id='${id}']`; + for (let [controlName, control] of Object.entries(unit.controls)) { + const query = `${unitQuery} div[data-control-name='${controlName}']`; + visitInput(query, control, cb); + } + } + // sinks + for (let ch of ['l', 'c', 'h'] as const) { + // console.log($sinksStore[ch]); + visitInput(`div[data-sink-ch='${ch}']`, $sinksStore[ch], cb); + } + }; + const draw = () => { + const ctx = cvs?.getContext('2d'); + const OFFSET = 8; + + if (ctx) { + ctx.reset(); + visitConnections((start, end) => { + if (ctx) { + ctx.strokeStyle = ctx.fillStyle = 'red'; + ctx.lineWidth = 5; + + ctx.beginPath(); + ctx.ellipse(start.x + OFFSET, start.y + OFFSET, 6, 6, 0, 0, 2 * Math.PI); + ctx.closePath(); + ctx.fill(); + + ctx.beginPath(); + ctx.ellipse(end.x + OFFSET, end.y + OFFSET, 6, 6, 0, 0, 2 * Math.PI); + ctx.closePath(); + ctx.fill(); + + ctx.beginPath(); + ctx.moveTo(start.x + OFFSET, start.y + OFFSET); + ctx.lineTo(end.x + OFFSET, end.y + OFFSET); + ctx.closePath(); + ctx.stroke(); + } + }); + } + }; + const loop = () => { + // setInterval(draw, 1000); + draw(); + requestAnimationFrame(loop); + }; + loop(); + + onMount(() => { + const resizeObserver = new ResizeObserver((entries) => { + const entry = entries.find((entry) => entry.target === cvs); + if (entry && cvs) { + const width = entry.devicePixelContentBoxSize[0].inlineSize; + const height = entry.devicePixelContentBoxSize[0].blockSize; + cvs.width = width; + cvs.height = height; + } + }); + + if (cvs) resizeObserver.observe(cvs, { box: 'device-pixel-content-box' }); + + // This callback cleans up the observer + return () => { + if (cvs) resizeObserver.unobserve(cvs); + }; + }); +</script> + +<canvas bind:this={cvs} /> + +<style> + canvas { + position: absolute; + z-index: 100; + top: 0; + left: 0; + height: 100vh; + width: 100vw; + opacity: 0.5; + pointer-events: none; + } +</style> diff --git a/src/lib/color.ts b/src/lib/color.ts @@ -1,6 +1,6 @@ import type { Color } from '$lib/types'; -// from ttps://observablehq.com/@shan/oklab-color-wheel +// from https://observablehq.com/@shan/oklab-color-wheel const gamma = (x: number) => (x >= 0.0031308 ? 1.055 * Math.pow(x, 1 / 2.4) - 0.055 : 12.92 * x) diff --git a/src/lib/engine.worker.ts b/src/lib/engine.worker.ts @@ -17,8 +17,10 @@ import { rescale, wrangle, unitRange, - oscShapes + oscShapes, + mathOps } from '$lib/types'; +import _ from 'lodash'; let config: SynthConfig | undefined = undefined; let unitState: UnitStateMap = new Map(); @@ -57,6 +59,9 @@ function getUnitState(id: UnitId): UnitState { return theState === undefined ? [] : theState; break; } + case 'lag': { + return theState === undefined ? [] : theState; + } default: { throw new Error('state for this invalid or NYI'); } @@ -75,7 +80,10 @@ function vUnit(x: { id: UnitId }): number { switch (unit.kind) { case 'osc': { const position = getUnitState(id); - return oscShapes.sine(position / LOOP_CYCLES, v(unit.controls.amount)); + const shapeIdx = Math.round( + wrangle(v(unit.controls.waveshape), RANGE.signal, unitRange.osc.waveshape) + ); + return oscShapes[shapeIdx](position / LOOP_CYCLES, v(unit.controls.amount)); } case 'const': { return v(unit.controls.value); @@ -87,6 +95,20 @@ function vUnit(x: { id: UnitId }): number { const frames: number[] = getUnitState(id); return frames.reduce((acc, item) => item + acc, 0) / frames.length; } + case 'math': { + const opIdx = Math.round(wrangle(v(unit.controls.op), RANGE.signal, unitRange.math.op)); + if (opIdx === 2) { + //mult + return ( + 5_000_000 * mathOps[opIdx](v(unit.controls.a) / 5_000_000, v(unit.controls.b) / 5_000_000) + ); + } else { + return mathOps[opIdx](v(unit.controls.a), v(unit.controls.b)); + } + } + case 'lag': { + return getUnitState(id)[0]; + } } } @@ -117,10 +139,9 @@ function v(input: Input): number { if (typeof input === 'number') { return input; } - const result = input.reduce((a, b) => a + vUnit(b), 0); + const result = (input.value || 0) + input.sources.reduce((a, b) => a + vUnit(b), 0); return result; } - function update() { // update all unit states if (!config) { @@ -139,10 +160,17 @@ function update() { RANGE.signal, unitRange.osc.superfine ); - setUnitState( - id, - (position + (coarse * LOOP_CYCLES) / 100 + fine * 20000 + superfine) % LOOP_CYCLES - ); + // i'm assuming that when these all get set to 0, the position should also reset + // that way it isn't stuck at some constant value that effects other units down the line + if (coarse === 0 && fine === 0 && superfine === 0) { + if (position !== 0) console.log(`Resetting position (${position}) of ${id} to 0.`); + setUnitState(id, 0); + } else { + setUnitState( + id, + (position + (coarse * LOOP_CYCLES) / 100 + fine * 20000 + superfine) % LOOP_CYCLES + ); + } break; } case 'smooth': { @@ -154,10 +182,39 @@ function update() { frames = frames.slice(0, n); } setUnitState(id, frames); + break; + } + case 'lag': { + const amt = wrangle(v(unit.controls.amount), RANGE.signal, unitRange.lag.amount); + if (amt === 0) { + setUnitState(id, [v(unit.controls.signal)]); + } else { + let arr = getUnitState(id) as number[]; + arr.push(v(unit.controls.signal)); + if (arr.length > amt) { + if (arr.length - 1 === amt) { + arr.pop(); + } else { + arr = arr.slice(arr.length - amt); + } + arr.unshift(); + } + setUnitState(id, arr); + } + break; } } } } +function throttleLog(k: string, msg: any) { + logMap[k] = msg; +} +let logMap: Record<string, any> = {}; +function logSometimes() { + Object.keys(logMap).map((k) => console.log(k, logMap[k])); + setTimeout(logSometimes, 250); +} +logSometimes(); function drawSquares() { if (config) { @@ -180,18 +237,9 @@ function drawSquares() { const c = config.sinks.c == null ? 0 : v(config.sinks.c); const h = config.sinks.h == null ? 0 : v(config.sinks.h); const rgb = oklch( - wrangle(l, RANGE.signal, { - min: 0, - max: 1 - }), - wrangle(c, RANGE.signal, { - min: 0, - max: 0.5 - }), - wrangle(h, RANGE.signal, { - min: 0, - max: 360 - }) + wrangle(l, RANGE.signal, RANGE.lch.l), + wrangle(c, RANGE.signal, RANGE.lch.c), + wrangle(h, RANGE.signal, RANGE.lch.h) ); data[di++] = rgb.r; data[di++] = rgb.g; diff --git a/src/lib/stores.ts b/src/lib/stores.ts @@ -11,7 +11,8 @@ import { RANGE, wrangle, unitRange, - type Pos + type Pos, + type Sinks } from '$lib/types'; import { v4 as uuidv4 } from 'uuid'; @@ -22,6 +23,14 @@ const randConst = () => { return rescale(sliderVal, RANGE.slider, RANGE.signal); }; +const mkInput = (value?: number) => { + const ret: Input = { sources: [] }; + if (value !== undefined) { + ret.value = value; + } + return ret; +}; + const mkUnitStore = () => { const { subscribe, set, update } = writable<Units>({}); const setControl = <K extends UnitKind>( @@ -47,30 +56,61 @@ const mkUnitStore = () => { let unit: Unit; switch (kind) { case 'const': { - unit = { kind: 'const', controls: { value: 0 }, pos: { x: 0, y: 0 } }; + unit = { kind: 'const', controls: { value: mkInput(0) }, pos: { x: 0, y: 0 } }; break; } case 'osc': { unit = { kind: 'osc', controls: { - coarse: wrangle(1, unitRange.osc.coarse, RANGE.signal), - fine: wrangle(1, unitRange.osc.coarse, RANGE.signal), - superfine: 0, - amount: RANGE.signal.max + coarse: mkInput(wrangle(1, unitRange.osc.coarse, RANGE.signal)), + fine: mkInput(wrangle(1, unitRange.osc.fine, RANGE.signal)), + superfine: mkInput(0), + amount: mkInput(RANGE.signal.max), + waveshape: mkInput(RANGE.signal.min) }, pos: { x: 0, y: 0 } }; break; } case 'noise': { - unit = { kind: 'noise', controls: { amount: RANGE.signal.max / 2 }, pos: { x: 0, y: 0 } }; + unit = { + kind: 'noise', + controls: { amount: mkInput(RANGE.signal.max / 2) }, + pos: { x: 0, y: 0 } + }; break; } case 'smooth': { unit = { kind: 'smooth', - controls: { frames: wrangle(3, unitRange.smooth.frames, RANGE.signal), signal: 0 }, + controls: { + frames: mkInput(wrangle(3, unitRange.smooth.frames, RANGE.signal)), + signal: mkInput() + }, + pos: { x: 0, y: 0 } + }; + break; + } + case 'math': { + unit = { + kind: 'math', + controls: { + a: mkInput(0), + op: mkInput(RANGE.signal.min), + b: mkInput(0) + }, + pos: { x: 0, y: 0 } + }; + break; + } + case 'lag': { + unit = { + kind: 'lag', + controls: { + signal: mkInput(), + amount: mkInput(0) + }, pos: { x: 0, y: 0 } }; break; @@ -92,3 +132,8 @@ export type UnitDragging = null | { initPos: Pos; pos: Pos; id: UnitId }; export const unitDragging = writable<UnitDragging>(null); export const unitStore = mkUnitStore(); +export const sinksStore = writable<Sinks>({ + l: { value: 0, sources: [] }, + c: { value: 0, sources: [] }, + h: { value: 0, sources: [] } +}); diff --git a/src/lib/types.ts b/src/lib/types.ts @@ -1,8 +1,9 @@ -export const LOOP_CYCLES = 100_000_000; +export const RES = 10_000; export const INPUT_RANGE = 100; export const ROWS = 100; export const COLS = 100; export const CELLS = ROWS * COLS; +export const LOOP_CYCLES = RES * CELLS; export type WithTarget<E, T> = E & { currentTarget: T }; export type Prettify<T> = { [k in keyof T]: T[k] } & {}; @@ -39,58 +40,51 @@ export type Pos = { y: number; }; -export const unitKinds = ['const', 'osc', 'noise', 'smooth'] as const; +export const unitKinds = ['const', 'osc', 'noise', 'smooth', 'math', 'lag'] as const; export type UnitKind = (typeof unitKinds)[number]; -export type Unit<K extends UnitKind = UnitKind> = { [P in K]: UnitMap[P] }[K]; +type Corr<X, K extends keyof X> = { [P in K]: X[P] }[K]; +export type Unit<K extends UnitKind = UnitKind> = Corr<UnitMap, K>; type UnitMap = { const: ConstUnit; osc: OscUnit; noise: NoiseUnit; smooth: SmoothUnit; + math: MathUnit; + lag: LagUnit; }; // TODO: can we error when keys don't exhaust UnitKind? Record can't infer the tuples correctly. const controlNames = { - osc: ['coarse', 'fine', 'superfine', 'amount'] as const, + osc: ['coarse', 'fine', 'superfine', 'amount', 'waveshape'] as const, noise: ['amount'] as const, const: ['value'] as const, - smooth: ['signal', 'frames'] as const + smooth: ['signal', 'frames'] as const, + math: ['a', 'op', 'b'] as const, + lag: ['signal', 'amount'] as const }; type ControlNames = typeof controlNames; export type ControlName<K extends UnitKind> = ControlNames[K][number]; -export type Controls<K extends UnitKind = UnitKind> = Prettify<{ +export type Controls<K extends UnitKind = UnitKind> = { [name in ControlNames[K][number]]: Input; -}>; - -export type ConstUnit = { - kind: 'const'; - pos: Pos; - controls: Controls<'const'>; -}; - -export type OscUnit = { - kind: 'osc'; - pos: Pos; - controls: Controls<'osc'>; }; -export type NoiseUnit = { - kind: 'noise'; +export type GenericUnit<K extends UnitKind> = { + kind: K; pos: Pos; - controls: Controls<'noise'>; -}; - -export type SmoothUnit = { - kind: 'smooth'; - pos: Pos; - controls: Controls<'smooth'>; + controls: Controls<K>; }; +export type ConstUnit = GenericUnit<'const'>; +export type OscUnit = GenericUnit<'osc'>; +export type NoiseUnit = GenericUnit<'noise'>; +export type SmoothUnit = GenericUnit<'smooth'>; +export type MathUnit = GenericUnit<'math'>; +export type LagUnit = GenericUnit<'lag'>; export type UnitId = string; export type Output = { id: UnitId }; -export type Input = number | Output[]; +export type Input = { value?: number; sources: Output[] }; export type Units = { [u: UnitId]: Unit }; export type UnitStateMap = Map<UnitId, UnitState>; @@ -103,6 +97,11 @@ export type NumberRange = { }; export const RANGE = { + lch: { + l: { min: 0, max: 1 }, + c: { min: 0, max: 0.5 }, + h: { min: 0, max: 360 } + }, color: { min: 0, max: 255 @@ -119,8 +118,28 @@ export const RANGE = { min: -5_000_000, max: 5_000_000 } -}; +} as const; +type OscShapes = Array<(p: number, a: number) => number>; + +export const oscShapes: OscShapes = [ + (position: number, amount: number) => { + return Math.sin(2 * Math.PI * position) * amount; + }, + (p, a) => { + return (p < 0.5 ? -1 : 1) * a; + }, + (p, a) => p * a +]; + +type MathOp = (a: number, b: number) => number; +export const mathOps: MathOp[] = [ + (a, b) => a + b, + (a, b) => a - b, + (a, b) => a * b, + (a, b) => (b === 0 ? 0 : a / b), + (a, b) => (b === 0 ? 0 : a % b) +]; export const unitRange: { [k in UnitKind]: Record<ControlName<k>, NumberRange>; } = { @@ -140,18 +159,22 @@ export const unitRange: { amount: { min: -50, max: 50 + }, + waveshape: { + min: 0, + max: Object.keys(oscShapes).length - 1 } }, const: { value: { - min: -50_000_000, - max: 50_000_000 + min: -5_000_000, + max: 5_000_000 } }, noise: { amount: { - min: -50, - max: 50 + min: -5000, + max: 5000 } }, smooth: { @@ -160,8 +183,20 @@ export const unitRange: { max: 25 }, signal: RANGE.signal + }, + math: { + a: RANGE.signal, + op: { min: 0, max: mathOps.length - 1 }, + b: RANGE.signal + }, + lag: { + signal: RANGE.signal, + amount: { + min: 0, + max: 10_000 + } } -}; +} as const; export function clamp(n: number, range: NumberRange) { return Math.max(range.min, Math.min(range.max, n)); @@ -169,6 +204,8 @@ export function clamp(n: number, range: NumberRange) { export function rescale(n: number, origin: NumberRange, dest: NumberRange) { return ((n - origin.min) / (origin.max - origin.min)) * (dest.max - dest.min) + dest.min; } + +/** fit n within its original range, then scale it to the destination range */ export function wrangle(n: number, origin: NumberRange, dest: NumberRange) { return rescale(clamp(n, origin), origin, dest); } @@ -196,7 +233,9 @@ export const is = { const: isUnit('const'), osc: isUnit('osc'), noise: isUnit('noise'), - smooth: isUnit('smooth') + smooth: isUnit('smooth'), + math: isUnit('math'), + lag: isUnit('lag') } }; @@ -214,44 +253,24 @@ export const ensure = { const: ensureUnit('const'), osc: ensureUnit('osc'), noise: ensureUnit('noise'), - smooth: ensureUnit('smooth') + smooth: ensureUnit('smooth'), + math: ensureUnit('math'), + lag: ensureUnit('lag') } }; export const inp = { toggle: (input: Input, output: Output): Input => { - if (typeof input === 'number') { - return [output]; - } else { - const deduped = input.filter((o) => o.id !== output.id); - if (input.length === deduped.length) { - return [...input, output]; - } else { - // it's there and we removed it - // if the last input signal was removed, we're back to a value. - return deduped.length === 0 ? 0 : deduped; - } - } + const deduped = input.sources.filter((o) => o.id !== output.id); + const inSources = deduped.length !== input.sources.length; + return { ...input, sources: [...deduped, ...(inSources ? [] : [output])] }; }, connected: (input: Input, output: Output): boolean => { - if (Array.isArray(input)) { - return Boolean(input.find((o) => o.id === output.id)); - } - return false; + return Boolean(input.sources.find((o) => o.id === output.id)); + }, + setValue: (input: Input, value: number): Input => { + return { ...input, value }; } }; export type UnitToConnect = Output | false; - -type OscShapes = { - [k: string]: (p: number, a: number) => number; -}; -export const oscShapes: OscShapes = { - sine: (position: number, amount: number) => { - return Math.sin(2 * Math.PI * position) * amount; - }, - square: (p, a) => { - return (p < 0.5 ? -1 : 1) * a; - }, - triangle: (p, a) => p * a -}; diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte @@ -1,5 +1,5 @@ <script lang="ts"> - import { unitDragging, unitStore, unitToConnect } from '$lib/stores'; + import { unitDragging, unitStore, unitToConnect, sinksStore } from '$lib/stores'; import { onMount } from 'svelte'; import { debounce } from 'lodash'; import Sink from '$lib/Sink.svelte'; @@ -19,7 +19,10 @@ import NoiseUnitComponent from '$lib/NoiseUnit.svelte'; import ConstUnitComponent from '$lib/ConstUnit.svelte'; import SmoothUnitComponent from '$lib/SmoothUnit.svelte'; + import MathUnitComponent from '$lib/MathUnit.svelte'; + import LagUnitComponent from '$lib/LagUnit.svelte'; import UnitDrag from '$lib/UnitDrag.svelte'; + import Wires from '$lib/Wires.svelte'; let cvs: HTMLCanvasElement | undefined; @@ -28,7 +31,7 @@ const loadWorker = async () => { const EngineWorker = await import('$lib/engine.worker?worker'); engineWorker = new EngineWorker.default(); - engineWorker.postMessage({ kind: 'config', content: { units, sinks } }); + engineWorker.postMessage({ kind: 'config', content: { units, sinks: $sinksStore } }); }; onMount(loadWorker); @@ -79,12 +82,14 @@ const getUnit = (id: UnitId): Unit => _getUnit(null, $unitStore, id); let uid = 0; $: units = $unitStore; - let sinks: Sinks = { l: 0, c: 0, h: 0 }; // let's update the engine whenever the config changes for now, even though we could ignore pos at least. $: { if (engineWorker) { - engineWorker.postMessage({ kind: 'config', content: { units: $unitStore, sinks } }); + engineWorker.postMessage({ + kind: 'config', + content: { units: $unitStore, sinks: $sinksStore } + }); } } onMount(() => { @@ -92,7 +97,7 @@ if (doc) { $unitStore = doc.units; uid = Object.keys(doc.units).length; - sinks = doc.sinks; + $sinksStore = doc.sinks; } }); @@ -103,7 +108,7 @@ }; function toUrl() { - return btoa(JSON.stringify({ version: 1, sinks, units })); + return btoa(JSON.stringify({ version: 1, sinks: $sinksStore, units })); } function fromUrl() { @@ -141,17 +146,24 @@ if (!$unitToConnect) { throw new Error('cant connect sink to nonexistant signal'); } - sinks[ch] = inp.toggle(sinks[ch], $unitToConnect); - engineWorker && engineWorker.postMessage({ kind: 'config', content: { sinks, units } }); + $sinksStore[ch] = inp.toggle($sinksStore[ch], $unitToConnect); + engineWorker && + engineWorker.postMessage({ kind: 'config', content: { sinks: $sinksStore, units } }); $unitToConnect = false; }; + const setSinkInput = (ch: 'l' | 'c' | 'h', input: Input) => { + $sinksStore[ch] = input; + engineWorker && + engineWorker.postMessage({ kind: 'config', content: { sinks: $sinksStore, units } }); + }; + $: onSinkConnect = $unitToConnect ? _onSinkConnect : null; - type SinkEntries = [keyof Sinks, Input | null][]; + type SinkEntries = [keyof Sinks, Input][]; // Object.entries COULD have extra stuff, so it doesn't assume keys are keyof Sinks exactly. // see https://stackoverflow.com/questions/60141960/typescript-key-value-relation-preserving-object-entries-type - $: sinkEntries = [...Object.entries(sinks)] as SinkEntries; + $: sinkEntries = [...Object.entries($sinksStore)] as SinkEntries; const getConnectHandler = (sdrag: false | Output, input: UnitId) => { if (!sdrag) return null; @@ -163,12 +175,14 @@ const unit = getUnit(input); // @ts-ignore unit[k] is a channel but i don't wanna prove it. unitStore.setUnit(input, { ...unit, [k]: inp.toggle(unit[k], { id: output }) }); - engineWorker && engineWorker.postMessage({ kind: 'config', content: { sinks, units } }); + engineWorker && + engineWorker.postMessage({ kind: 'config', content: { sinks: $sinksStore, units } }); $unitToConnect = false; }; </script> <canvas bind:this={cvs} /> +<Wires /> <div id="buttons"> {#each unitKinds as kind} @@ -180,6 +194,7 @@ <div id="sinks"> {#each sinkEntries as [channel, input]} <Sink + setInput={setSinkInput.bind(null, channel)} {input} {channel} {onSinkConnect} @@ -191,7 +206,7 @@ {#each [...Object.entries(units)] as [id, unit]} {@const pos = $unitDragging?.id === id ? $unitDragging.pos : unit.pos} <div class="unit" style={`top: ${pos.y}px; left: ${pos.x}px`}> - <div> + <div data-unit-id={id}> <UnitDrag {id}> <h2>{id.substring(0, 5)}-{unit.kind}</h2> </UnitDrag> @@ -210,6 +225,10 @@ <NoiseUnitComponent {id} /> {:else if unit.kind === 'smooth'} <SmoothUnitComponent {id} /> + {:else if unit.kind === 'math'} + <MathUnitComponent {id} /> + {:else if unit.kind === 'lag'} + <LagUnitComponent {id} /> {/if} </div> </div> diff --git a/static/turtle-by-olga-tsai.jpg b/static/turtle-by-olga-tsai.jpg Binary files differ. diff --git a/tsconfig.json b/tsconfig.json @@ -12,7 +12,7 @@ "noErrorTruncation": true, "lib": [ "webworker", - "es2019" + "es2023" ] } // Path aliases are handled by https://kit.svelte.dev/docs/configuration#alias