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)