Learn · Porting

Web Audio to JUCE parameter mapping

Your browser prototype sounds right. Then you port it to JUCE and the knob sits in the wrong place, the filter rings, or an old session loads with the wrong settings. Nearly always, the problem is in how the parameters were moved across, not in the DSP. This guide maps each Web Audio idea to its JUCE 8 counterpart, using the real PULSE tremolo port. Every number here was measured, either in Chrome 154 or against JUCE 8.0.15’s own classes.

PULSE TR-1 tremolo running in Chrome: Rate 5 Hz with the knob pointer left of centre, Depth 50 %, Output 50 %, Sine selected, and a scope showing a 5 Hz sine with a trough at −6.0 dB
The browser version of PULSE in Chrome. Rate reads 5 Hz, and the knob sits left of centre because the browser slider is linear.
The same PULSE panel inside the JUCE 8 standalone window on Linux: Rate 5.0 Hz with the knob pointer right of centre, Depth 50 %, Sine selected, and no Output knob
The same UI in the JUCE 8 standalone, also at 5.0 Hz. Here the knob is right of centre, because the JUCE parameter is skewed, and the Output knob is gone. Both differences are deliberate, and both are explained below.

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.

Measured level of a 440 Hz tone during a gain drag: setValueAtTime per UI frame makes a stair every 16.7 ms while setTargetAtTime is a smooth curve. Bars: setValueAtTime −48.4 dB, setTargetAtTime −100.5 dB, JUCE raw per block −50.2 dB, SmoothedValue Linear −133.1 dB, Multiplicative −119.7 dB
Measured, not simulated. The browser rows are from Chrome 154’s OfflineAudioContext with one slider event per 60 fps frame. The JUCE rows use juce::SmoothedValue from JUCE 8.0.15 with 512-sample blocks at 48 kHz.
Same drag, 1.0 → 0.1Energy 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:

Three measured waveforms of a gain jump from 1.0 to 0.25: instant change −41.4 dB above 2 kHz with a visible corner, setTargetAtTime −90.3 dB, SmoothedValue Linear −79.6 dB
An instant change measures −41.4 dB above 2 kHz whether Web Audio or plain C++ makes it. The linear ramp scores 10.7 dB worse than the exponential one because it stops dead at 20 ms, which leaves a small corner.

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:

RangeCentre of travelQuarter / three-quarter
PULSE rate 0.5–12 Hz, skew 0.53.375 Hz (5 Hz sits at 62.6 %)1 Hz sits at 20.9 %
Browser rate 0.5–12 Hz, linear6.25 Hz (5 Hz sits at 39.1 %)n/a
Filter 20 Hz–20 kHz, linear10,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 − depth in 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:

SettingGain at 1 kHzPeak
Web Audio, Q left at default (1, so 1 dB)1.1221.253 (+1.96 dB) at 777 Hz
Web Audio, Q = 0.7071 pasted from a C++ tutorial1.0851.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.70710.707 (−3.01 dB)none, 1.000
JUCE, Q = 1.01.0001.155 (+1.25 dB) at 708 Hz
JUCE, Q = −3.0103 copied from the browserinvalid: 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 gets ParameterID { "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. shape saves 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:

  1. A relay in the editor: WebSliderRelay rateRelay { "rate" } or WebComboBoxRelay 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.
  2. 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.
  3. 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 shows getScaledValue() 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 ParameterIDRange and curveSmoothingRelayNote
#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, linearone-pole, τ 20 msWebSliderRelay "depth"two AudioParams become one parameter. Gain = (1 − d/2) + (d/2)·LFO per sample
#shape select “sine” / “triangle” → lfo.type{"shape", 1} Choiceindex 0 Sine, 1 Trianglenone (instant, as in browser)WebComboBoxRelay "shape"strings become indices. Triangle phase matched to Web Audio’s
#volume master gain, τ 50 msnot portedn/an/an/athe DAW fader does this
START / STOP, test chord, file loadernot portedn/an/an/ahost feeds the audio. A plugin is always processing
DynamicsCompressor safety stagenot portedn/an/an/aear 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

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.

Want the whole port to read through?

Spec → Scaffold includes the PULSE JUCE 8 project this table comes from, with the processor, the WebView editor and relays, a JUCE-free DSP test, and the pluginval log. It also has three working browser plugins with their prompts and tests, plus the browser-to-JUCE guide that walks through the port. Questions? [email protected]