@libraz/libsonare 1.8.1 → 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.
Files changed (107) hide show
  1. package/README.md +5 -5
  2. package/dist/analysis.js +2 -0
  3. package/dist/analysis.js.map +1 -1
  4. package/dist/index.d.ts +8 -2
  5. package/dist/index.d.ts.map +1 -1
  6. package/dist/index.js +1927 -140
  7. package/dist/index.js.map +1 -1
  8. package/dist/instrument_types.d.ts +27 -3
  9. package/dist/instrument_types.d.ts.map +1 -1
  10. package/dist/mastering_core.d.ts +3 -2
  11. package/dist/mastering_core.d.ts.map +1 -1
  12. package/dist/mixer.d.ts +2 -2
  13. package/dist/project.d.ts +5 -4
  14. package/dist/project.d.ts.map +1 -1
  15. package/dist/project_class.d.ts +57 -3
  16. package/dist/project_class.d.ts.map +1 -1
  17. package/dist/project_internal.d.ts +35 -1
  18. package/dist/project_internal.d.ts.map +1 -1
  19. package/dist/project_types.d.ts +10 -1
  20. package/dist/project_types.d.ts.map +1 -1
  21. package/dist/public_types.d.ts +1 -0
  22. package/dist/public_types.d.ts.map +1 -1
  23. package/dist/public_types_mastering.d.ts +1 -1
  24. package/dist/public_types_mastering.d.ts.map +1 -1
  25. package/dist/public_types_vocal_edit.d.ts +250 -0
  26. package/dist/public_types_vocal_edit.d.ts.map +1 -0
  27. package/dist/public_types_vocal_project.d.ts +63 -0
  28. package/dist/public_types_vocal_project.d.ts.map +1 -0
  29. package/dist/realtime_engine.d.ts +57 -10
  30. package/dist/realtime_engine.d.ts.map +1 -1
  31. package/dist/repair_dereverb.d.ts +1 -0
  32. package/dist/repair_dereverb.d.ts.map +1 -1
  33. package/dist/repair_noise.d.ts +1 -0
  34. package/dist/repair_noise.d.ts.map +1 -1
  35. package/dist/sonare-analysis.wasm +0 -0
  36. package/dist/sonare.d.ts +53 -0
  37. package/dist/sonare.js +1 -1
  38. package/dist/sonare.wasm +0 -0
  39. package/dist/vocal_edit.d.ts +131 -0
  40. package/dist/vocal_edit.d.ts.map +1 -0
  41. package/dist/vocal_edit_worker.d.ts +51 -0
  42. package/dist/vocal_edit_worker.d.ts.map +1 -0
  43. package/dist/vocal_edit_worker.js +1462 -0
  44. package/dist/vocal_edit_worker.js.map +1 -0
  45. package/dist/vocal_edit_worker_client.d.ts +118 -0
  46. package/dist/vocal_edit_worker_client.d.ts.map +1 -0
  47. package/dist/vocal_edit_worker_protocol.d.ts +139 -0
  48. package/dist/vocal_edit_worker_protocol.d.ts.map +1 -0
  49. package/dist/vocal_project.d.ts +17 -0
  50. package/dist/vocal_project.d.ts.map +1 -0
  51. package/dist/web_midi.d.ts +4 -0
  52. package/dist/web_midi.d.ts.map +1 -1
  53. package/dist/worker.js.map +1 -1
  54. package/dist/worklet/engine-mixer-facade.d.ts +2 -1
  55. package/dist/worklet/engine-mixer-facade.d.ts.map +1 -1
  56. package/dist/worklet/engine-node.d.ts +8 -0
  57. package/dist/worklet/engine-node.d.ts.map +1 -1
  58. package/dist/worklet/engine-processor.d.ts +2 -1
  59. package/dist/worklet/engine-processor.d.ts.map +1 -1
  60. package/dist/worklet/engine-register.d.ts.map +1 -1
  61. package/dist/worklet/engine-strips.d.ts +4 -2
  62. package/dist/worklet/engine-strips.d.ts.map +1 -1
  63. package/dist/worklet/engine.d.ts +15 -3
  64. package/dist/worklet/engine.d.ts.map +1 -1
  65. package/dist/worklet/guards.d.ts +3 -1
  66. package/dist/worklet/guards.d.ts.map +1 -1
  67. package/dist/worklet/messages.d.ts +50 -5
  68. package/dist/worklet/messages.d.ts.map +1 -1
  69. package/dist/worklet/protocol.d.ts +10 -0
  70. package/dist/worklet/protocol.d.ts.map +1 -1
  71. package/dist/worklet.d.ts +1 -1
  72. package/dist/worklet.d.ts.map +1 -1
  73. package/dist/worklet.js +223 -16
  74. package/dist/worklet.js.map +1 -1
  75. package/package.json +6 -1
  76. package/src/index.ts +33 -0
  77. package/src/instrument_types.ts +29 -3
  78. package/src/mastering_core.ts +3 -2
  79. package/src/mixer.ts +2 -2
  80. package/src/project.ts +8 -1
  81. package/src/project_class.ts +136 -1
  82. package/src/project_internal.ts +90 -0
  83. package/src/project_types.ts +11 -1
  84. package/src/public_types.ts +1 -0
  85. package/src/public_types_mastering.ts +2 -0
  86. package/src/public_types_vocal_edit.ts +289 -0
  87. package/src/public_types_vocal_project.ts +69 -0
  88. package/src/realtime_engine.ts +86 -9
  89. package/src/repair_dereverb.ts +1 -0
  90. package/src/repair_noise.ts +1 -0
  91. package/src/sonare.js.d.ts +53 -0
  92. package/src/vocal_edit.ts +885 -0
  93. package/src/vocal_edit_worker.ts +832 -0
  94. package/src/vocal_edit_worker_client.ts +812 -0
  95. package/src/vocal_edit_worker_protocol.ts +206 -0
  96. package/src/vocal_project.ts +327 -0
  97. package/src/web_midi.ts +100 -5
  98. package/src/worklet/engine-mixer-facade.ts +2 -1
  99. package/src/worklet/engine-node.ts +47 -0
  100. package/src/worklet/engine-processor.ts +46 -1
  101. package/src/worklet/engine-register.ts +3 -0
  102. package/src/worklet/engine-strips.ts +31 -4
  103. package/src/worklet/engine.ts +27 -3
  104. package/src/worklet/guards.ts +26 -0
  105. package/src/worklet/messages.ts +59 -2
  106. package/src/worklet/protocol.ts +10 -0
  107. package/src/worklet.ts +2 -0
