- Overview
- Source layout
- Signal path
- Threads and real-time rules
- Talking to MPC
- Parameters and saved state
SubForce is a single shared object, subforce.so, that MPC loads through its VST2 host, plus a
touchscreen skin generated at build time. The plugin side (VST2 glue, the touchscreen logic, the
preset library, the build, test and bench tooling) is PolyForce's, trimmed to presets only; the
engine is SubForce's own.
flowchart LR
S[surface/surface.py] -->|params, layout, C++ headers| P[plugin/ VST2 glue]
S -->|params.json, layout.conf| G[skin generator] --> K[skin: TUI.json + PNGs]
D[dsp/ engine] --> P
P --> SO[subforce.so]
surface/surface.pyis the single source of the parameter list and the touchscreen pages. It writesparams.json,layout.conf,vst.json,build/param_ids.h(ids, value curves, limits),build/factory_presets.h(the factory presets, embedded) andbuild/skin_style.json(forskin_polish.py), after checking the layout and every preset (keys, ranges, names).dsp/is the engine: no VST, no files, no threads. It renders aPatchfor the keys it is given.plugin/is everything between the engine and MPC: VST2 entry points, MIDI, parameters, the touchscreen logic, the preset library and saved state.
| Path | Contents |
|---|---|
dsp/synth.* |
The engine: keys (priority, trigger, Duo, pedal), glide, the mod busses, Analog (drift, jitter, shapes, per-note and per-unit variation), the control step, the 2x render loop, idling |
dsp/osc.h |
The morphing oscillator, hard sync and the sub, band-limited with polyBLEP / polyBLAMP |
dsp/ladder.h |
The nonlinear transistor ladder (zero-delay feedback, four taps), its four stages in vector lanes |
dsp/simd.h |
Four-float vectors: NEON on the Force, GCC's generic vectors on x86 (the tests run the same arithmetic); NEON reciprocals instead of divisions |
dsp/halfband.h |
The 2x decimator (polyphase IIR halfband); tools/halfband_design.py designs it |
dsp/env.h |
The DAHDSR envelope |
dsp/mod.h |
The busses' sources, destinations, controls, rate modes and synced rates (also the envelopes' sync) |
dsp/fastmath.h |
exp2, log2, tan, tanh(x)/x, softclip, floor, random numbers |
dsp/stages.h |
Stage timers for the profiling build (-DSF_STAGE_TIMING) |
plugin/plugin.cpp |
VST2 glue: MIDI with sample offsets, transport, chunk state, denormal flush, CPU meter |
plugin/surface.* |
The touchscreen side: parameter values, stepping, the preset browser, pushes to MPC |
plugin/patch_map.* |
0..1 ↔ real values, display text, parameters → Patch |
plugin/library.* |
The preset library: scan, categories, favorites, recent |
plugin/presets.* |
Factory and user presets |
plugin/state.* |
The state text shared by projects and preset files |
plugin/paths.* |
Plugin folder, preset roots, data folder, atomic file writes |
plugin/trace.* |
Device diagnostics: every setParameter logged while /tmp/subforce.trace exists (Building) |
plugin/vst2.h |
A hand-written slice of the VST2 ABI (no Steinberg SDK) |
surface/skin_polish.py |
Redraws the knob strips, trigger buttons and stepper arrows after the skin generator |
presets/Factory/ |
Factory presets: NN_Category/NN_Name.sfp, a folder per browser category |
test/ |
The test suite (see Building); host.h is a fake MPC host |
tools/bench.cpp |
sfbench, the CPU bench: dlopen()s the .so like MPC and times every block |
tools/pgo_train.cpp |
The trainer for the profile-guided build (runs under qemu-arm) |
tools/demos.cpp |
Renders the presets to WAV, level-matches them (BS.1770 loudness) |
third_party/mpc-vst-plugins/ |
Vendored skin generator, installer and catalog checker (MIT), with marked local patches |
.github/workflows/build.yml |
CI: the test suites, the glibc 2.31 device build, the package and its catalog check; releases from vX.Y.Z tags |
flowchart LR
O1[Osc 1] --> M[Mixer]
S[Sub] --> M
O2[Osc 2 · sync] --> M
N[Noise · white..pink..dark] --> M
M -->|feedback: overload, 150 Hz-7 kHz| M
M -->|Multidrive gain| L[Ladder 6/12/18/24]
L --> D[Drive stage · asymmetric] --> V[VCA] --> DEC[2x decimator] --> OUT[Out L = R]
The feedback is the original's: the mixer's own output back into its feedback channel, one high-rate sample late, through that channel's overload (a cubic clipper), AC coupled at 150 Hz and band-limited at 7 kHz like an analog stage; its loop gain reaches unity at 85% of the knob.
Everything from the oscillators to the VCA runs at 88.2 kHz (2x), in one loop per sample:
- Every 8 samples (control step): glide, both mod busses, drift; the targets of every control value (the oscillators' phase increments and waves, the cutoff, resonance, drive, mixer levels, VCA gain). Each glides linearly to its target over the next 8 samples, so nothing steps.
- Every sample (44.1 kHz): both envelopes and the cutoff they move (
exp2andtanper sample, so a 1 ms filter EG snaps), four at a time and ahead of the audio for each control run; a bus in Hi range (up to 1 kHz) on pitch, cutoff, wave and volume (the control step only sets how far); the oscillator shapes (once per run while the wave knobs and Hi-range busses hold still). - Twice per sample (88.2 kHz): oscillators, sub and noise, the mixer with the feedback, the ladder, the drive stage, the VCA. Cutoff and VCA move halfway on the first half-step.
- The halfband decimator folds the two samples into one; a 5 Hz DC blocker; the volume.
The oscillators run one high-rate sample late (11 µs): a discontinuity between two samples corrects both, so hard sync and the keyboard reset are exact to the sub-sample.
When the amp EG has finished and the output has died away, the engine stops rendering (the filter EG keeps its release; the control grid keeps time: glides, busses, drift, the oscillators' free-running phase, all summed per control step so block sizes never change them); a new note wakes it, every control value starting at its target.
| Thread | Runs |
|---|---|
| Audio (one of MPC's audio workers; which one changes between calls, instances run concurrently) | processReplacing: MIDI, the engine, the CPU meter, every call back into MPC |
| UI (MPC's UI side) | Parameters, display text, saved state (chunks), the browser, preset loads |
- Nothing on the audio thread allocates, locks or throws.
- Host callbacks happen only from
processReplacing; never fromsetParameteror the dispatcher. - A
try/catchstands between every entry point and MPC: an exception never reaches the host. - The patch reaches the audio thread as a snapshot of every parameter (a seqlock: a preset half
written is never played); the engine gets a new
Patchonly when a value changed. - Denormals are flushed to zero while a block renders.
PolyForce's rules, device-proven on the Force:
- MPC only notices value changes the plugin makes (lit browser tiles, the stepper, snapped steps)
when they are pushed with
audioMasterAutomate, and only re-reads texts afteraudioMasterUpdateDisplay. The plugin pushes fromprocessReplacingonly: at most 48 values per block (round-robin), a display update for changed texts at most every 4 blocks, plus the CPU meter's at most twice a second. - A value MPC sends is recorded as what MPC shows only after the plugin has acted on it, so a preset load in between never has the old value pushed back.
- A Force sends every Q-Link detent, data-wheel click or drag event as the value it last read back
plus its step (sd88me/mpc-vst-plugins
docs/NOTES.md, "Input probe", MPC OS 3.9.1). Steppers measure each event from the plugin's own value and move exactly one item, whatever the delta (Q-Link detent 1/128, data wheel 0.01, touch drag, fast spins); MPC echoing the plugin's own value back is ignored. - A tap on a button toggles the value MPC read back, and a button always reads back 0, so every tap arrives as a 1 with no release in between: each 1 is a press, and the plugin springs the button back to 0 from the next block. (Both rules come from PolyForce's first device run: a rising-edge button and a stepper measuring from MPC's previous value each worked once.)
- MPC sends a second toggle about 0.7 s after a tap on a tile; a revert within 1 s is ignored.
- To see what MPC sends on a device, see diagnostics.
- Parameters may still change during 0.x (the previews); from v0.1 they are append-only:
MPC projects store values by index. Sound parameters (kind
synth) are saved and automatable; the surface's own values (the stepper, tiles, Rand Amount) are not. - Saved state (projects and
.sfppreset files) is the text formatsubforce 1:key=valuelines of real values (Hz, seconds, semitones…) plus, in a project, the preset key and the unit (Analog's tolerances: a project keeps its instances' units; one saved before units is unit 1; no two live instances are the same unit). Ranges can change without remapping saved projects. - Lists: the option lists in
surface.pymust match the engine's enums;static_asserts inplugin/patch_map.cppcheck the counts and that the amp EG and mod bus 2 mirror the filter EG and bus 1 key for key.