← ui

sound-source

ui

where a drawing of a sound hears it: a media element tapped once, per channel

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

Your installed source stays unchanged until you update it. How updates work

Latest changes · 2026-10-08
  • New: where a drawing of a sound gets the sound, one door for every player and every drawing. useSoundTap(element, readable) taps a media element once, after a press, connecting only once its context runs; soundReadable(src, crossOrigin) says whether its spectrum may be read; SoundSourceProvider / useSoundSource hand the analysers and whether it plays to anything inside; TimebaseSoundSource says it plays while the timebase around it plays. lib/sound-scale is the one analysis scale. AudioPlayback uses it; your own drawing reads useSoundSource.
  • New: a preview is heard by the drawings. While a timeline previews a moment (a word hovered, the bar hovered), TimebaseSoundSource hands down that moment's sound instead of the held one: the element's own recording, decoded once on the first preview, a quarter second at the moment looped into analysers that never reach the speakers (usePreviewSound). A halo round a face moves with the moment under the hand. useSoundTap returns the tap's context and element beside its analysers, and TimebaseSoundSource takes the tap as sound.
Updated 2026-10-08 · release notes

also called analyser

shelf media

uses playback

used by video

the demo above, verbatim

sound-source.demo.tsx
"use client"

import * as React from "react"
import { Sample } from "@/app/samples"
import { PlayerBar, PlayerSound } from "@/registry/base-nova/ui/player"
import {
  VideoPicture,
  VideoPlayback,
} from "@/registry/base-nova/ui/video-playback"
import { useSoundSource } from "./sound-source"

// #region meter
/** How many bands each channel is drawn in, low to high. */
const BANDS = 32

/** Your own drawing of the sound: anything inside a player reads it through
 *  `useSoundSource`. Here a stereo spectrum, the left channel rising from the middle
 *  line and the right falling from it, each in 32 bands spaced as hearing is (more
 *  of them low, where the sound is), written straight to the bars' style each
 *  frame, never a render. Before the sound is tapped (a page may start sound only
 *  after a press) it says so instead of drawing silence. */
function Meter() {
  const source = useSoundSource()
  const bars = React.useRef<(HTMLSpanElement | null)[]>([])
  const heard = source?.analysers != null
  React.useEffect(() => {
    const analysers = source?.analysers
    if (!analysers || !source.playing) return
    const bins = analysers.map(
      (a) => new Uint8Array(new ArrayBuffer(a.frequencyBinCount)),
    )
    // Each band's bins, by a power curve: band k covers [n·(k/B)², n·((k+1)/B)²).
    const edges = (n: number) =>
      Array.from({ length: BANDS + 1 }, (_, k) =>
        Math.max(k, Math.round(n * (k / BANDS) ** 2)),
      )
    let frame = 0
    const tick = () => {
      analysers.forEach((a, side) => {
        const b = bins[side] as Uint8Array<ArrayBuffer>
        a.getByteFrequencyData(b)
        const e = edges(b.length)
        for (let k = 0; k < BANDS; k++) {
          const from = e[k] as number
          const to = Math.max(from + 1, e[k + 1] as number)
          let peak = 0
          for (let i = from; i < to; i++) peak = Math.max(peak, b[i] ?? 0)
          const bar = bars.current[side * BANDS + k]
          if (bar) bar.style.transform = `scaleY(${peak / 255})`
        }
      })
      frame = requestAnimationFrame(tick)
    }
    frame = requestAnimationFrame(tick)
    return () => cancelAnimationFrame(frame)
  }, [source])
  return (
    <div className="relative grid h-28 grid-rows-2 gap-px">
      {[0, 1].map((side) => (
        <div
          key={side}
          className={`flex gap-0.5 ${side === 0 ? "items-end" : "items-start"}`}
        >
          {Array.from({ length: BANDS }, (_, k) => (
            <span
              // biome-ignore lint/suspicious/noArrayIndexKey: a fixed run of bands
              key={k}
              ref={(el) => {
                bars.current[side * BANDS + k] = el
              }}
              className={`h-full flex-1 scale-y-[0.02] rounded-full bg-foreground/80 ${side === 0 ? "origin-bottom" : "origin-top opacity-60"}`}
            />
          ))}
        </div>
      ))}
      {!heard && (
        <p className="absolute inset-0 m-0 grid place-items-center font-mono text-muted-foreground text-xs">
          press play to hear it drawn
        </p>
      )}
    </div>
  )
}
// #endregion

