

Compare the two Rate knobs. Both read 5 Hz, yet the browser knob sits at 39 % of its travel and the JUCE knob at 63 %. Nothing is broken. It is the first mapping decision you meet when porting, and if you don’t make it on purpose, an AI assistant will make it for you without saying so.
AudioParam vs AudioProcessorValueTreeState
In Web Audio, a parameter is an AudioParam on a node, such as lfo.frequency or vca.gain. You set it from JavaScript, and its range is whatever your <input type="range"> allows. Nothing outside the page knows it exists.
In a plugin, the host owns your parameters. It automates them, saves them in the session, shows them in its generic editor and calls them from a different thread. In JUCE 8 the usual home for them is AudioProcessorValueTreeState (APVTS): one layout that declares every parameter’s ID, name, range, default and display text. PULSE’s layout, unchanged from PluginProcessor.cpp:
layout.add (std::make_unique<AudioParameterFloat> (ParameterID { "rate", 1 }, "Rate",
NormalisableRange<float> (0.5f, 12.0f, 0.01f, 0.5f), 5.0f,
AudioParameterFloatAttributes().withLabel ("Hz")));
layout.add (std::make_unique<AudioParameterFloat> (ParameterID { "depth", 1 }, "Depth",
NormalisableRange<float> (0.0f, 1.0f, 0.001f), 0.5f, /* shows "50 %" */ ...));
layout.add (std::make_unique<AudioParameterChoice> (ParameterID { "shape", 1 }, "Shape",
StringArray { "Sine", "Triangle" }, 0));
Two rules carry over from the browser. The audio code reads the values and never owns them: PULSE uses getRawParameterValue() to get an std::atomic<float>* once in the constructor, then calls load() once per block. And one node property becomes one parameter only when the user can actually turn it. In the browser, Depth drives two AudioParams (vca.gain = 1 − depth/2 and lfoAmount.gain = depth/2). In JUCE that’s one depth parameter, and the formula moves into the DSP.
Smoothing: setTargetAtTime vs SmoothedValue
In the browser, PULSE glides every change with param.setTargetAtTime(v, ctx.currentTime, 0.02). That’s an exponential approach with a 20 ms time constant: it covers 63 % of the distance in 20 ms and 95 % in 60 ms. In JUCE the host may send a new value once per block, and a raw value applied per block steps the gain every 512 samples. To see how much each approach matters, we dragged the gain of a 440 Hz tone from 1.0 to 0.1 over 300 ms, the same drag five ways, and measured the energy above 2 kHz. A clean sine has essentially none, so anything there is zipper.

| Same drag, 1.0 → 0.1 | Energy above 2 kHz |
|---|---|
Web Audio, setValueAtTime per UI frame | −48.4 dB (audible zipper) |
Web Audio, setTargetAtTime(v, t, 0.02) | −100.5 dB |
| JUCE, raw value once per 512-sample block | −50.2 dB (audible zipper) |
JUCE, SmoothedValue<float> Linear, 20 ms | −133.1 dB |
JUCE, SmoothedValue Multiplicative, 20 ms | −119.7 dB |
A single jump tells the same story, with one twist. We stepped the gain from 1.0 to 0.25 at 0.5 s:

