{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "audio-sphere",
  "type": "registry:block",
  "title": "Audio Sphere",
  "description": "A ceramic sphere driven by an analyser: a demo track synthesised live in WebAudio swells it on the bass, punches it on every kick and ripples it on the highs, or wraps the spectrum around it as spikes. Connect your own audio too.",
  "docs": "Audio Sphere was added to public/vividsites/audio-sphere.html. Paste its markup, <style> and <script> where you want it on the page (or open the file to see it alone). Customise it with the CSS variables at the top. Docs and live demo: https://vividsites.app/c/audio-sphere",
  "categories": [
    "3d",
    "3d-objects"
  ],
  "files": [
    {
      "path": "components/vividsites/audio-sphere.html",
      "type": "registry:file",
      "target": "public/vividsites/audio-sphere.html",
      "content": "<!--\n  vividsites · audio-sphere\n  A ceramic sphere that plays along. Press play and a demo track (synthesised live in\n  WebAudio, no files: kick, clap, hats, bass, pad and an arp at 112 bpm) drives it\n  through an analyser: bass swells the whole form and punches it on every kick, mids\n  and highs ripple the surface, and in \"spikes\" mode the spectrum is wrapped around it\n  in rings. It breathes gently while paused. Drag to turn it.\n  Plug in your own audio with el.vsAudioSphere.connect(<audio> or any AudioNode).\n\n  Customise (attributes, read at mount; all live through the JS API too):\n    data-mode      blob | spikes                             (default blob)\n  CSS variables:\n    --vs-audio-sphere-bg          the stage; the sphere's shadows take its colour  (#1b37c4)\n    --vs-audio-sphere-accent      rim light and the kick flash                     (#ff7a59)\n    --vs-audio-sphere-reactivity  0 – 2                                            (1)\n    --vs-audio-sphere-height      component height                                 (100svh)\n  JS:\n    el.vsAudioSphere.play()   .pause()   .toggle()\n    el.vsAudioSphere.set({ mode: 'spikes', bg: '#161616', accent: '#c8f169', reactivity: 1.4 })\n    el.vsAudioSphere.connect(document.querySelector('audio'))      el.vsAudioSphere.destroy()\n  Needs: three@0.170.0 (importmap below). Remove the importmap if your page already maps \"three\".\n-->\n<link rel=\"stylesheet\" href=\"https://fonts.googleapis.com/css2?family=DM+Mono:wght@400&display=swap\">\n<div class=\"vs-audio-sphere\" data-mode=\"blob\">\n  <canvas class=\"vs-audio-sphere-canvas\" aria-hidden=\"true\"></canvas>\n  <button class=\"vs-audio-sphere-play\" type=\"button\" aria-pressed=\"false\" aria-label=\"Play demo track\">\n    <span class=\"vs-audio-sphere-icon\" aria-hidden=\"true\"><i></i><i></i></span>\n    <span class=\"vs-audio-sphere-label\">Play demo</span>\n    <span class=\"vs-audio-sphere-meter\" aria-hidden=\"true\"><b></b><b></b><b></b><b></b></span>\n  </button>\n  <div class=\"vs-audio-sphere-fallback\" aria-hidden=\"true\"></div>\n</div>\n\n<style>\n  .vs-audio-sphere {\n    --vs-audio-sphere-bg: #1b37c4;\n    --vs-audio-sphere-accent: #ff7a59;\n    --vs-audio-sphere-reactivity: 1;\n    --vs-audio-sphere-height: 100svh;\n    position: relative; width: 100%; height: var(--vs-audio-sphere-height); min-height: 420px; overflow: hidden; isolation: isolate;\n    background: var(--vs-audio-sphere-bg); cursor: grab; touch-action: pan-y; -webkit-tap-highlight-color: transparent;\n    transition: background-color .8s cubic-bezier(.16,1,.3,1);\n  }\n  .vs-audio-sphere.is-dragging { cursor: grabbing; }\n  .vs-audio-sphere-canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; opacity: 0; transform: scale(.92);\n    transition: opacity 1.2s cubic-bezier(.16,1,.3,1), transform 1.6s cubic-bezier(.34,1.4,.64,1); }\n  .vs-audio-sphere[data-vs-state=\"ready\"] .vs-audio-sphere-canvas { opacity: 1; transform: none; }\n  /* the transport: one pill, the only chrome */\n  .vs-audio-sphere-play { position: absolute; z-index: 2; left: 50%; bottom: max(28px, 5.5%); transform: translateX(-50%); display: inline-flex; align-items: center; gap: 10px;\n    height: 44px; padding: 0 16px 0 7px; border: 0; border-radius: 22px; cursor: pointer; -webkit-tap-highlight-color: transparent;\n    font: 400 12px/1 \"DM Mono\", ui-monospace, Menlo, monospace; letter-spacing: .06em; text-transform: uppercase; color: #fff;\n    background: rgba(255,255,255,.12); -webkit-backdrop-filter: blur(14px) saturate(1.4); backdrop-filter: blur(14px) saturate(1.4);\n    box-shadow: inset 0 1px 0 rgba(255,255,255,.22), inset 0 0 0 .5px rgba(255,255,255,.28), 0 10px 30px -10px rgba(0,0,0,.45);\n    transition: transform .5s cubic-bezier(.34,1.56,.64,1), background-color .2s cubic-bezier(.2,0,0,1); }\n  .vs-audio-sphere-play:hover { background: rgba(255,255,255,.18); transform: translateX(-50%) translateY(-2px); }\n  .vs-audio-sphere-play:active { transform: translateX(-50%) scale(.95); transition-duration: .12s; }\n  .vs-audio-sphere-play:focus-visible { outline: 2px solid #fff; outline-offset: 3px; }\n  .vs-audio-sphere-icon { position: relative; width: 30px; height: 30px; border-radius: 50%; background: #fff; display: grid; place-items: center; box-shadow: 0 2px 6px rgba(0,0,0,.25); }\n  .vs-audio-sphere-icon i { position: absolute; width: 0; height: 0; border-style: solid; transition: all .3s cubic-bezier(.2,0,0,1); }\n  .vs-audio-sphere-icon i:first-child { border-width: 6px 0 6px 10px; border-color: transparent transparent transparent var(--vs-audio-sphere-bg); transform: translateX(1.5px); }\n  .vs-audio-sphere-icon i:last-child { opacity: 0; }\n  .vs-audio-sphere.is-playing .vs-audio-sphere-icon i { border-width: 0; width: 3.5px; height: 11px; border-radius: 1px; background: var(--vs-audio-sphere-bg); opacity: 1; }\n  .vs-audio-sphere.is-playing .vs-audio-sphere-icon i:first-child { transform: translateX(-3px); }\n  .vs-audio-sphere.is-playing .vs-audio-sphere-icon i:last-child { transform: translateX(3px); }\n  .vs-audio-sphere-label { min-width: 7.2em; text-align: left; font-variant-numeric: tabular-nums; }\n  .vs-audio-sphere-meter { display: flex; align-items: flex-end; gap: 2px; height: 14px; }\n  .vs-audio-sphere-meter b { width: 2px; height: 100%; border-radius: 1px; background: currentColor; opacity: .85; transform-origin: bottom; transform: scaleY(.15); }\n  .vs-audio-sphere-fallback { position: absolute; inset: 0; display: none; place-items: center; }\n  .vs-audio-sphere-fallback::before { content: \"\"; width: min(56vmin, 480px); aspect-ratio: 1; border-radius: 50%;\n    background: radial-gradient(circle at 36% 30%, #fff, #e9ecf5 30%, color-mix(in srgb, var(--vs-audio-sphere-bg) 60%, #000) 100%); box-shadow: 0 40px 80px -30px rgba(0,0,0,.5); }\n  .vs-audio-sphere[data-vs-state=\"nogl\"] .vs-audio-sphere-fallback { display: grid; }\n  @media (prefers-reduced-motion: reduce) { .vs-audio-sphere-canvas { transition: opacity .3s; transform: none; } }\n</style>\n\n<script type=\"importmap\">{ \"imports\": { \"three\": \"https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.js\" } }</script>\n<script type=\"module\">\nimport * as THREE from 'three';\n\nconst NOISE = /* glsl */`\n  vec3 mod289(vec3 x){ return x - floor(x * (1.0 / 289.0)) * 289.0; } vec4 mod289(vec4 x){ return x - floor(x * (1.0 / 289.0)) * 289.0; }\n  vec4 permute(vec4 x){ return mod289(((x * 34.0) + 1.0) * x); } vec4 taylorInvSqrt(vec4 r){ return 1.79284291400159 - 0.85373472095314 * r; }\n  float snoise(vec3 v){\n    const vec2 C = vec2(1.0 / 6.0, 1.0 / 3.0); const vec4 D = vec4(0.0, 0.5, 1.0, 2.0);\n    vec3 i = floor(v + dot(v, C.yyy)); vec3 x0 = v - i + dot(i, C.xxx); vec3 g = step(x0.yzx, x0.xyz); vec3 l = 1.0 - g;\n    vec3 i1 = min(g.xyz, l.zxy); vec3 i2 = max(g.xyz, l.zxy); vec3 x1 = x0 - i1 + C.xxx; vec3 x2 = x0 - i2 + C.yyy; vec3 x3 = x0 - D.yyy; i = mod289(i);\n    vec4 p = permute(permute(permute(i.z + vec4(0.0, i1.z, i2.z, 1.0)) + i.y + vec4(0.0, i1.y, i2.y, 1.0)) + i.x + vec4(0.0, i1.x, i2.x, 1.0));\n    float n_ = 0.142857142857; vec3 ns = n_ * D.wyz - D.xzx; vec4 j = p - 49.0 * floor(p * ns.z * ns.z); vec4 x_ = floor(j * ns.z); vec4 y_ = floor(j - 7.0 * x_);\n    vec4 x = x_ * ns.x + ns.yyyy; vec4 y = y_ * ns.x + ns.yyyy; vec4 h = 1.0 - abs(x) - abs(y); vec4 b0 = vec4(x.xy, y.xy); vec4 b1 = vec4(x.zw, y.zw);\n    vec4 s0 = floor(b0) * 2.0 + 1.0; vec4 s1 = floor(b1) * 2.0 + 1.0; vec4 sh = -step(h, vec4(0.0));\n    vec4 a0 = b0.xzyw + s0.xzyw * sh.xxyy; vec4 a1 = b1.xzyw + s1.xzyw * sh.zzww;\n    vec3 p0 = vec3(a0.xy, h.x); vec3 p1 = vec3(a0.zw, h.y); vec3 p2 = vec3(a1.xy, h.z); vec3 p3 = vec3(a1.zw, h.w);\n    vec4 norm = taylorInvSqrt(vec4(dot(p0, p0), dot(p1, p1), dot(p2, p2), dot(p3, p3))); p0 *= norm.x; p1 *= norm.y; p2 *= norm.z; p3 *= norm.w;\n    vec4 m = max(0.6 - vec4(dot(x0, x0), dot(x1, x1), dot(x2, x2), dot(x3, x3)), 0.0); m = m * m;\n    return 42.0 * dot(m * m, vec4(dot(p0, x0), dot(p1, x1), dot(p2, x2), dot(p3, x3)));\n  }`;\nconst VERT = /* glsl */`\n  uniform float uTime, uBass, uMid, uHigh, uKick, uMode; uniform sampler2D uFFT;\n  varying vec3 vN, vW; varying float vH;\n  ${NOISE}\n  float h(vec3 p){\n    float blob = snoise(p * 0.9 + vec3(0.0, uTime * 0.3, 0.0)) * (0.04 + uBass * 0.15)\n               + snoise(p * 2.6 + uTime * 0.8) * uMid * 0.05\n               + snoise(p * 8.0 + uTime * 2.0) * uHigh * 0.02;\n    // spikes: every cell of a grid on the sphere listens to its own slice of the spectrum\n    vec3 q = p * 5.5; vec3 cell = floor(q); vec3 f = fract(q) - 0.5;\n    float bin = pow(fract(sin(dot(cell, vec3(12.9898, 78.233, 37.719))) * 43758.5453), 1.8) * 0.7 + 0.02;\n    float s = texture2D(uFFT, vec2(bin, 0.5)).r;\n    float ring = smoothstep(0.5, 0.0, length(f)) * s * s * 0.42;\n    return mix(blob, ring + blob * 0.25, uMode) + uKick * 0.05;\n  }\n  void main(){\n    vec3 p = normalize(position);\n    vec3 t = normalize(abs(p.y) < 0.99 ? cross(p, vec3(0.0, 1.0, 0.0)) : cross(p, vec3(1.0, 0.0, 0.0))); vec3 b = cross(p, t); float e = 0.012;\n    float h0 = h(p);\n    vec3 d0 = p * (1.0 + h0), d1 = normalize(p + t * e) * (1.0 + h(normalize(p + t * e))), d2 = normalize(p + b * e) * (1.0 + h(normalize(p + b * e)));\n    vec3 n = normalize(cross(d1 - d0, d2 - d0)); if (dot(n, p) < 0.0) n = -n;\n    vH = h0;\n    vec4 w = modelMatrix * vec4(d0, 1.0); vW = w.xyz; vN = normalize(mat3(modelMatrix) * n);\n    gl_Position = projectionMatrix * viewMatrix * w;\n  }`;\nconst FRAG = /* glsl */`\n  uniform vec3 uBg, uAccent; uniform float uKick, uBass, uLevel;\n  varying vec3 vN, vW; varying float vH;\n  float pw(float x, float e){ return pow(max(x, 0.0), e); }\n  void main(){\n    vec3 n = normalize(vN), v = normalize(cameraPosition - vW);\n    vec3 l = normalize(vec3(-0.55, 0.75, 0.55));\n    float diff = dot(n, l) * 0.5 + 0.5;                 // wrapped: ceramic, never pitch black\n    float ndv = clamp(dot(n, v), 0.0, 1.0);\n    vec3 shadow = uBg * 0.62;                           // the stage colour fills the shadow side\n    vec3 c = mix(shadow, vec3(0.97, 0.965, 0.955), pw(diff, 2.2));\n    c *= 0.82 + 0.18 * smoothstep(-0.06, 0.08, vH);     // valleys hold a little shadow\n    c += vec3(1.0) * pw(max(dot(reflect(-l, n), v), 0.0), 48.0) * 0.55;       // glaze highlight\n    c += vec3(1.0) * pw(max(dot(reflect(-normalize(vec3(0.7, 0.2, 0.6)), n), v), 0.0), 90.0) * 0.25;\n    float rim = pw(1.0 - ndv, 2.6);\n    c = mix(c, uAccent * 1.15, rim * (0.35 + uLevel * 0.9 + uKick * 0.6));\n    c += uAccent * smoothstep(0.08, 0.3, vH) * (0.25 + uKick * 0.8) * uLevel;  // peaks glow on the beat\n    gl_FragColor = vec4(c, 1.0);\n    #include <colorspace_fragment>\n  }`;\n\nclass Spring {\n  constructor(x, k = 120, c = 18) { this.x = x; this.v = 0; this.t = x; this.k = k; this.c = c; }\n  step(dt) { const n = Math.ceil(dt / (1 / 240)); const h = dt / n; for (let i = 0; i < n; i++) { this.v += (-this.k * (this.x - this.t) - this.c * this.v) * h; this.x += this.v * h; } return this.x; }\n}\nconst css = (el, n, f) => getComputedStyle(el).getPropertyValue(n).trim() || f;\n\n// ---- the demo track: a tiny step sequencer, scheduled ahead on the audio clock\nfunction makeTrack(ctx, out) {\n  const BPM = 112, STEP = 60 / BPM / 4; // 16ths\n  const noiseBuf = (() => { const b = ctx.createBuffer(1, ctx.sampleRate, ctx.sampleRate), d = b.getChannelData(0); for (let i = 0; i < d.length; i++) d[i] = Math.random() * 2 - 1; return b; })();\n  const bus = ctx.createGain(); bus.gain.value = 0.9; bus.connect(out);\n  const verb = (() => { const c = ctx.createConvolver(), len = ctx.sampleRate * 2.2, b = ctx.createBuffer(2, len, ctx.sampleRate); for (let ch = 0; ch < 2; ch++) { const d = b.getChannelData(ch); for (let i = 0; i < len; i++) d[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / len, 3); } c.buffer = b; const g = ctx.createGain(); g.gain.value = 0.22; c.connect(g); g.connect(bus); return c; })();\n  const mtof = (m) => 440 * Math.pow(2, (m - 69) / 12);\n  const kick = (t) => { const o = ctx.createOscillator(), g = ctx.createGain(); o.frequency.setValueAtTime(155, t); o.frequency.exponentialRampToValueAtTime(42, t + 0.14); g.gain.setValueAtTime(1.0, t); g.gain.exponentialRampToValueAtTime(0.001, t + 0.42); o.connect(g); g.connect(bus); o.start(t); o.stop(t + 0.45); };\n  const noise = (t, dur, type, f, q, vol, send) => { const s = ctx.createBufferSource(), fl = ctx.createBiquadFilter(), g = ctx.createGain(); s.buffer = noiseBuf; fl.type = type; fl.frequency.value = f; fl.Q.value = q; g.gain.setValueAtTime(vol, t); g.gain.exponentialRampToValueAtTime(0.001, t + dur); s.connect(fl); fl.connect(g); g.connect(bus); if (send) g.connect(verb); s.start(t, Math.random() * 0.5); s.stop(t + dur + 0.02); };\n  const clap = (t) => { noise(t, 0.2, 'bandpass', 1600, 0.9, 0.55, true); noise(t + 0.012, 0.16, 'bandpass', 2100, 1.2, 0.3, false); };\n  const hat = (t, v) => noise(t, v > 0.6 ? 0.09 : 0.035, 'highpass', 8200, 0.7, 0.16 * v, false);\n  const bass = (t, m, dur) => { const o = ctx.createOscillator(), f = ctx.createBiquadFilter(), g = ctx.createGain(); o.type = 'sawtooth'; o.frequency.value = mtof(m); f.type = 'lowpass'; f.frequency.setValueAtTime(900, t); f.frequency.exponentialRampToValueAtTime(160, t + dur); f.Q.value = 6; g.gain.setValueAtTime(0.0001, t); g.gain.linearRampToValueAtTime(0.32, t + 0.01); g.gain.exponentialRampToValueAtTime(0.001, t + dur); o.connect(f); f.connect(g); g.connect(bus); o.start(t); o.stop(t + dur + 0.02); };\n  const pad = (t, notes, dur) => notes.forEach((m) => [-7, 7].forEach((det) => { const o = ctx.createOscillator(), f = ctx.createBiquadFilter(), g = ctx.createGain(); o.type = 'sawtooth'; o.frequency.value = mtof(m); o.detune.value = det; f.type = 'lowpass'; f.frequency.setValueAtTime(500, t); f.frequency.linearRampToValueAtTime(1400, t + dur * 0.6); g.gain.setValueAtTime(0.0001, t); g.gain.linearRampToValueAtTime(0.028, t + 0.6); g.gain.linearRampToValueAtTime(0.0001, t + dur); o.connect(f); f.connect(g); g.connect(bus); g.connect(verb); o.start(t); o.stop(t + dur + 0.05); }));\n  const pluck = (t, m) => { const o = ctx.createOscillator(), f = ctx.createBiquadFilter(), g = ctx.createGain(); o.type = 'square'; o.frequency.value = mtof(m); f.type = 'lowpass'; f.frequency.setValueAtTime(3200, t); f.frequency.exponentialRampToValueAtTime(400, t + 0.2); g.gain.setValueAtTime(0.07, t); g.gain.exponentialRampToValueAtTime(0.001, t + 0.26); o.connect(f); f.connect(g); g.connect(bus); g.connect(verb); o.start(t); o.stop(t + 0.3); };\n  // A minor: Am – F – C – G, a bar each\n  const CH = [[57, 60, 64], [53, 57, 60], [48, 52, 55], [55, 59, 62]], RT = [33, 29, 36, 31];\n  const HAT = [0.4, 0.2, 0.9, 0.25, 0.4, 0.2, 0.9, 0.3, 0.4, 0.2, 0.9, 0.25, 0.4, 0.3, 0.9, 0.5];\n  const BASS = [1, 0, 0, 1, 0, 0, 1, 0, 1, 0, 0, 1, 0, 1, 0, 0];\n  const ARP = [0, 2, 1, 2, 0, 2, 1, 3];\n  let step = 0, next = 0, timer = 0;\n  const sched = () => {\n    while (next < ctx.currentTime + 0.12) {\n      const s = step % 16, bar = Math.floor(step / 16) % 4, sec = Math.floor(step / 64) % 4; // 4-bar phrases; the drums drop out on the 4th phrase's first half\n      const drums = !(sec === 3 && bar < 2);\n      if (drums && s % 4 === 0) kick(next);\n      if (drums && (s === 4 || s === 12)) clap(next);\n      if (HAT[s] && sec > 0) hat(next, HAT[s]);\n      if (BASS[s] && drums) bass(next, RT[bar] + (s === 14 ? 12 : 0), STEP * 2.2);\n      if (s === 0) pad(next, CH[bar], STEP * 16);\n      if (sec >= 2 && s % 2 === 0) { const n = CH[bar][ARP[(s / 2) % 8] % 3] + 12 + (ARP[(s / 2) % 8] === 3 ? 12 : 0); pluck(next, n); }\n      next += STEP; step++;\n    }\n  };\n  return {\n    start() { next = ctx.currentTime + 0.06; step = 0; sched(); timer = setInterval(sched, 25); },\n    stop() { clearInterval(timer); timer = 0; },\n  };\n}\n\nfunction mount(el) {\n  const canvas = el.querySelector('.vs-audio-sphere-canvas'), btn = el.querySelector('.vs-audio-sphere-play'), label = el.querySelector('.vs-audio-sphere-label'), bars = [...el.querySelectorAll('.vs-audio-sphere-meter b')];\n  const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;\n  let renderer;\n  try { renderer = new THREE.WebGLRenderer({ canvas, antialias: true, powerPreference: 'high-performance' }); } catch (e) { el.dataset.vsState = 'nogl'; }\n  if (renderer && !renderer.getContext()) { el.dataset.vsState = 'nogl'; renderer = null; }\n  const bg = new THREE.Color(css(el, '--vs-audio-sphere-bg', '#1b37c4')), bgTo = bg.clone();\n  const accent = new THREE.Color(css(el, '--vs-audio-sphere-accent', '#ff7a59')), accentTo = accent.clone();\n  const fftData = new Uint8Array(256);\n  const fftTex = new THREE.DataTexture(fftData, 256, 1, THREE.RedFormat, THREE.UnsignedByteType); fftTex.magFilter = fftTex.minFilter = THREE.LinearFilter; fftTex.needsUpdate = true;\n  const U = { uTime: { value: 0 }, uBass: { value: 0 }, uMid: { value: 0 }, uHigh: { value: 0 }, uKick: { value: 0 }, uLevel: { value: 0 }, uMode: { value: el.dataset.mode === 'spikes' ? 1 : 0 },\n    uFFT: { value: fftTex }, uBg: { value: bg }, uAccent: { value: accent } };\n  let scene, camera, mesh;\n  if (renderer) {\n    renderer.setClearColor(bg, 1);\n    scene = new THREE.Scene(); camera = new THREE.PerspectiveCamera(30, 1, 0.1, 50);\n    mesh = new THREE.Mesh(new THREE.IcosahedronGeometry(1, 90), new THREE.ShaderMaterial({ uniforms: U, vertexShader: VERT, fragmentShader: FRAG }));\n    scene.add(mesh);\n  }\n\n  // ---- audio\n  const A = { ctx: null, an: null, track: null, playing: false, src: null, freq: new Uint8Array(512), t0: 0, silent: 0 };\n  function ensureAudio() {\n    if (A.ctx) return;\n    const Ctx = window.AudioContext || window.webkitAudioContext; if (!Ctx) return;\n    A.ctx = new Ctx();\n    A.an = A.ctx.createAnalyser(); A.an.fftSize = 1024; A.an.smoothingTimeConstant = 0.72;\n    A.master = A.ctx.createGain(); A.master.gain.value = 0.7;\n    const comp = A.ctx.createDynamicsCompressor(); comp.threshold.value = -14; comp.ratio.value = 4;\n    A.master.connect(comp); comp.connect(A.an); A.an.connect(A.ctx.destination);\n    A.track = makeTrack(A.ctx, A.master);\n  }\n  function play() {\n    ensureAudio(); if (!A.ctx) return;\n    A.ctx.resume(); if (!A.src) A.track.start(); A.playing = true; A.t0 = A.ctx.currentTime;\n    A.master.gain.cancelScheduledValues(A.ctx.currentTime); A.master.gain.setTargetAtTime(0.7, A.ctx.currentTime, 0.02);\n    el.classList.add('is-playing'); btn.setAttribute('aria-pressed', 'true'); btn.setAttribute('aria-label', 'Pause demo track'); pop.v += 2;\n  }\n  function pause() {\n    if (!A.ctx) return; A.playing = false; if (!A.src) A.track.stop();\n    A.master.gain.setTargetAtTime(0, A.ctx.currentTime, 0.05);\n    el.classList.remove('is-playing'); btn.setAttribute('aria-pressed', 'false'); btn.setAttribute('aria-label', 'Play demo track'); label.textContent = 'Play demo';\n  }\n  const toggle = () => (A.playing ? pause() : play());\n  function connect(node) {\n    ensureAudio(); if (!A.ctx) return; A.track.stop();\n    const src = node instanceof HTMLMediaElement ? A.ctx.createMediaElementSource(node) : node;\n    src.connect(A.an); A.src = src; A.playing = true; el.classList.add('is-playing');\n  }\n  btn.addEventListener('click', (e) => { e.stopPropagation(); toggle(); });\n  btn.addEventListener('pointerdown', (e) => e.stopPropagation());\n\n  // ---- drag to turn\n  const S = { vis: true, raf: 0, last: 0, t: 0, drag: null, react: parseFloat(css(el, '--vs-audio-sphere-reactivity', '1')), fake: 0 };\n  const rot = { y: { x: 0, v: 0.12 }, x: new Spring(0.2, 60, 12) };\n  const band = { bass: new Spring(0, 260, 22), mid: new Spring(0, 200, 20), high: new Spring(0, 200, 20), level: new Spring(0, 60, 14) };\n  const kick = { v: 0, prev: 0, cool: 0 }, pop = new Spring(1, 320, 16), mode = new Spring(U.uMode.value, 60, 14);\n  const onDown = (e) => { S.drag = { x: e.clientX, y: e.clientY, t: performance.now() }; };\n  const onMove = (e) => {\n    if (!S.drag) return; const dx = e.clientX - S.drag.x, dy = e.clientY - S.drag.y, now = performance.now(), dt = Math.max(1, now - S.drag.t) / 1000;\n    if (Math.abs(dx) + Math.abs(dy) > 3 && !el.classList.contains('is-dragging')) { el.classList.add('is-dragging'); try { el.setPointerCapture(e.pointerId); } catch (_) {} }\n    rot.y.x += dx * 0.008; rot.y.v = rot.y.v * 0.4 + dx * 0.008 / dt * 0.6; rot.x.t = THREE.MathUtils.clamp(rot.x.t + dy * 0.006, -0.9, 0.9); rot.x.x = rot.x.t;\n    S.drag.x = e.clientX; S.drag.y = e.clientY; S.drag.t = now;\n  };\n  const onUp = () => { S.drag = null; el.classList.remove('is-dragging'); };\n  el.addEventListener('pointerdown', onDown); el.addEventListener('pointermove', onMove); el.addEventListener('pointerup', onUp); el.addEventListener('pointercancel', onUp);\n\n  function resize() {\n    if (!renderer) return; const w = el.clientWidth, h = el.clientHeight; if (!w || !h) return;\n    renderer.setPixelRatio(Math.min(devicePixelRatio, 2)); renderer.setSize(w, h, false); camera.aspect = w / h;\n    const fill = w < h ? 0.7 : 0.54, vh = Math.max(2.5 / fill, 2.5 / fill / camera.aspect);\n    camera.position.set(0, 0.05, vh / 2 / Math.tan(THREE.MathUtils.degToRad(camera.fov / 2))); camera.lookAt(0, 0.05, 0); camera.updateProjectionMatrix();\n  }\n  const avg = (a, lo, hi) => { let s = 0; for (let i = lo; i < hi; i++) s += a[i]; return s / Math.max(1, hi - lo) / 255; };\n\n  function frame(now) {\n    S.raf = 0; if (!S.vis) return;\n    const dt = Math.min(0.05, S.last ? (now - S.last) / 1000 : 0.016); S.last = now; S.t += dt;\n    let b = 0, m = 0, h = 0;\n    if (A.playing && A.an) {\n      A.an.getByteFrequencyData(A.freq);\n      const bin = A.ctx.sampleRate / A.an.fftSize; // Hz per bin\n      b = avg(A.freq, Math.floor(30 / bin), Math.ceil(140 / bin)); m = avg(A.freq, Math.floor(300 / bin), Math.ceil(2000 / bin)); h = avg(A.freq, Math.floor(3000 / bin), Math.ceil(10000 / bin));\n      for (let i = 0; i < 256; i++) { const j = Math.floor(Math.pow(i / 256, 1.8) * 300); fftData[i] = A.freq[j]; }\n      if (!A.src) { const tt = Math.max(0, A.ctx.currentTime - A.t0); label.textContent = `${String(Math.floor(tt / 60)).padStart(1, '0')}:${String(Math.floor(tt % 60)).padStart(2, '0')} · 112 bpm`; }\n    } else {\n      // paused: a slow breath so the form is never dead\n      const br = reduced ? 0.15 : 0.18 + 0.1 * Math.sin(S.t * 1.3);\n      b = br * 0.6; m = 0.08 + 0.05 * Math.sin(S.t * 0.7); h = 0.02;\n      for (let i = 0; i < 256; i++) fftData[i] = Math.max(0, fftData[i] * 0.9);\n    }\n    fftTex.needsUpdate = true;\n    const R = S.react * (reduced ? 0.4 : 1);\n    band.bass.t = b * R; band.mid.t = m * R; band.high.t = h * R; band.level.t = A.playing ? Math.min(1, (b + m) * R) : 0;\n    // onset: bass jumping above its recent level is a kick\n    kick.cool -= dt;\n    if (b - kick.prev > 0.08 && A.playing && kick.cool <= 0) { kick.v = 1; kick.cool = 0.2; pop.v -= 0.7 * R; }\n    kick.prev = kick.prev * 0.85 + b * 0.15; kick.v *= Math.exp(-dt * 7);\n    U.uBass.value = band.bass.step(dt); U.uMid.value = band.mid.step(dt); U.uHigh.value = band.high.step(dt); U.uLevel.value = band.level.step(dt); U.uKick.value = kick.v * R;\n    U.uMode.value = mode.step(dt); U.uTime.value = S.t * (reduced ? 0.2 : 1);\n    bars.forEach((el2, i) => { const v = A.playing ? [U.uBass.value, U.uMid.value * 1.3, U.uHigh.value * 2.2, kick.v][i] : 0.15; el2.style.transform = `scaleY(${Math.max(0.15, Math.min(1, v)).toFixed(3)})`; });\n    bg.lerp(bgTo, 1 - Math.exp(-dt * 5)); accent.lerp(accentTo, 1 - Math.exp(-dt * 5));\n    if (renderer) {\n      renderer.setClearColor(bg, 1);\n      if (!S.drag) { rot.y.v += ((reduced ? 0 : 0.12) - rot.y.v) * (1 - Math.exp(-dt * 1.2)); rot.y.x += rot.y.v * dt; }\n      mesh.rotation.set(rot.x.step(dt), rot.y.x, 0); mesh.scale.setScalar(THREE.MathUtils.clamp(pop.step(dt), 0.9, 1.12));\n      renderer.render(scene, camera);\n    }\n    loop();\n  }\n  const loop = () => { if (!S.raf && S.vis) S.raf = requestAnimationFrame(frame); };\n  const ro = new ResizeObserver(resize); ro.observe(el); resize();\n  const io = new IntersectionObserver(([e]) => { S.vis = e.isIntersecting; if (S.vis) { S.last = 0; loop(); } else if (A.playing && !A.src) pause(); }, { rootMargin: '60px' }); io.observe(el);\n  const onVis = () => { S.vis = !document.hidden; if (S.vis) { S.last = 0; loop(); } };\n  document.addEventListener('visibilitychange', onVis);\n  canvas.addEventListener('webglcontextlost', (e) => { e.preventDefault(); cancelAnimationFrame(S.raf); S.raf = 0; S.vis = false; });\n  canvas.addEventListener('webglcontextrestored', () => { S.vis = true; S.last = 0; resize(); loop(); });\n  if (renderer) { renderer.compile(scene, camera); requestAnimationFrame(() => { el.dataset.vsState = 'ready'; loop(); }); } else loop();\n\n  function set(o = {}) {\n    if (o.mode) { el.dataset.mode = o.mode; mode.t = o.mode === 'spikes' ? 1 : 0; pop.v += 1.5; }\n    if (o.bg) { el.style.setProperty('--vs-audio-sphere-bg', o.bg); bgTo.set(o.bg); }\n    if (o.accent) { el.style.setProperty('--vs-audio-sphere-accent', o.accent); accentTo.set(o.accent); }\n    if (o.reactivity != null) { S.react = +o.reactivity; el.style.setProperty('--vs-audio-sphere-reactivity', o.reactivity); }\n  }\n  function destroy() {\n    cancelAnimationFrame(S.raf); S.vis = false; ro.disconnect(); io.disconnect(); document.removeEventListener('visibilitychange', onVis);\n    if (A.track) A.track.stop(); if (A.ctx) A.ctx.close();\n    el.removeEventListener('pointerdown', onDown); el.removeEventListener('pointermove', onMove); el.removeEventListener('pointerup', onUp); el.removeEventListener('pointercancel', onUp);\n    if (renderer) { mesh.geometry.dispose(); mesh.material.dispose(); fftTex.dispose(); renderer.dispose(); } delete el.vsAudioSphere;\n  }\n  return { play, pause, toggle, connect, set, destroy };\n}\n\ndocument.querySelectorAll('.vs-audio-sphere').forEach((el) => { if (!el.vsAudioSphere) el.vsAudioSphere = mount(el); });\n</script>\n"
    }
  ],
  "meta": {
    "category": "3d",
    "group": "3D objects",
    "tags": [
      "audio",
      "visualizer",
      "music",
      "webaudio",
      "analyser",
      "sphere",
      "three.js"
    ],
    "tech": [
      "three.js",
      "GLSL",
      "JS",
      "CSS"
    ],
    "deps": [
      "https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.js"
    ],
    "source": null,
    "free": true,
    "accent": "#ff7a59",
    "added": "2026-10-01",
    "demo": "https://vividsites.app/api/component/audio-sphere/index.html",
    "thumbnail": "https://vividsites.app/api/component/audio-sphere/thumb.jpg",
    "video": "https://vividsites.app/api/component/audio-sphere/thumb.mp4",
    "page": "https://vividsites.app/c/audio-sphere"
  }
}