Commit graph

102 commits

Author SHA1 Message Date
monkey-w1n5t0n
fbeedba2f3 fix(modular): register worklet via concatenated blob url
The globalThis.FaustWorkletProcessor attachment on the base class isn't
enough in practice: Chrome's AudioWorkletGlobalScope isolates top-level
class declarations between separate addModule() calls, so a subclass
script loaded via a second addModule() still throws ReferenceError on
its 'extends FaustWorkletProcessor' clause.

Workaround: fetch both files, concatenate, and addModule() a single
blob URL. The base class and subclass end up in the same script
evaluation context and the extends clause resolves. Processor names
are memoised on a static Set so repeated sub-engine swaps don't try
to re-register (which would throw).
2026-04-11 08:33:29 +02:00
monkey-w1n5t0n
4507db00c8 fix(modular): matrix cells opt-in + per-group drawer for non-C15 engines
Two fixes driven by user reports:

1. MLP wasn't affecting the sound. Matrix cells are now opt-in to the
   MLP output vector rather than always-driven. Default modular paramMeta
   is 32 mod-source params (4 ADSR * A/D/S/R + 8 LFO * rate/morph), down
   from 512. The default patch's MM_Matrix/s00_d08_amp=1.0 now survives
   the first inference tick because it's not in paramMeta — ADSR1 stays
   routed to amp and the MLP drives envelope shape per joystick position.

   Matrix cells still clickable as direct-DSP knobs in modular-ui: setCell
   now routes through engine._setRawByLabel(). A later UI pass can add a
   "expose to MLP" menu entry that calls engine.setExposeMatrixCell(s,d).

   Also removes the earlier exampleCount-based routing gate; no longer
   needed now that the default patch is stable.

2. Per-group curve drawer (hover over section labels on the synth
   visualizer) is now available for all synth engines, not just C15.
   Refactored showGroupDrawer behind a getSectionView(sectionIndex)
   helper that returns a uniform view for either C15 (via SYNTH_SECTIONS
   + groupOverrides) or non-C15 (via nonC15Sections + nonC15GroupCurves
   + engineParamOverrides).

   Group-level curve persists across sub-engine swaps by group name, so
   e.g. tuning the "ADSR 1" curve survives a switch between subtractive
   and fm without being reset.

ModularEngine: adds setExposeMatrixCell(s,d,exposed) +
getExposedMatrixCells() + clears exposed cells on sub-engine swap.
2026-04-11 08:31:19 +02:00
monkey-w1n5t0n
70c858016f fix(a-app): gate modular MLP routing on example count to preserve default patch
Modular mode's default patch sets MM_Matrix/s00_d08_amp = 1.0 via
_setRawByLabel at engine init, but on the first inference tick
routeOutputs writes the untrained MLP's output (~0.5 normalised) to
every paramMeta index. For matrix cells the raw range is [-1, 1], so
normalised 0.5 denormalises to 0 — clobbering the default amp routing
and killing all audio.

Skip the setParam loop for modular when iml.exampleCount === 0, so the
worklet keeps running on the default patch we already pushed at init.
Once the user captures at least one training example the MLP has a
target to reproduce, and routing resumes normally.

Only affects modular mode; other engines are unchanged.
2026-04-11 08:20:24 +02:00
monkey-w1n5t0n
e6938ebb8b fix(playground): modular worklet base-class loading + live matrix display
Two fixes for Modular mode:

1. faust-worklet-processor.js: explicitly attach FaustWorkletProcessor
   to globalThis. Class declarations at the top of a classic script are
   lexically scoped to that script's evaluation context and do not
   propagate across separate addModule() calls, so the base class was
   invisible to subclass processors when they loaded in AudioWorklet-
   GlobalScope. Symptom: "FaustWorkletProcessor is not defined" at the
   extends clause of modular-subtractive-processor.js.

2. modular-ui.js + a-app.js: wire live matrix cell updates. Phase C
   wired matrix cells for writes (click cycles, precise editor) but
   not for reads — the MLP-driven values never propagated to the DOM.
   modular-ui now exposes updateLive(outputs), called from routeOutputs
   on every inference tick. Throttled to ~20 fps internally to avoid
   DOM thrashing. Only visible sources (within adsrCount/lfoCount) are
   updated; muted slots stay dark.
