@alia.onl/sdk 13.2.0 → 15.3.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.
Files changed (48) hide show
  1. package/CHANGELOG.md +139 -0
  2. package/README.md +53 -21
  3. package/package.json +9 -5
  4. package/src/components/AliaAvatarIcon.tsx +30 -0
  5. package/src/components/AliaChatContent.tsx +4 -7
  6. package/src/components/AliaChatMessageList.tsx +2 -10
  7. package/src/components/AliaChatScreen.tsx +1 -1
  8. package/src/components/AliaChatSheet.tsx +15 -4
  9. package/src/components/AliaComposer.tsx +62 -16
  10. package/src/components/voice/VoiceControls.tsx +28 -4
  11. package/src/components/voice/VoiceSession.tsx +23 -7
  12. package/src/hooks/useAliaActivity.ts +31 -0
  13. package/src/hooks/useAliaChat.ts +14 -3
  14. package/src/hooks/useAmbientWave.ts +3 -3
  15. package/src/hooks/useAudioLevelMonitor.ts +2 -2
  16. package/src/hooks/useDictation.ts +295 -0
  17. package/src/hooks/useTTS.ts +24 -63
  18. package/src/hooks/useVoiceRoom.ts +148 -736
  19. package/src/index.ts +9 -3
  20. package/src/lib/activity-socket.ts +102 -0
  21. package/src/lib/activity-store.ts +53 -0
  22. package/src/lib/api-url.ts +2 -0
  23. package/src/lib/audio-level.ts +2 -2
  24. package/src/lib/dictation-recorder-types.ts +37 -0
  25. package/src/lib/dictation-recorder.native.ts +90 -0
  26. package/src/lib/dictation-recorder.ts +131 -0
  27. package/src/lib/dictation.ts +208 -0
  28. package/src/lib/labels.tsx +3 -0
  29. package/src/lib/pcm.ts +57 -0
  30. package/src/lib/speech-language.ts +1 -1
  31. package/src/lib/speech-messages.ts +43 -97
  32. package/src/lib/speech-synthesis.ts +5 -1
  33. package/src/lib/turn-state.ts +15 -0
  34. package/src/lib/voice-audio-engine-types.ts +59 -0
  35. package/src/lib/voice-audio-engine.native.ts +232 -0
  36. package/src/lib/voice-audio-engine.ts +350 -0
  37. package/src/lib/voice-call.ts +539 -0
  38. package/src/lib/voice-levels.ts +7 -3
  39. package/src/lib/voice-protocol.ts +309 -0
  40. package/src/types.ts +49 -4
  41. package/src/voice.ts +31 -12
  42. package/tsconfig.json +2 -0
  43. package/src/hooks/useSpeechToText.ts +0 -268
  44. package/src/lib/speech-recognition-types.ts +0 -108
  45. package/src/lib/speech-recognition.native.ts +0 -237
  46. package/src/lib/speech-recognition.ts +0 -308
  47. package/src/lib/voice-text.ts +0 -198
  48. package/src/lib/voice-turn.ts +0 -89
@@ -0,0 +1,309 @@
1
+ /**
2
+ * The client half of Alia's voice-call protocol, `GET /v1/voice/session`
3
+ * (ADR 0017 §3–§4).
4
+ *
5
+ * The server's copy is `packages/api/src/lib/voice/protocol.ts`; the SDK does
6
+ * not depend on the API package, so the shapes are restated here and
7
+ * `VOICE_PROTOCOL_VERSION` is what turns a mismatch into a refusal instead of a
8
+ * misunderstanding.
9
+ *
10
+ * - Binary, client → Alia: microphone PCM16 LE, 24 kHz, mono, ~40 ms a frame.
11
+ * - Binary, Alia → client: `[u8 n][n bytes: assistant item id, UTF-8][PCM16 LE]`.
12
+ * - Text: one JSON event per frame. Every server event after
13
+ * `session.accepted` carries `seq`; a resume replays from `afterSeq`.
14
+ */
15
+
16
+ export const VOICE_PROTOCOL_VERSION = 1;
17
+
18
+ export const VOICE_SESSION_PATH = '/v1/voice/session';
19
+
20
+ /** PCM16 mono at this rate, both directions. */
21
+ export const VOICE_SAMPLE_RATE = 24_000;
22
+
23
+ /** Samples in one ~40 ms microphone frame. */
24
+ export const VOICE_FRAME_SAMPLES = 960;
25
+
26
+ /** Milliseconds of audio in `bytes` of PCM16 mono at {@link VOICE_SAMPLE_RATE}. */
27
+ export function pcmBytesToMs(bytes: number): number {
28
+ return (bytes / 2 / VOICE_SAMPLE_RATE) * 1000;
29
+ }
30
+
31
+ export type ProductVoice = 'male' | 'female';
32
+
33
+ /** Codes Alia ends a call with. */
34
+ export type VoiceServerErrorCode =
35
+ | 'not-authenticated'
36
+ | 'credential-not-supported'
37
+ | 'plan-required'
38
+ | 'credits-exhausted'
39
+ | 'agent-unavailable'
40
+ | 'invalid-request'
41
+ | 'resume-unavailable'
42
+ | 'realtime-unavailable'
43
+ | 'realtime-limited'
44
+ | 'realtime-rejected'
45
+ | 'realtime-failed';
46
+
47
+ const SERVER_ERROR_CODES: ReadonlySet<string> = new Set<VoiceServerErrorCode>([
48
+ 'not-authenticated',
49
+ 'credential-not-supported',
50
+ 'plan-required',
51
+ 'credits-exhausted',
52
+ 'agent-unavailable',
53
+ 'invalid-request',
54
+ 'resume-unavailable',
55
+ 'realtime-unavailable',
56
+ 'realtime-limited',
57
+ 'realtime-rejected',
58
+ 'realtime-failed',
59
+ ]);
60
+
61
+ /** Why a call ended. `call-moved-elsewhere` is a reason, never an error. */
62
+ export type VoiceCloseReason =
63
+ | 'hangup'
64
+ | 'call-moved-elsewhere'
65
+ | 'max-duration'
66
+ | 'idle-timeout'
67
+ | 'credits-exhausted'
68
+ | 'error'
69
+ | 'server-shutdown';
70
+
71
+ const CLOSE_REASONS: ReadonlySet<string> = new Set<VoiceCloseReason>([
72
+ 'hangup',
73
+ 'call-moved-elsewhere',
74
+ 'max-duration',
75
+ 'idle-timeout',
76
+ 'credits-exhausted',
77
+ 'error',
78
+ 'server-shutdown',
79
+ ]);
80
+
81
+ export type VoiceRole = 'user' | 'assistant';
82
+
83
+ export type VoiceClientEvent =
84
+ | { type: 'auth'; token: string }
85
+ | {
86
+ type: 'session.start';
87
+ protocol: number;
88
+ conversationId: string;
89
+ agentId?: string;
90
+ voice: ProductVoice;
91
+ locale: string;
92
+ }
93
+ | { type: 'session.resume'; protocol: number; voiceSessionId: string; afterSeq: number }
94
+ | { type: 'playback.interrupted'; itemId: string; playedMs: number }
95
+ | { type: 'session.end' };
96
+
97
+ export type VoiceServerEvent =
98
+ | { type: 'auth.ok' }
99
+ | { type: 'session.accepted'; voiceSessionId: string; callId: string }
100
+ | {
101
+ type: 'session.ready';
102
+ model: { id: string; displayName: string; publisher: string };
103
+ maxDurationMs: number;
104
+ idleTimeoutMs: number;
105
+ resumeWindowMs: number;
106
+ }
107
+ | { type: 'input.ready' }
108
+ | { type: 'user.speech_started'; itemId: string }
109
+ | { type: 'user.speech_stopped'; itemId: string }
110
+ | { type: 'transcript.delta'; role: VoiceRole; itemId: string; text: string }
111
+ | { type: 'transcript.done'; role: VoiceRole; itemId: string; text: string }
112
+ | { type: 'assistant.audio_done'; itemId: string }
113
+ | {
114
+ type: 'assistant.done';
115
+ itemId: string | null;
116
+ status: 'completed' | 'cancelled' | 'incomplete' | 'failed';
117
+ }
118
+ | { type: 'tool.started'; callId: string; name: string }
119
+ | { type: 'tool.finished'; callId: string; name: string; ok: boolean }
120
+ | { type: 'approval.requested'; toolName: string }
121
+ | { type: 'messages.saved'; conversationId: string; messageIds: string[] }
122
+ | { type: 'session.warning'; code: 'ending-soon'; remainingMs: number }
123
+ | { type: 'session.reconnecting' }
124
+ | { type: 'session.resumed'; afterSeq: number }
125
+ | {
126
+ type: 'error';
127
+ code: VoiceServerErrorCode;
128
+ message: string;
129
+ retryable: boolean;
130
+ reference: string;
131
+ }
132
+ | { type: 'session.closed'; reason: VoiceCloseReason };
133
+
134
+ /** A server event as received: numbered once a session exists. */
135
+ export type ReceivedVoiceEvent = VoiceServerEvent & { seq?: number };
136
+
137
+ const isString = (value: unknown): value is string => typeof value === 'string';
138
+ const isNumber = (value: unknown): value is number =>
139
+ typeof value === 'number' && Number.isFinite(value);
140
+
141
+ /**
142
+ * A server event, or `null` for a frame this SDK does not understand — which
143
+ * it ignores rather than guesses at.
144
+ */
145
+ export function parseVoiceServerEvent(raw: string): ReceivedVoiceEvent | null {
146
+ let value: unknown;
147
+ try {
148
+ value = JSON.parse(raw);
149
+ } catch {
150
+ return null;
151
+ }
152
+ if (value === null || typeof value !== 'object') return null;
153
+ const event = value as Record<string, unknown>;
154
+ const seq = isNumber(event.seq) ? { seq: event.seq } : {};
155
+ switch (event.type) {
156
+ case 'auth.ok':
157
+ case 'input.ready':
158
+ case 'session.reconnecting':
159
+ return { type: event.type, ...seq };
160
+ case 'session.accepted':
161
+ return isString(event.voiceSessionId) && isString(event.callId)
162
+ ? {
163
+ type: 'session.accepted',
164
+ voiceSessionId: event.voiceSessionId,
165
+ callId: event.callId,
166
+ ...seq,
167
+ }
168
+ : null;
169
+ case 'session.ready': {
170
+ const model = event.model as Record<string, unknown> | undefined;
171
+ if (
172
+ !model ||
173
+ !isString(model.id) ||
174
+ !isNumber(event.maxDurationMs) ||
175
+ !isNumber(event.idleTimeoutMs) ||
176
+ !isNumber(event.resumeWindowMs)
177
+ )
178
+ return null;
179
+ return {
180
+ type: 'session.ready',
181
+ model: {
182
+ id: model.id,
183
+ displayName: isString(model.displayName) ? model.displayName : model.id,
184
+ publisher: isString(model.publisher) ? model.publisher : '',
185
+ },
186
+ maxDurationMs: event.maxDurationMs,
187
+ idleTimeoutMs: event.idleTimeoutMs,
188
+ resumeWindowMs: event.resumeWindowMs,
189
+ ...seq,
190
+ };
191
+ }
192
+ case 'user.speech_started':
193
+ case 'user.speech_stopped':
194
+ case 'assistant.audio_done':
195
+ return isString(event.itemId) ? { type: event.type, itemId: event.itemId, ...seq } : null;
196
+ case 'transcript.delta':
197
+ case 'transcript.done':
198
+ return (event.role === 'user' || event.role === 'assistant') &&
199
+ isString(event.itemId) &&
200
+ isString(event.text)
201
+ ? { type: event.type, role: event.role, itemId: event.itemId, text: event.text, ...seq }
202
+ : null;
203
+ case 'assistant.done':
204
+ return event.itemId === null || isString(event.itemId)
205
+ ? {
206
+ type: 'assistant.done',
207
+ itemId: event.itemId as string | null,
208
+ status:
209
+ event.status === 'cancelled' ||
210
+ event.status === 'incomplete' ||
211
+ event.status === 'failed'
212
+ ? event.status
213
+ : 'completed',
214
+ ...seq,
215
+ }
216
+ : null;
217
+ case 'tool.started':
218
+ return isString(event.callId) && isString(event.name)
219
+ ? { type: 'tool.started', callId: event.callId, name: event.name, ...seq }
220
+ : null;
221
+ case 'tool.finished':
222
+ return isString(event.callId) && isString(event.name)
223
+ ? {
224
+ type: 'tool.finished',
225
+ callId: event.callId,
226
+ name: event.name,
227
+ ok: event.ok === true,
228
+ ...seq,
229
+ }
230
+ : null;
231
+ case 'approval.requested':
232
+ return isString(event.toolName)
233
+ ? { type: 'approval.requested', toolName: event.toolName, ...seq }
234
+ : null;
235
+ case 'messages.saved':
236
+ return isString(event.conversationId) && Array.isArray(event.messageIds)
237
+ ? {
238
+ type: 'messages.saved',
239
+ conversationId: event.conversationId,
240
+ messageIds: event.messageIds.filter(isString),
241
+ ...seq,
242
+ }
243
+ : null;
244
+ case 'session.warning':
245
+ return event.code === 'ending-soon' && isNumber(event.remainingMs)
246
+ ? { type: 'session.warning', code: 'ending-soon', remainingMs: event.remainingMs, ...seq }
247
+ : null;
248
+ case 'session.resumed':
249
+ return isNumber(event.afterSeq)
250
+ ? { type: 'session.resumed', afterSeq: event.afterSeq, ...seq }
251
+ : null;
252
+ case 'error':
253
+ return {
254
+ type: 'error',
255
+ // A code this SDK does not know is still a failure of the call upstream.
256
+ code:
257
+ isString(event.code) && SERVER_ERROR_CODES.has(event.code)
258
+ ? (event.code as VoiceServerErrorCode)
259
+ : 'realtime-failed',
260
+ message: isString(event.message) ? event.message : '',
261
+ retryable: event.retryable === true,
262
+ reference: isString(event.reference) ? event.reference : '',
263
+ ...seq,
264
+ };
265
+ case 'session.closed':
266
+ return {
267
+ type: 'session.closed',
268
+ reason:
269
+ isString(event.reason) && CLOSE_REASONS.has(event.reason)
270
+ ? (event.reason as VoiceCloseReason)
271
+ : 'error',
272
+ ...seq,
273
+ };
274
+ default:
275
+ return null;
276
+ }
277
+ }
278
+
279
+ /** `[u8 n][n bytes: item id][PCM16]`, or `null` for a frame that is not one. */
280
+ export function decodeAssistantAudioFrame(
281
+ frame: Uint8Array,
282
+ ): { itemId: string; pcm: Uint8Array } | null {
283
+ if (frame.byteLength < 2) return null;
284
+ const length = frame[0]!;
285
+ if (length === 0 || frame.byteLength < 1 + length) return null;
286
+ const itemId = new TextDecoder().decode(frame.subarray(1, 1 + length));
287
+ return { itemId, pcm: frame.subarray(1 + length) };
288
+ }
289
+
290
+ /** The test and dev counterpart: what the server sends. */
291
+ export function encodeAssistantAudioFrame(itemId: string, pcm: Uint8Array): Uint8Array {
292
+ const id = new TextEncoder().encode(itemId);
293
+ const frame = new Uint8Array(1 + id.byteLength + pcm.byteLength);
294
+ frame[0] = id.byteLength;
295
+ frame.set(id, 1);
296
+ frame.set(pcm, 1 + id.byteLength);
297
+ return frame;
298
+ }
299
+
300
+ /** `https://api.alia.onl` → `wss://api.alia.onl/v1/voice/session`. */
301
+ export function voiceSessionUrl(apiUrl: string): string {
302
+ const base = apiUrl.replace(/\/+$/, '');
303
+ const ws = base.startsWith('https://')
304
+ ? `wss://${base.slice('https://'.length)}`
305
+ : base.startsWith('http://')
306
+ ? `ws://${base.slice('http://'.length)}`
307
+ : base;
308
+ return `${ws}${VOICE_SESSION_PATH}`;
309
+ }
package/src/types.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  import type { ComponentType } from 'react';
2
2
  import type { SharedValue } from 'react-native-reanimated';
3
+ import type { VoiceErrorCode } from './lib/speech-messages';
4
+ import type { VoiceCloseReason } from './lib/voice-protocol';
3
5
 
4
6
  // ── Suggestion Types ──
5
7
 
@@ -74,8 +76,6 @@ export interface ChatMessage {
74
76
  speaker?: 'primary';
75
77
  /** Whether a voice transcript is still streaming */
76
78
  isStreaming?: boolean;
77
- /** Cached TTS audio URL for read-aloud */
78
- audioUrl?: string;
79
79
  /** Delegated agent identity */
80
80
  /** `character` is the agent's look as the server stores it (JSON), parsed where it is drawn. */
81
81
  agentInfo?: { id: string; name: string; character?: unknown };
@@ -92,9 +92,43 @@ export interface ToolInvocation {
92
92
 
93
93
  // ── Voice Types ──
94
94
 
95
- export type RoomState = 'disconnected' | 'connecting' | 'connected' | 'error';
95
+ /**
96
+ * The call's connection, coarse: what a bar or a wave needs. `reconnecting`
97
+ * while a dropped socket resumes or Alia re-opens the session upstream.
98
+ */
99
+ export type RoomState = 'disconnected' | 'connecting' | 'connected' | 'reconnecting' | 'error';
100
+ /** Who has the turn, coarse: the palette of the ambient field and the bar's word. */
96
101
  export type AgentState = 'idle' | 'listening' | 'thinking' | 'speaking';
97
102
 
103
+ /**
104
+ * The call's honest state (ADR 0017 §4). Every state is entered on an event
105
+ * that proves it, never on a timer:
106
+ *
107
+ * - `connecting` — socket open → authenticated → `session.start` accepted →
108
+ * the realtime session created upstream (`session.ready`).
109
+ * - `listening` — ONLY once the session is ready, the microphone delivered its
110
+ * first buffer and Alia acknowledged the first appended audio.
111
+ * - `user-speaking` — the server heard speech start.
112
+ * - `thinking` — from the end of the person's speech to the first answer
113
+ * audio or a tool.
114
+ * - `using-tool` — a tool runs in Alia; `tool` is its name, for a label only.
115
+ * - `speaking` — the first answer frame is PLAYING, not merely received.
116
+ * - `reconnecting` — a resume, or Alia re-opening the session upstream.
117
+ * - `ended` — the call is over; `call-moved-elsewhere` is a reason, not an error.
118
+ * - `error` — a typed failure ended it.
119
+ */
120
+ export type VoiceCallState =
121
+ | { kind: 'idle' }
122
+ | { kind: 'connecting' }
123
+ | { kind: 'listening' }
124
+ | { kind: 'user-speaking' }
125
+ | { kind: 'thinking' }
126
+ | { kind: 'using-tool'; tool: string }
127
+ | { kind: 'speaking' }
128
+ | { kind: 'reconnecting' }
129
+ | { kind: 'ended'; reason: VoiceCloseReason }
130
+ | { kind: 'error'; code: VoiceErrorCode };
131
+
98
132
  export interface VoiceToolInvocation {
99
133
  toolCallId: string;
100
134
  toolName: string;
@@ -128,6 +162,17 @@ export interface VoiceSessionState {
128
162
  export interface VoiceSessionProps {
129
163
  /** Alia API base URL. */
130
164
  apiUrl?: string;
165
+ /**
166
+ * The conversation the call's transcript is stored in. Omitted, each call
167
+ * starts a conversation of its own in the person's Alia history.
168
+ */
169
+ conversationId?: string;
170
+ /** The agent the call talks to; omitted, Alia. */
171
+ agentId?: string;
172
+ /** Language spoken (BCP-47); omitted, the platform's. */
173
+ locale?: string;
174
+ /** Which of the product's two voices answers. */
175
+ voice?: 'male' | 'female';
131
176
  /** Called on every room, agent, or transcript change. */
132
177
  onStateChange: (state: VoiceSessionState) => void;
133
178
  /**
@@ -140,6 +185,6 @@ export interface VoiceSessionProps {
140
185
  /**
141
186
  * The voice capability handed to `AliaChatScreen` / `AliaChatSheet`. Pass
142
187
  * `VoiceSession` from `@alia.onl/sdk/voice` to enable voice calls; leave it out
143
- * and the chat is text-only, with `livekit-client` absent from the module graph.
188
+ * and the chat is text-only, with the audio engine absent from the module graph.
144
189
  */
145
190
  export type VoiceSessionComponent = ComponentType<VoiceSessionProps>;
package/src/voice.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  // @alia.onl/sdk/voice — Alia voice surface
2
- // The on-device voice loop (listen → chat → speak), audio visualisation,
3
- // ambient wave, and sound effects. Split out of the root entry so text-chat
4
- // consumers never compile the call.
2
+ // The voice call (speech to speech through Alia's /v1/voice/session and Kaana),
3
+ // its audio engine, audio visualisation, ambient wave and sound effects. Split
4
+ // out of the root entry so text-chat consumers never compile the call or
5
+ // `react-native-audio-api`.
5
6
 
6
7
  // ── Voice components ──
7
8
  export { VoiceSession } from './components/voice/VoiceSession';
@@ -10,20 +11,37 @@ export { VOICE_CONTROLS_LABELS } from './components/voice/VoiceControls';
10
11
  export type { VoiceControlsLabels, VoiceControlsProps } from './components/voice/VoiceControls';
11
12
 
12
13
  // ── Voice hooks ──
13
- export { useVoiceRoom } from './hooks/useVoiceRoom';
14
+ export { useVoiceRoom, roomStateOf, agentStateOf } from './hooks/useVoiceRoom';
14
15
  export type { UseVoiceRoomOptions } from './hooks/useVoiceRoom';
15
- export { createAliaVoiceTurnSender } from './lib/voice-turn';
16
+ export { VoiceCall } from './lib/voice-call';
16
17
  export type {
17
- VoiceTurn,
18
- VoiceTurnMessage,
19
- VoiceTurnSender,
20
- AliaVoiceTurnSenderOptions,
21
- } from './lib/voice-turn';
18
+ VoiceCallOptions,
19
+ VoiceCallSnapshot,
20
+ VoiceSocket,
21
+ VoiceSocketFactory,
22
+ } from './lib/voice-call';
23
+ export { createVoiceAudioEngine, VoiceAudioEngineError } from './lib/voice-audio-engine';
24
+ export type {
25
+ VoiceAudioEngine,
26
+ VoiceAudioEngineErrorCode,
27
+ VoiceAudioEngineHandlers,
28
+ } from './lib/voice-audio-engine-types';
29
+ export {
30
+ VOICE_PROTOCOL_VERSION,
31
+ VOICE_SAMPLE_RATE,
32
+ decodeAssistantAudioFrame,
33
+ encodeAssistantAudioFrame,
34
+ voiceSessionUrl,
35
+ } from './lib/voice-protocol';
36
+ export type {
37
+ VoiceClientEvent,
38
+ VoiceCloseReason,
39
+ VoiceServerErrorCode,
40
+ VoiceServerEvent,
41
+ } from './lib/voice-protocol';
22
42
  export type { VoiceLevelSource } from './lib/voice-levels';
23
43
  export { VOICE_ERROR_MESSAGES } from './lib/speech-messages';
24
44
  export type { VoiceErrorCode } from './lib/speech-messages';
25
- export { SpeechClipError } from './lib/speech-synthesis';
26
- export type { SpeechClipErrorCode } from './lib/speech-synthesis';
27
45
  export { useAudioLevelMonitor } from './hooks/useAudioLevelMonitor';
28
46
  export { useAudioLevels } from './hooks/useAudioLevels';
29
47
  export { useAmbientWave } from './hooks/useAmbientWave';
@@ -40,6 +58,7 @@ export type { SoundName, SoundSources } from './hooks/useSoundEffects';
40
58
  export type {
41
59
  RoomState,
42
60
  AgentState,
61
+ VoiceCallState,
43
62
  VoiceMessage,
44
63
  VoiceToolInvocation,
45
64
  VoiceSessionProps,
package/tsconfig.json CHANGED
@@ -5,6 +5,8 @@
5
5
  "moduleResolution": "bundler",
6
6
  "jsx": "react-jsx",
7
7
  "strict": true,
8
+ "noUnusedLocals": true,
9
+ "noUnusedParameters": true,
8
10
  "esModuleInterop": true,
9
11
  "skipLibCheck": true,
10
12
  "declaration": true,