@opengeni/react 0.41.0 → 0.42.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/dist/{chunk-OMCFRWHL.js → chunk-UWYTCQWW.js} +1353 -82
- package/dist/chunk-UWYTCQWW.js.map +1 -0
- package/dist/components/composer-transcription-control.d.ts +14 -1
- package/dist/composer.d.ts +3 -1
- package/dist/composer.js +27 -1
- package/dist/hooks/use-voice-input.d.ts +25 -4
- package/dist/index.d.ts +3 -1
- package/dist/index.js +27 -1
- package/dist/index.js.map +1 -1
- package/dist/voice-recording-owner.d.ts +12 -0
- package/dist/voice-recording-store.d.ts +124 -0
- package/package.json +2 -2
- package/src/components/composer-transcription-control.tsx +130 -18
- package/src/composer.ts +30 -1
- package/src/hooks/use-voice-input.ts +893 -67
- package/src/index.ts +30 -1
- package/src/voice-recording-owner.ts +251 -0
- package/src/voice-recording-store.ts +528 -0
- package/dist/chunk-OMCFRWHL.js.map +0 -1
|
@@ -1,12 +1,27 @@
|
|
|
1
1
|
import type { ClientVoiceInputConfig, OpenGeniClient } from "@opengeni/sdk";
|
|
2
2
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
3
|
+
import {
|
|
4
|
+
IndexedDbVoiceRecordingStore,
|
|
5
|
+
VoiceRecordingOwnedError,
|
|
6
|
+
VoiceRecordingStorageUnavailableError,
|
|
7
|
+
createVoiceRecordingManifest,
|
|
8
|
+
type VoiceRecordingManifest,
|
|
9
|
+
type VoiceRecordingStore,
|
|
10
|
+
} from "../voice-recording-store";
|
|
11
|
+
import {
|
|
12
|
+
acquireDefaultVoiceRecordingOwnerLease,
|
|
13
|
+
type VoiceRecordingOwnerLease,
|
|
14
|
+
} from "../voice-recording-owner";
|
|
3
15
|
import { appendFinalTranscript } from "./use-transcription";
|
|
4
16
|
|
|
5
17
|
export type VoiceInputStatus =
|
|
6
18
|
| "idle"
|
|
7
19
|
| "requesting-permission"
|
|
8
20
|
| "recording"
|
|
21
|
+
| "saving"
|
|
9
22
|
| "transcribing"
|
|
23
|
+
| "recovered"
|
|
24
|
+
| "transcript-ready"
|
|
10
25
|
| "error";
|
|
11
26
|
|
|
12
27
|
export type UseVoiceInputOptions = {
|
|
@@ -18,6 +33,11 @@ export type UseVoiceInputOptions = {
|
|
|
18
33
|
setValue: (value: string) => void;
|
|
19
34
|
focusInput: () => void;
|
|
20
35
|
disabled?: boolean | undefined;
|
|
36
|
+
/** Test/embed seam. Production defaults to the origin-scoped IndexedDB store. */
|
|
37
|
+
createRecordingStore?: (() => VoiceRecordingStore) | undefined;
|
|
38
|
+
createRecordingId?: (() => string) | undefined;
|
|
39
|
+
createOwnerId?: (() => string) | undefined;
|
|
40
|
+
now?: (() => Date) | undefined;
|
|
21
41
|
};
|
|
22
42
|
|
|
23
43
|
export type UseVoiceInputResult = {
|
|
@@ -26,14 +46,32 @@ export type UseVoiceInputResult = {
|
|
|
26
46
|
available: boolean;
|
|
27
47
|
/** Live mic stream while recording; null once stopped/cancelled. */
|
|
28
48
|
stream: MediaStream | null;
|
|
49
|
+
recordingId: string | null;
|
|
50
|
+
durationSeconds: number;
|
|
51
|
+
locallySaved: boolean;
|
|
52
|
+
hasRecoverableRecording: boolean;
|
|
53
|
+
savedTranscript: string | null;
|
|
29
54
|
start: () => Promise<boolean>;
|
|
30
|
-
/** Stop capture
|
|
55
|
+
/** Stop capture, durably settle the final chunk, then transcribe into the draft. */
|
|
31
56
|
stop: () => void;
|
|
32
|
-
/**
|
|
57
|
+
/** Retry finalization/transcription from the already-persisted recording. */
|
|
58
|
+
retry: () => void;
|
|
59
|
+
/** Explicitly insert a durably saved transcript whose prior handoff may be uncertain. */
|
|
60
|
+
insertSavedTranscript: () => Promise<void>;
|
|
61
|
+
/** Cancel active work. A stopped/transcribing recording remains recoverable. */
|
|
33
62
|
cancel: () => void;
|
|
63
|
+
/** Intentionally delete the current durable recording. */
|
|
64
|
+
discard: () => Promise<void>;
|
|
34
65
|
};
|
|
35
66
|
|
|
67
|
+
export const VOICE_RECORDING_TIMESLICE_MILLISECONDS = 5_000;
|
|
68
|
+
export const VOICE_RECORDING_OWNER_HEARTBEAT_MILLISECONDS = 5_000;
|
|
69
|
+
export const VOICE_RECORDING_OWNER_STALE_MILLISECONDS = 30_000;
|
|
70
|
+
export const VOICE_RECORDING_CLIENT_MAX_DURATION_SECONDS = 600;
|
|
71
|
+
|
|
36
72
|
const MIME_PREFERENCES = ["audio/webm;codecs=opus", "audio/mp4", "audio/ogg;codecs=opus"];
|
|
73
|
+
const createDefaultVoiceRecordingId = () => crypto.randomUUID();
|
|
74
|
+
const currentDate = () => new Date();
|
|
37
75
|
|
|
38
76
|
export function useVoiceInput({
|
|
39
77
|
client,
|
|
@@ -44,138 +82,870 @@ export function useVoiceInput({
|
|
|
44
82
|
setValue,
|
|
45
83
|
focusInput,
|
|
46
84
|
disabled = false,
|
|
85
|
+
createRecordingStore,
|
|
86
|
+
createRecordingId = createDefaultVoiceRecordingId,
|
|
87
|
+
createOwnerId,
|
|
88
|
+
now = currentDate,
|
|
47
89
|
}: UseVoiceInputOptions): UseVoiceInputResult {
|
|
48
90
|
const [status, setStatus] = useState<VoiceInputStatus>("idle");
|
|
49
91
|
const [error, setError] = useState<string | null>(null);
|
|
50
92
|
const [stream, setStream] = useState<MediaStream | null>(null);
|
|
93
|
+
const [recordingId, setRecordingId] = useState<string | null>(null);
|
|
94
|
+
const [durationSeconds, setDurationSeconds] = useState(0);
|
|
95
|
+
const [locallySaved, setLocallySaved] = useState(false);
|
|
96
|
+
const [storageAvailable, setStorageAvailable] = useState(
|
|
97
|
+
() => Boolean(createRecordingStore) || typeof globalThis.indexedDB !== "undefined",
|
|
98
|
+
);
|
|
99
|
+
const createRecordingStoreRef = useRef(createRecordingStore);
|
|
100
|
+
createRecordingStoreRef.current = createRecordingStore;
|
|
101
|
+
const nowRef = useRef(now);
|
|
102
|
+
nowRef.current = now;
|
|
103
|
+
const readNow = useCallback(() => nowRef.current(), []);
|
|
51
104
|
const generationRef = useRef(0);
|
|
105
|
+
const workspaceIdRef = useRef(workspaceId);
|
|
106
|
+
const ownerIdRef = useRef<string | null>(null);
|
|
107
|
+
const ownerLeaseRef = useRef<VoiceRecordingOwnerLease | null>(null);
|
|
108
|
+
const ownerIdPromiseRef = useRef<Promise<string> | null>(null);
|
|
109
|
+
const createOwnerIdRef = useRef(createOwnerId);
|
|
52
110
|
const recorderRef = useRef<MediaRecorder | null>(null);
|
|
53
111
|
const streamRef = useRef<MediaStream | null>(null);
|
|
54
112
|
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
113
|
+
const ownerHeartbeatTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
55
114
|
const controllerRef = useRef<AbortController | null>(null);
|
|
56
115
|
const valueRef = useRef(value);
|
|
116
|
+
const storeRef = useRef<VoiceRecordingStore | null>(null);
|
|
117
|
+
const storePromiseRef = useRef<Promise<VoiceRecordingStore> | null>(null);
|
|
118
|
+
const ownsStoreRef = useRef(false);
|
|
119
|
+
const manifestRef = useRef<VoiceRecordingManifest | null>(null);
|
|
120
|
+
const persistenceQueueRef = useRef<Promise<void>>(Promise.resolve());
|
|
121
|
+
const persistenceErrorRef = useRef<unknown>(null);
|
|
122
|
+
const captureLimitErrorRef = useRef<string | null>(null);
|
|
123
|
+
const captureSettledRef = useRef<Promise<void>>(Promise.resolve());
|
|
124
|
+
const resolveCaptureSettledRef = useRef<(() => void) | null>(null);
|
|
125
|
+
const statusRef = useRef(status);
|
|
57
126
|
valueRef.current = value;
|
|
127
|
+
statusRef.current = status;
|
|
128
|
+
workspaceIdRef.current = workspaceId;
|
|
129
|
+
|
|
130
|
+
const ensureOwnerId = useCallback(async (): Promise<string> => {
|
|
131
|
+
if (ownerIdRef.current) return ownerIdRef.current;
|
|
132
|
+
ownerIdPromiseRef.current ??= (async () => {
|
|
133
|
+
const injectedOwnerId = createOwnerIdRef.current?.();
|
|
134
|
+
if (injectedOwnerId) {
|
|
135
|
+
ownerIdRef.current = injectedOwnerId;
|
|
136
|
+
return injectedOwnerId;
|
|
137
|
+
}
|
|
138
|
+
const lease = await acquireDefaultVoiceRecordingOwnerLease();
|
|
139
|
+
ownerLeaseRef.current = lease;
|
|
140
|
+
ownerIdRef.current = lease.ownerId;
|
|
141
|
+
return lease.ownerId;
|
|
142
|
+
})();
|
|
143
|
+
return await ownerIdPromiseRef.current;
|
|
144
|
+
}, []);
|
|
58
145
|
|
|
59
|
-
const
|
|
146
|
+
const stopOwnerHeartbeat = useCallback(() => {
|
|
147
|
+
if (ownerHeartbeatTimerRef.current) clearInterval(ownerHeartbeatTimerRef.current);
|
|
148
|
+
ownerHeartbeatTimerRef.current = null;
|
|
149
|
+
}, []);
|
|
150
|
+
|
|
151
|
+
const clearCaptureRuntime = useCallback((expectedStream?: MediaStream) => {
|
|
152
|
+
const captureStream = expectedStream ?? streamRef.current;
|
|
153
|
+
captureStream?.getTracks().forEach((track) => track.stop());
|
|
154
|
+
if (expectedStream && streamRef.current !== expectedStream) return;
|
|
60
155
|
if (timerRef.current) clearTimeout(timerRef.current);
|
|
61
156
|
timerRef.current = null;
|
|
62
|
-
streamRef.current?.getTracks().forEach((track) => track.stop());
|
|
63
157
|
streamRef.current = null;
|
|
64
158
|
recorderRef.current = null;
|
|
65
159
|
setStream(null);
|
|
66
160
|
}, []);
|
|
67
161
|
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
162
|
+
const clearVisibleRecording = useCallback(() => {
|
|
163
|
+
stopOwnerHeartbeat();
|
|
164
|
+
manifestRef.current = null;
|
|
165
|
+
setRecordingId(null);
|
|
166
|
+
setDurationSeconds(0);
|
|
167
|
+
setLocallySaved(false);
|
|
168
|
+
}, [stopOwnerHeartbeat]);
|
|
169
|
+
|
|
170
|
+
const ensureStore = useCallback(async (): Promise<VoiceRecordingStore> => {
|
|
171
|
+
if (storeRef.current) return storeRef.current;
|
|
172
|
+
if (!storePromiseRef.current) {
|
|
173
|
+
storePromiseRef.current = Promise.resolve().then(() => {
|
|
174
|
+
const factory = createRecordingStoreRef.current;
|
|
175
|
+
const store = factory?.() ?? new IndexedDbVoiceRecordingStore();
|
|
176
|
+
ownsStoreRef.current = factory === undefined;
|
|
177
|
+
storeRef.current = store;
|
|
178
|
+
setStorageAvailable(true);
|
|
179
|
+
return store;
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
try {
|
|
183
|
+
return await storePromiseRef.current;
|
|
184
|
+
} catch (reason) {
|
|
185
|
+
storePromiseRef.current = null;
|
|
186
|
+
if (reason instanceof VoiceRecordingStorageUnavailableError) setStorageAvailable(false);
|
|
187
|
+
throw reason;
|
|
188
|
+
}
|
|
189
|
+
}, []);
|
|
190
|
+
|
|
191
|
+
const rememberManifest = useCallback((manifest: VoiceRecordingManifest) => {
|
|
192
|
+
manifestRef.current = manifest;
|
|
193
|
+
setRecordingId(manifest.recordingId);
|
|
194
|
+
setDurationSeconds(manifest.totalDurationMilliseconds / 1_000);
|
|
195
|
+
setLocallySaved(manifest.chunkCount > 0);
|
|
196
|
+
}, []);
|
|
197
|
+
|
|
198
|
+
const beginOwnerHeartbeat = useCallback(
|
|
199
|
+
(manifest: VoiceRecordingManifest) => {
|
|
200
|
+
stopOwnerHeartbeat();
|
|
201
|
+
ownerHeartbeatTimerRef.current = setInterval(() => {
|
|
202
|
+
if (
|
|
203
|
+
manifestRef.current?.recordingId !== manifest.recordingId ||
|
|
204
|
+
manifestRef.current.ownerId !== ownerIdRef.current
|
|
205
|
+
) {
|
|
206
|
+
stopOwnerHeartbeat();
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
const updatedAt = readNow().toISOString();
|
|
210
|
+
void ensureStore()
|
|
211
|
+
.then((store) =>
|
|
212
|
+
store.updateManifest(
|
|
213
|
+
manifest.recordingId,
|
|
214
|
+
{ ownerHeartbeatAt: updatedAt },
|
|
215
|
+
updatedAt,
|
|
216
|
+
ownerIdRef.current ?? undefined,
|
|
217
|
+
),
|
|
218
|
+
)
|
|
219
|
+
.then((updated) => {
|
|
220
|
+
if (manifestRef.current?.recordingId === updated.recordingId) {
|
|
221
|
+
manifestRef.current = updated;
|
|
222
|
+
}
|
|
223
|
+
})
|
|
224
|
+
.catch(() => undefined);
|
|
225
|
+
}, VOICE_RECORDING_OWNER_HEARTBEAT_MILLISECONDS);
|
|
226
|
+
},
|
|
227
|
+
[ensureStore, readNow, stopOwnerHeartbeat],
|
|
228
|
+
);
|
|
229
|
+
|
|
230
|
+
const preserveForRetry = useCallback(
|
|
231
|
+
async (manifest: VoiceRecordingManifest, code: string | null, generation: number) => {
|
|
232
|
+
if (generation !== generationRef.current) return;
|
|
233
|
+
rememberManifest(manifest);
|
|
234
|
+
const transcriptReady =
|
|
235
|
+
manifest.finalizationState === "transcript-ready" && manifest.transcriptText !== null;
|
|
236
|
+
setStatus(transcriptReady ? "transcript-ready" : code ? "error" : "recovered");
|
|
237
|
+
setError(transcriptReady ? "handoff_uncertain" : code);
|
|
238
|
+
focusInput();
|
|
239
|
+
},
|
|
240
|
+
[focusInput, rememberManifest],
|
|
241
|
+
);
|
|
242
|
+
|
|
243
|
+
const updateManifestBestEffort = useCallback(
|
|
244
|
+
async (
|
|
245
|
+
manifest: VoiceRecordingManifest,
|
|
246
|
+
update: Parameters<VoiceRecordingStore["updateManifest"]>[1],
|
|
247
|
+
): Promise<VoiceRecordingManifest> => {
|
|
248
|
+
try {
|
|
249
|
+
return await (
|
|
250
|
+
await ensureStore()
|
|
251
|
+
).updateManifest(
|
|
252
|
+
manifest.recordingId,
|
|
253
|
+
update,
|
|
254
|
+
readNow().toISOString(),
|
|
255
|
+
ownerIdRef.current ?? undefined,
|
|
256
|
+
);
|
|
257
|
+
} catch {
|
|
258
|
+
return manifest;
|
|
259
|
+
}
|
|
260
|
+
},
|
|
261
|
+
[ensureStore, readNow],
|
|
262
|
+
);
|
|
263
|
+
|
|
264
|
+
const loadNextRecoverable = useCallback(
|
|
265
|
+
async (generation: number): Promise<void> => {
|
|
266
|
+
const active = () =>
|
|
267
|
+
generation === generationRef.current && workspaceIdRef.current === workspaceId;
|
|
268
|
+
const [store, ownerId] = await Promise.all([ensureStore(), ensureOwnerId()]);
|
|
269
|
+
if (!active()) return;
|
|
270
|
+
const staleBefore = new Date(
|
|
271
|
+
readNow().getTime() - VOICE_RECORDING_OWNER_STALE_MILLISECONDS,
|
|
272
|
+
).toISOString();
|
|
273
|
+
await store.cleanupHandedOffManifests({ ownerId, staleBefore }).catch(() => undefined);
|
|
274
|
+
if (!active()) return;
|
|
275
|
+
const manifests = await store.listRecoverableManifests(workspaceId, {
|
|
276
|
+
ownerId,
|
|
277
|
+
staleBefore,
|
|
278
|
+
});
|
|
279
|
+
if (!active()) return;
|
|
280
|
+
for (const candidate of manifests) {
|
|
281
|
+
try {
|
|
282
|
+
const claimedAt = readNow().toISOString();
|
|
283
|
+
const claimed = await store.claimManifest(
|
|
284
|
+
candidate.recordingId,
|
|
285
|
+
ownerId,
|
|
286
|
+
claimedAt,
|
|
287
|
+
staleBefore,
|
|
288
|
+
);
|
|
289
|
+
if (!active()) {
|
|
290
|
+
await store
|
|
291
|
+
.updateManifest(
|
|
292
|
+
claimed.recordingId,
|
|
293
|
+
{ ownerId: null, ownerHeartbeatAt: null },
|
|
294
|
+
readNow().toISOString(),
|
|
295
|
+
ownerId,
|
|
296
|
+
)
|
|
297
|
+
.catch(() => undefined);
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
rememberManifest(claimed);
|
|
301
|
+
beginOwnerHeartbeat(claimed);
|
|
302
|
+
setStatus(
|
|
303
|
+
claimed.finalizationState === "transcript-ready" && claimed.transcriptText !== null
|
|
304
|
+
? "transcript-ready"
|
|
305
|
+
: "recovered",
|
|
306
|
+
);
|
|
307
|
+
setError(null);
|
|
308
|
+
return;
|
|
309
|
+
} catch (reason) {
|
|
310
|
+
if (reason instanceof VoiceRecordingOwnedError) continue;
|
|
311
|
+
throw reason;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
if (active() && !manifestRef.current) {
|
|
315
|
+
setStatus("idle");
|
|
316
|
+
setError(null);
|
|
317
|
+
}
|
|
318
|
+
},
|
|
319
|
+
[beginOwnerHeartbeat, ensureOwnerId, ensureStore, readNow, rememberManifest, workspaceId],
|
|
320
|
+
);
|
|
321
|
+
|
|
322
|
+
const finalizePersistedRecording = useCallback(
|
|
323
|
+
async (generation: number): Promise<void> => {
|
|
324
|
+
const manifest = manifestRef.current;
|
|
325
|
+
const maxSizeBytes = capability?.maxSizeBytes;
|
|
326
|
+
if (!manifest || !client || !maxSizeBytes || manifest.workspaceId !== workspaceId) return;
|
|
327
|
+
const controller = new AbortController();
|
|
328
|
+
controllerRef.current?.abort();
|
|
329
|
+
controllerRef.current = controller;
|
|
330
|
+
const active = () =>
|
|
331
|
+
generation === generationRef.current &&
|
|
332
|
+
workspaceIdRef.current === workspaceId &&
|
|
333
|
+
!controller.signal.aborted &&
|
|
334
|
+
manifestRef.current?.recordingId === manifest.recordingId &&
|
|
335
|
+
manifestRef.current.workspaceId === workspaceId;
|
|
336
|
+
let transcribing = manifest;
|
|
337
|
+
try {
|
|
338
|
+
const store = await ensureStore();
|
|
339
|
+
if (!active()) return;
|
|
340
|
+
transcribing = await store.updateManifest(
|
|
341
|
+
manifest.recordingId,
|
|
342
|
+
{
|
|
343
|
+
captureState: "stopped",
|
|
344
|
+
uploadState: "syncing",
|
|
345
|
+
transcriptionState: "transcribing",
|
|
346
|
+
ownerHeartbeatAt: readNow().toISOString(),
|
|
347
|
+
},
|
|
348
|
+
readNow().toISOString(),
|
|
349
|
+
ownerIdRef.current ?? undefined,
|
|
350
|
+
);
|
|
351
|
+
if (!active()) return;
|
|
352
|
+
rememberManifest(transcribing);
|
|
353
|
+
setStatus("transcribing");
|
|
354
|
+
setError(null);
|
|
355
|
+
if (transcribing.totalBytes > maxSizeBytes) {
|
|
356
|
+
throw { code: "too_large" };
|
|
357
|
+
}
|
|
358
|
+
const chunks = await store.listChunks(transcribing.recordingId);
|
|
359
|
+
if (!active()) return;
|
|
360
|
+
if (chunks.length === 0) {
|
|
361
|
+
const retained = await store.updateManifest(
|
|
362
|
+
transcribing.recordingId,
|
|
363
|
+
{ uploadState: "retrying", transcriptionState: "retrying" },
|
|
364
|
+
readNow().toISOString(),
|
|
365
|
+
ownerIdRef.current ?? undefined,
|
|
366
|
+
);
|
|
367
|
+
if (!active()) return;
|
|
368
|
+
await preserveForRetry(retained, "invalid_audio", generation);
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
const audio = new Blob(
|
|
372
|
+
chunks
|
|
373
|
+
.sort((left, right) => left.chunkNumber - right.chunkNumber)
|
|
374
|
+
.map((chunk) => chunk.audio),
|
|
375
|
+
{ type: transcribing.mimeType },
|
|
376
|
+
);
|
|
377
|
+
if (!active()) return;
|
|
378
|
+
if (audio.size > maxSizeBytes) throw { code: "too_large" };
|
|
379
|
+
const response = await client.transcribeAudio(workspaceId, {
|
|
380
|
+
audio,
|
|
381
|
+
mimeType: audio.type,
|
|
382
|
+
durationSeconds: transcribing.totalDurationMilliseconds / 1_000,
|
|
383
|
+
signal: controller.signal,
|
|
384
|
+
});
|
|
385
|
+
if (!active()) return;
|
|
386
|
+
const ready = await store.updateManifest(
|
|
387
|
+
transcribing.recordingId,
|
|
388
|
+
{
|
|
389
|
+
uploadState: "complete",
|
|
390
|
+
transcriptionState: "complete",
|
|
391
|
+
finalizationState: "transcript-ready",
|
|
392
|
+
transcriptText: response.text,
|
|
393
|
+
ownerHeartbeatAt: readNow().toISOString(),
|
|
394
|
+
},
|
|
395
|
+
readNow().toISOString(),
|
|
396
|
+
ownerIdRef.current ?? undefined,
|
|
397
|
+
);
|
|
398
|
+
if (!active()) return;
|
|
399
|
+
rememberManifest(ready);
|
|
400
|
+
|
|
401
|
+
const next = appendFinalTranscript(valueRef.current, response.text);
|
|
402
|
+
if (next !== valueRef.current) {
|
|
403
|
+
valueRef.current = next;
|
|
404
|
+
setValue(next);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
let handedOff: VoiceRecordingManifest;
|
|
408
|
+
try {
|
|
409
|
+
handedOff = await store.updateManifest(
|
|
410
|
+
ready.recordingId,
|
|
411
|
+
{ finalizationState: "handed-off" },
|
|
412
|
+
readNow().toISOString(),
|
|
413
|
+
ownerIdRef.current ?? undefined,
|
|
414
|
+
);
|
|
415
|
+
} catch {
|
|
416
|
+
if (!active()) return;
|
|
417
|
+
rememberManifest(ready);
|
|
418
|
+
setStatus("transcript-ready");
|
|
419
|
+
setError("handoff_uncertain");
|
|
420
|
+
focusInput();
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
if (!active()) return;
|
|
424
|
+
rememberManifest(handedOff);
|
|
425
|
+
await store
|
|
426
|
+
.discard(handedOff.recordingId, ownerIdRef.current ?? undefined)
|
|
427
|
+
.catch(() => undefined);
|
|
428
|
+
if (!active()) return;
|
|
429
|
+
clearVisibleRecording();
|
|
430
|
+
setStatus("idle");
|
|
431
|
+
setError(null);
|
|
432
|
+
focusInput();
|
|
433
|
+
await loadNextRecoverable(generation);
|
|
434
|
+
} catch (reason) {
|
|
435
|
+
if (!active()) return;
|
|
436
|
+
const retained = await updateManifestBestEffort(transcribing, {
|
|
437
|
+
uploadState: "retrying",
|
|
438
|
+
transcriptionState: "retrying",
|
|
439
|
+
});
|
|
440
|
+
if (!active()) return;
|
|
441
|
+
await preserveForRetry(
|
|
442
|
+
retained,
|
|
443
|
+
controller.signal.aborted ? null : errorCode(reason),
|
|
444
|
+
generation,
|
|
445
|
+
);
|
|
446
|
+
} finally {
|
|
447
|
+
if (controllerRef.current === controller) controllerRef.current = null;
|
|
448
|
+
}
|
|
449
|
+
},
|
|
450
|
+
[
|
|
451
|
+
capability?.maxSizeBytes,
|
|
452
|
+
clearVisibleRecording,
|
|
453
|
+
client,
|
|
454
|
+
ensureStore,
|
|
455
|
+
focusInput,
|
|
456
|
+
loadNextRecoverable,
|
|
457
|
+
readNow,
|
|
458
|
+
preserveForRetry,
|
|
459
|
+
rememberManifest,
|
|
460
|
+
setValue,
|
|
461
|
+
updateManifestBestEffort,
|
|
462
|
+
workspaceId,
|
|
463
|
+
],
|
|
464
|
+
);
|
|
79
465
|
|
|
80
|
-
const stop = () => {
|
|
466
|
+
const stop = useCallback(() => {
|
|
81
467
|
const recorder = recorderRef.current;
|
|
82
468
|
if (!recorder || recorder.state === "inactive") return;
|
|
83
469
|
if (timerRef.current) clearTimeout(timerRef.current);
|
|
84
470
|
timerRef.current = null;
|
|
85
|
-
setStatus("
|
|
471
|
+
setStatus("saving");
|
|
86
472
|
recorder.stop();
|
|
87
|
-
};
|
|
473
|
+
}, []);
|
|
88
474
|
|
|
89
|
-
const start = async (): Promise<boolean> => {
|
|
475
|
+
const start = useCallback(async (): Promise<boolean> => {
|
|
90
476
|
if (
|
|
91
477
|
disabled ||
|
|
92
478
|
!client ||
|
|
93
479
|
!enabled ||
|
|
94
480
|
!capability?.available ||
|
|
481
|
+
manifestRef.current !== null ||
|
|
95
482
|
status === "requesting-permission" ||
|
|
96
483
|
status === "recording" ||
|
|
484
|
+
status === "saving" ||
|
|
97
485
|
status === "transcribing" ||
|
|
98
486
|
!navigator.mediaDevices?.getUserMedia ||
|
|
99
487
|
typeof MediaRecorder === "undefined"
|
|
100
488
|
) {
|
|
101
489
|
return false;
|
|
102
490
|
}
|
|
103
|
-
const
|
|
491
|
+
const prerequisiteGeneration = generationRef.current;
|
|
492
|
+
let store: VoiceRecordingStore;
|
|
493
|
+
let ownerId: string;
|
|
494
|
+
try {
|
|
495
|
+
[store, ownerId] = await Promise.all([ensureStore(), ensureOwnerId()]);
|
|
496
|
+
} catch (reason) {
|
|
497
|
+
setStatus("error");
|
|
498
|
+
setError(
|
|
499
|
+
reason instanceof VoiceRecordingStorageUnavailableError
|
|
500
|
+
? "storage_unavailable"
|
|
501
|
+
: errorCode(reason),
|
|
502
|
+
);
|
|
503
|
+
return false;
|
|
504
|
+
}
|
|
505
|
+
if (
|
|
506
|
+
prerequisiteGeneration !== generationRef.current ||
|
|
507
|
+
workspaceIdRef.current !== workspaceId ||
|
|
508
|
+
ownerIdRef.current !== ownerId ||
|
|
509
|
+
manifestRef.current !== null ||
|
|
510
|
+
statusRef.current === "requesting-permission" ||
|
|
511
|
+
statusRef.current === "recording" ||
|
|
512
|
+
statusRef.current === "saving" ||
|
|
513
|
+
statusRef.current === "transcribing"
|
|
514
|
+
) {
|
|
515
|
+
return false;
|
|
516
|
+
}
|
|
104
517
|
const generation = ++generationRef.current;
|
|
518
|
+
const startAttemptIsCurrent = () =>
|
|
519
|
+
generation === generationRef.current &&
|
|
520
|
+
workspaceIdRef.current === workspaceId &&
|
|
521
|
+
ownerIdRef.current === ownerId;
|
|
522
|
+
let attemptStream: MediaStream | null = null;
|
|
523
|
+
let attemptRecorder: MediaRecorder | null = null;
|
|
524
|
+
let attemptPersistenceQueue = Promise.resolve();
|
|
525
|
+
let attemptPersistenceError: unknown = null;
|
|
526
|
+
let attemptCaptureLimitError: string | null = null;
|
|
527
|
+
const attemptCaptureSettlement: { resolve: (() => void) | null } = { resolve: null };
|
|
528
|
+
const attemptManifest: { current: VoiceRecordingManifest | null } = { current: null };
|
|
529
|
+
let attemptNextChunkNumber = 0;
|
|
530
|
+
let attemptLastChunkEndMilliseconds = 0;
|
|
531
|
+
let attemptRecordingStartedAt = 0;
|
|
532
|
+
const attemptOwnsSharedCapture = () =>
|
|
533
|
+
startAttemptIsCurrent() &&
|
|
534
|
+
attemptStream !== null &&
|
|
535
|
+
streamRef.current === attemptStream &&
|
|
536
|
+
attemptRecorder !== null &&
|
|
537
|
+
recorderRef.current === attemptRecorder &&
|
|
538
|
+
attemptManifest.current !== null &&
|
|
539
|
+
manifestRef.current?.recordingId === attemptManifest.current.recordingId &&
|
|
540
|
+
manifestRef.current.workspaceId === workspaceId &&
|
|
541
|
+
manifestRef.current.ownerId === ownerId;
|
|
105
542
|
setStatus("requesting-permission");
|
|
106
543
|
setError(null);
|
|
544
|
+
let acquiredStream: MediaStream | null = null;
|
|
545
|
+
let recorderStarted = false;
|
|
107
546
|
try {
|
|
108
547
|
const mediaStream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
|
109
|
-
|
|
548
|
+
acquiredStream = mediaStream;
|
|
549
|
+
attemptStream = mediaStream;
|
|
550
|
+
if (!startAttemptIsCurrent()) {
|
|
110
551
|
mediaStream.getTracks().forEach((track) => track.stop());
|
|
111
552
|
return false;
|
|
112
553
|
}
|
|
554
|
+
streamRef.current = mediaStream;
|
|
555
|
+
setStream(mediaStream);
|
|
113
556
|
const mimeType = chooseMimeType(capability.acceptedMimeTypes);
|
|
114
557
|
const recorder = new MediaRecorder(mediaStream, mimeType ? { mimeType } : undefined);
|
|
115
|
-
|
|
116
|
-
const
|
|
117
|
-
|
|
558
|
+
attemptRecorder = recorder;
|
|
559
|
+
const createdAt = readNow();
|
|
560
|
+
const manifest = createVoiceRecordingManifest({
|
|
561
|
+
recordingId: createRecordingId(),
|
|
562
|
+
workspaceId,
|
|
563
|
+
mimeType: recorder.mimeType || mimeType || "audio/webm",
|
|
564
|
+
createdAt: createdAt.toISOString(),
|
|
565
|
+
ownerId,
|
|
566
|
+
});
|
|
567
|
+
attemptManifest.current = manifest;
|
|
568
|
+
await store.createManifest(manifest);
|
|
569
|
+
if (!startAttemptIsCurrent()) {
|
|
570
|
+
await store.discard(manifest.recordingId, ownerId);
|
|
571
|
+
clearCaptureRuntime(mediaStream);
|
|
572
|
+
return false;
|
|
573
|
+
}
|
|
574
|
+
rememberManifest(manifest);
|
|
575
|
+
beginOwnerHeartbeat(manifest);
|
|
576
|
+
persistenceQueueRef.current = attemptPersistenceQueue;
|
|
577
|
+
persistenceErrorRef.current = null;
|
|
578
|
+
captureLimitErrorRef.current = null;
|
|
118
579
|
recorderRef.current = recorder;
|
|
119
|
-
setStream(mediaStream);
|
|
120
580
|
recorder.ondataavailable = (event) => {
|
|
121
|
-
if (event.data.size
|
|
122
|
-
};
|
|
123
|
-
recorder.onstop = () => {
|
|
124
|
-
clearRuntime();
|
|
125
|
-
if (generation !== generationRef.current) return;
|
|
126
|
-
const audio = new Blob(chunks, { type: recorder.mimeType || mimeType || "audio/webm" });
|
|
127
|
-
if (audio.size === 0) {
|
|
128
|
-
setStatus("error");
|
|
129
|
-
setError("invalid_audio");
|
|
581
|
+
if (event.data.size === 0 || attemptPersistenceError || attemptCaptureLimitError) {
|
|
130
582
|
return;
|
|
131
583
|
}
|
|
132
|
-
const
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
584
|
+
const chunkNumber = attemptNextChunkNumber++;
|
|
585
|
+
const elapsed = Math.max(0, readNow().getTime() - attemptRecordingStartedAt);
|
|
586
|
+
const eventTimecode = Number.isFinite(event.timecode) ? Math.max(0, event.timecode) : 0;
|
|
587
|
+
const endMilliseconds = Math.max(attemptLastChunkEndMilliseconds, eventTimecode, elapsed);
|
|
588
|
+
const startMilliseconds = attemptLastChunkEndMilliseconds;
|
|
589
|
+
const durationMilliseconds = Math.max(0, endMilliseconds - startMilliseconds);
|
|
590
|
+
attemptLastChunkEndMilliseconds = endMilliseconds;
|
|
591
|
+
attemptPersistenceQueue = attemptPersistenceQueue
|
|
592
|
+
.then(async () => {
|
|
593
|
+
const result = await store.persistChunk({
|
|
594
|
+
recordingId: manifest.recordingId,
|
|
595
|
+
ownerId,
|
|
596
|
+
chunkNumber,
|
|
597
|
+
capturedAt: readNow().toISOString(),
|
|
598
|
+
startMilliseconds,
|
|
599
|
+
durationMilliseconds,
|
|
600
|
+
mimeType: manifest.mimeType,
|
|
601
|
+
audio: event.data,
|
|
602
|
+
});
|
|
603
|
+
if (!startAttemptIsCurrent()) return;
|
|
604
|
+
rememberManifest(result.manifest);
|
|
605
|
+
if (result.manifest.totalBytes > capability.maxSizeBytes) {
|
|
606
|
+
attemptCaptureLimitError = "too_large";
|
|
607
|
+
if (attemptOwnsSharedCapture()) captureLimitErrorRef.current = "too_large";
|
|
608
|
+
if (recorder.state !== "inactive") recorder.stop();
|
|
147
609
|
}
|
|
148
|
-
setStatus("idle");
|
|
149
|
-
focusInput();
|
|
150
610
|
})
|
|
151
611
|
.catch((reason: unknown) => {
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
612
|
+
attemptPersistenceError = reason;
|
|
613
|
+
if (attemptOwnsSharedCapture()) persistenceErrorRef.current = reason;
|
|
614
|
+
if (recorder.state !== "inactive") recorder.stop();
|
|
615
|
+
});
|
|
616
|
+
if (attemptOwnsSharedCapture()) {
|
|
617
|
+
persistenceQueueRef.current = attemptPersistenceQueue;
|
|
618
|
+
}
|
|
619
|
+
};
|
|
620
|
+
recorder.onstop = () => {
|
|
621
|
+
if (attemptOwnsSharedCapture()) {
|
|
622
|
+
clearCaptureRuntime(mediaStream);
|
|
623
|
+
} else {
|
|
624
|
+
mediaStream.getTracks().forEach((track) => track.stop());
|
|
625
|
+
}
|
|
626
|
+
void attemptPersistenceQueue
|
|
627
|
+
.catch((reason: unknown) => {
|
|
628
|
+
attemptPersistenceError = reason;
|
|
155
629
|
})
|
|
156
|
-
.
|
|
157
|
-
|
|
630
|
+
.then(async () => {
|
|
631
|
+
const resolveSettled = attemptCaptureSettlement.resolve;
|
|
632
|
+
attemptCaptureSettlement.resolve = null;
|
|
633
|
+
resolveSettled?.();
|
|
634
|
+
if (resolveCaptureSettledRef.current === resolveSettled) {
|
|
635
|
+
resolveCaptureSettledRef.current = null;
|
|
636
|
+
}
|
|
637
|
+
const stoppedCaptureIsCurrent = () =>
|
|
638
|
+
generation === generationRef.current &&
|
|
639
|
+
workspaceIdRef.current === workspaceId &&
|
|
640
|
+
ownerIdRef.current === ownerId &&
|
|
641
|
+
manifestRef.current?.recordingId === manifest.recordingId &&
|
|
642
|
+
manifestRef.current.workspaceId === workspaceId &&
|
|
643
|
+
manifestRef.current.ownerId === ownerId;
|
|
644
|
+
if (!stoppedCaptureIsCurrent()) return;
|
|
645
|
+
const current = manifestRef.current;
|
|
646
|
+
if (!current) return;
|
|
647
|
+
const stopped = await updateManifestBestEffort(current, { captureState: "stopped" });
|
|
648
|
+
if (
|
|
649
|
+
!stoppedCaptureIsCurrent() ||
|
|
650
|
+
stopped.recordingId !== manifest.recordingId ||
|
|
651
|
+
stopped.workspaceId !== workspaceId ||
|
|
652
|
+
stopped.ownerId !== ownerId
|
|
653
|
+
) {
|
|
654
|
+
return;
|
|
655
|
+
}
|
|
656
|
+
rememberManifest(stopped);
|
|
657
|
+
if (attemptPersistenceError) {
|
|
658
|
+
await preserveForRetry(stopped, "storage_unavailable", generation);
|
|
659
|
+
return;
|
|
660
|
+
}
|
|
661
|
+
if (attemptCaptureLimitError) {
|
|
662
|
+
await preserveForRetry(stopped, attemptCaptureLimitError, generation);
|
|
663
|
+
return;
|
|
664
|
+
}
|
|
665
|
+
await finalizePersistedRecording(generation);
|
|
158
666
|
});
|
|
159
667
|
};
|
|
160
|
-
|
|
668
|
+
captureSettledRef.current = new Promise<void>((resolve) => {
|
|
669
|
+
attemptCaptureSettlement.resolve = resolve;
|
|
670
|
+
resolveCaptureSettledRef.current = resolve;
|
|
671
|
+
});
|
|
672
|
+
attemptRecordingStartedAt = readNow().getTime();
|
|
673
|
+
recorder.start(VOICE_RECORDING_TIMESLICE_MILLISECONDS);
|
|
674
|
+
recorderStarted = true;
|
|
161
675
|
setStatus("recording");
|
|
162
676
|
timerRef.current = setTimeout(
|
|
163
|
-
|
|
164
|
-
Math.min(capability.maxDurationSeconds,
|
|
677
|
+
stop,
|
|
678
|
+
Math.min(capability.maxDurationSeconds, VOICE_RECORDING_CLIENT_MAX_DURATION_SECONDS) *
|
|
679
|
+
1_000,
|
|
165
680
|
);
|
|
166
681
|
return true;
|
|
167
682
|
} catch (reason) {
|
|
683
|
+
const resolveSettled = attemptCaptureSettlement.resolve;
|
|
684
|
+
attemptCaptureSettlement.resolve = null;
|
|
685
|
+
resolveSettled?.();
|
|
686
|
+
if (resolveCaptureSettledRef.current === resolveSettled) {
|
|
687
|
+
resolveCaptureSettledRef.current = null;
|
|
688
|
+
}
|
|
689
|
+
if (acquiredStream) {
|
|
690
|
+
if (startAttemptIsCurrent() && streamRef.current === acquiredStream) {
|
|
691
|
+
clearCaptureRuntime(acquiredStream);
|
|
692
|
+
} else {
|
|
693
|
+
acquiredStream.getTracks().forEach((track) => track.stop());
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
const failedManifest = attemptManifest.current;
|
|
697
|
+
if (failedManifest && !recorderStarted) {
|
|
698
|
+
const discarded = await store
|
|
699
|
+
.discard(failedManifest.recordingId, ownerId)
|
|
700
|
+
.then(() => true)
|
|
701
|
+
.catch(() => false);
|
|
702
|
+
if (!discarded) {
|
|
703
|
+
await store
|
|
704
|
+
.updateManifest(
|
|
705
|
+
failedManifest.recordingId,
|
|
706
|
+
{ captureState: "stopped", ownerId: null, ownerHeartbeatAt: null },
|
|
707
|
+
readNow().toISOString(),
|
|
708
|
+
ownerId,
|
|
709
|
+
)
|
|
710
|
+
.catch(() => undefined);
|
|
711
|
+
}
|
|
712
|
+
}
|
|
168
713
|
if (generation !== generationRef.current) return false;
|
|
169
|
-
|
|
714
|
+
const visibleManifest = manifestRef.current as VoiceRecordingManifest | null;
|
|
715
|
+
if (failedManifest && visibleManifest?.recordingId === failedManifest.recordingId) {
|
|
716
|
+
clearVisibleRecording();
|
|
717
|
+
}
|
|
170
718
|
setStatus("error");
|
|
171
|
-
setError(
|
|
719
|
+
setError(
|
|
720
|
+
reason instanceof VoiceRecordingStorageUnavailableError
|
|
721
|
+
? "storage_unavailable"
|
|
722
|
+
: errorCode(reason),
|
|
723
|
+
);
|
|
172
724
|
return false;
|
|
173
725
|
}
|
|
174
|
-
}
|
|
726
|
+
}, [
|
|
727
|
+
capability,
|
|
728
|
+
beginOwnerHeartbeat,
|
|
729
|
+
clearCaptureRuntime,
|
|
730
|
+
clearVisibleRecording,
|
|
731
|
+
client,
|
|
732
|
+
createRecordingId,
|
|
733
|
+
disabled,
|
|
734
|
+
enabled,
|
|
735
|
+
ensureOwnerId,
|
|
736
|
+
ensureStore,
|
|
737
|
+
finalizePersistedRecording,
|
|
738
|
+
readNow,
|
|
739
|
+
preserveForRetry,
|
|
740
|
+
rememberManifest,
|
|
741
|
+
status,
|
|
742
|
+
stop,
|
|
743
|
+
updateManifestBestEffort,
|
|
744
|
+
workspaceId,
|
|
745
|
+
]);
|
|
746
|
+
|
|
747
|
+
const retry = useCallback(() => {
|
|
748
|
+
if (
|
|
749
|
+
!manifestRef.current ||
|
|
750
|
+
manifestRef.current.finalizationState === "transcript-ready" ||
|
|
751
|
+
manifestRef.current.workspaceId !== workspaceId ||
|
|
752
|
+
!client ||
|
|
753
|
+
disabled ||
|
|
754
|
+
!enabled ||
|
|
755
|
+
status === "saving" ||
|
|
756
|
+
status === "transcribing"
|
|
757
|
+
) {
|
|
758
|
+
return;
|
|
759
|
+
}
|
|
760
|
+
const generation = ++generationRef.current;
|
|
761
|
+
controllerRef.current?.abort();
|
|
762
|
+
setError(null);
|
|
763
|
+
void finalizePersistedRecording(generation);
|
|
764
|
+
}, [client, disabled, enabled, finalizePersistedRecording, status, workspaceId]);
|
|
765
|
+
|
|
766
|
+
const insertSavedTranscript = useCallback(async (): Promise<void> => {
|
|
767
|
+
const manifest = manifestRef.current;
|
|
768
|
+
if (
|
|
769
|
+
!manifest ||
|
|
770
|
+
manifest.workspaceId !== workspaceId ||
|
|
771
|
+
manifest.finalizationState !== "transcript-ready" ||
|
|
772
|
+
manifest.transcriptText === null
|
|
773
|
+
) {
|
|
774
|
+
return;
|
|
775
|
+
}
|
|
776
|
+
const generation = ++generationRef.current;
|
|
777
|
+
controllerRef.current?.abort();
|
|
778
|
+
controllerRef.current = null;
|
|
779
|
+
const store = await ensureStore();
|
|
780
|
+
if (generation !== generationRef.current) return;
|
|
781
|
+
const next = appendFinalTranscript(valueRef.current, manifest.transcriptText);
|
|
782
|
+
if (next !== valueRef.current) {
|
|
783
|
+
valueRef.current = next;
|
|
784
|
+
setValue(next);
|
|
785
|
+
}
|
|
786
|
+
let handedOff: VoiceRecordingManifest;
|
|
787
|
+
try {
|
|
788
|
+
handedOff = await store.updateManifest(
|
|
789
|
+
manifest.recordingId,
|
|
790
|
+
{ finalizationState: "handed-off" },
|
|
791
|
+
readNow().toISOString(),
|
|
792
|
+
ownerIdRef.current ?? undefined,
|
|
793
|
+
);
|
|
794
|
+
} catch {
|
|
795
|
+
if (generation !== generationRef.current) return;
|
|
796
|
+
rememberManifest(manifest);
|
|
797
|
+
setStatus("transcript-ready");
|
|
798
|
+
setError("handoff_uncertain");
|
|
799
|
+
focusInput();
|
|
800
|
+
return;
|
|
801
|
+
}
|
|
802
|
+
if (generation !== generationRef.current) return;
|
|
803
|
+
await store
|
|
804
|
+
.discard(handedOff.recordingId, ownerIdRef.current ?? undefined)
|
|
805
|
+
.catch(() => undefined);
|
|
806
|
+
if (generation !== generationRef.current) return;
|
|
807
|
+
clearVisibleRecording();
|
|
808
|
+
setStatus("idle");
|
|
809
|
+
setError(null);
|
|
810
|
+
focusInput();
|
|
811
|
+
await loadNextRecoverable(generation);
|
|
812
|
+
}, [
|
|
813
|
+
clearVisibleRecording,
|
|
814
|
+
ensureStore,
|
|
815
|
+
focusInput,
|
|
816
|
+
loadNextRecoverable,
|
|
817
|
+
readNow,
|
|
818
|
+
rememberManifest,
|
|
819
|
+
setValue,
|
|
820
|
+
workspaceId,
|
|
821
|
+
]);
|
|
822
|
+
|
|
823
|
+
const discard = useCallback(async (): Promise<void> => {
|
|
824
|
+
const manifest = manifestRef.current;
|
|
825
|
+
const generation = ++generationRef.current;
|
|
826
|
+
controllerRef.current?.abort();
|
|
827
|
+
controllerRef.current = null;
|
|
828
|
+
const recorder = recorderRef.current;
|
|
829
|
+
let captureSettled = persistenceQueueRef.current;
|
|
830
|
+
if (recorder && recorder.state !== "inactive") {
|
|
831
|
+
captureSettled = captureSettledRef.current;
|
|
832
|
+
recorder.stop();
|
|
833
|
+
}
|
|
834
|
+
clearCaptureRuntime();
|
|
835
|
+
await captureSettled.catch(() => undefined);
|
|
836
|
+
if (generation !== generationRef.current) return;
|
|
837
|
+
if (manifest) {
|
|
838
|
+
try {
|
|
839
|
+
await (await ensureStore()).discard(manifest.recordingId, ownerIdRef.current ?? undefined);
|
|
840
|
+
} catch {
|
|
841
|
+
if (generation !== generationRef.current) return;
|
|
842
|
+
rememberManifest(manifest);
|
|
843
|
+
setStatus("error");
|
|
844
|
+
setError("storage_unavailable");
|
|
845
|
+
focusInput();
|
|
846
|
+
return;
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
clearVisibleRecording();
|
|
850
|
+
persistenceErrorRef.current = null;
|
|
851
|
+
captureLimitErrorRef.current = null;
|
|
852
|
+
setStatus("idle");
|
|
853
|
+
setError(null);
|
|
854
|
+
focusInput();
|
|
855
|
+
await loadNextRecoverable(generation);
|
|
856
|
+
}, [
|
|
857
|
+
clearCaptureRuntime,
|
|
858
|
+
clearVisibleRecording,
|
|
859
|
+
ensureStore,
|
|
860
|
+
focusInput,
|
|
861
|
+
loadNextRecoverable,
|
|
862
|
+
rememberManifest,
|
|
863
|
+
]);
|
|
864
|
+
|
|
865
|
+
const cancel = useCallback(() => {
|
|
866
|
+
if (status === "recording" || status === "requesting-permission") {
|
|
867
|
+
void discard();
|
|
868
|
+
return;
|
|
869
|
+
}
|
|
870
|
+
if (status === "saving" || status === "transcribing") {
|
|
871
|
+
const generation = ++generationRef.current;
|
|
872
|
+
controllerRef.current?.abort();
|
|
873
|
+
controllerRef.current = null;
|
|
874
|
+
const captureSettled = status === "saving" ? captureSettledRef.current : Promise.resolve();
|
|
875
|
+
void captureSettled.then(async () => {
|
|
876
|
+
const manifest = manifestRef.current;
|
|
877
|
+
if (!manifest) return;
|
|
878
|
+
const retained = await updateManifestBestEffort(manifest, {
|
|
879
|
+
captureState: "stopped",
|
|
880
|
+
uploadState: "retrying",
|
|
881
|
+
transcriptionState: "retrying",
|
|
882
|
+
});
|
|
883
|
+
await preserveForRetry(retained, null, generation);
|
|
884
|
+
});
|
|
885
|
+
}
|
|
886
|
+
}, [discard, preserveForRetry, status, updateManifestBestEffort]);
|
|
887
|
+
|
|
888
|
+
useEffect(() => {
|
|
889
|
+
const current = manifestRef.current;
|
|
890
|
+
const generation = ++generationRef.current;
|
|
891
|
+
if (current && current.workspaceId !== workspaceId) {
|
|
892
|
+
controllerRef.current?.abort();
|
|
893
|
+
controllerRef.current = null;
|
|
894
|
+
const recorder = recorderRef.current;
|
|
895
|
+
let captureSettled = persistenceQueueRef.current;
|
|
896
|
+
if (recorder && recorder.state !== "inactive") {
|
|
897
|
+
captureSettled = captureSettledRef.current;
|
|
898
|
+
recorder.stop();
|
|
899
|
+
}
|
|
900
|
+
clearCaptureRuntime();
|
|
901
|
+
clearVisibleRecording();
|
|
902
|
+
setStatus("idle");
|
|
903
|
+
setError(null);
|
|
904
|
+
void captureSettled
|
|
905
|
+
.catch(() => undefined)
|
|
906
|
+
.then(async () => {
|
|
907
|
+
const store = await ensureStore();
|
|
908
|
+
const wasProcessing =
|
|
909
|
+
current.uploadState === "syncing" || current.transcriptionState === "transcribing";
|
|
910
|
+
await store.updateManifest(
|
|
911
|
+
current.recordingId,
|
|
912
|
+
{
|
|
913
|
+
...(current.captureState === "capturing" ? { captureState: "stopped" as const } : {}),
|
|
914
|
+
...(wasProcessing
|
|
915
|
+
? { uploadState: "retrying" as const, transcriptionState: "retrying" as const }
|
|
916
|
+
: {}),
|
|
917
|
+
ownerId: null,
|
|
918
|
+
ownerHeartbeatAt: null,
|
|
919
|
+
},
|
|
920
|
+
readNow().toISOString(),
|
|
921
|
+
ownerIdRef.current ?? undefined,
|
|
922
|
+
);
|
|
923
|
+
})
|
|
924
|
+
.catch(() => undefined);
|
|
925
|
+
}
|
|
926
|
+
if (!manifestRef.current) {
|
|
927
|
+
void loadNextRecoverable(generation).catch((reason: unknown) => {
|
|
928
|
+
if (reason instanceof VoiceRecordingStorageUnavailableError) setStorageAvailable(false);
|
|
929
|
+
});
|
|
930
|
+
}
|
|
931
|
+
}, [
|
|
932
|
+
clearCaptureRuntime,
|
|
933
|
+
clearVisibleRecording,
|
|
934
|
+
ensureStore,
|
|
935
|
+
loadNextRecoverable,
|
|
936
|
+
readNow,
|
|
937
|
+
workspaceId,
|
|
938
|
+
]);
|
|
175
939
|
|
|
176
940
|
useEffect(() => {
|
|
177
941
|
const onKeyDown = (event: KeyboardEvent) => {
|
|
178
|
-
if (
|
|
942
|
+
if (
|
|
943
|
+
event.key === "Escape" &&
|
|
944
|
+
(status === "requesting-permission" ||
|
|
945
|
+
status === "recording" ||
|
|
946
|
+
status === "saving" ||
|
|
947
|
+
status === "transcribing")
|
|
948
|
+
) {
|
|
179
949
|
event.preventDefault();
|
|
180
950
|
cancel();
|
|
181
951
|
}
|
|
@@ -188,21 +958,77 @@ export function useVoiceInput({
|
|
|
188
958
|
() => () => {
|
|
189
959
|
generationRef.current += 1;
|
|
190
960
|
controllerRef.current?.abort();
|
|
961
|
+
controllerRef.current = null;
|
|
962
|
+
const manifest = manifestRef.current;
|
|
963
|
+
const ownerReady = ownerIdPromiseRef.current;
|
|
964
|
+
stopOwnerHeartbeat();
|
|
191
965
|
const recorder = recorderRef.current;
|
|
192
|
-
|
|
193
|
-
if (recorder && recorder.state !== "inactive")
|
|
966
|
+
let captureSettled = persistenceQueueRef.current;
|
|
967
|
+
if (recorder && recorder.state !== "inactive") {
|
|
968
|
+
captureSettled = captureSettledRef.current;
|
|
969
|
+
recorder.stop();
|
|
970
|
+
}
|
|
971
|
+
clearCaptureRuntime();
|
|
972
|
+
void captureSettled
|
|
973
|
+
.catch(() => undefined)
|
|
974
|
+
.then(async () => {
|
|
975
|
+
const ownerId =
|
|
976
|
+
ownerIdRef.current ?? (ownerReady ? await ownerReady.catch(() => null) : null);
|
|
977
|
+
const store = storeRef.current;
|
|
978
|
+
if (store && manifest && ownerId) {
|
|
979
|
+
const wasProcessing =
|
|
980
|
+
statusRef.current === "saving" || statusRef.current === "transcribing";
|
|
981
|
+
await store
|
|
982
|
+
.updateManifest(
|
|
983
|
+
manifest.recordingId,
|
|
984
|
+
{
|
|
985
|
+
...(manifest.captureState === "capturing"
|
|
986
|
+
? { captureState: "stopped" as const }
|
|
987
|
+
: {}),
|
|
988
|
+
...(wasProcessing
|
|
989
|
+
? {
|
|
990
|
+
uploadState: "retrying" as const,
|
|
991
|
+
transcriptionState: "retrying" as const,
|
|
992
|
+
}
|
|
993
|
+
: {}),
|
|
994
|
+
ownerId: null,
|
|
995
|
+
ownerHeartbeatAt: null,
|
|
996
|
+
},
|
|
997
|
+
readNow().toISOString(),
|
|
998
|
+
ownerId,
|
|
999
|
+
)
|
|
1000
|
+
.catch(() => undefined);
|
|
1001
|
+
}
|
|
1002
|
+
if (ownsStoreRef.current) await store?.close();
|
|
1003
|
+
})
|
|
1004
|
+
.catch(() => undefined)
|
|
1005
|
+
.finally(() => {
|
|
1006
|
+
ownerLeaseRef.current?.release();
|
|
1007
|
+
ownerLeaseRef.current = null;
|
|
1008
|
+
});
|
|
194
1009
|
},
|
|
195
|
-
[
|
|
1010
|
+
[clearCaptureRuntime, readNow, stopOwnerHeartbeat],
|
|
196
1011
|
);
|
|
197
1012
|
|
|
198
1013
|
return {
|
|
199
1014
|
status,
|
|
200
1015
|
error,
|
|
201
|
-
available: Boolean(capability?.available && enabled && !disabled),
|
|
1016
|
+
available: Boolean(capability?.available && enabled && !disabled && storageAvailable),
|
|
202
1017
|
stream,
|
|
1018
|
+
recordingId,
|
|
1019
|
+
durationSeconds,
|
|
1020
|
+
locallySaved,
|
|
1021
|
+
hasRecoverableRecording: manifestRef.current !== null && status !== "recording",
|
|
1022
|
+
savedTranscript:
|
|
1023
|
+
manifestRef.current?.finalizationState === "transcript-ready"
|
|
1024
|
+
? manifestRef.current.transcriptText
|
|
1025
|
+
: null,
|
|
203
1026
|
start,
|
|
204
1027
|
stop,
|
|
1028
|
+
retry,
|
|
1029
|
+
insertSavedTranscript,
|
|
205
1030
|
cancel,
|
|
1031
|
+
discard,
|
|
206
1032
|
};
|
|
207
1033
|
}
|
|
208
1034
|
|