One cut, no dual path. Closes ALIGNMENT defect 3 ("Arduino-CLI build
machinery is actively hostile") and vision bullet 4.
platformio.ini carries 16 [env:], one per variant, each passing
-DMEMLNAUT_MODE_TYPE; selftest passes -DNISPS_SELFTEST=1 instead. The env list
IS the registry now — the .ino comment-registry and the NISPS_ST_* token-paste
table are deleted rather than migrated. L12 noted that table was already
silently missing the currently-shipped SLPWorkshop variant, which is the whole
argument against having a second list.
Also deleted: the Python/sed machinery that rewrote the COMMITTED .ino on every
build, the sketch symlink forest, the global TFT_eSPI User_Setup.h mutation
(now -D flags — TFT_eSPI's own documented PlatformIO recipe), the UF2
boot-mount detection stack (upload_protocol=picotool talks to the bootloader
directly), and build-firmware-arch.sh entirely. Scripts 683 -> 435 lines.
memllib is vendored at lib/memllib/ from upstream e291192; no submodules
remain. VENDORED.md records provenance and the re-sync procedure.
S9: a firmware-build CI job compiles three representative envs against a cached
toolchain and reports per-variant flash/RAM. Firmware is in an automated gate
for the FIRST time. The old ci.yml comment justified excluding it as "low
verification value" — an assessment that did not survive contact, since the
SelfTest variant sat broken for an unknown period calling a DisplayDriver
method that did not exist at the pinned memllib commit, and nothing noticed
because nothing built it.
Verified: all 16 envs build from an empty cache, each within ~520 bytes of the
arduino-cli binary it replaces, flash and RAM. Measured as .text+.rodata /
.data+.bss+vector+uninitialized — NOT PlatformIO's console line, which
double-counts .data on this board. This does not prove the hardware boots; no
flash+smoke test was possible and that stays an operator chokepoint.
slpworkshop 248232/145028 pafsynth 256880/149716 selftest 216228/17960
(all 16 in the CI log format; none exceeds 2% of a 16 MB flash)
Two traps recorded so nobody rediscovers them: vendoring memllib's subdirs
without a src/ wrapper makes PlatformIO's library builder silently compile
NOTHING while still linking; and project build_flags land BEFORE the
framework's own -std=gnu++17 -Os, so build_unflags is required.
CORRECTION carried in this commit: the firmware sizes in c19d846's message and
the first version of the memllib recon doc were wrong — SLPWorkshop 145348,
PAFSynth 145300, SelfTest 141840. They came from building variants in sequence
through a SHARED incremental arduino-cli build directory, which reused stale
objects and under-reported by ~75 KB. Clean-cache rebuilds of the identical
commit give 216736/18492 for SelfTest. The real cost of the memllib upstream
bump is +216 bytes flash, not +316. Never measure firmware size through a
reused build dir.
HISTORY NOTE: this commit and the docs commit before it were rebuilt (force-push,
2026-07-21) so that each contains only what its message describes. The first
versions had the firmware deletions stranded in the docs commit by a shared-index
race between concurrent agents; content is byte-identical to the originals.
Gates: run-all-tests.sh ALL GREEN (nisps/ untouched by this change beyond
include paths); 16/16 pio envs build.
112 lines
4.5 KiB
C++
112 lines
4.5 KiB
C++
// firmware/glue/audio_driver.hpp — Bridge memllib AudioDriver to nisps modes.
|
|
//
|
|
// memllib's AudioDriver delivers samples via a *block* callback shaped like:
|
|
// void cb(float in[][kBufferSize], float out[][kBufferSize], size_t nch, size_t nf)
|
|
// The nisps `Mode` concept exposes a per-sample `process(stereosample_t)`.
|
|
//
|
|
// We register a free function as the AudioDriver block callback and pump it
|
|
// frame-by-frame into the active mode. The mode pointer is held in a
|
|
// templated free function that captures it by reference at call-site (so we
|
|
// avoid std::function indirection in the audio path).
|
|
//
|
|
// The two `stereosample_t` types — the pre-existing firmware POD and the new
|
|
// nisps namespaced one — have identical memory layout (two floats L,R) so the
|
|
// bridge does an explicit field-wise copy. No reinterpret_cast.
|
|
|
|
#pragma once
|
|
|
|
#include <Arduino.h>
|
|
|
|
#include "nisps/core/perf.hpp"
|
|
#include "nisps/core/types.hpp"
|
|
#include "audio/AudioDriver.hpp"
|
|
|
|
namespace nisps_firmware {
|
|
|
|
// Pointer to the active mode. The audio block callback reads through this.
|
|
// Set during setup1() before AudioDriver::Setup() is called. Marked
|
|
// `__not_in_flash("audio")` so the audio ISR path does not pay flash latency.
|
|
struct ActiveModeBridge {
|
|
void* mode_ptr = nullptr; // type-erased Mode*
|
|
void (*process_block)(void*,
|
|
float[][kBufferSize],
|
|
float[][kBufferSize],
|
|
size_t, size_t) = nullptr;
|
|
};
|
|
|
|
// Defined in the .ino (not inline-in-header) — `inline` and `__not_in_flash`
|
|
// section attributes don't combine cleanly: comdat groups want shared
|
|
// linkage, named sections want unique ownership. The .ino owns the
|
|
// definition.
|
|
extern volatile ActiveModeBridge AUDIO_MEM g_active_mode_bridge;
|
|
|
|
// Templated trampoline: instantiated once per concrete Mode type. It does the
|
|
// per-sample loop and the field-wise stereosample_t copy. Templated rather
|
|
// than virtual because the audio path forbids virtual dispatch (architecture
|
|
// §3.5).
|
|
//
|
|
// We can't use `__not_in_flash_func(name)` on a templated function because
|
|
// that macro stringifies the function name into the section attribute, and
|
|
// the template instantiations all share a single section name — collisions
|
|
// are benign at link time but a per-instantiation section would be cleaner.
|
|
// Use `__attribute__((hot))` instead for these templated trampolines and
|
|
// rely on the platform linker default for placement.
|
|
template <typename Mode>
|
|
__attribute__((hot)) static void process_block_typed(
|
|
void* mode_ptr,
|
|
float in[][kBufferSize],
|
|
float out[][kBufferSize],
|
|
size_t /*n_channels*/,
|
|
size_t n_frames) {
|
|
auto* mode = static_cast<Mode*>(mode_ptr);
|
|
for (size_t i = 0; i < n_frames; ++i) {
|
|
::stereosample_t fw_in{in[0][i], in[1][i]};
|
|
::nisps::stereosample_t in_ns{fw_in.L, fw_in.R};
|
|
const auto out_ns = mode->process(in_ns);
|
|
out[0][i] = out_ns.L;
|
|
out[1][i] = out_ns.R;
|
|
|
|
// Modes that override `analyse(stereosample_t)` (e.g. SoundAnalysisMIDI)
|
|
// see the input frame here, before the next sample.
|
|
if constexpr (requires { mode->analyse(in_ns); }) {
|
|
mode->analyse(in_ns);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Inner forward — the .ino provides the actual block callback (with
|
|
// `__not_in_flash_func` placement) and forwards into this. Keeping the
|
|
// non-template body in a non-inline function avoids comdat / section
|
|
// conflicts.
|
|
inline void dispatch_audio_block(
|
|
float in[][kBufferSize],
|
|
float out[][kBufferSize],
|
|
size_t n_channels,
|
|
size_t n_frames) {
|
|
auto& bridge = const_cast<ActiveModeBridge&>(g_active_mode_bridge);
|
|
if (bridge.mode_ptr == nullptr || bridge.process_block == nullptr) {
|
|
for (size_t i = 0; i < n_frames; ++i) {
|
|
out[0][i] = 0.f;
|
|
out[1][i] = 0.f;
|
|
}
|
|
return;
|
|
}
|
|
bridge.process_block(bridge.mode_ptr, in, out, n_channels, n_frames);
|
|
}
|
|
|
|
// Public registration: call from setup1() with the active mode and the
|
|
// `__not_in_flash_func`-placed block callback that forwards into
|
|
// `dispatch_audio_block`.
|
|
template <typename Mode>
|
|
inline void register_audio_engine(Mode& mode, audiocallback_block_fptr_t block_cb) {
|
|
ActiveModeBridge b{};
|
|
b.mode_ptr = static_cast<void*>(&mode);
|
|
b.process_block = &process_block_typed<Mode>;
|
|
__sync_synchronize();
|
|
const_cast<ActiveModeBridge&>(g_active_mode_bridge) = b;
|
|
__sync_synchronize();
|
|
|
|
AudioDriver::SetBlockCallback(block_cb);
|
|
}
|
|
|
|
} // namespace nisps_firmware
|