note preview on click

This commit is contained in:
2026-01-25 12:09:21 +00:00
parent e2ee7a57e6
commit aa9d9acaa7
3 changed files with 111 additions and 2 deletions

View File

@@ -16,6 +16,7 @@ import {
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";
@@ -95,7 +96,26 @@ function Index() {
</div>
<div className="flex flex-col gap-2">
<span className="text-sm font-medium">Note/Colour</span>
<ColorKeyboard value={state.color} onChange={(color) => setState({ ...state, color })} />
<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: synthRef.current,
});
setState({ ...state, color });
}}
/>
</div>
<div className="flex flex-col gap-2">
<span className="text-sm font-medium">Octave</span>

View File

@@ -52,7 +52,10 @@ export default function ColorKeyboard({
backgroundColor: ["D", "E"].includes(note) ? "#000000" : "#ffffff",
color: ["D", "E"].includes(note) ? "#ffffff" : "#000000",
}
: { backgroundColor: color }
: {
backgroundColor: color,
color: ["D", "E"].includes(note) ? "#000000" : "#ffffff",
}
}
>
{note}

View File

@@ -1,3 +1,6 @@
import { noteToFrequency } from "@/constants/colorScale";
import { mapGrainToNoise, mapPresetToOscType, mapRoundnessToFade, mapSizeToGain } from "@/lib/audio/mapping";
import type { Preset } from "@/types/shape";
import * as Tone from "tone";
export type SynthNodes = {
@@ -45,3 +48,86 @@ export function disposeSynth(nodes: SynthNodes) {
nodes.noise.dispose();
nodes.gain.dispose();
}
type PreviewOptions = {
preset: Preset;
roundness: number;
size: number;
grain: number;
note: string;
octave: number;
synthNodes: SynthNodes | null;
};
const PREVIEW_ATTACK = 0.02;
const PREVIEW_DURATION = 0.2;
const PREVIEW_CLEANUP = 0.05;
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;
}
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));
const stopAt = now + PREVIEW_DURATION + PREVIEW_CLEANUP;
previewGain.gain.setValueAtTime(0, now);
previewGain.gain.linearRampToValueAtTime(peak, now + PREVIEW_ATTACK);
previewGain.gain.linearRampToValueAtTime(0, now + PREVIEW_DURATION);
oscillatorA.start(now);
oscillatorB.start(now);
noise.start(now);
oscillatorA.stop(stopAt);
oscillatorB.stop(stopAt);
noise.stop(stopAt);
window.setTimeout(
() => {
oscillatorA.dispose();
oscillatorB.dispose();
crossFade.dispose();
noise.dispose();
previewGain.dispose();
if (wasMuted) {
destination.mute = true;
}
if (previousGain !== null && options.synthNodes) {
options.synthNodes.gain.gain.value = previousGain;
}
},
(stopAt - now) * 1000,
);
}