{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "voice-orb",
  "type": "registry:block",
  "title": "Voice Orb",
  "description": "An audio-reactive voice orb drawn in one WebGL shader: a domain-warped glass sphere lit by a cold and a warm light, whose rim, size and flow follow the microphone or your model's voice on springs. Comes with a mic button, live level ring and a speech-like demo signal when the mic is blocked.",
  "docs": "Voice Orb was added to public/vividsites/voice-orb.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/voice-orb",
  "categories": [
    "ui",
    "ai-interfaces"
  ],
  "files": [
    {
      "path": "components/vividsites/voice-orb.html",
      "type": "registry:file",
      "target": "public/vividsites/voice-orb.html",
      "content": "<!--\nvs-voice-orb: an audio-reactive voice orb for voice assistants. One WebGL fragment shader draws a\n  glass sphere with domain-warped flow between two lights, a fresnel rim and a soft glow. Its size,\n  rim wobble and flow speed follow the amplitude and low/high bands of a live signal, smoothed on\n  springs. States: idle (slow breathing), listening (the mic), speaking (your model's audio).\n  A round mic button sits underneath with a live level ring; a status line reports the state.\n\n  Markup:  the block below. Only .vs-voice-orb, .vs-voice-orb-orb and the canvas are required;\n           the mic button and status line are optional.\n  Attributes on .vs-voice-orb:\n    data-demo      when the mic is blocked/missing, or \"speaking\" has no audio connected, drive the\n                   orb with a built-in speech-like signal (syllables, pauses, consonant bursts).\n                   Remove it in production if you always connect real audio.\n    data-hotkey    Space (outside inputs/buttons) toggles listening.\n    data-manual    don't auto-mount; call vsVoiceOrb.mount(el, opts) yourself.\n  CSS variables: --vs-voice-orb-size, --vs-voice-orb-cold, --vs-voice-orb-warm (the two lights; the\n    script re-reads them, so changing them cross-fades the palette), --vs-voice-orb-reactivity (0–2),\n    --vs-voice-orb-ink, --vs-voice-orb-error, --vs-voice-orb-font. --vs-voice-orb-lvl (0–1) is written\n    every frame, so you can hang your own CSS off the level.\n  JS API:\n    const orb = vsVoiceOrb.mount(el, { colors: ['#3aa6ff', '#ffb070'], reactivity: 1 })\n    orb.setState('idle' | 'listening' | 'speaking')\n    orb.connect(mediaStream)   // a mic stream: analysed while listening (never played back)\n    orb.connect(audioNode)     // your TTS output, e.g. ctx.createMediaElementSource(audio): analysed while speaking\n    orb.setLevel(0..1, { low, high })   // push levels yourself (server-side VAD, websockets); holds ~600 ms\n    orb.listen()  orb.toggle()  orb.state  orb.level  orb.destroy()\n  Events (bubble from .vs-voice-orb): vs-voice-orb:state {state}, vs-voice-orb:mic {stream},\n    vs-voice-orb:error {message, error}, vs-voice-orb:interrupt (mic pressed while speaking).\n  Clicking the orb does the same as the mic button. DPR is capped at 2, no MSAA; rendering pauses\n  off-screen and in hidden tabs; the WebGL context can be lost and restored; without WebGL a CSS\n  gradient disc stands in. Reduced motion: slow flow, small wobble, no redraws while idle.\n-->\n<link rel=\"preconnect\" href=\"https://fonts.googleapis.com\"><link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n<link href=\"https://fonts.googleapis.com/css2?family=Space+Mono&display=swap\" rel=\"stylesheet\">\n<div class=\"vs-voice-orb\" data-demo data-hotkey role=\"group\" aria-label=\"Voice assistant\">\n  <div class=\"vs-voice-orb-orb\" aria-hidden=\"true\">\n    <canvas class=\"vs-voice-orb-canvas\"></canvas>\n    <div class=\"vs-voice-orb-fallback\"></div>\n  </div>\n  <div class=\"vs-voice-orb-bar\">\n    <button class=\"vs-voice-orb-mic\" type=\"button\" aria-pressed=\"false\" aria-label=\"Start listening\">\n      <svg class=\"vs-voice-orb-g-mic\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.6\" stroke-linecap=\"round\" aria-hidden=\"true\"><rect x=\"8.6\" y=\"2.8\" width=\"6.8\" height=\"11.6\" rx=\"3.4\"/><path d=\"M5.2 11.2a6.8 6.8 0 0 0 13.6 0M12 18v3.2\"/></svg>\n      <svg class=\"vs-voice-orb-g-stop\" viewBox=\"0 0 24 24\" aria-hidden=\"true\"><rect x=\"7\" y=\"7\" width=\"10\" height=\"10\" rx=\"2.4\" fill=\"currentColor\"/></svg>\n      <svg class=\"vs-voice-orb-g-off\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.6\" stroke-linecap=\"round\" aria-hidden=\"true\"><path d=\"M15.4 9.6V6.2a3.4 3.4 0 0 0-6.4-1.6M8.6 8.6v2.4a3.4 3.4 0 0 0 5.6 2.6M5.2 11.2a6.8 6.8 0 0 0 11 5.3M18.8 11.2c0 .9-.2 1.8-.5 2.6M12 18v3.2M4 4l16 16\"/></svg>\n      <svg class=\"vs-voice-orb-g-spin\" viewBox=\"0 0 24 24\" fill=\"none\" aria-hidden=\"true\"><circle cx=\"12\" cy=\"12\" r=\"9\" stroke=\"currentColor\" stroke-opacity=\".2\" stroke-width=\"1.6\"/><path d=\"M12 3a9 9 0 0 1 9 9\" stroke=\"currentColor\" stroke-width=\"1.6\" stroke-linecap=\"round\"/></svg>\n    </button>\n    <p class=\"vs-voice-orb-status\" role=\"status\" aria-live=\"polite\">Ready</p>\n  </div>\n</div>\n\n<style>\n.vs-voice-orb {\n  --vs-voice-orb-size: min(480px, 60vh, 74vw);  /* orb diameter */\n  --vs-voice-orb-cold: #3aa6ff;                  /* cold light: listening, top-left */\n  --vs-voice-orb-warm: #ffb070;                  /* warm light: speaking, bottom-right */\n  --vs-voice-orb-reactivity: 1;                  /* 0 = calm, 2 = very reactive */\n  --vs-voice-orb-ink: #eef3ff;                   /* button glyph + status text */\n  --vs-voice-orb-error: #ff6a5a;\n  --vs-voice-orb-font: \"Space Mono\", ui-monospace, \"SF Mono\", Menlo, monospace;\n\n  --vs-voice-orb-lvl: 0;                         /* 0..1, written by the script every frame */\n  position: relative; display: flex; flex-direction: column; align-items: center;\n  width: 100%; overflow-x: clip; padding: 0 0 4px;\n  color: var(--vs-voice-orb-ink); -webkit-tap-highlight-color: transparent;\n}\n.vs-voice-orb, .vs-voice-orb * { box-sizing: border-box; }\n\n/* the orb: the canvas overhangs the box by 22% so the glow has room */\n.vs-voice-orb-orb {\n  position: relative; width: var(--vs-voice-orb-size); aspect-ratio: 1; flex: none;\n  border-radius: 50%; cursor: pointer; touch-action: manipulation;\n}\n.vs-voice-orb-canvas {\n  position: absolute; inset: -22%; width: 144%; height: 144%; display: block; pointer-events: none;\n}\n/* static fallback (no WebGL): two soft lights in a disc, breathing with the level */\n.vs-voice-orb-fallback {\n  position: absolute; inset: 9%; border-radius: 50%; display: none;\n  background:\n    radial-gradient(60% 60% at 30% 28%, color-mix(in srgb, var(--vs-voice-orb-cold) 90%, #fff) 0%, transparent 62%),\n    radial-gradient(70% 70% at 72% 76%, var(--vs-voice-orb-warm) 0%, transparent 64%),\n    radial-gradient(closest-side, #0b1426 55%, #05070c);\n  box-shadow: 0 0 80px -10px color-mix(in srgb, var(--vs-voice-orb-cold) 55%, transparent),\n              inset 0 0 0 1px rgba(255,255,255,.12), inset 0 -20px 60px rgba(0,0,0,.35);\n  transform: scale(calc(1 + var(--vs-voice-orb-lvl) * .08));\n}\n.vs-voice-orb.is-fallback .vs-voice-orb-fallback { display: block; }\n.vs-voice-orb.is-fallback .vs-voice-orb-canvas { display: none; }\n\n/* controls under the orb */\n.vs-voice-orb-bar { display: flex; flex-direction: column; align-items: center; gap: 12px; margin-top: calc(var(--vs-voice-orb-size) * .06); }\n\n.vs-voice-orb-mic {\n  --c: var(--vs-voice-orb-cold);\n  all: unset; position: relative; display: grid; place-items: center; width: 60px; height: 60px; border-radius: 50%;\n  cursor: pointer; color: var(--vs-voice-orb-ink);\n  background: linear-gradient(180deg, rgba(34,40,54,.78), rgba(14,18,28,.78));\n  box-shadow: inset 0 0 0 .5px rgba(255,255,255,.16), inset 0 1px 0 rgba(255,255,255,.12),\n              0 1px 2px rgba(0,0,0,.5), 0 10px 28px -8px rgba(0,0,0,.7);\n  -webkit-backdrop-filter: blur(12px); backdrop-filter: blur(12px);\n  transition: transform .45s cubic-bezier(.3,1.5,.5,1), background .25s, box-shadow .25s, color .25s;\n}\n.vs-voice-orb-mic:hover { transform: scale(1.05); background: linear-gradient(180deg, rgba(46,53,70,.82), rgba(20,25,37,.82)); }\n.vs-voice-orb-mic:active { transform: scale(.93); transition-duration: .12s; }\n.vs-voice-orb-mic:focus-visible { box-shadow: inset 0 0 0 .5px rgba(255,255,255,.16), 0 0 0 2px #05070c, 0 0 0 4px var(--c); }\n.vs-voice-orb-mic:disabled { opacity: .4; cursor: not-allowed; transform: none; }\n.vs-voice-orb-mic svg { grid-area: 1 / 1; width: 22px; height: 22px; overflow: visible;\n  transition: opacity .3s cubic-bezier(.16,1,.3,1), transform .5s cubic-bezier(.16,1,.3,1); }\n.vs-voice-orb-mic .vs-voice-orb-g-stop, .vs-voice-orb-mic .vs-voice-orb-g-off, .vs-voice-orb-mic .vs-voice-orb-g-spin { opacity: 0; transform: scale(.5) rotate(-30deg); }\n/* the level ring: two hairline rings that breathe with the input */\n.vs-voice-orb-mic::before, .vs-voice-orb-mic::after {\n  content: \"\"; position: absolute; inset: 0; border-radius: 50%; pointer-events: none; opacity: 0;\n  box-shadow: 0 0 0 1.5px var(--c); transition: opacity .3s;\n  transform: scale(calc(1.06 + var(--vs-voice-orb-lvl) * .34));\n}\n.vs-voice-orb-mic::after { box-shadow: 0 0 0 1px var(--c); transform: scale(calc(1.14 + var(--vs-voice-orb-lvl) * .62)); }\n\n.vs-voice-orb[data-state=\"listening\"] .vs-voice-orb-mic {\n  background: linear-gradient(180deg, color-mix(in srgb, var(--c) 36%, #0e1320), color-mix(in srgb, var(--c) 18%, #0a0e18));\n  box-shadow: inset 0 0 0 .5px color-mix(in srgb, var(--c) 70%, transparent), inset 0 1px 0 rgba(255,255,255,.16),\n              0 0 32px -6px color-mix(in srgb, var(--c) 60%, transparent), 0 10px 28px -8px rgba(0,0,0,.7);\n}\n.vs-voice-orb[data-state=\"listening\"] .vs-voice-orb-mic::before { opacity: calc(.25 + var(--vs-voice-orb-lvl) * .6); }\n.vs-voice-orb[data-state=\"listening\"] .vs-voice-orb-mic::after { opacity: calc(var(--vs-voice-orb-lvl) * .35); }\n.vs-voice-orb[data-state=\"speaking\"] .vs-voice-orb-mic { --c: var(--vs-voice-orb-warm); }\n.vs-voice-orb[data-state=\"speaking\"] .vs-voice-orb-g-mic { opacity: 0; transform: scale(.5) rotate(30deg); }\n.vs-voice-orb[data-state=\"speaking\"] .vs-voice-orb-g-stop { opacity: 1; transform: none; }\n.vs-voice-orb.is-error:not([data-state=\"listening\"]) .vs-voice-orb-mic { --c: var(--vs-voice-orb-error); color: var(--vs-voice-orb-error);\n  box-shadow: inset 0 0 0 .5px color-mix(in srgb, var(--vs-voice-orb-error) 60%, transparent), 0 10px 28px -8px rgba(0,0,0,.7); }\n.vs-voice-orb.is-error:not([data-state=\"listening\"]) .vs-voice-orb-g-mic { opacity: 0; }\n.vs-voice-orb.is-error:not([data-state=\"listening\"]) .vs-voice-orb-g-off { opacity: 1; transform: none; }\n.vs-voice-orb[aria-busy=\"true\"] .vs-voice-orb-mic { cursor: progress; }\n.vs-voice-orb[aria-busy=\"true\"] .vs-voice-orb-g-mic { opacity: .35; transform: scale(.8); }\n.vs-voice-orb[aria-busy=\"true\"] .vs-voice-orb-g-spin { opacity: 1; transform: none; }\n.vs-voice-orb-g-spin { animation: vs-voice-orb-spin .8s linear infinite; }\n.vs-voice-orb-g-spin circle { transform-origin: 12px 12px; }\n@keyframes vs-voice-orb-spin { to { rotate: 360deg; } }\n\n.vs-voice-orb-status {\n  display: flex; align-items: center; gap: 8px; margin: 0; padding: 7px 12px 7px 10px; border-radius: 99px;\n  font: 400 10.5px/1 var(--vs-voice-orb-font); letter-spacing: .16em; text-transform: uppercase;\n  color: color-mix(in srgb, var(--vs-voice-orb-ink) 62%, transparent); white-space: nowrap;\n  background: rgba(10,13,20,.5); box-shadow: inset 0 0 0 .5px rgba(255,255,255,.08);\n  -webkit-backdrop-filter: blur(10px); backdrop-filter: blur(10px);\n}\n.vs-voice-orb-status::before {\n  content: \"\"; width: 5px; height: 5px; border-radius: 50%; flex: none;\n  background: color-mix(in srgb, var(--vs-voice-orb-ink) 40%, transparent); transition: background .3s, box-shadow .3s;\n}\n.vs-voice-orb[data-state=\"listening\"] .vs-voice-orb-status::before { background: var(--vs-voice-orb-cold); box-shadow: 0 0 8px var(--vs-voice-orb-cold); }\n.vs-voice-orb[data-state=\"speaking\"] .vs-voice-orb-status::before { background: var(--vs-voice-orb-warm); box-shadow: 0 0 8px var(--vs-voice-orb-warm); }\n.vs-voice-orb.is-error .vs-voice-orb-status::before { background: var(--vs-voice-orb-error); box-shadow: none; }\n\n@media (prefers-reduced-motion: reduce) {\n  .vs-voice-orb-mic, .vs-voice-orb-mic svg { transition: none; }\n  .vs-voice-orb-g-spin { animation-duration: 2.4s; }\n}\n@media (forced-colors: active) { .vs-voice-orb-mic { border: 1px solid ButtonText; } }\n</style>\n\n<script>\n(() => {\n  if (window.vsVoiceOrb) return;\n  const reduce = matchMedia('(prefers-reduced-motion: reduce)');\n\n  const VS = 'attribute vec2 p;void main(){gl_Position=vec4(p,0.,1.);}';\n  const FS = `precision highp float;\nuniform vec2 uRes; uniform float uT, uWT, uAmp, uLow, uHigh, uWob, uState, uPress;\nuniform vec3 uA, uB; uniform vec2 uLight;\nfloat h3(vec3 p){ p = fract(p * .3183099 + .1); p *= 17.; return fract(p.x * p.y * p.z * (p.x + p.y + p.z)); }\nfloat n3(vec3 x){ vec3 i = floor(x), f = fract(x); f = f * f * (3. - 2. * f);\n  return mix(mix(mix(h3(i), h3(i + vec3(1,0,0)), f.x), mix(h3(i + vec3(0,1,0)), h3(i + vec3(1,1,0)), f.x), f.y),\n             mix(mix(h3(i + vec3(0,0,1)), h3(i + vec3(1,0,1)), f.x), mix(h3(i + vec3(0,1,1)), h3(i + vec3(1,1,1)), f.x), f.y), f.z); }\nfloat fbm(vec3 p){ float a = .5, s = 0.; for (int i = 0; i < 2; i++){ s += a * n3(p); p = p * 2.07 + vec3(1.7, 9.2, 3.1); a *= .45; } return s / .725; }\nvoid main(){\n  float S = .5 * min(uRes.x, uRes.y);\n  vec2 p = (gl_FragCoord.xy - .5 * uRes) / S;\n  float r = length(p); vec2 dir = p / max(r, 1e-4);\n  float px = 1.2 / S;\n  // silhouette: seamless noise around the rim, bigger with level; highs add fine ripple\n  float w1 = n3(vec3(dir * 1.5, uWT * .5)) - .5;\n  float w2 = n3(vec3(dir * 3.2 + 7., uWT * 1.4)) - .5;\n  float R = .63 * (1. + uAmp * .085 + uLow * .035) * (1. - uPress * .04);\n  float edge = R + w1 * (.012 + uWob * .075) + w2 * (.004 + uHigh * .04);\n  float inside = 1. - smoothstep(edge - px, edge + px, r);\n  float side = clamp(.5 + .5 * dot(dir, vec2(.72, -.69)) + (uState - .5) * .55, 0., 1.);\n  vec3 gcol = mix(uA, uB, smoothstep(.15, .85, side));\n  float d = max(r - edge, 0.);\n  float glow = exp(-d * (9. - uAmp * 3.)) * (.2 + uAmp * .5) + exp(-d * 28.) * .18;\n  glow *= smoothstep(1., .72, r);\n  vec3 col = gcol * glow; float alpha = glow;\n  if (r < edge + 2. * px) {\n    vec2 q = p / edge; float rr = min(dot(q, q), 1.);\n    float z = sqrt(1. - rr);\n    vec3 n = vec3(q, z);\n    // flow lives on the sphere: a slow vortex (faster near the core), refracted toward the rim, domain-warped\n    float t = uT;\n    float sw = t * .22 + sin(t * .37) * 1.4 * (1.15 - rr);   // rigid spin + a bounded twist (never winds up)\n    vec2 qs = mat2(cos(sw), -sin(sw), sin(sw), cos(sw)) * q;\n    vec3 sp = vec3(qs * (1.05 - .25 * z), z * .6) * 1.15;\n    vec3 wa = vec3(fbm(sp + vec3(0., 0., t)), fbm(sp + vec3(5.2, 1.3, -t * .8)), fbm(sp + vec3(-3.1, 7.7, t * .6)));\n    float f = fbm(sp * .85 + (wa - .5) * (2.1 + uLow * 1.8) + vec3(t * .3, -t * .2, 0.));\n    // a second, deeper layer at another scale for parallax depth\n    float g = fbm(vec3(qs * 1.9, z) + (wa.zxy - .5) * 1.6 - vec3(0., t * .4, t * .2));\n    // two lights: cold from the top-left, warm from the bottom-right; the state leans the balance.\n    // The split is large-scale and soft, so it never reads as land and sea.\n    float lean = dot(q, vec2(.72, -.69)) * .9 + (uState - .5) * 1.3 + (wa.x - .5) * 1.4;\n    float m = smoothstep(-.75, .75, lean);\n    vec3 body = mix(uA, uB, m), other = mix(uA, uB, 1. - m * .6);\n    float veil = pow(smoothstep(.4, .85, f), 1.4);\n    float rib = pow(max(1. - abs(sin(f * 7. + dot(qs, vec2(1.6, .9)) * 2.2 + wa.y * 2. - t * 1.3)), 0.), 9.);\n    float rib2 = pow(max(1. - abs(sin(g * 9. - dot(qs, vec2(-.8, 1.4)) * 2.6 + t * .9)), 0.), 14.);\n    vec3 c = vec3(.004, .007, .018) + uA * .03;\n    c += uA * smoothstep(.45, .9, g) * .14 * (1. - uState * .6);                       // cold haze in the deep layer\n    vec3 vc = mix(uA * .55, body, veil);                           // dim light leans cold, never brown\n    c += vc * veil * (.85 + uAmp * .5);\n    float ribSoft = pow(max(1. - abs(sin(f * 7. + dot(qs, vec2(1.6, .9)) * 2.2 + wa.y * 2. - t * 1.3)), 0.), 2.5);\n    c += body * ribSoft * (.1 + uAmp * .12);\n    c += mix(body, vec3(1.), .35) * rib * (.7 + uAmp * .6 + uHigh * .6);\n    c += mix(other, vec3(1.), .2) * rib2 * (.18 + uHigh * .7);\n    // pools of light where each colour enters the glass\n    vec2 c1 = vec2(-.4, .42) + .1 * vec2(sin(t * 1.3), cos(t * 1.1));\n    vec2 c2 = vec2(.42, -.42) + .1 * vec2(cos(t * 1.2), sin(t * .9));\n    c += uA * exp(-dot(q - c1, q - c1) * 3.) * (.28 + (1. - uState) * .3);\n    c += uB * exp(-dot(q - c2, q - c2) * 3.) * (.28 + uState * .3);\n    c *= .75 + .4 * pow(max(1. - z, 0.), .8);\n    // rim: fresnel light in the local colour\n    float fres = pow(max(1. - z, 0.), 2.2);\n    c += mix(gcol, vec3(1.), .25) * fres * (1. + uAmp * 1.1);\n    // soft specular, follows the pointer a little\n    vec3 L = normalize(vec3(-.45 + uLight.x * .35, .55 + uLight.y * .35, .75));\n    float s1 = pow(max(dot(reflect(-L, n), vec3(0., 0., 1.)), 0.), 28.);\n    c += vec3(1.) * s1 * .35;\n    c = 1. - exp(-c * 1.25);\n    col = mix(col, c, inside); alpha = mix(alpha, 1., inside);\n  }\n  float dn = (h3(vec3(gl_FragCoord.xy, uT)) - .5) / 255.;\n  alpha = clamp(alpha, 0., 1.);\n  gl_FragColor = vec4(min(col + dn, vec3(alpha)), alpha);\n}`;\n\n  const rgb = s => {\n    s = (s || '').trim();\n    let m = s.match(/^#([0-9a-f]{3})$/i);\n    if (m) return m[1].split('').map(c => parseInt(c + c, 16) / 255);\n    m = s.match(/^#([0-9a-f]{6})/i);\n    if (m) return [0, 2, 4].map(i => parseInt(m[1].slice(i, i + 2), 16) / 255);\n    m = s.match(/rgba?\\(([^)]+)\\)/);\n    if (m) return m[1].split(/[ ,/]+/).slice(0, 3).map(v => parseFloat(v) / 255);\n    return null;\n  };\n  // semi-implicit spring\n  const sp = (x, k, c) => ({ x, v: 0, g: x, k, c });\n  const step = (s, h) => { s.v += (-s.k * (s.x - s.g) - s.c * s.v) * h; s.x += s.v * h; };\n\n  // a speech-like demo signal: syllables with attack/decay, phrase pauses, consonant bursts\n  function synth() {\n    let t0 = 0, syl = null, phrase = 0;\n    const R = (a, b) => a + Math.random() * (b - a);\n    const next = t => {\n      const pause = phrase > 2 && Math.random() < .2;\n      phrase = pause ? 0 : phrase + 1;\n      const gap = pause ? R(.32, .7) : R(.025, .09);\n      const start = t + gap, dur = R(.1, .26);\n      return { start, end: start + dur, dur, peak: (phrase === 1 ? R(.8, 1) : R(.45, .9)), fric: Math.random() < .4 };\n    };\n    return t => {\n      if (!syl) { t0 = t; syl = next(t); }\n      if (t > syl.end + .06) syl = next(t);\n      const u = (t - syl.start) / syl.dur;\n      let env = 0;\n      if (u > 0 && u < 1) env = Math.min(1, u / .14) * Math.pow(1 - u, .7);\n      else if (u >= 1) env = Math.exp(-(t - syl.end) * 40) * 0;\n      const vib = .9 + .1 * Math.sin(t * 34);\n      const level = syl.peak * env * vib;\n      const burst = syl.fric && u > -.04 && u < .18 ? (1 - Math.abs(u - .07) / .11) : 0;\n      return { level, low: level * (.75 + .25 * Math.sin(t * 7.3 + t0)), high: Math.max(0, burst) * .9 + level * .3 };\n    };\n  }\n\n  function makeAnalyser(ctx) {\n    const a = ctx.createAnalyser(); a.fftSize = 1024; a.smoothingTimeConstant = .5;\n    return { a, td: new Float32Array(a.fftSize), fd: new Uint8Array(a.frequencyBinCount), src: null };\n  }\n  function readAnalyser(A) {\n    const { a, td, fd } = A;\n    a.getFloatTimeDomainData(td); a.getByteFrequencyData(fd);\n    let s = 0; for (let i = 0; i < td.length; i++) s += td[i] * td[i];\n    const rms = Math.sqrt(s / td.length);\n    const level = Math.min(1, Math.max(0, (20 * Math.log10(rms + 1e-7) + 56) / 40));\n    const hz = a.context.sampleRate / a.fftSize;\n    const band = (lo, hi) => { let t = 0, n = 0; for (let i = Math.floor(lo / hz); i <= Math.ceil(hi / hz) && i < fd.length; i++) { t += fd[i]; n++; } return n ? t / n / 255 : 0; };\n    return { level, low: Math.min(1, band(80, 500) * 1.3), high: Math.min(1, band(2000, 7000) * 2.2) };\n  }\n\n  function mount(el, opts = {}) {\n    if (el.__vsVo) return el.__vsVo;\n    if (opts.colors) { el.style.setProperty('--vs-voice-orb-cold', opts.colors[0]); el.style.setProperty('--vs-voice-orb-warm', opts.colors[1]); }\n    if (opts.reactivity != null) el.style.setProperty('--vs-voice-orb-reactivity', opts.reactivity);\n    const orb = el.querySelector('.vs-voice-orb-orb');\n    const canvas = el.querySelector('.vs-voice-orb-canvas');\n    const mic = el.querySelector('.vs-voice-orb-mic');\n    const status = el.querySelector('.vs-voice-orb-status');\n    const demo = el.hasAttribute('data-demo');\n    let state = 'idle', errMsg = '', manual = null, manualAt = 0, destroyed = false;\n    let actx = null, micA = null, outA = null, stream = null, fake = null;\n    const S = { amp: sp(.05, 240, 20), low: sp(0, 200, 22), high: sp(0, 320, 26), wob: sp(0, 120, 11),\n      state: sp(.5, 60, 14), press: sp(0, 600, 26), lx: sp(0, 40, 12), ly: sp(0, 40, 12) };\n    const col = { a: [.23, .65, 1], b: [1, .69, .44], ta: null, tb: null };\n    let react = 1, frame = 0, T = 0, WT = 0, last = 0, raf = 0, visible = true;\n\n    const readVars = () => {\n      const cs = getComputedStyle(el);\n      col.ta = rgb(cs.getPropertyValue('--vs-voice-orb-cold')) || col.ta || col.a;\n      col.tb = rgb(cs.getPropertyValue('--vs-voice-orb-warm')) || col.tb || col.b;\n      const rv = parseFloat(cs.getPropertyValue('--vs-voice-orb-reactivity')); react = isNaN(rv) ? 1 : rv;\n    };\n    readVars(); col.a = col.ta.slice(); col.b = col.tb.slice();\n\n    // ---- WebGL\n    let gl = null, prog = null, U = {};\n    const setupGL = () => {\n      const sh = (type, src) => { const s = gl.createShader(type); gl.shaderSource(s, src); gl.compileShader(s); if (!gl.getShaderParameter(s, gl.COMPILE_STATUS) && !gl.isContextLost()) throw new Error(gl.getShaderInfoLog(s)); return s; };\n      prog = gl.createProgram();\n      gl.attachShader(prog, sh(gl.VERTEX_SHADER, VS)); gl.attachShader(prog, sh(gl.FRAGMENT_SHADER, FS));\n      gl.bindAttribLocation(prog, 0, 'p'); gl.linkProgram(prog); gl.useProgram(prog);\n      const b = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, b);\n      gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);\n      gl.enableVertexAttribArray(0); gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);\n      U = {}; for (const n of ['uRes', 'uT', 'uWT', 'uAmp', 'uLow', 'uHigh', 'uWob', 'uState', 'uPress', 'uA', 'uB', 'uLight']) U[n] = gl.getUniformLocation(prog, n);\n      gl.clearColor(0, 0, 0, 0);\n    };\n    try {\n      gl = canvas.getContext('webgl', { alpha: true, premultipliedAlpha: true, antialias: false, depth: false, stencil: false, preserveDrawingBuffer: false });\n      if (gl) setupGL();\n    } catch (e) { gl = null; }\n    if (!gl) el.classList.add('is-fallback');\n    canvas.addEventListener('webglcontextlost', e => { e.preventDefault(); prog = null; });\n    canvas.addEventListener('webglcontextrestored', () => { try { setupGL(); resize(); wake(); } catch (e) { el.classList.add('is-fallback'); } });\n\n    const resize = () => {\n      if (!gl) return;\n      const dpr = Math.min(devicePixelRatio || 1, 2), w = canvas.clientWidth, h = canvas.clientHeight;\n      const W = Math.max(1, Math.round(w * dpr)), H = Math.max(1, Math.round(h * dpr));\n      if (canvas.width !== W || canvas.height !== H) { canvas.width = W; canvas.height = H; }\n    };\n    const ro = new ResizeObserver(() => { resize(); wake(); }); ro.observe(canvas);\n\n    // ---- status + state\n    const LABEL = { idle: 'Ready', listening: 'Listening', speaking: 'Speaking' };\n    const say = () => {\n      let txt;\n      if (state === 'idle') txt = errMsg || (el.hasAttribute('data-hotkey') ? 'Ready \\u00b7 space to talk' : 'Ready');\n      else txt = (errMsg && state === 'listening' ? errMsg : LABEL[state]) + (fake ? ' \\u00b7 demo signal' : '');\n      if (status && status.textContent !== txt) status.textContent = txt;\n    };\n    const emit = (n, d) => el.dispatchEvent(new CustomEvent('vs-voice-orb:' + n, { bubbles: true, detail: d }));\n    function setState(s) {\n      if (!LABEL[s]) return api;\n      const prev = state; state = s; el.dataset.state = s;\n      if (s !== 'listening') stopMic();\n      if (s === 'idle') { fake = null; }\n      if (s === 'speaking') { fake = !outA && demo ? synth() : null; errMsg = ''; el.classList.remove('is-error'); }\n      if (mic) {\n        mic.setAttribute('aria-pressed', String(s === 'listening'));\n        mic.setAttribute('aria-label', s === 'listening' ? 'Stop listening' : s === 'speaking' ? 'Stop speaking' : 'Start listening');\n      }\n      S.state.g = s === 'listening' ? 0 : s === 'speaking' ? 1 : .5;\n      say(); wake();\n      if (prev !== s) emit('state', { state: s });\n      return api;\n    }\n    function stopMic() {\n      if (stream) { stream.getTracks().forEach(t => t.stop()); stream = null; }\n      if (micA && micA.src) { try { micA.src.disconnect(); } catch (_) {} micA.src = null; }\n    }\n    const busy = b => { b ? el.setAttribute('aria-busy', 'true') : el.removeAttribute('aria-busy'); };\n    async function listen() {\n      errMsg = ''; el.classList.remove('is-error');\n      if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) return micFail('No microphone');\n      busy(true); say();\n      try {\n        const s = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true } });\n        if (destroyed) { s.getTracks().forEach(t => t.stop()); return; }\n        connect(s); fake = null; setState('listening'); emit('mic', { stream: s });\n      } catch (e) {\n        micFail(e && e.name === 'NotAllowedError' ? 'Mic blocked' : 'No microphone', e);\n      } finally { busy(false); }\n    }\n    function micFail(msg, error) {\n      errMsg = msg; el.classList.add('is-error'); emit('error', { message: msg, error });\n      if (demo) { fake = synth(); setState('listening'); } else { setState('idle'); }\n      say();\n    }\n    function toggle() {\n      if (el.getAttribute('aria-busy') === 'true') return;\n      if (state === 'idle') listen();\n      else { if (state === 'speaking') emit('interrupt', {}); setState('idle'); }\n    }\n\n    function connect(x) {\n      if (typeof MediaStream !== 'undefined' && x instanceof MediaStream) {\n        const AC = window.AudioContext || window.webkitAudioContext; if (!AC) return api;\n        actx = actx || new AC(); if (actx.state === 'suspended') actx.resume();\n        stopMic(); stream = x;\n        micA = micA || makeAnalyser(actx);\n        micA.src = actx.createMediaStreamSource(x); micA.src.connect(micA.a);\n      } else if (x && x.context && typeof x.connect === 'function') {\n        if (!outA || outA.a.context !== x.context) outA = makeAnalyser(x.context);\n        if (outA.src) try { outA.src.disconnect(outA.a); } catch (_) {}\n        outA.src = x; x.connect(outA.a);\n        if (state === 'speaking') fake = null;\n      }\n      say(); wake(); return api;\n    }\n\n    // ---- input\n    if (mic) mic.addEventListener('click', toggle);\n    if (orb) {\n      orb.addEventListener('pointerdown', e => { if (e.button > 0) return; S.press.g = 1; wake(); });\n      const up = () => { S.press.g = 0; wake(); };\n      orb.addEventListener('pointerup', e => { if (S.press.g) toggle(); up(); });\n      orb.addEventListener('pointerleave', up); orb.addEventListener('pointercancel', up);\n    }\n    const onMove = e => {\n      const b = el.getBoundingClientRect(); if (!b.width) return;\n      const ob = orb.getBoundingClientRect();\n      S.lx.g = Math.max(-1, Math.min(1, (e.clientX - (ob.left + ob.width / 2)) / (ob.width * .8)));\n      S.ly.g = Math.max(-1, Math.min(1, -(e.clientY - (ob.top + ob.height / 2)) / (ob.height * .8)));\n      wake();\n    };\n    el.addEventListener('pointermove', onMove);\n    el.addEventListener('pointerleave', () => { S.lx.g = 0; S.ly.g = 0; });\n    const onKey = e => {\n      if (e.code !== 'Space' || e.repeat || !el.hasAttribute('data-hotkey')) return;\n      const t = e.target; if (t && (t.isContentEditable || /^(INPUT|TEXTAREA|SELECT|BUTTON|A)$/.test(t.tagName))) return;\n      e.preventDefault(); toggle();\n    };\n    document.addEventListener('keydown', onKey);\n\n    // ---- loop (paused off-screen and in hidden tabs)\n    const io = new IntersectionObserver(es => { visible = es[0].isIntersecting; wake(); }); io.observe(el);\n    const onVis = () => wake(); document.addEventListener('visibilitychange', onVis);\n    function wake() { if (!raf && !destroyed && visible && !document.hidden) { last = performance.now(); raf = requestAnimationFrame(tick); } }\n    function tick(now) {\n      raf = 0; if (destroyed || !visible || document.hidden) return;\n      const dt = Math.min(.05, (now - last) / 1000 || .016); last = now; frame++;\n      if (frame % 20 === 1) readVars();\n      const rm = reduce.matches;\n      // input level\n      let L = { level: 0, low: 0, high: 0 };\n      const t = now / 1000;\n      if (manual && now - manualAt < 600) L = manual;\n      else if (state === 'listening' && micA && micA.src) L = readAnalyser(micA);\n      else if (state === 'speaking' && outA && outA.src) L = readAnalyser(outA);\n      else if (state !== 'idle' && fake) L = fake(t);\n      const breathe = state === 'idle' ? .05 + .045 * (.5 + .5 * Math.sin(t * 1.3)) : .08;\n      const k = Math.max(0, react) * (rm ? .35 : 1);\n      S.amp.g = Math.min(1.25, breathe + L.level * k * .95);\n      S.low.g = Math.min(1.2, L.low * k); S.high.g = Math.min(1.2, L.high * k);\n      S.wob.g = Math.min(1.3, S.amp.x * .9 + Math.min(.6, Math.abs(S.amp.v) * .06));\n      const n = Math.max(1, Math.round(dt * 240)), hh = dt / n;\n      for (const key in S) for (let i = 0; i < n; i++) step(S[key], hh);\n      for (const key of ['a', 'b']) for (let i = 0; i < 3; i++) col[key][i] += (col['t' + key][i] - col[key][i]) * Math.min(1, dt * 6);\n      // flow speed rises with energy; integrated, so changes never jump\n      T += dt * (rm ? .03 : (.09 + S.amp.x * .55 + S.high.x * .2));\n      WT += dt * (rm ? .05 : (.35 + S.amp.x * 1.8));\n      el.style.setProperty('--vs-voice-orb-lvl', Math.max(0, Math.min(1, (S.amp.x - .06) * 1.25)).toFixed(3));\n      if (gl && prog && !gl.isContextLost()) {\n        gl.viewport(0, 0, canvas.width, canvas.height); gl.clear(gl.COLOR_BUFFER_BIT);\n        gl.uniform2f(U.uRes, canvas.width, canvas.height);\n        gl.uniform1f(U.uT, T); gl.uniform1f(U.uWT, WT);\n        gl.uniform1f(U.uAmp, S.amp.x); gl.uniform1f(U.uLow, S.low.x); gl.uniform1f(U.uHigh, S.high.x);\n        gl.uniform1f(U.uWob, rm ? S.wob.x * .3 : S.wob.x); gl.uniform1f(U.uState, S.state.x); gl.uniform1f(U.uPress, S.press.x);\n        gl.uniform3fv(U.uA, col.a); gl.uniform3fv(U.uB, col.b); gl.uniform2f(U.uLight, S.lx.x, S.ly.x);\n        gl.drawArrays(gl.TRIANGLES, 0, 3);\n      }\n      // reduced motion + idle + settled: stop drawing until something changes\n      const still = rm && state === 'idle' && Object.values(S).every(s => Math.abs(s.v) < .001 && Math.abs(s.x - s.g) < .01);\n      if (!still) raf = requestAnimationFrame(tick);\n    }\n\n    const api = {\n      setState, connect,\n      setLevel(v, bands) { const l = Math.max(0, Math.min(1, +v || 0)); manual = { level: l, low: bands && bands.low != null ? bands.low : l * .8, high: bands && bands.high != null ? bands.high : l * .4 }; manualAt = performance.now(); wake(); return api; },\n      listen, toggle,\n      get state() { return state; },\n      get level() { return S.amp.x; },\n      destroy() {\n        destroyed = true; cancelAnimationFrame(raf); raf = 0; stopMic(); io.disconnect(); ro.disconnect();\n        document.removeEventListener('keydown', onKey); document.removeEventListener('visibilitychange', onVis);\n        if (mic) mic.removeEventListener('click', toggle);\n        if (actx) actx.close().catch(() => {});\n        const lose = gl && gl.getExtension('WEBGL_lose_context'); if (lose) lose.loseContext();\n        delete el.__vsVo;\n      },\n    };\n    el.__vsVo = api;\n    resize(); setState('idle'); wake();\n    return api;\n  }\n\n  const init = () => document.querySelectorAll('.vs-voice-orb:not([data-manual])').forEach(el => mount(el));\n  document.readyState === 'loading' ? document.addEventListener('DOMContentLoaded', init) : init();\n  window.vsVoiceOrb = { mount };\n})();\n</script>\n"
    }
  ],
  "meta": {
    "category": "ui",
    "group": "AI interfaces",
    "tags": [
      "voice",
      "orb",
      "audio",
      "microphone",
      "webgl",
      "shader",
      "ai",
      "assistant",
      "visualizer"
    ],
    "tech": [
      "CSS",
      "JS",
      "WebGL",
      "GLSL"
    ],
    "deps": [],
    "source": null,
    "free": true,
    "accent": "#3aa6ff",
    "added": "2026-10-01",
    "demo": "https://vividsites.app/api/component/voice-orb/index.html",
    "thumbnail": "https://vividsites.app/api/component/voice-orb/thumb.jpg",
    "video": "https://vividsites.app/api/component/voice-orb/thumb.mp4",
    "page": "https://vividsites.app/c/voice-orb"
  }
}