@medalsocial/meda 0.1.0 → 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/LICENSE +201 -0
- package/README.md +45 -227
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/shell/index.d.ts +15 -15
- package/dist/shell/index.js +15 -15
- package/dist/shell/public.d.ts +2 -4
- package/dist/shell/public.js +1 -3
- package/dist/shell/shell-app-rail.d.ts +1 -1
- package/dist/shell/shell-header.js +1 -1
- package/dist/shell/shell-layout-utils.d.ts +1 -1
- package/dist/shell/shell-module-nav.d.ts +1 -1
- package/dist/shell/shell-panel-rail.d.ts +1 -1
- package/dist/shell/shell-route-utils.d.ts +1 -1
- package/dist/shell/shell-scrollable-content.d.ts +1 -1
- package/dist/shell/shell-state.js +4 -1
- package/dist/shell/shell-tab-bar.d.ts +1 -1
- package/dist/shell/shell-tab-bar.js +1 -1
- package/dist/shell/utils.d.ts +1 -1
- package/dist/shell/workbench-layout.d.ts +1 -1
- package/dist/styles.css +22 -0
- package/dist/voice/index.d.ts +9 -0
- package/dist/voice/index.js +4 -0
- package/dist/voice/public.d.ts +1 -0
- package/dist/voice/public.js +1 -0
- package/dist/voice/types.d.ts +8 -0
- package/dist/voice/types.js +1 -0
- package/dist/voice/use-mic-capture.d.ts +14 -0
- package/dist/voice/use-mic-capture.js +185 -0
- package/dist/voice/voice-level.d.ts +8 -0
- package/dist/voice/voice-level.js +29 -0
- package/dist/voice/voice-orb-scene.d.ts +12 -0
- package/dist/voice/voice-orb-scene.js +121 -0
- package/dist/voice/voice-orb-shader.d.ts +2 -0
- package/dist/voice/voice-orb-shader.js +255 -0
- package/dist/voice/voice-orb-volume.d.ts +12 -0
- package/dist/voice/voice-orb-volume.js +21 -0
- package/dist/voice/voice-orb.d.ts +45 -0
- package/dist/voice/voice-orb.js +123 -0
- package/dist/voice/voice-status-pill.d.ts +7 -0
- package/dist/voice/voice-status-pill.js +26 -0
- package/package.json +53 -12
|
@@ -10,7 +10,9 @@ export function clampSidePanelWidth(width) {
|
|
|
10
10
|
function readStoredSidePanelWidth(storageKey) {
|
|
11
11
|
if (typeof window === 'undefined')
|
|
12
12
|
return DEFAULT_SIDE_PANEL_WIDTH;
|
|
13
|
-
const storedValue = typeof window.localStorage?.getItem === 'function'
|
|
13
|
+
const storedValue = typeof window.localStorage?.getItem === 'function'
|
|
14
|
+
? window.localStorage.getItem(storageKey)
|
|
15
|
+
: null;
|
|
14
16
|
const stored = Number(storedValue);
|
|
15
17
|
return Number.isFinite(stored) && stored > 0
|
|
16
18
|
? clampSidePanelWidth(stored)
|
|
@@ -102,6 +104,7 @@ export function ShellStateProvider({ children, adapter, initialActiveRail = 'hom
|
|
|
102
104
|
function closeCommandPalette() {
|
|
103
105
|
setCommandPaletteOpen(false);
|
|
104
106
|
}
|
|
107
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: inner setters/handlers are re-created each render; wrapping all of them in useCallback is a separate refactor — deps here are the observable state values only.
|
|
105
108
|
const value = useMemo(() => ({
|
|
106
109
|
activePanelView,
|
|
107
110
|
lastPanelView: lastPanelViewRef.current,
|
|
@@ -20,7 +20,7 @@ export function ShellTabBar({ tabs, activeTab, onTabChange, className, ariaLabel
|
|
|
20
20
|
});
|
|
21
21
|
}) }));
|
|
22
22
|
}
|
|
23
|
-
return (_jsx("
|
|
23
|
+
return (_jsx("nav", { "aria-label": ariaLabel, className: joinClasses(tabBarClassName, className), children: tabs.map((tab) => {
|
|
24
24
|
const isActive = tab.id === activeTab;
|
|
25
25
|
return (_jsx("button", { type: "button", "data-active": isActive, onClick: () => onTabChange?.(tab.id), className: joinClasses(tabClassName, isActive ? activeTabClassName : inactiveTabClassName), children: tab.label }, tab.id));
|
|
26
26
|
}) }));
|
package/dist/shell/utils.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ShellCommandDefinition, ShellModuleDefinition, ShellPanelDefinition } from './types';
|
|
1
|
+
import type { ShellCommandDefinition, ShellModuleDefinition, ShellPanelDefinition } from './types.js';
|
|
2
2
|
export interface ShellPanelCollection {
|
|
3
3
|
globalViews: ShellPanelDefinition[];
|
|
4
4
|
moduleViews: ShellPanelDefinition[];
|
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 @@
|
|
|
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";
|