Skip to content

Route mc.zerr.combinator~ through the core EnvelopeCombinator - #19

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

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

Conversation

@zeyuyang42

Copy link
Copy Markdown
Collaborator

The problem

mc.zerr.combinator~ was faking mode selection.

  • zerr_combinator_new parsed the mode symbol into a local, left a // TODO: Initialize the ZerrCombinator based on the mode, and dropped it. Only the atom type was checked, so mc.zerr.combinator~ 2 banana instantiated happily.
  • x->zc was set to NULL and never assigned — zerr::EnvelopeCombinator was never constructed anywhere in the Max tree. zerr_combinator.hpp had its unique_ptr initializer commented out with a stale signature.
  • perform64 hardcoded a max-combination loop, so 2 root and 2 max produced identical output — while the help patch shipped both side by side as if they differed.
  • bang posted the literal string "everything" as the mode.

Pd was already doing this correctly (puredata/src/zerr_combinator~.cppzerr_combinator.cpp → core), so this brings Max in line.

What changed

ZerrCombinator now owns a real zerr::EnvelopeCombinator, built in two stages because Max hands over the object's shape in stages — the number of envelope sets is known at creation, but the channel count, sample rate and block size only arrive in dsp64. The constructor validates; prepare() (called from dsp64, where allocating is safe) builds the core module, is idempotent so toggling DSP does not reallocate, and rebuilds when the shape changes.

perform() is noexcept now — it runs inside Max's C call stack, where the previous throw std::invalid_argument would have terminated the host. Copies are clamped to the prepared block size.

Arguments: mode is optional and defaults to max (what the object has always computed, so no saved patch changes). Positional args stop at the first @attribute. The mode value is validated; a bad one reports the error and falls back rather than returning NULL, since a typo shouldn't leave a dead object box with no inlets.

Fixed in passing: dsp64 used to return without dsp_add64 when the inlets disagreed on channel count, making the muting branch in perform64 unreachable and leaving the outlet holding whatever the previous DSP chain wrote — so muting never actually silenced. And chans was a writable attribute shadowing a signal-derived value; it is read-only now. Sample rate and block size come from the dsp64 arguments rather than sys_getsr()/sys_getblksize(), which differ inside poly~.

Help patch: the description was copied verbatim from the Pd three-argument object and described arguments Max does not take. Rewritten for the real two-argument form, add documented, and root corrected — it is the Nth root of N sets multiplied, not a square root (which only coincides for two sets).

⚠️ Behaviour change

mc.zerr.combinator~ 2 root used to compute max and now computes the geometric mean, so existing patches using root will get quieter output. That is the fix, but it changes mixes.

Verification

Driven through the real ZerrCombinator API the same way the external does, built against this branch's libzerr_core.a under ASan + UBSan (clean):

Two sets of constants 0.25 and 0.81, three envelopes per set:

mode output expected
max 0.8100 0.81
add 1.0600 1.06
root 0.4500 √(0.25×0.81) = 0.45

Before this PR all three read 0.81.

Lifecycle, all passing: perform() before prepare() is silent; prepare() is idempotent on repeat; re-prepare across 3→8 channels and 64→128 block size keeps correct output; sampleframes smaller than the prepared block size is handled; prepare(0 channels) is rejected; too-few input pointers silences instead of reading out of bounds. A bogus mode is rejected at construction, not at DSP start.

Not yet exercised in Max itself — the listening pass is still to do.

Not in this PR

Runtime mode switching (the "add message" half of the task) needs a setter on the core EnvelopeCombinator, whose mode is currently bound once in initialize(). That is a separate change, along with Pd parity for the message. Mode remains construction-time on both platforms.

Routing through the core also reintroduces a per-block Blocks copy, since EnvelopeCombinator::perform takes and returns by value — a core signature change, so it belongs with the same follow-up (and overlaps board ID 140).

Refs Zerr* board ID 136.

🤖 Generated with Claude Code

The mode argument was parsed and thrown away. `zerr_combinator_new` read the
symbol into a local, left a `// TODO: Initialize the ZerrCombinator based on the
mode`, and never constructed a ZerrCombinator at all -- `x->zc` stayed NULL for
the object's whole life. `perform64` hardcoded a max-combination loop, so
`mc.zerr.combinator~ 2 root` and `2 max` computed exactly the same thing while
the help patch shipped both side by side as if they differed. `bang` reported
the literal string "everything" as the mode.

Make the object real. ZerrCombinator now owns a zerr::EnvelopeCombinator and is
built in two stages, because Max hands over the object's shape in stages: the
number of envelope sets is known at creation, but the channel count, sample rate
and block size only arrive in dsp64. So the constructor validates, and prepare()
-- called from dsp64, where allocating is safe -- builds the core module. It is
idempotent, so toggling DSP does not reallocate, and it rebuilds when the
channel count or block size changes.

perform() is now noexcept: it runs inside Max's C call stack, where the previous
`throw std::invalid_argument` would have terminated the host. Anything
unexpected zeroes the output instead. Copies are clamped to the prepared block
size.

Also on the argument path: the mode is now optional and defaults to "max", which
is what the object has always computed, so no saved patch changes behaviour.
Positional arguments stop at the first @Attribute. The mode value is validated
rather than just its atom type -- `2 banana` used to instantiate happily. A bad
mode reports the error and falls back rather than returning NULL, since a typo
should not leave a dead object box with no inlets.

Two things fixed in passing:

- dsp64 returned without dsp_add64 when the inlets disagreed on channel count,
  which made the muting branch in perform64 unreachable and left the outlet
  holding whatever the previous DSP chain wrote. The perform routine is now
  always added, so muting actually silences.
- `chans` was a writable attribute shadowing a value derived from the signal;
  setting it only desynced the object. It is read-only now.

The sample rate and block size come from the dsp64 arguments rather than
sys_getsr()/sys_getblksize(), which differ inside poly~.

Help patch: the description was copied verbatim from the Pd three-argument
object and described arguments Max does not take. Rewritten for the real
two-argument form, "add" documented, and "root" corrected -- it is the Nth root
of N sets multiplied, not a square root, which only coincides for two sets.

Runtime mode switching needs a setter on the core module and is left for a
follow-up; mode is still fixed at construction on both platforms.

Note for anyone with existing patches: `2 root` used to compute max and now
computes the geometric mean, so it will be quieter.

Refs Zerr* board ID 136.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* @throws std::invalid_argument if inputCount < 1 or mode is not a known mode
*/
ZerrCombinator(const zerr::SystemConfigs& sys_config, int inputCount, std::string mode)
: systemConfigs { sys_config }

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

where does this external get the system config if you have delete it? just explain, don't rush to change anything

if (!combinator->initialize()) {
return false;
};
return mode == "add" || mode == "root" || mode == "max";

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this wraper should be thiner, so for now if I added a new mode in core I still need to update here. also explain first.

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