Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions projects/web-synth-9x1a/JOURNAL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
15 changes: 14 additions & 1 deletion projects/web-synth-9x1a/src/audio/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ export class AudioCore {
private params: Map<string, AudioParam> = new Map();
private masterGain: GainNode | null = null;
private masterLimiter: DynamicsCompressorNode | null = null;
private masterAnalyser: AnalyserNode | null = null;
private analyserData: Float32Array | null = null;

private constructor() {}

Expand All @@ -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') {
Expand Down
134 changes: 81 additions & 53 deletions projects/web-synth-9x1a/src/audio/nodes/processors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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();
Expand All @@ -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`);
Expand Down
11 changes: 11 additions & 0 deletions projects/web-synth-9x1a/src/components/nodes/DelayNode.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,17 @@ export function DelayNode({ id, data }: { id: string, data: Record<string, any>
>
Randomize
</button>
<button
onClick={() => updateNodeData(id, {
delayTime: 0.5,
feedback: 0.5,
mix: 0.5,
bypass: false
})}
className="mt-1 bg-gray-700 hover:bg-gray-600 text-xs py-1 rounded text-gray-300 transition-colors"
>
Reset to Default
</button>
</div>

<Handle type="target" position={Position.Left} id="in" className="w-3 h-3 bg-purple-500" />
Expand Down
10 changes: 10 additions & 0 deletions projects/web-synth-9x1a/src/components/nodes/DistortionNode.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,16 @@ export function DistortionNode({ id, data }: { id: string, data: Record<string,
className="mt-1"
/>
</label>
<button
onClick={() => updateNodeData(id, {
drive: 50,
mix: 1.0,
bypass: false
})}
className="mt-1 bg-gray-700 hover:bg-gray-600 text-xs py-1 rounded text-gray-300 transition-colors"
>
Reset to Default
</button>
</div>

<Handle type="target" position={Position.Left} id="in" className="w-3 h-3 bg-orange-600" />
Expand Down
11 changes: 11 additions & 0 deletions projects/web-synth-9x1a/src/components/nodes/FilterNode.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,17 @@ export function FilterNode({ id, data }: { id: string, data: Record<string, any>
>
Randomize
</button>
<button
onClick={() => updateNodeData(id, {
frequency: 1000,
Q: 1,
type: 'lowpass',
bypass: false
})}
className="mt-1 bg-gray-700 hover:bg-gray-600 text-xs py-1 rounded text-gray-300 transition-colors"
>
Reset to Default
</button>
</div>

<Handle type="target" position={Position.Left} id="in" className="w-3 h-3 bg-orange-500" />
Expand Down
40 changes: 38 additions & 2 deletions projects/web-synth-9x1a/src/components/nodes/OutputNode.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,49 @@
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<string, any> }) {
const updateNodeData = useStore((state) => state.updateNodeData);
const [isPlaying, setIsPlaying] = useState(false);
const [masterVolume, setMasterVolume] = useState(1.0);
const [isMuted, setIsMuted] = useState(false);
const [isClipping, setIsClipping] = useState(false);
const clipRef = useRef<boolean>(false);
const animFrameRef = useRef<number | null>(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) {
Expand All @@ -31,7 +66,7 @@ export function OutputNode({ id = 'output', data = {} }: { id?: string, data?: R
};

return (
<div className="bg-gray-800 border-2 border-red-500 rounded-md p-4 min-w-[150px] shadow-[0_0_15px_rgba(239,68,68,0.3)]">
<div className="bg-gray-800 border-2 border-red-500 rounded-md p-4 min-w-[150px] shadow-[0_0_15px_rgba(239,68,68,0.3)] relative">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center">
<input
Expand All @@ -52,6 +87,7 @@ export function OutputNode({ id = 'output', data = {} }: { id?: string, data?: R
{isMuted ? <VolumeX size={18} /> : <Volume2 size={18} />}
</button>
</div>
<div className="absolute top-2 right-12 w-3 h-3 rounded-full border border-gray-900" style={{ backgroundColor: isClipping ? '#ff0000' : '#4b0000', boxShadow: isClipping ? '0 0 8px #ff0000' : 'none', transition: 'background-color 0.1s' }} title="Clipping Indicator" />

<button
onClick={togglePlay}
Expand Down
Loading