AtlatestRepositorysigil-audio
sigil-audio / tree / src / sigil / audioladder.sgl
1
;;; (sigil audio ladder) - Step a producer's render rate down when it cannot keep up2
;;;3
;;; A game that synthesises its audio on the frame thread (a tracker, a synth)4
;;; spends time per frame in proportion to the rate it renders at. On a weak5
;;; device that time does not fit a 60 fps frame, and the audio breaks up. The6
;;; ladder measures what the producer costs, and when the steady cost is over7
;;; budget it steps the render rate down a rung (48000 -> 32000 -> 24000 ->8
;;; 22050, say). The sink then upsamples the lower rate to the device's9
;;; (`set-audio-stream-rate!` on the web sink), so the music keeps playing a10
;;; little duller instead of stuttering.11
;;;12
;;; The ladder is the policy and the measurement; it does not touch a sink or a13
;;; player. The game declares the rungs, the budget and the floor, feeds it the14
;;; cost of each pull, and asks it once a frame whether to step:15
;;;16
;;; ```scheme17
;;; (import (sigil audio ladder))18
;;; (define ladder (make-rate-ladder (audio-context-sample-rate ctx)19
;;; rungs: '(32000 24000 22050)20
;;; budget-ms: 4.021
;;; key: "ring 16384"))22
;;;23
;;; ;; each pull of the producer: timed and recorded24
;;; (rate-ladder-measure ladder n (lambda () (player-pull! p buf 0 n)))25
;;;26
;;; ;; each cue the game plays: the next pulls are a tap's frame, not a sample27
;;; (rate-ladder-quiet! ladder)28
;;;29
;;; ;; once a frame: a new rate when the ladder stepped, else #f30
;;; (let ((rate (rate-ladder-step! ladder)))31
;;; (when rate (reopen-player-at! rate)))32
;;;33
;;; (rate-ladder-report ladder) ; => "48000/24000 BY LADDER@20.2S"34
;;; ```35
;;;36
;;; ## The rules, and why each one exists37
;;;38
;;; Every rule below was learned from a measurement on a phone (Crash the39
;;; Stack, 2026-09-21 to 23), and the ladder's value is mostly in its refusals:40
;;;41
;;; - **The median of a window of pulls decides, never one pull** (`window:`,42
;;; 60 by default, about a second of frames). The first tile tap's frame43
;;; alone dragged a MEAN from 48000 to 32000: a mean plus one hostile frame44
;;; is not a device's speed.45
;;; - **Pulls next to a cue or an open are not measured.** The frame that46
;;; draws a tap, or parses and opens a tune, is not a sample of steady cost.47
;;; `rate-ladder-quiet!` refuses pulls for `quiet-ms:` (150); an open, and48
;;; 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 (or lifting one) can raise the rate, and no52
;;; hint, restore or step goes below the floor.53
;;; - **A stored rate is a measurement under one buffering.** The rung a54
;;; device lands on is not a property of the device alone: the same phone55
;;; settled at 24000 with a 256 ms ring and at 32000, 3 s sooner, with a56
;;; 320 ms ring, because a ring that is not repeatedly drained gives a57
;;; calmer median. So what the ladder asks you to remember carries a `key:`58
;;; (describe the buffering: "ring 12288"), and `rate-ladder-restore!`59
;;; ignores a stored rate whose key differs.60
;;;61
;;; ## Reasons, for a readout62
;;;63
;;; `rate-ladder-reason` answers why the rate is what it is: `device` (the64
;;; device's own rate), `stored` (restored from a previous boot), `hint` (the65
;;; game started low, e.g. from the page's cores/memory hints), `pin` (a test66
;;; or a player's setting pinned it; the ladder does not step), or67
;;; `(ladder . seconds)` (the ladder stepped, that many seconds after the68
;;; ladder was made). `rate-ladder-reason-string` and `rate-ladder-report`69
;;; spell it the way a one-line readout wants it.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))79
(export make-rate-ladder80
rate-ladder?81
rate-ladder-rate82
rate-ladder-device-rate83
rate-ladder-rungs84
rate-ladder-budget85
rate-ladder-set-budget!86
rate-ladder-reason87
rate-ladder-reason-string88
rate-ladder-report89
rate-ladder-pinned?90
rate-ladder-pin!91
rate-ladder-unpin!92
rate-ladder-hint!93
rate-ladder-restore!94
rate-ladder-remembered95
rate-ladder-opened!96
rate-ladder-quiet!97
rate-ladder-record!98
rate-ladder-measure99
rate-ladder-step!100
rate-ladder-pulls101
rate-ladder-last-cost) ; the median cost per frame (ms) of the last window decided, or #f103
(begin104
(define-struct rate-ladder105
(device-rate)106
(rungs) ; below the device's rate, at or above the floor, descending107
(budget mutable: #t) ; ms per frame the producer may spend, steady108
(window) ; measured pulls a decision takes109
(skip) ; pulls refused after an open or a step110
(quiet-ms) ; ms refused after a cue111
(frame-rate) ; the frames per second the budget is per112
(clock) ; () -> ms113
(remember) ; (string) -> any, or #f: called after a step114
(key) ; the buffering a remembered rate was measured under115
(lowest) ; the floor: no hint, restore or step goes below it116
(boot-ms)117
(rate mutable: #t)118
(reason mutable: #t)119
(pinned mutable: #t)120
(samples) ; the window: per-frame costs, ms121
(count mutable: #t) ; measured pulls in the window122
(skip-left mutable: #t)123
(quiet-until mutable: #t)124
(last-cost mutable: #t)) ; the median of the last window decided, ms per frame, or #f126
;;; Make a ladder for a producer on a device running at DEVICE-RATE Hz.127
;;;128
;;; Keywords:129
;;; rungs: the rates below the device's to step through, in any130
;;; order (default '(32000 24000 22050)). Rungs at or above131
;;; the device's rate are dropped.132
;;; floor: the lowest rate the ladder may reach (default #f: the133
;;; lowest rung). A judgement about your weakest device.134
;;; budget-ms: the steady cost per frame the producer may spend, ms135
;;; (default 4.0)136
;;; frame-rate: the frames per second that budget is per (default 60)137
;;; window: measured pulls per decision (default 60)138
;;; skip: pulls refused after an open or a step (default 30)139
;;; quiet-ms: ms refused after rate-ladder-quiet! (default 150)140
;;; remember: a procedure given rate-ladder-remembered's string after141
;;; each step (a store for the next boot), or #f142
;;; key: the buffering the rate is measured under, as a string143
;;; (default ""), e.g. "ring 16384"144
;;; clock: a procedure answering the time in ms (default: the145
;;; monotonic clock, fractional ms; a test passes its own).146
;;; It must resolve well under a millisecond: a pull costs147
;;; tenths of one, and a whole-ms clock rounds those to 0 or148
;;; 1, which moves the median across the budget.149
(define (make-rate-ladder device-rate150
(keys: (rungs '(32000 24000 22050))151
(floor #f)152
(budget-ms 4.0)153
(frame-rate 60)154
(window 60)155
(skip 30)156
(quiet-ms 150)157
(remember #f)158
(key "")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-rate165
rungs: rs166
budget: (* 1.0 budget-ms)167
window: (max 1 window)168
skip: (max 0 skip)169
quiet-ms: quiet-ms170
frame-rate: frame-rate171
clock: clock172
remember: remember173
key: key174
lowest: lowest175
boot-ms: now176
rate: device-rate177
reason: 'device178
pinned: #f179
samples: (make-vector (max 1 window) 0.0)180
count: 0181
skip-left: (max 0 skip)182
quiet-until: now183
last-cost: #f)))185
;; ms on the monotonic clock, fractional (current-jiffy is nanoseconds186
;; natively; the browser's clock under wasm)187
(define (monotonic-ms)188
(/ (* 1000.0 (current-jiffy)) (jiffies-per-second)))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))193
(set-rate-ladder-budget! l (* 1.0 ms))))195
(define (clear-window! l)196
(set-rate-ladder-count! l 0))198
;; ---------------------------------------------------------------------199
;; Reasons200
;; ---------------------------------------------------------------------202
;;; Why the rate is what it is, for a readout: "DEVICE", "STORED", "HINT",203
;;; "PIN" or "LADDER@<seconds>S".204
(define (rate-ladder-reason-string l)205
(let ((r (rate-ladder-reason l)))206
(cond ((pair? r) (string-append "LADDER@" (number->string (cdr r)) "S"))207
((eq? r 'stored) "STORED")208
((eq? r 'hint) "HINT")209
((eq? r 'pin) "PIN")210
(else "DEVICE"))))212
;;; "<device-rate>/<rate> BY <reason>", e.g. "48000/24000 BY LADDER@20.2S".213
(define (rate-ladder-report l)214
(string-append (number->string (rate-ladder-device-rate l)) "/"215
(number->string (rate-ladder-rate l)) " BY "216
(rate-ladder-reason-string l)))218
(define (rate-ladder-pinned? l) (and (rate-ladder-pinned l) #t))220
;; ---------------------------------------------------------------------221
;; Starting rates: pin, hint, stored222
;; ---------------------------------------------------------------------224
;;; Pin the rate (clamped to the device's): the ladder stops stepping and225
;;; 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)229
(set-rate-ladder-reason! l 'pin)230
(clear-window! l)231
(rate-ladder-rate l))233
;;; Lift a pin: back to the device's rate (up, if the pin was lower), and234
;;; 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))238
(set-rate-ladder-reason! l 'device)239
(clear-window! l)240
(rate-ladder-rate l))242
;;; Start low on a device the game already believes is weak (the page's243
;;; cores/memory hints). Applies only while the ladder is unpinned and244
;;; 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))251
(>= rate (rate-ladder-lowest l)))252
(begin253
(set-rate-ladder-rate! l rate)254
(set-rate-ladder-reason! l 'hint)255
(clear-window! l)256
#t)257
#f))259
;;; The string to keep for the next boot: "<rate> <key>", or "<rate>" when260
;;; the key is "".261
(define (rate-ladder-remembered 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))))266
;;; Restore a rate a previous boot remembered (rate-ladder-remembered's267
;;; string). Ignored, answering #f, when the ladder is pinned, the string268
;;; does not read, it was measured under a different key, it is below the269
;;; floor, or it is not below the rate in force (a restore never raises the270
;;; 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)))))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 key277
(integer? rate)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)))282
(begin283
(set-rate-ladder-rate! l rate)284
(set-rate-ladder-reason! l 'stored)285
(clear-window! l)286
rate)287
#f)))289
;; ---------------------------------------------------------------------290
;; Measuring291
;; ---------------------------------------------------------------------293
;;; The producer (re)opened: its first pulls carry the open's own work, so294
;;; the next `skip` pulls and the next `quiet-ms` are not measured, and the295
;;; window starts over.296
(define (rate-ladder-opened! l)297
(clear-window! l)298
(set-rate-ladder-skip-left! l (rate-ladder-skip l))299
(rate-ladder-quiet! l))301
;;; A cue (a tap's sound, an effect) just played: the pulls in the next302
;;; `quiet-ms` share a frame with its drawing and are not measured.303
(define (rate-ladder-quiet! l)304
(set-rate-ladder-quiet-until! l (+ ((rate-ladder-clock l)) (rate-ladder-quiet-ms l))))306
;;; Record one pull: MS spent producing FRAMES frames at the ladder's307
;;; current rate. Answers #t if it was measured, #f if refused (inside a308
;;; skip or a quiet window, the window already full and waiting for309
;;; rate-ladder-step!, or no frames).310
(define (rate-ladder-record! l ms frames)311
(let ((now ((rate-ladder-clock l))))312
(cond ((or (not (number? frames)) (<= frames 0)) #f)313
((> (rate-ladder-skip-left l) 0)314
(set-rate-ladder-skip-left! l (- (rate-ladder-skip-left l) 1))315
#f)316
((< now (rate-ladder-quiet-until l)) #f)317
((>= (rate-ladder-count l) (rate-ladder-window l)) #f)318
(else319
;; ms per frame-rate frame: the frames one display frame needs320
;; at this rate, at this pull's cost per frame321
(vector-set! (rate-ladder-samples l) (rate-ladder-count l)322
(/ (* 1.0 ms (rate-ladder-rate l))323
(* frames (rate-ladder-frame-rate l))))324
(set-rate-ladder-count! l (+ (rate-ladder-count l) 1))325
#t))))327
;;; Call THUNK (the producer's pull of FRAMES frames), timing it with the328
;;; ladder's clock, record the pull, and answer THUNK's result.329
(define (rate-ladder-measure l frames thunk)330
(let* ((t0 ((rate-ladder-clock l)))331
(result (thunk)))332
(rate-ladder-record! l (- ((rate-ladder-clock l)) t0) frames)333
result))335
;;; Measured pulls in the current window.336
(define (rate-ladder-pulls l) (rate-ladder-count l))338
(define (median v n)339
(let* ((s (list-sort < (let loop ((i (- n 1)) (acc '()))340
(if (< i 0) acc (loop (- i 1) (cons (vector-ref v i) acc))))))341
(half (/ (- n (modulo n 2)) 2)))342
(if (even? n)343
(/ (+ (list-ref s (- half 1)) (list-ref s half)) 2.0)344
(list-ref s half))))346
(define (rung-below l rate)347
(let loop ((rs (rate-ladder-rungs l)))348
(cond ((null? rs) #f)349
((< (car rs) rate) (car rs))350
(else (loop (cdr rs))))))352
;;; Once a frame. When the window is full, decide on its median: over353
;;; budget, with a rung below and no pin, step down and answer the new354
;;; rate (the producer should reopen at it; the next `skip` pulls are355
;;; refused, and the remember procedure is called). Otherwise answer #f.356
;;; The rate never goes up here.357
(define (rate-ladder-step! l)358
(if (< (rate-ladder-count l) (rate-ladder-window l))359
#f360
(let ((cost (median (rate-ladder-samples l) (rate-ladder-window l)))361
(next (rung-below l (rate-ladder-rate l))))362
(set-rate-ladder-last-cost! l cost)363
(clear-window! l)364
(if (and (not (rate-ladder-pinned l))365
(> cost (rate-ladder-budget l))366
next)367
(let ((secs (/ (round (/ (- ((rate-ladder-clock l)) (rate-ladder-boot-ms l)) 100)) 10.0)))368
(set-rate-ladder-rate! l next)369
(set-rate-ladder-reason! l (cons 'ladder secs))370
(set-rate-ladder-skip-left! l (rate-ladder-skip l))371
(when (rate-ladder-remember l)372
((rate-ladder-remember l) (rate-ladder-remembered l)))373
next)374
#f))))))