// 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