Skip to content

SDK: a card-build counterpart of ev_trig_muted (#777) - #1312

Merged
repeat98 merged 2 commits into
repeat98:mainfrom
timhastie:sdk-card-trig-muted
Nov 8, 2026
Merged

repeat98 merged 2 commits into
repeat98:mainfrom
timhastie:sdk-card-trig-muted

Conversation

@timhastie

Copy link
Copy Markdown
Contributor

SDK: a card-build counterpart of ev_trig_muted (#777)

@repeat98 @Zac-Kyoti, this is the SDK part of option 2 on #777: a meeting point where Mute Modes can answer and FM Synth can ask on a card build, with neither module reading the other's code or data. It changes no module and no card image.

What is in

sdk/machines/octatrack/card/trig_muted.inc (GNU as) defines one symbol, modwerk_card_ev_trig_muted.

  • The answer is unsigned f(void) in the C convention and writes nothing. Bits 0-7 have the same meaning as in ev_trig_muted: bit t means track t+1's new trigs are dropped now.
  • Bits 8-15 are new. They mark which of those tracks also have their sounding note ended at the mute, as Mute Modes ends a sample track's note in OTFX-T with a note-off. FM Synth holds its own voice and ignores that note-off, so without this byte it cannot tell OTFX-T from DT-T. The base event ignores bits above 7 today, so the USB answer could take the same byte later.
  • The ask is a macro, MODWERK_CARD_ASK_TRIG_MUTED. It makes a weak reference to the answer and returns 0 when no module answers, so the machine plays every trig, as stock does.
  • Placement. Both the answer and the ask sit in DRAM units. Every card build links its DRAM units in one link, natively in remix/platform_build.py and in the configurator in coldfire-link.ts, so the two meet whatever the module order. ROM units can't take part: the native builder links them one at a time before the DRAM link, and the configurator links them after it.
  • One answer per image. Both linkers refuse a second definition. An OR over several answering modules would need a table in the builder; I left that out until a second mute module exists.
  • Docs. card/README.md, a line in sdk/machines/octatrack/README.md, the muted-trigs item in docs/module-guides/machines.md, and the changelog entry.

Tests

  • sdk/tests/test_card_trig_muted.py links an asking unit, an answering unit and a bystander unit with the native DRAM link (platform_build.link_runtime). It covers neither unit, asker only, answerer only, both, and both in the other order.
    • The asker alone loads 0.
    • The answerer alone is linked, and nothing calls it.
    • Together, the ask loads the answer's address in either order.
    • The bystander's bytes never change.
    • A second answer is refused.
    • It also checks the contract against events.json.
  • src/engine/card-trig-muted.test.ts links the same objects with the configurator's linker. It gives GNU's bytes and symbols in all five cases and refuses a second answer.
  • npm run sdk:check: 568 tests OK. modules:generate leaves the tree unchanged. modules:check -- --base origin/main and sdk:imports:check pass. In npm run pr:ready on f577d62 every stage passes except vitest, whose failures were timeouts on a loaded machine. Those test files pass when re-run under Node 24 with a long timeout (122 files, 1,115 tests).

End to end, on prototypes that are not in this PR

I wrote local prototypes of both sides to check that the contract does the job:

I built them with the native exporter and ran them on ot_emu (MKI, DSP lockstep). T1 was FM SYNTH and T2 a FLEX sine with the same trigs, both muted mid-pattern:

MUTE MODE 0.1.4 + Mute Modes 0.2.2 with the ask and the answer
DT-T: the six trigs after the mute all play, -27.0 dBFS peak none, -90.3 dBFS
DT-T: the note sounding at the mute (HOLD INF) rides on, -30.1 dBFS rides on, -30.1 dBFS
DT-T: after STOP, still muted rings, -30.0 dBFS ended, -106.2 dBFS
OTFX-T: the six trigs after the mute all play, -27.0 dBFS none, -90.3 dBFS
OTFX-T: the note sounding at the mute rings on, -30.1 dBFS cut: -60 dBFS within 20 ms, under -80 dBFS by 130 ms
OT and OTFX not run silent after the mute (answer 0)

T2, the plain sample, behaved the same in both images.

The image with both prototypes differs from the 0.1.4 + Mute Modes image in 3 ROM bytes (one detour target) plus the runtime. FM Synth alone with the ask differs from #1146's image in the same 3 bytes, and its ask loads 0.

One question before anyone builds on it

A DRAM unit reserves 1,707 pages (10,487,808 B) of the sample arena (coldfire-runtime.ts:28). So a card build with Mute Modes but no other DRAM unit loses that much sample memory just to carry an answer nobody asks. Beside FM Synth, which already has a DRAM unit, the answer adds only its own bytes. Jannik, which would you prefer?

  1. Accept that cost.
  2. A small builder flag so the answer is linked only when another requested DRAM unit is already linked. Every asker is a DRAM unit, so the answer would then cost nothing extra. I can add it here or as its own PR.
  3. Let a ROM unit answer. That needs the configurator to link ROM units before the DRAM link, or to pass ROM symbols into it.

Zac, thanks for agreeing on #777. Are you OK with the meaning of bits 8-15? On your STOP point: with both prototypes, a muted FM note at HOLD INF ends at STOP (the DT-T row above). FM Synth's ask comes as its own release after this lands, and nothing here changes 0.1.4.

A card build has no base events. sdk/machines/octatrack/card/trig_muted.inc names one
meeting point, modwerk_card_ev_trig_muted: a mute module answers it from a DRAM unit, a
machine asks it from a DRAM unit (a weak reference), and with either one missing the ask is
0 and nothing changes in what plays. Bits 0-7 are ev_trig_muted's answer; bits 8-15 name the tracks whose
sounding note the mute ends (OTFX-T's note-off), which an engine that keeps its own notes
needs. Host tests: the native DRAM link and the configurator's linker give the same bytes for
every combination and refuse a second answer.
@Zac-Kyoti

Copy link
Copy Markdown
Contributor

@timhastie @repeat98 Thanks, Tim. This does the job, and the prototype numbers are convincing.

Bits 8-15: yes. Mute Modes already ends a sample track's sounding note at an OTFX-T mute, so the byte describes existing behaviour. I'd like the contract to pin three things down, so the two sides can't drift:

  1. A state, not a one-frame event. Bit 8+t is set in every frame where bit t is set and MUTE MODE is OTFX-T. It's never set without bit t, and never in DT-T. The machine ends a sounding note when it first sees the bit set, and starts nothing while it stays set.
  2. The SIDE-CHAIN key track. A key track's bits are clear in both bytes: its trigs and its note carry on, because the compressor still hears it. That's the same exemption fresh_bind makes (KEYMASK).
  3. Unmute. When a track's bits clear, the machine plays its next trig as usual. A note it cut stays cut.

Placement: option 2, please. With option 1, every card build with Mute Modes and no other DRAM module would lose about 10 MB of sample memory for an answer nobody asks. #1195 already adds a "link only when another module carries a runtime" rule (placement.json → joinRuntime, tools/build/runtime_join.py natively, createStaticColdFireRuntime in the configurator). Today it's restricted to a stock-DSP effect's descriptor units, but extending it to an answer unit with no ROM references is a small change. One rule would be better than two flags. Happy to do that extension in #1195 or after it, whichever Jannik prefers.

One catch for Mute Modes' real answer. fresh_bind's test reads KEYMASK, which lives inside the mm_softmute unit (a cave/ROM unit on card builds, next to SHADOW and UNHELD). If the answer has to be a DRAM unit, it can't name that byte in the configurator, which links ROM units after the DRAM link. Natively, ROM symbols do reach the DRAM link. Your prototype used a stand-in test, so it didn't hit this. Options I see:

  • the configurator passes the ROM units' symbols into the DRAM link (your option 3's prerequisite);
  • or Mute Modes keeps KEYMASK in its DRAM answer unit and its ROM code reads it there, which has the same linking problem in reverse;
  • or mm_softmute itself joins the runtime when an asker is present.

Jannik, which would you rather? The first seems cleanest, and #1195 already resolves a joined unit's symbols on the configurator side, so it's close.

@repeat98

repeat98 commented Nov 8, 2026

Copy link
Copy Markdown
Owner

Thanks Tim, and thanks Zac for the quick read.

FM Synth's ask and Mute Modes' answer then come as their own releases.

…key track clear, unmute)

Zac's three points from repeat98#1312, in card/README.md and the trig_muted.inc header. The
configurator fixture follows the header's new digest.
@timhastie

Copy link
Copy Markdown
Contributor Author

Thanks Zac and Jannik. I've added Zac's three points to card/README.md and the header of trig_muted.inc:

  • bits 8-15 are a state;
  • a SIDE-CHAIN key track's bits are clear in both bytes;
  • on unmute the next trig plays, and a cut note stays cut.

The host tests pass on both linkers. Zac, if you'd rather not take the configurator fix, let me know and I'll do it. I'll write FM Synth's ask once this is in.

@repeat98
repeat98 merged commit d1d5e00 into repeat98:main Nov 8, 2026
8 checks passed
@repeat98

repeat98 commented Nov 8, 2026

Copy link
Copy Markdown
Owner

Merged, thanks Tim, and thanks Zac for pinning the contract down.

Next steps, each in its own pull request: Zac's joinRuntime extension for an answer unit and the configurator fix that gives the DRAM link the ROM units' symbols (with the same-bytes test), then Mute Modes' answer and FM Synth's ask as their own releases.

@Zac-Kyoti

Copy link
Copy Markdown
Contributor

@timhastie @repeat98 I'll take the configurator fix (the DRAM link gets the ROM units' symbols, with the same-bytes test against the native builder) as its own Fix: pull request on current main. I'm starting on it now. Then the joinRuntime extension for an answer unit once #1195 is in. Thanks for writing the contract up, Tim.

repeat98 pushed a commit that referenced this pull request Nov 10, 2026
…e native builder does (#1312)

A reference a DRAM unit makes that no DRAM unit, platform symbol or region defines resolves from
the ROM units' global symbols. Native: tools/build/dram_rom_refs.py, applied by build_bus.py to its
DRAM link. Configurator: coldfire-runtime.ts resolveRomReferences; planStaticOs links provisionally,
places the menus (composeModuleMenus returns romSymbols), then relinks with them and checks the
layout is unchanged. A build that linked before has no such reference, so nothing else changes.
Same-bytes test: sdk/tests/test_dram_rom_symbols.py writes the fixture from GNU's link,
src/engine/dram-rom-symbols.test.ts links it with the configurator.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
repeat98 added a commit that referenced this pull request Nov 10, 2026
Fix: the configurator's DRAM link takes the ROM units' symbols, as the native builder does (#1312)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants