docs(audio): close three defects in the phase 3c contract

Re-read section 16 before handing the implementation slice onward. Three
defects, all of the same classes the Phase 3a review turned up.

The 16.5 ending-bound table gave a contribution for every node type
except the one that contains other nodes, so a one-shot whose tail lived
inside a component had no defined bound. Components now contribute the
bound of their own graph by the same rule, terminating on the existing
nesting cap.

The 16.3 state machine offered no exit from CREATED for a stop arriving
before scheduling except FAILED, which would have reported an ordinary
cancellation as a fault. Permit CREATED -> FINISHED, and say why it
differs from SCHEDULED -> RELEASING: a created instance is connected to
nothing, so there is no signal to ramp down.

Automation point ordering was a stage conflation. Points had to be in
strictly increasing `at` order while `at` was a DurationSpec, which
section 6.2 permits to be a procedural TimeSpec resolved at
instantiation — so the ordering rule could not have been enforced at the
semantic stage where it was filed. This is structurally the same defect
as the audioMaxFrequency one closed in Phase 3a. Fix `at` as a duration
literal; point values remain full ValueSpecs.

Still contract only. No runtime change, and no sound has been heard from
any build.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_011FWPdCqKaaDnP9NC3JAwh6
This commit is contained in:
2026-09-05 23:29:23 +00:00
co-authored by Claude Opus 5
parent 42e4b32fd6
commit 50fb72c0e8
2 changed files with 16 additions and 5 deletions
+8 -5
View File
@@ -1284,7 +1284,7 @@ An automation track drives one node property along an authored curve measured fr
| `target` | string | Yes | `<node-key>.<property>`, resolved in the same graph. Follows the encapsulation rules of 15.15: a component's internals are unreachable, and a component instance exposes only its declared parameters. |
| `mode` | enum | No (default `absolute`) | `absolute`, `offset`, or `scale`. |
| `interpolation` | enum | No (default `linear`) | `step`, `linear`, `exponential`, or `smooth`. |
| `points` | array | Yes | `2` to `256` breakpoints, each `{ "at": DurationSpec, "value": ValueSpec<number> }`. |
| `points` | array | Yes | `2` to `256` breakpoints, each `{ "at": <duration literal>, "value": ValueSpec<number> }`. |
`automation` is an array rather than a keyed map because a track has no identity an author needs to reference. It defaults to `[]`.
@@ -1292,7 +1292,9 @@ An automation track drives one node property along an authored curve measured fr
**Exclusivity.** Only one automation track may directly control a property in a recipe instance (PRD 54). Two tracks addressing the same expanded target are `ERR_AUTOMATION_CONFLICT`. This is the automation counterpart of the `ERR_CONFLICTING_BINDING` rule in section 8.2, and for the same reason: two writers to one scalar has no defined answer. Modulation is unaffected — multiple modulation routes onto one property still sum (15.13), because summation *is* their defined answer.
**Points.** `at` is measured from the node instance's start and is sampled once at instantiation (14.4), like every other DurationSpec in this contract. Points must be in strictly increasing `at` order; equal or decreasing times are `ERR_INVALID_RANGE_ORDER`. Fewer than two points is `ERR_SCHEMA_VALIDATION` — a single point is a constant and belongs in the property's base value. Before the first point the track holds the first point's value; after the last point it holds the last point's value and does not loop.
**Points.** `at` is measured from the node instance's start and is a **duration literal only** (section 6.1). Unlike every other duration in this contract it may *not* be a procedural `TimeSpec` (section 6.2), and a `TimeSpec` there is `ERR_TYPE_MISMATCH`. The reason is the staging rule of 14.5: points must be in strictly increasing `at` order, and an order over values that resolve randomly at instantiation could not be checked at the semantic stage at all. Fixing `at` as a literal keeps the ordering rule decidable at import, where a malformed curve should be caught. A track's *values* remain full ValueSpecs and may be random; it is only the curve's shape in time that is authored, not sampled.
Equal or decreasing times are `ERR_INVALID_RANGE_ORDER`. Fewer than two points is `ERR_SCHEMA_VALIDATION` — a single point is a constant and belongs in the property's base value. Before the first point the track holds the first point's value; after the last point it holds the last point's value and does not loop.
**Modes.** Given the property's resolved base value `b` and the track's current curve value `a`, the track contributes:
@@ -1346,14 +1348,14 @@ A sound instance occupies exactly one state (PRD 57):
Permitted transitions, and nothing else:
```text
CREATED -> SCHEDULED | FAILED
CREATED -> SCHEDULED | FINISHED | FAILED
SCHEDULED -> ACTIVE | RELEASING | FAILED
ACTIVE -> RELEASING | FINISHED | FAILED
RELEASING -> FINISHED | FAILED
FINISHED -> DISPOSED
```
A stop request in `SCHEDULED` goes to `RELEASING` rather than straight to `FINISHED`, so a single code path handles teardown whether or not the voice ever sounded. `ACTIVE -> FINISHED` without a release is reserved for a one-shot that has reached its determinable ending, where the envelope has already returned to zero and a further release would be redundant. `DISPOSED` and `FAILED` are terminal; a second stop on a disposed instance is a no-op, never an error.
A stop request in `SCHEDULED` goes to `RELEASING` rather than straight to `FINISHED`, so a single code path handles teardown whether or not the voice ever sounded: a scheduled instance is already connected to its bus and may begin sounding at any moment on the audio clock. A stop request in `CREATED` goes straight to `FINISHED` instead, because a created instance is not connected to anything and no signal can escape it; ramping a gain that reaches no bus would be ceremony, not safety. `ACTIVE -> FINISHED` without a release is reserved for a one-shot that has reached its determinable ending, where the envelope has already returned to zero and a further release would be redundant. `DISPOSED` and `FAILED` are terminal; a second stop on a disposed instance is a no-op, never an error.
Every state change is observable to the runtime's own diagnostics, but state names are not exposed to exhibits. An exhibit describes desired behavior, not runtime bookkeeping.
@@ -1381,9 +1383,10 @@ The bound is computed as the longest path from any source to `output`, where eac
| `delay` | `time x ceil(log(1/1000) / log(feedback))` for `feedback > 0`, else `time`; the time for the internal feedback path to fall `60` dB |
| `reverb` | `predelay + decay` |
| `resonator` | the longest `decay` among its retained modes |
| `component` | the bound of the component's own graph, computed by this same rule over its internal nodes, from its sources or its `input` to its `output` |
| every other node | zero; they colour a signal without extending it |
The instance's ending is the maximum over all source-to-`output` paths of the sum of contributions along that path, plus the release duration of 16.4.
The instance's ending is the maximum over all source-to-`output` paths of the sum of contributions along that path, plus the release duration of 16.4. Component contributions recurse, and the recursion terminates because component nesting is capped at `8` levels (15.14 rule 11). A component whose internal graph is itself unbounded makes every path through it unbounded, exactly as an unbounded source does at recipe root.
A `oneshot` recipe whose bound is unbounded — that is, one whose audible path begins at an `oscillator` or `noise` source — is `ERR_INDETERMINATE_ONESHOT` at validation. The author's remedies are to declare `mode: "continuous"` and stop the sound explicitly, or to gate the source through an `impulse`-driven path. This is a semantic error rather than a runtime one because it is decidable from the document, and catching it at import is the difference between a rejected exhibit and a voice that never frees itself.
@@ -19,6 +19,14 @@ Phase 3c is delivered in four slices, recorded in the implementation plan. This
- **Unlock reports once per batch.** A long pre-unlock interval raises a single counted `INFO_AUDIO_UNLOCK_SKIP` rather than flooding the panel with one entry per skipped one-shot.
- **Master protection is specified in shape, provisional in value.** Finite-sample handling is normative now because a `NaN` in the output buffer is never acceptable at any ceiling. The peak ceiling, numerical tolerance, and release behavior carry provisional values and are confirmed by GC6 measurement.
## Post-review corrections
Section 16 was re-read before handing the implementation slice onward. Three defects were found in it and fixed in a follow-up commit. All three are the same classes the Phase 3a review turned up, which is why the review step exists rather than being a formality:
1. **The 16.5 ending-bound table omitted `component`.** Every node type had a contribution except the one that contains other nodes, so a one-shot whose tail lived inside a component had no defined bound. Components now contribute the bound of their own graph, computed by the same rule; the recursion terminates on the 15.14 nesting cap.
2. **The 16.3 state machine had no exit from `CREATED` for a stop arriving before scheduling** — only `FAILED`, which would have misreported an ordinary cancellation as a fault. `CREATED -> FINISHED` is now permitted, and the text says why it differs from `SCHEDULED -> RELEASING`: a created instance is connected to nothing, so there is no signal to ramp down.
3. **Automation point ordering was a stage conflation.** Points had to be in strictly increasing `at` order while `at` was a DurationSpec, which section 6.2 permits to be a procedural `TimeSpec` resolved at instantiation — so the ordering rule could not have been enforced at the semantic stage where it was filed. This is structurally the same defect as the `audioMaxFrequency` one closed in Phase 3a. `at` is now a duration literal only; point *values* remain full ValueSpecs.
## Verification
This slice changes documentation only. The full suite still runs 62 tests with zero failures and all three exhibit fixtures still validate, confirming no regression, not that section 16 is implemented. Section cross-references resolve with no dangling targets, and every diagnostic code section 16 uses appears in the single section 7 table.