open-standards/wai/extensions/score.md
Transaction Science 56ff513aec
Some checks failed
WAI breaking changes and the conformance runner / WAI breaking · does this change touch WAI (push) Successful in 17s
WAI breaking changes and the conformance runner / WAI breaking · wire, record and API against main · linux (push) Failing after 54s
WAI breaking changes and the conformance runner / WAI breaking and conform · their tests, and the independent verifiers through wai conform · linux (push) Failing after 2m33s
repository checks / lockfiles, advisories, tests in CI, workflow hygiene, spec and section references, public text (push) Successful in 3m8s
joule-moq-room room service / joule-moq-room · does this change touch it (push) Has been cancelled
joule-moq-room room service / joule-moq-room, every target (push) Has been cancelled
wai-audit collector and player integration / does this change touch it (push) Has been cancelled
wai-audit collector and player integration / collector · linux (push) Has been cancelled
wai-audit collector and player integration / collector, player integration and the browser end to end · macos (push) Has been cancelled
WAI operation catalogue / does this change touch it (push) Has been cancelled
WAI operation catalogue / the catalogue and every door · linux (push) Has been cancelled
wai-quantum for JavaScript / does this change touch it (push) Has been cancelled
wai-quantum for JavaScript / javascript · linux (push) Has been cancelled
wai-quantum studio and desktop / does this change touch it (push) Has been cancelled
wai-quantum studio and desktop / studio · browser (push) Has been cancelled
wai-quantum studio and desktop / desktop app · macos (push) Has been cancelled
wai-quantum studio and desktop / desktop app · linux (push) Has been cancelled
wai-quantum studio and desktop / desktop app · windows (push) Has been cancelled
wai-sink-android native sink library / does this change touch it (push) Has been cancelled
wai-sink-android native sink library / the JNI layer and the platform-free half on the host JVM · linux-arm64 (push) Has been cancelled
wai-sink-android native sink library / every ABI, the AAR, and the instrumented tests in a headless emulator · macos (push) Has been cancelled
wai-sink-swift native sink package / does this change touch it (push) Has been cancelled
wai-sink-swift native sink package / libwai slices, desktop tests, simulator tests · macos (push) Has been cancelled
WAI Studio / does this change touch it (push) Has been cancelled
WAI Studio / the catalogue's actions, the unit tests and the core · linux (push) Has been cancelled
WAI Studio / Studio in Chromium and Firefox · macos (push) Has been cancelled
WAI Studio / Studio in WebKit · linux (push) Has been cancelled
wai-ledger service / does this change touch it (push) Has been cancelled
wai-ledger service / service · linux (push) Has been cancelled
wai-ledger service / service · macos (push) Has been cancelled
joule-moq-remote host agent / joule-moq-remote · does this change touch it (push) Has been cancelled
joule-moq-remote host agent / joule-moq-remote, every target (linux-arm64) (push) Has been cancelled
joule-moq-remote host agent / joule-moq-remote, every target (macos) (push) Has been cancelled
joule-moq-web browser client / joule-moq-web · does this change touch it (push) Has been cancelled
joule-moq-web browser client / joule-moq-web · browser end-to-end (macos) (push) Has been cancelled
wai-app and Create / wai-app · does this change touch it (push) Has been cancelled
wai-app and Create / wai-app · tests, clippy, the line language's verifier (linux) (push) Has been cancelled
wai-app and Create / wai-app · Create, browser end-to-end (macos) (push) Has been cancelled
wai-fleet service / does this change touch it (push) Has been cancelled
wai-fleet service / service · linux (push) Has been cancelled
wai-fleet service / service · macos (push) Has been cancelled
wai.audio.score: band-limited timbres, a sine and noise, a voice per note, and two channels
The reference synthesiser gains what a short channel sting needs, all in the
wai.det.fixed64 floor and trig-free, every committed score rendering byte for byte as
before (the three first-revision vectors keep their mixdown hash, PCM SHA-256 and score
hash):

