Skip to content

Free a released voice's oscs; describing an osc no longer allocates it - #1106

Merged
dpwe merged 4 commits into
shorepine:mainfrom
rt-rtos:osc-free-on-release
Aug 17, 2026
Merged

Free a released voice's oscs; describing an osc no longer allocates it#1106
dpwe merged 4 commits into
shorepine:mainfrom
rt-rtos:osc-free-on-release

Conversation

@rt-rtos

@rt-rtos rt-rtos commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Summary

Follows through on #1105: release_voice_oscs() now schedules its
per-osc reset with RESET_FREE_OSC or'd in, and play_delta frees the
block 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_string and capturing a #1100 voice
snapshot both run events through amy_event_to_deltas_queue at base
osc 0, and the ingest-side ensure_osc_allocd materialized oscs 0..n-1
as a side effect. That ensure now fires only for the live queue -
play_delta already ensures at execution - and deleting a synth no
longer 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 free
traffic'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:

Periodic soak Seeded-chaos soak
Workload 30,000 fixed cycles: configure/play/release, 3 synth shapes around a long-lived tenant 90,000 random create/play/resize/repatch/release ops across 8 synths, with foreign ballast allocations interleaved in both heaps
Osc traffic ~10^6 alloc/frees; 12 live at every sample boundary 4-200 live oscs
Allocation failures 0 0
PSRAM total free constant to the byte across all 1,200 samples +0.0% between run halves
Largest free block one split at cycle 25, then constant deepest trough inside the first 0.3% of ops, never deeper after
Alloc latency probe - (added after this run) flat: ~1105 cycles internal / ~1385 PSRAM mean in both halves; worst single malloc ~13 us
Render mean 1510 us, flat -0.2% between run halves
After teardown oscs 0; free returns to the pre-tenant value boot-state heap minus ~6 KB one-time lazy init (identical at 1k and 90k ops)

Testing

  • tests/test_osc_free_on_release.c (new, wired into make ctest):
    asserts per-osc against osc_to_voice ownership, 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 ctest 8/8. A full make test run does not complete in my
    environment with or without this change: TestFuzzWireParser (Harden the wire-message parser against arbitrary junk input #1089)
    hangs on pristine 55e044d too. Bisected to a one-liner, unrelated to
    this PR: a bare out-of-range velocity spins the engine -
    amy.send_wire('l500Z') then render hangs CPU-bound (l200Z is fine,
    l500Z and up hang; the fuzz loop hits it at random message 91,
    ...l29193-...). py3.14, linux/x86-64. All audio tests that run
    before it pass identically on both trees.

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.
@rt-rtos
rt-rtos force-pushed the osc-free-on-release branch from 5e72905 to 85fd9de Compare August 14, 2026 20:21
@dpwe

dpwe commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator

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?

dpwe and others added 2 commits August 16, 2026 19:29
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
@dpwe

dpwe commented Aug 16, 2026

Copy link
Copy Markdown
Collaborator

Pushed two commits here, thanks for the patch — the measurements in the description are exactly what this needed.

FREE_OSC as its own delta param, rather than a bit or'd into the RESET_OSC payload. That payload is otherwise an osc number, and the flag was also being published as RESET_FREE_OSC in amy/constants.py and amy_api.generated.js, where it reads as a user-settable reset= value — those two files have dropped out of the diff now. patches.c has the two schedulers side by side, identical apart from the param: schedule_osc_reset for state, schedule_osc_free for storage, the latter called only from release_voice_oscs.

One change beyond the encoding: play_delta handles FREE_OSC before the ensure_osc_allocd that every other param needs, and returns. Otherwise the if (d->param != RESET_OSC) guard would allocate the osc on the way to freeing it, and the early return also means nothing below can touch what was just freed. Same oscs freed at the same point in the same order.

Also merged main in. The branch was based on 55e044d, before #1108, so CI here would have hung for hours in TestFuzzWireParser on the log2_lut bug rather than reporting on your change.

For the record on why the free stays conditional rather than becoming the way all osc resets work: I tried making play_delta's reset branch free unconditionally, and it segfaults the render suite. play_delta does re-allocate the delta's own osc at execution, but oscs reference each other through chained_osc, mod_source[] and algo_source[], and those are dereferenced with only a range check — e.g. synth[chained_osc]->role = SYNTH_IS_CHAINED;. Freeing an osc something still points at turns the next touch into a NULL deref. So scoping the free to voice release, as you have it, is load-bearing.

Locally on the merged branch: make ctest 10/10 including your test_osc_free_on_release, and make test 132/132.

@dpwe
dpwe merged commit 9a45777 into shorepine:main Aug 17, 2026
12 checks passed
@bwhitman

Copy link
Copy Markdown
Collaborator

⛓️ tulipcc integration PR opened

This 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.

@rt-rtos

rt-rtos commented Aug 17, 2026

Copy link
Copy Markdown
Contributor Author

Sorry for missing your first comment.
My intent with the flag-bit was likely to be minimally invasive by not adding a param to the enum.
Revisiting it, that was optimizing for the wrong scarcity: enum ids aren't scarce, and the overload cost every reader of the payload field knowing the mask.

Thanks for taking it the rest of the way, and for writing down the chained-osc/mod-source constraint.

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.

3 participants