@@ -0,0 +1,812 @@
1
+ import { SonareError } from './errors';
2
+ import type {
3
+ VocalAnalysis,
4
+ VocalApplyRequest,
5
+ VocalCapabilities,
6
+ VocalCreateRequest,
7
+ VocalEditResult,
8
+ VocalHistoryState,
9
+ VocalNotesResult,
10
+ VocalPitchEvaluation,
11
+ VocalRenderRequest,
12
+ VocalRenderResult,
13
+ VocalRestoreRequest,
14
+ VocalStateBytes,
15
+ VocalStateToken,
16
+ VocalUint64,
17
+ } from './public_types_vocal_edit';
18
+ import {
19
+ compareUint64,
20
+ stateTokenEquals,
21
+ type VocalWorkerCreateMessage,
22
+ type VocalWorkerCreateResult,
23
+ type VocalWorkerErrorMessage,
24
+ type VocalWorkerMutateMessage,
25
+ type VocalWorkerMutation,
26
+ type VocalWorkerPreviewMessage,
27
+ type VocalWorkerRequestMessage,
28
+ type VocalWorkerResponseMessage,
29
+ } from './vocal_edit_worker_protocol';
30
+
31
+ export interface VocalEditWorker {
32
+ postMessage(message: unknown, transfer?: Transferable[]): void;
33
+ terminate(): unknown;
34
+ addEventListener?(type: string, listener: EventListener): void;
35
+ removeEventListener?(type: string, listener: EventListener): void;
36
+ on?(type: 'message' | 'error', listener: (...args: unknown[]) => void): unknown;
37
+ off?(type: 'message' | 'error', listener: (...args: unknown[]) => void): unknown;
38
+ }
39
+
40
+ export interface VocalEditWorkerClientOptions {
41
+ worker?: VocalEditWorker;
42
+ workerUrl?: string | URL;
43
+ workerFactory?: (url: URL) => VocalEditWorker;
44
+ terminateWorkerOnDispose?: boolean;
45
+ }
46
+
47
+ /** Per-call transfer behaviour for a request that carries typed arrays. */
48
+ export interface VocalEditWorkerTransferOptions {
49
+ /**
50
+ * Preserve the caller's typed arrays by copying them before dispatch.
51
+ *
52
+ * The default is `false`: typed-array buffers are transferred to the Worker
53
+ * and become detached on the calling thread, avoiding a second copy.
54
+ */
55
+ copy?: boolean;
56
+ }
57
+
58
+ export interface VocalEditWorkerCallOptions extends VocalEditWorkerTransferOptions {
59
+ signal?: AbortSignal;
60
+ }
61
+
62
+ export class VocalEditWorkerStaleResultError extends Error {
63
+ constructor(message = 'A vocal edit Worker result is stale') {
64
+ super(message);
65
+ this.name = 'StaleResultError';
66
+ }
67
+ }
68
+
69
+ export class VocalEditWorkerTask<T> implements PromiseLike<T> {
70
+ constructor(
71
+ readonly result: Promise<T>,
72
+ private readonly cancelRequest: () => void,
73
+ ) {}
74
+
75
+ cancel(): void {
76
+ this.cancelRequest();
77
+ }
78
+
79
+ // biome-ignore lint/suspicious/noThenProperty: PromiseLike intentionally exposes then.
80
+ then<TResult1 = T, TResult2 = never>(
81
+ onfulfilled?: ((value: T) => TResult1 | PromiseLike<TResult1>) | null,
82
+ onrejected?: ((reason: unknown) => TResult2 | PromiseLike<TResult2>) | null,
83
+ ): Promise<TResult1 | TResult2> {
84
+ return this.result.then(onfulfilled, onrejected);
85
+ }
86
+
87
+ catch<TResult = never>(
88
+ onrejected?: ((reason: unknown) => TResult | PromiseLike<TResult>) | null,
89
+ ): Promise<T | TResult> {
90
+ return this.result.catch(onrejected);
91
+ }
92
+
93
+ finally(onfinally?: (() => void) | null): Promise<T> {
94
+ return this.result.finally(onfinally);
95
+ }
96
+ }
97
+
98
+ type RevisionArgument = VocalUint64 | { expectedRevision?: VocalUint64 };
99
+
100
+ function resolveRevision(
101
+ argument: RevisionArgument | undefined,
102
+ fallback: VocalUint64,
103
+ ): VocalUint64 {
104
+ const value = typeof argument === 'string' ? argument : argument?.expectedRevision;
105
+ return value ?? fallback;
106
+ }
107
+
108
+ interface PendingCall {
109
+ resolve: (value: unknown) => void;
110
+ reject: (reason: unknown) => void;
111
+ sessionId: string;
112
+ intent: VocalUint64;
113
+ baseToken?: VocalStateToken;
114
+ preview: boolean;
115
+ session?: VocalEditWorkerSession;
116
+ cancelFlag?: Int32Array;
117
+ signal?: AbortSignal;
118
+ abortListener?: () => void;
119
+ }
120
+
121
+ function incrementDecimal(value: VocalUint64): VocalUint64 {
122
+ if (compareUint64(value, '18446744073709551615') >= 0) {
123
+ throw new RangeError('vocal Worker intent sequence exhausted uint64');
124
+ }
125
+ const digits = value.split('').map((digit) => digit.charCodeAt(0) - 48);
126
+ let carry = 1;
127
+ for (let i = digits.length - 1; i >= 0; i--) {
128
+ const next = digits[i] + carry;
129
+ digits[i] = next % 10;
130
+ carry = Math.floor(next / 10);
131
+ }
132
+ if (carry !== 0) {
133
+ digits.unshift(carry);
134
+ }
135
+ return digits.join('');
136
+ }
137
+
138
+ function cancellationFlag(): Int32Array | undefined {
139
+ if (typeof SharedArrayBuffer === 'undefined') {
140
+ return undefined;
141
+ }
142
+ return new Int32Array(new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT));
143
+ }
144
+
145
+ function cloneForWorker(
146
+ value: unknown,
147
+ copy: boolean,
148
+ transfers: Transferable[],
149
+ transferred = new Set<ArrayBuffer>(),
150
+ ): unknown {
151
+ if (value instanceof Float32Array) {
152
+ const next = copy ? value.slice() : value;
153
+ if (next.buffer instanceof ArrayBuffer && !transferred.has(next.buffer)) {
154
+ transferred.add(next.buffer);
155
+ transfers.push(next.buffer);
156
+ }
157
+ return next;
158
+ }
159
+ if (value instanceof Uint8Array) {
160
+ const next = copy ? value.slice() : value;
161
+ if (next.buffer instanceof ArrayBuffer && !transferred.has(next.buffer)) {
162
+ transferred.add(next.buffer);
163
+ transfers.push(next.buffer);
164
+ }
165
+ return next;
166
+ }
167
+ if (Array.isArray(value)) {
168
+ return value.map((item) => cloneForWorker(item, copy, transfers, transferred));
169
+ }
170
+ if (value !== null && typeof value === 'object') {
171
+ return Object.fromEntries(
172
+ Object.entries(value).map(([key, item]) => [
173
+ key,
174
+ cloneForWorker(item, copy, transfers, transferred),
175
+ ]),
176
+ );
177
+ }
178
+ return value;
179
+ }
180
+
181
+ function cloneRequest<T>(request: T, copy: boolean, transfers: Transferable[]): T {
182
+ return cloneForWorker(request, copy, transfers) as T;
183
+ }
184
+
185
+ function workerError(message: VocalWorkerErrorMessage): Error {
186
+ if (message.error.name === 'StaleResultError') {
187
+ return new VocalEditWorkerStaleResultError(message.error.message);
188
+ }
189
+ const { code, codeName } = message.error;
190
+ const error =
191
+ message.error.name === 'SonareError' && typeof code === 'number'
192
+ ? new SonareError(code, codeName ?? '', message.error.message)
193
+ : new Error(message.error.message);
194
+ error.name = message.error.name;
195
+ for (const key of [
196
+ 'code',
197
+ 'reason',
198
+ 'field',
199
+ 'expected',
200
+ 'actual',
201
+ 'expectedText',
202
+ 'actualText',
203
+ ] as const) {
204
+ const value = message.error[key];
205
+ if (value !== undefined) {
206
+ Object.defineProperty(error, key, { value, enumerable: true, configurable: true });
207
+ }
208
+ }
209
+ return error;
210
+ }
211
+
212
+ function sameSessionToken(a: VocalStateToken, b: VocalStateToken): boolean {
213
+ return a.sessionEpoch === b.sessionEpoch && a.profileId === b.profileId;
214
+ }
215
+
216
+ function nextSessionId(counter: number): string {
217
+ return `vocal-${counter.toString(36)}`;
218
+ }
219
+
220
+ function abortError(): DOMException {
221
+ return new DOMException('The operation was aborted', 'AbortError');
222
+ }
223
+
224
+ export class VocalEditWorkerClient {
225
+ private readonly worker: VocalEditWorker;
226
+ private readonly ownsWorker: boolean;
227
+ private readonly pending = new Map<number, PendingCall>();
228
+ private readonly latestIntent = new Map<string, VocalUint64>();
229
+ private readonly sessions = new Set<VocalEditWorkerSession>();
230
+ private nextId = 1;
231
+ private nextSession = 1;
232
+ private closed = false;
233
+ private usesEventTarget = false;
234
+
235
+ constructor(options: VocalEditWorkerClientOptions = {}) {
236
+ this.ownsWorker = options.worker === undefined || options.terminateWorkerOnDispose === true;
237
+ if (options.worker) {
238
+ this.worker = options.worker;
239
+ } else {
240
+ if (!options.workerFactory && typeof Worker === 'undefined') {
241
+ throw new Error('VocalEditWorkerClient requires a Worker implementation');
242
+ }
243
+ const url =
244
+ options.workerUrl === undefined
245
+ ? new URL('./vocal_edit_worker.js', import.meta.url)
246
+ : new URL(options.workerUrl, import.meta.url);
247
+ this.worker =
248
+ options.workerFactory?.(url) ??
249
+ new Worker(url, { type: 'module', name: 'sonare-vocal-edit' });
250
+ }
251
+ if (this.worker.addEventListener) {
252
+ this.usesEventTarget = true;
253
+ this.worker.addEventListener('message', this.onMessage as EventListener);
254
+ this.worker.addEventListener('error', this.onError as EventListener);
255
+ } else if (this.worker.on) {
256
+ this.worker.on('message', this.onNodeMessage);
257
+ this.worker.on('error', this.onNodeError);
258
+ } else {
259
+ throw new TypeError('VocalEditWorkerClient requires Worker event listeners');
260
+ }
261
+ }
262
+
263
+ create(
264
+ request: VocalCreateRequest,
265
+ options: VocalEditWorkerCallOptions = {},
266
+ ): VocalEditWorkerTask<VocalEditWorkerSession> {
267
+ return this.openSession({ kind: 'create', request }, options);
268
+ }
269
+
270
+ restore(
271
+ request: VocalRestoreRequest,
272
+ options: VocalEditWorkerCallOptions = {},
273
+ ): VocalEditWorkerTask<VocalEditWorkerSession> {
274
+ return this.openSession({ kind: 'restore', request }, options);
275
+ }
276
+
277
+ private postDisposeMessage(sessionId: string, clientIntentSequence: VocalUint64): void {
278
+ try {
279
+ this.worker.postMessage({
280
+ type: 'sonare:vocal-dispose',
281
+ id: this.nextId++,
282
+ sessionId,
283
+ clientIntentSequence,
284
+ });
285
+ } catch {
286
+ // Disposal is best effort when a host closes the Worker concurrently.
287
+ }
288
+ }
289
+
290
+ private postCancelMessage(id: number, pending: PendingCall): void {
291
+ if (pending.cancelFlag) {
292
+ Atomics.store(pending.cancelFlag, 0, 1);
293
+ }
294
+ try {
295
+ this.worker.postMessage({
296
+ type: 'sonare:vocal-cancel',
297
+ id,
298
+ sessionId: pending.sessionId,
299
+ clientIntentSequence: pending.intent,
300
+ });
301
+ } catch {
302
+ // Disposal still rejects the local task when the Worker is unavailable.
303
+ }
304
+ }
305
+
306
+ dispose(): void {
307
+ if (this.closed) {
308
+ return;
309
+ }
310
+ const pendingEntries = [...this.pending.entries()];
311
+ const sessionIntents = new Map<string, VocalUint64>();
312
+ for (const session of this.sessions) {
313
+ sessionIntents.set(session.sessionId, this.latestIntent.get(session.sessionId) ?? '0');
314
+ }
315
+ for (const [, pending] of pendingEntries) {
316
+ if (!sessionIntents.has(pending.sessionId)) {
317
+ sessionIntents.set(pending.sessionId, pending.intent);
318
+ }
319
+ }
320
+ this.closed = true;
321
+ // A create can finish after this method returns. Cancel it first, then
322
+ // dispose every known session id to cover the race where native creation
323
+ // has already installed the session before the cancel is observed.
324
+ for (const [id, pending] of pendingEntries) {
325
+ this.postCancelMessage(id, pending);
326
+ }
327
+ for (const [sessionId, intent] of sessionIntents) {
328
+ this.postDisposeMessage(sessionId, intent);
329
+ }
330
+ if (this.usesEventTarget) {
331
+ this.worker.removeEventListener?.('message', this.onMessage as EventListener);
332
+ this.worker.removeEventListener?.('error', this.onError as EventListener);
333
+ } else {
334
+ this.worker.off?.('message', this.onNodeMessage);
335
+ this.worker.off?.('error', this.onNodeError);
336
+ }
337
+ for (const [, pending] of pendingEntries) {
338
+ pending.abortListener && pending.signal?.removeEventListener('abort', pending.abortListener);
339
+ pending.reject(new Error('VocalEditWorkerClient was disposed'));
340
+ }
341
+ this.pending.clear();
342
+ this.sessions.clear();
343
+ this.latestIntent.clear();
344
+ if (this.ownsWorker) {
345
+ this.worker.terminate();
346
+ }
347
+ }
348
+
349
+ /** @internal Called by VocalEditWorkerSession. */
350
+ disposeSession(session: VocalEditWorkerSession): VocalEditWorkerTask<null> {
351
+ const task = this.call({
352
+ type: 'sonare:vocal-dispose',
353
+ id: 0,
354
+ sessionId: session.sessionId,
355
+ clientIntentSequence: this.intent(session.sessionId),
356
+ });
357
+ const result = task.result.then(() => {
358
+ this.sessions.delete(session);
359
+ this.latestIntent.delete(session.sessionId);
360
+ return null;
361
+ });
362
+ return new VocalEditWorkerTask(result, () => task.cancel());
363
+ }
364
+
365
+ /** @internal */
366
+ callMutation<T>(
367
+ session: VocalEditWorkerSession,
368
+ mutation: VocalWorkerMutation,
369
+ options: VocalEditWorkerTransferOptions = {},
370
+ ): VocalEditWorkerTask<T> {
371
+ const message: VocalWorkerMutateMessage = {
372
+ type: 'sonare:vocal-mutate',
373
+ id: 0,
374
+ sessionId: session.sessionId,
375
+ clientIntentSequence: this.intent(session.sessionId),
376
+ baseToken: session.currentToken(),
377
+ mutation,
378
+ };
379
+ // The reply token is adopted in onMessage, before any stale-intent check.
380
+ const task = this.call<T>(message, { copy: options.copy }, session);
381
+ const result = task.result.then((response) => response.result);
382
+ // Mutations are FIFO and never dropped, so a mutation cannot be cancelled.
383
+ return new VocalEditWorkerTask(result, () => {});
384
+ }
385
+
386
+ /** @internal */
387
+ callPreview(
388
+ session: VocalEditWorkerSession,
389
+ request: VocalRenderRequest,
390
+ options: VocalEditWorkerTransferOptions = {},
391
+ ): VocalEditWorkerTask<VocalRenderResult> {
392
+ const signal = request.signal;
393
+ const { signal: _ignoredSignal, ...portableRequest } = request;
394
+ const message: VocalWorkerPreviewMessage = {
395
+ type: 'sonare:vocal-preview',
396
+ id: 0,
397
+ sessionId: session.sessionId,
398
+ clientIntentSequence: this.intent(session.sessionId),
399
+ baseToken: session.currentToken(),
400
+ request: portableRequest,
401
+ };
402
+ const task = this.call<VocalRenderResult>(message, { signal, copy: options.copy }, session);
403
+ // A render result carries requestId/profile metadata in its token. It is
404
+ // a snapshot of the state used for rendering, not a new session token, so
405
+ // it is compared against the session token and never adopted.
406
+ const result = task.result.then((response) => response.result);
407
+ return new VocalEditWorkerTask(result, () => task.cancel());
408
+ }
409
+
410
+ private intent(sessionId: string): VocalUint64 {
411
+ const next = incrementDecimal(this.latestIntent.get(sessionId) ?? '0');
412
+ this.latestIntent.set(sessionId, next);
413
+ return next;
414
+ }
415
+
416
+ private openSession(
417
+ request: VocalWorkerCreateMessage['request'],
418
+ options: VocalEditWorkerCallOptions,
419
+ ): VocalEditWorkerTask<VocalEditWorkerSession> {
420
+ const sessionId = nextSessionId(this.nextSession++);
421
+ let cancelled = false;
422
+ const task = this.callCreate(sessionId, request, options);
423
+ const result = task.result.then((value) => {
424
+ const session = new VocalEditWorkerSession(this, sessionId, value.token, value);
425
+ // The Worker finished before it saw the cancellation; release its session.
426
+ if (cancelled || options.signal?.aborted) {
427
+ if (!this.closed) {
428
+ session.dispose().result.catch(() => {});
429
+ }
430
+ throw abortError();
431
+ }
432
+ this.sessions.add(session);
433
+ return session;
434
+ });
435
+ return new VocalEditWorkerTask(result, () => {
436
+ cancelled = true;
437
+ task.cancel();
438
+ });
439
+ }
440
+
441
+ private callCreate(
442
+ sessionId: string,
443
+ request: VocalWorkerCreateMessage['request'],
444
+ options: VocalEditWorkerCallOptions,
445
+ ): VocalEditWorkerTask<VocalWorkerCreateResult> {
446
+ const message: VocalWorkerCreateMessage = {
447
+ type: 'sonare:vocal-create',
448
+ id: 0,
449
+ sessionId,
450
+ clientIntentSequence: this.intent(sessionId),
451
+ request,
452
+ };
453
+ const task = this.call<unknown>(message, options);
454
+ const result = task.result.then((response) => response.result as VocalWorkerCreateResult);
455
+ return new VocalEditWorkerTask(result, () => task.cancel());
456
+ }
457
+
458
+ private call<T = unknown>(
459
+ input: VocalWorkerRequestMessage,
460
+ options: VocalEditWorkerCallOptions = {},
461
+ session?: VocalEditWorkerSession,
462
+ ): VocalEditWorkerTask<{ result: T; token?: VocalStateToken }> {
463
+ if (this.closed) {
464
+ throw new Error('VocalEditWorkerClient was disposed');
465
+ }
466
+ const id = this.nextId++;
467
+ const transfers: Transferable[] = [];
468
+ const message = cloneRequest(
469
+ { ...input, id },
470
+ options.copy === true,
471
+ transfers,
472
+ ) as VocalWorkerRequestMessage;
473
+ const cancelFlag = cancellationFlag();
474
+ if (cancelFlag) {
475
+ (
476
+ message as VocalWorkerCreateMessage | VocalWorkerMutateMessage | VocalWorkerPreviewMessage
477
+ ).cancelBuffer = cancelFlag.buffer as SharedArrayBuffer;
478
+ }
479
+ const result = new Promise<{ result: T; token?: VocalStateToken }>((resolve, reject) => {
480
+ if (options.signal?.aborted) {
481
+ reject(abortError());
482
+ return;
483
+ }
484
+ const pending: PendingCall = {
485
+ resolve: (value) => resolve(value as { result: T; token?: VocalStateToken }),
486
+ reject,
487
+ sessionId: message.sessionId,
488
+ intent: message.clientIntentSequence,
489
+ baseToken:
490
+ message.type === 'sonare:vocal-mutate' || message.type === 'sonare:vocal-preview'
491
+ ? message.baseToken
492
+ : undefined,
493
+ preview: message.type === 'sonare:vocal-preview',
494
+ session,
495
+ cancelFlag,
496
+ signal: options.signal,
497
+ };
498
+ if (options.signal) {
499
+ pending.abortListener = () => {
500
+ if (cancelFlag) {
501
+ Atomics.store(cancelFlag, 0, 1);
502
+ }
503
+ this.worker.postMessage({
504
+ type: 'sonare:vocal-cancel',
505
+ id,
506
+ sessionId: message.sessionId,
507
+ clientIntentSequence: message.clientIntentSequence,
508
+ });
509
+ };
510
+ options.signal.addEventListener('abort', pending.abortListener, { once: true });
511
+ }
512
+ this.pending.set(id, pending);
513
+ });
514
+ if (!options.signal?.aborted) {
515
+ try {
516
+ this.worker.postMessage(message, transfers);
517
+ } catch (error) {
518
+ // e.g. DataCloneError: the Worker never saw the call, so nothing will answer it.
519
+ const pending = this.pending.get(id);
520
+ if (pending) {
521
+ this.pending.delete(id);
522
+ if (pending.abortListener && pending.signal) {
523
+ pending.signal.removeEventListener('abort', pending.abortListener);
524
+ }
525
+ pending.reject(error);
526
+ }
527
+ }
528
+ }
529
+ return new VocalEditWorkerTask(result, () => {
530
+ const pending = this.pending.get(id);
531
+ if (!pending) {
532
+ return;
533
+ }
534
+ if (pending.cancelFlag) {
535
+ Atomics.store(pending.cancelFlag, 0, 1);
536
+ }
537
+ this.worker.postMessage({
538
+ type: 'sonare:vocal-cancel',
539
+ id,
540
+ sessionId: pending.sessionId,
541
+ clientIntentSequence: pending.intent,
542
+ });
543
+ });
544
+ }
545
+
546
+ private readonly onMessage = (event: MessageEvent<VocalWorkerResponseMessage>): void => {
547
+ const message = event.data;
548
+ if (message.type === 'sonare:vocal-progress') {
549
+ return;
550
+ }
551
+ const pending = this.pending.get(message.id);
552
+ if (!pending) {
553
+ return;
554
+ }
555
+ this.pending.delete(message.id);
556
+ if (pending.abortListener && pending.signal) {
557
+ pending.signal.removeEventListener('abort', pending.abortListener);
558
+ }
559
+ // Every mutation reply, result or error, carries the Worker's state after
560
+ // it ran. Adopt it before the intent check so a superseded reply still
561
+ // advances the token the next pipelined call will be compared against.
562
+ if (
563
+ !pending.preview &&
564
+ pending.session &&
565
+ message.token &&
566
+ pending.baseToken &&
567
+ sameSessionToken(message.token, pending.baseToken)
568
+ ) {
569
+ pending.session.updateToken(message.token);
570
+ }
571
+ const latest = this.latestIntent.get(pending.sessionId) ?? pending.intent;
572
+ if (compareUint64(pending.intent, latest) < 0) {
573
+ pending.reject(new VocalEditWorkerStaleResultError());
574
+ return;
575
+ }
576
+ if (message.token && pending.baseToken) {
577
+ const tokenMatches =
578
+ pending.preview && pending.session
579
+ ? stateTokenEquals(message.token, pending.session.latestToken())
580
+ : sameSessionToken(message.token, pending.baseToken);
581
+ if (!tokenMatches) {
582
+ pending.reject(new VocalEditWorkerStaleResultError());
583
+ return;
584
+ }
585
+ }
586
+ if (pending.preview && !message.token) {
587
+ pending.reject(new VocalEditWorkerStaleResultError());
588
+ return;
589
+ }
590
+ if (message.type === 'sonare:vocal-error') {
591
+ pending.reject(workerError(message));
592
+ return;
593
+ }
594
+ pending.resolve({ result: message.result, token: message.token });
595
+ };
596
+
597
+ private readonly onError = (event: ErrorEvent): void => {
598
+ const error = new Error(event.message || 'Vocal edit Worker failed');
599
+ for (const pending of this.pending.values()) {
600
+ pending.abortListener && pending.signal?.removeEventListener('abort', pending.abortListener);
601
+ pending.reject(error);
602
+ }
603
+ this.pending.clear();
604
+ };
605
+
606
+ private readonly onNodeMessage = (data: unknown): void => {
607
+ this.onMessage({ data } as MessageEvent<VocalWorkerResponseMessage>);
608
+ };
609
+
610
+ private readonly onNodeError = (error: unknown): void => {
611
+ this.onError({ message: error instanceof Error ? error.message : String(error) } as ErrorEvent);
612
+ };
613
+ }
614
+
615
+ /** Selects the open draft instead of the committed session for a read-only call. */
616
+ export interface VocalEditWorkerReadOptions {
617
+ draft?: boolean;
618
+ }
619
+
620
+ export class VocalEditWorkerSession {
621
+ readonly sessionId: string;
622
+ private tokenValue: VocalStateToken;
623
+ private disposed = false;
624
+ readonly created: VocalWorkerCreateResult;
625
+ private readonly client: VocalEditWorkerClient;
626
+
627
+ /** @internal Created by VocalEditWorkerClient.create/restore. */
628
+ constructor(
629
+ client: VocalEditWorkerClient,
630
+ sessionId: string,
631
+ token: VocalStateToken,
632
+ created: VocalWorkerCreateResult,
633
+ ) {
634
+ this.client = client;
635
+ this.sessionId = sessionId;
636
+ this.tokenValue = token;
637
+ this.created = created;
638
+ }
639
+
640
+ currentToken(): VocalStateToken {
641
+ this.requireAlive();
642
+ return this.tokenValue;
643
+ }
644
+
645
+ updateToken(token: VocalStateToken): void {
646
+ this.tokenValue = token;
647
+ }
648
+
649
+ /** @internal The adopted token, readable after dispose for reply checks. */
650
+ latestToken(): VocalStateToken {
651
+ return this.tokenValue;
652
+ }
653
+
654
+ token(): VocalStateToken {
655
+ this.requireAlive();
656
+ return { ...this.tokenValue };
657
+ }
658
+
659
+ /** The adopted revision; like {@link token}, it follows replies already received. */
660
+ revision(): VocalUint64 {
661
+ return this.token().revision;
662
+ }
663
+
664
+ notes(): VocalEditWorkerTask<VocalNotesResult> {
665
+ this.requireAlive();
666
+ return this.client.callMutation(this, { kind: 'notes' });
667
+ }
668
+
669
+ analysis(): VocalEditWorkerTask<VocalAnalysis> {
670
+ this.requireAlive();
671
+ return this.client.callMutation(this, { kind: 'analysis' });
672
+ }
673
+
674
+ capabilities(): VocalEditWorkerTask<VocalCapabilities> {
675
+ this.requireAlive();
676
+ return this.client.callMutation(this, { kind: 'capabilities' });
677
+ }
678
+
679
+ outputLengthSamples(): VocalEditWorkerTask<number> {
680
+ this.requireAlive();
681
+ return this.client.callMutation(this, { kind: 'outputLength' });
682
+ }
683
+
684
+ history(): VocalEditWorkerTask<VocalHistoryState> {
685
+ this.requireAlive();
686
+ return this.client.callMutation(this, { kind: 'history' });
687
+ }
688
+
689
+ /** Token of the open draft; rejects when no draft is open. */
690
+ draftToken(): VocalEditWorkerTask<VocalStateToken> {
691
+ this.requireAlive();
692
+ return this.client.callMutation(this, { kind: 'draftToken' });
693
+ }
694
+
695
+ evaluatePitch(
696
+ noteId: number,
697
+ options: VocalEditWorkerReadOptions = {},
698
+ ): VocalEditWorkerTask<VocalPitchEvaluation> {
699
+ this.requireAlive();
700
+ return this.client.callMutation(this, {
701
+ kind: 'evaluatePitch',
702
+ noteId,
703
+ draft: options.draft === true,
704
+ });
705
+ }
706
+
707
+ sourceSampleToDestinationSample(
708
+ noteId: number,
709
+ sourceSample: number,
710
+ options: VocalEditWorkerReadOptions = {},
711
+ ): VocalEditWorkerTask<number> {
712
+ this.requireAlive();
713
+ return this.client.callMutation(this, {
714
+ kind: 'mapCoordinate',
715
+ noteId,
716
+ sample: sourceSample,
717
+ inverse: false,
718
+ draft: options.draft === true,
719
+ });
720
+ }
721
+
722
+ destinationSampleToSourceSample(
723
+ noteId: number,
724
+ destinationSample: number,
725
+ options: VocalEditWorkerReadOptions = {},
726
+ ): VocalEditWorkerTask<number> {
727
+ this.requireAlive();
728
+ return this.client.callMutation(this, {
729
+ kind: 'mapCoordinate',
730
+ noteId,
731
+ sample: destinationSample,
732
+ inverse: true,
733
+ draft: options.draft === true,
734
+ });
735
+ }
736
+
737
+ beginEdit(options: RevisionArgument = {}): VocalEditWorkerTask<VocalStateToken> {
738
+ this.requireAlive();
739
+ return this.client.callMutation(this, {
740
+ kind: 'beginEdit',
741
+ expectedRevision: resolveRevision(options, this.tokenValue.revision),
742
+ });
743
+ }
744
+
745
+ apply(
746
+ request: VocalApplyRequest,
747
+ options: VocalEditWorkerTransferOptions = {},
748
+ ): VocalEditWorkerTask<VocalEditResult> {
749
+ this.requireAlive();
750
+ if (request.expectedGeneration === undefined) {
751
+ throw new TypeError('expectedGeneration is required');
752
+ }
753
+ return this.client.callMutation(this, { kind: 'apply', request }, options);
754
+ }
755
+
756
+ commit(options: RevisionArgument = {}): VocalEditWorkerTask<VocalEditResult> {
757
+ this.requireAlive();
758
+ return this.client.callMutation(this, {
759
+ kind: 'commit',
760
+ expectedRevision: resolveRevision(options, this.tokenValue.revision),
761
+ });
762
+ }
763
+
764
+ cancel(): VocalEditWorkerTask<null> {
765
+ this.requireAlive();
766
+ return this.client.callMutation(this, { kind: 'cancel' });
767
+ }
768
+
769
+ undo(options: RevisionArgument = {}): VocalEditWorkerTask<VocalEditResult> {
770
+ this.requireAlive();
771
+ return this.client.callMutation(this, {
772
+ kind: 'undo',
773
+ expectedRevision: resolveRevision(options, this.tokenValue.revision),
774
+ });
775
+ }
776
+
777
+ redo(options: RevisionArgument = {}): VocalEditWorkerTask<VocalEditResult> {
778
+ this.requireAlive();
779
+ return this.client.callMutation(this, {
780
+ kind: 'redo',
781
+ expectedRevision: resolveRevision(options, this.tokenValue.revision),
782
+ });
783
+ }
784
+
785
+ exportState(): VocalEditWorkerTask<VocalStateBytes> {
786
+ this.requireAlive();
787
+ return this.client.callMutation(this, { kind: 'exportState' });
788
+ }
789
+
790
+ preview(
791
+ request: VocalRenderRequest,
792
+ options: VocalEditWorkerTransferOptions = {},
793
+ ): VocalEditWorkerTask<VocalRenderResult> {
794
+ this.requireAlive();
795
+ return this.client.callPreview(this, request, options);
796
+ }
797
+
798
+ /** Releases the Worker session and any open draft; later calls on it are rejected. */
799
+ dispose(): VocalEditWorkerTask<null> {
800
+ if (this.disposed) {
801
+ return new VocalEditWorkerTask(Promise.resolve(null), () => {});
802
+ }
803
+ this.disposed = true;
804
+ return this.client.disposeSession(this);
805
+ }
806
+
807
+ private requireAlive(): void {
808
+ if (this.disposed) {
809
+ throw new TypeError('vocal Worker session has already been disposed');
810
+ }
811
+ }
812
+ }