@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
|
@@ -20,14 +20,112 @@ export interface SpeechClipRequest {
|
|
|
20
20
|
readonly input: string;
|
|
21
21
|
readonly voice: ProductVoice;
|
|
22
22
|
readonly speed?: number;
|
|
23
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
|
|
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 === '')
|
|
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
|
+
}
|