2026-04-11 08:08:29 +02:00
monkey-w1n5t0n
2993328859 fix(a-app): bail out of showGroupDrawer for non-C15 engines
showGroupDrawer indexes into the hardcoded SYNTH_SECTIONS table and
groupOverrides, which only exist for the shaper-feedback engine. For
additive/fm/modular engines, paramToSection is rebuilt dynamically from
paramMeta groups, so region.index points into a larger dynamic section
list and SYNTH_SECTIONS[region.index] is undefined. Hovering a section
label in Modular mode threw "Cannot read properties of undefined
(reading 'name')".

These engines have their own param UIs; the C15 group drawer doesn't
apply. Guard and early-return.
2026-04-11 07:53:37 +02:00
monkey-w1n5t0n
afff406d92 feat(playground): add Modular audio mode with shared mod pool
New "Modular" engine in a-immersive with three hot-swappable Faust
sub-engines (subtractive/additive/fm) sharing a common modulation pool:
16 ADSR slots + 32 LFO slots (single-knob sine->tri->square->saw
wavemorph) routed through a 48-source x 10-destination matrix per
engine. Per-connection scalar amounts in [-1, 1], summed at each
destination. Default MLP output count is 512 (32 mod-source params +
480 matrix cells); model reinits on sub-engine swap, count change, or
engine-param exposure toggle.

Faust layer:
- mod-pool.lib: shared ADSR/LFO/source-bus library
- gen-modular-dsp.py: byte-reproducible generator (source of truth)
- modular-subtractive: faithful Minimoog (3 osc, ladder filter, no envs)
- modular-additive: 64-partial, spectral shape + formants, no envs/LFOs
- modular-fm: 4-op matrix + self-feedback, no envs
- All three share d08=amp, d09=pan conventions
- MODULAR_DESTINATIONS.md: authoritative destination table

JS layer:
- ModularEngine: self-contained SynthEngine with getState/setState,
  setSubEngine, setModSourceCount, setExposeEngineParam
- modular-ui: drawer with sub-engine toggle, ADSR/LFO count steppers,
  per-slot enable switches, matrix grid editor (tap-cycle, long-press
  precise, right-click menu, negative amounts), preset overlay
- modular-presets: 6 named presets (Slow pad, Plucky bass, Crystal,
  DX bell, Morphing drone + default)
- a-app.js: Modular mode registered, paramMeta:change -> resizeMLP,
  modular DSP state persisted under modularDspState, window.__nisps
  debug hooks for programmatic control

Tests: tests/e2e/modular-mode.spec.js (11 Playwright tests, all passing
including DSP state survives reload, sub-engine swap keeps paramCount,
preset apply verification).

Also fixes a pre-existing build.sh bug where the -e flag caused faust
to overwrite .wasm outputs with expanded DSP source text, leaving
additive/fm-matrix/eoc-* committed as invalid WebAssembly. Rebuilt all
affected engines with the corrected script. Added an early-message
buffer to faust-worklet-processor.js so setParam calls arriving before
wasm instantiation are queued rather than dropped (needed when the user
configures modular state before clicking Start Audio).
2026-04-11 07:34:41 +02:00
w1n5t0n
b72914813c test(shapeseq): add test runner + smoke tests
Shell runner for the shapeseq unit test suite and a smoke test that
exercises the core import paths.
2026-04-07 00:03:29 +01:00
w1n5t0n
1e9dda6e38 fix(a-app): create AudioCanvas before computing target output count
Move the lazy AudioCanvas initialization above the outputCountForMode()
call in setOutputMode() so that the real output count is available
instead of the 12-output fallback.
2026-04-07 00:01:37 +01:00
w1n5t0n
9ebfec8814 feat(shapeseq): session serialization + preset integration
Add serializeShapeSeqState/restoreShapeSeqState for complete ShapeSeq
state capture (chain config, primitive states, projection knobs, voice
mode, MLP weights, freeze state, clock config). SessionPresetManager
lazily imports the session module when a ShapeSeq engine is registered,
including ShapeSeq state in save/load without a hard dependency.
2026-04-07 00:01:12 +01:00
w1n5t0n
70da83d6dc feat(freeze-ui): freeze/live param toggle interaction
When frozen, param rows dim to indicate frozen state. Tapping a row
toggles it to "live" (highlighted, receives ML deltas). Unfreezing
clears all visual indicators.
2026-04-07 00:00:56 +01:00
w1n5t0n
8bb965740e feat(chain-ui): drawer layout + signal-based reactive updates
Replace the inline chain builder with a full-screen slide-up drawer
(overlay + glass backdrop + drag handle). Param sliders now subscribe
to a vanilla signal (createArraySignal) for O(changed params) updates
instead of querySelectorAll sweeps. Card list uses keyed reconciliation.
2026-04-06 23:59:25 +01:00
w1n5t0n
4240b03c1c feat(sequencer): mono/poly voice mode
Mono mode (default) kills the previous note before firing a new one —
classic monophonic synth behavior. Poly mode retriggers same-pitch
notes without killing others. All active notes are released on mode
switch.
2026-04-06 23:59:04 +01:00
w1n5t0n
4aedc92bf9 feat(shapeseq): unified/dual MLP mode switching
Unified mode: a single MLP drives both timbre and sequence — the timbre
MLP's outputs are partitioned and a configurable slice feeds the
sequencer pipeline via setSequenceOutputsFromTimbre().

