@opengeni/react 0.40.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.
@@ -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 and immediately upload/transcribe into the draft (no Send). */
55
+ /** Stop capture, durably settle the final chunk, then transcribe into the draft. */
31
56
  stop: () => void;
32
- /** Discard the in-flight recording or transcription; Escape also cancels. */
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 clearRuntime = useCallback(() => {
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 cancel = useCallback(() => {
69
- generationRef.current += 1;
70
- controllerRef.current?.abort();
71
- controllerRef.current = null;
72
- const recorder = recorderRef.current;
73
- clearRuntime();
74
- if (recorder && recorder.state !== "inactive") recorder.stop();
75
- setStatus("idle");
76
- setError(null);
77
- focusInput();
78
- }, [clearRuntime, focusInput]);
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("transcribing");
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 voiceClient = client;
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
- if (generation !== generationRef.current) {
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
- const chunks: BlobPart[] = [];
116
- const startedAt = Date.now();
117
- streamRef.current = mediaStream;
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 > 0) chunks.push(event.data);
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 controller = new AbortController();
133
- controllerRef.current = controller;
134
- void voiceClient
135
- .transcribeAudio(workspaceId, {
136
- audio,
137
- mimeType: audio.type,
138
- durationSeconds: Math.max(0, (Date.now() - startedAt) / 1000),
139
- signal: controller.signal,
140
- })
141
- .then((response) => {
142
- if (generation !== generationRef.current || controller.signal.aborted) return;
143
- const next = appendFinalTranscript(valueRef.current, response.text);
144
- if (next !== valueRef.current) {
145
- valueRef.current = next;
146
- setValue(next);
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
- if (generation !== generationRef.current || controller.signal.aborted) return;
153
- setStatus("error");
154
- setError(errorCode(reason));
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
- .finally(() => {
157
- if (controllerRef.current === controller) controllerRef.current = null;
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
- recorder.start();
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
- () => stop(),
164
- Math.min(capability.maxDurationSeconds, 60) * 1000,
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
- clearRuntime();
714
+ const visibleManifest = manifestRef.current as VoiceRecordingManifest | null;
715
+ if (failedManifest && visibleManifest?.recordingId === failedManifest.recordingId) {
716
+ clearVisibleRecording();
717
+ }
170
718
  setStatus("error");
171
- setError(errorCode(reason));
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 (event.key === "Escape" && status !== "idle") {
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
- clearRuntime();
193
- if (recorder && recorder.state !== "inactive") recorder.stop();
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
- [clearRuntime],
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