← ui

audible

ui

one sound on a page at a time: what starts playing silences the rest

npx shadcn add @ag/audible
Already installed? Preview an update
npx shadcn add @ag/audible --dry-run

Your 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) implements pressure: (state, at) => void; one made with ambientBudget and handed to GalleryAmbient budget hears the machine only if you call watchPressure on it.
Updated 2026-10-12 · release notes

shelf media

uses remembered

used by video, film-mosaic

the demo above, verbatim

audible.demo.tsx
"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

audible.ts
// 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-audible.ts
"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-budget.ts
// 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)
    },
  }
}
machine-pressure.ts
// 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
  }
}