Skip to content

Configurable note-priority / trigger / voicing for the polyphonic engine (fixes #252) - #1273

Open
thopman wants to merge 1 commit into
grame-cncm:master-devfrom
thopman:pr-note-priority
Open

Configurable note-priority / trigger / voicing for the polyphonic engine (fixes #252)#1273
thopman wants to merge 1 commit into
grame-cncm:master-devfrom
thopman:pr-note-priority

Conversation

@thopman

@thopman thopman commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Summary

Faust's -nvoices engine currently has one fixed voice-allocation behaviour. It works well
for true polyphony but is wrong for monophonic and duophonic instruments: with a single
sounding voice, holding note A, pressing then releasing B leaves silence instead of
falling back to the still-held A — the long-standing complaint in #252. Classic mono synths
instead return to the held note (last/low/high-note priority), and let you choose the
retrigger behaviour.

This PR adds a small, opt-in note-allocation policy to mydsp_poly so the polyphonic engine
behaves correctly for mono, duo, and poly as one unified mechanism. Everything is
selected per-DSP via declare options metadata and all defaults reproduce the current
behaviour
, so existing patches are unaffected.

Fixes #252.

New declare options metadata

[voicing:mono|duo|poly]          - how many notes may sound at once (default: poly)
[note_priority:last|low|high]    - which held notes win (Prophet / Moog / Korg)   (default: last)
[trigger:multi|single]           - envelope re-strike vs legato pitch-steer        (default: multi)
[steal:oldest|quietest|low|high] - voice-reuse tiebreaker on true overflow          (default: oldest)

Example — an authentic last-note-priority mono synth with legato:

declare options "[nvoices:3][voicing:mono][note_priority:last][trigger:single]";

Design

  • A held-note stack (NoteInfo) tracks every physically-down key, independent of voice
    allocation — the "memory" that was missing.
  • updateVoices() picks the target set (≤ fMaxSounding held notes, by priority) and
    reconciles voices: notes newly in the set are assigned (attacking, or legato pitch-steer
    under single trigger); notes that left it are released. mono/duo/poly are the same code
    with a budget of 1/2/N.
  • voicing is kept separate from nvoices: mono/duo cap the sounding count but keep the
    physical voice pool, so the existing half-buffer legato crossfade still has a spare
    instance to fade between.
  • Self-configuring: mydsp_poly reads its policy from the DSP's own metadata in its
    constructor (an explicit PolyPolicy* argument still overrides). This is why the change
    touches only the engine — every architecture (jack, alsa, pipewire, coreaudio, portaudio,
    bela, juce, vst, max, unity, the dsp_poly_factory for llvm/interp/wasm, …) picks up the
    options with no per-architecture changes.

Scope

Two files, both in architecture/faust/:

  • dsp/poly-dsp.h — held-note stack, updateVoices(), steal-mode selection, self-configuring
    constructor.
  • gui/MidiUI.h — the policy enums, PolyPolicy, and a metadata-parsing MidiMeta::analyse
    overload.

No other architecture files are modified.

Backward compatibility

With default options the engine is behaviourally identical to before. Verified at the sample
level: the tests/architecture-tests organ regression (2- and 8-voice, via faust2dummy)
produces bit-identical output to master-dev — including the 2-voice case that exercises
voice-stealing.

Testing

  • Deterministic host harness (31 assertions): mono last/low/high fall-back (the monophony handling #252 fix),
    duophony, single-vs-multi trigger, poly note-stack overflow, and a default-poly regression.
  • Metadata-parsing test through MidiMeta::analyse.
  • End-to-end faust2jack poly+MIDI build (compiles and links against the real architecture,
    with -DPOLY — an early version of this branch had an enum value named POLY that
    collided with that macro; the enum values are now PascalCase to avoid any macro clash).
  • architecture-tests organ 2/8-voice output bit-identical to master-dev (above).
  • Formatting: git clang-format against the repo .clang-format (no changes reported).

Documentation

I've kept this PR to the code. If the direction looks good, I'm happy to follow up with a
docs PR to faustdoc describing the new [voicing] / [note_priority] / [trigger] /
[steal] options — happy to align on wording/placement first.

if it wasn't obvious: co-authored by Claude, but fully validated by me manually and automated deterministic tests.

…ame-cncm#252)

Adds a held-note stack and a reconciliation core to mydsp_poly so the
polyphonic engine behaves correctly for monophonic and duophonic instruments,
not just true polyphony. Fixes the long-standing issue grame-cncm#252: with a single
sounding voice, holding note A, pressing then releasing B now falls back to the
still-held A instead of going silent.

New per-DSP 'declare options' metadata (all default to the historical behavior,
so existing patches are unaffected):

  [voicing:mono|duo|poly]          - simultaneously-sounding note budget
  [note_priority:last|low|high]    - which held notes sound (Prophet / Moog / Korg)
  [trigger:multi|single]           - envelope re-strike vs legato pitch-steer
  [steal:oldest|quietest|low|high] - voice-reuse tiebreaker on overflow

Design:
  * A held-note stack (NoteInfo) tracks every physically-down key, independent of
    voice allocation (the missing "memory").
  * updateVoices() selects the target set (<= fMaxSounding held notes, by
    priority) and reconciles voices: new target notes are assigned (attacking, or
    legato pitch-steer under single trigger), notes that left the set are
    released. mono/duo/poly are the same mechanism with a different budget.
  * voicing is separate from nvoices: mono/duo cap the sounding count but keep the
    physical voice pool so the legato crossfade still has a spare instance.
  * getFreeVoice() honors the steal policy.

The policy is parsed by a new MidiMeta::analyse() overload. mydsp_poly reads it
from the DSP's own metadata in its constructor (an explicit PolyPolicy argument
overrides), so every host architecture honors the options with no per-architecture
changes.

Validated by a deterministic host harness (31 assertions: mono last/low/high
fall-back, duophony, single-vs-multi trigger, poly note-stack overflow, and a
default-poly regression) plus an end-to-end metadata-parsing test.
@sletz

sletz commented Jul 10, 2026

Copy link
Copy Markdown
Member

Thanks for the proposal, some questions:

@thopman

thopman commented Jul 10, 2026

Copy link
Copy Markdown
Contributor Author

Hi Stephane,

Preparing for a short vacation. Got my laptop with me. Will come back to this somewhere over the weekend.

Greetings,

Thomas

@thopman

thopman commented Jul 13, 2026

Copy link
Copy Markdown
Contributor Author

Hi Stephane,

  • what is the semantic of duo mode ?

Duo is classic duophony like the ARP Odyssey: at most 2 notes sound at the same time, chosen from all held keys by the same note_priority rule (last/low/high). Release one of the two and a still-held third note takes its place — the same fallback as mono, just with a budget of 2. nvoices stays the physical voice pool; duo only caps how many sound, so the legato hand-off always has a spare voice to crossfade into.

Disclaimer: mono needs 3 voices (one sounding, one for the legato/steal hand-off, one for the ringing release tail) and duo needs 4 voices (two sounding, plus the same hand-off and tail spares) — voicing caps how many notes sound, not how many voices the engine keeps alive.

Different problem (stale voice-internal state vs. note-to-voice assignment), so no — but the same bug exists in the C++ engine too, and this PR's single keyOn choke point is exactly where your snap-on-keyOn fix would go, in both codebases. Happy to do that as a follow-up.

  • the code is much more complex: any idea on how it behaves with heavy uses-cases ? (lot of notes playing together, lots of voices...) ? How the default mode (similar to previous one) behaves with the new code ? Any way to measure all that ?

You're correct i did a bunch of tests and the event path in this PR is not good enough. I benchmarked it against stock — the audio path is untouched, but keyOn cost up to ~3 µs worst case where stock takes 40 ns, it allocated memory on every note event, and it silently changed default poly behaviour on voice overflow. I don't want to ship that.

So I reworked it, two changes:

The engine is opt-in via the metadata. Declare none of the options and the original code runs — the new engine isn't just cheap then, it doesn't run at all. I verified this byte-for-byte against an unpatched Faust on every commit, and it measures the same 40 ns everywhere.
The opt-in engine got optimized: no allocations after construction, mono/duo cost 60–200 ns per note event, poly absolute worst case ~450 ns. One 64-sample block is 1.3 ms, so this is nothing.
The benchmark and the regression gate (behavioral tests + byte-compare against golden renders) are part of the work — if you want i can share them

How should we go forward? I can rework this PR in place or close it and open a fresh one with the final design. Your call — I'd rather tailor it to what fits Faust than defend my first version.

very happy to help with this.

  • and yes the faustdoc would have to be updated then.

same applies here.

@sletz

sletz commented Jul 14, 2026

Copy link
Copy Markdown
Member

Disclaimer: mono needs 3 voices (one sounding, one for the legato/steal hand-off, one for the ringing release tail) and duo needs 4 voices (two sounding, plus the same hand-off and tail spares) — voicing caps how many notes sound, not how many voices the engine keeps alive.

So nvoices will only make sense in poly mode right ?

You're correct i did a bunch of tests and the event path in this PR is not good enough. I benchmarked it against stock — the audio path is untouched, but keyOn cost up to ~3 µs worst case where stock takes 40 ns, it allocated memory on every note event, and it silently changed default poly behaviour on voice overflow. I don't want to ship that.

So I reworked it, two changes:

The engine is opt-in via the metadata. Declare none of the options and the original code runs — the new engine isn't just cheap then, it doesn't run at all. I verified this byte-for-byte against an unpatched Faust on every commit, and it measures the same 40 ns everywhere. The opt-in engine got optimized: no allocations after construction, mono/duo cost 60–200 ns per note event, poly absolute worst case ~450 ns. One 64-sample block is 1.3 ms, so this is nothing. The benchmark and the regression gate (behavioral tests + byte-compare against golden renders) are part of the work — if you want i can share them

How should we go forward? I can rework this PR in place or close it and open a fresh one with the final design. Your call — I'd rather tailor it to what fits Faust than defend my first version.

Better open a fresh one, thanks.

very happy to help with this.

OK thanks.

  • and yes the faustdoc would have to be updated then.

same applies here.

OK thanks.

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.

monophony handling

2 participants