(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 for quiet-ms: (150); an open, and every step, refuses the next skip: 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"), and rate-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-ladderprocedure

Construct a rate-ladder struct.

rate-ladder?procedure

Test if a value is a rate-ladder struct.

Get the device-rate field of a rate-ladder struct.

Get the rungs field of a rate-ladder struct.

Get the budget field of a rate-ladder struct.

Get the window field of a rate-ladder struct.

Get the skip field of a rate-ladder struct.

Get the quiet-ms field of a rate-ladder struct.

Get the frame-rate field of a rate-ladder struct.

Get the clock field of a rate-ladder struct.

Get the remember field of a rate-ladder struct.

Get the key field of a rate-ladder struct.

Get the lowest field of a rate-ladder struct.

Get the boot-ms field of a rate-ladder struct.

Get the rate field of a rate-ladder struct.

Get the reason field of a rate-ladder struct.

Get the pinned field of a rate-ladder struct.

Get the samples field of a rate-ladder struct.

Get the count field of a rate-ladder struct.

Get the skip-left field of a rate-ladder struct.

Get the quiet-until field of a rate-ladder struct.

Get the last-cost field of a rate-ladder struct.

Set the budget field of a rate-ladder struct.

Set the rate field of a rate-ladder struct.

Set the reason field of a rate-ladder struct.

Set the pinned field of a rate-ladder struct.

Set the count field of a rate-ladder struct.

Set the skip-left field of a rate-ladder struct.

Set the quiet-until field of a rate-ladder struct.

Set the last-cost field of a rate-ladder struct.

Make 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.

The budget, ms per frame. A test arm lowers it to force the ladder.

Why the rate is what it is, for a readout: "DEVICE", "STORED", "HINT", "PIN" or "LADDER@<seconds>S".

"<device-rate>/<rate> BY <reason>", e.g. "48000/24000 BY LADDER@20.2S".

Pin 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.

Lift a pin: back to the device's rate (up, if the pin was lower), and the ladder runs again.

Start 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.

The string to keep for the next boot: "<rate> <key>", or "<rate>" when the key is "".

Restore 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.

The 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.

A 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.

Record 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).

Call THUNK (the producer's pull of FRAMES frames), timing it with the ladder's clock, record the pull, and answer THUNK's result.

Measured pulls in the current window.

Once 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.

(No description)