(sigil audio ladder)
Step a producer's render rate down when it cannot keep up
A game that synthesises its audio on the frame thread (a tracker, a synth) spends time per frame in proportion to the rate it renders at. On a weak device that time does not fit a 60 fps frame, and the audio breaks up. The ladder measures what the producer costs, and when the steady cost is over budget it steps the render rate down a rung (48000 -> 32000 -> 24000 -> 22050, say). The sink then upsamples the lower rate to the device's (set-audio-stream-rate! on the web sink), so the music keeps playing a little duller instead of stuttering.
The ladder is the policy and the measurement; it does not touch a sink or a player. The game declares the rungs, the budget and the floor, feeds it the cost of each pull, and asks it once a frame whether to step:
(import (sigil audio ladder))
(define ladder (make-rate-ladder (audio-context-sample-rate ctx)
rungs: '(32000 24000 22050)
budget-ms: 4.0
key: "ring 16384"))
;; each pull of the producer: timed and recorded
(rate-ladder-measure ladder n (lambda () (player-pull! p buf 0 n)))
;; each cue the game plays: the next pulls are a tap's frame, not a sample
(rate-ladder-quiet! ladder)
;; once a frame: a new rate when the ladder stepped, else #f
(let ((rate (rate-ladder-step! ladder)))
(when rate (reopen-player-at! rate)))
(rate-ladder-report ladder) ; => "48000/24000 BY LADDER@20.2S"The rules, and why each one exists
Every rule below was learned from a measurement on a phone (Crash the Stack, 2026-09-21 to 23), and the ladder's value is mostly in its refusals:
- The median of a window of pulls decides, never one pull (
window:, 60 by default, about a second of frames). The first tile tap's frame alone dragged a MEAN from 48000 to 32000: a mean plus one hostile frame is not a device's speed. - Pulls next to a cue or an open are not measured. The frame that draws a tap, or parses and opens a tune, is not a sample of steady cost.
rate-ladder-quiet!refuses pulls forquiet-ms:(150); an open, and every step, refuses the nextskip:pulls (30) as well. - It steps down only. A step is a reopen at the audible position, heard as a short gap, so a ladder that went up and down would chatter. Nothing but an explicit pin (or lifting one) can raise the rate, and no hint, restore or step goes below the floor.
- A stored rate is a measurement under one buffering. The rung a device lands on is not a property of the device alone: the same phone settled at 24000 with a 256 ms ring and at 32000, 3 s sooner, with a 320 ms ring, because a ring that is not repeatedly drained gives a calmer median. So what the ladder asks you to remember carries a
key:(describe the buffering: "ring 12288"), andrate-ladder-restore!ignores a stored rate whose key differs.
Reasons, for a readout
rate-ladder-reason answers why the rate is what it is: device (the device's own rate), stored (restored from a previous boot), hint (the game started low, e.g. from the page's cores/memory hints), pin (a test or a player's setting pinned it; the ladder does not step), or (ladder . seconds) (the ladder stepped, that many seconds after the ladder was made). rate-ladder-reason-string and rate-ladder-report spell it the way a one-line readout wants it.
Exports
rate-ladderprocedureConstruct a rate-ladder struct.
rate-ladder?procedureTest if a value is a rate-ladder struct.
rate-ladder-device-rateprocedureGet the device-rate field of a rate-ladder struct.
rate-ladder-rungsprocedureGet the rungs field of a rate-ladder struct.
rate-ladder-budgetprocedureGet the budget field of a rate-ladder struct.
rate-ladder-windowprocedureGet the window field of a rate-ladder struct.
rate-ladder-skipprocedureGet the skip field of a rate-ladder struct.
rate-ladder-quiet-msprocedureGet the quiet-ms field of a rate-ladder struct.
rate-ladder-frame-rateprocedureGet the frame-rate field of a rate-ladder struct.
rate-ladder-clockprocedureGet the clock field of a rate-ladder struct.
rate-ladder-rememberprocedureGet the remember field of a rate-ladder struct.
rate-ladder-keyprocedureGet the key field of a rate-ladder struct.
rate-ladder-lowestprocedureGet the lowest field of a rate-ladder struct.
rate-ladder-boot-msprocedureGet the boot-ms field of a rate-ladder struct.
rate-ladder-rateprocedureGet the rate field of a rate-ladder struct.
rate-ladder-reasonprocedureGet the reason field of a rate-ladder struct.
rate-ladder-pinnedprocedureGet the pinned field of a rate-ladder struct.
rate-ladder-samplesprocedureGet the samples field of a rate-ladder struct.
rate-ladder-countprocedureGet the count field of a rate-ladder struct.
rate-ladder-skip-leftprocedureGet the skip-left field of a rate-ladder struct.
rate-ladder-quiet-untilprocedureGet the quiet-until field of a rate-ladder struct.
rate-ladder-last-costprocedureGet the last-cost field of a rate-ladder struct.
set-rate-ladder-budget!procedureSet the budget field of a rate-ladder struct.
set-rate-ladder-rate!procedureSet the rate field of a rate-ladder struct.
set-rate-ladder-reason!procedureSet the reason field of a rate-ladder struct.
set-rate-ladder-pinned!procedureSet the pinned field of a rate-ladder struct.
set-rate-ladder-count!procedureSet the count field of a rate-ladder struct.
set-rate-ladder-skip-left!procedureSet the skip-left field of a rate-ladder struct.
set-rate-ladder-quiet-until!procedureSet the quiet-until field of a rate-ladder struct.
set-rate-ladder-last-cost!procedureSet the last-cost field of a rate-ladder struct.
make-rate-ladderprocedureMake a ladder for a producer on a device running at DEVICE-RATE Hz.
Keywords: rungs: the rates below the device's to step through, in any order (default '(32000 24000 22050)). Rungs at or above the device's rate are dropped. floor: the lowest rate the ladder may reach (default #f: the lowest rung). A judgement about your weakest device. budget-ms: the steady cost per frame the producer may spend, ms (default 4.0) frame-rate: the frames per second that budget is per (default 60) window: measured pulls per decision (default 60) skip: pulls refused after an open or a step (default 30) quiet-ms: ms refused after rate-ladder-quiet! (default 150) remember: a procedure given rate-ladder-remembered's string after each step (a store for the next boot), or #f key: the buffering the rate is measured under, as a string (default ""), e.g. "ring 16384" clock: a procedure answering the time in ms (default: the monotonic clock, fractional ms; a test passes its own). It must resolve well under a millisecond: a pull costs tenths of one, and a whole-ms clock rounds those to 0 or 1, which moves the median across the budget.
rate-ladder-set-budget!procedureThe budget, ms per frame. A test arm lowers it to force the ladder.
rate-ladder-reason-stringprocedureWhy the rate is what it is, for a readout: "DEVICE", "STORED", "HINT", "PIN" or "LADDER@<seconds>S".
rate-ladder-reportprocedure"<device-rate>/<rate> BY <reason>", e.g. "48000/24000 BY LADDER@20.2S".
rate-ladder-pin!procedurePin the rate (clamped to the device's): the ladder stops stepping and reports PIN. A pin, or lifting one, is the only way the rate goes up.
rate-ladder-unpin!procedureLift a pin: back to the device's rate (up, if the pin was lower), and the ladder runs again.
rate-ladder-hint!procedureStart low on a device the game already believes is weak (the page's cores/memory hints). Applies only while the ladder is unpinned and still at the device's rate, only downward, and not below the floor. Answers #t if applied.
rate-ladder-rememberedprocedureThe string to keep for the next boot: "<rate> <key>", or "<rate>" when the key is "".
rate-ladder-restore!procedureRestore a rate a previous boot remembered (rate-ladder-remembered's string). Ignored, answering #f, when the ladder is pinned, the string does not read, it was measured under a different key, it is below the floor, or it is not below the rate in force (a restore never raises the rate). Answers the restored rate otherwise.
rate-ladder-opened!procedureThe producer (re)opened: its first pulls carry the open's own work, so the next skip pulls and the next quiet-ms are not measured, and the window starts over.
rate-ladder-quiet!procedureA cue (a tap's sound, an effect) just played: the pulls in the next quiet-ms share a frame with its drawing and are not measured.
rate-ladder-record!procedureRecord one pull: MS spent producing FRAMES frames at the ladder's current rate. Answers #t if it was measured, #f if refused (inside a skip or a quiet window, the window already full and waiting for rate-ladder-step!, or no frames).
rate-ladder-measureprocedureCall THUNK (the producer's pull of FRAMES frames), timing it with the ladder's clock, record the pull, and answer THUNK's result.
rate-ladder-pullsprocedureMeasured pulls in the current window.
rate-ladder-step!procedureOnce a frame. When the window is full, decide on its median: over budget, with a rung below and no pin, step down and answer the new rate (the producer should reopen at it; the next skip pulls are refused, and the remember procedure is called). Otherwise answer #f. The rate never goes up here.
rate-ladder-pinned?variable(No description)