Free a released voice's oscs; describing an osc no longer allocates it - #1106
Conversation
Releasing a voice discards its oscs' state (release_voice_oscs scheduled a RESET_OSC that wiped each osc in place) but kept their storage forever: the arena only ever grew, to the session's all-time high-water mark, and the only way back was amy_reset_oscs(). On MCU targets that means a synth's memory tracks its history, not its scene (shorepine#1105). Release now schedules the reset with RESET_FREE_OSC or'd into the osc number, and play_delta frees the block instead of resetting it in place. Behaviorally identical: an unallocated osc reads as its defaults (shorepine#1098), and the next touch re-allocates lazily, exactly as first touch does. Two describe-paths had to stop allocating for this to be visible in tests, and they were allocation leaks in their own right: storing a patch_string and capturing a shorepine#1100 voice snapshot both run events through amy_event_to_deltas_queue at base osc 0, whose ingest-side ensure_osc_allocd allocated oscs 0..n-1 that nothing was playing. The ingest-side ensure now fires only when the deltas are headed for the live queue; a delta list built off-queue stays descriptive, and play_delta already ensures at execution for everything that plays. Deleting a synth also no longer takes a voice snapshot nothing will replay. tests/test_osc_free_on_release.c pins the contract per-osc against osc_to_voice ownership: release and shrink free exactly the owned blocks, freed oscs re-allocate on reuse, 20 configure/release cycles end where one ends, built-in patches included. make ctest 8/8.
5e72905 to
85fd9de
Compare
|
Thanks for this. The point about inadvertent allocation of oscs in the range 0..n-1 is a nice catch, although I think it has no practical impact? n is typically < 10, and we expect all those oscs to be realized. Still, it was waiting to catch us as a bug should we change osc numbering or something. I don't like adding flag bits to the osc number to convey extra information through the delta queue. Do we need two types of osc reset, with and without freeing? Can't we just make all osc resets free the memory? |
RESET_FREE_OSC or'd a flag into a RESET_OSC delta's payload, where the payload is otherwise an osc number, and shipped the flag as a public constant in amy/constants.py and amy_api.generated.js -- as though amy.send(reset=...) were a way to invoke it, which it isn't. FREE_OSC is a param in its own right now. patches.c has the two schedulers side by side, identical apart from that param: schedule_osc_reset for state, schedule_osc_free for storage, the latter called from release_voice_oscs. play_delta handles FREE_OSC before the ensure_osc_allocd that every other param needs, so we no longer allocate an osc on the way to freeing it, and no branch below it can dereference what we just freed. No behavior change: the same oscs are freed at the same point in the same order. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
# Conflicts: # .gitignore # Makefile
|
Pushed two commits here, thanks for the patch — the measurements in the description are exactly what this needed.
One change beyond the encoding: Also merged main in. The branch was based on For the record on why the free stays conditional rather than becoming the way all osc resets work: I tried making Locally on the merged branch: |
⛓️ tulipcc integration PR openedThis merge was pinned into tulipcc for full-system CI: shorepine/tulipcc#1325 Test it there and merge that PR to move tulipcc onto this AMY. |
|
Sorry for missing your first comment. Thanks for taking it the rest of the way, and for writing down the chained-osc/mod-source constraint. |
Summary
Follows through on #1105:
release_voice_oscs()now schedules itsper-osc reset with
RESET_FREE_OSCor'd in, andplay_deltafrees theblock instead of wiping it in place. Behaviorally identical - an
unallocated osc reads as defaults (#1098) and re-allocates lazily on
next touch - the memory just comes back.
Describing an osc no longer allocates it
Discovered two describe-paths that allocate oscs needlessly: storing a
patch_stringand capturing a #1100 voicesnapshot both run events through
amy_event_to_deltas_queueat baseosc 0, and the ingest-side
ensure_osc_allocdmaterialized oscs 0..n-1as a side effect. That ensure now fires only for the live queue -
play_deltaalready ensures at execution - and deleting a synth nolonger takes a snapshot nothing will replay.
On fragmentation
The blocks are near-uniform (~560 B at DEFAULT_NUM_BREAKPOINTS), a
voice's oscs are contiguous and freed together, and AMY
already frees blocks mid-session (the breakpoint-growth realloc;
amy_reset_oscs()frees the whole arena) - this changes the freetraffic's volume, not its kind. The worst case is bounded by the status
quo: never freeing is permanent maximal occupancy.
Measured on ESP32-S3 (arena in PSRAM per the ESP default
ram_caps_events), two 30-minute on-target soaks:Testing
tests/test_osc_free_on_release.c(new, wired intomake ctest):asserts per-osc against
osc_to_voiceownership, not totals -release and shrink free exactly the owned blocks, freed oscs
re-allocate on reuse and play, 20 configure/release cycles end where
one ends, built-in patches included.
make ctest8/8. A fullmake testrun does not complete in myenvironment with or without this change:
TestFuzzWireParser(Harden the wire-message parser against arbitrary junk input #1089)hangs on pristine
55e044dtoo. Bisected to a one-liner, unrelated tothis PR: a bare out-of-range velocity spins the engine -
amy.send_wire('l500Z')then render hangs CPU-bound (l200Zis fine,l500Zand up hang; the fuzz loop hits it at random message 91,...l29193-...). py3.14, linux/x86-64. All audio tests that runbefore it pass identically on both trees.