@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
@@ -42,6 +42,8 @@ export interface AliaChatLabels {
42
42
  dropUnavailable: string;
43
43
  /** Why dictation failed, in the host's language. */
44
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;
45
47
  // Welcome
46
48
  /** The empty chat's greeting for a signed-out visitor. */
47
49
  greeting: string;
@@ -99,6 +101,7 @@ export const DEFAULT_LABELS: AliaChatLabels = {
99
101
  dropHint: 'Drop files here to attach them',
100
102
  dropUnavailable: "Files can't be attached right now",
101
103
  voiceError: (code) => VOICE_ERROR_MESSAGES[code],
104
+ dictationRemaining: (seconds) => `${seconds}s of dictation left`,
102
105
  greeting: "Hi, what's on your mind?",
103
106
  greetingNamed: (name) => `Hi ${name}, what's on your mind?`,
104
107
  copy: 'Copy',
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 to recognize when the caller names none.
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,108 +1,54 @@
1
- import type { SpeechRecognitionFailure } from './speech-recognition-types';
2
- import type { SpeechClipError } from './speech-synthesis';
1
+ import type { VoiceServerErrorCode } from './voice-protocol';
3
2
 
4
3
  /**
5
4
  * Every way dictation or a voice call can fail that a person is told about,
6
- * as a stable code.
5
+ * as a stable code (ADR 0017 §3, §7).
7
6
  *
8
- * The hooks expose the code beside the English sentence (`errorCode` /
9
- * `turnErrorCode` next to `error` / `turnError`) so an app can say it in its
10
- * own language; the sentence stays for a consumer that has no translations.
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.
11
19
  */
12
20
  export type VoiceErrorCode =
13
- | 'speech-unsupported'
14
- | 'microphone-denied'
15
- | 'microphone-busy'
16
- | 'microphone-missing'
17
- | 'speech-network'
18
- | 'speech-language'
19
- | 'speech-failed'
21
+ | VoiceServerErrorCode
22
+ | 'mic-denied'
23
+ | 'mic-unavailable'
24
+ | 'connection-lost'
20
25
  | 'voice-unavailable'
21
- | 'not-authenticated'
22
- | 'turn-failed'
23
- | 'no-answer'
24
- | 'not-played'
25
- | 'synthesis-unavailable'
26
- | 'synthesis-limited'
27
- | 'synthesis-rejected'
28
- | 'synthesis-failed';
26
+ | 'too-long'
27
+ | 'transcription-unavailable'
28
+ | 'transcription-limited'
29
+ | 'transcription-rejected'
30
+ | 'transcription-failed';
29
31
 
30
- /**
31
- * The English copy for each code — the same sentences dictation and voice mode
32
- * used when they recorded through `expo-audio` and LiveKit, so moving
33
- * recognition onto the device did not change what an error says.
34
- */
32
+ /** The English copy for each code. */
35
33
  export const VOICE_ERROR_MESSAGES: Readonly<Record<VoiceErrorCode, string>> = {
36
- 'speech-unsupported': 'Speech recognition is not available on this device or browser',
37
- 'microphone-denied': 'Microphone permission required',
38
- 'microphone-busy': 'Microphone is in use by another application',
39
- 'microphone-missing': 'No microphone found — check your input devices',
40
- 'speech-network': 'Speech recognition needs a network connection',
41
- 'speech-language': 'Speech recognition does not support this language',
42
- 'speech-failed': 'Speech recognition failed',
43
- 'voice-unavailable': 'Voice is not available on this device',
44
34
  'not-authenticated': 'Not authenticated',
45
- 'turn-failed': 'Voice request failed',
46
- 'no-answer': 'No answer came back — try saying it again',
47
- 'not-played': 'The answer could not be played aloud — it is in the chat',
48
- 'synthesis-unavailable': "Alia's voice is not available right now — the answer is in the chat",
49
- 'synthesis-limited': "Alia's voice has reached its limit for now — the answer is in the chat",
50
- 'synthesis-rejected': "Alia's voice could not read this answer — it is in the chat",
51
- 'synthesis-failed': "Alia's voice failed — the answer is in the chat",
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',
52
54
  };
53
-
54
- /**
55
- * The code for a clip `/v1/audio/speech` could not make, from the typed code
56
- * the route answered with. A failure the route did not name (a network
57
- * failure, an unknown code) is `synthesis-failed`.
58
- */
59
- export function synthesisFailureCode(failure: SpeechClipError): VoiceErrorCode {
60
- switch (failure.code) {
61
- case 'NOT_AUTHENTICATED':
62
- return 'not-authenticated';
63
- case 'SPEECH_UNAVAILABLE':
64
- return 'synthesis-unavailable';
65
- case 'SPEECH_LIMIT_REACHED':
66
- return 'synthesis-limited';
67
- case 'SPEECH_REJECTED':
68
- case 'INVALID_REQUEST':
69
- return 'synthesis-rejected';
70
- case 'SPEECH_PROVIDER_FAILED':
71
- case 'SPEECH_GENERATION_FAILED':
72
- case null:
73
- return 'synthesis-failed';
74
- }
75
- }
76
-
77
- /**
78
- * The code for a recognition failure. `null` for the failures that are not
79
- * errors: `aborted` is a stop the caller asked for, and `no-speech` is a
80
- * silence the caller recovers from.
81
- */
82
- export function speechFailureCode(failure: SpeechRecognitionFailure): VoiceErrorCode | null {
83
- switch (failure.code) {
84
- case 'aborted':
85
- case 'no-speech':
86
- return null;
87
- case 'unsupported':
88
- return 'speech-unsupported';
89
- case 'not-allowed':
90
- return 'microphone-denied';
91
- case 'audio-capture':
92
- return failure.detail === 'NotReadableError' || failure.detail === 'TrackStartError'
93
- ? 'microphone-busy'
94
- : 'microphone-missing';
95
- case 'network':
96
- return 'speech-network';
97
- case 'language-not-supported':
98
- return 'speech-language';
99
- case 'other':
100
- return 'speech-failed';
101
- }
102
- }
103
-
104
- /** The English copy a person sees when listening fails; `null` as for `speechFailureCode`. */
105
- export function speechFailureMessage(failure: SpeechRecognitionFailure): string | null {
106
- const code = speechFailureCode(failure);
107
- return code === null ? null : VOICE_ERROR_MESSAGES[code];
108
- }
@@ -20,7 +20,11 @@ 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;
@@ -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
+ }