import ShapeCanvas from "@/components/canvas/ShapeCanvas"; import ColorKeyboard from "@/components/controls/ColorKeyboard"; import OctaveSelector from "@/components/controls/OctaveSelector"; import PresetSelector from "@/components/controls/PresetSelector"; import { Slider } from "@/components/ui/slider"; import { colorScale } from "@/constants/colorScale"; import { useShapeState } from "@/hooks/useShapeState"; import { type PreviewVoice, playPreviewSample, startPreviewVoice, stopPreviewVoice } from "@/lib/audio/synth"; import { useEffect, useRef, useState } from "react"; import Layout from "./Layout"; import { cn } from "./lib/utils"; const KEY_NOTE_BINDINGS = [ { key: "z", note: "C", octaveOffset: -1 }, { key: "x", note: "C#", octaveOffset: -1 }, { key: "c", note: "D", octaveOffset: -1 }, { key: "v", note: "D#", octaveOffset: -1 }, { key: "b", note: "E", octaveOffset: -1 }, { key: "n", note: "F", octaveOffset: -1 }, { key: "m", note: "F#", octaveOffset: -1 }, { key: ",", note: "G", octaveOffset: -1 }, { key: ".", note: "G#", octaveOffset: -1 }, { key: "a", note: "A", octaveOffset: -1 }, { key: "s", note: "A#", octaveOffset: -1 }, { key: "d", note: "B", octaveOffset: -1 }, { key: "f", note: "C", octaveOffset: 0 }, { key: "g", note: "C#", octaveOffset: 0 }, { key: "h", note: "D", octaveOffset: 0 }, { key: "j", note: "D#", octaveOffset: 0 }, { key: "k", note: "E", octaveOffset: 0 }, { key: "l", note: "F", octaveOffset: 0 }, { key: ";", note: "F#", octaveOffset: 0 }, { key: "'", note: "G", octaveOffset: 0 }, { key: "q", note: "G#", octaveOffset: 0 }, { key: "w", note: "A", octaveOffset: 0 }, { key: "e", note: "A#", octaveOffset: 0 }, { key: "r", note: "B", octaveOffset: 0 }, { key: "t", note: "C", octaveOffset: 1 }, { key: "y", note: "C#", octaveOffset: 1 }, { key: "u", note: "D", octaveOffset: 1 }, { key: "i", note: "D#", octaveOffset: 1 }, { key: "o", note: "E", octaveOffset: 1 }, { key: "p", note: "F", octaveOffset: 1 }, { key: "[", note: "F#", octaveOffset: 1 }, { key: "]", note: "G", octaveOffset: 1 }, { key: "1", note: "G#", octaveOffset: 1 }, { key: "2", note: "A", octaveOffset: 1 }, { key: "3", note: "A#", octaveOffset: 1 }, { key: "4", note: "B", octaveOffset: 1 }, { key: "5", note: "C", octaveOffset: 2 }, { key: "6", note: "C#", octaveOffset: 2 }, { key: "7", note: "D", octaveOffset: 2 }, { key: "8", note: "D#", octaveOffset: 2 }, { key: "9", note: "E", octaveOffset: 2 }, { key: "0", note: "F", octaveOffset: 2 }, { key: "-", note: "F#", octaveOffset: 2 }, { key: "=", note: "G", octaveOffset: 2 }, ]; const KEY_NOTE_MAP = new Map(KEY_NOTE_BINDINGS.map((binding) => [binding.key, binding])); const COLOR_BY_NOTE = new Map(colorScale.map((entry) => [entry.note, entry.color])); const MIN_OCTAVE = 1; const MAX_OCTAVE = 8; function clampOctave(value: number) { return Math.min(MAX_OCTAVE, Math.max(MIN_OCTAVE, value)); } function Index() { const [dimensions, setDimensions] = useState({ width: window.innerWidth - 320, height: window.innerHeight, }); useEffect(() => { const handleResize = () => { setDimensions({ width: window.innerWidth - 320, height: window.innerHeight, }); }; window.addEventListener("resize", handleResize); return () => window.removeEventListener("resize", handleResize); }, []); const centerX = dimensions.width / 2; const centerY = dimensions.height / 2; const [state, setState] = useShapeState(centerX, centerY); const activeVoicesRef = useRef }>>(new Map()); const keyToNoteRef = useRef>(new Map()); useEffect(() => { const stopAllVoices = () => { const entries = Array.from(activeVoicesRef.current.values()); activeVoicesRef.current.clear(); keyToNoteRef.current.clear(); for (const entry of entries) { if (entry.voice) { stopPreviewVoice(entry.voice, null); } } }; const handleKeyDown = (event: KeyboardEvent) => { if (event.repeat || event.metaKey || event.ctrlKey || event.altKey) { return; } const target = event.target; if (target instanceof HTMLElement) { const tagName = target.tagName.toLowerCase(); if ( target.isContentEditable || tagName === "input" || tagName === "textarea" || tagName === "select" ) { return; } } const normalizedKey = event.key.length === 1 ? event.key.toLowerCase() : event.key; const binding = KEY_NOTE_MAP.get(normalizedKey); if (!binding) { return; } setState((prev) => { const targetOctave = clampOctave(prev.octave + binding.octaveOffset); const color = COLOR_BY_NOTE.get(binding.note) ?? prev.color; const noteKey = `${binding.note}${targetOctave}`; keyToNoteRef.current.set(normalizedKey, noteKey); const existingEntry = activeVoicesRef.current.get(noteKey); if (existingEntry) { existingEntry.keys.add(normalizedKey); } else { activeVoicesRef.current.set(noteKey, { voice: null, keys: new Set([normalizedKey]) }); void startPreviewVoice({ preset: prev.preset, roundness: prev.roundness, size: prev.size, grain: prev.grain, note: binding.note, octave: targetOctave, synthNodes: null, }).then((voice) => { const entry = activeVoicesRef.current.get(noteKey); if (!entry) { stopPreviewVoice(voice, null); return; } entry.voice = voice; if (entry.keys.size === 0) { activeVoicesRef.current.delete(noteKey); stopPreviewVoice(voice, null); } }); } return { ...prev, color, }; }); }; const handleKeyUp = (event: KeyboardEvent) => { const normalizedKey = event.key.length === 1 ? event.key.toLowerCase() : event.key; const noteKey = keyToNoteRef.current.get(normalizedKey); if (!noteKey) { return; } keyToNoteRef.current.delete(normalizedKey); const entry = activeVoicesRef.current.get(noteKey); if (!entry) { return; } entry.keys.delete(normalizedKey); if (entry.keys.size === 0) { activeVoicesRef.current.delete(noteKey); if (entry.voice) { stopPreviewVoice(entry.voice, null); } } }; window.addEventListener("keydown", handleKeyDown); window.addEventListener("keyup", handleKeyUp); window.addEventListener("blur", stopAllVoices); return () => { window.removeEventListener("keydown", handleKeyDown); window.removeEventListener("keyup", handleKeyUp); window.removeEventListener("blur", stopAllVoices); stopAllVoices(); }; }, [setState]); const sidebarContent = (
Shape setState({ ...state, preset })} />
Note/Colour { const note = colorScale.find((entry) => entry.color.toLowerCase() === color.toLowerCase()) ?.note ?? colorScale[0].note; void playPreviewSample({ preset: state.preset, roundness: state.roundness, size: state.size, grain: state.grain, note, octave: state.octave, synthNodes: null, }); setState({ ...state, color }); }} />
Octave setState({ ...state, octave })} />
Roundness setState({ ...state, roundness: v })} className={state.preset === "circle" ? "opacity-50 pointer-events-none" : ""} />
Wobble setState({ ...state, wobble: v })} />
Wobble Speed setState({ ...state, wobbleSpeed: v })} />
Wobble Randomness setState({ ...state, wobbleRandomness: v })} />
); return ( ); } export default Index;