Ignore osc references that reach outside the voice being configured - #1127
Conversation
An osc number in a synth-directed command is voice-relative: base_osc is added to reach the real osc. Nothing checked the result, so a number past the end of the voice silently addressed -- or pointed at -- whatever happened to live there, which is another voice of the same synth, or another synth entirely. amy.send(synth=1, osc=0, mod_source=1) on a one-osc voice FM'd itself from a neighbour's oscillator. Every place that combines an osc number with a base_osc now takes the voice's osc count and checks against it: - amy_event_to_deltas_queue() takes oscs_per_voice, and checks the osc the event addresses plus each reference it carries (chained_osc, mod_source, algo_source). An out-of-range reference is dropped and the rest of the event still applies; an out-of-range target drops the osc-addressed part of the event. - add_deltas_to_queue_with_baseosc() checks the same for a stored patch replayed into a voice. - 0 means no voice context -- absolute osc numbers from the C API and the MIDI mapping path, or a patch being described rather than played -- and nothing is checked. RESET_OSC is deliberately not checked: its payload is a mask of RESET_* bits as often as it is an osc number, so a range test would reject things like RESET_ALL_OSCS. The bound has to be read live, not captured. A patch string can re-shape its own voice as it runs: the drum kits start with `if3iv1in38Z`, so patch_oscs says 1 osc until that first command executes and turns the voice into 38. Binding to the value known before the string ran rejected the whole rest of the patch and silenced every drum test. amy/test.py's TestModOscOOB (the case this started from) now has a reference. It was generated only after checking that the rendered audio with the out-of-range mod_source is bit-identical to the same patch with that parameter never sent -- "ignored" really means ignored. make ctest 10/10, make test 133/133. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
It had one user left. reset_osc's payload is an osc number sometimes and a mask of RESET_* bits the rest of the time, so the choice belongs at the call site, decided the same way play_delta decides it: an osc number gets EVENT_TO_DELTA_OSC_REF (voice-relative -- range-checked and offset by base_osc), a mask gets EVENT_TO_DELTA_I (through untouched). That also stops something meaningless: the old macro added base_osc to whatever it was given, so a synth-directed reset=RESET_ALL_OSCS arrived as 8192 + base_osc. It kept working only because the mask bit survives the addition. OSC_REF keeps the "don't allocate for a reset" exemption the old macro had: reset_osc() is a no-op on an unallocated osc, which is already at its defaults, so materializing one to clear it would undo #1106. Checked both payload forms by hand: reset=1 on a 2-osc voice clears that voice's osc 1 and leaves osc 0; reset=3 on a 1-osc voice is refused and changes nothing; reset=RESET_ALL_OSCS through a synth still clears it. make ctest 10/10, make test 133/133. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
tests/test_voice_osc_range.c covers what is refused (mod_source, chained_osc, algo_source, an addressed osc, a reset carrying an osc number -- each past the end of the voice), what is kept (the same references in range, and a reset carrying a RESET_* mask, which is not an osc number at all), that an event with no synth is absolute and unbounded, and that the osc a refused reference would have reached is left untouched. That last one is the point of the exercise: against the pre-change sources it reports OVERWRITTEN, because the number reached into the neighbouring synth's oscillator. Five checks fail there in total. It also pins the case that must NOT be refused -- a patch string that re-shapes its own voice as it runs. The drum kits open with `if3iv1in38Z` and a bound captured before the string ran rejected everything after it, silencing every kit; the test asserts all 38 of the kit's oscs end up configured. docs/synth.md gains a note that osc numbers in synth-addressed commands are voice-relative, that mod_source/chained_osc/algo_source are too, and that anything outside the voice is refused rather than landing on a neighbour. api.md's `v` row points at it. make ctest 11/11, make test 133/133. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The bound the rest of a patch string is held to was worked out by re-reading osc_to_voice/num_oscs_for_voice per command, which noticed a mid-string re-shape only as a side effect: correct, but you had to know the voice tables were being consulted behind the loop to see why a drum kit's 38 oscs were not rejected by a bound of 1. The loop now watches for it directly. An event carrying oscs_per_voice re-shapes the voice, so it updates the local bound as it goes by, and everything after it answers to the new size. That is the thing that actually happens, written where it happens. Applying it from that event rather than the next is right: the event carrying oscs_per_voice is synth-level and names no oscs of its own. add_deltas_to_queue_with_baseosc goes back to the bound it is passed -- its deltas are already parsed, and oscs_per_voice is a parse-time command, so nothing in a delta list can re-shape anything. oscs_in_voice_now is gone with both of its callers. make ctest 11/11 (including the drum kit case), make test 133/133. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
🎛️ AMY HW CI (AMYboard bench)Flashed this PR's AMY (LoadTestChord: 6-voice Juno ✅ PASS — the bench ran the test to completion.
Full chord settled render μs: 2800 (was 2791, Δ +0.3%) (peak 2811, 39 samples) ⬇️ Artifacts: serial log · load trace · report Self-hosted bench (amyboardci). FAIL means only that the test could not run — the load values are informational, with no threshold and no audio compare. See |
⛓️ tulipcc integration PR openedThis merge was pinned into tulipcc for full-system CI: shorepine/tulipcc#1331 Test it there and merge that PR to move tulipcc onto this AMY. |
The bug
An osc number in a command addressed to a synth is voice-relative:
base_oscis added to reach the real osc. Nothing checked the result, so a number past the end of the voice addressed — or pointed at — whatever happened to live there, which is another voice of the same synth, or another synth entirely.That last line used to FM synth 1 from synth 2's oscillator. The new ctest, run against the pre-change sources, puts it plainly:
FAIL osc base+1 untouched (OVERWRITTEN).The change
Every place that combines an osc number with a
base_oscnow takes the voice's osc count and checks against it, through one helper:amy_event_to_deltas_queue(e, base_osc, oscs_per_voice, queue)— checks the osc the event addresses, plus each reference it carries:chained_osc,mod_source[],algo_source[]. An out-of-range reference is dropped and the rest of the event still applies; an out-of-range target drops the osc-addressed part.add_deltas_to_queue_with_baseosc(d, base_osc, oscs_per_voice, queue, time)— the same for a stored patch replayed into a voice.oscs_per_voice == 0means no voice context — absolute osc numbers from the C API and the MIDI-mapping path, or a patch being described rather than played — and nothing is checked.The message names the parameter and the bound (
mod_source osc 1 is outside this voice's 1 osc, ignored). It prints once per voice, which is where the combination happens.EVENT_TO_DELTA_WITH_BASEOSCis gone; its last user wasreset_osc, whose payload is an osc number sometimes and a mask ofRESET_*bits the rest of the time. That choice now sits at the call site, decided the wayplay_deltadecides it: an osc number goes through the checked macro, a mask throughEVENT_TO_DELTA_Iuntouched. Incidentally that stops something meaningless — the old macro addedbase_oscto masks too, soreset=RESET_ALL_OSCSon a synth arrived as8192 + base_oscand only worked because the mask bit survives the addition.The subtlety: the bound has to be read live
A patch string can re-shape its own voice as it runs. The drum kits open with
if3iv1in38Z, sopatch_oscs[384]says 1 osc until that first command executes and turns the voice into 38. My first version passed the count known before the string ran and rejected the entire rest of the patch — 14 tests failed, every drum kit silent.oscs_in_voice_now()re-reads the owning voice's size per command instead.Testing
tests/test_voice_osc_range.c(new, inmake ctest): what is refused (mod_source,chained_osc,algo_source, an addressedosc, aresetcarrying an osc number — each past the voice), what is kept (the same in range; aresetcarrying a mask), that a no-synthevent is absolute and unbounded, that the osc a refused reference would have reached is untouched, and that a patch re-shaping its own voice is not fought by the bound (all 38 kit oscs configured). 5 checks fail against pre-change sources.amy/test.py: TestModOscOOB— the rendering case this started from. Its reference was generated only after confirming the audio with the out-of-rangemod_sourceis bit-identical (max abs diff 0.0) to the same patch with that parameter never sent: "ignored" really means ignored.make ctest11/11,make test133/133.Docs
docs/synth.mdgains a note that osc numbers in synth-addressed commands are voice-relative, thatmod_source/chained_osc/algo_sourceare too, and that anything outside the voice is refused rather than landing on a neighbour.docs/api.md'svrow points at it.🤖 Generated with Claude Code