So SmoothedValue Linear doesn’t sound the same as setTargetAtTime. A linear ramp reaches the target in exactly the ramp time, but an exponential smoother is only 63 % of the way there after one time constant. If the port has to feel identical, copy the curve rather than the name. PULSE does that in Source/dsp/Tremolo.h with a one-pole smoother whose coefficient is 1 − exp(−1 / (0.02 × sampleRate)), which is setTargetAtTime’s own formula. Use SmoothedValue Linear when you want a ramp of known length. Use Multiplicative for values that live on a log scale, like frequency, but it can’t start or end at 0.
Prompt that gets this right: “Port the smoothing exactly. The browser uses setTargetAtTime with a 0.02 s time constant on rate and depth. In C++, use a per-sample one-pole smoother with coefficient 1 − exp(−1/(0.02·sr)), call reset() in prepareToPlay, and don’t use SmoothedValue for these two.”
Skew and NormalisableRange for frequency and time
A browser range input is linear unless you write the curve yourself. A JUCE parameter stores a 0–1 normalised value, and its NormalisableRange decides how that maps to real units. The host, the automation lane and PULSE’s WebView knob all work in that normalised value. So the skew changes the feel of every control at once: knob, automation curve and generic editor alike. Measured with JUCE 8.0.15:
| Range | Centre of travel | Quarter / three-quarter |
|---|---|---|
| PULSE rate 0.5–12 Hz, skew 0.5 | 3.375 Hz (5 Hz sits at 62.6 %) | 1 Hz sits at 20.9 % |
| Browser rate 0.5–12 Hz, linear | 6.25 Hz (5 Hz sits at 39.1 %) | n/a |
| Filter 20 Hz–20 kHz, linear | 10,010 Hz (1 kHz at 4.9 %) | everything musical in the first fifth |
Filter 20 Hz–20 kHz, setSkewForCentre(1000) | 1,000 Hz (skew 0.2299) | 68 Hz / 5,737 Hz |
Delay time 1–2000 ms, setSkewForCentre(250) | 250 ms (skew 0.3328) | 32 ms / 843 ms |
For a frequency, set the centre to where the sweet spot is, not to the arithmetic middle. If the browser version felt good, measure where your hand sits and match it. A step needs a second look too. The browser rate moves in 0.1 Hz steps and the JUCE one in 0.01, so automation in the plugin is smoother than the browser slider ever was. That’s fine, but it means the two won’t land on the same values.
dB vs linear gain
Web Audio’s GainNode.gain is a linear multiplier, and so is the buffer maths in processBlock. Musicians think in dB, and hosts show what you give them. The useful facts, from juce::Decibels:
- Gain 0.5 is −6.02 dB, 0.25 is −12.04 dB and 0.1 is −20.00 dB. −6 dB back to linear is 0.5012, not 0.5.
Decibels::gainToDecibels(0.0f)returns −100, not −∞, because −100 dB is the default floor. If your UI shows “−100 dB”, that’s silence.- PULSE’s scope shows the trough as
1 − depthin dB, so 50 % depth reads −6.0 dB in both screenshots above. The parameter itself stays linear 0–1 and the dB is display only.
For an output or makeup gain control, make the parameter dB (for example −60 to +6, labelled “dB”), and convert once per block with Decibels::decibelsToGain. Then smooth the linear result. A range that is already in dB needs no skew. Don’t ask the host to automate a linear gain from 0 to 1: almost all the useful travel is crammed into the top fifth.
A real mapping decision from PULSE: the browser’s Output knob (master gain, smoothed with a 0.05 s time constant) was not ported. In a DAW the channel fader does that job, and a duplicate output control is one more thing that can save at the wrong level.
The Q-in-dB trap
For lowpass and highpass BiquadFilterNodes, the Web Audio spec defines Q in dB. For bandpass, peaking, notch and allpass it’s a plain linear Q. JUCE’s makeLowPass (sampleRate, frequency, Q) always takes a linear Q. So the same number means two different filters. A 1 kHz lowpass, measured:
| Setting | Gain at 1 kHz | Peak |
|---|---|---|
| Web Audio, Q left at default (1, so 1 dB) | 1.122 | 1.253 (+1.96 dB) at 777 Hz |
| Web Audio, Q = 0.7071 pasted from a C++ tutorial | 1.085 | 1.222 (+1.74 dB) at 759 Hz |
| Web Audio, Q = −3.0103 (flat) | 0.707 (−3.01 dB) | none, 1.000 |
JUCE IIRCoefficients::makeLowPass, Q = 0.7071 | 0.707 (−3.01 dB) | none, 1.000 |
| JUCE, Q = 1.0 | 1.000 | 1.155 (+1.25 dB) at 708 Hz |
| JUCE, Q = −3.0103 copied from the browser | invalid: Q must be positive (asserts in debug) | |
Convert with Q_linear = 10^(Q_dB / 20): −3.0103 dB is 0.7071, and the browser’s default 1 dB is 1.122. Inside a feedback loop, a peak of 1.25 is the difference between echoes that fade and echoes that grow. The feedback delay guide measures exactly that. If your parameter is called “Resonance”, store it in whatever unit you show the user, and convert in one helper used by both builds.
Parameter IDs and versioning for saved sessions
In the browser, id="rate" is just a DOM hook. In a plugin, the parameter ID is a promise. The host saves automation and session state against it, and PULSE’s getStateInformation writes the whole APVTS tree (type STATE) as XML. Rename rate to lfoRate in v1.1 and every saved session silently loses that value, and every automation lane goes dead.
- Freeze IDs at the first release. Names (“Rate”) can change, but IDs can’t. PULSE uses the same lowercase IDs in the browser HTML, the WebView relays and the APVTS layout, so there’s one name for each control everywhere.
- The number in
ParameterID { "rate", 1 }is the version hint. Set it to the plugin version where the parameter first appeared, and never lower it. A parameter added in 2.0 getsParameterID { "tone", 2 }, while the old ones stay at 1. AU hosts use it to keep parameter order stable across updates. - Append, don’t reorder. Add new parameters at the end of the layout, and give each a default that sounds like the old version (for a new “tone” filter, wide open).
- Keep choice indices stable.
shapesaves 0 for Sine and 1 for Triangle. Add “Square” as index 2, never in between. - Test the round trip. Save a session with the old build, load it in the new one, and check every value. pluginval’s “Plugin state restoration” test (PULSE passes at strictness 10) checks a single build, not v1 against v2.
The WebView relay
PULSE keeps its HTML panel inside the plugin through JUCE 8’s WebBrowserComponent, so the knobs no longer talk to Web Audio. Each control goes through three pieces:
- A relay in the editor:
WebSliderRelay rateRelay { "rate" }orWebComboBoxRelay shapeRelay { "shape" }. The relays must be declared before the browser member, because the browser’s options read them during construction. Get the order wrong and the page loads with no parameters. - An attachment that ties the relay to the APVTS parameter:
WebSliderParameterAttachment rateAttachment (*apvts.getParameter ("rate"), rateRelay, nullptr). Automation, undo and saved state now move the web knob too. - The page side, in
ui/main.js:Juce.getSliderState("rate"), where the string must match the relay name exactly. The page works in normalised values (getNormalisedValue/setNormalisedValue) and showsgetScaledValue()as the label. That’s why the JUCE knob follows the skew and the browser one doesn’t.
Two details are easy to miss. First, call sliderDragStarted() on pointerdown and sliderDragEnded() on pointerup. Without them, a single drag records dozens of undo steps and automation writes stutter. Second, the editor needs .withNativeIntegrationEnabled(), or window.__JUCE__ doesn’t exist, and the page renders but nothing moves.
The PULSE mapping table
Every control in the browser build, and what it became in vibepatch-tremolo-juce (JUCE 8.0.15, VST3 + Standalone, pluginval strictness 10 pass):
| Browser (audio.js / index.html) | JUCE ParameterID | Range and curve | Smoothing | Relay | Note |
|---|---|---|---|---|---|
#rate 0.5–12, step 0.1 → lfo.frequency | {"rate", 1} Float, “Hz” | 0.5–12, step 0.01, skew 0.5 (centre 3.375 Hz) | one-pole, τ 20 ms (same as setTargetAtTime 0.02) | WebSliderRelay "rate" | knob angle changes: 5 Hz at 63 % instead of 39 % |
#depth 0–1, step 0.01 → vca.gain + lfoAmount.gain | {"depth", 1} Float, shows “50 %” | 0–1, step 0.001, linear | one-pole, τ 20 ms | WebSliderRelay "depth" | two AudioParams become one parameter. Gain = (1 − d/2) + (d/2)·LFO per sample |
#shape select “sine” / “triangle” → lfo.type | {"shape", 1} Choice | index 0 Sine, 1 Triangle | none (instant, as in browser) | WebComboBoxRelay "shape" | strings become indices. Triangle phase matched to Web Audio’s |
#volume master gain, τ 50 ms | not ported | n/a | n/a | n/a | the DAW fader does this |
| START / STOP, test chord, file loader | not ported | n/a | n/a | n/a | host feeds the audio. A plugin is always processing |
| DynamicsCompressor safety stage | not ported | n/a | n/a | n/a | ear protection for the browser test page only. Never ship a hidden limiter |
The last three rows matter as much as the first three. Half of porting is deciding what not to bring across. If you ask an AI assistant to “port everything”, you will get a Volume parameter and a hidden compressor.
Checklist before you build
- One list of IDs, used by the HTML, the relays and the APVTS layout.
- A range, step, skew and unit for each parameter, chosen on purpose.
- Smoothing curves copied, not renamed: exponential for exponential.
- Every filter Q converted from dB to linear wherever a lowpass or highpass is involved.
- A save-and-reload test against the previous build before every release.
For the rest of the port (CMake, formats, pluginval), see from browser prototype to VST/AU and the JUCE learning path. If something clicks or zips after the port, the troubleshooting guide lists the usual suspects.
Keep going
- Learn hub, the full path map
- Build a feedback delay with AI
- From browser prototype to VST/AU
- A JUCE learning path
How we measured: the browser figures come from Chrome 154’s OfflineAudioContext and BiquadFilterNode.getFrequencyResponse at 48 kHz. The JUCE figures come from a console build against JUCE 8.0.15 (juce_core + juce_audio_basics: SmoothedValue, NormalisableRange, Decibels, IIRCoefficients). Zipper and click are scored as energy above 2 kHz relative to a 440 Hz tone, with a Hann window.