@opengeni/react-native 1.4.5-canary.37615713321001
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/LICENSE +190 -0
- package/README.md +74 -0
- package/dist/adapters.d.ts +53 -0
- package/dist/adapters.js +3 -0
- package/dist/adapters.js.map +1 -0
- package/dist/attachments.d.ts +75 -0
- package/dist/chunk-3VSZAQ7G.js +391 -0
- package/dist/chunk-3VSZAQ7G.js.map +1 -0
- package/dist/chunk-5ZVK7WKA.js +88 -0
- package/dist/chunk-5ZVK7WKA.js.map +1 -0
- package/dist/chunk-EEDIMY3L.js +240 -0
- package/dist/chunk-EEDIMY3L.js.map +1 -0
- package/dist/chunk-M23SDI4R.js +83 -0
- package/dist/chunk-M23SDI4R.js.map +1 -0
- package/dist/chunk-PHJBSAR3.js +2006 -0
- package/dist/chunk-PHJBSAR3.js.map +1 -0
- package/dist/environment.d.ts +19 -0
- package/dist/expo.d.ts +25 -0
- package/dist/expo.js +267 -0
- package/dist/expo.js.map +1 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +3345 -0
- package/dist/index.js.map +1 -0
- package/dist/presentation.d.ts +262 -0
- package/dist/read-acknowledgement.d.ts +39 -0
- package/dist/realtime/call-events.d.ts +11 -0
- package/dist/realtime/call-view.d.ts +26 -0
- package/dist/realtime/expo-call.d.ts +11 -0
- package/dist/realtime/native-call.d.ts +104 -0
- package/dist/realtime/native-realtime.d.ts +35 -0
- package/dist/realtime/use-native-session-realtime.d.ts +25 -0
- package/dist/session-view.d.ts +87 -0
- package/dist/timeline/activity.d.ts +34 -0
- package/dist/timeline/composer.d.ts +86 -0
- package/dist/timeline/controls.d.ts +27 -0
- package/dist/timeline/decisions.d.ts +29 -0
- package/dist/timeline/feedback.d.ts +26 -0
- package/dist/timeline/icon-nodes.generated.d.ts +738 -0
- package/dist/timeline/icon.d.ts +11 -0
- package/dist/timeline/index.d.ts +32 -0
- package/dist/timeline/index.js +5419 -0
- package/dist/timeline/index.js.map +1 -0
- package/dist/timeline/markdown.d.ts +11 -0
- package/dist/timeline/markdown.js +415 -0
- package/dist/timeline/markdown.js.map +1 -0
- package/dist/timeline/message-attachments.d.ts +13 -0
- package/dist/timeline/message-timeline.d.ts +36 -0
- package/dist/timeline/model-picker.d.ts +17 -0
- package/dist/timeline/primitives.d.ts +85 -0
- package/dist/timeline/queue-dock.d.ts +19 -0
- package/dist/timeline/session-actions.d.ts +34 -0
- package/dist/timeline/session-list.d.ts +50 -0
- package/dist/timeline/session-screen.d.ts +35 -0
- package/dist/timeline/session-signals.d.ts +37 -0
- package/dist/timeline/sheet.d.ts +14 -0
- package/dist/timeline/theme.d.ts +58 -0
- package/dist/timeline/thinking-orb.d.ts +13 -0
- package/dist/timeline/turn.d.ts +80 -0
- package/dist/ui/attention.d.ts +14 -0
- package/dist/ui/cards.d.ts +21 -0
- package/dist/ui/composer.d.ts +21 -0
- package/dist/ui/format.d.ts +13 -0
- package/dist/ui/icons.d.ts +9 -0
- package/dist/ui/index.d.ts +12 -0
- package/dist/ui/index.js +1757 -0
- package/dist/ui/index.js.map +1 -0
- package/dist/ui/steps-sheet.d.ts +10 -0
- package/dist/ui/theme.d.ts +63 -0
- package/dist/ui/timeline.d.ts +48 -0
- package/dist/ui/web-tokens.gen.d.ts +90 -0
- package/dist/use-native-session.d.ts +40 -0
- package/dist/view-model.d.ts +52 -0
- package/dist/voice-input.d.ts +102 -0
- package/dist/webrtc.d.ts +9 -0
- package/dist/webrtc.js +19 -0
- package/dist/webrtc.js.map +1 -0
- package/expo-module.config.json +7 -0
- package/ios/OpenGeniCall.podspec +22 -0
- package/ios/OpenGeniCallAppDelegateSubscriber.swift +29 -0
- package/ios/OpenGeniCallCenter.swift +189 -0
- package/ios/OpenGeniCallModule.swift +64 -0
- package/package.json +93 -0
- package/src/adapters.ts +150 -0
- package/src/attachments.ts +396 -0
- package/src/environment.tsx +214 -0
- package/src/expo.ts +295 -0
- package/src/index.ts +98 -0
- package/src/presentation.ts +285 -0
- package/src/read-acknowledgement.ts +72 -0
- package/src/realtime/call-events.ts +41 -0
- package/src/realtime/call-view.tsx +220 -0
- package/src/realtime/expo-call.ts +49 -0
- package/src/realtime/native-call.ts +266 -0
- package/src/realtime/native-realtime.ts +115 -0
- package/src/realtime/use-native-session-realtime.ts +29 -0
- package/src/session-view.tsx +2251 -0
- package/src/timeline/activity.tsx +736 -0
- package/src/timeline/composer.tsx +686 -0
- package/src/timeline/controls.tsx +151 -0
- package/src/timeline/decisions.tsx +651 -0
- package/src/timeline/feedback.tsx +251 -0
- package/src/timeline/icon-nodes.generated.ts +1830 -0
- package/src/timeline/icon.tsx +44 -0
- package/src/timeline/index.ts +67 -0
- package/src/timeline/markdown.tsx +510 -0
- package/src/timeline/message-attachments.tsx +155 -0
- package/src/timeline/message-timeline.tsx +1042 -0
- package/src/timeline/model-picker.tsx +450 -0
- package/src/timeline/primitives.tsx +561 -0
- package/src/timeline/queue-dock.tsx +331 -0
- package/src/timeline/session-actions.tsx +379 -0
- package/src/timeline/session-list.tsx +338 -0
- package/src/timeline/session-screen.tsx +488 -0
- package/src/timeline/session-signals.tsx +312 -0
- package/src/timeline/sheet.tsx +153 -0
- package/src/timeline/theme.tsx +128 -0
- package/src/timeline/thinking-orb.tsx +93 -0
- package/src/timeline/turn.tsx +569 -0
- package/src/ui/attention.tsx +220 -0
- package/src/ui/cards.tsx +267 -0
- package/src/ui/composer.tsx +296 -0
- package/src/ui/format.ts +86 -0
- package/src/ui/icons.tsx +131 -0
- package/src/ui/index.ts +18 -0
- package/src/ui/steps-sheet.tsx +71 -0
- package/src/ui/theme.ts +261 -0
- package/src/ui/timeline.tsx +837 -0
- package/src/ui/web-tokens.gen.ts +94 -0
- package/src/use-native-session.ts +219 -0
- package/src/view-model.ts +485 -0
- package/src/voice-input.ts +382 -0
- package/src/webrtc.ts +30 -0
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
2
|
+
|
|
3
|
+
/** A finished recording on the device. */
|
|
4
|
+
export interface NativeVoiceRecording {
|
|
5
|
+
uri: string;
|
|
6
|
+
mimeType: string;
|
|
7
|
+
durationSeconds: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The platform recorder behind dictation. The Expo implementation lives in
|
|
12
|
+
* `@opengeni/react-native/expo` (`useExpoVoiceRecorder`).
|
|
13
|
+
*/
|
|
14
|
+
export interface NativeVoiceRecorder {
|
|
15
|
+
/** Ask for microphone access; resolves false when the person declines. */
|
|
16
|
+
requestPermission(): Promise<boolean>;
|
|
17
|
+
start(): Promise<void>;
|
|
18
|
+
/**
|
|
19
|
+
* Stop and keep the recording; null when nothing was captured. The file must
|
|
20
|
+
* survive a following `start()`: dictation keeps it until its text is in the
|
|
21
|
+
* draft, then calls `discard`.
|
|
22
|
+
*/
|
|
23
|
+
stop(): Promise<NativeVoiceRecording | null>;
|
|
24
|
+
/** Stop and throw the current recording away. */
|
|
25
|
+
cancel(): Promise<void>;
|
|
26
|
+
/** Delete a kept recording file once it is no longer needed. */
|
|
27
|
+
discard?(recording: NativeVoiceRecording): void;
|
|
28
|
+
/** Seconds captured so far while recording. */
|
|
29
|
+
durationSeconds(): number;
|
|
30
|
+
/** The current input level from 0 to 1 while recording, when the platform meters it. */
|
|
31
|
+
level(): number | null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type NativeVoiceInputStatus =
|
|
35
|
+
| "idle"
|
|
36
|
+
| "requesting-permission"
|
|
37
|
+
| "recording"
|
|
38
|
+
| "transcribing"
|
|
39
|
+
| "error";
|
|
40
|
+
|
|
41
|
+
export interface NativeVoiceInput {
|
|
42
|
+
/** Dictation is offered: a recorder exists and the deployment can transcribe. */
|
|
43
|
+
available: boolean;
|
|
44
|
+
status: NativeVoiceInputStatus;
|
|
45
|
+
error: string | null;
|
|
46
|
+
/** Total seconds captured in this dictation, across segments. */
|
|
47
|
+
durationSeconds: number;
|
|
48
|
+
/** Input level from 0 to 1 while recording (null when not metered). */
|
|
49
|
+
level: number | null;
|
|
50
|
+
/**
|
|
51
|
+
* Always null: long dictation rolls over into segments under the server's
|
|
52
|
+
* per-request limit, so there is no recording cap to count down to.
|
|
53
|
+
*/
|
|
54
|
+
maxDurationSeconds: number | null;
|
|
55
|
+
/** A finished recording is kept on the device after a failed transcription. */
|
|
56
|
+
hasSavedRecording: boolean;
|
|
57
|
+
start(): void;
|
|
58
|
+
/** Stop, transcribe and append the text to the draft. */
|
|
59
|
+
stop(): void;
|
|
60
|
+
/** Throw the current dictation away (recording or saved). */
|
|
61
|
+
cancel(): void;
|
|
62
|
+
/** Transcribe a saved recording again. */
|
|
63
|
+
retry(): void;
|
|
64
|
+
/** Hide the error message; a saved recording stays available to retry. */
|
|
65
|
+
clearError(): void;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Append dictated text to the draft, as the web composer does. */
|
|
69
|
+
export function appendDictation(draft: string, transcript: string): string {
|
|
70
|
+
const final = transcript.trim();
|
|
71
|
+
if (!final) return draft;
|
|
72
|
+
if (!draft) return final;
|
|
73
|
+
return /\s$/u.test(draft) ? `${draft}${final}` : `${draft} ${final}`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** An error from the transcription request, with the HTTP status when there was one. */
|
|
77
|
+
export class NativeTranscriptionError extends Error {
|
|
78
|
+
readonly status: number | null;
|
|
79
|
+
constructor(message: string, status: number | null) {
|
|
80
|
+
super(message);
|
|
81
|
+
this.name = "NativeTranscriptionError";
|
|
82
|
+
this.status = status;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Whether trying the same audio again can succeed: network failures, timeouts,
|
|
88
|
+
* rate limits and server errors. Rejections of the audio itself (bad request,
|
|
89
|
+
* too large, unsupported, forbidden) do not change on retry.
|
|
90
|
+
*/
|
|
91
|
+
export function isRetryableTranscriptionError(cause: unknown): boolean {
|
|
92
|
+
const status = cause instanceof NativeTranscriptionError ? cause.status : null;
|
|
93
|
+
if (status === null) return true;
|
|
94
|
+
return status === 408 || status === 425 || status === 429 || status === 499 || status >= 500;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Delays between automatic attempts; the recording is kept whatever happens. */
|
|
98
|
+
export const TRANSCRIPTION_RETRY_DELAYS_MS = [1_000, 3_000, 8_000] as const;
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Where a long recording rolls over into a new segment: a little under the
|
|
102
|
+
* server's per-request limit, so a segment is never rejected as too long.
|
|
103
|
+
*/
|
|
104
|
+
export function dictationSegmentSeconds(maxDurationSeconds: number | null): number | null {
|
|
105
|
+
if (maxDurationSeconds === null || !(maxDurationSeconds > 0)) return null;
|
|
106
|
+
return Math.max(1, maxDurationSeconds - Math.min(3, maxDurationSeconds * 0.1));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
type Segment = { recording: NativeVoiceRecording; text: string | null };
|
|
110
|
+
|
|
111
|
+
const wait = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Dictation for a native composer: record on the device, send the audio to the
|
|
115
|
+
* deployment's transcription API, and append the text to the draft. The draft
|
|
116
|
+
* is read at completion, so typing while transcribing is preserved.
|
|
117
|
+
*
|
|
118
|
+
* Nothing said is lost: a long recording rolls over into segments under the
|
|
119
|
+
* per-request limit (transcribed in the background while recording goes on),
|
|
120
|
+
* failed requests are retried, and a recording whose text could not be fetched
|
|
121
|
+
* stays on the device until it is transcribed or the person discards it.
|
|
122
|
+
*/
|
|
123
|
+
export function useNativeVoiceInput(input: {
|
|
124
|
+
recorder: NativeVoiceRecorder | null;
|
|
125
|
+
/** Whether the deployment offers transcription in this workspace. */
|
|
126
|
+
available: boolean;
|
|
127
|
+
/** The server's per-request duration limit; long dictation is segmented under it. */
|
|
128
|
+
maxDurationSeconds?: number | null | undefined;
|
|
129
|
+
transcribe: (recording: NativeVoiceRecording) => Promise<string>;
|
|
130
|
+
getValue: () => string;
|
|
131
|
+
setValue: (value: string) => void;
|
|
132
|
+
/** Host feedback (haptics) when recording starts and stops. */
|
|
133
|
+
onFeedback?: ((event: "start" | "stop" | "cancel") => void) | undefined;
|
|
134
|
+
/** Scope key; changing it cancels a running capture (another session or workspace). */
|
|
135
|
+
scope?: string | undefined;
|
|
136
|
+
/** Test seam for the retry delays. */
|
|
137
|
+
retryDelaysMs?: readonly number[] | undefined;
|
|
138
|
+
}): NativeVoiceInput {
|
|
139
|
+
const [status, setStatus] = useState<NativeVoiceInputStatus>("idle");
|
|
140
|
+
const [error, setError] = useState<string | null>(null);
|
|
141
|
+
const [durationSeconds, setDurationSeconds] = useState(0);
|
|
142
|
+
const [level, setLevel] = useState<number | null>(null);
|
|
143
|
+
const [hasSavedRecording, setHasSavedRecording] = useState(false);
|
|
144
|
+
const generation = useRef(0);
|
|
145
|
+
const statusRef = useRef<NativeVoiceInputStatus>("idle");
|
|
146
|
+
const segments = useRef<Segment[]>([]);
|
|
147
|
+
const inFlight = useRef(new Map<Segment, Promise<void>>());
|
|
148
|
+
const capturedBefore = useRef(0);
|
|
149
|
+
const rolling = useRef(false);
|
|
150
|
+
const latest = useRef(input);
|
|
151
|
+
latest.current = input;
|
|
152
|
+
const segmentSeconds = dictationSegmentSeconds(input.maxDurationSeconds ?? null);
|
|
153
|
+
|
|
154
|
+
const update = useCallback((next: NativeVoiceInputStatus) => {
|
|
155
|
+
statusRef.current = next;
|
|
156
|
+
setStatus(next);
|
|
157
|
+
}, []);
|
|
158
|
+
|
|
159
|
+
const discardAll = useCallback(() => {
|
|
160
|
+
const recorder = latest.current.recorder;
|
|
161
|
+
for (const segment of segments.current) recorder?.discard?.(segment.recording);
|
|
162
|
+
segments.current = [];
|
|
163
|
+
inFlight.current.clear();
|
|
164
|
+
capturedBefore.current = 0;
|
|
165
|
+
setHasSavedRecording(false);
|
|
166
|
+
}, []);
|
|
167
|
+
|
|
168
|
+
/** Transcribe one segment, retrying transient failures; resolves either way. */
|
|
169
|
+
const transcribeSegment = useCallback((segment: Segment): Promise<void> => {
|
|
170
|
+
if (segment.text !== null) return Promise.resolve();
|
|
171
|
+
const running = inFlight.current.get(segment);
|
|
172
|
+
if (running) return running;
|
|
173
|
+
const work = (async () => {
|
|
174
|
+
const delays = latest.current.retryDelaysMs ?? TRANSCRIPTION_RETRY_DELAYS_MS;
|
|
175
|
+
const limit = latest.current.maxDurationSeconds ?? null;
|
|
176
|
+
// The reported length never exceeds the limit: a recording stopped a
|
|
177
|
+
// moment after the cap must not be rejected as too long.
|
|
178
|
+
const recording =
|
|
179
|
+
limit !== null && segment.recording.durationSeconds > limit
|
|
180
|
+
? { ...segment.recording, durationSeconds: limit }
|
|
181
|
+
: segment.recording;
|
|
182
|
+
for (let attempt = 0; ; attempt += 1) {
|
|
183
|
+
try {
|
|
184
|
+
segment.text = await latest.current.transcribe(recording);
|
|
185
|
+
return;
|
|
186
|
+
} catch (cause) {
|
|
187
|
+
if (attempt >= delays.length || !isRetryableTranscriptionError(cause)) throw cause;
|
|
188
|
+
await wait(delays[attempt]!);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
})().finally(() => inFlight.current.delete(segment));
|
|
192
|
+
inFlight.current.set(segment, work);
|
|
193
|
+
return work;
|
|
194
|
+
}, []);
|
|
195
|
+
|
|
196
|
+
/** Transcribe every kept segment in order and append the text once all are in. */
|
|
197
|
+
const complete = useCallback(
|
|
198
|
+
async (ticket: number) => {
|
|
199
|
+
update("transcribing");
|
|
200
|
+
setError(null);
|
|
201
|
+
try {
|
|
202
|
+
for (const segment of [...segments.current]) {
|
|
203
|
+
await transcribeSegment(segment);
|
|
204
|
+
if (ticket !== generation.current) return;
|
|
205
|
+
}
|
|
206
|
+
const text = segments.current
|
|
207
|
+
.map((segment) => segment.text?.trim() ?? "")
|
|
208
|
+
.filter(Boolean)
|
|
209
|
+
.join(" ");
|
|
210
|
+
latest.current.setValue(appendDictation(latest.current.getValue(), text));
|
|
211
|
+
discardAll();
|
|
212
|
+
update("idle");
|
|
213
|
+
} catch (cause) {
|
|
214
|
+
if (ticket !== generation.current) return;
|
|
215
|
+
// The recording stays on the device; Retry sends the same audio again.
|
|
216
|
+
setHasSavedRecording(true);
|
|
217
|
+
setError(
|
|
218
|
+
isRetryableTranscriptionError(cause)
|
|
219
|
+
? "Couldn't reach transcription. Your recording is saved. Retry when you're back online."
|
|
220
|
+
: `Couldn't transcribe this recording${
|
|
221
|
+
cause instanceof Error && cause.message ? ` (${cause.message})` : ""
|
|
222
|
+
}. It's saved, so you can retry.`,
|
|
223
|
+
);
|
|
224
|
+
update("error");
|
|
225
|
+
}
|
|
226
|
+
},
|
|
227
|
+
[discardAll, transcribeSegment, update],
|
|
228
|
+
);
|
|
229
|
+
|
|
230
|
+
const finish = useCallback(async () => {
|
|
231
|
+
const recorder = latest.current.recorder;
|
|
232
|
+
if (!recorder || statusRef.current !== "recording") return;
|
|
233
|
+
const ticket = generation.current;
|
|
234
|
+
latest.current.onFeedback?.("stop");
|
|
235
|
+
update("transcribing");
|
|
236
|
+
setLevel(null);
|
|
237
|
+
// A rollover in progress hands over its segment before the final stop.
|
|
238
|
+
while (rolling.current) await wait(20);
|
|
239
|
+
try {
|
|
240
|
+
const recording = await recorder.stop();
|
|
241
|
+
if (ticket !== generation.current) return;
|
|
242
|
+
if (recording && recording.durationSeconds >= 0.3) {
|
|
243
|
+
segments.current.push({ recording, text: null });
|
|
244
|
+
} else if (recording) {
|
|
245
|
+
recorder.discard?.(recording);
|
|
246
|
+
}
|
|
247
|
+
} catch {
|
|
248
|
+
// The platform could not hand over the last segment; earlier ones are kept.
|
|
249
|
+
}
|
|
250
|
+
if (ticket !== generation.current) return;
|
|
251
|
+
if (segments.current.length === 0) {
|
|
252
|
+
capturedBefore.current = 0;
|
|
253
|
+
update("idle");
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
await complete(ticket);
|
|
257
|
+
}, [complete, update]);
|
|
258
|
+
|
|
259
|
+
/** Close the current segment and keep recording into a new one. */
|
|
260
|
+
const rollover = useCallback(async () => {
|
|
261
|
+
const recorder = latest.current.recorder;
|
|
262
|
+
if (!recorder || rolling.current || statusRef.current !== "recording") return;
|
|
263
|
+
const ticket = generation.current;
|
|
264
|
+
rolling.current = true;
|
|
265
|
+
try {
|
|
266
|
+
const recording = await recorder.stop();
|
|
267
|
+
if (recording) {
|
|
268
|
+
capturedBefore.current += recording.durationSeconds;
|
|
269
|
+
const segment: Segment = { recording, text: null };
|
|
270
|
+
segments.current.push(segment);
|
|
271
|
+
// Start transcribing now; failures are retried again at the end.
|
|
272
|
+
void transcribeSegment(segment).catch(() => undefined);
|
|
273
|
+
}
|
|
274
|
+
if (ticket !== generation.current || statusRef.current !== "recording") return;
|
|
275
|
+
await recorder.start();
|
|
276
|
+
} catch {
|
|
277
|
+
if (ticket !== generation.current) return;
|
|
278
|
+
// The microphone could not restart: keep what was captured and transcribe it.
|
|
279
|
+
rolling.current = false;
|
|
280
|
+
if (segments.current.length > 0) await complete(ticket);
|
|
281
|
+
else update("idle");
|
|
282
|
+
return;
|
|
283
|
+
} finally {
|
|
284
|
+
rolling.current = false;
|
|
285
|
+
}
|
|
286
|
+
}, [complete, transcribeSegment, update]);
|
|
287
|
+
|
|
288
|
+
const start = useCallback(() => {
|
|
289
|
+
const recorder = latest.current.recorder;
|
|
290
|
+
if (!recorder || !latest.current.available) return;
|
|
291
|
+
if (statusRef.current === "recording" || statusRef.current === "transcribing") return;
|
|
292
|
+
// A saved recording is transcribed first; a new dictation never replaces it.
|
|
293
|
+
if (segments.current.length > 0) {
|
|
294
|
+
void complete(++generation.current);
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
const ticket = ++generation.current;
|
|
298
|
+
setError(null);
|
|
299
|
+
setDurationSeconds(0);
|
|
300
|
+
capturedBefore.current = 0;
|
|
301
|
+
update("requesting-permission");
|
|
302
|
+
void (async () => {
|
|
303
|
+
try {
|
|
304
|
+
const granted = await recorder.requestPermission();
|
|
305
|
+
if (ticket !== generation.current) return;
|
|
306
|
+
if (!granted) {
|
|
307
|
+
setError("Allow microphone access in Settings to dictate.");
|
|
308
|
+
update("error");
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
await recorder.start();
|
|
312
|
+
if (ticket !== generation.current) {
|
|
313
|
+
await recorder.cancel();
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
latest.current.onFeedback?.("start");
|
|
317
|
+
update("recording");
|
|
318
|
+
} catch {
|
|
319
|
+
if (ticket !== generation.current) return;
|
|
320
|
+
// The platform's reason (no input device, audio session busy) is not actionable.
|
|
321
|
+
setError("Couldn't start the microphone. Try again.");
|
|
322
|
+
update("error");
|
|
323
|
+
}
|
|
324
|
+
})();
|
|
325
|
+
}, [complete, update]);
|
|
326
|
+
|
|
327
|
+
const cancel = useCallback(() => {
|
|
328
|
+
generation.current += 1;
|
|
329
|
+
const recorder = latest.current.recorder;
|
|
330
|
+
const wasRecording = statusRef.current === "recording";
|
|
331
|
+
update("idle");
|
|
332
|
+
setError(null);
|
|
333
|
+
setLevel(null);
|
|
334
|
+
setDurationSeconds(0);
|
|
335
|
+
if (wasRecording) latest.current.onFeedback?.("cancel");
|
|
336
|
+
if (recorder && wasRecording) void recorder.cancel().catch(() => undefined);
|
|
337
|
+
discardAll();
|
|
338
|
+
}, [discardAll, update]);
|
|
339
|
+
|
|
340
|
+
const retry = useCallback(() => {
|
|
341
|
+
if (segments.current.length === 0 || statusRef.current === "transcribing") return;
|
|
342
|
+
const ticket = ++generation.current;
|
|
343
|
+
void complete(ticket);
|
|
344
|
+
}, [complete]);
|
|
345
|
+
|
|
346
|
+
// Duration and level tick while recording; near the per-request limit the
|
|
347
|
+
// recording rolls over into a new segment instead of stopping.
|
|
348
|
+
useEffect(() => {
|
|
349
|
+
if (status !== "recording") return;
|
|
350
|
+
const timer = setInterval(() => {
|
|
351
|
+
const recorder = latest.current.recorder;
|
|
352
|
+
if (!recorder || rolling.current) return;
|
|
353
|
+
const seconds = recorder.durationSeconds();
|
|
354
|
+
setDurationSeconds(capturedBefore.current + seconds);
|
|
355
|
+
setLevel(recorder.level());
|
|
356
|
+
if (segmentSeconds !== null && seconds >= segmentSeconds) void rollover();
|
|
357
|
+
}, 100);
|
|
358
|
+
return () => clearInterval(timer);
|
|
359
|
+
}, [rollover, segmentSeconds, status]);
|
|
360
|
+
|
|
361
|
+
// Leaving the scope (another session or workspace) drops a running capture.
|
|
362
|
+
const scope = input.scope;
|
|
363
|
+
useEffect(() => () => cancel(), [cancel, scope]);
|
|
364
|
+
|
|
365
|
+
return {
|
|
366
|
+
available: Boolean(input.recorder) && input.available,
|
|
367
|
+
status,
|
|
368
|
+
error,
|
|
369
|
+
durationSeconds,
|
|
370
|
+
level,
|
|
371
|
+
maxDurationSeconds: null,
|
|
372
|
+
hasSavedRecording,
|
|
373
|
+
start,
|
|
374
|
+
stop: () => void finish(),
|
|
375
|
+
cancel,
|
|
376
|
+
retry,
|
|
377
|
+
clearError: () => {
|
|
378
|
+
setError(null);
|
|
379
|
+
if (statusRef.current === "error" && segments.current.length === 0) update("idle");
|
|
380
|
+
},
|
|
381
|
+
};
|
|
382
|
+
}
|
package/src/webrtc.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// @opengeni/react-native/webrtc: the ready-made native WebRTC adapter.
|
|
2
|
+
//
|
|
3
|
+
// A separate entry point so hosts without voice never load the native WebRTC
|
|
4
|
+
// module. Requires the optional peer dependency `react-native-webrtc`.
|
|
5
|
+
import { mediaDevices, RTCAudioSession, RTCPeerConnection } from "react-native-webrtc";
|
|
6
|
+
import type { NativeCallAdapter } from "./realtime/native-call";
|
|
7
|
+
import type { NativeWebRtcAdapter } from "./realtime/native-realtime";
|
|
8
|
+
|
|
9
|
+
export function createReactNativeWebRtcAdapter(
|
|
10
|
+
configuration: RTCConfiguration = {},
|
|
11
|
+
): NativeWebRtcAdapter {
|
|
12
|
+
return {
|
|
13
|
+
createPeerConnection: () =>
|
|
14
|
+
new RTCPeerConnection(configuration as never) as unknown as globalThis.RTCPeerConnection,
|
|
15
|
+
getUserMedia: async (constraints) =>
|
|
16
|
+
(await mediaDevices.getUserMedia(constraints as never)) as unknown as MediaStream,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Hand the system call's audio session to WebRTC: CallKit activates it (so
|
|
22
|
+
* audio keeps running with the phone locked and follows AirPods or CarPlay),
|
|
23
|
+
* and WebRTC starts and stops its voice-processing audio on those signals.
|
|
24
|
+
*/
|
|
25
|
+
export function connectCallAudioToWebRtc(call: NativeCallAdapter): () => void {
|
|
26
|
+
return call.subscribe((event) => {
|
|
27
|
+
if (event.type === "audioSessionActivated") RTCAudioSession.audioSessionDidActivate();
|
|
28
|
+
else if (event.type === "audioSessionDeactivated") RTCAudioSession.audioSessionDidDeactivate();
|
|
29
|
+
});
|
|
30
|
+
}
|