{
  "name": "audio-viz-utils",
  "type": "registry:lib",
  "description": "Windowed audio spectrum helpers",
  "dependencies": [
    "@remotion/media-utils",
    "remotion"
  ],
  "registryDependencies": [],
  "files": [
    {
      "path": "registry/bases/default/lib/audio-viz-utils.ts",
      "type": "registry:lib",
      "content": "import {\n  useWindowedAudioData,\n  visualizeAudio,\n} from \"@remotion/media-utils\";\nimport { useCurrentFrame, useVideoConfig } from \"remotion\";\n\n/** Remotion recommends a generous window so sliding reloads do not drop audio mid-playback. */\nexport const DEFAULT_AUDIO_WINDOW_SECONDS = 30;\n\nconst clamp01 = (value: number) => Math.min(1, Math.max(0, value));\nconst lerp = (a: number, b: number, t: number) => a + (b - a) * t;\n\nexport type SpectrumBandOptions = {\n  /** How many display bands the FFT bins collapse into. */\n  bandCount?: number;\n  /**\n   * Bins below this index are folded into the first band. Bin 0 carries DC plus\n   * rumble, which otherwise dwarfs everything musical.\n   */\n  minBin?: number;\n  /**\n   * Music loses roughly 6 dB per octave, so an untilted spectrum renders as a\n   * ramp that collapses into the noise floor after the first few bars. Tilting\n   * it back up spreads the bands across the full height.\n   */\n  tiltDbPerOctave?: number;\n  /** Band level mapped to 0. */\n  floorDb?: number;\n  /** Band level mapped to 1. */\n  ceilingDb?: number;\n  /** Blends each band toward its neighbours to remove single-bin comb artifacts. */\n  neighbourBlend?: number;\n};\n\nexport const SPECTRUM_DEFAULTS = {\n  bandCount: 48,\n  minBin: 1,\n  tiltDbPerOctave: 6,\n  // Window measured against tilted band levels: a full-scale mix lands around\n  // -6 dB on transients and drops to roughly -20 dB between hits.\n  floorDb: -20,\n  ceilingDb: -5,\n  neighbourBlend: 0.25,\n} as const satisfies Required<SpectrumBandOptions>;\n\n/** Geometric band edges — equal width per octave rather than per hertz. */\nfunction logBandEdges(binCount: number, bandCount: number, minBin: number) {\n  const first = Math.max(1, minBin);\n  const ratio = (binCount / first) ** (1 / bandCount);\n\n  return Array.from(\n    { length: bandCount + 1 },\n    (_, index) => first * ratio ** index,\n  );\n}\n\n/**\n * Turns raw `visualizeAudio()` magnitudes into 0-1 display bands.\n *\n * Linear downsampling puts every octave above ~1 kHz into the last couple of\n * bars and leaves the rest of the display static, so bins are grouped\n * logarithmically, tilted to undo the natural spectral rolloff, and mapped\n * through a decibel window.\n */\nexport function toSpectrumBands(\n  frequencies: number[],\n  options: SpectrumBandOptions = {},\n): number[] {\n  const bandCount = options.bandCount ?? SPECTRUM_DEFAULTS.bandCount;\n  const minBin = options.minBin ?? SPECTRUM_DEFAULTS.minBin;\n  const tiltDbPerOctave =\n    options.tiltDbPerOctave ?? SPECTRUM_DEFAULTS.tiltDbPerOctave;\n  const floorDb = options.floorDb ?? SPECTRUM_DEFAULTS.floorDb;\n  const ceilingDb = options.ceilingDb ?? SPECTRUM_DEFAULTS.ceilingDb;\n  const neighbourBlend =\n    options.neighbourBlend ?? SPECTRUM_DEFAULTS.neighbourBlend;\n\n  if (frequencies.length === 0 || bandCount <= 0) {\n    return [];\n  }\n\n  const edges = logBandEdges(frequencies.length, bandCount, minBin);\n  const referenceBin = edges[0];\n  const span = Math.max(1, ceilingDb - floorDb);\n\n  const raw = Array.from({ length: bandCount }, (_, index) => {\n    const start = Math.floor(edges[index]);\n    const end = Math.max(start + 1, Math.floor(edges[index + 1]));\n\n    let sum = 0;\n    let count = 0;\n    for (let bin = start; bin < end && bin < frequencies.length; bin += 1) {\n      const value = frequencies[bin];\n      if (Number.isFinite(value) && value > 0) {\n        sum += value;\n        count += 1;\n      }\n    }\n\n    if (count === 0) {\n      return 0;\n    }\n\n    const magnitude = sum / count;\n    const centerBin = Math.sqrt(edges[index] * edges[index + 1]);\n    const octaves = Math.log2(centerBin / referenceBin);\n    const db = 20 * Math.log10(magnitude) + octaves * tiltDbPerOctave;\n\n    return clamp01((db - floorDb) / span);\n  });\n\n  if (neighbourBlend <= 0) {\n    return raw;\n  }\n\n  return raw.map((value, index) => {\n    const previous = raw[index - 1] ?? value;\n    const next = raw[index + 1] ?? value;\n    return lerp(value, (previous + next) / 2, neighbourBlend);\n  });\n}\n\nexport type EnvelopeOptions = {\n  /** 0-1 per frame. Higher snaps to transients faster. */\n  attack?: number;\n  /** 0-1 per frame. Lower leaves a longer tail after a hit. */\n  release?: number;\n};\n\nexport const ENVELOPE_DEFAULTS = {\n  attack: 0.62,\n  release: 0.26,\n} as const satisfies Required<EnvelopeOptions>;\n\n/**\n * Runs an attack/release follower over a window of past frames.\n *\n * Remotion renders every frame from scratch, so smoothing cannot be carried in\n * state — the history is replayed instead, which keeps the result identical\n * whether the frame is reached by playback, by seeking, or by a distributed\n * render.\n */\nexport function followEnvelope(\n  history: number[][],\n  options: EnvelopeOptions = {},\n): number[] {\n  // Spreading `options` directly would let an explicitly-undefined field\n  // overwrite the default with undefined, which propagates as NaN.\n  const attack = options.attack ?? ENVELOPE_DEFAULTS.attack;\n  const release = options.release ?? ENVELOPE_DEFAULTS.release;\n\n  if (history.length === 0) {\n    return [];\n  }\n\n  let envelope = [...history[0]];\n\n  for (let index = 1; index < history.length; index += 1) {\n    const current = history[index];\n    envelope = envelope.map((previous, band) => {\n      const target = current[band] ?? 0;\n      return lerp(previous, target, target > previous ? attack : release);\n    });\n  }\n\n  return envelope;\n}\n\n/**\n * Peak level per band with a linear falloff — the cap that hangs above a bar\n * after a transient and slides back down.\n */\nexport function followPeaks(\n  history: number[][],\n  fallPerFrame = 0.022,\n): number[] {\n  if (history.length === 0) {\n    return [];\n  }\n\n  let peaks = [...history[0]];\n\n  for (let index = 1; index < history.length; index += 1) {\n    const current = history[index];\n    peaks = peaks.map((previous, band) =>\n      Math.max(current[band] ?? 0, previous - fallPerFrame),\n    );\n  }\n\n  return peaks;\n}\n\n/** Mean level across a slice of the band range. `to` is exclusive. */\nexport function bandEnergy(bands: number[], from = 0, to = bands.length) {\n  const slice = bands.slice(from, to);\n  if (slice.length === 0) {\n    return 0;\n  }\n  return slice.reduce((sum, value) => sum + value, 0) / slice.length;\n}\n\n/**\n * Deterministic stand-in used while audio data loads, and in environments where\n * the source cannot be fetched. Two incommensurate rates keep it from reading\n * as a single looping sine.\n */\nexport function idleBands(bandCount: number, seconds: number): number[] {\n  return Array.from({ length: bandCount }, (_, index) => {\n    const position = index / Math.max(1, bandCount - 1);\n    const rolloff = 0.86 - position * 0.42;\n    const sway =\n      Math.sin(seconds * Math.PI * 2 * 0.6 - position * 3.1) * 0.15 +\n      Math.sin(seconds * Math.PI * 2 * 1.37 - position * 5.7) * 0.07;\n    return clamp01(rolloff * 0.44 + sway);\n  });\n}\n\nexport type UseSpectrumBarsOptions = {\n  src: string;\n  numberOfSamples?: number;\n  windowInSeconds?: number;\n  /**\n   * Optional frame override.\n   * Pass a parent `frame` when using inside `<Sequence from={...}>` to avoid discontinuities.\n   */\n  frame?: number;\n};\n\nexport function useSpectrumBars({\n  src,\n  numberOfSamples = 64,\n  windowInSeconds = DEFAULT_AUDIO_WINDOW_SECONDS,\n  frame: frameOverride,\n}: UseSpectrumBarsOptions) {\n  const currentFrame = useCurrentFrame();\n  const frame = frameOverride ?? currentFrame;\n  const { fps } = useVideoConfig();\n\n  const { audioData, dataOffsetInSeconds } = useWindowedAudioData({\n    src,\n    frame,\n    fps,\n    windowInSeconds,\n  });\n\n  if (!audioData) {\n    return { frequencies: null as number[] | null, audioData: null };\n  }\n\n  const frequencies = visualizeAudio({\n    fps,\n    frame,\n    audioData,\n    numberOfSamples,\n    optimizeFor: \"speed\",\n    dataOffsetInSeconds,\n  });\n\n  return { frequencies, audioData };\n}\n\nexport type UseAudioBandsOptions = UseSpectrumBarsOptions &\n  SpectrumBandOptions &\n  EnvelopeOptions & {\n    /** How many past frames feed the follower. */\n    historyFrames?: number;\n    peakFallPerFrame?: number;\n    /**\n     * Scales the display so the loudest recent band reaches near full height.\n     * Without it a quietly mastered track renders as a permanently short\n     * display and a hot one sits clipped at the top.\n     */\n    autoGain?: boolean;\n    /** Lookback used to find the reference level for `autoGain`. */\n    gainWindowInSeconds?: number;\n    /** Height the reference level is scaled to. */\n    gainTarget?: number;\n    /** Reference levels below this are treated as silence and left alone. */\n    gainReferenceFloor?: number;\n    /** Upper bound on the auto-gain multiplier. */\n    maxGain?: number;\n    /**\n     * Gamma applied after auto-gain. A tilted spectrum of dense material is\n     * close to flat, which reads as a solid slab; values above 1 push the\n     * quieter bands down so the shape stays legible.\n     */\n    contrast?: number;\n  };\n\nexport type AudioBands = {\n  /** Smoothed 0-1 level per band, low frequency first. */\n  bands: number[];\n  /** Decaying peak level per band, for cap markers. */\n  peaks: number[];\n  /** True once real audio data is driving the bands. */\n  isReady: boolean;\n  /** Resolved frame, so callers stay in sync inside a `<Sequence>`. */\n  frame: number;\n  fps: number;\n};\n\n/**\n * One call for the whole chain: windowed audio data, FFT, logarithmic bands,\n * attack/release smoothing, and peak tracking — with an animated fallback so a\n * component never renders a dead flat line while audio loads.\n */\nexport function useAudioBands({\n  src,\n  numberOfSamples = 128,\n  windowInSeconds = DEFAULT_AUDIO_WINDOW_SECONDS,\n  frame: frameOverride,\n  historyFrames = 6,\n  peakFallPerFrame = 0.022,\n  attack,\n  release,\n  autoGain = true,\n  gainWindowInSeconds = 0.5,\n  gainTarget = 1,\n  gainReferenceFloor = 0.08,\n  maxGain = 6,\n  contrast = 1.7,\n  ...bandOptions\n}: UseAudioBandsOptions): AudioBands {\n  const currentFrame = useCurrentFrame();\n  const frame = frameOverride ?? currentFrame;\n  const { fps } = useVideoConfig();\n  const bandCount = bandOptions.bandCount ?? SPECTRUM_DEFAULTS.bandCount;\n\n  const { audioData, dataOffsetInSeconds } = useWindowedAudioData({\n    src,\n    frame,\n    fps,\n    windowInSeconds,\n  });\n\n  if (!audioData) {\n    const idle = idleBands(bandCount, frame / fps);\n    return { bands: idle, peaks: idle, isReady: false, frame, fps };\n  }\n\n  const bandsAtFrame = (targetFrame: number) =>\n    toSpectrumBands(\n      visualizeAudio({\n        fps,\n        frame: Math.max(0, targetFrame),\n        audioData,\n        numberOfSamples,\n        optimizeFor: \"speed\",\n        dataOffsetInSeconds,\n      }),\n      bandOptions,\n    );\n\n  const history = Array.from({ length: historyFrames }, (_, index) =>\n    bandsAtFrame(frame - (historyFrames - 1 - index)),\n  );\n\n  const smoothed = followEnvelope(history, { attack, release });\n  const peaks = followPeaks(history, peakFallPerFrame);\n\n  /**\n   * The loudest band this frame sets the scale, so the display always fills\n   * regardless of how the source was mastered. Blending in a slower reference\n   * keeps quiet passages visibly quieter instead of pumping every one of them\n   * back up to full height.\n   */\n  const gain = (() => {\n    if (!autoGain) return 1;\n\n    const peakOf = (values: number[]) =>\n      values.reduce((max, value) => Math.max(max, value), 0);\n\n    const immediate = peakOf(smoothed);\n    const lookback = Math.max(1, Math.round(gainWindowInSeconds * fps));\n    const slow = Math.max(\n      immediate,\n      peakOf(bandsAtFrame(frame - lookback)),\n    );\n    const reference = lerp(immediate, slow, 0.45);\n\n    if (reference <= gainReferenceFloor) return 1;\n    return Math.min(maxGain, Math.max(1, gainTarget / reference));\n  })();\n\n  const shape = (values: number[]) =>\n    values.map((value) => clamp01(value * gain) ** contrast);\n\n  return {\n    bands: shape(smoothed),\n    peaks: shape(peaks),\n    isReady: true,\n    frame,\n    fps,\n  };\n}\n\n/** Logarithmic scaling for more balanced bar heights. */\nexport function scaleFrequenciesForDisplay(\n  frequencies: number[],\n  minDb = -60,\n  maxDb = -12,\n) {\n  const span = Math.max(1, maxDb - minDb);\n\n  return frequencies.map((value) => {\n    if (!Number.isFinite(value) || value <= 0) return 0;\n    const db = 20 * Math.log10(value);\n    if (!Number.isFinite(db)) return 0;\n    return clamp01((db - minDb) / span);\n  });\n}\n\n/** Downsample FFT bins into fewer display bars (peak per bucket). */\nexport function downsampleSpectrum(values: number[], barCount: number): number[] {\n  if (barCount <= 0) return [];\n  if (values.length <= barCount) return values.map((v) => (Number.isFinite(v) ? v : 0));\n\n  const bucketSize = values.length / barCount;\n  return Array.from({ length: barCount }, (_, index) => {\n    const start = Math.floor(index * bucketSize);\n    const end = Math.floor((index + 1) * bucketSize);\n    let peak = 0;\n    for (let i = start; i < end; i++) {\n      const value = values[i] ?? 0;\n      if (Number.isFinite(value)) peak = Math.max(peak, value);\n    }\n    return peak;\n  });\n}\n"
    }
  ]
}