Files
tsos/src/Index.tsx
2026-02-01 09:26:02 +00:00

415 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { colorScale } from "@/constants/colorScale";
import { useShapeState } from "@/hooks/useShapeState";
import { type PreviewVoice, playPreviewSample, startPreviewVoice, stopPreviewVoice } from "@/lib/audio/synth";
import { Info } from "lucide-react";
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<Map<string, { voice: PreviewVoice | null; keys: Set<string> }>>(new Map());
const keyToNoteRef = useRef<Map<string, string>>(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 = (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<div className="flex items-center gap-1">
<span className="text-sm font-medium">Shape</span>
<Tooltip>
<TooltipTrigger asChild>
<Info className="size-3.5 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent>
<p>
Selects the oscillator waveform. Square = pulse with odd harmonics, Circle =
sine (pure tone), Triangle = sawtooth (all harmonics).
</p>
</TooltipContent>
</Tooltip>
</div>
<PresetSelector value={state.preset} onChange={(preset) => setState({ ...state, preset })} />
</div>
<div className="flex flex-col gap-2">
<div className="flex items-center gap-1">
<span className="text-sm font-medium">Note/Colour</span>
<Tooltip>
<TooltipTrigger asChild>
<Info className="size-3.5 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent>
<p>
Sets the fundamental frequency (f) from the 12-tone chromatic scale using
equal temperament (A4 = 440Hz).
</p>
</TooltipContent>
</Tooltip>
</div>
<ColorKeyboard
value={state.color}
onChange={(color) => {
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 });
}}
/>
</div>
<div className="flex flex-col gap-2">
<div className="flex items-center gap-1">
<span className="text-sm font-medium">Octave</span>
<Tooltip>
<TooltipTrigger asChild>
<Info className="size-3.5 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent>
<p>
Transposes frequency by octaves: f = f × 2. Doubles frequency per octave,
shifting the entire harmonic series.
</p>
</TooltipContent>
</Tooltip>
</div>
<OctaveSelector value={state.octave} onChange={(octave) => setState({ ...state, octave })} />
</div>
<div className="flex flex-col gap-2">
<div className="flex items-center gap-1">
<span className="text-sm font-medium">Size</span>
<Tooltip>
<TooltipTrigger asChild>
<Info className="size-3.5 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent>
<p>
Controls gain/amplitude in dB. Affects signal level, RMS power, and loudness
perception.
</p>
</TooltipContent>
</Tooltip>
</div>
<Slider
value={[state.size]}
min={0}
max={100}
onValueChange={([v]) => setState({ ...state, size: v })}
/>
</div>
<div className="flex flex-col gap-2">
<div className="flex items-center gap-1">
<span
className={cn(
"text-sm font-medium",
state.preset === "circle" ? "opacity-50 pointer-events-none select-none" : "",
)}
>
Roundness
</span>
<Tooltip>
<TooltipTrigger asChild>
<Info
className={cn(
"size-3.5 text-muted-foreground cursor-help",
state.preset === "circle" ? "opacity-50" : "",
)}
/>
</TooltipTrigger>
<TooltipContent>
<p>
Adjusts duty cycle ratio or wave crest factor, altering the balance between
fundamental and harmonics.
</p>
</TooltipContent>
</Tooltip>
</div>
<Slider
value={[state.roundness]}
min={0}
max={100}
onValueChange={([v]) => setState({ ...state, roundness: v })}
className={state.preset === "circle" ? "opacity-50 pointer-events-none" : ""}
/>
</div>
<div className="flex flex-col gap-2">
<div className="flex items-center gap-1">
<span className="text-sm font-medium">Wobble</span>
<Tooltip>
<TooltipTrigger asChild>
<Info className="size-3.5 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent>
<p>
LFO modulation depth in cents (±100 = ±1 semitone). Controls pitch deviation
magnitude from FM synthesis.
</p>
</TooltipContent>
</Tooltip>
<span className="text-xs text-muted-foreground">(currently only visual)</span>
</div>
<Slider
value={[state.wobble]}
min={0}
max={100}
onValueChange={([v]) => setState({ ...state, wobble: v })}
/>
</div>
<div className="flex flex-col gap-2">
<div className="flex items-center gap-1">
<span className="text-sm font-medium">Wobble Speed</span>
<Tooltip>
<TooltipTrigger asChild>
<Info className="size-3.5 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent>
<p>
LFO frequency in Hz. Determines how many pitch modulation cycles occur per
second.
</p>
</TooltipContent>
</Tooltip>
<span className="text-xs text-muted-foreground">(currently only visual)</span>
</div>
<Slider
value={[state.wobbleSpeed]}
min={0}
max={100}
onValueChange={([v]) => setState({ ...state, wobbleSpeed: v })}
/>
</div>
<div className="flex flex-col gap-2">
<div className="flex items-center gap-1">
<span className="text-sm font-medium">Wobble Randomness</span>
<Tooltip>
<TooltipTrigger asChild>
<Info className="size-3.5 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent>
<p>
Adds noise-based jitter to the LFO signal. Breaks periodicity for organic,
non-mechanical modulation.
</p>
</TooltipContent>
</Tooltip>
<span className="text-xs text-muted-foreground">(currently only visual)</span>
</div>
<Slider
value={[state.wobbleRandomness]}
min={0}
max={100}
onValueChange={([v]) => setState({ ...state, wobbleRandomness: v })}
/>
</div>
</div>
);
return (
<Layout sidebarContent={sidebarContent} waveformColor={state.color}>
<ShapeCanvas state={state} onStateChange={setState} />
</Layout>
);
}
export default Index;