@libraz/libsonare 1.5.5 → 1.7.0

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 (80) hide show
  1. package/README.md +109 -20
  2. package/dist/analysis.d.ts +5809 -0
  3. package/dist/analysis.js +2574 -0
  4. package/dist/analysis.js.map +1 -0
  5. package/dist/index.d.ts +884 -55
  6. package/dist/index.js +1302 -292
  7. package/dist/index.js.map +1 -1
  8. package/dist/schemas/realtime-voice-changer-preset-pack.schema.json +27 -0
  9. package/dist/schemas/realtime-voice-changer-preset.schema.json +140 -0
  10. package/dist/sonare-analysis.js +2 -0
  11. package/dist/sonare-analysis.wasm +0 -0
  12. package/dist/sonare.js +1 -1
  13. package/dist/sonare.wasm +0 -0
  14. package/dist/worker.d.ts +65 -0
  15. package/dist/worker.js +734 -0
  16. package/dist/worker.js.map +1 -0
  17. package/dist/worklet.d.ts +628 -88
  18. package/dist/worklet.js +1816 -266
  19. package/dist/worklet.js.map +1 -1
  20. package/package.json +23 -11
  21. package/src/_chain_config.ts +5 -3
  22. package/src/analysis.ts +137 -0
  23. package/src/analysis_helpers.ts +40 -29
  24. package/src/audio.ts +7 -2
  25. package/src/clip_page_streamer.ts +62 -8
  26. package/src/codes.ts +87 -64
  27. package/src/effects_mastering.ts +1 -0
  28. package/src/effects_transform.ts +179 -10
  29. package/src/errors.ts +1 -0
  30. package/src/feature_core.ts +140 -2
  31. package/src/feature_music.ts +25 -3
  32. package/src/feature_pitch.ts +70 -1
  33. package/src/feature_spectral.ts +295 -7
  34. package/src/feature_spectrogram.ts +203 -3
  35. package/src/features.ts +16 -1
  36. package/src/index.ts +120 -8
  37. package/src/mastering_chain.ts +20 -8
  38. package/src/mastering_core.ts +5 -0
  39. package/src/mastering_dynamics.ts +7 -1
  40. package/src/metering.ts +6 -1
  41. package/src/mixer.ts +3 -3
  42. package/src/opfs_clip_pages.ts +26 -1
  43. package/src/project.ts +13 -0
  44. package/src/project_class.ts +204 -23
  45. package/src/project_internal.ts +38 -34
  46. package/src/project_types.ts +114 -11
  47. package/src/public_types.ts +68 -0
  48. package/src/public_types_mastering.ts +22 -0
  49. package/src/public_types_mixing.ts +27 -2
  50. package/src/public_types_music.ts +9 -0
  51. package/src/public_types_realtime.ts +23 -5
  52. package/src/public_types_spectral.ts +33 -0
  53. package/src/quick_analysis.ts +46 -10
  54. package/src/realtime_engine.ts +167 -6
  55. package/src/realtime_voice_changer.ts +51 -77
  56. package/src/sonare-analysis.js.d.ts +8 -0
  57. package/src/sonare.js.d.ts +340 -6
  58. package/src/stream_analyzer.ts +6 -1
  59. package/src/streaming_processors.ts +15 -0
  60. package/src/web_midi.ts +46 -11
  61. package/src/worker.ts +196 -0
  62. package/src/worker_client.ts +352 -0
  63. package/src/worker_protocol.ts +59 -0
  64. package/src/worklet/engine-automation.ts +2 -1
  65. package/src/worklet/engine-capture-facade.ts +13 -4
  66. package/src/worklet/engine-clips.ts +12 -3
  67. package/src/worklet/engine-node.ts +264 -21
  68. package/src/worklet/engine-offline.ts +7 -2
  69. package/src/worklet/engine-parameter-facade.ts +55 -5
  70. package/src/worklet/engine-processor.ts +435 -62
  71. package/src/worklet/engine-register.ts +9 -1
  72. package/src/worklet/engine-strips.ts +19 -3
  73. package/src/worklet/engine-sync.ts +2 -6
  74. package/src/worklet/engine-tempo-facade.ts +8 -6
  75. package/src/worklet/engine.ts +390 -13
  76. package/src/worklet/guards.ts +121 -8
  77. package/src/worklet/messages.ts +166 -8
  78. package/src/worklet/protocol.ts +305 -17
  79. package/src/worklet/voice-changer-processor.ts +12 -15
  80. package/src/worklet.ts +20 -1
