color-synth

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

+page.svelte (8325B)


      1 <script lang="ts">
      2 	import { unitDragging, unitStore, unitToConnect, sinksStore } from '$lib/stores';
      3 	import { onMount } from 'svelte';
      4 	import { debounce } from 'lodash';
      5 	import Sink from '$lib/Sink.svelte';
      6 	import {
      7 		COLS,
      8 		ROWS,
      9 		inp,
     10 		rescale,
     11 		RANGE,
     12 		is,
     13 		getUnit as _getUnit,
     14 		unitKinds,
     15 		UNIT_NAMES
     16 	} from '$lib/types';
     17 	import type {
     18 		Output,
     19 		Input,
     20 		Unit,
     21 		UnitId,
     22 		NumberUnit,
     23 		Units,
     24 		Sinks,
     25 		UnitKind,
     26 		Pos
     27 	} from '$lib/types';
     28 	import OscUnitComponent from '$lib/OscUnit.svelte';
     29 	import NoiseUnitComponent from '$lib/NoiseUnit.svelte';
     30 	import NumberUnitComponent from '$lib/NumberUnit.svelte';
     31 	import SmoothUnitComponent from '$lib/SmoothUnit.svelte';
     32 	import MathUnitComponent from '$lib/MathUnit.svelte';
     33 	import LagUnitComponent from '$lib/LagUnit.svelte';
     34 	import ImgUnitComponent from '$lib/ImgUnit.svelte';
     35 	import UnitDrag from '$lib/UnitDrag.svelte';
     36 	import Wires from '$lib/Wires.svelte';
     37 
     38 	const debug = false;
     39 
     40 	let cvs: HTMLCanvasElement | undefined;
     41 
     42 	// initialize worker
     43 	let engineWorker: Worker | undefined = undefined;
     44 	const loadWorker = async () => {
     45 		const EngineWorker = await import('$lib/engine.worker?worker');
     46 		engineWorker = new EngineWorker.default();
     47 		engineWorker.postMessage({ kind: 'config', content: { units, sinks: $sinksStore } });
     48 	};
     49 
     50 	onMount(loadWorker);
     51 
     52 	$: {
     53 		if (engineWorker) {
     54 			engineWorker.onmessage = (msg) => {
     55 				requestAnimationFrame(() => {
     56 					if (cvs) {
     57 						const ctx = cvs.getContext('2d');
     58 						if (!ctx) return;
     59 						ctx.imageSmoothingEnabled = false;
     60 
     61 						const offscreen = new OffscreenCanvas(COLS, ROWS);
     62 						const offscreenCtx = offscreen.getContext('2d');
     63 						if (!offscreenCtx) throw new Error('cant get 2d context for offscreen canvas');
     64 
     65 						const arr = new Uint8ClampedArray(msg.data.content);
     66 						const img = new ImageData(arr, COLS);
     67 						offscreenCtx.putImageData(img, 0, 0);
     68 						ctx.drawImage(offscreen, 0, 0, cvs.width, cvs.height);
     69 					}
     70 				});
     71 			};
     72 		}
     73 	}
     74 
     75 	onMount(() => {
     76 		const resizeObserver = new ResizeObserver((entries) => {
     77 			const entry = entries.find((entry) => entry.target === cvs);
     78 			if (entry && cvs) {
     79 				const width = entry.devicePixelContentBoxSize[0].inlineSize;
     80 				const height = entry.devicePixelContentBoxSize[0].blockSize;
     81 				cvs.width = width;
     82 				cvs.height = height;
     83 				engineWorker && engineWorker.postMessage({ kind: 'window', content: { width, height } });
     84 			}
     85 		});
     86 
     87 		if (cvs) resizeObserver.observe(cvs, { box: 'device-pixel-content-box' });
     88 
     89 		// This callback cleans up the observer
     90 		return () => {
     91 			if (cvs) resizeObserver.unobserve(cvs);
     92 		};
     93 	});
     94 
     95 	const getUnit = (id: UnitId): Unit => _getUnit(null, $unitStore, id);
     96 	let uid = 0;
     97 	$: units = $unitStore;
     98 
     99 	// let's update the engine whenever the config changes for now, even though we could ignore pos at least.
    100 	$: {
    101 		if (engineWorker) {
    102 			engineWorker.postMessage({
    103 				kind: 'config',
    104 				content: { units: $unitStore, sinks: $sinksStore }
    105 			});
    106 		}
    107 	}
    108 	onMount(() => {
    109 		const doc: SerializedState = fromUrl();
    110 		if (doc) {
    111 			$unitStore = doc.units;
    112 			uid = Object.keys(doc.units).length;
    113 			$sinksStore = doc.sinks;
    114 		}
    115 	});
    116 
    117 	type SerializedState = {
    118 		version: 1;
    119 		units: Units;
    120 		sinks: Sinks;
    121 	};
    122 
    123 	function toUrl() {
    124 		return btoa(JSON.stringify({ version: 1, sinks: $sinksStore, units }));
    125 	}
    126 
    127 	function fromUrl() {
    128 		const doc = atob(new URL(document.location.toString()).searchParams.get('z') || '');
    129 		if (doc) {
    130 			return JSON.parse(doc);
    131 		}
    132 	}
    133 
    134 	function _updateUrl() {
    135 		const url = new URL(String(location));
    136 		url.searchParams.set('z', toUrl());
    137 		history.pushState({}, '', url);
    138 	}
    139 	const updateUrl = debounce(_updateUrl, 250);
    140 
    141 	function addUnit(unit: Unit): UnitId {
    142 		const id = String(uid++);
    143 		unitStore.setUnit(id, unit);
    144 		return id;
    145 	}
    146 
    147 	const handleSignalStart = (o: Output) => {
    148 		$unitToConnect = o;
    149 		const h = () => {
    150 			$unitToConnect = false;
    151 			document.removeEventListener('mouseup', h);
    152 		};
    153 		setTimeout(() => {
    154 			document.addEventListener('mouseup', h);
    155 		}, 0);
    156 	};
    157 
    158 	const _onSinkConnect = (ch: 'l' | 'c' | 'h'): void => {
    159 		if (!$unitToConnect) {
    160 			throw new Error('cant connect sink to nonexistant signal');
    161 		}
    162 		$sinksStore[ch] = inp.toggle($sinksStore[ch], $unitToConnect);
    163 		engineWorker &&
    164 			engineWorker.postMessage({ kind: 'config', content: { sinks: $sinksStore, units } });
    165 		$unitToConnect = false;
    166 	};
    167 
    168 	const setSinkInput = (ch: 'l' | 'c' | 'h', input: Input) => {
    169 		$sinksStore[ch] = input;
    170 		engineWorker &&
    171 			engineWorker.postMessage({ kind: 'config', content: { sinks: $sinksStore, units } });
    172 	};
    173 
    174 	$: onSinkConnect = $unitToConnect ? _onSinkConnect : null;
    175 
    176 	type SinkEntries = [keyof Sinks, Input][];
    177 	// Object.entries COULD have extra stuff, so it doesn't assume keys are keyof Sinks exactly.
    178 	// see https://stackoverflow.com/questions/60141960/typescript-key-value-relation-preserving-object-entries-type
    179 	$: sinkEntries = [...Object.entries($sinksStore)] as SinkEntries;
    180 
    181 	const getConnectHandler = (sdrag: false | Output, input: UnitId) => {
    182 		if (!sdrag) return null;
    183 		if (sdrag.id === input) return null;
    184 		return connectHandler.bind(null, sdrag.id, input);
    185 	};
    186 
    187 	const connectHandler = (output: UnitId, input: UnitId, k: string) => {
    188 		const unit = getUnit(input);
    189 		// @ts-ignore unit[k] is a channel but i don't wanna prove it.
    190 		unitStore.setUnit(input, { ...unit, [k]: inp.toggle(unit[k], { id: output }) });
    191 		engineWorker &&
    192 			engineWorker.postMessage({ kind: 'config', content: { sinks: $sinksStore, units } });
    193 		$unitToConnect = false;
    194 	};
    195 </script>
    196 
    197 <canvas bind:this={cvs} />
    198 <Wires />
    199 
    200 <div id="buttons">
    201 	{#each unitKinds as kind}
    202 		{#if kind !== 'lag'}
    203 			<button on:click={() => unitStore.addUnit(kind)}>add {UNIT_NAMES[kind]}</button>
    204 		{/if}
    205 	{/each}
    206 	<button on:click={updateUrl}>save</button>
    207 </div>
    208 
    209 <div id="sinks">
    210 	{#each sinkEntries as [channel, input]}
    211 		<Sink
    212 			setInput={setSinkInput.bind(null, channel)}
    213 			{input}
    214 			{channel}
    215 			{onSinkConnect}
    216 			connected={$unitToConnect && input ? inp.connected(input, $unitToConnect) : false}
    217 		/>
    218 	{/each}
    219 </div>
    220 <div id="units">
    221 	{#each [...Object.entries(units)] as [id, unit]}
    222 		{@const pos = $unitDragging?.id === id ? $unitDragging.pos : unit.pos}
    223 		<div class="unit" style={`top: ${pos.y}px; left: ${pos.x}px`}>
    224 			<div data-unit-id={id}>
    225 				<UnitDrag {id}>
    226 					<h2>
    227 						{#if debug}{id.substring(0, 5)}-{/if}{UNIT_NAMES[unit.kind]}
    228 					</h2>
    229 				</UnitDrag>
    230 				<div
    231 					class="output-dragger"
    232 					on:mousedown={(e) => {
    233 						e.preventDefault();
    234 						handleSignalStart({ id });
    235 					}}
    236 				/>
    237 				{#if unit.kind === 'number'}
    238 					<NumberUnitComponent {id} />
    239 				{:else if unit.kind === 'osc'}
    240 					<OscUnitComponent {id} />
    241 				{:else if unit.kind === 'noise'}
    242 					<NoiseUnitComponent {id} />
    243 				{:else if unit.kind === 'smooth'}
    244 					<SmoothUnitComponent {id} />
    245 				{:else if unit.kind === 'math'}
    246 					<MathUnitComponent {id} />
    247 				{:else if unit.kind === 'lag'}
    248 					<LagUnitComponent {id} />
    249 				{:else if unit.kind === 'img'}
    250 					<ImgUnitComponent {id} />
    251 				{/if}
    252 			</div>
    253 		</div>
    254 	{/each}
    255 </div>
    256 
    257 <style>
    258 	.output-dragger {
    259 		position: absolute;
    260 		top: 0;
    261 		right: 0;
    262 		width: 16px;
    263 		height: 16px;
    264 		border-radius: 8px;
    265 		background: purple;
    266 		margin: 5px;
    267 	}
    268 	#sinks {
    269 		position: absolute;
    270 		top: 0;
    271 		right: 0;
    272 		display: flex;
    273 		flex-direction: column;
    274 	}
    275 	.unit {
    276 		width: 300px;
    277 		position: absolute;
    278 		z-index: 1;
    279 	}
    280 	#buttons {
    281 		z-index: 1;
    282 		position: fixed;
    283 		bottom: 5px;
    284 		right: 5px;
    285 	}
    286 	h2 {
    287 		margin: 0;
    288 		padding: 0 5px;
    289 		background: white;
    290 	}
    291 	canvas {
    292 		width: 100vw;
    293 		height: 100vh;
    294 		background: salmon;
    295 	}
    296 	#units {
    297 		position: absolute;
    298 		left: 10px;
    299 		top: 10px;
    300 		display: flex;
    301 	}
    302 	.unit {
    303 		--main-color: #eee;
    304 		--r: 6px;
    305 		--shadow: rgba(200, 200, 200, 255);
    306 		--s-g: linear-gradient(45deg, #eee, #dedede);
    307 		--angle: 45deg;
    308 		background: var(--s-g);
    309 		padding: 2px;
    310 		border-radius: var(--r);
    311 	}
    312 	.unit::before,
    313 	.unit::after {
    314 		content: '';
    315 		position: absolute;
    316 		width: 100%;
    317 		height: 100%;
    318 		background: var(--s-g);
    319 		border-radius: var(--r);
    320 		z-index: -1;
    321 	}
    322 	.unit::before {
    323 		top: 2px;
    324 		left: 2px;
    325 	}
    326 	.unit::after {
    327 		top: 4px;
    328 		left: 4px;
    329 	}
    330 	.unit > div {
    331 		overflow: hidden;
    332 		background: var(--main-color);
    333 		border-radius: var(--r);
    334 	}
    335 </style>