@libraz/libsonare 1.8.0 → 1.8.2
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/README.md +5 -5
- package/dist/_effects_common.d.ts +2 -1
- package/dist/_effects_common.d.ts.map +1 -1
- package/dist/analysis.js +3 -0
- package/dist/analysis.js.map +1 -1
- package/dist/effects_mastering.d.ts +2 -2
- package/dist/effects_mastering.d.ts.map +1 -1
- package/dist/effects_note_ops.d.ts +55 -30
- package/dist/effects_note_ops.d.ts.map +1 -1
- package/dist/effects_timepitch.d.ts +17 -12
- package/dist/effects_timepitch.d.ts.map +1 -1
- package/dist/feature_decompose.d.ts +1 -1
- package/dist/feature_decompose.d.ts.map +1 -1
- package/dist/index.d.ts +11 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2098 -155
- package/dist/index.js.map +1 -1
- package/dist/instrument_types.d.ts +27 -3
- package/dist/instrument_types.d.ts.map +1 -1
- package/dist/mastering_core.d.ts +30 -3
- package/dist/mastering_core.d.ts.map +1 -1
- package/dist/mixer.d.ts +2 -2
- package/dist/mixer.d.ts.map +1 -1
- package/dist/project.d.ts +5 -4
- package/dist/project.d.ts.map +1 -1
- package/dist/project_class.d.ts +57 -3
- package/dist/project_class.d.ts.map +1 -1
- package/dist/project_internal.d.ts +35 -1
- package/dist/project_internal.d.ts.map +1 -1
- package/dist/project_types.d.ts +17 -6
- package/dist/project_types.d.ts.map +1 -1
- package/dist/public_types.d.ts +1 -0
- package/dist/public_types.d.ts.map +1 -1
- package/dist/public_types_mastering.d.ts +19 -1
- package/dist/public_types_mastering.d.ts.map +1 -1
- package/dist/public_types_playback.d.ts +1 -0
- package/dist/public_types_playback.d.ts.map +1 -1
- package/dist/public_types_spectral.d.ts +5 -5
- package/dist/public_types_spectral.d.ts.map +1 -1
- package/dist/public_types_vocal_edit.d.ts +250 -0
- package/dist/public_types_vocal_edit.d.ts.map +1 -0
- package/dist/public_types_vocal_project.d.ts +63 -0
- package/dist/public_types_vocal_project.d.ts.map +1 -0
- package/dist/realtime_engine.d.ts +106 -16
- package/dist/realtime_engine.d.ts.map +1 -1
- package/dist/repair_dereverb.d.ts +1 -0
- package/dist/repair_dereverb.d.ts.map +1 -1
- package/dist/repair_noise.d.ts +1 -0
- package/dist/repair_noise.d.ts.map +1 -1
- package/dist/sonare-analysis.wasm +0 -0
- package/dist/sonare.d.ts +89 -0
- package/dist/sonare.js +1 -1
- package/dist/sonare.wasm +0 -0
- package/dist/streaming_processors.d.ts +5 -0
- package/dist/streaming_processors.d.ts.map +1 -1
- package/dist/vocal_edit.d.ts +131 -0
- package/dist/vocal_edit.d.ts.map +1 -0
- package/dist/vocal_edit_worker.d.ts +51 -0
- package/dist/vocal_edit_worker.d.ts.map +1 -0
- package/dist/vocal_edit_worker.js +1462 -0
- package/dist/vocal_edit_worker.js.map +1 -0
- package/dist/vocal_edit_worker_client.d.ts +118 -0
- package/dist/vocal_edit_worker_client.d.ts.map +1 -0
- package/dist/vocal_edit_worker_protocol.d.ts +139 -0
- package/dist/vocal_edit_worker_protocol.d.ts.map +1 -0
- package/dist/vocal_project.d.ts +17 -0
- package/dist/vocal_project.d.ts.map +1 -0
- package/dist/web_midi.d.ts +4 -0
- package/dist/web_midi.d.ts.map +1 -1
- package/dist/worker.js.map +1 -1
- package/dist/worklet/engine-mixer-facade.d.ts +42 -5
- package/dist/worklet/engine-mixer-facade.d.ts.map +1 -1
- package/dist/worklet/engine-node.d.ts +10 -0
- package/dist/worklet/engine-node.d.ts.map +1 -1
- package/dist/worklet/engine-processor.d.ts +6 -1
- package/dist/worklet/engine-processor.d.ts.map +1 -1
- package/dist/worklet/engine-register.d.ts.map +1 -1
- package/dist/worklet/engine-strips.d.ts +8 -4
- package/dist/worklet/engine-strips.d.ts.map +1 -1
- package/dist/worklet/engine-sync.d.ts +4 -2
- package/dist/worklet/engine-sync.d.ts.map +1 -1
- package/dist/worklet/engine.d.ts +42 -9
- package/dist/worklet/engine.d.ts.map +1 -1
- package/dist/worklet/guards.d.ts +10 -2
- package/dist/worklet/guards.d.ts.map +1 -1
- package/dist/worklet/messages.d.ts +54 -9
- package/dist/worklet/messages.d.ts.map +1 -1
- package/dist/worklet/mixer-processor.d.ts +2 -2
- package/dist/worklet/mixer-processor.d.ts.map +1 -1
- package/dist/worklet/protocol.d.ts +20 -2
- package/dist/worklet/protocol.d.ts.map +1 -1
- package/dist/worklet.d.ts +1 -1
- package/dist/worklet.d.ts.map +1 -1
- package/dist/worklet.js +808 -204
- package/dist/worklet.js.map +1 -1
- package/package.json +6 -1
- package/src/_effects_common.ts +36 -6
- package/src/effects_mastering.ts +4 -0
- package/src/effects_note_ops.ts +92 -44
- package/src/effects_timepitch.ts +42 -31
- package/src/feature_decompose.ts +2 -2
- package/src/index.ts +39 -0
- package/src/instrument_types.ts +29 -3
- package/src/mastering_core.ts +98 -2
- package/src/mixer.ts +41 -21
- package/src/project.ts +8 -1
- package/src/project_class.ts +136 -1
- package/src/project_internal.ts +90 -0
- package/src/project_types.ts +18 -6
- package/src/public_types.ts +1 -0
- package/src/public_types_mastering.ts +23 -0
- package/src/public_types_playback.ts +1 -0
- package/src/public_types_spectral.ts +5 -5
- package/src/public_types_vocal_edit.ts +289 -0
- package/src/public_types_vocal_project.ts +69 -0
- package/src/realtime_engine.ts +157 -24
- package/src/repair_dereverb.ts +1 -0
- package/src/repair_noise.ts +1 -0
- package/src/sonare.js.d.ts +89 -0
- package/src/streaming_processors.ts +8 -0
- package/src/vocal_edit.ts +885 -0
- package/src/vocal_edit_worker.ts +832 -0
- package/src/vocal_edit_worker_client.ts +812 -0
- package/src/vocal_edit_worker_protocol.ts +206 -0
- package/src/vocal_project.ts +327 -0
- package/src/web_midi.ts +100 -5
- package/src/worklet/engine-mixer-facade.ts +422 -87
- package/src/worklet/engine-node.ts +69 -4
- package/src/worklet/engine-processor.ts +129 -34
- package/src/worklet/engine-register.ts +3 -0
- package/src/worklet/engine-strips.ts +36 -14
- package/src/worklet/engine-sync.ts +6 -1
- package/src/worklet/engine.ts +76 -40
- package/src/worklet/guards.ts +81 -0
- package/src/worklet/messages.ts +63 -4
- package/src/worklet/mixer-processor.ts +10 -3
- package/src/worklet/protocol.ts +39 -8
- package/src/worklet.ts +2 -0
|
@@ -0,0 +1,832 @@
|
|
|
1
|
+
import { init } from './index';
|
|
2
|
+
import type {
|
|
3
|
+
VocalAnalysis,
|
|
4
|
+
VocalCapabilities,
|
|
5
|
+
VocalCreateRequest,
|
|
6
|
+
VocalEditOperation,
|
|
7
|
+
VocalEditResult,
|
|
8
|
+
VocalHistoryState,
|
|
9
|
+
VocalNotesResult,
|
|
10
|
+
VocalPitchEvaluation,
|
|
11
|
+
VocalRenderResult,
|
|
12
|
+
VocalRestoreRequest,
|
|
13
|
+
VocalStateBytes,
|
|
14
|
+
VocalStateToken,
|
|
15
|
+
} from './public_types_vocal_edit';
|
|
16
|
+
import {
|
|
17
|
+
createVocalEditSession,
|
|
18
|
+
restoreVocalEditSession,
|
|
19
|
+
type VocalRenderJob,
|
|
20
|
+
type VocalRenderSnapshot,
|
|
21
|
+
} from './vocal_edit';
|
|
22
|
+
import {
|
|
23
|
+
compareUint64,
|
|
24
|
+
type VocalWorkerCreateMessage,
|
|
25
|
+
type VocalWorkerCreateResult,
|
|
26
|
+
type VocalWorkerErrorMessage,
|
|
27
|
+
type VocalWorkerMutateMessage,
|
|
28
|
+
type VocalWorkerPreviewMessage,
|
|
29
|
+
type VocalWorkerRequestMessage,
|
|
30
|
+
type VocalWorkerResponseMessage,
|
|
31
|
+
type VocalWorkerResult,
|
|
32
|
+
} from './vocal_edit_worker_protocol';
|
|
33
|
+
|
|
34
|
+
export interface VocalEditWorkerEndpoint {
|
|
35
|
+
postMessage(message: VocalWorkerResponseMessage, transfer?: Transferable[]): void;
|
|
36
|
+
addEventListener(
|
|
37
|
+
type: 'message',
|
|
38
|
+
listener: (event: MessageEvent<VocalWorkerRequestMessage>) => void,
|
|
39
|
+
): void;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface VocalEditWorkerNativeDraft {
|
|
43
|
+
token(): VocalStateToken;
|
|
44
|
+
notes(): VocalNotesResult;
|
|
45
|
+
apply(request: {
|
|
46
|
+
expectedGeneration: string;
|
|
47
|
+
operations: readonly VocalEditOperation[];
|
|
48
|
+
}): VocalEditResult;
|
|
49
|
+
commit(expectedRevision: string): VocalEditResult;
|
|
50
|
+
cancel(): void;
|
|
51
|
+
evaluatePitch(noteId: number): VocalPitchEvaluation;
|
|
52
|
+
sourceSampleToDestinationSample(noteId: number, sourceSample: number): number;
|
|
53
|
+
destinationSampleToSourceSample(noteId: number, destinationSample: number): number;
|
|
54
|
+
captureRenderSnapshot(): VocalRenderSnapshot;
|
|
55
|
+
exportState?(): VocalStateBytes;
|
|
56
|
+
dispose(): void;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface VocalEditWorkerNativeSession {
|
|
60
|
+
token(): VocalStateToken;
|
|
61
|
+
notes(): VocalNotesResult;
|
|
62
|
+
analysis(): VocalAnalysis;
|
|
63
|
+
capabilities(): VocalCapabilities;
|
|
64
|
+
beginEdit(expectedRevision?: string): VocalEditWorkerNativeDraft;
|
|
65
|
+
undo(expectedRevision?: string): VocalEditResult;
|
|
66
|
+
redo(expectedRevision?: string): VocalEditResult;
|
|
67
|
+
outputLengthSamples(): number;
|
|
68
|
+
history(): VocalHistoryState;
|
|
69
|
+
evaluatePitch(noteId: number): VocalPitchEvaluation;
|
|
70
|
+
sourceSampleToDestinationSample(noteId: number, sourceSample: number): number;
|
|
71
|
+
destinationSampleToSourceSample(noteId: number, destinationSample: number): number;
|
|
72
|
+
captureRenderSnapshot(): VocalRenderSnapshot;
|
|
73
|
+
exportState(): Uint8Array;
|
|
74
|
+
dispose(): void;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface VocalEditWorkerFactory {
|
|
78
|
+
create(request: VocalCreateRequest): VocalEditWorkerNativeSession;
|
|
79
|
+
restore(request: VocalRestoreRequest): VocalEditWorkerNativeSession;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const productionFactory: VocalEditWorkerFactory = {
|
|
83
|
+
create: createVocalEditSession,
|
|
84
|
+
restore: restoreVocalEditSession,
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
interface ActiveCommand {
|
|
88
|
+
id: number;
|
|
89
|
+
cancelled: boolean;
|
|
90
|
+
preview: boolean;
|
|
91
|
+
cancelBuffer?: SharedArrayBuffer;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
interface QueuedCommand {
|
|
95
|
+
message: VocalWorkerMutateMessage | VocalWorkerPreviewMessage;
|
|
96
|
+
active: ActiveCommand;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
interface WorkerSession {
|
|
100
|
+
value: VocalEditWorkerNativeSession;
|
|
101
|
+
draft?: VocalEditWorkerNativeDraft;
|
|
102
|
+
latestIntent: string;
|
|
103
|
+
queue: QueuedCommand[];
|
|
104
|
+
running: ActiveCommand | undefined;
|
|
105
|
+
draining: boolean;
|
|
106
|
+
/** Set synchronously when a dispose arrives; the session accepts no more work. */
|
|
107
|
+
disposing: boolean;
|
|
108
|
+
/** Settles once the native session is released. */
|
|
109
|
+
released?: Promise<void>;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function errorPayload(error: unknown): VocalWorkerErrorMessage['error'] {
|
|
113
|
+
const candidate = error as Partial<Error> & {
|
|
114
|
+
code?: number;
|
|
115
|
+
codeName?: string;
|
|
116
|
+
reason?: number;
|
|
117
|
+
field?: string;
|
|
118
|
+
expected?: string;
|
|
119
|
+
actual?: string;
|
|
120
|
+
expectedText?: string;
|
|
121
|
+
actualText?: string;
|
|
122
|
+
};
|
|
123
|
+
return {
|
|
124
|
+
name: candidate.name ?? 'Error',
|
|
125
|
+
message: candidate.message ?? String(error),
|
|
126
|
+
...(candidate.code === undefined ? {} : { code: candidate.code }),
|
|
127
|
+
...(candidate.codeName === undefined ? {} : { codeName: candidate.codeName }),
|
|
128
|
+
...(candidate.reason === undefined ? {} : { reason: candidate.reason }),
|
|
129
|
+
...(candidate.field === undefined ? {} : { field: candidate.field }),
|
|
130
|
+
...(candidate.expected === undefined ? {} : { expected: candidate.expected }),
|
|
131
|
+
...(candidate.actual === undefined ? {} : { actual: candidate.actual }),
|
|
132
|
+
...(candidate.expectedText === undefined ? {} : { expectedText: candidate.expectedText }),
|
|
133
|
+
...(candidate.actualText === undefined ? {} : { actualText: candidate.actualText }),
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function transferables(value: unknown): Transferable[] {
|
|
138
|
+
const result: Transferable[] = [];
|
|
139
|
+
const seen = new Set<ArrayBuffer>();
|
|
140
|
+
const visit = (item: unknown): void => {
|
|
141
|
+
if (item === null || item === undefined || typeof item !== 'object') {
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
if (ArrayBuffer.isView(item)) {
|
|
145
|
+
const buffer = item.buffer;
|
|
146
|
+
if (buffer instanceof ArrayBuffer && !seen.has(buffer)) {
|
|
147
|
+
seen.add(buffer);
|
|
148
|
+
result.push(buffer);
|
|
149
|
+
}
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
if (item instanceof ArrayBuffer) {
|
|
153
|
+
if (!seen.has(item)) {
|
|
154
|
+
seen.add(item);
|
|
155
|
+
result.push(item);
|
|
156
|
+
}
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
if (Array.isArray(item)) {
|
|
160
|
+
item.forEach(visit);
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
Object.values(item).forEach(visit);
|
|
164
|
+
};
|
|
165
|
+
visit(value);
|
|
166
|
+
return result;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function cancelledError(): Error {
|
|
170
|
+
const error = new Error('Vocal render cancelled');
|
|
171
|
+
error.name = 'AbortError';
|
|
172
|
+
return error;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function isCancelled(active: ActiveCommand): boolean {
|
|
176
|
+
return (
|
|
177
|
+
active.cancelled ||
|
|
178
|
+
(active.cancelBuffer !== undefined &&
|
|
179
|
+
Atomics.load(new Int32Array(active.cancelBuffer), 0) !== 0)
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function nextMacrotask(): Promise<void> {
|
|
184
|
+
return new Promise((resolve) => setTimeout(resolve, 0));
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function resultToken(value: unknown, session: WorkerSession): VocalStateToken {
|
|
188
|
+
const candidate = value as { token?: VocalStateToken } | null;
|
|
189
|
+
return candidate?.token ?? currentToken(session);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function currentToken(session: WorkerSession): VocalStateToken {
|
|
193
|
+
return session.draft?.token() ?? session.value.token();
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function postResult(
|
|
197
|
+
endpoint: VocalEditWorkerEndpoint,
|
|
198
|
+
id: number,
|
|
199
|
+
sessionId: string,
|
|
200
|
+
clientIntentSequence: string,
|
|
201
|
+
result: VocalWorkerResult,
|
|
202
|
+
token?: VocalStateToken,
|
|
203
|
+
): void {
|
|
204
|
+
endpoint.postMessage(
|
|
205
|
+
{
|
|
206
|
+
type: 'sonare:vocal-result',
|
|
207
|
+
id,
|
|
208
|
+
sessionId,
|
|
209
|
+
clientIntentSequence,
|
|
210
|
+
result,
|
|
211
|
+
...(token ? { token } : {}),
|
|
212
|
+
},
|
|
213
|
+
transferables(result),
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function postError(
|
|
218
|
+
endpoint: VocalEditWorkerEndpoint,
|
|
219
|
+
id: number,
|
|
220
|
+
sessionId: string,
|
|
221
|
+
clientIntentSequence: string,
|
|
222
|
+
error: unknown,
|
|
223
|
+
token?: VocalStateToken,
|
|
224
|
+
): void {
|
|
225
|
+
endpoint.postMessage({
|
|
226
|
+
type: 'sonare:vocal-error',
|
|
227
|
+
id,
|
|
228
|
+
sessionId,
|
|
229
|
+
clientIntentSequence,
|
|
230
|
+
error: errorPayload(error),
|
|
231
|
+
...(token ? { token } : {}),
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function rejectQueuedCommands(
|
|
236
|
+
endpoint: VocalEditWorkerEndpoint,
|
|
237
|
+
session: WorkerSession,
|
|
238
|
+
error: Error,
|
|
239
|
+
): void {
|
|
240
|
+
for (const queued of session.queue) {
|
|
241
|
+
postError(
|
|
242
|
+
endpoint,
|
|
243
|
+
queued.message.id,
|
|
244
|
+
queued.message.sessionId,
|
|
245
|
+
queued.message.clientIntentSequence,
|
|
246
|
+
error,
|
|
247
|
+
currentToken(session),
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
session.queue = [];
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Mutations are validated by the native expectedRevision/expectedGeneration
|
|
254
|
+
// checks when they run, not against the token the client held at enqueue time:
|
|
255
|
+
// a pipelined mutation queued behind another one legitimately carries an older
|
|
256
|
+
// base token. Only a token from a different session epoch is refused here.
|
|
257
|
+
function ensureSameEpoch(
|
|
258
|
+
message: VocalWorkerMutateMessage | VocalWorkerPreviewMessage,
|
|
259
|
+
session: WorkerSession,
|
|
260
|
+
): void {
|
|
261
|
+
if (message.baseToken && message.baseToken.sessionEpoch !== currentToken(session).sessionEpoch) {
|
|
262
|
+
const error = new Error('The vocal edit request targets another session epoch');
|
|
263
|
+
error.name = 'StaleResultError';
|
|
264
|
+
throw error;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function draftRequired(session: WorkerSession): VocalEditWorkerNativeDraft {
|
|
269
|
+
if (!session.draft) {
|
|
270
|
+
throw new Error('No vocal edit draft is open');
|
|
271
|
+
}
|
|
272
|
+
return session.draft;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** The open draft when `draft` is set, otherwise the session. */
|
|
276
|
+
function readTarget(
|
|
277
|
+
session: WorkerSession,
|
|
278
|
+
draft: boolean | undefined,
|
|
279
|
+
): VocalEditWorkerNativeDraft | VocalEditWorkerNativeSession {
|
|
280
|
+
return draft === true ? draftRequired(session) : session.value;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
async function runPreview(
|
|
284
|
+
message: VocalWorkerPreviewMessage,
|
|
285
|
+
session: WorkerSession,
|
|
286
|
+
active: ActiveCommand,
|
|
287
|
+
endpoint: VocalEditWorkerEndpoint,
|
|
288
|
+
): Promise<VocalRenderResult> {
|
|
289
|
+
const snapshot = session.draft
|
|
290
|
+
? session.draft.captureRenderSnapshot()
|
|
291
|
+
: session.value.captureRenderSnapshot();
|
|
292
|
+
let job: VocalRenderJob | undefined;
|
|
293
|
+
try {
|
|
294
|
+
job = snapshot.beginRenderJob({ ...message.request, signal: undefined });
|
|
295
|
+
while (true) {
|
|
296
|
+
if (isCancelled(active)) {
|
|
297
|
+
job.abort();
|
|
298
|
+
throw cancelledError();
|
|
299
|
+
}
|
|
300
|
+
const progress = job.next();
|
|
301
|
+
endpoint.postMessage({
|
|
302
|
+
type: 'sonare:vocal-progress',
|
|
303
|
+
id: message.id,
|
|
304
|
+
sessionId: message.sessionId,
|
|
305
|
+
clientIntentSequence: message.clientIntentSequence,
|
|
306
|
+
complete: progress.complete,
|
|
307
|
+
token: currentToken(session),
|
|
308
|
+
});
|
|
309
|
+
if (progress.complete) {
|
|
310
|
+
return job.finalize();
|
|
311
|
+
}
|
|
312
|
+
// One job_next call per macrotask is intentional. Without the yield a
|
|
313
|
+
// cancel message cannot reach a synchronous WASM renderer on browsers
|
|
314
|
+
// that do not expose SharedArrayBuffer.
|
|
315
|
+
await nextMacrotask();
|
|
316
|
+
}
|
|
317
|
+
} finally {
|
|
318
|
+
job?.dispose();
|
|
319
|
+
snapshot.dispose();
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
async function execute(
|
|
324
|
+
command: QueuedCommand,
|
|
325
|
+
session: WorkerSession,
|
|
326
|
+
endpoint: VocalEditWorkerEndpoint,
|
|
327
|
+
): Promise<void> {
|
|
328
|
+
const message = command.message;
|
|
329
|
+
const active = command.active;
|
|
330
|
+
session.running = active;
|
|
331
|
+
try {
|
|
332
|
+
ensureSameEpoch(message, session);
|
|
333
|
+
let result: VocalWorkerResult;
|
|
334
|
+
if (message.type === 'sonare:vocal-preview') {
|
|
335
|
+
result = await runPreview(message, session, active, endpoint);
|
|
336
|
+
} else {
|
|
337
|
+
const mutation = message.mutation;
|
|
338
|
+
switch (mutation.kind) {
|
|
339
|
+
case 'beginEdit':
|
|
340
|
+
if (session.draft) {
|
|
341
|
+
throw new Error('A vocal edit draft is already open');
|
|
342
|
+
}
|
|
343
|
+
session.draft = session.value.beginEdit(mutation.expectedRevision);
|
|
344
|
+
result = session.draft.token();
|
|
345
|
+
break;
|
|
346
|
+
case 'apply':
|
|
347
|
+
result = draftRequired(session).apply(mutation.request);
|
|
348
|
+
break;
|
|
349
|
+
case 'commit':
|
|
350
|
+
result = draftRequired(session).commit(mutation.expectedRevision);
|
|
351
|
+
session.draft?.dispose();
|
|
352
|
+
session.draft = undefined;
|
|
353
|
+
break;
|
|
354
|
+
case 'cancel':
|
|
355
|
+
draftRequired(session).cancel();
|
|
356
|
+
session.draft?.dispose();
|
|
357
|
+
session.draft = undefined;
|
|
358
|
+
result = null;
|
|
359
|
+
break;
|
|
360
|
+
case 'undo':
|
|
361
|
+
if (session.draft) {
|
|
362
|
+
throw new Error('Cancel or commit the vocal draft before undo');
|
|
363
|
+
}
|
|
364
|
+
result = session.value.undo(mutation.expectedRevision);
|
|
365
|
+
break;
|
|
366
|
+
case 'redo':
|
|
367
|
+
if (session.draft) {
|
|
368
|
+
throw new Error('Cancel or commit the vocal draft before redo');
|
|
369
|
+
}
|
|
370
|
+
result = session.value.redo(mutation.expectedRevision);
|
|
371
|
+
break;
|
|
372
|
+
case 'notes':
|
|
373
|
+
result = session.draft ? session.draft.notes() : session.value.notes();
|
|
374
|
+
break;
|
|
375
|
+
case 'analysis':
|
|
376
|
+
result = session.value.analysis();
|
|
377
|
+
break;
|
|
378
|
+
case 'capabilities':
|
|
379
|
+
result = session.value.capabilities();
|
|
380
|
+
break;
|
|
381
|
+
case 'exportState':
|
|
382
|
+
if (session.draft) {
|
|
383
|
+
throw new Error('Commit or cancel the vocal draft before export');
|
|
384
|
+
}
|
|
385
|
+
result = { data: session.value.exportState() };
|
|
386
|
+
break;
|
|
387
|
+
case 'outputLength':
|
|
388
|
+
result = session.value.outputLengthSamples();
|
|
389
|
+
break;
|
|
390
|
+
case 'history':
|
|
391
|
+
result = session.value.history();
|
|
392
|
+
break;
|
|
393
|
+
case 'draftToken':
|
|
394
|
+
result = draftRequired(session).token();
|
|
395
|
+
break;
|
|
396
|
+
case 'evaluatePitch':
|
|
397
|
+
result = readTarget(session, mutation.draft).evaluatePitch(mutation.noteId);
|
|
398
|
+
break;
|
|
399
|
+
case 'mapCoordinate': {
|
|
400
|
+
const target = readTarget(session, mutation.draft);
|
|
401
|
+
result = mutation.inverse
|
|
402
|
+
? target.destinationSampleToSourceSample(mutation.noteId, mutation.sample)
|
|
403
|
+
: target.sourceSampleToDestinationSample(mutation.noteId, mutation.sample);
|
|
404
|
+
break;
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
// A mutation that ran is reported as run: cancellation covers renders only.
|
|
409
|
+
if (message.type === 'sonare:vocal-preview' && isCancelled(active)) {
|
|
410
|
+
throw cancelledError();
|
|
411
|
+
}
|
|
412
|
+
postResult(
|
|
413
|
+
endpoint,
|
|
414
|
+
message.id,
|
|
415
|
+
message.sessionId,
|
|
416
|
+
message.clientIntentSequence,
|
|
417
|
+
result,
|
|
418
|
+
resultToken(result, session),
|
|
419
|
+
);
|
|
420
|
+
} catch (error) {
|
|
421
|
+
postError(
|
|
422
|
+
endpoint,
|
|
423
|
+
message.id,
|
|
424
|
+
message.sessionId,
|
|
425
|
+
message.clientIntentSequence,
|
|
426
|
+
error,
|
|
427
|
+
currentToken(session),
|
|
428
|
+
);
|
|
429
|
+
} finally {
|
|
430
|
+
session.running = undefined;
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
async function drain(session: WorkerSession, endpoint: VocalEditWorkerEndpoint): Promise<void> {
|
|
435
|
+
if (session.draining) {
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
session.draining = true;
|
|
439
|
+
try {
|
|
440
|
+
while (session.queue.length !== 0 && !session.disposing) {
|
|
441
|
+
const command = session.queue.shift() as QueuedCommand;
|
|
442
|
+
await execute(command, session, endpoint);
|
|
443
|
+
}
|
|
444
|
+
} finally {
|
|
445
|
+
session.draining = false;
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function queueCommand(
|
|
450
|
+
endpoint: VocalEditWorkerEndpoint,
|
|
451
|
+
sessions: Map<string, WorkerSession>,
|
|
452
|
+
message: VocalWorkerMutateMessage | VocalWorkerPreviewMessage,
|
|
453
|
+
): void {
|
|
454
|
+
const session = sessions.get(message.sessionId);
|
|
455
|
+
if (!session || session.disposing) {
|
|
456
|
+
postError(
|
|
457
|
+
endpoint,
|
|
458
|
+
message.id,
|
|
459
|
+
message.sessionId,
|
|
460
|
+
message.clientIntentSequence,
|
|
461
|
+
new Error('Unknown vocal session'),
|
|
462
|
+
);
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
if (compareUint64(message.clientIntentSequence, session.latestIntent) <= 0) {
|
|
466
|
+
const error = new Error('A stale vocal client intent was rejected');
|
|
467
|
+
error.name = 'StaleResultError';
|
|
468
|
+
postError(
|
|
469
|
+
endpoint,
|
|
470
|
+
message.id,
|
|
471
|
+
message.sessionId,
|
|
472
|
+
message.clientIntentSequence,
|
|
473
|
+
error,
|
|
474
|
+
currentToken(session),
|
|
475
|
+
);
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
session.latestIntent = message.clientIntentSequence;
|
|
479
|
+
if (message.type === 'sonare:vocal-preview') {
|
|
480
|
+
// Only previews that have not started may be coalesced. Mutations remain
|
|
481
|
+
// FIFO so a rapid sequence of drag updates cannot reorder the edit state.
|
|
482
|
+
const stale = session.queue.filter(
|
|
483
|
+
(command) => command.message.type === 'sonare:vocal-preview',
|
|
484
|
+
);
|
|
485
|
+
session.queue = session.queue.filter(
|
|
486
|
+
(command) => command.message.type !== 'sonare:vocal-preview',
|
|
487
|
+
);
|
|
488
|
+
for (const command of stale) {
|
|
489
|
+
postError(
|
|
490
|
+
endpoint,
|
|
491
|
+
command.message.id,
|
|
492
|
+
command.message.sessionId,
|
|
493
|
+
command.message.clientIntentSequence,
|
|
494
|
+
Object.assign(new Error('Preview superseded'), { name: 'StaleResultError' }),
|
|
495
|
+
currentToken(session),
|
|
496
|
+
);
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
session.queue.push({
|
|
500
|
+
message,
|
|
501
|
+
active: {
|
|
502
|
+
id: message.id,
|
|
503
|
+
cancelled: false,
|
|
504
|
+
preview: message.type === 'sonare:vocal-preview',
|
|
505
|
+
cancelBuffer: message.cancelBuffer,
|
|
506
|
+
},
|
|
507
|
+
});
|
|
508
|
+
void drain(session, endpoint);
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
export interface VocalEditWorkerDependencies {
|
|
512
|
+
factory?: VocalEditWorkerFactory;
|
|
513
|
+
initialize?: () => Promise<void>;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
const UINT64_MAX = '18446744073709551615';
|
|
517
|
+
|
|
518
|
+
const mutationKinds = new Set<string>([
|
|
519
|
+
'beginEdit',
|
|
520
|
+
'apply',
|
|
521
|
+
'commit',
|
|
522
|
+
'cancel',
|
|
523
|
+
'undo',
|
|
524
|
+
'redo',
|
|
525
|
+
'notes',
|
|
526
|
+
'analysis',
|
|
527
|
+
'capabilities',
|
|
528
|
+
'exportState',
|
|
529
|
+
'outputLength',
|
|
530
|
+
'history',
|
|
531
|
+
'draftToken',
|
|
532
|
+
'evaluatePitch',
|
|
533
|
+
'mapCoordinate',
|
|
534
|
+
]);
|
|
535
|
+
|
|
536
|
+
function malformed(message: string): TypeError {
|
|
537
|
+
return new TypeError(`malformed vocal Worker message: ${message}`);
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
541
|
+
return value !== null && typeof value === 'object';
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
function isDecimalUint64(value: unknown): value is string {
|
|
545
|
+
return (
|
|
546
|
+
typeof value === 'string' &&
|
|
547
|
+
/^(0|[1-9]\d*)$/.test(value) &&
|
|
548
|
+
compareUint64(value, UINT64_MAX) <= 0
|
|
549
|
+
);
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
function optionalDecimal(value: unknown, field: string): void {
|
|
553
|
+
if (value !== undefined && !isDecimalUint64(value)) {
|
|
554
|
+
throw malformed(`${field} must be a decimal uint64 string`);
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/** The request id a reply can be addressed to, or undefined when none can. */
|
|
559
|
+
function replyId(data: unknown): number | undefined {
|
|
560
|
+
// A cancel names another request's id; answering it would settle that call.
|
|
561
|
+
if (!isRecord(data) || data.type === 'sonare:vocal-cancel') {
|
|
562
|
+
return undefined;
|
|
563
|
+
}
|
|
564
|
+
return Number.isSafeInteger(data.id) && (data.id as number) > 0 ? (data.id as number) : undefined;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/** Check the shape of an incoming message; payload contents are checked natively. */
|
|
568
|
+
function validateRequest(data: unknown): VocalWorkerRequestMessage {
|
|
569
|
+
if (!isRecord(data)) {
|
|
570
|
+
throw malformed('message must be an object');
|
|
571
|
+
}
|
|
572
|
+
if (!Number.isSafeInteger(data.id) || (data.id as number) <= 0) {
|
|
573
|
+
throw malformed('id must be a positive safe integer');
|
|
574
|
+
}
|
|
575
|
+
if (typeof data.sessionId !== 'string' || data.sessionId.length === 0) {
|
|
576
|
+
throw malformed('sessionId must be a non-empty string');
|
|
577
|
+
}
|
|
578
|
+
if (!isDecimalUint64(data.clientIntentSequence)) {
|
|
579
|
+
throw malformed('clientIntentSequence must be a decimal uint64 string');
|
|
580
|
+
}
|
|
581
|
+
if (
|
|
582
|
+
data.cancelBuffer !== undefined &&
|
|
583
|
+
!(
|
|
584
|
+
typeof SharedArrayBuffer !== 'undefined' &&
|
|
585
|
+
data.cancelBuffer instanceof SharedArrayBuffer &&
|
|
586
|
+
data.cancelBuffer.byteLength >= Int32Array.BYTES_PER_ELEMENT
|
|
587
|
+
)
|
|
588
|
+
) {
|
|
589
|
+
throw malformed('cancelBuffer must be a SharedArrayBuffer');
|
|
590
|
+
}
|
|
591
|
+
if (
|
|
592
|
+
data.baseToken !== undefined &&
|
|
593
|
+
!(isRecord(data.baseToken) && isDecimalUint64(data.baseToken.sessionEpoch))
|
|
594
|
+
) {
|
|
595
|
+
throw malformed('baseToken must be a state token');
|
|
596
|
+
}
|
|
597
|
+
switch (data.type) {
|
|
598
|
+
case 'sonare:vocal-create': {
|
|
599
|
+
const request = data.request;
|
|
600
|
+
if (
|
|
601
|
+
!isRecord(request) ||
|
|
602
|
+
(request.kind !== 'create' && request.kind !== 'restore') ||
|
|
603
|
+
!isRecord(request.request)
|
|
604
|
+
) {
|
|
605
|
+
throw malformed('create request must name create or restore with a request object');
|
|
606
|
+
}
|
|
607
|
+
break;
|
|
608
|
+
}
|
|
609
|
+
case 'sonare:vocal-mutate': {
|
|
610
|
+
const mutation = data.mutation;
|
|
611
|
+
if (
|
|
612
|
+
!isRecord(mutation) ||
|
|
613
|
+
typeof mutation.kind !== 'string' ||
|
|
614
|
+
!mutationKinds.has(mutation.kind)
|
|
615
|
+
) {
|
|
616
|
+
throw malformed('mutation.kind is not a vocal Worker mutation');
|
|
617
|
+
}
|
|
618
|
+
if (mutation.kind === 'apply' && !isRecord(mutation.request)) {
|
|
619
|
+
throw malformed('apply mutation must carry a request object');
|
|
620
|
+
}
|
|
621
|
+
if (mutation.kind === 'commit' && !isDecimalUint64(mutation.expectedRevision)) {
|
|
622
|
+
throw malformed('commit expectedRevision must be a decimal uint64 string');
|
|
623
|
+
}
|
|
624
|
+
optionalDecimal(mutation.expectedRevision, 'expectedRevision');
|
|
625
|
+
break;
|
|
626
|
+
}
|
|
627
|
+
case 'sonare:vocal-preview':
|
|
628
|
+
if (!isRecord(data.request)) {
|
|
629
|
+
throw malformed('preview request must be an object');
|
|
630
|
+
}
|
|
631
|
+
break;
|
|
632
|
+
case 'sonare:vocal-dispose':
|
|
633
|
+
case 'sonare:vocal-cancel':
|
|
634
|
+
break;
|
|
635
|
+
default:
|
|
636
|
+
throw malformed('unknown message type');
|
|
637
|
+
}
|
|
638
|
+
return data as unknown as VocalWorkerRequestMessage;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
/** Install the dedicated vocal-edit protocol on a browser or Node endpoint. */
|
|
642
|
+
export function installVocalEditWorkerEndpoint(
|
|
643
|
+
endpoint: VocalEditWorkerEndpoint,
|
|
644
|
+
dependencies: VocalEditWorkerDependencies = {},
|
|
645
|
+
): void {
|
|
646
|
+
const sessions = new Map<string, WorkerSession>();
|
|
647
|
+
const creating = new Map<number, ActiveCommand>();
|
|
648
|
+
const factory = dependencies.factory ?? productionFactory;
|
|
649
|
+
const initialize = dependencies.initialize ?? init;
|
|
650
|
+
|
|
651
|
+
const cancel = (message: VocalWorkerRequestMessage): void => {
|
|
652
|
+
const pendingCreate = creating.get(message.id);
|
|
653
|
+
if (pendingCreate) {
|
|
654
|
+
pendingCreate.cancelled = true;
|
|
655
|
+
return;
|
|
656
|
+
}
|
|
657
|
+
const session = sessions.get(message.sessionId);
|
|
658
|
+
if (!session) {
|
|
659
|
+
return;
|
|
660
|
+
}
|
|
661
|
+
// Mutations are FIFO and never dropped, so a cancel reaches previews only.
|
|
662
|
+
const running = session.running;
|
|
663
|
+
if (running?.id === message.id && running.preview) {
|
|
664
|
+
running.cancelled = true;
|
|
665
|
+
return;
|
|
666
|
+
}
|
|
667
|
+
const queuedIndex = session.queue.findIndex(
|
|
668
|
+
(command) =>
|
|
669
|
+
command.message.id === message.id && command.message.type === 'sonare:vocal-preview',
|
|
670
|
+
);
|
|
671
|
+
if (queuedIndex >= 0) {
|
|
672
|
+
const [queued] = session.queue.splice(queuedIndex, 1);
|
|
673
|
+
postError(
|
|
674
|
+
endpoint,
|
|
675
|
+
queued.message.id,
|
|
676
|
+
queued.message.sessionId,
|
|
677
|
+
queued.message.clientIntentSequence,
|
|
678
|
+
cancelledError(),
|
|
679
|
+
currentToken(session),
|
|
680
|
+
);
|
|
681
|
+
}
|
|
682
|
+
};
|
|
683
|
+
|
|
684
|
+
const create = async (message: VocalWorkerCreateMessage): Promise<void> => {
|
|
685
|
+
const active: ActiveCommand = {
|
|
686
|
+
id: message.id,
|
|
687
|
+
cancelled: false,
|
|
688
|
+
preview: false,
|
|
689
|
+
cancelBuffer: message.cancelBuffer,
|
|
690
|
+
};
|
|
691
|
+
creating.set(message.id, active);
|
|
692
|
+
let value: VocalEditWorkerNativeSession | undefined;
|
|
693
|
+
try {
|
|
694
|
+
await initialize();
|
|
695
|
+
if (isCancelled(active)) {
|
|
696
|
+
throw cancelledError();
|
|
697
|
+
}
|
|
698
|
+
if (sessions.has(message.sessionId)) {
|
|
699
|
+
throw new Error('Vocal session id is already in use');
|
|
700
|
+
}
|
|
701
|
+
value =
|
|
702
|
+
message.request.kind === 'create'
|
|
703
|
+
? factory.create(message.request.request)
|
|
704
|
+
: factory.restore(message.request.request);
|
|
705
|
+
if (isCancelled(active)) {
|
|
706
|
+
throw cancelledError();
|
|
707
|
+
}
|
|
708
|
+
const result: VocalWorkerCreateResult = {
|
|
709
|
+
sessionId: message.sessionId,
|
|
710
|
+
token: value.token(),
|
|
711
|
+
notes: value.notes(),
|
|
712
|
+
analysis: value.analysis(),
|
|
713
|
+
capabilities: value.capabilities(),
|
|
714
|
+
};
|
|
715
|
+
sessions.set(message.sessionId, {
|
|
716
|
+
value,
|
|
717
|
+
latestIntent: message.clientIntentSequence,
|
|
718
|
+
queue: [],
|
|
719
|
+
running: undefined,
|
|
720
|
+
draining: false,
|
|
721
|
+
disposing: false,
|
|
722
|
+
});
|
|
723
|
+
value = undefined;
|
|
724
|
+
postResult(
|
|
725
|
+
endpoint,
|
|
726
|
+
message.id,
|
|
727
|
+
message.sessionId,
|
|
728
|
+
message.clientIntentSequence,
|
|
729
|
+
result,
|
|
730
|
+
result.token,
|
|
731
|
+
);
|
|
732
|
+
} catch (error) {
|
|
733
|
+
value?.dispose();
|
|
734
|
+
postError(endpoint, message.id, message.sessionId, message.clientIntentSequence, error);
|
|
735
|
+
} finally {
|
|
736
|
+
creating.delete(message.id);
|
|
737
|
+
}
|
|
738
|
+
};
|
|
739
|
+
|
|
740
|
+
const dispose = (message: VocalWorkerRequestMessage): void => {
|
|
741
|
+
const session = sessions.get(message.sessionId);
|
|
742
|
+
if (!session) {
|
|
743
|
+
// Nothing to release, but the client still waits for this reply.
|
|
744
|
+
postResult(endpoint, message.id, message.sessionId, message.clientIntentSequence, null);
|
|
745
|
+
return;
|
|
746
|
+
}
|
|
747
|
+
if (session.disposing) {
|
|
748
|
+
// A repeated dispose settles with the first one.
|
|
749
|
+
void session.released?.then(() =>
|
|
750
|
+
postResult(endpoint, message.id, message.sessionId, message.clientIntentSequence, null),
|
|
751
|
+
);
|
|
752
|
+
return;
|
|
753
|
+
}
|
|
754
|
+
session.disposing = true;
|
|
755
|
+
const active = session.running;
|
|
756
|
+
if (active) {
|
|
757
|
+
active.cancelled = true;
|
|
758
|
+
}
|
|
759
|
+
rejectQueuedCommands(endpoint, session, new Error('Vocal session disposed'));
|
|
760
|
+
session.released = (async (): Promise<void> => {
|
|
761
|
+
while (session.draining) {
|
|
762
|
+
await nextMacrotask();
|
|
763
|
+
}
|
|
764
|
+
session.draft?.dispose();
|
|
765
|
+
session.value.dispose();
|
|
766
|
+
sessions.delete(message.sessionId);
|
|
767
|
+
})();
|
|
768
|
+
void session.released.then(() =>
|
|
769
|
+
postResult(endpoint, message.id, message.sessionId, message.clientIntentSequence, null),
|
|
770
|
+
);
|
|
771
|
+
};
|
|
772
|
+
|
|
773
|
+
endpoint.addEventListener('message', (event) => {
|
|
774
|
+
const data: unknown = event.data;
|
|
775
|
+
try {
|
|
776
|
+
const message = validateRequest(data);
|
|
777
|
+
switch (message.type) {
|
|
778
|
+
case 'sonare:vocal-cancel':
|
|
779
|
+
cancel(message);
|
|
780
|
+
return;
|
|
781
|
+
case 'sonare:vocal-create':
|
|
782
|
+
void create(message);
|
|
783
|
+
return;
|
|
784
|
+
case 'sonare:vocal-dispose':
|
|
785
|
+
dispose(message);
|
|
786
|
+
return;
|
|
787
|
+
default:
|
|
788
|
+
queueCommand(endpoint, sessions, message);
|
|
789
|
+
}
|
|
790
|
+
} catch (error) {
|
|
791
|
+
const id = replyId(data);
|
|
792
|
+
if (id === undefined) {
|
|
793
|
+
return;
|
|
794
|
+
}
|
|
795
|
+
const record = data as Record<string, unknown>;
|
|
796
|
+
try {
|
|
797
|
+
postError(
|
|
798
|
+
endpoint,
|
|
799
|
+
id,
|
|
800
|
+
typeof record.sessionId === 'string' ? record.sessionId : '',
|
|
801
|
+
isDecimalUint64(record.clientIntentSequence) ? record.clientIntentSequence : '0',
|
|
802
|
+
error,
|
|
803
|
+
);
|
|
804
|
+
} catch {
|
|
805
|
+
// The endpoint itself failed; there is no channel left to report on.
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
});
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
function browserEndpoint(): VocalEditWorkerEndpoint | null {
|
|
812
|
+
const scope = globalThis as unknown as {
|
|
813
|
+
document?: unknown;
|
|
814
|
+
postMessage?: (message: VocalWorkerResponseMessage, transfer?: Transferable[]) => void;
|
|
815
|
+
addEventListener?: (
|
|
816
|
+
type: 'message',
|
|
817
|
+
listener: (event: MessageEvent<VocalWorkerRequestMessage>) => void,
|
|
818
|
+
) => void;
|
|
819
|
+
};
|
|
820
|
+
if (scope.document !== undefined || !scope.postMessage || !scope.addEventListener) {
|
|
821
|
+
return null;
|
|
822
|
+
}
|
|
823
|
+
return {
|
|
824
|
+
postMessage: (message, transfer) => scope.postMessage?.(message, transfer),
|
|
825
|
+
addEventListener: (type, listener) => scope.addEventListener?.(type, listener),
|
|
826
|
+
};
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
const endpoint = browserEndpoint();
|
|
830
|
+
if (endpoint) {
|
|
831
|
+
installVocalEditWorkerEndpoint(endpoint);
|
|
832
|
+
}
|