package/src/web_midi.ts CHANGED
@@ -1,4 +1,36 @@
1
- import type { MidiCcBindOptions, RealtimeEngine } from './realtime_engine';
1
+ import type { MidiCcBindOptions } from './realtime_engine';
2
+
3
+ export interface WebMidiEngine {
4
+ bindMidiCc(
5
+ channel: number,
6
+ controller: number,
7
+ paramId: number,
8
+ options?: MidiCcBindOptions,
9
+ ): void;
10
+ setMidiInputSource(destinationId?: number): void;
11
+ clearMidiInputSource(): void;
12
+ pushMidiInputNoteOn(
13
+ group: number,
14
+ channel: number,
15
+ note: number,
16
+ velocity: number,
17
+ time?: number,
18
+ ): void;
19
+ pushMidiInputNoteOff(
20
+ group: number,
21
+ channel: number,
22
+ note: number,
23
+ velocity?: number,
24
+ time?: number,
25
+ ): void;
26
+ pushMidiInputCc(
27
+ group: number,
28
+ channel: number,
29
+ controller: number,
30
+ value: number,
31
+ time?: number,
32
+ ): void;
33
+ }
2
34
 
3
35
  type MidiInputState = 'connected' | 'disconnected';
4
36
 
@@ -103,7 +135,7 @@ export function isWebMidiAvailable(): boolean {
103
135
  }
104
136
 
