@libraz/libsonare 1.5.4 → 1.6.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 +5730 -0
- package/dist/analysis.js +2464 -0
- package/dist/analysis.js.map +1 -0
- package/dist/index.d.ts +873 -63
- package/dist/index.js +1037 -265
- 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 +707 -0
- package/dist/worker.js.map +1 -0
- package/dist/worklet.d.ts +596 -86
- package/dist/worklet.js +1658 -235
- package/dist/worklet.js.map +1 -1
- package/package.json +23 -11
- package/src/_chain_config.ts +5 -23
- package/src/analysis.ts +137 -0
- package/src/analysis_helpers.ts +29 -0
- package/src/audio.ts +5 -3
- package/src/clip_page_streamer.ts +62 -8
- package/src/codes.ts +51 -64
- package/src/effects_mastering.ts +1 -0
- package/src/effects_transform.ts +12 -6
- package/src/errors.ts +1 -0
- package/src/feature_core.ts +159 -3
- package/src/feature_music.ts +35 -16
- package/src/feature_pitch.ts +75 -6
- package/src/feature_spectral.ts +234 -8
- package/src/feature_spectrogram.ts +186 -5
- package/src/features.ts +16 -1
- package/src/index.ts +104 -7
- package/src/mastering_chain.ts +20 -8
- package/src/mastering_core.ts +7 -0
- package/src/mastering_dynamics.ts +7 -1
- package/src/metering.ts +51 -5
- package/src/mixer.ts +14 -1
- package/src/opfs_clip_pages.ts +26 -1
- package/src/project.ts +7 -0
- package/src/project_class.ts +74 -13
- package/src/project_internal.ts +24 -29
- package/src/project_types.ts +69 -10
- package/src/public_types.ts +68 -0
- package/src/public_types_mastering.ts +28 -0
- package/src/public_types_mixing.ts +11 -2
- package/src/public_types_music.ts +13 -0
- package/src/public_types_spectral.ts +43 -3
- package/src/quick_analysis.ts +48 -12
- package/src/realtime_engine.ts +162 -4
- package/src/realtime_voice_changer.ts +51 -77
- package/src/sonare-analysis.js.d.ts +8 -0
- package/src/sonare.js.d.ts +305 -9
- package/src/streaming_processors.ts +22 -1
- package/src/validation.ts +6 -0
- package/src/web_midi.ts +36 -4
- 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 +212 -4
- package/src/worklet/engine-offline.ts +7 -2
- package/src/worklet/engine-parameter-facade.ts +26 -5
- package/src/worklet/engine-processor.ts +383 -42
- package/src/worklet/engine-register.ts +8 -0
- package/src/worklet/engine-strips.ts +17 -1
- package/src/worklet/engine-sync.ts +2 -6
- package/src/worklet/engine-tempo-facade.ts +8 -6
- package/src/worklet/engine.ts +380 -13
- package/src/worklet/guards.ts +22 -1
- package/src/worklet/messages.ts +141 -9
- package/src/worklet/protocol.ts +303 -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;
|
|
@@ -237,7 +269,7 @@ export async function bindWebMidi(
|
|
|
237
269
|
}
|
|
238
270
|
|
|
239
271
|
function dispatchMidiMessage(
|
|
240
|
-
engine:
|
|
272
|
+
engine: WebMidiEngine,
|
|
241
273
|
event: MidiMessageEventLike,
|
|
242
274
|
group: number,
|
|
243
275
|
runningStatus: number,
|
|
@@ -300,7 +332,7 @@ function dispatchMidiMessage(
|
|
|
300
332
|
}
|
|
301
333
|
|
|
302
334
|
function dispatchUmpMessage(
|
|
303
|
-
engine:
|
|
335
|
+
engine: WebMidiEngine,
|
|
304
336
|
words: ArrayLike<number>,
|
|
305
337
|
portTimeSamples: number,
|
|
306
338
|
): void {
|
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 };
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Internal structured-clone protocol for the offline Web Worker bridge.
|
|
3
|
+
*
|
|
4
|
+
* Keep this deliberately limited to one-shot operations. Embind handles own
|
|
5
|
+
* native resources and cannot safely cross a worker boundary.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export type OfflineWorkerOperation =
|
|
9
|
+
| 'analyze'
|
|
10
|
+
| 'detectBpm'
|
|
11
|
+
| 'detectKey'
|
|
12
|
+
| 'detectChords'
|
|
13
|
+
| 'masterAudio'
|
|
14
|
+
| 'masterAudioStereo';
|
|
15
|
+
|
|
16
|
+
export interface OfflineWorkerRunMessage {
|
|
17
|
+
type: 'sonare:offline-run';
|
|
18
|
+
id: number;
|
|
19
|
+
operation: OfflineWorkerOperation;
|
|
20
|
+
request: Record<string, unknown>;
|
|
21
|
+
/** A shared flag lets cancellation reach synchronous WASM progress callbacks. */
|
|
22
|
+
cancelBuffer?: SharedArrayBuffer;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface OfflineWorkerCancelMessage {
|
|
26
|
+
type: 'sonare:offline-cancel';
|
|
27
|
+
id: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export type OfflineWorkerRequestMessage = OfflineWorkerRunMessage | OfflineWorkerCancelMessage;
|
|
31
|
+
|
|
32
|
+
export interface OfflineWorkerProgressMessage {
|
|
33
|
+
type: 'sonare:offline-progress';
|
|
34
|
+
id: number;
|
|
35
|
+
progress: number;
|
|
36
|
+
stage: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface OfflineWorkerResultMessage {
|
|
40
|
+
type: 'sonare:offline-result';
|
|
41
|
+
id: number;
|
|
42
|
+
result: unknown;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface OfflineWorkerErrorMessage {
|
|
46
|
+
type: 'sonare:offline-error';
|
|
47
|
+
id: number;
|
|
48
|
+
error: {
|
|
49
|
+
name: string;
|
|
50
|
+
message: string;
|
|
51
|
+
code?: number;
|
|
52
|
+
codeName?: string;
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export type OfflineWorkerResponseMessage =
|
|
57
|
+
| OfflineWorkerProgressMessage
|
|
58
|
+
| OfflineWorkerResultMessage
|
|
59
|
+
| OfflineWorkerErrorMessage;
|
|
@@ -31,7 +31,8 @@ export function scheduleParam(
|
|
|
31
31
|
// Mirror the lane to the live worklet engine so scheduled automation plays
|
|
32
32
|
// back in real time, not just in renderOffline(). Lanes can exceed the
|
|
33
33
|
// fixed-size SAB command record, so they ride an out-of-band 'syncAutomation'
|
|
34
|
-
// message
|
|
34
|
+
// message whose port handler publishes the bounded snapshot consumed by
|
|
35
|
+
// process() (like syncClips/syncMarkers).
|
|
35
36
|
ctx.postSync({ type: 'syncAutomation', paramId, points: lane });
|
|
36
37
|
}
|
|
37
38
|
|