// Speaking Orb / Ship Notes. Signal Orb plus a voice: the sphere talks and live captions follow the words.
// No dependencies, no network. Mic and speech synthesis only when you call listen() / say().
(() => {
if (customElements.get('speaking-orb')) return;
const names = ['listening', 'thinking', 'searching', 'speaking', 'done'];
const colors = [[255,185,105],[178,151,255],[97,219,249],[255,150,214],[136,239,194]];
const SPK = 3;
const tau = Math.PI * 2;
const cache = new Map();
const seedsFor = count => {
// a dense Fibonacci sphere shows moire stripes, so past 1000 points each one gets a small fixed nudge
const jitter = count > 1000 ? (1 - 1000 / count) * 1.6 / Math.sqrt(count) : 0;
const rnd = n => { const x = Math.sin(n * 12.9898) * 43758.5453; return x - Math.floor(x); };
if (!cache.has(count)) cache.set(count, Array.from({length:count}, (_,i) => {
const y0 = 1 - 2 * (i + .5) / count, a = i * 2.399963229728653 + (rnd(i) - .5) * jitter * 6;
const y = Math.max(-1, Math.min(1, y0 + (rnd(i + .37) - .5) * jitter));
return {x:Math.cos(a)*Math.sqrt(1-y*y), y, z:Math.sin(a)*Math.sqrt(1-y*y), a, u:i/count, sa:Math.sin(a), ca:Math.cos(a)};
}));
return cache.get(count);
};
const bandTilt = [[.32,0],[-.72,1.02],[1.08,-.82]].map(([tilt,rot]) => [Math.cos(tilt+1),Math.sin(tilt+1),Math.cos(rot),Math.sin(rot)]);
const autoCount = () => matchMedia('(pointer: coarse)').matches ? 6000 : 12000;
const clamp = (v, a = 0, b = 1) => Math.max(a, Math.min(b, v));
const ease = p => p * p * (3 - 2 * p);
// rough syllables, only to fake a voice envelope when the browser gives us no audio
const syl = w => Math.max(1, (w.toLowerCase().replace(/[^a-z]/g, '').replace(/e$/, '').match(/[aeiouy]+/g) || []).length);
const pulseEnv = (words, t) => {
let e = 0;
for (const w of words) {
if (t < w.start - .05 || t > w.end + .15) continue;
const n = syl(w.w), len = Math.max(.08, w.end - w.start), k = clamp((t - w.start) / len) * n;
const within = k - Math.floor(k);
e = Math.max(e, (.55 + .45 * Math.sin(Math.PI * clamp(within * 1.15))) * clamp((t - w.start + .05) / .05) * clamp((w.end + .15 - t) / .15));
}
return e;
};
// the default system voice is often the robotic one; prefer the neural/natural voices most browsers ship
const bestVoice = () => {
const vs = (speechSynthesis.getVoices() || []).filter(v => /^en/i.test(v.lang));
const score = v => (/natural|neural|online/i.test(v.name) ? 4 : 0) + (/google/i.test(v.name) ? 3 : 0)
+ (/ava|aria|jenny|samantha|emma|sonia|libby|daniel|serena/i.test(v.name) ? 2 : 0) + (/en-US|en-GB/i.test(v.lang) ? 1 : 0) - (v.localService === false ? 0 : .5);
return vs.sort((a, b) => score(b) - score(a))[0] || null;
};
if ('speechSynthesis' in window) speechSynthesis.getVoices();
// captions: phrases split at sentence ends and every ~7 words, like real subtitles
const groupsOf = words => {
const out = []; let cur = [];
words.forEach((w, i) => { cur.push(i); if (/[.!?]$/.test(w.w) || cur.length >= 4 && /[,;:]$/.test(w.w) || cur.length >= 8) { out.push(cur); cur = []; } });
if (cur.length) out.push(cur);
return out;
};
const VS = `attribute vec2 p;attribute vec3 d;uniform vec2 res;varying float a;varying float s;
void main(){float size=max(d.x,2.0);float k=min(1.0,d.x*d.x/(size*size));a=d.y*k;s=d.z*k;
gl_Position=vec4(p/res*2.0-1.0,0.0,1.0)*vec4(1,-1,1,1);gl_PointSize=size;}`;
const FS = `precision mediump float;uniform vec3 col;varying float a;varying float s;
void main(){float r=length(gl_PointCoord-.5)*2.0;if(r>1.0)discard;
float dot=mix(1.0,.065,smoothstep(.17,.23,r))*(1.0-smoothstep(.9,1.0,r));
float sp=mix(1.0,.065,smoothstep(.08,.12,r))*(1.0-smoothstep(.17,.2,r));
gl_FragColor=vec4(col*dot*a+vec3(sp*s),min(1.0,dot*a+sp*s));}`;
const setupGL = canvas => {
const gl = canvas.getContext('webgl', {premultipliedAlpha:true, preserveDrawingBuffer:true, antialias:false});
if (!gl) return null;
const sh = (type, src) => { const s = gl.createShader(type); gl.shaderSource(s, src); gl.compileShader(s); return s; };
const prog = gl.createProgram();
gl.attachShader(prog, sh(gl.VERTEX_SHADER, VS)); gl.attachShader(prog, sh(gl.FRAGMENT_SHADER, FS)); gl.linkProgram(prog);
if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) return null;
gl.useProgram(prog);
const buf = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, buf);
const lp = gl.getAttribLocation(prog, 'p'), ld = gl.getAttribLocation(prog, 'd');
gl.enableVertexAttribArray(lp); gl.enableVertexAttribArray(ld);
gl.vertexAttribPointer(lp, 2, gl.FLOAT, false, 20, 0); gl.vertexAttribPointer(ld, 3, gl.FLOAT, false, 20, 8);
gl.enable(gl.BLEND); gl.blendFunc(gl.ONE, gl.ONE);
return {gl, res:gl.getUniformLocation(prog, 'res'), col:gl.getUniformLocation(prog, 'col')};
};
const sprite = (rgb, core) => {
const c = document.createElement('canvas'); c.width = c.height = 64;
const g = c.getContext('2d'), grad = g.createRadialGradient(32,32,0,32,32,32);
grad.addColorStop(0, `rgba(${rgb},1)`); grad.addColorStop(core, `rgba(${rgb},1)`);
grad.addColorStop(core + .03, `rgba(${rgb},.065)`); grad.addColorStop(.94, `rgba(${rgb},.065)`); grad.addColorStop(1, `rgba(${rgb},0)`);
g.fillStyle = grad; g.fillRect(0,0,64,64);
return c;
};
const CSS = `:host{display:block;width:100%;position:relative;contain:layout paint;--cap:#fff;--cap-dim:rgba(255,255,255,.52)}
.orb{position:relative;width:100%;aspect-ratio:1}
canvas{position:absolute;inset:0;width:100%;height:100%;display:block}
.cap{position:relative;margin:-4% auto 0;width:var(--cap-w,92%);min-height:2.7em;text-align:center;font:600 var(--cap-size,24px)/1.3 var(--cap-font,ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,sans-serif);letter-spacing:-.01em;color:var(--cap);text-wrap:balance}
.cap.user{--cap:#ffd6a8;font-weight:500}
.cap span{display:inline-block;white-space:pre;will-change:opacity,transform}
canvas.fx{pointer-events:none}`;
class SpeakingOrb extends HTMLElement {
static get observedAttributes() { return ['state','level','particles']; }
connectedCallback() {
if (!this.shadowRoot) {
this.attachShadow({mode:'open'}).innerHTML = `
`;
const [halo, dots] = this.shadowRoot.querySelectorAll('canvas');
this.canvas=halo; this.ctx=halo.getContext('2d'); this.dots=dots; this.capEl=this.shadowRoot.querySelector('.cap');this.fx=this.shadowRoot.querySelector('.fx');this.fctx=this.fx.getContext('2d');
this.gl=setupGL(dots); if(!this.gl) this.dctx=dots.getContext('2d');
}
this.media=matchMedia('(prefers-reduced-motion: reduce)');
this.weights=names.map(n=>n===this.state?1:0); this.time=0; this.last=0; this.slow=0; this.env=0; this.onsets=[]; this.line=null;
this.resize=()=>{const w=this.canvas.parentNode.getBoundingClientRect().width,size=Math.max(1,Math.round(w*Math.min(devicePixelRatio||1,2)));
this.canvas.width=this.canvas.height=this.dots.width=this.dots.height=size;this.style.setProperty('--cap-size',Math.round(clamp(w*.066,18,34))+'px');
const hr=this.getBoundingClientRect(),k=Math.min(devicePixelRatio||1,2);this.fx.width=Math.round(hr.width*k);this.fx.height=Math.round(hr.height*k);this.glyphCache=new Map();this.draw(this.time);};
this.observer=new ResizeObserver(this.resize);this.observer.observe(this);this.resize();
this.onMotion=()=>{cancelAnimationFrame(this.frame);this.last=0;this.start();};
this.media.addEventListener('change',this.onMotion);
this.setAttribute('role','img');this.setAttribute('aria-label',`Assistant ${this.state}`);
this.start();
}
disconnectedCallback(){cancelAnimationFrame(this.frame);this.observer?.disconnect();this.media?.removeEventListener('change',this.onMotion);this.stopMic?.();}
start(){if(!this.hasAttribute('recording'))this.frame=requestAnimationFrame(this.tick);}
get state(){const v=this.getAttribute('state');return names.includes(v)?v:'listening';}
set state(v){this.setAttribute('state',v);}
get level(){const n=Number(this.getAttribute('level')??'.5');return Number.isFinite(n)?clamp(n):.5;}
get particles(){const n=parseInt(this.getAttribute('particles'),10);return Number.isFinite(n)?clamp(n,200,20000):(this.auto??=this.gl?autoCount():1000);}
attributeChangedCallback(){if(this.ctx)this.setAttribute('aria-label',`Assistant ${this.state}`);}
// say(text) speaks with the browser voice; say(text, {words, audio}) follows your own audio.
// words: [{w, start, end}] in seconds from the audio start. audio: an