@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.
- package/README.md +109 -20
- package/dist/analysis.d.ts +5809 -0
- package/dist/analysis.js +2574 -0
- package/dist/analysis.js.map +1 -0
- package/dist/index.d.ts +884 -55
- package/dist/index.js +1302 -292
- package/dist/index.js.map +1 -1
- package/dist/schemas/realtime-voice-changer-preset-pack.schema.json +27 -0
- package/dist/schemas/realtime-voice-changer-preset.schema.json +140 -0
- package/dist/sonare-analysis.js +2 -0
- package/dist/sonare-analysis.wasm +0 -0
- package/dist/sonare.js +1 -1
- package/dist/sonare.wasm +0 -0
- package/dist/worker.d.ts +65 -0
- package/dist/worker.js +734 -0
- package/dist/worker.js.map +1 -0
- package/dist/worklet.d.ts +628 -88
- package/dist/worklet.js +1816 -266
- package/dist/worklet.js.map +1 -1
- package/package.json +23 -11
- package/src/_chain_config.ts +5 -3
- package/src/analysis.ts +137 -0
- package/src/analysis_helpers.ts +40 -29
- package/src/audio.ts +7 -2
- package/src/clip_page_streamer.ts +62 -8
- package/src/codes.ts +87 -64
- package/src/effects_mastering.ts +1 -0
- package/src/effects_transform.ts +179 -10
- package/src/errors.ts +1 -0
- package/src/feature_core.ts +140 -2
- package/src/feature_music.ts +25 -3
- package/src/feature_pitch.ts +70 -1
- package/src/feature_spectral.ts +295 -7
- package/src/feature_spectrogram.ts +203 -3
- package/src/features.ts +16 -1
- package/src/index.ts +120 -8
- package/src/mastering_chain.ts +20 -8
- package/src/mastering_core.ts +5 -0
- package/src/mastering_dynamics.ts +7 -1
- package/src/metering.ts +6 -1
- package/src/mixer.ts +3 -3
- package/src/opfs_clip_pages.ts +26 -1
- package/src/project.ts +13 -0
- package/src/project_class.ts +204 -23
- package/src/project_internal.ts +38 -34
- package/src/project_types.ts +114 -11
- package/src/public_types.ts +68 -0
- package/src/public_types_mastering.ts +22 -0
- package/src/public_types_mixing.ts +27 -2
- package/src/public_types_music.ts +9 -0
- package/src/public_types_realtime.ts +23 -5
- package/src/public_types_spectral.ts +33 -0
- package/src/quick_analysis.ts +46 -10
- package/src/realtime_engine.ts +167 -6
- package/src/realtime_voice_changer.ts +51 -77
- package/src/sonare-analysis.js.d.ts +8 -0
- package/src/sonare.js.d.ts +340 -6
- package/src/stream_analyzer.ts +6 -1
- package/src/streaming_processors.ts +15 -0
- package/src/web_midi.ts +46 -11
- package/src/worker.ts +196 -0
- package/src/worker_client.ts +352 -0
- package/src/worker_protocol.ts +59 -0
- package/src/worklet/engine-automation.ts +2 -1
- package/src/worklet/engine-capture-facade.ts +13 -4
- package/src/worklet/engine-clips.ts +12 -3
- package/src/worklet/engine-node.ts +264 -21
- package/src/worklet/engine-offline.ts +7 -2
- package/src/worklet/engine-parameter-facade.ts +55 -5
- package/src/worklet/engine-processor.ts +435 -62
- package/src/worklet/engine-register.ts +9 -1
- package/src/worklet/engine-strips.ts +19 -3
- package/src/worklet/engine-sync.ts +2 -6
- package/src/worklet/engine-tempo-facade.ts +8 -6
- package/src/worklet/engine.ts +390 -13
- package/src/worklet/guards.ts +121 -8
- package/src/worklet/messages.ts +166 -8
- package/src/worklet/protocol.ts +305 -17
- package/src/worklet/voice-changer-processor.ts +12 -15
- package/src/worklet.ts +20 -1
package/src/web_midi.ts
CHANGED
|
@@ -1,4 +1,36 @@
|
|
|
1
|
-
import type { MidiCcBindOptions
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
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 };
|