one sound on a page at a time: what starts playing silences the rest
npx shadcn add @ag/audibleAlready installed? Preview an update
npx shadcn add @ag/audible --dry-runYour installed source stays unchanged until you update it. How updates work
Latest changes · 2026-10-12
- New:
handVoice(setting), the voice of many videos playing muted (a gallery's cells, a wall's tiles): only the one under the hand is heard while the group's remembered sound is on. The gallery's sound is it now. - New:
lib/ambient-budget(ambientBudget,ambientStart,AMBIENT_SAMPLE_MS), how many videos a page plays by themselves at once, in turns of one pass, as many as the machine keeps up with. The gallery's and the film wall's. - New: the budget also heeds the machine's load. Its frame counts said only whether the videos kept up, so a gallery grew its decoders until the browser dropped frames while everything else on the machine starved (a gallery taking files in froze a laptop with no frame dropped).
watchPressure(budget)(ui/machine-pressure) hears the CPU's pressure where the browser says it (Chrome and Edge; elsewhere the frames decide alone): the budget never grows while it is serious, and while it is critical gives a decoder up at once and one more every window after, down to one. Galleries, loose cells and the film wall wire it themselves. A budget written by hand (AmbientBudget) implementspressure: (state, at) => void; one made withambientBudgetand handed toGalleryAmbient budgethears the machine only if you callwatchPressureon it.
the demo above, verbatim
"use client"
import * as React from "react"
import { Sample } from "@/app/samples"
import { Button } from "@/registry/base-nova/ui/button"
import { useAudible, useHolding } from "@/registry/base-nova/ui/use-audible"
import { Video } from "@/registry/base-nova/ui/video"
// #region floor
// Who holds the page's one floor, named for this demo.
function Holder() {
const holder = useHolding()
return (
<p className="font-mono text-foreground/60 text-xs">
heard:{" "}
{holder === null
? "nothing"
: typeof holder === "string"
? holder
: "a video"}
</p>
)
}
// A voice of the page's own: a tone it says it plays. Starting it silences any
// video speaking; a video starting silences it.
function Tone() {
const [on, setOn] = React.useState(false)
const voice = useAudible("the tone", () => setOn(false))
return (
<Button
size="sm"
variant="secondary"
pressed={on}
onClick={() => {
if (on) {
voice.hush()
setOn(false)
} else {
voice.speak()
setOn(true)
}
}}
>
{on ? "the tone plays" : "play the tone"}
</Button>
)
}
// #endregion
export default function AudibleDemo() {
return (
<div className="space-y-8">
<Sample
name="floor"
glance="wide"
title="one voice on the page"
description="play one video with its sound, then the other: the first pauses. The tone is a voice of the page's own, on the same floor."
with="floor"
>
<div className="flex max-w-xl flex-col gap-4">
<div className="grid grid-cols-2 gap-4">
<Video
src="/bunny.mp4"
poster="/bunny-poster.jpg"
label="big buck bunny"
width={1280}
height={720}
className="rounded-lg"
/>
<Video
src="/apollo-11/eagle-has-landed.mp4"
poster="/apollo-11/eagle-poster.jpg"
label="the eagle has landed"
width={1280}
height={720}
className="rounded-lg"
/>
</div>
<div className="flex items-center gap-4">
<Tone />
<Holder />
</div>
</div>
</Sample>
</div>
)
}the source, every file an install writes
// WHAT IS AUDIBLE: one voice on a page at a time. A thing that starts to sound claims
// the floor under its key; the voice that held it is silenced (each of its claimers
// told, in the order they claimed: a video pauses, a gallery cell ramps down and
// mutes, a reading closes its docked face). Claims under the SAME key are one voice:
// a reading and the video it plays claim together and are silenced together, never
// each other. A claimer releases when it stops wanting the floor (it unmounts, it is
// closed); the floor is free once the voice's last claimer has gone.
//
// One floor per page: whatever sounds, from any item, asks it, so a gallery cell, a
// player and a reading can never be heard at once. Keys are compared by identity: a
// string names a voice several things share, an object (an element) a voice of one.
//
// Framework-free (no React, no DOM); ui/use-audible is the hook over it. Proven by
// scripts/examples/audible.ts.
/** Who a voice is: a shared name, or the one thing speaking (an element). */
export type VoiceKey = string | object
/** What a claimer does when its voice loses the floor. */
export type Silence = () => void
let holder: { key: VoiceKey; claims: Set<Silence> } | null = null
const listeners = new Set<() => void>()
const notify = () => {
for (const l of listeners) l()
}
/** Claims the floor for `key`. Another voice holding it is silenced first; a claim
* under the voice already holding it joins it. Returns the release. */
export function claim(key: VoiceKey, silence: Silence): () => void {
if (holder && holder.key !== key) {
const was = holder
holder = null
for (const s of was.claims) s()
}
if (!holder) holder = { key, claims: new Set() }
holder.claims.add(silence)
notify()
return () => release(key, silence)
}
/** Gives one claim back: the floor is free once the voice has none left. Releasing a
* claim that no longer holds the floor (its voice was silenced) does nothing. */
export function release(key: VoiceKey, silence: Silence): void {
if (!holder || holder.key !== key || !holder.claims.delete(silence)) return
if (holder.claims.size === 0) holder = null
notify()
}
/** The voice holding the floor, or null. */
export const holding = (): VoiceKey | null => holder?.key ?? null
/** Hears every change of who holds the floor. */
export function subscribe(listener: () => void): () => void {
listeners.add(listener)
return () => {
listeners.delete(listener)
}
}"use client"
// The hook over lib/audible: a component's claim on the page's one voice, released when
// it unmounts, and who holds the floor now. And `handVoice`, the voice of many videos
// that sound one at a time, under the hand.
import * as React from "react"
import {
claim,
holding,
type Silence,
subscribe,
type VoiceKey,
} from "@/registry/base-nova/lib/audible"
import type { Setting } from "@/registry/base-nova/lib/remembered-core"
import { recall, remember } from "@/registry/base-nova/ui/remembered"
// ── THE HAND'S VOICE: many videos playing muted (a gallery's cells, a wall's tiles), of
// which at most ONE is ever audible: the one under the hand, and only while the group's
// sound is on. The sound is a setting of the group, never of a video ("sound on" stays on
// from one to the next, remembered: `on`). Taking moves the sound (the old one ramps
// down and mutes, the new one ramps up), leaving silences it, and the audible one holds
// the page's one floor (lib/audible): whatever else sounded is silenced, and a voice
// taking the floor from elsewhere ramps it down. Unmuting inside the click that turns
// sound on is allowed by the browser, and that click is a sticky activation, so later
// takes sound with no further click; an unmuted play the browser still refuses falls
// back to muted, and the sound is off until turned on again.
/** The volume ramp across a handover, so a move between videos never clicks. */
const RAMP_MS = 80
export interface HandVoice {
/** The video under the hand: audible if the sound is on. */
take: (el: HTMLVideoElement) => void
/** The hand left it (or it stops playing): muted, the floor given back. */
leave: (el: HTMLVideoElement) => void
/** Turn the group's sound on or off: remembered, and applied to the one under the
* hand now. */
set: (on: boolean) => void
}
/** One group's voice, its sound kept in `on`. */
export function handVoice(on: Setting<boolean>): HandVoice {
let owner: HTMLVideoElement | null = null
let floor: (() => void) | null = null
const ramps = new WeakMap<HTMLVideoElement, number>()
const ramp = (el: HTMLVideoElement, to: number, done?: () => void) => {
cancelAnimationFrame(ramps.get(el) ?? 0)
const from = el.volume
const start = performance.now()
const step = () => {
const p = Math.min(1, (performance.now() - start) / RAMP_MS)
el.volume = from + (to - from) * p
if (p < 1) ramps.set(el, requestAnimationFrame(step))
else done?.()
}
ramps.set(el, requestAnimationFrame(step))
}
/** The owner takes the page's floor, unless it holds it already. Losing it to another
* voice ramps it down and mutes it, and it is no longer the owner. */
const takeFloor = (el: HTMLVideoElement) => {
if (floor) return
floor = claim(el, () => {
floor = null
ramp(el, 0, () => {
el.muted = true
})
if (owner === el) owner = null
})
}
const leaveFloor = () => {
floor?.()
floor = null
}
/** The owner sounds if the sound is on (taking the floor), else it is muted (and
* holds no floor: a muted video silences nothing). */
const voice = () => {
const el = owner
if (!el) return
if (!recall(on)) {
el.muted = true
leaveFloor()
return
}
takeFloor(el)
const wasPlaying = !el.paused
el.volume = 0
el.muted = false
ramp(el, 1)
// A browser that refuses unmuted playback pauses the element (or rejects play):
// fall back to muted, keep it playing, and say so.
const refused = () => {
if (owner !== el) return
el.muted = true
el.play().catch(() => {})
remember(on, false)
}
if (el.paused && !wasPlaying) el.play().catch(refused)
else window.setTimeout(() => wasPlaying && el.paused && refused(), 60)
}
return {
take: (el) => {
const old = owner
if (old === el) return voice()
// The old owner hands over: ramped down and muted, its floor given back for the
// new owner to take (a handover inside the group silences nothing else).
if (old) ramp(old, 0, () => (old.muted = true))
leaveFloor()
owner = el
voice()
},
leave: (el) => {
el.muted = true
if (owner !== el) return
owner = null
leaveFloor()
},
set: (value) => {
remember(on, value)
voice()
},
}
}
/** This component's voice under `key`: `speak()` claims the floor (silencing whoever
* held it), `hush()` gives the claim back, and an unmount gives it back too. `silence`
* is what it does when another voice takes the floor; the latest one is called. */
export function useAudible(
key: VoiceKey,
silence: Silence,
): { speak: () => void; hush: () => void } {
const latest = React.useRef(silence)
React.useLayoutEffect(() => {
latest.current = silence
})
const releaseRef = React.useRef<(() => void) | null>(null)
const hush = React.useCallback(() => {
releaseRef.current?.()
releaseRef.current = null
}, [])
const speak = React.useCallback(() => {
hush()
releaseRef.current = claim(key, () => {
releaseRef.current = null
latest.current()
})
}, [key, hush])
React.useEffect(() => hush, [hush])
return React.useMemo(() => ({ speak, hush }), [speak, hush])
}
/** The voice holding the floor now, or null: re-renders when it changes. */
export function useHolding(): VoiceKey | null {
return React.useSyncExternalStore(subscribe, holding, () => null)
}// AMBIENT MOTION'S BUDGET: how many videos a page plays by themselves at once (a
// gallery's cells, a wall's tiles), as the machine shows it can. Framework-free;
// ui/gallery-cell and ui/film-wall ask it for turns. Proven by
// scripts/examples/gallery-ambient.ts.
//
// TURNS. Every video that wants to play asks for a turn; a granted one plays ONE PASS
// (its pieces in turn, its span round once), then gives its slot to the one that has
// waited longest and joins the back of the queue. With nobody waiting it keeps its slot
// and loops on. So every video plays, however many there are, a few at a time.
//
// HOW MANY AT A TIME IS THE MACHINE'S: the budget starts at what the machine says of
// itself (`ambientStart`, half its cores) and each playing video reports its own frame
// counters (getVideoPlaybackQuality). Over a window of its play, a video that dropped
// at most `KEEPS_UP` of its frames kept up, one that dropped more than `FALLS_BEHIND`
// fell behind. The budget grows by one while videos wait and as many videos as it has
// slots kept up since its last change, and shrinks to one fewer than are playing the
// moment any falls behind (never below one); a size it fell behind at is not tried
// again for `RETRY_MS`. A consumer may cap it; below the cap the machine decides.
//
// WHAT KEEPING UP COSTS: frame counts say whether the videos keep up, never what that
// takes from the rest of the machine; a browser decodes a dozen videos without dropping
// a frame while the machine's other work (an indexer, the window server, every other
// app) starves (2026-10-12: a gallery taking files in froze a laptop with nothing
// dropped). Where the machine says how loaded it is (`pressure`, the Compute Pressure
// API's states, ui/machine-pressure), the budget grows only while it is `nominal` or
// `fair`, holds while `serious`, and while `critical` gives up a decoder at once and one
// more every window after, down to one. Where nothing says it, the frames alone decide.
/** Where a budget starts: half the machine's cores, at least two (what any machine
* that plays video at all keeps up with). Started at two and grown on evidence, a
* screen of twenty took half a minute to wake one cell at a time ("so slowly",
* Adrian, 2026-10-11); started from the machine, the frame counts correct it within
* a window either way. */
export const ambientStart = (cores: number) =>
Math.max(2, Math.floor(cores / 2))
/** How often a playing video reads its frame counters. */
export const AMBIENT_SAMPLE_MS = 500
/** One verdict on a video: at least two seconds of its play holding at least thirty
* presented frames, so one late frame is a few percent of it and never a verdict. */
const WINDOW_MS = 2000
const WINDOW_FRAMES = 30
/** Kept up: at most one frame in fifty dropped over a window, a stutter no eye
* catches. */
const KEEPS_UP = 0.02
/** Fell behind: more than one frame in ten dropped over a window, a stutter anyone
* sees. Between the two the budget holds. */
const FALLS_BEHIND = 0.1
/** A size the machine fell behind at waits this long before it is tried again, so a
* budget at its edge does not grow and cut a video every few seconds. */
const RETRY_MS = 30_000
/** A video's own counters since its source was set (getVideoPlaybackQuality): the
* frames it was handed and those it could not draw in time. */
export interface FrameCount {
total: number
dropped: number
}
/** How loaded the machine says it is: the Compute Pressure API's states, lightest
* first. */
export type AmbientPressure = "nominal" | "fair" | "serious" | "critical"
/** A page's decoders for ambient motion, granted in turns. */
export interface AmbientBudget {
/** A video wants a turn: `turn(true)` when it is granted (at once if a slot is
* free), `turn(false)` when it gives the slot back and waits again (its pass ended
* while others waited, or the budget shrank under it). */
request: (id: symbol, turn: (granted: boolean) => void) => void
/** A granted video played one pass: it yields to the longest waiting, if any. */
passed: (id: symbol) => void
/** A granted video's counters at `at` ms, every `AMBIENT_SAMPLE_MS` while it shows. */
measure: (id: symbol, at: number, frames: FrameCount) => void
/** The machine's load changed, at `at` ms on the clock `measure` reads. */
pressure: (state: AmbientPressure, at: number) => void
/** The video no longer wants a turn (off screen, motion off, gone). */
release: (id: symbol) => void
/** How it stands now: its size, who plays (in grant order) and who waits (longest
* first). */
view: () => {
size: number
granted: readonly symbol[]
waiting: readonly symbol[]
}
/** Called after any change of who plays and who waits (a waiting video's place in
* line moves); returns the unsubscribe. */
subscribe: (listener: () => void) => () => void
}
/** One budget, `cap` decoders at most (none by default: the machine's verdicts alone
* bound it), `start` of them at first (`ambientStart`). */
export function ambientBudget(
cap = Number.POSITIVE_INFINITY,
start = 2,
): AmbientBudget {
if (!(cap >= 1)) throw new Error(`ambient-budget: a cap of ${cap}`)
if (!(start >= 1)) throw new Error(`ambient-budget: a start of ${start}`)
type Turn = (granted: boolean) => void
type Since = FrameCount & { at: number }
let size = Math.min(start, cap)
const listeners = new Set<() => void>()
const changed = () => {
for (const listener of listeners) listener()
}
/** The playing, in grant order, each with where its current window began. */
const granted = new Map<symbol, { turn: Turn; since: Since | null }>()
let waiting: { id: symbol; turn: Turn }[] = []
/** Videos that kept up over a whole window since the size last changed. */
const kept = new Set<symbol>()
/** The size the machine last fell behind at, and until when it is not tried. */
let behind = { size: Number.POSITIVE_INFINITY, until: 0 }
/** The machine's load as last said; `nominal` until something says otherwise. */
let strain: AmbientPressure = "nominal"
const fill = () => {
while (granted.size < size && waiting.length > 0) {
const next = waiting.shift() as (typeof waiting)[number]
granted.set(next.id, { turn: next.turn, since: null })
next.turn(true)
}
}
/** A granted video to the back of the queue. */
const yieldTurn = (id: symbol) => {
const g = granted.get(id) as { turn: Turn }
granted.delete(id)
waiting.push({ id, turn: g.turn })
g.turn(false)
}
/** The size changed: every verdict so far was about the old one. */
const anew = () => {
kept.clear()
for (const g of granted.values()) g.since = null
}
/** Too many for the machine: one fewer than are playing (never below one), the
* longest playing giving way first (it has had the most of its turn), and the size
* it was at not tried again for `RETRY_MS`. */
const shrink = (at: number) => {
const playing = Math.min(size, granted.size)
size = Math.max(1, playing - 1)
behind = { size: playing, until: at + RETRY_MS }
while (granted.size > size) yieldTurn(granted.keys().next().value as symbol)
anew()
changed()
}
return {
request(id, turn) {
if (granted.has(id) || waiting.some((w) => w.id === id))
throw new Error("ambient-budget: a turn asked for twice")
waiting.push({ id, turn })
fill()
changed()
},
// A pass or a count from a video whose turn was just taken back (the budget shrank
// while its frame was in flight) is about a turn that is over: ignored.
passed(id) {
if (!granted.has(id) || waiting.length === 0) return
yieldTurn(id)
fill()
changed()
},
measure(id, at, frames) {
const g = granted.get(id)
if (!g) return
const since = g.since
if (since === null || frames.total < since.total) {
g.since = { at, ...frames }
return
}
const seen = frames.total - since.total
if (at - since.at < WINDOW_MS || seen < WINDOW_FRAMES) return
const share = (frames.dropped - since.dropped) / seen
g.since = { at, ...frames }
// A machine still critical a whole window after it gave a decoder up gives up
// another, however smoothly the videos play.
if (share > FALLS_BEHIND || strain === "critical") {
shrink(at)
return
}
if (share > KEEPS_UP) return
kept.add(id)
const grow =
(strain === "nominal" || strain === "fair") &&
waiting.length > 0 &&
size < cap &&
kept.size >= size &&
(size + 1 < behind.size || at >= behind.until)
if (!grow) return
size += 1
fill()
anew()
changed()
},
pressure(state, at) {
const was = strain
strain = state
if (state === "critical" && was !== "critical" && granted.size > 1)
shrink(at)
},
release(id) {
granted.delete(id)
waiting = waiting.filter((w) => w.id !== id)
fill()
changed()
},
view: () => ({
size,
granted: [...granted.keys()],
waiting: waiting.map((w) => w.id),
}),
subscribe(listener) {
listeners.add(listener)
return () => listeners.delete(listener)
},
}
}// THE MACHINE'S OWN WORD ON ITS LOAD, for the page's ambient budgets (lib/ambient-budget
// `pressure`): the Compute Pressure API's CPU states (nominal, fair, serious, critical,
// from the processor's load and heat), heard by one observer for the whole page and
// told to every budget alive. Chrome and Edge say it; where the browser has no
// PressureObserver (Safari, Firefox) nothing is heard and the frame counts decide alone.
import type {
AmbientBudget,
AmbientPressure,
} from "@/registry/base-nova/lib/ambient-budget"
/** The Compute Pressure API, as much of it as the budgets read (not in TypeScript's DOM
* types yet). */
interface PressureRecord {
state: AmbientPressure
}
interface PressureObserverLike {
observe: (
source: "cpu",
options?: { sampleInterval?: number },
) => Promise<void>
disconnect: () => void
}
type PressureObserverCtor = new (
callback: (records: PressureRecord[]) => void,
) => PressureObserverLike
/** At most one state a second: the budget's verdicts are two-second windows. */
const SAMPLE_MS = 1000
const budgets = new Set<AmbientBudget>()
let observer: PressureObserverLike | null = null
let last: AmbientPressure | null = null
/** `budget` hears the machine's load from now on, its last state at once; the return
* stops it. The observer lives while any budget listens. */
export function watchPressure(budget: AmbientBudget): () => void {
const Ctor = (globalThis as { PressureObserver?: PressureObserverCtor })
.PressureObserver
if (!Ctor) return () => {}
budgets.add(budget)
if (last) budget.pressure(last, performance.now())
if (!observer) {
const made = new Ctor((records) => {
const state = records.at(-1)?.state
if (!state) return
last = state
for (const b of budgets) b.pressure(state, performance.now())
})
observer = made
// Refused where a permissions policy withholds it (an iframe not granted
// compute-pressure): the frames decide alone there, said once. An observer
// disconnected before it started (its last budget gone first: React's development
// mount runs every effect twice) is refused too, and that is no news.
made.observe("cpu", { sampleInterval: SAMPLE_MS }).catch((e: Error) => {
if (made !== observer) return
console.warn(
`machine-pressure: the CPU's load is not heard (${e.message})`,
)
})
}
return () => {
budgets.delete(budget)
if (budgets.size > 0 || !observer) return
observer.disconnect()
observer = null
last = null
}
}
