@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.
- package/CHANGELOG.md +190 -0
- package/README.md +83 -20
- package/package.json +8 -4
- package/src/components/AliaAvatarIcon.tsx +30 -0
- package/src/components/AliaChatContent.tsx +57 -107
- package/src/components/AliaChatMessageList.tsx +2 -10
- package/src/components/AliaChatScreen.tsx +28 -25
- package/src/components/AliaChatSheet.tsx +30 -8
- package/src/components/AliaComposer.tsx +382 -0
- package/src/components/ComposerDropZone.tsx +235 -0
- package/src/components/voice/VoiceControls.tsx +28 -4
- package/src/components/voice/VoiceSession.tsx +23 -7
- package/src/hooks/useAliaActivity.ts +31 -0
- package/src/hooks/useAliaChat.ts +14 -3
- package/src/hooks/useAmbientWave.ts +3 -3
- package/src/hooks/useAttachmentIntake.ts +401 -0
- package/src/hooks/useAudioLevelMonitor.ts +2 -2
- package/src/hooks/useDictation.ts +295 -0
- package/src/hooks/useFocusWhenPresented.ts +41 -0
- package/src/hooks/usePendingQuestions.ts +19 -0
- package/src/hooks/useTTS.ts +24 -63
- package/src/hooks/useVoiceRoom.ts +148 -671
- package/src/index.ts +19 -3
- package/src/lib/activity-socket.ts +102 -0
- package/src/lib/activity-store.ts +53 -0
- package/src/lib/api-url.ts +2 -0
- package/src/lib/attachment-intake.ts +154 -0
- package/src/lib/attachment-tiles.ts +74 -0
- package/src/lib/audio-level.ts +2 -2
- package/src/lib/dictation-recorder-types.ts +37 -0
- package/src/lib/dictation-recorder.native.ts +90 -0
- package/src/lib/dictation-recorder.ts +131 -0
- package/src/lib/dictation.ts +208 -0
- package/src/lib/labels.tsx +50 -1
- package/src/lib/pcm.ts +57 -0
- package/src/lib/speech-language.ts +1 -1
- package/src/lib/speech-messages.ts +43 -65
- package/src/lib/speech-synthesis.ts +104 -9
- package/src/lib/turn-state.ts +15 -0
- package/src/lib/voice-audio-engine-types.ts +59 -0
- package/src/lib/voice-audio-engine.native.ts +232 -0
- package/src/lib/voice-audio-engine.ts +350 -0
- package/src/lib/voice-call.ts +539 -0
- package/src/lib/voice-levels.ts +7 -3
- package/src/lib/voice-protocol.ts +309 -0
- package/src/tools.ts +1 -0
- package/src/types.ts +63 -4
- package/src/voice.ts +31 -10
- package/tsconfig.json +2 -0
- package/src/hooks/useSpeechToText.ts +0 -268
- package/src/lib/speech-recognition-types.ts +0 -108
- package/src/lib/speech-recognition.native.ts +0 -237
- package/src/lib/speech-recognition.ts +0 -308
- package/src/lib/voice-text.ts +0 -198
- package/src/lib/voice-turn.ts +0 -89
|
@@ -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
|
+
}
|
package/src/lib/labels.tsx
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createContext, type ReactNode, useContext, useMemo } from 'react';
|
|
2
|
+
import { VOICE_ERROR_MESSAGES, type VoiceErrorCode } from './speech-messages';
|
|
2
3
|
import { englishWorkLog, type Translate } from './work-log-copy';
|
|
3
4
|
|
|
4
5
|
/** Every string the chat UI draws itself. Bloom's own copy follows `BloomProvider locale`. */
|
|
@@ -15,6 +16,34 @@ export interface AliaChatLabels {
|
|
|
15
16
|
addPhotos: string;
|
|
16
17
|
startVoice: string;
|
|
17
18
|
voiceFailed: string;
|
|
19
|
+
/** The field's accessible name. */
|
|
20
|
+
composerMessage: string;
|
|
21
|
+
/** The add button's accessible name. */
|
|
22
|
+
composerAddMenu: string;
|
|
23
|
+
/** The mode selector's accessible name (the host's power levels). */
|
|
24
|
+
composerModes: string;
|
|
25
|
+
/** The dictation button's accessible name. */
|
|
26
|
+
composerVoice: string;
|
|
27
|
+
composerSend: string;
|
|
28
|
+
composerStop: string;
|
|
29
|
+
/** A tile's remove button. */
|
|
30
|
+
composerRemove: string;
|
|
31
|
+
/** Bloom names a retry button "<this> <file name>". */
|
|
32
|
+
composerRetry: string;
|
|
33
|
+
/** What a tile without a file name is called. */
|
|
34
|
+
composerUntitled: string;
|
|
35
|
+
fileReadFailed: (name: string) => string;
|
|
36
|
+
fileTooLarge: (name: string, limit: string) => string;
|
|
37
|
+
fileEmpty: (name: string) => string;
|
|
38
|
+
/** A file of a kind the chat cannot send (a document, where only photos go). */
|
|
39
|
+
fileUnsupported: (name: string) => string;
|
|
40
|
+
/** Over the composer while a file is dragged onto it. */
|
|
41
|
+
dropHint: string;
|
|
42
|
+
dropUnavailable: string;
|
|
43
|
+
/** Why dictation failed, in the host's language. */
|
|
44
|
+
voiceError: (code: VoiceErrorCode) => string;
|
|
45
|
+
/** Over the composer in the last 30 s of a dictation's five minutes. */
|
|
46
|
+
dictationRemaining: (seconds: number) => string;
|
|
18
47
|
// Welcome
|
|
19
48
|
/** The empty chat's greeting for a signed-out visitor. */
|
|
20
49
|
greeting: string;
|
|
@@ -56,6 +85,23 @@ export const DEFAULT_LABELS: AliaChatLabels = {
|
|
|
56
85
|
addPhotos: 'Photos',
|
|
57
86
|
startVoice: 'Start voice',
|
|
58
87
|
voiceFailed: "Couldn't start the voice call",
|
|
88
|
+
composerMessage: 'Message',
|
|
89
|
+
composerAddMenu: 'Add to chat',
|
|
90
|
+
composerModes: 'Power level',
|
|
91
|
+
composerVoice: 'Dictate',
|
|
92
|
+
composerSend: 'Send message',
|
|
93
|
+
composerStop: 'Stop generating',
|
|
94
|
+
composerRemove: 'Remove',
|
|
95
|
+
composerRetry: 'Retry',
|
|
96
|
+
composerUntitled: 'untitled',
|
|
97
|
+
fileReadFailed: (name) => `${name} could not be read — select to try again`,
|
|
98
|
+
fileTooLarge: (name, limit) => `${name} is larger than ${limit} and was not attached`,
|
|
99
|
+
fileEmpty: (name) => `${name} is empty — folders can't be attached`,
|
|
100
|
+
fileUnsupported: (name) => `${name} can't be sent here — only photos can`,
|
|
101
|
+
dropHint: 'Drop files here to attach them',
|
|
102
|
+
dropUnavailable: "Files can't be attached right now",
|
|
103
|
+
voiceError: (code) => VOICE_ERROR_MESSAGES[code],
|
|
104
|
+
dictationRemaining: (seconds) => `${seconds}s of dictation left`,
|
|
59
105
|
greeting: "Hi, what's on your mind?",
|
|
60
106
|
greetingNamed: (name) => `Hi ${name}, what's on your mind?`,
|
|
61
107
|
copy: 'Copy',
|
|
@@ -87,7 +133,10 @@ export function AliaChatLabelsProvider({
|
|
|
87
133
|
labels?: Partial<AliaChatLabels>;
|
|
88
134
|
children: ReactNode;
|
|
89
135
|
}) {
|
|
90
|
-
|
|
136
|
+
// Over whatever labels are already in reach, so a part that takes its own `labels` (the
|
|
137
|
+
// composer) refines the chat's copy instead of resetting it to English.
|
|
138
|
+
const inherited = useContext(LabelsContext);
|
|
139
|
+
const value = useMemo(() => ({ ...inherited, ...labels }), [inherited, labels]);
|
|
91
140
|
return <LabelsContext.Provider value={value}>{children}</LabelsContext.Provider>;
|
|
92
141
|
}
|
|
93
142
|
|
package/src/lib/pcm.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PCM16 in and out of the -1..1 floats audio engines work in, and the level a
|
|
3
|
+
* buffer reads at. No imports, so every engine (and the tests) shares one
|
|
4
|
+
* definition of the conversion and of the meter.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { levelFromFrames } from './audio-level';
|
|
8
|
+
|
|
9
|
+
/** Floats (-1..1) to PCM16 little-endian bytes. */
|
|
10
|
+
export function floatToPcm16(samples: ArrayLike<number>): Uint8Array {
|
|
11
|
+
const bytes = new Uint8Array(samples.length * 2);
|
|
12
|
+
const view = new DataView(bytes.buffer);
|
|
13
|
+
for (let index = 0; index < samples.length; index += 1) {
|
|
14
|
+
const clamped = Math.max(-1, Math.min(1, samples[index] ?? 0));
|
|
15
|
+
view.setInt16(index * 2, clamped < 0 ? clamped * 0x8000 : clamped * 0x7fff, true);
|
|
16
|
+
}
|
|
17
|
+
return bytes;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** PCM16 little-endian bytes to floats (-1..1). A trailing odd byte is dropped. */
|
|
21
|
+
export function pcm16ToFloat(bytes: Uint8Array): Float32Array {
|
|
22
|
+
const count = Math.floor(bytes.byteLength / 2);
|
|
23
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, count * 2);
|
|
24
|
+
const samples = new Float32Array(count);
|
|
25
|
+
for (let index = 0; index < count; index += 1) {
|
|
26
|
+
samples[index] = view.getInt16(index * 2, true) / 0x8000;
|
|
27
|
+
}
|
|
28
|
+
return samples;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Linear resampling. Good enough for speech in a call: the model hears 24 kHz
|
|
33
|
+
* and the device plays at its own rate; neither is a recording kept.
|
|
34
|
+
*/
|
|
35
|
+
export function resampleLinear(
|
|
36
|
+
samples: Float32Array,
|
|
37
|
+
fromRate: number,
|
|
38
|
+
toRate: number,
|
|
39
|
+
): Float32Array {
|
|
40
|
+
if (fromRate === toRate || samples.length === 0) return samples;
|
|
41
|
+
const ratio = fromRate / toRate;
|
|
42
|
+
const length = Math.max(1, Math.floor(samples.length / ratio));
|
|
43
|
+
const out = new Float32Array(length);
|
|
44
|
+
for (let index = 0; index < length; index += 1) {
|
|
45
|
+
const position = index * ratio;
|
|
46
|
+
const left = Math.floor(position);
|
|
47
|
+
const right = Math.min(left + 1, samples.length - 1);
|
|
48
|
+
const fraction = position - left;
|
|
49
|
+
out[index] = (samples[left] ?? 0) * (1 - fraction) + (samples[right] ?? 0) * fraction;
|
|
50
|
+
}
|
|
51
|
+
return out;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The field's 0..1 level of a PCM16 buffer, on the dictation meter's dBFS scale. */
|
|
55
|
+
export function levelOfPcm16(bytes: Uint8Array): number {
|
|
56
|
+
return levelFromFrames(Array.from(pcm16ToFloat(bytes)));
|
|
57
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The language
|
|
2
|
+
* The language spoken, when the caller names none (dictation and calls).
|
|
3
3
|
*
|
|
4
4
|
* The browser's own language on web; the runtime's resolved locale on native
|
|
5
5
|
* (Hermes implements `Intl`); `en-US` when neither answers. An app should pass
|
|
@@ -1,76 +1,54 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { VoiceServerErrorCode } from './voice-protocol';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Every way dictation or a voice call can fail that a person is told about,
|
|
5
|
-
* as a stable code.
|
|
5
|
+
* as a stable code (ADR 0017 §3, §7).
|
|
6
6
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
7
|
+
* - **Alia's**, sent by `/v1/voice/session` when it ends a call:
|
|
8
|
+
* `VoiceServerErrorCode` (`plan-required`, `credits-exhausted`,
|
|
9
|
+
* `realtime-*`, …).
|
|
10
|
+
* - **The device's:** `mic-denied`, `mic-unavailable`, `connection-lost`, and
|
|
11
|
+
* `voice-unavailable` where this platform has no audio engine for a call.
|
|
12
|
+
* - **Dictation's** (`POST /v1/audio/transcriptions`): `too-long` and
|
|
13
|
+
* `transcription-*`, beside the shared `mic-*` and `not-authenticated`.
|
|
14
|
+
*
|
|
15
|
+
* The hooks expose the code beside the English sentence so an app can say it in
|
|
16
|
+
* its own language; the sentence stays for a consumer with no translations.
|
|
17
|
+
* Nothing here is a recognizer's: speech is recognized by Kaana, never by the
|
|
18
|
+
* browser or the operating system.
|
|
10
19
|
*/
|
|
11
20
|
export type VoiceErrorCode =
|
|
12
|
-
|
|
|
13
|
-
| '
|
|
14
|
-
| '
|
|
15
|
-
| '
|
|
16
|
-
| 'speech-network'
|
|
17
|
-
| 'speech-language'
|
|
18
|
-
| 'speech-failed'
|
|
21
|
+
| VoiceServerErrorCode
|
|
22
|
+
| 'mic-denied'
|
|
23
|
+
| 'mic-unavailable'
|
|
24
|
+
| 'connection-lost'
|
|
19
25
|
| 'voice-unavailable'
|
|
20
|
-
| '
|
|
21
|
-
| '
|
|
22
|
-
| '
|
|
23
|
-
| '
|
|
26
|
+
| 'too-long'
|
|
27
|
+
| 'transcription-unavailable'
|
|
28
|
+
| 'transcription-limited'
|
|
29
|
+
| 'transcription-rejected'
|
|
30
|
+
| 'transcription-failed';
|
|
24
31
|
|
|
25
|
-
/**
|
|
26
|
-
* The English copy for each code — the same sentences dictation and voice mode
|
|
27
|
-
* used when they recorded through `expo-audio` and LiveKit, so moving
|
|
28
|
-
* recognition onto the device did not change what an error says.
|
|
29
|
-
*/
|
|
32
|
+
/** The English copy for each code. */
|
|
30
33
|
export const VOICE_ERROR_MESSAGES: Readonly<Record<VoiceErrorCode, string>> = {
|
|
31
|
-
'speech-unsupported': 'Speech recognition is not available on this device or browser',
|
|
32
|
-
'microphone-denied': 'Microphone permission required',
|
|
33
|
-
'microphone-busy': 'Microphone is in use by another application',
|
|
34
|
-
'microphone-missing': 'No microphone found — check your input devices',
|
|
35
|
-
'speech-network': 'Speech recognition needs a network connection',
|
|
36
|
-
'speech-language': 'Speech recognition does not support this language',
|
|
37
|
-
'speech-failed': 'Speech recognition failed',
|
|
38
|
-
'voice-unavailable': 'Voice is not available on this device',
|
|
39
34
|
'not-authenticated': 'Not authenticated',
|
|
40
|
-
'
|
|
41
|
-
'
|
|
42
|
-
'
|
|
35
|
+
'credential-not-supported': 'Voice is only available when you are signed in to Alia',
|
|
36
|
+
'plan-required': 'Voice mode needs a paid plan',
|
|
37
|
+
'credits-exhausted': "You've run out of credits for this call",
|
|
38
|
+
'agent-unavailable': 'This agent is not available for a call',
|
|
39
|
+
'invalid-request': 'The call could not be started',
|
|
40
|
+
'resume-unavailable': 'The call could not be picked up again — start a new one',
|
|
41
|
+
'realtime-unavailable': 'Voice is not available right now — try again later',
|
|
42
|
+
'realtime-limited': 'Voice has reached its limit for now — try again later',
|
|
43
|
+
'realtime-rejected': 'The call could not be started with these settings',
|
|
44
|
+
'realtime-failed': 'The call failed — try again',
|
|
45
|
+
'mic-denied': 'Microphone permission required',
|
|
46
|
+
'mic-unavailable': 'The microphone could not be used — check your input devices',
|
|
47
|
+
'connection-lost': 'The connection was lost',
|
|
48
|
+
'voice-unavailable': 'Voice is not available on this device or browser',
|
|
49
|
+
'too-long': 'That dictation was too long — five minutes at most',
|
|
50
|
+
'transcription-unavailable': 'Dictation is not available right now',
|
|
51
|
+
'transcription-limited': 'Dictation has reached its limit for now — try again later',
|
|
52
|
+
'transcription-rejected': 'That recording could not be transcribed',
|
|
53
|
+
'transcription-failed': 'Dictation failed — try again',
|
|
43
54
|
};
|
|
44
|
-
|
|
45
|
-
/**
|
|
46
|
-
* The code for a recognition failure. `null` for the failures that are not
|
|
47
|
-
* errors: `aborted` is a stop the caller asked for, and `no-speech` is a
|
|
48
|
-
* silence the caller recovers from.
|
|
49
|
-
*/
|
|
50
|
-
export function speechFailureCode(failure: SpeechRecognitionFailure): VoiceErrorCode | null {
|
|
51
|
-
switch (failure.code) {
|
|
52
|
-
case 'aborted':
|
|
53
|
-
case 'no-speech':
|
|
54
|
-
return null;
|
|
55
|
-
case 'unsupported':
|
|
56
|
-
return 'speech-unsupported';
|
|
57
|
-
case 'not-allowed':
|
|
58
|
-
return 'microphone-denied';
|
|
59
|
-
case 'audio-capture':
|
|
60
|
-
return failure.detail === 'NotReadableError' || failure.detail === 'TrackStartError'
|
|
61
|
-
? 'microphone-busy'
|
|
62
|
-
: 'microphone-missing';
|
|
63
|
-
case 'network':
|
|
64
|
-
return 'speech-network';
|
|
65
|
-
case 'language-not-supported':
|
|
66
|
-
return 'speech-language';
|
|
67
|
-
case 'other':
|
|
68
|
-
return 'speech-failed';
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
/** The English copy a person sees when listening fails; `null` as for `speechFailureCode`. */
|
|
73
|
-
export function speechFailureMessage(failure: SpeechRecognitionFailure): string | null {
|
|
74
|
-
const code = speechFailureCode(failure);
|
|
75
|
-
return code === null ? null : VOICE_ERROR_MESSAGES[code];
|
|
76
|
-
}
|