From 6882786c8280fbc8c56f2fcd987e21073cb31bbe Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Sun, 2 Aug 2026 22:09:34 +0000 Subject: [PATCH] feat(web-synth-9x1a): Implement Phase 11 improvements - Add 12 new tasks to JOURNAL.md under Phase 11. - Add Clipping Indicator to Output Node. - Implement auto-pan LFO sync in Panning Node. - Implement bypass switch for Reverb Node. - Add Reset to Default buttons for Delay, Distortion, Filter, and Reverb nodes. Co-authored-by: kkd16 <112675076+kkd16@users.noreply.github.com> --- projects/web-synth-9x1a/JOURNAL.md | 40 ++++++ projects/web-synth-9x1a/src/audio/core.ts | 15 +- .../src/audio/nodes/processors.ts | 134 +++++++++++------- .../src/components/nodes/DelayNode.tsx | 11 ++ .../src/components/nodes/DistortionNode.tsx | 10 ++ .../src/components/nodes/FilterNode.tsx | 11 ++ .../src/components/nodes/OutputNode.tsx | 40 +++++- .../src/components/nodes/PanningNode.tsx | 35 +++++ .../src/components/nodes/ReverbNode.tsx | 19 +++ projects/web-synth-9x1a/src/store.ts | 34 ++++- 10 files changed, 291 insertions(+), 58 deletions(-) diff --git a/projects/web-synth-9x1a/JOURNAL.md b/projects/web-synth-9x1a/JOURNAL.md index 418c24a5..ad0016bd 100644 --- a/projects/web-synth-9x1a/JOURNAL.md +++ b/projects/web-synth-9x1a/JOURNAL.md @@ -118,3 +118,43 @@ - [x] Add a "Randomize Parameters" button to Delay Node - [x] Add a "Randomize Parameters" button to Filter Node - [x] Add a "Randomize Parameters" button to Oscillator Node + + +## Expansion Phase 10 (New Additions) +- [ ] Implement auto-pan LFO sync in Panning Node +- [ ] Add a Clipping Indicator to Output Node +- [ ] Fix AudioCore to support separate input and output nodes for complex effects +- [ ] Add a visual metronome for the Sequencer Node +- [ ] Add tooltips and visual guides for new users +- [ ] Add stereo widening utility node +- [ ] Add Global Tempo state +- [ ] Implement node collapsing/folding + + +## Expansion Phase 11 (New Additions) +- [x] Add Clipping Indicator to Output Node +- [ ] Add Master Volume clipping protection (soft clip limit) +- [x] Implement auto-pan LFO sync in Panning Node (internal LFO) +- [ ] Add dry/wet control to Chorus Node +- [ ] Add a Drive/Saturation parameter to the Filter Node +- [ ] Add an envelope follower node +- [ ] Add a Wavefolder Node +- [x] Implement bypass switch for Reverb Node +- [x] Add a "Reset to Default" button to Delay Node +- [x] Add a "Reset to Default" button to Distortion Node +- [x] Add a "Reset to Default" button to Filter Node +- [x] Add a "Reset to Default" button to Reverb Node + +## Expansion Phase 11 (New Additions) +- [ ] Add Clipping Indicator to Output Node +- [ ] Add Master Volume clipping protection (soft clip limit) +- [ ] Implement auto-pan LFO sync in Panning Node (internal LFO) +- [ ] Add dry/wet control to Chorus Node +- [ ] Add a Drive/Saturation parameter to the Filter Node +- [ ] Add an envelope follower node +- [ ] Add a Wavefolder Node +- [ ] Implement bypass switch for Reverb Node +- [ ] Add a "Reset to Default" button to Delay Node +- [ ] Add a "Reset to Default" button to Distortion Node +- [ ] Add a "Reset to Default" button to Filter Node +- [ ] Add a "Reset to Default" button to Reverb Node diff --git a/projects/web-synth-9x1a/src/audio/core.ts b/projects/web-synth-9x1a/src/audio/core.ts index 2c23072c..02fac4c0 100644 --- a/projects/web-synth-9x1a/src/audio/core.ts +++ b/projects/web-synth-9x1a/src/audio/core.ts @@ -6,6 +6,8 @@ export class AudioCore { private params: Map = new Map(); private masterGain: GainNode | null = null; private masterLimiter: DynamicsCompressorNode | null = null; + private masterAnalyser: AnalyserNode | null = null; + private analyserData: Float32Array | null = null; private constructor() {} @@ -30,11 +32,22 @@ export class AudioCore { this.masterLimiter.release.value = 0.050; this.masterGain.connect(this.masterLimiter); - this.masterLimiter.connect(this.ctx.destination); + this.masterAnalyser = this.ctx.createAnalyser(); + this.masterLimiter.connect(this.masterAnalyser); + this.masterAnalyser.connect(this.ctx.destination); + this.analyserData = new Float32Array(this.masterAnalyser.fftSize); } return this.ctx; } + public getMasterAnalyserData(): Float32Array | null { + if (this.masterAnalyser && this.analyserData) { + this.masterAnalyser.getFloatTimeDomainData(this.analyserData as any); + return this.analyserData; + } + return null; + } + public async resumeContext() { const ctx = this.getContext(); if (ctx.state === 'suspended') { diff --git a/projects/web-synth-9x1a/src/audio/nodes/processors.ts b/projects/web-synth-9x1a/src/audio/nodes/processors.ts index 4a053d9e..973549d9 100644 --- a/projects/web-synth-9x1a/src/audio/nodes/processors.ts +++ b/projects/web-synth-9x1a/src/audio/nodes/processors.ts @@ -215,6 +215,7 @@ export class ReverbWrapper { public dryNode: GainNode; public wetNode: GainNode; public outputNode: GainNode; + public isBypassed: boolean = false; constructor(id: string) { const ctx = audioCore.getContext(); @@ -235,76 +236,64 @@ export class ReverbWrapper { this.dryNode.connect(this.outputNode); this.wetNode.connect(this.outputNode); - // Generate a simple impulse response for default reverb - this.setDecay(2.0); // 2 seconds decay default - - // Register node (input -> inputNode, output is outputNode conceptually, - // but audioCore's connect assumes a single AudioNode interface. - // To support input and output from this wrapper, we register inputNode - // and rely on our audioCore.connect logic to handle single nodes or params. - // We actually need to expose outputNode to be connected *from*. - // Wait, the current audioCore registerNode stores *one* node per ID which - // is used for both input and output. We can work around this by registering - // the outputNode as the main node, and overriding how connections are made, - // or we can register inputNode and override disconnect. - // Actually, in our current architecture, registerNode stores ONE AudioNode. - // Let's create a custom interface or wrapper logic in audioCore? No, we - // can just register the input node for incoming connections, but we must - // make sure outgoing connections come from the outputNode. - // Wait, audioCore.getNode(id) is used for BOTH source and target. - // So if source.connect is called, we need source to be outputNode. - // If we register outputNode, incoming connections will hit outputNode. - // Let's register inputNode, and we'll need to update audioCore or we can - // hack it by exposing a connect method. - // Wait, if we register inputNode, then incoming edges connect to inputNode. - // If outgoing edges connect from Reverb, audioCore will call source.connect, - // meaning inputNode.connect. This is a flaw in the current AudioCore for - // composite nodes. - // Let's modify audioCore slightly to handle composite nodes, or we can just - // expose outputNode by patching its connect/disconnect methods. - - // Hack for now: Register inputNode, but override its connect/disconnect methods - // to act on the outputNode. + this.generateImpulseResponse(2.0, 2.0); - (this.inputNode as any).connect = (destination: any) => { - return this.outputNode.connect(destination); + // Provide generic connect/disconnect by hooking the input node + (this.inputNode as any).connect = (dest: any, output?: number, input?: number) => { + if (output !== undefined && input !== undefined) return this.outputNode.connect(dest, output, input); + if (output !== undefined) return this.outputNode.connect(dest, output); + return this.outputNode.connect(dest); }; - - (this.inputNode as any).disconnect = (destination?: any) => { - if (destination) { - this.outputNode.disconnect(destination); - } else { - this.outputNode.disconnect(); - } + (this.inputNode as any).disconnect = (dest?: any, output?: number, input?: number) => { + if (dest && output !== undefined && input !== undefined) return this.outputNode.disconnect(dest, output, input); + if (dest && output !== undefined) return this.outputNode.disconnect(dest, output); + if (dest) return this.outputNode.disconnect(dest); + return this.outputNode.disconnect(); }; audioCore.registerNode(id, this.inputNode); } - // Generate a synthetic impulse response + public setMix(mix: number) { + if (this.isBypassed) return; + this.dryNode.gain.setValueAtTime(Math.cos(mix * 0.5 * Math.PI), audioCore.getContext().currentTime); + this.wetNode.gain.setValueAtTime(Math.cos((1.0 - mix) * 0.5 * Math.PI), audioCore.getContext().currentTime); + } + + public setBypass(bypass: boolean) { + this.isBypassed = bypass; + if (bypass) { + this.dryNode.gain.setValueAtTime(1, audioCore.getContext().currentTime); + this.wetNode.gain.setValueAtTime(0, audioCore.getContext().currentTime); + } else { + // Need to restore original mix, which might be lost. + // It's handled by updateNodeData usually sending mix alongside bypass. + // But just in case, we will let store.ts or React handle the state. + } + } + public setDecay(decay: number) { + this.generateImpulseResponse(decay, 2.0); + } + + private generateImpulseResponse(duration: number, decay: number) { const ctx = audioCore.getContext(); - const length = ctx.sampleRate * decay; - const impulse = ctx.createBuffer(2, length, ctx.sampleRate); + const sampleRate = ctx.sampleRate; + const length = sampleRate * duration; + const impulse = ctx.createBuffer(2, length, sampleRate); const left = impulse.getChannelData(0); const right = impulse.getChannelData(1); for (let i = 0; i < length; i++) { - const n = i; // decay envelope - const envelope = Math.pow(1 - n / length, 2.0); // exponential decay - left[i] = (Math.random() * 2 - 1) * envelope; - right[i] = (Math.random() * 2 - 1) * envelope; + const n = i; // decay + left[i] = (Math.random() * 2 - 1) * Math.pow(1 - n / length, decay); + right[i] = (Math.random() * 2 - 1) * Math.pow(1 - n / length, decay); } + // Create new convolver if needed? No, can just set buffer this.convolver.buffer = impulse; } - public setMix(mix: number) { // 0.0 to 1.0 - // Equal power crossfade - this.dryNode.gain.setValueAtTime(Math.cos(mix * 0.5 * Math.PI), audioCore.getContext().currentTime); - this.wetNode.gain.setValueAtTime(Math.cos((1.0 - mix) * 0.5 * Math.PI), audioCore.getContext().currentTime); - } - public destroy(id: string) { this.inputNode.disconnect(); this.convolver.disconnect(); @@ -317,21 +306,60 @@ export class ReverbWrapper { export class PanningWrapper { public node: StereoPannerNode; + public lfo: OscillatorNode; + public lfoGain: GainNode; + private isAutoPan: boolean = false; constructor(id: string) { const ctx = audioCore.getContext(); this.node = ctx.createStereoPanner(); this.node.pan.value = 0; + this.lfo = ctx.createOscillator(); + this.lfo.type = 'sine'; + this.lfo.frequency.value = 1.0; + + this.lfoGain = ctx.createGain(); + this.lfoGain.gain.value = 0.0; + + this.lfo.connect(this.lfoGain); + this.lfoGain.connect(this.node.pan); + + this.lfo.start(); + audioCore.registerNode(id, this.node); audioCore.registerParam(`${id}.pan`, this.node.pan); } public setPan(value: number) { - this.node.pan.setValueAtTime(value, audioCore.getContext().currentTime); + if (!this.isAutoPan) { + this.node.pan.setValueAtTime(value, audioCore.getContext().currentTime); + } + } + + public setAutoPan(enabled: boolean) { + this.isAutoPan = enabled; + if (enabled) { + // lfo gain is non-zero, let it run + } else { + this.lfoGain.gain.setValueAtTime(0, audioCore.getContext().currentTime); + } + } + + public setAutoPanRate(rate: number) { + this.lfo.frequency.setValueAtTime(rate, audioCore.getContext().currentTime); + } + + public setAutoPanDepth(depth: number) { + if (this.isAutoPan) { + this.lfoGain.gain.setValueAtTime(depth, audioCore.getContext().currentTime); + } } public destroy(id: string) { + this.lfo.stop(); + this.lfo.disconnect(); + this.lfoGain.disconnect(); this.node.disconnect(); audioCore.unregisterNode(id); audioCore.unregisterParam(`${id}.pan`); diff --git a/projects/web-synth-9x1a/src/components/nodes/DelayNode.tsx b/projects/web-synth-9x1a/src/components/nodes/DelayNode.tsx index 35198bad..9abc645e 100644 --- a/projects/web-synth-9x1a/src/components/nodes/DelayNode.tsx +++ b/projects/web-synth-9x1a/src/components/nodes/DelayNode.tsx @@ -81,6 +81,17 @@ export function DelayNode({ id, data }: { id: string, data: Record > Randomize + diff --git a/projects/web-synth-9x1a/src/components/nodes/DistortionNode.tsx b/projects/web-synth-9x1a/src/components/nodes/DistortionNode.tsx index 0e640d43..d79f8f72 100644 --- a/projects/web-synth-9x1a/src/components/nodes/DistortionNode.tsx +++ b/projects/web-synth-9x1a/src/components/nodes/DistortionNode.tsx @@ -59,6 +59,16 @@ export function DistortionNode({ id, data }: { id: string, data: Record + diff --git a/projects/web-synth-9x1a/src/components/nodes/FilterNode.tsx b/projects/web-synth-9x1a/src/components/nodes/FilterNode.tsx index fab3350d..73a2f44a 100644 --- a/projects/web-synth-9x1a/src/components/nodes/FilterNode.tsx +++ b/projects/web-synth-9x1a/src/components/nodes/FilterNode.tsx @@ -89,6 +89,17 @@ export function FilterNode({ id, data }: { id: string, data: Record > Randomize + diff --git a/projects/web-synth-9x1a/src/components/nodes/OutputNode.tsx b/projects/web-synth-9x1a/src/components/nodes/OutputNode.tsx index 62bb9c8c..cb4017a4 100644 --- a/projects/web-synth-9x1a/src/components/nodes/OutputNode.tsx +++ b/projects/web-synth-9x1a/src/components/nodes/OutputNode.tsx @@ -1,7 +1,7 @@ import { Handle, Position } from '@xyflow/react'; import { Volume2, VolumeX } from 'lucide-react'; import { audioCore } from '../../audio/core'; -import { useState } from 'react'; +import { useState, useEffect, useRef } from 'react'; import { useStore } from '../../store'; export function OutputNode({ id = 'output', data = {} }: { id?: string, data?: Record }) { @@ -9,6 +9,41 @@ export function OutputNode({ id = 'output', data = {} }: { id?: string, data?: R const [isPlaying, setIsPlaying] = useState(false); const [masterVolume, setMasterVolume] = useState(1.0); const [isMuted, setIsMuted] = useState(false); + const [isClipping, setIsClipping] = useState(false); + const clipRef = useRef(false); + const animFrameRef = useRef(null); + + useEffect(() => { + const updateClipping = () => { + const data = audioCore.getMasterAnalyserData(); + let clipping = false; + if (data) { + for (let i = 0; i < data.length; i++) { + if (Math.abs(data[i]) > 0.99) { + clipping = true; + break; + } + } + } + + if (clipping && !clipRef.current) { + setIsClipping(true); + clipRef.current = true; + } else if (!clipping && clipRef.current) { + // Simple hold time could be added, but frame by frame is fine for simple visual + setIsClipping(false); + clipRef.current = false; + } + + animFrameRef.current = requestAnimationFrame(updateClipping); + }; + + animFrameRef.current = requestAnimationFrame(updateClipping); + + return () => { + if (animFrameRef.current) cancelAnimationFrame(animFrameRef.current); + }; + }, []); const togglePlay = async () => { if (!isPlaying) { @@ -31,7 +66,7 @@ export function OutputNode({ id = 'output', data = {} }: { id?: string, data?: R }; return ( -
+
: }
+
+
@@ -57,6 +66,16 @@ export function ReverbNode({ id, data }: { id: string; data: any }) { className="w-full accent-pink-500" />
+
((set, get) => ({ if (data.mix !== undefined) wrapper.setMix(data.mix); if (data.bypass !== undefined) wrapper.setBypass(data.bypass); } else if (wrapper instanceof PanningWrapper) { + if (data.autoPan !== undefined) { + wrapper.setAutoPan(data.autoPan); + if (!data.autoPan) { + const currentNode = get().nodes.find(n => n.id === id); + if (currentNode && currentNode.data.pan !== undefined) { + wrapper.setPan(currentNode.data.pan as number); + } else { + wrapper.setPan(0); + } + } else { + // ensure depth and rate are applied if just toggled + const currentNode = get().nodes.find(n => n.id === id); + if (currentNode) { + if (currentNode.data.autoPanRate !== undefined) wrapper.setAutoPanRate(currentNode.data.autoPanRate as number); + if (currentNode.data.autoPanDepth !== undefined) wrapper.setAutoPanDepth(currentNode.data.autoPanDepth as number); + } + } + } + if (data.autoPanRate !== undefined) wrapper.setAutoPanRate(data.autoPanRate); + if (data.autoPanDepth !== undefined) wrapper.setAutoPanDepth(data.autoPanDepth); if (data.pan !== undefined) wrapper.setPan(data.pan); } else if (wrapper instanceof FilterWrapper) { if (data.frequency !== undefined) wrapper.setFrequency(data.frequency); @@ -278,8 +298,18 @@ export const useStore = create((set, get) => ({ if (data.mix !== undefined) wrapper.setMix(data.mix); if (data.bypass !== undefined) wrapper.setBypass(data.bypass); } else if (wrapper instanceof ReverbWrapper) { - - if (data.mix !== undefined) wrapper.setMix(data.mix); + if (data.bypass !== undefined) { + wrapper.setBypass(data.bypass); + if (!data.bypass) { + const currentNode = get().nodes.find(n => n.id === id); + if (currentNode && currentNode.data.mix !== undefined) { + wrapper.setMix(currentNode.data.mix as number); + } else { + wrapper.setMix(0.5); + } + } + } + if (data.mix !== undefined && !data.bypass) wrapper.setMix(data.mix); if (data.decay !== undefined) wrapper.setDecay(data.decay); } },