acdsp — Aesthetic Computer DSP #
Last updated: 2026-05-23
physically-modeled compressors and an EQ knowledge graph, written in portable C99 so the same code runs in three places:
- the pop/ mastering pipeline (native CLI),
- the AC browser runtime (wasm — pieces can call comp/EQ at
paint), - AC Native (static lib, when fedac/native grows audio out).
zero deps beyond libm. one library, one source of truth. no ffmpeg
acompressor approximations.
why c #
ffmpeg's acompressor is a generic feed-forward VCA — clean, but program-
independent envelopes and no harmonic generation. that's the audible
"tell" of the current pop/ masters and the reason every lane keeps tacking
a brightening pass on the end. real compressors are physical: a JFET's
asymmetric transfer curve, a vactrol's two-time-constant release, a tube's
soft-clip curve. modeling them needs sample-accurate state, not filter
strings.
c was picked because:
- AC Native is c — same toolchain, same calling conventions
- c compiles trivially to wasm via emcc, no glue
- the resulting
acdsp.wasmcan later become an AC piece API (comp.la2a({ peak: 8 })inside apaint) - the lowest-common-denominator dependency story (libm only)
build #
cd pop/dsp/c
make # → ./acdsp (native CLI)
make lib # → ./libacdsp.a (link target for AC Native)
make wasm # → ./acdsp.js + ./acdsp.wasm (needs emcc on PATH)
make test # synthesizes a sine, runs it through the chain
status #
| stage | done | next |
|---|---|---|
| native CLI | ✅ | |
| wasm build target | ✅ stubbed | wire into AC runtime piece API |
| AC Native static lib | ✅ via make lib |
actual integration when audio out lands |
| biquad EQ (RBJ cookbook) | ✅ | |
| EQ knowledge graph | ✅ | codegen eq_graph.c from JSON |
| 1176 (FET) | ✅ | |
| LA-2A (vactrol) | next — two-time-constant release | |
| Fairchild 670 (vari-mu) | tube transfer curve + prog. recovery | |
| SSL G-bus (VCA + SC HPF) | real sidechain HPF | |
| loudnorm (ITU-R BS.1770) | so ffmpeg shrinks to demux/encode | |
| true-peak limiter | with 4× oversample |
what's physical about the 1176 #
src/comp_1176.c. not a knob-mapped acompressor:
- stereo-linked peak detector (max of |L|, |R|) feeds a single-pole
envelope smoother. coefficient is
exp(-1 / (τ · sr)). attack when the static curve wants more reduction; release otherwise. - soft-knee static curve (RBJ-style quadratic), threshold internal at −10 dBFS with a 6 dB knee. the user gets the unit's input/output knobs; drive the input harder to push more signal into the curve.
- FET asymmetric saturation with positive DC bias for 2nd-harmonic
dominance — the audible FET fingerprint. drive depth scales with the
instantaneous gain-reduction amount: 0 dB GR ⇒ 30% of base depth,
18 dB GR ⇒ 100%. the FET conducts harder ⇒ more nonlinearity. this is
the part
acompressorcannot do. - A12 line-amp tanh on the way out — gentle symmetric warmth from the post-FET stage.
- attack/release knobs map to the unit's actual time sweep:
- attack 1 → 800 μs, attack 7 → 20 μs (exponential)
- release 1 → 1100 ms, release 7 → 50 ms (exponential)
- ratios 4 / 8 / 12 / 20.
references for the physics: Eichas/Möller JFET papers (2014–2017), Zölzer's DAFX second edition (envelope/curve formulations), Airwindows 1176-ish sources (MIT, for sanity-checking sat curves). GPL sources were not consulted — LSP/Calf/Steinberg are off limits.
eq knowledge graph #
pop/dsp/eq-graph.json is the source of truth. each entry resolves to a
single biquad (peak / shelf / HP / LP), carrying a frequency center, Q,
default gain, a why: note, and optional conflicts: / pairs_with:
hints. the C lib mirrors the table in src/eq_graph.c (kept in sync by
hand for now; a codegen step is in the TODO).
| intent | kind | freq | default gain | use when |
|---|---|---|---|---|
sub |
highpass | 30 Hz | — | strip subsonic; FM + small speakers can't reproduce |
rumble |
highpass | 60 Hz | — | aggressive low cut for material with no sub content |
warmth |
peak | 150 Hz | +1.5 | body without mud; lifts chest of vocal / weight of synths |
mud |
peak | 250 Hz | −2 | low-mid cloudiness from stacked synths/sub-pad |
chest |
peak | 220 Hz | +2 | vocal body restore (conflicts with mud) |
boxy |
peak | 500 Hz | −2 | cardboard-tube honk on vocals / overdriven mids |
nasal |
peak | 900 Hz | −2 | pinched / nasal on vocals + close-mic'd dialogue |
honk |
peak | 1200 Hz | −2 | upper-mid bark on saws, leads, bright synths |
harshness |
peak | 3000 Hz | −2 | edginess on cymbals, guitar tops, bright vocals |
presence |
peak | 4000 Hz | +2 | forward / in-the-room; lifts vocals through a busy mix |
bite |
peak | 5500 Hz | +1.5 | snap on transients — beater click, snare crack, pick |
sibilance |
peak | 7500 Hz | −3 | s/t harshness on vocals (narrow Q) |
air |
highshelf | 11 kHz | +1.8 | open top — silk on vocals, sheen on cymbals |
sparkle |
highshelf | 14 kHz | +1.5 | glass top; mastering-only |
tilt-bright |
highshelf | 8 kHz | +1.2 | broad tilt for dark mixes |
tilt-warm |
lowshelf | 200 Hz | +1 | broad warm tilt for thin material |
adding an intent: edit the JSON, mirror the row in src/eq_graph.c,
make.
chain spec syntax #
stages are colon-separated; multiple stages are space-separated.
1176:ratio=4:in=-12:out=+6:attack=4:release=4:iron=0.5
eq:<intent>[=gain_db] # knowledge-graph lookup
eq:presence=+2
eq:air # uses graph's gain_default
eq:peak:f=3500:q=1.2:g=+2 # raw biquad
eq:hp:f=30 # raw highpass
eq:highshelf:f=11000:g=+1.8
1176 keys:
| key | meaning | range / default |
|---|---|---|
ratio |
1176 ratio (4 / 8 / 12 / 20) | 4 |
in |
input-knob trim in dB (drives signal toward curve) | 0 |
out |
output-knob make-up in dB | 0 |
attack |
knob units 1..7 (1=800 μs, 7=20 μs, exponential) | 4 |
release |
knob units 1..7 (1=1100 ms, 7=50 ms, exponential) | 4 |
iron |
FET + A12 nonlinearity depth | 0..2, default 0.5 |
attack-us |
direct override in μs (overrides attack knob) |
— |
release-ms |
direct override in ms (overrides release knob) |
— |
threshold |
internal threshold override (rarely needed) | −10 dBFS |
knee |
soft-knee width in dB | 6 |
cli #
acdsp in.wav out.wav --chain "stage1 stage2 ..."
[--bits 16|24|32] # PCM int width (default 24)
[--float] # 32-bit IEEE float output
[--quiet]
example — a danceable master-bus pass:
./acdsp scratch-mix.wav master.wav \
--chain "eq:sub 1176:ratio=4:in=-3:out=+3:iron=0.5 eq:mud=-1.5 eq:presence=+2 eq:air=+1.5" \
--float
input WAV formats accepted: PCM 8 / 16 / 24 / 32 int, IEEE float 32, mono or stereo, any sample rate.
node api (pop/lib/master.mjs) #
import {
acdspAvailable, // → boolean: is the native binary built?
eqGraph, // → parsed eq-graph.json (with why / conflicts notes)
compressor, // → "1176:..." spec fragment
eq, // → "eq:..." spec fragment
chain, // → joins fragments with spaces
processWav, // → runs the CLI, returns { ok, ms, stderr }
presets, // → curated starting chains
} from "pop/lib/master.mjs";
// build a chain by knobs:
const spec = chain(
eq("sub"),
compressor("1176", { ratio: 4, in: -3, out: +3, attack: 5, release: 3, iron: 0.5 }),
eq("mud", -1.5),
eq("presence", +2),
eq("air", +1.5),
);
// or pick a preset and tune it:
const spec2 = presets.danceMasterBus({ in_db: -3, out_db: +3, iron: 0.7 });
// run it:
const r = processWav("in.wav", "out.wav", spec, { float: true });
if (!r.ok) throw new Error(r.stderr);
presets currently shipped:
danceMasterBus({ in_db, out_db, iron })— sub HP → 1176 4:1 → mud cut → presence → airvocalLead({ in_db, out_db, iron })— rumble HP → 1176 8:1 → nasal cut → presence → de-ess → airdrumSlam({ in_db, out_db, iron })— 1176 20:1 ("all-buttons" feel) → bite → air
using it from a lane #
pop/dance/bin/bake.mjs is the reference pattern. opt in with --acdsp:
node pop/dance/bin/bake.mjs --acdsp
without the flag, the original ffmpeg-only chain runs (zero risk for
shipping lanes). with the flag, the C 1176 + EQ chain runs before
ffmpeg's loudnorm/limit/fade, between the post-FX stage and the final
encode. existing lanes can be refit one file at a time — see
pop/dance/bin/bake.mjs:60-95 for the diff.
how to add a new compressor #
- write
src/comp_<name>.{h,c}— a single struct +init+update+process(buf, n_frames, channels). studycomp_1176.cas the reference shape. - add a parser branch in
src/chain.c(mirrorparse_1176). - add the source file to
SRC_LIBin the Makefile. make— done. it'll be callable via--chain "<name>:..."in the CLI and via the node shim.
if you're modeling a new unit, the priority is the physical fingerprint
(envelope shape, harmonic content, sidechain HPF if any, program
dependency), not the knob count. an LA-2A with attack/release knobs is
not an LA-2A.
roadmap #
- LA-2A: vactrol envelope (LED → LDR resistance lag), fast follower + slow leak release model. shorter code than 1176; ~60 lines.
- Fairchild 670: vari-mu tube — gain reduction is the tube's transfer curve, so GR amount controls the harmonic flavor. 6-position recovery switch with multi-stage release.
- SSL G-bus: clean VCA + real sidechain Linkwitz-Riley HPF + the 4K manual's auto-release curve.
- loudnorm: ITU-R BS.1770-4 + EBU R128 integrated LUFS + LRA + TP.
- true-peak limiter: 4× oversampled with ISR detection.
once loudnorm + limiter land, ffmpeg shrinks to a demux/encode wrapper and the DSP is entirely portable.