Skip to content

Latest commit

 

History

History
156 lines (133 loc) · 9.61 KB

File metadata and controls

156 lines (133 loc) · 9.61 KB

Architecture


Overview

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]
Loading
  • surface/surface.py is the single source of the parameter list and the touchscreen pages. It writes params.json, layout.conf, vst.json, build/param_ids.h (ids, value curves, limits), build/factory_presets.h (the factory presets, embedded) and build/skin_style.json (for skin_polish.py), after checking the layout and every preset (keys, ranges, names).
  • dsp/ is the engine: no VST, no files, no threads. It renders a Patch for 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.

Source layout

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

Signal path

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]
Loading

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:

  1. 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.
  2. Every sample (44.1 kHz): both envelopes and the cutoff they move (exp2 and tan per 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).
  3. 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.
  4. 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.

Threads and real-time rules

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 from setParameter or the dispatcher.
  • A try/catch stands 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 Patch only when a value changed.
  • Denormals are flushed to zero while a block renders.

Talking to MPC

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 after audioMasterUpdateDisplay. The plugin pushes from processReplacing only: 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 and saved state

  • 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 .sfp preset files) is the text format subforce 1: key=value lines 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.py must match the engine's enums; static_asserts in plugin/patch_map.cpp check the counts and that the amp EG and mod bus 2 mirror the filter EG and bus 1 key for key.