feat(firmware): ExternalSynthMIDIMode + 6 device variants
Joystick -> MLP -> MIDI CC for an external hardware synth, using the
compile-time device templates from nisps/midi/generated. ExternalSynthMIDIMode
<const MidiDevice&, NOut> mirrors SoundAnalysisMIDIMode (NoOpEngine,
kRouteOutputsToEngine=false, pushes ControlChange events). A consteval
pick_cc_slots curates which NOut params fill the output slots (prefers musical
params over Bank Select/global). Adds six flashable variants
(MEMLNautModeExtSynth{Sub37,SubPhatty,Pro12,AnalogKeys,Hydrasynth,JD800}) wired
into mode_select.hpp + the .ino variant list + NISPS_ST guards, and a src/nisps/
midi symlink so the sketch tree can reach nisps/midi.
Host-compile-verified under C++20 (incl. via the sketch include path) and
lint-clean; full arduino-cli build + flash is on the hardware (no toolchain here.
2026-06-28 20:13:08 +02:00
|
|
|
// nisps/modes/external_synth_midi.hpp — Drive an external MIDI synth via CC.
|
|
|
|
|
//
|
|
|
|
|
// A platform-agnostic mode that maps the abstract joystick inputs through the
|
|
|
|
|
// MLP to MIDI Control Change messages for an EXTERNAL hardware synth, using a
|
|
|
|
|
// compile-time-selected device template from nisps/midi/generated/midi_devices.hpp
|
|
|
|
|
// (the same templates the Manifold browser picker uses).
|
|
|
|
|
//
|
|
|
|
|
// - Audio engine = NoOpEngine (engine_id "thru"); no audio is produced.
|
|
|
|
|
// - ML inputs (4) = the abstract joystick channels (joy_x/y/z/w).
|
|
|
|
|
// - ML outputs (NOut) = MIDI CC values pushed into the ControlEvent ring for
|
|
|
|
|
// the firmware glue to dispatch (output_router -> midi_io::queueCC).
|
|
|
|
|
// - Device is chosen at COMPILE TIME (one flashable variant per synth, e.g.
|
|
|
|
|
// MEMLNautModeExtSynthSub37). Which of the device's params occupy the NOut
|
|
|
|
|
// output slots is chosen by `pick_cc_slots` (prefers musical params over
|
|
|
|
|
// Bank Select / global housekeeping CCs). A runtime `enabled_` mask can
|
|
|
|
|
// mute individual slots (set_param_enabled); rich per-param selection is the
|
|
|
|
|
// browser's job — the hardware drives this curated subset.
|
|
|
|
|
//
|
|
|
|
|
// `kRouteOutputsToEngine = false` (specialised below) so ModeBase skips routing
|
|
|
|
|
// ML outputs into engine params, exactly like SoundAnalysisMIDIMode.
|
refactor(codegen): codegen owns mode identity, per-mode schemas and net dims
Phase 3 (S1, S5, S6, S25, L11, L37, ST11, ST12). Behaviour-preserving by
construction: the diff on the generated directories is PURELY ADDITIVE (217
insertions, 0 deletions), so no emitted constant changed value. This moves where
truth lives; it does not change what truth says.
- S5: nine mode headers each carried a mechanically identical 12-field
positional ParamSchema aggregate. codegen now emits one
`inline constexpr ParamSchema k<Mode>Schema` per mode, and the struct itself
moved into generated/schema_types.hpp. Each param_schema() is a one-line
return.
- S6 + S25: every mode hand-typed its net shape a second time as MLP template
args, duplicating the schema's own dims. codegen emits a `<Mode>MLP` alias
built from the already-emitted constants (not re-literalled), and all nine
modes use it. NMaxExamples still defaults from kDefaultMaxExamples (Phase 2).
- S1: model.ts hand-imported all nine schemas by name and hand-paired each with
its overlay — so the SET of modes was hand-maintained and could silently drift
from codegen. codegen now emits ALL_MODE_SCHEMAS; SCHEMA_MODE_OVERLAYS is
purely display truth (label/glyph/css/order), which stays hand-curated.
- L37: deleted the hand-written modeEngineId switch, which duplicated
schema.engine_id and silently defaulted unknown modes to 'thru'. Routes on
MFMode.engineId with exactly one documented exception: sound_analysis_midi
declares engine_id 'thru' because its ModeBase audio slot really is
NoOpEngine, while it separately drives the real AnalysisEngine.
- L11: ExternalSynthMIDIMode's shape was literal in two places; now named once
in an ext_synth_defaults namespace with an ExtSynthMIDIMLP alias. Full folding
into the JSON pipeline is NOT done and the reason is recorded in-file: it is a
template family over an externally-supplied Device and variable NOut, with no
single (device, NOut) schema to author.
- ST12: extracted codegen/lib.ts for the helpers both generators duplicated, and
corrected the comment that claimed they had to be separate. Proof the
extraction was behaviour-free: regenerating the MIDI-device outputs produces a
byte-identical tree.
- ST11: deleted codegen/templates/ — dead "reference" files no generator reads,
already drifted from the real emitters.
Gates: run-all-tests.sh ALL GREEN; codegen idempotent (re-running both
generators yields no further diff), which is what CI's dirty-diff gate checks.
2026-07-21 14:02:23 +02:00
|
|
|
//
|
|
|
|
|
// NOT folded into the schemas/modes/*.json codegen pipeline (L11, one-core
|
|
|
|
|
// simplification 2026-07): that pipeline emits ONE static schema per JSON
|
|
|
|
|
// file, but this "mode" is a C++ template family over an externally supplied
|
|
|
|
|
// `Device` (one of the nisps/midi/generated device templates) and an output
|
|
|
|
|
// count `NOut` — there is no single (device, NOut) pair to author a JSON
|
|
|
|
|
// schema for, and its per-output "params" aren't independently named at all;
|
|
|
|
|
// they're whichever NOut slots `pick_cc_slots` selects from `Device.params`,
|
|
|
|
|
// which is ITSELF already schema-generated (codegen/generate-midi-devices.ts,
|
|
|
|
|
// schemas/midi_devices/). Folding would mean inventing a new
|
|
|
|
|
// device-templated schema kind for a single, already-degenerate consumer —
|
|
|
|
|
// not a behaviour-preserving consolidation. Instead: the net-shape + ML
|
|
|
|
|
// defaults this mode needs (identical to every other 4-joystick-input mode —
|
|
|
|
|
// see e.g. schemas/modes/memlcelium.json) are named ONCE below
|
|
|
|
|
// (`ext_synth_defaults`), so the `MLP<>` shape and the `ParamSchema` instance
|
|
|
|
|
// both read from the same place instead of re-typing the same literals.
|
feat(firmware): ExternalSynthMIDIMode + 6 device variants
Joystick -> MLP -> MIDI CC for an external hardware synth, using the
compile-time device templates from nisps/midi/generated. ExternalSynthMIDIMode
<const MidiDevice&, NOut> mirrors SoundAnalysisMIDIMode (NoOpEngine,
kRouteOutputsToEngine=false, pushes ControlChange events). A consteval
pick_cc_slots curates which NOut params fill the output slots (prefers musical
params over Bank Select/global). Adds six flashable variants
(MEMLNautModeExtSynth{Sub37,SubPhatty,Pro12,AnalogKeys,Hydrasynth,JD800}) wired
into mode_select.hpp + the .ino variant list + NISPS_ST guards, and a src/nisps/
midi symlink so the sketch tree can reach nisps/midi.
Host-compile-verified under C++20 (incl. via the sketch include path) and
lint-clean; full arduino-cli build + flash is on the hardware (no toolchain here.
2026-06-28 20:13:08 +02:00
|
|
|
|
|
|
|
|
#pragma once
|
|
|
|
|
|
|
|
|
|
#include <array>
|
|
|
|
|
#include <cstddef>
|
|
|
|
|
#include <cstdint>
|
|
|
|
|
#include <span>
|
|
|
|
|
#include <string_view>
|
|
|
|
|
|
|
|
|
|
#include "../core/concepts.hpp"
|
|
|
|
|
#include "../core/perf.hpp"
|
|
|
|
|
#include "../core/types.hpp"
|
|
|
|
|
#include "../engines/base.hpp" // NoOpEngine
|
|
|
|
|
#include "../midi/generated/midi_devices.hpp"
|
|
|
|
|
#include "../ml/mlp.hpp"
|
|
|
|
|
#include "base.hpp"
|
|
|
|
|
|
|
|
|
|
namespace nisps::modes {
|
|
|
|
|
|
refactor(codegen): codegen owns mode identity, per-mode schemas and net dims
Phase 3 (S1, S5, S6, S25, L11, L37, ST11, ST12). Behaviour-preserving by
construction: the diff on the generated directories is PURELY ADDITIVE (217
insertions, 0 deletions), so no emitted constant changed value. This moves where
truth lives; it does not change what truth says.
- S5: nine mode headers each carried a mechanically identical 12-field
positional ParamSchema aggregate. codegen now emits one
`inline constexpr ParamSchema k<Mode>Schema` per mode, and the struct itself
moved into generated/schema_types.hpp. Each param_schema() is a one-line
return.
- S6 + S25: every mode hand-typed its net shape a second time as MLP template
args, duplicating the schema's own dims. codegen emits a `<Mode>MLP` alias
built from the already-emitted constants (not re-literalled), and all nine
modes use it. NMaxExamples still defaults from kDefaultMaxExamples (Phase 2).
- S1: model.ts hand-imported all nine schemas by name and hand-paired each with
its overlay — so the SET of modes was hand-maintained and could silently drift
from codegen. codegen now emits ALL_MODE_SCHEMAS; SCHEMA_MODE_OVERLAYS is
purely display truth (label/glyph/css/order), which stays hand-curated.
- L37: deleted the hand-written modeEngineId switch, which duplicated
schema.engine_id and silently defaulted unknown modes to 'thru'. Routes on
MFMode.engineId with exactly one documented exception: sound_analysis_midi
declares engine_id 'thru' because its ModeBase audio slot really is
NoOpEngine, while it separately drives the real AnalysisEngine.
- L11: ExternalSynthMIDIMode's shape was literal in two places; now named once
in an ext_synth_defaults namespace with an ExtSynthMIDIMLP alias. Full folding
into the JSON pipeline is NOT done and the reason is recorded in-file: it is a
template family over an externally-supplied Device and variable NOut, with no
single (device, NOut) schema to author.
- ST12: extracted codegen/lib.ts for the helpers both generators duplicated, and
corrected the comment that claimed they had to be separate. Proof the
extraction was behaviour-free: regenerating the MIDI-device outputs produces a
byte-identical tree.
- ST11: deleted codegen/templates/ — dead "reference" files no generator reads,
already drifted from the real emitters.
Gates: run-all-tests.sh ALL GREEN; codegen idempotent (re-running both
generators yields no further diff), which is what CI's dirty-diff gate checks.
2026-07-21 14:02:23 +02:00
|
|
|
// Shared net-shape + ML defaults for every ExternalSynthMIDIMode
|
|
|
|
|
// instantiation (see the file-header comment for why these are hand-named
|
|
|
|
|
// rather than codegen'd): 4 joystick inputs -> [10,14,18] hidden -> NOut
|
|
|
|
|
// (device-CC-count-driven) outputs, matching every other 4-joystick-input
|
refactor(ml): one runtime-configurable training default (S26)
The operator's call: "there should be one default learning rate and one
default max iterations and they should both be configurable at runtime."
There were SIX copies, not the four the audit described, and they did not
agree:
nisps/ml/mlp.hpp no-arg train() hardcoding 1.f / 1000u / 0.001f —
and firmware's ONLY training path calls exactly
this, so firmware had no runtime knob at all
wasm-iml.ts train() and trainAsync() TS default params (x2)
engine-api.ts learningRate ?? 1.0, with no maxIterations knob
vcv/src/iml.hpp 200 / 0.1 / 0.00001 — silently divergent
external_synth_midi.hpp its own kDefaultLearningRate/kDefaultMaxIterations
schemas/modes/*.json x9, identical, read by nobody at runtime
Now: schemas/ml_defaults.json is the single declaration (validated against a
sibling meta-schema, matching the midi_device.schema.json convention), codegen
emits it to C++ and TS in the same run, and MLPCore carries a TrainConfig whose
default member initialisers read the generated constant.
set_train_config()/nisps_ml_set_train_config() make it runtime-overridable on
every target; the explicit-argument train() overload is untouched. min_error
joins the tuple — it was duplicated identically and belongs with the other two.
The per-mode ml block loses default_learning_rate/default_max_iterations.
default_spread stays (genuinely wired on both targets) and input_channels stays
(codegen-time validated, real information for sound_analysis_midi).
VCV BEHAVIOUR CHANGE, deliberate: MEMLNaut.cpp constructs IML positionally and
relies on those defaults, so the module moves to 1000/1.0/0.001 — 5x the max
iterations, 10x the learning rate, and a 100x looser early-stop threshold. The
old values were never justified anywhere; they arrived with fbc68eb alongside
an unrelated module rewrite and no tuning rationale. Firmware and WASM have
shipped 1.0/1000 all along. It is now runtime-settable if this turns out worse.
The generated header lands in nisps/ml/generated/, not nisps/modes/generated/
where the rest of codegen output lives: training hyperparameters are an ML
fact, and nisps/ml sits below nisps/modes, so emitting them there would make
mlp.hpp include upward. The agent that built this flagged the directory-crossing
rather than hiding it; this is the fix. CI's generated-freshness gate learns the
new directory.
Gates: run-all-tests.sh ALL GREEN — 4/4 ctest, parity PASS (max delta 2.38e-7),
lint clean, manifold typecheck + 17 unit + 33 e2e (which exercise train() and
trainAsync() through a real browser).
2026-07-21 17:20:10 +02:00
|
|
|
// mode's schema default_spread (0.6). The training hyperparameter defaults
|
|
|
|
|
// (learning_rate/max_iterations/min_error) are no longer part of
|
|
|
|
|
// `ParamSchema` at all (S26) — they live once, globally, in
|
|
|
|
|
// `nisps::ml::generated::kMlTrainDefaults` (nisps/ml/generated/
|
|
|
|
|
// ml_defaults.hpp) and are wired via `nisps::ml::MLPCore::TrainConfig`'s
|
|
|
|
|
// default member initialisers instead.
|
refactor(codegen): codegen owns mode identity, per-mode schemas and net dims
Phase 3 (S1, S5, S6, S25, L11, L37, ST11, ST12). Behaviour-preserving by
construction: the diff on the generated directories is PURELY ADDITIVE (217
insertions, 0 deletions), so no emitted constant changed value. This moves where
truth lives; it does not change what truth says.
- S5: nine mode headers each carried a mechanically identical 12-field
positional ParamSchema aggregate. codegen now emits one
`inline constexpr ParamSchema k<Mode>Schema` per mode, and the struct itself
moved into generated/schema_types.hpp. Each param_schema() is a one-line
return.
- S6 + S25: every mode hand-typed its net shape a second time as MLP template
args, duplicating the schema's own dims. codegen emits a `<Mode>MLP` alias
built from the already-emitted constants (not re-literalled), and all nine
modes use it. NMaxExamples still defaults from kDefaultMaxExamples (Phase 2).
- S1: model.ts hand-imported all nine schemas by name and hand-paired each with
its overlay — so the SET of modes was hand-maintained and could silently drift
from codegen. codegen now emits ALL_MODE_SCHEMAS; SCHEMA_MODE_OVERLAYS is
purely display truth (label/glyph/css/order), which stays hand-curated.
- L37: deleted the hand-written modeEngineId switch, which duplicated
schema.engine_id and silently defaulted unknown modes to 'thru'. Routes on
MFMode.engineId with exactly one documented exception: sound_analysis_midi
declares engine_id 'thru' because its ModeBase audio slot really is
NoOpEngine, while it separately drives the real AnalysisEngine.
- L11: ExternalSynthMIDIMode's shape was literal in two places; now named once
in an ext_synth_defaults namespace with an ExtSynthMIDIMLP alias. Full folding
into the JSON pipeline is NOT done and the reason is recorded in-file: it is a
template family over an externally-supplied Device and variable NOut, with no
single (device, NOut) schema to author.
- ST12: extracted codegen/lib.ts for the helpers both generators duplicated, and
corrected the comment that claimed they had to be separate. Proof the
extraction was behaviour-free: regenerating the MIDI-device outputs produces a
byte-identical tree.
- ST11: deleted codegen/templates/ — dead "reference" files no generator reads,
already drifted from the real emitters.
Gates: run-all-tests.sh ALL GREEN; codegen idempotent (re-running both
generators yields no further diff), which is what CI's dirty-diff gate checks.
2026-07-21 14:02:23 +02:00
|
|
|
namespace ext_synth_defaults {
|
|
|
|
|
inline constexpr std::size_t kInputSize = 4u;
|
|
|
|
|
inline constexpr std::array<std::string_view, 4> kInputChannels{
|
|
|
|
|
std::string_view{"joy_x"}, std::string_view{"joy_y"},
|
|
|
|
|
std::string_view{"joy_z"}, std::string_view{"joy_w"}};
|
|
|
|
|
inline constexpr std::array<std::size_t, 3> kHiddenLayers{10u, 14u, 18u};
|
|
|
|
|
inline constexpr float kDefaultSpread = 0.6f;
|
|
|
|
|
} // namespace ext_synth_defaults
|
|
|
|
|
|
|
|
|
|
// The net-shape alias every instantiation uses (mirrors S6/S25's per-mode
|
|
|
|
|
// `<Mode>MLP` codegen alias, generalised to a template parameter since NOut
|
|
|
|
|
// varies per device/output-count combination).
|
|
|
|
|
template <std::size_t NOut>
|
|
|
|
|
using ExtSynthMIDIMLP = ::nisps::ml::MLP<
|
|
|
|
|
ext_synth_defaults::kInputSize,
|
|
|
|
|
ext_synth_defaults::kHiddenLayers[0],
|
|
|
|
|
ext_synth_defaults::kHiddenLayers[1],
|
|
|
|
|
ext_synth_defaults::kHiddenLayers[2],
|
|
|
|
|
NOut>;
|
|
|
|
|
|
feat(firmware): ExternalSynthMIDIMode + 6 device variants
Joystick -> MLP -> MIDI CC for an external hardware synth, using the
compile-time device templates from nisps/midi/generated. ExternalSynthMIDIMode
<const MidiDevice&, NOut> mirrors SoundAnalysisMIDIMode (NoOpEngine,
kRouteOutputsToEngine=false, pushes ControlChange events). A consteval
pick_cc_slots curates which NOut params fill the output slots (prefers musical
params over Bank Select/global). Adds six flashable variants
(MEMLNautModeExtSynth{Sub37,SubPhatty,Pro12,AnalogKeys,Hydrasynth,JD800}) wired
into mode_select.hpp + the .ino variant list + NISPS_ST guards, and a src/nisps/
midi symlink so the sketch tree can reach nisps/midi.
Host-compile-verified under C++20 (incl. via the sketch include path) and
lint-clean; full arduino-cli build + flash is on the hardware (no toolchain here.
2026-06-28 20:13:08 +02:00
|
|
|
template <const ::nisps::midi::generated::MidiDevice& Device, std::size_t NOut>
|
|
|
|
|
class ExternalSynthMIDIMode;
|
|
|
|
|
|
|
|
|
|
// Compile-time slot picker: choose which NOut device params the MLP drives.
|
|
|
|
|
// Prefers "musical" params (any group except "global"); fills any remaining
|
|
|
|
|
// slots from the leftover (global) params. Deterministic, zero runtime cost.
|
|
|
|
|
template <const ::nisps::midi::generated::MidiDevice& Device, std::size_t NOut>
|
|
|
|
|
consteval std::array<std::uint8_t, NOut> pick_cc_slots() {
|
|
|
|
|
std::array<std::uint8_t, NOut> slots{};
|
|
|
|
|
std::size_t n = 0u;
|
|
|
|
|
for (std::size_t i = 0u; i < Device.params.size() && n < NOut; ++i) {
|
|
|
|
|
if (Device.params[i].group != std::string_view{"global"}) {
|
|
|
|
|
slots[n++] = static_cast<std::uint8_t>(i);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
for (std::size_t i = 0u; i < Device.params.size() && n < NOut; ++i) {
|
|
|
|
|
if (Device.params[i].group == std::string_view{"global"}) {
|
|
|
|
|
slots[n++] = static_cast<std::uint8_t>(i);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return slots;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
} // namespace nisps::modes
|
|
|
|
|
|
|
|
|
|
namespace nisps {
|
|
|
|
|
// Specialise: this mode does not route ML outputs to engine params.
|
|
|
|
|
template <const ::nisps::midi::generated::MidiDevice& D, std::size_t N>
|
|
|
|
|
struct ModeRoutesOutputsToEngine<modes::ExternalSynthMIDIMode<D, N>> : std::false_type {};
|
|
|
|
|
} // namespace nisps
|
|
|
|
|
|
|
|
|
|
namespace nisps::modes {
|
|
|
|
|
|
|
|
|
|
template <const ::nisps::midi::generated::MidiDevice& Device, std::size_t NOut = 8u>
|
|
|
|
|
class ExternalSynthMIDIMode : public ModeBase<
|
|
|
|
|
ExternalSynthMIDIMode<Device, NOut>,
|
|
|
|
|
NoOpEngine,
|
refactor(codegen): codegen owns mode identity, per-mode schemas and net dims
Phase 3 (S1, S5, S6, S25, L11, L37, ST11, ST12). Behaviour-preserving by
construction: the diff on the generated directories is PURELY ADDITIVE (217
insertions, 0 deletions), so no emitted constant changed value. This moves where
truth lives; it does not change what truth says.
- S5: nine mode headers each carried a mechanically identical 12-field
positional ParamSchema aggregate. codegen now emits one
`inline constexpr ParamSchema k<Mode>Schema` per mode, and the struct itself
moved into generated/schema_types.hpp. Each param_schema() is a one-line
return.
- S6 + S25: every mode hand-typed its net shape a second time as MLP template
args, duplicating the schema's own dims. codegen emits a `<Mode>MLP` alias
built from the already-emitted constants (not re-literalled), and all nine
modes use it. NMaxExamples still defaults from kDefaultMaxExamples (Phase 2).
- S1: model.ts hand-imported all nine schemas by name and hand-paired each with
its overlay — so the SET of modes was hand-maintained and could silently drift
from codegen. codegen now emits ALL_MODE_SCHEMAS; SCHEMA_MODE_OVERLAYS is
purely display truth (label/glyph/css/order), which stays hand-curated.
- L37: deleted the hand-written modeEngineId switch, which duplicated
schema.engine_id and silently defaulted unknown modes to 'thru'. Routes on
MFMode.engineId with exactly one documented exception: sound_analysis_midi
declares engine_id 'thru' because its ModeBase audio slot really is
NoOpEngine, while it separately drives the real AnalysisEngine.
- L11: ExternalSynthMIDIMode's shape was literal in two places; now named once
in an ext_synth_defaults namespace with an ExtSynthMIDIMLP alias. Full folding
into the JSON pipeline is NOT done and the reason is recorded in-file: it is a
template family over an externally-supplied Device and variable NOut, with no
single (device, NOut) schema to author.
- ST12: extracted codegen/lib.ts for the helpers both generators duplicated, and
corrected the comment that claimed they had to be separate. Proof the
extraction was behaviour-free: regenerating the MIDI-device outputs produces a
byte-identical tree.
- ST11: deleted codegen/templates/ — dead "reference" files no generator reads,
already drifted from the real emitters.
Gates: run-all-tests.sh ALL GREEN; codegen idempotent (re-running both
generators yields no further diff), which is what CI's dirty-diff gate checks.
2026-07-21 14:02:23 +02:00
|
|
|
ExtSynthMIDIMLP<NOut>,
|
feat(firmware): ExternalSynthMIDIMode + 6 device variants
Joystick -> MLP -> MIDI CC for an external hardware synth, using the
compile-time device templates from nisps/midi/generated. ExternalSynthMIDIMode
<const MidiDevice&, NOut> mirrors SoundAnalysisMIDIMode (NoOpEngine,
kRouteOutputsToEngine=false, pushes ControlChange events). A consteval
pick_cc_slots curates which NOut params fill the output slots (prefers musical
params over Bank Select/global). Adds six flashable variants
(MEMLNautModeExtSynth{Sub37,SubPhatty,Pro12,AnalogKeys,Hydrasynth,JD800}) wired
into mode_select.hpp + the .ino variant list + NISPS_ST guards, and a src/nisps/
midi symlink so the sketch tree can reach nisps/midi.
Host-compile-verified under C++20 (incl. via the sketch include path) and
lint-clean; full arduino-cli build + flash is on the hardware (no toolchain here.
2026-06-28 20:13:08 +02:00
|
|
|
4u> {
|
|
|
|
|
static_assert(Device.params.size() >= NOut,
|
|
|
|
|
"device template has fewer params than the mode's output count");
|
|
|
|
|
|
|
|
|
|
public:
|
|
|
|
|
using Base = ModeBase<ExternalSynthMIDIMode<Device, NOut>, NoOpEngine,
|
refactor(codegen): codegen owns mode identity, per-mode schemas and net dims
Phase 3 (S1, S5, S6, S25, L11, L37, ST11, ST12). Behaviour-preserving by
construction: the diff on the generated directories is PURELY ADDITIVE (217
insertions, 0 deletions), so no emitted constant changed value. This moves where
truth lives; it does not change what truth says.
- S5: nine mode headers each carried a mechanically identical 12-field
positional ParamSchema aggregate. codegen now emits one
`inline constexpr ParamSchema k<Mode>Schema` per mode, and the struct itself
moved into generated/schema_types.hpp. Each param_schema() is a one-line
return.
- S6 + S25: every mode hand-typed its net shape a second time as MLP template
args, duplicating the schema's own dims. codegen emits a `<Mode>MLP` alias
built from the already-emitted constants (not re-literalled), and all nine
modes use it. NMaxExamples still defaults from kDefaultMaxExamples (Phase 2).
- S1: model.ts hand-imported all nine schemas by name and hand-paired each with
its overlay — so the SET of modes was hand-maintained and could silently drift
from codegen. codegen now emits ALL_MODE_SCHEMAS; SCHEMA_MODE_OVERLAYS is
purely display truth (label/glyph/css/order), which stays hand-curated.
- L37: deleted the hand-written modeEngineId switch, which duplicated
schema.engine_id and silently defaulted unknown modes to 'thru'. Routes on
MFMode.engineId with exactly one documented exception: sound_analysis_midi
declares engine_id 'thru' because its ModeBase audio slot really is
NoOpEngine, while it separately drives the real AnalysisEngine.
- L11: ExternalSynthMIDIMode's shape was literal in two places; now named once
in an ext_synth_defaults namespace with an ExtSynthMIDIMLP alias. Full folding
into the JSON pipeline is NOT done and the reason is recorded in-file: it is a
template family over an externally-supplied Device and variable NOut, with no
single (device, NOut) schema to author.
- ST12: extracted codegen/lib.ts for the helpers both generators duplicated, and
corrected the comment that claimed they had to be separate. Proof the
extraction was behaviour-free: regenerating the MIDI-device outputs produces a
byte-identical tree.
- ST11: deleted codegen/templates/ — dead "reference" files no generator reads,
already drifted from the real emitters.
Gates: run-all-tests.sh ALL GREEN; codegen idempotent (re-running both
generators yields no further diff), which is what CI's dirty-diff gate checks.
2026-07-21 14:02:23 +02:00
|
|
|
ExtSynthMIDIMLP<NOut>, 4u>;
|
feat(firmware): ExternalSynthMIDIMode + 6 device variants
Joystick -> MLP -> MIDI CC for an external hardware synth, using the
compile-time device templates from nisps/midi/generated. ExternalSynthMIDIMode
<const MidiDevice&, NOut> mirrors SoundAnalysisMIDIMode (NoOpEngine,
kRouteOutputsToEngine=false, pushes ControlChange events). A consteval
pick_cc_slots curates which NOut params fill the output slots (prefers musical
params over Bank Select/global). Adds six flashable variants
(MEMLNautModeExtSynth{Sub37,SubPhatty,Pro12,AnalogKeys,Hydrasynth,JD800}) wired
into mode_select.hpp + the .ino variant list + NISPS_ST guards, and a src/nisps/
midi symlink so the sketch tree can reach nisps/midi.
Host-compile-verified under C++20 (incl. via the sketch include path) and
lint-clean; full arduino-cli build + flash is on the hardware (no toolchain here.
2026-06-28 20:13:08 +02:00
|
|
|
using Base::Base;
|
|
|
|
|
|
|
|
|
|
static constexpr std::string_view mode_id() noexcept { return Device.device_id; }
|
|
|
|
|
static constexpr const ParamSchema& param_schema() noexcept { return kSchema; }
|
|
|
|
|
|
|
|
|
|
// Map the NOut MLP outputs onto the selected device CCs and queue them.
|
|
|
|
|
void on_post_inference() noexcept {
|
|
|
|
|
const auto outs = this->ml_.outputs();
|
|
|
|
|
const std::uint8_t ch =
|
|
|
|
|
Device.default_channel > 0u ? static_cast<std::uint8_t>(Device.default_channel - 1u) : 0u;
|
|
|
|
|
for (std::size_t i = 0u; i < NOut && i < outs.size(); ++i) {
|
|
|
|
|
if (!enabled_[i]) continue;
|
|
|
|
|
const auto& p = Device.params[kSlots[i]];
|
|
|
|
|
float v = outs[i];
|
|
|
|
|
if (v < 0.f) v = 0.f;
|
|
|
|
|
else if (v > 1.f) v = 1.f;
|
|
|
|
|
const float lo = static_cast<float>(p.min);
|
|
|
|
|
const float hi = static_cast<float>(p.max);
|
|
|
|
|
ControlEvent ce{};
|
|
|
|
|
ce.kind = ControlEvent::Kind::ControlChange;
|
|
|
|
|
ce.channel = ch;
|
|
|
|
|
ce.data1 = p.cc;
|
|
|
|
|
ce.data2 = static_cast<std::uint8_t>(lo + v * (hi - lo) + 0.5f);
|
|
|
|
|
(void)this->push_control_event(ce);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Runtime mute of an individual output slot (hardware has no rich selector;
|
|
|
|
|
// this is the hook a future TFT menu / glue would drive).
|
|
|
|
|
void set_param_enabled(std::size_t slot, bool on) noexcept {
|
|
|
|
|
if (slot < NOut) enabled_[slot] = on;
|
|
|
|
|
}
|
|
|
|
|
static constexpr std::size_t slot_count() noexcept { return NOut; }
|
|
|
|
|
// The device param backing output slot i (for glue / display by name).
|
|
|
|
|
static constexpr const ::nisps::midi::generated::MidiParam& slot_param(std::size_t i) noexcept {
|
|
|
|
|
return Device.params[kSlots[i]];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private:
|
|
|
|
|
static constexpr auto kSlots = pick_cc_slots<Device, NOut>();
|
|
|
|
|
|
|
|
|
|
std::array<bool, NOut> enabled_ = [] {
|
|
|
|
|
std::array<bool, NOut> a{};
|
|
|
|
|
for (auto& b : a) b = true;
|
|
|
|
|
return a;
|
|
|
|
|
}();
|
|
|
|
|
|
|
|
|
|
static constexpr std::array<generated::Param, 0> kNoParams{};
|
|
|
|
|
static constexpr std::array<std::string_view, 0> kNoVoiceSpaces{};
|
|
|
|
|
static constexpr generated::UIConfig kUI{generated::PrimaryInput::Joystick, false, false};
|
|
|
|
|
|
|
|
|
|
static inline constexpr ParamSchema kSchema = ParamSchema{
|
|
|
|
|
Device.device_id,
|
|
|
|
|
std::string_view{"thru"},
|
refactor(codegen): codegen owns mode identity, per-mode schemas and net dims
Phase 3 (S1, S5, S6, S25, L11, L37, ST11, ST12). Behaviour-preserving by
construction: the diff on the generated directories is PURELY ADDITIVE (217
insertions, 0 deletions), so no emitted constant changed value. This moves where
truth lives; it does not change what truth says.
- S5: nine mode headers each carried a mechanically identical 12-field
positional ParamSchema aggregate. codegen now emits one
`inline constexpr ParamSchema k<Mode>Schema` per mode, and the struct itself
moved into generated/schema_types.hpp. Each param_schema() is a one-line
return.
- S6 + S25: every mode hand-typed its net shape a second time as MLP template
args, duplicating the schema's own dims. codegen emits a `<Mode>MLP` alias
built from the already-emitted constants (not re-literalled), and all nine
modes use it. NMaxExamples still defaults from kDefaultMaxExamples (Phase 2).
- S1: model.ts hand-imported all nine schemas by name and hand-paired each with
its overlay — so the SET of modes was hand-maintained and could silently drift
from codegen. codegen now emits ALL_MODE_SCHEMAS; SCHEMA_MODE_OVERLAYS is
purely display truth (label/glyph/css/order), which stays hand-curated.
- L37: deleted the hand-written modeEngineId switch, which duplicated
schema.engine_id and silently defaulted unknown modes to 'thru'. Routes on
MFMode.engineId with exactly one documented exception: sound_analysis_midi
declares engine_id 'thru' because its ModeBase audio slot really is
NoOpEngine, while it separately drives the real AnalysisEngine.
- L11: ExternalSynthMIDIMode's shape was literal in two places; now named once
in an ext_synth_defaults namespace with an ExtSynthMIDIMLP alias. Full folding
into the JSON pipeline is NOT done and the reason is recorded in-file: it is a
template family over an externally-supplied Device and variable NOut, with no
single (device, NOut) schema to author.
- ST12: extracted codegen/lib.ts for the helpers both generators duplicated, and
corrected the comment that claimed they had to be separate. Proof the
extraction was behaviour-free: regenerating the MIDI-device outputs produces a
byte-identical tree.
- ST11: deleted codegen/templates/ — dead "reference" files no generator reads,
already drifted from the real emitters.
Gates: run-all-tests.sh ALL GREEN; codegen idempotent (re-running both
generators yields no further diff), which is what CI's dirty-diff gate checks.
2026-07-21 14:02:23 +02:00
|
|
|
std::span<const std::string_view>(ext_synth_defaults::kInputChannels),
|
|
|
|
|
ext_synth_defaults::kInputSize,
|
|
|
|
|
std::span<const std::size_t>(ext_synth_defaults::kHiddenLayers),
|
feat(firmware): ExternalSynthMIDIMode + 6 device variants
Joystick -> MLP -> MIDI CC for an external hardware synth, using the
compile-time device templates from nisps/midi/generated. ExternalSynthMIDIMode
<const MidiDevice&, NOut> mirrors SoundAnalysisMIDIMode (NoOpEngine,
kRouteOutputsToEngine=false, pushes ControlChange events). A consteval
pick_cc_slots curates which NOut params fill the output slots (prefers musical
params over Bank Select/global). Adds six flashable variants
(MEMLNautModeExtSynth{Sub37,SubPhatty,Pro12,AnalogKeys,Hydrasynth,JD800}) wired
into mode_select.hpp + the .ino variant list + NISPS_ST guards, and a src/nisps/
midi symlink so the sketch tree can reach nisps/midi.
Host-compile-verified under C++20 (incl. via the sketch include path) and
lint-clean; full arduino-cli build + flash is on the hardware (no toolchain here.
2026-06-28 20:13:08 +02:00
|
|
|
NOut,
|
refactor(codegen): codegen owns mode identity, per-mode schemas and net dims
Phase 3 (S1, S5, S6, S25, L11, L37, ST11, ST12). Behaviour-preserving by
construction: the diff on the generated directories is PURELY ADDITIVE (217
insertions, 0 deletions), so no emitted constant changed value. This moves where
truth lives; it does not change what truth says.
- S5: nine mode headers each carried a mechanically identical 12-field
positional ParamSchema aggregate. codegen now emits one
`inline constexpr ParamSchema k<Mode>Schema` per mode, and the struct itself
moved into generated/schema_types.hpp. Each param_schema() is a one-line
return.
- S6 + S25: every mode hand-typed its net shape a second time as MLP template
args, duplicating the schema's own dims. codegen emits a `<Mode>MLP` alias
built from the already-emitted constants (not re-literalled), and all nine
modes use it. NMaxExamples still defaults from kDefaultMaxExamples (Phase 2).
- S1: model.ts hand-imported all nine schemas by name and hand-paired each with
its overlay — so the SET of modes was hand-maintained and could silently drift
from codegen. codegen now emits ALL_MODE_SCHEMAS; SCHEMA_MODE_OVERLAYS is
purely display truth (label/glyph/css/order), which stays hand-curated.
- L37: deleted the hand-written modeEngineId switch, which duplicated
schema.engine_id and silently defaulted unknown modes to 'thru'. Routes on
MFMode.engineId with exactly one documented exception: sound_analysis_midi
declares engine_id 'thru' because its ModeBase audio slot really is
NoOpEngine, while it separately drives the real AnalysisEngine.
- L11: ExternalSynthMIDIMode's shape was literal in two places; now named once
in an ext_synth_defaults namespace with an ExtSynthMIDIMLP alias. Full folding
into the JSON pipeline is NOT done and the reason is recorded in-file: it is a
template family over an externally-supplied Device and variable NOut, with no
single (device, NOut) schema to author.
- ST12: extracted codegen/lib.ts for the helpers both generators duplicated, and
corrected the comment that claimed they had to be separate. Proof the
extraction was behaviour-free: regenerating the MIDI-device outputs produces a
byte-identical tree.
- ST11: deleted codegen/templates/ — dead "reference" files no generator reads,
already drifted from the real emitters.
Gates: run-all-tests.sh ALL GREEN; codegen idempotent (re-running both
generators yields no further diff), which is what CI's dirty-diff gate checks.
2026-07-21 14:02:23 +02:00
|
|
|
ext_synth_defaults::kDefaultSpread,
|
feat(firmware): ExternalSynthMIDIMode + 6 device variants
Joystick -> MLP -> MIDI CC for an external hardware synth, using the
compile-time device templates from nisps/midi/generated. ExternalSynthMIDIMode
<const MidiDevice&, NOut> mirrors SoundAnalysisMIDIMode (NoOpEngine,
kRouteOutputsToEngine=false, pushes ControlChange events). A consteval
pick_cc_slots curates which NOut params fill the output slots (prefers musical
params over Bank Select/global). Adds six flashable variants
(MEMLNautModeExtSynth{Sub37,SubPhatty,Pro12,AnalogKeys,Hydrasynth,JD800}) wired
into mode_select.hpp + the .ino variant list + NISPS_ST guards, and a src/nisps/
midi symlink so the sketch tree can reach nisps/midi.
Host-compile-verified under C++20 (incl. via the sketch include path) and
lint-clean; full arduino-cli build + flash is on the hardware (no toolchain here.
2026-06-28 20:13:08 +02:00
|
|
|
std::span<const generated::Param>(kNoParams),
|
|
|
|
|
std::span<const std::string_view>(kNoVoiceSpaces),
|
|
|
|
|
kUI,
|
|
|
|
|
};
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
} // namespace nisps::modes
|