@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.
- 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
|
@@ -20,6 +20,346 @@ import {
|
|
|
20
20
|
cn
|
|
21
21
|
} from "./chunk-TK7G6XLT.js";
|
|
22
22
|
|
|
23
|
+
// src/voice-recording-store.ts
|
|
24
|
+
var DEFAULT_DATABASE_NAME = "opengeni-voice-recordings-v1";
|
|
25
|
+
var DATABASE_VERSION = 1;
|
|
26
|
+
var MANIFEST_STORE = "recordings";
|
|
27
|
+
var CHUNK_STORE = "chunks";
|
|
28
|
+
var CHUNKS_BY_RECORDING = "by-recording";
|
|
29
|
+
var VoiceRecordingStorageUnavailableError = class extends Error {
|
|
30
|
+
constructor() {
|
|
31
|
+
super("Durable voice recording storage is unavailable.");
|
|
32
|
+
this.name = "VoiceRecordingStorageUnavailableError";
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
var VoiceRecordingNotFoundError = class extends Error {
|
|
36
|
+
constructor(recordingId) {
|
|
37
|
+
super(`Voice recording ${recordingId} was not found.`);
|
|
38
|
+
this.name = "VoiceRecordingNotFoundError";
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
var VoiceRecordingChunkConflictError = class extends Error {
|
|
42
|
+
constructor(recordingId, chunkNumber) {
|
|
43
|
+
super(`Voice recording ${recordingId} chunk ${chunkNumber} conflicts with persisted audio.`);
|
|
44
|
+
this.name = "VoiceRecordingChunkConflictError";
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
var VoiceRecordingChunkSequenceError = class extends Error {
|
|
48
|
+
constructor(expected, received) {
|
|
49
|
+
super(`Expected voice recording chunk ${expected}, received ${received}.`);
|
|
50
|
+
this.name = "VoiceRecordingChunkSequenceError";
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
var VoiceRecordingOwnedError = class extends Error {
|
|
54
|
+
constructor(recordingId) {
|
|
55
|
+
super(`Voice recording ${recordingId} is active in another browser tab.`);
|
|
56
|
+
this.name = "VoiceRecordingOwnedError";
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
function createVoiceRecordingManifest(input) {
|
|
60
|
+
return {
|
|
61
|
+
version: 1,
|
|
62
|
+
recordingId: input.recordingId,
|
|
63
|
+
workspaceId: input.workspaceId,
|
|
64
|
+
createdAt: input.createdAt,
|
|
65
|
+
updatedAt: input.createdAt,
|
|
66
|
+
mimeType: input.mimeType,
|
|
67
|
+
codec: codecForMimeType(input.mimeType),
|
|
68
|
+
captureState: "capturing",
|
|
69
|
+
uploadState: "pending",
|
|
70
|
+
transcriptionState: "pending",
|
|
71
|
+
finalizationState: "pending",
|
|
72
|
+
ownerId: input.ownerId ?? null,
|
|
73
|
+
ownerHeartbeatAt: input.ownerId ? input.createdAt : null,
|
|
74
|
+
transcriptText: null,
|
|
75
|
+
nextChunkNumber: 0,
|
|
76
|
+
chunkCount: 0,
|
|
77
|
+
totalBytes: 0,
|
|
78
|
+
totalDurationMilliseconds: 0
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
async function prepareVoiceRecordingChunk(input) {
|
|
82
|
+
if (!Number.isSafeInteger(input.chunkNumber) || input.chunkNumber < 0) {
|
|
83
|
+
throw new VoiceRecordingChunkSequenceError(0, input.chunkNumber);
|
|
84
|
+
}
|
|
85
|
+
if (!Number.isFinite(input.startMilliseconds) || input.startMilliseconds < 0) {
|
|
86
|
+
throw new RangeError("Voice recording chunk start must be non-negative.");
|
|
87
|
+
}
|
|
88
|
+
if (!Number.isFinite(input.durationMilliseconds) || input.durationMilliseconds < 0) {
|
|
89
|
+
throw new RangeError("Voice recording chunk duration must be non-negative.");
|
|
90
|
+
}
|
|
91
|
+
const bytes = new Uint8Array(await input.audio.arrayBuffer());
|
|
92
|
+
if (bytes.byteLength === 0) throw new RangeError("Voice recording chunks cannot be empty.");
|
|
93
|
+
return {
|
|
94
|
+
recordingId: input.recordingId,
|
|
95
|
+
chunkNumber: input.chunkNumber,
|
|
96
|
+
capturedAt: input.capturedAt,
|
|
97
|
+
startMilliseconds: Math.round(input.startMilliseconds),
|
|
98
|
+
durationMilliseconds: Math.round(input.durationMilliseconds),
|
|
99
|
+
mimeType: input.mimeType,
|
|
100
|
+
codec: codecForMimeType(input.mimeType),
|
|
101
|
+
byteLength: bytes.byteLength,
|
|
102
|
+
sha256: await sha256Hex(bytes),
|
|
103
|
+
uploadState: "pending",
|
|
104
|
+
audio: input.audio
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
function planVoiceRecordingChunkCommit(input) {
|
|
108
|
+
const { manifest, chunk, existingChunk } = input;
|
|
109
|
+
if (manifest.recordingId !== chunk.recordingId) {
|
|
110
|
+
throw new VoiceRecordingChunkConflictError(chunk.recordingId, chunk.chunkNumber);
|
|
111
|
+
}
|
|
112
|
+
if (existingChunk) {
|
|
113
|
+
if (existingChunk.sha256 !== chunk.sha256 || existingChunk.byteLength !== chunk.byteLength || existingChunk.mimeType !== chunk.mimeType) {
|
|
114
|
+
throw new VoiceRecordingChunkConflictError(chunk.recordingId, chunk.chunkNumber);
|
|
115
|
+
}
|
|
116
|
+
return { manifest, chunk: existingChunk, deduplicated: true };
|
|
117
|
+
}
|
|
118
|
+
if (chunk.chunkNumber !== manifest.nextChunkNumber) {
|
|
119
|
+
throw new VoiceRecordingChunkSequenceError(manifest.nextChunkNumber, chunk.chunkNumber);
|
|
120
|
+
}
|
|
121
|
+
const updatedManifest = {
|
|
122
|
+
...manifest,
|
|
123
|
+
updatedAt: chunk.capturedAt,
|
|
124
|
+
ownerHeartbeatAt: manifest.ownerId ? chunk.capturedAt : manifest.ownerHeartbeatAt,
|
|
125
|
+
nextChunkNumber: manifest.nextChunkNumber + 1,
|
|
126
|
+
chunkCount: manifest.chunkCount + 1,
|
|
127
|
+
totalBytes: manifest.totalBytes + chunk.byteLength,
|
|
128
|
+
totalDurationMilliseconds: Math.max(
|
|
129
|
+
manifest.totalDurationMilliseconds,
|
|
130
|
+
chunk.startMilliseconds + chunk.durationMilliseconds
|
|
131
|
+
)
|
|
132
|
+
};
|
|
133
|
+
return { manifest: updatedManifest, chunk, deduplicated: false };
|
|
134
|
+
}
|
|
135
|
+
var IndexedDbVoiceRecordingStore = class {
|
|
136
|
+
database;
|
|
137
|
+
constructor(options) {
|
|
138
|
+
const factory = options && "indexedDB" in options ? options.indexedDB : globalThis.indexedDB;
|
|
139
|
+
if (!factory) throw new VoiceRecordingStorageUnavailableError();
|
|
140
|
+
this.database = openDatabase(factory, options?.databaseName ?? DEFAULT_DATABASE_NAME);
|
|
141
|
+
}
|
|
142
|
+
async createManifest(manifest) {
|
|
143
|
+
const database = await this.database;
|
|
144
|
+
const transaction = database.transaction(MANIFEST_STORE, "readwrite");
|
|
145
|
+
transaction.objectStore(MANIFEST_STORE).add(manifest);
|
|
146
|
+
await transactionComplete(transaction);
|
|
147
|
+
}
|
|
148
|
+
async getManifest(recordingId) {
|
|
149
|
+
const database = await this.database;
|
|
150
|
+
const transaction = database.transaction(MANIFEST_STORE, "readonly");
|
|
151
|
+
const manifest = await requestResult(
|
|
152
|
+
transaction.objectStore(MANIFEST_STORE).get(recordingId)
|
|
153
|
+
);
|
|
154
|
+
await transactionComplete(transaction);
|
|
155
|
+
return manifest ? normalizeManifest(manifest) : null;
|
|
156
|
+
}
|
|
157
|
+
async listRecoverableManifests(workspaceId, ownership) {
|
|
158
|
+
const database = await this.database;
|
|
159
|
+
const transaction = database.transaction(MANIFEST_STORE, "readonly");
|
|
160
|
+
const manifests = await requestResult(
|
|
161
|
+
transaction.objectStore(MANIFEST_STORE).getAll()
|
|
162
|
+
);
|
|
163
|
+
await transactionComplete(transaction);
|
|
164
|
+
return manifests.map(normalizeManifest).filter(
|
|
165
|
+
(manifest) => manifest.workspaceId === workspaceId && manifest.captureState !== "discarded" && manifest.finalizationState !== "handed-off" && manifestAvailableToOwner(manifest, ownership)
|
|
166
|
+
).sort((left, right) => left.createdAt.localeCompare(right.createdAt));
|
|
167
|
+
}
|
|
168
|
+
async claimManifest(recordingId, ownerId, claimedAt, staleBefore) {
|
|
169
|
+
const database = await this.database;
|
|
170
|
+
const transaction = database.transaction(MANIFEST_STORE, "readwrite");
|
|
171
|
+
const manifests = transaction.objectStore(MANIFEST_STORE);
|
|
172
|
+
const stored = await requestResult(
|
|
173
|
+
manifests.get(recordingId)
|
|
174
|
+
);
|
|
175
|
+
if (!stored) {
|
|
176
|
+
transaction.abort();
|
|
177
|
+
throw new VoiceRecordingNotFoundError(recordingId);
|
|
178
|
+
}
|
|
179
|
+
const manifest = normalizeManifest(stored);
|
|
180
|
+
if (manifest.finalizationState === "handed-off" || !manifestAvailableToOwner(manifest, { ownerId, staleBefore })) {
|
|
181
|
+
transaction.abort();
|
|
182
|
+
throw new VoiceRecordingOwnedError(recordingId);
|
|
183
|
+
}
|
|
184
|
+
const updated = {
|
|
185
|
+
...manifest,
|
|
186
|
+
captureState: manifest.captureState === "capturing" ? "stopped" : manifest.captureState,
|
|
187
|
+
ownerId,
|
|
188
|
+
ownerHeartbeatAt: claimedAt,
|
|
189
|
+
updatedAt: claimedAt
|
|
190
|
+
};
|
|
191
|
+
manifests.put(updated);
|
|
192
|
+
await transactionComplete(transaction);
|
|
193
|
+
return updated;
|
|
194
|
+
}
|
|
195
|
+
async listChunks(recordingId) {
|
|
196
|
+
const database = await this.database;
|
|
197
|
+
const transaction = database.transaction(CHUNK_STORE, "readonly");
|
|
198
|
+
const chunks = await requestResult(
|
|
199
|
+
transaction.objectStore(CHUNK_STORE).index(CHUNKS_BY_RECORDING).getAll(recordingId)
|
|
200
|
+
);
|
|
201
|
+
await transactionComplete(transaction);
|
|
202
|
+
return chunks.sort((left, right) => left.chunkNumber - right.chunkNumber);
|
|
203
|
+
}
|
|
204
|
+
async persistChunk(input) {
|
|
205
|
+
const chunk = await prepareVoiceRecordingChunk(input);
|
|
206
|
+
const database = await this.database;
|
|
207
|
+
const transaction = database.transaction([MANIFEST_STORE, CHUNK_STORE], "readwrite");
|
|
208
|
+
const manifests = transaction.objectStore(MANIFEST_STORE);
|
|
209
|
+
const chunks = transaction.objectStore(CHUNK_STORE);
|
|
210
|
+
const [manifest, existingChunk] = await Promise.all([
|
|
211
|
+
requestResult(manifests.get(input.recordingId)),
|
|
212
|
+
requestResult(
|
|
213
|
+
chunks.get([input.recordingId, input.chunkNumber])
|
|
214
|
+
)
|
|
215
|
+
]);
|
|
216
|
+
if (!manifest) {
|
|
217
|
+
transaction.abort();
|
|
218
|
+
throw new VoiceRecordingNotFoundError(input.recordingId);
|
|
219
|
+
}
|
|
220
|
+
const normalizedManifest = normalizeManifest(manifest);
|
|
221
|
+
assertManifestOwnership(normalizedManifest, input.ownerId);
|
|
222
|
+
const result = planVoiceRecordingChunkCommit({
|
|
223
|
+
manifest: normalizedManifest,
|
|
224
|
+
chunk,
|
|
225
|
+
existingChunk: existingChunk ?? null
|
|
226
|
+
});
|
|
227
|
+
if (!result.deduplicated) {
|
|
228
|
+
chunks.add(result.chunk);
|
|
229
|
+
manifests.put(result.manifest);
|
|
230
|
+
}
|
|
231
|
+
await transactionComplete(transaction);
|
|
232
|
+
return result;
|
|
233
|
+
}
|
|
234
|
+
async updateManifest(recordingId, update, updatedAt, ownerId) {
|
|
235
|
+
const database = await this.database;
|
|
236
|
+
const transaction = database.transaction(MANIFEST_STORE, "readwrite");
|
|
237
|
+
const manifests = transaction.objectStore(MANIFEST_STORE);
|
|
238
|
+
const manifest = await requestResult(
|
|
239
|
+
manifests.get(recordingId)
|
|
240
|
+
);
|
|
241
|
+
if (!manifest) {
|
|
242
|
+
transaction.abort();
|
|
243
|
+
throw new VoiceRecordingNotFoundError(recordingId);
|
|
244
|
+
}
|
|
245
|
+
const normalizedManifest = normalizeManifest(manifest);
|
|
246
|
+
assertManifestOwnership(normalizedManifest, ownerId);
|
|
247
|
+
const updated = { ...normalizedManifest, ...update, updatedAt };
|
|
248
|
+
manifests.put(updated);
|
|
249
|
+
await transactionComplete(transaction);
|
|
250
|
+
return updated;
|
|
251
|
+
}
|
|
252
|
+
async discard(recordingId, ownerId) {
|
|
253
|
+
const database = await this.database;
|
|
254
|
+
const transaction = database.transaction([MANIFEST_STORE, CHUNK_STORE], "readwrite");
|
|
255
|
+
const manifests = transaction.objectStore(MANIFEST_STORE);
|
|
256
|
+
const stored = await requestResult(
|
|
257
|
+
manifests.get(recordingId)
|
|
258
|
+
);
|
|
259
|
+
if (!stored) {
|
|
260
|
+
transaction.abort();
|
|
261
|
+
throw new VoiceRecordingNotFoundError(recordingId);
|
|
262
|
+
}
|
|
263
|
+
assertManifestOwnership(normalizeManifest(stored), ownerId);
|
|
264
|
+
const chunks = transaction.objectStore(CHUNK_STORE);
|
|
265
|
+
const chunkKeys = await requestResult(
|
|
266
|
+
chunks.index(CHUNKS_BY_RECORDING).getAllKeys(recordingId)
|
|
267
|
+
);
|
|
268
|
+
for (const key of chunkKeys) chunks.delete(key);
|
|
269
|
+
manifests.delete(recordingId);
|
|
270
|
+
await transactionComplete(transaction);
|
|
271
|
+
}
|
|
272
|
+
async cleanupHandedOffManifests(ownership) {
|
|
273
|
+
const database = await this.database;
|
|
274
|
+
const transaction = database.transaction([MANIFEST_STORE, CHUNK_STORE], "readwrite");
|
|
275
|
+
const manifests = transaction.objectStore(MANIFEST_STORE);
|
|
276
|
+
const chunks = transaction.objectStore(CHUNK_STORE);
|
|
277
|
+
const handedOff = (await requestResult(manifests.getAll())).map(normalizeManifest).filter(
|
|
278
|
+
(manifest) => manifest.finalizationState === "handed-off" && manifestAvailableToOwner(manifest, ownership)
|
|
279
|
+
);
|
|
280
|
+
const chunkKeys = await Promise.all(
|
|
281
|
+
handedOff.map(
|
|
282
|
+
(manifest) => requestResult(
|
|
283
|
+
chunks.index(CHUNKS_BY_RECORDING).getAllKeys(manifest.recordingId)
|
|
284
|
+
)
|
|
285
|
+
)
|
|
286
|
+
);
|
|
287
|
+
handedOff.forEach((manifest, index) => {
|
|
288
|
+
for (const key of chunkKeys[index] ?? []) chunks.delete(key);
|
|
289
|
+
manifests.delete(manifest.recordingId);
|
|
290
|
+
});
|
|
291
|
+
await transactionComplete(transaction);
|
|
292
|
+
return handedOff.length;
|
|
293
|
+
}
|
|
294
|
+
async close() {
|
|
295
|
+
(await this.database).close();
|
|
296
|
+
}
|
|
297
|
+
};
|
|
298
|
+
function normalizeManifest(manifest) {
|
|
299
|
+
return {
|
|
300
|
+
...manifest,
|
|
301
|
+
ownerId: manifest.ownerId ?? null,
|
|
302
|
+
ownerHeartbeatAt: manifest.ownerHeartbeatAt ?? null,
|
|
303
|
+
transcriptText: manifest.transcriptText ?? null
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
function manifestAvailableToOwner(manifest, ownership) {
|
|
307
|
+
if (!manifest.ownerId) return true;
|
|
308
|
+
if (!ownership) return false;
|
|
309
|
+
if (manifest.ownerId === ownership.ownerId) return true;
|
|
310
|
+
return !manifest.ownerHeartbeatAt || manifest.ownerHeartbeatAt <= ownership.staleBefore;
|
|
311
|
+
}
|
|
312
|
+
function assertManifestOwnership(manifest, ownerId) {
|
|
313
|
+
if (manifest.ownerId !== (ownerId ?? null)) {
|
|
314
|
+
throw new VoiceRecordingOwnedError(manifest.recordingId);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
function codecForMimeType(mimeType) {
|
|
318
|
+
const match = /(?:^|;)\s*codecs?\s*=\s*"?([^;"]+)/iu.exec(mimeType);
|
|
319
|
+
return match?.[1]?.trim().toLowerCase() ?? null;
|
|
320
|
+
}
|
|
321
|
+
async function sha256Hex(bytes) {
|
|
322
|
+
const digest = await crypto.subtle.digest("SHA-256", Uint8Array.from(bytes).buffer);
|
|
323
|
+
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
324
|
+
}
|
|
325
|
+
function openDatabase(factory, name) {
|
|
326
|
+
return new Promise((resolve, reject) => {
|
|
327
|
+
const request = factory.open(name, DATABASE_VERSION);
|
|
328
|
+
request.onupgradeneeded = () => {
|
|
329
|
+
const database = request.result;
|
|
330
|
+
if (!database.objectStoreNames.contains(MANIFEST_STORE)) {
|
|
331
|
+
database.createObjectStore(MANIFEST_STORE, { keyPath: "recordingId" });
|
|
332
|
+
}
|
|
333
|
+
if (!database.objectStoreNames.contains(CHUNK_STORE)) {
|
|
334
|
+
const chunks = database.createObjectStore(CHUNK_STORE, {
|
|
335
|
+
keyPath: ["recordingId", "chunkNumber"]
|
|
336
|
+
});
|
|
337
|
+
chunks.createIndex(CHUNKS_BY_RECORDING, "recordingId", { unique: false });
|
|
338
|
+
}
|
|
339
|
+
};
|
|
340
|
+
request.onsuccess = () => {
|
|
341
|
+
const database = request.result;
|
|
342
|
+
database.onversionchange = () => database.close();
|
|
343
|
+
resolve(database);
|
|
344
|
+
};
|
|
345
|
+
request.onerror = () => reject(request.error ?? new Error("Failed to open voice storage."));
|
|
346
|
+
request.onblocked = () => reject(new Error("Voice recording storage upgrade is blocked."));
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
function requestResult(request) {
|
|
350
|
+
return new Promise((resolve, reject) => {
|
|
351
|
+
request.onsuccess = () => resolve(request.result);
|
|
352
|
+
request.onerror = () => reject(request.error ?? new Error("Voice recording storage failed."));
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
function transactionComplete(transaction) {
|
|
356
|
+
return new Promise((resolve, reject) => {
|
|
357
|
+
transaction.oncomplete = () => resolve();
|
|
358
|
+
transaction.onabort = () => reject(transaction.error ?? new Error("Voice storage aborted."));
|
|
359
|
+
transaction.onerror = () => reject(transaction.error ?? new Error("Voice storage failed."));
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
|
|
23
363
|
// src/hooks/use-transcription.ts
|
|
24
364
|
import {
|
|
25
365
|
authorizeTranscriptionAdapter,
|
|
@@ -590,7 +930,192 @@ function transcriptionPolicyRevision(policy, selection) {
|
|
|
590
930
|
|
|
591
931
|
// src/hooks/use-voice-input.ts
|
|
592
932
|
import { useCallback as useCallback2, useEffect as useEffect2, useRef as useRef2, useState as useState2 } from "react";
|
|
933
|
+
|
|
934
|
+
// src/voice-recording-owner.ts
|
|
935
|
+
var VOICE_RECORDING_OWNER_SESSION_KEY = "opengeni.voice-recording-owner.v1";
|
|
936
|
+
var VOICE_RECORDING_OWNER_LOCK_PREFIX = "opengeni.voice-recording-owner:";
|
|
937
|
+
var VOICE_RECORDING_OWNER_CHANNEL = "opengeni.voice-recording-owner.v1";
|
|
938
|
+
var OWNER_LOCK_RELOAD_GRACE_MILLISECONDS = 250;
|
|
939
|
+
var OWNER_LOCK_RELOAD_NAVIGATION_MILLISECONDS = 5e3;
|
|
940
|
+
var OWNER_BROADCAST_PROBE_MILLISECONDS = 100;
|
|
941
|
+
var OWNER_BROADCAST_RELOAD_ATTEMPTS = 10;
|
|
942
|
+
var sharedLeasePromise = null;
|
|
943
|
+
var sharedLeaseConsumers = 0;
|
|
944
|
+
async function acquireDefaultVoiceRecordingOwnerLease() {
|
|
945
|
+
sharedLeaseConsumers += 1;
|
|
946
|
+
sharedLeasePromise ??= createUnderlyingOwnerLease();
|
|
947
|
+
let underlying;
|
|
948
|
+
try {
|
|
949
|
+
underlying = await sharedLeasePromise;
|
|
950
|
+
} catch (error) {
|
|
951
|
+
sharedLeaseConsumers -= 1;
|
|
952
|
+
if (sharedLeaseConsumers === 0) sharedLeasePromise = null;
|
|
953
|
+
throw error;
|
|
954
|
+
}
|
|
955
|
+
let released = false;
|
|
956
|
+
return {
|
|
957
|
+
ownerId: underlying.ownerId,
|
|
958
|
+
release: () => {
|
|
959
|
+
if (released) return;
|
|
960
|
+
released = true;
|
|
961
|
+
sharedLeaseConsumers = Math.max(0, sharedLeaseConsumers - 1);
|
|
962
|
+
if (sharedLeaseConsumers === 0) {
|
|
963
|
+
sharedLeasePromise = null;
|
|
964
|
+
underlying.release();
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
};
|
|
968
|
+
}
|
|
969
|
+
async function createUnderlyingOwnerLease() {
|
|
970
|
+
const candidate = readSessionOwnerId() ?? crypto.randomUUID();
|
|
971
|
+
const reloadNavigation = isReloadNavigation();
|
|
972
|
+
if (hasWebLocks()) {
|
|
973
|
+
const retained = await tryAcquireWebLock(
|
|
974
|
+
candidate,
|
|
975
|
+
reloadNavigation ? OWNER_LOCK_RELOAD_NAVIGATION_MILLISECONDS : OWNER_LOCK_RELOAD_GRACE_MILLISECONDS
|
|
976
|
+
);
|
|
977
|
+
if (retained) {
|
|
978
|
+
writeSessionOwnerId(candidate);
|
|
979
|
+
return retained;
|
|
980
|
+
}
|
|
981
|
+
const rotated = crypto.randomUUID();
|
|
982
|
+
writeSessionOwnerId(rotated);
|
|
983
|
+
const acquired = await tryAcquireWebLock(rotated, OWNER_LOCK_RELOAD_GRACE_MILLISECONDS);
|
|
984
|
+
if (acquired) return acquired;
|
|
985
|
+
}
|
|
986
|
+
const broadcastLease = await tryAcquireBroadcastLease(
|
|
987
|
+
readSessionOwnerId() ?? candidate,
|
|
988
|
+
reloadNavigation ? OWNER_BROADCAST_RELOAD_ATTEMPTS : 0
|
|
989
|
+
);
|
|
990
|
+
if (broadcastLease) return broadcastLease;
|
|
991
|
+
const ownerId = crypto.randomUUID();
|
|
992
|
+
writeSessionOwnerId(ownerId);
|
|
993
|
+
return { ownerId, release: () => void 0 };
|
|
994
|
+
}
|
|
995
|
+
function hasWebLocks() {
|
|
996
|
+
return typeof navigator !== "undefined" && navigator.locks !== null && navigator.locks !== void 0 && typeof navigator.locks.request === "function";
|
|
997
|
+
}
|
|
998
|
+
async function tryAcquireWebLock(ownerId, waitMilliseconds) {
|
|
999
|
+
if (!hasWebLocks()) return null;
|
|
1000
|
+
return await new Promise((resolve) => {
|
|
1001
|
+
const controller = new AbortController();
|
|
1002
|
+
let settled = false;
|
|
1003
|
+
const settle = (lease) => {
|
|
1004
|
+
if (settled) return;
|
|
1005
|
+
settled = true;
|
|
1006
|
+
resolve(lease);
|
|
1007
|
+
};
|
|
1008
|
+
const timeout = setTimeout(() => controller.abort(), waitMilliseconds);
|
|
1009
|
+
void navigator.locks.request(
|
|
1010
|
+
`${VOICE_RECORDING_OWNER_LOCK_PREFIX}${ownerId}`,
|
|
1011
|
+
{ mode: "exclusive", signal: controller.signal },
|
|
1012
|
+
async () => {
|
|
1013
|
+
clearTimeout(timeout);
|
|
1014
|
+
let releaseLock = null;
|
|
1015
|
+
const held = new Promise((release) => {
|
|
1016
|
+
releaseLock = release;
|
|
1017
|
+
});
|
|
1018
|
+
settle({
|
|
1019
|
+
ownerId,
|
|
1020
|
+
release: () => releaseLock?.()
|
|
1021
|
+
});
|
|
1022
|
+
await held;
|
|
1023
|
+
}
|
|
1024
|
+
).catch(() => {
|
|
1025
|
+
clearTimeout(timeout);
|
|
1026
|
+
settle(null);
|
|
1027
|
+
});
|
|
1028
|
+
});
|
|
1029
|
+
}
|
|
1030
|
+
async function tryAcquireBroadcastLease(initialOwnerId, retainCandidateAttempts) {
|
|
1031
|
+
const BroadcastChannelConstructor = typeof window !== "undefined" ? window.BroadcastChannel : void 0;
|
|
1032
|
+
if (!BroadcastChannelConstructor) return null;
|
|
1033
|
+
let ownerId = initialOwnerId;
|
|
1034
|
+
for (let attempt = 0; attempt < retainCandidateAttempts + 3; attempt += 1) {
|
|
1035
|
+
const channel = new BroadcastChannelConstructor(VOICE_RECORDING_OWNER_CHANNEL);
|
|
1036
|
+
const instanceId = crypto.randomUUID();
|
|
1037
|
+
let occupied = false;
|
|
1038
|
+
const onMessage = (event) => {
|
|
1039
|
+
const message = ownerCoordinationMessage(event.data);
|
|
1040
|
+
if (!message || message.ownerId !== ownerId) return;
|
|
1041
|
+
if (message.type === "voice-recording-owner.probe") {
|
|
1042
|
+
if (message.instanceId === instanceId) return;
|
|
1043
|
+
channel.postMessage({
|
|
1044
|
+
type: "voice-recording-owner.occupied",
|
|
1045
|
+
ownerId,
|
|
1046
|
+
targetInstanceId: message.instanceId
|
|
1047
|
+
});
|
|
1048
|
+
return;
|
|
1049
|
+
}
|
|
1050
|
+
if (message.targetInstanceId === instanceId) occupied = true;
|
|
1051
|
+
};
|
|
1052
|
+
channel.addEventListener("message", onMessage);
|
|
1053
|
+
channel.postMessage({
|
|
1054
|
+
type: "voice-recording-owner.probe",
|
|
1055
|
+
ownerId,
|
|
1056
|
+
instanceId
|
|
1057
|
+
});
|
|
1058
|
+
await delay(OWNER_BROADCAST_PROBE_MILLISECONDS);
|
|
1059
|
+
if (!occupied) {
|
|
1060
|
+
writeSessionOwnerId(ownerId);
|
|
1061
|
+
return {
|
|
1062
|
+
ownerId,
|
|
1063
|
+
release: () => {
|
|
1064
|
+
channel.removeEventListener("message", onMessage);
|
|
1065
|
+
channel.close();
|
|
1066
|
+
}
|
|
1067
|
+
};
|
|
1068
|
+
}
|
|
1069
|
+
channel.removeEventListener("message", onMessage);
|
|
1070
|
+
channel.close();
|
|
1071
|
+
if (attempt < retainCandidateAttempts) continue;
|
|
1072
|
+
ownerId = crypto.randomUUID();
|
|
1073
|
+
writeSessionOwnerId(ownerId);
|
|
1074
|
+
}
|
|
1075
|
+
return null;
|
|
1076
|
+
}
|
|
1077
|
+
function isReloadNavigation() {
|
|
1078
|
+
if (typeof performance === "undefined") return false;
|
|
1079
|
+
return performance.getEntriesByType("navigation").some((entry) => {
|
|
1080
|
+
return "type" in entry && entry.type === "reload";
|
|
1081
|
+
});
|
|
1082
|
+
}
|
|
1083
|
+
function ownerCoordinationMessage(value) {
|
|
1084
|
+
if (!value || typeof value !== "object") return null;
|
|
1085
|
+
const candidate = value;
|
|
1086
|
+
if (candidate.type === "voice-recording-owner.probe" && typeof candidate.ownerId === "string" && typeof candidate.instanceId === "string") {
|
|
1087
|
+
return candidate;
|
|
1088
|
+
}
|
|
1089
|
+
if (candidate.type === "voice-recording-owner.occupied" && typeof candidate.ownerId === "string" && typeof candidate.targetInstanceId === "string") {
|
|
1090
|
+
return candidate;
|
|
1091
|
+
}
|
|
1092
|
+
return null;
|
|
1093
|
+
}
|
|
1094
|
+
function readSessionOwnerId() {
|
|
1095
|
+
try {
|
|
1096
|
+
return typeof window === "undefined" ? null : window.sessionStorage.getItem(VOICE_RECORDING_OWNER_SESSION_KEY);
|
|
1097
|
+
} catch {
|
|
1098
|
+
return null;
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
function writeSessionOwnerId(ownerId) {
|
|
1102
|
+
try {
|
|
1103
|
+
window.sessionStorage.setItem(VOICE_RECORDING_OWNER_SESSION_KEY, ownerId);
|
|
1104
|
+
} catch {
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
function delay(milliseconds) {
|
|
1108
|
+
return new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
// src/hooks/use-voice-input.ts
|
|
1112
|
+
var VOICE_RECORDING_TIMESLICE_MILLISECONDS = 5e3;
|
|
1113
|
+
var VOICE_RECORDING_OWNER_HEARTBEAT_MILLISECONDS = 5e3;
|
|
1114
|
+
var VOICE_RECORDING_OWNER_STALE_MILLISECONDS = 3e4;
|
|
1115
|
+
var VOICE_RECORDING_CLIENT_MAX_DURATION_SECONDS = 600;
|
|
593
1116
|
var MIME_PREFERENCES = ["audio/webm;codecs=opus", "audio/mp4", "audio/ogg;codecs=opus"];
|
|
1117
|
+
var createDefaultVoiceRecordingId = () => crypto.randomUUID();
|
|
1118
|
+
var currentDate = () => /* @__PURE__ */ new Date();
|
|
594
1119
|
function useVoiceInput({
|
|
595
1120
|
client,
|
|
596
1121
|
workspaceId,
|
|
@@ -599,120 +1124,738 @@ function useVoiceInput({
|
|
|
599
1124
|
value,
|
|
600
1125
|
setValue,
|
|
601
1126
|
focusInput,
|
|
602
|
-
disabled = false
|
|
1127
|
+
disabled = false,
|
|
1128
|
+
createRecordingStore,
|
|
1129
|
+
createRecordingId = createDefaultVoiceRecordingId,
|
|
1130
|
+
createOwnerId,
|
|
1131
|
+
now = currentDate
|
|
603
1132
|
}) {
|
|
604
1133
|
const [status, setStatus] = useState2("idle");
|
|
605
1134
|
const [error, setError] = useState2(null);
|
|
606
1135
|
const [stream, setStream] = useState2(null);
|
|
1136
|
+
const [recordingId, setRecordingId] = useState2(null);
|
|
1137
|
+
const [durationSeconds, setDurationSeconds] = useState2(0);
|
|
1138
|
+
const [locallySaved, setLocallySaved] = useState2(false);
|
|
1139
|
+
const [storageAvailable, setStorageAvailable] = useState2(
|
|
1140
|
+
() => Boolean(createRecordingStore) || typeof globalThis.indexedDB !== "undefined"
|
|
1141
|
+
);
|
|
1142
|
+
const createRecordingStoreRef = useRef2(createRecordingStore);
|
|
1143
|
+
createRecordingStoreRef.current = createRecordingStore;
|
|
1144
|
+
const nowRef = useRef2(now);
|
|
1145
|
+
nowRef.current = now;
|
|
1146
|
+
const readNow = useCallback2(() => nowRef.current(), []);
|
|
607
1147
|
const generationRef = useRef2(0);
|
|
1148
|
+
const workspaceIdRef = useRef2(workspaceId);
|
|
1149
|
+
const ownerIdRef = useRef2(null);
|
|
1150
|
+
const ownerLeaseRef = useRef2(null);
|
|
1151
|
+
const ownerIdPromiseRef = useRef2(null);
|
|
1152
|
+
const createOwnerIdRef = useRef2(createOwnerId);
|
|
608
1153
|
const recorderRef = useRef2(null);
|
|
609
1154
|
const streamRef = useRef2(null);
|
|
610
1155
|
const timerRef = useRef2(null);
|
|
1156
|
+
const ownerHeartbeatTimerRef = useRef2(null);
|
|
611
1157
|
const controllerRef = useRef2(null);
|
|
612
1158
|
const valueRef = useRef2(value);
|
|
1159
|
+
const storeRef = useRef2(null);
|
|
1160
|
+
const storePromiseRef = useRef2(null);
|
|
1161
|
+
const ownsStoreRef = useRef2(false);
|
|
1162
|
+
const manifestRef = useRef2(null);
|
|
1163
|
+
const persistenceQueueRef = useRef2(Promise.resolve());
|
|
1164
|
+
const persistenceErrorRef = useRef2(null);
|
|
1165
|
+
const captureLimitErrorRef = useRef2(null);
|
|
1166
|
+
const captureSettledRef = useRef2(Promise.resolve());
|
|
1167
|
+
const resolveCaptureSettledRef = useRef2(null);
|
|
1168
|
+
const statusRef = useRef2(status);
|
|
613
1169
|
valueRef.current = value;
|
|
614
|
-
|
|
1170
|
+
statusRef.current = status;
|
|
1171
|
+
workspaceIdRef.current = workspaceId;
|
|
1172
|
+
const ensureOwnerId = useCallback2(async () => {
|
|
1173
|
+
if (ownerIdRef.current) return ownerIdRef.current;
|
|
1174
|
+
ownerIdPromiseRef.current ??= (async () => {
|
|
1175
|
+
const injectedOwnerId = createOwnerIdRef.current?.();
|
|
1176
|
+
if (injectedOwnerId) {
|
|
1177
|
+
ownerIdRef.current = injectedOwnerId;
|
|
1178
|
+
return injectedOwnerId;
|
|
1179
|
+
}
|
|
1180
|
+
const lease = await acquireDefaultVoiceRecordingOwnerLease();
|
|
1181
|
+
ownerLeaseRef.current = lease;
|
|
1182
|
+
ownerIdRef.current = lease.ownerId;
|
|
1183
|
+
return lease.ownerId;
|
|
1184
|
+
})();
|
|
1185
|
+
return await ownerIdPromiseRef.current;
|
|
1186
|
+
}, []);
|
|
1187
|
+
const stopOwnerHeartbeat = useCallback2(() => {
|
|
1188
|
+
if (ownerHeartbeatTimerRef.current) clearInterval(ownerHeartbeatTimerRef.current);
|
|
1189
|
+
ownerHeartbeatTimerRef.current = null;
|
|
1190
|
+
}, []);
|
|
1191
|
+
const clearCaptureRuntime = useCallback2((expectedStream) => {
|
|
1192
|
+
const captureStream = expectedStream ?? streamRef.current;
|
|
1193
|
+
captureStream?.getTracks().forEach((track) => track.stop());
|
|
1194
|
+
if (expectedStream && streamRef.current !== expectedStream) return;
|
|
615
1195
|
if (timerRef.current) clearTimeout(timerRef.current);
|
|
616
1196
|
timerRef.current = null;
|
|
617
|
-
streamRef.current?.getTracks().forEach((track) => track.stop());
|
|
618
1197
|
streamRef.current = null;
|
|
619
1198
|
recorderRef.current = null;
|
|
620
1199
|
setStream(null);
|
|
621
1200
|
}, []);
|
|
622
|
-
const
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
1201
|
+
const clearVisibleRecording = useCallback2(() => {
|
|
1202
|
+
stopOwnerHeartbeat();
|
|
1203
|
+
manifestRef.current = null;
|
|
1204
|
+
setRecordingId(null);
|
|
1205
|
+
setDurationSeconds(0);
|
|
1206
|
+
setLocallySaved(false);
|
|
1207
|
+
}, [stopOwnerHeartbeat]);
|
|
1208
|
+
const ensureStore = useCallback2(async () => {
|
|
1209
|
+
if (storeRef.current) return storeRef.current;
|
|
1210
|
+
if (!storePromiseRef.current) {
|
|
1211
|
+
storePromiseRef.current = Promise.resolve().then(() => {
|
|
1212
|
+
const factory = createRecordingStoreRef.current;
|
|
1213
|
+
const store = factory?.() ?? new IndexedDbVoiceRecordingStore();
|
|
1214
|
+
ownsStoreRef.current = factory === void 0;
|
|
1215
|
+
storeRef.current = store;
|
|
1216
|
+
setStorageAvailable(true);
|
|
1217
|
+
return store;
|
|
1218
|
+
});
|
|
1219
|
+
}
|
|
1220
|
+
try {
|
|
1221
|
+
return await storePromiseRef.current;
|
|
1222
|
+
} catch (reason) {
|
|
1223
|
+
storePromiseRef.current = null;
|
|
1224
|
+
if (reason instanceof VoiceRecordingStorageUnavailableError) setStorageAvailable(false);
|
|
1225
|
+
throw reason;
|
|
1226
|
+
}
|
|
1227
|
+
}, []);
|
|
1228
|
+
const rememberManifest = useCallback2((manifest) => {
|
|
1229
|
+
manifestRef.current = manifest;
|
|
1230
|
+
setRecordingId(manifest.recordingId);
|
|
1231
|
+
setDurationSeconds(manifest.totalDurationMilliseconds / 1e3);
|
|
1232
|
+
setLocallySaved(manifest.chunkCount > 0);
|
|
1233
|
+
}, []);
|
|
1234
|
+
const beginOwnerHeartbeat = useCallback2(
|
|
1235
|
+
(manifest) => {
|
|
1236
|
+
stopOwnerHeartbeat();
|
|
1237
|
+
ownerHeartbeatTimerRef.current = setInterval(() => {
|
|
1238
|
+
if (manifestRef.current?.recordingId !== manifest.recordingId || manifestRef.current.ownerId !== ownerIdRef.current) {
|
|
1239
|
+
stopOwnerHeartbeat();
|
|
1240
|
+
return;
|
|
1241
|
+
}
|
|
1242
|
+
const updatedAt = readNow().toISOString();
|
|
1243
|
+
void ensureStore().then(
|
|
1244
|
+
(store) => store.updateManifest(
|
|
1245
|
+
manifest.recordingId,
|
|
1246
|
+
{ ownerHeartbeatAt: updatedAt },
|
|
1247
|
+
updatedAt,
|
|
1248
|
+
ownerIdRef.current ?? void 0
|
|
1249
|
+
)
|
|
1250
|
+
).then((updated) => {
|
|
1251
|
+
if (manifestRef.current?.recordingId === updated.recordingId) {
|
|
1252
|
+
manifestRef.current = updated;
|
|
1253
|
+
}
|
|
1254
|
+
}).catch(() => void 0);
|
|
1255
|
+
}, VOICE_RECORDING_OWNER_HEARTBEAT_MILLISECONDS);
|
|
1256
|
+
},
|
|
1257
|
+
[ensureStore, readNow, stopOwnerHeartbeat]
|
|
1258
|
+
);
|
|
1259
|
+
const preserveForRetry = useCallback2(
|
|
1260
|
+
async (manifest, code, generation) => {
|
|
1261
|
+
if (generation !== generationRef.current) return;
|
|
1262
|
+
rememberManifest(manifest);
|
|
1263
|
+
const transcriptReady = manifest.finalizationState === "transcript-ready" && manifest.transcriptText !== null;
|
|
1264
|
+
setStatus(transcriptReady ? "transcript-ready" : code ? "error" : "recovered");
|
|
1265
|
+
setError(transcriptReady ? "handoff_uncertain" : code);
|
|
1266
|
+
focusInput();
|
|
1267
|
+
},
|
|
1268
|
+
[focusInput, rememberManifest]
|
|
1269
|
+
);
|
|
1270
|
+
const updateManifestBestEffort = useCallback2(
|
|
1271
|
+
async (manifest, update) => {
|
|
1272
|
+
try {
|
|
1273
|
+
return await (await ensureStore()).updateManifest(
|
|
1274
|
+
manifest.recordingId,
|
|
1275
|
+
update,
|
|
1276
|
+
readNow().toISOString(),
|
|
1277
|
+
ownerIdRef.current ?? void 0
|
|
1278
|
+
);
|
|
1279
|
+
} catch {
|
|
1280
|
+
return manifest;
|
|
1281
|
+
}
|
|
1282
|
+
},
|
|
1283
|
+
[ensureStore, readNow]
|
|
1284
|
+
);
|
|
1285
|
+
const loadNextRecoverable = useCallback2(
|
|
1286
|
+
async (generation) => {
|
|
1287
|
+
const active = () => generation === generationRef.current && workspaceIdRef.current === workspaceId;
|
|
1288
|
+
const [store, ownerId] = await Promise.all([ensureStore(), ensureOwnerId()]);
|
|
1289
|
+
if (!active()) return;
|
|
1290
|
+
const staleBefore = new Date(
|
|
1291
|
+
readNow().getTime() - VOICE_RECORDING_OWNER_STALE_MILLISECONDS
|
|
1292
|
+
).toISOString();
|
|
1293
|
+
await store.cleanupHandedOffManifests({ ownerId, staleBefore }).catch(() => void 0);
|
|
1294
|
+
if (!active()) return;
|
|
1295
|
+
const manifests = await store.listRecoverableManifests(workspaceId, {
|
|
1296
|
+
ownerId,
|
|
1297
|
+
staleBefore
|
|
1298
|
+
});
|
|
1299
|
+
if (!active()) return;
|
|
1300
|
+
for (const candidate of manifests) {
|
|
1301
|
+
try {
|
|
1302
|
+
const claimedAt = readNow().toISOString();
|
|
1303
|
+
const claimed = await store.claimManifest(
|
|
1304
|
+
candidate.recordingId,
|
|
1305
|
+
ownerId,
|
|
1306
|
+
claimedAt,
|
|
1307
|
+
staleBefore
|
|
1308
|
+
);
|
|
1309
|
+
if (!active()) {
|
|
1310
|
+
await store.updateManifest(
|
|
1311
|
+
claimed.recordingId,
|
|
1312
|
+
{ ownerId: null, ownerHeartbeatAt: null },
|
|
1313
|
+
readNow().toISOString(),
|
|
1314
|
+
ownerId
|
|
1315
|
+
).catch(() => void 0);
|
|
1316
|
+
return;
|
|
1317
|
+
}
|
|
1318
|
+
rememberManifest(claimed);
|
|
1319
|
+
beginOwnerHeartbeat(claimed);
|
|
1320
|
+
setStatus(
|
|
1321
|
+
claimed.finalizationState === "transcript-ready" && claimed.transcriptText !== null ? "transcript-ready" : "recovered"
|
|
1322
|
+
);
|
|
1323
|
+
setError(null);
|
|
1324
|
+
return;
|
|
1325
|
+
} catch (reason) {
|
|
1326
|
+
if (reason instanceof VoiceRecordingOwnedError) continue;
|
|
1327
|
+
throw reason;
|
|
1328
|
+
}
|
|
1329
|
+
}
|
|
1330
|
+
if (active() && !manifestRef.current) {
|
|
1331
|
+
setStatus("idle");
|
|
1332
|
+
setError(null);
|
|
1333
|
+
}
|
|
1334
|
+
},
|
|
1335
|
+
[beginOwnerHeartbeat, ensureOwnerId, ensureStore, readNow, rememberManifest, workspaceId]
|
|
1336
|
+
);
|
|
1337
|
+
const finalizePersistedRecording = useCallback2(
|
|
1338
|
+
async (generation) => {
|
|
1339
|
+
const manifest = manifestRef.current;
|
|
1340
|
+
const maxSizeBytes = capability?.maxSizeBytes;
|
|
1341
|
+
if (!manifest || !client || !maxSizeBytes || manifest.workspaceId !== workspaceId) return;
|
|
1342
|
+
const controller = new AbortController();
|
|
1343
|
+
controllerRef.current?.abort();
|
|
1344
|
+
controllerRef.current = controller;
|
|
1345
|
+
const active = () => generation === generationRef.current && workspaceIdRef.current === workspaceId && !controller.signal.aborted && manifestRef.current?.recordingId === manifest.recordingId && manifestRef.current.workspaceId === workspaceId;
|
|
1346
|
+
let transcribing = manifest;
|
|
1347
|
+
try {
|
|
1348
|
+
const store = await ensureStore();
|
|
1349
|
+
if (!active()) return;
|
|
1350
|
+
transcribing = await store.updateManifest(
|
|
1351
|
+
manifest.recordingId,
|
|
1352
|
+
{
|
|
1353
|
+
captureState: "stopped",
|
|
1354
|
+
uploadState: "syncing",
|
|
1355
|
+
transcriptionState: "transcribing",
|
|
1356
|
+
ownerHeartbeatAt: readNow().toISOString()
|
|
1357
|
+
},
|
|
1358
|
+
readNow().toISOString(),
|
|
1359
|
+
ownerIdRef.current ?? void 0
|
|
1360
|
+
);
|
|
1361
|
+
if (!active()) return;
|
|
1362
|
+
rememberManifest(transcribing);
|
|
1363
|
+
setStatus("transcribing");
|
|
1364
|
+
setError(null);
|
|
1365
|
+
if (transcribing.totalBytes > maxSizeBytes) {
|
|
1366
|
+
throw { code: "too_large" };
|
|
1367
|
+
}
|
|
1368
|
+
const chunks = await store.listChunks(transcribing.recordingId);
|
|
1369
|
+
if (!active()) return;
|
|
1370
|
+
if (chunks.length === 0) {
|
|
1371
|
+
const retained = await store.updateManifest(
|
|
1372
|
+
transcribing.recordingId,
|
|
1373
|
+
{ uploadState: "retrying", transcriptionState: "retrying" },
|
|
1374
|
+
readNow().toISOString(),
|
|
1375
|
+
ownerIdRef.current ?? void 0
|
|
1376
|
+
);
|
|
1377
|
+
if (!active()) return;
|
|
1378
|
+
await preserveForRetry(retained, "invalid_audio", generation);
|
|
1379
|
+
return;
|
|
1380
|
+
}
|
|
1381
|
+
const audio = new Blob(
|
|
1382
|
+
chunks.sort((left, right) => left.chunkNumber - right.chunkNumber).map((chunk) => chunk.audio),
|
|
1383
|
+
{ type: transcribing.mimeType }
|
|
1384
|
+
);
|
|
1385
|
+
if (!active()) return;
|
|
1386
|
+
if (audio.size > maxSizeBytes) throw { code: "too_large" };
|
|
1387
|
+
const response = await client.transcribeAudio(workspaceId, {
|
|
1388
|
+
audio,
|
|
1389
|
+
mimeType: audio.type,
|
|
1390
|
+
durationSeconds: transcribing.totalDurationMilliseconds / 1e3,
|
|
1391
|
+
signal: controller.signal
|
|
1392
|
+
});
|
|
1393
|
+
if (!active()) return;
|
|
1394
|
+
const ready = await store.updateManifest(
|
|
1395
|
+
transcribing.recordingId,
|
|
1396
|
+
{
|
|
1397
|
+
uploadState: "complete",
|
|
1398
|
+
transcriptionState: "complete",
|
|
1399
|
+
finalizationState: "transcript-ready",
|
|
1400
|
+
transcriptText: response.text,
|
|
1401
|
+
ownerHeartbeatAt: readNow().toISOString()
|
|
1402
|
+
},
|
|
1403
|
+
readNow().toISOString(),
|
|
1404
|
+
ownerIdRef.current ?? void 0
|
|
1405
|
+
);
|
|
1406
|
+
if (!active()) return;
|
|
1407
|
+
rememberManifest(ready);
|
|
1408
|
+
const next = appendFinalTranscript(valueRef.current, response.text);
|
|
1409
|
+
if (next !== valueRef.current) {
|
|
1410
|
+
valueRef.current = next;
|
|
1411
|
+
setValue(next);
|
|
1412
|
+
}
|
|
1413
|
+
let handedOff;
|
|
1414
|
+
try {
|
|
1415
|
+
handedOff = await store.updateManifest(
|
|
1416
|
+
ready.recordingId,
|
|
1417
|
+
{ finalizationState: "handed-off" },
|
|
1418
|
+
readNow().toISOString(),
|
|
1419
|
+
ownerIdRef.current ?? void 0
|
|
1420
|
+
);
|
|
1421
|
+
} catch {
|
|
1422
|
+
if (!active()) return;
|
|
1423
|
+
rememberManifest(ready);
|
|
1424
|
+
setStatus("transcript-ready");
|
|
1425
|
+
setError("handoff_uncertain");
|
|
1426
|
+
focusInput();
|
|
1427
|
+
return;
|
|
1428
|
+
}
|
|
1429
|
+
if (!active()) return;
|
|
1430
|
+
rememberManifest(handedOff);
|
|
1431
|
+
await store.discard(handedOff.recordingId, ownerIdRef.current ?? void 0).catch(() => void 0);
|
|
1432
|
+
if (!active()) return;
|
|
1433
|
+
clearVisibleRecording();
|
|
1434
|
+
setStatus("idle");
|
|
1435
|
+
setError(null);
|
|
1436
|
+
focusInput();
|
|
1437
|
+
await loadNextRecoverable(generation);
|
|
1438
|
+
} catch (reason) {
|
|
1439
|
+
if (!active()) return;
|
|
1440
|
+
const retained = await updateManifestBestEffort(transcribing, {
|
|
1441
|
+
uploadState: "retrying",
|
|
1442
|
+
transcriptionState: "retrying"
|
|
1443
|
+
});
|
|
1444
|
+
if (!active()) return;
|
|
1445
|
+
await preserveForRetry(
|
|
1446
|
+
retained,
|
|
1447
|
+
controller.signal.aborted ? null : errorCode(reason),
|
|
1448
|
+
generation
|
|
1449
|
+
);
|
|
1450
|
+
} finally {
|
|
1451
|
+
if (controllerRef.current === controller) controllerRef.current = null;
|
|
1452
|
+
}
|
|
1453
|
+
},
|
|
1454
|
+
[
|
|
1455
|
+
capability?.maxSizeBytes,
|
|
1456
|
+
clearVisibleRecording,
|
|
1457
|
+
client,
|
|
1458
|
+
ensureStore,
|
|
1459
|
+
focusInput,
|
|
1460
|
+
loadNextRecoverable,
|
|
1461
|
+
readNow,
|
|
1462
|
+
preserveForRetry,
|
|
1463
|
+
rememberManifest,
|
|
1464
|
+
setValue,
|
|
1465
|
+
updateManifestBestEffort,
|
|
1466
|
+
workspaceId
|
|
1467
|
+
]
|
|
1468
|
+
);
|
|
1469
|
+
const stop = useCallback2(() => {
|
|
634
1470
|
const recorder = recorderRef.current;
|
|
635
1471
|
if (!recorder || recorder.state === "inactive") return;
|
|
636
1472
|
if (timerRef.current) clearTimeout(timerRef.current);
|
|
637
1473
|
timerRef.current = null;
|
|
638
|
-
setStatus("
|
|
1474
|
+
setStatus("saving");
|
|
639
1475
|
recorder.stop();
|
|
640
|
-
};
|
|
641
|
-
const start = async () => {
|
|
642
|
-
if (disabled || !client || !enabled || !capability?.available || status === "requesting-permission" || status === "recording" || status === "transcribing" || !navigator.mediaDevices?.getUserMedia || typeof MediaRecorder === "undefined") {
|
|
1476
|
+
}, []);
|
|
1477
|
+
const start = useCallback2(async () => {
|
|
1478
|
+
if (disabled || !client || !enabled || !capability?.available || manifestRef.current !== null || status === "requesting-permission" || status === "recording" || status === "saving" || status === "transcribing" || !navigator.mediaDevices?.getUserMedia || typeof MediaRecorder === "undefined") {
|
|
1479
|
+
return false;
|
|
1480
|
+
}
|
|
1481
|
+
const prerequisiteGeneration = generationRef.current;
|
|
1482
|
+
let store;
|
|
1483
|
+
let ownerId;
|
|
1484
|
+
try {
|
|
1485
|
+
[store, ownerId] = await Promise.all([ensureStore(), ensureOwnerId()]);
|
|
1486
|
+
} catch (reason) {
|
|
1487
|
+
setStatus("error");
|
|
1488
|
+
setError(
|
|
1489
|
+
reason instanceof VoiceRecordingStorageUnavailableError ? "storage_unavailable" : errorCode(reason)
|
|
1490
|
+
);
|
|
1491
|
+
return false;
|
|
1492
|
+
}
|
|
1493
|
+
if (prerequisiteGeneration !== generationRef.current || workspaceIdRef.current !== workspaceId || ownerIdRef.current !== ownerId || manifestRef.current !== null || statusRef.current === "requesting-permission" || statusRef.current === "recording" || statusRef.current === "saving" || statusRef.current === "transcribing") {
|
|
643
1494
|
return false;
|
|
644
1495
|
}
|
|
645
|
-
const voiceClient = client;
|
|
646
1496
|
const generation = ++generationRef.current;
|
|
1497
|
+
const startAttemptIsCurrent = () => generation === generationRef.current && workspaceIdRef.current === workspaceId && ownerIdRef.current === ownerId;
|
|
1498
|
+
let attemptStream = null;
|
|
1499
|
+
let attemptRecorder = null;
|
|
1500
|
+
let attemptPersistenceQueue = Promise.resolve();
|
|
1501
|
+
let attemptPersistenceError = null;
|
|
1502
|
+
let attemptCaptureLimitError = null;
|
|
1503
|
+
const attemptCaptureSettlement = { resolve: null };
|
|
1504
|
+
const attemptManifest = { current: null };
|
|
1505
|
+
let attemptNextChunkNumber = 0;
|
|
1506
|
+
let attemptLastChunkEndMilliseconds = 0;
|
|
1507
|
+
let attemptRecordingStartedAt = 0;
|
|
1508
|
+
const attemptOwnsSharedCapture = () => startAttemptIsCurrent() && attemptStream !== null && streamRef.current === attemptStream && attemptRecorder !== null && recorderRef.current === attemptRecorder && attemptManifest.current !== null && manifestRef.current?.recordingId === attemptManifest.current.recordingId && manifestRef.current.workspaceId === workspaceId && manifestRef.current.ownerId === ownerId;
|
|
647
1509
|
setStatus("requesting-permission");
|
|
648
1510
|
setError(null);
|
|
1511
|
+
let acquiredStream = null;
|
|
1512
|
+
let recorderStarted = false;
|
|
649
1513
|
try {
|
|
650
1514
|
const mediaStream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
|
651
|
-
|
|
1515
|
+
acquiredStream = mediaStream;
|
|
1516
|
+
attemptStream = mediaStream;
|
|
1517
|
+
if (!startAttemptIsCurrent()) {
|
|
652
1518
|
mediaStream.getTracks().forEach((track) => track.stop());
|
|
653
1519
|
return false;
|
|
654
1520
|
}
|
|
1521
|
+
streamRef.current = mediaStream;
|
|
1522
|
+
setStream(mediaStream);
|
|
655
1523
|
const mimeType = chooseMimeType(capability.acceptedMimeTypes);
|
|
656
1524
|
const recorder = new MediaRecorder(mediaStream, mimeType ? { mimeType } : void 0);
|
|
657
|
-
|
|
658
|
-
const
|
|
659
|
-
|
|
1525
|
+
attemptRecorder = recorder;
|
|
1526
|
+
const createdAt = readNow();
|
|
1527
|
+
const manifest = createVoiceRecordingManifest({
|
|
1528
|
+
recordingId: createRecordingId(),
|
|
1529
|
+
workspaceId,
|
|
1530
|
+
mimeType: recorder.mimeType || mimeType || "audio/webm",
|
|
1531
|
+
createdAt: createdAt.toISOString(),
|
|
1532
|
+
ownerId
|
|
1533
|
+
});
|
|
1534
|
+
attemptManifest.current = manifest;
|
|
1535
|
+
await store.createManifest(manifest);
|
|
1536
|
+
if (!startAttemptIsCurrent()) {
|
|
1537
|
+
await store.discard(manifest.recordingId, ownerId);
|
|
1538
|
+
clearCaptureRuntime(mediaStream);
|
|
1539
|
+
return false;
|
|
1540
|
+
}
|
|
1541
|
+
rememberManifest(manifest);
|
|
1542
|
+
beginOwnerHeartbeat(manifest);
|
|
1543
|
+
persistenceQueueRef.current = attemptPersistenceQueue;
|
|
1544
|
+
persistenceErrorRef.current = null;
|
|
1545
|
+
captureLimitErrorRef.current = null;
|
|
660
1546
|
recorderRef.current = recorder;
|
|
661
|
-
setStream(mediaStream);
|
|
662
1547
|
recorder.ondataavailable = (event) => {
|
|
663
|
-
if (event.data.size
|
|
664
|
-
};
|
|
665
|
-
recorder.onstop = () => {
|
|
666
|
-
clearRuntime();
|
|
667
|
-
if (generation !== generationRef.current) return;
|
|
668
|
-
const audio = new Blob(chunks, { type: recorder.mimeType || mimeType || "audio/webm" });
|
|
669
|
-
if (audio.size === 0) {
|
|
670
|
-
setStatus("error");
|
|
671
|
-
setError("invalid_audio");
|
|
1548
|
+
if (event.data.size === 0 || attemptPersistenceError || attemptCaptureLimitError) {
|
|
672
1549
|
return;
|
|
673
1550
|
}
|
|
674
|
-
const
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
1551
|
+
const chunkNumber = attemptNextChunkNumber++;
|
|
1552
|
+
const elapsed = Math.max(0, readNow().getTime() - attemptRecordingStartedAt);
|
|
1553
|
+
const eventTimecode = Number.isFinite(event.timecode) ? Math.max(0, event.timecode) : 0;
|
|
1554
|
+
const endMilliseconds = Math.max(attemptLastChunkEndMilliseconds, eventTimecode, elapsed);
|
|
1555
|
+
const startMilliseconds = attemptLastChunkEndMilliseconds;
|
|
1556
|
+
const durationMilliseconds = Math.max(0, endMilliseconds - startMilliseconds);
|
|
1557
|
+
attemptLastChunkEndMilliseconds = endMilliseconds;
|
|
1558
|
+
attemptPersistenceQueue = attemptPersistenceQueue.then(async () => {
|
|
1559
|
+
const result = await store.persistChunk({
|
|
1560
|
+
recordingId: manifest.recordingId,
|
|
1561
|
+
ownerId,
|
|
1562
|
+
chunkNumber,
|
|
1563
|
+
capturedAt: readNow().toISOString(),
|
|
1564
|
+
startMilliseconds,
|
|
1565
|
+
durationMilliseconds,
|
|
1566
|
+
mimeType: manifest.mimeType,
|
|
1567
|
+
audio: event.data
|
|
1568
|
+
});
|
|
1569
|
+
if (!startAttemptIsCurrent()) return;
|
|
1570
|
+
rememberManifest(result.manifest);
|
|
1571
|
+
if (result.manifest.totalBytes > capability.maxSizeBytes) {
|
|
1572
|
+
attemptCaptureLimitError = "too_large";
|
|
1573
|
+
if (attemptOwnsSharedCapture()) captureLimitErrorRef.current = "too_large";
|
|
1574
|
+
if (recorder.state !== "inactive") recorder.stop();
|
|
687
1575
|
}
|
|
688
|
-
setStatus("idle");
|
|
689
|
-
focusInput();
|
|
690
1576
|
}).catch((reason) => {
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
})
|
|
695
|
-
|
|
1577
|
+
attemptPersistenceError = reason;
|
|
1578
|
+
if (attemptOwnsSharedCapture()) persistenceErrorRef.current = reason;
|
|
1579
|
+
if (recorder.state !== "inactive") recorder.stop();
|
|
1580
|
+
});
|
|
1581
|
+
if (attemptOwnsSharedCapture()) {
|
|
1582
|
+
persistenceQueueRef.current = attemptPersistenceQueue;
|
|
1583
|
+
}
|
|
1584
|
+
};
|
|
1585
|
+
recorder.onstop = () => {
|
|
1586
|
+
if (attemptOwnsSharedCapture()) {
|
|
1587
|
+
clearCaptureRuntime(mediaStream);
|
|
1588
|
+
} else {
|
|
1589
|
+
mediaStream.getTracks().forEach((track) => track.stop());
|
|
1590
|
+
}
|
|
1591
|
+
void attemptPersistenceQueue.catch((reason) => {
|
|
1592
|
+
attemptPersistenceError = reason;
|
|
1593
|
+
}).then(async () => {
|
|
1594
|
+
const resolveSettled = attemptCaptureSettlement.resolve;
|
|
1595
|
+
attemptCaptureSettlement.resolve = null;
|
|
1596
|
+
resolveSettled?.();
|
|
1597
|
+
if (resolveCaptureSettledRef.current === resolveSettled) {
|
|
1598
|
+
resolveCaptureSettledRef.current = null;
|
|
1599
|
+
}
|
|
1600
|
+
const stoppedCaptureIsCurrent = () => generation === generationRef.current && workspaceIdRef.current === workspaceId && ownerIdRef.current === ownerId && manifestRef.current?.recordingId === manifest.recordingId && manifestRef.current.workspaceId === workspaceId && manifestRef.current.ownerId === ownerId;
|
|
1601
|
+
if (!stoppedCaptureIsCurrent()) return;
|
|
1602
|
+
const current = manifestRef.current;
|
|
1603
|
+
if (!current) return;
|
|
1604
|
+
const stopped = await updateManifestBestEffort(current, { captureState: "stopped" });
|
|
1605
|
+
if (!stoppedCaptureIsCurrent() || stopped.recordingId !== manifest.recordingId || stopped.workspaceId !== workspaceId || stopped.ownerId !== ownerId) {
|
|
1606
|
+
return;
|
|
1607
|
+
}
|
|
1608
|
+
rememberManifest(stopped);
|
|
1609
|
+
if (attemptPersistenceError) {
|
|
1610
|
+
await preserveForRetry(stopped, "storage_unavailable", generation);
|
|
1611
|
+
return;
|
|
1612
|
+
}
|
|
1613
|
+
if (attemptCaptureLimitError) {
|
|
1614
|
+
await preserveForRetry(stopped, attemptCaptureLimitError, generation);
|
|
1615
|
+
return;
|
|
1616
|
+
}
|
|
1617
|
+
await finalizePersistedRecording(generation);
|
|
696
1618
|
});
|
|
697
1619
|
};
|
|
698
|
-
|
|
1620
|
+
captureSettledRef.current = new Promise((resolve) => {
|
|
1621
|
+
attemptCaptureSettlement.resolve = resolve;
|
|
1622
|
+
resolveCaptureSettledRef.current = resolve;
|
|
1623
|
+
});
|
|
1624
|
+
attemptRecordingStartedAt = readNow().getTime();
|
|
1625
|
+
recorder.start(VOICE_RECORDING_TIMESLICE_MILLISECONDS);
|
|
1626
|
+
recorderStarted = true;
|
|
699
1627
|
setStatus("recording");
|
|
700
1628
|
timerRef.current = setTimeout(
|
|
701
|
-
|
|
702
|
-
Math.min(capability.maxDurationSeconds,
|
|
1629
|
+
stop,
|
|
1630
|
+
Math.min(capability.maxDurationSeconds, VOICE_RECORDING_CLIENT_MAX_DURATION_SECONDS) * 1e3
|
|
703
1631
|
);
|
|
704
1632
|
return true;
|
|
705
1633
|
} catch (reason) {
|
|
1634
|
+
const resolveSettled = attemptCaptureSettlement.resolve;
|
|
1635
|
+
attemptCaptureSettlement.resolve = null;
|
|
1636
|
+
resolveSettled?.();
|
|
1637
|
+
if (resolveCaptureSettledRef.current === resolveSettled) {
|
|
1638
|
+
resolveCaptureSettledRef.current = null;
|
|
1639
|
+
}
|
|
1640
|
+
if (acquiredStream) {
|
|
1641
|
+
if (startAttemptIsCurrent() && streamRef.current === acquiredStream) {
|
|
1642
|
+
clearCaptureRuntime(acquiredStream);
|
|
1643
|
+
} else {
|
|
1644
|
+
acquiredStream.getTracks().forEach((track) => track.stop());
|
|
1645
|
+
}
|
|
1646
|
+
}
|
|
1647
|
+
const failedManifest = attemptManifest.current;
|
|
1648
|
+
if (failedManifest && !recorderStarted) {
|
|
1649
|
+
const discarded = await store.discard(failedManifest.recordingId, ownerId).then(() => true).catch(() => false);
|
|
1650
|
+
if (!discarded) {
|
|
1651
|
+
await store.updateManifest(
|
|
1652
|
+
failedManifest.recordingId,
|
|
1653
|
+
{ captureState: "stopped", ownerId: null, ownerHeartbeatAt: null },
|
|
1654
|
+
readNow().toISOString(),
|
|
1655
|
+
ownerId
|
|
1656
|
+
).catch(() => void 0);
|
|
1657
|
+
}
|
|
1658
|
+
}
|
|
706
1659
|
if (generation !== generationRef.current) return false;
|
|
707
|
-
|
|
1660
|
+
const visibleManifest = manifestRef.current;
|
|
1661
|
+
if (failedManifest && visibleManifest?.recordingId === failedManifest.recordingId) {
|
|
1662
|
+
clearVisibleRecording();
|
|
1663
|
+
}
|
|
708
1664
|
setStatus("error");
|
|
709
|
-
setError(
|
|
1665
|
+
setError(
|
|
1666
|
+
reason instanceof VoiceRecordingStorageUnavailableError ? "storage_unavailable" : errorCode(reason)
|
|
1667
|
+
);
|
|
710
1668
|
return false;
|
|
711
1669
|
}
|
|
712
|
-
}
|
|
1670
|
+
}, [
|
|
1671
|
+
capability,
|
|
1672
|
+
beginOwnerHeartbeat,
|
|
1673
|
+
clearCaptureRuntime,
|
|
1674
|
+
clearVisibleRecording,
|
|
1675
|
+
client,
|
|
1676
|
+
createRecordingId,
|
|
1677
|
+
disabled,
|
|
1678
|
+
enabled,
|
|
1679
|
+
ensureOwnerId,
|
|
1680
|
+
ensureStore,
|
|
1681
|
+
finalizePersistedRecording,
|
|
1682
|
+
readNow,
|
|
1683
|
+
preserveForRetry,
|
|
1684
|
+
rememberManifest,
|
|
1685
|
+
status,
|
|
1686
|
+
stop,
|
|
1687
|
+
updateManifestBestEffort,
|
|
1688
|
+
workspaceId
|
|
1689
|
+
]);
|
|
1690
|
+
const retry = useCallback2(() => {
|
|
1691
|
+
if (!manifestRef.current || manifestRef.current.finalizationState === "transcript-ready" || manifestRef.current.workspaceId !== workspaceId || !client || disabled || !enabled || status === "saving" || status === "transcribing") {
|
|
1692
|
+
return;
|
|
1693
|
+
}
|
|
1694
|
+
const generation = ++generationRef.current;
|
|
1695
|
+
controllerRef.current?.abort();
|
|
1696
|
+
setError(null);
|
|
1697
|
+
void finalizePersistedRecording(generation);
|
|
1698
|
+
}, [client, disabled, enabled, finalizePersistedRecording, status, workspaceId]);
|
|
1699
|
+
const insertSavedTranscript = useCallback2(async () => {
|
|
1700
|
+
const manifest = manifestRef.current;
|
|
1701
|
+
if (!manifest || manifest.workspaceId !== workspaceId || manifest.finalizationState !== "transcript-ready" || manifest.transcriptText === null) {
|
|
1702
|
+
return;
|
|
1703
|
+
}
|
|
1704
|
+
const generation = ++generationRef.current;
|
|
1705
|
+
controllerRef.current?.abort();
|
|
1706
|
+
controllerRef.current = null;
|
|
1707
|
+
const store = await ensureStore();
|
|
1708
|
+
if (generation !== generationRef.current) return;
|
|
1709
|
+
const next = appendFinalTranscript(valueRef.current, manifest.transcriptText);
|
|
1710
|
+
if (next !== valueRef.current) {
|
|
1711
|
+
valueRef.current = next;
|
|
1712
|
+
setValue(next);
|
|
1713
|
+
}
|
|
1714
|
+
let handedOff;
|
|
1715
|
+
try {
|
|
1716
|
+
handedOff = await store.updateManifest(
|
|
1717
|
+
manifest.recordingId,
|
|
1718
|
+
{ finalizationState: "handed-off" },
|
|
1719
|
+
readNow().toISOString(),
|
|
1720
|
+
ownerIdRef.current ?? void 0
|
|
1721
|
+
);
|
|
1722
|
+
} catch {
|
|
1723
|
+
if (generation !== generationRef.current) return;
|
|
1724
|
+
rememberManifest(manifest);
|
|
1725
|
+
setStatus("transcript-ready");
|
|
1726
|
+
setError("handoff_uncertain");
|
|
1727
|
+
focusInput();
|
|
1728
|
+
return;
|
|
1729
|
+
}
|
|
1730
|
+
if (generation !== generationRef.current) return;
|
|
1731
|
+
await store.discard(handedOff.recordingId, ownerIdRef.current ?? void 0).catch(() => void 0);
|
|
1732
|
+
if (generation !== generationRef.current) return;
|
|
1733
|
+
clearVisibleRecording();
|
|
1734
|
+
setStatus("idle");
|
|
1735
|
+
setError(null);
|
|
1736
|
+
focusInput();
|
|
1737
|
+
await loadNextRecoverable(generation);
|
|
1738
|
+
}, [
|
|
1739
|
+
clearVisibleRecording,
|
|
1740
|
+
ensureStore,
|
|
1741
|
+
focusInput,
|
|
1742
|
+
loadNextRecoverable,
|
|
1743
|
+
readNow,
|
|
1744
|
+
rememberManifest,
|
|
1745
|
+
setValue,
|
|
1746
|
+
workspaceId
|
|
1747
|
+
]);
|
|
1748
|
+
const discard = useCallback2(async () => {
|
|
1749
|
+
const manifest = manifestRef.current;
|
|
1750
|
+
const generation = ++generationRef.current;
|
|
1751
|
+
controllerRef.current?.abort();
|
|
1752
|
+
controllerRef.current = null;
|
|
1753
|
+
const recorder = recorderRef.current;
|
|
1754
|
+
let captureSettled = persistenceQueueRef.current;
|
|
1755
|
+
if (recorder && recorder.state !== "inactive") {
|
|
1756
|
+
captureSettled = captureSettledRef.current;
|
|
1757
|
+
recorder.stop();
|
|
1758
|
+
}
|
|
1759
|
+
clearCaptureRuntime();
|
|
1760
|
+
await captureSettled.catch(() => void 0);
|
|
1761
|
+
if (generation !== generationRef.current) return;
|
|
1762
|
+
if (manifest) {
|
|
1763
|
+
try {
|
|
1764
|
+
await (await ensureStore()).discard(manifest.recordingId, ownerIdRef.current ?? void 0);
|
|
1765
|
+
} catch {
|
|
1766
|
+
if (generation !== generationRef.current) return;
|
|
1767
|
+
rememberManifest(manifest);
|
|
1768
|
+
setStatus("error");
|
|
1769
|
+
setError("storage_unavailable");
|
|
1770
|
+
focusInput();
|
|
1771
|
+
return;
|
|
1772
|
+
}
|
|
1773
|
+
}
|
|
1774
|
+
clearVisibleRecording();
|
|
1775
|
+
persistenceErrorRef.current = null;
|
|
1776
|
+
captureLimitErrorRef.current = null;
|
|
1777
|
+
setStatus("idle");
|
|
1778
|
+
setError(null);
|
|
1779
|
+
focusInput();
|
|
1780
|
+
await loadNextRecoverable(generation);
|
|
1781
|
+
}, [
|
|
1782
|
+
clearCaptureRuntime,
|
|
1783
|
+
clearVisibleRecording,
|
|
1784
|
+
ensureStore,
|
|
1785
|
+
focusInput,
|
|
1786
|
+
loadNextRecoverable,
|
|
1787
|
+
rememberManifest
|
|
1788
|
+
]);
|
|
1789
|
+
const cancel = useCallback2(() => {
|
|
1790
|
+
if (status === "recording" || status === "requesting-permission") {
|
|
1791
|
+
void discard();
|
|
1792
|
+
return;
|
|
1793
|
+
}
|
|
1794
|
+
if (status === "saving" || status === "transcribing") {
|
|
1795
|
+
const generation = ++generationRef.current;
|
|
1796
|
+
controllerRef.current?.abort();
|
|
1797
|
+
controllerRef.current = null;
|
|
1798
|
+
const captureSettled = status === "saving" ? captureSettledRef.current : Promise.resolve();
|
|
1799
|
+
void captureSettled.then(async () => {
|
|
1800
|
+
const manifest = manifestRef.current;
|
|
1801
|
+
if (!manifest) return;
|
|
1802
|
+
const retained = await updateManifestBestEffort(manifest, {
|
|
1803
|
+
captureState: "stopped",
|
|
1804
|
+
uploadState: "retrying",
|
|
1805
|
+
transcriptionState: "retrying"
|
|
1806
|
+
});
|
|
1807
|
+
await preserveForRetry(retained, null, generation);
|
|
1808
|
+
});
|
|
1809
|
+
}
|
|
1810
|
+
}, [discard, preserveForRetry, status, updateManifestBestEffort]);
|
|
1811
|
+
useEffect2(() => {
|
|
1812
|
+
const current = manifestRef.current;
|
|
1813
|
+
const generation = ++generationRef.current;
|
|
1814
|
+
if (current && current.workspaceId !== workspaceId) {
|
|
1815
|
+
controllerRef.current?.abort();
|
|
1816
|
+
controllerRef.current = null;
|
|
1817
|
+
const recorder = recorderRef.current;
|
|
1818
|
+
let captureSettled = persistenceQueueRef.current;
|
|
1819
|
+
if (recorder && recorder.state !== "inactive") {
|
|
1820
|
+
captureSettled = captureSettledRef.current;
|
|
1821
|
+
recorder.stop();
|
|
1822
|
+
}
|
|
1823
|
+
clearCaptureRuntime();
|
|
1824
|
+
clearVisibleRecording();
|
|
1825
|
+
setStatus("idle");
|
|
1826
|
+
setError(null);
|
|
1827
|
+
void captureSettled.catch(() => void 0).then(async () => {
|
|
1828
|
+
const store = await ensureStore();
|
|
1829
|
+
const wasProcessing = current.uploadState === "syncing" || current.transcriptionState === "transcribing";
|
|
1830
|
+
await store.updateManifest(
|
|
1831
|
+
current.recordingId,
|
|
1832
|
+
{
|
|
1833
|
+
...current.captureState === "capturing" ? { captureState: "stopped" } : {},
|
|
1834
|
+
...wasProcessing ? { uploadState: "retrying", transcriptionState: "retrying" } : {},
|
|
1835
|
+
ownerId: null,
|
|
1836
|
+
ownerHeartbeatAt: null
|
|
1837
|
+
},
|
|
1838
|
+
readNow().toISOString(),
|
|
1839
|
+
ownerIdRef.current ?? void 0
|
|
1840
|
+
);
|
|
1841
|
+
}).catch(() => void 0);
|
|
1842
|
+
}
|
|
1843
|
+
if (!manifestRef.current) {
|
|
1844
|
+
void loadNextRecoverable(generation).catch((reason) => {
|
|
1845
|
+
if (reason instanceof VoiceRecordingStorageUnavailableError) setStorageAvailable(false);
|
|
1846
|
+
});
|
|
1847
|
+
}
|
|
1848
|
+
}, [
|
|
1849
|
+
clearCaptureRuntime,
|
|
1850
|
+
clearVisibleRecording,
|
|
1851
|
+
ensureStore,
|
|
1852
|
+
loadNextRecoverable,
|
|
1853
|
+
readNow,
|
|
1854
|
+
workspaceId
|
|
1855
|
+
]);
|
|
713
1856
|
useEffect2(() => {
|
|
714
1857
|
const onKeyDown = (event) => {
|
|
715
|
-
if (event.key === "Escape" && status
|
|
1858
|
+
if (event.key === "Escape" && (status === "requesting-permission" || status === "recording" || status === "saving" || status === "transcribing")) {
|
|
716
1859
|
event.preventDefault();
|
|
717
1860
|
cancel();
|
|
718
1861
|
}
|
|
@@ -724,20 +1867,61 @@ function useVoiceInput({
|
|
|
724
1867
|
() => () => {
|
|
725
1868
|
generationRef.current += 1;
|
|
726
1869
|
controllerRef.current?.abort();
|
|
1870
|
+
controllerRef.current = null;
|
|
1871
|
+
const manifest = manifestRef.current;
|
|
1872
|
+
const ownerReady = ownerIdPromiseRef.current;
|
|
1873
|
+
stopOwnerHeartbeat();
|
|
727
1874
|
const recorder = recorderRef.current;
|
|
728
|
-
|
|
729
|
-
if (recorder && recorder.state !== "inactive")
|
|
1875
|
+
let captureSettled = persistenceQueueRef.current;
|
|
1876
|
+
if (recorder && recorder.state !== "inactive") {
|
|
1877
|
+
captureSettled = captureSettledRef.current;
|
|
1878
|
+
recorder.stop();
|
|
1879
|
+
}
|
|
1880
|
+
clearCaptureRuntime();
|
|
1881
|
+
void captureSettled.catch(() => void 0).then(async () => {
|
|
1882
|
+
const ownerId = ownerIdRef.current ?? (ownerReady ? await ownerReady.catch(() => null) : null);
|
|
1883
|
+
const store = storeRef.current;
|
|
1884
|
+
if (store && manifest && ownerId) {
|
|
1885
|
+
const wasProcessing = statusRef.current === "saving" || statusRef.current === "transcribing";
|
|
1886
|
+
await store.updateManifest(
|
|
1887
|
+
manifest.recordingId,
|
|
1888
|
+
{
|
|
1889
|
+
...manifest.captureState === "capturing" ? { captureState: "stopped" } : {},
|
|
1890
|
+
...wasProcessing ? {
|
|
1891
|
+
uploadState: "retrying",
|
|
1892
|
+
transcriptionState: "retrying"
|
|
1893
|
+
} : {},
|
|
1894
|
+
ownerId: null,
|
|
1895
|
+
ownerHeartbeatAt: null
|
|
1896
|
+
},
|
|
1897
|
+
readNow().toISOString(),
|
|
1898
|
+
ownerId
|
|
1899
|
+
).catch(() => void 0);
|
|
1900
|
+
}
|
|
1901
|
+
if (ownsStoreRef.current) await store?.close();
|
|
1902
|
+
}).catch(() => void 0).finally(() => {
|
|
1903
|
+
ownerLeaseRef.current?.release();
|
|
1904
|
+
ownerLeaseRef.current = null;
|
|
1905
|
+
});
|
|
730
1906
|
},
|
|
731
|
-
[
|
|
1907
|
+
[clearCaptureRuntime, readNow, stopOwnerHeartbeat]
|
|
732
1908
|
);
|
|
733
1909
|
return {
|
|
734
1910
|
status,
|
|
735
1911
|
error,
|
|
736
|
-
available: Boolean(capability?.available && enabled && !disabled),
|
|
1912
|
+
available: Boolean(capability?.available && enabled && !disabled && storageAvailable),
|
|
737
1913
|
stream,
|
|
1914
|
+
recordingId,
|
|
1915
|
+
durationSeconds,
|
|
1916
|
+
locallySaved,
|
|
1917
|
+
hasRecoverableRecording: manifestRef.current !== null && status !== "recording",
|
|
1918
|
+
savedTranscript: manifestRef.current?.finalizationState === "transcript-ready" ? manifestRef.current.transcriptText : null,
|
|
738
1919
|
start,
|
|
739
1920
|
stop,
|
|
740
|
-
|
|
1921
|
+
retry,
|
|
1922
|
+
insertSavedTranscript,
|
|
1923
|
+
cancel,
|
|
1924
|
+
discard
|
|
741
1925
|
};
|
|
742
1926
|
}
|
|
743
1927
|
function chooseMimeType(accepted) {
|
|
@@ -2503,7 +3687,15 @@ function HelpPanel({
|
|
|
2503
3687
|
}
|
|
2504
3688
|
|
|
2505
3689
|
// src/components/composer-transcription-control.tsx
|
|
2506
|
-
import {
|
|
3690
|
+
import {
|
|
3691
|
+
ClipboardPasteIcon,
|
|
3692
|
+
LoaderCircleIcon as LoaderCircleIcon2,
|
|
3693
|
+
MicIcon,
|
|
3694
|
+
RefreshCwIcon,
|
|
3695
|
+
SquareIcon,
|
|
3696
|
+
Trash2Icon,
|
|
3697
|
+
XIcon as XIcon2
|
|
3698
|
+
} from "lucide-react";
|
|
2507
3699
|
import { AnimatePresence as AnimatePresence3, motion as motion3 } from "motion/react";
|
|
2508
3700
|
import { useEffect as useEffect4, useState as useState5 } from "react";
|
|
2509
3701
|
import { Fragment as Fragment2, jsx as jsx4, jsxs as jsxs4 } from "react/jsx-runtime";
|
|
@@ -2520,7 +3712,12 @@ var defaultMessages = {
|
|
|
2520
3712
|
retry: "Retry voice input",
|
|
2521
3713
|
requestingPermission: "Requesting microphone\u2026",
|
|
2522
3714
|
recording: "Recording. Press Escape to cancel.",
|
|
3715
|
+
saving: "Saving audio locally\u2026",
|
|
2523
3716
|
transcribing: "Transcribing\u2026",
|
|
3717
|
+
recovered: "Recording recovered and saved locally.",
|
|
3718
|
+
recoveredTranscript: "Transcript saved locally. Check your draft before inserting.",
|
|
3719
|
+
insertRecoveredTranscript: "Insert saved transcript",
|
|
3720
|
+
discardRecovered: "Discard saved recording",
|
|
2524
3721
|
unavailableDisabled: "Voice input is unavailable while the composer is disabled.",
|
|
2525
3722
|
unavailable: "Voice input is unavailable for this workspace.",
|
|
2526
3723
|
errorPermissionDenied: "Microphone permission was denied. Your draft was not changed.",
|
|
@@ -2528,6 +3725,9 @@ var defaultMessages = {
|
|
|
2528
3725
|
errorUnavailable: "Voice input is not configured.",
|
|
2529
3726
|
errorTooLarge: "Recording is too large. Try a shorter message.",
|
|
2530
3727
|
errorInvalidAudio: "The recording could not be read. Try again.",
|
|
3728
|
+
errorStorageUnavailable: "Voice input stopped because audio could not be saved safely.",
|
|
3729
|
+
errorRetryable: "Recording is saved locally. Retry transcription when ready.",
|
|
3730
|
+
errorHandoffUncertain: "Transcript is saved. Check your draft before inserting it again.",
|
|
2531
3731
|
errorUnknown: "Voice input could not start. Try again."
|
|
2532
3732
|
};
|
|
2533
3733
|
var WAVEFORM_BARS = 18;
|
|
@@ -2541,7 +3741,9 @@ function ComposerTranscriptionControl({
|
|
|
2541
3741
|
capability = null,
|
|
2542
3742
|
workspaceEnabled = false,
|
|
2543
3743
|
messages: overrides,
|
|
2544
|
-
className
|
|
3744
|
+
className,
|
|
3745
|
+
createRecordingStore,
|
|
3746
|
+
createOwnerId
|
|
2545
3747
|
}) {
|
|
2546
3748
|
const composer = useChatComposer();
|
|
2547
3749
|
const messages = { ...defaultMessages, ...overrides };
|
|
@@ -2553,14 +3755,18 @@ function ComposerTranscriptionControl({
|
|
|
2553
3755
|
value: composer.value,
|
|
2554
3756
|
setValue: composer.setValue,
|
|
2555
3757
|
focusInput: composer.focusInput,
|
|
2556
|
-
disabled: composer.disabled
|
|
3758
|
+
disabled: composer.disabled,
|
|
3759
|
+
createRecordingStore,
|
|
3760
|
+
createOwnerId
|
|
2557
3761
|
});
|
|
2558
3762
|
const { status } = transcription;
|
|
2559
|
-
const active = status === "requesting-permission" || status === "recording" || status === "transcribing";
|
|
2560
|
-
const
|
|
3763
|
+
const active = status === "requesting-permission" || status === "recording" || status === "saving" || status === "transcribing";
|
|
3764
|
+
const recoverable = transcription.hasRecoverableRecording && (status === "recovered" || status === "transcript-ready" || status === "error");
|
|
3765
|
+
const savedTranscript = status === "transcript-ready" && transcription.savedTranscript !== null;
|
|
3766
|
+
const unavailableMessage = composer.disabled ? messages.unavailableDisabled : !capability?.available || !workspaceEnabled ? messages.unavailable : !transcription.available ? messages.errorStorageUnavailable : null;
|
|
2561
3767
|
const idleLabel = unavailableMessage ?? (status === "error" ? messages.retry : messages.start);
|
|
2562
3768
|
const errorMessage3 = transcription.error ? transcriptionErrorMessage(transcription.error, messages) : null;
|
|
2563
|
-
const announcement = status === "requesting-permission" ? messages.requestingPermission : status === "recording" ? messages.recording : status === "transcribing" ? messages.transcribing : status === "error" ? errorMessage3 ?? messages.errorUnknown : unavailableMessage;
|
|
3769
|
+
const announcement = status === "requesting-permission" ? messages.requestingPermission : status === "recording" ? messages.recording : status === "saving" ? messages.saving : status === "transcribing" ? messages.transcribing : status === "recovered" ? messages.recovered : status === "transcript-ready" ? messages.recoveredTranscript : status === "error" ? errorMessage3 ?? messages.errorUnknown : unavailableMessage;
|
|
2564
3770
|
function start(event) {
|
|
2565
3771
|
if (unavailableMessage) {
|
|
2566
3772
|
event.preventDefault();
|
|
@@ -2574,7 +3780,51 @@ function ComposerTranscriptionControl({
|
|
|
2574
3780
|
className: cn("inline-flex min-w-0 items-center gap-1.5", className),
|
|
2575
3781
|
"data-transcription-status": status,
|
|
2576
3782
|
children: [
|
|
2577
|
-
/* @__PURE__ */ jsx4(AnimatePresence3, { mode: "popLayout", initial: false, children:
|
|
3783
|
+
/* @__PURE__ */ jsx4(AnimatePresence3, { mode: "popLayout", initial: false, children: recoverable ? /* @__PURE__ */ jsxs4(
|
|
3784
|
+
motion3.span,
|
|
3785
|
+
{
|
|
3786
|
+
initial: { opacity: 0, scale: 0.96 },
|
|
3787
|
+
animate: { opacity: 1, scale: 1 },
|
|
3788
|
+
exit: { opacity: 0, scale: 0.96 },
|
|
3789
|
+
transition: { duration: 0.16, ease: [0.22, 1, 0.36, 1] },
|
|
3790
|
+
className: cn(
|
|
3791
|
+
"inline-flex h-8 min-w-0 items-center gap-1 rounded-og-md border border-og-border/80",
|
|
3792
|
+
"bg-og-surface-2/70 pl-2 pr-1 pointer-coarse:h-11"
|
|
3793
|
+
),
|
|
3794
|
+
children: [
|
|
3795
|
+
/* @__PURE__ */ jsx4("span", { className: "max-w-44 truncate text-og-xs text-og-fg-muted max-sm:max-w-28", children: savedTranscript ? errorMessage3 ?? messages.recoveredTranscript : status === "error" ? errorMessage3 ?? messages.errorRetryable : messages.recovered }),
|
|
3796
|
+
/* @__PURE__ */ jsx4(Tip, { tip: savedTranscript ? messages.insertRecoveredTranscript : messages.retry, children: /* @__PURE__ */ jsx4(
|
|
3797
|
+
"button",
|
|
3798
|
+
{
|
|
3799
|
+
type: "button",
|
|
3800
|
+
onClick: () => savedTranscript ? void transcription.insertSavedTranscript() : transcription.retry(),
|
|
3801
|
+
"aria-label": savedTranscript ? messages.insertRecoveredTranscript : messages.retry,
|
|
3802
|
+
className: cn(
|
|
3803
|
+
"inline-flex size-7 shrink-0 items-center justify-center rounded-og-sm",
|
|
3804
|
+
"bg-og-fg text-og-bg transition-colors duration-150 motion-reduce:transition-none",
|
|
3805
|
+
"hover:bg-og-fg-muted pointer-coarse:size-11"
|
|
3806
|
+
),
|
|
3807
|
+
children: savedTranscript ? /* @__PURE__ */ jsx4(ClipboardPasteIcon, { className: "size-3.5" }) : /* @__PURE__ */ jsx4(RefreshCwIcon, { className: "size-3.5" })
|
|
3808
|
+
}
|
|
3809
|
+
) }),
|
|
3810
|
+
/* @__PURE__ */ jsx4(Tip, { tip: messages.discardRecovered, children: /* @__PURE__ */ jsx4(
|
|
3811
|
+
"button",
|
|
3812
|
+
{
|
|
3813
|
+
type: "button",
|
|
3814
|
+
onClick: () => void transcription.discard(),
|
|
3815
|
+
"aria-label": messages.discardRecovered,
|
|
3816
|
+
className: cn(
|
|
3817
|
+
"inline-flex size-7 shrink-0 items-center justify-center rounded-og-sm",
|
|
3818
|
+
"text-og-fg-muted transition-colors duration-150 motion-reduce:transition-none",
|
|
3819
|
+
"hover:bg-og-surface-3 hover:text-og-status-failed pointer-coarse:size-11"
|
|
3820
|
+
),
|
|
3821
|
+
children: /* @__PURE__ */ jsx4(Trash2Icon, { className: "size-3.5" })
|
|
3822
|
+
}
|
|
3823
|
+
) })
|
|
3824
|
+
]
|
|
3825
|
+
},
|
|
3826
|
+
"recovered"
|
|
3827
|
+
) : active ? /* @__PURE__ */ jsxs4(
|
|
2578
3828
|
motion3.span,
|
|
2579
3829
|
{
|
|
2580
3830
|
initial: { opacity: 0, scale: 0.96 },
|
|
@@ -2583,7 +3833,7 @@ function ComposerTranscriptionControl({
|
|
|
2583
3833
|
transition: { duration: 0.16, ease: [0.22, 1, 0.36, 1] },
|
|
2584
3834
|
className: cn(
|
|
2585
3835
|
"inline-flex h-8 items-center gap-1 rounded-og-md border border-og-border/80",
|
|
2586
|
-
"bg-og-surface-2/70 pl-2 pr-1 pointer-coarse:h-
|
|
3836
|
+
"bg-og-surface-2/70 pl-2 pr-1 pointer-coarse:h-11"
|
|
2587
3837
|
),
|
|
2588
3838
|
children: [
|
|
2589
3839
|
status === "requesting-permission" ? /* @__PURE__ */ jsx4(
|
|
@@ -2604,7 +3854,7 @@ function ComposerTranscriptionControl({
|
|
|
2604
3854
|
VoiceWaveform,
|
|
2605
3855
|
{
|
|
2606
3856
|
stream: status === "recording" ? transcription.stream : null,
|
|
2607
|
-
mode: status === "
|
|
3857
|
+
mode: status === "recording" ? "recording" : "transcribing"
|
|
2608
3858
|
}
|
|
2609
3859
|
)
|
|
2610
3860
|
] }),
|
|
@@ -2619,7 +3869,7 @@ function ComposerTranscriptionControl({
|
|
|
2619
3869
|
className: cn(
|
|
2620
3870
|
"inline-flex size-7 shrink-0 items-center justify-center rounded-og-sm",
|
|
2621
3871
|
"text-og-fg-muted transition-colors duration-150 motion-reduce:transition-none",
|
|
2622
|
-
"hover:bg-og-surface-3 hover:text-og-fg pointer-coarse:size-
|
|
3872
|
+
"hover:bg-og-surface-3 hover:text-og-fg pointer-coarse:size-11"
|
|
2623
3873
|
),
|
|
2624
3874
|
children: /* @__PURE__ */ jsx4(XIcon2, { className: "size-3.5" })
|
|
2625
3875
|
}
|
|
@@ -2633,15 +3883,15 @@ function ComposerTranscriptionControl({
|
|
|
2633
3883
|
className: cn(
|
|
2634
3884
|
"inline-flex size-7 shrink-0 items-center justify-center rounded-og-sm",
|
|
2635
3885
|
"bg-og-fg text-og-bg transition-colors duration-150 motion-reduce:transition-none",
|
|
2636
|
-
"hover:bg-og-fg-muted pointer-coarse:size-
|
|
3886
|
+
"hover:bg-og-fg-muted pointer-coarse:size-11"
|
|
2637
3887
|
),
|
|
2638
3888
|
children: /* @__PURE__ */ jsx4(SquareIcon, { className: "size-2.5 fill-current" })
|
|
2639
3889
|
}
|
|
2640
3890
|
) })
|
|
2641
|
-
] }) : status === "transcribing" ? /* @__PURE__ */ jsx4("span", { className: "og-shimmer-text px-1.5 text-og-xs font-medium whitespace-nowrap", children: messages.transcribing }) : /* @__PURE__ */ jsx4("span", { className: "px-1.5 text-og-xs text-og-fg-muted whitespace-nowrap", children: messages.requestingPermission })
|
|
3891
|
+
] }) : status === "transcribing" || status === "saving" ? /* @__PURE__ */ jsx4("span", { className: "og-shimmer-text px-1.5 text-og-xs font-medium whitespace-nowrap", children: status === "saving" ? messages.saving : messages.transcribing }) : /* @__PURE__ */ jsx4("span", { className: "px-1.5 text-og-xs text-og-fg-muted whitespace-nowrap", children: messages.requestingPermission })
|
|
2642
3892
|
]
|
|
2643
3893
|
},
|
|
2644
|
-
status === "transcribing" ? "
|
|
3894
|
+
status === "transcribing" || status === "saving" ? "processing" : "capture"
|
|
2645
3895
|
) : /* @__PURE__ */ jsx4(Tip, { tip: idleLabel, children: /* @__PURE__ */ jsx4(
|
|
2646
3896
|
motion3.button,
|
|
2647
3897
|
{
|
|
@@ -2655,14 +3905,14 @@ function ComposerTranscriptionControl({
|
|
|
2655
3905
|
"aria-pressed": false,
|
|
2656
3906
|
"aria-disabled": unavailableMessage !== null,
|
|
2657
3907
|
className: cn(
|
|
2658
|
-
"inline-flex size-8 shrink-0 items-center justify-center rounded-og-md pointer-coarse:size-
|
|
3908
|
+
"inline-flex size-8 shrink-0 items-center justify-center rounded-og-md pointer-coarse:size-11",
|
|
2659
3909
|
"text-og-fg-muted transition-colors duration-150 motion-reduce:transition-none",
|
|
2660
3910
|
unavailableMessage ? "cursor-not-allowed opacity-45" : "hover:bg-og-surface-2 hover:text-og-fg"
|
|
2661
3911
|
),
|
|
2662
3912
|
children: /* @__PURE__ */ jsx4(MicIcon, { className: "size-4" })
|
|
2663
3913
|
}
|
|
2664
3914
|
) }, "idle") }),
|
|
2665
|
-
status === "error" && errorMessage3 ? /* @__PURE__ */ jsx4(Tip, { tip: errorMessage3, children: /* @__PURE__ */ jsx4(
|
|
3915
|
+
status === "error" && errorMessage3 && !recoverable ? /* @__PURE__ */ jsx4(Tip, { tip: errorMessage3, children: /* @__PURE__ */ jsx4(
|
|
2666
3916
|
"span",
|
|
2667
3917
|
{
|
|
2668
3918
|
"aria-hidden": "true",
|
|
@@ -2772,6 +4022,14 @@ function transcriptionErrorMessage(code, messages) {
|
|
|
2772
4022
|
return messages.errorTooLarge;
|
|
2773
4023
|
case "invalid_audio":
|
|
2774
4024
|
return messages.errorInvalidAudio;
|
|
4025
|
+
case "storage_unavailable":
|
|
4026
|
+
return messages.errorStorageUnavailable;
|
|
4027
|
+
case "network":
|
|
4028
|
+
case "provider":
|
|
4029
|
+
case "timeout":
|
|
4030
|
+
return messages.errorRetryable;
|
|
4031
|
+
case "handoff_uncertain":
|
|
4032
|
+
return messages.errorHandoffUncertain;
|
|
2775
4033
|
case "unknown":
|
|
2776
4034
|
return messages.errorUnknown;
|
|
2777
4035
|
default:
|
|
@@ -2780,10 +4038,23 @@ function transcriptionErrorMessage(code, messages) {
|
|
|
2780
4038
|
}
|
|
2781
4039
|
|
|
2782
4040
|
export {
|
|
4041
|
+
VoiceRecordingStorageUnavailableError,
|
|
4042
|
+
VoiceRecordingNotFoundError,
|
|
4043
|
+
VoiceRecordingChunkConflictError,
|
|
4044
|
+
VoiceRecordingChunkSequenceError,
|
|
4045
|
+
VoiceRecordingOwnedError,
|
|
4046
|
+
createVoiceRecordingManifest,
|
|
4047
|
+
prepareVoiceRecordingChunk,
|
|
4048
|
+
planVoiceRecordingChunkCommit,
|
|
4049
|
+
IndexedDbVoiceRecordingStore,
|
|
2783
4050
|
INITIAL_TRANSCRIPTION_CONTROL_STATE,
|
|
2784
4051
|
transitionTranscriptionControl,
|
|
2785
4052
|
appendFinalTranscript,
|
|
2786
4053
|
useTranscription,
|
|
4054
|
+
VOICE_RECORDING_TIMESLICE_MILLISECONDS,
|
|
4055
|
+
VOICE_RECORDING_OWNER_HEARTBEAT_MILLISECONDS,
|
|
4056
|
+
VOICE_RECORDING_OWNER_STALE_MILLISECONDS,
|
|
4057
|
+
VOICE_RECORDING_CLIENT_MAX_DURATION_SECONDS,
|
|
2787
4058
|
useVoiceInput,
|
|
2788
4059
|
OPEN_WORKSTREAM_CONTROL_EVENT,
|
|
2789
4060
|
parseCommandLine,
|
|
@@ -2820,4 +4091,4 @@ export {
|
|
|
2820
4091
|
Status,
|
|
2821
4092
|
ComposerTranscriptionControl
|
|
2822
4093
|
};
|
|
2823
|
-
//# sourceMappingURL=chunk-
|
|
4094
|
+
//# sourceMappingURL=chunk-UWYTCQWW.js.map
|