Skip to content

sk-engines 0.3.2

Choose a tag to compare

@shakfu shakfu released this 15 Jun 18:15

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_param discarded the deck index, so deck A and deck B knobs collapsed onto one control set (chorus). A manifest deck_mode key 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, on FaustEngine<Traits> with a new decks dimension (decks==1 keeps 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 sibling FaustChainEngine<Traits> template (src/engine/faust/faust_chain.h); the stages get engine-scoped namespaces (fx_<engine>_<stage>) so stage names can't collide. Both advertise CapDualDeck and need no platform change (the platform already delivers every knob for both decks; set_param just honours the deck arg). Shipped with two demos: dfilter (parallel - a resonant low-pass per channel, ~83% SRAM_EXEC) and voice (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 (on voice this 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.txt marker blocks), registers the kernel(s), and emits the control-diagram spec (deck B drawn as a deckB override 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 (radio engine). make ENGINE=radio builds 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 length before 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 the tape engine's SPK_USE_STREAM streaming 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.txt rebases the resampler, so an unconverted 44.1 kHz card plays at correct pitch. scripts/convert_radio_audio.py converts 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)

  • radio SD 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 every NAME.raw on 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_bank now 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 .wav stations with per-file sample rate. Banks can now mix headerless .raw and 16-bit-mono PCM .wav stations. A WAV body is int16 like a .raw, so they share one streaming path: RawStreamReader::begin_wav parses the header (a chunk walk validating fmt=1/16-bit/mono) for the body offset+length and the file's own sample rate, so a 44.1 kHz .wav plays at correct pitch with no rate.txt (the rate ratio is per-deck - the WAV header rate, or the global rate.txt for raw). Indexing a .wav costs one f_open+header-parse per file at bank load (a .raw stays a pure f_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 to docs/diagrams/ (sources) with their rendered SVGs in docs/media/ (output), and a new make diagrams target renders each docs/diagrams/*.d2docs/media/<name>.svg via the d2 CLI - an incremental pattern rule (a diagram re-renders only when its source changes), with a clear error if d2 isn'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's re.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.dsp owns 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 overflows SRAM_EXEC at -O2 (~106%) and the reverb branch now builds at OPT = -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 standalone ENGINE=gigaverb engine is unaffected (only removed from the reverb); the obsolete test_reverb_giga is dropped, and test_reverb now 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 in freeverb.dsp for 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 now make faust-engine, and the Faust kernel-compile target make faust-gen is now make 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-engine make gen-engine GEN_EXPORT=<export-dir>:<name> was added as the gen~ analogue of faust-engine (a thin wrapper over scripts/gen_engine.py; the per-spec batch form stays gen-engines). Both old names (engine-gen, faust-gen) are kept as deprecated aliases so nothing breaks. The layering now reads cleanly: faust-engine / gen-engine generate one engine from its source; faust-kernels compiles all Faust kernels; gen-engines regenerates 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: reverb and tape refactored onto faust_capture.h. The Faust "bind a kernel's sliders to platform knobs" machinery (CaptureUI : UI + the Bind/Role structs) existed in three copies - a full one in reverb_engine.cpp, a trimmed one in tape/tapefx.h, and the shared src/engine/faust/faust_capture.h written for the generated-Faust path (the chorus demo). Both hand-written engines now use the shared header and their private copies are deleted, so one zone-capture implementation serves the generator, reverb, and tape (and shuttle, via tapefx.h). reverb keeps its Knob enum, which converts to the shared Bind's generic int role index; the shared CaptureUI carries 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 a CaptureUI<bool CaptureDef> template flag, so the hand-written engines instantiate CaptureUI<false> and pay nothing for it - reverb's SRAM_EXEC rises just +288 B (175408 -> 175696 B, 92.3%). Behaviour-identical and re-verified host-green (reverb/tape FX/shuttle suites), clean target builds of reverb/tape/shuttle/gigaverb/chorus, and confirmed on hardware (reverb both algorithms + tape flashed and verified working). tape's six FX sliders are all native [0,1], so the shared Role::set linear-map (0 + v*1 = v) writes the normalized value straight through exactly as the old Cap did. (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>.md keeps the user-facing reference (what the engine is + signature behavior, the control map, routing/display, SD-card prep + file formats, the build/flash commands) and docs/dev/<name>-impl.md holds 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 existing tape-impl.md/shuttle-impl.md absorbed the moved content (deduped). passthrough (a minimal reference example) is left whole. The convention is recorded in docs/engines/README.md, which also gains the missing radio row. (docs/engines/*.md, docs/dev/*-impl.md)