- score §2.2, §3.1: waveforms 4 to 6, the saw, square and 25 %-pulse with each step
  corrected by a polynomial (PolyBLEP); 7, a sine by a refined parabola; 8, noise held
  from a 32-bit LCG between wraps of the phase.
- score §2.1, §3.4: a contract of two channels mixes down to interleaved frames; a reader
  refuses any count but 1 and 2; a score with no voices centres each note.
- score §2.4, §3.3: an optional third section, one 73-byte voice per note: a
  constant-power pan by the profile's integer square root, curved attack, decay and
  release (the release from the level at note-off), up to four one-pole low-pass sections
  that follow the envelope, a linear glide, the noise seed, and up to eight echoes at
  alternating pans. The 65-byte note record is unchanged; the score hash binds the
  voices when present.
- wai score build|render: channels 2, waveforms to 8, each note's optional voice; render
  prints frames and channels. Receipt blocks are 4096 frames.
- score-conformance: five vectors at 48 kHz (band-limited, two channels, voiced in one,
  a two-second sting, a voiced scale); multi_verify.py's independent synth agrees with
  all eight.
- packaging §5.8: a score of one or two channels packs beside the lead with its voices,
  its floor the FLAC of the mixdown in its channels; verify holds the floor to the
  base's channels and rate. pack-conformance/multi/multi-sting-01 is a voiced
  two-channel score at 48 kHz.
- The composing sink synthesises and plays a score in its contract's channels; its
  reference package's score is now two channels at 48 kHz, voiced.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 14:21:46 -05:00

