Before you change anything
Most debugging time is lost to guessing. Two habits cut it dramatically:
- Open the console. In Chrome or Edge press
Cmd+Option+J(Mac) orCtrl+Shift+J(Windows). A red error with a line number is worth more than any description of the symptom. - Save a known-good version. Commit with git, or just duplicate the folder, before every new feature. When something breaks you can compare against the last version that worked instead of asking the AI to “fix it” blind.
Quick map: symptom to likely cause
| You hear or see | Most likely cause | Jump to |
|---|---|---|
| Nothing at all | Suspended AudioContext, or a node never connected to the output | No sound |
| A click when a note starts or stops | Gain jumping instantly instead of ramping | Clicks and pops |
| Crunchy stepping while you turn a knob | Parameter set directly with no smoothing | Zipper noise |
| A delay that keeps getting louder | Feedback at or above 1.0 | Runaway feedback |
| Fine with one note, distorted with chords | Voices summing past full scale | Clipping chords |
| Gets slower or glitchier the longer you play | Old voices never stopped or disconnected | Voices piling up |
| Sample loads in one setup, not another | Opening the page from file:// | Samples won’t load |
| Native build missing from the DAW | Wrong folder, no rescan, or failed validation | DAW can’t see it |
No sound at all
Browsers won’t let a page make sound until the user interacts with it, so an AudioContext created on page load starts suspended. Nothing errors; it just stays silent. Resume it from a click:
const ctx = new AudioContext();
document.querySelector('#start').addEventListener('click', async () => {
if (ctx.state === 'suspended') await ctx.resume();
// start your synth here
});
If the context is running and it’s still silent, something isn’t connected to ctx.destination. Log ctx.state, then trace the chain from the source to the output. A gain node left at 0, or a filter with its cutoff at 20 Hz, is just as silent as a missing connection.
Prompt that works: “The page is silent. Log ctx.state after I press Start, and list every node between the oscillator and ctx.destination with its current gain or frequency.”
Clicks and pops on notes
A click is a sudden jump in the waveform. The usual culprit is code that sets gain.value = 1 to start a note and calls osc.stop() while it’s still loud. Give every note a short envelope, even a 5 ms one:
function noteOn(gainNode, t = ctx.currentTime) {
gainNode.gain.cancelScheduledValues(t);
gainNode.gain.setValueAtTime(0, t);
gainNode.gain.linearRampToValueAtTime(0.8, t + 0.01); // 10 ms attack
}
function noteOff(gainNode, osc, release = 0.3, t = ctx.currentTime) {
gainNode.gain.cancelScheduledValues(t);
gainNode.gain.setValueAtTime(gainNode.gain.value, t);
gainNode.gain.setTargetAtTime(0, t, release / 4);
osc.stop(t + release + 0.05); // stop after it's silent
}
Zipper noise when you move a knob
Assigning filter.frequency.value = x on every slider event makes the value jump in steps you can hear. Glide to the new value instead:
slider.addEventListener('input', e => {
filter.frequency.setTargetAtTime(+e.target.value, ctx.currentTime, 0.02);
});
The same problem exists in native plugins. In JUCE the fix is juce::SmoothedValue: set a target when the parameter changes and read the smoothed value per sample in processBlock.
Runaway feedback in a delay
If the feedback gain can reach 1.0, each repeat is as loud as the last; above it, they grow until everything distorts. Cap it, and put a filter in the loop so repeats darken and fade naturally:
const delay = ctx.createDelay(2.0);
const fb = ctx.createGain();
const tone = ctx.createBiquadFilter();
tone.type = 'lowpass';
tone.frequency.value = 4000;
tone.Q.value = -3.01; // flat: the default Q adds a resonant peak that makes echoes grow
input.connect(delay);
delay.connect(tone).connect(fb).connect(delay); // the loop
delay.connect(wet);
function setFeedback(v) {
fb.gain.setTargetAtTime(Math.min(v, 0.92), ctx.currentTime, 0.02);
}
Write the cap into your scope document so the AI doesn’t “helpfully” raise the slider range to 1.2 later.
Fine with one note, distorted with chords
Four voices at gain 0.8 sum to 3.2, well past the 1.0 the output can handle. Give voices a lower level, route them through one master gain, and add a gentle limiter as a safety net rather than a fix:
const master = ctx.createGain();
master.gain.value = 0.5;
const safety = ctx.createDynamicsCompressor();
safety.threshold.value = -6;
safety.ratio.value = 12;
master.connect(safety).connect(ctx.destination);
// each voice: voiceGain peak around 0.25, connected to master
Voices piling up and CPU climbing
Web Audio oscillators and buffer sources are one-shot: once stopped they can’t restart, and every new note needs a new one. That’s fine, but code that never stops old voices, or rebuilds the whole graph on each slider move, leaks nodes until the audio starts crackling. Stop each voice after its release, disconnect it in onended, and keep long-lived nodes (filters, delays, master gain) created once:
osc.onended = () => { osc.disconnect(); voiceGain.disconnect(); };
Samples won’t load
Double-clicking index.html opens it from file://, and browsers block fetch() of local files from there, so decodeAudioData never gets the sample. Serve the folder instead:
cd your-project
python3 -m http.server 8000
# then open http://localhost:8000
If it still fails, check the console for a 404 (wrong path or filename case) or a decode error (an unusual format; try a 16-bit or 24-bit WAV).
Your native build doesn’t show up in the DAW
Once you move from a browser prototype to a native build (see browser prototype to VST/AU), the problems change. Check these in order:
- Right folder. On macOS, Audio Units go in
~/Library/Audio/Plug-Ins/Componentsand VST3 bundles in~/Library/Audio/Plug-Ins/VST3. On Windows, VST3 goes inC:\Program Files\Common Files\VST3. - Rescan. Most DAWs cache their plugin list. Use the DAW’s rescan option or restart it. Some hosts need their VST3 system folders switched on in preferences first.
- Validation. Logic only lists Audio Units that pass Apple’s
auvalcheck. Runauval -ato find your plugin’s codes, thenauval -v aufx Abcd Manuwith your own codes, and read the first failure. The free pluginval tool tests VST3 and AU builds the same way. - Gatekeeper. A build you made yourself on the same Mac normally loads. An unsigned build copied from elsewhere may be quarantined; removing the quarantine flag with
xattr -dr com.apple.quarantineon the bundle is only appropriate for builds you trust.
When the AI keeps breaking working code
This is a process problem more than a code problem, and it has a process fix:
- One change per prompt. “Add reverb and presets and MIDI” makes it impossible to tell which change broke the sound.
- Paste the real error. Copy the console message verbatim, with its line number, plus what you expected and what you heard.
- Point back to the scope. Keep a
SCOPE.mdin the project and say “re-read SCOPE.md before editing.” See scope your first plugin. - Roll back early. If two fix attempts haven’t worked, return to the last good version and ask for the feature again in smaller steps.
A good bug report to your assistant: “After adding the filter LFO, notes click on release. Expected: smooth 300 ms fade. Console is clean. Here is noteOff(): … Fix only the release; don’t change the LFO.”
Keep going
- Learn hub, the full path map
- Scope your first plugin
- From browser prototype to VST/AU
- A JUCE learning path
Related on the blog: Common mistakes · Web Audio API for musicians · Prompt chain for audio plugins