export default function Demo() {
  return (
    <Sample name="meter" glance="large" title="your own drawing" with="meter">
      <VideoPlayback
        src="/bunny-stereo.mp4"
        poster="/bunny-poster.jpg"
        label="Big Buck Bunny, stereo"
        loop
        sound
      >
        <div className="mx-auto flex w-full max-w-md flex-col gap-4">
          <VideoPicture aspect="16 / 9" />
          <Meter />
          <PlayerBar label="Big Buck Bunny, stereo">
            <PlayerSound />
          </PlayerBar>
        </div>
      </VideoPlayback>
    </Sample>
  )
}

the source, every file an install writes

sound-source.tsx
"use client"

// Where a drawing of a sound gets the sound: a media element's spectrum, analysed per
// channel and handed down. One door for every player (ui/audio's AudioPlayback, and
// ui/video-playback's VideoPlayback with `sound`) and every drawing (ui/sound-ribbon,
// or your own through `useSoundSource`).
//
// The element is tapped (createMediaElementSource) once and never twice (the API
// throws on a second tap), folded to left and right by Web Audio's speaker rules (mono
// on both sides, 5.1 folded the standard way) and analysed per channel. An
// AudioContext may only start after the person has pressed something, so the tap
// happens at the first play a press asks for, or at the first press after the page has
// had one. Once tapped, the element's sound flows through the context: a context that
// is not running would silence it, so the tap connects only once the context runs. A
// cross-origin source is tapped only with `crossOrigin` set (and the server sending
// CORS headers): without, the browser hands Web Audio silence and the element would go
// quiet, so it plays untapped and has no spectrum. Same-origin works as is.

import * as React from "react"
import { SOUND_SCALE } from "@/registry/base-nova/lib/sound-scale"
import { useTimebaseState } from "@/registry/base-nova/ui/playback"

/** An analyser set the way every drawing here reads it (lib/sound-scale): its window,
 *  its light smoothing, its decibel scale. */
export function soundAnalyser(context: BaseAudioContext): AnalyserNode {
  const node = context.createAnalyser()
  node.fftSize = SOUND_SCALE.fftSize
  node.smoothingTimeConstant = SOUND_SCALE.smoothing
  node.minDecibels = SOUND_SCALE.decibels.min
  node.maxDecibels = SOUND_SCALE.decibels.max
  return node
}

/** The left and right analysers of any node: it is mixed to two channels by Web
 *  Audio's speaker rules (mono copied to both, 5.1 folded the standard way), split, and
 *  each side analysed. Nothing is connected onward: the sound still goes where the
 *  caller sends it. */
export function channelAnalysers(
  context: BaseAudioContext,
  node: AudioNode,
): [AnalyserNode, AnalyserNode] {
  const stereo = context.createGain()
  stereo.channelCount = 2
  stereo.channelCountMode = "explicit"
  stereo.channelInterpretation = "speakers"
  const split = context.createChannelSplitter(2)
  const left = soundAnalyser(context)
  const right = soundAnalyser(context)
  node.connect(stereo)
  stereo.connect(split)
  split.connect(left, 0)
  split.connect(right, 1)
  return [left, right]
}

/** Where a sound's spectrum comes from: one analyser per channel (one, mirrored, or
 *  left and right) once they exist (an AudioContext starts only after a press, so they
 *  may come late), and whether the sound plays. */
