Skip to content

Make the combination mode switchable at runtime - #20

Open
zeyuyang42 wants to merge 1 commit into
combinator-core-static-modefrom
combinator-runtime-mode
Open

zeyuyang42 wants to merge 1 commit into
combinator-core-static-modefrom
combinator-runtime-mode

Conversation

@zeyuyang42

Copy link
Copy Markdown
Collaborator

Stacked on #19base is combinator-core-static-mode, not main. Review #19 first; this diff only shows the delta.

Why the core had to change

Mode was bound once in EnvelopeCombinator::initialize(), which resolved a string to a member-function pointer. Nothing could change it afterwards, so the "add a message for mode change" half of the task needed a core setter before the Max side could do anything.

Core

ProcessFunction processFunc → a CombMode enum in a std::atomic, dispatched by a switch in perform().

Deliberately not std::atomic<ProcessFunction>: a pointer-to-member-function is 16 bytes on the Itanium ABI, so that atomic would not be lock-free and set_mode() could block the audio thread on an internal lock. A static_assert pins the requirement. perform() loads the mode once per block, so a block is never processed partly in one mode and partly in another.

This also removes existing UB: processFunc had no in-class initializer, so if (processFunc) in perform() was reading an indeterminate value, not testing for null. The mode now has a valid default, and a prepared flag records whether initialize() actually ran.

Pd is unaffected — the constructor signature is unchanged and initialize() still returns false on an unknown mode, which is exactly what puredata/src/zerr_combinator.cpp:34 gates on. Verified by building it.

Addressing the review comment on #19

You flagged that the wrapper should be thinner — adding a mode in core shouldn't require editing the Max side. Fixed: CombMode, tryParseCombMode/parseCombMode, toString and combModeNames now live in types.h beside the existing parseGenMode. The wrapper's private mode table and the hardcoded "add, root or max" error string are both gone. Adding a mode is now a one-place edit.

Two parse entry points on purpose: the throwing one for construction, the noexcept one for the message handler, which runs on a host's C stack where an escaping exception would terminate the process.

Max

A mode message plus a matching mode attribute, both routed through one helper so they can't diverge — mode root, @mode root and the inspector all work. No defer_low: the core publishes through a lock-free atomic, so switching mid-stream is safe and deferring would only add latency.

Performance

perform now takes and returns by reference. #19 had reintroduced a per-block Blocks copy in each direction; the wrapper reads the core's output buffer directly and no longer keeps one of its own. The remaining input copy is elementwise and allocation-free.

Verification

Driven through the real wrapper API against this branch's core.

Runtime switching, two sets of constants 0.25 / 0.81:

action output expected
start max 0.8100 0.81
setMode root 0.4500 0.45
setMode add 1.0600 1.06
setMode max 0.8100 0.81
setMode banana rejected, 0.8100 unchanged

Also passing: a mode set before DSP ever started is applied by the next prepare(); a reshape (3→8 channels) preserves a mode set at runtime; all the #19 lifecycle cases still pass.

Thread safety: 30,000 set_mode() calls from a control thread while perform() ran continuously on another — clean under ThreadSanitizer, no race reports. Also clean under ASan + UBSan.

All three targets (core, maxmsp, puredata) build with zero errors.

Not yet exercised in Max — the listening pass on the new message boxes is still to do.

Not in this PR

Pd parity for the mode message (now ~20 lines, since the core supports it), and three pre-existing Pd bugs found while reading puredata/src/zerr_combinator~.cpp: the leaked strdup'd mode at :36, a freebytes size mismatch at :66 against the getbytes at :45, and silent return NULL on every bad-arg path. Those stack next.

Refs Zerr* board ID 136.

🤖 Generated with Claude Code

The mode was bound once in EnvelopeCombinator::initialize(), which resolved a
string to a member-function pointer. Nothing could change it afterwards, so
"add a message for mode change" needed the core to grow a setter first.

Replace the member-function pointer with a CombMode enum held in a
std::atomic, dispatched by a switch in perform(). Not std::atomic of the
pointer-to-member: that is 16 bytes on the Itanium ABI and would not be
lock-free, so set_mode() could block the audio thread on an internal lock. A
static_assert pins the lock-free requirement. perform() loads the mode once per
block, so a block is never processed partly in one mode and partly in another,
and set_mode() is safe to call from a control thread with DSP running.

This also removes existing UB. processFunc had no in-class initializer, so the
`if (processFunc)` guard in perform() was reading an indeterminate value rather
than testing for null. The mode now has a valid default and a `prepared` flag
records whether initialize() actually ran.

The constructor signature is unchanged and initialize() still returns false on
an unknown mode name, which is what the Pd wrapper gates on, so Pd is unaffected
-- verified by building it.

CombMode, tryParseCombMode/parseCombMode, toString and combModeNames live in
types.h next to the existing GenMode parser. That answers the review point on
the previous commit: the Max wrapper had its own copy of the mode-name table so
that a typo could be caught at object-creation time rather than at DSP start.
Both the table and the hardcoded "add, root or max" error text are gone, and
adding a mode is now a one-place edit in types.h. The two parse entry points are
deliberate -- the throwing one for construction, the noexcept one for the
message handler, which runs on a host's C stack where an escaping exception
would terminate the process.

On the Max side: a "mode" message plus a matching "mode" attribute, both routed
through one helper so they cannot diverge, so `mode root`, `@mode root` and the
inspector all work. No deferral -- the core publishes through a lock-free
atomic, so switching mid-stream is safe and deferring would only add latency.

EnvelopeCombinator::perform now takes and returns by reference. Routing Max
through the core had reintroduced a per-block copy in each direction; the Max
wrapper reads the core's output buffer directly and no longer keeps an output
buffer of its own. The remaining input copy is elementwise and allocation-free.

Help patch: mode message boxes wired into the left inlet of the `2 max` example.

Refs Zerr* board ID 136.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.

1 participant