Skip to content

Design notes — Part XIV — v3.0, Phase 15: Voxo DS 2.0, the Decent Sampler coverage's second generation (steps 87–91: the sustain pedal v2, chorus and phaser, the filter family, the modulator matrix, the instrument slot and the bounce — opened 2026-10-25 at step 87)

Opened 2026-10-25, the day Phase 14 was folded (Part XIII); merged into DECISIONS.md as Part XIV at the phase’s fold. The spec is PROJECT_SPEC.md (v5) §12.2–§12.3 and voxo/COMPAT_REPORT.md (the ledger of what is not played) with TO_PROJECT_SPEC.md §12.7–§12.12 as the phase’s draft (the proposals, corrected by each step as it ships); the Pianobook libraries the author keeps are the acceptance material. THE CORE IS FROZEN (step 78 to step 92): every step here is Voxo’s, libvoxo additive — “DS 2.0” names the format coverage, the library’s version follows its ABI (DECISIONS_10 #1). The instruments: tests/voxo_preset_tests.cpp (the headless bounces against the constant-level fixtures of tests/fixtures/dspresets/), tests/voxo_tests.cpp (the contract: zero allocation in voxo_render), tests/voxo_fuzz.cpp (the mutated presets, now driven through the pedal), the storm from the Release build, the web and lab gates. Numbered entries follow; DECISIONS_14 #n means this part.


#1 — Step 87: the sustain pedal v2 — the half-pedal, re-pedalling, the pedal noises, the CC filter and the choke groups (2026-10-25; Voxo 0.27.0, the ABI unchanged; evidence docs/evidence/step87)

Section titled “#1 — Step 87: the sustain pedal v2 — the half-pedal, re-pedalling, the pedal noises, the CC filter and the choke groups (2026-10-25; Voxo 0.27.0, the ABI unchanged; evidence docs/evidence/step87)”

What the spec asked (ROADMAP_6.md step 87; TO_PROJECT_SPEC §12.7): four mechanisms the format expects — (1) pedal noise and release-sample triggers, “<group trigger="cc"> with onLoCC/onHiCC, evaluated on CC 64 crossing 64 upward or downward”; (2) half-pedalling, a note released at 0 < CC 64 < 127 entering a damped-sustain release whose rate interpolates between the preset’s release and sustainDecay; (3) re-pedalling, the pedal pressed after note-off catching an audible tail at its level and slowing its decay; (4) choke groups — silencedByTags with silencingMode normal / fast — and “group-level seqMode gated by CC 64”. The gates: a headless bounce of pedal-down, half-pedal, re-pedal and a hi-hat choke, each asserted on its envelope; zero audio-thread allocations; the compat report’s sentences for these features retired. What shipped is §12.7 as corrected below. The decisions:

  1. The format’s attributes, read from the format, not from the proposal (2026-10-25). The Decent Sampler developer guide (decentsampler-developers-guide.readthedocs.io, the <groups> element) was fetched before the parser was written. There is NO trigger="cc" — trigger takes attack, release, first, legato, continuous. A CC-triggered sample is written with the CC NUMBER IN THE ATTRIBUTE NAME: onLoCC64="64" onHiCC64="127" on the <sample> (or the group; “if you use onLoCCN, you must also use a corresponding onHiCCN for the same CC number”), the pedal-noise idiom being <group name="Pedal Noise" pitchKeyTrack="0"><sample path="…DW.wav" onLoCC64="64" onHiCC64="127"/><sample path="…UP.wav" onLoCC64="0" onHiCC64="63"/></group> (the maker’s own answer in the Q&A thread “Sustain Pedal Noise”, 2021: no rootNote, loNote or hiNote on such samples). The note-on filter is loCCN/hiCCN (“when a note-on is received the engine decides, from the last received value of that CC, whether the sample plays”). The choke is silencedByTags, silencingMode fast | normal — THE DEFAULT IS FAST (“immediately”) — and silencingDecay (seconds, overriding the mode). sustainDecay appears nowhere: it is VOXO’S OWN attribute, kept under the proposal’s name (the format ignores what it does not know). The proposal’s “group-level seqMode gated by CC 64” has no attribute behind it; the pedal-down and pedal-up groups the author meant are loCC64/hiCC64 on the groups, which is what ships.
  2. What the parser reads (voxo/src/ds_preset.cpp, ds_preset.h): cc_range finds the first onLoCC<N> / loCC<N> attribute on the sample, the group or <groups> (the cascade), composes the hi partner’s name from N and reads it through the same cascade (hi < lo swapped; a missing hi = 127; N 0..127, three digits at most); silencedByTags, silencingMode, silencingDecay, pitchKeyTrack and sustainDecay cascade the same way; a sample’s tags ADD to its group’s. trigger="continuous" is parsed and plays as an attack zone (the always-on sample is not built — recorded). One CC pair of each kind per element: the first found.
  3. The compiled zone and the dictionary (instrument.h/.cpp): a zone carries on_cc / on_lo / on_hi (255 = a note zone), cond_cc / cond_lo / cond_hi (255 = unconditional), key_track, tag_mask and silenced_by — the instrument’s tag DICTIONARY gives every distinct tag a bit, 64 at most (a 65th tag neither silences nor is silenced; a library with more is beyond the format’s drum kits) — and silence_s: silencingDecay as given, else 5 ms for fast (the engine’s release floor: “immediately” clicks), −1 for normal (the group’s release). The instrument lists its CC-triggered zones (cc_zones) and the CCs they watch (cc_trigger_mask), and flags choke when any zone is silenced by anything, so the dispatch pays for the pass only in presets that use it. A CC-triggered zone ANSWERS NO NOTE (it leaves the reach mask too): the Q&A’s samples carry no note range, and the format’s defaults would otherwise fire a thump on every key.
  4. THE PEDAL IS A POSITION, THE RATES INTERPOLATE (voxo.cpp: Channel::pedal, pedal_damp_coef, pedal_set, pedal_apply). CC 64 / 127 is the pedal’s position p on the channel (the master’s reaches the zone, as before). A note-off at p > 0 is held (pedalled), and every note layer takes a second envelope factor, damp, decaying toward silence at the rate r(p) = (1 − p)·r_release + p·r_down — r_release = 4.6 / the group’s release (5 ms at least), r_down = 4.6 / sustainDecay, or 0 without it. So: a switch pedal at 127 on a preset without the attribute gives r = 0 — the hold, BIT FOR BIT the old pedal (damp stays 1.0: x + (−x·0) and gg·1.0 are exact; the two pedal gates of voxo_tests and the MPE-zone gate pass unchanged); a preset that declares sustainDecay="1.0" decays under the full pedal at 4.6 / s (measured: the ratio across a quarter second 0.317, the formula’s 0.317); a continuous pedal half way (64) on the same preset decays at 25.1 / s (measured 0.081 over a tenth of a second, the formula’s 0.081). The pedal moving re-damps every pedalled voice (lifting slowly speeds the decay progressively); p = 0 releases what it held, the release samples firing as before. Known and accepted: a switch pedal that sends 64 for “down” (rare; most send 127) now holds a note at half the release rate instead of forever on presets without sustainDecay; the option of a threshold waits for a library that shows it. A Suzu voice under the pedal takes the same rule through its declared contraction (its T60 stretched by 1/(1 − p); the full pedal a hold, as before); the sine holds.
  5. RE-PEDALLING CATCHES A TAIL WHERE IT IS (pedal_apply, ENV_HOLD). The pedal coming down (p > 0) over a voice that is releasing with a note layer still audible (env × damp > 10⁻³, not a release sample, not a choked layer) moves that layer to ENV_HOLD — the envelope frozen at its value — and the voice back to pedalled; its decay from there is the pedal’s damping, its next lift the normal release. No attack, no jump: the layer’s output continues from the same sample. Measured: a note lifted and caught 30 ms into its 0.1 s release sits at 0.0356 (0.5 × UNIT × e^{−1.38}) and reads 0.0356 half a second later; the lift empties it in the release. A pedal noise, a choked layer and a release sample are never caught; the CC-triggered voices are never pedalled.
  6. THE PEDAL NOISES FIRE ONCE PER CROSSING (cc_fire, Voice::cc_voice). Every CC’s last value is kept per channel (Channel::cc_last[128], the master’s written to the zone); a zone with onLoCCN/onHiCCN fires when CC N’s value ENTERS its range — the previous value outside, the new one inside — as a voice of its own on the event’s channel: no note (note 0xFF, so no note-off finds it), full velocity, the pitch of the zone’s root through pitchKeyTrack (0 the root exactly: freq raised to the tracking’s power, 1 bit for bit the note), never held, never pedalled, its layers playing out through the group’s envelope to the sample’s end; its tags choke what they choke. The format, by the maker’s note on a Pianobook piano, triggers on EVERY message inside the range (“the PEDALDOWN.wav will be triggered repeatedly while holding the pedal down”) — Voxo fires on the crossing, the proposal’s reading, so a continuous pedal does not machine-gun the thump; recorded as a deliberate difference. Measured: the pedal going to 127 fires the 0.25 zone once (one voice, one layer; 127 and 100 again add nothing; it ends), to 0 the 0.125 zone once (30 again adds nothing). The CC filter: note 60’s resonance zone, loCC64="64" hiCC64="127", silent with the pedal up, playing with it down — the pedal-down resonance group of the proposal, through the format’s own mechanism.
  7. THE CHOKE RUNS BEFORE THE NEW LAYERS START (choke_layers). A dispatch (a note-on, a release trigger, a CC trigger) first ORs the tag masks of the zones about to start and silences every playing layer anywhere whose silenced_by meets them — so a group silenced by its own tag (a mono hi-hat) chokes its predecessor and never itself — then starts the new layers. A choked layer goes to its release phase with its coefficient from silence_s (5 ms, silencingDecay’s, or the group’s release) and is marked, so a later note-off changes nothing and a re-pedal does not catch it. Measured on two open hats (looped 0.5) and a closed hat (looped 0.25): both opens 1.0 × UNIT; 20 ms after the closed hat strikes 0.1789 against the formula’s 0.1788 (the fast one gone within its 5 ms, the normal one at e^{−4.6·0.0175/0.3} of its level in its 0.3 s release); after it the closed hat alone, one layer. The union over a round robin’s candidates (one tag set for every position) can over-silence by a tag only some positions carry — a kit that does that is not known; recorded.
  8. The contract and the fuzz. Nothing above allocates in the callback: the CC voices come from the pool, the tag dictionary and the CC-zone list are compiled on the shell thread, the per-channel CC memory is 2 KB of plain bytes. The preset suite’s blocks 19 and 20 arm the counting allocator around the pedal noises, the filter, the catch and the choke (the suite’s own measurement buffer allocated before arming — the first run counted its own std::vectors, eleven and three, and was corrected). voxo_fuzz seeds the two pedal fixtures and their samples and, after every load that succeeds, plays five notes through a random pedal position, a lift, a catch and a panic, asserting finite output: 280 271 loads in 60 s, 108 776 loaded, no crash, no non-finite sample.
  9. The compat report retires no sentence, because none covered these features: a pedal-noise sample was silently a note zone and a choke tag was ignored — the report had no line for an attribute. voxo/COMPAT_REPORT.md gains a paragraph on what the pedal mechanics play since this step; the eleven canonical sentences stand and the test that pins them passes.
  10. Found in the web gate: step 86’s matrix section was never bit for bit, and its record did not show it. The gate read RED on the Duffing’s matrix section — the audio bit-identical, the trace, the inspection and the recent trace within −100.9 dB from the section’s slide event on. Not the pedal (no CC 64 in the script) and not the chaotic modulator (the section without it reads the same): the slide-to-pitch route of step 86 multiplies the frequency by exp2f(m/12), an ulp apart between the libraries, and the cell’s state walks from there (the plain Duffing section stays bit for bit). The class is the TOLERANT list’s (the trumpet’s pow, the sax’s exp2f); kind 4 joins it with the measured figure, the gate GREEN again. Step 86’s evidence file (web_and_lab_gates.txt) held the gate’s tail only, so the line never appeared: an evidence file is the whole output, never a tail — the rule from here.
  11. The tablets, both plugged in, and the spike’s pedal. The shells’ spike storms (ios/Sources/VoxoSpike.swift, android/cpp/sumi_jni.cpp: a test hook, not a feature) now press the pedal on the master channel across their forty pushed notes — down for ten (their note-offs held, the damping), half way for five, up for five (the catch on the next press) — so a device run exercises the pedal’s paths under the storm. On the sampler’s Dan Tranh demo both are clean: the iPad 0 XRuns, render at most 0.105 ms of its 2.67 ms burst; the Tab (the release APK signed with the debug key) 0 XRuns, 0.801 ms of its 4 ms. On Suzu patch 0 the iPad reads 0.210 ms and the Tab 21 proxy XRuns at 4.424 ms — the carried Tab item of DECISIONS_13 #1 (its audio thread some forty times the Mac’s cost; a shell thread-placement item, ADPF/affinity), now with the pedal holding fifteen voices under the storm; AAudio reports 0 device XRuns on both runs. Not this step’s code: the sampler’s run is the one the pedal touches, and it is clean.
  12. What stays with the proposal and what does not. Kept: the four mechanisms, the rate interpolation, the catch, sustainDecay’s name and meaning, the gates. Corrected: the attribute names (above), the choke’s default (fast, the format’s), “a 5 ms linear fade” is a 5 ms one-pole (the engine’s release floor, no click), the “seqMode gated by CC 64” (it is loCC64/hiCC64). Not built, recorded: trigger="continuous", previousNotes / legatoInterval (step 90’s legato), releaseTriggerDecay, tag polyphony limits, a cc voice’s velocity from the CC’s value (full velocity ships), more than one CC pair per element, the switch-pedal-at-64 threshold.

Evidence (docs/evidence/step87/SUMMARY.md): the Release suites (the preset suite’s blocks 16–20, the sampler suite, the synth suite), the Debug ctest, the fuzz minute, the storm on the Dan Tranh library, the web and lab gates, both tablets under the spike with the pedal (tablets.txt, tablets/).


#2 — Step 88: the chorus and the phaser, and the CC map’s chorus targets (2026-10-25; Voxo 0.28.0, additive — VOXO_CTL_CHORUS_MIX / _RATE / _DEPTH; evidence docs/evidence/step88)

Section titled “#2 — Step 88: the chorus and the phaser, and the CC map’s chorus targets (2026-10-25; Voxo 0.28.0, additive — VOXO_CTL_CHORUS_MIX / _RATE / _DEPTH; evidence docs/evidence/step88)”

What the spec asked (ROADMAP_6.md step 88; TO_PROJECT_SPEC §12.8): <effect type="chorus"> at bus and group level — a stereo quadrature-LFO delay line pre-allocated at load (mix, modDepth, modRate, the 90° stereo offset), 4-point Hermite reads; <effect type="phaser"> — 4 or 6 cascaded all-passes under a stereo LFO (mix, modDepth, modRate, feedback, centerFrequency); the CC map’s 1006–1008. The gates: the effects bit-identical across the native builds at a fixed seed; the storm with chorus on every voice at 0 XRuns; the report’s chorus sentence retired. What shipped is §12.8’s chorus and phaser as corrected (the EQ, the gain and the wave shaper of the same subsection are step 89’s by the roadmap). The decisions:

  1. The format, read first (the developer guide’s <effects> element and Appendix B). The chorus takes mix (0..1, default 0.5), modDepth (0..1, 0.2), modRate (Hz, 0..10, 0.2) and nothing else — no delay, feedback or width attribute. The phaser takes those plus centerFrequency (Hz, 0..22000, 400) and feedback (−1..1, 0.7); it has no stages attribute (six it is, the proposal’s “4 or 6” decided by the format’s silence). Group-level effects are PER VOICE in the format (“initialized every time a note is started … two notes, two instances”), and only the filters, the gain and the chorus may sit at the group level — the phaser is the bus’s alone. Every effect takes enabled (default true). The generic binding names — FX_MIX, FX_MOD_DEPTH, FX_MOD_RATE, FX_CENTER_FREQUENCY, FX_FEEDBACK (from the appendix’s summary, the page’s verbatim half ending before the effect rows) — name the effect by its position, so the parser resolves them by the KIND of the effect the binding points at (binding_kind: the instrument’s by position, a group’s by effectIndex); the unambiguous names stay as they were.
  2. The chorus (bus.h/.cpp Chorus): two delay lines at a 7 ms centre swung by ±4 ms × depth (CENTRE_MS, SWING_MS — [ITERATE] by ear against the author’s chorus library), the LFO a magic-circle cell — the leapfrog rotation of suzu.h, exact and drift-free, its (x, y) the quadrature pair, so the right channel is 90° from the left for free and the prewarp 2 sin(π f/fs) is the one libm call, once a block — the fractional read the §12.2 Hermite (hermite4, the same tangents as the sampler’s), the output (1 − mix)·dry + mix·wet with mix 0 leaving the samples untouched. THE BUFFERS ARE FIXED (1536 frames a channel, 16 ms at 96 kHz; the swing never exceeds 11 ms), so an instance sits inside every Voice (12 KB, 786 KB over the pool of 64) and one on the bus: no allocation anywhere, the “pre-allocated at load” of the proposal made structural. The group-level chorus follows the format: a chorused voice’s layers are summed into a scratch block (voice_scratch, 4096 frames; a larger block skips the group chorus, as the plate’s does), its own chorus runs on the sum, the result is added to the bus — one instance a note, the settings of the first group carrying a layer when a stack spans groups (recorded), the live values from the group’s state (its FX_MIX / FX_MOD_DEPTH / FX_MOD_RATE bindings). A fresh note clears its chorus (the fixed seed: the LFO at phase 0); a retrigger keeps its line.
  3. The phaser (Phaser): six first-order all-pass stages a channel, H = (a + z⁻¹)/(1 + a z⁻¹) with a = (tan(πf/fs) − 1)/(tan(πf/fs) + 1), swept f = centerFrequency × 2^(2·depth·lfo) — ±2 octaves at full depth, the coefficient refreshed every sixteen samples (a tan a channel, a staircase 60 dB under the sweep) — the feedback from the chain’s last output into its input, held within ±0.95 (the format’s ±1 is the loop’s margin), the output (1 − mix)·dry + mix·chain. Mix 0 returns before touching the block. At the centre the chain’s phase is −3π exactly, so mix ½ is a true notch there: the saw’s 440 Hz harmonic 132 dB down with the phaser frozen at 440 (gate 24), 220 Hz within 0.6 dB (the chain’s −318.8° there), and at mix 1 the chain alone passes every harmonic at the dry’s level.
  4. Bit-identical across the native builds. The chorus and the phaser are compiled without fused multiply-add contraction (bus.cpp’s #pragma clang fp contract(off) / GCC’s optimize / MSVC’s fp_contract, placed before them so the reverb and the delay keep the compiler’s default), and their only libm calls are the prewarp’s sin and the phaser’s tan / exp2 — the same bits in the Debug (-O0 still contracts: the frontend’s fmuladd intrinsic lowers to fmadd on arm64 whatever the level, found below), the Release and the no-FMA reference builds on one platform. Measured: two fresh instances of the sweep identical in every sample, the output’s FNV hash 66a88a01 in the Debug run — the Release run’s hash is in the evidence beside it.
  5. Found: the Debug build fuses too. The group-chorus gate first asked for two chorused notes to equal the dry pair delayed, bit for bit; they sat a last bit apart in a third of the samples. Not the chorus (one note is bit for bit against the dry delayed, through the bus and through the group): the dry path accumulates each layer with out += s·g·pan, which clang contracts into one fused rounding even at -O0, while the chorused path rounds the product into the scratch and adds afterwards. The gate is a tolerance now (−143.6 dB of the peak measured, the bar −120) and the class is recorded: the sampler’s own mix is FMA-dependent wherever two voices add, which is why the chorus and the phaser carry the pragma and the sampler does not.
  6. The gates (tests/voxo_preset_tests.cpp 21–25 on tests/fixtures/dspresets/fx/: a 220 Hz sine and a 110 Hz saw looped on whole periods). 21: the bus chorus at mix 0 is the dry bit for bit; mix 1 at depth 0 through VOXO_CTL_CHORUS_MIX is the dry delayed by exactly 336 samples at 48 k (7 ms), bit for bit, the first 336 samples silent. 22: full depth at 2 Hz through VOXO_CTL_CHORUS_DEPTH: the left’s pitch swings 209.2–231.0 Hz, 21.8 Hz peak to peak against the formula’s 22.1 (±4 ms × 2π × 2 Hz on 220 Hz); where the left’s swing peaks (+11.0 Hz) the right reads −0.8 Hz — the quadrature. 23: the group chorus on one note is the bus chorus bit for bit; two notes the dry pair delayed within −143.6 dB; nothing allocated. 24: the phaser’s notch (above) and its FX_MIX binding resolved by position. 25: the sweep with feedback 0.7 bounded (peak 0.368 against the dry’s 0.258), finite, two fresh instances identical, the 440 Hz harmonic moving 20.4 dB across 50 ms windows of the period (a quarter-second window averaged the sweep away: 0.4 dB — the window follows the notch’s dwell); a disabled chorus beside it passed over. The fuzz seeds the three effects presets and their waves: 256 925 loads in 60 s, 100 383 loaded, no crash.
  7. The CC map and the shells. VOXO_CTL_CHORUS_MIX 1006 (0..1), _RATE 1007 (0..10 Hz: the CC × 10), _DEPTH 1008 (0..1), VOXO_CTL_COUNT 9; a route turns the bus chorus on, as the reverb’s do. presets/SCHEMA.md names them; the desktop’s target table (app_ctl_target_count + 9, the names), the iPad’s reload range (1000…1008) and the Tab’s name list carry them; the settings reference lists the three. The report’s chorus sentence is retired — VOXO_NOTE_CHORUS stays in the ABI, never raised, its copy empty — and the phaser no longer appears among the unknown effects; features.dspreset gained a bit_crusher so the unknown-effect note keeps a witness; the copy test skips a retired bit.
  8. Not built, recorded: the bus EQ, <effect type="gain"> as a bus stage and <effect type="wave_shaper"> (step 89); the listed order of the bus effects (chorus, phaser, reverb, delay is Voxo’s); a stack spanning groups with different choruses (the first group’s rules); stereo_simulator, pitch_shift, wave_folder, bit_crusher, compressor, stutter (the unknown effects, named in the report); effectTags on a binding; the phaser’s stages (none in the format).

Evidence (docs/evidence/step88/SUMMARY.md): the Release suites, the Debug ctest, the fuzz minute, the storm on the Dan Tranh library with a chorus on every voice and the phaser on the bus, the web and lab gates, the tablets’ compiles.


#3 — Step 89: the filter family, the filter envelope, the bus EQ, the gain and the wave shaper (2026-10-25; Voxo 0.29.0, no new entry point; evidence docs/evidence/step89)

Section titled “#3 — Step 89: the filter family, the filter envelope, the bus EQ, the gain and the wave shaper (2026-10-25; Voxo 0.29.0, no new entry point; evidence docs/evidence/step89)”

What the spec asked (ROADMAP_6.md step 89; TO_PROJECT_SPEC §12.9 and the tail of §12.8): the per-voice SVF’s highpass, bandpass, notch, peak and lowpass_1pl outputs from the same loop; <modulators><envelope> to cutoff and resonance; <effect type="eq"> (low shelf, peaking mid, high shelf), <effect type="gain">, <effect type="wave_shaper">. The gates: each filter’s response against its analytic curve within 0.5 dB; the report’s “other filters” sentence retired. What shipped is §12.9 as corrected (the subsection rewritten as shipped). The decisions:

  1. The format, read first (the developer guide’s <effects> element and the modulators page). The voice filters are lowpass (lowpass_4pl the legacy spelling), lowpass_1pl (no resonance), bandpass, highpass, all with frequency and resonance — and resonance is the Q (0.001..5, default 0.7), not an amount: step 51 read it as clamp(resonance − 0.7, 0, 1) and mapped that to k = 2 − 1.9·amount, so a preset at the default sat at Q 0.5 (−6 dB at the cutoff) instead of 0.7 (−3 dB). Corrected: k = 1/Q, Q held within 0.1..18 (Group::resonance, GroupState::resonance and the FX_FILTER_RESONANCE / FX_FILTER_Q bindings carry the Q itself); every lowpass preset is 3 dB brighter at its cutoff than at 2.0, and the step-51 CC 74 gate’s margin shrank from a factor 2 to 1.49 between the open and the centre readings (the bar lowered to 1.3, the ordering intact). notch takes Q/q (0.01..18, 0.7) at 10 kHz by default; peak adds gain (linear 0.0001..10, or decibels under gainUnit="decibels") — there is no <effect type="eq">: the EQ is peak, low_shelf (200 Hz) and high_shelf (5 kHz) with q, gain, gainUnit. gain takes level in dB by default ("-6dB" allowed; the default −6 dB) or linear under levelUnit="linear". wave_shaper takes drive (1..1000), driveBoost (0..1), outputLevel (0..1, default 0.1) and highQuality (read, ignored). The group level admits the filters, the gain, the chorus and (in the guide’s examples) the wave shaper; the peak, the shelves and the notch are the bus’s. The bindings: FX_FILTER_FREQUENCY, FX_FILTER_Q, FX_FILTER_GAIN, FX_DRIVE, FX_DRIVE_BOOST, FX_OUTPUT_LEVEL; <envelope> carries attack, decay, sustain, release, modAmount, scope, modBehavior (set | add | multiply | modulate).
  2. The family on the one SVF (voxo.cpp render_layer, Group::filter_mode): the Zavalishin loop’s low (v₂), high (v₀ − k·v₁ − v₂), band at unity (k·v₁) and notch (v₀ − k·v₁) outputs selected by the mode, the state and the per-sample coefficient ramp untouched; lowpass_1pl the TPT one-pole on the same state. The voice’s filter is the group’s first enabled lowpass / lowpass_1pl / highpass / bandpass / notch, else the instrument’s first enabled lowpass / highpass / bandpass (an instrument-level notch is the bus EQ’s, where the format puts it); none = a lowpass at 22 kHz so CC 74 moves something, as before. A lowpass at the top with the default Q skips the loop — the dry, bit for bit (the step-88 gates unchanged); the other modes always run. The proposal’s “peak” output is not an SVF output: the peaking EQ is a biquad on the bus (below). Measured (gate 26, the Release run): every mode at 1 kHz at Q 0.7 and 2, and the one-pole, within 0.001 dB of its analytic curve over the saw’s thirty harmonics — the TPT SVF is the exact bilinear transform of the analog prototype prewarped at the cutoff, |H(f)| the prototype’s at Ω = tan(πf/fs)/tan(πfc/fs) — against the 0.5 dB bar; lowpass_4pl at the instrument level is the group’s lowpass bit for bit; enabled="false" the dry bit for bit; the lowpass at Q 0.7 reads −3.01 dB on 990 Hz.
  3. The filter envelope (Layer::fenv / fenv_stage, fenv_advance, fenv_apply): the first <envelope> carrying a binding on the voice filter’s frequency or Q (instrument level: every group; group level: that group; one a group, the first wins) plays PER VOICE whatever its scope (“most envelopes should be per note” — the guide), stepped ONCE A BLOCK: a linear attack, an exponential decay (4.6/T) to the sustain, an exponential release that follows the amplitude envelope’s (a note-off, a choke, a retrigger; the pedal’s catch freezes it with the amplitude). Its value at the block’s start and end is folded into the two cutoff coefficients that already ramp per sample, so the sweep costs the block’s two tangents and nothing per sample; the Q is read at the block’s start. modAmount scales the envelope before the binding’s translation; modBehavior (the binding’s, else the element’s; default set): set replaces the cutoff — under the default timbre’s multiplier, so CC 74 still plays — add adds, multiply scales, modulate is read as multiply (its definition is not on the page; recorded). Measured (gate 31): set 200..8000 Hz, add 500 + 0..6000, multiply 500 × 1..16 at modAmount ½ — the 3300 Hz harmonic over the fundamental −33.8 / −34.5 / −37.5 dB after the attack, 10–12 dB lower in the sustain, 24–35 dB lower in the release; the Q from 5 to 0.7 over half a second: 990 Hz −8.8 dB at the start, −22.1 settled; an envelope on AMP_VOLUME raises no note and plays.
  4. The bus EQ (bus.h/.cpp Biquad, EqChain; Params::eq[8]): every enabled peak, low_shelf, high_shelf and instrument-level notch in its listed order, eight stages at most, RBJ’s cookbook in direct form I (the shelves by Q), the coefficients recomputed every block at the device’s rate (a rate change reallocates nothing; the state clears with the reverb’s). FX_FILTER_FREQUENCY / FX_FILTER_Q / FX_FILTER_GAIN bound to an EQ effect’s position resolve to its stage (Target::Eq*, the curve’s group the stage index, eq_stage_of counting the enabled EQ-kind effects before it as the chain does; the gain binding’s value linear); bound to a voice filter’s position, to the voice filter (target_of takes the effect’s kind and level). Measured (gate 28): the peak (+6 dB at 1 kHz, Q 1, in dB or linear), the shelves (200 Hz +6, 2 kHz −6) and the notch within 0.002 dB of RBJ’s response evaluated on the unit circle (the cookbook transcribed in the test, not bus.cpp’s); a four-effect chain with a disabled stage within 0.000 dB of the three enabled stages’ product; FX_FILTER_GAIN on CC 20 (1..2 linear) lifting 990 Hz by 6.02 dB, RBJ’s 6.02.
  5. The gain and the wave shaper. The bus’s enabled gains fold into one factor applied after the EQ (Params::gain_lin); a group’s into the group’s gain (its zones’). The shaper (shaper_tanh, shape): y = outputLevel · tanh(x · drive · (½ + ½·driveBoost)) through Eigen’s (13,6) rational tanh — within 3e-7 of libm over the range (measured; the (7,6) Padé first tried was 1e-4 off near 4 and the gate caught it), no libm call, the same bits everywhere — evaluated AT THE FORMAT’S SCALE: the engine’s samples sit at 0.40 of full scale, so the shaper divides by that going in and multiplies going out, and a preset’s drive means what it means in Decent Sampler (without this a drive of 1 was a drive of 0.4). On the bus the first enabled one; at the group level per voice through the scratch path the chorus uses (chorused now means chorus or shaper), before the chorus. FX_DRIVE, FX_DRIVE_BOOST, FX_OUTPUT_LEVEL bound live, group or bus by the binding’s level as the chorus’s. The bus order from this step: the wave shaper, the EQ’s stages, the gain, the chorus, the phaser, the reverb, the delay — the tone before the modulations and the space. Measured (gates 29–30): the dry × 0.5011872 within 2e-8 sample for sample for level="-6", "-6dB", the default and a group’s; levelUnit="linear" 0.5 exactly; the bus shaper within 7e-8 of 0.16·tanh(50x); the group shaper on one voice the bus shaper bit for bit; the drive on CC 20 from 1 to 100 squaring the saw (its second harmonic from −6.1 to −46.0 dB); the level on CC 21 at 64 scaling by 0.5039.
  6. Found on the way: the loop’s edges clicked (step 51’s read_frame). The notch gate’s second half-second read 1.2 dB off its first; the cause was not the filter: a loop without a crossfade read its outer interpolation taps from the sample around the loop — the padding’s zeros when the loop spans the whole sample, as the fixtures’ and many libraries’ do — a click once a pass (two frames of a 4-point read against zeros at the end, one at the start; eighteen output samples at a sixfold upsampling). The last two frames before loopEnd and, once the loop has wrapped, the first frame after loopStart read their taps wrapped into the loop (read_frame_wrapped, Layer::looped); the first version indexed i0 − 1 in the unsigned frame index and crashed on the loop’s first frame (the Release suite’s run caught it at block 11, the backtrace named it): the index is signed. Gate 27 asserts the dry saw repeats its 4800-sample period across the loop point within 1e-4 (2.7e-5 measured; 1.6e-2 before). The filter gates measure 110 cycles ending a tenth of a second before the loop point.
  7. The report and the fixtures. The “EQ and other filters” sentence is RETIRED (VOXO_NOTE_OTHER_FILTERS stays in the ABI, never raised, its copy empty; the copy test counts nine sentences now); the modulators sentence reads “Modulators (LFOs, sequences): …” — an <envelope> raises nothing. A group-level peak or shelf (outside the format) is passed over silently. voxo/COMPAT_REPORT.md gains the step’s paragraph and loses the row. The fixtures (tests/fixtures/dspresets/fx/, twenty-seven presets on the looped 110 Hz saw): saw_dry, filter_<mode>_q07 / _q2, filter_1pole, filter_legacy, filter_disabled, eq_peak / _lin / _lowshelf / _highshelf / _notch / _chain, gain_db / _suffix / _linear / _group / _default, shaper / _group / _cc, fenv_set / _add / _mult / _q / _only. The suite’s blocks 26–32; nothing allocates in the callback (gate 32).
  8. Not built, recorded: modulate as its own behaviour; an envelope on the bus EQ or on anything but the voice filter (the amp envelope through <envelope> is step 90’s matrix); scope="global" as one envelope shared by the notes (per voice always); a stack spanning groups with different shapers (the first group’s rules, as the chorus); highQuality on the shaper (no oversampling); a group-level peak or shelf; the listed order of the bus effects across kinds (within the EQ it is kept); the filter’s Q ramped within a block.

Evidence (docs/evidence/step89/SUMMARY.md): the Release suites (the preset suite’s blocks 26–32, the sampler suite, the synth suite), the Debug ctest, the fuzz minute, the storm on the Dan Tranh library with a filter envelope, a lowpass and a shaper on every voice and the EQ, the gain and a shaper on the bus, the web and lab gates, the tablets’ compiles.


#4 — Step 90: the modulator matrix, legato and glide, the compat report pruned (2026-10-26; Voxo 0.30.0, additive — voxo_set_tempo; evidence docs/evidence/step90)

Section titled “#4 — Step 90: the modulator matrix, legato and glide, the compat report pruned (2026-10-26; Voxo 0.30.0, additive — voxo_set_tempo; evidence docs/evidence/step90)”

What the spec asked (ROADMAP_6.md step 90; TO_PROJECT_SPEC §12.10–§12.11): <lfo> (frequency, waveform sine | triangle | square | saw, offset, phase, sync free | tempo from the host’s BPM); multi-target <binding> modulation of amp, pan, pitch, cutoff and effect parameters; group legato / monophonic mode with glideTime and glideMode; the compat report’s table pruned to the genuinely out-of-scope. The gates: the preset suite’s goldens for every modulator; a long voxo_fuzz run; the compat test green. What shipped is §12.10–§12.11 as corrected (both subsections rewritten as shipped). The decisions:

  1. The format, read first (the guide’s modulators page, groups page, true-legato topic, appendix B — the site is decentsampler-developers-guide.readthedocs.io, “developers” plural; the singular host 404s). The <lfo> has shape (sine, square, saw on the attribute’s line; the SHAPE binding’s list adds triangle — all four play), frequency (Hz, or under frequencyFormat="musical_time" “an integer index into the same musical subdivision list used by musical_time controls (for example, 10 = 1/8, 13 = 1/4, 19 = 1, 24 = 5/1)”), modAmount (0..1, default 1), delayTime (seconds, 0), scope (global the default for LFOs; voice “starts a new LFO on each note-on”), modBehavior (set the default; add “the legacy behavior”; multiply; modulate “adds a zero-centered modulation delta around the target parameter’s current/base value, so a neutral envelope value contributes no offset”), enabled. No offset, phase or sync attribute exists — the proposal’s three are not the format’s; the tempo sync is the musical-time format on the host’s tempo. The <envelope> adds delay and hold to step 89’s ADSR, and the curves attackCurve (−100, logarithmic), decayCurve / releaseCurve (100, exponential). The groups page: glideTime (“the glide/portamento time in seconds. A value of 0.0 would mean no portamento”, default 0, inherited), glideMode (“always, legato (… only when transitioning from one note to another), and off”, default legato), polyphony (the innermost wins; 1 = monophonic), trigger attack | release | first (“only be played if no other notes are playing”) | legato (“only be played if some other notes are already playing”) | continuous, previousNotes (a comma list; the legato page writes previousNote), legatoInterval (“only play the sample if the previously triggered note is exactly this semitone distance”). The modulatable parameters: “almost all effect parameters”, the group’s and the global volume, pan and tuning; the appendix’s AMP_VOLUME at the instrument level is marked not modulatable (Voxo modulates it anyway: every group), PAN −100..100, GROUP_TUNING / GLOBAL_TUNING −36..36 semitones, GLIDE_TIME 0..10, GLIDE_MODE.
  2. The matrix (instrument.h Modulator / ModTarget, MOD_MAX 8 of MOD_TARGETS_MAX 8; instrument.cpp’s compile from every enabled <lfo> / <envelope> source; voxo.cpp lfo_raw, lfo_phase, menv_advance, menv_value, mod_read, mod_out, mod_apply): a modulator is a function of time — an LFO of the rendered clock (clock_seconds), or of the voice’s own clock since its note-on under scope=“voice” (Voice::start_s), its delayTime holding the waveform at its start (the sine and the triangle at their centre, the square at its top, the saw at zero); an envelope per voice whatever its scope (Voice::menv[k]: delay, a linear attack, hold, an exponential decay at 4.6/T to the sustain, the release following the voice’s — a note-off, a lift of the pedal, a retrigger — “most envelopes should be per note”, the guide). Both are READ ONCE A BLOCK, at the block’s start and end; every target scales the raw 0..1 by its amount (the element’s modAmount × the binding’s, since the appendix leaves the binding’s undefined and the features fixture carries one), reads its curve (the binding’s translation, 33 points) and folds the result by its behaviour — set, add, multiply, or MODULATE = base + (curve − the curve at the neutral input: the LFO’s 0.5, the envelope’s 0). The targets fold into the ramps the voice already runs: AMP_VOLUME on the pressure gain’s start and end, GROUP_TUNING as semitones on the block’s pitch line (exp2 at the start and the end), PAN once a block onto the zone’s pan (render_layer’s pan_add, the equal-power law), FX_FILTER_FREQUENCY / _Q as step 89’s envelope did (set under the default timbre’s multiplier, so CC 74 still plays), the group chorus’s and shaper’s live values for the scratch path, and — by position — the bus effects’ parameters and the EQ stages’ into a LIVE COPY of voxo_bus::Params taken every block (the CC bindings’ and the shell’s values stay the base; an envelope on the bus has no voice and contributes its idle value: recorded). Step 89’s Group::fenv is gone: the filter envelope is one slot with one target, and gate 31’s figures read exactly as before. Per layer per block the cost is the targets’ curve reads; per sample nothing.
  3. Musical time (musical_whole_notes): the guide prints four anchors and not the list; the reconstruction — a triplet, the plain value and the dotted value for every subdivision from 1/64 to the whole note, then whole bars — matches all four (index 10 = 1/8, 13 = 1/4, 19 = 1, 24 = 5/1) and is recorded as a reconstruction. The LFO’s rate is bpm / (240 × the subdivision in whole notes): a quarter at 120 BPM is 2 Hz. voxo_set_tempo (20..300, else 120) is the additive entry point; the desktop’s sound_apply, the iPad’s SumiCanvas.swift (beside sumi_set_params) and the Tab’s sumi_jni.cpp pass the session’s BPM — the same number the roll layouts scroll by.
  4. Legato and glide (voice_dispatch, GroupState::last_hz / glide_time, Voice::glide_s / glide_t / glide_from, voxo_t::prev_note): the dispatch counts the other held notes (others_held: any other active voice held or pedalled with a note layer) and reads the previously triggered note; trigger="first" and "legato", previousNotes (a 128-bit mask) and legatoInterval are conditions in matches, beside step 87’s CC filter. A group with polyphony N at N sounding voices releases its oldest’s layers as the new one starts (N = 1 the monophonic mode). A group with a glide time (and a mode other than off) starts the new voice at the previous note’s CURRENT frequency — the newest voice sounding in the group, mid-glide or bent as it may be — and walks to its own target along a straight line in semitones over glideTime (the block’s start and end on the line; a bend during the glide moves the line’s end), under legato only while that note is still held or pedalled, under always from the group’s last note-on frequency whatever became of it. The first gliding group a stack touches sets the voice’s glide. The Suzu source keeps its own pitch path (no glide; recorded). Measured (gate 39): 222.2 Hz fifteen milliseconds into a 0.2 s glide from 220, 233.0 at a hundred (the line’s 233.1), 246.9 settled; glideMode off 247.1 at forty milliseconds; always 233.0 after a released note, legato 247.1.
  5. The gates (tests/voxo_preset_tests.cpp 33–41 on tests/fixtures/dspresets/mod/: eighteen presets on the looped 220 Hz sine and 110 Hz saw, the instants chosen where the waveform makes the expectation exact). 33: the tremolo (sine 2 Hz × 0.5..1) peak over dip 1.994; a note started a quarter second into the instrument’s clock reads 1.994 (its peak) 0.375 s later under the global LFO and 0.501 (its dip) under the voice LFO. 34: the square’s second half 0.0, the saw’s level at 0.8 s over 0.2 s 3.958, the triangle’s quarter over its half 1.996. 35: the vibrato 226.4 / 214.0 Hz (226.4 / 213.7); the pan 0.0010 left over right at the top, 956 at the bottom. 36: a quarter note 1.994 at 120 BPM at the 2 Hz instants, 1.998 at 60 BPM at the 1 Hz instants (1.006 at the 2 Hz ones — the same level on a 1 Hz sine); delayTime 0.5 flat within 0.01 dB then 1.994. 37: the cutoff modulated ±200 Hz about 300 on the sine 13.6 dB between the LFO’s top and bottom (the analytic 13.7); the bus peak’s gain under a global LFO 5.99 dB (6). 38: the envelope silent through its delay, 0.1604 in its hold, the sustain 0.499 of it, the release −29.9 dB a tenth of a second after the note-off (the modulator’s e^{−2.3} from 0.5 under the amplitude envelope’s e^{−0.46}: −30.0). 39: the glide (above). 40: the triggers and the previous-note attributes, told by the saw’s second harmonic — both fixtures are pitched to the played note, so their fundamentals coincide (the first version of the gate measured the saw’s fundamental and read the sine). 41: nothing allocated. The compat checks: the features preset’s seven notes, the eight sentences and the three retired bits’ silence.
  6. Found on the way: a saw through a swept filter is not a witness through 50 ms windows. The cutoff gate first ran the saw: the implied cutoff zigzagged ±5 dB between consecutive windows (five and a half saw periods a window: the neighbouring harmonics’ leakage into the measured bin). A sine through the same sweep traced the analytic curve within half a decibel at every 40 ms (the probe’s table in the evidence): the engine was right, the measurement was not; the fixture is the sine.
  7. Found on the way: a scripted region cut anchored 1500 lines away emptied voxo.cpp. The edit replacing step 89’s envelope functions took its end anchor from the next function-level comment it could name — render_layer’s — and deleted the dispatch, the voice start and the pedal between. The file came back from HEAD: the author had committed step 89 minutes before. The rule recorded: the end anchor is the text that immediately follows the region, with an assert on the cut’s size and a backup in the scratchpad before a large scripted edit.
  8. The report (§12.11): the modulators sentence RETIRED (VOXO_NOTE_MODULATORS stays in the ABI, never raised, its copy empty); an unknown modulator element is named with the unknown bindings; the table is the eight sentences that remain true. voxo/COMPAT_REPORT.md gains the step’s paragraph.
  9. Not built, recorded: the LFO’s and the envelope’s curves (attackCurve and its kin: the linear attack and the 4.6/T exponentials stand in); GLIDE_MODE as a binding; the tag-level polyphony (<tag polyphony>); scope="global" as one envelope shared by the notes (per voice always); an envelope on the bus (no voice: its idle value); a stack spanning groups with different glides (the first gliding group rules); the glide on the Suzu source; TRIGGER / MOD_DELAY_TIME / FREQUENCY / SHAPE as modulator-level binding targets; the oscillator’s polyphony.

Evidence (docs/evidence/step90/SUMMARY.md): the Release suites (the preset suite’s blocks 33–41, the sampler suite, the synth suite), the Debug ctest, the five-minute fuzz seeded with the matrix fixtures, the storm on the Dan Tranh library with a per-note vibrato, a tempo-synced tremolo and pan, a slow LFO on the bus reverb and EQ and a legato glide on every group, the web and lab gates, both tablets under the spike.


#5 — Step 91: the instrument slot, the offline bounce and the output device (2026-10-26; Voxo 0.31.0, additive — voxo_set_device / voxo_get_device / voxo_device_count / voxo_device_name, voxo_preset_hash / voxo_preset_name / voxo_preset_file_hash; the preset schema 3; evidence docs/evidence/step91)

Section titled “#5 — Step 91: the instrument slot, the offline bounce and the output device (2026-10-26; Voxo 0.31.0, additive — voxo_set_device / voxo_get_device / voxo_device_count / voxo_device_name, voxo_preset_hash / voxo_preset_name / voxo_preset_file_hash; the preset schema 3; evidence docs/evidence/step91)”

What the spec asked (ROADMAP_6.md step 91; TO_PROJECT_SPEC §12.12; PROJECT_SPEC.md §16’s sound items): a library referenced by name and hash in a preset, never embedded; the offline bounce of a replay (--replay-wav productized on every shell, Voxo deviceless); the desktop’s output-device row (miniaudio’s enumeration, the name in the INI, “System default” first, the fallback when the name is gone — DECISIONS_6 #42). The gates: a preset with a missing library loads with the sentence, not a crash; the bounce byte-identical to the live render at the same seed on the desktop; the device switch without a stop. What shipped is §12.12 as corrected (the subsection rewritten as shipped). The decisions:

  1. The instrument slot is an identity, and the identity is the preset’s XML (sumi_preset_t::library_name / library_hash / library_path / library_present, presets/SCHEMA.md library, the schema 3). A session preset names the library the sound played by its name (the .dspreset’s stem) and the FNV-1a 64 of its preset XML — voxo_ds::Instrument::xml_hash, computed as the file is read, before the samples — with the path where the writer found it as a hint; the samples are not hashed (a gigabyte zip would make every save a minute; the XML names the instrument). voxo_preset_hash / voxo_preset_name answer for the loaded instrument from fields set ON THE SHELL THREAD at load (voxo_t::preset_hash, preset_name): the first version read the current instrument’s model and answered for the PREVIOUS one until the callback’s swap — the preset suite’s block 42 caught it (an empty name, the old hash). voxo_preset_file_hash reads a .dspreset’s or a .dslibrary’s XML off the disk without a sample (read_preset_xml: the first preset inside the archive), so a shell can test a candidate in milliseconds. A recording’s session header carries the slot too (the serializer writes what the session holds): a replay names its instrument.
  2. The resolution is the shell’s, by name then by hash, and the sentence is the result. The desktop (app_resolve_library): the hint path if it exists and hashes the same, then — over the hint’s folder, the current instrument’s folder, ~/Music/midi-sink and ~/Music/Decent Sampler — <name>.dslibrary, <name>.dspreset, <name>/<name>.dspreset, then any .dspreset / .dslibrary (and <dir>/<dir>.dspreset) one level down whose XML hashes the same; app_settings_from_preset sets sound_preset to the find or clears it and sets sound_library_note — “Library ’…’ is not on this computer: the preset plays without it.” — which the Sound section’s report shows while no instrument is loaded. The iPad (SoundController.resolveLibrary) and the Tab (Sound.resolveLibrary, nativeVoxoFileHash) search their Instruments folder the same way (the relative names they already list), load the find, else clear the instrument and show the sentence in the Sound page’s report; the desktop writes the slot through app_sound_library_set as a load lands, the iPad from libraryIdentity, the Tab in the JNI’s session as nativeVoxoLoad returns.
  3. The bounce is the replay library’s walker over a deviceless engine, and every shell binds the same two callbacks (replay/src/sumi_replay_bounce.c, sumi_replay_bounce): the recording’s frames in order, each frame’s bytes through push, then that frame’s worth of samples (dt × rate, the fraction carried) through render in 128-frame calls, a 32-bit float stereo WAV at 48 kHz written as it goes (the sizes patched at the end); the visuals’ events are not applied — the bounce is the sound alone; no engine dependency (the replay library stays pure C beside hostmpe and the presets). Each shell binds push → voxo_push_midi and render → voxo_render on a fresh voxo_create never started — Voxo deviceless, as every headless test renders — configured as its live instance is: the desktop through app_sound_configure (ONE body, factored out of main.cpp’s sound_apply with app_settings_suzu_params: the gain, the dialect, the tempo, the device, the source and the patch, the tuning, the CC map’s bus routes, the instrument loaded synchronously), the iPad through applySource(to:) / applyTuning(to:) and a synchronous voxo_load_preset, the Tab from g.vcfg — the record of what its natives applied (the gain, the dialect, the tempo, the source and the patch index, the patch file, the tuning, the preset). The WAV lands beside the recording: <config>/replays/<name>.wav (the desktop’s Bounce to WAV), Documents/Replays (the iPad’s waveform button, the WAV shareable from the row), the app’s Replays (the Tab’s bounce, wav exporting it). Synchronous — a large library loads first; the tablets run it off the main thread, the desktop in the settings window’s call (recorded as [ITERATE]: a worker for a long bounce).
  4. The gate, as read. The live device’s output cannot be byte-aligned to the frame clock: the device’s callbacks fall where they fall against the frames’ pushes, so two live replays of the same recording differ in which block a note-on lands in. “Byte-identical to the live render at the same seed” is therefore read as: the bounce IS the deterministic rendering of a performance — the same recording, engine configuration, rate and block give the same bytes, on every shell by construction (one walker, one engine, one configuration body), measured on the desktop by the bench’s --bounce-test (two bounces through the host’s own path — app_sound_configure on fresh instances — compared byte for byte) and in the replay suite over a synthetic engine. Measured: the bench’s four-second demo recording (480 frames, 794 events) through the Dan Tranh library bounced twice to 192 000 frames (4.00 s, 597 MIDI events reach the sound, peak 0.036), 1 536 044 bytes identical, the hash 164241f1; the replay suite’s four frames of 1/120 s give 1600 frames in sixteen render calls (three of 128 and a 16, four times), the WAV 44 + 8 × 1600 bytes, two bounces the same, the header a RIFF/WAVE of format 3, two channels, 32 bits. The bench’s --replay-wav now shares the walker (its own loop beside the visuals is gone). Found: a bench gate that renders no frame hangs the lab at exit — sg_shutdown waits on Metal’s frame semaphore that no frame ever signalled — so the two new gates render one frame first (t19_step); the first run sat twelve minutes at 0 % CPU before sample named the wait.
  5. The device. voxo_backend_enumerate lists miniaudio’s playback devices on a fresh context (the none backend answers 0); voxo_backend_start takes want_device and opens the device of that name, else the default (DECISIONS_6 #42’s rule: a name that is gone falls back); voxo_set_device on a running instance stops the backend and starts it again on the new name with the voices, the bus and the preset untouched — a short gap, no stop the shell toggles (voxo_core_device_opened resets the run’s counters and adopts the new device’s rate; the public voxo_stop, which silences the pool, is not called). The desktop’s Sound section gains Output device (“System default” first, the names refreshed every two seconds, a chosen name no longer present shown as such), the INI sound_device, sound_apply → voxo_set_device. Measured (--device-test): four devices on the author’s Mac (the LG display, the speakers, Teams, Zoom); started on the speakers, moved to the LG while running — running, the stats naming it, 188 callbacks in the half second after the move — a name that does not exist falling back to the speakers, and back to the default, 188 callbacks each.
  6. The gates (tests/preset_tests.c: the slot’s round trip with escapes in the name, a bad hash reading as 0 = name only, a file without the slot leaving it absent, the schema 3; tests/replay_tests.c: the bounce over the synthetic engine; tests/voxo_tests.cpp: the device list, the name kept for the next start, no preset = hash 0; tests/voxo_preset_tests.cpp block 42: the identity off the file and off the loaded instrument agreeing for a .dspreset and a .dslibrary, a missing file false; the bench’s --bounce-test and --device-test), the Release suites, the Debug ctest, the fuzz minute, the storm, the web and lab gates (the wasm’s none backend builds the enumeration as 0), both tablets under the spike on 0.31.0.
  7. Not built, recorded: the tablets’ output-device row (the desktop’s alone, as the roadmap says); a worker thread for the desktop’s bounce; a bounce at the device’s rate rather than 48 kHz; the bounce of the visuals (a video); the slot’s search beyond one folder level; a hash over the samples; Voxo Dorean (§12.6).

Evidence (docs/evidence/step91/SUMMARY.md): the Release suites (the five), the Debug ctest, the fuzz minute, the bounce and device gates’ whole outputs, the storm, the web and lab gates, both tablets under the spike.

Steps 87–91 shipped (#1–#5), each Voxo additive (0.27.0 → 0.31.0) and the core frozen throughout; the preset schema 3; TO_PROJECT_SPEC.md §12.7–§12.12 stand as shipped (their fold into PROJECT_SPEC.md is the author’s, at spec v6); the pre-release tag v3.0.0-alpha.5 is the author’s to cut. Folded with Part XV on 2026-10-26, the author’s word.

midi-sink 2.0.0 · AGPL-3.0 · source