export interface SoundSource {
  analysers: readonly AnalyserNode[] | null
  playing: boolean
}

const SoundSourceContext = React.createContext<SoundSource | null>(null)

/** Hands a sound's spectrum to everything inside. */
export function SoundSourceProvider({
  source,
  children,
}: {
  source: SoundSource
  children: React.ReactNode
}) {
  return (
    <SoundSourceContext.Provider value={source}>
      {children}
    </SoundSourceContext.Provider>
  )
}

/** The sound source around this component, or null where there is none. */
export function useSoundSource(): SoundSource | null {
  return React.useContext(SoundSourceContext)
}

/** Whether Web Audio can read this source: asked for with CORS, or a path on this
 *  origin (a relative URL, a blob, data). Decided from the string alone, the same on
 *  the server and in the browser; an absolute URL counts as another origin, so a
 *  same-origin one written in full passes `crossOrigin` too. */
export function soundReadable(
  src: string,
  crossOrigin: string | undefined,
): boolean {
  return crossOrigin !== undefined || !/^(?:[a-z][a-z\d+.-]*:)?\/\//i.test(src)
}

/** A media element's spectrum: the analysers once tapped (null before), and `tap`,
 *  which a player calls inside a play the person asked for (its `beforePlay`). The
 *  first press anywhere taps an element that started playing without one (autoplay, a
 *  playlist going on). The graph dies with the element. */
export function useSoundTap(
  el: HTMLMediaElement | null,
  readable: boolean,
): SoundTap & { tap: () => void } {
  const [tapped, setTapped] = React.useState<{
    analysers: readonly AnalyserNode[]
    context: AudioContext
  } | null>(null)
  const graph = React.useRef<{
    context: globalThis.AudioContext | null
    tapped: boolean
  }>({ context: null, tapped: false })

  const tap = React.useCallback(() => {
    const g = graph.current
    if (!el || g.tapped || !readable) return
    // Before anyone has pressed anything a context cannot start; the first press will.
    if (navigator.userActivation && !navigator.userActivation.hasBeenActive)
      return
    if (!g.context) g.context = new window.AudioContext()
    const context = g.context
    const connect = () => {
      if (g.tapped || context.state !== "running") return
      g.tapped = true
      const tapped = context.createMediaElementSource(el)
      // The sound goes on to the speakers as it came; the analysers only listen.
      tapped.connect(context.destination)
      setTapped({ analysers: channelAnalysers(context, tapped), context })
    }
    if (context.state === "running") connect()
    else context.resume().then(connect, () => {})
  }, [el, readable])

  React.useEffect(() => {
    if (!el) return
    const g = graph.current
    return () => {
      void g.context?.close()
      graph.current = { context: null, tapped: false }
      setTapped(null)
    }
  }, [el])

  React.useEffect(() => {
    if (!el || tapped) return
    const press = () => tap()
    window.addEventListener("pointerdown", press, { capture: true })
    window.addEventListener("keydown", press, { capture: true })
    return () => {
      window.removeEventListener("pointerdown", press, { capture: true })
      window.removeEventListener("keydown", press, { capture: true })
    }
  }, [el, tapped, tap])

  return {
    analysers: tapped?.analysers ?? null,
    context: tapped?.context ?? null,
    element: el,
    tap,
  }
}

/** A tapped element: its analysers and the context they live in (null before the tap),
 *  and the element, whose own recording a preview is heard from. */
export interface SoundTap {
  analysers: readonly AnalyserNode[] | null
  context: AudioContext | null
  element: HTMLMediaElement | null
}

/** How much of the recording a preview loops, s: long enough to be a sound (a
 *  syllable), short enough to be the moment under the hand. */
const PREVIEW_S = 0.25

// A recording decoded once per source and context, however many previews ask.
const decoded = new WeakMap<
  BaseAudioContext,
  Map<string, Promise<AudioBuffer>>