Dual mode (default): separate MLPs for timbre and sequence with
independent training. The sequencer owns its own IML instance.
2026-04-06 23:57:41 +01:00
w1n5t0n
9b08918b1e feat(shapeseq): freeze-as-algorithm/pattern + delta controller
Two freeze modes for capturing and preserving musical state:

- Algorithm freeze: snapshot all primitive params + seeds. Individual
  params can be selectively unfrozen for ML-driven exploration via
  DeltaController (applies MLP deltas to frozen base values with
  per-param boundary enforcement).

- Pattern freeze: snapshot the realized note pattern. The sequencer
  bypasses the primitive chain entirely and loops the frozen pattern.

Extracts _runPipeline() from setSequenceInputs() for reuse.
2026-04-06 23:55:23 +01:00
w1n5t0n
122bdb9843 feat(sequencer): generation counter forces re-eval on config changes
The dirty-check that skips re-evaluation when inputs haven't changed now
also checks a generation counter. Config changes (tempo, step count,
projection, chain edits) bump the counter, ensuring the pipeline re-runs
even when joystick position is static.
2026-04-06 23:51:57 +01:00
w1n5t0n
5796d8fd35 feat(seq-iml): configurable output count with scaled architecture
The sequence MLP output count is now configurable (default 16). Hidden
layer widths scale with the output count to keep inference cheap while
providing sufficient capacity. The sequencer exposes setOutputCount()
to resize the MLP at runtime.
2026-04-06 23:45:55 +01:00
w1n5t0n
4cea170795 refactor(projection): simplify to 3 global knobs
Replace the composable transform chain (VelocityCurve, GateThreshold,
RangeMap, OctaveFolder, StutterMap, presets) with 3 straightforward
post-chain knobs: velocityCurve, gateThreshold, and pitchRange. The
old system was over-engineered for a research prototype — the new API
is easier to wire to UI controls and reason about.
2026-04-06 23:41:30 +01:00
w1n5t0n
af4f19bc72 refactor(PitchWalker): redesign as processor, add reEvalOnLoop
PitchWalker was classified as a generator but always consumed the
incoming pattern's triggers. Reclassify as a processor so it modifies
the pattern produced by an upstream generator (clonePattern instead of
createPattern). Add reEvalOnLoop flag on the Primitive base class; when
set, the sequencer re-evaluates the chain at each loop start so
stateful primitives produce evolving patterns across loops.
2026-04-06 23:37:32 +01:00
w1n5t0n
b4b4025f59 feat(shapeseq): polyrhythm — per-generator step counts with LCM tiling
Generators can now have independent step counts. When merging patterns
of different lengths, both are tiled to their LCM. Chain exposes
setGeneratorStepCount/getGeneratorStepCount for polyrhythmic control.
2026-04-06 23:31:45 +01:00
w1n5t0n
aec58537db feat(pattern): add midiNote field + trigger-aware merge
Replace the pitch*127 encoding hack with a dedicated midiNote field on
pattern steps. IntervalLock now writes integer MIDI notes to step.midiNote
while preserving the original [0,1] pitch for other consumers. The clock
and sequencer bridge pass midiNote through the event pipeline.

