patternist: fix note-offs that never reach the output - #2158
Conversation
Two paths clear in_flight without producing a note-off the downstream can actually see, which leaves the synth holding notes forever while the step sequencer keeps sending note-ons: - the end_discontinuous branch stamps its note-offs at 0. That is outside [tick_start; tick_start + frames[ as soon as the interval does not begin on a buffer boundary, so every consumer that windows on the tick drops them - avendish does exactly this in port_run_preprocess.hpp. in_flight is cleared right after, so the notes are never released again. - all_notes_off() did not clear in_flight at all, so the same notes were offed a second time on the next step. Both now go through release_all(), which stamps at the start of the tick. Also: - channelChanged assigned the raw 1-16 model value to the 0-15 wire channel, off by one against the constructor. Changing the channel while notes were held also sent their note-offs on the new channel, stranding them on the old one: remember the channel notes were started on and release everything before switching. - guard pattern.length <= 0 (modulo by zero) and tk.speed == 0. - the default pattern declared length 4 for 16-step lanes, so three quarters of the built-in rhythm never played. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
pattern_node lived in PatternExecutor.cpp, behind the Execution component machinery, so none of it could be reached from a test. Moved to a PatternNode.hpp the executor includes; no behaviour change. The tests drive it tick by tick. Leaving the musical fields of the token at zero makes get_quantification_date() return prev_date, so one tick is one step - which keeps them about note pairing rather than about the quantization arithmetic that token_request owns. Covered, including what the accompanying fix changes: - a step releases what the previous one held, for a single lane, for notes alternating between two lanes, and for a note repeated on one lane - legato holds a note across a step and releases it when the lane rests - a legato step strikes the note if it was not already held - end_discontinuous releases inside the tick rather than at 0 - all_notes_off clears the in-flight set, so the next step does not release the same notes a second time - changing channel releases the held notes on the channel they were struck on - channel conversion and clamping - a zero pattern length and a zero speed do not divide by zero - steps past the end of a shorter lane still release what is held - lanes above the MIDI range (accent, slide) are not struck Also updates the midi node expectation in MidiMessageTest: a tick now comes out in chronological order, so the note-on of the second note precedes the note-off of the first, which ends later. Needs ossia/libossia#914. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Added tests, plus the refactor they needed.
Covered:
⚠ Ordering with ossia/libossia#914This commit also updates That means this PR needs the libossia submodule bumped to include ossia/libossia#914, or that test fails. Merge #914 first and bump, then this. |
node_process::stop() calls all_notes_off(), which wrote the note-offs straight into the outlet. But that runs outside of a tick, and init_outlet() clears every outlet before the node runs again - so nothing ever read them. Stopping a pattern left the synth holding whatever the last step struck. ossia::nodes::midi does not have the problem because midi_node_process::stop() requests a tick and raises a flag the node consumes from run(), where the outlet is live. Same shape here: all_notes_off() now only raises mustStop, pattern_node_process requests the tick, and the flush happens in run(). The requested token is a default-constructed one, so mustStop has to be handled before the empty-tick early return. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Second review of the playback path turned up one more, and it is the plainest instance of the reported symptom yet: stopping a pattern never released what it was holding.
static void init_outlet(outlet& out, execution_state&)
{
out.visit(clear_data{}); // midi_port -> messages.clear()
out.pre_process();
}so nothing ever read them. void stop() override
{
midi& n = *static_cast<midi*>(node.get());
n.request(ossia::token_request{});
n.mustStop = true;
}Same shape here now: Worth noting my earlier change made this worse before it made it better: the original Two cases replace the old |
get_quantification_date() reports only the first quantification point of a tick, so every other step in it was dropped without a trace. A small division, a large buffer or a high tempo are enough: a quarter note of music at a sixteenth division is four steps, of which one was played. Iterating get_quantification_dates() instead plays them all, each stamped at its own date inside the buffer. The step body moves to play_step() unchanged. Needs ossia/libossia#916. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Patternist no longer drops steps.
It now iterates Test: one tick worth a quarter note, two alternating lanes, sixteenth division. Seven messages at 0 / 250 / 250 / 500 / 500 / 750 / 750, note-off before note-on at each shared timestamp. Previously that tick produced one. This PR now depends on two libossia PRs: ossia/libossia#916 for |
|
Built and run locally: all 15 Patternist cases pass, 103 assertions, clang 22 on Windows, against a score tree with the three libossia branches applied.
One thing worth knowing, unrelated to this PR:
|
The file did not compile there at all: ScopedIgnoreSigtrap uses SIGTRAP, which the mingw CRT does not define. Score itself already knows better - Debug.hpp maps DEBUG_BREAK to DebugBreak() on Windows and raise(SIGTRAP) elsewhere - the test only mirrored the POSIX half. The Windows side installs a vectored exception handler that swallows EXCEPTION_BREAKPOINT. It has to step the instruction pointer over the trap by hand: the context is reported at the trapping instruction, not past it, so resuming as-is runs the same int3 forever. Found by running it - the first version spun. Renamed to ScopedIgnoreDebugBreak, since it is no longer about a signal. All 202 assertions pass on Windows now, which also covers the note ordering expectation of the executor case somewhere other than Linux CI. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Score already knew how to do this: The Windows side installs a vectored exception handler that swallows const auto at = reinterpret_cast<uintptr_t>(ex->ExceptionRecord->ExceptionAddress);
ex->ContextRecord->Rip = at + 1; // int3My first version just returned Renamed to |
|
Superseded by #2163, which carries every commit from this branch plus the backwards-playback work for the plug-in hosts. Nothing here is dropped:
#2163 additionally makes the pattern walk backwards when the timeline does, and takes each step's sample from the grid point's musical position rather than its flick-truncated date. Closing in favour of that one. |
Companion to ossia/libossia#914 and ossia/libossia#915.
The Patternist is not dropping note-offs
Reported symptom: a pattern alternating 36 / 38 across two lanes, observed through
MIDI to array→Value display, only ever shows[144, 36, 100] [144, 38, 100].The step logic is correct. Lifting
pattern_node's step loop verbatim into a standalone program against the real libremidi gives:and
from_midi1::note_off(0, 36, 0)round-trips through UMP to80 24 00. The note-offs are emitted and they are well-formed.The measurement chain is what loses them. The note-off and the note-on of a step share one timestamp and land in the same tick, in that order.
MIDI to arraywrites both into its value port, in order.Value displaythen takes a plain (non sample-accurate)ossia::valuecontrol input, and avendish samples those with:Only the last value of the tick survives — always the note-on. On top of that
ExecutorUpdateControlValueInUi::handle_controlsdrains its queue keeping only the last tuple percoarseUpdateTimerpoll (~60–100ms), so values are lost across ticks too. The chain cannot show a note-off that is immediately followed by a note-on, whatever the source emits.What this PR actually fixes
None of the below is the cause of the report above; they are real note-off-losing defects found while chasing it.
end_discontinuousstamps its note-offs at0. That is outside[tick_start; tick_start + frames[as soon as the interval does not begin on a buffer boundary, so consumers that window on the tick drop them — avendish does exactly this inport_run_preprocess.hpp.in_flight.clear()runs right after, so those notes are never released again. Now stamped at the start of the tick.all_notes_off()did not clearin_flight, so the same notes were offed a second time on the following step. Both paths now go throughrelease_all().Channel handling.
channelChangedassigned the raw 1–16 model value to the 0–15 wire channel, off by one against the constructor'selement.channel() - 1. Changing the channel while notes were held also sent their note-offs on the new channel, stranding them on the old one. The node now remembers which channel the in-flight notes were started on and releases them before switching.Robustness.
pattern.length <= 0reachedcurrent = (current + 1) % pattern.length(modulo by zero);tk.speed == 0divided by zero when computing the event date.Default pattern. It declared
length = 4for 16-step lanes, so three quarters of the built-in rhythm never played.