From 705e2a031115d440330974f964e6e32a46ef8e7a Mon Sep 17 00:00:00 2001 From: Oliver Bryan Date: Sun, 25 Jan 2026 10:25:40 +0000 Subject: [PATCH] map shape to audio with tone.js --- src/Index.tsx | 35 +++++++++++++++++++++++++++++++++++ src/hooks/useAudioContext.ts | 32 ++++++++++++++++++++++++++++++++ src/hooks/useSynth.ts | 20 ++++++++++++++++++++ src/lib/audio/mapping.ts | 31 +++++++++++++++++++++++++++++++ 4 files changed, 118 insertions(+) create mode 100644 src/hooks/useAudioContext.ts create mode 100644 src/hooks/useSynth.ts create mode 100644 src/lib/audio/mapping.ts diff --git a/src/Index.tsx b/src/Index.tsx index 279721f..c2fd096 100644 --- a/src/Index.tsx +++ b/src/Index.tsx @@ -1,8 +1,13 @@ import ShapeCanvas from "@/components/canvas/ShapeCanvas"; import PresetSelector from "@/components/controls/PresetSelector"; import { Slider } from "@/components/ui/slider"; +import { Toggle } from "@/components/ui/toggle"; +import { useAudioContext } from "@/hooks/useAudioContext"; import { useShapeState } from "@/hooks/useShapeState"; +import { useSynth } from "@/hooks/useSynth"; +import { mapGrainToNoise, mapPresetToOscType, mapRoundnessToFade, mapSizeToGain } from "@/lib/audio/mapping"; import { useEffect, useState } from "react"; +import * as Tone from "tone"; import Layout from "./Layout"; import { cn } from "./lib/utils"; @@ -28,9 +33,30 @@ function Index() { const centerY = dimensions.height / 2; const [state, setState] = useShapeState(centerX, centerY); + const { isMuted, toggleMute } = useAudioContext(); + const synthRef = useSynth(); + + useEffect(() => { + if (!synthRef.current) return; + + const nodes = synthRef.current; + nodes.oscillatorA.type = mapPresetToOscType(state.preset); + nodes.crossFade.fade.value = mapRoundnessToFade(state.roundness); + nodes.gain.gain.value = Tone.dbToGain(mapSizeToGain(state.size)); + + const grain = mapGrainToNoise(state.grain); + const noiseDb = grain === 0 ? Number.NEGATIVE_INFINITY : -40 + (-12 - -40) * grain; + nodes.noise.volume.value = noiseDb; + }, [state.preset, state.roundness, state.size, state.grain, synthRef]); const sidebarContent = (
+
+ Audio + + {isMuted ? "Unmute" : "Mute"} + +
Shape setState({ ...state, preset })} /> @@ -79,6 +105,15 @@ function Index() { onValueChange={([v]) => setState({ ...state, wobbleRandomness: v })} />
+
+ Noise + setState({ ...state, grain: v })} + /> +
); diff --git a/src/hooks/useAudioContext.ts b/src/hooks/useAudioContext.ts new file mode 100644 index 0000000..538e4fc --- /dev/null +++ b/src/hooks/useAudioContext.ts @@ -0,0 +1,32 @@ +import { useCallback, useEffect, useState } from "react"; +import { getDestination, start } from "tone"; + +export function useAudioContext() { + const [isMuted, setIsMuted] = useState(true); + const destination = getDestination(); + + useEffect(() => { + destination.mute = true; + + return () => { + destination.mute = true; + }; + }, [destination]); + + const toggleMute = useCallback(async () => { + if (isMuted) { + await start(); + destination.mute = false; + setIsMuted(false); + return; + } + + destination.mute = true; + setIsMuted(true); + }, [destination, isMuted]); + + return { + isMuted, + toggleMute, + }; +} diff --git a/src/hooks/useSynth.ts b/src/hooks/useSynth.ts new file mode 100644 index 0000000..c061c3c --- /dev/null +++ b/src/hooks/useSynth.ts @@ -0,0 +1,20 @@ +import type { SynthNodes } from "@/lib/audio/synth"; +import { createSynth, disposeSynth } from "@/lib/audio/synth"; +import { useEffect, useRef } from "react"; + +export function useSynth() { + const synthRef = useRef(null); + + useEffect(() => { + synthRef.current = createSynth(); + + return () => { + if (synthRef.current) { + disposeSynth(synthRef.current); + synthRef.current = null; + } + }; + }, []); + + return synthRef; +} diff --git a/src/lib/audio/mapping.ts b/src/lib/audio/mapping.ts new file mode 100644 index 0000000..718546a --- /dev/null +++ b/src/lib/audio/mapping.ts @@ -0,0 +1,31 @@ +import type { Preset } from "@/types/shape"; + +export function mapPresetToOscType(preset: Preset): "sawtooth" | "square" | "sine" { + switch (preset) { + case "triangle": + return "sawtooth"; + case "square": + return "square"; + case "circle": + return "sine"; + } +} + +export function mapRoundnessToFade(roundness: number): number { + return clamp01(roundness / 100); +} + +export function mapSizeToGain(size: number): number { + const minDb = -30; + const maxDb = -6; + const t = clamp01(size / 100); + return minDb + (maxDb - minDb) * t; +} + +export function mapGrainToNoise(grain: number): number { + return clamp01(grain / 100); +} + +function clamp01(value: number) { + return Math.min(1, Math.max(0, value)); +}