Also reworks mergePatterns to be trigger-aware: when only one generator
fires at a step, use its values directly instead of averaging with the
other generator's defaults.
2026-04-06 23:28:01 +01:00
w1n5t0n
3f4ffcd4f9 refactor(event-bus): extract to shared module
The EventBus was duplicated inside shapeseq/ but is used by other
playground code too. Move the canonical implementation to js/event-bus.js
and turn the shapeseq copy into a re-export shim.
2026-04-06 23:18:47 +01:00
w1n5t0n
41106f22c0 feat(audio-canvas): per-file loop count, beat-synced lengths, param range groups, pitch lock
- Per-file loop count (0-4) with stepper UI on each waveform cell;
  dynamic MLP output sizing (8 range + N_loops × 4 position outputs)
- Beat-synced loop length: global bars/beats selector (2/4/8/16/32/64)
  with per-loop overrides via long-press dropdown, persistent badges
- Global param range groups: dual-thumb range sliders (volume, pitch,
  position, filter) with ML-driven min/max and per-loop position mapping
- Pitch lock toggle (1×) to freeze playback rate across all submodes
- Fix: _resizeOutputs now syncs _loopOutputs to prevent stale reads
- Fix: granular visuals consolidated per-clip to prevent multi-loop flicker
2026-04-03 20:36:24 +01:00
w1n5t0n
960676acf3 feat(tests): add 24 Playwright e2e tests for engine switching + fix hidden param filtering
Tests cover:
- Default state (C15, 126 params, heatmap cells)
- Engine switcher UI (3 cards, active state)
- Switch to Additive (48 params, outputs bounded, training works)
- Switch to FM Matrix (55 params, outputs bounded, training works)
- Round-trip switching (C15→Add→C15, C15→FM→Add→C15)
- Warm-start weight preservation across resize
- EOC chain accessibility across engines
- SynthVisualizer visibility across engines
- No console errors during any switch

Also fixes: faustJsonToParamMeta now filters [hidden:1] params,
giving correct counts (48 additive, 55 FM) instead of including
freq/gate/_vel control inputs.
2026-04-03 19:28:23 +01:00
w1n5t0n
cb3bda034b fix(playground): FaustEngineBase creates AudioContext if none passed; add stop()/start() methods 2026-04-03 19:08:52 +01:00
w1n5t0n
3fabc342a0 fix(playground): getParamOverride uses engineParamOverrides for non-C15; guard undefined sectionMap entries 2026-04-03 19:01:14 +01:00
w1n5t0n
f19d5e5baa fix(playground): engine switch — eager paramMeta + engine-agnostic SynthVisualizer (meml-gdh8)
When switching from C15 to Additive or FM engines, the MLP was resized
to 0 outputs because paramMeta wasn't loaded until init() (which needs
AudioContext). The SynthVisualizer also disappeared because its section
map was hardcoded to C15's 18 param groups.

- Add FaustEngineBase.loadParamMeta() for eager JSON-only loading
- Call loadParamMeta() before setActiveEngine() in engine switch handler
- Add SynthVisualizer.rebuild(paramMeta) to derive sections from groups
- Add rebuildParamToSection() / restoreC15ParamToSection() for engine-agnostic
  param-to-section mapping
