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
fiRELEASES.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 adocs/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