105
137
  export async function bindWebMidi(
106
- engine: RealtimeEngine,
138
+ engine: WebMidiEngine,
107
139
  options: BindWebMidiOptions = {},
108
140
  ): Promise<WebMidiBinding> {
109
141
  const navigatorWithMidi = globalThis.navigator as NavigatorWithMidi | undefined;
@@ -182,6 +214,10 @@ export async function bindWebMidi(
182
214
  bound.delete(input.id);
183
215
  };
184
216
 
217
+ // Does not call notify() itself: both call sites below (the initial sync
218
+ // and stateListener's fallback branch) already notify exactly once after
219
+ // calling this, so notifying here too would fire onInputsChanged twice per
220
+ // hotplug event that goes through the fallback branch.
185
221
  const refreshInputs = () => {
186
222
  for (const [, entry] of bound) {
187
223
  if (!shouldBind(entry.input)) {
@@ -191,7 +227,6 @@ export async function bindWebMidi(
191
227
  for (const [, input] of iterInputs(access)) {
192
228
  bindInput(input);
193
229
  }
194
- notify();
195
230
  };
196
231
 
197
232
  const stateListener = (event: MidiConnectionEventLike) => {
@@ -212,6 +247,7 @@ export async function bindWebMidi(
212
247
  };
213
248
 
214
249
  refreshInputs();
250
+ notify();
215
251
  if (access.addEventListener) {
216
252
  access.addEventListener('statechange', stateListener);
217
253
  } else {
@@ -237,7 +273,7 @@ export async function bindWebMidi(
237
273
  }
238
274
 
239
275
  function dispatchMidiMessage(
240
- engine: RealtimeEngine,
276
+ engine: WebMidiEngine,
241
277
  event: MidiMessageEventLike,
242
278
  group: number,
243
279
  runningStatus: number,
@@ -300,7 +336,7 @@ function dispatchMidiMessage(
300
336
  }
301
337
 
302
338
  function dispatchUmpMessage(
303
- engine: RealtimeEngine,
339
+ engine: WebMidiEngine,
304
340
  words: ArrayLike<number>,
305
341
  portTimeSamples: number,
306
342
  ): void {
@@ -336,12 +372,11 @@ function dispatchUmpMessage(
336
372
  if (status === 0x8) {
337
373
  engine.pushMidiInputNoteOff(group, channel, data1, (word1 >>> 25) & 0x7f, portTimeSamples);
338
374
  } else if (status === 0x9) {
339
- const velocity = (word1 >>> 25) & 0x7f;
340
- if (velocity === 0) {
341
- engine.pushMidiInputNoteOff(group, channel, data1, 0, portTimeSamples);
342
- } else {
343
- engine.pushMidiInputNoteOn(group, channel, data1, velocity, portTimeSamples);
344
- }
375
+ // MIDI 2.0 note-on is distinct from note-off (status 0x8). Downsample
376
+ // its 16-bit velocity to the engine's 7-bit API, retaining a nonzero
377
+ // note-on even when the top seven bits are all zero.
378
+ const velocity = Math.max(1, (word1 >>> 25) & 0x7f);
379
+ engine.pushMidiInputNoteOn(group, channel, data1, velocity, portTimeSamples);
345
380
  } else if (status === 0xb) {
346
381
  engine.pushMidiInputCc(group, channel, data1, (word1 >>> 25) & 0x7f, portTimeSamples);
347
382
  }
package/src/worker.ts ADDED
@@ -0,0 +1,196 @@
1
+ /**
2
+ * Offline one-shot operations hosted inside a dedicated Web Worker.
3
+ *
4
+ * Only value-based calls are exposed here: analysis and preset mastering. Do
5
+ * not add `Project`, `Mixer`, or realtime handles. Their native lifetime is
6
+ * bound to one JavaScript realm, so moving them across a Worker boundary would
7
+ * make ownership and `delete()` semantics unsound.
8
+ */
9
+
10
+ import { ErrorCode, SonareError } from './errors';
11
+ import { init } from './index';
12
+ import type { MasterAudioRequest, MasterAudioStereoRequest } from './mastering_chain';
13
+ import { masterAudio, masterAudioStereo } from './mastering_chain';
14
+ import type {
15
+ DetectChordsRequest,
16
+ DetectKeyRequest,
17
+ MusicAnalyzeRequest,
18
+ SamplesRequest,
19
+ } from './quick_analysis';
20
+ import { analyzeWithProgress, detectBpm, detectChords, detectKey } from './quick_analysis';
21
+ import type {
22
+ OfflineWorkerErrorMessage,
23
+ OfflineWorkerRequestMessage,
24
+ OfflineWorkerResponseMessage,
25
+ OfflineWorkerRunMessage,
26
+ } from './worker_protocol';
27
+
28
+ export type { OfflineWorkerOperation } from './worker_protocol';
29
+
30
+ /** Minimal endpoint shared by browser Workers and the Node worker-thread test bridge. */
31
+ export interface OfflineWorkerEndpoint {
32
+ postMessage(message: OfflineWorkerResponseMessage, transfer?: Transferable[]): void;
33
+ addEventListener(
34
+ type: 'message',
35
+ listener: (event: MessageEvent<OfflineWorkerRequestMessage>) => void,
36
+ ): void;
37
+ }
38
+
39
+ function transferableBuffers(value: unknown, seen = new Set<object>()): Transferable[] {
40
+ const buffers: Transferable[] = [];
41
+ const seenBuffers = new Set<ArrayBuffer>();
42
+ const visit = (item: unknown): void => {
43
+ if (item === null || item === undefined || typeof item !== 'object') {
44
+ return;
45
+ }
46
+ if (ArrayBuffer.isView(item)) {
47
+ const buffer = item.buffer;
48
+ if (buffer instanceof ArrayBuffer && !seenBuffers.has(buffer)) {
49
+ seenBuffers.add(buffer);
50
+ buffers.push(buffer);
51
+ }
52
+ return;
53
+ }
54
+ if (item instanceof ArrayBuffer) {
55
+ if (!seenBuffers.has(item)) {
56
+ seenBuffers.add(item);
57
+ buffers.push(item);
58
+ }
59
+ return;
60
+ }
61
+ if (seen.has(item)) {
62
+ return;
63
+ }
64
+ seen.add(item);
65
+ if (Array.isArray(item)) {
66
+ item.forEach(visit);
67
+ return;
68
+ }
69
+ Object.values(item).forEach(visit);
70
+ };
71
+ visit(value);
72
+ return buffers;
73
+ }
74
+
75
+ function errorMessage(error: unknown): OfflineWorkerErrorMessage['error'] {
76
+ if (error instanceof SonareError) {
77
+ return {
78
+ name: error.name,
79
+ message: error.message,
80
+ code: error.code,
81
+ codeName: error.codeName,
82
+ };
83
+ }
84
+ if (error instanceof Error) {
85
+ return { name: error.name, message: error.message };
86
+ }
87
+ return { name: 'Error', message: String(error) };
88
+ }
89
+
90
+ function cancelledError(): SonareError {
91
+ return new SonareError(ErrorCode.Cancelled, 'Cancelled', 'Operation cancelled');
92
+ }
93
+
94
+ /**
95
+ * Install the protocol on an endpoint. This export is intentionally useful to
96
+ * the Node `worker_threads` test adapter; browsers install it automatically
97
+ * when this module is loaded as a Worker entry point.
98
+ */
99
+ export function installOfflineWorkerEndpoint(endpoint: OfflineWorkerEndpoint): void {
100
+ const cancelled = new Set<number>();
101
+
102
+ const run = async (message: OfflineWorkerRunMessage): Promise<void> => {
103
+ const cancelFlag = message.cancelBuffer ? new Int32Array(message.cancelBuffer) : undefined;
104
+ const isCancelled = (): boolean =>
105
+ cancelled.has(message.id) || (cancelFlag !== undefined && Atomics.load(cancelFlag, 0) !== 0);
106
+ const onProgress = (progress: number, stage: string): undefined | false => {
107
+ endpoint.postMessage({ type: 'sonare:offline-progress', id: message.id, progress, stage });
108
+ return isCancelled() ? false : undefined;
109
+ };
110
+
111
+ try {
112
+ await init();
113
+ if (isCancelled()) {
114
+ throw cancelledError();
115
+ }
116
+
117
+ let result: unknown;
118
+ switch (message.operation) {
119
+ case 'analyze':
120
+ result = analyzeWithProgress({
121
+ ...(message.request as unknown as MusicAnalyzeRequest),
122
+ onProgress,
123
+ });
124
+ break;
125
+ case 'detectBpm':
126
+ result = detectBpm(message.request as unknown as SamplesRequest);
127
+ break;
128
+ case 'detectKey':
129
+ result = detectKey(message.request as unknown as DetectKeyRequest);
130
+ break;
131
+ case 'detectChords':
132
+ result = detectChords(message.request as unknown as DetectChordsRequest);
133
+ break;
134
+ case 'masterAudio':
135
+ result = masterAudio({
136
+ ...(message.request as unknown as MasterAudioRequest),
137
+ onProgress,
138
+ });
139
+ break;
140
+ case 'masterAudioStereo':
141
+ result = masterAudioStereo({
142
+ ...(message.request as unknown as MasterAudioStereoRequest),
143
+ onProgress,
144
+ });
145
+ break;
146
+ }
147
+ if (isCancelled()) {
148
+ throw cancelledError();
149
+ }
150
+ endpoint.postMessage(
151
+ { type: 'sonare:offline-result', id: message.id, result },
152
+ transferableBuffers(result),
153
+ );
154
+ } catch (error) {
155
+ endpoint.postMessage({
156
+ type: 'sonare:offline-error',
157
+ id: message.id,
158
+ error: errorMessage(error),
159
+ });
160
+ } finally {
161
+ cancelled.delete(message.id);
162
+ }
163
+ };
164
+
165
+ endpoint.addEventListener('message', (event) => {
166
+ const message = event.data;
167
+ if (message.type === 'sonare:offline-cancel') {
168
+ cancelled.add(message.id);
169
+ return;
170
+ }
171
+ void run(message);
172
+ });
173
+ }
174
+
175
+ function browserWorkerEndpoint(): OfflineWorkerEndpoint | null {
176
+ const scope = globalThis as unknown as {
177
+ document?: unknown;
178
+ postMessage?: (message: OfflineWorkerResponseMessage, transfer?: Transferable[]) => void;
179
+ addEventListener?: (
180
+ type: 'message',
181
+ listener: (event: MessageEvent<OfflineWorkerRequestMessage>) => void,
182
+ ) => void;
183
+ };
184
+ if (scope.document !== undefined || !scope.postMessage || !scope.addEventListener) {
185
+ return null;
186
+ }
187
+ return {
188
+ postMessage: (message, transfer) => scope.postMessage?.(message, transfer),
189
+ addEventListener: (type, listener) => scope.addEventListener?.(type, listener),
190
+ };
191
+ }
192
+
193
+ const endpoint = browserWorkerEndpoint();
194
+ if (endpoint) {
195
+ installOfflineWorkerEndpoint(endpoint);
196
+ }
@@ -0,0 +1,352 @@
1
+ import { ErrorCode, SonareError } from './errors';
2
+ import type { MasterAudioRequest, MasterAudioStereoRequest } from './mastering_chain';
3
+ import type {
4
+ DetectChordsRequest,
5
+ DetectKeyRequest,
6
+ MusicAnalyzeRequest,
7
+ SamplesRequest,
8
+ } from './quick_analysis';
9
+ import type { OfflineWorkerOperation, OfflineWorkerResponseMessage } from './worker_protocol';
10
+
11
+ type WorkerRequest<T> = Omit<T, 'onProgress'>;
12
+
13
+ /** Progress relayed from the Worker at the core's native progress boundaries. */
14
+ export interface OfflineWorkerProgress {
15
+ progress: number;
16
+ stage: string;
17
+ }
18
+
19
+ /** Per-call behaviour for a one-shot operation. */
20
+ export interface OfflineWorkerCallOptions {
21
+ /**
22
+ * Preserve the caller's input arrays by copying them before dispatch.
23
+ *
24
+ * The default is `false`: `Float32Array` buffers are transferred to the
25
+ * Worker and become detached on the calling thread, avoiding a second copy.
26
+ */
27
+ copy?: boolean;
28
+ /** Receives native progress boundaries for analysis and mastering calls. */
29
+ onProgress?: (update: OfflineWorkerProgress) => void;
30
+ }
31
+
32
+ /** Options used to create an {@link OfflineWorkerClient}. */
33
+ export interface OfflineWorkerClientOptions {
34
+ /** Reuse a Worker owned by the host instead of creating one. */
35
+ worker?: OfflineWorker;
36
+ /** URL of the Worker entry; defaults to the adjacent published `worker.js`. */
37
+ workerUrl?: string | URL;
38
+ /** Test/host hook for creating a dedicated Worker. */
39
+ workerFactory?: (url: URL) => OfflineWorker;
40
+ /** Terminate a supplied Worker when {@link OfflineWorkerClient.dispose} runs. */
41
+ terminateWorkerOnDispose?: boolean;
42
+ }
43
+
44
+ /** Browser Worker or Node `worker_threads.Worker` used by the client. */
45
+ export interface OfflineWorker {
46
+ postMessage(message: unknown, transfer?: Transferable[]): void;
47
+ terminate(): unknown;
48
+ addEventListener?(type: string, listener: EventListener): void;
49
+ removeEventListener?(type: string, listener: EventListener): void;
50
+ on?(type: 'message' | 'error', listener: (...args: unknown[]) => void): unknown;
51
+ off?(type: 'message' | 'error', listener: (...args: unknown[]) => void): unknown;
52
+ }
53
+
54
+ /**
55
+ * A cancellable promise returned by an {@link OfflineWorkerClient} call.
56
+ *
57
+ * It is Promise-like, so `await client.analyze(...)` works. Call `cancel()` to
58
+ * request cancellation at the next native progress boundary. In browsers this
59
+ * uses a `SharedArrayBuffer` flag, which requires cross-origin isolation for
60
+ * prompt cancellation while synchronous WASM is running. The worker forwards
61
+ * that flag through the same native cancellation callback used by synchronous
62
+ * one-shot requests.
63
+ */
64
+ export class OfflineWorkerTask<T> implements PromiseLike<T> {
65
+ constructor(
66
+ readonly result: Promise<T>,
67
+ private readonly cancelRequest: () => void,
68
+ ) {}
69
+
70
+ cancel(): void {
71
+ this.cancelRequest();
72
+ }
73
+
74
+ // biome-ignore lint/suspicious/noThenProperty: this intentionally implements PromiseLike so callers can await a task and cancel it.
75
+ then<TResult1 = T, TResult2 = never>(
76
+ onfulfilled?: ((value: T) => TResult1 | PromiseLike<TResult1>) | null,
77
+ onrejected?: ((reason: unknown) => TResult2 | PromiseLike<TResult2>) | null,
78
+ ): Promise<TResult1 | TResult2> {
79
+ return this.result.then(onfulfilled, onrejected);
80
+ }
81
+
82
+ catch<TResult = never>(
83
+ onrejected?: ((reason: unknown) => TResult | PromiseLike<TResult>) | null,
84
+ ): Promise<T | TResult> {
85
+ return this.result.catch(onrejected);
86
+ }
87
+
88
+ finally(onfinally?: (() => void) | null): Promise<T> {
89
+ return this.result.finally(onfinally);
90
+ }
91
+ }
92
+
93
+ interface PendingCall {
94
+ resolve: (value: unknown) => void;
95
+ reject: (reason: unknown) => void;
96
+ onProgress?: (update: OfflineWorkerProgress) => void;
97
+ cancelFlag?: Int32Array;
98
+ }
99
+
100
+ function cloneForWorker(
101
+ value: unknown,
102
+ copy: boolean,
103
+ transfers: Transferable[],
104
+ transferred = new Set<ArrayBuffer>(),
105
+ ): unknown {
106
+ if (value instanceof Float32Array) {
107
+ const samples = copy ? value.slice() : value;
108
+ const buffer = samples.buffer;
109
+ if (!(buffer instanceof ArrayBuffer)) {
110
+ throw new TypeError(
111
+ 'OfflineWorkerClient only transfers Float32Array values backed by ArrayBuffer',
112
+ );
113
+ }
114
+ if (!transferred.has(buffer)) {
115
+ transferred.add(buffer);
116
+ transfers.push(buffer);
117
+ }
118
+ return samples;
119
+ }
120
+ if (Array.isArray(value)) {
121
+ return value.map((item) => cloneForWorker(item, copy, transfers, transferred));
122
+ }
123
+ if (value !== null && typeof value === 'object') {
124
+ return Object.fromEntries(
125
+ Object.entries(value).map(([key, item]) => [
126
+ key,
127
+ cloneForWorker(item, copy, transfers, transferred),
128
+ ]),
129
+ );
130
+ }
131
+ return value;
132
+ }
133
+
134
+ function cancellationFlag(): Int32Array | undefined {
135
+ if (typeof SharedArrayBuffer === 'undefined') {
136
+ return undefined;
137
+ }
138
+ return new Int32Array(new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT));
139
+ }
140
+
141
+ function workerError(
142
+ message: Extract<OfflineWorkerResponseMessage, { type: 'sonare:offline-error' }>,
143
+ ): Error {
144
+ if (message.error.code !== undefined) {
145
+ return new SonareError(
146
+ message.error.code,
147
+ message.error.codeName ?? 'Unknown',
148
+ message.error.message,
149
+ );
150
+ }
151
+ const error = new Error(message.error.message);
152
+ error.name = message.error.name;
153
+ return error;
154
+ }
155
+
156
+ /**
157
+ * Owns a dedicated Worker for value-based offline analysis and mastering.
158
+ *
159
+ * `Project`, `Mixer`, and realtime APIs are intentionally absent: their native
160
+ * handles are local to one JavaScript realm and cannot be transferred safely.
161
+ */
162
+ export class OfflineWorkerClient {
163
+ private readonly worker: OfflineWorker;
164
+ private readonly ownsWorker: boolean;
165
+ private readonly pending = new Map<number, PendingCall>();
166
+ private nextId = 1;
167
+ private closed = false;
168
+ private usesEventTarget = false;
169
+
170
+ constructor(options: OfflineWorkerClientOptions = {}) {
171
+ this.ownsWorker = options.worker === undefined || options.terminateWorkerOnDispose === true;
172
+ if (options.worker) {
173
+ this.worker = options.worker;
174
+ } else {
175
+ if (!options.workerFactory && typeof Worker === 'undefined') {
176
+ throw new Error('OfflineWorkerClient requires a browser Worker implementation');
177
+ }
178
+ const url =
179
+ options.workerUrl === undefined
180
+ ? new URL('./worker.js', import.meta.url)
181
+ : new URL(options.workerUrl, import.meta.url);
182
+ this.worker =
183
+ options.workerFactory?.(url) ?? new Worker(url, { type: 'module', name: 'sonare-offline' });
184
+ }
185
+ if (this.worker.addEventListener) {
186
+ this.usesEventTarget = true;
187
+ this.worker.addEventListener('message', this.onMessage as EventListener);
188
+ this.worker.addEventListener('error', this.onError as EventListener);
189
+ } else if (this.worker.on) {
190
+ this.worker.on('message', this.onNodeMessage);
191
+ this.worker.on('error', this.onNodeError);
192
+ } else {
193
+ throw new TypeError('OfflineWorkerClient requires Worker event listeners');
194
+ }
195
+ }
196
+
197
+ /** Dispatch full music analysis to the Worker. */
198
+ analyze(
199
+ request: WorkerRequest<MusicAnalyzeRequest>,
200
+ options?: OfflineWorkerCallOptions,
201
+ ): OfflineWorkerTask<ReturnType<typeof import('./quick_analysis').analyze>> {
202
+ return this.call('analyze', request, options);
203
+ }
204
+
205
+ /** Dispatch BPM detection to the Worker. */
206
+ detectBpm(
207
+ request: SamplesRequest,
208
+ options?: OfflineWorkerCallOptions,
209
+ ): OfflineWorkerTask<number> {
210
+ return this.call('detectBpm', request, options);
211
+ }
212
+
213
+ /** Dispatch key detection to the Worker. */
214
+ detectKey(
215
+ request: DetectKeyRequest,
216
+ options?: OfflineWorkerCallOptions,
217
+ ): OfflineWorkerTask<ReturnType<typeof import('./quick_analysis').detectKey>> {
218
+ return this.call('detectKey', request, options);
219
+ }
220
+
221
+ /** Dispatch chord detection to the Worker. */
222
+ detectChords(
223
+ request: DetectChordsRequest,
224
+ options?: OfflineWorkerCallOptions,
225
+ ): OfflineWorkerTask<ReturnType<typeof import('./quick_analysis').detectChords>> {
226
+ return this.call('detectChords', request, options);
227
+ }
228
+
229
+ /** Dispatch mono preset mastering to the Worker. */
230
+ masterAudio(
231
+ request: WorkerRequest<MasterAudioRequest>,
232
+ options?: OfflineWorkerCallOptions,
233
+ ): OfflineWorkerTask<ReturnType<typeof import('./mastering_chain').masterAudio>> {
234
+ return this.call('masterAudio', request, options);
235
+ }
236
+
237
+ /** Dispatch stereo preset mastering to the Worker. */
238
+ masterAudioStereo(
239
+ request: WorkerRequest<MasterAudioStereoRequest>,
240
+ options?: OfflineWorkerCallOptions,
241
+ ): OfflineWorkerTask<ReturnType<typeof import('./mastering_chain').masterAudioStereo>> {
242
+ return this.call('masterAudioStereo', request, options);
243
+ }
244
+
245
+ /** Stop accepting calls, reject outstanding work, and release the Worker if owned. */
246
+ dispose(): void {
247
+ if (this.closed) {
248
+ return;
249
+ }
250
+ this.closed = true;
251
+ if (this.usesEventTarget) {
252
+ this.worker.removeEventListener?.('message', this.onMessage as EventListener);
253
+ this.worker.removeEventListener?.('error', this.onError as EventListener);
254
+ } else {
255
+ this.worker.off?.('message', this.onNodeMessage);
256
+ this.worker.off?.('error', this.onNodeError);
257
+ }
258
+ for (const { reject } of this.pending.values()) {
259
+ reject(new Error('OfflineWorkerClient was disposed'));
260
+ }
261
+ this.pending.clear();
262
+ if (this.ownsWorker) {
263
+ this.worker.terminate();
264
+ }
265
+ }
266
+
267
+ private call<T>(
268
+ operation: OfflineWorkerOperation,
269
+ request: object,
270
+ options: OfflineWorkerCallOptions = {},
271
+ ): OfflineWorkerTask<T> {
272
+ if (this.closed) {
273
+ throw new Error('OfflineWorkerClient was disposed');
274
+ }
275
+ const id = this.nextId++;
276
+ const transfers: Transferable[] = [];
277
+ const preparedRequest = cloneForWorker(request, options.copy === true, transfers) as Record<
278
+ string,
279
+ unknown
280
+ >;
281
+ const cancelFlag = cancellationFlag();
282
+ const result = new Promise<T>((resolve, reject) => {
283
+ this.pending.set(id, {
284
+ resolve: (value) => resolve(value as T),
285
+ reject,
286
+ onProgress: options.onProgress,
287
+ cancelFlag,
288
+ });
289
+ });
290
+ this.worker.postMessage(
291
+ {
292
+ type: 'sonare:offline-run',
293
+ id,
294
+ operation,
295
+ request: preparedRequest,
296
+ ...(cancelFlag ? { cancelBuffer: cancelFlag.buffer as SharedArrayBuffer } : {}),
297
+ },
298
+ transfers,
299
+ );
300
+ return new OfflineWorkerTask(result, () => {
301
+ if (!this.pending.has(id)) {
302
+ return;
303
+ }
304
+ if (cancelFlag) {
305
+ Atomics.store(cancelFlag, 0, 1);
306
+ }
307
+ // A message also covers a call cancelled before it begins and is the
308
+ // best available fallback where SharedArrayBuffer is unavailable.
309
+ this.worker.postMessage({ type: 'sonare:offline-cancel', id });
310
+ });
311
+ }
312
+
313
+ private readonly onMessage = (event: MessageEvent<OfflineWorkerResponseMessage>): void => {
314
+ const message = event.data;
315
+ const pending = this.pending.get(message.id);
316
+ if (!pending) {
317
+ return;
318
+ }
319
+ if (message.type === 'sonare:offline-progress') {
320
+ pending.onProgress?.({ progress: message.progress, stage: message.stage });
321
+ return;
322
+ }
323
+ this.pending.delete(message.id);
324
+ if (message.type === 'sonare:offline-result') {
325
+ pending.resolve(message.result);
326
+ return;
327
+ }
328
+ pending.reject(workerError(message));
329
+ };
330
+
331
+ private readonly onError = (event: ErrorEvent): void => {
332
+ const error = new Error(event.message || 'Offline Worker failed');
333
+ for (const { reject } of this.pending.values()) {
334
+ reject(error);
335
+ }
336
+ this.pending.clear();
337
+ };
338
+
339
+ private readonly onNodeMessage = (data: unknown): void => {
340
+ this.onMessage({ data } as MessageEvent<OfflineWorkerResponseMessage>);
341
+ };
342
+
343
+ private readonly onNodeError = (error: unknown): void => {
344
+ this.onError(
345
+ error instanceof Error
346
+ ? ({ message: error.message } as ErrorEvent)
347
+ : ({ message: String(error) } as ErrorEvent),
348
+ );
349
+ };
350
+ }
351
+
352
+ export { ErrorCode };