From 7971ca229c8be8ac30ed1c32a2f71e6303fb8ce2 Mon Sep 17 00:00:00 2001 From: Oliver Bryan Date: Sun, 25 Jan 2026 14:20:22 +0000 Subject: [PATCH] multi-note support (chords + overlapping melody lines) --- src/Index.tsx | 146 +++++++++++++++++++------------------ src/lib/audio/synth.ts | 160 ++++++++++++++++++++++++++++++++++++----- 2 files changed, 217 insertions(+), 89 deletions(-) diff --git a/src/Index.tsx b/src/Index.tsx index 20606e7..88351b7 100644 --- a/src/Index.tsx +++ b/src/Index.tsx @@ -3,22 +3,10 @@ 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 { Toggle } from "@/components/ui/toggle"; -import { colorScale, noteToFrequency } from "@/constants/colorScale"; -import { useAudioContext } from "@/hooks/useAudioContext"; +import { colorScale } from "@/constants/colorScale"; import { useShapeState } from "@/hooks/useShapeState"; -import { useSynth } from "@/hooks/useSynth"; -import { useWobbleAnimation } from "@/hooks/useWobbleAnimation"; -import { - mapGrainToNoise, - mapPresetToOscType, - mapRoundnessToFade, - mapSizeToGain, - mapWobbleToDetune, -} from "@/lib/audio/mapping"; -import { playPreviewSample } from "@/lib/audio/synth"; -import { useEffect, useState } from "react"; -import * as Tone from "tone"; +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"; @@ -100,45 +88,21 @@ function Index() { const centerY = dimensions.height / 2; const [state, setState] = useShapeState(centerX, centerY); - const { isMuted, toggleMute } = useAudioContext(); - const synthRef = useSynth(); - const pitchTime = useWobbleAnimation(state.wobbleSpeed); + const activeVoicesRef = useRef }>>(new Map()); + const keyToNoteRef = useRef>(new Map()); useEffect(() => { - if (!synthRef.current) return; + 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 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 detuneDepth = mapWobbleToDetune(state.wobble); - const detune = Math.sin(pitchTime * Math.PI * 2) * detuneDepth; - nodes.oscillatorA.detune.value = detune; - nodes.oscillatorB.detune.value = detune; - - const note = - colorScale.find((entry) => entry.color.toLowerCase() === state.color.toLowerCase())?.note ?? - colorScale[0].note; - const frequency = noteToFrequency(note, state.octave); - nodes.oscillatorA.frequency.value = frequency; - nodes.oscillatorB.frequency.value = frequency; - - 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.wobble, - state.grain, - state.color, - state.octave, - synthRef, - pitchTime, - ]); - - useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { if (event.repeat || event.metaKey || event.ctrlKey || event.altKey) { return; @@ -165,18 +129,37 @@ function Index() { setState((prev) => { const targetOctave = clampOctave(prev.octave + binding.octaveOffset); - console.log(`${binding.note + targetOctave} ${prev.octave} + ${binding.octaveOffset}`); const color = COLOR_BY_NOTE.get(binding.note) ?? prev.color; + const noteKey = `${binding.note}${targetOctave}`; - void playPreviewSample({ - preset: prev.preset, - roundness: prev.roundness, - size: prev.size, - grain: prev.grain, - note: binding.note, - octave: targetOctave, - synthNodes: synthRef.current, - }); + 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, @@ -185,18 +168,41 @@ function Index() { }); }; + 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); - return () => window.removeEventListener("keydown", handleKeyDown); - }, [setState, synthRef]); + 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 = (
-
- Audio - - {isMuted ? "Unmute" : "Mute"} - -
Shape setState({ ...state, preset })} /> @@ -217,7 +223,7 @@ function Index() { grain: state.grain, note, octave: state.octave, - synthNodes: synthRef.current, + synthNodes: null, }); setState({ ...state, color }); diff --git a/src/lib/audio/synth.ts b/src/lib/audio/synth.ts index 778cb5a..133a32b 100644 --- a/src/lib/audio/synth.ts +++ b/src/lib/audio/synth.ts @@ -59,23 +59,84 @@ type PreviewOptions = { synthNodes: SynthNodes | null; }; +export type PreviewVoice = { + oscillatorA: Tone.Oscillator; + oscillatorB: Tone.Oscillator; + crossFade: Tone.CrossFade; + noise: Tone.Noise; + gain: Tone.Gain; +}; + const PREVIEW_ATTACK = 0.02; const PREVIEW_DURATION = 0.2; const PREVIEW_CLEANUP = 0.05; +const PREVIEW_RELEASE = 0.08; + +type PreviewSharedState = { + destination: ReturnType; + wasMuted: boolean; + previousGain: number | null; + activeCount: number; + sessionId: number; + startPromise: Promise | null; +}; + +const previewSharedState: PreviewSharedState = { + destination: Tone.getDestination(), + wasMuted: false, + previousGain: null, + activeCount: 0, + sessionId: 0, + startPromise: null, +}; + +async function acquirePreviewShared(synthNodes: SynthNodes | null) { + previewSharedState.activeCount += 1; + + if (previewSharedState.activeCount === 1) { + previewSharedState.sessionId += 1; + const sessionId = previewSharedState.sessionId; + const destination = Tone.getDestination(); + previewSharedState.destination = destination; + previewSharedState.wasMuted = destination.mute; + previewSharedState.previousGain = + previewSharedState.wasMuted && synthNodes ? synthNodes.gain.gain.value : null; + + if (previewSharedState.wasMuted) { + previewSharedState.startPromise = Tone.start().then(() => { + if (previewSharedState.activeCount > 0 && previewSharedState.sessionId === sessionId) { + destination.mute = false; + if (synthNodes) { + synthNodes.gain.gain.value = 0; + } + } + }); + } else { + previewSharedState.startPromise = Promise.resolve(); + } + } + + if (previewSharedState.startPromise) { + await previewSharedState.startPromise; + } +} + +function releasePreviewShared(synthNodes: SynthNodes | null) { + previewSharedState.activeCount = Math.max(0, previewSharedState.activeCount - 1); + + if (previewSharedState.activeCount === 0) { + if (previewSharedState.wasMuted) { + previewSharedState.destination.mute = true; + } + + if (previewSharedState.previousGain !== null && synthNodes) { + synthNodes.gain.gain.value = previewSharedState.previousGain; + } + } +} export async function playPreviewSample(options: PreviewOptions) { - const destination = Tone.getDestination(); - const wasMuted = destination.mute; - - if (wasMuted) { - await Tone.start(); - destination.mute = false; - } - - const previousGain = wasMuted && options.synthNodes ? options.synthNodes.gain.gain.value : null; - if (wasMuted && options.synthNodes) { - options.synthNodes.gain.gain.value = 0; - } + await acquirePreviewShared(options.synthNodes); const previewGain = new Tone.Gain(0); const crossFade = new Tone.CrossFade(mapRoundnessToFade(options.roundness)); @@ -120,13 +181,74 @@ export async function playPreviewSample(options: PreviewOptions) { noise.dispose(); previewGain.dispose(); - if (wasMuted) { - destination.mute = true; - } - - if (previousGain !== null && options.synthNodes) { - options.synthNodes.gain.gain.value = previousGain; - } + releasePreviewShared(options.synthNodes); + }, + (stopAt - now) * 1000, + ); +} + +export async function startPreviewVoice(options: PreviewOptions): Promise { + await acquirePreviewShared(options.synthNodes); + + const previewGain = new Tone.Gain(0); + const crossFade = new Tone.CrossFade(mapRoundnessToFade(options.roundness)); + const oscillatorA = new Tone.Oscillator({ type: mapPresetToOscType(options.preset) }); + const oscillatorB = new Tone.Oscillator({ type: "sine" }); + const noise = new Tone.Noise({ type: "white" }); + + oscillatorA.connect(crossFade.a); + oscillatorB.connect(crossFade.b); + crossFade.connect(previewGain); + noise.connect(previewGain); + previewGain.toDestination(); + + const frequency = noteToFrequency(options.note, options.octave); + oscillatorA.frequency.value = frequency; + oscillatorB.frequency.value = frequency; + + const grain = mapGrainToNoise(options.grain); + const noiseDb = grain === 0 ? Number.NEGATIVE_INFINITY : -40 + (-12 - -40) * grain; + noise.volume.value = noiseDb; + + const now = Tone.now(); + const peak = Tone.dbToGain(mapSizeToGain(options.size)); + + previewGain.gain.setValueAtTime(0, now); + previewGain.gain.linearRampToValueAtTime(peak, now + PREVIEW_ATTACK); + + oscillatorA.start(now); + oscillatorB.start(now); + noise.start(now); + + return { + oscillatorA, + oscillatorB, + crossFade, + noise, + gain: previewGain, + }; +} + +export function stopPreviewVoice(voice: PreviewVoice, synthNodes: SynthNodes | null) { + const now = Tone.now(); + const stopAt = now + PREVIEW_RELEASE + PREVIEW_CLEANUP; + + voice.gain.gain.cancelScheduledValues(now); + voice.gain.gain.setValueAtTime(voice.gain.gain.value, now); + voice.gain.gain.linearRampToValueAtTime(0, now + PREVIEW_RELEASE); + + voice.oscillatorA.stop(stopAt); + voice.oscillatorB.stop(stopAt); + voice.noise.stop(stopAt); + + window.setTimeout( + () => { + voice.oscillatorA.dispose(); + voice.oscillatorB.dispose(); + voice.crossFade.dispose(); + voice.noise.dispose(); + voice.gain.dispose(); + releasePreviewShared(synthNodes); }, (stopAt - now) * 1000, );