@alia.onl/sdk 13.2.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 (48) hide show
  1. package/CHANGELOG.md +129 -0
  2. package/README.md +52 -20
  3. package/package.json +8 -4
  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 +57 -11
  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
package/src/index.ts CHANGED
@@ -6,6 +6,7 @@
6
6
  export type { AliaChatAgent, AliaChatState } from './components/AliaChatContent';
7
7
  export { AliaChatSheet } from './components/AliaChatSheet';
8
8
  export type { AliaChatSheetProps, AliaChatSheetRef } from './components/AliaChatSheet';
9
+ export type { AliaTurn } from './components/AliaChatContent';
9
10
  export { AliaChatScreen } from './components/AliaChatScreen';
10
11
  export type { AliaChatScreenProps } from './components/AliaChatScreen';
11
12
 
@@ -19,6 +20,9 @@ export type { AmbientFieldProps } from './components/AmbientField';
19
20
  export { ALIA_AVATAR, AliaAvatar } from './components/AliaAvatar';
20
21
  export { DEFAULT_AGENT_CHARACTER, readAgentCharacter } from './lib/agent-character';
21
22
  export type { AliaAvatarProps, AliaAvatarState } from './components/AliaAvatar';
23
+ export { AliaAvatarIcon } from './components/AliaAvatarIcon';
24
+ export { useAliaActivity } from './hooks/useAliaActivity';
25
+ export type { UseAliaActivityOptions } from './hooks/useAliaActivity';
22
26
  export { IdentityMark } from './components/IdentityMark';
23
27
  export type { IdentityMarkProps, IdentityMarkState } from './components/IdentityMark';
24
28
  export { AliaMarkdown } from './components/Markdown';
@@ -51,15 +55,17 @@ export type { UseAliaChatOptions, UseAliaChatReturn } from './hooks/useAliaChat'
51
55
  export { AliaChatStreamError } from './lib/chat-stream';
52
56
  export type { AliaChatFailure } from './lib/chat-stream';
53
57
 
54
- // ── Text-to-speech / speech-to-text (no livekit) ──
58
+ // ── Read aloud and dictation (no call, no audio engine) ──
55
59
  export { useTTS } from './hooks/useTTS';
56
60
  export type { UseTTSOptions } from './hooks/useTTS';
57
- export { useSpeechToText, useSTTStore } from './hooks/useSpeechToText';
61
+ export { useDictation, useDictationStore } from './hooks/useDictation';
62
+ export type { UseDictationOptions, DictationState } from './hooks/useDictation';
63
+ export { DictationError, requestTranscription } from './lib/dictation';
64
+ export type { DictationErrorCode, TranscriptionResult } from './lib/dictation';
58
65
  export { VOICE_ERROR_MESSAGES } from './lib/speech-messages';
59
66
  export type { VoiceErrorCode } from './lib/speech-messages';
60
67
  export { SpeechClipError } from './lib/speech-synthesis';
61
68
  export type { SpeechClipErrorCode } from './lib/speech-synthesis';
62
- export type { UseSTTOptions } from './hooks/useSpeechToText';
63
69
 
64
70
  // ── Model catalogue (GET /catalogue) ──
65
71
  // Exported so a consumer building its own picker reads the real models the
