Skip to content

hi_faust: three bugs in sustain pedal handling in FaustUI.h cause stuck notes, premature releases, and silent pedal in polyphonic Faust nodes #954

@morphoice

Description

@morphoice

File: hi_faust/FaustUI.h
Class: scriptnode::faust::faust_ui
Repro: Any polyphonic faust_jit / faust_static node with a button("gate"),
driven by a SilentSynth or other polyphonic source, played with a
sustain pedal (MIDI CC 64). All three symptoms are easily
reproducible while holding the pedal and pressing/releasing notes
in various orders.

Background:

struct MidiZones {
float* zones[(int)HardcodedMidiZones::numHardcodedMidiZones];
bool sustain = false;
};
PolyData<MidiZones, NumVoices> midiZones;

The mz.sustain flag is the only piece of per-voice state used to decide
whether a note-off should drop the gate. It is mutated exclusively by the
CC 64 branch in handleHiseEvent(). There is no global (faust_ui-level)
record of the current pedal state.

================================================================
Bug 1: MidiZones::reset() does not clear sustain

Location: FaustUI.h lines 195-198

void reset()
{
memset(zones, 0,
sizeof(float*) * (size_t)HardcodedMidiZones::numHardcodedMidiZones);
}

The sustain member is only initialised by its in-class default
(bool sustain = false; at line 201) at construction. reset() never
re-zeroes it.

When a voice slot in PolyData<MidiZones, NumVoices> midiZones (line 218)
is recycled for a new voice, the new voice inherits whatever sustain
was set to during the previous voice's lifetime.

Symptom: A note plays normally, but on key release the gate stays at 1
forever. Pressing and releasing the sustain pedal once after the fact
clears the flag and the stuck voice releases. Until then the voice runs
indefinitely (silent_killer cannot fire because the envelope is not
silent).

Trace:

  1. Slot N has sustain=true at end of a previous note (e.g. voice ended
    while pedal was still down, or was stolen before CC 64 up).
  2. New voice allocated to slot N. zones[] cleared, sustain still true.
  3. NoteOn -> gate = 1.
  4. NoteOff -> if (!mz.sustain) is false -> gate stays at 1.
  5. No CC 64 event arrives -> stuck forever.

Suggested fix:

void reset()
{
memset(zones, 0,
sizeof(float*) * (size_t)HardcodedMidiZones::numHardcodedMidiZones);
sustain = false;
}

Additionally, MidiZones::reset() (or at minimum mz.sustain = false)
should be called on voice start, since slot recycling in the synth does
not appear to route through faust_ui::reset() at lines 358-367.

================================================================
Bug 2: CC 64 up drops the gate on voices whose keys are still held

Location: FaustUI.h lines 338-355

else if (isSustain)
{
auto thisSustain = e.getControllerValue() > 64;

  for (auto& mz : midiZones)
  {
      if (mz.sustain != thisSustain)
      {
          mz.sustain = thisSustain;

          if (!thisSustain)
          {
              if (auto gatePtr = mz.zones[(int)HardcodedMidiZones::Gate])
                  *gatePtr = 0.0f;
          }
      }
  }

}

mz.sustain is being used as a single conflated flag for two distinct
states:
(a) "the pedal is currently down"
(b) "this voice was released while the pedal was down"

The CC 64 down branch sets mz.sustain = true on every iterated voice,
including voices whose keys are still being physically held. The CC 64 up
branch then unconditionally drops the gate on every voice whose flag was
true.

Symptom: Hold a chord, press the pedal, release the pedal while the
chord is still being held. All held notes go silent (gate -> 0) even
though no note-off has been received.

Trace:

  1. NoteOn for note A while key is held -> gate = 1, mz.sustain = false.
  2. CC 64 down -> mz.sustain becomes true (no gate change).
  3. CC 64 up -> mz.sustain != false, so flag is cleared AND gate is
    dropped to 0. Note A goes silent even though its key is still held.

Suggested fix: add a per-voice keyDown flag and only drop the gate when
both the key is up and the pedal is up.

