docs: raise the format specification to revision 0.9 and land the reconciliation
Revision 0.9 adds section 20, the cadence and event subsystems contract, and carries two corrections the implementation forced. Section 6.1 now states that a duration is the authored literal or a non-negative finite number already in milliseconds, since a DurationSpec may be the resolved output of a ValueSpec or a bounded TimeSpec, with the one documented exception of an automation track's `at`, which 19.1 keeps literal-only so that point ordering stays decidable at import. Section 20.11 documents the rejection of an undeclared input name in an event action's `with` map as ERR_UNKNOWN_FIELD — the section's own convention for that shape of error, replacing an invented code that appeared nowhere in the registry. The review record is committed with the code it describes: the two code triages that found these defects, the reconciliation plan that sequenced the fixes, and a follow-up debt record listing what was deliberately left open — the unchecked JSON Schema artifact, degenerate path arcs, post-effect transient allocation, the window-traffic fixture's per-copy wrap bounds, and the unstated `ownership: "persistent"` value on a sound action. None of the five blocks phase 6; all five are written down rather than dropped. Devlog entries are backfilled for the two milestones that had none: phase 3c slice 2, the audio lifecycle and voice ceilings, and slice 4d, the renderer core. The implementation status summary now reflects the reconciled state rather than the in-flight one. 231 tests pass. tools/verify-spec-contract.py reports 46 declared diagnostic codes with every used code resolving and its two long-standing unresolved cross-references unchanged. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01ShxxFqFmCUDQnQvFNm4TKy
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
_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.
|
||||
Reference in New Issue
Block a user