@@ -0,0 +1,102 @@
1
+ import { io, type Socket } from 'socket.io-client';
2
+ import type { AliaAvatarState } from '../components/AliaAvatar';
3
+ import { useAliaActivityStore } from './activity-store';
4
+
5
+ /** A run that goes quiet this long is over: a crashed run never says `idle`. */
6
+ export const ACTIVITY_QUIET_MS = 90_000;
7
+ /** Between tries after the server refuses a handshake (socket.io does not retry those itself). */
8
+ const RETRY_MS = 5_000;
9
+
10
+ const STATES: ReadonlySet<string> = new Set<AliaAvatarState>([
11
+ 'idle',
12
+ 'thinking',
13
+ 'working',
14
+ 'writing',
15
+ ]);
16
+
17
+ interface Connection {
18
+ socket: Socket;
19
+ refs: number;
20
+ quiet: Map<string, ReturnType<typeof setTimeout>>;
21
+ retry: ReturnType<typeof setTimeout> | null;
22
+ }
23
+
24
+ /** One connection per API origin, however many buttons ask for it. */
25
+ const connections = new Map<string, Connection>();
26
+
27
+ function open(url: string, getToken: () => string | null): Connection {
28
+ const socket = io(url, {
29
+ transports: ['websocket'],
30
+ // Read on every (re)connect, so a rotated access token is the one that goes out.
31
+ auth: (cb) => cb({ token: getToken() }),
32
+ reconnection: true,
33
+ reconnectionAttempts: Infinity,
34
+ reconnectionDelay: 1000,
35
+ reconnectionDelayMax: 10_000,
36
+ });
37
+ const connection: Connection = { socket, refs: 0, quiet: new Map(), retry: null };
38
+ const store = useAliaActivityStore.getState;
39
+
40
+ const reset = (): void => {
41
+ for (const timer of connection.quiet.values()) clearTimeout(timer);
42
+ connection.quiet.clear();
43
+ store().clearRemote();
44
+ };
45
+
46
+ socket.on('connect', () => socket.emit('subscribe-alia-activity'));
47
+ // What happened while disconnected is unknown; a stale "working" is worse than idle.
48
+ socket.on('disconnect', reset);
49
+ socket.on('connect_error', () => {
50
+ // A refused handshake (no token yet, an expired one) ends the socket's own retrying.
51
+ if (socket.active || connection.retry !== null) return;
52
+ connection.retry = setTimeout(() => {
53
+ connection.retry = null;
54
+ socket.connect();
55
+ }, RETRY_MS);
56
+ });
57
+ socket.on('alia:activity', (event: unknown) => {
58
+ if (typeof event !== 'object' || event === null) return;
59
+ const { state, sessionId } = event as { state?: unknown; sessionId?: unknown };
60
+ if (typeof sessionId !== 'string' || typeof state !== 'string' || !STATES.has(state)) return;
61
+ const pending = connection.quiet.get(sessionId);
62
+ if (pending !== undefined) clearTimeout(pending);
63
+ connection.quiet.delete(sessionId);
64
+ store().setRemote(sessionId, state as AliaAvatarState);
65
+ if (state !== 'idle') {
66
+ connection.quiet.set(
67
+ sessionId,
68
+ setTimeout(() => {
69
+ connection.quiet.delete(sessionId);
70
+ store().setRemote(sessionId, 'idle');
71
+ }, ACTIVITY_QUIET_MS),
72
+ );
73
+ }
74
+ });
75
+ return connection;
76
+ }
77
+
78
+ /**
79
+ * Listen for Alia's activity on the person's behalf. Every caller shares the one connection for
80
+ * `url`; the last to release closes it. Returns the release.
81
+ */
82
+ export function acquireActivitySocket(url: string, getToken: () => string | null): () => void {
83
+ let held = connections.get(url);
84
+ if (!held) {
85
+ held = open(url, getToken);
86
+ connections.set(url, held);
87
+ }
88
+ const connection = held;
89
+ connection.refs++;
90
+ let released = false;
91
+ return () => {
92
+ if (released) return;
93
+ released = true;
94
+ if (--connection.refs > 0) return;
95
+ connections.delete(url);
96
+ if (connection.retry !== null) clearTimeout(connection.retry);
97
+ connection.socket.removeAllListeners();
98
+ connection.socket.disconnect();
99
+ for (const timer of connection.quiet.values()) clearTimeout(timer);
100
+ useAliaActivityStore.getState().clearRemote();
101
+ };
102
+ }
@@ -0,0 +1,53 @@
1
+ import { create } from 'zustand';
2
+ import type { AliaAvatarState } from '../components/AliaAvatar';
3
+
4
+ type Sources = Readonly<Record<string, AliaAvatarState>>;
5
+
6
+ /**
7
+ * What Alia is doing, from every place that can know. Two sources feed it and neither owns it:
8
+ *
9
+ * - `local`: the turns running in THIS app, one entry per `useAliaChat` instance (the sheet);
10
+ * - `remote`: runs the server reports for the person, one entry per agent session, so a button
11
+ * reacts to work that is not in any open chat.
12
+ *
13
+ * An idle source holds no entry, so neither map grows with use.
14
+ */
15
+ interface AliaActivityStore {
16
+ local: Sources;
17
+ remote: Sources;
18
+ setLocal(source: string, state: AliaAvatarState): void;
19
+ setRemote(session: string, state: AliaAvatarState): void;
20
+ clearRemote(): void;
21
+ }
22
+
23
+ function put(map: Sources, key: string, state: AliaAvatarState): Sources {
24
+ if (state === 'idle') {
25
+ if (!(key in map)) return map;
26
+ const { [key]: _gone, ...rest } = map;
27
+ return rest;
28
+ }
29
+ return map[key] === state ? map : { ...map, [key]: state };
30
+ }
31
+
32
+ export const useAliaActivityStore = create<AliaActivityStore>((set) => ({
33
+ local: {},
34
+ remote: {},
35
+ setLocal: (source, state) => set((s) => ({ local: put(s.local, source, state) })),
36
+ setRemote: (session, state) => set((s) => ({ remote: put(s.remote, session, state) })),
37
+ clearRemote: () => set((s) => (Object.keys(s.remote).length === 0 ? s : { remote: {} })),
38
+ }));
39
+
40
+ /** The most telling thing any source is doing: a tool call outranks thinking, thinking outranks writing. */
41
+ const RANK: Record<AliaAvatarState, number> = { idle: 0, writing: 1, thinking: 2, working: 3 };
42
+
43
+ export function busiest(...maps: ReadonlyArray<Sources>): AliaAvatarState {
44
+ let top: AliaAvatarState = 'idle';
45
+ for (const map of maps) {
46
+ for (const state of Object.values(map)) if (RANK[state] > RANK[top]) top = state;
47
+ }
48
+ return top;
49
+ }
50
+
51
+ /** A primitive, so a subscriber re-renders when the state changes and not when a session does. */
52
+ export const selectAliaActivity = (s: AliaActivityStore): AliaAvatarState =>
53
+ busiest(s.local, s.remote);
@@ -0,0 +1,2 @@
1
+ /** Where Alia's API is: `EXPO_PUBLIC_ALIA_API_URL`, then `https://api.alia.onl`. */
2
+ export const ALIA_API_URL = process.env.EXPO_PUBLIC_ALIA_API_URL ?? 'https://api.alia.onl';
@@ -3,8 +3,8 @@
3
3
  * time constants, for every source that drives it.
