@medalsocial/meda 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -56,12 +56,17 @@ See the [demo app](./demo) for a live playground.
56
56
 
57
57
  ## Alternative: shadcn registry
58
58
 
59
- Prefer to copy source into your project instead of installing? The shadcn-compatible registry lives at [`./registry`](./registry). Serve it statically and run:
59
+ Prefer to copy source into your project instead of installing? The shadcn-compatible registry is served alongside the demo playground on Cloudflare Workers.
60
60
 
61
61
  ```bash
62
- npx shadcn add <registry-url>/r/meda-shell.json
62
+ # Once DNS for meda.medalsocial.com is live:
63
+ npx shadcn add https://meda.medalsocial.com/r/meda-shell.json
64
+ npx shadcn add https://meda.medalsocial.com/r/meda-shell-state.json
65
+ npx shadcn add https://meda.medalsocial.com/r/meda-workbench-layout.json
63
66
  ```
64
67
 
68
+ The registry index is at `https://meda.medalsocial.com/registry.json`. Source JSON files live under [`./registry`](./registry) in this repo and are deployed as static assets via Cloudflare Workers — see `wrangler.toml` and `.github/workflows/deploy-worker.yml`.
69
+
65
70
  ## Development
66
71
 
67
72
  ```bash
package/dist/styles.css CHANGED
@@ -2,3 +2,25 @@
2
2
  --meda-shell-panel-dock-radius: var(--radius-4xl, 1.5rem);
3
3
  --meda-shell-panel-dock-shadow: var(--shadow-panel-elevated, 0 32px 90px rgb(0 0 0 / 0.45));
4
4
  }
5
+
6
+ /* VoiceOrb (WebGL): minimal container styles — visual identity lives in the GLSL shader. */
7
+ .meda-voice-orb {
8
+ background: transparent;
9
+ border: 0;
10
+ padding: 0;
11
+ isolation: isolate;
12
+ overflow: hidden;
13
+ border-radius: 9999px;
14
+ cursor: pointer;
15
+ position: relative;
16
+ }
17
+ .meda-voice-orb[disabled] {
18
+ cursor: not-allowed;
19
+ opacity: 0.6;
20
+ }
21
+ .meda-voice-orb canvas {
22
+ display: block;
23
+ width: 100% !important;
24
+ height: 100% !important;
25
+ pointer-events: none;
26
+ }
@@ -0,0 +1,9 @@
1
+ export type { MicState, PcmFrame, TurnPhase } from './types.js';
2
+ export type { UseMicCaptureOptions, UseMicCaptureReturn } from './use-mic-capture.js';
3
+ export { useMicCapture } from './use-mic-capture.js';
4
+ export type { VoiceLevelProps } from './voice-level.js';
5
+ export { VoiceLevel } from './voice-level.js';
6
+ export type { VoiceOrbProps, VoiceOrbVariant } from './voice-orb.js';
7
+ export { VoiceOrb } from './voice-orb.js';
8
+ export type { VoiceStatusPillProps } from './voice-status-pill.js';
9
+ export { VoiceStatusPill } from './voice-status-pill.js';
@@ -0,0 +1,4 @@
1
+ export { useMicCapture } from './use-mic-capture.js';
2
+ export { VoiceLevel } from './voice-level.js';
3
+ export { VoiceOrb } from './voice-orb.js';
4
+ export { VoiceStatusPill } from './voice-status-pill.js';
@@ -0,0 +1 @@
1
+ export * from './index.js';
@@ -0,0 +1 @@
1
+ export * from './index.js';
@@ -0,0 +1,8 @@
1
+ export type MicState = 'idle' | 'requesting' | 'denied' | 'ready' | 'capturing';
2
+ export type TurnPhase = 'idle' | 'listening' | 'thinking' | 'speaking' | 'error';
3
+ export interface PcmFrame {
4
+ /** Int16 PCM at 16kHz mono, typically 20ms = 320 samples = 640 bytes. */
5
+ pcm: ArrayBuffer;
6
+ /** Voice activity proxy from RMS, 0..1. */
7
+ level: number;
8
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,14 @@
1
+ import type { MicState, PcmFrame } from './types.js';
2
+ export interface UseMicCaptureOptions {
3
+ autoStart?: boolean;
4
+ onFrame?: (frame: PcmFrame) => void;
5
+ audioConstraints?: MediaTrackConstraints;
6
+ }
7
+ export interface UseMicCaptureReturn {
8
+ state: MicState;
9
+ level: number;
10
+ error: Error | null;
11
+ start: () => Promise<void>;
12
+ stop: () => void;
13
+ }
14
+ export declare function useMicCapture(opts?: UseMicCaptureOptions): UseMicCaptureReturn;
@@ -0,0 +1,185 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'react';
2
+ const WORKLET_CODE = `
3
+ const TARGET_RATE = 16000;
4
+ const FRAME_SAMPLES = 320;
5
+
6
+ class MicCapture extends AudioWorkletProcessor {
7
+ constructor() {
8
+ super();
9
+ this.inputRate = sampleRate;
10
+ this.ratio = this.inputRate / TARGET_RATE;
11
+ this.buffer = [];
12
+ // Persist the fractional resampling offset across process() callbacks so
13
+ // non-integer ratios (e.g. 44.1kHz→16kHz) don't accumulate ~1% drift.
14
+ this.resampleOffset = 0;
15
+ }
16
+ resampleAndAppend(input) {
17
+ let i = this.resampleOffset;
18
+ while (i < input.length) {
19
+ this.buffer.push(input[Math.floor(i)]);
20
+ i += this.ratio;
21
+ }
22
+ // Carry the fractional part forward into the next callback.
23
+ this.resampleOffset = i - input.length;
24
+ }
25
+ process(inputs) {
26
+ const ch = inputs[0]?.[0];
27
+ if (!ch) return true;
28
+ this.resampleAndAppend(ch);
29
+ while (this.buffer.length >= FRAME_SAMPLES) {
30
+ const frame = this.buffer.slice(0, FRAME_SAMPLES);
31
+ this.buffer = this.buffer.slice(FRAME_SAMPLES);
32
+ const pcm = new Int16Array(FRAME_SAMPLES);
33
+ let sumSq = 0;
34
+ for (let j = 0; j < FRAME_SAMPLES; j++) {
35
+ const s = Math.max(-1, Math.min(1, frame[j]));
36
+ pcm[j] = (s * 0x7fff) | 0;
37
+ sumSq += s * s;
38
+ }
39
+ const rms = Math.sqrt(sumSq / FRAME_SAMPLES);
40
+ this.port.postMessage({ pcm: pcm.buffer, level: rms }, [pcm.buffer]);
41
+ }
42
+ return true;
43
+ }
44
+ }
45
+ registerProcessor('meda-mic-capture', MicCapture);
46
+ `;
47
+ export function useMicCapture(opts = {}) {
48
+ const [state, setState] = useState('idle');
49
+ const [level, setLevel] = useState(0);
50
+ const [error, setError] = useState(null);
51
+ const ctxRef = useRef(null);
52
+ const streamRef = useRef(null);
53
+ const nodeRef = useRef(null);
54
+ const onFrameRef = useRef(opts.onFrame);
55
+ onFrameRef.current = opts.onFrame;
56
+ // Generation token: incremented on every start/stop so an in-flight start()
57
+ // can detect that a newer call has superseded it and bail out.
58
+ const genRef = useRef(0);
59
+ const stop = useCallback(() => {
60
+ genRef.current += 1;
61
+ nodeRef.current?.disconnect();
62
+ nodeRef.current = null;
63
+ streamRef.current?.getTracks().forEach((t) => {
64
+ t.stop();
65
+ });
66
+ streamRef.current = null;
67
+ void ctxRef.current?.close();
68
+ ctxRef.current = null;
69
+ setState('idle');
70
+ setLevel(0);
71
+ }, []);
72
+ const start = useCallback(async () => {
73
+ // Bug 5 fix: tear down any existing capture before starting a new one so
74
+ // we don't orphan the previous stream/context.
75
+ if (ctxRef.current ?? streamRef.current) {
76
+ stop();
77
+ }
78
+ // Grab the current generation so we can detect if stop() (or a second
79
+ // start()) is called while we are awaiting getUserMedia / addModule.
80
+ genRef.current += 1;
81
+ const gen = genRef.current;
82
+ setState('requesting');
83
+ setError(null);
84
+ // Helper for the stale-generation early-return paths (after addModule):
85
+ // releases resources acquired by this invocation without touching refs
86
+ // that may already belong to a superseding start().
87
+ const cleanupRefs = (stream, ctx) => {
88
+ stream?.getTracks().forEach((t) => {
89
+ t.stop();
90
+ });
91
+ void ctx?.close();
92
+ // Clear refs only if this generation still owns them (a superseding
93
+ // start() may have already set new refs).
94
+ if (genRef.current === gen) {
95
+ if (streamRef.current === stream)
96
+ streamRef.current = null;
97
+ if (ctxRef.current === ctx)
98
+ ctxRef.current = null;
99
+ nodeRef.current = null;
100
+ }
101
+ };
102
+ // Track resources acquired by THIS invocation so the catch block can
103
+ // release exactly what we opened — not whatever a newer start() may have
104
+ // placed in the shared refs.
105
+ let acquiredStream = null;
106
+ let acquiredCtx = null;
107
+ try {
108
+ acquiredStream = await navigator.mediaDevices.getUserMedia({
109
+ audio: opts.audioConstraints ?? {
110
+ echoCancellation: true,
111
+ noiseSuppression: true,
112
+ autoGainControl: true,
113
+ },
114
+ });
115
+ // If stop() fired while we were awaiting getUserMedia a newer generation
116
+ // is active — release the stream we just acquired and bail out.
117
+ if (genRef.current !== gen) {
118
+ acquiredStream.getTracks().forEach((t) => {
119
+ t.stop();
120
+ });
121
+ return;
122
+ }
123
+ streamRef.current = acquiredStream;
124
+ acquiredCtx = new AudioContext();
125
+ ctxRef.current = acquiredCtx;
126
+ const blob = new Blob([WORKLET_CODE], { type: 'application/javascript' });
127
+ const url = URL.createObjectURL(blob);
128
+ try {
129
+ await acquiredCtx.audioWorklet.addModule(url);
130
+ }
131
+ finally {
132
+ URL.revokeObjectURL(url);
133
+ }
134
+ // Check again after the async addModule call.
135
+ if (genRef.current !== gen) {
136
+ cleanupRefs(acquiredStream, acquiredCtx);
137
+ return;
138
+ }
139
+ const node = new AudioWorkletNode(acquiredCtx, 'meda-mic-capture');
140
+ nodeRef.current = node;
141
+ node.port.onmessage = (e) => {
142
+ const msg = e.data;
143
+ setLevel(msg.level);
144
+ onFrameRef.current?.({ pcm: msg.pcm, level: msg.level });
145
+ };
146
+ const src = acquiredCtx.createMediaStreamSource(acquiredStream);
147
+ src.connect(node);
148
+ // Web Audio graphs only run when connected to a destination. The
149
+ // worklet's process() callback is never invoked otherwise — no PCM
150
+ // frames, no level updates. Route through a 0-gain node so we don't
151
+ // produce audible feedback from the user's own mic.
152
+ const silent = acquiredCtx.createGain();
153
+ silent.gain.value = 0;
154
+ node.connect(silent);
155
+ silent.connect(acquiredCtx.destination);
156
+ setState('capturing');
157
+ }
158
+ catch (err) {
159
+ // Release only the resources THIS start() acquired. Reading from refs
160
+ // here would be unsafe — a superseding start() may have already stored
161
+ // its own resources there.
162
+ acquiredStream?.getTracks().forEach((t) => {
163
+ t.stop();
164
+ });
165
+ void acquiredCtx?.close();
166
+ if (genRef.current === gen) {
167
+ if (streamRef.current === acquiredStream)
168
+ streamRef.current = null;
169
+ if (ctxRef.current === acquiredCtx)
170
+ ctxRef.current = null;
171
+ nodeRef.current = null;
172
+ const e = err;
173
+ setError(e);
174
+ setState(e.name === 'NotAllowedError' ? 'denied' : 'idle');
175
+ }
176
+ }
177
+ }, [opts.audioConstraints, stop]);
178
+ // biome-ignore lint/correctness/useExhaustiveDependencies: intentionally empty - should only run on mount
179
+ useEffect(() => {
180
+ if (opts.autoStart)
181
+ void start();
182
+ return stop;
183
+ }, []);
184
+ return { state, level, error, start, stop };
185
+ }
@@ -0,0 +1,8 @@
1
+ export interface VoiceLevelProps {
2
+ level: number;
3
+ variant?: 'bars' | 'wave' | 'ring';
4
+ width?: number;
5
+ height?: number;
6
+ className?: string;
7
+ }
8
+ export declare function VoiceLevel({ level, variant, width, height, className, }: VoiceLevelProps): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,29 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import * as React from 'react';
3
+ const BAR_COUNT = 9;
4
+ export function VoiceLevel({ level, variant = 'bars', width = 140, height = 48, className, }) {
5
+ const historyRef = React.useRef(Array(BAR_COUNT).fill(0));
6
+ const [, force] = React.useState(0);
7
+ // Only the 'bars' variant reads historyRef, so gate history updates and the
8
+ // resulting forced rerender to that variant — avoids a superfluous render on
9
+ // every level tick for 'ring' and 'wave'.
10
+ React.useEffect(() => {
11
+ if (variant !== 'bars')
12
+ return;
13
+ historyRef.current = [...historyRef.current.slice(1), Math.min(1, Math.max(0, level))];
14
+ force((v) => v + 1);
15
+ }, [level, variant]);
16
+ if (variant === 'bars') {
17
+ return (_jsx("div", { role: "presentation", className: ['flex items-end gap-1', className ?? ''].join(' '), style: { width, height }, children: historyRef.current.map((v, i) => (_jsx("span", { className: "flex-1 rounded-sm bg-primary", style: { height: `${15 + v * 85}%`, opacity: 0.4 + v * 0.6 } }, i))) }));
18
+ }
19
+ if (variant === 'ring') {
20
+ const r = Math.min(width, height) / 2 - 4;
21
+ const c = 2 * Math.PI * r;
22
+ const filled = c * Math.min(1, Math.max(0, level));
23
+ return (_jsxs("svg", { width: width, height: height, className: className, role: "presentation", children: [_jsx("circle", { cx: width / 2, cy: height / 2, r: r, stroke: "hsl(var(--border))", strokeWidth: "3", fill: "none" }), _jsx("circle", { cx: width / 2, cy: height / 2, r: r, stroke: "hsl(var(--primary))", strokeWidth: "3", fill: "none", strokeDasharray: `${filled} ${c}`, strokeLinecap: "round", transform: `rotate(-90 ${width / 2} ${height / 2})` })] }));
24
+ }
25
+ // 'wave' — simple bezier that scales with level
26
+ const peak = height / 2 - 2;
27
+ const amp = peak * Math.min(1, Math.max(0, level));
28
+ return (_jsx("svg", { width: width, height: height, className: className, role: "presentation", children: _jsx("path", { d: `M 0 ${height / 2} Q ${width / 4} ${height / 2 - amp}, ${width / 2} ${height / 2} T ${width} ${height / 2}`, stroke: "hsl(var(--primary))", strokeWidth: "2", fill: "none" }) }));
29
+ }
@@ -0,0 +1,12 @@
1
+ import type { TurnPhase } from './types.js';
2
+ export type VoiceOrbVariant = 'metal' | 'aurora';
3
+ export interface SceneProps {
4
+ level?: number;
5
+ outputLevel?: number;
6
+ phase?: TurnPhase;
7
+ colors: [string, string];
8
+ reducedMotion?: boolean;
9
+ pressed?: boolean;
10
+ variant?: VoiceOrbVariant;
11
+ }
12
+ export declare function Scene({ level, outputLevel, phase, colors, reducedMotion, pressed, variant, }: SceneProps): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,121 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ // Portions adapted from ElevenLabs UI's Orb component (MIT):
4
+ // https://github.com/elevenlabs/ui/blob/main/apps/www/registry/elevenlabs-ui/ui/orb.tsx
5
+ // Copyright (c) ElevenLabs Inc.
6
+ // Adaptations: Meda token-driven theming, 5-phase state model, outputLevel uniform,
7
+ // procedural simplex noise (no texture asset required).
8
+ import { useFrame } from '@react-three/fiber';
9
+ import * as React from 'react';
10
+ import * as THREE from 'three';
11
+ import { FRAGMENT_SHADER, VERTEX_SHADER } from './voice-orb-shader.js';
12
+ import { createVolumeSmoother } from './voice-orb-volume.js';
13
+ // Maps phase name to numeric target for uPhaseT
14
+ const PHASE_TARGET = {
15
+ idle: 0,
16
+ listening: 1,
17
+ thinking: 2,
18
+ speaking: 3,
19
+ error: 4,
20
+ };
21
+ // splitmix32 — fast deterministic PRNG seeded at mount
22
+ function splitmix32(seed) {
23
+ let a = seed;
24
+ return () => {
25
+ a = (a + 0x9e3779b9) | 0;
26
+ let t = a ^ (a >>> 16);
27
+ t = Math.imul(t, 0x21f0aaad);
28
+ t = t ^ (t >>> 15);
29
+ t = Math.imul(t, 0x735a2d97);
30
+ t = t ^ (t >>> 15);
31
+ return (t >>> 0) / 4294967296;
32
+ };
33
+ }
34
+ export function Scene({ level = 0, outputLevel = 0, phase = 'idle', colors, reducedMotion = false, pressed = false, variant = 'aurora', }) {
35
+ const meshRef = React.useRef(null);
36
+ // Smoothers for audio signals
37
+ const inputSmoother = React.useRef(createVolumeSmoother());
38
+ const outputSmoother = React.useRef(createVolumeSmoother());
39
+ // Transition state refs (avoid re-renders)
40
+ const phaseTRef = React.useRef(PHASE_TARGET[phase]);
41
+ const errorFlashRef = React.useRef(0);
42
+ const pressedRef = React.useRef(pressed ? 1 : 0);
43
+ const prevPhaseRef = React.useRef(phase);
44
+ // Color targets
45
+ const colorARef = React.useRef(new THREE.Color(colors[0]));
46
+ const colorBRef = React.useRef(new THREE.Color(colors[1]));
47
+ // Sync latest prop values to refs
48
+ React.useEffect(() => {
49
+ colorARef.current.set(colors[0]);
50
+ colorBRef.current.set(colors[1]);
51
+ }, [colors]);
52
+ React.useEffect(() => {
53
+ // Trigger error flash envelope when entering error phase
54
+ if (phase === 'error' && prevPhaseRef.current !== 'error') {
55
+ errorFlashRef.current = 1;
56
+ }
57
+ prevPhaseRef.current = phase;
58
+ }, [phase]);
59
+ // Random offsets for the 7 oval blobs (seeded once at mount)
60
+ const offsets = React.useMemo(() => {
61
+ const rng = splitmix32(Math.floor(Math.random() * 2 ** 32));
62
+ return new Float32Array(Array.from({ length: 7 }, () => rng() * Math.PI * 2));
63
+ }, []);
64
+ // Build uniforms once — intentionally empty dep array; all values are mutated
65
+ // in useFrame each tick. Initial values come from props captured at mount.
66
+ // biome-ignore lint/correctness/useExhaustiveDependencies: uniform object is mutated in useFrame; re-creating it would reset animation state
67
+ const uniforms = React.useMemo(() => ({
68
+ uTime: { value: 0 },
69
+ uAnimation: { value: 0.1 },
70
+ uInputVolume: { value: 0 },
71
+ uOutputVolume: { value: 0 },
72
+ uColorA: new THREE.Uniform(new THREE.Color(colors[0])),
73
+ uColorB: new THREE.Uniform(new THREE.Color(colors[1])),
74
+ uPhaseT: { value: PHASE_TARGET[phase] },
75
+ uErrorFlash: { value: 0 },
76
+ uPressed: { value: 0 },
77
+ uOpacity: { value: 0 },
78
+ uOffsets: { value: offsets },
79
+ uVariant: { value: variant === 'aurora' ? 1 : 0 },
80
+ }), [] // intentionally empty — uniforms are mutated in useFrame
81
+ );
82
+ useFrame((_, delta) => {
83
+ const mat = meshRef.current?.material;
84
+ if (!mat)
85
+ return;
86
+ const u = mat.uniforms;
87
+ // Fade in on first render
88
+ if (u.uOpacity.value < 1) {
89
+ u.uOpacity.value = Math.min(1, u.uOpacity.value + delta * 2);
90
+ }
91
+ // Time & animation — frozen under reduced motion
92
+ if (!reducedMotion) {
93
+ u.uTime.value += delta * 0.5;
94
+ u.uAnimation.value += delta * (0.1 + (1 - (u.uOutputVolume.value - 1) ** 2) * 0.9);
95
+ }
96
+ // Smooth audio signals; clamp to 0 under reduced motion
97
+ inputSmoother.current.update(reducedMotion ? 0 : Math.min(1, Math.max(0, level)));
98
+ outputSmoother.current.update(reducedMotion ? 0 : Math.min(1, Math.max(0, outputLevel)));
99
+ u.uInputVolume.value = inputSmoother.current.value;
100
+ u.uOutputVolume.value = outputSmoother.current.value;
101
+ // Phase interpolation — 220ms transition
102
+ const phaseTarget = PHASE_TARGET[phase];
103
+ phaseTRef.current += (phaseTarget - phaseTRef.current) * Math.min(1, delta / 0.22);
104
+ u.uPhaseT.value = phaseTRef.current;
105
+ // Error flash decay over ~1.5s
106
+ if (errorFlashRef.current > 0) {
107
+ errorFlashRef.current = Math.max(0, errorFlashRef.current - delta / 1.5);
108
+ u.uErrorFlash.value = errorFlashRef.current;
109
+ }
110
+ // Press envelope
111
+ const pressTarget = pressed ? 1 : 0;
112
+ pressedRef.current += (pressTarget - pressedRef.current) * Math.min(1, delta / 0.1);
113
+ u.uPressed.value = pressedRef.current;
114
+ // Color lerp
115
+ u.uColorA.value.lerp(colorARef.current, 0.08);
116
+ u.uColorB.value.lerp(colorBRef.current, 0.08);
117
+ // Variant — switched live (no animation; consumer changes are instant)
118
+ u.uVariant.value = variant === 'aurora' ? 1 : 0;
119
+ });
120
+ return (_jsxs("mesh", { ref: meshRef, children: [_jsx("circleGeometry", { args: [3.5, 64] }), _jsx("shaderMaterial", { uniforms: uniforms, vertexShader: VERTEX_SHADER, fragmentShader: FRAGMENT_SHADER, transparent: true })] }));
121
+ }
@@ -0,0 +1,2 @@
1
+ export declare const VERTEX_SHADER = "\nuniform float uTime;\nvarying vec2 vUv;\n\nvoid main() {\n vUv = uv;\n gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);\n}\n";
2
+ export declare const FRAGMENT_SHADER = "\n\nvec3 mod289_3(vec3 x) { return x - floor(x * (1.0 / 289.0)) * 289.0; }\nvec4 mod289_4(vec4 x) { return x - floor(x * (1.0 / 289.0)) * 289.0; }\nvec4 permute(vec4 x) { return mod289_4(((x * 34.0) + 10.0) * x); }\nvec4 taylorInvSqrt(vec4 r) { return 1.79284291400159 - 0.85373472095314 * r; }\n\nfloat snoise(vec3 v) {\n const vec2 C = vec2(1.0/6.0, 1.0/3.0);\n const vec4 D = vec4(0.0, 0.5, 1.0, 2.0);\n\n vec3 i = floor(v + dot(v, C.yyy));\n vec3 x0 = v - i + dot(i, C.xxx);\n\n vec3 g = step(x0.yzx, x0.xyz);\n vec3 l = 1.0 - g;\n vec3 i1 = min(g.xyz, l.zxy);\n vec3 i2 = max(g.xyz, l.zxy);\n\n vec3 x1 = x0 - i1 + C.xxx;\n vec3 x2 = x0 - i2 + C.yyy;\n vec3 x3 = x0 - D.yyy;\n\n i = mod289_3(i);\n vec4 p = permute(permute(permute(\n i.z + vec4(0.0, i1.z, i2.z, 1.0))\n + i.y + vec4(0.0, i1.y, i2.y, 1.0))\n + i.x + vec4(0.0, i1.x, i2.x, 1.0));\n\n float n_ = 0.142857142857;\n vec3 ns = n_ * D.wyz - D.xzx;\n\n vec4 j = p - 49.0 * floor(p * ns.z * ns.z);\n\n vec4 x_ = floor(j * ns.z);\n vec4 y_ = floor(j - 7.0 * x_);\n\n vec4 x = x_ * ns.x + ns.yyyy;\n vec4 y = y_ * ns.x + ns.yyyy;\n vec4 h = 1.0 - abs(x) - abs(y);\n\n vec4 b0 = vec4(x.xy, y.xy);\n vec4 b1 = vec4(x.zw, y.zw);\n\n vec4 s0 = floor(b0) * 2.0 + 1.0;\n vec4 s1 = floor(b1) * 2.0 + 1.0;\n vec4 sh = -step(h, vec4(0.0));\n\n vec4 a0 = b0.xzyw + s0.xzyw * sh.xxyy;\n vec4 a1 = b1.xzyw + s1.xzyw * sh.zzww;\n\n vec3 p0 = vec3(a0.xy, h.x);\n vec3 p1 = vec3(a0.zw, h.y);\n vec3 p2 = vec3(a1.xy, h.z);\n vec3 p3 = vec3(a1.zw, h.w);\n\n vec4 norm = taylorInvSqrt(vec4(dot(p0,p0), dot(p1,p1), dot(p2,p2), dot(p3,p3)));\n p0 *= norm.x;\n p1 *= norm.y;\n p2 *= norm.z;\n p3 *= norm.w;\n\n vec4 m = max(0.5 - vec4(dot(x0,x0), dot(x1,x1), dot(x2,x2), dot(x3,x3)), 0.0);\n m = m * m;\n return 105.0 * dot(m * m, vec4(dot(p0,x0), dot(p1,x1), dot(p2,x2), dot(p3,x3)));\n}\n\n// Fractional Brownian Motion: stacked octaves of simplex noise\nfloat fbm(vec3 p) {\n float value = 0.0;\n float amplitude = 0.5;\n float frequency = 1.0;\n for (int i = 0; i < 4; i++) {\n value += amplitude * snoise(p * frequency);\n amplitude *= 0.5;\n frequency *= 2.0;\n }\n return value;\n}\n\n\nuniform float uTime;\nuniform float uAnimation;\nuniform float uInputVolume;\nuniform float uOutputVolume;\nuniform vec3 uColorA;\nuniform vec3 uColorB;\nuniform float uPhaseT;\nuniform float uErrorFlash;\nuniform float uPressed;\nuniform float uOpacity;\nuniform float uVariant; // 0 = metal, 1 = aurora\n\nconst vec3 ERROR_COLOR = vec3(0.85, 0.18, 0.18);\nconst float PI = 3.14159265358979323846;\n\nvarying vec2 vUv;\n\n// =============================================================================\n// METAL look \u2014 smooth chrome-like sphere with strong specular + depth shading.\n// Apple Siri / Vision Pro adjacent.\n// =============================================================================\nvec4 metalLook(vec2 uv, float r, vec3 cA, vec3 cB, float drive) {\n // Two octaves of noise for surface variation\n float n = fbm(vec3(uv * 1.4, uAnimation * 0.18)) * 0.5 + 0.5;\n float n2 = fbm(vec3(uv * 2.6 + 4.7, uAnimation * 0.10)) * 0.5 + 0.5;\n\n // Vertical light bias \u2014 top brighter than bottom (3D illusion)\n float topLight = smoothstep(-1.0, 1.0, uv.y * 0.6 + 0.4);\n float mixT = clamp(n * 0.55 + topLight * 0.45 + n2 * 0.08, 0.0, 1.0);\n\n // Light cream color for the highlight band \u2014 gives the chrome look.\n // Brightens cA toward white for the upper portion of the sphere.\n vec3 highlightTone = mix(cA, vec3(0.95, 0.92, 0.98), 0.55);\n vec3 deepTone = mix(cB, vec3(0.04, 0.02, 0.10), 0.55);\n\n // 4-stop ramp: cream highlight \u2192 cA \u2192 cB \u2192 deep core\n vec3 base;\n if (mixT > 0.66) {\n base = mix(cA, highlightTone, (mixT - 0.66) / 0.34);\n } else if (mixT > 0.33) {\n base = mix(cB, cA, (mixT - 0.33) / 0.33);\n } else {\n base = mix(deepTone, cB, mixT / 0.33);\n }\n\n // Specular: tight bright spot top-left\n vec2 specCenter = vec2(-0.35, 0.45);\n float spec = smoothstep(0.45, 0.0, length(uv - specCenter));\n base += vec3(1.0) * spec * 0.32;\n\n // Bottom shadow for depth\n float shadow = smoothstep(0.3, 1.0, -uv.y) * 0.35;\n base = mix(base, base * 0.55, shadow);\n\n // Inner glow on audio\n float glow = (1.0 - r) * (1.0 - r);\n base += cA * drive * glow * 0.5;\n\n return vec4(base, 1.0);\n}\n\n// =============================================================================\n// AURORA look \u2014 soft layered ribbons drifting across a translucent disc.\n// Northern-lights aesthetic. Calm, atmospheric.\n// =============================================================================\nvec4 auroraLook(vec2 uv, float r, vec3 cA, vec3 cB, float drive) {\n float t = uAnimation * 0.25;\n\n // Disc base \u2014 deep, translucent. cB darkened.\n vec3 baseColor = mix(cB * 0.25, cB * 0.55, smoothstep(1.0, 0.0, r));\n\n // Three drifting ribbons at different y-offsets, angles, and speeds.\n // Each ribbon is a horizontally-stretched soft band whose vertical position\n // oscillates with time + noise.\n\n // Ribbon 1 \u2014 upper, primary color\n float y1 = uv.y - (sin(t * 0.7 + uv.x * 1.5) * 0.18) - 0.15;\n float band1 = exp(-pow(y1 / 0.18, 2.0));\n float n1 = fbm(vec3(uv.x * 2.0 + t, uv.y * 0.8, t * 0.4)) * 0.5 + 0.5;\n vec3 ribbon1 = cA * band1 * n1 * 1.4;\n\n // Ribbon 2 \u2014 middle, accent color (cooler \u2014 shift cA toward cyan)\n vec3 cool = mix(cA, vec3(0.36, 0.83, 1.0), 0.45);\n float y2 = uv.y - (sin(t * 0.55 + uv.x * 2.2 + 1.7) * 0.22) + 0.05;\n float band2 = exp(-pow(y2 / 0.14, 2.0));\n float n2 = fbm(vec3(uv.x * 2.6 + t * 0.6 + 3.1, uv.y * 0.6, t * 0.3)) * 0.5 + 0.5;\n vec3 ribbon2 = cool * band2 * n2 * 1.0;\n\n // Ribbon 3 \u2014 lower, warm accent (shift cA toward pink)\n vec3 warm = mix(cA, vec3(1.0, 0.49, 0.83), 0.55);\n float y3 = uv.y - (sin(t * 0.4 + uv.x * 1.8 + 4.2) * 0.18) + 0.30;\n float band3 = exp(-pow(y3 / 0.16, 2.0));\n float n3 = fbm(vec3(uv.x * 2.2 + t * 0.45 + 7.9, uv.y * 0.7, t * 0.35)) * 0.5 + 0.5;\n vec3 ribbon3 = warm * band3 * n3 * 0.85;\n\n // Stack ribbons additively over the disc\n vec3 base = baseColor + ribbon1 + ribbon2 + ribbon3;\n\n // Audio drive intensifies all ribbons + brightens center\n base += (ribbon1 + ribbon2 + ribbon3) * drive * 0.6;\n float glow = (1.0 - r) * (1.0 - r);\n base += cA * drive * glow * 0.35;\n\n // Subtle inner rim accent for legibility against dark backgrounds\n float rim = smoothstep(0.82, 0.98, r);\n base += cA * rim * 0.22;\n\n return vec4(base, 1.0);\n}\n\nvoid main() {\n vec2 uv = vUv * 2.0 - 1.0;\n float r = length(uv);\n\n // Discard fragments outside the unit circle so the canvas stays clean.\n if (r > 1.0) {\n gl_FragColor = vec4(0.0);\n return;\n }\n\n // ---- Phase masks (0..1 for each phase) ---------------------------------\n // uPhaseT smoothly interpolates between numeric phases:\n // 0=idle, 1=listening, 2=thinking, 3=speaking, 4=error.\n float isListening = clamp(1.0 - abs(uPhaseT - 1.0), 0.0, 1.0);\n float isThinking = clamp(1.0 - abs(uPhaseT - 2.0), 0.0, 1.0);\n float isSpeaking = clamp(1.0 - abs(uPhaseT - 3.0), 0.0, 1.0);\n\n float inputDrive = uInputVolume * isListening;\n float outputDrive = uOutputVolume * isSpeaking;\n float thinkPulse = isThinking * (0.5 + 0.5 * sin(uTime * 2.0));\n float drive = max(inputDrive, max(outputDrive, thinkPulse * 0.4));\n\n // ---- Resolve effective colors with error tint --------------------------\n vec3 cA = mix(uColorA, ERROR_COLOR, uErrorFlash);\n vec3 cB = mix(uColorB, ERROR_COLOR * 0.55, uErrorFlash * 0.7);\n\n // ---- Pick variant ------------------------------------------------------\n vec4 result;\n if (uVariant > 0.5) {\n result = auroraLook(uv, r, cA, cB, drive);\n } else {\n result = metalLook(uv, r, cA, cB, drive);\n }\n\n vec3 base = result.rgb;\n\n // ---- Press: slight core brighten ---------------------------------------\n base = mix(base, base * 1.18, uPressed * (1.0 - smoothstep(0.0, 0.55, r)));\n\n // ---- Edge falloff: smooth alpha at the disc boundary -------------------\n float alpha = smoothstep(1.0, 0.92, r);\n\n gl_FragColor = vec4(base, alpha * uOpacity);\n}\n";
@@ -0,0 +1,255 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Portions adapted from ElevenLabs UI's Orb component (MIT):
3
+ // https://github.com/elevenlabs/ui/blob/main/apps/www/registry/elevenlabs-ui/ui/orb.tsx
4
+ // Copyright (c) ElevenLabs Inc.
5
+ // Adaptations: Meda token-driven theming, 5-phase state model (idle/listening/thinking/
6
+ // speaking/error), outputLevel uniform, procedural simplex noise (replaces texture sampling).
7
+ // Simplex noise implementation based on Stefan Gustavson's public-domain GLSL noise.
8
+ export const VERTEX_SHADER = /* glsl */ `
9
+ uniform float uTime;
10
+ varying vec2 vUv;
11
+
12
+ void main() {
13
+ vUv = uv;
14
+ gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
15
+ }
16
+ `;
17
+ // Stefan Gustavson's simplex noise — public domain
18
+ // https://github.com/stegu/webgl-noise (public domain)
19
+ const SIMPLEX_NOISE_GLSL = /* glsl */ `
20
+ vec3 mod289_3(vec3 x) { return x - floor(x * (1.0 / 289.0)) * 289.0; }
21
+ vec4 mod289_4(vec4 x) { return x - floor(x * (1.0 / 289.0)) * 289.0; }
22
+ vec4 permute(vec4 x) { return mod289_4(((x * 34.0) + 10.0) * x); }
23
+ vec4 taylorInvSqrt(vec4 r) { return 1.79284291400159 - 0.85373472095314 * r; }
24
+
25
+ float snoise(vec3 v) {
26
+ const vec2 C = vec2(1.0/6.0, 1.0/3.0);
27
+ const vec4 D = vec4(0.0, 0.5, 1.0, 2.0);
28
+
29
+ vec3 i = floor(v + dot(v, C.yyy));
30
+ vec3 x0 = v - i + dot(i, C.xxx);
31
+
32
+ vec3 g = step(x0.yzx, x0.xyz);
33
+ vec3 l = 1.0 - g;
34
+ vec3 i1 = min(g.xyz, l.zxy);
35
+ vec3 i2 = max(g.xyz, l.zxy);
36
+
37
+ vec3 x1 = x0 - i1 + C.xxx;
38
+ vec3 x2 = x0 - i2 + C.yyy;
39
+ vec3 x3 = x0 - D.yyy;
40
+
41
+ i = mod289_3(i);
42
+ vec4 p = permute(permute(permute(
43
+ i.z + vec4(0.0, i1.z, i2.z, 1.0))
44
+ + i.y + vec4(0.0, i1.y, i2.y, 1.0))
45
+ + i.x + vec4(0.0, i1.x, i2.x, 1.0));
46
+
47
+ float n_ = 0.142857142857;
48
+ vec3 ns = n_ * D.wyz - D.xzx;
49
+
50
+ vec4 j = p - 49.0 * floor(p * ns.z * ns.z);
51
+
52
+ vec4 x_ = floor(j * ns.z);
53
+ vec4 y_ = floor(j - 7.0 * x_);
54
+
55
+ vec4 x = x_ * ns.x + ns.yyyy;
56
+ vec4 y = y_ * ns.x + ns.yyyy;
57
+ vec4 h = 1.0 - abs(x) - abs(y);
58
+
59
+ vec4 b0 = vec4(x.xy, y.xy);
60
+ vec4 b1 = vec4(x.zw, y.zw);
61
+
62
+ vec4 s0 = floor(b0) * 2.0 + 1.0;
63
+ vec4 s1 = floor(b1) * 2.0 + 1.0;
64
+ vec4 sh = -step(h, vec4(0.0));
65
+
66
+ vec4 a0 = b0.xzyw + s0.xzyw * sh.xxyy;
67
+ vec4 a1 = b1.xzyw + s1.xzyw * sh.zzww;
68
+
69
+ vec3 p0 = vec3(a0.xy, h.x);
70
+ vec3 p1 = vec3(a0.zw, h.y);
71
+ vec3 p2 = vec3(a1.xy, h.z);
72
+ vec3 p3 = vec3(a1.zw, h.w);
73
+
74
+ vec4 norm = taylorInvSqrt(vec4(dot(p0,p0), dot(p1,p1), dot(p2,p2), dot(p3,p3)));
75
+ p0 *= norm.x;
76
+ p1 *= norm.y;
77
+ p2 *= norm.z;
78
+ p3 *= norm.w;
79
+
80
+ vec4 m = max(0.5 - vec4(dot(x0,x0), dot(x1,x1), dot(x2,x2), dot(x3,x3)), 0.0);
81
+ m = m * m;
82
+ return 105.0 * dot(m * m, vec4(dot(p0,x0), dot(p1,x1), dot(p2,x2), dot(p3,x3)));
83
+ }
84
+
85
+ // Fractional Brownian Motion: stacked octaves of simplex noise
86
+ float fbm(vec3 p) {
87
+ float value = 0.0;
88
+ float amplitude = 0.5;
89
+ float frequency = 1.0;
90
+ for (int i = 0; i < 4; i++) {
91
+ value += amplitude * snoise(p * frequency);
92
+ amplitude *= 0.5;
93
+ frequency *= 2.0;
94
+ }
95
+ return value;
96
+ }
97
+ `;
98
+ export const FRAGMENT_SHADER = /* glsl */ `
99
+ ${SIMPLEX_NOISE_GLSL}
100
+
101
+ uniform float uTime;
102
+ uniform float uAnimation;
103
+ uniform float uInputVolume;
104
+ uniform float uOutputVolume;
105
+ uniform vec3 uColorA;
106
+ uniform vec3 uColorB;
107
+ uniform float uPhaseT;
108
+ uniform float uErrorFlash;
109
+ uniform float uPressed;
110
+ uniform float uOpacity;
111
+ uniform float uVariant; // 0 = metal, 1 = aurora
112
+
113
+ const vec3 ERROR_COLOR = vec3(0.85, 0.18, 0.18);
114
+ const float PI = 3.14159265358979323846;
115
+
116
+ varying vec2 vUv;
117
+
118
+ // =============================================================================
119
+ // METAL look — smooth chrome-like sphere with strong specular + depth shading.
120
+ // Apple Siri / Vision Pro adjacent.
121
+ // =============================================================================
122
+ vec4 metalLook(vec2 uv, float r, vec3 cA, vec3 cB, float drive) {
123
+ // Two octaves of noise for surface variation
124
+ float n = fbm(vec3(uv * 1.4, uAnimation * 0.18)) * 0.5 + 0.5;
125
+ float n2 = fbm(vec3(uv * 2.6 + 4.7, uAnimation * 0.10)) * 0.5 + 0.5;
126
+
127
+ // Vertical light bias — top brighter than bottom (3D illusion)
128
+ float topLight = smoothstep(-1.0, 1.0, uv.y * 0.6 + 0.4);
129
+ float mixT = clamp(n * 0.55 + topLight * 0.45 + n2 * 0.08, 0.0, 1.0);
130
+
131
+ // Light cream color for the highlight band — gives the chrome look.
132
+ // Brightens cA toward white for the upper portion of the sphere.
133
+ vec3 highlightTone = mix(cA, vec3(0.95, 0.92, 0.98), 0.55);
134
+ vec3 deepTone = mix(cB, vec3(0.04, 0.02, 0.10), 0.55);
135
+
136
+ // 4-stop ramp: cream highlight → cA → cB → deep core
137
+ vec3 base;
138
+ if (mixT > 0.66) {
139
+ base = mix(cA, highlightTone, (mixT - 0.66) / 0.34);
140
+ } else if (mixT > 0.33) {
141
+ base = mix(cB, cA, (mixT - 0.33) / 0.33);
142
+ } else {
143
+ base = mix(deepTone, cB, mixT / 0.33);
144
+ }
145
+
146
+ // Specular: tight bright spot top-left
147
+ vec2 specCenter = vec2(-0.35, 0.45);
148
+ float spec = smoothstep(0.45, 0.0, length(uv - specCenter));
149
+ base += vec3(1.0) * spec * 0.32;
150
+
151
+ // Bottom shadow for depth
152
+ float shadow = smoothstep(0.3, 1.0, -uv.y) * 0.35;
153
+ base = mix(base, base * 0.55, shadow);
154
+
155
+ // Inner glow on audio
156
+ float glow = (1.0 - r) * (1.0 - r);
157
+ base += cA * drive * glow * 0.5;
158
+
159
+ return vec4(base, 1.0);
160
+ }
161
+
162
+ // =============================================================================
163
+ // AURORA look — soft layered ribbons drifting across a translucent disc.
164
+ // Northern-lights aesthetic. Calm, atmospheric.
165
+ // =============================================================================
166
+ vec4 auroraLook(vec2 uv, float r, vec3 cA, vec3 cB, float drive) {
167
+ float t = uAnimation * 0.25;
168
+
169
+ // Disc base — deep, translucent. cB darkened.
170
+ vec3 baseColor = mix(cB * 0.25, cB * 0.55, smoothstep(1.0, 0.0, r));
171
+
172
+ // Three drifting ribbons at different y-offsets, angles, and speeds.
173
+ // Each ribbon is a horizontally-stretched soft band whose vertical position
174
+ // oscillates with time + noise.
175
+
176
+ // Ribbon 1 — upper, primary color
177
+ float y1 = uv.y - (sin(t * 0.7 + uv.x * 1.5) * 0.18) - 0.15;
178
+ float band1 = exp(-pow(y1 / 0.18, 2.0));
179
+ float n1 = fbm(vec3(uv.x * 2.0 + t, uv.y * 0.8, t * 0.4)) * 0.5 + 0.5;
180
+ vec3 ribbon1 = cA * band1 * n1 * 1.4;
181
+
182
+ // Ribbon 2 — middle, accent color (cooler — shift cA toward cyan)
183
+ vec3 cool = mix(cA, vec3(0.36, 0.83, 1.0), 0.45);
184
+ float y2 = uv.y - (sin(t * 0.55 + uv.x * 2.2 + 1.7) * 0.22) + 0.05;
185
+ float band2 = exp(-pow(y2 / 0.14, 2.0));
186
+ float n2 = fbm(vec3(uv.x * 2.6 + t * 0.6 + 3.1, uv.y * 0.6, t * 0.3)) * 0.5 + 0.5;
187
+ vec3 ribbon2 = cool * band2 * n2 * 1.0;
188
+
189
+ // Ribbon 3 — lower, warm accent (shift cA toward pink)
190
+ vec3 warm = mix(cA, vec3(1.0, 0.49, 0.83), 0.55);
191
+ float y3 = uv.y - (sin(t * 0.4 + uv.x * 1.8 + 4.2) * 0.18) + 0.30;
192
+ float band3 = exp(-pow(y3 / 0.16, 2.0));
193
+ float n3 = fbm(vec3(uv.x * 2.2 + t * 0.45 + 7.9, uv.y * 0.7, t * 0.35)) * 0.5 + 0.5;
194
+ vec3 ribbon3 = warm * band3 * n3 * 0.85;
195
+
196
+ // Stack ribbons additively over the disc
197
+ vec3 base = baseColor + ribbon1 + ribbon2 + ribbon3;
198
+
199
+ // Audio drive intensifies all ribbons + brightens center
200
+ base += (ribbon1 + ribbon2 + ribbon3) * drive * 0.6;
201
+ float glow = (1.0 - r) * (1.0 - r);
202
+ base += cA * drive * glow * 0.35;
203
+
204
+ // Subtle inner rim accent for legibility against dark backgrounds
205
+ float rim = smoothstep(0.82, 0.98, r);
206
+ base += cA * rim * 0.22;
207
+
208
+ return vec4(base, 1.0);
209
+ }
210
+
211
+ void main() {
212
+ vec2 uv = vUv * 2.0 - 1.0;
213
+ float r = length(uv);
214
+
215
+ // Discard fragments outside the unit circle so the canvas stays clean.
216
+ if (r > 1.0) {
217
+ gl_FragColor = vec4(0.0);
218
+ return;
219
+ }
220
+
221
+ // ---- Phase masks (0..1 for each phase) ---------------------------------
222
+ // uPhaseT smoothly interpolates between numeric phases:
223
+ // 0=idle, 1=listening, 2=thinking, 3=speaking, 4=error.
224
+ float isListening = clamp(1.0 - abs(uPhaseT - 1.0), 0.0, 1.0);
225
+ float isThinking = clamp(1.0 - abs(uPhaseT - 2.0), 0.0, 1.0);
226
+ float isSpeaking = clamp(1.0 - abs(uPhaseT - 3.0), 0.0, 1.0);
227
+
228
+ float inputDrive = uInputVolume * isListening;
229
+ float outputDrive = uOutputVolume * isSpeaking;
230
+ float thinkPulse = isThinking * (0.5 + 0.5 * sin(uTime * 2.0));
231
+ float drive = max(inputDrive, max(outputDrive, thinkPulse * 0.4));
232
+
233
+ // ---- Resolve effective colors with error tint --------------------------
234
+ vec3 cA = mix(uColorA, ERROR_COLOR, uErrorFlash);
235
+ vec3 cB = mix(uColorB, ERROR_COLOR * 0.55, uErrorFlash * 0.7);
236
+
237
+ // ---- Pick variant ------------------------------------------------------
238
+ vec4 result;
239
+ if (uVariant > 0.5) {
240
+ result = auroraLook(uv, r, cA, cB, drive);
241
+ } else {
242
+ result = metalLook(uv, r, cA, cB, drive);
243
+ }
244
+
245
+ vec3 base = result.rgb;
246
+
247
+ // ---- Press: slight core brighten ---------------------------------------
248
+ base = mix(base, base * 1.18, uPressed * (1.0 - smoothstep(0.0, 0.55, r)));
249
+
250
+ // ---- Edge falloff: smooth alpha at the disc boundary -------------------
251
+ float alpha = smoothstep(1.0, 0.92, r);
252
+
253
+ gl_FragColor = vec4(base, alpha * uOpacity);
254
+ }
255
+ `;
@@ -0,0 +1,12 @@
1
+ export interface VolumeSmoother {
2
+ /** Call each frame with the raw 0..1 signal. */
3
+ update(raw: number): void;
4
+ /** Current smoothed value 0..1. */
5
+ readonly value: number;
6
+ }
7
+ /**
8
+ * Creates a volume smoother with asymmetric attack/decay envelopes.
9
+ * @param attackAlpha - Per-frame alpha toward higher values (default 0.85 ≈ 50ms at 60Hz)
10
+ * @param decayAlpha - Per-frame alpha toward lower values (default 0.30 ≈ 300ms at 60Hz)
11
+ */
12
+ export declare function createVolumeSmoother(attackAlpha?: number, decayAlpha?: number): VolumeSmoother;
@@ -0,0 +1,21 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Volume signal smoother: fast attack (~50ms), slow decay (~300ms).
3
+ // Attack/decay constants are frame-rate independent when called at ~60Hz.
4
+ /**
5
+ * Creates a volume smoother with asymmetric attack/decay envelopes.
6
+ * @param attackAlpha - Per-frame alpha toward higher values (default 0.85 ≈ 50ms at 60Hz)
7
+ * @param decayAlpha - Per-frame alpha toward lower values (default 0.30 ≈ 300ms at 60Hz)
8
+ */
9
+ export function createVolumeSmoother(attackAlpha = 0.85, decayAlpha = 0.3) {
10
+ let _value = 0;
11
+ return {
12
+ update(raw) {
13
+ const clamped = Math.min(1, Math.max(0, raw));
14
+ const alpha = clamped > _value ? attackAlpha : decayAlpha;
15
+ _value = _value + (clamped - _value) * alpha;
16
+ },
17
+ get value() {
18
+ return _value;
19
+ },
20
+ };
21
+ }
@@ -0,0 +1,45 @@
1
+ import * as React from 'react';
2
+ import type { TurnPhase } from './types.js';
3
+ import { type VoiceOrbVariant } from './voice-orb-scene.js';
4
+ export type { VoiceOrbVariant } from './voice-orb-scene.js';
5
+ /**
6
+ * Convert any CSS color token to a hex string the Three.js shader can consume.
7
+ *
8
+ * Handles:
9
+ * - hex: #RGB / #RRGGBB / #RRGGBBAA
10
+ * - Tailwind/shadcn space-separated HSL: "271 36% 60%"
11
+ * - hsl(), rgb(), oklch(), color(), var() — resolved via browser CSSOM
12
+ */
13
+ export declare function parseColor(value: string, fallback?: string): string;
14
+ export interface VoiceOrbProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
15
+ /** Held / not held. Drives the squish + halo intensity. */
16
+ pressed: boolean;
17
+ /** 0..1 mic input level. Drives shader input-volume uniform. */
18
+ level?: number;
19
+ /** 0..1 TTS playback level. Drives shader output-volume uniform. */
20
+ outputLevel?: number;
21
+ /** Visual phase. Drives state-specific shader behavior. */
22
+ phase?: TurnPhase;
23
+ /** Diameter in px. Defaults to 144. */
24
+ size?: number;
25
+ disabled?: boolean;
26
+ /** A11y label. Defaults to "Hold to talk". */
27
+ label?: string;
28
+ /**
29
+ * Override the gradient pair [colorA, colorB]. If omitted, reads --primary
30
+ * and --accent from CSS at mount and on theme change.
31
+ */
32
+ colors?: [string, string];
33
+ /**
34
+ * Visual variant.
35
+ *
36
+ * - `'aurora'` (default): soft layered ribbons of color drifting across a
37
+ * translucent disc. Northern-lights aesthetic. Calm, atmospheric, reads
38
+ * like motion even at idle. Default for Medal apps.
39
+ * - `'metal'`: smooth chrome-like sphere with strong specular + depth
40
+ * shading. Apple Siri / Vision Pro adjacent. Use for object-like,
41
+ * premium-feeling presence.
42
+ */
43
+ variant?: VoiceOrbVariant;
44
+ }
45
+ export declare const VoiceOrb: React.ForwardRefExoticComponent<VoiceOrbProps & React.RefAttributes<HTMLButtonElement>>;
@@ -0,0 +1,123 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ import { Canvas } from '@react-three/fiber';
4
+ import * as React from 'react';
5
+ import { Scene } from './voice-orb-scene.js';
6
+ // Note: CSS for `.meda-voice-orb` is shipped via @medalsocial/meda/styles.css
7
+ // (re-exported from globals.css). We avoid a JS-side CSS import here so
8
+ // Meda's tsc-only build pipeline doesn't need CSS bundling.
9
+ // ---------------------------------------------------------------------------
10
+ // Theming helpers
11
+ // ---------------------------------------------------------------------------
12
+ const FALLBACK_COLOR = '#9A6AC2'; // Pilot purple
13
+ function hslToHex(h, s, l) {
14
+ const sl = s / 100;
15
+ const ll = l / 100;
16
+ const a = sl * Math.min(ll, 1 - ll);
17
+ const f = (n) => {
18
+ const k = (n + h / 30) % 12;
19
+ const color = ll - a * Math.max(Math.min(k - 3, 9 - k, 1), -1);
20
+ return Math.round(255 * color)
21
+ .toString(16)
22
+ .padStart(2, '0');
23
+ };
24
+ return `#${f(0)}${f(8)}${f(4)}`;
25
+ }
26
+ /**
27
+ * Convert any CSS color token to a hex string the Three.js shader can consume.
28
+ *
29
+ * Handles:
30
+ * - hex: #RGB / #RRGGBB / #RRGGBBAA
31
+ * - Tailwind/shadcn space-separated HSL: "271 36% 60%"
32
+ * - hsl(), rgb(), oklch(), color(), var() — resolved via browser CSSOM
33
+ */
34
+ export function parseColor(value, fallback = FALLBACK_COLOR) {
35
+ const v = value.trim();
36
+ if (!v)
37
+ return fallback;
38
+ // Already a hex literal — pass through directly.
39
+ if (/^#[0-9a-fA-F]{3,8}$/.test(v))
40
+ return v;
41
+ // Tailwind/shadcn convention: "H S% L%" (no leading "hsl(")
42
+ const hslMatch = v.match(/^(\d+(?:\.\d+)?)\s+(\d+(?:\.\d+)?)%\s+(\d+(?:\.\d+)?)%$/);
43
+ if (hslMatch) {
44
+ return hslToHex(Number(hslMatch[1]), Number(hslMatch[2]), Number(hslMatch[3]));
45
+ }
46
+ // Functional notation — hsl(), rgb(), oklch(), color(), var(), etc.
47
+ // Let the browser resolve it via a throw-away element.
48
+ if (typeof document !== 'undefined' && (v.includes('(') || v.startsWith('var'))) {
49
+ const probe = document.createElement('div');
50
+ probe.style.display = 'none';
51
+ probe.style.color = v;
52
+ document.body.appendChild(probe);
53
+ const computed = getComputedStyle(probe).color;
54
+ document.body.removeChild(probe);
55
+ const m = computed.match(/rgb(?:a)?\((\d+)[,\s]+(\d+)[,\s]+(\d+)/);
56
+ if (m) {
57
+ const r = Number(m[1]);
58
+ const g = Number(m[2]);
59
+ const b = Number(m[3]);
60
+ return `#${[r, g, b].map((x) => x.toString(16).padStart(2, '0')).join('')}`;
61
+ }
62
+ }
63
+ return fallback;
64
+ }
65
+ function readMedaColors(el) {
66
+ const style = getComputedStyle(el);
67
+ const primary = style.getPropertyValue('--primary').trim();
68
+ const accent = style.getPropertyValue('--accent').trim();
69
+ const colorA = parseColor(primary);
70
+ const colorB = accent ? parseColor(accent) : colorA;
71
+ return [colorA, colorB];
72
+ }
73
+ const DEFAULT_COLORS = ['#9A6AC2', '#7B4FAB'];
74
+ // ---------------------------------------------------------------------------
75
+ // Component
76
+ // ---------------------------------------------------------------------------
77
+ export const VoiceOrb = React.forwardRef(function VoiceOrb({ pressed, level = 0, outputLevel = 0, phase = 'idle', size = 144, disabled, label = 'Hold to talk', colors: colorsProp, variant = 'aurora', className, style, ...rest }, ref) {
78
+ const buttonRef = React.useRef(null);
79
+ // Merge forwarded ref
80
+ React.useImperativeHandle(ref, () => buttonRef.current);
81
+ // Resolved theme colors (from CSSOM unless overridden by prop)
82
+ const [resolvedColors, setResolvedColors] = React.useState(colorsProp ?? DEFAULT_COLORS);
83
+ // prefers-reduced-motion
84
+ const [reducedMotion, setReducedMotion] = React.useState(false);
85
+ React.useEffect(() => {
86
+ const mq = window.matchMedia('(prefers-reduced-motion: reduce)');
87
+ setReducedMotion(mq.matches);
88
+ const handler = (e) => setReducedMotion(e.matches);
89
+ mq.addEventListener('change', handler);
90
+ return () => mq.removeEventListener('change', handler);
91
+ }, []);
92
+ // Theme color resolution
93
+ React.useEffect(() => {
94
+ if (colorsProp) {
95
+ setResolvedColors(colorsProp);
96
+ return;
97
+ }
98
+ const update = () => {
99
+ setResolvedColors(readMedaColors(document.documentElement));
100
+ };
101
+ update();
102
+ const observer = new MutationObserver(update);
103
+ observer.observe(document.documentElement, {
104
+ attributes: true,
105
+ attributeFilter: ['class', 'data-theme'],
106
+ });
107
+ return () => observer.disconnect();
108
+ }, [colorsProp]);
109
+ const btnStyle = {
110
+ width: size,
111
+ height: size,
112
+ ...style,
113
+ };
114
+ return (_jsx("button", { ref: buttonRef, type: "button", "aria-pressed": pressed, "aria-label": label, disabled: disabled, "data-phase": phase, "data-pressed": pressed, className: [
115
+ 'meda-voice-orb',
116
+ 'relative inline-flex items-center justify-center rounded-full',
117
+ 'select-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
118
+ 'transition-transform duration-200 ease-out',
119
+ disabled ? 'cursor-not-allowed opacity-60' : 'cursor-pointer',
120
+ pressed ? 'scale-[0.97]' : 'scale-100',
121
+ className ?? '',
122
+ ].join(' '), style: btnStyle, ...rest, children: _jsx(Canvas, { gl: { alpha: true, antialias: true, premultipliedAlpha: true }, style: { pointerEvents: 'none' }, children: _jsx(Scene, { level: level, outputLevel: outputLevel, phase: phase, colors: resolvedColors, reducedMotion: reducedMotion, pressed: pressed, variant: variant }) }) }));
123
+ });
@@ -0,0 +1,7 @@
1
+ import type { TurnPhase } from './types.js';
2
+ export interface VoiceStatusPillProps {
3
+ phase: TurnPhase;
4
+ thinkingForMs?: number;
5
+ className?: string;
6
+ }
7
+ export declare function VoiceStatusPill({ phase, thinkingForMs, className }: VoiceStatusPillProps): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,26 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ const PHASE_LABEL = {
3
+ idle: 'Idle',
4
+ listening: 'Listening',
5
+ thinking: 'Thinking',
6
+ speaking: 'Speaking',
7
+ error: 'Error',
8
+ };
9
+ export function VoiceStatusPill({ phase, thinkingForMs, className }) {
10
+ const tone = phase === 'listening'
11
+ ? 'bg-primary text-primary-foreground'
12
+ : phase === 'speaking'
13
+ ? 'bg-emerald-500 text-white'
14
+ : phase === 'error'
15
+ ? 'bg-destructive text-destructive-foreground'
16
+ : 'bg-muted text-muted-foreground';
17
+ const seconds = thinkingForMs ? (thinkingForMs / 1000).toFixed(1) : null;
18
+ return (_jsxs("span", { role: "status", "data-phase": phase, className: [
19
+ 'inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-xs font-medium',
20
+ tone,
21
+ className ?? '',
22
+ ].join(' '), children: [_jsx("span", { className: [
23
+ 'size-1.5 rounded-full bg-current',
24
+ phase === 'thinking' ? 'animate-pulse' : '',
25
+ ].join(' ') }), PHASE_LABEL[phase], phase === 'thinking' && seconds && _jsxs("span", { className: "opacity-70", children: ["\u00B7 ", seconds, "s"] })] }));
26
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@medalsocial/meda",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "Shared Meda UI shell and runtime package.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -28,15 +28,25 @@
28
28
  "types": "./dist/shell/index.d.ts",
29
29
  "default": "./dist/shell/index.js"
30
30
  },
31
+ "./voice": {
32
+ "types": "./dist/voice/index.d.ts",
33
+ "default": "./dist/voice/index.js"
34
+ },
31
35
  "./styles.css": "./dist/styles.css"
32
36
  },
33
37
  "publishConfig": {
34
38
  "access": "public"
35
39
  },
36
40
  "peerDependencies": {
41
+ "@react-three/fiber": "^9.0.0",
37
42
  "react": ">=19",
38
43
  "react-dom": ">=19"
39
44
  },
45
+ "peerDependenciesMeta": {
46
+ "@react-three/fiber": {
47
+ "optional": true
48
+ }
49
+ },
40
50
  "dependencies": {
41
51
  "@base-ui/react": "^1.4.0",
42
52
  "@fontsource-variable/geist": "^5.2.8",
@@ -47,12 +57,17 @@
47
57
  "lucide-react": "^1.8.0",
48
58
  "react-resizable-panels": "^4.10.0",
49
59
  "tailwind-merge": "^3.5.0",
60
+ "three": "^0.170.0",
50
61
  "vaul": "^1.1.2"
51
62
  },
52
63
  "devDependencies": {
53
64
  "@biomejs/biome": "^2.4.12",
54
65
  "@changesets/changelog-github": "^0.5.1",
55
66
  "@changesets/cli": "^2.29.0",
67
+ "@react-three/drei": "^10.7.7",
68
+ "@react-three/fiber": "^9.6.0",
69
+ "@tailwindcss/vite": "^4.2.4",
70
+ "@types/three": "^0.184.0",
56
71
  "@testing-library/jest-dom": "^6.9.1",
57
72
  "@testing-library/react": "^16.3.2",
58
73
  "@types/react": "^19.2.14",
@@ -62,9 +77,11 @@
62
77
  "jsdom": "^29.0.2",
63
78
  "react": "^19.2.4",
64
79
  "react-dom": "^19.2.4",
80
+ "tailwindcss": "^4.2.4",
65
81
  "typescript": "~6.0.2",
66
82
  "vite": "^8.0.4",
67
- "vitest": "^4.1.4"
83
+ "vitest": "^4.1.4",
84
+ "wrangler": "^4.0.0"
68
85
  },
69
86
  "scripts": {
70
87
  "build": "pnpm exec tsc -p tsconfig.build.json && node ./scripts/build.mjs",
@@ -73,11 +90,14 @@
73
90
  "format": "biome format --write .",
74
91
  "demo:dev": "vite --config vite.config.demo.ts",
75
92
  "demo:build": "vite build --config vite.config.demo.ts",
93
+ "worker:build": "pnpm demo:build && node ./scripts/copy-registry-to-dist.mjs",
94
+ "worker:dev": "pnpm worker:build && wrangler dev",
95
+ "worker:deploy": "pnpm worker:build && wrangler deploy",
76
96
  "test": "vitest run --environment jsdom",
77
97
  "test:run": "vitest run --environment jsdom",
78
98
  "typecheck": "tsc --noEmit",
79
99
  "version": "changeset version",
80
- "release": "pnpm build && pnpm publish --access public --no-git-checks",
100
+ "release": "pnpm build && changeset publish",
81
101
  "registry:validate": "node ./registry/scripts/validate-registry.mjs"
82
102
  }
83
103
  }