const svgMicOff = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none"><path d="M16.25 11.5V7C16.25 4.65279 14.3472 2.75 12 2.75C10.6334 2.75 9.41741 3.39504 8.63994 4.39727M3 3L7.75 7.75M7.75 7.75V11.5C7.75 13.8472 9.65279 15.75 12 15.75C13.0463 15.75 14.0043 15.3719 14.7449 14.7449M7.75 7.75L14.7449 14.7449M14.7449 14.7449L17.0474 17.0474M21 21L17.0474 17.0474M12 19C8.92453 19 6.2814 17.1489 5.12407 14.5M12 19V21.25M12 19C13.9439 19 15.715 18.2605 17.0474 17.0474" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>';
const svgSpeakerOn = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none"><path d="M19.2478 4.75194C21.1027 6.60683 22.25 9.16933 22.25 11.9998C22.25 14.8302 21.1027 17.3927 19.2478 19.2476M15.8891 8.11132C16.8844 9.10662 17.5 10.4816 17.5 12.0004C17.5 13.5192 16.8844 14.8942 15.8891 15.8895M3.75 7.74999H5.35491C5.77433 7.74999 6.18314 7.61813 6.52352 7.37306L11.4578 3.82034C11.7886 3.58221 12.25 3.81856 12.25 4.22611V19.7739C12.25 20.1814 11.7886 20.4178 11.4578 20.1796L6.52352 16.6269C6.18314 16.3818 5.77433 16.25 5.35491 16.25H3.75C2.64543 16.25 1.75 15.3546 1.75 14.25V9.74999C1.75 8.64542 2.64543 7.74999 3.75 7.74999Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>';
const svgSpeakerOff = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none"><path d="M21.5 9.99999L19.3787 12.1213M19.3787 12.1213L17.2574 14.2426M19.3787 12.1213L17.2574 9.99999M19.3787 12.1213L21.5 14.2426M3.75 7.74999H5.35491C5.77433 7.74999 6.18314 7.61813 6.52352 7.37306L11.4578 3.82034C11.7886 3.58221 12.25 3.81856 12.25 4.22611V19.7739C12.25 20.1814 11.7886 20.4178 11.4578 20.1796L6.52352 16.6269C6.18314 16.3818 5.77433 16.25 5.35491 16.25H3.75C2.64543 16.25 1.75 15.3546 1.75 14.25V9.74999C1.75 8.64542 2.64543 7.74999 3.75 7.74999Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>';
const HEARTS = ["glass", "silver", "void"];
{ id: "af_heart", label: "Heart" },
{ id: "af_bella", label: "Bella" },
{ id: "am_michael", label: "Michael" },
{ id: "bf_emma", label: "Emma" },
{ id: "bm_george", label: "George" },
{ id: "bm_fable", label: "Fable" }
{ id: "live", label: "Live" },
{ id: "readback", label: "Read back" }
{ id: "room", label: "Room" },
{ id: "hall", label: "Hall" },
{ id: "delay", label: "Delay" },
{ id: "radio", label: "Radio" },
{ id: "robot", label: "Robot" }
const VERT = `#version 300 es
vec2 p = vec2((gl_VertexID << 1) & 2, gl_VertexID & 2);
gl_Position = vec4(p * 2.0 - 1.0, 0.0, 1.0);
vec3 finish(vec3 col, vec2 frag) {
col = 1.0 - exp(-col * 1.1);
col = pow(max(col, 0.0), vec3(1.0 / 2.2));
// interleaved gradient noise: an even, grain-free dither
col += (fract(52.9829189 * fract(dot(frag, vec2(0.06711056, 0.00583715)))) - 0.5) / 255.0;
return uBg + col * (1.0 - uBg);
const SCENE = (direct) => `#version 300 es
${direct ? "#define DIRECT" : ""}
uniform float uShift; // the scene's centre, moved up from the stage's (device px)
uniform float uLive; // 0 asleep → 1 listening (sprung: it surges past 1 waking)
uniform float uDorm; // how dormant, 0..1: asleep × Rest (dim, grey, calm, drawn in)
uniform float uVoice; // loudness, 0..1, normalised to the room
uniform vec3 uBands; // low / mid / high, 0..1
uniform vec3 uMood; // the felt colour (see MOODS)
uniform float uMoodAmt; // how strongly it's felt, 0..1
uniform float uMoodEnergy; // calmer (<0) or livelier (>0)
uniform int uHeart; // 0 glass orb, 1 silver orb, 2 the void
uniform float uGlass; // the orb's face, 0 silver → 1 glass (eased: it turns as it wakes)
uniform vec2 uTilt; // the hand's parallax: the camera raised (degrees) and turned (radians)
const float R_IN = 2.6; // the disk's inner edge (just inside the last stable orbit)
const float R_OUT = 11.0; // where it has thinned to nothing
const float B_CRIT = 2.598; // the shadow's radius as seen from far away (3√3/2)
// ── value noise + fbm (the disk's streaks) ──
p = fract(p * 0.3183099 + 0.1);
return fract(p.x * p.y * p.z * (p.x + p.y + p.z));
f = f * f * (3.0 - 2.0 * f);
return mix(mix(mix(hash3(i), hash3(i + vec3(1, 0, 0)), f.x),
mix(hash3(i + vec3(0, 1, 0)), hash3(i + vec3(1, 1, 0)), f.x), f.y),
mix(mix(hash3(i + vec3(0, 0, 1)), hash3(i + vec3(1, 0, 1)), f.x),
mix(hash3(i + vec3(0, 1, 1)), hash3(i + vec3(1, 1, 1)), f.x), f.y), f.z);
return (vnoise(p) * 0.5 + vnoise(p * 2.03 + vec3(1.7, 9.2, 3.1)) * 0.25) / 0.75;
for (int i = 0; i < 4; i++) {
p = p * 2.03 + vec3(1.7, 9.2, 3.1);
// The disk is SOLID GLASS, not smoke: one wide body and a few outer bands,
// each a smooth slab with crisp edges. Their boundaries wander slowly with
// angle and time (a low-frequency wobble, sheared by the orbit), so the ring
// lives without dissolving into noise.
float band(float r, float a, float b, float e) {
return smoothstep(a, a + e, r) * (1.0 - smoothstep(b - e, b, r));
// Coverage of the disk at radius r, and where across its band the point
// sits (0 at an edge, 1 mid-band) for the glass sheen. Every band edge rides
// its own share of two slow wobble fields (w1, w2 in 0..1), so the bands
// swell, pinch and nearly merge. The outer bands are STREAMS: each covers
// only part of the circle (m, a per-stream mask over angle), so it begins,
// swells, thins into a tail and ends, and the orbit carries it round. That
// is what makes the silhouette pour instead of holding a lens shape.
vec2 slabs(float r, float w1, float w2, float ripple, vec4 m, float E) {
float e0 = 4.35 + v * 1.3 + ripple;
float a1 = 4.55 + v * 0.7 + u * 0.4 + ripple * 0.8, b1 = 5.35 + u * 1.2;
float a2 = 5.5 + u * 0.9, b2 = 6.2 + v * 1.2 + u * 0.3;
float a3 = 6.35 + v * 0.8, b3 = 7.4 - u * 1.1;
float a4 = 7.6 - v * 0.7, b4 = 8.3 + u * 0.8;
// a stream thins toward its tails, not just fades
b1 = a1 + max(b1 - a1, 0.12) * m.x; b2 = a2 + max(b2 - a2, 0.1) * m.y;
b3 = a3 + max(b3 - a3, 0.15) * m.z; b4 = a4 + max(b4 - a4, 0.08) * m.w;
float cov = band(r, R_IN, e0, E) + band(r, a1, b1, E) * 0.95 * m.x + band(r, a2, b2, E) * 0.85 * m.y
+ band(r, a3, b3, E) * 0.7 * m.z + band(r, a4, b4, E) * 0.5 * m.w;
if (r < e0) s = (r - R_IN) / (e0 - R_IN);
else if (r < b1) s = (r - a1) / max(b1 - a1, 1e-3);
else if (r < b2) s = (r - a2) / max(b2 - a2, 1e-3);
else if (r < b3) s = (r - a3) / max(b3 - a3, 1e-3);
else s = (r - a4) / max(b4 - a4, 1e-3);
return vec2(cov, 1.0 - abs(2.0 * clamp(s, 0.0, 1.0) - 1.0));
// ── THE MATERIAL: dispersive glass light ──
// Any object can wear it: hand it a coverage function sampled at a few
// offsets ACROSS an edge (here: radius), and it returns the light. Six
// wavelengths, each seeing the geometry a hair displaced and carrying its own
// spectral colour, sum back to white where coverage is solid, and fan out into
// a continuous spectrum (warm orange and yellow one side, cyan and blue the
// other) wherever an edge or a thread sits. RGB-only splitting can't do that:
// it fringes in three primaries.
// x 0..1 → red … violet, a smooth ramp
1.4 - abs(x - 0.08) * 3.6,
1.25 - abs(x - 0.42) * 3.4,
1.4 - abs(x - 0.86) * 3.6), 0.0, 1.0);
// The threads' own colours: the reference's loop (orange, yellow, cyan, blue,
// magenta, back to orange). Green barely appears in it, so it barely appears
// here; the full spectrum is kept for the edge split above.
vec3 loopColour(float x) {
vec3 c0 = vec3(1.0, 0.42, 0.08), c1 = vec3(1.0, 0.86, 0.22), c2 = vec3(0.2, 0.86, 1.0),
c3 = vec3(0.28, 0.36, 1.0), c4 = vec3(1.0, 0.28, 0.74);
if (x < 1.0) return mix(c0, c1, x);
if (x < 2.0) return mix(c1, c2, x - 1.0);
if (x < 3.0) return mix(c2, c3, x - 2.0);
if (x < 4.0) return mix(c3, c4, x - 3.0);
return mix(c4, c0, x - 4.0);
// Blend two noise samples by weights that sum to 1 WITHOUT losing contrast
// (a plain mix of two unrelated noises flattens toward grey mid-fade).
float vpMix(float a, float b, float wa) {
return 0.5 + ((a - 0.5) * wa + (b - 0.5) * wb) / sqrt(wa * wa + wb * wb);
// Fine threads inside the bands, drawn along the flow: high frequency across
// the radius, low around the orbit, warped by the same wobble so they pour
// with the streams. Crisp (smoothstep), so every thread is an edge to split.
// Sampled in both flow phases (ringA / ringB, weight wa). Where a pixel
// covers more disk than a thread is wide (fp, the footprint), the detail
// fades to its average instead of aliasing.
// COST. This runs per wavelength per disk crossing, the hottest code in the
// experiment (with it, frames alternated 16/33ms; without it, a flat 60), so
// the noise is only computed where it can be seen: the coarse layer (which
// carries the rainbow fringe) per wavelength, the fine layer once per
// crossing (kFine, passed in), and neither where the footprint has faded it.
float threads(float r, vec2 ringA, vec2 ringB, float wa, float w1, float fp, float kFine) {
float coarse = 1.0 - smoothstep(0.05, 0.14, fp);
float zn = r * 9.0 + (w1 - 0.5) * 5.0;
n = vpMix(vnoise(vec3(ringA * 1.4, zn)), vnoise(vec3(ringB * 1.4, zn)), wa);
float f = mix(0.5, n, coarse) * 0.7 + kFine * 0.3;
float soft = 0.12 + fp * 2.0;
return 0.45 + 0.55 * smoothstep(0.5 - soft, 0.5 + soft, f);
// The disk's slow shape fields at orbital angle a: two wobbles, the liquid
// ripple, and where round the circle each stream exists.
struct Shape { float w1; float w2; float ripple; vec4 m; };
Shape shapeAt(float a, float r, float t) {
vec2 ring = vec2(cos(a), sin(a));
// slow, broad wobbles: two octaves carry them (four cost double, unseen)
sh.w1 = fbm2(vec3(ring * 1.1, r * 0.35 + t * 0.16));
sh.w2 = fbm2(vec3(ring * 2.3 + 7.0, t * 0.22));
sh.ripple = 0.22 * sin(a * 3.0 + t * 0.9) + 0.12 * sin(a * 5.0 - t * 1.3);
for (int i = 0; i < 4; i++) {
sh.m[i] = vnoise(vec3(ring * 1.6 + fi * 3.7, t * (0.12 + 0.04 * fi) + fi));
// The light a ray picks up crossing the disk plane at p, travelling along v;
// fp is how much disk (in radius) one pixel covers there.
// THE FLOW. Inner rings orbit faster than outer ones (Keplerian, ω ∝ r^−1.5),
// which is what shears the streams. Left to run, that shear winds the pattern
// tighter forever: after minutes it is finer than a pixel and the disk turns
// to moiré. So the orbit is split: a rigid turn that runs forever, plus the
// shear, which runs in two copies half a period apart. Each copy resets every
// FLOW_P seconds, but only while its weight is zero, so the eye only ever sees
// a flow that keeps pouring (the flow-map crossfade).
const float OMEGA_REF = 0.33; // the rigid turn: the orbit near mid-disk
const float FLOW_P = 10.0;
vec4 disk(vec3 p, vec3 v, float t, float fp) {
// awake, it pours harder; spoken to, harder still
float en = uEnergy * (1.0 + 0.35 * uLive + 0.6 * uVoice) * (1.0 + 0.45 * uMoodEnergy * uMoodAmt) * (1.0 - 0.6 * uDorm);
if (r < R_IN - 0.3 || r > R_OUT) return vec4(0.0);
float phi = atan(p.z, p.x);
float rigid = phi + mod(t * OMEGA_REF, 6.28318);
float shear = 3.2 * pow(r, -1.5) - OMEGA_REF;
float tA = mod(t, FLOW_P);
float tB = mod(t + 0.5 * FLOW_P, FLOW_P);
float wa = 1.0 - abs(2.0 * tA / FLOW_P - 1.0);
wa = wa * wa * (3.0 - 2.0 * wa);
float aA = rigid + shear * tA;
float aB = rigid + shear * tB;
Shape A = shapeAt(aA, r, t);
Shape B = shapeAt(aB, r, t);
// two slow wobble fields shape the bands (scaled about 0.5 by Energy)
float w1 = 0.5 + (vpMix(A.w1, B.w1, wa) - 0.5) * en;
float w2 = 0.5 + (vpMix(A.w2, B.w2, wa) - 0.5) * en;
// the body's edge ripples like a liquid surface
float ripple = mix(B.ripple, A.ripple, wa) * en;
// spoken to, the edge comes alive: the lows deepen the liquid ripple, the
// mids add a finer, quicker one
ripple *= 1.0 + 1.8 * uBands.x;
ripple += 0.14 * uBands.y * sin(rigid * 8.0 - t * 5.0) * smoothstep(R_IN + 0.8, R_IN + 2.0, r);
// where round the circle each stream exists (soft ends = tails)
for (int i = 0; i < 4; i++) m[i] = smoothstep(0.32, 0.62, vpMix(A.m[i], B.m[i], wa));
vec2 ringA = vec2(cos(aA), sin(aA));
vec2 ringB = vec2(cos(aB), sin(aB));
// edges: crisp, but never thinner than the pixel that draws them
float E = max(0.04, fp * 1.3);
// THE VOICE IN THE SHAPE. Loudness swells the whole disk outward (sampling
// a smaller radius), and rings travel out from the centre through the band
// edges, so each syllable visibly pushes through it.
// (dormant, it draws in a little; waking, it breathes back out)
float swell = (1.0 + 0.1 * uVoice) * (1.0 - 0.06 * uDorm);
float rings = uVoice * 0.22 * sin(r * 2.6 - t * 7.0) * smoothstep(R_IN + 0.3, R_IN + 1.5, r);
float rv = r / swell + rings;
// the material: six wavelengths, each sampling the geometry (slabs ×
// threads) at its own radius; dispersion grows outward, the hot lip stays
float sp = 0.26 * uSpread * (0.4 + 0.6 * smoothstep(R_IN, R_IN + 2.5, r)) * (1.0 - 0.6 * uDorm);
// the fine thread layer, once for this crossing (faded where too small)
float fine = 1.0 - smoothstep(0.025, 0.07, fp);
float zk = rv * 17.0 - t * 0.1;
kFine = mix(0.5, vpMix(vnoise(vec3(ringA * 3.1 + 2.0, zk)), vnoise(vec3(ringB * 3.1 + 2.0, zk)), wa), fine);
for (int k = 0; k < TAPS; k++) {
float x = (float(k) + 0.5) / float(TAPS);
float rk = rv + (x - 0.5) * sp;
vec2 sl = slabs(rk, w1, w2, ripple, m, E);
float c = sl.x * threads(rk, ringA, ringB, wa, w1, fp, kFine);
if (k == TAPS / 2) { covMid = sl.x; across = sl.y; }
// sheen across each band: edges catch more light than the middle, the way
float sheen = 0.7 + 0.6 * pow(1.0 - across, 3.0);
// a highlight travelling round the ring, light sliding over a solid
float glint = pow(max(cos(phi - 2.3 + 0.25 * sin(t * 0.3)), 0.0), 10.0);
// white-hot inside; outward, each thread carries its own spectral colour
// (hue steps fast across the radius, drifts slowly along the orbit), the
// reference's orange-beside-yellow-beside-cyan
float heat = exp(-(r - R_IN) * 0.5);
float hue = r * 0.42 + (w1 - 0.5) * 0.8 + 0.15 * sin(rigid + 0.6) - t * 0.03;
vec3 outer = mix(vec3(1.0, 0.55, 0.2), loopColour(hue), 0.82);
// a felt mood tints the outer streams (the white-hot core stays white)
outer = mix(outer, uMood * (0.75 + 0.25 * loopColour(hue)), 0.72 * uMoodAmt);
// white belongs to the hot inner lip only; the streams keep their colour
vec3 tint = mix(outer, vec3(1.0, 0.98, 0.95), smoothstep(0.32, 0.8, heat));
// Doppler beaming: the side orbiting toward the camera burns brighter
vec3 orbit = normalize(vec3(-sin(phi), 0.0, cos(phi)));
float beam = pow(clamp(1.0 + 0.22 * dot(orbit, -normalize(v)), 0.4, 2.0), 3.0);
// (dormant, the white-hot lip cools too: it sits far past white, where
// dimming the whole only reads after the heat itself comes down)
float lum = (1.5 + 2.0 * heat * (1.0 - 0.8 * uDorm)) * sheen * (1.0 + 1.5 * glint) * beam;
vec3 c = tint * cov * lum * uFlare * (1.0 + 0.15 * uLive + 0.25 * uVoice);
// DORMANT: embers. Dimmed and drained toward a warm grey, so waking has
// somewhere to come up from
c = mix(c, vec3(dot(c, vec3(0.3, 0.5, 0.2))) * vec3(1.0, 0.88, 0.76), 0.65 * uDorm) * (1.0 - 0.75 * uDorm);
// solid: the body occludes what's behind it
float alpha = clamp(covMid * 1.15, 0.0, 0.97);
// ── THE ORB: a glass sphere at the heart, just outside the horizon ──
// Big enough that light bends round it the way it bends round the shadow
// (seen from far away its outline sits almost exactly on the shadow's edge),
// so the disk still arches over it and passes in front. Gravity decides WHERE
// it sits; its FACE is drawn as a sphere looking straight at you, like the
// references: dark glass above, light below, and between them an S-curve
// that wraps with the sphere's curvature and wears the disk's six-wavelength
// dispersion. (A truly lensed orb arrives glancing and rings white; a
// volumetric surface seen from a few degrees above reads as a band, not a
// line. Both were tried.)
// o: the point on the face, unit disc, x right / y up; zz: its depth (1 at
// the centre, 0 at the outline); lw: the line's width in face units.
const float ORB_EDGE_B = 2.6128; // its outline: R / sqrt(1 − 1/R), where rays just graze it
// The S-curve at x for strand k (0 = the main curve). Awake it moves more;
// the lows lift it, the mids ripple it.
float orbCurve(float x, float t, float k) {
// awake, it breathes even in silence (so you can see it's listening)
float amp = (1.0 + uLive * (0.3 + 0.18 * sin(t * 2.1)) + 0.8 * uBands.x + 0.35 * uVoice) * (1.0 - 0.65 * uDorm);
float y = 0.17 * amp * sin(x * 2.3 + t * 0.85 + ph)
+ 0.07 * (1.0 + 1.2 * uBands.y) * sin(x * 4.7 - t * 1.25 - ph * 0.6)
+ 0.025 * uBands.y * sin(x * 9.0 - t * 4.0 + ph * 2.0);
return (y * (1.0 - 0.28 * k) + 0.12) * sqrt(max(1.0 - x * x * 0.55, 0.0));
vec3 orbShade(vec2 o, float zz, float t, float glass, float lw) {
// the curve, following the sphere's curvature toward the outline
float yw = orbCurve(o.x, t, 0.0);
float fill = smoothstep(lw, -lw, d);
// the dark glass: truly dark (0.004 linear reads ~30/255 after gamma)
vec3 col = vec3(0.0004, 0.0004, 0.0007);
// glass: coloured light pooled just under the curve, dying into the dark
// glass (the Siri redesign): magenta → blue → cyan across the face
vec3 tint = loopColour(0.5 + o.x * 0.3 + 0.08 * sin(t * 0.3));
tint = mix(tint, uMood, 0.7 * uMoodAmt);
vec3 glassC = tint * fill * (exp(d * (4.0 - 1.2 * uVoice)) * (0.9 + 0.5 * uLive + 0.9 * uVoice) + 0.04);
// silver: liquid metal, brighter toward the bottom where it catches the
// light (the macOS 27 orb)
vec3 silverC = mix(vec3(0.72, 0.75, 0.82), uMood * 0.85, 0.45 * uMoodAmt) * fill * (0.16 + 0.34 * smoothstep(0.2, -0.95, o.y));
// one turns into the other by POURING: the glass spreads from the middle
// of the curve outward (and drains back the same way), a soft front
// travelling across the face, never the whole face cross-fading at once
float far = length(vec2(o.x, d * 1.6));
// (the last tenth sweeps the front past the whole face: a curve lifted
// by a loud voice reaches further than the resting one, and must not keep
// a silver crescent at the bottom of a glass orb)
float front = mix(-0.1, 2.6, glass) + 2.0 * smoothstep(0.9, 1.0, glass);
col += mix(silverC, glassC, 1.0 - smoothstep(front - 0.35, front, far)) * (1.0 - 0.65 * uDorm);
// the limb: a sphere darkens toward its outline
// the curve itself: six wavelengths, each a hair higher or lower
for (int k = 0; k < TAPS; k++) {
float x = (float(k) + 0.5) / float(TAPS);
float dk = d + (x - 0.5) * 0.04 * uSpread * (1.0 + 1.5 * uBands.z);
line += w * exp(-dk * dk / (lw * lw));
col += (line / lnorm * (2.2 + 0.8 * uLive + 1.6 * uBands.z + 1.0 * uVoice) + exp(-d * d / 0.003) * (0.12 + 0.12 * uLive + 0.25 * uVoice)) * (1.0 - 0.6 * uDorm);
// while you speak, fainter strands of the curve fan out round the main one,
// each in its own colour (Siri's waves splitting)
for (int s = 1; s <= 2; s++) {
float ds = o.y - orbCurve(o.x, t, ks);
vec3 sc = loopColour(0.45 + 0.22 * ks + o.x * 0.15);
col += sc * exp(-ds * ds / (wk * wk)) * uVoice * (1.1 / ks) * zz;
// the glass: a rim lit warm by the disk all round it, a soft key above
// (wide and low: a luminous falloff into the edge, not a drawn rim)
float fres = pow(1.0 - zz, 2.5);
col += fres * vec3(1.0, 0.86, 0.7) * (0.04 + 0.14 * smoothstep(0.4, -0.5, o.y));
vec2 key = o - vec2(-0.32, 0.5);
// (faint and tight: gamma lifts tiny linear values, and a broad 0.05 key
// read as a grey dome over the dark glass)
col += exp(-dot(key, key) * 14.0) * vec3(0.9, 0.93, 1.0) * 0.012;
${direct ? FINISH_FN : ""}
vec2 frag = gl_FragCoord.xy;
float sh = min(uRes.x, uRes.y);
vec2 uv = (frag - 0.5 * uRes - vec2(0.0, uShift)) / sh;
// camera: on a circle level with the disk, raised uView degrees (the hand
// raises and turns it a little: uTilt), looking at the hole; the lens is
// set so the shadow spans uSize of the short side
float el = radians(max(uView + uTilt.x, 1.0));
vec3 ro = vec3(sin(uTilt.y) * cos(el), sin(el), cos(uTilt.y) * cos(el)) * D;
vec3 fw = normalize(-ro);
vec3 rt = normalize(cross(fw, vec3(0.0, 1.0, 0.0)));
float k = (B_CRIT / D) / (uSize * 0.5);
vec3 rd = normalize(fw + (uv.x * rt + uv.y * up) * k);
// one pixel's angular size: with distance travelled, the footprint
// ── step the photon through the hole's gravity
vec3 hv = cross(pos, vel);
// light that has already wrapped round the hole once (the under-arc, the
// ring hugging the shadow) is real but should whisper, not compete
// the orb: rays carry on through it, so its edge can fade into the disk
// wrapping behind; what lay in FRONT of it is kept apart
vec3 colFront = vec3(0.0);
for (int i = 0; i < STEPS; i++) {
float r2 = dot(pos, pos);
if (r2 < 1.0) { swallowed = 1.0; break; } // fell in (alpha stays = the disk in front)
// "behind" begins once the ray passes close to the orb, touching it or not
// (rays that just miss it skim the photon sphere and gather speckle)
if (uHeart < 2 && reached < 0.5 && r2 < (R_ORB + 0.2) * (R_ORB + 0.2)) {
// aimed inside the edge (+ the fade's margin), nothing behind will show
// (see "behind" below): stop here and save the steps
if (sqrt(h2) < ORB_EDGE_B + 0.03) break;
// small steps near the hole (finest where light orbits it, so the thin
// ring hugging the shadow stays clean), long strides far out
float dt = clamp(mix(0.022, 0.06, smoothstep(2.0, 4.5, r)) * r, 0.02, 1.2);
vel += -1.5 * h2 * pos / (r2 * r2 * r) * dt;
// the third and later images of the disk are thinner than a pixel: sampled,
// they speckle. Past the second pass, the photon ring below stands in
if (prev.y * pos.y < 0.0 && pass_ > 0.2) {
vec3 cross_ = mix(prev, pos, prev.y / (prev.y - pos.y));
// a pixel's footprint on the disk: wider with distance, much wider at
// a grazing angle (the disk is seen nearly edge-on)
float fp = pa * travelled / max(abs(normalize(vel).y), 0.03);
// lensing squeezes the second image (the under-arc, the far side's wrap)
// far below the pixel: draw it blurred, never sampled sharp
// (and past the point where the thread noise switches off: it's cheap)
if (pass_ < 1.0) fp += 0.16;
vec4 d = disk(cross_, vel, t, fp);
// the disk's underside (rays curling up under the hole: the under-arc)
// glows faintly; its top surface carries the light
float side = vel.y > 0.0 ? 0.22 : 1.0;
col += (1.0 - alpha) * d.rgb * pass_ * side;
alpha += (1.0 - alpha) * d.a * pass_ * side;
} else if (prev.y * pos.y < 0.0) {
// escaped: heading out past the disk's edge, a ray never turns back (in
// this gravity an outgoing ray outside the photon sphere climbs forever)
// and any plane it crosses out here holds no disk. Exact, and it spares
// every background pixel the long climb it used to make to r = 40
if (r2 > (R_OUT + 0.5) * (R_OUT + 0.5) && dot(pos, vel) > 0.0) break;
// The shadow, its edge and the photon ring all come from the IMPACT
// PARAMETER b: how far off-centre the ray was aimed. It's set by the pixel
// alone and varies smoothly across the screen, and Schwarzschild is exact
// about it: aimed inside 3√3/2, the ray falls in. Anything measured along
// the ray (closest approach, step count) flips within a sliver thinner than
// a pixel there, and speckles.
float px = pa * D; // one pixel, in impact-parameter units
// the orb: its outline is where rays just graze r = R_ORB
float inside = 1.0 - smoothstep(edgeB - px, edgeB + px, b);
if (reached < 0.5) { colFront = col; alphaFront = alpha; }
// a soft edge: the orb fades over the outer ~6% of its radius, into
// whatever lies behind it (the lensed disk), never cut on one pixel
inside = 1.0 - smoothstep(edgeB - 0.16, edgeB + px, b);
// only light from OUTSIDE the critical radius shows through the fade: inside
// it, rays circle the hole before falling in and their disk light is
vec3 behind = (col - colFront) * smoothstep(edgeB + 0.03, edgeB + 0.14, b);
col = colFront + (1.0 - inside) * behind;
// Gravity decides WHERE the orb sits (its outline, what passes in front
// of it), but its face is shaded as a plain sphere looking at you. Rays
// that truly reach it arrive glancing (it sits just outside the photon
// sphere, where light bends hardest), and a glancing orb rings white
// all over; the references are calm, front-on spheres.
float rUv = edgeB / (D * k); // the orb's radius on screen, in uv
float zz = sqrt(max(1.0 - dot(o, o), 0.0));
float lw = max(0.012, 1.2 / (rUv * sh)); // never thinner than a pixel
col += (1.0 - alphaFront) * orbShade(o, zz, t, uGlass, lw) * inside;
// behind whatever disk lies in front, the shadow is black
col *= 1.0 - inside * (1.0 - alpha);
// the photon ring: one clean hairline hugging the void (an orb has a soft
// edge instead; a hairline round it read as an ink outline)
float pr = (b - edgeB - 1.5 * px) / max(px * 1.2, 0.004);
col += vec3(1.0, 0.93, 0.85) * 0.55 * exp(-pr * pr) * (1.0 - alpha) * uFlare;
outColor = vec4(finish(col, frag), 1.0);
// alpha marks the void, so the finish keeps the bloom out of it (the mask
// fades across the edge with b, never stepping pixel by pixel). An orb
// shines, so it takes the bloom.
// (an orb's dark glass mostly keeps the disk's bloom off it too)
float hole = uHeart < 2 ? inside * (1.0 - alphaFront) * 0.92 : max(inside, smoothstep(B_CRIT + 0.35, B_CRIT, b)) * (1.0 - alpha);
outColor = vec4(col, hole);
const DOWN = `#version 300 es
uniform float uKnee; // first pass only: keep just the light past white
vec3 c = texture(uSrc, uv).rgb;
float l = max(c.r, max(c.g, c.b));
c *= smoothstep(uKnee, uKnee * 2.0, l);
c += tap(vUv + vec2(h.x, -h.y));
c += tap(vUv - vec2(h.x, -h.y));
outColor = vec4(c / 8.0, 1.0);
const UP = `#version 300 es
vec3 c = texture(uSrc, vUv + vec2(-h.x * 2.0, 0.0)).rgb;
c += texture(uSrc, vUv + vec2(-h.x, h.y)).rgb * 2.0;
c += texture(uSrc, vUv + vec2(0.0, h.y * 2.0)).rgb;
c += texture(uSrc, vUv + vec2(h.x, h.y)).rgb * 2.0;
c += texture(uSrc, vUv + vec2(h.x * 2.0, 0.0)).rgb;
c += texture(uSrc, vUv + vec2(h.x, -h.y)).rgb * 2.0;
c += texture(uSrc, vUv + vec2(0.0, -h.y * 2.0)).rgb;
c += texture(uSrc, vUv + vec2(-h.x, -h.y)).rgb * 2.0;
outColor = vec4(c / 12.0, 1.0);
const COMPOSITE = `#version 300 es
uniform sampler2D uScene;
uniform sampler2D uBloom;
// lens fringe: red and blue land a hair apart, more toward the frame's edge
vec2 ca = (vUv - 0.5) * 0.006 * uCA;
vec3 scene = vec3(texture(uScene, vUv + ca).r, texture(uScene, vUv).g, texture(uScene, vUv - ca).b);
// the shadow stays black: bloom doesn't wash into it
float hole = texture(uScene, vUv).a;
vec3 col = scene + texture(uBloom, vUv).rgb * 0.11 * uBloomAmt * (1.0 - 0.9 * hole);
outColor = vec4(finish(col, gl_FragCoord.xy), 1.0);
const BG = [10 / 255, 10 / 255, 11 / 255];
const STYLE_ID = "exp-orb-style";
font: 14px/1.4 system-ui,sans-serif;
transform: translateX(-50%);
background: rgba(255,255,255,0.07);
border: 1px solid rgba(255,255,255,0.14);
transition: border-color 180ms ease,scale 160ms ease-out;
transform: translateX(-50%);
font: 400 14px/20px var(--font-sans);
letter-spacing: -0.005em;
transition: color 150ms ease;
.orb__word[aria-checked="true"] {
.orb__mode:focus-visible,.orb__word:focus-visible {
outline: 2px solid #ececec;
@media (hover:hover) and (pointer:fine) {
.orb__mode:hover,.orb__word:hover {
transition: transform 280ms cubic-bezier(0.23,1,0.32,1),width 280ms cubic-bezier(0.23,1,0.32,1);
font-family: var(--font-sans);
font: 400 11px/14px var(--font-sans);
text-transform: uppercase;
font: 400 15px/24px var(--font-sans);
letter-spacing: -0.005em;
transition: color 150ms ease;
.orb__item[aria-checked="true"] {
.orb__item:focus-visible {
outline: 2px solid #ececec;
@media (hover:hover) and (pointer:fine) {
transform-origin: 100% 50%;
transition: transform 280ms cubic-bezier(0.23,1,0.32,1);
@media (prefers-reduced-motion:reduce) {
@media (hover:hover) and (pointer:fine) {
border-color: rgba(255,255,255,0.32);
.orb__mic:focus-visible {
outline: 2px solid #ececec;
.orb__mic[aria-pressed="true"] {
.orb__mic[data-state="blocked"] {
width: min(560px,calc(100% - 48px));
transform: translateX(-50%);
font: 400 17px/1.45 var(--font-sans);
padding: 0 0.13em 0.06em;
transition: color 240ms ease;
font: 400 13px/1.3 var(--font-sans);
font-variant-numeric: tabular-nums;
transition: color 180ms ease;
.orb__offer:focus-visible {
outline: 2px solid #ececec;
@media (hover:hover) and (pointer:fine) {
.orb__offer:not(:disabled):hover {
if (document.getElementById(STYLE_ID)) return;
const el = document.createElement("style");
document.head.appendChild(el);
function program(gl, frag) {
const sh = (type, src) => {
const s = gl.createShader(type);
if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(s) ?? "shader");
const p = gl.createProgram();
gl.attachShader(p, sh(gl.VERTEX_SHADER, VERT));
gl.attachShader(p, sh(gl.FRAGMENT_SHADER, frag));
if (!gl.getProgramParameter(p, gl.LINK_STATUS)) throw new Error(gl.getProgramInfoLog(p) ?? "link");
return { p, u: (name) => name in loc ? loc[name] : loc[name] = gl.getUniformLocation(p, name) };
const NS = "http://www.w3.org/2000/svg";
const BAR_X = [3.75, 7.75, 12, 16.25, 20.25];
const BAR_HALF = [1.25, 6.25, 2.25, 4.25, 1.25];
const spring = (k, c) => ({ x: 0, v: 0, to: 0, at: 0, k, c });
function stepSpring(sp, dt, now) {
const target = now >= sp.at ? sp.to : 1 - sp.to;
for (let t = dt; t > 0; t -= 1 / 240) {
const h = Math.min(t, 1 / 240);
sp.v += (sp.k * (target - sp.x) - sp.c * sp.v) * h;
const stepFree = (sp, dt) => stepSpring(sp, dt, Infinity);
const tune = (sp, k, z) => {
sp.c = 2 * z * Math.sqrt(k);
const r3 = (n) => Math.round(n * 1e3) / 1e3;
const pill = (cx, top, bottom, hw) => hw < 0.01 ? `M${cx} ${r3(top)}V${r3(bottom)}` : `M${r3(cx - hw)} ${r3(top + hw)}A${r3(hw)} ${r3(hw)} 0 0 1 ${r3(cx + hw)} ${r3(top + hw)}V${r3(bottom - hw)}A${r3(hw)} ${r3(hw)} 0 0 1 ${r3(cx - hw)} ${r3(bottom - hw)}Z`;
function micGlyph(reduced) {
const svg = document.createElementNS(NS, "svg");
svg.setAttribute("viewBox", "0 0 24 24");
svg.setAttribute("fill", "none");
svg.setAttribute("aria-hidden", "true");
svg.setAttribute("shape-rendering", "geometricPrecision");
const el = (tag, attrs, parent = svg) => {
const e = document.createElementNS(NS, tag);
for (const k in attrs) e.setAttribute(k, attrs[k]);
const memo = new WeakMap();
const put = (e, k, v) => {
if (!m) memo.set(e, m = {});
const stroke = { stroke: "currentColor", "stroke-width": WEIGHT, "stroke-linecap": "round", "stroke-linejoin": "round" };
const fold = el("g", {});
el("path", { ...stroke, "vector-effect": "non-scaling-stroke", d: "M5.124 14.5C6.281 17.149 8.924 19 12 19C15.075 19 17.719 17.149 18.876 14.5" }, fold);
el("path", { ...stroke, "vector-effect": "non-scaling-stroke", d: "M12 19V21.25" }, fold);
const cap = el("path", { ...stroke });
const bars = BAR_X.map((_, i) => i === 2 ? null : el("path", stroke));
const spark = el("path", {
d: "M17.24 5.185L16.544 3.373C16.457 3.148 16.241 3 16 3C15.759 3 15.543 3.148 15.456 3.373L14.76 5.185C14.658 5.449 14.449 5.658 14.185 5.76L12.373 6.456C12.148 6.543 12 6.759 12 7C12 7.241 12.148 7.457 12.373 7.544L14.185 8.24C14.449 8.342 14.658 8.551 14.76 8.815L15.456 10.627C15.543 10.852 15.759 11 16 11C16.241 11 16.457 10.852 16.544 10.627L17.24 8.815C17.342 8.551 17.551 8.342 17.815 8.24L19.627 7.544C19.852 7.457 20 7.241 20 7C20 6.759 19.852 6.543 19.627 6.456L17.815 5.76C17.551 5.658 17.342 5.449 17.24 5.185Z"
const S = (k, z) => spring(k, 2 * z * Math.sqrt(k));
const parts = Object.keys(P);
const IN = { spark: 0, fold: 0.04, capW: 0.08, capH: 0.17, inner: 0.22, outer: 0.27 };
const OUT = { outer: 0, inner: 0.035, capH: 0.07, capW: 0.11, fold: 0.145, spark: 0.18 };
const baseK = Object.fromEntries(parts.map((key) => [key, P[key].k]));
const heights = BAR_HALF.map((h) => ({ ...S(260, 0.7), x: h, to: h }));
const still = (sp, to) => Math.abs(sp.x - to) < 1e-4 && Math.abs(sp.v) < 1e-4;
const resting = (half) => {
if (!drawn) return false;
for (const key of parts) if (clock < P[key].at || !still(P[key], P[key].to)) return false;
for (let i = 0; i < heights.length; i++) if (!still(heights[i], half[i])) return false;
for (const key of parts) {
const k = baseK[key] * (on ? 1 : OUT_K);
P[key].c *= Math.sqrt(k / P[key].k);
P[key].at = clock + (reduced ? 0 : (on ? IN : OUT)[key]);
const step = (frame, half) => {
const dt = Math.min(frame, 1 / 30);
if (resting(half)) return;
if (!reduced) for (const key of parts) stepSpring(P[key], dt, clock);
heights.forEach((h, i) => {
stepSpring(h, dt, clock);
const hh = heights.map((h) => Math.max(0, h.x));
if (sp >= 0.995) put(spark, "visibility", "hidden");
put(spark, "visibility", "visible");
put(spark, "transform", `translate(16 7) rotate(${r3(90 * sp)}) scale(${r3(Math.max(0, 1 - sp))}) translate(-16 -7)`);
const top = 2.75 + (12 - hh[2] - 2.75) * ch;
const bottom = 15.75 + (12 + hh[2] - 15.75) * ch;
put(cap, "d", pill(12, top, bottom, Math.max(0, 4.25 * (1 - cw))));
const fp = Math.min(1, Math.max(0, P.fold.x));
if (fp >= 0.995) put(fold, "visibility", "hidden");
put(fold, "visibility", "visible");
put(fold, "transform", `translate(12 ${r3(bottom)}) scale(${r3(1 - fp)}) translate(-12 -15.75)`);
const p = i === 0 || i === 4 ? P.outer.x : P.inner.x;
if (p <= 5e-3) return put(b, "visibility", "hidden");
put(b, "visibility", "visible");
const x = 12 + (BAR_X[i] - 12) * Math.min(p, 1);
put(b, "d", `M${r3(x)} ${r3(12 - len)}V${r3(12 + len)}`);
return { svg, set, step, settled: () => P.outer.to === 1 && P.outer.x > 0.98 };
const Recognition = () => window.SpeechRecognition ?? window.webkitSpeechRecognition;
const within = (p, ms, fallback) => Promise.race([p, new Promise((r) => setTimeout(() => r(fallback), ms))]);
async function onDevice(lang) {
const SR = Recognition();
if (!SR || typeof SR.available !== "function") return "unavailable";
for (const quality of ["dictation", void 0]) {
const r = await within(SR.available({ langs: [lang], processLocally: true, ...quality ? { quality } : {} }), 2500, "unavailable");
if (r === "available") return "available";
if (r === "downloadable" || r === "downloading") return "downloadable";
function deviceSpeech(lang, hooks) {
const SR = Recognition();
if (!("processLocally" in r) && !("options" in r)) throw new Error("no on-device recognition");
if ("processLocally" in r) r.processLocally = true;
if ("options" in r) r.options = { langs: [lang], processLocally: true };
for (let i = e.resultIndex; i < e.results.length; i++) {
const piece = e.results[i][0].transcript.trim();
if (e.results[i].isFinal) settled = `${settled} ${piece}`.trim();
else tail = `${tail} ${piece}`.trim();
hooks.show(settled, tail);
pause = window.setTimeout(() => {
if (e.error === "no-speech" || e.error === "aborted") return;
const TRANSFORMERS = "@huggingface/transformers@4.2.0/dist/transformers.min.js";
const PHONEMIZER = "phonemizer@1.2.1/dist/phonemizer.js";
const MOONSHINE_WORKER = `
const SR = 16000, FRAME = 512;
const SPEECH = 0.3, EXIT = 0.1; // VAD: start / keep-going probability
const MIN_SILENCE = 0.4 * SR; // a pause this long ends a segment
const PAD = 0.08 * SR, MIN_SPEECH = 0.25 * SR;
const MAX = 30 * SR; // Moonshine's window
const PARTIAL_MIN = 0.25 * SR; // new audio before another live read
const WINDOW = 6 * SR, WINDOW_HARD = 10 * SR, SEARCH = 1.5 * SR;
const GAP = 0.15; // a real gap: energy < 15% of the median
const TOKENS_PER_SECOND = 6.5; // Moonshine's own cap against loops
const DTYPES = { webgpu: { encoder_model: "fp32", decoder_model_merged: "q4" },
wasm: { encoder_model: "fp32", decoder_model_merged: "q8" } };
let T, vad, asr, feel, runtime;
// one honest progress figure per part: only big files count (a small one
// finishing first would read 100%), and it never goes backwards
const progressFor = (part) => {
const files = new Map(); let shown = 0;
if (p.status !== "progress" || !p.file || !p.total || p.total < 4e6) return;
let l = 0, t = 0; for (const f of files.values()) { l += f.loaded || 0; t += f.total; }
shown = Math.max(shown, t ? l / t : 0);
postMessage({ type: "progress", part, value: shown });
async function boot({ runtime: url, host }) {
T.env.remoteHost = host + "/hf/";
T.env.backends.onnx.wasm.wasmPaths = host + "/npm/onnxruntime-web@" + T.env.backends.onnx.versions.web + "/dist/";
// MOOD: a 4.5 MB TinyBERT reads six emotions from settled text; on the CPU
// (it's instant there, and the GPU stays with the scene and the captions)
async function loadMood() {
feel = await T.pipeline("text-classification", "AdamCodd/tinybert-emotion-balanced", { dtype: "q8", device: "wasm" });
postMessage({ type: "ready", part: "mood" });
async function loadCaptions() {
const progress = progressFor("captions");
const device = (await navigator.gpu?.requestAdapter().catch(() => null)) ? "webgpu" : "wasm";
vad = await T.AutoModel.from_pretrained("onnx-community/silero-vad",
{ config: { model_type: "custom" }, dtype: "fp32", progress_callback: progress });
asr = await T.pipeline("automatic-speech-recognition", "onnx-community/moonshine-base-ONNX",
{ device, dtype: DTYPES[device], progress_callback: progress });
postMessage({ type: "preparing", part: "captions" }); // downloaded; shaders compile next
await asr(new Float32Array(SR));
sr = new T.Tensor("int64", [SR], []); state = fresh();
postMessage({ type: "ready", part: "captions", device });
// VOICE: Kokoro-82M speaks text back. Measured on an M-series Mac: fp32 on
// WebGPU runs ~4.7x real time; q8 on the CPU only ~0.64x (the page isn't
// cross-origin isolated, so ONNX gets one thread); fp16 on WebGPU outputs NaN.
// So: fp32 on the GPU where there is one, q8 on the CPU where there isn't. Text → phonemes (espeak-ng via the phonemizer package) →
// tokens → a waveform, sentence by sentence so playback starts on the
// first. The text handling is ported from kokoro-js (Apache-2.0).
let tts, ttsTok, phon, voiceHost, speakId = 0;
const voiceData = new Map();
async function loadVoice({ phonemizer, host }) {
const progress = progressFor("voice");
phon = await import(phonemizer);
const gpu = await navigator.gpu?.requestAdapter().catch(() => null);
[tts, ttsTok] = await Promise.all([
T.StyleTextToSpeech2Model.from_pretrained("onnx-community/Kokoro-82M-v1.0-ONNX",
gpu ? { dtype: "fp32", device: "webgpu", progress_callback: progress } : { dtype: "q8", device: "wasm", progress_callback: progress }),
T.AutoTokenizer.from_pretrained("onnx-community/Kokoro-82M-v1.0-ONNX", { progress_callback: progress }),
postMessage({ type: "ready", part: "voice" });
// a voice is 510 style vectors (one per input length), ~0.5 MB, cached
async function voiceStyles(id) {
if (voiceData.has(id)) return voiceData.get(id);
const url = (voiceHost ? voiceHost + "/hf" : "https://huggingface.co") + "/onnx-community/Kokoro-82M-v1.0-ONNX/resolve/main/voices/" + id + ".bin";
let cache = null, buf = null;
try { cache = await caches.open("orb-voices"); const hit = await cache.match(url); if (hit) buf = await hit.arrayBuffer(); } catch {}
const r = await fetch(url);
if (!r.ok) throw new Error("voice " + id + ": " + r.status);
buf = await r.arrayBuffer();
try { await cache?.put(url, new Response(buf.slice(0))); } catch {}
const data = new Float32Array(buf);
// numbers read the way people say them: years, times, money, decimals
if (m.includes(".")) return m;
const [h, mm] = m.split(":").map(Number);
return mm === 0 ? h + " o'clock" : mm < 10 ? h + " oh " + mm : h + " " + mm;
const y = parseInt(m.slice(0, 4), 10);
if (y < 1100 || y % 1000 < 10) return m;
const left = m.slice(0, 2), right = parseInt(m.slice(2, 4), 10), s = m.endsWith("s") ? "s" : "";
if (y % 1000 >= 100 && y % 1000 <= 999) {
if (right === 0) return left + " hundred" + s;
if (right < 10) return left + " oh " + right + s;
return left + " " + right + s;
const unit = m[0] === "$" ? "dollar" : "pound";
if (isNaN(Number(m.slice(1)))) return m.slice(1) + " " + unit + "s";
if (!m.includes(".")) return m.slice(1) + " " + unit + (m.slice(1) === "1" ? "" : "s");
const [whole, part] = m.slice(1).split(".");
const d = parseInt(part.padEnd(2, "0"), 10);
return whole + " " + unit + (whole === "1" ? "" : "s") + " and " + d + " " +
(m[0] === "$" ? (d === 1 ? "cent" : "cents") : d === 1 ? "penny" : "pence");
const sayPoint = (m) => { const [a, b] = m.split("."); return a + " point " + b.split("").join(" "); };
.replace(/[‘’]/g, "'").replace(/«/g, "“").replace(/»/g, "”").replace(/[“”]/g, '"')
.replace(/\\(/g, "«").replace(/\\)/g, "»")
.replace(/[^\\S \\n]/g, " ").replace(/ +/, " ").replace(/(?<=\\n) +(?=\\n)/g, "")
.replace(/\\bD[Rr]\\.(?= [A-Z])/g, "Doctor")
.replace(/\\b(?:Mr\\.|MR\\.(?= [A-Z]))/g, "Mister")
.replace(/\\b(?:Ms\\.|MS\\.(?= [A-Z]))/g, "Miss")
.replace(/\\b(?:Mrs\\.|MRS\\.(?= [A-Z]))/g, "Mrs")
.replace(/\\betc\\.(?! [A-Z])/gi, "etc")
.replace(/\\b(y)eah?\\b/gi, "$1e'a")
.replace(/\\d*\\.\\d+|\\b\\d{4}s?\\b|(?<!:)\\b(?:[1-9]|1[0-2]):[0-5]\\d\\b(?!:)/g, sayNumber)
.replace(/(?<=\\d),(?=\\d)/g, "")
.replace(/[$£]\\d+(?:\\.\\d+)?(?: hundred| thousand| (?:[bm]|tr)illion)*\\b|[$£]\\d+\\.\\d\\d?\\b/gi, sayMoney)
.replace(/\\d*\\.\\d+/g, sayPoint)
.replace(/(?<=\\d)-(?=\\d)/g, " to ")
.replace(/(?<=\\d)S/g, " S")
.replace(/(?<=[BCDFGHJ-NP-TV-Z])'?s\\b/g, "'S")
.replace(/(?<=X')S\\b/g, "s")
.replace(/(?:[A-Za-z]\\.){2,} [a-z]/g, (m) => m.replace(/\\./g, "-"))
.replace(/(?<=[A-Z])\\.(?=[A-Z])/gi, "-")
const PUNCT = /(\\s*[;:,.!?¡¿—…"«»“”(){}[\\]]+\\s*)+/g;
async function phonemes(text, lang) {
for (const m of text.matchAll(PUNCT)) {
if (last < m.index) parts.push({ punct: false, t: text.slice(last, m.index) });
if (m[0]) parts.push({ punct: true, t: m[0] });
last = m.index + m[0].length;
if (last < text.length) parts.push({ punct: false, t: text.slice(last) });
const code = lang === "a" ? "en-us" : "en";
let ph = (await Promise.all(parts.map(async ({ punct, t }) => (punct ? t : (await phon.phonemize(t, code)).join(" "))))).join("");
ph = ph.replace(/kəkˈoːɹoʊ/g, "kˈoʊkəɹoʊ").replace(/kəkˈɔːɹəʊ/g, "kˈəʊkəɹəʊ")
.replace(/ʲ/g, "j").replace(/r/g, "ɹ").replace(/x/g, "k").replace(/ɬ/g, "l")
.replace(/(?<=[a-zɹː])(?=hˈʌndɹɪd)/g, " ").replace(/ z(?=[;:,.!?¡¿—…"«»“” ]|$)/g, "z");
if (lang === "a") ph = ph.replace(/(?<=nˈaɪn)ti(?!ː)/g, "di");
async function speakText({ id, text, voice, speed }) {
const styles = await voiceStyles(voice);
const sentences = text.match(/[^.!?…]+[.!?…]*["')\\]]*\\s*/g) || [text];
for (const s of sentences) {
if (id !== speakId) return; // hushed or superseded
const { input_ids } = ttsTok(await phonemes(t, voice[0]), { truncation: true });
const off = 256 * Math.min(Math.max(input_ids.dims.at(-1) - 2, 0), 509);
const style = new T.Tensor("float32", styles.slice(off, off + 256), [1, 256]);
const { waveform } = await (chain = chain.then(() => tts({ input_ids, style, speed: new T.Tensor("float32", [speed], [1]) })));
if (id !== speakId) return;
const pcm = new Float32Array(waveform.data);
postMessage({ type: "speech", id, pcm, rate: 24000 }, [pcm.buffer]);
postMessage({ type: "spoken", id });
let chain = Promise.resolve(), state, sr;
const BUF = new Float32Array(MAX);
let at = 0, rec = false, after = 0, since = 0, busy = false, win = 0, prev = [];
const fresh = () => new T.Tensor("float32", new Float32Array(256), [2, 1, 128]);
async function speech(frame) {
const input = new T.Tensor("float32", frame, [1, frame.length]);
const { output, stateN } = await (chain = chain.then(() => vad({ input, sr, state })));
const p = output.data[0];
return p > SPEECH || (rec && p >= EXIT);
const lead = prev.reduce((n, b) => n + b.length, 0), out = new Float32Array(lead + end);
let o = 0; for (const b of prev) { out.set(b, o); o += b.length; }
out.set(BUF.subarray(0, end), o);
async function read(a, kind, w) {
const max_new_tokens = Math.max(8, Math.ceil(a.length / SR * TOKENS_PER_SECOND));
const r = await (chain = chain.then(() => asr(a, { max_new_tokens })));
const text = ((Array.isArray(r) ? r[0]?.text : r.text) || "").trim();
if (kind === "partial" && w !== win) return; // that window has moved on
postMessage({ type: kind, text, window: w });
for (let s = Math.max(FRAME, at - SEARCH); s + FRAME <= at - FRAME; s += FRAME) {
let e = 0; for (let i = s; i < s + FRAME; i++) e += BUF[i] * BUF[i];
const median = [...en].sort((a, b) => a.e - b.e)[en.length >> 1].e;
const q = en.reduce((a, b) => (b.e < a.e ? b : a));
if (q.e > median * GAP && at < WINDOW_HARD) return; // wait for a real gap
const split = q.s + FRAME / 2;
read(audio(split), "commit", win); win++;
prev = []; // no lead-in: it would re-hear the last word
BUF.copyWithin(0, split, at); at -= split; BUF.fill(0, at); since = 0;
if (at >= MIN_SPEECH) read(audio(Math.min(at + PAD, MAX)), "final", win);
win++; BUF.fill(0); at = 0; rec = false; after = 0; since = 0;
async function hear(frame) {
const was = rec, isSpeech = await speech(frame);
if (!was && !isSpeech) { if (prev.length >= Math.ceil(PAD / FRAME)) prev.shift(); prev.push(frame); return; }
if (frame.length >= MAX - at) { endSegment(); return; }
BUF.set(frame, at); at += frame.length; since += frame.length;
if (since >= PARTIAL_MIN && !busy) { since = 0; busy = true; read(audio(at), "partial", win).finally(() => (busy = false)); }
if (at >= WINDOW) commitOlder();
if (after >= MIN_SILENCE) endSegment();
const loader = { captions: loadCaptions, mood: loadMood, voice: loadVoice }[m.part];
loads[m.part] ??= runtime.then(() => loader(m)).catch((err) => {
postMessage({ type: "error", part: m.part, message: String(err?.message ?? err) });
feel(m.text, { top_k: null }).then((scores) => postMessage({ type: "mood", id: m.id, scores }));
if (m.type === "speak") {
if (tts) speakText(m).catch((err) => postMessage({ type: "spoken", id: m.id, error: String(err?.message ?? err) }));
if (m.type === "hush") { speakId = -1; return; }
if (!asr || !state) return;
if (m.type === "audio") hear(m.buffer);
else if (m.type === "flush") { if (rec) endSegment(); prev = []; state = fresh(); }
class OrbTap extends AudioWorkletProcessor {
constructor() { super(); this.ratio = sampleRate / 16000; this.pos = 0; this.acc = 0; this.n = 0; this.out = new Float32Array(512); this.k = 0; }
const ch = inputs[0] && inputs[0][0];
for (let i = 0; i < ch.length; i++) {
this.acc += ch[i]; this.n++; this.pos += 1;
if (this.pos >= this.ratio) {
this.pos -= this.ratio; this.out[this.k++] = this.acc / this.n; this.acc = 0; this.n = 0;
if (this.k === 512) { this.port.postMessage(this.out, [this.out.buffer]); this.out = new Float32Array(512); this.k = 0; }
registerProcessor("orb-tap", OrbTap);
let w = text.split(/\s+/).filter(Boolean);
const key = (s) => s?.toLowerCase().replace(/[^a-z']/g, "");
for (let n = 1; n <= 4; n++) {
for (let i = 0; i + 3 * n <= w.length; i++) {
for (let k = 0; k < n; k++) if (key(w[a + k]) !== key(w[b + k])) return false;
while (i + (reps + 1) * n <= w.length && same(i, i + reps * n)) reps++;
if (reps >= 3) w = [...w.slice(0, i + n), ...w.slice(i + reps * n)];
return w.join(" ").replace(/\bi\b/g, "I").replace(/\bi'(m|ve|ll|d)\b/g, "I'$1");
function joinText(head, next) {
const cont = !/[.?!]$/.test(head) && !/^I(\b|')/.test(next);
return `${head} ${cont ? next.charAt(0).toLowerCase() + next.slice(1) : next}`;
function orbBrain(host) {
const url = URL.createObjectURL(new Blob([MOONSHINE_WORKER], { type: "text/javascript" }));
const worker = new Worker(url, { type: "module" });
URL.revokeObjectURL(url);
const base = host || "https://cdn.jsdelivr.net";
return parts[part] ??= new Promise((resolve, reject) => {
if (m.type !== "ready" && m.type !== "error" || m.part !== part) return;
worker.removeEventListener("message", on);
if (m.type === "ready") resolve();
reject(new Error(`${part} failed to load`));
worker.addEventListener("message", on);
worker.postMessage({ type: "load", part, runtime: `${base}/npm/${TRANSFORMERS}`, phonemizer: `${base}/npm/${PHONEMIZER}`, host });
// the six emotion scores for a piece of settled text
return new Promise((resolve) => {
if (m.type !== "mood" || m.id !== id) return;
worker.removeEventListener("message", on);
worker.addEventListener("message", on);
worker.postMessage({ type: "mood", id, text });
speak(id, text, voice, speed) {
worker.postMessage({ type: "speak", id, text, voice, speed });
worker.postMessage({ type: "hush" });
function moonshine(hooks, brain) {
const { worker } = brain;
worker.addEventListener("message", (e) => {
if (m.type === "progress" && m.part === "captions") hooks.progress(m.value ?? 0);
else if (m.type === "preparing" && m.part === "captions") hooks.progress(-1);
else if (m.type === "partial" && m.text) hooks.show(settled, joinText(settled, tidy(m.text)).slice(settled.length).trim());
else if (m.type === "commit") {
if (m.text) settled = joinText(settled, tidy(m.text));
} else if (m.type === "final") {
const all = joinText(settled, tidy(m.text ?? ""));
if (all) hooks.show(all, "");
} else if (m.type === "error" && m.part === "captions") hooks.failed(m.message ?? "error");
const load = () => brain.need("captions");
const ctx = new AudioContext();
const src = URL.createObjectURL(new Blob([TAP], { type: "text/javascript" }));
await ctx.audioWorklet.addModule(src);
URL.revokeObjectURL(src);
const node = new AudioWorkletNode(ctx, "orb-tap");
node.port.onmessage = (e) => worker.postMessage({ type: "audio", buffer: e.data }, [e.data.buffer]);
const mute = ctx.createGain();
ctx.createMediaStreamSource(stream).connect(node).connect(mute).connect(ctx.destination);
tap.node.port.onmessage = null;
worker.postMessage({ type: "flush" });
joy: { rgb: [1, 0.68, 0.18], energy: 0.5 },
love: { rgb: [1, 0.32, 0.55], energy: 0.2 },
surprise: { rgb: [0.25, 0.85, 1], energy: 0.8 },
// electric cyan, a swell
sadness: { rgb: [0.1, 0.26, 1], energy: -0.6 },
fear: { rgb: [0.78, 0.2, 0.95], energy: 0.35 },
// magenta-violet, restless (kept clear of sadness's blue)
anger: { rgb: [1, 0.1, 0.08], energy: 0.9 }
function moodOf(scores) {
const top = scores.reduce((a, b2) => b2.score > a.score ? b2 : a, { label: "", score: 0 });
if (top.score < 0.45) return null;
let r = 0, g = 0, b = 0, en = 0, w = 0;
for (const { label, score } of scores) {
if (!m || score < 0.15) continue;
const amt = Math.min(1, Math.max(0, (top.score - 0.45) / 0.45));
return { rgb: [r / w, g / w, b / w], energy: en / w, amt: amt * amt * (3 - 2 * amt), label: top.label };
function voiceRack(ctx, input) {
const hp = ctx.createBiquadFilter();
const comp = ctx.createDynamicsCompressor();
comp.threshold.value = -20;
comp.attack.value = 4e-3;
comp.release.value = 0.2;
const limit = ctx.createDynamicsCompressor();
limit.threshold.value = -3;
limit.attack.value = 1e-3;
limit.release.value = 0.1;
const master = ctx.createGain();
input.connect(hp).connect(comp);
limit.connect(master).connect(ctx.destination);
const space = (seconds, decay) => {
const n = Math.floor(ctx.sampleRate * seconds);
const ir = ctx.createBuffer(2, n, ctx.sampleRate);
for (let c = 0; c < 2; c++) {
const d = ir.getChannelData(c);
for (let i = 0; i < n; i++) d[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / n, decay);
const conv = ctx.createConvolver();
const g = ctx.createGain();
const build = (effect) => {
for (const n of parts) n.disconnect();
const keep = (...n) => (parts.push(...n), n[0]);
const wet = (node, level) => {
const g = keep(gain(level));
node.connect(g).connect(limit);
const dry = (level) => wet(comp, level);
const r = keep(space(2.8, 2.2));
} else if (effect === "delay") {
const d = keep(ctx.createDelay(1));
d.delayTime.value = 0.23;
const fb = keep(gain(0.34));
d.connect(fb).connect(d);
} else if (effect === "radio") {
const lo = keep(ctx.createBiquadFilter());
lo.frequency.value = 500;
const hi = keep(ctx.createBiquadFilter());
hi.frequency.value = 3200;
const drive = keep(ctx.createWaveShaper());
const curve = new Float32Array(1024);
for (let i = 0; i < 1024; i++) {
const x = i / 1023 * 2 - 1;
curve[i] = Math.tanh(2.6 * x);
comp.connect(lo).connect(hi).connect(drive);
} else if (effect === "robot") {
const ring = keep(gain(0));
osc = ctx.createOscillator();
osc.frequency.value = 55;
const comb = keep(ctx.createDelay(0.05));
comb.delayTime.value = 9e-3;
const cfb = keep(gain(0.55));
comb.connect(cfb).connect(comb);
const r = keep(space(0.7, 3));
master.gain.setTargetAtTime(v ? 0.95 : 0, ctx.currentTime, 0.03);
for (let i = 2; i < freq.length; i++) if (freq[i] > peak) peak = freq[i], at = i;
for (let i = 2; i < freq.length; i++) if (Math.abs(i - at) > 2 && freq[i] > second) second = freq[i];
const pure = peak > 190 && peak - second > 70;
const parked = Math.abs(at - howlAt) <= 1;
howl = pure && parked ? howl + dt : 0;
if (howl < 0.6) return false;
function captionLine(parent, reduced) {
const box = document.createElement("div");
box.className = "orb__cap";
box.setAttribute("aria-hidden", "true");
const line = document.createElement("p");
line.className = "orb__line";
const note = document.createElement("button");
note.className = "orb__offer";
const o = offers.shift();
note.textContent = o.text;
const RISE = "cubic-bezier(0.16, 1, 0.3, 1)";
const OUT = "cubic-bezier(0.23, 1, 0.32, 1)";
const enter = (w, i) => {
w.inner.animate([{ transform: "translateY(110%)" }, { transform: "translateY(0)" }], {
const leave = (w, at) => {
w.outer.classList.add("is-leaving");
if (reduced) return w.outer.remove();
const box2 = line.getBoundingClientRect();
const r = at ?? w.outer.getBoundingClientRect();
Object.assign(w.outer.style, { position: "absolute", left: `${r.left - box2.left}px`, top: `${r.top - box2.top}px` });
w.inner.animate([{ transform: "translateY(0)" }, { transform: "translateY(-110%)" }], { duration: 220, easing: OUT, fill: "forwards" }).finished.then(() => w.outer.remove(), () => w.outer.remove());
const at = words.map((w) => w.outer.getBoundingClientRect());
words.forEach((w, i) => leave(w, at[i]));
const before = new Map(words.map((w) => [w, w.outer.getBoundingClientRect()]));
words.forEach((w) => leave(w, before.get(w)));
...fin.split(/\s+/).filter(Boolean).map((t) => ({ t, live: false })),
...tail.split(/\s+/).filter(Boolean).map((t) => ({ t, live: true }))
const drop = Math.max(0, tokens.length - MAX);
const keep = tokens.slice(drop);
const gone = Math.min(drop, words.length);
for (let i = 0; i < gone; i++) leave(words[i], before.get(words[i]));
words = words.slice(gone);
keep.forEach((tok, i) => {
const outer = document.createElement("span");
outer.className = "orb__w";
const inner = document.createElement("span");
outer.appendChild(inner);
w = { outer, inner, text: "" };
} else if (w.text !== tok.t && !reduced) {
w.inner.animate([{ filter: "blur(3px)" }, { filter: "blur(0)" }], { duration: 140, easing: "ease-out" });
w.inner.textContent = tok.t;
w.outer.classList.toggle("is-live", tok.live);
words.slice(keep.length).forEach((w) => leave(w, before.get(w)));
words = words.slice(0, keep.length);
const a = w.outer.getBoundingClientRect();
const dx = b.left - a.left;
const dy = b.top - a.top;
if (Math.abs(dx) + Math.abs(dy) < 0.5) continue;
w.outer.animate([{ transform: `translate(${dx}px, ${dy}px)` }, { transform: "translate(0, 0)" }], { duration: 280, easing: OUT });
words.forEach((w) => w.outer.classList.remove("is-live"));
offers.push({ text, act });
if (!next()) note.hidden = true;
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const P = { ...DEFAULTS };
const wrap = document.createElement("div");
const canvas = document.createElement("canvas");
wrap.appendChild(canvas);
const mic = document.createElement("button");
mic.className = "orb__mic";
mic.setAttribute("aria-pressed", "false");
mic.setAttribute("aria-label", "Talk to the orb");
const glyph = micGlyph(reduced);
mic.appendChild(glyph.svg);
const status = document.createElement("p");
status.className = "orb__status";
status.setAttribute("aria-live", "polite");
const spk = document.createElement("button");
spk.className = "orb__mic orb__spk";
spk.setAttribute("aria-pressed", "false");
spk.setAttribute("aria-label", "Speak back");
spk.innerHTML = svgSpeakerOff;
const ctl = document.createElement("div");
ctl.className = "orb__ctl";
const unit = document.createElement("div");
unit.className = "orb__unit";
wrap.append(ctl, status);
const caps = captionLine(wrap, reduced);
const gl = canvas.getContext("webgl2", { antialias: false, alpha: false });
const note = document.createElement("p");
note.className = "orb__note";
note.textContent = "This one needs WebGL2.";
return () => wrap.remove();
const hdr = !!gl.getExtension("EXT_color_buffer_float") || !!gl.getExtension("EXT_color_buffer_half_float");
gl.getExtension("OES_texture_half_float_linear");
const scene = program(gl, SCENE(!hdr));
const down = hdr ? program(gl, DOWN) : null;
const up = hdr ? program(gl, UP) : null;
const comp = hdr ? program(gl, COMPOSITE) : null;
gl.bindVertexArray(gl.createVertexArray());
const makeTarget = (w, h) => {
const tex = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, tex);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA16F, w, h, 0, gl.RGBA, gl.HALF_FLOAT, null);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
const fbo = gl.createFramebuffer();
gl.bindFramebuffer(gl.FRAMEBUFFER, fbo);
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, tex, 0);
return { fbo, tex, w, h };
const freeTargets = () => {
for (const t of [sceneT, ...chain]) {
gl.deleteFramebuffer(t.fbo);
const buildTargets = () => {
const w = Math.max(1, Math.round(canvas.width * scale));
const h = Math.max(1, Math.round(canvas.height * scale));
sceneT = makeTarget(w, h);
for (let i = 0; i < LEVELS; i++) {
lw = Math.max(1, lw >> 1);
lh = Math.max(1, lh >> 1);
chain.push(makeTarget(lw, lh));
const css = Math.max(1, wrap.clientWidth * wrap.clientHeight);
const dpr = Math.min(window.devicePixelRatio || 1, dprCap, Math.sqrt(26e5 / css));
const w = Math.max(1, Math.round(wrap.clientWidth * dpr));
const h = Math.max(1, Math.round(wrap.clientHeight * dpr));
if (canvas.width === w && canvas.height === h && (sceneT || !hdr)) return;
const CAPTIONS = 16 + 50;
const short = Math.min(W, H);
let r = 0.5 * Math.min(P.size, 0.92 * W / (3.3 * short)) * short;
const below = CAPTIONS + (placed?.capOffset ?? (W < 760 ? 36 : 0)) + 16;
if (3.28 * r + GAP + MIC + 2 * below > field) r = Math.max(24, (field - 2 * below - GAP - MIC) / 3.28);
const spare = field - (3.28 * r + GAP + MIC);
const cy = (spare >= 2 * below ? spare / 2 : Math.max(16, spare - below)) + 1.76 * r;
return { r, cy, size: 2 * r / short };
const { r, cy } = layout(wrap.clientWidth, wrap.clientHeight);
const rowTop = cy + 1.52 * r + GAP;
ctl.style.top = `${rowTop}px`;
const extra = placed?.capOffset ?? 0;
if (placed?.id === "a") placed.root.style.top = `${rowTop + MIC + 16}px`;
if (placed?.id === "c") placed.place?.(cy);
caps.box.style.top = `${rowTop + MIC + 16 + extra}px`;
const ro = new ResizeObserver(resize);
const pass = (prog, target2, src) => {
gl.bindFramebuffer(gl.FRAMEBUFFER, target2 ? target2.fbo : null);
gl.viewport(0, 0, target2 ? target2.w : canvas.width, target2 ? target2.h : canvas.height);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, src.tex);
gl.uniform1i(prog.u("uSrc"), 0);
gl.uniform2f(prog.u("uTexel"), 1 / src.w, 1 / src.h);
gl.drawArrays(gl.TRIANGLES, 0, 3);
const draw = (time2, L) => {
const sw = sceneT ? sceneT.w : canvas.width;
const shh = sceneT ? sceneT.h : canvas.height;
gl.uniform2f(scene.u("uRes"), sw, shh);
gl.uniform1f(scene.u("uTime"), time2);
const H = wrap.clientHeight || 1;
gl.uniform1f(scene.u("uSize"), L.size);
gl.uniform1f(scene.u("uShift"), (H / 2 - L.cy) * (shh / H));
gl.uniform1f(scene.u("uEnergy"), P.energy);
gl.uniform1f(scene.u("uSpread"), P.spread);
gl.uniform1f(scene.u("uFlare"), P.flare);
gl.uniform1f(scene.u("uView"), P.view);
gl.uniform1i(scene.u("uHeart"), Math.max(0, HEARTS.indexOf(P.heart)));
gl.uniform1f(scene.u("uGlass"), glassAmt.x);
gl.uniform1f(scene.u("uLive"), live);
gl.uniform1f(scene.u("uDorm"), dorm);
gl.uniform1f(scene.u("uVoice"), voice);
gl.uniform3f(scene.u("uBands"), bands[0], bands[1], bands[2]);
gl.uniform3f(scene.u("uMood"), mood.rgb[0], mood.rgb[1], mood.rgb[2]);
gl.uniform1f(scene.u("uMoodAmt"), mood.amt);
gl.uniform1f(scene.u("uMoodEnergy"), mood.energy);
gl.uniform2f(scene.u("uTilt"), tilt[0].x, tilt[1].x);
gl.uniform3f(scene.u("uBg"), ...BG);
if (!hdr || !sceneT || !down || !up || !comp) {
pass(scene, sceneT, null);
gl.uniform1f(down.u("uKnee"), t === chain[0] ? 1.7 : 0);
gl.blendFunc(gl.ONE, gl.ONE);
for (let i = chain.length - 2; i >= 0; i--) pass(up, chain[i], chain[i + 1]);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, sceneT.tex);
gl.uniform1i(comp.u("uScene"), 0);
gl.activeTexture(gl.TEXTURE1);
gl.bindTexture(gl.TEXTURE_2D, chain[0].tex);
gl.uniform1i(comp.u("uBloom"), 1);
gl.uniform1f(comp.u("uBloomAmt"), P.bloom * (1 - 0.55 * dorm));
gl.uniform1f(comp.u("uCA"), P.spread);
gl.uniform3f(comp.u("uBg"), ...BG);
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
gl.viewport(0, 0, canvas.width, canvas.height);
gl.drawArrays(gl.TRIANGLES, 0, 3);
const liveSp = spring(40, 2 * 0.55 * Math.sqrt(40));
const peaks = [0.05, 0.05, 0.05, 0.05];
const setStatus = (state, text) => {
mic.dataset.state = state;
mic.setAttribute("aria-pressed", state === "listening" ? "true" : "false");
mic.setAttribute("aria-label", state === "listening" ? "Stop listening" : state === "blocked" ? "Microphone blocked" : "Talk to the orb");
if (state === "blocked") {
mic.innerHTML = svgMicOff;
mic.firstElementChild?.animate([{ scale: "0.25" }, { scale: "1" }], { duration: 200, easing: "cubic-bezier(0.2, 0, 0, 1)" });
} else if (!mic.contains(glyph.svg)) mic.replaceChildren(glyph.svg);
status.textContent = text;
const lang = navigator.language || "en-US";
const PREF = "orb:captions";
return localStorage.getItem(PREF) === "on";
localStorage.setItem(PREF, "on");
const getBrain = () => brain ??= orbBrain(opts.captionHost ?? CAPTION_HOST);
const mood = { rgb: [1, 1, 1], amt: 0, energy: 0 };
const moodTo = { rgb: [1, 1, 1], amt: 0, energy: 0 };
const MOOD_PREF = "orb:mood";
const feel = async (text) => {
if (!moodOn || !brain || !text.trim()) return;
const m = moodOf(await brain.classify(text));
moodAt = performance.now();
moodLabel = m?.label ?? "";
if (!m) return void (moodTo.amt = 0);
moodTo.energy = m.energy;
const easeMood = (dt) => {
if (moodTo.amt > 0 && performance.now() - moodAt > 1e4) moodTo.amt = 0;
const k = 1 - Math.exp(-dt * 2.5);
for (let i = 0; i < 3; i++) mood.rgb[i] += (moodTo.rgb[i] - mood.rgb[i]) * k;
mood.amt += (moodTo.amt - mood.amt) * k;
mood.energy += (moodTo.energy - mood.energy) * k;
const startMood = async () => {
await getBrain().need("mood");
const offerMood = () => {
on = localStorage.getItem(MOOD_PREF) === "on";
if (on) return void startMood();
caps.offer("Mood :: let the orb feel what you say, ~27 MB once", () => {
localStorage.setItem(MOOD_PREF, "on");
const n = fin ? fin.split(/\s+/).length : 0;
if (n - feltWords >= 8) {
void feel(fin.split(/\s+/).slice(-24).join(" "));
status.textContent = spoken;
void feel(said).then(() => speakBack(said));
progress: (v) => caps.say(v < 0 ? "Preparing captions" : `Loading captions :: ${Math.round(v * 100)}%`),
caps.say("Captions couldn't start here");
setTimeout(() => caps.quiet(), 2600);
if (typeof Worker === "undefined" || typeof AudioWorkletNode === "undefined") return caps.quiet();
caps.say("Loading captions");
const m = moonshine(hooks, getBrain());
if (listening && ear) await m.start(ear.stream);
caps.say("Captions couldn't load");
setTimeout(() => caps.quiet(), 2600);
else caps.offer("Captions :: runs on this device, a one-time 150 MB download", () => void go());
const useDevice = () => {
const e = deviceSpeech(lang, { ...hooks, failed: () => (engine?.destroy(), engine = null, fallback()) });
if (typeof Worker !== "undefined") offerMood();
const beginCaptions = async () => {
if (engine && ear) return void await engine.start(ear.stream);
const local = await onDevice(lang);
if (local === "available") useDevice();
else if (local === "downloadable") {
caps.say("Downloading captions");
const ok = await Recognition().install({ langs: [lang], processLocally: true, quality: "dictation" }).catch(() => false);
if (ok && listening) useDevice();
else if (!ok) fallback();
else caps.offer("Captions :: a one-time download, it stays on this device", () => void go());
const endCaptions = () => {
const VOICE_PREF = "orb:voice";
joy: { speed: 1.06, pitch: 1.08 },
love: { speed: 0.97, pitch: 1.02 },
surprise: { speed: 1.08, pitch: 1.12 },
sadness: { speed: 0.9, pitch: 0.92 },
fear: { speed: 1.05, pitch: 1.04 },
anger: { speed: 1.04, pitch: 0.96 }
const ctx = new AudioContext();
const an = ctx.createAnalyser();
an.smoothingTimeConstant = 0.55;
an.connect(ctx.destination);
return mouth = { ctx, an, time: new Uint8Array(1024), freq: new Uint8Array(512), next: 0, live: new Set() };
const bestVoice = () => {
const all = typeof speechSynthesis === "undefined" ? [] : speechSynthesis.getVoices();
const mine = all.filter((v) => v.lang.toLowerCase().startsWith(lang.slice(0, 2).toLowerCase()));
return mine.find((v) => v.localService && /premium|enhanced|natural|neural/i.test(v.name)) ?? mine.find((v) => /samantha|daniel|karen|moira|serena|google/i.test(v.name)) ?? mine.find((v) => v.localService) ?? mine[0] ?? null;
const finishSpeaking = () => {
status.textContent = listening ? "Listening" : "";
if (listening && ear && !speaking) void engine?.start(ear.stream);
const maybeDone = () => {
if (speaking && viaKokoro && workerDone && mouth && mouth.live.size === 0) finishSpeaking();
const playPcm = (pcm, rate) => {
const buf = m.ctx.createBuffer(1, pcm.length, rate);
buf.copyToChannel(pcm, 0);
const src = m.ctx.createBufferSource();
const at = Math.max(m.ctx.currentTime + 0.03, m.next);
m.next = at + buf.duration;
const systemSpeak = (text, v) => {
if (typeof speechSynthesis === "undefined") return finishSpeaking();
const u = new SpeechSynthesisUtterance(text);
const best = bestVoice();
if (best) u.voice = best;
u.onboundary = () => kick = 1;
u.onend = u.onerror = () => finishSpeaking();
speechSynthesis.cancel();
speechSynthesis.speak(u);
const speakBack = (text) => {
if (!speakerOn || P.speaker !== "readback" || !listening || !text.trim()) return;
const id = ++speakSerial;
status.textContent = "Speaking";
const v = MOOD_VOICE[moodLabel] ?? { speed: 1, pitch: 1 };
brain.speak(id, text, P.voice, v.speed);
} else systemSpeak(text, v);
for (const s of mouth.live) {
if (typeof speechSynthesis !== "undefined") speechSynthesis.cancel();
const onSpeech = (e) => {
if (m.type === "speech" && m.id === speakSerial && m.pcm) playPcm(m.pcm, m.rate ?? 24e3);
else if (m.type === "spoken" && m.id === speakSerial) {
if (m.error) finishSpeaking();
} else if (m.type === "progress" && m.part === "voice") caps.say(`Loading voices :: ${Math.round((m.value ?? 0) * 100)}%`);
const loadKokoro = async () => {
b.worker.addEventListener("message", onSpeech);
caps.say("Loading voices");
caps.say("Voices couldn't load: the system voice speaks instead");
setTimeout(() => caps.quiet(), 2600);
let voiceSize = "~92 MB";
void navigator.gpu?.requestAdapter().then((a) => a && (voiceSize = "~330 MB")).catch(() => {
const prepareVoice = () => {
if (kokoro || typeof Worker === "undefined") return;
on = localStorage.getItem(VOICE_PREF) === "on";
if (on) return void loadKokoro();
caps.offer(`Natural voices :: on this device, ${voiceSize} once`, () => {
localStorage.setItem(VOICE_PREF, "on");
const applySpeaker = () => {
const liveOn = speakerOn && P.speaker === "live";
if (liveOn && ear && !heardTip) {
caps.say("Live :: headphones keep it from feeding back");
setTimeout(() => caps.quiet(), 3200);
if (!(speakerOn && P.speaker === "readback")) {
const setSpeaker = (on) => {
const live2 = P.speaker === "live";
spk.setAttribute("aria-pressed", String(on));
spk.setAttribute("aria-label", live2 ? on ? "Stop hearing yourself" : "Hear yourself" : on ? "Stop reading back" : "Read back what you say");
spk.innerHTML = on ? svgSpeakerOn : svgSpeakerOff;
if (!reduced) spk.firstElementChild?.animate([{ scale: "0.25" }, { scale: "1" }], { duration: 200, easing: "cubic-bezier(0.2, 0, 0, 1)" });
void getMouth().ctx.resume();
if (typeof speechSynthesis !== "undefined") speechSynthesis.getVoices();
if (on) void ear?.ctx.resume();
const toggleSpeaker = () => setSpeaker(!speakerOn);
spk.addEventListener("click", toggleSpeaker);
const SETTINGS = "orb:speaker-settings";
const saved = JSON.parse(localStorage.getItem(SETTINGS) || "{}");
if (SPEAKER_MODES.some((m) => m.id === saved.speaker)) P.speaker = saved.speaker;
if (EFFECTS.some((e) => e.id === saved.effect)) P.effect = saved.effect;
if (VOICES.some((v) => v.id === saved.voice)) P.voice = saved.voice;
const optionsNow = () => P.speaker === "live" ? EFFECTS : VOICES;
const chosen = () => P.speaker === "live" ? P.effect : P.voice;
const modeLabel = () => SPEAKER_MODES.find((m) => m.id === P.speaker)?.label ?? "";
localStorage.setItem(SETTINGS, JSON.stringify({ speaker: P.speaker, effect: P.effect, voice: P.voice }));
const RISE = "cubic-bezier(0.16, 1, 0.3, 1)";
const OUT = "cubic-bezier(0.23, 1, 0.32, 1)";
const skinId = wrap.clientWidth < 760 ? "a" : "c";
wrap.classList.add(`orb--${skinId}`);
const optButton = (o, cls) => {
const b = document.createElement("button");
b.setAttribute("role", "radio");
const mask = document.createElement("span");
mask.className = "orb__m";
const inner = document.createElement("span");
inner.textContent = o.label;
const radios = (group, pick) => {
group.setAttribute("role", "radiogroup");
group.addEventListener("keydown", (e) => {
const items = [...group.querySelectorAll("[role=radio]")];
const i = items.indexOf(document.activeElement);
const step = e.key === "ArrowRight" || e.key === "ArrowDown" ? 1 : e.key === "ArrowLeft" || e.key === "ArrowUp" ? -1 : 0;
if (!step || i < 0) return;
const next = items[(i + step + items.length) % items.length];
group.addEventListener("click", (e) => {
const b = e.target.closest("[role=radio]");
if (b && group.contains(b)) pick(b.dataset.id);
const mark = (group, id) => {
for (const b of group.querySelectorAll("[role=radio]")) {
const on = b.dataset.id === id;
b.setAttribute("aria-checked", String(on));
b.tabIndex = on ? 0 : -1;
const settle = (el) => el.getAnimations().forEach((a) => a.cancel());
const riseIn = (items, delay = 0) => {
items.forEach((el, i) => {
el.animate([{ transform: "translateY(110%)" }, { transform: "translateY(0)" }], {
const dropOut = (items) => {
if (reduced) return Promise.resolve();
(el) => el.animate([{ transform: "translateY(0)" }, { transform: "translateY(110%)" }], { duration: 180, easing: OUT, fill: "forwards" }).finished.catch(() => {
const drawIn = (el, delay) => {
const at = el.style.transform;
el.animate([{ transform: `${at} scaleX(0)` }, { transform: `${at} scaleX(1)` }], { duration: 320, delay, easing: RISE, fill: "backwards" });
const drawOut = (el) => {
const at = el.style.transform;
el.animate([{ transform: `${at} scaleX(1)` }, { transform: `${at} scaleX(0)` }], { duration: 140, easing: OUT, fill: "forwards" });
const line = document.createElement("div");
line.className = "orb__line2";
const mode = document.createElement("button");
mode.className = "orb__mode";
const modeMask = document.createElement("span");
modeMask.className = "orb__m";
const modeIn = document.createElement("span");
modeMask.appendChild(modeIn);
mode.appendChild(modeMask);
const group = document.createElement("div");
group.className = "orb__words";
const rule = document.createElement("span");
rule.className = "orb__rule";
line.append(mode, group, rule);
radios(group, (id) => pickSetting(P.speaker === "live" ? "effect" : "voice", id));
mode.addEventListener("click", () => pickSetting("speaker", P.speaker === "live" ? "readback" : "live"));
const placeRule = (instant) => {
const b = group.querySelector(`[data-id="${chosen()}"]`);
if (instant) rule.style.transition = "none";
rule.style.width = `${b.offsetWidth}px`;
rule.style.transform = `translateX(${b.offsetLeft}px)`;
rule.style.transition = "";
modeIn.textContent = `${modeLabel()} ::`;
mode.setAttribute("aria-label", `Speaker: ${modeLabel()}. Switch to ${P.speaker === "live" ? "Read back" : "Live"}`);
group.setAttribute("aria-label", P.speaker === "live" ? "Effect" : "Voice");
group.replaceChildren(...optionsNow().map((o) => optButton(o, "orb__word")));
const words = [modeIn, ...group.querySelectorAll(".orb__m > span")];
drawIn(rule, 120 + words.length * 30);
void dropOut([modeIn, ...group.querySelectorAll(".orb__m > span")]).then(() => {
if (!speakerOn) line.hidden = true;
void dropOut([modeIn, ...group.querySelectorAll(".orb__m > span")]).then(() => {
const words = [modeIn, ...group.querySelectorAll(".orb__m > span")];
drawIn(rule, 120 + words.length * 30);
const desk = document.createElement("div");
desk.className = "orb__desk";
const section = (title) => {
const g = document.createElement("div");
g.className = "orb__sect";
const h = document.createElement("p");
h.className = "orb__head";
const hm = document.createElement("span");
const hi = document.createElement("span");
const list = document.createElement("div");
list.className = "orb__list";
list.setAttribute("aria-label", title);
const tick = document.createElement("span");
tick.className = "orb__tick";
return { g, h: hi, list, tick };
const modeSec = section("Speaker");
const optSec = section("Effect");
radios(modeSec.list, (id) => pickSetting("speaker", id));
radios(optSec.list, (id) => pickSetting(P.speaker === "live" ? "effect" : "voice", id));
const placeTick = (sec, id, instant) => {
const b = sec.list.querySelector(`[data-id="${id}"]`);
if (instant) sec.tick.style.transition = "none";
const x = b.offsetLeft - 24;
const y = b.offsetTop + b.offsetHeight / 2;
sec.tick.style.transform = `translate(${x}px, ${y}px)`;
void sec.tick.offsetWidth;
sec.tick.style.transition = "";
for (const b of [...modeSec.list.querySelectorAll("[role=radio]")]) b.remove();
modeSec.list.append(...SPEAKER_MODES.map((o) => optButton(o, "orb__item")));
mark(modeSec.list, P.speaker);
optSec.h.textContent = P.speaker === "live" ? "Effect" : "Voice";
optSec.list.setAttribute("aria-label", optSec.h.textContent);
for (const b of [...optSec.list.querySelectorAll("[role=radio]")]) b.remove();
optSec.list.append(...optionsNow().map((o) => optButton(o, "orb__item")));
mark(optSec.list, chosen());
const longest = Math.max(EFFECTS.length, VOICES.length);
const rowsNow = optSec.list.querySelectorAll("[role=radio]").length;
const item = optSec.list.querySelector("[role=radio]");
const pitch = item ? item.offsetHeight + 2 : 30;
const full = desk.offsetHeight + (longest - rowsNow) * pitch;
desk.style.top = `${Math.round(horizon - full / 2)}px`;
const all = () => [modeSec.h, ...modeSec.list.querySelectorAll(".orb__m > span"), optSec.h, ...optSec.list.querySelectorAll(".orb__m > span")];
placeTick(modeSec, P.speaker, true);
placeTick(optSec, chosen(), true);
drawIn(modeSec.tick, 160);
drawIn(optSec.tick, 120 + labels.length * 30);
void dropOut(all()).then(() => {
if (!speakerOn) desk.hidden = true;
const old = [optSec.h, ...optSec.list.querySelectorAll(".orb__m > span")];
mark(modeSec.list, P.speaker);
placeTick(modeSec, P.speaker, false);
void dropOut(old).then(() => {
placeTick(optSec, chosen(), true);
const labels = [optSec.h, ...optSec.list.querySelectorAll(".orb__m > span")];
drawIn(optSec.tick, 120 + labels.length * 30);
mark(optSec.list, chosen());
placeTick(optSec, chosen(), false);
const pickSetting = (key, id) => {
if (P.speaker === id) return;
const showChip = (on) => skin.show(on);
placed = { id: skinId, root: skin.root, capOffset: skin.capOffset, place: skin.place?.bind(skin) };
ear.stream.getTracks().forEach((tr) => tr.stop());
const listen = async () => {
setStatus("idle", "Stopped listening");
if (mic.dataset.state === "blocked") return;
mic.setAttribute("aria-pressed", "true");
const stream = await navigator.mediaDevices.getUserMedia({
audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true }
const ctx = new AudioContext({ latencyHint: "interactive" });
const an = ctx.createAnalyser();
an.smoothingTimeConstant = 0.55;
const source = ctx.createMediaStreamSource(stream);
const rack = voiceRack(ctx, source);
rack.setEffect(P.effect);
ear = { ctx, stream, an, time: new Uint8Array(an.fftSize), freq: new Uint8Array(an.frequencyBinCount), rack };
setStatus("listening", "Listening");
const settledAt = performance.now();
if (glyph.settled() || performance.now() - settledAt > 1200) void beginCaptions();
else requestAnimationFrame(begin);
requestAnimationFrame(begin);
setStatus("blocked", "Microphone blocked: allow it in the browser to talk to the orb");
mic.addEventListener("click", listen);
const target = new Float64Array(4);
const drive = new Float64Array(5);
const half = Float64Array.from(BAR_HALF);
const awake = listening || speaking;
if (liveSp.to !== (awake ? 1 : 0)) {
liveSp.to = awake ? 1 : 0;
tune(liveSp, awake ? 40 : 10, awake && !reduced ? 0.55 : 1);
dorm = P.rest * Math.min(1, Math.max(0, 1 - live));
kick = Math.max(0, kick - dt * 5);
const src = speaking && viaKokoro && mouth ? { an: mouth.an, time: mouth.time, freq: mouth.freq, rate: mouth.ctx.sampleRate } : speaking ? null : ear ? { an: ear.an, time: ear.time, freq: ear.freq, rate: ear.ctx.sampleRate } : null;
if (speaking && !viaKokoro) {
target[0] = 0.25 + 0.6 * kick;
src.an.getByteTimeDomainData(src.time);
for (let i = 0; i < src.time.length; i++) sum += ((src.time[i] - 128) / 128) ** 2;
const rms = Math.sqrt(sum / src.time.length);
src.an.getByteFrequencyData(src.freq);
if (ear && src.an === ear.an && ear.rack.guard(ear.freq, dt)) {
caps.say("Feedback :: the speaker turned off, try headphones");
setTimeout(() => caps.quiet(), 3200);
const hz = src.rate / src.an.fftSize;
const avg2 = (lo, hi) => {
for (let i = Math.floor(lo / hz); i <= Math.min(src.freq.length - 1, Math.ceil(hi / hz)); i++) {
target[1] = avg2(80, 350);
target[2] = avg2(350, 2e3);
target[3] = avg2(2e3, 6e3);
for (let i = 0; i < 4; i++) {
peaks[i] = Math.max(v, peaks[i] - dt * 0.04, i === 0 ? 0.02 : 0.08);
const floor = i === 0 ? 6e-3 : 0.04;
target[i] = Math.min(1, Math.max(0, (v - floor) / (peaks[i] - floor)));
const follow = (cur, to) => cur + (to - cur) * (1 - Math.exp(-dt * (to > cur ? 22 : 5)));
const damp = reduced ? 0.4 : 1;
voice = follow(voice, target[0] * damp);
for (let i = 0; i < 3; i++) bands[i] = follow(bands[i], target[i + 1] * damp);
const holding = glyph.settled() && (listening || speaking);
held += ((holding ? 1 : 0) - held) * (1 - Math.exp(-dt * (holding ? 4 : 18)));
drive[0] = drive[4] = bands[2];
for (let i = 0; i < half.length; i++) {
const alive = 0.88 + 0.12 * Math.sin(breath * 3.2 - i * 0.9) + 0.95 * drive[i];
half[i] = Math.min(9.5, BAR_HALF[i] * (1 + held * (alive - 1)));
const ptr = { x: 0, y: 0, in: false, downX: 0, downY: 0 };
const tilt = [spring(22, 2 * 0.9 * Math.sqrt(22)), spring(22, 2 * 0.9 * Math.sqrt(22))];
const onPointer = (e) => {
if (e.pointerType !== "mouse") {
if (e.type === "pointerdown") return void (ptr.downX = e.clientX, ptr.downY = e.clientY);
if (e.target?.closest?.("button")) return;
if (!ptr.in && Math.hypot(e.clientX - ptr.downX, e.clientY - ptr.downY) < 10) return;
const onPointerOut = (e) => {
if (e.type === "pointerup" && e.pointerType === "mouse") return;
wrap.addEventListener("pointermove", onPointer);
wrap.addEventListener("pointerdown", onPointer);
for (const t of ["pointerleave", "pointercancel", "pointerup"]) wrap.addEventListener(t, onPointerOut);
const stepTilt = (dt, cy) => {
const b = wrap.getBoundingClientRect();
const short = Math.min(b.width, b.height) || 1;
const up2 = Math.max(-1, Math.min(1, (cy - (ptr.y - b.top)) / short / 0.4));
const side = Math.max(-1, Math.min(1, (ptr.x - b.left - b.width / 2) / short / 0.55));
tilt[0].to = (up2 > 0 ? 4 : 2) * up2;
} else tilt[0].to = tilt[1].to = 0;
if (sp.x === sp.to && sp.v === 0) continue;
if (Math.abs(sp.to - sp.x) < 1e-4 && Math.abs(sp.v) < 1e-3) Object.assign(sp, { x: sp.to, v: 0 });
const glassTo = () => P.heart === "glass" || P.heart === "silver" && listening ? 1 : 0;
const glassAmt = spring(9, 2 * Math.sqrt(9));
glassAmt.x = glassAmt.to = glassTo();
const stepGlass = (dt) => {
if (reduced) return void (glassAmt.x = glassAmt.to);
if (glassAmt.x !== glassAmt.to || glassAmt.v !== 0) stepFree(glassAmt, dt);
let last = performance.now();
raf = requestAnimationFrame(frame);
const dt = Math.min(ms / 1e3, 0.1);
time += dt * P.speed * (1 + 0.5 * live + 0.6 * voice) * (1 - 0.75 * dorm) * (1 + 0.35 * mood.energy * mood.amt) * (reduced ? 0.15 : 1);
if (ms < 200) avg += (ms - avg) * 0.08;
over = avg > 18.2 ? over + 1 : 0;
if (undo && --undo.after <= 0) {
if (avg > undo.avg * 0.9) {
if (governing && !undo && over > 30) {
const k = Math.min(0.92, Math.max(0.6, Math.sqrt(14 / avg)));
const was = { scale, dprCap, avg, after: 60 };
if (hdr && scale > 0.35) {
scale = Math.max(0.35, scale * k);
} else if (!hdr && dprCap > 1) {
dprCap = Math.max(1, Math.min(dprCap, window.devicePixelRatio || 1) * k);
const L = layout(wrap.clientWidth, wrap.clientHeight || 1);
if (raf || !onScreen || document.hidden) return;
last = performance.now();
raf = requestAnimationFrame(frame);
cancelAnimationFrame(raf);
const io = new IntersectionObserver(([e]) => {
onScreen = e.isIntersecting;
const onVis = () => document.hidden ? stop() : run();
document.addEventListener("visibilitychange", onVis);
const set = (key, value) => {
if (key === "heart" && HEARTS.includes(value)) P.heart = value;
else if (key === "voice" && VOICES.some((v) => v.id === value)) pickSetting("voice", value);
else if (key === "effect" && EFFECTS.some((e) => e.id === value)) pickSetting("effect", value);
else if (key === "speaker" && SPEAKER_MODES.some((m) => m.id === value)) pickSetting("speaker", value);
else if (key in P && typeof value === "number") P[key] = value;
if (key === "size") placeMic();
spk.removeEventListener("click", toggleSpeaker);
mic.removeEventListener("click", listen);
wrap.removeEventListener("pointermove", onPointer);
wrap.removeEventListener("pointerdown", onPointer);
for (const t of ["pointerleave", "pointercancel", "pointerup"]) wrap.removeEventListener(t, onPointerOut);
document.removeEventListener("visibilitychange", onVis);
gl.getExtension("WEBGL_lose_context")?.loseContext();
experiment.mount(document.querySelector(".stage"));