- Fix tooltip to use engineParamOverrides for non-C15 engines
2026-04-03 18:41:05 +01:00
w1n5t0n
78e9e64dee fix(playground): move engine switcher to synth drawer, fix output mode labels
- Engine switcher now lives in the Synth drawer (was incorrectly in NISPS params drawer)
- Params drawer renamed to "NISPS" (it's for ML engine tuning, not synth engine selection)
- Output mode button says "Synth" (generic) not "C15 Shaper-Feedback" (engine-specific)
- Dock icon for params drawer now labelled "NISPS" with correct tooltip
2026-04-03 18:30:20 +01:00
w1n5t0n
d5a315904a feat(playground): expand audio canvas import formats + add file picker button
Support opus, webm, mp4, wma, aif, aiff in addition to existing formats.
Add "+" button to control bar for importing files without drag-and-drop (mobile).
2026-04-03 18:29:59 +01:00
w1n5t0n
bc68ee64f8 Merge branch 'worktree-agent-a3abdb63' 2026-04-03 18:26:31 +01:00
w1n5t0n
329b326ede feat(playground): per-engine param presets for additive + FM (meml-ax4)
Additive engine: 5 presets across 3 tiers (Spectral Basics, Formant Play,
Harmonic Sculptor, Phase Explorer, Full Spectrum). FM engine: 5 presets
across 3 tiers (Simple FM, Bell Tones, Matrix Explorer, Feedback Machine,
Full Matrix).

Engine-aware preset system: applyPreset() dispatches to C15 groupOverrides
or new flat engineParamOverrides depending on active engine. Preset selector
dropdown rebuilds on engine switch. URL ?preset param searches all engines.
2026-04-03 18:26:12 +01:00
w1n5t0n
09991c030b Merge branch 'worktree-agent-a6e16a29' 2026-04-03 18:22:42 +01:00
w1n5t0n
8cf29fc7eb Merge branch 'worktree-agent-ab96c9fd' 2026-04-03 18:22:19 +01:00
w1n5t0n
97376c8d3f Merge branch 'worktree-agent-a188c7ec' 2026-04-03 18:22:19 +01:00
w1n5t0n
5ac67df699 Merge EOC Delay + Saturation + Master Bus modules (meml-2sl, meml-2zm)
Resolved conflicts: merged all 6 module imports in eoc-chain-ui.js and index.js.
All moduleFactory cases now return real Faust WASM implementations — no stubs remain.
2026-04-03 18:22:00 +01:00
w1n5t0n
2cda1a6178 feat(playground): EOC NISPS Independent mode — separate FX joystick (meml-vqo) 2026-04-03 18:21:59 +01:00
w1n5t0n
e01660c1d4 feat(playground): Linked mode training target UI — separate FX RL buttons (meml-0r0) 2026-04-03 18:20:52 +01:00
w1n5t0n
3c44b75416 fix(playground): decouple MIDIInput from C15Bridge — use SynthEngine interface (meml-se4) 2026-04-03 18:20:29 +01:00
w1n5t0n
a9e2b5a8f9 feat(playground/eoc): Delay + Saturation + Master Bus modules (meml-2sl, meml-2zm)
Faust DSP sources, compiled WASM + JSON descriptors, AudioWorklet processors,
and EOCModule subclasses for all three effects.
2026-04-03 18:19:32 +01:00
w1n5t0n
5a004c6a3e Merge Shared + Linked NISPS modes (meml-dqt, meml-vqg)
Resolved eoc:change listener conflict: both modes coexist in same handler.
Shared mode resizes combined MLP; Linked mode manages second IML.
2026-04-03 18:16:54 +01:00
w1n5t0n
e8cb8e4195 feat(playground): EOC NISPS Linked mode — dual MLP from same controller (meml-vqg) 2026-04-03 18:03:12 +01:00
w1n5t0n
4f44cd016d feat(playground): EOC NISPS Shared mode — combined MLP for synth + effects (meml-dqt)
- Add totalOutputCount() helper: engine params + EOC params when nispsMode === 'shared'
- setActiveEngine() calls resizeMLP(totalOutputCount()) and rebuilds heatmap with combined paramMeta
- eoc:change listener handles nispsMode-changed and module-* events: resizes MLP, rebuilds heatmap, toggles .shared-mode CSS class
- routeOutputs() slices outputs: indices < engineParamCount go to engine, remainder to eocChain.setParam() in Shared mode
- CSS: .heatmap-strip.shared-mode adds green bottom border and '+ FX' label to signal combined output space
2026-04-03 18:01:10 +01:00
w1n5t0n
0a5f2544ca feat(playground/eoc): EQ + Compressor + Reverb modules (meml-4b4, meml-cpe, meml-wwc)
Faust DSP sources, compiled WASM + JSON descriptors, AudioWorklet processors,
and EOCModule subclasses for all three effects.

- meml-4b4: 4-band parametric EQ (fi.low_shelf, fi.peak_eq × 2, fi.high_shelf)
  10 params: freq/gain per band, Q for the two mid bell bands
- meml-cpe: stereo feed-forward compressor (co.compressor_mono × 2)
  7 params: threshold, ratio, attack, release, knee, makeup, mix (parallel)
- meml-wwc: zita reverb (re.zita_rev1_stereo) with pre-delay and M/S width
  8 active params + mod_rate placeholder (zita internal mod not yet exposed)

All three DSPs compile cleanly under Faust 2.83.1. moduleFactory in
eoc-chain-ui.js now returns real instances for eq/compressor/reverb.
2026-04-03 17:57:36 +01:00
w1n5t0n
6007ab3853 Merge branch 'worktree-agent-ae813a27' 2026-04-03 17:57:05 +01:00
w1n5t0n
5e8102c8c3 feat(playground): Additive + FM engine integration, live engine switching (meml-mdy, meml-355)
- Add AdditiveEngine (48 params) and FMEngine (55 params) wrapping FaustEngineBase
- Remove comingSoon flags from ENGINES descriptors
- Replace stub onSwitch handler with real engine construction + init + setActiveEngine
- Fix setActiveEngine EOC rewire to work with both C15 (_bridge.audioContext) and Faust (_audioCtx)
- Fix _startEocChain audioCtx resolution to support Faust engines
- Fix synth param send loop: use N_OUTPUTS instead of hardcoded SYNTH_PARAM_MAP.length
2026-04-03 17:56:35 +01:00
w1n5t0n
6f1107bae2 feat(playground): engine-aware heatmap + MIDI CC storage on engine switch (meml-qaa)
- Add rebuildHeatmap(paramMeta): resets rawParamValues/_lastSentParams and rebuilds
  #heatmap-cells from engine paramMeta; colors from SYNTH_PARAM_COLORS for C15,
  _colorFromGroup() hash for other engines
- Replace static SYNTH_PARAM_NAMES/COLORS lookups in buildHeatmap, setHeatmapValue,
  and showParamPopup with activeEngine.paramMeta[index] lookups
- Scope MIDI CC localStorage key per engine via midiCCStorageKey()
- Add reloadMidiCCMap() helper; called in setActiveEngine() after engine switch
- loadCCMap/saveCCMap in midi-cc-map.js accept optional key parameter
2026-04-03 17:55:39 +01:00
w1n5t0n
25caf726d8 feat(playground): EOC chain wired into audio graph, bypass mode manual sliders (meml-xp3)
- Wire EOCChain into audio graph after c15.start() (both start-btn and
  quick-play paths); guarded by _eocInited flag so init happens once per
  AudioContext lifetime
- getOutputNode() on C15Adapter now returns limiter (last node before
  destination) so EOC inserts correctly between limiter and destination
- Expose limiterNode getter on C15Bridge
- Add getCurrentParamValue(i) to EOCModule base class, backed by _paramValues
  array that setParam() writes to
- Add _buildParamSliders() to EOCChainUI: collapsible ▶ params toggle per
  module row, one range slider per paramMeta entry, calls module.setParam()
  on input; labelled "manual" when nispsMode=bypass
- eoc:change listener in a-app.js logs paramCount (hook point for future modes)
- saveState()/loadState() persist eocModules (id, enabled, params) and
  eocNispsMode; restore re-adds modules via moduleFactory and re-applies values
- CSS: eoc-params-section, eoc-params-toggle, eoc-param-row/label/slider;
  eoc-module-row gains flex-wrap to accommodate params section below controls
2026-04-03 17:49:14 +01:00
w1n5t0n
f1b6e1aba0 feat(playground): engine switcher UI component (meml-phg)
Adds EngineSwitcher module with clickable engine cards (active highlight,
coming-soon greyed state, loading bar animation, confirm-on-switch when
training data exists). Wired into the Engine drawer; C15 active,
Additive/FM marked coming-soon with toast. Dock title reflects active
engine; engineId persisted to/restored from localStorage.
2026-04-03 17:49:07 +01:00
w1n5t0n
46e1e59aaf feat(playground): EOC chain drawer UI with drag-and-drop + bypass toggles (meml-u6x)
Adds eoc-chain-ui.js with NISPS mode selector, draggable module rows, bypass
pill toggles, remove buttons, param count badges, add-module bar, and stub
moduleFactory. Wires into a-immersive.html dock + drawer-stack and a-app.js init.
2026-04-03 17:42:59 +01:00
w1n5t0n
70d807cdce feat(playground): EOC module base class + chain container (meml-vx2)
Adds playground/js/eoc/ with EOCModule base class, EOCChain container,
and barrel index. Implements bypass GainNode graph, connect/rewire
algorithm, ordered module management, flat NISPS param routing, and
eoc:change CustomEvent dispatch on structural changes.
2026-04-03 17:38:22 +01:00
w1n5t0n
7cbda2304b feat(playground): dynamic engine label in output mode toggle (meml-i40) 2026-04-03 17:34:14 +01:00