Commit09fb5899Recorded24 Sep 2026Repositorysigil

Audio guide, RELEASES and CHANGELOG for cues, rates, the underrun split and the ladder; the cue/rate harness in test-gates

Changed
 CHANGELOG.md                  |  23 +++++++++++++++++++++++
 Makefile                      |   3 ++-
 RELEASES.md                   |  45 +++++++++++++++++++++++++++++++++++++++++++++
 docs/guides/graphics-audio.md | 103 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 4 files changed, 173 insertions(+), 1 deletion(-)
Diff
CHANGELOG.mdmodified
@@ -12,6 +12,29 @@ Earlier releases (v0.14.3 and below) predate this changelog; see
12
13
## Unreleased
14
+15
- **`sigil-wasm-audio` plays sound effects without waiting behind the music.**
+16
`load-audio-cue` uploads a sound once to a second AudioWorklet processor per
+17
context; `play-audio-cue` posts "play it now" and the processor mixes it into
+18
the next 128-frame render quantum, independent of any sink's ring depth (a cue
+19
pushed into a 12288-frame ring is heard 256 ms late at 48 kHz). No
+20
`SharedArrayBuffer` needed; with no worklet a play starts an
+21
AudioBufferSourceNode on a buffer built once at load. Also
+22
`unload-audio-cue!`, `stop-audio-cues!`, `set-audio-cue-volume!`,
+23
`audio-cue-mode` and `audio-cue-lag` (the frames a play waited for its
+24
quantum, measured in the worklet).
+25
- **A `sigil-wasm-audio` sink can be fed below the context's rate.**
+26
`open-audio-stream rate:` and `set-audio-stream-rate!`; the drain upsamples
+27
with a 4-tap cubic Hermite and a change lands at the write head it was asked
+28
at, so queued frames keep their rate. At the context's rate the ring is still
+29
copied sample for sample.
+30
- **Starved frames are split into sounding and idle.**
+31
`audio-stream-underruns-sounding` counts output frames that cut sound off
+32
(the ring ran dry within 128 frames of a sample above -80 dB, after the sink's
+33
volume); `audio-stream-underruns-idle` is the rest. The total is unchanged.
+34
- The three sink modes (worklet-sab, worklet-msg, scriptprocessor) share one
+35
drain function, embedded into the worklet with `Function.prototype.toString`.
+36
`test/integration/test-wasm-audio-worklet-cues-rate.mjs` (in `make
+37
test-gates`) drives the real processors in all three modes.
38
- **`sigil-wasm-audio` drains off the main thread on a page that is not
39
cross-origin isolated.** The bridge had two sink modes: a `SharedArrayBuffer`
40
ring drained by an `AudioWorkletNode` when the page is isolated (COOP/COEP),
Makefilemodified
@@ -924,7 +924,8 @@ test-gates: $(RELEASE_BIN)
924
@if command -v node >/dev/null 2>&1; then \
925
node ./test/integration/test-web-loader-dispatch.mjs && \
926
node ./test/integration/test-wasm-audio-worklet.mjs && \
−927
node ./test/integration/test-wasm-audio-worklet-msg.mjs; \
+927
node ./test/integration/test-wasm-audio-worklet-msg.mjs && \
+928
node ./test/integration/test-wasm-audio-worklet-cues-rate.mjs; \
929
else \
930
echo "SKIP: test-web-loader-dispatch.mjs and test-wasm-audio-worklet*.mjs need node; the web loader's dispatch contract and the audio bridge's worklet modes DID NOT RUN."; \
931
fi
RELEASES.mdmodified
@@ -18,6 +18,51 @@ slug: releases
18
# Sigil Releases
19
20
+21
## 0.22.7 (unreleased)
+22
+23
Two things every Sigil game with its own sound wanted, built into the browser audio sink
+24
so no game has to build them again. Sound effects now play the moment they are triggered,
+25
however much music is buffered, and a synth that is too slow for a weak device can step
+26
its render rate down and let the sink upsample. The dropout counter also says which
+27
dropouts a player could hear.
+28
+29
**Existing projects build without changes.** The new calls reach `(sigil audio sink)`
+30
through the sigil-audio release that ships alongside this one.
+31
+32
### Sound effects play at once in the browser
+33
+34
A sound effect pushed into a stream was heard one buffer-depth after the tap: 256 ms on a
+35
phone keeping 12288 frames ahead, and almost immediately right after a stall, so the delay
+36
came and went. Load a sound once with `load-audio-cue` and play it with `play-audio-cue`:
+37
the AudioWorklet mixes it into the next 128-frame render quantum (2.7 ms at 48 kHz),
+38
whatever the music's buffer holds. It works on pages that are not cross-origin isolated
+39
too, and pages with no AudioWorklet play it from a buffer built once at load.
+40
`audio-cue-lag` reports how many frames the last play waited, for an on-device readout.
+41
+42
### Streams can be fed below the device's rate
+43
+44
`open-audio-stream` takes `rate:`, and `set-audio-stream-rate!` changes it mid-stream. The
+45
sink upsamples with a 4-tap cubic on the audio thread, and a change applies from the next
+46
frame pushed, so the frames already buffered still play at the rate they were rendered at.
+47
A game that renders its music at 24000 on a 48000 device no longer needs a resampler of its
+48
own on the frame thread.
+49
+50
### Dropouts a player heard, counted apart
+51
+52
`audio-stream-underruns` counted every output frame a stream could not fill, including a
+53
stream with nothing to play running dry, which nobody hears. `audio-stream-underruns-sounding`
+54
counts only the frames that cut sound off, and `audio-stream-underruns-idle` the rest.
+55
+56
### A shared rate ladder
+57
+58
`(sigil audio ladder)`, in the same sigil-audio release, steps a producer's render rate down
+59
a rung (48000, 32000, 24000, 22050, or rungs you choose) when its steady cost per frame is
+60
over budget. It is the ladder Crash the Stack tuned on a phone: the median of 60 pulls
+61
decides, pulls next to a sound effect or a tune opening are not measured, the rate never
+62
steps back up, and a remembered rate is ignored when the buffering it was measured under
+63
has changed. `rate-ladder-report` gives the readout line, such as `48000/24000 BY
+64
LADDER@20.2S`.
+65
66
## 0.22.6 (2026-09-24)
67
68
A reliability release for scripts, plus the groundwork for `sigil env`. Two defects let a
docs/guides/graphics-audio.mdmodified
@@ -258,6 +258,109 @@ Supported format: OGG Vorbis.
258
(audio-muted?) ; => #t or #f
259
```
260
+261
### Streaming Audio on Every Target
+262
+263
A game that makes its own sound (a synth, a tracker) pushes PCM into a
+264
stream with `(sigil audio sink)`. The same source runs natively, where
+265
miniaudio drains the stream, and in the browser, where an AudioWorklet does
+266
(sigil-audio plus sigil-wasm-audio on the web config).
+267
+268
```sigil
+269
(import (sigil audio sink))
+270
+271
(define ctx (audio-context-open)) ; resume it from a tap on the web
+272
(define rate (audio-context-sample-rate ctx))
+273
(define music (open-audio-stream channels: 2 buffer-frames: 16384))
+274
+275
;; each frame: top the stream up toward the depth you want kept ahead
+276
(push-audio-samples music pcm n-frames) ; interleaved float32
+277
(audio-stream-depth music) ; frames queued, not yet heard
+278
```
+279
+280
The depth a stream keeps ahead is how long a frame may stall before the
+281
output runs dry. It is also how late anything pushed now is heard: at 48 kHz
+282
a 12288-frame depth is 256 ms.
+283
+284
#### Cues: sounds that must be heard at once
+285
+286
Do not push a tap or a hit into a stream: it would wait behind everything
+287
queued. Load it once as a cue and play it by handle:
+288
+289
```sigil
+290
(define tap (load-audio-cue (render-tap rate) channels: 1)) ; float32 PCM at `rate`
+291
+292
(play-audio-cue tap volume: 0.8 pan: -0.25)
+293
(set-audio-cue-volume! 0.5) ; the SFX volume, reaching cues already playing
+294
(stop-audio-cues!)
+295
```
+296
+297
In the browser a cue is mixed in the AudioWorklet in the next 128-frame
+298
render quantum (2.7 ms at 48 kHz), whatever any stream holds, cross-origin
+299
isolated or not; a page with no worklet plays it from a buffer made once at
+300
load. `(audio-cue-mode)` says which, and `(audio-cue-lag)` reports the frames
+301
the last play waited for its quantum. Natively a cue plays through the
+302
device callback, like `play-sound`.
+303
+304
#### Rendering below the device's rate
+305
+306
A producer can render at a lower rate than the device's and let the stream
+307
upsample (a 4-tap cubic, in the audio thread):
+308
+309
```sigil
+310
(define music (open-audio-stream rate: 24000))
+311
(set-audio-stream-rate! music 22050) ; from the frames pushed next
+312
```
+313
+314
A rate change applies from the stream's write position when you call it:
+315
frames already queued play at the rate they were rendered at, so a producer
+316
can step down without reopening the stream. Depth and room count the
+317
stream's own frames.
+318
+319
#### Knowing what the player heard
+320
+321
`(audio-stream-underruns s)` counts output frames the stream could not fill.
+322
Not all of them are audible: a stream with nothing to say that runs dry is
+323
silent either way. `(audio-stream-underruns-sounding s)` counts the frames
+324
that cut sound off (the stream ran dry within 128 frames of a sample above
+325
-80 dB), and `(audio-stream-underruns-idle s)` the rest. Judge a fix by the
+326
sounding count.
+327
+328
#### The rate ladder
+329
+330
On a weak device a synth can cost more per frame than a 60 fps frame has.
+331
`(sigil audio ladder)` measures what the producer costs and steps its render
+332
rate down a rung when the steady cost is over budget:
+333
+334
```sigil
+335
(import (sigil audio ladder))
+336
+337
(define ladder (make-rate-ladder rate
+338
rungs: '(32000 24000 22050)
+339
budget-ms: 4.0
+340
key: "ring 16384")) ; the buffering it measures under
+341
+342
;; each pull of the producer, timed
+343
(rate-ladder-measure ladder n (lambda () (render! buf n)))
+344
;; each cue: the next 150 ms of pulls share a frame with the tap
+345
(rate-ladder-quiet! ladder)
+346
;; once a frame
+347
(let ((new-rate (rate-ladder-step! ladder)))
+348
(when new-rate
+349
(reopen-synth-at! new-rate)
+350
(set-audio-stream-rate! music new-rate)))
+351
+352
(rate-ladder-report ladder) ; => "48000/24000 BY LADDER@20.2S"
+353
```
+354
+355
The median of 60 measured pulls decides, never a single pull. Pulls within
+356
150 ms of a cue and the 30 after an open or a step are not measured. The rate
+357
only goes down; `rate-ladder-pin!` is the one way to set it higher. To keep a
+358
device's rung across runs, pass `remember:` a procedure that stores
+359
`(rate-ladder-remembered ladder)` and call `rate-ladder-restore!` with it at
+360
the next boot. A stored rate is ignored under a different `key:`, because the
+361
rung a device lands on depends on the buffering around the measurement: one
+362
phone settled a rung higher with a 320 ms ring than with a 256 ms ring.
+363
364
## Example: Simple Game
365
366
```sigil