struct MidiZones {
float* zones[(int)HardcodedMidiZones::numHardcodedMidiZones];
bool sustain = false;
bool keyDown = false;
};

// NoteOn branch:
mz.keyDown = true;
// ... existing gate=1, freq, gain assignments

// NoteOff branch:
mz.keyDown = false;
if (!mz.sustain) { /* drop gate (unchanged) */ }

// CC 64 branch, on pedal-up:
if (!thisSustain && !mz.keyDown)
{
if (auto gatePtr = mz.zones[(int)HardcodedMidiZones::Gate])
*gatePtr = 0.0f;
}

================================================================
Bug 3: Voices allocated while the pedal is held may not see sustain

Location: FaustUI.h lines 317-327 (the NoteOn branch)

if (e.isNoteOn())
{
auto& mz = midiZones.get();

  if (auto gatePtr = mz.zones[(int)HardcodedMidiZones::Gate])
      *gatePtr = 1.0f;
  if (auto freqPtr = mz.zones[(int)HardcodedMidiZones::Frequency])
      *freqPtr = e.getFrequency();
  if (auto gainPtr = mz.zones[(int)HardcodedMidiZones::Gain])
      *gainPtr = e.getFloatVelocity();

}

At voice start, mz.sustain is never synchronised with the actual pedal
state. There is no faust_ui-level pedal-state field for it to consult.
Combined with the fact that mz.sustain is only ever written by the CC
64 handler (and not reset on voice end, per Bug 1), a voice allocated
to a slot that was not in scope for the most recent CC 64 down event
ends up with the wrong flag.

Symptom: Pedal is held, user plays a new note, then releases the key
while the pedal is still down. The note's gate drops to 0 immediately
instead of being sustained by the pedal. The pedal appears to have no
effect on that voice.

Trace (one common path):

  1. Pedal down -> CC 64 down handler iterates voice slots. Slot N is
    either inactive or simply not in scope of the iteration (see note
    on PolyData semantics below). Slot N's sustain is unchanged.
  2. NoteOn arrives, allocates voice on slot N. mz.sustain is still its
    default (or last) value of false.
  3. NoteOff -> if (!mz.sustain) is true -> gate drops to 0. The pedal
    has no sustaining effect.

Note on PolyData iteration: for (auto& mz : midiZones) in PolyData
iterates either all NumVoices slots (when called with voiceIndex == -1)
or only the current voice (when called inside voice rendering). The
correctness of the CC 64 handler depends on which call site this is.
Either way the architectural problem stands: there is no synchronisation
between the pedal state and voice allocation, so a freshly allocated
voice cannot reliably observe a currently-held pedal.

Suggested fix: store the pedal state as a non-per-voice field of
faust_ui (e.g. bool pedalDown = false;), update it in the CC 64 branch,
and synchronise per-voice state at voice start.

// in faust_ui:
bool pedalDown = false;

// in NoteOn branch, before doing anything else:
auto& mz = midiZones.get();
mz.sustain = pedalDown;
mz.keyDown = true; // see Bug 2

// in CC 64 branch, at the top:
pedalDown = thisSustain;
// then proceed with the per-voice loop

================================================================
Combined effect and verification

The three bugs are independent and produce different symptoms:
Bug 1: stuck note after pedal release (cleared by another pedal cycle)
Bug 2: held notes cut off on pedal release
Bug 3: pedal has no sustaining effect on freshly allocated voices

They share a root cause: the per-voice sustain flag is the only piece
of pedal-related state in the file, and it is neither initialised on
voice start, nor reset on voice end, nor synchronised with actual key
state. The minimal correct model needs:

  • a faust_ui-level pedalDown field
  • per-voice sustain and keyDown fields
  • both per-voice fields reset on voice start (and in MidiZones::reset)
  • gate transitions decided by (keyDown OR sustain) -> 1, else 0

All three are reproducible with a polyphonic Faust node
(button("gate"), hslider("freq"), hslider("gain")) driven by a
SilentSynth at VoiceLimit > 1 with Synth.setShouldKillRetriggeredNote
disabled.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions