@alia.onl/sdk 13.1.0 → 15.2.1

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 (55) hide show
  1. package/CHANGELOG.md +190 -0
  2. package/README.md +83 -20
  3. package/package.json +8 -4
  4. package/src/components/AliaAvatarIcon.tsx +30 -0
  5. package/src/components/AliaChatContent.tsx +57 -107
  6. package/src/components/AliaChatMessageList.tsx +2 -10
  7. package/src/components/AliaChatScreen.tsx +28 -25
  8. package/src/components/AliaChatSheet.tsx +30 -8
  9. package/src/components/AliaComposer.tsx +382 -0
  10. package/src/components/ComposerDropZone.tsx +235 -0
  11. package/src/components/voice/VoiceControls.tsx +28 -4
  12. package/src/components/voice/VoiceSession.tsx +23 -7
  13. package/src/hooks/useAliaActivity.ts +31 -0
  14. package/src/hooks/useAliaChat.ts +14 -3
  15. package/src/hooks/useAmbientWave.ts +3 -3
  16. package/src/hooks/useAttachmentIntake.ts +401 -0
  17. package/src/hooks/useAudioLevelMonitor.ts +2 -2
  18. package/src/hooks/useDictation.ts +295 -0
  19. package/src/hooks/useFocusWhenPresented.ts +41 -0
  20. package/src/hooks/usePendingQuestions.ts +19 -0
  21. package/src/hooks/useTTS.ts +24 -63
  22. package/src/hooks/useVoiceRoom.ts +148 -671
  23. package/src/index.ts +19 -3
  24. package/src/lib/activity-socket.ts +102 -0
  25. package/src/lib/activity-store.ts +53 -0
  26. package/src/lib/api-url.ts +2 -0
  27. package/src/lib/attachment-intake.ts +154 -0
  28. package/src/lib/attachment-tiles.ts +74 -0
  29. package/src/lib/audio-level.ts +2 -2
  30. package/src/lib/dictation-recorder-types.ts +37 -0
  31. package/src/lib/dictation-recorder.native.ts +90 -0
  32. package/src/lib/dictation-recorder.ts +131 -0
  33. package/src/lib/dictation.ts +208 -0
  34. package/src/lib/labels.tsx +50 -1
  35. package/src/lib/pcm.ts +57 -0
  36. package/src/lib/speech-language.ts +1 -1
  37. package/src/lib/speech-messages.ts +43 -65
  38. package/src/lib/speech-synthesis.ts +104 -9
  39. package/src/lib/turn-state.ts +15 -0
  40. package/src/lib/voice-audio-engine-types.ts +59 -0
  41. package/src/lib/voice-audio-engine.native.ts +232 -0
  42. package/src/lib/voice-audio-engine.ts +350 -0
  43. package/src/lib/voice-call.ts +539 -0
  44. package/src/lib/voice-levels.ts +7 -3
  45. package/src/lib/voice-protocol.ts +309 -0
  46. package/src/tools.ts +1 -0
  47. package/src/types.ts +63 -4
  48. package/src/voice.ts +31 -10
  49. package/tsconfig.json +2 -0
  50. package/src/hooks/useSpeechToText.ts +0 -268
  51. package/src/lib/speech-recognition-types.ts +0 -108
  52. package/src/lib/speech-recognition.native.ts +0 -237
  53. package/src/lib/speech-recognition.ts +0 -308
  54. package/src/lib/voice-text.ts +0 -198
  55. package/src/lib/voice-turn.ts +0 -89
@@ -20,14 +20,112 @@ export interface SpeechClipRequest {
20
20
  readonly input: string;
21
21
  readonly voice: ProductVoice;
22
22
  readonly speed?: number;
23
- /** Both or neither: the route stores the clip on that message when given. */
23
+ /**
24
+ * Both or neither: the saved message being read aloud. The route answers 404
25
+ * for one this person does not hold, and files the clip under that
26
+ * conversation, so deleting the conversation deletes it.
27
+ */
24
28
  readonly conversationId?: string;
25
29
  readonly messageId?: string;
26
30
  readonly signal: AbortSignal;
27
31
  }
28
32
 
29
- /** Synthesize `input` and return the private link the route answers with. */
33
+ /**
34
+ * The codes `/v1/audio/speech` answers a failure with, plus the two this side
35
+ * knows on its own: `NOT_AUTHENTICATED` (no session token, or the route said
36
+ * 401) and `INVALID_REQUEST` (the route refused the body).
37
+ */
38
+ export type SpeechClipErrorCode =
39
+ | 'SPEECH_UNAVAILABLE'
40
+ | 'SPEECH_REJECTED'
41
+ | 'SPEECH_LIMIT_REACHED'
42
+ | 'SPEECH_PROVIDER_FAILED'
43
+ | 'SPEECH_GENERATION_FAILED'
44
+ | 'INVALID_REQUEST'
45
+ | 'NOT_AUTHENTICATED';
46
+
47
+ const SERVER_CODES: ReadonlySet<string> = new Set<SpeechClipErrorCode>([
48
+ 'SPEECH_UNAVAILABLE',
49
+ 'SPEECH_REJECTED',
50
+ 'SPEECH_LIMIT_REACHED',
51
+ 'SPEECH_PROVIDER_FAILED',
52
+ 'SPEECH_GENERATION_FAILED',
53
+ 'INVALID_REQUEST',
54
+ ]);
55
+
56
+ /**
57
+ * A clip that could not be made, with what the route said about it: the HTTP
58
+ * status (`null` when no request was answered) and its typed `code` (`null`
59
+ * when the route named none this SDK knows — a network failure, an older or
60
+ * newer server).
61
+ */
62
+ export class SpeechClipError extends Error {
63
+ readonly status: number | null;
64
+ readonly code: SpeechClipErrorCode | null;
65
+ readonly retryable: boolean;
66
+ /** Oxy's request id, for support, when the route gave one. */
67
+ readonly requestId: string | null;
68
+
69
+ constructor(
70
+ message: string,
71
+ details: {
72
+ status: number | null;
73
+ code: SpeechClipErrorCode | null;
74
+ retryable?: boolean;
75
+ requestId?: string | null;
76
+ },
77
+ ) {
78
+ super(message);
79
+ this.name = 'SpeechClipError';
80
+ this.status = details.status;
81
+ this.code = details.code;
82
+ this.retryable = details.retryable ?? false;
83
+ this.requestId = details.requestId ?? null;
84
+ }
85
+ }
86
+
87
+ function clipErrorFrom(status: number, body: unknown): SpeechClipError {
88
+ const envelope =
89
+ body !== null && typeof body === 'object' ? (body as { error?: unknown }).error : undefined;
90
+ const error =
91
+ envelope !== null && typeof envelope === 'object'
92
+ ? (envelope as {
93
+ code?: unknown;
94
+ message?: unknown;
95
+ retryable?: unknown;
96
+ requestId?: unknown;
97
+ })
98
+ : {};
99
+ const named =
100
+ typeof error.code === 'string' && SERVER_CODES.has(error.code)
101
+ ? (error.code as SpeechClipErrorCode)
102
+ : null;
103
+ const code = named ?? (status === 401 ? 'NOT_AUTHENTICATED' : null);
104
+ const message =
105
+ status === 504
106
+ ? 'Request timed out — please try again'
107
+ : typeof error.message === 'string' && error.message !== ''
108
+ ? error.message
109
+ : typeof envelope === 'string' && envelope !== ''
110
+ ? envelope
111
+ : 'TTS failed';
112
+ return new SpeechClipError(message, {
113
+ status,
114
+ code,
115
+ retryable: error.retryable === true,
116
+ requestId: typeof error.requestId === 'string' ? error.requestId : null,
117
+ });
118
+ }
119
+
120
+ /**
121
+ * Synthesize `input` and return the private link the route answers with.
122
+ *
123
+ * @throws {SpeechClipError} when the route refuses or answers no link; a
124
+ * network failure or an abort is thrown as `fetch` threw it.
125
+ */
30
126
  export async function requestSpeechClip(request: SpeechClipRequest): Promise<string> {
127
+ if (request.token === '')
128
+ throw new SpeechClipError('Not authenticated', { status: null, code: 'NOT_AUTHENTICATED' });
31
129
  const response = await fetch(`${request.apiUrl}/v1/audio/speech`, {
32
130
  method: 'POST',
33
131
  signal: request.signal,
@@ -46,16 +144,13 @@ export async function requestSpeechClip(request: SpeechClipRequest): Promise<str
46
144
  });
47
145
 
48
146
  if (!response.ok) {
49
- if (response.status === 504) {
50
- throw new Error('Request timed out — please try again');
51
- }
52
- const errData = await response.json().catch(() => ({}));
53
- throw new Error(errData.error?.message || errData.error || 'TTS failed');
147
+ throw clipErrorFrom(response.status, await response.json().catch(() => null));
54
148
  }
55
149
 
56
- const data = (await response.json()) as { audioUrl?: unknown };
150
+ const data = (await response.json().catch(() => ({}))) as { audioUrl?: unknown };
57
151
  request.signal.throwIfAborted();
58
- if (typeof data.audioUrl !== 'string' || data.audioUrl === '') throw new Error('TTS failed');
152
+ if (typeof data.audioUrl !== 'string' || data.audioUrl === '')
153
+ throw new SpeechClipError('TTS failed', { status: response.status, code: null });
59
154
  return data.audioUrl;
60
155
  }
61
156
 
@@ -0,0 +1,15 @@
1
+ import type { ChatMessage } from '../types';
2
+ import type { AliaAvatarState } from '../components/AliaAvatar';
3
+
4
+ /** What a chat turn is doing, from its messages: the one reading the header mark and every button share. */
5
+ export function turnActivityState(
6
+ messages: ReadonlyArray<ChatMessage>,
7
+ isStreaming: boolean,
8
+ ): AliaAvatarState {
9
+ if (!isStreaming) return 'idle';
10
+ const last = messages[messages.length - 1];
11
+ if (last?.role === 'assistant' && last.toolInvocations?.some((t) => t.state === 'call'))
12
+ return 'working';
13
+ if (last?.thinking) return 'thinking';
14
+ return 'writing';
15
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * The seam between a voice call and the device's audio (ADR 0017 §4).
3
+ *
4
+ * One engine captures the microphone and plays the answer, because echo
5
+ * cancellation only cancels what the SAME audio stack plays:
6
+ *
7
+ * - web (`voice-audio-engine.ts`): `getUserMedia` with echo cancellation, noise
8
+ * suppression and gain control, and two `AudioWorklet`s in one `AudioContext`;
9
+ * - iOS / Android (`voice-audio-engine.native.ts`): `react-native-audio-api`.
10
+ *
11
+ * Audio crosses the seam as PCM16 little-endian, 24 kHz, mono — the format the
12
+ * call carries in both directions — so the call never resamples or converts.
13
+ * Tests drive a call through a fake engine that implements this interface.
14
+ */
15
+
16
+ /** Why an engine could not start, or stopped working mid-call. */
17
+ export type VoiceAudioEngineErrorCode = 'mic-denied' | 'mic-unavailable' | 'voice-unavailable';
18
+
19
+ export class VoiceAudioEngineError extends Error {
20
+ readonly code: VoiceAudioEngineErrorCode;
21
+
22
+ constructor(code: VoiceAudioEngineErrorCode, message?: string) {
23
+ super(message ?? code);
24
+ this.name = 'VoiceAudioEngineError';
25
+ this.code = code;
26
+ }
27
+ }
28
+
29
+ export interface VoiceAudioEngineHandlers {
30
+ /** One captured frame (~40 ms), PCM16 24 kHz mono. */
31
+ onCaptureFrame(pcm16: Uint8Array): void;
32
+ /** The microphone's level, 0..1, from the real captured buffers. */
33
+ onCaptureLevel(level: number): void;
34
+ /** The first sample of `itemId` is audible now — not received, PLAYING. */
35
+ onPlaybackStarted(itemId: string): void;
36
+ /** The level of what is playing, 0..1, from the real played buffers. */
37
+ onPlaybackLevel(level: number): void;
38
+ /** Everything queued has been played. */
39
+ onPlaybackIdle(): void;
40
+ /** The device failed during the call (the microphone went away). */
41
+ onError(error: VoiceAudioEngineError): void;
42
+ }
43
+
44
+ export interface VoiceAudioEngine {
45
+ /**
46
+ * Ask for the microphone and start capturing and the player. Rejects with a
47
+ * {@link VoiceAudioEngineError}.
48
+ */
49
+ start(handlers: VoiceAudioEngineHandlers): Promise<void>;
50
+ /** Queue a chunk of `itemId`'s audio behind what is already queued. */
51
+ enqueue(itemId: string, pcm16: Uint8Array): void;
52
+ /**
53
+ * Drop everything queued and stop playing at once. Resolves with how much of
54
+ * the item that was playing was actually heard, or `null` when nothing was.
55
+ */
56
+ flush(): Promise<{ itemId: string; playedMs: number } | null>;
57
+ /** Release the microphone and the player. Safe to call more than once. */
58
+ stop(): void;
59
+ }
@@ -0,0 +1,232 @@
1
+ /**
2
+ * The iOS / Android voice-call engine, on `react-native-audio-api` (Software
3
+ * Mansion) — capture and playback in ONE native audio stack, because the
4
+ * platform echo canceller only removes what that same stack plays (ADR 0017
5
+ * §4). `expo-audio` cannot do this: its stream captures without echo
6
+ * cancellation and it cannot play a PCM stream.
7
+ *
8
+ * - **iOS:** the audio session is `playAndRecord` in mode `voiceChat`, which is
9
+ * Apple's voice-processing path (echo cancellation, noise suppression, gain
10
+ * control), with the loudspeaker as default output and Bluetooth headsets
11
+ * allowed.
12
+ * - **Android:** `react-native-audio-api` 0.13.6 opens its recorder through
13
+ * Oboe with the default input preset and exposes no `voiceCommunication`
14
+ * preset, so the platform echo canceller is NOT engaged by this engine. That
15
+ * is the Android AEC gate of ADR 0017 §4, to be measured on the Pixel 8a; if
16
+ * it fails, the ADR's fallback is a small Expo module of Alia's own around
17
+ * `AudioRecord(VOICE_COMMUNICATION)` behind this same interface.
18
+ *
19
+ * A new native build is required: the library's config plugin is in
20
+ * `packages/app/app.json`.
21
+ */
22
+
23
+ import {
24
+ type AudioBufferQueueSourceNode,
25
+ AudioContext,
26
+ AudioManager,
27
+ AudioRecorder,
28
+ } from 'react-native-audio-api';
29
+ import { floatToPcm16, levelOfPcm16, pcm16ToFloat } from './pcm';
30
+ import { pcmBytesToMs, VOICE_FRAME_SAMPLES, VOICE_SAMPLE_RATE } from './voice-protocol';
31
+ import {
32
+ VoiceAudioEngineError,
33
+ type VoiceAudioEngine,
34
+ type VoiceAudioEngineHandlers,
35
+ } from './voice-audio-engine-types';
36
+
37
+ export * from './voice-audio-engine-types';
38
+
39
+ /** Held before the first buffer of a burst, so network jitter does not stutter it. */
40
+ const PREBUFFER_MS = 80;
41
+ /** A burst shorter than that still plays once nothing more arrives for this long. */
42
+ const PREBUFFER_IDLE_MS = 100;
43
+
44
+ interface Queued {
45
+ readonly id: string;
46
+ readonly itemId: string;
47
+ readonly ms: number;
48
+ readonly level: number;
49
+ }
50
+
51
+ export function createVoiceAudioEngine(): VoiceAudioEngine {
52
+ let context: AudioContext | null = null;
53
+ let recorder: AudioRecorder | null = null;
54
+ let player: AudioBufferQueueSourceNode | null = null;
55
+ let handlers: VoiceAudioEngineHandlers | null = null;
56
+ let stopped = false;
57
+
58
+ /** Buffers handed to the player, oldest (playing) first. */
59
+ let queued: Queued[] = [];
60
+ /** Chunks held back until the prebuffer is full. */
61
+ let pending: Array<{ itemId: string; pcm: Uint8Array }> = [];
62
+ let pendingMs = 0;
63
+ let prebufferTimer: ReturnType<typeof setTimeout> | null = null;
64
+ /** The item playing now, how much of it has finished, and when its current buffer began. */
65
+ let current: { itemId: string; playedMs: number; bufferStartedAt: number } | null = null;
66
+
67
+ const beginBuffer = (entry: Queued): void => {
68
+ if (context === null || handlers === null) return;
69
+ if (current === null || current.itemId !== entry.itemId) {
70
+ current = { itemId: entry.itemId, playedMs: 0, bufferStartedAt: context.currentTime };
71
+ handlers.onPlaybackStarted(entry.itemId);
72
+ } else {
73
+ current.bufferStartedAt = context.currentTime;
74
+ }
75
+ handlers.onPlaybackLevel(entry.level);
76
+ };
77
+
78
+ const releasePlayer = (): void => {
79
+ if (player === null) return;
80
+ const old = player;
81
+ player = null;
82
+ old.onBufferEnded = null;
83
+ try {
84
+ old.clearBuffers();
85
+ old.stop();
86
+ } catch {
87
+ // Already stopped.
88
+ }
89
+ old.disconnect();
90
+ };
91
+
92
+ /** A fresh player per burst: a queue source that ran dry is not relied on to resume. */
93
+ const ensurePlayer = (): AudioBufferQueueSourceNode | null => {
94
+ if (player !== null || context === null) return player;
95
+ const node = context.createBufferQueueSource();
96
+ node.connect(context.destination);
97
+ node.onBufferEnded = ({ bufferId }) => {
98
+ if (stopped || node !== player) return;
99
+ const index = queued.findIndex((entry) => entry.id === bufferId);
100
+ if (index === -1) return;
101
+ const [ended] = queued.splice(index, 1);
102
+ if (ended && current?.itemId === ended.itemId) current.playedMs += ended.ms;
103
+ const next = queued[0];
104
+ if (next !== undefined) {
105
+ beginBuffer(next);
106
+ return;
107
+ }
108
+ releasePlayer();
109
+ handlers?.onPlaybackLevel(0);
110
+ handlers?.onPlaybackIdle();
111
+ };
112
+ node.start();
113
+ player = node;
114
+ return node;
115
+ };
116
+
117
+ const push = (itemId: string, pcm: Uint8Array): void => {
118
+ const node = ensurePlayer();
119
+ if (node === null || context === null) return;
120
+ const samples = pcm16ToFloat(pcm);
121
+ if (samples.length === 0) return;
122
+ const buffer = context.createBuffer(1, samples.length, VOICE_SAMPLE_RATE);
123
+ buffer.copyToChannel(samples as Float32Array<ArrayBuffer>, 0);
124
+ const id = node.enqueueBuffer(buffer);
125
+ const entry: Queued = {
126
+ id,
127
+ itemId,
128
+ ms: pcmBytesToMs(pcm.byteLength),
129
+ level: levelOfPcm16(pcm),
130
+ };
131
+ queued.push(entry);
132
+ if (queued.length === 1) beginBuffer(entry);
133
+ };
134
+
135
+ const releasePending = (): void => {
136
+ if (prebufferTimer !== null) clearTimeout(prebufferTimer);
137
+ prebufferTimer = null;
138
+ const chunks = pending;
139
+ pending = [];
140
+ pendingMs = 0;
141
+ for (const chunk of chunks) push(chunk.itemId, chunk.pcm);
142
+ };
143
+
144
+ return {
145
+ async start(nextHandlers: VoiceAudioEngineHandlers): Promise<void> {
146
+ handlers = nextHandlers;
147
+ AudioManager.setAudioSessionOptions({
148
+ iosCategory: 'playAndRecord',
149
+ iosMode: 'voiceChat',
150
+ iosOptions: ['defaultToSpeaker', 'allowBluetoothHFP'],
151
+ });
152
+ const permission = await AudioManager.requestRecordingPermissions().catch(() => 'Denied');
153
+ if (permission !== 'Granted') throw new VoiceAudioEngineError('mic-denied');
154
+ try {
155
+ await AudioManager.setAudioSessionActivity(true);
156
+ } catch {
157
+ throw new VoiceAudioEngineError('mic-unavailable');
158
+ }
159
+ if (stopped) return;
160
+
161
+ context = new AudioContext({ sampleRate: VOICE_SAMPLE_RATE });
162
+ recorder = new AudioRecorder();
163
+ const ready = recorder.onAudioReady(
164
+ { sampleRate: VOICE_SAMPLE_RATE, bufferLength: VOICE_FRAME_SAMPLES, channelCount: 1 },
165
+ ({ buffer }) => {
166
+ if (stopped) return;
167
+ const pcm = floatToPcm16(buffer.getChannelData(0));
168
+ handlers?.onCaptureLevel(levelOfPcm16(pcm));
169
+ handlers?.onCaptureFrame(pcm);
170
+ },
171
+ );
172
+ if (ready.status === 'error')
173
+ throw new VoiceAudioEngineError('mic-unavailable', ready.message);
174
+ const started = await recorder.start();
175
+ if (started.status === 'error')
176
+ throw new VoiceAudioEngineError('mic-unavailable', started.message);
177
+ },
178
+
179
+ enqueue(itemId: string, pcm16: Uint8Array): void {
180
+ if (stopped || context === null) return;
181
+ if (player !== null) {
182
+ push(itemId, pcm16);
183
+ return;
184
+ }
185
+ pending.push({ itemId, pcm: pcm16 });
186
+ pendingMs += pcmBytesToMs(pcm16.byteLength);
187
+ if (prebufferTimer !== null) clearTimeout(prebufferTimer);
188
+ if (pendingMs >= PREBUFFER_MS) releasePending();
189
+ else prebufferTimer = setTimeout(releasePending, PREBUFFER_IDLE_MS);
190
+ },
191
+
192
+ async flush(): Promise<{ itemId: string; playedMs: number } | null> {
193
+ if (prebufferTimer !== null) clearTimeout(prebufferTimer);
194
+ prebufferTimer = null;
195
+ pending = [];
196
+ pendingMs = 0;
197
+ const playing = current;
198
+ const head = queued[0];
199
+ let heard: { itemId: string; playedMs: number } | null = null;
200
+ if (playing !== null && context !== null) {
201
+ const intoBuffer =
202
+ head !== undefined && head.itemId === playing.itemId
203
+ ? Math.min(head.ms, Math.max(0, (context.currentTime - playing.bufferStartedAt) * 1000))
204
+ : 0;
205
+ heard = { itemId: playing.itemId, playedMs: Math.round(playing.playedMs + intoBuffer) };
206
+ }
207
+ queued = [];
208
+ current = null;
209
+ releasePlayer();
210
+ handlers?.onPlaybackLevel(0);
211
+ return heard;
212
+ },
213
+
214
+ stop(): void {
215
+ if (stopped) return;
216
+ stopped = true;
217
+ if (prebufferTimer !== null) clearTimeout(prebufferTimer);
218
+ releasePlayer();
219
+ if (recorder !== null) {
220
+ recorder.clearOnAudioReady();
221
+ void recorder.stop().catch(() => undefined);
222
+ }
223
+ void context?.close().catch(() => undefined);
224
+ void AudioManager.setAudioSessionActivity(false).catch(() => undefined);
225
+ recorder = null;
226
+ context = null;
227
+ handlers = null;
228
+ queued = [];
229
+ pending = [];
230
+ },
231
+ };
232
+ }