Commit54e6248dRecorded24 Sep 2026Repositorysigil-audio
Ladder: a sub-millisecond default clock, the floor on hint and restore; review fixes
Message
The default clock was current-milliseconds, whole milliseconds: a pull costing 0.6 ms read as 1 ms more than half the time and stepped a device that was under budget. It is now the monotonic jiffy clock in fractional ms. Hints and stored rates below the floor are refused; the remembered string has no trailing space with the default key. Native tests pin the 128-frame loudness hold's boundary.
Changed
CHANGELOG.md | 15 +++++++++++----
src/sigil/audio/ladder.sgl | 52 ++++++++++++++++++++++++++++++++++++----------------
test/test-ladder.sgl | 46 ++++++++++++++++++++++++++++++++++++++++++++++
test/test-sink-rate-split.sgl | 13 +++++++++++++
4 files changed, 106 insertions(+), 20 deletions(-)Diff
CHANGELOG.mdmodified
@@ -13,15 +13,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
13
its steady cost per frame is over budget, the policy Crash the Stack tuned on 14
a phone. The median of a 60-pull window decides; pulls within 150 ms of a 15
cue, and the 30 after an open or a step, are not measured; the rate only goes−16
down (a pin is the one way up); a remembered rate carries a buffering key and−17
is ignored under a different one. `rate-ladder-report` gives the readout+16
down (a pin, or lifting one, is the only way up) and never below the floor; a+17
remembered rate carries a buffering key and is ignored under a different one.+18
The default clock is the monotonic jiffy clock in fractional ms: a pull costs+19
tenths of a millisecond, and a whole-ms clock moves the median across the+20
budget. `rate-ladder-report` gives the readout 21
line (`48000/24000 BY LADDER@20.2S`). Pure Sigil; `test/test-ladder.sgl`. 22
- **Cues in `(sigil audio sink)`**: `load-audio-cue`, `play-audio-cue`, 23
`unload-audio-cue!`, `stop-audio-cues!`, `set-audio-cue-volume!`, 24
`audio-cue-mode`, `audio-cue-lag`. On the web they come from 25
sigil-wasm-audio (mixed in the AudioWorklet in the next render quantum); 26
natively a cue is a sound in memory played through `play-sound`, mixed in−24
the device callback. Neither waits behind a stream's ring.+27
the device callback. Neither waits behind a stream's ring. The web arm needs+28
the sigil-wasm-audio released with sigil 0.22.7; an older one lacks these+29
names and the web build fails. 30
- **Streams below the device's rate**: `open-audio-stream rate:` and 31
`set-audio-stream-rate!`, upsampled on the audio thread with a 4-tap cubic; 32
a change lands at the write head it was asked at. `audio-stream-rate`.@@ -33,7 +38,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
38
39
- `play-sound` publishes a slot's `playing` flag with release ordering and 40
the device callback reads it with acquire, so the callback never mixes a slot−36
whose sound and position were not yet written.+41
whose sound and position were not yet written. (A slot reused by `play-sound`+42
while the callback is mixing it after `stop-all-sounds` is still a race; not+43
addressed here.) 44
45
## [0.12.2] - 2026-09-20 46
src/sigil/audio/ladder.sglmodified
@@ -48,7 +48,8 @@
48
;;; every step, refuses the next `skip:` pulls (30) as well. 49
;;; - **It steps down only.** A step is a reopen at the audible position, 50
;;; heard as a short gap, so a ladder that went up and down would chatter.−51
;;; Nothing but an explicit pin can raise the rate.+51
;;; Nothing but an explicit pin (or lifting one) can raise the rate, and no+52
;;; hint, restore or step goes below the floor. 53
;;; - **A stored rate is a measurement under one buffering.** The rung a 54
;;; device lands on is not a property of the device alone: the same phone 55
;;; settled at 24000 with a 256 ms ring and at 32000, 3 s sooner, with a@@ -70,6 +71,7 @@
71
(define-library (sigil audio ladder) 72
(import (sigil core) 73
(sigil math)+74
(sigil time) 75
(sigil struct) 76
(sigil list) 77
(sigil string))@@ -110,6 +112,7 @@
112
(clock) ; () -> ms 113
(remember) ; (string) -> any, or #f: called after a step 114
(key) ; the buffering a remembered rate was measured under+115
(lowest) ; the floor: no hint, restore or step goes below it 116
(boot-ms) 117
(rate mutable: #t) 118
(reason mutable: #t)@@ -138,8 +141,11 @@
141
;;; each step (a store for the next boot), or #f 142
;;; key: the buffering the rate is measured under, as a string 143
;;; (default ""), e.g. "ring 16384"−141
;;; clock: a procedure answering the time in ms (default−142
;;; current-milliseconds; a test passes its own)+144
;;; clock: a procedure answering the time in ms (default: the+145
;;; monotonic clock, fractional ms; a test passes its own).+146
;;; It must resolve well under a millisecond: a pull costs+147
;;; tenths of one, and a whole-ms clock rounds those to 0 or+148
;;; 1, which moves the median across the budget. 149
(define (make-rate-ladder device-rate 150
(keys: (rungs '(32000 24000 22050)) 151
(floor #f)@@ -150,9 +156,10 @@
156
(quiet-ms 150) 157
(remember #f) 158
(key "")−153
(clock current-milliseconds)))+159
(clock monotonic-ms))) 160
(let* ((low (or floor 0)) 161
(rs (list-sort > (filter (lambda (r) (and (< r device-rate) (>= r low))) rungs)))+162
(lowest (or floor (if (null? rs) device-rate (list-ref rs (- (length rs) 1))))) 163
(now (clock))) 164
(rate-ladder device-rate: device-rate 165
rungs: rs@@ -164,6 +171,7 @@
171
clock: clock 172
remember: remember 173
key: key+174
lowest: lowest 175
boot-ms: now 176
rate: device-rate 177
reason: 'device@@ -174,6 +182,11 @@
182
quiet-until: now 183
last-cost: #f))) 184
+185
;; ms on the monotonic clock, fractional (current-jiffy is nanoseconds+186
;; natively; the browser's clock under wasm)+187
(define (monotonic-ms)+188
(/ (* 1000.0 (current-jiffy)) (jiffies-per-second)))+189
190
;;; The budget, ms per frame. A test arm lowers it to force the ladder. 191
(define (rate-ladder-set-budget! l ms) 192
(when (and (number? ms) (> ms 0))@@ -209,7 +222,7 @@
222
;; --------------------------------------------------------------------- 223
224
;;; Pin the rate (clamped to the device's): the ladder stops stepping and−212
;;; reports PIN. A pin is the only way the rate goes up.+225
;;; reports PIN. A pin, or lifting one, is the only way the rate goes up. 226
(define (rate-ladder-pin! l rate) 227
(set-rate-ladder-rate! l (max 1 (min rate (rate-ladder-device-rate l)))) 228
(set-rate-ladder-pinned! l #t)@@ -217,7 +230,8 @@
230
(clear-window! l) 231
(rate-ladder-rate l)) 232
−220
;;; Lift a pin: back to the device's rate, and the ladder runs again.+233
;;; Lift a pin: back to the device's rate (up, if the pin was lower), and+234
;;; the ladder runs again. 235
(define (rate-ladder-unpin! l) 236
(set-rate-ladder-pinned! l #f) 237
(set-rate-ladder-rate! l (rate-ladder-device-rate l))@@ -227,12 +241,14 @@
241
242
;;; Start low on a device the game already believes is weak (the page's 243
;;; cores/memory hints). Applies only while the ladder is unpinned and−230
;;; still at the device's rate, and only downward. Answers #t if applied.+244
;;; still at the device's rate, only downward, and not below the floor.+245
;;; Answers #t if applied. 246
(define (rate-ladder-hint! l rate) 247
(if (and (not (rate-ladder-pinned l)) 248
(eq? (rate-ladder-reason l) 'device)+249
(integer? rate) 250
(< rate (rate-ladder-device-rate l))−235
(> rate 0))+251
(>= rate (rate-ladder-lowest l))) 252
(begin 253
(set-rate-ladder-rate! l rate) 254
(set-rate-ladder-reason! l 'hint)@@ -240,22 +256,26 @@
256
#t) 257
#f)) 258
−243
;;; The string to keep for the next boot: "<rate> <key>".+259
;;; The string to keep for the next boot: "<rate> <key>", or "<rate>" when+260
;;; the key is "". 261
(define (rate-ladder-remembered l)−245
(string-append (number->string (rate-ladder-rate l)) " " (rate-ladder-key l)))+262
(if (equal? (rate-ladder-key l) "")+263
(number->string (rate-ladder-rate l))+264
(string-append (number->string (rate-ladder-rate l)) " " (rate-ladder-key l)))) 265
266
;;; Restore a rate a previous boot remembered (rate-ladder-remembered's 267
;;; string). Ignored, answering #f, when the ladder is pinned, the string−249
;;; does not read, it was measured under a different key, or it is not−250
;;; below the rate in force (a restore never raises the rate). Answers the−251
;;; restored rate otherwise.+268
;;; does not read, it was measured under a different key, it is below the+269
;;; floor, or it is not below the rate in force (a restore never raises the+270
;;; rate). Answers the restored rate otherwise. 271
(define (rate-ladder-restore! l stored) 272
(let* ((sp (and (string? stored) (string-index stored (lambda (c) (char=? c #\space)))))−254
(rate (and sp (string->number (substring stored 0 sp))))−255
(key (and sp (substring stored (+ sp 1) (string-length stored)))))+273
(end (and (string? stored) (or sp (string-length stored))))+274
(rate (and end (string->number (substring stored 0 end))))+275
(key (and end (if sp (substring stored (+ sp 1) (string-length stored)) "")))) 276
(if (and rate key 277
(integer? rate)−258
(> rate 0)+278
(>= rate (rate-ladder-lowest l)) 279
(not (rate-ladder-pinned l)) 280
(equal? key (rate-ladder-key l)) 281
(< rate (rate-ladder-rate l)))test/test-ladder.sglmodified
@@ -7,6 +7,7 @@
7
8
(import (sigil test) 9
(sigil math)+10
(sigil time) 11
(sigil audio ladder)) 12
13
(define now 0)@@ -216,3 +217,48 @@
217
(assert-false (rate-ladder-restore! l "fast k")) 218
(assert-false (rate-ladder-restore! l "22050")) 219
(assert-false (rate-ladder-restore! l #f)))))+220
+221
(test-group "the review's findings"+222
(test "the default clock resolves well under a millisecond"+223
(let ((l (make-rate-ladder 48000 skip: 0 window: 1 quiet-ms: 0)))+224
;; a pull that spins 0.3 ms on the jiffy clock, 800 frames at 48000+225
;; (one 60 fps frame): its cost is its time+226
(rate-ladder-measure l 800+227
(lambda ()+228
(let ((t0 (current-jiffy))+229
(need (* 0.0003 (jiffies-per-second))))+230
(let spin ()+231
(when (< (- (current-jiffy) t0) need) (spin))))))+232
(rate-ladder-step! l)+233
(let ((cost (rate-ladder-last-cost l)))+234
(assert-true (>= cost 0.3))+235
;; a whole-millisecond clock can only answer 0, 1, 2...+236
(assert-false (integer? cost)))))+237
+238
(test "a hint below the floor is refused"+239
(let ((l (ladder)))+240
(assert-false (rate-ladder-hint! l 11025))+241
(assert-equal 48000 (rate-ladder-rate l))))+242
+243
(test "a stored rate below the floor is refused"+244
(let ((l (ladder key: "k")))+245
(assert-false (rate-ladder-restore! l "8000 k"))+246
(assert-equal 48000 (rate-ladder-rate l))))+247
+248
(test "with no floor given, the lowest rung is the floor"+249
(let ((l (make-rate-ladder 48000 rungs: '(32000 24000) clock: clock)))+250
(assert-false (rate-ladder-hint! l 22050))+251
(assert-true (rate-ladder-hint! l 24000))))+252
+253
(test "with the default key the remembered string is the bare rate, and it restores"+254
(let* ((kept #f)+255
(l (ladder remember: (lambda (s) (set! kept s)))))+256
(window! l 8.0)+257
(assert-equal "32000" kept)+258
(let ((l2 (ladder)))+259
(assert-equal 32000 (rate-ladder-restore! l2 kept)))))+260
+261
(test "unpin raises the rate back to the device's"+262
(let ((l (ladder)))+263
(rate-ladder-pin! l 24000)+264
(assert-equal 48000 (rate-ladder-unpin! l)))))test/test-sink-rate-split.sglmodified
@@ -83,6 +83,19 @@
83
(assert-equal 0 (audio-stream-underruns-sounding s)) 84
(close-audio-stream! s))) 85
+86
(test "the hold is 128 frames: dry 127 quiet frames after sound is sounding, 128 idle"+87
(let ((s (open-audio-stream buffer-frames: 4096)))+88
(push-audio-samples s (frames 64 (lambda (i) 0.1)) 64)+89
(push-audio-samples s (frames 127 (lambda (i) 0.0)) 127)+90
(drain (+ 64 127 10))+91
(assert-equal 10 (audio-stream-underruns-sounding s))+92
(push-audio-samples s (frames 64 (lambda (i) 0.1)) 64)+93
(push-audio-samples s (frames 128 (lambda (i) 0.0)) 128)+94
(drain (+ 64 128 10))+95
(assert-equal 10 (audio-stream-underruns-sounding s))+96
(assert-equal 20 (audio-stream-underruns s))+97
(close-audio-stream! s)))+98
99
(test "sound at volume 0 is not heard: dry after it is idle" 100
(let ((s (open-audio-stream buffer-frames: 4096 volume: 0.0))) 101
(push-audio-samples s (frames 256 (sine DEVICE)) 256)