mirror of
https://github.com/hex248/tsos.git
synced 2026-02-07 18:23:05 +00:00
multi-note support (chords + overlapping melody lines)
This commit is contained in:
146
src/Index.tsx
146
src/Index.tsx
@@ -3,22 +3,10 @@ import ColorKeyboard from "@/components/controls/ColorKeyboard";
|
|||||||
import OctaveSelector from "@/components/controls/OctaveSelector";
|
import OctaveSelector from "@/components/controls/OctaveSelector";
|
||||||
import PresetSelector from "@/components/controls/PresetSelector";
|
import PresetSelector from "@/components/controls/PresetSelector";
|
||||||
import { Slider } from "@/components/ui/slider";
|
import { Slider } from "@/components/ui/slider";
|
||||||
import { Toggle } from "@/components/ui/toggle";
|
import { colorScale } from "@/constants/colorScale";
|
||||||
import { colorScale, noteToFrequency } from "@/constants/colorScale";
|
|
||||||
import { useAudioContext } from "@/hooks/useAudioContext";
|
|
||||||
import { useShapeState } from "@/hooks/useShapeState";
|
import { useShapeState } from "@/hooks/useShapeState";
|
||||||
import { useSynth } from "@/hooks/useSynth";
|
import { type PreviewVoice, playPreviewSample, startPreviewVoice, stopPreviewVoice } from "@/lib/audio/synth";
|
||||||
import { useWobbleAnimation } from "@/hooks/useWobbleAnimation";
|
import { useEffect, useRef, useState } from "react";
|
||||||
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 Layout from "./Layout";
|
import Layout from "./Layout";
|
||||||
import { cn } from "./lib/utils";
|
import { cn } from "./lib/utils";
|
||||||
|
|
||||||
@@ -100,45 +88,21 @@ function Index() {
|
|||||||
const centerY = dimensions.height / 2;
|
const centerY = dimensions.height / 2;
|
||||||
|
|
||||||
const [state, setState] = useShapeState(centerX, centerY);
|
const [state, setState] = useShapeState(centerX, centerY);
|
||||||
const { isMuted, toggleMute } = useAudioContext();
|
const activeVoicesRef = useRef<Map<string, { voice: PreviewVoice | null; keys: Set<string> }>>(new Map());
|
||||||
const synthRef = useSynth();
|
const keyToNoteRef = useRef<Map<string, string>>(new Map());
|
||||||
const pitchTime = useWobbleAnimation(state.wobbleSpeed);
|
|
||||||
|
|
||||||
useEffect(() => {
|
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) => {
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
if (event.repeat || event.metaKey || event.ctrlKey || event.altKey) {
|
if (event.repeat || event.metaKey || event.ctrlKey || event.altKey) {
|
||||||
return;
|
return;
|
||||||
@@ -165,18 +129,37 @@ function Index() {
|
|||||||
|
|
||||||
setState((prev) => {
|
setState((prev) => {
|
||||||
const targetOctave = clampOctave(prev.octave + binding.octaveOffset);
|
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 color = COLOR_BY_NOTE.get(binding.note) ?? prev.color;
|
||||||
|
const noteKey = `${binding.note}${targetOctave}`;
|
||||||
|
|
||||||
void playPreviewSample({
|
keyToNoteRef.current.set(normalizedKey, noteKey);
|
||||||
preset: prev.preset,
|
const existingEntry = activeVoicesRef.current.get(noteKey);
|
||||||
roundness: prev.roundness,
|
if (existingEntry) {
|
||||||
size: prev.size,
|
existingEntry.keys.add(normalizedKey);
|
||||||
grain: prev.grain,
|
} else {
|
||||||
note: binding.note,
|
activeVoicesRef.current.set(noteKey, { voice: null, keys: new Set([normalizedKey]) });
|
||||||
octave: targetOctave,
|
void startPreviewVoice({
|
||||||
synthNodes: synthRef.current,
|
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 {
|
return {
|
||||||
...prev,
|
...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);
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
window.addEventListener("keyup", handleKeyUp);
|
||||||
}, [setState, synthRef]);
|
window.addEventListener("blur", stopAllVoices);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("keydown", handleKeyDown);
|
||||||
|
window.removeEventListener("keyup", handleKeyUp);
|
||||||
|
window.removeEventListener("blur", stopAllVoices);
|
||||||
|
stopAllVoices();
|
||||||
|
};
|
||||||
|
}, [setState]);
|
||||||
|
|
||||||
const sidebarContent = (
|
const sidebarContent = (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<span className="text-sm font-medium">Audio</span>
|
|
||||||
<Toggle pressed={!isMuted} onPressedChange={toggleMute} variant="outline">
|
|
||||||
{isMuted ? "Unmute" : "Mute"}
|
|
||||||
</Toggle>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<span className="text-sm font-medium">Shape</span>
|
<span className="text-sm font-medium">Shape</span>
|
||||||
<PresetSelector value={state.preset} onChange={(preset) => setState({ ...state, preset })} />
|
<PresetSelector value={state.preset} onChange={(preset) => setState({ ...state, preset })} />
|
||||||
@@ -217,7 +223,7 @@ function Index() {
|
|||||||
grain: state.grain,
|
grain: state.grain,
|
||||||
note,
|
note,
|
||||||
octave: state.octave,
|
octave: state.octave,
|
||||||
synthNodes: synthRef.current,
|
synthNodes: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
setState({ ...state, color });
|
setState({ ...state, color });
|
||||||
|
|||||||
@@ -59,23 +59,84 @@ type PreviewOptions = {
|
|||||||
synthNodes: SynthNodes | null;
|
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_ATTACK = 0.02;
|
||||||
const PREVIEW_DURATION = 0.2;
|
const PREVIEW_DURATION = 0.2;
|
||||||
const PREVIEW_CLEANUP = 0.05;
|
const PREVIEW_CLEANUP = 0.05;
|
||||||
|
const PREVIEW_RELEASE = 0.08;
|
||||||
|
|
||||||
|
type PreviewSharedState = {
|
||||||
|
destination: ReturnType<typeof Tone.getDestination>;
|
||||||
|
wasMuted: boolean;
|
||||||
|
previousGain: number | null;
|
||||||
|
activeCount: number;
|
||||||
|
sessionId: number;
|
||||||
|
startPromise: Promise<void> | 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) {
|
export async function playPreviewSample(options: PreviewOptions) {
|
||||||
const destination = Tone.getDestination();
|
await acquirePreviewShared(options.synthNodes);
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
const previewGain = new Tone.Gain(0);
|
const previewGain = new Tone.Gain(0);
|
||||||
const crossFade = new Tone.CrossFade(mapRoundnessToFade(options.roundness));
|
const crossFade = new Tone.CrossFade(mapRoundnessToFade(options.roundness));
|
||||||
@@ -120,13 +181,74 @@ export async function playPreviewSample(options: PreviewOptions) {
|
|||||||
noise.dispose();
|
noise.dispose();
|
||||||
previewGain.dispose();
|
previewGain.dispose();
|
||||||
|
|
||||||
if (wasMuted) {
|
releasePreviewShared(options.synthNodes);
|
||||||
destination.mute = true;
|
},
|
||||||
}
|
(stopAt - now) * 1000,
|
||||||
|
);
|
||||||
if (previousGain !== null && options.synthNodes) {
|
}
|
||||||
options.synthNodes.gain.gain.value = previousGain;
|
|
||||||
}
|
export async function startPreviewVoice(options: PreviewOptions): Promise<PreviewVoice> {
|
||||||
|
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,
|
(stopAt - now) * 1000,
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user