sk-engines 0.3.2
Changes in this release
Added
-
Generated Faust engines can now use both decks (
dfilter,voice). The Faust generator (scripts/gen_faust_engine.py) previously emitted only single-deck engines -FaustEngine::set_paramdiscarded the deck index, so deck A and deck B knobs collapsed onto one control set (chorus). A manifestdeck_modekey now selects two ways to use both decks as independent banks.parallel(DoubleMono): two instances of one mono kernel, deck A on the left channel and deck B on the right, each with its own knobs, onFaustEngine<Traits>with a newdecksdimension (decks==1keeps the single-deck path byte-for-byte).series(chain): two different kernels wired A->B with deck A driving stage 1 and deck B stage 2 - covering FX->FX and instrument->FX - on a siblingFaustChainEngine<Traits>template (src/engine/faust/faust_chain.h); the stages get engine-scoped namespaces (fx_<engine>_<stage>) so stage names can't collide. Both advertiseCapDualDeckand need no platform change (the platform already delivers every knob for both decks;set_paramjust honours the deck arg). Shipped with two demos:dfilter(parallel - a resonant low-pass per channel, ~83% SRAM_EXEC) andvoice(series - a drone oscillator into a resonant filter, the instrument->FX case that also exercises the 0-input chain path, ~84%). Both filters share a control layout: PITCH = cutoff, POS = reso, SIZE = drive, SOS = mix (onvoicethis is deck B; deck A is the oscillator's Pitch=freq / Size=shape / SOS=level). Each is.dsp(s) + a JSON manifest with no hand-written C++; the generator emits the wrapper, wires the build (Makefile/engine_select.h/CMakeLists.txtmarker blocks), registers the kernel(s), and emits the control-diagram spec (deck B drawn as adeckBoverride for the series case). Host-tested for the property a single-deck engine structurally cannot have: per-deck/per-stage independence (make -C host test-dfilter test-voice). Still hand-written (out of scope): a runtime route/mode switch, per-deck voice allocation, >2 decks. Also hardens the Faust kernel recipe (make faust-kernels) to force UTF-8 (PYTHONUTF8=1) so cyfaust can emit kernels whose library metadata contains non-ASCII characters (e.g.oscillators.lib). (src/engine/faust/{faust_fx.h,faust_chain.h},scripts/gen_faust_engine.py,src/engine/dfilter/,src/engine/voice/,host/test_dfilter.cpp,host/test_voice.cpp,Makefile,docs/dev/engine-gen.md§9,docs/engine-types/faust.md,docs/engines/{dfilter,voice}.md) -
Dual virtual RadioMusic (
radioengine).make ENGINE=radiobuilds a streaming sample player modelled on Music Thing Modular's RadioMusic, as a dual instrument: each deck (A/B) is an independent virtual radio browsing one shared SD library of numbered banks (/radio/0../radio/15), blended by the crossfader and placed by the routing switch. Its signature is the free-running virtual playhead - a per-deck monotonic frame clock advances every block, and tuning to a station seeks its file to(clock + START) mod lengthbefore streaming forward, so every station sounds as though it kept broadcasting while you were tuned elsewhere (leave one and return and it has advanced). Per deck: PITCH = station tuning (+ V/oct CV), POS = start offset (+ CV), SIZE = 0.5–2x varispeed, ENV = inter-station static (a filtered-noise "tuning hiss" crossfaded in on a switch), MIX = volume, Alt+PITCH = bank (held ring-dot selector), the mix fader blends A/B, the routing switch sets the stereo topology, and the Play pad / gate-in RESET a stopped deck. It reuses thetapeengine'sSPK_USE_STREAMstreaming stack (lock-free per-deck SDRAM ring + main-loop FatFs pump) and adds a headerless raw-16-bit codec (RawStreamReader), a directory scan, and the playhead - so stations stream off SD with no in-RAM length cap (the original recordings run ~25 min / 130 MB; only a tiny per-station name+length index sits in RAM). Stations are signed 16-bit mono PCM at 48 kHz: a headerless.raw(the original RadioMusic format, length = filesize/2) assumes 48 kHz unless an optional one-line/radio/rate.txtrebases the resampler, so an unconverted 44.1 kHz card plays at correct pitch.scripts/convert_radio_audio.pyconverts or whole-card-mirrors a stock RadioMusic card (resampling 44.1→48 kHz, preserving names).capabilities() = CapOwnDisplay | CapDualDeck | CapAux. Host-tested (make -C host test-radio: the playhead modulo, station/bank quantization, varispeed, static, the codec) and verified on hardware; ~83% SRAM_EXEC. (src/engine/radio/radio_engine.{h,cpp},src/memory/raw_stream.h,src/engine/istreamdeck.h,src/hw/stream_deck.{h,cpp},src/engine/engine_select.h,host/test_radio.cpp,scripts/convert_radio_audio.py,Makefile,CMakeLists.txt,Makefile.cmake,docs/engines/radio.md,docs/dev/radio-impl.md) -
radioSD robustness: macOS metadata filter + anti-stutter guards. Reading an original RadioMusic card copied via Finder, the bank scan indexed the hidden AppleDouble companions (._NAME.raw, ~4 KB of metadata macOS writes next to everyNAME.rawon a FAT card) as bogus tiny "stations" that looped a sliver of garbage - a fast stutter, interleaved one-for-one with the real stations as you tuned.scan_banknow drops them three ways: the FAT hidden/system attribute (AM_HID|AM_SYS, which macOS sets on dot-prefixed files), a leading-dot name, and a 32 KB minimum size; the converter skips dotfiles on the source side too. Three further guards stop any re-open-driven stutter, since a (re)open flushes and re-seeds the per-deck ring: station-select hysteresis (a deadband so CV/pot noise near a station boundary can't chatter the target), settle-every-prepare (a target oscillating on a boundary never settles, so the engine holds the last clean station instead of re-opening ~5x/s there), and a debounced RESET that is a no-op on a live deck (a repeated/floating-gate trigger can't flush a playing stream). Verified on hardware (the stutter was field-reported and is resolved). (src/hw/stream_deck.cpp,src/engine/radio/radio_engine.{h,cpp},scripts/convert_radio_audio.py) -
radio: 16-bit.wavstations with per-file sample rate. Banks can now mix headerless.rawand 16-bit-mono PCM.wavstations. A WAV body is int16 like a.raw, so they share one streaming path:RawStreamReader::begin_wavparses the header (a chunk walk validatingfmt=1/16-bit/mono) for the body offset+length and the file's own sample rate, so a 44.1 kHz.wavplays at correct pitch with norate.txt(the rate ratio is per-deck - the WAV header rate, or the globalrate.txtfor raw). Indexing a.wavcosts onef_open+header-parse per file at bank load (a.rawstays a puref_stat); playback cost is identical. The converter gains--format wav. Host-tested (header parse / seek / rewind / rate + rejection of stereo/8-bit/float, and the engine dispatching to the WAV path with the header rate rebasing playback). (src/memory/raw_stream.h,src/hw/stream_deck.{h,cpp},src/engine/istreamdeck.h,src/engine/radio/radio_engine.{h,cpp},scripts/convert_radio_audio.py,host/test_radio.cpp) -
make diagrams: render the d2 diagram sources to SVG. The d2 sources moved todocs/diagrams/(sources) with their rendered SVGs indocs/media/(output), and a newmake diagramstarget renders eachdocs/diagrams/*.d2→docs/media/<name>.svgvia thed2CLI - an incremental pattern rule (a diagram re-renders only when its source changes), with a clear error ifd2isn't installed. (Makefile,docs/diagrams/,docs/media/)
Changed
-
Reverb: legible display + a third all-Faust voice (Greyhole), gen~ gigaverb removed. The reverb was visually dead - the rings lit only with the live output level, so the panel went dark whenever the input was quiet and showed nothing about the patch.
render()now draws each ring as three layers at once: the active algorithm as the ring colour (plate blue / hall violet / greyhole teal) over a dim full-ring baseline (never fully dark, readable in silence); the DECAY knob (PITCH) as the bright arc length (turning it gives instant feedback); and the output level as the arc's brightness on top, so the ring pulses with signal and visibly fades out as the tail decays. The three mode L/C/R LEDs - which sit at the Reel/Slice/Drift switch - now show the selected algorithm in its colour (so the 3rd position reads as a distinct teal) instead of the route (route stays legible from the two independent per-deck rings in DoubleMono). The third voice is now Greyhole (Faust'sre.greyhole- a modulated diffusion network with a feedback pitch-shifter, a lush evolving/ambient reverb) instead of the gen~ gigaverb, which sounded poor and pulled the gen~ runtime: the 3-position switch maps cleanly to plate/hall/greyhole, all-Faust, with no gen~ dependency.greyhole.dspowns its dry/wet crossfade and maps all six knob roles (Feedback/Size/Damp/Diffusion/ModDepth/Mix) to a real control. Greyhole is the heaviest voice, so the three-voice build overflowsSRAM_EXECat-O2(~106%) and the reverb branch now builds atOPT = -Os(as reso does), fitting at 185.5 KB / 97.4% with ~5 KB headroom; its per-block device CPU is unmeasured (verify on hardware). The standaloneENGINE=gigaverbengine is unaffected (only removed from the reverb); the obsoletetest_reverb_gigais dropped, andtest_reverbnow exercises all three voices (its response sums both channels so a stereo-only effect like Greyhole's modulation registers). A lighter Freeverb alternative is kept infreeverb.dspfor an easy swap-back. (src/engine/reverb/{reverb_engine.{h,cpp},greyhole.dsp,freeverb.dsp},Makefile,host/Makefile,host/test_reverb.cpp,docs/engines/reverb.md,docs/dev/reverb-impl.md) -
Renamed the engine-generator make targets for backend symmetry.
make engine-gen(the Faust new-engine generator) is nowmake faust-engine, and the Faust kernel-compile targetmake faust-genis nowmake faust-kernels(it compiles.dsp->faust_kernel_*.h; it never produced engines, and one engine can have several kernels - so the old name misled). A single-enginemake gen-engine GEN_EXPORT=<export-dir>:<name>was added as the gen~ analogue offaust-engine(a thin wrapper overscripts/gen_engine.py; the per-spec batch form staysgen-engines). Both old names (engine-gen,faust-gen) are kept as deprecated aliases so nothing breaks. The layering now reads cleanly:faust-engine/gen-enginegenerate one engine from its source;faust-kernelscompiles all Faust kernels;gen-enginesregenerates all gen~ engines. Generated<name>_engine.h+ kernel header comments, both generator scripts, and the docs were updated. (Makefile,scripts/gen_faust_engine.py,scripts/gen_engine.py,docs/) -
Shared Faust zone-capture:
reverbandtaperefactored ontofaust_capture.h. The Faust "bind a kernel's sliders to platform knobs" machinery (CaptureUI : UI+ theBind/Rolestructs) existed in three copies - a full one inreverb_engine.cpp, a trimmed one intape/tapefx.h, and the sharedsrc/engine/faust/faust_capture.hwritten for the generated-Faust path (thechorusdemo). Both hand-written engines now use the shared header and their private copies are deleted, so one zone-capture implementation serves the generator,reverb, andtape(andshuttle, viatapefx.h).reverbkeeps itsKnobenum, which converts to the sharedBind's genericintrole index; the sharedCaptureUIcarries the optional "Level" output-gain pin reverb relies on. The slider-default capture the generator needs (to seed its boot param cache) is gated behind aCaptureUI<bool CaptureDef>template flag, so the hand-written engines instantiateCaptureUI<false>and pay nothing for it - reverb'sSRAM_EXECrises just +288 B (175408 -> 175696 B, 92.3%). Behaviour-identical and re-verified host-green (reverb/tape FX/shuttlesuites), clean target builds ofreverb/tape/shuttle/gigaverb/chorus, and confirmed on hardware (reverbboth algorithms +tapeflashed and verified working).tape's six FX sliders are all native[0,1], so the sharedRole::setlinear-map (0 + v*1 = v) writes the normalized value straight through exactly as the oldCapdid. (src/engine/faust/faust_capture.h,src/engine/reverb/reverb_engine.cpp,src/engine/tape/tapefx.h,src/engine/faust/faust_fx.h,docs/dev/engine-gen.md,docs/engine-types/faust.md) -
Engine docs split into user-facing + developer-facing. Each engine's documentation now separates audiences:
docs/engines/<name>.mdkeeps the user-facing reference (what the engine is + signature behavior, the control map, routing/display, SD-card prep + file formats, the build/flash commands) anddocs/dev/<name>-impl.mdholds the developer-facing material (architecture and internal data flow, the file-by-file map, implementation constants and risks/watch-items, bring-up status, bug writeups, and the roadmap / "what's left"). Each engine doc links to its impl notes near the top. Applied across all engines, roughly halving the engine docs (e.g. tape 254→113, reso 169→65, edrums 245→141, radio 220→131); the existingtape-impl.md/shuttle-impl.mdabsorbed the moved content (deduped).passthrough(a minimal reference example) is left whole. The convention is recorded indocs/engines/README.md, which also gains the missingradiorow. (docs/engines/*.md,docs/dev/*-impl.md)