orb-ui 0.1.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 ADDED
@@ -0,0 +1,114 @@
1
+ # orb-ui
2
+
3
+ The simplest way to add voice UI to your React app. One install, one component, works with Vapi and ElevenLabs out of the box.
4
+
5
+ ```jsx
6
+ import { VoiceOrb } from 'orb-ui'
7
+ import { createVapiAdapter } from 'orb-ui/adapters'
8
+
9
+ <VoiceOrb adapter={createVapiAdapter(vapi, { assistantId: 'your-id' })} theme="circle" />
10
+ ```
11
+
12
+ ## Install
13
+
14
+ ```bash
15
+ npm install orb-ui
16
+ # or
17
+ yarn add orb-ui
18
+ ```
19
+
20
+ ## Quick Start
21
+
22
+ ### With Vapi
23
+
24
+ ```jsx
25
+ import Vapi from '@vapi-ai/web'
26
+ import { VoiceOrb } from 'orb-ui'
27
+ import { createVapiAdapter } from 'orb-ui/adapters'
28
+
29
+ const vapi = new Vapi('your-public-key')
30
+ const adapter = createVapiAdapter(vapi, { assistantId: 'your-assistant-id' })
31
+
32
+ function App() {
33
+ return <VoiceOrb adapter={adapter} theme="circle" />
34
+ }
35
+ ```
36
+
37
+ ### With ElevenLabs
38
+
39
+ ```jsx
40
+ import { Conversation } from '@elevenlabs/client'
41
+ import { VoiceOrb } from 'orb-ui'
42
+ import { createElevenLabsAdapter } from 'orb-ui/adapters'
43
+
44
+ const adapter = createElevenLabsAdapter(Conversation, { agentId: 'your-agent-id' })
45
+
46
+ function App() {
47
+ return <VoiceOrb adapter={adapter} theme="circle" />
48
+ }
49
+ ```
50
+
51
+ ### Controlled mode (custom integration)
52
+
53
+ ```jsx
54
+ import { VoiceOrb } from 'orb-ui'
55
+ import { useState } from 'react'
56
+
57
+ function App() {
58
+ const [state, setState] = useState('idle')
59
+ const [volume, setVolume] = useState(0)
60
+
61
+ return <VoiceOrb state={state} volume={volume} theme="circle" />
62
+ }
63
+ ```
64
+
65
+ ## Themes
66
+
67
+ | Theme | Description |
68
+ |---|---|
69
+ | `debug` | State + volume display with start/stop. Use to verify your integration works. |
70
+ | `circle` | Pulsing circle that reacts to volume. |
71
+ | `bars` | Three bars that animate with voice. |
72
+
73
+ ## Props
74
+
75
+ | Prop | Type | Default | Description |
76
+ |---|---|---|---|
77
+ | `theme` | `'debug' \| 'circle' \| 'bars'` | `'debug'` | Visual theme |
78
+ | `state` | `OrbState` | `'idle'` | Conversation state (controlled mode) |
79
+ | `volume` | `number` | `0` | Audio volume, 0–1 (controlled mode) |
80
+ | `adapter` | `OrbAdapter` | — | Provider adapter (manages state + volume automatically) |
81
+ | `size` | `number` | `200` | Size in pixels |
82
+ | `onStart` | `() => void` | — | Custom start handler (overrides adapter.start()) |
83
+ | `onStop` | `() => void` | — | Custom stop handler (overrides adapter.stop()) |
84
+
85
+ ## States
86
+
87
+ `idle` · `connecting` · `listening` · `thinking` · `speaking` · `error` · `disconnected`
88
+
89
+ ## Supported Providers
90
+
91
+ | Provider | Adapter |
92
+ |---|---|
93
+ | [Vapi](https://vapi.ai) | `createVapiAdapter` from `orb-ui/adapters` |
94
+ | [ElevenLabs](https://elevenlabs.io/conversational-ai) | `createElevenLabsAdapter` from `orb-ui/adapters` |
95
+ | Custom | Use controlled mode — pass `state` and `volume` directly |
96
+
97
+ ## Development
98
+
99
+ ```bash
100
+ git clone https://github.com/alexanderqchen/orb-ui.git
101
+ cd orb-ui
102
+ yarn install
103
+ cd demo && yarn install && cd ..
104
+
105
+ # Build the library
106
+ yarn build
107
+
108
+ # Run demo locally
109
+ cd demo && yarn dev
110
+ ```
111
+
112
+ ## License
113
+
114
+ MIT © [Alexander Chen](https://github.com/alexanderqchen)
@@ -0,0 +1,146 @@
1
+ export declare interface AdapterCallbacks {
2
+ onStateChange: (state: OrbState) => void;
3
+ onVolumeChange: (volume: number) => void;
4
+ }
5
+
6
+ /**
7
+ * Creates an OrbAdapter for ElevenLabs Conversational AI.
8
+ *
9
+ * Unlike createVapiAdapter, this adapter owns the session lifecycle because
10
+ * ElevenLabs requires callbacks to be injected at Conversation.startSession()
11
+ * time. Use the returned start() and stop() methods with VoiceOrb's props.
12
+ *
13
+ * @param ConversationClass - The Conversation class from @elevenlabs/client
14
+ * @param config - agentId / signedUrl + any other startSession options
15
+ *
16
+ * @example
17
+ * import { Conversation } from '@elevenlabs/client'
18
+ * import { VoiceOrb } from 'orb-ui'
19
+ * import { createElevenLabsAdapter } from 'orb-ui/adapters'
20
+ *
21
+ * const adapter = createElevenLabsAdapter(Conversation, {
22
+ * agentId: 'your-agent-id',
23
+ * })
24
+ *
25
+ * function App() {
26
+ * return (
27
+ * <VoiceOrb
28
+ * adapter={adapter}
29
+ * theme="circle"
30
+ * onStart={() => adapter.start()}
31
+ * onStop={() => adapter.stop()}
32
+ * />
33
+ * )
34
+ * }
35
+ */
36
+ export declare function createElevenLabsAdapter(ConversationClass: ElevenLabsConversationClass, config: ElevenLabsConfig): ElevenLabsOrbAdapter;
37
+
38
+ export declare function createVapiAdapter(client: VapiClient, options?: VapiAdapterOptions): OrbAdapter;
39
+
40
+ declare interface ElevenLabsCallbacks {
41
+ onConnect?: (props: {
42
+ conversationId: string;
43
+ }) => void;
44
+ onDisconnect?: (details: unknown) => void;
45
+ onError?: (message: string, context?: unknown) => void;
46
+ onModeChange?: (prop: {
47
+ mode: ElevenLabsMode;
48
+ }) => void;
49
+ onStatusChange?: (prop: {
50
+ status: ElevenLabsStatus;
51
+ }) => void;
52
+ onVadScore?: (props: {
53
+ vadScore: number;
54
+ }) => void;
55
+ }
56
+
57
+ declare type ElevenLabsConfig = {
58
+ agentId?: string;
59
+ signedUrl?: string;
60
+ [key: string]: unknown;
61
+ };
62
+
63
+ declare interface ElevenLabsConversation {
64
+ endSession(): Promise<void>;
65
+ getInputVolume(): number;
66
+ getOutputVolume(): number;
67
+ getInputByteFrequencyData(): Uint8Array;
68
+ getOutputByteFrequencyData(): Uint8Array;
69
+ }
70
+
71
+ declare interface ElevenLabsConversationClass {
72
+ startSession(options: ElevenLabsConfig & ElevenLabsCallbacks): Promise<ElevenLabsConversation>;
73
+ }
74
+
75
+ declare type ElevenLabsMode = 'speaking' | 'listening';
76
+
77
+ export declare interface ElevenLabsOrbAdapter extends OrbAdapter {
78
+ /** Call this from VoiceOrb's onStart prop to begin a conversation. */
79
+ start(): Promise<void>;
80
+ /** Call this from VoiceOrb's onStop prop to end the current conversation. */
81
+ stop(): Promise<void>;
82
+ }
83
+
84
+ declare type ElevenLabsStatus = 'disconnected' | 'connecting' | 'connected' | 'disconnecting';
85
+
86
+ export declare interface OrbAdapter {
87
+ /**
88
+ * Subscribe to state and volume changes from a voice provider.
89
+ * Returns an unsubscribe function to clean up listeners.
90
+ */
91
+ subscribe(callbacks: {
92
+ onStateChange: (state: OrbState) => void;
93
+ onVolumeChange: (volume: number) => void;
94
+ }): () => void;
95
+ /** Start the voice session. Called internally by VoiceOrb on click. */
96
+ start?: () => void | Promise<void>;
97
+ /** Stop the voice session. Called internally by VoiceOrb on click. */
98
+ stop?: () => void | Promise<void>;
99
+ }
100
+
101
+ export declare type OrbState = 'idle' | 'connecting' | 'listening' | 'thinking' | 'speaking' | 'error' | 'disconnected';
102
+
103
+ /**
104
+ * Creates an OrbAdapter for Vapi voice agents.
105
+ *
106
+ * State mapping:
107
+ * vapi.start() called (intercepted) → 'connecting'
108
+ * call-start → 'listening'
109
+ * message (final user transcript) → 'thinking'
110
+ * speech-start → 'speaking'
111
+ * speech-end → 'listening' (debounced 350 ms)
112
+ * call-end → 'disconnected'
113
+ * error → 'error'
114
+ *
115
+ * Volume: raw Vapi values are normalized (noise gate + EMA) before being
116
+ * passed to onVolumeChange, so themes receive a clean 0–1 signal.
117
+ *
118
+ * @param client - A Vapi instance from @vapi-ai/web
119
+ * @param options - Optional config (e.g. assistantId to pass to vapi.start())
120
+ */
121
+ declare interface VapiAdapterOptions {
122
+ /** Assistant ID passed to vapi.start() when the orb is clicked. */
123
+ assistantId?: string;
124
+ }
125
+
126
+ declare interface VapiClient {
127
+ on(event: 'call-start', listener: () => void): void;
128
+ on(event: 'call-end', listener: () => void): void;
129
+ on(event: 'speech-start', listener: () => void): void;
130
+ on(event: 'speech-end', listener: () => void): void;
131
+ on(event: 'volume-level', listener: (volume: number) => void): void;
132
+ on(event: 'message', listener: (message: VapiMessage) => void): void;
133
+ on(event: 'error', listener: (error: unknown) => void): void;
134
+ removeListener(event: string, listener: (...args: unknown[]) => void): void;
135
+ start(...args: any[]): Promise<unknown>;
136
+ stop(): void;
137
+ }
138
+
139
+ declare interface VapiMessage {
140
+ type: string;
141
+ role?: string;
142
+ transcriptType?: 'partial' | 'final';
143
+ transcript?: string;
144
+ }
145
+
146
+ export { }
@@ -0,0 +1,149 @@
1
+ function h(e) {
2
+ let l = "idle", t = null;
3
+ function s(i) {
4
+ if (t && (clearTimeout(t), t = null), l === "speaking" && i === "listening") {
5
+ t = setTimeout(() => {
6
+ l = i, e(i), t = null;
7
+ }, 350);
8
+ return;
9
+ }
10
+ l = i, e(i);
11
+ }
12
+ function c() {
13
+ t && (clearTimeout(t), t = null);
14
+ }
15
+ return { emitState: s, clearTimer: c };
16
+ }
17
+ function I(e, l) {
18
+ var m;
19
+ let t = 0;
20
+ function s(a) {
21
+ const n = a < 0.12 ? 0 : (a - 0.12) / 0.88, o = n > t ? 0.65 : 0.12;
22
+ return t = t + (n - t) * o, t;
23
+ }
24
+ let c = null;
25
+ if (typeof navigator < "u" && ((m = navigator.mediaDevices) != null && m.getUserMedia)) {
26
+ const a = navigator.mediaDevices.getUserMedia.bind(navigator.mediaDevices);
27
+ navigator.mediaDevices.getUserMedia = async (n) => {
28
+ const o = await a(n);
29
+ return n != null && n.audio && (c = o), o;
30
+ };
31
+ }
32
+ function i() {
33
+ c == null || c.getTracks().forEach((a) => {
34
+ a.readyState === "live" && a.stop();
35
+ }), c = null;
36
+ }
37
+ return {
38
+ async start() {
39
+ await e.start(l == null ? void 0 : l.assistantId);
40
+ },
41
+ stop() {
42
+ e.stop();
43
+ },
44
+ subscribe({ onStateChange: a, onVolumeChange: n }) {
45
+ const { emitState: o, clearTimer: g } = h(a), d = () => o("listening"), v = () => {
46
+ o("disconnected"), n(0), t = 0, i();
47
+ }, r = () => o("speaking"), f = () => {
48
+ o("listening"), n(0);
49
+ }, E = (u) => {
50
+ n(s(u));
51
+ }, S = (u) => {
52
+ u.type === "transcript" && u.transcriptType === "final" && u.role === "user" && (o("thinking"), n(0));
53
+ }, O = (u) => {
54
+ console.error("[orb-ui/vapi] Error:", u), o("error"), n(0), t = 0, i();
55
+ };
56
+ e.on("call-start", d), e.on("call-end", v), e.on("speech-start", r), e.on("speech-end", f), e.on("volume-level", E), e.on("message", S), e.on("error", O);
57
+ const b = e.start.bind(e);
58
+ return e.start = async (...u) => (o("connecting"), b(...u)), () => {
59
+ g(), e.removeListener("call-start", d), e.removeListener("call-end", v), e.removeListener("speech-start", r), e.removeListener("speech-end", f), e.removeListener("volume-level", E), e.removeListener("message", S), e.removeListener("error", O), e.start = b, i();
60
+ };
61
+ }
62
+ };
63
+ }
64
+ const y = 2.7, p = 0.05, M = 0.5, T = 0.15;
65
+ function L() {
66
+ let e = 0;
67
+ return function(t) {
68
+ const s = t > e ? M : T;
69
+ return e = e + (t - e) * s, e;
70
+ };
71
+ }
72
+ function _(e, l) {
73
+ let t = null, s = null, c = "listening";
74
+ const i = L(), m = L(), a = /* @__PURE__ */ new Set();
75
+ function n(r) {
76
+ a.forEach((f) => f.onStateChange(r));
77
+ }
78
+ function o(r) {
79
+ a.forEach((f) => f.onVolumeChange(r));
80
+ }
81
+ function g() {
82
+ s || (s = setInterval(() => {
83
+ if (!t) return;
84
+ const r = t.getOutputVolume();
85
+ o(i(Math.min(1, r * y)));
86
+ }, 33));
87
+ }
88
+ function d() {
89
+ s && (clearInterval(s), s = null), o(0);
90
+ }
91
+ const v = {
92
+ onStatusChange: ({ status: r }) => {
93
+ r === "connecting" && n("connecting");
94
+ },
95
+ onConnect: () => {
96
+ n("listening");
97
+ },
98
+ onModeChange: ({ mode: r }) => {
99
+ c = r, r === "speaking" ? (n("speaking"), g()) : (n("listening"), d());
100
+ },
101
+ onVadScore: ({ vadScore: r }) => {
102
+ if (c === "listening") {
103
+ const f = r < p ? 0 : (r - p) / (1 - p);
104
+ o(m(f));
105
+ }
106
+ },
107
+ onDisconnect: () => {
108
+ d(), n("disconnected"), o(0), t = null;
109
+ },
110
+ onError: (r) => {
111
+ console.error("[orb-ui/elevenlabs] Error:", r), d(), n("error"), o(0), t = null;
112
+ }
113
+ };
114
+ return {
115
+ // ── OrbAdapter.subscribe ────────────────────────────────────────────────
116
+ subscribe(r) {
117
+ return a.add(r), () => {
118
+ a.delete(r), a.size === 0 && d();
119
+ };
120
+ },
121
+ // ── Lifecycle ───────────────────────────────────────────────────────────
122
+ async start() {
123
+ if (!t)
124
+ try {
125
+ t = await e.startSession({
126
+ ...l,
127
+ ...v
128
+ });
129
+ } catch (r) {
130
+ console.error("[orb-ui/elevenlabs] startSession failed:", r), n("error"), o(0);
131
+ }
132
+ },
133
+ async stop() {
134
+ if (t) {
135
+ d();
136
+ try {
137
+ await t.endSession();
138
+ } catch (r) {
139
+ console.error("[orb-ui/elevenlabs] endSession failed:", r);
140
+ }
141
+ t = null;
142
+ }
143
+ }
144
+ };
145
+ }
146
+ export {
147
+ _ as createElevenLabsAdapter,
148
+ I as createVapiAdapter
149
+ };
@@ -0,0 +1,52 @@
1
+ import { JSX as JSX_2 } from 'react/jsx-runtime';
2
+
3
+ export declare interface OrbAdapter {
4
+ /**
5
+ * Subscribe to state and volume changes from a voice provider.
6
+ * Returns an unsubscribe function to clean up listeners.
7
+ */
8
+ subscribe(callbacks: {
9
+ onStateChange: (state: OrbState) => void;
10
+ onVolumeChange: (volume: number) => void;
11
+ }): () => void;
12
+ /** Start the voice session. Called internally by VoiceOrb on click. */
13
+ start?: () => void | Promise<void>;
14
+ /** Stop the voice session. Called internally by VoiceOrb on click. */
15
+ stop?: () => void | Promise<void>;
16
+ }
17
+
18
+ export declare type OrbState = 'idle' | 'connecting' | 'listening' | 'thinking' | 'speaking' | 'error' | 'disconnected';
19
+
20
+ export declare type OrbTheme = 'debug' | 'circle' | 'bars';
21
+
22
+ export declare function VoiceOrb({ state: stateProp, volume: volumeProp, adapter, theme, size, className, style, onStart, onStop, }: VoiceOrbProps): JSX_2.Element;
23
+
24
+ export declare interface VoiceOrbProps {
25
+ /**
26
+ * Current conversation state. Required in controlled mode (no adapter).
27
+ * Overrides adapter state if both are provided.
28
+ */
29
+ state?: OrbState;
30
+ /**
31
+ * Current audio volume, normalized 0–1.
32
+ * Overrides adapter volume if both are provided.
33
+ */
34
+ volume?: number;
35
+ /**
36
+ * Provider adapter (Vapi, ElevenLabs, etc.).
37
+ * Handles state and volume automatically from the SDK.
38
+ */
39
+ adapter?: OrbAdapter;
40
+ /** Visual theme. Defaults to 'debug'. */
41
+ theme?: OrbTheme;
42
+ /** Size in pixels. Defaults to 200. */
43
+ size?: number;
44
+ className?: string;
45
+ style?: React.CSSProperties;
46
+ /** Called when the Start button is clicked (debug theme only). */
47
+ onStart?: () => void;
48
+ /** Called when the Stop button is clicked (debug theme only). */
49
+ onStop?: () => void;
50
+ }
51
+
52
+ export { }
package/dist/orb-ui.js ADDED
@@ -0,0 +1,397 @@
1
+ import { jsxs as C, jsx as i } from "react/jsx-runtime";
2
+ import { useRef as y, useLayoutEffect as q, useEffect as T, useState as O, useCallback as G } from "react";
3
+ const N = [
4
+ "idle",
5
+ "connecting",
6
+ "listening",
7
+ "thinking",
8
+ "speaking",
9
+ "error",
10
+ "disconnected"
11
+ ], I = {
12
+ idle: "#888",
13
+ connecting: "#f0c040",
14
+ listening: "#40c0f0",
15
+ thinking: "#c040f0",
16
+ speaking: "#40f080",
17
+ error: "#f04040",
18
+ disconnected: "#555"
19
+ };
20
+ function V({
21
+ state: e,
22
+ volume: u,
23
+ size: n,
24
+ className: x,
25
+ style: S,
26
+ onStart: g,
27
+ onStop: a
28
+ }) {
29
+ return /* @__PURE__ */ C(
30
+ "div",
31
+ {
32
+ className: x,
33
+ style: {
34
+ width: n,
35
+ fontFamily: "monospace",
36
+ fontSize: 12,
37
+ background: "#111",
38
+ color: "#ccc",
39
+ border: "1px solid #333",
40
+ borderRadius: 8,
41
+ padding: 12,
42
+ boxSizing: "border-box",
43
+ userSelect: "none",
44
+ ...S
45
+ },
46
+ children: [
47
+ /* @__PURE__ */ i("div", { style: { color: "#555", marginBottom: 10, fontSize: 10, letterSpacing: 1 }, children: "ORB DEBUG" }),
48
+ /* @__PURE__ */ C("div", { style: { marginBottom: 8 }, children: [
49
+ /* @__PURE__ */ i("span", { style: { color: "#555" }, children: "state " }),
50
+ /* @__PURE__ */ i("span", { style: { color: I[e], fontWeight: "bold" }, children: e })
51
+ ] }),
52
+ /* @__PURE__ */ C("div", { style: { marginBottom: 10 }, children: [
53
+ /* @__PURE__ */ i("span", { style: { color: "#555" }, children: "volume " }),
54
+ /* @__PURE__ */ i("span", { style: { color: "#ccc" }, children: u.toFixed(2) }),
55
+ /* @__PURE__ */ i(
56
+ "div",
57
+ {
58
+ style: {
59
+ marginTop: 4,
60
+ height: 4,
61
+ background: "#222",
62
+ borderRadius: 2,
63
+ overflow: "hidden"
64
+ },
65
+ children: /* @__PURE__ */ i(
66
+ "div",
67
+ {
68
+ style: {
69
+ height: "100%",
70
+ width: `${u * 100}%`,
71
+ background: I[e],
72
+ borderRadius: 2,
73
+ transition: "width 50ms linear"
74
+ }
75
+ }
76
+ )
77
+ }
78
+ )
79
+ ] }),
80
+ /* @__PURE__ */ C("div", { style: { marginBottom: 10 }, children: [
81
+ /* @__PURE__ */ i("div", { style: { color: "#555", marginBottom: 4, fontSize: 10 }, children: "force state" }),
82
+ /* @__PURE__ */ i("div", { style: { display: "flex", flexWrap: "wrap", gap: 4 }, children: N.map((r) => /* @__PURE__ */ i(
83
+ "button",
84
+ {
85
+ style: {
86
+ fontSize: 10,
87
+ padding: "2px 6px",
88
+ background: e === r ? I[r] : "#222",
89
+ color: e === r ? "#000" : "#888",
90
+ border: `1px solid ${e === r ? I[r] : "#333"}`,
91
+ borderRadius: 3,
92
+ cursor: "pointer"
93
+ },
94
+ onClick: () => {
95
+ console.warn(
96
+ `[orb-ui debug] To force state '${r}', use controlled mode: <VoiceOrb state="${r}" />`
97
+ );
98
+ },
99
+ children: r
100
+ },
101
+ r
102
+ )) })
103
+ ] }),
104
+ /* @__PURE__ */ C("div", { style: { display: "flex", gap: 6 }, children: [
105
+ /* @__PURE__ */ i(
106
+ "button",
107
+ {
108
+ onClick: g,
109
+ style: {
110
+ flex: 1,
111
+ padding: "4px 0",
112
+ background: "#1a3a1a",
113
+ color: "#40f080",
114
+ border: "1px solid #40f080",
115
+ borderRadius: 4,
116
+ cursor: "pointer",
117
+ fontSize: 11
118
+ },
119
+ children: "Start"
120
+ }
121
+ ),
122
+ /* @__PURE__ */ i(
123
+ "button",
124
+ {
125
+ onClick: a,
126
+ style: {
127
+ flex: 1,
128
+ padding: "4px 0",
129
+ background: "#3a1a1a",
130
+ color: "#f04040",
131
+ border: "1px solid #f04040",
132
+ borderRadius: 4,
133
+ cursor: "pointer",
134
+ fontSize: 11
135
+ },
136
+ children: "Stop"
137
+ }
138
+ )
139
+ ] })
140
+ ]
141
+ }
142
+ );
143
+ }
144
+ function L(e) {
145
+ return [
146
+ parseInt(e.slice(1, 3), 16),
147
+ parseInt(e.slice(3, 5), 16),
148
+ parseInt(e.slice(5, 7), 16)
149
+ ];
150
+ }
151
+ const v = {
152
+ idle: "#cccccc",
153
+ connecting: "#cccccc",
154
+ listening: "#60a5fa",
155
+ speaking: "#a3e635",
156
+ thinking: "#fbbf24",
157
+ error: "#f87171",
158
+ disconnected: "#444444"
159
+ }, W = `
160
+ @keyframes orb-circle-idle-pulse {
161
+ from { transform: scale(1); }
162
+ to { transform: scale(1.06); }
163
+ }
164
+ @keyframes orb-circle-connecting-pulse {
165
+ from { transform: scale(1); }
166
+ to { transform: scale(1.06); }
167
+ }
168
+ @keyframes orb-circle-thinking-spin {
169
+ from { transform: rotate(0deg); }
170
+ to { transform: rotate(360deg); }
171
+ }
172
+ `, M = 0.88, K = 0.22, j = 0.9, D = 0.15, U = 16, Q = 10, H = 0.55;
173
+ function Y({ state: e, volume: u, size: n, className: x, style: S }) {
174
+ const g = y(null), a = y(0), r = y(u);
175
+ q(() => {
176
+ r.current = u;
177
+ }, [u]);
178
+ const f = y(1), p = y(0), A = y(L(v.idle));
179
+ T(() => {
180
+ const s = "orb-circle-keyframes";
181
+ if (!document.getElementById(s)) {
182
+ const m = document.createElement("style");
183
+ m.id = s, m.textContent = W, document.head.appendChild(m);
184
+ }
185
+ }, []), T(() => {
186
+ const s = g.current;
187
+ if (s)
188
+ if (e === "listening" || e === "speaking") {
189
+ const m = e === "speaking" ? M : j, $ = e === "speaking" ? K : D, h = e === "speaking" ? U : Q, c = () => {
190
+ const b = r.current, R = m + b * $, l = b * h;
191
+ f.current += (R - f.current) * H, p.current += (l - p.current) * H;
192
+ const t = L(v[e]), [o, d, k] = A.current;
193
+ A.current = [
194
+ o + (t[0] - o) * 0.05,
195
+ d + (t[1] - d) * 0.05,
196
+ k + (t[2] - k) * 0.05
197
+ ];
198
+ const [_, F, B] = A.current.map(Math.round);
199
+ s.style.transform = `scale(${f.current})`, s.style.background = `rgb(${_},${F},${B})`, s.style.boxShadow = `0 0 ${p.current}px ${p.current * 0.25}px rgb(${_},${F},${B})`, s.style.animation = "none", a.current = requestAnimationFrame(c);
200
+ };
201
+ return a.current = requestAnimationFrame(c), () => {
202
+ cancelAnimationFrame(a.current);
203
+ };
204
+ } else
205
+ cancelAnimationFrame(a.current), f.current = 1, p.current = 0, A.current = L(v[e] ?? v.idle), s.style.transform = "", s.style.boxShadow = "", s.style.background = v[e] ?? v.idle, e === "idle" ? s.style.animation = "orb-circle-idle-pulse 3s ease-in-out infinite alternate" : e === "connecting" ? s.style.animation = "orb-circle-connecting-pulse 1.2s ease-in-out infinite alternate" : s.style.animation = "none";
206
+ }, [e]);
207
+ const w = n * 0.55;
208
+ return /* @__PURE__ */ C(
209
+ "div",
210
+ {
211
+ className: x,
212
+ style: {
213
+ width: n,
214
+ height: n,
215
+ display: "flex",
216
+ alignItems: "center",
217
+ justifyContent: "center",
218
+ position: "relative",
219
+ ...S
220
+ },
221
+ children: [
222
+ /* @__PURE__ */ i(
223
+ "div",
224
+ {
225
+ ref: g,
226
+ style: {
227
+ width: w,
228
+ height: w,
229
+ borderRadius: "50%",
230
+ // Initial color — rAF overwrites this immediately on first frame
231
+ background: v[e]
232
+ }
233
+ }
234
+ ),
235
+ e === "thinking" && /* @__PURE__ */ i("div", { style: {
236
+ position: "absolute",
237
+ width: n * 0.68,
238
+ height: n * 0.68,
239
+ border: "2px dashed #fbbf24",
240
+ borderRadius: "50%",
241
+ animation: "orb-circle-thinking-spin 1.5s linear infinite",
242
+ pointerEvents: "none"
243
+ } })
244
+ ]
245
+ }
246
+ );
247
+ }
248
+ const E = 5, J = 1.4, X = Math.PI * 2 / E, P = {
249
+ idle: "#cccccc",
250
+ connecting: "#cccccc",
251
+ listening: "#60a5fa",
252
+ speaking: "#a3e635",
253
+ thinking: "#fbbf24",
254
+ error: "#f87171",
255
+ disconnected: "#444444"
256
+ };
257
+ function Z(e) {
258
+ const u = e * 0.55, n = e * 0.06;
259
+ return `
260
+ @keyframes orb-bars-wave {
261
+ 0%, 100% { height: ${n}px; }
262
+ 50% { height: ${u * 0.5}px; }
263
+ }
264
+ @keyframes orb-bars-wave-fast {
265
+ 0%, 100% { height: ${n}px; }
266
+ 50% { height: ${u * 0.5}px; }
267
+ }
268
+ `;
269
+ }
270
+ function z({ state: e, volume: u, size: n, className: x, style: S }) {
271
+ const g = y([]), a = y(0), r = y(new Array(E).fill(0)), f = y(null), p = y(u);
272
+ T(() => {
273
+ p.current = u;
274
+ }, [u]), T(() => {
275
+ const h = "orb-bars-keyframes";
276
+ let c = document.getElementById(h);
277
+ c || (c = document.createElement("style"), c.id = h, document.head.appendChild(c)), c.textContent = Z(n), f.current = c;
278
+ }, [n]), T(() => {
279
+ const h = n * 0.55, c = n * 0.06, b = P[e], R = (l, t) => {
280
+ for (let o = 0; o < E; o++) {
281
+ const d = g.current[o];
282
+ d && (d.style.height = `${l[o]}px`, d.style.background = t, d.style.animation = "none");
283
+ }
284
+ };
285
+ if (e === "listening" || e === "speaking") {
286
+ const l = e === "speaking" ? 1 : 0.4, t = () => {
287
+ const o = p.current, d = Date.now() / 1e3;
288
+ for (let k = 0; k < E; k++) {
289
+ const _ = 0.5 + 0.15 * Math.sin(d * J * l * Math.PI * 2 + k * X), F = c + (h - c) * o * _, B = F > r.current[k] ? 0.3 : 0.2;
290
+ r.current[k] += (F - r.current[k]) * B;
291
+ }
292
+ R(r.current, b), a.current = requestAnimationFrame(t);
293
+ };
294
+ return a.current = requestAnimationFrame(t), () => cancelAnimationFrame(a.current);
295
+ }
296
+ if (e === "thinking") {
297
+ const l = () => {
298
+ const t = Date.now(), o = [];
299
+ for (let d = 0; d < E; d++)
300
+ o.push(c + (h - c) * 0.5 * (1 + Math.sin(t / 500 + d * 0.8)));
301
+ R(o, b), a.current = requestAnimationFrame(l);
302
+ };
303
+ return a.current = requestAnimationFrame(l), () => cancelAnimationFrame(a.current);
304
+ }
305
+ if (cancelAnimationFrame(a.current), e === "idle" || e === "connecting") {
306
+ const l = e === "idle" ? "1.8s" : "1s";
307
+ for (let t = 0; t < E; t++) {
308
+ const o = g.current[t];
309
+ if (!o) continue;
310
+ o.style.background = b, o.style.height = "";
311
+ const d = e === "idle" ? "orb-bars-wave" : "orb-bars-wave-fast";
312
+ o.style.animation = `${d} ${l} ease-in-out ${t * 0.15}s infinite`;
313
+ }
314
+ return;
315
+ }
316
+ for (let l = 0; l < E; l++) {
317
+ const t = g.current[l];
318
+ t && (t.style.height = `${c}px`, t.style.background = b, t.style.animation = "none");
319
+ }
320
+ }, [e, n]);
321
+ const A = n * 0.055, w = n * 0.035, s = n * 0.03, m = n * 0.55, $ = n * 0.06;
322
+ return /* @__PURE__ */ i(
323
+ "div",
324
+ {
325
+ className: x,
326
+ style: {
327
+ width: n,
328
+ height: n,
329
+ display: "flex",
330
+ alignItems: "center",
331
+ justifyContent: "center",
332
+ gap: w,
333
+ ...S
334
+ },
335
+ children: Array.from({ length: E }, (h, c) => /* @__PURE__ */ i(
336
+ "div",
337
+ {
338
+ ref: (b) => {
339
+ g.current[c] = b;
340
+ },
341
+ style: {
342
+ width: A,
343
+ minHeight: $,
344
+ maxHeight: m,
345
+ height: $,
346
+ borderRadius: s,
347
+ background: P[e],
348
+ transition: "background 0.3s ease"
349
+ }
350
+ },
351
+ c
352
+ ))
353
+ }
354
+ );
355
+ }
356
+ function te({
357
+ state: e,
358
+ volume: u,
359
+ adapter: n,
360
+ theme: x = "debug",
361
+ size: S = 200,
362
+ className: g,
363
+ style: a,
364
+ onStart: r,
365
+ onStop: f
366
+ }) {
367
+ const [p, A] = O("idle"), [w, s] = O(0);
368
+ T(() => n ? n.subscribe({
369
+ onStateChange: A,
370
+ onVolumeChange: s
371
+ }) : void 0, [n]);
372
+ const m = e ?? p, $ = u ?? w, h = m !== "idle" && m !== "disconnected" && m !== "error", c = G(() => {
373
+ var t, o;
374
+ h ? f ? f() : (t = n == null ? void 0 : n.stop) == null || t.call(n) : r ? r() : (o = n == null ? void 0 : n.start) == null || o.call(n);
375
+ }, [n, h, r, f]), b = !!(n != null && n.start || r || f), R = { state: m, volume: $, size: S, className: g, style: a }, l = (() => {
376
+ switch (x) {
377
+ case "circle":
378
+ return /* @__PURE__ */ i(Y, { ...R });
379
+ case "bars":
380
+ return /* @__PURE__ */ i(z, { ...R });
381
+ case "debug":
382
+ default:
383
+ return /* @__PURE__ */ i(V, { ...R, onStart: r, onStop: f });
384
+ }
385
+ })();
386
+ return b && x !== "debug" ? /* @__PURE__ */ i(
387
+ "div",
388
+ {
389
+ onClick: c,
390
+ style: { cursor: "pointer", display: "inline-flex" },
391
+ children: l
392
+ }
393
+ ) : l;
394
+ }
395
+ export {
396
+ te as VoiceOrb
397
+ };
package/package.json ADDED
@@ -0,0 +1,60 @@
1
+ {
2
+ "name": "orb-ui",
3
+ "version": "0.1.0",
4
+ "description": "The simplest voice AI UI component library for React. Works with Vapi and ElevenLabs.",
5
+ "type": "module",
6
+ "module": "./dist/orb-ui.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "import": "./dist/orb-ui.js",
11
+ "types": "./dist/index.d.ts"
12
+ },
13
+ "./adapters": {
14
+ "import": "./dist/adapters.js",
15
+ "types": "./dist/adapters/index.d.ts"
16
+ }
17
+ },
18
+ "files": [
19
+ "dist"
20
+ ],
21
+ "scripts": {
22
+ "build": "tsc && vite build",
23
+ "dev": "vite build --watch",
24
+ "typecheck": "tsc --noEmit",
25
+ "lint": "eslint src",
26
+ "test": "vitest"
27
+ },
28
+ "peerDependencies": {
29
+ "react": ">=18.0.0",
30
+ "react-dom": ">=18.0.0"
31
+ },
32
+ "devDependencies": {
33
+ "@types/react": "^18.3.0",
34
+ "@types/react-dom": "^18.3.0",
35
+
36
+ "@typescript-eslint/eslint-plugin": "^7.0.0",
37
+ "@typescript-eslint/parser": "^7.0.0",
38
+ "@vitejs/plugin-react": "^4.3.0",
39
+ "eslint": "^8.57.0",
40
+ "typescript": "^5.4.0",
41
+ "vite": "^5.3.0",
42
+ "vite-plugin-dts": "^3.9.0",
43
+ "vitest": "^1.6.0"
44
+ },
45
+ "keywords": [
46
+ "voice",
47
+ "ai",
48
+ "react",
49
+ "animation",
50
+ "component",
51
+ "vapi",
52
+ "elevenlabs"
53
+ ],
54
+ "license": "MIT",
55
+ "repository": {
56
+ "type": "git",
57
+ "url": "https://github.com/alexanderqchen/orb-ui.git"
58
+ },
59
+ "dependencies": {}
60
+ }