@opengeni/react 0.41.0 → 0.42.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{chunk-OMCFRWHL.js → chunk-UWYTCQWW.js} +1353 -82
- package/dist/chunk-UWYTCQWW.js.map +1 -0
- package/dist/components/composer-transcription-control.d.ts +14 -1
- package/dist/composer.d.ts +3 -1
- package/dist/composer.js +27 -1
- package/dist/hooks/use-voice-input.d.ts +25 -4
- package/dist/index.d.ts +3 -1
- package/dist/index.js +27 -1
- package/dist/index.js.map +1 -1
- package/dist/voice-recording-owner.d.ts +12 -0
- package/dist/voice-recording-store.d.ts +124 -0
- package/package.json +2 -2
- package/src/components/composer-transcription-control.tsx +130 -18
- package/src/composer.ts +30 -1
- package/src/hooks/use-voice-input.ts +893 -67
- package/src/index.ts +30 -1
- package/src/voice-recording-owner.ts +251 -0
- package/src/voice-recording-store.ts +528 -0
- package/dist/chunk-OMCFRWHL.js.map +0 -1
|
@@ -0,0 +1,528 @@
|
|
|
1
|
+
const DEFAULT_DATABASE_NAME = "opengeni-voice-recordings-v1";
|
|
2
|
+
const DATABASE_VERSION = 1;
|
|
3
|
+
const MANIFEST_STORE = "recordings";
|
|
4
|
+
const CHUNK_STORE = "chunks";
|
|
5
|
+
const CHUNKS_BY_RECORDING = "by-recording";
|
|
6
|
+
|
|
7
|
+
export type VoiceRecordingCaptureState = "capturing" | "stopped" | "discarded";
|
|
8
|
+
export type VoiceRecordingUploadState = "pending" | "syncing" | "retrying" | "complete";
|
|
9
|
+
export type VoiceRecordingTranscriptionState = "pending" | "transcribing" | "retrying" | "complete";
|
|
10
|
+
export type VoiceRecordingFinalizationState = "pending" | "transcript-ready" | "handed-off";
|
|
11
|
+
export type VoiceRecordingChunkUploadState = "pending" | "syncing" | "complete";
|
|
12
|
+
|
|
13
|
+
export type VoiceRecordingManifest = {
|
|
14
|
+
version: 1;
|
|
15
|
+
recordingId: string;
|
|
16
|
+
workspaceId: string;
|
|
17
|
+
createdAt: string;
|
|
18
|
+
updatedAt: string;
|
|
19
|
+
mimeType: string;
|
|
20
|
+
codec: string | null;
|
|
21
|
+
captureState: VoiceRecordingCaptureState;
|
|
22
|
+
uploadState: VoiceRecordingUploadState;
|
|
23
|
+
transcriptionState: VoiceRecordingTranscriptionState;
|
|
24
|
+
finalizationState: VoiceRecordingFinalizationState;
|
|
25
|
+
/** Tab/process lease. A different owner may take over only after this heartbeat is stale. */
|
|
26
|
+
ownerId: string | null;
|
|
27
|
+
ownerHeartbeatAt: string | null;
|
|
28
|
+
/** Authoritative provider result, persisted before any composer draft mutation. */
|
|
29
|
+
transcriptText: string | null;
|
|
30
|
+
nextChunkNumber: number;
|
|
31
|
+
chunkCount: number;
|
|
32
|
+
totalBytes: number;
|
|
33
|
+
totalDurationMilliseconds: number;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export type VoiceRecordingChunk = {
|
|
37
|
+
recordingId: string;
|
|
38
|
+
chunkNumber: number;
|
|
39
|
+
capturedAt: string;
|
|
40
|
+
startMilliseconds: number;
|
|
41
|
+
durationMilliseconds: number;
|
|
42
|
+
mimeType: string;
|
|
43
|
+
codec: string | null;
|
|
44
|
+
byteLength: number;
|
|
45
|
+
sha256: string;
|
|
46
|
+
uploadState: VoiceRecordingChunkUploadState;
|
|
47
|
+
audio: Blob;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export type PersistVoiceRecordingChunkInput = {
|
|
51
|
+
recordingId: string;
|
|
52
|
+
ownerId?: string | undefined;
|
|
53
|
+
chunkNumber: number;
|
|
54
|
+
capturedAt: string;
|
|
55
|
+
startMilliseconds: number;
|
|
56
|
+
durationMilliseconds: number;
|
|
57
|
+
mimeType: string;
|
|
58
|
+
audio: Blob;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
export type PersistVoiceRecordingChunkResult = {
|
|
62
|
+
manifest: VoiceRecordingManifest;
|
|
63
|
+
chunk: VoiceRecordingChunk;
|
|
64
|
+
deduplicated: boolean;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
export interface VoiceRecordingStore {
|
|
68
|
+
createManifest(manifest: VoiceRecordingManifest): Promise<void>;
|
|
69
|
+
getManifest(recordingId: string): Promise<VoiceRecordingManifest | null>;
|
|
70
|
+
listRecoverableManifests(
|
|
71
|
+
workspaceId: string,
|
|
72
|
+
ownership?: { ownerId: string; staleBefore: string },
|
|
73
|
+
): Promise<VoiceRecordingManifest[]>;
|
|
74
|
+
claimManifest(
|
|
75
|
+
recordingId: string,
|
|
76
|
+
ownerId: string,
|
|
77
|
+
claimedAt: string,
|
|
78
|
+
staleBefore: string,
|
|
79
|
+
): Promise<VoiceRecordingManifest>;
|
|
80
|
+
listChunks(recordingId: string): Promise<VoiceRecordingChunk[]>;
|
|
81
|
+
persistChunk(input: PersistVoiceRecordingChunkInput): Promise<PersistVoiceRecordingChunkResult>;
|
|
82
|
+
updateManifest(
|
|
83
|
+
recordingId: string,
|
|
84
|
+
update: Partial<
|
|
85
|
+
Pick<
|
|
86
|
+
VoiceRecordingManifest,
|
|
87
|
+
| "captureState"
|
|
88
|
+
| "uploadState"
|
|
89
|
+
| "transcriptionState"
|
|
90
|
+
| "finalizationState"
|
|
91
|
+
| "ownerId"
|
|
92
|
+
| "ownerHeartbeatAt"
|
|
93
|
+
| "transcriptText"
|
|
94
|
+
>
|
|
95
|
+
>,
|
|
96
|
+
updatedAt: string,
|
|
97
|
+
ownerId?: string | undefined,
|
|
98
|
+
): Promise<VoiceRecordingManifest>;
|
|
99
|
+
discard(recordingId: string, ownerId?: string | undefined): Promise<void>;
|
|
100
|
+
cleanupHandedOffManifests(ownership: { ownerId: string; staleBefore: string }): Promise<number>;
|
|
101
|
+
close(): Promise<void>;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export class VoiceRecordingStorageUnavailableError extends Error {
|
|
105
|
+
constructor() {
|
|
106
|
+
super("Durable voice recording storage is unavailable.");
|
|
107
|
+
this.name = "VoiceRecordingStorageUnavailableError";
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export class VoiceRecordingNotFoundError extends Error {
|
|
112
|
+
constructor(recordingId: string) {
|
|
113
|
+
super(`Voice recording ${recordingId} was not found.`);
|
|
114
|
+
this.name = "VoiceRecordingNotFoundError";
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export class VoiceRecordingChunkConflictError extends Error {
|
|
119
|
+
constructor(recordingId: string, chunkNumber: number) {
|
|
120
|
+
super(`Voice recording ${recordingId} chunk ${chunkNumber} conflicts with persisted audio.`);
|
|
121
|
+
this.name = "VoiceRecordingChunkConflictError";
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export class VoiceRecordingChunkSequenceError extends Error {
|
|
126
|
+
constructor(expected: number, received: number) {
|
|
127
|
+
super(`Expected voice recording chunk ${expected}, received ${received}.`);
|
|
128
|
+
this.name = "VoiceRecordingChunkSequenceError";
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export class VoiceRecordingOwnedError extends Error {
|
|
133
|
+
constructor(recordingId: string) {
|
|
134
|
+
super(`Voice recording ${recordingId} is active in another browser tab.`);
|
|
135
|
+
this.name = "VoiceRecordingOwnedError";
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function createVoiceRecordingManifest(input: {
|
|
140
|
+
recordingId: string;
|
|
141
|
+
workspaceId: string;
|
|
142
|
+
mimeType: string;
|
|
143
|
+
createdAt: string;
|
|
144
|
+
ownerId?: string | null | undefined;
|
|
145
|
+
}): VoiceRecordingManifest {
|
|
146
|
+
return {
|
|
147
|
+
version: 1,
|
|
148
|
+
recordingId: input.recordingId,
|
|
149
|
+
workspaceId: input.workspaceId,
|
|
150
|
+
createdAt: input.createdAt,
|
|
151
|
+
updatedAt: input.createdAt,
|
|
152
|
+
mimeType: input.mimeType,
|
|
153
|
+
codec: codecForMimeType(input.mimeType),
|
|
154
|
+
captureState: "capturing",
|
|
155
|
+
uploadState: "pending",
|
|
156
|
+
transcriptionState: "pending",
|
|
157
|
+
finalizationState: "pending",
|
|
158
|
+
ownerId: input.ownerId ?? null,
|
|
159
|
+
ownerHeartbeatAt: input.ownerId ? input.createdAt : null,
|
|
160
|
+
transcriptText: null,
|
|
161
|
+
nextChunkNumber: 0,
|
|
162
|
+
chunkCount: 0,
|
|
163
|
+
totalBytes: 0,
|
|
164
|
+
totalDurationMilliseconds: 0,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export async function prepareVoiceRecordingChunk(
|
|
169
|
+
input: PersistVoiceRecordingChunkInput,
|
|
170
|
+
): Promise<VoiceRecordingChunk> {
|
|
171
|
+
if (!Number.isSafeInteger(input.chunkNumber) || input.chunkNumber < 0) {
|
|
172
|
+
throw new VoiceRecordingChunkSequenceError(0, input.chunkNumber);
|
|
173
|
+
}
|
|
174
|
+
if (!Number.isFinite(input.startMilliseconds) || input.startMilliseconds < 0) {
|
|
175
|
+
throw new RangeError("Voice recording chunk start must be non-negative.");
|
|
176
|
+
}
|
|
177
|
+
if (!Number.isFinite(input.durationMilliseconds) || input.durationMilliseconds < 0) {
|
|
178
|
+
throw new RangeError("Voice recording chunk duration must be non-negative.");
|
|
179
|
+
}
|
|
180
|
+
const bytes = new Uint8Array(await input.audio.arrayBuffer());
|
|
181
|
+
if (bytes.byteLength === 0) throw new RangeError("Voice recording chunks cannot be empty.");
|
|
182
|
+
return {
|
|
183
|
+
recordingId: input.recordingId,
|
|
184
|
+
chunkNumber: input.chunkNumber,
|
|
185
|
+
capturedAt: input.capturedAt,
|
|
186
|
+
startMilliseconds: Math.round(input.startMilliseconds),
|
|
187
|
+
durationMilliseconds: Math.round(input.durationMilliseconds),
|
|
188
|
+
mimeType: input.mimeType,
|
|
189
|
+
codec: codecForMimeType(input.mimeType),
|
|
190
|
+
byteLength: bytes.byteLength,
|
|
191
|
+
sha256: await sha256Hex(bytes),
|
|
192
|
+
uploadState: "pending",
|
|
193
|
+
audio: input.audio,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export function planVoiceRecordingChunkCommit(input: {
|
|
198
|
+
manifest: VoiceRecordingManifest;
|
|
199
|
+
chunk: VoiceRecordingChunk;
|
|
200
|
+
existingChunk: VoiceRecordingChunk | null;
|
|
201
|
+
}): PersistVoiceRecordingChunkResult {
|
|
202
|
+
const { manifest, chunk, existingChunk } = input;
|
|
203
|
+
if (manifest.recordingId !== chunk.recordingId) {
|
|
204
|
+
throw new VoiceRecordingChunkConflictError(chunk.recordingId, chunk.chunkNumber);
|
|
205
|
+
}
|
|
206
|
+
if (existingChunk) {
|
|
207
|
+
if (
|
|
208
|
+
existingChunk.sha256 !== chunk.sha256 ||
|
|
209
|
+
existingChunk.byteLength !== chunk.byteLength ||
|
|
210
|
+
existingChunk.mimeType !== chunk.mimeType
|
|
211
|
+
) {
|
|
212
|
+
throw new VoiceRecordingChunkConflictError(chunk.recordingId, chunk.chunkNumber);
|
|
213
|
+
}
|
|
214
|
+
return { manifest, chunk: existingChunk, deduplicated: true };
|
|
215
|
+
}
|
|
216
|
+
if (chunk.chunkNumber !== manifest.nextChunkNumber) {
|
|
217
|
+
throw new VoiceRecordingChunkSequenceError(manifest.nextChunkNumber, chunk.chunkNumber);
|
|
218
|
+
}
|
|
219
|
+
const updatedManifest: VoiceRecordingManifest = {
|
|
220
|
+
...manifest,
|
|
221
|
+
updatedAt: chunk.capturedAt,
|
|
222
|
+
ownerHeartbeatAt: manifest.ownerId ? chunk.capturedAt : manifest.ownerHeartbeatAt,
|
|
223
|
+
nextChunkNumber: manifest.nextChunkNumber + 1,
|
|
224
|
+
chunkCount: manifest.chunkCount + 1,
|
|
225
|
+
totalBytes: manifest.totalBytes + chunk.byteLength,
|
|
226
|
+
totalDurationMilliseconds: Math.max(
|
|
227
|
+
manifest.totalDurationMilliseconds,
|
|
228
|
+
chunk.startMilliseconds + chunk.durationMilliseconds,
|
|
229
|
+
),
|
|
230
|
+
};
|
|
231
|
+
return { manifest: updatedManifest, chunk, deduplicated: false };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export class IndexedDbVoiceRecordingStore implements VoiceRecordingStore {
|
|
235
|
+
private readonly database: Promise<IDBDatabase>;
|
|
236
|
+
|
|
237
|
+
constructor(options?: { indexedDB?: IDBFactory | null; databaseName?: string }) {
|
|
238
|
+
const factory = options && "indexedDB" in options ? options.indexedDB : globalThis.indexedDB;
|
|
239
|
+
if (!factory) throw new VoiceRecordingStorageUnavailableError();
|
|
240
|
+
this.database = openDatabase(factory, options?.databaseName ?? DEFAULT_DATABASE_NAME);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
async createManifest(manifest: VoiceRecordingManifest): Promise<void> {
|
|
244
|
+
const database = await this.database;
|
|
245
|
+
const transaction = database.transaction(MANIFEST_STORE, "readwrite");
|
|
246
|
+
transaction.objectStore(MANIFEST_STORE).add(manifest);
|
|
247
|
+
await transactionComplete(transaction);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
async getManifest(recordingId: string): Promise<VoiceRecordingManifest | null> {
|
|
251
|
+
const database = await this.database;
|
|
252
|
+
const transaction = database.transaction(MANIFEST_STORE, "readonly");
|
|
253
|
+
const manifest = await requestResult<VoiceRecordingManifest | undefined>(
|
|
254
|
+
transaction.objectStore(MANIFEST_STORE).get(recordingId),
|
|
255
|
+
);
|
|
256
|
+
await transactionComplete(transaction);
|
|
257
|
+
return manifest ? normalizeManifest(manifest) : null;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
async listRecoverableManifests(
|
|
261
|
+
workspaceId: string,
|
|
262
|
+
ownership?: { ownerId: string; staleBefore: string },
|
|
263
|
+
): Promise<VoiceRecordingManifest[]> {
|
|
264
|
+
const database = await this.database;
|
|
265
|
+
const transaction = database.transaction(MANIFEST_STORE, "readonly");
|
|
266
|
+
const manifests = await requestResult<VoiceRecordingManifest[]>(
|
|
267
|
+
transaction.objectStore(MANIFEST_STORE).getAll(),
|
|
268
|
+
);
|
|
269
|
+
await transactionComplete(transaction);
|
|
270
|
+
return manifests
|
|
271
|
+
.map(normalizeManifest)
|
|
272
|
+
.filter(
|
|
273
|
+
(manifest) =>
|
|
274
|
+
manifest.workspaceId === workspaceId &&
|
|
275
|
+
manifest.captureState !== "discarded" &&
|
|
276
|
+
manifest.finalizationState !== "handed-off" &&
|
|
277
|
+
manifestAvailableToOwner(manifest, ownership),
|
|
278
|
+
)
|
|
279
|
+
.sort((left, right) => left.createdAt.localeCompare(right.createdAt));
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
async claimManifest(
|
|
283
|
+
recordingId: string,
|
|
284
|
+
ownerId: string,
|
|
285
|
+
claimedAt: string,
|
|
286
|
+
staleBefore: string,
|
|
287
|
+
): Promise<VoiceRecordingManifest> {
|
|
288
|
+
const database = await this.database;
|
|
289
|
+
const transaction = database.transaction(MANIFEST_STORE, "readwrite");
|
|
290
|
+
const manifests = transaction.objectStore(MANIFEST_STORE);
|
|
291
|
+
const stored = await requestResult<VoiceRecordingManifest | undefined>(
|
|
292
|
+
manifests.get(recordingId),
|
|
293
|
+
);
|
|
294
|
+
if (!stored) {
|
|
295
|
+
transaction.abort();
|
|
296
|
+
throw new VoiceRecordingNotFoundError(recordingId);
|
|
297
|
+
}
|
|
298
|
+
const manifest = normalizeManifest(stored);
|
|
299
|
+
if (
|
|
300
|
+
manifest.finalizationState === "handed-off" ||
|
|
301
|
+
!manifestAvailableToOwner(manifest, { ownerId, staleBefore })
|
|
302
|
+
) {
|
|
303
|
+
transaction.abort();
|
|
304
|
+
throw new VoiceRecordingOwnedError(recordingId);
|
|
305
|
+
}
|
|
306
|
+
const updated: VoiceRecordingManifest = {
|
|
307
|
+
...manifest,
|
|
308
|
+
captureState: manifest.captureState === "capturing" ? "stopped" : manifest.captureState,
|
|
309
|
+
ownerId,
|
|
310
|
+
ownerHeartbeatAt: claimedAt,
|
|
311
|
+
updatedAt: claimedAt,
|
|
312
|
+
};
|
|
313
|
+
manifests.put(updated);
|
|
314
|
+
await transactionComplete(transaction);
|
|
315
|
+
return updated;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
async listChunks(recordingId: string): Promise<VoiceRecordingChunk[]> {
|
|
319
|
+
const database = await this.database;
|
|
320
|
+
const transaction = database.transaction(CHUNK_STORE, "readonly");
|
|
321
|
+
const chunks = await requestResult<VoiceRecordingChunk[]>(
|
|
322
|
+
transaction.objectStore(CHUNK_STORE).index(CHUNKS_BY_RECORDING).getAll(recordingId),
|
|
323
|
+
);
|
|
324
|
+
await transactionComplete(transaction);
|
|
325
|
+
return chunks.sort((left, right) => left.chunkNumber - right.chunkNumber);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
async persistChunk(
|
|
329
|
+
input: PersistVoiceRecordingChunkInput,
|
|
330
|
+
): Promise<PersistVoiceRecordingChunkResult> {
|
|
331
|
+
const chunk = await prepareVoiceRecordingChunk(input);
|
|
332
|
+
const database = await this.database;
|
|
333
|
+
const transaction = database.transaction([MANIFEST_STORE, CHUNK_STORE], "readwrite");
|
|
334
|
+
const manifests = transaction.objectStore(MANIFEST_STORE);
|
|
335
|
+
const chunks = transaction.objectStore(CHUNK_STORE);
|
|
336
|
+
const [manifest, existingChunk] = await Promise.all([
|
|
337
|
+
requestResult<VoiceRecordingManifest | undefined>(manifests.get(input.recordingId)),
|
|
338
|
+
requestResult<VoiceRecordingChunk | undefined>(
|
|
339
|
+
chunks.get([input.recordingId, input.chunkNumber]),
|
|
340
|
+
),
|
|
341
|
+
]);
|
|
342
|
+
if (!manifest) {
|
|
343
|
+
transaction.abort();
|
|
344
|
+
throw new VoiceRecordingNotFoundError(input.recordingId);
|
|
345
|
+
}
|
|
346
|
+
const normalizedManifest = normalizeManifest(manifest);
|
|
347
|
+
assertManifestOwnership(normalizedManifest, input.ownerId);
|
|
348
|
+
const result = planVoiceRecordingChunkCommit({
|
|
349
|
+
manifest: normalizedManifest,
|
|
350
|
+
chunk,
|
|
351
|
+
existingChunk: existingChunk ?? null,
|
|
352
|
+
});
|
|
353
|
+
if (!result.deduplicated) {
|
|
354
|
+
chunks.add(result.chunk);
|
|
355
|
+
manifests.put(result.manifest);
|
|
356
|
+
}
|
|
357
|
+
await transactionComplete(transaction);
|
|
358
|
+
return result;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
async updateManifest(
|
|
362
|
+
recordingId: string,
|
|
363
|
+
update: Partial<
|
|
364
|
+
Pick<
|
|
365
|
+
VoiceRecordingManifest,
|
|
366
|
+
| "captureState"
|
|
367
|
+
| "uploadState"
|
|
368
|
+
| "transcriptionState"
|
|
369
|
+
| "finalizationState"
|
|
370
|
+
| "ownerId"
|
|
371
|
+
| "ownerHeartbeatAt"
|
|
372
|
+
| "transcriptText"
|
|
373
|
+
>
|
|
374
|
+
>,
|
|
375
|
+
updatedAt: string,
|
|
376
|
+
ownerId?: string | undefined,
|
|
377
|
+
): Promise<VoiceRecordingManifest> {
|
|
378
|
+
const database = await this.database;
|
|
379
|
+
const transaction = database.transaction(MANIFEST_STORE, "readwrite");
|
|
380
|
+
const manifests = transaction.objectStore(MANIFEST_STORE);
|
|
381
|
+
const manifest = await requestResult<VoiceRecordingManifest | undefined>(
|
|
382
|
+
manifests.get(recordingId),
|
|
383
|
+
);
|
|
384
|
+
if (!manifest) {
|
|
385
|
+
transaction.abort();
|
|
386
|
+
throw new VoiceRecordingNotFoundError(recordingId);
|
|
387
|
+
}
|
|
388
|
+
const normalizedManifest = normalizeManifest(manifest);
|
|
389
|
+
assertManifestOwnership(normalizedManifest, ownerId);
|
|
390
|
+
const updated = { ...normalizedManifest, ...update, updatedAt };
|
|
391
|
+
manifests.put(updated);
|
|
392
|
+
await transactionComplete(transaction);
|
|
393
|
+
return updated;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
async discard(recordingId: string, ownerId?: string | undefined): Promise<void> {
|
|
397
|
+
const database = await this.database;
|
|
398
|
+
const transaction = database.transaction([MANIFEST_STORE, CHUNK_STORE], "readwrite");
|
|
399
|
+
const manifests = transaction.objectStore(MANIFEST_STORE);
|
|
400
|
+
const stored = await requestResult<VoiceRecordingManifest | undefined>(
|
|
401
|
+
manifests.get(recordingId),
|
|
402
|
+
);
|
|
403
|
+
if (!stored) {
|
|
404
|
+
transaction.abort();
|
|
405
|
+
throw new VoiceRecordingNotFoundError(recordingId);
|
|
406
|
+
}
|
|
407
|
+
assertManifestOwnership(normalizeManifest(stored), ownerId);
|
|
408
|
+
const chunks = transaction.objectStore(CHUNK_STORE);
|
|
409
|
+
const chunkKeys = await requestResult<IDBValidKey[]>(
|
|
410
|
+
chunks.index(CHUNKS_BY_RECORDING).getAllKeys(recordingId),
|
|
411
|
+
);
|
|
412
|
+
for (const key of chunkKeys) chunks.delete(key);
|
|
413
|
+
manifests.delete(recordingId);
|
|
414
|
+
await transactionComplete(transaction);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
async cleanupHandedOffManifests(ownership: {
|
|
418
|
+
ownerId: string;
|
|
419
|
+
staleBefore: string;
|
|
420
|
+
}): Promise<number> {
|
|
421
|
+
const database = await this.database;
|
|
422
|
+
const transaction = database.transaction([MANIFEST_STORE, CHUNK_STORE], "readwrite");
|
|
423
|
+
const manifests = transaction.objectStore(MANIFEST_STORE);
|
|
424
|
+
const chunks = transaction.objectStore(CHUNK_STORE);
|
|
425
|
+
const handedOff = (await requestResult<VoiceRecordingManifest[]>(manifests.getAll()))
|
|
426
|
+
.map(normalizeManifest)
|
|
427
|
+
.filter(
|
|
428
|
+
(manifest) =>
|
|
429
|
+
manifest.finalizationState === "handed-off" &&
|
|
430
|
+
manifestAvailableToOwner(manifest, ownership),
|
|
431
|
+
);
|
|
432
|
+
const chunkKeys = await Promise.all(
|
|
433
|
+
handedOff.map((manifest) =>
|
|
434
|
+
requestResult<IDBValidKey[]>(
|
|
435
|
+
chunks.index(CHUNKS_BY_RECORDING).getAllKeys(manifest.recordingId),
|
|
436
|
+
),
|
|
437
|
+
),
|
|
438
|
+
);
|
|
439
|
+
handedOff.forEach((manifest, index) => {
|
|
440
|
+
for (const key of chunkKeys[index] ?? []) chunks.delete(key);
|
|
441
|
+
manifests.delete(manifest.recordingId);
|
|
442
|
+
});
|
|
443
|
+
await transactionComplete(transaction);
|
|
444
|
+
return handedOff.length;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
async close(): Promise<void> {
|
|
448
|
+
(await this.database).close();
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
function normalizeManifest(manifest: VoiceRecordingManifest): VoiceRecordingManifest {
|
|
453
|
+
return {
|
|
454
|
+
...manifest,
|
|
455
|
+
ownerId: manifest.ownerId ?? null,
|
|
456
|
+
ownerHeartbeatAt: manifest.ownerHeartbeatAt ?? null,
|
|
457
|
+
transcriptText: manifest.transcriptText ?? null,
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
function manifestAvailableToOwner(
|
|
462
|
+
manifest: VoiceRecordingManifest,
|
|
463
|
+
ownership?: { ownerId: string; staleBefore: string },
|
|
464
|
+
): boolean {
|
|
465
|
+
if (!manifest.ownerId) return true;
|
|
466
|
+
if (!ownership) return false;
|
|
467
|
+
if (manifest.ownerId === ownership.ownerId) return true;
|
|
468
|
+
return !manifest.ownerHeartbeatAt || manifest.ownerHeartbeatAt <= ownership.staleBefore;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function assertManifestOwnership(
|
|
472
|
+
manifest: VoiceRecordingManifest,
|
|
473
|
+
ownerId: string | undefined,
|
|
474
|
+
): void {
|
|
475
|
+
if (manifest.ownerId !== (ownerId ?? null)) {
|
|
476
|
+
throw new VoiceRecordingOwnedError(manifest.recordingId);
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function codecForMimeType(mimeType: string): string | null {
|
|
481
|
+
const match = /(?:^|;)\s*codecs?\s*=\s*"?([^;"]+)/iu.exec(mimeType);
|
|
482
|
+
return match?.[1]?.trim().toLowerCase() ?? null;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
async function sha256Hex(bytes: Uint8Array): Promise<string> {
|
|
486
|
+
const digest = await crypto.subtle.digest("SHA-256", Uint8Array.from(bytes).buffer);
|
|
487
|
+
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
function openDatabase(factory: IDBFactory, name: string): Promise<IDBDatabase> {
|
|
491
|
+
return new Promise((resolve, reject) => {
|
|
492
|
+
const request = factory.open(name, DATABASE_VERSION);
|
|
493
|
+
request.onupgradeneeded = () => {
|
|
494
|
+
const database = request.result;
|
|
495
|
+
if (!database.objectStoreNames.contains(MANIFEST_STORE)) {
|
|
496
|
+
database.createObjectStore(MANIFEST_STORE, { keyPath: "recordingId" });
|
|
497
|
+
}
|
|
498
|
+
if (!database.objectStoreNames.contains(CHUNK_STORE)) {
|
|
499
|
+
const chunks = database.createObjectStore(CHUNK_STORE, {
|
|
500
|
+
keyPath: ["recordingId", "chunkNumber"],
|
|
501
|
+
});
|
|
502
|
+
chunks.createIndex(CHUNKS_BY_RECORDING, "recordingId", { unique: false });
|
|
503
|
+
}
|
|
504
|
+
};
|
|
505
|
+
request.onsuccess = () => {
|
|
506
|
+
const database = request.result;
|
|
507
|
+
database.onversionchange = () => database.close();
|
|
508
|
+
resolve(database);
|
|
509
|
+
};
|
|
510
|
+
request.onerror = () => reject(request.error ?? new Error("Failed to open voice storage."));
|
|
511
|
+
request.onblocked = () => reject(new Error("Voice recording storage upgrade is blocked."));
|
|
512
|
+
});
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function requestResult<T>(request: IDBRequest<T>): Promise<T> {
|
|
516
|
+
return new Promise((resolve, reject) => {
|
|
517
|
+
request.onsuccess = () => resolve(request.result);
|
|
518
|
+
request.onerror = () => reject(request.error ?? new Error("Voice recording storage failed."));
|
|
519
|
+
});
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
function transactionComplete(transaction: IDBTransaction): Promise<void> {
|
|
523
|
+
return new Promise((resolve, reject) => {
|
|
524
|
+
transaction.oncomplete = () => resolve();
|
|
525
|
+
transaction.onabort = () => reject(transaction.error ?? new Error("Voice storage aborted."));
|
|
526
|
+
transaction.onerror = () => reject(transaction.error ?? new Error("Voice storage failed."));
|
|
527
|
+
});
|
|
528
|
+
}
|