>()
function decodedSound(
  context: BaseAudioContext,
  src: string,
): Promise<AudioBuffer> {
  const mine = decoded.get(context) ?? new Map<string, Promise<AudioBuffer>>()
  decoded.set(context, mine)
  let sound = mine.get(src)
  if (!sound) {
    sound = fetch(src)
      .then((r) => {
        if (!r.ok) throw new Error(`sound-source: ${src} answered ${r.status}`)
        return r.arrayBuffer()
      })
      .then((bytes) => context.decodeAudioData(bytes))
    mine.set(src, sound)
  }
  return sound
}

/** The sound of the moment a preview shows: the element's own recording, decoded once
 *  (on the first preview), a short stretch at `ms` looped into analysers that never
 *  reach the speakers. Null while there is no preview, no tap yet, or the recording is
 *  still decoding. A drawing reading these sees the hovered moment, not the silence
 *  of a held timebase. */
export function usePreviewSound(
  { context, element }: SoundTap,
  ms: number | null,
): readonly AnalyserNode[] | null {
  const src = element?.currentSrc ?? ""
  const [sound, setSound] = React.useState<{
    src: string
    buffer: AudioBuffer
  } | null>(null)
  const bus = React.useMemo(() => {
    if (!context) return null
    const input = context.createGain()
    return { input, analysers: channelAnalysers(context, input) }
  }, [context])
  const buffer = sound?.src === src ? sound.buffer : null
  const asked = ms !== null

  React.useEffect(() => {
    if (!context || !src || !asked || buffer) return
    let live = true
    decodedSound(context, src).then(
      (b) => live && setSound({ src, buffer: b }),
      (error: unknown) => console.warn("sound-source: no preview sound", error),
    )
    return () => {
      live = false
    }
  }, [context, src, asked, buffer])

  React.useEffect(() => {
    if (!context || !bus || !buffer || ms === null) return
    const at = Math.min(
      Math.max(0, ms / 1000),
      Math.max(0, buffer.duration - PREVIEW_S),
    )
    const node = context.createBufferSource()
    node.buffer = buffer
    node.loop = true
    node.loopStart = at
    node.loopEnd = at + PREVIEW_S
    node.connect(bus.input)
    node.start(0, at)
    return () => {
      node.stop()
      node.disconnect()
    }
  }, [context, bus, buffer, ms])

  return bus && buffer && ms !== null ? bus.analysers : null
}

/** The spectrum for everything inside, playing when the timebase around it plays;
 *  while it previews, the previewed moment's sound (`usePreviewSound`). Read here,
 *  under the timebase, so the player itself never re-renders on a play or pause. */
export function TimebaseSoundSource({
  sound,
  children,
}: {
  sound: SoundTap
  children: React.ReactNode
}) {
  const playing = useTimebaseState((s) => s.playing && s.preview === null)
  const preview = useTimebaseState((s) => s.preview)
  const heard = usePreviewSound(sound, preview)
  const { analysers } = sound
  const source = React.useMemo(
    () =>
      heard ? { analysers: heard, playing: true } : { analysers, playing },
    [heard, analysers, playing],
  )
  return <SoundSourceProvider source={source}>{children}</SoundSourceProvider>
}
sound-scale.ts
// How a sound is analysed for drawing, one spelling for the analysers that read it
// (ui/sound-source) and the drawings that assume their scale (lib/ribbon-lines).
//
// 2048 samples is a 43 ms window at 48 kHz with bins 23 Hz apart, fine enough for the
// low end of a log scale. Web Audio's own smoothing is kept light (τ ≈ 18 ms at 60
// frames a second): a drawing follows with its own attack and release. The decibel
// scale runs to -10 dBFS because Web Audio's default top, -30, clips a mastered
// track's bass.
export const SOUND_SCALE = {
  fftSize: 2048,
  smoothing: 0.4,
  decibels: { min: -100, max: -10 },
} as const