4
4
  *
5
5
  * Dictation got here first and is the reference: the microphone's level is
6
- * taken as dBFS (on web, from an analyser beside the recognizer —
7
- * `speech-recognition.ts`), normalised against a -60 floor, and `useAmbientWave`
6
+ * taken as dBFS (on web, from an analyser beside the recorder —
7
+ * `dictation-recorder.ts`), normalised against a -60 floor, and `useAmbientWave`
8
8
  * smooths it with a fast attack and a slow decay before it reaches
9
9
  * `AmbientField`. That is the behaviour people like, so it is the behaviour
10
10
  * everything else adopts rather than re-invents — including "read aloud", which
@@ -0,0 +1,37 @@
1
+ import type { DictationClip } from './dictation';
2
+
3
+ /** Why the microphone could not record. */
4
+ export type DictationRecorderErrorCode = 'mic-denied' | 'mic-unavailable';
5
+
6
+ export class DictationRecorderError extends Error {
7
+ readonly code: DictationRecorderErrorCode;
8
+
9
+ constructor(code: DictationRecorderErrorCode, message?: string) {
10
+ super(message ?? code);
11
+ this.name = 'DictationRecorderError';
12
+ this.code = code;
13
+ }
14
+ }
15
+
16
+ /**
17
+ * A clip recorder for the composer's microphone:
18
+ *
19
+ * - web (`dictation-recorder.ts`): `MediaRecorder`, `audio/webm;codecs=opus`
20
+ * or `audio/mp4` (Safari), level from an `AnalyserNode` on the same stream;
21
+ * - iOS / Android (`dictation-recorder.native.ts`): the `expo-audio`
22
+ * recorder, `RecordingPresets.HIGH_QUALITY` (m4a/AAC → `audio/mp4`) with
23
+ * metering on.
24
+ *
25
+ * Levels are the real recorder's, 0..1 on the dictation meter's dBFS scale.
26
+ */
27
+ export interface DictationRecorder {
28
+ /**
29
+ * Ask for the microphone and start recording; resolves once the recorder
30
+ * says it started. Rejects with a {@link DictationRecorderError}.
31
+ */
32
+ start(onLevel: (level: number) => void): Promise<void>;
33
+ /** Stop and hand over the clip, or `null` when nothing was recorded. */
34
+ stop(): Promise<DictationClip | null>;
35
+ /** Stop and throw the clip away. */
36
+ cancel(): void;
37
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Dictation's iOS / Android recorder: the `expo-audio` recorder (already a
3
+ * peer of this SDK), `RecordingPresets.HIGH_QUALITY` in mono with metering on.
4
+ * The clip is m4a/AAC, sent as `audio/mp4`. No new native module: the call's
5
+ * engine (`react-native-audio-api`) is not needed to record a clip.
6
+ */
7
+
8
+ import { useMemo } from 'react';
9
+ import {
10
+ RecordingPresets,
11
+ requestRecordingPermissionsAsync,
12
+ setAudioModeAsync,
13
+ useAudioRecorder,
14
+ } from 'expo-audio';
15
+ import { levelFromDbfs } from './audio-level';
16
+ import type { DictationClip } from './dictation';
17
+ import { DictationRecorderError, type DictationRecorder } from './dictation-recorder-types';
18
+
19
+ export * from './dictation-recorder-types';
20
+
21
+ const LEVEL_POLL_MS = 100;
22
+
23
+ const OPTIONS = { ...RecordingPresets.HIGH_QUALITY, numberOfChannels: 1, isMeteringEnabled: true };
24
+
25
+ export function useDictationRecorder(): DictationRecorder {
26
+ const recorder = useAudioRecorder(OPTIONS);
27
+
28
+ return useMemo(() => {
29
+ let levelTimer: ReturnType<typeof setInterval> | null = null;
30
+ let lastDurationMs = 0;
31
+ let recording = false;
32
+
33
+ const stopLevels = (): void => {
34
+ if (levelTimer !== null) clearInterval(levelTimer);
35
+ levelTimer = null;
36
+ };
37
+ const releaseSession = (): void => {
38
+ void setAudioModeAsync({ allowsRecording: false }).catch(() => undefined);
39
+ };
40
+
41
+ return {
42
+ async start(onLevel) {
43
+ const permission = await requestRecordingPermissionsAsync().catch(() => null);
44
+ if (!permission?.granted) throw new DictationRecorderError('mic-denied');
45
+ try {
46
+ await setAudioModeAsync({ allowsRecording: true, playsInSilentMode: true });
47
+ await recorder.prepareToRecordAsync();
48
+ recorder.record();
49
+ } catch {
50
+ releaseSession();
51
+ throw new DictationRecorderError('mic-unavailable');
52
+ }
53
+ recording = true;
54
+ lastDurationMs = 0;
55
+ levelTimer = setInterval(() => {
56
+ const status = recorder.getStatus();
57
+ lastDurationMs = status.durationMillis;
58
+ onLevel(typeof status.metering === 'number' ? levelFromDbfs(status.metering) : 0);
59
+ }, LEVEL_POLL_MS);
60
+ },
61
+
62
+ async stop(): Promise<DictationClip | null> {
63
+ if (!recording) return null;
64
+ recording = false;
65
+ stopLevels();
66
+ const durationMs = recorder.getStatus().durationMillis || lastDurationMs;
67
+ try {
68
+ await recorder.stop();
69
+ } finally {
70
+ releaseSession();
71
+ }
72
+ const uri = recorder.uri;
73
+ if (!uri) return null;
74
+ return {
75
+ file: { uri, name: 'dictation.m4a', type: 'audio/mp4' },
76
+ mediaType: 'audio/mp4',
77
+ durationMs,
78
+ };
79
+ },
80
+
81
+ cancel(): void {
82
+ if (!recording) return;
83
+ recording = false;
84
+ stopLevels();
85
+ void recorder.stop().catch(() => undefined);
86
+ releaseSession();
87
+ },
88
+ };
89
+ }, [recorder]);
90
+ }
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Dictation's web recorder: `MediaRecorder` over `getUserMedia`, with an
3
+ * `AnalyserNode` on the same stream for the level meter. See
4
+ * `dictation-recorder-types.ts`.
5
+ */
6
+
7
+ import { useMemo } from 'react';
8
+ import { levelFromFrames } from './audio-level';
9
+ import { normalizeAudioMediaType, type DictationClip } from './dictation';
10
+ import { DictationRecorderError, type DictationRecorder } from './dictation-recorder-types';
11
+
12
+ export * from './dictation-recorder-types';
13
+
14
+ const LEVEL_POLL_MS = 100;
15
+
16
+ /** Opus in WebM where the browser records it (Chromium, Firefox), MP4/AAC on Safari. */
17
+ function recordingMimeType(): string | null {
18
+ const Recorder = globalThis.MediaRecorder;
19
+ if (!Recorder) return null;
20
+ for (const type of ['audio/webm;codecs=opus', 'audio/webm', 'audio/mp4']) {
21
+ if (Recorder.isTypeSupported?.(type)) return type;
22
+ }
23
+ return null;
24
+ }
25
+
26
+ export function createWebDictationRecorder(): DictationRecorder {
27
+ let stream: MediaStream | null = null;
28
+ let recorder: MediaRecorder | null = null;
29
+ let context: AudioContext | null = null;
30
+ let levelTimer: ReturnType<typeof setInterval> | null = null;
31
+ let chunks: Blob[] = [];
32
+ let startedAt = 0;
33
+
34
+ const release = (): void => {
35
+ if (levelTimer !== null) clearInterval(levelTimer);
36
+ levelTimer = null;
37
+ for (const track of stream?.getTracks() ?? []) track.stop();
38
+ void context?.close().catch(() => undefined);
39
+ stream = null;
40
+ context = null;
41
+ };
42
+
43
+ return {
44
+ async start(onLevel) {
45
+ const mimeType = recordingMimeType();
46
+ const media = globalThis.navigator?.mediaDevices;
47
+ if (mimeType === null || !media?.getUserMedia)
48
+ throw new DictationRecorderError('mic-unavailable');
49
+ try {
50
+ stream = await media.getUserMedia({
51
+ audio: { noiseSuppression: true, echoCancellation: true, autoGainControl: true },
52
+ });
53
+ } catch (caught: unknown) {
54
+ const name = (caught as { name?: unknown } | null)?.name;
55
+ throw new DictationRecorderError(
56
+ name === 'NotAllowedError' || name === 'SecurityError' ? 'mic-denied' : 'mic-unavailable',
57
+ );
58
+ }
59
+
60
+ const AudioContextCtor = globalThis.AudioContext;
61
+ if (AudioContextCtor) {
62
+ context = new AudioContextCtor();
63
+ const analyser = context.createAnalyser();
64
+ analyser.fftSize = 1024;
65
+ context.createMediaStreamSource(stream).connect(analyser);
66
+ const frames = new Float32Array(analyser.fftSize);
67
+ levelTimer = setInterval(() => {
68
+ analyser.getFloatTimeDomainData(frames);
69
+ onLevel(levelFromFrames(Array.from(frames)));
70
+ }, LEVEL_POLL_MS);
71
+ }
72
+
73
+ chunks = [];
74
+ const active = new MediaRecorder(stream, { mimeType });
75
+ recorder = active;
76
+ active.ondataavailable = (event) => {
77
+ if (event.data.size > 0) chunks.push(event.data);
78
+ };
79
+ await new Promise<void>((resolve, reject) => {
80
+ active.onstart = () => {
81
+ startedAt = Date.now();
82
+ resolve();
83
+ };
84
+ active.onerror = () => reject(new DictationRecorderError('mic-unavailable'));
85
+ try {
86
+ active.start();
87
+ } catch {
88
+ reject(new DictationRecorderError('mic-unavailable'));
89
+ }
90
+ }).catch((error: unknown) => {
91
+ release();
92
+ throw error;
93
+ });
94
+ },
95
+
96
+ stop(): Promise<DictationClip | null> {
97
+ const active = recorder;
98
+ recorder = null;
99
+ if (active === null || active.state === 'inactive') {
100
+ release();
101
+ return Promise.resolve(null);
102
+ }
103
+ return new Promise((resolve) => {
104
+ active.onstop = () => {
105
+ const durationMs = Date.now() - startedAt;
106
+ const mediaType = normalizeAudioMediaType(active.mimeType || 'audio/webm');
107
+ const blob = new Blob(chunks, { type: mediaType });
108
+ chunks = [];
109
+ release();
110
+ resolve(blob.size === 0 ? null : { file: blob, mediaType, durationMs });
111
+ };
112
+ active.stop();
113
+ });
114
+ },
115
+
116
+ cancel(): void {
117
+ const active = recorder;
118
+ recorder = null;
119
+ chunks = [];
120
+ if (active !== null && active.state !== 'inactive') {
121
+ active.onstop = null;
122
+ active.stop();
123
+ }
124
+ release();
125
+ },
126
+ };
127
+ }
128
+
129
+ export function useDictationRecorder(): DictationRecorder {
130
+ return useMemo(() => createWebDictationRecorder(), []);
131
+ }
@@ -0,0 +1,208 @@
1
+ /**
2
+ * Dictation's network half: a recorded clip to `POST /v1/audio/transcriptions`
3
+ * (ADR 0017 §7), which Alia answers with a Kaana transcription model computed
4
+ * from the catalogue. Free on every plan; at most five minutes per clip.
5
+ *
6
+ * Batch, not streaming: the text arrives once the person stops. Nothing is
7
+ * recognized on the device.
8
+ */
9
+
10
+ import type { VoiceErrorCode } from './speech-messages';
11
+
12
+ /** The longest clip one dictation may be. */
13
+ export const MAX_DICTATION_MS = 5 * 60_000;
14
+
15
+ /** A clip as a recorder hands it over: a web `Blob`, or a React Native file. */
16
+ export interface DictationClip {
17
+ readonly file: Blob | { readonly uri: string; readonly name: string; readonly type: string };
18
+ /** Without parameters: `audio/webm`, `audio/mp4`, … */
19
+ readonly mediaType: string;
20
+ /** Measured by the recorder. */
21
+ readonly durationMs: number;
22
+ }
23
+
24
+ export interface TranscriptionResult {
25
+ readonly text: string;
26
+ readonly language?: string;
27
+ readonly durationMs?: number;
28
+ /** The model that transcribed it (its name may be shown). */
29
+ readonly model?: string;
30
+ }
31
+
32
+ /** What the route answers a failure with, plus the two this side knows on its own. */
33
+ export type DictationErrorCode =
34
+ | 'TRANSCRIPTION_UNAVAILABLE'
35
+ | 'TRANSCRIPTION_LIMITED'
36
+ | 'TRANSCRIPTION_REJECTED'
37
+ | 'TRANSCRIPTION_FAILED'
38
+ | 'CLIP_TOO_LONG'
39
+ | 'INVALID_REQUEST'
40
+ | 'NOT_AUTHENTICATED'
41
+ | 'CREDENTIAL_NOT_SUPPORTED';
42
+
43
+ const SERVER_CODES: ReadonlySet<string> = new Set<DictationErrorCode>([
44
+ 'TRANSCRIPTION_UNAVAILABLE',
45
+ 'TRANSCRIPTION_LIMITED',
46
+ 'TRANSCRIPTION_REJECTED',
47
+ 'TRANSCRIPTION_FAILED',
48
+ 'CLIP_TOO_LONG',
49
+ 'INVALID_REQUEST',
50
+ 'NOT_AUTHENTICATED',
51
+ 'CREDENTIAL_NOT_SUPPORTED',
52
+ ]);
53
+
54
+ export class DictationError extends Error {
55
+ /** `null` when no request was answered (network) or the code is unknown. */
56
+ readonly code: DictationErrorCode | null;
57
+ readonly status: number | null;
58
+ readonly retryable: boolean;
59
+
60
+ constructor(
61
+ message: string,
62
+ details: { code: DictationErrorCode | null; status: number | null; retryable?: boolean },
63
+ ) {
64
+ super(message);
65
+ this.name = 'DictationError';
66
+ this.code = details.code;
67
+ this.status = details.status;
68
+ this.retryable = details.retryable ?? false;
69
+ }
70
+ }
71
+
72
+ /** The `VoiceErrorCode` an app says a failed transcription with. */
73
+ export function dictationFailureCode(error: unknown): VoiceErrorCode {
74
+ if (!(error instanceof DictationError)) return 'transcription-failed';
75
+ switch (error.code) {
76
+ case 'TRANSCRIPTION_UNAVAILABLE':
77
+ return 'transcription-unavailable';
78
+ case 'TRANSCRIPTION_LIMITED':
79
+ return 'transcription-limited';
80
+ case 'TRANSCRIPTION_REJECTED':
81
+ case 'INVALID_REQUEST':
82
+ return 'transcription-rejected';
83
+ case 'CLIP_TOO_LONG':
84
+ return 'too-long';
85
+ case 'NOT_AUTHENTICATED':
86
+ return 'not-authenticated';
87
+ case 'CREDENTIAL_NOT_SUPPORTED':
88
+ return 'credential-not-supported';
89
+ case 'TRANSCRIPTION_FAILED':
90
+ case null:
91
+ return 'transcription-failed';
92
+ }
93
+ }
94
+
95
+ /** `audio/webm;codecs=opus` → `audio/webm`; iOS's m4a names → `audio/mp4`. */
96
+ export function normalizeAudioMediaType(type: string): string {
97
+ const bare = type.split(';')[0]!.trim().toLowerCase();
98
+ if (bare === 'audio/x-m4a' || bare === 'audio/m4a' || bare === 'audio/aac-mp4')
99
+ return 'audio/mp4';
100
+ return bare;
101
+ }
102
+
103
+ function fileNameFor(mediaType: string): string {
104
+ switch (mediaType) {
105
+ case 'audio/webm':
106
+ return 'dictation.webm';
107
+ case 'audio/ogg':
108
+ return 'dictation.ogg';
109
+ case 'audio/mpeg':
110
+ return 'dictation.mp3';
111
+ case 'audio/wav':
112
+ return 'dictation.wav';
113
+ default:
114
+ return 'dictation.m4a';
115
+ }
116
+ }
117
+
118
+ export interface TranscriptionRequest {
119
+ readonly apiUrl: string;
120
+ readonly token: string;
121
+ readonly clip: DictationClip;
122
+ /** BCP-47. */
123
+ readonly language: string;
124
+ readonly signal?: AbortSignal;
125
+ }
126
+
127
+ /**
128
+ * Transcribe one clip.
129
+ *
130
+ * @throws {DictationError} for every refusal; an abort is thrown as `fetch` threw it.
131
+ */
132
+ export async function requestTranscription(
133
+ request: TranscriptionRequest,
134
+ ): Promise<TranscriptionResult> {
135
+ if (request.token === '')
136
+ throw new DictationError('Not authenticated', { code: 'NOT_AUTHENTICATED', status: null });
137
+ const mediaType = normalizeAudioMediaType(request.clip.mediaType);
138
+ const form = new FormData();
139
+ const { file } = request.clip;
140
+ if (typeof Blob !== 'undefined' && file instanceof Blob) {
141
+ form.append('file', file, fileNameFor(mediaType));
142
+ } else {
143
+ // React Native's FormData takes a file by `{ uri, name, type }`.
144
+ form.append('file', file as unknown as Blob);
145
+ }
146
+ form.append('language', request.language);
147
+ form.append('durationMs', String(Math.max(0, Math.round(request.clip.durationMs))));
148
+
149
+ let response: Response;
150
+ try {
151
+ response = await fetch(`${request.apiUrl}/v1/audio/transcriptions`, {
152
+ method: 'POST',
153
+ headers: { Authorization: `Bearer ${request.token}` },
154
+ body: form,
155
+ ...(request.signal ? { signal: request.signal } : {}),
156
+ });
157
+ } catch (caught: unknown) {
158
+ if ((caught as { name?: unknown } | null)?.name === 'AbortError') throw caught;
159
+ throw new DictationError('The transcription request did not reach Alia', {
160
+ code: null,
161
+ status: null,
162
+ retryable: true,
163
+ });
164
+ }
165
+
166
+ const body: unknown = await response.json().catch(() => null);
167
+ if (!response.ok) {
168
+ const envelope =
169
+ body !== null && typeof body === 'object' ? (body as { error?: unknown }).error : undefined;
170
+ const error =
171
+ envelope !== null && typeof envelope === 'object'
172
+ ? (envelope as { code?: unknown; message?: unknown; retryable?: unknown })
173
+ : {};
174
+ const named =
175
+ typeof error.code === 'string' && SERVER_CODES.has(error.code)
176
+ ? (error.code as DictationErrorCode)
177
+ : null;
178
+ throw new DictationError(
179
+ typeof error.message === 'string' && error.message !== ''
180
+ ? error.message
181
+ : 'Transcription failed',
182
+ {
183
+ code:
184
+ named ??
185
+ (response.status === 401
186
+ ? 'NOT_AUTHENTICATED'
187
+ : response.status === 413
188
+ ? 'CLIP_TOO_LONG'
189
+ : null),
190
+ status: response.status,
191
+ retryable: error.retryable === true,
192
+ },
193
+ );
194
+ }
195
+ const result = body !== null && typeof body === 'object' ? (body as Record<string, unknown>) : {};
196
+ if (typeof result.text !== 'string') {
197
+ throw new DictationError('Alia answered no transcription', {
198
+ code: 'TRANSCRIPTION_FAILED',
199
+ status: response.status,
200
+ });
201
+ }
202
+ return {
203
+ text: result.text,
204
+ ...(typeof result.language === 'string' ? { language: result.language } : {}),
205
+ ...(typeof result.durationMs === 'number' ? { durationMs: result.durationMs } : {}),
206
+ ...(typeof result.model === 'string' ? { model: result.model } : {}),
207
+ };
208
+ }