ConversationVoice orb
Built by DesignMe, free to use under the MIT licence.
Expert design and dev support for startups and enterprises.
Book a callVoice orb
A hands-free voice chat, with an orb that shows who is talking. Free and MIT licensed, as HTML or a typed React component, in two styles with light and dark mode.
Talk to the agent about Pricing v4
You're muted. Unmute so the agent can hear you.
Install
Add it to your project with the CLI.
HTML, CSS and JavaScript
npx designme add voice-orbReact
npx designme add voice-orb --reactThe CLI copies the source into your project, so you own it and can change anything. The HTML version lands in ./designme with the shared core and a demo.html. The React version lands in src/components/designme and needs React 19; it works in Next.js, Vite and Astro.
About
What it is for and what it does.
Voice orb is the screen for a hands-free voice chat with the agent. An orb in the middle swells with whoever is talking, in the accent colour while it listens to you and violet while the agent speaks, with what was said written under it word by word. Below sit mute and end, and someone who talks while muted is told so, with a button to unmute.
- When people talk to the agent instead of typing, and need to see that it is listening.
- When it should be clear at a glance whose turn it is: yours, the agent's, or the agent thinking.
- When people mute often, and should find out straight away when they talk into a muted microphone.
- When the chat should end with a record of it, like a saved transcript.
statemoves throughidle,listening,thinking,speakingandended. The status line, its dot and the orb's colour follow it.levelfrom 0 to 1 sets how far the orb swells, how fast the light inside it flows, how bright its heart glows, how much its rim wobbles and how much its highlight shimmers. Feed it from your audio, or from a made-up curve as the demo does.- The orb is a glass sphere lit from the top left, with a darker side, a bright edge and a small sharp highlight. Inside it, three layers of light in the colours of whoever is talking, and a third hue, flow at different speeds and shift against each other as if in a volume. A small WebGL shader draws it, once a frame. Nothing reaches past its edge, so it needs no room around it. Without WebGL a 2D canvas draws a simpler sphere, with reduced motion it holds still, and off screen it stops drawing.
captionshows what was said last and who said it, each new word fading in.muted/onMutedChangedrive the mute button. While muted, listening shows as Muted and a slash crosses the microphone.mutedNoticefolds open "You're muted" with an Unmute button, for when your code hears someone talking while muted.- The timer shows
elapsedin minutes and seconds. Onceended, the buttons give way to a row with the length, and Start again when you passonRestart.
- The orb is decorative and hidden from screen readers; the status line names the state in words.
- The caption is an
aria-live="polite"region, so what is said is read out. - The mute button is labelled "Mute" and uses
aria-pressedto say whether the microphone is off.
Usage
Import it and drive it from your own state.
Import
import { VoiceOrb } from '@/components/designme/voice-orb/VoiceOrb';Example
The demo above as a React component, 85 lines you can copy.
'use client';
// The animated preview from designme.dev, rebuilt from the React component: an example of driving it from your own state.
import { useEffect, useRef, useState } from 'react';
import { VoiceOrb, type VoiceState } from './VoiceOrb';
import { useReducedMotion } from '../lib/hooks';
import { useStory, useWait } from '../lib/useScript';
/* ---------- Voice orb: start, mute while the agent answers, talk while muted, unmute and go on, then end ---------- */
// what is said, turn by turn. The demo plays it as a script: it never uses the microphone or makes a sound
const TURNS: [speaker: string, line: string][] = [
['You', 'Make the yearly price the default on the pricing page.'],
['Agent', 'Done. Yearly is now the default on all three plans in Pricing v4.'],
['You', 'Show the yearly saving in green too.'],
['Agent', 'Added. The saving shows in green under each yearly price.']
];
export default function VoiceOrbDemo() {
const ref = useRef<HTMLDivElement>(null);
const wait = useWait();
const [state, setState] = useState<VoiceState>('idle');
const [muted, setMuted] = useState(false);
const [notice, setNotice] = useState(false);
const [caption, setCaption] = useState<{ speaker: string; text: string; id: string }>();
const [secs, setSecs] = useState(0);
const [level, setLevel] = useState(0);
const run = useRef(0), talking = useRef(false), mutedNow = useRef(false), lv = useRef(0);
const reduce = useReducedMotion();
mutedNow.current = muted;
// a made-up level: a voice-like wobble while someone talks, a faint idle while it listens
useEffect(() => {
let raf = 0;
const tick = (t: number) => {
const to = talking.current ? .5 + .28 * Math.sin(t / 90) * Math.sin(t / 233) + .16 * Math.sin(t / 57) : state === 'listening' && !muted ? .05 + .03 * Math.sin(t / 420) : 0;
lv.current += (Math.max(0, to) - lv.current) * .18; setLevel(lv.current); raf = requestAnimationFrame(tick);
};
// with reduced motion the level holds still; the setting is followed as it changes
if (!reduce) raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
}, [state, muted, reduce]);
// the clock runs from start to end, whatever happens between
const live = state !== 'idle' && state !== 'ended';
useEffect(() => {
if (!live) return;
const t = setInterval(() => setSecs(s => s + 1), 1000);
return () => clearInterval(t);
}, [live]);
const say = async (speaker: string, line: string, ms: number, id: number, n: number) => {
const words = line.split(' '); talking.current = true;
for (let k = 1; k <= words.length; k++) { if (id !== run.current) return; setCaption({ speaker, text: words.slice(0, k).join(' '), id: String(n) }); await wait(ms); }
talking.current = false;
};
const start = async () => {
const id = ++run.current; setSecs(0); setCaption(undefined);
for (let n = 0; n < TURNS.length; n++) {
const [speaker, line] = TURNS[n];
if (speaker === 'You') {
setState('listening'); await wait(1200); if (id !== run.current) return;
// they start talking while muted: say so, and wait for them to unmute
if (mutedNow.current) { setNotice(true); while (mutedNow.current) { await wait(100); if (id !== run.current) return; } setNotice(false); await wait(400); }
await say(speaker, line, 230, id, n); if (id !== run.current) return;
setState('thinking'); await wait(1300);
} else { setState('speaking'); await say(speaker, line, 190, id, n); if (id !== run.current) return; setState('listening'); }
}
};
const end = () => { run.current++; talking.current = false; setNotice(false); setState('ended'); };
// the preview's story, on the same beats as the site's
useStory(ref, async ({ wait, press }) => {
await wait(2100); press('.dm-vor-go');
await wait(7000); press('.dm-vor-mute');
await wait(4200); press('.dm-vor-mw .dm-pill');
await wait(6700); press('.dm-vor-end');
});
return (
<div ref={ref} style={{ width: '100%' }}>
<VoiceOrb state={state} level={level} muted={muted} onMutedChange={m => { setMuted(m); if (!m) setNotice(false); }} mutedNotice={notice}
caption={caption} hint="Talk to the agent about Pricing v4" elapsed={secs}
onStart={() => void start()} onEnd={end} onRestart={() => { setMuted(false); void start(); }} />
</div>
);
}
Props
Everything VoiceOrb takes.
| Prop | Type | What it does |
|---|---|---|
staterequired | VoiceState | |
level | number | How loud whoever is talking is, 0 to 1. The orb swells with it. Feed it from your audio, or from a made-up curve |
muted | boolean | Your microphone is off. Listening shows as Muted, and the mute button is pressed |
onMutedChange | (muted: boolean) => void | |
mutedNotice | boolean | Shows "You're muted" with an Unmute button, for when someone talks while muted |
caption | { speaker: string; text: string; id?: string } | What was said last and who said it. Each word fades in as it arrives; a new `id` (or speaker) starts a new line |
hint | string | Shown before anything is said, like "Talk to the agent about Pricing v4" |
elapsed | number | Seconds since the chat started, shown top right |
onStart | () => void | |
onEnd | () => void | |
onRestart | () => void | Start again, from the row that shows once the chat has ended |
endedDetail | string | The quiet line under "Voice chat ended". Defaults to the length and "Transcript saved" |
labels | { ready?: string; listening?: string; thinking?: string; speaking?: string; muted?: string; ended?: string; start?: string; end?: string; mute?: string; unmute?: string; mutedNotice?: string; endedTitle?: string; restart?: string } | |
className | string |
Code
The full source, as the CLI installs it.
VoiceOrb.tsxReact · 247 lines
'use client';
import { useEffect, useRef, useState, type CSSProperties } from 'react';
import { cx } from '../lib/cx';
import { Icon } from '../lib/icons';
import { useReducedMotion } from '../lib/hooks';
import { Fold, Swap, Tip } from '../lib/motion';
import '../designme.css';
import './voice-orb.css';
/** idle (not started), listening, thinking, speaking (the agent) or ended */
export type VoiceState = 'idle' | 'listening' | 'thinking' | 'speaking' | 'ended';
export interface VoiceOrbProps {
state: VoiceState;
/** How loud whoever is talking is, 0 to 1. The orb swells with it. Feed it from your audio, or from a made-up curve */
level?: number;
/** Your microphone is off. Listening shows as Muted, and the mute button is pressed */
muted?: boolean;
onMutedChange?: (muted: boolean) => void;
/** Shows "You're muted" with an Unmute button, for when someone talks while muted */
mutedNotice?: boolean;
/** What was said last and who said it. Each word fades in as it arrives; a new `id` (or speaker) starts a new line */
caption?: { speaker: string; text: string; id?: string };
/** Shown before anything is said, like "Talk to the agent about Pricing v4" */
hint?: string;
/** Seconds since the chat started, shown top right */
elapsed?: number;
onStart?: () => void;
onEnd?: () => void;
/** Start again, from the row that shows once the chat has ended */
onRestart?: () => void;
/** The quiet line under "Voice chat ended". Defaults to the length and "Transcript saved" */
endedDetail?: string;
labels?: { ready?: string; listening?: string; thinking?: string; speaking?: string; muted?: string; ended?: string; start?: string; end?: string; mute?: string; unmute?: string; mutedNotice?: string; endedTitle?: string; restart?: string };
className?: string;
}
const fmt = (s: number) => `${Math.floor(s / 60)}:${String(Math.floor(s) % 60).padStart(2, '0')}`;
/** A hands-free voice chat: an orb that swells with whoever is talking, the accent colour for you and violet for the agent, what was
said under it, and mute and end below. It draws what you pass; it never opens the microphone itself. */
export function VoiceOrb({
state, level = 0, muted = false, onMutedChange, mutedNotice = false, caption, hint, elapsed = 0, onStart, onEnd, onRestart, endedDetail, labels = {}, className
}: VoiceOrbProps) {
const L = { ready: 'Ready', listening: 'Listening', thinking: 'Thinking', speaking: 'Speaking', muted: 'Muted', ended: 'Ended', start: 'Start talking', end: 'End', mute: 'Mute', unmute: 'Unmute', mutedNotice: "You're muted. Unmute so the agent can hear you.", endedTitle: 'Voice chat ended', restart: 'Start again', ...labels };
// muted only changes how listening looks: the agent can still think and speak
const m = muted && state === 'listening' ? 'muted' : state;
const status = { idle: L.ready, listening: L.listening, thinking: L.thinking, speaking: L.speaking, muted: L.muted, ended: L.ended }[m];
const on = state !== 'idle' && state !== 'ended';
return (
<div data-dm="" className={className} style={{ width: '100%' }}>
<div className={cx('dm-vor dm-panel', on && 'dm-on')} data-m={m}>
<div className="dm-vor-top"><span className="dm-vor-dot" /><Swap className="dm-vor-st">{status}</Swap><span className="dm-vor-t dm-num dm-muted">{fmt(elapsed)}</span></div>
<div className="dm-vor-orb" aria-hidden="true" style={{ '--dm-lv': Math.max(0, Math.min(1, level)).toFixed(3) } as CSSProperties}><i className="dm-vor-pc" /><i className="dm-vor-core"><OrbLight level={level} /></i></div>
<div className="dm-vor-cap" aria-live="polite">
<Swap className="dm-vor-who dm-muted">{caption?.speaker ?? ''}</Swap>
{caption
? <p className="dm-vor-tx" key={caption.id ?? caption.speaker}>{caption.text.split(' ').filter(Boolean).map((w, i) => <span key={i} className="dm-w">{w} </span>)}</p>
: <p className="dm-vor-tx dm-hint">{hint}</p>}
</div>
<Fold open={mutedNotice && muted && on} className="dm-vor-mf">
<div className="dm-vor-mw"><p><Icon name="alert" size="xs" /><span>{L.mutedNotice}</span></p><button type="button" className="dm-pill dm-sm" onClick={() => onMutedChange?.(false)}>{L.unmute}</button></div>
</Fold>
{state === 'ended' ? (
<div className="dm-vor-done dm-enter">
<Icon name="ok" />
<span className="dm-t">{L.endedTitle}<span className="dm-muted">{endedDetail ?? `${fmt(elapsed)} · Transcript saved`}</span></span>
{onRestart && <button type="button" className="dm-pill dm-sm" onClick={onRestart}>{L.restart}</button>}
</div>
) : (
<div className="dm-vor-f">
<button type="button" className="dm-pill dm-primary dm-sm dm-vor-go" onClick={onStart}><Icon name="mic" size="xs" />{L.start}</button>
<Tip label={muted ? L.unmute : L.mute}><button type="button" className="dm-ib dm-fill dm-vor-mute" aria-label={L.mute} aria-pressed={muted} onClick={() => onMutedChange?.(!muted)}><Icon name="mic" /></button></Tip>
<span className="dm-sp" />
<button type="button" className="dm-pill dm-sm dm-vor-end" onClick={onEnd}>{L.end}</button>
</div>
)}
</div>
</div>
);
}
/** The lit glass sphere, with light flowing inside it in the colours of whoever is talking, faster and brighter as they get
louder. Where no canvas can draw the core keeps its plain gradient; with reduced motion it is one still frame, drawn again
when the speaker changes */
function OrbLight({ level }: { level: number }) {
const ref = useRef<HTMLCanvasElement>(null), lv = useRef(level), reduce = useReducedMotion();
const [on, setOn] = useState(false);
lv.current = Math.max(0, Math.min(1, level));
useEffect(() => {
const cv = ref.current, box = cv?.closest<HTMLElement>('.dm-vor'); if (!cv || !box) return;
const s = orbShader(cv); if (!s) return;
setOn(true);
const dot = box.querySelector('.dm-vor-dot')!, pc = box.querySelector('.dm-vor-pc')!;
// the two colours are read a few times a second, not every frame: reading them makes the browser recheck the page's styles
let cols: [string, string] | null = null, colsAt = -1e9;
const draw = (ms: number) => { const now = performance.now(); if (!cols || now - colsAt > 250) { cols = [getComputedStyle(dot).backgroundColor, getComputedStyle(pc).backgroundColor]; colsAt = now; } s.paint(ms, lv.current, cols[0], cols[1]); };
let raf = 0, seen = true, flow = 0, last = 0, dead = false;
const io = new IntersectionObserver(e => { seen = e[0].isIntersecting; }); io.observe(cv);
const mo = new MutationObserver(() => setTimeout(() => { if (!dead) { cols = null; draw(flow); } }, 450));
if (reduce) { draw(0); mo.observe(box, { attributes: true, attributeFilter: ['data-m'] }); }
else {
const tick = (t: number) => { flow += Math.min(50, t - (last || t)) * (1 + lv.current * 2.5); last = t; if (seen) draw(flow); raf = requestAnimationFrame(tick); };
draw(0); raf = requestAnimationFrame(tick);
}
// the context is let go once the canvas has left the page, not when an effect merely runs again on the same canvas
return () => { dead = true; cancelAnimationFrame(raf); io.disconnect(); mo.disconnect(); setTimeout(() => { if (!cv.isConnected) s.lose(); }); };
}, [reduce]);
return <canvas ref={ref} className={cx('dm-vor-gl', on && 'dm-on')} />;
}
// the glass sphere, as a fragment shader: the site's own source, copied in by scripts/sync.mjs (edit soft-2/index.html, then
// sync). It is lit as a ball: normals from the sphere, a soft key light from the top left with a deeper, shifted hue past the
// terminator toward the bottom right, light gathered through the glass on the far side, a fresnel edge, a broad sheen and a
// small sharp highlight. Inside, the view bends as it enters the glass and the light is read at three depths along it: deep
// pools of the two colours and a third hue swirl slowly, liquid light flows through the middle and two thin ribbons turn the
// other way near the surface. The level speeds the flow, brightens the heart, makes the rim wobble a little and the highlight
// shimmer. At its widest the sphere still sits inside the canvas, and nothing is drawn outside it
// <orb-frag>
const FRAG = `#ifdef GL_FRAGMENT_PRECISION_HIGH
precision highp float;
#else
precision mediump float;
#endif
uniform vec2 R;uniform float T,L;uniform vec3 A0,B0;
float h(vec2 p){return fract(sin(dot(p,vec2(127.1,311.7)))*43758.5453);}
float n(vec2 p){vec2 i=floor(p),f=fract(p);f=f*f*(3.-2.*f);return mix(mix(h(i),h(i+vec2(1,0)),f.x),mix(h(i+vec2(0,1)),h(i+1.),f.x),f.y);}
float fbm(vec2 p){float v=0.,a=.5;for(int i=0;i<4;i++){v+=a*n(p);p=mat2(1.6,1.2,-1.2,1.6)*p+vec2(1.7,9.2);a*=.5;}return v;}
mat2 rot(float a){float c=cos(a),s=sin(a);return mat2(c,s,-s,c);}
vec3 tn(vec3 c){float mx=max(c.r,max(c.g,c.b)),d=mx-min(c.r,min(c.g,c.b));c*=max(1.,.5/max(mx,.01));mx=max(mx,.5);d=mx-min(c.r,min(c.g,c.b));return max(mx-(mx-c)*mix(1.,clamp(.55*mx/max(d,.001),1.,3.),smoothstep(.03,.12,d)),0.);}
vec3 hue(vec3 c,float a){vec3 k=vec3(.57735);float co=cos(a);return c*co+cross(k,c)*sin(a)+k*dot(k,c)*(1.-co);}
void main(){
vec3 A=tn(A0),B=tn(B0),C=hue(A,mix(-.9,1.4,smoothstep(-.1,.1,A.b-A.g)));
vec2 u=(gl_FragCoord.xy*2.-R)/R.y;
float t=T*.001,an=atan(u.y,u.x);
float w=(.002+.008*L)*(sin(3.*an+t*1.9)+.6*sin(5.*an-t*2.7)+.4*sin(2.*an-t*1.1));
float rad=.955*(1.+.008*sin(t*.9)+w);
vec2 q=u/rad;float r=length(q),e=3./(R.y*rad);
float a=1.-smoothstep(1.-e,1.,r);
if(a<=0.){gl_FragColor=vec4(0.);return;}
float z=sqrt(max(1.-r*r,0.));
vec3 N=vec3(q,z);
N.xy+=(vec2(n(q*5.+t*1.6),n(q*5.-t*1.3+7.))-.5)*(.03+.12*L);N=normalize(N);
vec3 S=vec3(q,z),D=refract(vec3(0,0,-1),N,.72),P1=S+D*z*.35,P2=S+D*z*.9,P3=S+D*z*1.5;
float s=t*.22;
vec3 d1=P3-vec3(.55*sin(s*1.3),.5*cos(s*1.1),.3*sin(s*.7)),d2=P3-vec3(.55*cos(s*.9+2.),.5*sin(s*1.2+1.),.3*cos(s*.8+3.)),d3=P3-vec3(.5*sin(s*1.6+4.),.5*cos(s*1.4+5.),.3*sin(s+1.));
float g1=exp(-dot(d1,d1)*2.6),g2=exp(-dot(d2,d2)*2.6),g3=exp(-dot(d3,d3)*3.2);
vec3 col=mix(mix(mix(mix(A,B,.4)*.55,B,g2*.8),C,g3*.7),A,g1*.95);
vec2 m=rot(t*.09)*P2.xy*1.5,o=vec2(fbm(m+vec2(0.,t*.3)),fbm(m+vec2(5.2,1.3)-t*.25));
float f=fbm(m+(o-.5)*(2.5+2.*L)+vec2(t*.12,-t*.08));
col=mix(col,mix(mix(A,B,.5),vec3(1.),.15),smoothstep(.45,.8,f)*.5)*(.8+.35*smoothstep(.2,.6,f));
vec2 k=rot(-t*.33)*P1.xy;
float b1=k.y+.32*sin(k.x*2.4+t*1.4)+.12*sin(k.x*5.3-t*2.2)+(o.x-.5)*.5,b2=k.x-.3*sin(k.y*2.1-t*1.1)+.4;
col+=mix(mix(A,C,.35),vec3(1.),.35)*(exp(-b1*b1*40.)+.6*exp(-b2*b2*55.))*(.25+.35*L)*z;
col+=mix(A,vec3(1.),.4)*exp(-dot(P2.xy,P2.xy)*3.)*(.08+.3*L);
vec3 lk=normalize(vec3(-.5,.62,.6)),dk=hue(col,.35);
col=mix(dk*dk*.6,col*1.06,smoothstep(-.55,.75,dot(N,lk)));
col+=mix(B,vec3(1.),.5)*pow(1.-z,1.6)*smoothstep(.1,.95,dot(q/max(r,1e-3),-normalize(lk.xy)))*.55;
col=mix(col,mix(vec3(.06),vec3(1.),smoothstep(-.3,.9,N.y*.7-N.x*.4)),pow(1.-N.z,3.)*.5);
float nh=max(dot(N,normalize(lk+vec3(0,0,1))),0.);
col+=pow(nh,700.)*(1.2+.8*L*sin(t*9.+q.x*8.))+pow(nh,60.)*.25+pow(nh,8.)*.1;
gl_FragColor=vec4(clamp(col,0.,1.)*a,a);
}`;
// </orb-frag>
/** Draws the sphere on the orb's canvas: one draw a frame, at most 2 device pixels a CSS pixel, and nothing made anew each
frame. Where WebGL is missing it is drawn in a few gradients on a 2D canvas instead. Returns paint(ms of flow, level 0 to 1,
colour, second colour) and lose(), or null when neither canvas can draw */
function orbShader(cv: HTMLCanvasElement) {
// a computed colour to 0 to 1 channels. Mid-transition it can come as oklab() or color(), so a 1px canvas reads it. Only a
// colour that changed is read, a few times a second at most
const px = document.createElement('canvas').getContext('2d', { willReadFrequently: true })!; px.canvas.width = px.canvas.height = 1;
const rgb = (s: string, o: Float32Array) => { px.clearRect(0, 0, 1, 1); px.fillStyle = '#000'; px.fillStyle = s; px.fillRect(0, 0, 1, 1); const d = px.getImageData(0, 0, 1, 1).data; o[0] = d[0] / 255; o[1] = d[1] / 255; o[2] = d[2] / 255; };
const ta = new Float32Array(3), tb = new Float32Array(3), ca = new Float32Array(3), cb = new Float32Array(3);
let sa = '', sb = '', w = 0, at = -1e9, prev = -1e9;
// the canvas's size in device pixels, checked a few times a second
const fit = (now: number) => { if (w && now - at < 300) return false; at = now; const n = Math.round(cv.clientWidth * Math.min(2, devicePixelRatio || 1)) || 1; if (n === w) return false; w = cv.width = cv.height = n; return true; };
// a new colour is glided to over a few frames; after a pause (a still frame, or back on screen) it is taken at once
const tint = (a: string, b: string, now: number, snap: boolean) => {
if (a !== sa) { sa = a; rgb(a, ta); } if (b !== sb) { sb = b; rgb(b, tb); }
const k = snap || now - prev > 200 ? 1 : Math.min(1, (now - prev) / 110); prev = now;
for (let i = 0; i < 3; i++) { ca[i] += (ta[i] - ca[i]) * k; cb[i] += (tb[i] - cb[i]) * k; }
};
const gl = cv.getContext('webgl', { premultipliedAlpha: true, antialias: false, powerPreference: 'low-power' });
if (gl) {
const sh = (type: number, src: string) => { const s = gl.createShader(type)!; gl.shaderSource(s, src); gl.compileShader(s); return s; };
const pr = gl.createProgram()!;
gl.attachShader(pr, sh(gl.VERTEX_SHADER, 'attribute vec2 p;void main(){gl_Position=vec4(p,0.,1.);}'));
gl.attachShader(pr, sh(gl.FRAGMENT_SHADER, FRAG));
gl.linkProgram(pr);
const lose = () => { gl.getExtension('WEBGL_lose_context')?.loseContext(); };
if (!gl.getProgramParameter(pr, gl.LINK_STATUS)) { lose(); return null; }
gl.useProgram(pr);
gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer()); gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]), gl.STATIC_DRAW);
const p = gl.getAttribLocation(pr, 'p'); gl.enableVertexAttribArray(p); gl.vertexAttribPointer(p, 2, gl.FLOAT, false, 0, 0);
const U = (k: string) => gl.getUniformLocation(pr, k), uR = U('R'), uT = U('T'), uL = U('L'), uA = U('A0'), uB = U('B0');
return {
paint(ms: number, lv: number, a: string, b: string) {
const now = performance.now(); if (fit(now)) gl.viewport(0, 0, w, w);
tint(a, b, now, false);
gl.uniform2f(uR, w, w); gl.uniform1f(uT, ms); gl.uniform1f(uL, lv); gl.uniform3fv(uA, ca); gl.uniform3fv(uB, cb);
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
},
lose
};
}
const x = cv.getContext('2d'); if (!x) return null;
// the shader's colours: dark ones lifted, pale ones given more chroma, and a third hue turned round the grey axis toward
// the warm side (blue and violet toward magenta, lime toward amber)
const tn = (o: Float32Array, d: Float32Array) => { const lift = Math.max(1, .5 / Math.max(.01, o[0], o[1], o[2])), mx = Math.max(o[0], o[1], o[2]) * lift, dd = mx - Math.min(o[0], o[1], o[2]) * lift, s = Math.min(1, Math.max(0, (dd - .03) / .09)), k = 1 + (Math.min(3, Math.max(1, .55 * mx / Math.max(dd, .001))) - 1) * s * s * (3 - 2 * s); for (let i = 0; i < 3; i++) d[i] = Math.max(0, mx - (mx - o[i] * lift) * k); };
const hue = (o: Float32Array, d: Float32Array, ang: number) => { const co = Math.cos(ang), si = Math.sin(ang) * .57735, m = (o[0] + o[1] + o[2]) / 3 * (1 - co); d[0] = o[0] * co + (o[2] - o[1]) * si + m; d[1] = o[1] * co + (o[0] - o[2]) * si + m; d[2] = o[2] * co + (o[1] - o[0]) * si + m; };
const A = new Float32Array(3), B = new Float32Array(3), C = new Float32Array(3), M = new Float32Array(3);
const css = (o: Float32Array, al: number, f = 1) => `rgba(${[0, 1, 2].map(i => Math.round(Math.min(1, Math.max(0, o[i] * f)) * 255)).join(',')},${al})`;
// every gradient is made for a sphere of radius 1 at the origin, and placed by the transform as it is drawn
const grad = (cx: number, cy: number, r: number, stops: (number | string)[]) => { const g = x.createRadialGradient(cx, cy, 0, cx, cy, r); for (let i = 0; i < stops.length; i += 2) g.addColorStop(stops[i] as number, stops[i + 1] as string); return g; };
const shade = grad(-.35, -.4, 1.6, [0, 'rgba(255,255,255,.16)', .45, 'rgba(0,0,0,0)', 1, 'rgba(0,0,0,.5)']),
rim = grad(0, 0, 1, [.78, 'rgba(255,255,255,0)', 1, 'rgba(255,255,255,.28)']),
sheen = grad(-.32, -.38, .6, [0, 'rgba(255,255,255,.22)', 1, 'rgba(255,255,255,0)']),
spec = grad(-.4, -.46, .14, [0, 'rgba(255,255,255,.95)', 1, 'rgba(255,255,255,0)']);
let g: { base: string; a: CanvasGradient; b: CanvasGradient; c: CanvasGradient; core: CanvasGradient } | null = null, mid = 0, rad = 0;
const put = (fill: string | CanvasGradient, dx: number, dy: number, sz: number) => { x.setTransform(rad * sz, 0, 0, rad * sz, mid + rad * dx, mid + rad * dy); x.fillStyle = fill; x.fillRect(-1, -1, 2, 2); };
return {
paint(ms: number, lv: number, a: string, b: string) {
const now = performance.now(); fit(now);
// the colour gradients are made again only when a colour changes
if (!g || a !== sa || b !== sb) {
tint(a, b, now, true); tn(ca, A); tn(cb, B); hue(A, C, A[2] > A[1] ? 1.4 : -.9);
for (let i = 0; i < 3; i++) M[i] = (A[i] + B[i]) / 2;
g = { base: css(M, 1, .7), a: grad(0, 0, 1, [0, css(A, .95), 1, css(A, 0)]), b: grad(0, 0, 1, [0, css(B, .95), 1, css(B, 0)]), c: grad(0, 0, 1, [0, css(C, .55), 1, css(C, 0)]), core: grad(0, 0, 1, [0, css(A, 1, 1.4), 1, css(A, 0)]) };
}
const t = ms * .001, s = t * .22; mid = w / 2; rad = mid * .955 * (1 + .008 * Math.sin(t * .9));
x.setTransform(1, 0, 0, 1, 0, 0); x.clearRect(0, 0, w, w);
x.save(); x.beginPath(); x.arc(mid, mid, rad, 0, Math.PI * 2); x.clip();
x.fillStyle = g.base; x.fillRect(0, 0, w, w);
put(g.a, .55 * Math.sin(s * 1.3), -.5 * Math.cos(s * 1.1), 1.1);
put(g.b, .55 * Math.cos(s * .9 + 2), -.5 * Math.sin(s * 1.2 + 1), 1.1);
put(g.c, .5 * Math.sin(s * 1.6 + 4), -.5 * Math.cos(s * 1.4 + 5), .9);
x.globalAlpha = .15 + .45 * lv; put(g.core, 0, 0, .7);
x.globalAlpha = 1; put(shade, 0, 0, 1); put(rim, 0, 0, 1); put(sheen, 0, 0, 1);
x.globalAlpha = .85 + .15 * lv * Math.sin(t * 9); put(spec, 0, 0, 1);
x.restore();
},
lose() {}
};
}
voice-orb.cssReact · 61 lines
/* Generated from designme.dev by scripts/sync.mjs. Edit the site, then sync. */
/* ---------- Voice orb ---------- */
/* a hands-free voice chat: an orb that swells with whoever is talking, the accent colour for you and violet for the agent, what
was said under it, and mute and end below. Talking while muted says so, the way calls do */
.dm-vor{position:relative;width:100%;max-width:380px;margin:0 auto;padding:var(--dm-s-4);display:flex;flex-direction:column;gap:var(--dm-s-3);--dm-oc:var(--dm-faint);--dm-oc2:var(--dm-accent)}
/* --oc is whoever is talking, --oc2 the second colour that flows through the orb with it. Before the chat starts the dot is
grey and the orb is a quiet glass with a little of the accent inside it */
/* the colours are mixed in oklch, so the step between them stays bright (lime to violet passes through teal, not grey) */
.dm-vor[data-m="listening"]{--dm-oc:var(--dm-accent);--dm-oc2:color-mix(in oklch,var(--dm-pl) 45%,var(--dm-accent))}
.dm-vor[data-m="speaking"]{--dm-oc:var(--dm-pl);--dm-oc2:color-mix(in oklch,var(--dm-accent) 45%,var(--dm-pl))}
.dm-vor[data-m="thinking"]{--dm-oc:color-mix(in oklch,var(--dm-accent) 50%,var(--dm-pl));--dm-oc2:var(--dm-accent)}
.dm-vor[data-m="muted"]{--dm-oc:color-mix(in srgb,var(--dm-muted) 55%,var(--dm-faint));--dm-oc2:var(--dm-faint)}
.dm-vor-top{display:flex;align-items:center;gap:var(--dm-s-2);font-size:13px}
.dm-vor-dot{width:8px;height:8px;border-radius:50%;background:var(--dm-oc);transition:background .4s}
.dm-vor[data-m="listening"] .dm-vor-dot,.dm-vor[data-m="speaking"] .dm-vor-dot{animation:dm-vor-pulse 1.4s var(--dm-ease-io) infinite}
@keyframes dm-vor-pulse{0%,100%{box-shadow:0 0 0 0 color-mix(in srgb,var(--dm-oc) 35%,transparent)}50%{box-shadow:0 0 0 5px transparent}}
.dm-vor-t{margin-left:auto;font-size:12.5px}
.dm-vor-orb{position:relative;width:112px;height:112px;margin:var(--dm-s-2) auto;--dm-lv:0}
.dm-vor-orb i{position:absolute;inset:0;border-radius:50%}
/* round in Blueprint too, where every other corner is square: like the turning loaders, its shape is what it says */
.dm-vor .dm-vor-orb i{border-radius:50%!important}
/* the sphere: nothing reaches past its edge, no glow or ring, so it never needs room around it. It swells with the level up to
its box and no further, so a pulse is never cut off. Before the canvas draws (or where it can't) it is a plain gradient
circle, lit from the top left and deeper at the foot */
.dm-vor-core{overflow:hidden;background-color:color-mix(in srgb,var(--dm-oc) 80%,var(--dm-surface));background-image:radial-gradient(circle at 34% 28%,rgba(255,255,255,.4),rgba(255,255,255,0) 60%);box-shadow:inset 0 -10px 20px color-mix(in srgb,var(--dm-oc) 55%,#000 10%);transform:scale(calc(.9 + var(--dm-lv) * .1));transition:background-color .4s}
/* the lit glass sphere, drawn on a canvas the size of the orb. Its edge breathes a little inside the box, so the plain
circle behind it goes once it draws */
.dm-vor-gl{position:absolute;inset:0;width:100%;height:100%;border-radius:50%;opacity:0;transition:opacity .4s}
.dm-vor-gl.dm-on{opacity:1}
.dm-vor-core:has(>.dm-vor-gl.dm-on){background:none;box-shadow:none}
/* reads the second colour as it eases from one speaker to the next */
.dm-vor-pc{visibility:hidden;background:var(--dm-oc2);transition:background .4s}
.dm-vor-cap,.dm-vor-mf,.dm-vor-f{position:relative}
.dm-vor[data-m="thinking"] .dm-vor-core{animation:dm-vor-br 1.6s var(--dm-ease-io) infinite alternate}
@keyframes dm-vor-br{from{transform:scale(.9)}to{transform:scale(.96)}}
.dm-vor-cap{min-height:88px;display:flex;flex-direction:column;gap:2px;text-align:center}
.dm-vor-who{font-size:12px;min-height:18px}
.dm-vor-tx{margin:0;font-size:14px;line-height:1.6;color:var(--dm-fg)}
.dm-vor-tx.dm-hint{color:var(--dm-muted);font-size:13px}
.dm-vor-tx .dm-w{animation:dm-vor-w .45s var(--dm-ease) both}
@keyframes dm-vor-w{from{opacity:0;filter:blur(3px)}to{opacity:1;filter:none}}
.dm-vor-mw{display:flex;align-items:center;gap:10px;padding:var(--dm-s-2) var(--dm-s-2) var(--dm-s-2) var(--dm-s-3);border-radius:var(--dm-r-in);background:var(--dm-warn-bg)}
.dm-vor-mw p{margin:0;flex:1;display:flex;gap:var(--dm-s-2);font-size:13px;line-height:1.45;color:var(--dm-fg)}
.dm-vor-mw p svg{color:var(--dm-warn);flex:none;margin-top:3px}
.dm-vor-mw .dm-pill,.dm-vor-done .dm-pill{background:var(--dm-surface);color:var(--dm-fg);box-shadow:var(--dm-shadow)}
.dm-vor-f{display:flex;align-items:center;gap:var(--dm-s-2);min-height:32px}
.dm-vor-f .dm-sp{flex:1}
.dm-vor:not(.dm-on) .dm-vor-f{justify-content:center}
.dm-vor:not(.dm-on) .dm-vor-mute,.dm-vor:not(.dm-on) .dm-vor-end,.dm-vor:not(.dm-on) .dm-vor-f .dm-sp,.dm-vor.dm-on .dm-vor-go{display:none}
.dm-vor-mute{position:relative}
.dm-vor-mute[aria-pressed="true"]{background:var(--dm-bad-bg);color:var(--dm-bad);box-shadow:none}
/* the slash across the microphone once it is muted */
.dm-vor-mute::after{content:"";position:absolute;left:50%;top:50%;width:1.5px;height:18px;margin:-9px 0 0 -.75px;border-radius:2px;background:currentColor;transform:rotate(-45deg) scaleY(0);transition:transform .25s var(--dm-ease)}
.dm-vor-mute[aria-pressed="true"]::after{transform:rotate(-45deg) scaleY(1)}
.dm-vor-end{background:var(--dm-bad);color:var(--dm-on-ink)}
@media (hover:hover) and (pointer:fine){.dm-vor-end:hover{background:color-mix(in srgb,var(--dm-bad) 86%,#000)}}
.dm-vor-done{display:flex;align-items:center;gap:var(--dm-s-3);padding:10px 10px 10px var(--dm-s-3);border-radius:var(--dm-r-in);background:var(--dm-chip);font-size:13.5px}
.dm-vor-done>svg{color:var(--dm-fg2);flex:none}
.dm-vor-done .dm-t{flex:1;min-width:0;display:flex;flex-direction:column}
.dm-vor-done .dm-t span{font-size:13px}
voice-orb.htmlHTML · 17 lines
<div class="card" data-c="voice-orb">
<div class="stage-in">
<div class="vor panel" data-m="idle">
<div class="vor-top"><span class="vor-dot"></span><span class="vor-st">Ready</span><span class="vor-t num muted">0:00</span></div>
<div class="vor-orb" aria-hidden="true"><i class="vor-pc"></i><i class="vor-core"><canvas class="vor-gl"></canvas></i></div>
<div class="vor-cap" aria-live="polite"><span class="vor-who muted"></span><p class="vor-tx hint">Talk to the agent about Pricing v4</p></div>
<div class="fold shut vor-mf"><div><div class="vor-mw"><p><svg class="i xs"><use href="#i-alert"/></svg><span>You're muted. Unmute so the agent can hear you.</span></p><button class="pill sm vor-un">Unmute</button></div></div></div>
<div class="vor-f">
<button class="pill primary sm vor-go"><svg class="i xs"><use href="#i-mic"/></svg>Start talking</button>
<button class="ib fill vor-mute" aria-label="Mute" aria-pressed="false" data-tip="Mute"><svg class="i s"><use href="#i-mic"/></svg></button>
<span class="sp"></span>
<button class="pill sm vor-end">End</button>
</div>
</div>
</div>
</div>
voice-orb.cssHTML · 60 lines
/* ---------- Voice orb ---------- */
/* a hands-free voice chat: an orb that swells with whoever is talking, the accent colour for you and violet for the agent, what
was said under it, and mute and end below. Talking while muted says so, the way calls do */
.vor{position:relative;width:100%;max-width:380px;margin:0 auto;padding:var(--s-4);display:flex;flex-direction:column;gap:var(--s-3);--oc:var(--faint);--oc2:var(--accent)}
/* --oc is whoever is talking, --oc2 the second colour that flows through the orb with it. Before the chat starts the dot is
grey and the orb is a quiet glass with a little of the accent inside it */
/* the colours are mixed in oklch, so the step between them stays bright (lime to violet passes through teal, not grey) */
.vor[data-m="listening"]{--oc:var(--accent);--oc2:color-mix(in oklch,var(--pl) 45%,var(--accent))}
.vor[data-m="speaking"]{--oc:var(--pl);--oc2:color-mix(in oklch,var(--accent) 45%,var(--pl))}
.vor[data-m="thinking"]{--oc:color-mix(in oklch,var(--accent) 50%,var(--pl));--oc2:var(--accent)}
.vor[data-m="muted"]{--oc:color-mix(in srgb,var(--muted) 55%,var(--faint));--oc2:var(--faint)}
.vor-top{display:flex;align-items:center;gap:var(--s-2);font-size:13px}
.vor-dot{width:8px;height:8px;border-radius:50%;background:var(--oc);transition:background .4s}
.vor[data-m="listening"] .vor-dot,.vor[data-m="speaking"] .vor-dot{animation:vor-pulse 1.4s var(--ease-io) infinite}
@keyframes vor-pulse{0%,100%{box-shadow:0 0 0 0 color-mix(in srgb,var(--oc) 35%,transparent)}50%{box-shadow:0 0 0 5px transparent}}
.vor-t{margin-left:auto;font-size:12.5px}
.vor-orb{position:relative;width:112px;height:112px;margin:var(--s-2) auto;--lv:0}
.vor-orb i{position:absolute;inset:0;border-radius:50%}
/* round in Blueprint too, where every other corner is square: like the turning loaders, its shape is what it says */
.vor .vor-orb i{border-radius:50%!important}
/* the sphere: nothing reaches past its edge, no glow or ring, so it never needs room around it. It swells with the level up to
its box and no further, so a pulse is never cut off. Before the canvas draws (or where it can't) it is a plain gradient
circle, lit from the top left and deeper at the foot */
.vor-core{overflow:hidden;background-color:color-mix(in srgb,var(--oc) 80%,var(--surface));background-image:radial-gradient(circle at 34% 28%,rgba(255,255,255,.4),rgba(255,255,255,0) 60%);box-shadow:inset 0 -10px 20px color-mix(in srgb,var(--oc) 55%,#000 10%);transform:scale(calc(.9 + var(--lv) * .1));transition:background-color .4s}
/* the lit glass sphere, drawn on a canvas the size of the orb. Its edge breathes a little inside the box, so the plain
circle behind it goes once it draws */
.vor-gl{position:absolute;inset:0;width:100%;height:100%;border-radius:50%;opacity:0;transition:opacity .4s}
.vor-gl.on{opacity:1}
.vor-core:has(>.vor-gl.on){background:none;box-shadow:none}
/* reads the second colour as it eases from one speaker to the next */
.vor-pc{visibility:hidden;background:var(--oc2);transition:background .4s}
.vor-cap,.vor-mf,.vor-f{position:relative}
.vor[data-m="thinking"] .vor-core{animation:vor-br 1.6s var(--ease-io) infinite alternate}
@keyframes vor-br{from{transform:scale(.9)}to{transform:scale(.96)}}
.vor-cap{min-height:88px;display:flex;flex-direction:column;gap:2px;text-align:center}
.vor-who{font-size:12px;min-height:18px}
.vor-tx{margin:0;font-size:14px;line-height:1.6;color:var(--fg)}
.vor-tx.hint{color:var(--muted);font-size:13px}
.vor-tx .w{animation:vor-w .45s var(--ease) both}
@keyframes vor-w{from{opacity:0;filter:blur(3px)}to{opacity:1;filter:none}}
.vor-mw{display:flex;align-items:center;gap:10px;padding:var(--s-2) var(--s-2) var(--s-2) var(--s-3);border-radius:var(--r-in);background:var(--warn-bg)}
.vor-mw p{margin:0;flex:1;display:flex;gap:var(--s-2);font-size:13px;line-height:1.45;color:var(--fg)}
.vor-mw p svg{color:var(--warn);flex:none;margin-top:3px}
.vor-mw .pill,.vor-done .pill{background:var(--surface);color:var(--fg);box-shadow:var(--shadow)}
.vor-f{display:flex;align-items:center;gap:var(--s-2);min-height:32px}
.vor-f .sp{flex:1}
.vor:not(.on) .vor-f{justify-content:center}
.vor:not(.on) .vor-mute,.vor:not(.on) .vor-end,.vor:not(.on) .vor-f .sp,.vor.on .vor-go{display:none}
.vor-mute{position:relative}
.vor-mute[aria-pressed="true"]{background:var(--bad-bg);color:var(--bad);box-shadow:none}
/* the slash across the microphone once it is muted */
.vor-mute::after{content:"";position:absolute;left:50%;top:50%;width:1.5px;height:18px;margin:-9px 0 0 -.75px;border-radius:2px;background:currentColor;transform:rotate(-45deg) scaleY(0);transition:transform .25s var(--ease)}
.vor-mute[aria-pressed="true"]::after{transform:rotate(-45deg) scaleY(1)}
.vor-end{background:var(--bad);color:var(--on-ink)}
@media (hover:hover) and (pointer:fine){.vor-end:hover{background:color-mix(in srgb,var(--bad) 86%,#000)}}
.vor-done{display:flex;align-items:center;gap:var(--s-3);padding:10px 10px 10px var(--s-3);border-radius:var(--r-in);background:var(--chip);font-size:13.5px}
.vor-done>svg{color:var(--fg2);flex:none}
.vor-done .t{flex:1;min-width:0;display:flex;flex-direction:column}
.vor-done .t span{font-size:13px}
voice-orb.jsHTML · 215 lines
// the glass sphere of Voice orb, as a fragment shader. It is lit as a ball: normals from the sphere, a soft key light from the
// top left with a deeper, shifted hue past the terminator toward the bottom right, light gathered through the glass on the
// far side, a fresnel edge that reflects a soft room, a broad sheen and a small sharp highlight. Inside, the view bends as
// it enters the glass and the light is read at three depths along it, so the layers shift against each other like things
// in a volume: deep pools of the two colours and a third hue swirl slowly, liquid light (noise bent by noise) flows through
// the middle, and two thin ribbons turn the other way near the surface. The level (L) speeds the flow up, bends the liquid
// further, brightens the heart, makes the rim wobble a little and the highlight shimmer. At its widest the sphere still sits
// inside the canvas, and nothing is drawn outside it. The React component reads this same source
const ORB_FRAG = `#ifdef GL_FRAGMENT_PRECISION_HIGH
precision highp float;
#else
precision mediump float;
#endif
uniform vec2 R;uniform float T,L;uniform vec3 A0,B0;
float h(vec2 p){return fract(sin(dot(p,vec2(127.1,311.7)))*43758.5453);}
float n(vec2 p){vec2 i=floor(p),f=fract(p);f=f*f*(3.-2.*f);return mix(mix(h(i),h(i+vec2(1,0)),f.x),mix(h(i+vec2(0,1)),h(i+1.),f.x),f.y);}
float fbm(vec2 p){float v=0.,a=.5;for(int i=0;i<4;i++){v+=a*n(p);p=mat2(1.6,1.2,-1.2,1.6)*p+vec2(1.7,9.2);a*=.5;}return v;}
mat2 rot(float a){float c=cos(a),s=sin(a);return mat2(c,s,-s,c);}
vec3 tn(vec3 c){float mx=max(c.r,max(c.g,c.b)),d=mx-min(c.r,min(c.g,c.b));c*=max(1.,.5/max(mx,.01));mx=max(mx,.5);d=mx-min(c.r,min(c.g,c.b));return max(mx-(mx-c)*mix(1.,clamp(.55*mx/max(d,.001),1.,3.),smoothstep(.03,.12,d)),0.);}
vec3 hue(vec3 c,float a){vec3 k=vec3(.57735);float co=cos(a);return c*co+cross(k,c)*sin(a)+k*dot(k,c)*(1.-co);}
void main(){
vec3 A=tn(A0),B=tn(B0),C=hue(A,mix(-.9,1.4,smoothstep(-.1,.1,A.b-A.g)));
vec2 u=(gl_FragCoord.xy*2.-R)/R.y;
float t=T*.001,an=atan(u.y,u.x);
float w=(.002+.008*L)*(sin(3.*an+t*1.9)+.6*sin(5.*an-t*2.7)+.4*sin(2.*an-t*1.1));
float rad=.955*(1.+.008*sin(t*.9)+w);
vec2 q=u/rad;float r=length(q),e=3./(R.y*rad);
float a=1.-smoothstep(1.-e,1.,r);
if(a<=0.){gl_FragColor=vec4(0.);return;}
float z=sqrt(max(1.-r*r,0.));
vec3 N=vec3(q,z);
N.xy+=(vec2(n(q*5.+t*1.6),n(q*5.-t*1.3+7.))-.5)*(.03+.12*L);N=normalize(N);
vec3 S=vec3(q,z),D=refract(vec3(0,0,-1),N,.72),P1=S+D*z*.35,P2=S+D*z*.9,P3=S+D*z*1.5;
float s=t*.22;
vec3 d1=P3-vec3(.55*sin(s*1.3),.5*cos(s*1.1),.3*sin(s*.7)),d2=P3-vec3(.55*cos(s*.9+2.),.5*sin(s*1.2+1.),.3*cos(s*.8+3.)),d3=P3-vec3(.5*sin(s*1.6+4.),.5*cos(s*1.4+5.),.3*sin(s+1.));
float g1=exp(-dot(d1,d1)*2.6),g2=exp(-dot(d2,d2)*2.6),g3=exp(-dot(d3,d3)*3.2);
vec3 col=mix(mix(mix(mix(A,B,.4)*.55,B,g2*.8),C,g3*.7),A,g1*.95);
vec2 m=rot(t*.09)*P2.xy*1.5,o=vec2(fbm(m+vec2(0.,t*.3)),fbm(m+vec2(5.2,1.3)-t*.25));
float f=fbm(m+(o-.5)*(2.5+2.*L)+vec2(t*.12,-t*.08));
col=mix(col,mix(mix(A,B,.5),vec3(1.),.15),smoothstep(.45,.8,f)*.5)*(.8+.35*smoothstep(.2,.6,f));
vec2 k=rot(-t*.33)*P1.xy;
float b1=k.y+.32*sin(k.x*2.4+t*1.4)+.12*sin(k.x*5.3-t*2.2)+(o.x-.5)*.5,b2=k.x-.3*sin(k.y*2.1-t*1.1)+.4;
col+=mix(mix(A,C,.35),vec3(1.),.35)*(exp(-b1*b1*40.)+.6*exp(-b2*b2*55.))*(.25+.35*L)*z;
col+=mix(A,vec3(1.),.4)*exp(-dot(P2.xy,P2.xy)*3.)*(.08+.3*L);
vec3 lk=normalize(vec3(-.5,.62,.6)),dk=hue(col,.35);
col=mix(dk*dk*.6,col*1.06,smoothstep(-.55,.75,dot(N,lk)));
col+=mix(B,vec3(1.),.5)*pow(1.-z,1.6)*smoothstep(.1,.95,dot(q/max(r,1e-3),-normalize(lk.xy)))*.55;
col=mix(col,mix(vec3(.06),vec3(1.),smoothstep(-.3,.9,N.y*.7-N.x*.4)),pow(1.-N.z,3.)*.5);
float nh=max(dot(N,normalize(lk+vec3(0,0,1))),0.);
col+=pow(nh,700.)*(1.2+.8*L*sin(t*9.+q.x*8.))+pow(nh,60.)*.25+pow(nh,8.)*.1;
gl_FragColor=vec4(clamp(col,0.,1.)*a,a);
}`;
// draws the sphere on Voice orb's canvas: one draw a frame, at most 2 device pixels a CSS pixel, and nothing made anew each
// frame. Where WebGL is missing it is drawn in a few gradients on a 2D canvas instead: the same pools of colour drifting under
// the same light, terminator, edge and highlight. Returns paint(ms of flow, level 0 to 1, colour, second colour), or null
// when neither canvas can draw. The context is let go when the component stops
function orbShader(cv, c){
// a computed colour to 0 to 1 channels. Mid-transition it can come as oklab() or color(), so a 1px canvas reads it. Only a
// colour that changed is read, a few times a second at most
const px = document.createElement('canvas').getContext('2d', { willReadFrequently:true }); px.canvas.width = px.canvas.height = 1;
const rgb = (s, o) => { px.clearRect(0, 0, 1, 1); px.fillStyle = '#000'; px.fillStyle = s; px.fillRect(0, 0, 1, 1); const d = px.getImageData(0, 0, 1, 1).data; o[0] = d[0] / 255; o[1] = d[1] / 255; o[2] = d[2] / 255; };
const ta = new Float32Array(3), tb = new Float32Array(3), ca = new Float32Array(3), cb = new Float32Array(3);
let sa = '', sb = '', w = 0, at = -1e9, prev = -1e9;
// the canvas's size in device pixels, checked a few times a second
const fit = now => { if (w && now - at < 300) return false; at = now; const n = Math.round(cv.clientWidth * Math.min(2, devicePixelRatio || 1)) || 1; if (n === w) return false; w = cv.width = cv.height = n; return true; };
// a new colour is glided to over a few frames; after a pause (a still frame, or back on screen) it is taken at once
const tint = (a, b, now, snap) => {
if (a !== sa){ sa = a; rgb(a, ta); } if (b !== sb){ sb = b; rgb(b, tb); }
const k = snap || now - prev > 200 ? 1 : Math.min(1, (now - prev) / 110); prev = now;
for (let i = 0; i < 3; i++){ ca[i] += (ta[i] - ca[i]) * k; cb[i] += (tb[i] - cb[i]) * k; }
};
const gl = cv.getContext('webgl', { premultipliedAlpha:true, antialias:false, powerPreference:'low-power' });
if (gl){
const sh = (type, src) => { const s = gl.createShader(type); gl.shaderSource(s, src); gl.compileShader(s); return s; };
const pr = gl.createProgram();
gl.attachShader(pr, sh(gl.VERTEX_SHADER, 'attribute vec2 p;void main(){gl_Position=vec4(p,0.,1.);}'));
gl.attachShader(pr, sh(gl.FRAGMENT_SHADER, ORB_FRAG));
gl.linkProgram(pr);
const lose = () => { const x = gl.getExtension('WEBGL_lose_context'); if (x) x.loseContext(); };
if (!gl.getProgramParameter(pr, gl.LINK_STATUS)){ lose(); return null; }
gl.useProgram(pr);
gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer()); gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1,-1, 1,-1, -1,1, 1,1]), gl.STATIC_DRAW);
const p = gl.getAttribLocation(pr, 'p'); gl.enableVertexAttribArray(p); gl.vertexAttribPointer(p, 2, gl.FLOAT, false, 0, 0);
const U = k => gl.getUniformLocation(pr, k), uR = U('R'), uT = U('T'), uL = U('L'), uA = U('A0'), uB = U('B0');
c.offs.push(lose);
cv.classList.add('on');
return (ms, lv, a, b) => {
const now = performance.now(); if (fit(now)) gl.viewport(0, 0, w, w);
tint(a, b, now, false);
gl.uniform2f(uR, w, w); gl.uniform1f(uT, ms); gl.uniform1f(uL, lv); gl.uniform3fv(uA, ca); gl.uniform3fv(uB, cb);
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
};
}
const x = cv.getContext('2d'); if (!x) return null;
// the shader's colours: dark ones lifted, pale ones given more chroma, and a third hue turned round the grey axis toward
// the warm side (blue and violet toward magenta, lime toward amber)
const tn = (o, d) => { const lift = Math.max(1, .5 / Math.max(.01, o[0], o[1], o[2])), mx = Math.max(o[0], o[1], o[2]) * lift, dd = mx - Math.min(o[0], o[1], o[2]) * lift, s = Math.min(1, Math.max(0, (dd - .03) / .09)), k = 1 + (Math.min(3, Math.max(1, .55 * mx / Math.max(dd, .001))) - 1) * s * s * (3 - 2 * s); for (let i = 0; i < 3; i++) d[i] = Math.max(0, mx - (mx - o[i] * lift) * k); };
const hue = (o, d, ang) => { const co = Math.cos(ang), si = Math.sin(ang) * .57735, m = (o[0] + o[1] + o[2]) / 3 * (1 - co); d[0] = o[0] * co + (o[2] - o[1]) * si + m; d[1] = o[1] * co + (o[0] - o[2]) * si + m; d[2] = o[2] * co + (o[1] - o[0]) * si + m; };
const A = new Float32Array(3), B = new Float32Array(3), C = new Float32Array(3), M = new Float32Array(3);
const css = (o, al, f = 1) => `rgba(${[0, 1, 2].map(i => Math.round(Math.min(1, Math.max(0, o[i] * f)) * 255)).join(',')},${al})`;
// every gradient is made for a sphere of radius 1 at the origin, and placed by the transform as it is drawn
const grad = (cx, cy, r, stops) => { const g = x.createRadialGradient(cx, cy, 0, cx, cy, r); for (let i = 0; i < stops.length; i += 2) g.addColorStop(stops[i], stops[i + 1]); return g; };
const shade = grad(-.35, -.4, 1.6, [0, 'rgba(255,255,255,.16)', .45, 'rgba(0,0,0,0)', 1, 'rgba(0,0,0,.5)']),
rim = grad(0, 0, 1, [.78, 'rgba(255,255,255,0)', 1, 'rgba(255,255,255,.28)']),
sheen = grad(-.32, -.38, .6, [0, 'rgba(255,255,255,.22)', 1, 'rgba(255,255,255,0)']),
spec = grad(-.4, -.46, .14, [0, 'rgba(255,255,255,.95)', 1, 'rgba(255,255,255,0)']);
let g = null, mid = 0, rad = 0;
const put = (fill, dx, dy, sz) => { x.setTransform(rad * sz, 0, 0, rad * sz, mid + rad * dx, mid + rad * dy); x.fillStyle = fill; x.fillRect(-1, -1, 2, 2); };
cv.classList.add('on');
return (ms, lv, a, b) => {
const now = performance.now(); fit(now);
// the colour gradients are made again only when a colour changes
if (!g || a !== sa || b !== sb){
tint(a, b, now, true); tn(ca, A); tn(cb, B); hue(A, C, A[2] > A[1] ? 1.4 : -.9);
for (let i = 0; i < 3; i++) M[i] = (A[i] + B[i]) / 2;
g = { base:css(M, 1, .7), a:grad(0, 0, 1, [0, css(A, .95), 1, css(A, 0)]), b:grad(0, 0, 1, [0, css(B, .95), 1, css(B, 0)]), c:grad(0, 0, 1, [0, css(C, .55), 1, css(C, 0)]), core:grad(0, 0, 1, [0, css(A, 1, 1.4), 1, css(A, 0)]) };
}
const t = ms * .001, s = t * .22; mid = w / 2; rad = mid * .955 * (1 + .008 * Math.sin(t * .9));
x.setTransform(1, 0, 0, 1, 0, 0); x.clearRect(0, 0, w, w);
x.save(); x.beginPath(); x.arc(mid, mid, rad, 0, Math.PI * 2); x.clip();
x.fillStyle = g.base; x.fillRect(0, 0, w, w);
put(g.a, .55 * Math.sin(s * 1.3), -.5 * Math.cos(s * 1.1), 1.1);
put(g.b, .55 * Math.cos(s * .9 + 2), -.5 * Math.sin(s * 1.2 + 1), 1.1);
put(g.c, .5 * Math.sin(s * 1.6 + 4), -.5 * Math.cos(s * 1.4 + 5), .9);
x.globalAlpha = .15 + .45 * lv; put(g.core, 0, 0, .7);
x.globalAlpha = 1; put(shade, 0, 0, 1); put(rim, 0, 0, 1); put(sheen, 0, 0, 1);
x.globalAlpha = .85 + .15 * lv * Math.sin(t * 9); put(spec, 0, 0, 1);
x.restore();
};
}
DM.def('voice-orb', (r, c) => {
const IC = { warn:I('alert','xs'), mic:I('mic','xs'), micm:I('mic') };
r.innerHTML = r.innerHTML.replace(/\{(\w+)\}/g, (m, k) => IC[k] || m);
const root = $('.vor', r), orb = $('.vor-orb', r), st = $('.vor-st', r), tm = $('.vor-t', r), who = $('.vor-who', r), tx = $('.vor-tx', r), mf = $('.vor-mf', r), f = $('.vor-f', r), mute = $('.vor-mute', r);
// what is said, turn by turn. The demo plays it as a script: it never uses the microphone or makes a sound
const TURNS = [['You', 'Make the yearly price the default on the pricing page.'], ['Agent', 'Done. Yearly is now the default on all three plans in Pricing v4.'], ['You', 'Show the yearly saving in green too.'], ['Agent', 'Added. The saving shows in green under each yearly price.']];
const HINT = 'Talk to the agent about Pricing v4', LABEL = { idle:'Ready', listening:'Listening', thinking:'Thinking', speaking:'Speaking', muted:'Muted', ended:'Ended' };
let mode = 'idle', muted = false, talking = false, said = 0, secs = 0, run = 0, done = null, lv = 0;
const fmt = s => `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`;
// muted only changes how listening looks: the agent can still think and speak
const show = () => { const m = muted && mode === 'listening' ? 'muted' : mode; if (root.dataset.m !== m){ root.dataset.m = m; swap(st, LABEL[m]); } };
const set = m => { mode = m; show(); };
const notice = on => { mf.classList.toggle('shut', !on); mf.inert = !on; };
notice(false);
// centred in its stage at every moment: it glides up as the muted notice or the ended row opens, so it never sits high
centre(c, root, () => root.offsetHeight);
// the orb is a lit glass sphere with light flowing inside it in the colours of whoever is talking, faster and brighter as
// they get louder (orbShader, above). Where no canvas can draw, the core keeps its plain gradient
const paint = orbShader($('.vor-gl', orb), c), dot = $('.vor-dot', r), pc = $('.vor-pc', orb);
let seen = true, flow = 0, last = 0;
if (paint && 'IntersectionObserver' in window){ const io = new IntersectionObserver(e => { seen = e[0].isIntersecting; }); io.observe(orb); c.offs.push(() => io.disconnect()); }
// the two colours are read a few times a second, not every frame: reading them makes the browser recheck the page's styles
let cols = null, colsAt = -1e9;
const draw = t => { if (!paint) return; const now = performance.now(); if (!cols || now - colsAt > 250){ cols = [getComputedStyle(dot).backgroundColor, getComputedStyle(pc).backgroundColor]; colsAt = now; } paint(t, lv, cols[0], cols[1]); };
// a first frame straight away, so the sphere is there even while a pointer rests on the card and the loop holds
draw(0);
// the orb follows a level: a made-up voice while someone talks, a faint idle while it listens. With reduced motion it is
// still: one frame, drawn again when the colours change. The setting is read every frame, so it can change mid-visit
let still = false;
c.loop(t => {
if (REDUCE){ if (!still){ still = true; lv = 0; orb.style.setProperty('--lv', '0'); draw(0); } last = 0; return; }
still = false;
const to = talking ? .5 + .28 * Math.sin(t / 90) * Math.sin(t / 233) + .16 * Math.sin(t / 57) : mode === 'listening' && !muted ? .05 + .03 * Math.sin(t / 420) : 0;
lv += (Math.max(0, to) - lv) * .18; orb.style.setProperty('--lv', lv.toFixed(3));
flow += Math.min(50, t - (last || t)) * (1 + lv * 2.5); last = t;
if (seen) draw(flow);
});
if (paint){ const mo = new MutationObserver(() => c.later(450, () => { cols = null; if (REDUCE) draw(0); })); mo.observe(root, { attributes:true, attributeFilter:['data-m'] }); c.offs.push(() => mo.disconnect()); }
c.every(1000, () => { if (mode !== 'idle' && mode !== 'ended'){ secs++; tm.textContent = fmt(secs); } });
async function say(name, line, ms, id){
swap(who, name); tx.classList.remove('hint'); tx.innerHTML = ''; talking = true;
for (const w of line.split(' ')){ if (id !== run) return; tx.insertAdjacentHTML('beforeend', `<span class="w">${w} </span>`); await c.wait(ms); }
talking = false;
}
async function talk(id){
for (const [name, line] of TURNS){
if (name === 'You'){
set('listening'); await c.wait(1200); if (id !== run) return;
// they start talking while muted: say so, and wait for them to unmute
if (muted){ notice(true); while (muted){ await c.wait(100); if (id !== run) return; } await c.wait(400); if (id !== run) return; }
await say(name, line, 230, id); if (id !== run) return;
set('thinking'); await c.wait(1300);
} else { set('speaking'); await say(name, line, 190, id); if (id !== run) return; set('listening'); }
said++;
}
}
function start(){
const id = ++run; said = 0; secs = 0; tm.textContent = fmt(0);
if (done){ done.remove(); done = null; } f.hidden = false; root.classList.add('on');
c.run(() => talk(id));
}
function setMuted(on){
muted = on; mute.setAttribute('aria-pressed', on); mute.dataset.tip = on ? 'Unmute' : 'Mute';
if (!on) notice(false); show();
}
$('.vor-go', r).onclick = start;
mute.onclick = () => setMuted(!muted);
$('.vor-un', r).onclick = () => setMuted(false);
$('.vor-end', r).onclick = () => {
run++; talking = false; notice(false); set('ended'); root.classList.remove('on'); f.hidden = true;
done = html(`<div class="vor-done">${I('ok')}<span class="t">Voice chat ended<span class="muted">${fmt(secs)} · Transcript saved</span></span><button class="pill sm">Start again</button></div>`);
root.appendChild(done); enter(done);
$('button', done).onclick = () => { setMuted(false); swap(who, ''); tx.classList.add('hint'); tx.textContent = HINT; start(); };
};
// the preview's story: start, mute while the agent answers, talk while muted, unmute and go on, then end
c.story(async s => {
await s.beat(3); await s.tap($('.vor-go', r)); s.away();
await s.until(() => mode === 'speaking'); await s.beat(3); await s.tap(mute); s.away();
await s.until(() => !mf.classList.contains('shut')); await s.beat(4);
await s.tap($('.vor-un', r)); s.away();
await s.until(() => said === TURNS.length); await s.beat(3);
await s.tap($('.vor-end', r)); s.away(); await s.beat(1);
});
});
Questions
What people ask about it.
- Does it use the microphone or play sound?
- No. It draws the
state,levelandcaptionyou pass. Your code records, transcribes and plays the agent's voice. The demo plays a written script and never asks for the microphone. - How is it different from Voice capture?
- Voice capture records one message to check and fix before sending. Voice orb is a back-and-forth conversation, with the agent answering out loud.
- How do I drive the orb from real audio?
- Read the loudness from your audio, for example with a Web Audio AnalyserNode, scale it to 0 to 1, and pass it as
levelon each animation frame.
More in Conversation
Everything for asking, answering and replying, by text or by voice.