311 lines
17 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# WAI Extension: Score (Music as Instructions)
> Status: Draft. Phase 6 — the deepest audio lever. Ship the
> composition, not the recording: a score of note events the sink
> synthesizes. Conformance is **mixdown-equivalence**, the music
> counterpart of spatial audio. Reference impl: the `score` feature of
> `wai-rs`; corpus: `score-conformance/`; live sink:
> wai.transaction.science/score.
Keywords **MUST**, **MUST NOT**, **SHOULD**, **MAY** are RFC 2119/8174.
## 1. Scope and model
A `wai.audio.score` object carries **no recorded audio** — only note
events. Where `wai.audio.scene` mixes recorded sound *objects*, a score
is reconstructed from instructions: a deterministic reference synth
turns the notes into a canonical PCM mixdown of one channel or two, and a
sink with a richer instrument (soundfont, neural) renders the same score
for presentation. A note may carry a voice (§2.4): its place between the
two channels, curved envelope segments, a low-pass filter that follows
its envelope, a pitch glide, a noise seed and echoes.
It is lever 3 (instructions-at-the-sink) for music — a minute of music
is kilobytes of notes, not megabytes of samples.
In scope: the container, the note model, the deterministic reference
synthesis, the **mixdown-equivalence** conformance criterion, the
receipt profile. Out of scope: a transport; the sink's instrument
(soundfonts, neural synths — presentation, not conformance); MIDI-file
import (an authoring concern).
Relationship to the core spec: mirrors spatial audio. The reconstruct
runs in the `wai.det.fixed64` floor; `media` takes the value `"audio"`;
`wai.audio.score` is authoritative.
## 2. Container (`WSCR`)
```
"WSCR" | u16 n_sections | section table (u8 kind, u32 off, u32 len) | sections
```
| kind | section | required |
|---|---|---|
| `0x01` | contract (canonical JSON) | REQUIRED |
| `0x02` | note events | REQUIRED |
| `0x03` | voices, one per note (§2.4) | OPTIONAL |
`off` counts from the end of the section table; the reference writer writes the sections
in this order, each padded with zero bytes to a multiple of 8. A score with a voices
section is **voiced**. A reader skips a section of a kind the table does not name: it does
not enter the mixdown. A score of the first revision's two sections renders as it always
has (§3.2).
### 2.1 Contract
```json
{ "channels": 1, "duration": 34000, "numeric": "wai.det.fixed64",
"sample_rate": 22050 }
```
`duration` is the rendered mixdown length in samples of each channel (sample frames).
`channels` is 1 or 2; a reader refuses any other count. A mix of two channels is the
left and the right, interleaved (§3.4).
**Reader limit.** A reader MUST NOT refuse a score for its length while `duration ×
channels` is at most 134 217 728 (2²⁷, the audio limit of SPEC §2), and MAY refuse one
past it (`duration_too_long`), when it reads the contract, before it renders anything.
A conforming encoder writes no score past it.
### 2.2 Notes
`n_notes u32`, then per note (65 bytes):
`start u64 | dur u64 | phase_inc i64 Fx | gain i64 Fx | waveform u8 |
attack u64 | decay u64 | sustain i64 Fx | release u64`.
`phase_inc` is the per-sample phase advance `freq / sample_rate`,
authored off-line from the pitch — so the floor never evaluates an
exponential to turn a note into a frequency (the same authoring
discipline as the avatar quaternion). `waveform` selects a reference
timbre (§3.1):
| code | timbre |
|---|---|
| `0` | saw, piecewise-linear |
| `1` | square, piecewise-linear |
| `2` | triangle, piecewise-linear |
| `3` | 25 %-pulse, piecewise-linear |
| `4` | saw, band-limited (each step corrected, PolyBLEP) |
| `5` | square, band-limited |
| `6` | 25 %-pulse, band-limited |
| `7` | sine (a refined parabola) |
| `8` | noise (a generator's value held between wraps of the phase) |
A note of another code is silent. The band-limited timbres are the piecewise-linear
ones with the step at each discontinuity spread over the samples beside it, so a high
note does not fold its harmonics back below the Nyquist frequency.
### 2.3 Authoring form (informative)
`wai score build` writes a `WSCR` from a JSON document that holds every
field as an integer, so the committed document is the definition and no
platform's floating-point `pow` can change a pitch:
```json
{ "sample_rate": 22050, "channels": 1, "duration": 34000,
"numeric": "wai.det.fixed64",
"notes": [ { "start": 0, "dur": 3500, "phase_inc": 50960238,
"gain": 1717986918, "waveform": 0, "attack": 220,
"decay": 600, "sustain": 2791728742, "release": 1500 } ] }
```
A voiced score of two channels at 48 000 Hz: one band-limited saw (A4,
`phase_inc` = round(440 / 48 000 × 2³²)), panned half left, its decay and
release curved, through two low-pass sections that open with its envelope, with
three echoes a quarter second apart crossing from right to left:
```json
{ "sample_rate": 48000, "channels": 2, "duration": 72000,
"numeric": "wai.det.fixed64",
"notes": [ { "start": 0, "dur": 24000, "phase_inc": 39370534,
"gain": 1288490189, "waveform": 4, "attack": 480,
"decay": 24000, "sustain": 2147483648, "release": 9600,
"voice": { "pan": -2147483648, "decay_curve": 4,
"release_curve": 4, "lp_poles": 2,
"lp_a": 214748365, "lp_env": 1717986918,
"echo_taps": 3, "echo_delay": 12000,
"echo_gain": 1717986918,
"echo_pan": 3006477107 } } ] }
```
`phase_inc`, `gain`, `sustain` and a voice's `pan`, `lp_a`, `lp_env`, `glide_to`,
`echo_gain` and `echo_pan` are the raw Q32.32 bits (a pan negative for the left).
`numeric` may be left out. `voice` is optional on each note, and each of its members
is optional, a member left out taking the default voice's value (§2.4); a score in
which any note has a `voice` is voiced, and a note of it with none takes the default
voice. The tool refuses a number with a fraction or an exponent, a member it does not
name, a member given twice (readers differ on which of the two values they keep), a
channel count other than 1 or 2, a `duration × channels` past 2²⁶, a `phase_inc` or a
`glide_to` of a whole cycle or more, a `gain` or `sustain` above 1.0, a waveform above
8, a voice member out of the range §2.4 gives, a `pan` or an `echo_pan` other than 0
in one channel, a `glide` with no `glide_to`, an echo of no delay, and a note whose
release, or last echo, would end after `duration`, so the mixdown always holds every
note whole. `score-conformance/notes/` holds each vector in this form, and `wai score
build` turns it back into the committed `WSCR` byte for byte.
### 2.4 Voices
A voices section holds one 73-byte record per note, in the notes' order, and nothing
else: its length is `n_notes × 73` (it has no count of its own). Each record:
```
pan i64 Fx | attack_curve u8 | decay_curve u8 | release_curve u8 | lp_poles u8 |
lp_a i64 Fx | lp_env i64 Fx | glide_to i64 Fx | glide u64 | seed u32 |
echo_taps u8 | echo_delay u64 | echo_gain i64 Fx | echo_pan i64 Fx
```
| field | meaning | range |
|---|---|---|
| `pan` | the note's place in a two-channel mix, −1.0 left to 1.0 right | −1.0 to 1.0; 0 in one channel |
| `attack_curve`, `decay_curve`, `release_curve` | the exponent of each envelope segment (§3.3); 1 is a line | 1 to 8 |
| `lp_poles` | one-pole low-pass sections in series; 0 is none | 0 to 4 |
| `lp_a` | each section's coefficient at an envelope of 0 | 0 to 1.0 |
| `lp_env` | what the coefficient gains per unit of envelope | 0 to 1.0 |
| `glide_to` | the phase increment the note glides to | 0 to below 1.0 |
| `glide` | the glide's length in samples; 0 is none (`glide_to` is then not read) | any |
| `seed` | the noise generator's seed (waveform 8) | any |
| `echo_taps` | how many echoes of the note | 0 to 8 |
| `echo_delay` | samples from one echo to the next | 1 to 2²⁷ when `echo_taps` > 0 |
| `echo_gain` | each echo's level against the one before it | 0 to 1.0 |
| `echo_pan` | the pan of the odd echoes; the even ones sit at its negation | −1.0 to 1.0; 0 in one channel |
A reader refuses a voices section of another length, and a record with a field out of
its range. The **default voice** is `pan` 0, the three curves 1, `lp_poles` 0, `lp_a`
1.0, `lp_env` 0, `glide_to` 0, `glide` 0, `seed` 0, `echo_taps` 0, `echo_delay` 0,
`echo_gain` 0 and `echo_pan` 0. A voiced note sounds from `start` to its **end**,
`start + dur + release + echo_taps × echo_delay` (each sum exact); a note of a score with
no voices to `start + dur + release`.
## 3. Reference synthesis (trig-free)
Every value is a `wai.det.fixed64` Q32.32 number: `+` and `−` saturate, `×` and `÷` are
the profile's (computed in 128 bits, rounded toward negative infinity, then saturated),
a negation of the least value is the greatest, and √ is the profile's (the floor square
root of the raw bits times 2³², and 0 for a value of 0 or below). An expression is
evaluated as written, `×` and `÷` before `+` and `−` and otherwise left to right, each
operation rounded as it is made; `1` is 1.0, `½` is 2³¹ raw, `¼` is 2³⁰ raw, and an
integer `n` of samples used as a value is `n · 2³²` raw. The whole path is `+ − × ÷ √`
in the `Fx` floor — bit-identical on every machine.
### 3.1 Oscillators
A note's phase `p` starts at 0. At each sample the oscillator gives a value from `p` and
the sample's increment `dt` (the note's `phase_inc`, or its glide's, §3.3); then
`p ← p + dt`, and where `p ≥ 1`, `p ← p − 1` once (the phase **wraps**).
- `0` saw: `2·p − 1`. `1` square: `1` for `p < ½`, else `−1`. `2` triangle: `4·p − 1`
for `p < ½`, else `3 − 4·p`. `3` pulse: `1` for `p < ¼`, else `−1`.
- The step correction `B(t, dt)`: `0` for `dt ≤ 0`; for `t < dt`, with `x = t ÷ dt`,
`x + x − x·x − 1`; for `t > 1 − dt`, with `x = (t − 1) ÷ dt`, `x·x + x + x + 1`;
otherwise `0`. With `W(q)` = `q − 1` for `q ≥ 1` and `q` otherwise:
`4` saw: `2·p − 1 − B(p, dt)`; `5` square: `s + B(p, dt) − B(W(p + ½), dt)`, `s` the
square's value; `6` pulse: `s + B(p, dt) − B(W(p + ½ + ¼), dt)`, `s` the pulse's value.
- `7` sine: `u = 2·p − 1`, `y = 4·u·(1 − |u|)`, `y ← P·(y·|y| − y) + y` with `P` =
966 367 642 raw (0.225), and the value is `−y` (sin 2π`p` to within a thousandth).
- `8` noise: the value **held**, from a 32-bit state `s`, set to the voice's `seed` (0
in a score with no voices) when the note starts, and advanced once then and again each
time the phase wraps: `s ← (1 664 525 · s + 1 013 904 223) mod 2³²`, the held value
then `((s >> 8) − 2²³) · 2⁹` raw, in `[−1, 1)`. A `phase_inc` just below 1 draws a new
value every sample after the first.
### 3.2 A score with no voices
Each note's envelope at `t` samples into it is the linear ADSR: for `attack > 0` and
`t < attack`, `t ÷ attack`; else for `t < attack + decay`, `1 − (1 − sustain) ×
(t − attack) ÷ decay` (or `sustain` when `decay` is 0); else for `t < dur`, `sustain`;
else for `release > 0` and `t < dur + release`, `sustain × (1 − (t − dur) ÷ release)`;
else 0. The note's sample is `osc × env × gain`, for `t` from 0 while `start + t` is
below both `start + dur + release` and `duration`. In one channel it is added to the
mix; in two, each channel adds it times `√((1 − 0) / 2)` (the centre, §3.3's pan of 0).
### 3.3 A voiced score
For each note and its voice, with `F(t, n)` = ⌊`t` · 2³² / `n`⌋ raw (exact, for `t < n`)
and `v^k` = `v` multiplied by `v` `k − 1` times in turn:
1. *Glide.* With `glide` = 0, `dt = phase_inc`. Otherwise `dt = phase_inc + ⌊(glide_to −
phase_inc) · min(t, glide) / glide⌋` raw, the difference of the raw values times
`min(t, glide)`, divided by `glide` toward negative infinity: the increment moves in a
line to `glide_to` and holds there.
2. *Envelope.* The held level `H(t)`: for `attack > 0` and `t < attack`,
`1 − (1 − F(t, attack))^attack_curve`; else, with `u = t − attack`, for `decay > 0`
and `u < decay`, `sustain + (1 − sustain) × (1 − F(u, decay))^decay_curve`; else
`sustain`. The envelope `E(t)` is `H(t)` for `t < dur`; for `release > 0` and
`t − dur < release`, `H(dur) × (1 − F(t − dur, release))^release_curve`; else 0. The
release starts from the level the note had at note-off, so a note let go during its
attack or decay does not jump.
3. *Low-pass.* With `lp_poles` = `k` > 0, the coefficient is `a = lp_a + lp_env × E(t)`,
held to `[0, 1]`; each section `i` of `1..k`, its state 0 when the note starts, makes
`y_i ← y_i + a × (x − y_i)` from its input `x` (the oscillator's value for the first,
the section before's `y` for the rest), and the last section's `y` is the filtered
value. With `lp_poles` 0 the oscillator's value passes.
4. *Gain.* `x = filtered × E(t) × gain`.
5. *Pan.* The gains of a pan `q` are `(L, R) = (√((1 − q) ⁄ 2), √((1 + q) ⁄ 2))`, each
`⁄ 2` the raw value shifted right one bit (floor): `L² + R²` is 1 at every pan, to
within the rounding, and the centre is 3 037 000 499 raw (√½) on each side. In two channels the note adds
`x × L` to the left and `x × R` to the right at frame `start + t`, by its `pan`; in
one channel it adds `x`.
6. *Echoes.* Echo `j` of `1..echo_taps` has the level `g_j = g_{j−1} × echo_gain`, `g_0
= 1`, and the pan `echo_pan` for odd `j`, `−echo_pan` for even `j`. After the note's
own sample, each echo in order adds `x × g_j` at frame `start + t + j × echo_delay`,
by its pan (in one channel, `x × g_j`), while that frame is below `duration`.
`t` runs from 0 while `start + t` is below both `start + dur + release` and `duration`;
each sample the oscillator's value is taken, then the phase advances by that sample's
`dt` (§3.1).
### 3.4 Mixdown
The mix is `duration × channels` values, each 0 to start, two channels interleaved left,
right (frame `f`'s left is value `2f`, its right `2f + 1`). The notes are rendered in
their order; within a note, sample by sample as above, each addition saturating as it
is made. Each value is then quantized to `i16` (`round(v · 32767)`, half away from zero,
`v` first clamped to `[−1, 1]`) in integer space.
## 4. Conformance — mixdown-equivalence
> Given the same `wai.audio.score`, a conforming sink MUST reconstruct
> the identical canonical `i16` PCM via the reference synth, hence the
> identical BLAKE3 — on every machine, no tolerance parameter.
```
mixdown_hash = BLAKE3("wai:score-mixdown\x01" || i16le PCM)
score_hash = BLAKE3("wai:score\x01" || contract || notes [|| voices]) // identity
```
The PCM is the mix of §3.4, two channels interleaved. `contract`, `notes` and `voices`
are the sections' bytes; a score with no voices section hashes as the first revision's
did. A conformance vector also states `pcm_sha256`, the SHA-256 of the same `i16le` PCM
(the audio buffer of SPEC §7 *Digests*), so a verifier that holds only SHA-256 checks
the samples. `wai score render` prints both, with `samples` the frames and `channels`
the contract's, and, with `--pcm`, writes the samples.
A sink MAY play a soundfont/neural instrument instead; that is
presentation and does not affect conformance, exactly as the audio-scene
HRTF render or the worlds renderer does not.
In a package ([packaging](packaging.md) §5, *Components beside the lead*), a score is cut at the lead's piece starts:
each piece a `WSCR` of the notes that sound in it (to their end, §2.4, echoes included),
its starts relative to the piece and their voices with them, whose mixdown is the
whole's for the piece's frames; its entry states the SHA-256 of that mixdown, and its
floor is the mixdown as FLAC in the score's channels.
## 5. Receipt (JWP profile)
`score_hash` is the identity; the group's objects are the mix PCM blocks
(4096 frames each, every channel of them; the last may be short), Merkle-bound; one Ed25519 signature (verified as
SPEC §7 *Signatures* requires) over score_hash + root + exact work (= duration × notes) + carried
`joules_micro`; `parent_receipt_hash` chains — the worlds/audio receipt
shape reused. The figure's acquisition class rides beside it as an optional signed label
([energy-measurement](energy-measurement.md) §2.8): absent, the receipt keeps
its legacy bytes and the figure is unlabelled; present, it is inside the
signature. The reference meter labels every figure it measures
`OnChipCounter`, with its declared uncertainty.
## Appendix
Mixdown-equivalence is spatial audio's criterion with the recorded
objects replaced by note events: both render a deterministic `i16` mix
in the `Fx` floor and hash it. The score is the most extreme
instructions-at-the-sink case in audio — there is no recording anywhere
in the payload, only the composition. Sight, hearing, touch, motion,
state, captured-content provenance — and now the composition itself —
are one floor, one verification.