color-synth

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

commit bc65021938b86b284cf07a62f69cdeccc401f005
parent ad431becdd17df5a1f5afd0405e431be35131591
Author: massi <mdsiboldi@gmail.com>
Date:   Tue, 18 Jul 2023 09:28:06 -0700

better sliders, ranges, math, etc

Diffstat:
Mpackage-lock.json | 11+++++++++++
Mpackage.json | 1+
Asrc/lib/NumberSelector.svelte | 133+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/lib/Slider.svelte | 41++++++++++++++++++-----------------------
Msrc/lib/engine.worker.ts | 82++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------------
Msrc/lib/types.ts | 140++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Msrc/routes/+page.svelte | 127+++++++++++++++++++++++++++++++------------------------------------------------
7 files changed, 403 insertions(+), 132 deletions(-)

diff --git a/package-lock.json b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.0.1", "dependencies": { "@sveltejs/adapter-static": "^2.0.2", + "@types/offscreencanvas": "^2019.7.0", "lodash": "^4.17.21" }, "devDependencies": { @@ -615,6 +616,11 @@ "integrity": "sha512-Hr5Jfhc9eYOQNPYO5WLDq/n4jqijdHNlDXjuAQkkt+mWdQR+XJToOHrsD4cPaMXpn6KO7y2+wM8AZEs8VpBLVA==", "dev": true }, + "node_modules/@types/offscreencanvas": { + "version": "2019.7.0", + "resolved": "https://registry.npmjs.org/@types/offscreencanvas/-/offscreencanvas-2019.7.0.tgz", + "integrity": "sha512-PGcyveRIpL1XIqK8eBsmRBt76eFgtzuPiSTyKHZxnGemp2yzGzWpjYKAfK3wIMiU7eH+851yEpiuP8JZerTmWg==" + }, "node_modules/@types/pug": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/@types/pug/-/pug-2.0.6.tgz", @@ -3299,6 +3305,11 @@ "integrity": "sha512-Hr5Jfhc9eYOQNPYO5WLDq/n4jqijdHNlDXjuAQkkt+mWdQR+XJToOHrsD4cPaMXpn6KO7y2+wM8AZEs8VpBLVA==", "dev": true }, + "@types/offscreencanvas": { + "version": "2019.7.0", + "resolved": "https://registry.npmjs.org/@types/offscreencanvas/-/offscreencanvas-2019.7.0.tgz", + "integrity": "sha512-PGcyveRIpL1XIqK8eBsmRBt76eFgtzuPiSTyKHZxnGemp2yzGzWpjYKAfK3wIMiU7eH+851yEpiuP8JZerTmWg==" + }, "@types/pug": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/@types/pug/-/pug-2.0.6.tgz", diff --git a/package.json b/package.json @@ -30,6 +30,7 @@ "type": "module", "dependencies": { "@sveltejs/adapter-static": "^2.0.2", + "@types/offscreencanvas": "^2019.7.0", "lodash": "^4.17.21" } } diff --git a/src/lib/NumberSelector.svelte b/src/lib/NumberSelector.svelte @@ -0,0 +1,133 @@ +<script lang="ts"> + import { range, clamp } from '$lib/types'; + export let value: number; + export let updateValue: (n: number) => void; + + const MIN = range.slider.min; + const MAX = range.slider.max; + + let pos: + | { + start: { x: number; y: number }; + delta: { x: number; y: number }; + mouse: { x: number; y: number }; + } + | undefined; + let el: HTMLDivElement | undefined; + + const handleMove = (evt: MouseEvent) => { + if (!pos) return; + pos.delta.x += evt.movementX; + //pos.delta.y += evt.movementY; + let mouseX = (pos.start.x + pos.delta.x / 2) % window.innerWidth; + + if (mouseX < 0) { + mouseX += window.innerWidth; + } + pos.mouse.x = mouseX; + + //let mouseY = (pos.start.y + pos.delta.y) % window.innerHeight; + //if (mouseY < 0) { + // mouseY += window.innerHeight; + //} + //pos.mouse.y = mouseY; + }; + + type Ticker = { start: () => void; stop: () => void }; + const Ticker = (): Ticker => { + let c = 0; + let tDelta: number | undefined; + let cont = true; + const run = (ts: number) => { + if (!cont || !pos || !pos.delta) { + return; + } + if (tDelta === undefined) { + tDelta = ts; + } else { + tDelta = ts - tDelta; + const rate = Math.pow(pos.delta.x / 200, 3); + c = c + rate * tDelta; + if (Math.abs(c) > 10000) { + let n = Math.floor(c / 10000); + c -= n * 10000; + const truncValue = clamp(value + n, { min: -500_000, max: 500_000 }); + updateValue(truncValue); + } + } + requestAnimationFrame(run); + }; + const stop = () => { + cont = false; + }; + const start = () => { + cont = true; + requestAnimationFrame(run); + }; + return { start, stop }; + }; + + let ticker: Ticker | undefined; + + $: distance = pos ? Math.sqrt(pos.delta.x * pos.delta.x + pos.delta.y * pos.delta.y) : 0; + + const handleMouseDown = async (evt: MouseEvent) => { + await el?.requestPointerLock(); + pos = { + start: { + x: evt.pageX, + y: evt.pageY + }, + delta: { + x: 0, + y: 0 + }, + mouse: { + x: evt.pageX, + y: evt.pageY + } + }; + ticker = Ticker(); + ticker.start(); + document.addEventListener('mousemove', handleMove); + document.addEventListener('mouseup', handleMouseUp); + }; + + const handleMouseUp = () => { + ticker?.stop(); + ticker = undefined; + pos = undefined; + document.exitPointerLock(); + document.removeEventListener('mousemove', handleMove); + document.removeEventListener('mouseup', handleMouseUp); + }; + + const handleDbl = () => { + console.log('dbl'); + updateValue(0); + }; + + $: if (el) { + el.removeEventListener('dblclick', handleDbl); + el.addEventListener('dblclick', handleDbl); + el.removeEventListener('mousedown', handleMouseDown); + //el.addEventListener('mousedown', handleMouseDown); + } +</script> + +<div bind:this={el} class="sel">{value} {pos?.delta.x}</div> +{#if pos} + <div class="tmp-pointer" style={`top:${pos.mouse.y}px; left:${pos.mouse.x}px`} /> +{/if} + +<style> + .sel { + font-family: monospace; + } + .tmp-pointer { + position: fixed; + height: 10px; + width: 10px; + background: red; + } +</style> diff --git a/src/lib/Slider.svelte b/src/lib/Slider.svelte @@ -1,20 +1,21 @@ <script lang="ts"> import type { Unit, UnitId, UnitMap } from '$lib/types'; - import { INPUT_RANGE, getUnit as _getUnit, is } from '$lib/types'; + import { rescale, range, INPUT_RANGE, getUnit as _getUnit, is } from '$lib/types'; + import NumberSelector from '$lib/NumberSelector.svelte'; + export let id: UnitId; export let units: UnitMap; - export let handleInput: (id: string, coarseVal: number | null, fineVal: number | null) => void; + export let handleInput: (id: string, value: number) => void; export let handleChange: () => void; $: props = { units, handleInput, handleChange }; $: getUnit = _getUnit.bind(null, units); $: unit = getUnit(id); - $: coarseUnit = !is.unit.const(unit) ? 0 : Math.floor(unit.value / 100_000) * 100_000; - $: fineUnit = !is.unit.const(unit) ? 0 : unit.value % 100_000; + $: sliderValue = Math.round(rescale(unit.value, range.signal, range.slider)); - const _handleInput = (...args) => { - handleInput(...args); + const _handleInput = (val: number) => { + handleInput(id, Math.round(rescale(val, range.slider, range.signal))); unit = unit; }; </script> @@ -22,26 +23,15 @@ <div class="unit-container"> {#if unit.kind === 'const'} <h3>{unit.kind}</h3> - <div class="val"> - {unit.value} - </div> + <NumberSelector value={sliderValue} updateValue={_handleInput} /> <input name={id} type="range" - min={(-1 * INPUT_RANGE) / 2} - max={INPUT_RANGE / 2} - 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 - 1} + min={range.slider.min} + max={range.slider.max} step={1} - value={fineUnit} - on:input={(e) => _handleInput(id, null, Number(e.target?.value))} + value={sliderValue} + on:input={(e) => _handleInput(Number(e.target?.value))} on:change={handleChange} /> {:else if unit.kind === 'combinator'} @@ -59,7 +49,12 @@ {:else if unit.kind === 'osc'} <h3>{unit.kind}</h3> <h4>rate</h4> - <svelte:self id={unit.rate.id} {...props} /> + coarse + <svelte:self id={unit.coarse.id} {...props} /> + fine + <svelte:self id={unit.fine.id} {...props} /> + superfine + <svelte:self id={unit.superfine.id} {...props} /> <h4>amount</h4> <svelte:self id={unit.amount.id} {...props} /> {:else} diff --git a/src/lib/engine.worker.ts b/src/lib/engine.worker.ts @@ -7,31 +7,33 @@ import type { UnitState, UnitStateMap, } from "$lib/types"; -import { getUnit, INPUT_RANGE, LOOP_CYCLES } from "$lib/types"; +import { + CELLS, + clamp, + getUnit, + INPUT_RANGE, + LOOP_CYCLES, + range, + rescale, + wrangle, +} from "$lib/types"; let config: SynthConfig | undefined = undefined; let unitState: UnitStateMap = new Map(); -let canvas: OffscreenCanvas | undefined; +let canvas: OffscreenCanvas = new OffscreenCanvas(1, 1); 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; + console.log(content); + canvas = new OffscreenCanvas(content.width, content.height); } } }; @@ -68,21 +70,23 @@ function v(input: Input): number { case "osc": { const position = getUnitState(id); return Math.sin(2 * Math.PI * (position / LOOP_CYCLES)) * - v(unit.amount) - 0.5; + v(unit.amount); + //if (id % 7 === 0) { + // return (position < LOOP_CYCLES / 2 ? -1 : 1) * v(unit.amount); + //} else if (id % 5 === 0) { + // return (position / LOOP_CYCLES) * v(unit.amount); + //} else { + // return Math.sin(2 * Math.PI * (position / LOOP_CYCLES)) * + // v(unit.amount); + //} } case "const": { + // range is whatever the control for it is??? 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); } @@ -100,8 +104,19 @@ function update() { switch (unit.kind) { case "osc": { const position = getUnitState(id); - const rate = v(unit.rate); - setUnitState(id, (position + rate) % LOOP_CYCLES); + const coarse = rescale(v(unit.coarse), range.signal, range.osc.coarse); + const fine = rescale(v(unit.fine), range.signal, range.osc.fine); + const superfine = rescale( + v(unit.superfine), + range.signal, + range.osc.superfine, + ); + setUnitState( + id, + (position + coarse * LOOP_CYCLES / 100 + fine * 20000 + + superfine * 10) % + LOOP_CYCLES, + ); break; } } @@ -109,7 +124,7 @@ function update() { } function drawSquares() { - if (config && canvas) { + if (config) { const ctx = canvas.getContext("2d"); const cols = 100; const rows = 50; @@ -121,16 +136,27 @@ function drawSquares() { 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; + color[0] = wrangle(v(config.sinks.red), range.signal, { + min: 0, + max: 100, + }); + color[1] = wrangle(v(config.sinks.green), range.signal, { + min: 0, + max: 0.5, + }); + color[2] = wrangle(v(config.sinks.blue), range.signal, { + min: 0, + max: 360, + }); + ctx.fillStyle = `oklch(${color[0]}% ${color[1]} ${color[2]}deg)`; + const x = col * paneWidth; + const y = row * paneHeight; ctx.fillRect(x, y, paneWidth, paneHeight); update(); } } + const bmp = canvas.transferToImageBitmap(); + postMessage({ kind: "bmp", content: bmp }, [bmp]); } requestAnimationFrame(drawSquares); } diff --git a/src/lib/types.ts b/src/lib/types.ts @@ -1,5 +1,9 @@ +import type { OffscreenCavas } from "@types/offscreencanvas"; export const LOOP_CYCLES = 100_000_000; -export const INPUT_RANGE = 100_000_000; +export const INPUT_RANGE = 100; +export const ROWS = 100; +export const COLS = 50; +export const CELLS = ROWS * COLS; export type SynthConfig = { sinks: Sinks; @@ -19,13 +23,16 @@ export type EngineMessage = { content: OffscreenCanvas; }; -export type Unit = +export type HigherUnit = | OscUnit - | ConstUnit | CombinatorUnit | RescaleUnit | NoiseUnit; +export type Unit = + | ConstUnit + | HigherUnit; + export type UnitId = string; // To support units with multiple outputs. @@ -41,7 +48,9 @@ export type RescaleUnit = { // 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: Input; + coarse: Input; + fine: Input; + superfine: Input; amount: Input; }; @@ -70,12 +79,120 @@ export type Sinks = { blue?: Input; }; +type Range = { + min: number; + max: number; +}; + +export const range = { + osc: { + coarse: { + min: -50, + max: 50, + }, + fine: { + min: -50, + max: 50, + }, + superfine: { + min: -50, + max: 50, + }, + amount: { + min: -50, + max: 50, + }, + output: { + min: -1, + max: 1, + }, + }, + noise: { + amount: { + min: -50, + max: 50, + }, + }, + color: { + min: 0, + max: 255, + }, + pmone: { + min: -1, + max: 1, + }, + slider: { + min: -50, + max: 50, + }, + signal: { + min: -5_000_000, + max: 5_000_000, + }, +}; + +//export function rangeForInput<U extends Unit>( +// units: UnitMap, +// id: UnitId, +// k: keyof Omit<U, "kind">, +//): Range { +// let unit = getUnit(units, id); +// switch (unit.kind) { +// case "const": { +// return range.slider; +// } +// case "osc": { +// switch (k) { +// case "amount": { +// return range.osc.amount; +// } +// case "coarse": { +// return range.osc.coarse; +// } +// case "fine": { +// return range.osc.fine; +// } +// default: { +// throw new Error(`cannot find range for ${String(k)} in ${unit.kind}`) +// } +// } +// } +// case "combinator": { +// return unit.sources.reduce((accum, (src) => { +// console.log('hi'); +// return +// })) +// } +// default: +// throw new Error("NYI: range for " + unit.kind); +// } +//} + +export function clamp(n: number, range: Range) { + return Math.max(range.min, Math.min(range.max, n)); +} +export function rescale(n: number, origin: Range, dest: Range) { + return ((n - origin.min) / (origin.max - origin.min)) * + (dest.max - dest.min) + dest.min; +} +export function wrangle(n: number, origin: Range, dest: Range) { + return rescale(clamp(n, origin), origin, dest); +} + 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 function getHigherUnit(units: UnitMap, id: UnitId): HigherUnit { + const result = getUnit(units, id); + if (result.kind === "const") { + throw new Error("lower unit found"); + } + return result; +} + export const is = { input: (i: any): i is Input => { return i === Object(i) && i.id; @@ -84,5 +201,20 @@ export const is = { const: (u: Unit): u is ConstUnit => { return u.kind === "const"; }, + osc: (u: Unit): u is OscUnit => { + return u.kind === "osc"; + }, + }, +}; + +export const ensure = { + unit: { + osc: (u: Unit): OscUnit => { + if (is.unit.osc(u)) { + return u; + } else { + throw new Error("this is not an osc"); + } + }, }, }; diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte @@ -2,7 +2,7 @@ import { onMount } from 'svelte'; import { debounce } from 'lodash'; import Slider from '$lib/Slider.svelte'; - import { is, getUnit as _getUnit, INPUT_RANGE } from '$lib/types'; + import { rescale, range, is, getUnit as _getUnit, INPUT_RANGE } from '$lib/types'; import type { Input, Unit, UnitId, ConstUnit, UnitMap, Sinks } from '$lib/types'; let cvs: HTMLCanvasElement | undefined; @@ -14,17 +14,26 @@ const EngineWorker = await import('$lib/engine.worker?worker'); engineWorker = new EngineWorker.default(); engineWorker.postMessage({ kind: 'config', content: { units, sinks } }); + if (cvs && engineWorker && !offscreenCanvas) { + const { innerHeight: height, innerWidth: width } = window; + console.log('sending canvas', { height, width }); + setTimeout(() => { + engineWorker?.postMessage({ kind: 'window', content: { width, height } }); + }, 0); + } }; 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]); + if (engineWorker) { + engineWorker.onmessage = (msg) => { + if (cvs) { + const ctx = cvs.getContext('bitmaprenderer'); + ctx?.transferFromImageBitmap(msg.data.content); + } + }; } } @@ -34,6 +43,8 @@ if (entry && cvs) { const width = entry.devicePixelContentBoxSize[0].inlineSize; const height = entry.devicePixelContentBoxSize[0].blockSize; + cvs.width = width; + cvs.height = height; engineWorker && engineWorker.postMessage({ kind: 'window', content: { width, height } }); } }); @@ -83,10 +94,6 @@ history.pushState({}, '', url); } const updateUrl = debounce(_updateUrl, 250); - $: { - console.log(location); - console.log(fromUrl()); - } function addUnit(unit: Unit): Input { const id = String(uid++); @@ -98,50 +105,32 @@ type EzUnit = Input | number | undefined; // make a const unit or use the supplied one function ez(input: EzUnit): Input { - return is.input(input) ? input : mk.c(input || 0); + return is.input(input) ? input : mk.c(input || randConst()); } - function gatherControls(accum: Set<Input>, ...ins: Input[]) { - for (let input of ins) { - const unit = getUnit(input.id); - switch (unit.kind) { - case 'osc': { - gatherControls(accum, unit.rate, unit.amount); - break; - } - case 'rescale': { - gatherControls(accum, unit.input); - break; - } - case 'noise': { - gatherControls(accum, unit.amount); - break; - } - case 'const': { - accum.add(input); - break; - } - case 'combinator': { - gatherControls(accum, ...unit.sources); - break; - } - } - } - return accum; - } - $: unitsToControl = gatherControls(new Set(), ...Object.values(sinks)); + const randConst = () => { + const sliderVal = Math.round(rescale(Math.random(), { min: 0, max: 1 }, range.slider)); + return rescale(sliderVal, range.slider, range.signal); + }; const mk = { c: function mkConst(value?: number): Input { return addUnit({ kind: 'const', - value: value || 0 + value: value == null ? randConst() : value }); }, - osc: function mkOsc(rate?: EzUnit, amount?: EzUnit): Input { + osc: function mkOsc( + coarse?: EzUnit, + fine?: EzUnit, + superfine?: EzUnit, + amount?: EzUnit + ): Input { return addUnit({ kind: 'osc', - rate: mk.re(0, INPUT_RANGE / 10, ez(rate)), + coarse: ez(coarse), + fine: ez(fine), + superfine: superfine ? ez(superfine) : mk.c(0), amount: ez(amount) }); }, @@ -151,17 +140,6 @@ sources: ids.map(ez) }); }, - re: function rescale(min?: EzUnit, max?: EzUnit, input?: EzUnit) { - return addUnit({ - kind: 'rescale', - min: ez(min), - max: ez(max), - input: ez(input) - }); - }, - re_color: function rescaleToColorRange(input?: EzUnit) { - return mk.re(0, 255, input); - }, n: function noise(amt: EzUnit) { return addUnit({ kind: 'noise', amount: ez(amt) }); } @@ -170,36 +148,31 @@ const get = { constUnit: (id: UnitId): ConstUnit => { const unit = getUnit(id); - if (unit.kind !== 'const') throw new Error('expecting a const here bro....'); + if (!is.unit.const(unit)) throw new Error('expecting a const here bro....'); return unit; } }; - const red = mk.re_color( - mk.add( - mk.c(INPUT_RANGE / 2), // - mk.n(0), - mk.osc(mk.add(mk.c(0), mk.osc(), mk.osc(mk.c(), mk.osc())), INPUT_RANGE / 2) // - ) + const shared = new Array(10).fill(0).map(() => mk.c()); + + const red = mk.add( + mk.c(), // + mk.osc(mk.add(mk.c(), mk.osc())) ); - const green = mk.re_color(); - const blue = mk.re_color(); + const green = mk.add( + mk.c(), // + mk.osc(mk.add(mk.c(), mk.osc())) + ); + const blue = mk.add( + mk.c(), // + mk.osc(mk.add(mk.c(), mk.osc())) + ); + sinks = { red, green, blue }; - function updateUnit(k: UnitId, vCoarse: number | null, vFine: number | null) { + function updateUnit(k: UnitId, value: number) { const unit = get.constUnit(k); - const oldVal = unit.value; - if (typeof vCoarse === typeof vFine) { - throw new Error('nyi: coarse and fine simul adjust'); - } - console.log({ oldVal, vCoarse, vFine }); - - if (vCoarse !== null) { - unit.value = (oldVal % 100_000) + vCoarse; - } else if (vFine !== null) { - unit.value = Math.floor(oldVal / 100_000) * 100_000 + Math.abs(vFine); - } - console.log({ value: unit.value }); + unit.value = value; units = units; } </script>