_model: devlog-entry --- schema_version: 1 --- title: Giving every voice an ending --- date: 2026-09-05 --- author: Labyricorn --- summary: The second slice of the audio engine is the one about endings — a seven-state lifecycle, an engine-owned release gain no author can reach, a proof that a one-shot sound actually stops, and a four-step eviction order for when the voice ceiling is reached. --- tags: Audio, Lifecycle, Limits, Runtime, Phase 3 --- source_commit: 9ae2c760754477df86b01cede0dd2aaa28963921 --- body: The [second slice of Phase 3c](https://git.labyricorn.com/Labyricorn/XZBT/commit/9ae2c760754477df86b01cede0dd2aaa28963921) is about endings. The first slice could build a sound graph and start it. This one has to answer what happens after that: when a sound is finished, who says so, what it costs while it waits to be cleaned up, and what the runtime does when more sounds are asked for than it can carry. ## A state machine, and the transitions that are refused A voice moves through seven states — `CREATED`, `SCHEDULED`, `ACTIVE`, `RELEASING`, `FINISHED`, `DISPOSED`, and `FAILED`. Writing them down is the easy half. The half that matters is that every transition *not* in the permitted set raises `ERR_RUNTIME_FAULT` rather than being quietly tolerated, and that calling `stop()` on a voice that has already ended is a verified no-op rather than an error. A runtime that shrugs at an illegal transition will eventually let a disposed voice be resurrected by a stale reference, and the bug that produces is unreproducible. ## The gain the author cannot reach Release is implemented as a gain node the engine owns, sitting between a sound graph's `output` and its destination bus. It starts at 1.0, and entering `RELEASING` ramps it linearly to zero over the release duration. The important properties are negative ones: it cannot be bypassed, it cannot be addressed from a document, and it does not count against an author's node limits. Release is not an effect an author composes with — it is the runtime's guarantee that nothing stops with a click. An author who wants a longer fade declares `release` on the recipe, between `0ms` and `10s`, defaulting to `50ms`. Declaring it inside a component definition is `ERR_UNKNOWN_FIELD`, because release belongs to a sound instance and not to any part of one. ## Proving a one-shot ends A `oneshot` sound has to be *determinably* finite, and the runtime computes that bound rather than guessing at it: a longest-path traversal over the expanded route graph, summing each node's documented contribution — an impulse's duration, a delay's decay bound, a reverb's predelay plus decay, a resonator's longest mode ring-down, an inlined component's own bound — plus the recipe's release. The consequence is a rejection an author can act on. An oscillator or a noise source on an audible path in a one-shot sound has no bound, so the document fails validation with `ERR_INDETERMINATE_ONESHOT`. The remedy is to declare the sound `continuous` and stop it explicitly, which is what the author actually meant. Catching this at import is the whole point: the alternative is a "one-shot" that runs until the exhibit closes. ## What happens at the ceiling One-shot and continuous voices have independent ceilings — 64 and 16 — and a voice occupies its slot from `CREATED` all the way to `DISPOSED`, not merely while it is audible. A `FINISHED` voice still costs a slot until it is cleaned up, which is the honest accounting. When a request arrives at the ceiling, eviction runs in a strict order: dispose the oldest `FINISHED` instance; failing that, advance the oldest `RELEASING` instance to immediate completion and dispose it; failing that, and only for one-shots, evict the oldest `ACTIVE` one-shot by starting its release; failing that, refuse the request. Every eviction and every refusal emits `WARN_VOICE_LIMIT`. Two rules in that list are deliberate and worth stating plainly. Nothing is ever hard-stopped — step 2 completes a release rather than dropping the node mid-sample, because a drop is a click, and a click is a defect. And the two pools never evict across each other: a burst of one-shots cannot silence the continuous bed an exhibit is built on. A refused one-shot is a sound the visitor does not hear; an evicted ambience is an exhibit that has visibly broken. ## The reconciliation that came first Five inconsistencies in Section 16 were found and corrected *before* this code was written, in `e5ed468` — among them an eviction step that contradicted the no-hard-stop invariant, a recipe field table that had omitted `release` entirely, and an author remedy the contract elsewhere forbids. Implementing against a section that still disagrees with itself produces code that encodes the disagreement, and the disagreement then becomes very hard to see. All 67 tests pass, and two clean builds are byte-identical. Traces 7, 9, and 10 of 16.11 are automated. What remains open is the part no test can close: no sound has yet been heard from a production build.