@libraz/libsonare 1.4.1 → 1.5.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 +50 -19
- package/dist/index.d.ts +5384 -1
- package/dist/index.js +867 -573
- package/dist/index.js.map +1 -1
- package/dist/sonare.js +1 -1
- package/dist/sonare.wasm +0 -0
- package/dist/worklet.d.ts +1083 -5227
- package/dist/worklet.js +2677 -2452
- package/dist/worklet.js.map +1 -1
- package/package.json +4 -9
- package/src/clip_page_streamer.ts +250 -0
- package/src/effects_mastering.ts +85 -1089
- package/src/effects_transform.ts +286 -0
- package/src/effects_voice_change.ts +118 -0
- package/src/feature_spectrogram.ts +42 -2
- package/src/features.ts +1 -0
- package/src/index.ts +11 -0
- package/src/mastering_chain.ts +200 -0
- package/src/mastering_core.ts +248 -0
- package/src/mastering_dynamics.ts +105 -0
- package/src/mastering_repair.ts +161 -0
- package/src/mixing_oneshot.ts +54 -0
- package/src/module_state.ts +1 -2
- package/src/project.ts +71 -1712
- package/src/project_class.ts +861 -0
- package/src/project_internal.ts +332 -0
- package/src/project_synth.ts +43 -0
- package/src/project_types.ts +570 -0
- package/src/public_types.ts +6 -1221
- package/src/public_types_acoustic.ts +115 -0
- package/src/public_types_mastering.ts +333 -0
- package/src/public_types_mixing.ts +97 -0
- package/src/public_types_music.ts +352 -0
- package/src/public_types_realtime.ts +163 -0
- package/src/public_types_spectral.ts +194 -0
- package/src/realtime_engine.ts +94 -0
- package/src/sonare.js.d.ts +72 -0
- package/src/worklet/engine-automation.ts +73 -0
- package/src/worklet/engine-capture-facade.ts +80 -0
- package/src/worklet/engine-clips.ts +71 -0
- package/src/worklet/engine-markers.ts +93 -0
- package/src/worklet/engine-mixer-facade.ts +186 -0
- package/src/worklet/engine-node.ts +451 -0
- package/src/worklet/engine-offline.ts +162 -0
- package/src/worklet/engine-options.ts +13 -0
- package/src/worklet/engine-parameter-facade.ts +172 -0
- package/src/worklet/engine-processor.ts +764 -0
- package/src/worklet/engine-register.ts +136 -0
- package/src/worklet/engine-strips.ts +315 -0
- package/src/worklet/engine-sync.ts +94 -0
- package/src/worklet/engine-tempo-facade.ts +141 -0
- package/src/worklet/engine.ts +998 -0
- package/src/worklet/guards.ts +14 -1
- package/src/worklet/messages.ts +60 -20
- package/src/worklet/mixer-processor.ts +368 -0
- package/src/worklet/protocol.ts +3 -0
- package/src/worklet/voice-changer-processor.ts +246 -0
- package/src/worklet.ts +20 -3549
- package/dist/sonare-rt-module.js +0 -2
- package/dist/sonare-rt.js +0 -2
- package/dist/sonare-rt.wasm +0 -0
- package/src/sonare-rt.d.ts +0 -93
package/src/worklet.ts
CHANGED
|
@@ -1,129 +1,19 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
EngineClip,
|
|
7
|
-
EngineMarker,
|
|
8
|
-
EngineMetronomeConfig,
|
|
9
|
-
EngineMidiClipSchedule,
|
|
10
|
-
EngineParameterInfo,
|
|
11
|
-
EngineScopeTelemetry,
|
|
12
|
-
EngineTempoSegment,
|
|
13
|
-
EngineTimeSignatureSegment,
|
|
14
|
-
EngineTrackLane,
|
|
15
|
-
EngineTrackSend,
|
|
16
|
-
EngineTransportState,
|
|
17
|
-
EqBand,
|
|
18
|
-
MixerRealtimeBuffer,
|
|
19
|
-
PanLaw,
|
|
20
|
-
PanMode,
|
|
21
|
-
} from './index';
|
|
22
|
-
import {
|
|
23
|
-
engineCapabilities,
|
|
24
|
-
init as initSonareModule,
|
|
25
|
-
isInitialized,
|
|
26
|
-
Mixer,
|
|
27
|
-
RealtimeEngine,
|
|
28
|
-
RealtimeVoiceChanger,
|
|
29
|
-
} from './index';
|
|
30
|
-
import type { SonareModule } from './sonare.js';
|
|
31
|
-
import type { SonareRtModule } from './sonare-rt';
|
|
1
|
+
// Public entry for the AudioWorklet surface. The implementation is split into
|
|
2
|
+
// focused modules under ./worklet/*; tsup bundles them back into a single
|
|
3
|
+
// self-contained dist/worklet.js (code-splitting is disabled because a real
|
|
4
|
+
// AudioWorkletGlobalScope cannot resolve sibling chunks), so the public surface
|
|
5
|
+
// is unchanged.
|
|
32
6
|
|
|
33
7
|
// With code-splitting disabled, the worklet bundle carries its own copy of the
|
|
34
|
-
// module singleton
|
|
35
|
-
//
|
|
36
|
-
// realm can initialize its own wasm instance, independent of the main-thread
|
|
37
|
-
// `index` module.
|
|
8
|
+
// module singleton. Re-export the lifecycle so that realm can initialize its
|
|
9
|
+
// own wasm instance, independent of the main-thread `index` module.
|
|
38
10
|
export { init, isInitialized } from './index';
|
|
11
|
+
export { SonareEngine } from './worklet/engine';
|
|
12
|
+
export { SonareRealtimeEngineNode } from './worklet/engine-node';
|
|
39
13
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
isEngineCaptureResponseMessage,
|
|
44
|
-
isEngineCommandRecord,
|
|
45
|
-
isEngineSyncMessage,
|
|
46
|
-
isEngineTelemetryRecord,
|
|
47
|
-
isEngineTransportRequestMessage,
|
|
48
|
-
isEngineTransportResponseMessage,
|
|
49
|
-
isMeterSnapshot,
|
|
50
|
-
isRealtimeVoiceChangerMessage,
|
|
51
|
-
isWorkletMessage,
|
|
52
|
-
} from './worklet/guards';
|
|
53
|
-
import {
|
|
54
|
-
DEFAULT_METRONOME_CONFIG,
|
|
55
|
-
type ResolvedMetronomeConfig,
|
|
56
|
-
resolveMetronomeConfig,
|
|
57
|
-
type SonareEngineCaptureRequestMessage,
|
|
58
|
-
type SonareEngineCaptureResponseMessage,
|
|
59
|
-
type SonareEngineInstrumentSyncMessage,
|
|
60
|
-
type SonareEngineSyncCaptureMessage,
|
|
61
|
-
type SonareEngineSyncMessage,
|
|
62
|
-
type SonareEngineTransportFacade,
|
|
63
|
-
type SonareEngineTransportRequestMessage,
|
|
64
|
-
type SonareEngineTransportResponseMessage,
|
|
65
|
-
type SonareRealtimeEngineNodeCapabilities,
|
|
66
|
-
type SonareRealtimeEngineNodeOptions,
|
|
67
|
-
type SonareRealtimeEngineWorkletProcessorOptions,
|
|
68
|
-
type SonareRealtimeVoiceChangerMessage,
|
|
69
|
-
type SonareRealtimeVoiceChangerWorkletProcessorOptions,
|
|
70
|
-
type SonareRtRealtimeEngineRuntimeOptions,
|
|
71
|
-
type SonareWorkletMessage,
|
|
72
|
-
type SonareWorkletProcessorOptions,
|
|
73
|
-
type WorkletPort,
|
|
74
|
-
type WorkletTransport,
|
|
75
|
-
} from './worklet/messages';
|
|
76
|
-
// --- internal modules (split out of this file; bundled back into a single
|
|
77
|
-
// dist/worklet.js by tsup, so the public surface is unchanged) ---
|
|
78
|
-
import {
|
|
79
|
-
createSonareEngineCommandRingBuffer,
|
|
80
|
-
createSonareEngineTelemetryRingBuffer,
|
|
81
|
-
createSonareMeterRingBuffer,
|
|
82
|
-
createSonareScopeRingBuffer,
|
|
83
|
-
ENGINE_MIXER_PARAM_FADER_DB,
|
|
84
|
-
ENGINE_MIXER_PARAM_PAN,
|
|
85
|
-
encodeFrameHi,
|
|
86
|
-
encodeFrameLo,
|
|
87
|
-
engineMixerBusTarget,
|
|
88
|
-
engineMixerLaneTarget,
|
|
89
|
-
engineMixerMasterTarget,
|
|
90
|
-
engineRingFromSharedBuffer,
|
|
91
|
-
isRecord,
|
|
92
|
-
magnitudeToDb,
|
|
93
|
-
meterFromEngine,
|
|
94
|
-
meterRingFromSharedBuffer,
|
|
95
|
-
popSonareEngineCommandRingBuffer,
|
|
96
|
-
pushSonareEngineCommandRingBuffer,
|
|
97
|
-
readSonareEngineTelemetryRingBuffer,
|
|
98
|
-
readSonareMeterRingBuffer,
|
|
99
|
-
readSonareScopeRingBuffer,
|
|
100
|
-
type SharedMeterRingWriter,
|
|
101
|
-
type SharedScopeRingWriter,
|
|
102
|
-
type SharedSpectrumRingWriter,
|
|
103
|
-
SONARE_ENGINE_COMMAND_RECORD_BYTES,
|
|
104
|
-
SONARE_ENGINE_TELEMETRY_RECORD_BYTES,
|
|
105
|
-
SONARE_METER_RING_RECORD_FLOATS,
|
|
106
|
-
SONARE_SCOPE_RING_RECORD_PREFIX_FLOATS,
|
|
107
|
-
type SonareEngineCommandRecord,
|
|
108
|
-
type SonareEngineCommandRingBuffer,
|
|
109
|
-
SonareEngineCommandType,
|
|
110
|
-
SonareEngineTelemetryError,
|
|
111
|
-
type SonareEngineTelemetryRecord,
|
|
112
|
-
type SonareEngineTelemetryRingBuffer,
|
|
113
|
-
SonareEngineTelemetryType,
|
|
114
|
-
type SonareMeterRingBuffer,
|
|
115
|
-
type SonareScopeRingBuffer,
|
|
116
|
-
type SonareWorkletMeterSnapshot,
|
|
117
|
-
type SonareWorkletScopeSnapshot,
|
|
118
|
-
type SonareWorkletSpectrumSnapshot,
|
|
119
|
-
scopeRingFromSharedBuffer,
|
|
120
|
-
spectrumRingFromSharedBuffer,
|
|
121
|
-
telemetryFromEngine,
|
|
122
|
-
toBigInt64,
|
|
123
|
-
toDb,
|
|
124
|
-
writeSonareEngineTelemetryRingBuffer,
|
|
125
|
-
} from './worklet/protocol';
|
|
126
|
-
|
|
14
|
+
export type { SonareEngineOptions } from './worklet/engine-options';
|
|
15
|
+
export { SonareRealtimeEngineWorkletProcessor } from './worklet/engine-processor';
|
|
16
|
+
export { registerSonareRealtimeEngineWorkletProcessor } from './worklet/engine-register';
|
|
127
17
|
export type {
|
|
128
18
|
SonareEngineCaptureRequestMessage,
|
|
129
19
|
SonareEngineCaptureResponseMessage,
|
|
@@ -159,7 +49,6 @@ export type {
|
|
|
159
49
|
SonareRealtimeVoiceChangerResetMessage,
|
|
160
50
|
SonareRealtimeVoiceChangerSetConfigMessage,
|
|
161
51
|
SonareRealtimeVoiceChangerWorkletProcessorOptions,
|
|
162
|
-
SonareRtRealtimeEngineRuntimeOptions,
|
|
163
52
|
SonareWorkletDestroyMessage,
|
|
164
53
|
SonareWorkletMessage,
|
|
165
54
|
SonareWorkletProcessorOptions,
|
|
@@ -167,6 +56,10 @@ export type {
|
|
|
167
56
|
SonareWorkletSetMeterIntervalMessage,
|
|
168
57
|
SonareWorkletTransportMessage,
|
|
169
58
|
} from './worklet/messages';
|
|
59
|
+
export {
|
|
60
|
+
registerSonareWorkletProcessor,
|
|
61
|
+
SonareWorkletProcessor,
|
|
62
|
+
} from './worklet/mixer-processor';
|
|
170
63
|
export {
|
|
171
64
|
createSonareEngineCommandRingBuffer,
|
|
172
65
|
createSonareEngineTelemetryRingBuffer,
|
|
@@ -213,3429 +106,7 @@ export {
|
|
|
213
106
|
sonareSpectrumRingBufferByteLength,
|
|
214
107
|
writeSonareEngineTelemetryRingBuffer,
|
|
215
108
|
} from './worklet/protocol';
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
offlineChannelCount?: number;
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
type SuspendableAudioContext = BaseAudioContext & {
|
|
224
|
-
suspend?: () => Promise<void>;
|
|
225
|
-
resume?: () => Promise<void>;
|
|
226
|
-
};
|
|
227
|
-
|
|
228
|
-
/**
|
|
229
|
-
* AudioWorklet-style mixer bridge backed by the package's single `sonare.wasm`.
|
|
230
|
-
*
|
|
231
|
-
* The WASM module must already be initialized via `init()` before constructing
|
|
232
|
-
* this bridge. Each AudioWorklet input is treated as one stereo strip:
|
|
233
|
-
* `inputs[strip][0]` is left and `inputs[strip][1]` is right. Missing channels
|
|
234
|
-
* are replaced with preallocated silence.
|
|
235
|
-
*/
|
|
236
|
-
export class SonareWorkletProcessor {
|
|
237
|
-
readonly sampleRate: number;
|
|
238
|
-
readonly blockSize: number;
|
|
239
|
-
private mixer: Mixer;
|
|
240
|
-
private realtime: MixerRealtimeBuffer;
|
|
241
|
-
private closed = false;
|
|
242
|
-
private processedFrames = 0;
|
|
243
|
-
private lastMeterFrame = 0;
|
|
244
|
-
private meterIntervalFrames: number;
|
|
245
|
-
private spectrumIntervalFrames: number;
|
|
246
|
-
private lastSpectrumFrame = 0;
|
|
247
|
-
private transport?: WorkletTransport;
|
|
248
|
-
private meterRing?: SharedMeterRingWriter;
|
|
249
|
-
private spectrumRing?: SharedSpectrumRingWriter;
|
|
250
|
-
private spectrumBands: Float32Array;
|
|
251
|
-
|
|
252
|
-
constructor(options: SonareWorkletProcessorOptions, transport?: WorkletTransport) {
|
|
253
|
-
if (!options.sceneJson) {
|
|
254
|
-
throw new Error('sceneJson is required.');
|
|
255
|
-
}
|
|
256
|
-
this.sampleRate = options.sampleRate ?? 48000;
|
|
257
|
-
this.blockSize = options.blockSize ?? 128;
|
|
258
|
-
this.meterIntervalFrames = Math.max(0, Math.floor(options.meterIntervalFrames ?? 2048));
|
|
259
|
-
this.spectrumIntervalFrames = Math.max(0, Math.floor(options.spectrumIntervalFrames ?? 0));
|
|
260
|
-
this.transport = transport;
|
|
261
|
-
this.meterIntervalFrames = Math.max(0, Math.floor(options.meterIntervalFrames ?? 2048));
|
|
262
|
-
this.meterRing = options.meterSharedBuffer
|
|
263
|
-
? meterRingFromSharedBuffer(options.meterSharedBuffer, options.meterRingCapacity)
|
|
264
|
-
: undefined;
|
|
265
|
-
this.spectrumRing = options.spectrumSharedBuffer
|
|
266
|
-
? spectrumRingFromSharedBuffer(
|
|
267
|
-
options.spectrumSharedBuffer,
|
|
268
|
-
options.spectrumRingCapacity,
|
|
269
|
-
options.spectrumBands,
|
|
270
|
-
)
|
|
271
|
-
: undefined;
|
|
272
|
-
const spectrumBandCount = this.spectrumRing?.bands ?? Math.max(1, options.spectrumBands ?? 16);
|
|
273
|
-
this.spectrumBands = new Float32Array(spectrumBandCount);
|
|
274
|
-
this.mixer = Mixer.fromSceneJson(options.sceneJson, this.sampleRate, this.blockSize);
|
|
275
|
-
this.mixer.compile();
|
|
276
|
-
const sceneStripCount = this.mixer.stripCount();
|
|
277
|
-
const stripCount = options.stripCount ?? sceneStripCount;
|
|
278
|
-
if (stripCount !== sceneStripCount) {
|
|
279
|
-
throw new Error('stripCount must match the scene strip count.');
|
|
280
|
-
}
|
|
281
|
-
this.realtime = this.mixer.createRealtimeBuffer();
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
process(inputs: WorkletInput, outputs: WorkletOutput): boolean {
|
|
285
|
-
if (this.closed) {
|
|
286
|
-
return false;
|
|
287
|
-
}
|
|
288
|
-
const output = outputs[0];
|
|
289
|
-
const leftOut = output?.[0];
|
|
290
|
-
const rightOut = output?.[1];
|
|
291
|
-
if (!leftOut) {
|
|
292
|
-
return true;
|
|
293
|
-
}
|
|
294
|
-
const frames = leftOut.length;
|
|
295
|
-
// The mixer's realtime heap buffers are sized to blockSize. A render quantum
|
|
296
|
-
// that differs from blockSize (e.g. a future browser using a quantum other
|
|
297
|
-
// than 128, or a misconfigured blockSize) must NOT return false here:
|
|
298
|
-
// returning false permanently terminates the AudioWorkletProcessor and
|
|
299
|
-
// silently kills the node mid-stream. Instead degrade gracefully by
|
|
300
|
-
// processing min(frames, blockSize) and zero-filling any remainder, mirroring
|
|
301
|
-
// the sonare-rt processor's behaviour.
|
|
302
|
-
const usable = Math.min(frames, this.blockSize);
|
|
303
|
-
|
|
304
|
-
for (let strip = 0; strip < this.realtime.leftInputs.length; strip++) {
|
|
305
|
-
const input = inputs[strip];
|
|
306
|
-
const left = input?.[0];
|
|
307
|
-
const right = input?.[1];
|
|
308
|
-
const leftTarget = this.realtime.leftInputs[strip];
|
|
309
|
-
const rightTarget = this.realtime.rightInputs[strip];
|
|
310
|
-
if (left && left.length >= usable) {
|
|
311
|
-
leftTarget.set(left.subarray(0, usable));
|
|
312
|
-
if (right && right.length >= usable) {
|
|
313
|
-
rightTarget.set(right.subarray(0, usable));
|
|
314
|
-
} else {
|
|
315
|
-
rightTarget.set(left.subarray(0, usable));
|
|
316
|
-
}
|
|
317
|
-
} else {
|
|
318
|
-
leftTarget.fill(0);
|
|
319
|
-
rightTarget.fill(0);
|
|
320
|
-
}
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
this.realtime.process(usable);
|
|
324
|
-
if (usable === frames) {
|
|
325
|
-
leftOut.set(this.realtime.outLeft.subarray(0, usable));
|
|
326
|
-
if (rightOut) {
|
|
327
|
-
rightOut.set(this.realtime.outRight.subarray(0, usable));
|
|
328
|
-
}
|
|
329
|
-
} else {
|
|
330
|
-
// frames > blockSize: fill the produced part and zero the remaining tail.
|
|
331
|
-
leftOut.fill(0);
|
|
332
|
-
leftOut.set(this.realtime.outLeft.subarray(0, usable));
|
|
333
|
-
if (rightOut) {
|
|
334
|
-
rightOut.fill(0);
|
|
335
|
-
rightOut.set(this.realtime.outRight.subarray(0, usable));
|
|
336
|
-
}
|
|
337
|
-
}
|
|
338
|
-
this.processedFrames += usable;
|
|
339
|
-
this.publishMeter(
|
|
340
|
-
this.realtime.outLeft.subarray(0, usable),
|
|
341
|
-
this.realtime.outRight.subarray(0, usable),
|
|
342
|
-
);
|
|
343
|
-
this.publishSpectrum(
|
|
344
|
-
this.realtime.outLeft.subarray(0, usable),
|
|
345
|
-
this.realtime.outRight.subarray(0, usable),
|
|
346
|
-
);
|
|
347
|
-
return true;
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
receiveMessage(message: SonareWorkletMessage): void {
|
|
351
|
-
if (this.closed) {
|
|
352
|
-
return;
|
|
353
|
-
}
|
|
354
|
-
if (message.type === 'destroy') {
|
|
355
|
-
this.destroy();
|
|
356
|
-
return;
|
|
357
|
-
}
|
|
358
|
-
if (message.type === 'setMeterInterval') {
|
|
359
|
-
this.meterIntervalFrames = Math.max(0, Math.floor(message.frames));
|
|
360
|
-
return;
|
|
361
|
-
}
|
|
362
|
-
if (message.type === 'scheduleInsertAutomation') {
|
|
363
|
-
this.mixer.scheduleInsertAutomation(
|
|
364
|
-
message.stripIndex,
|
|
365
|
-
message.insertIndex,
|
|
366
|
-
message.paramId,
|
|
367
|
-
message.samplePos ?? this.processedFrames,
|
|
368
|
-
message.value,
|
|
369
|
-
message.curve ?? 'linear',
|
|
370
|
-
);
|
|
371
|
-
}
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
destroy(): void {
|
|
375
|
-
if (!this.closed) {
|
|
376
|
-
this.mixer.delete();
|
|
377
|
-
this.closed = true;
|
|
378
|
-
}
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
private publishMeter(left: Float32Array, right: Float32Array): void {
|
|
382
|
-
if (!this.transport || this.meterIntervalFrames <= 0) {
|
|
383
|
-
return;
|
|
384
|
-
}
|
|
385
|
-
if (this.processedFrames - this.lastMeterFrame < this.meterIntervalFrames) {
|
|
386
|
-
return;
|
|
387
|
-
}
|
|
388
|
-
this.lastMeterFrame = this.processedFrames;
|
|
389
|
-
|
|
390
|
-
let peakL = 0;
|
|
391
|
-
let peakR = 0;
|
|
392
|
-
let sumL = 0;
|
|
393
|
-
let sumR = 0;
|
|
394
|
-
let sumLR = 0;
|
|
395
|
-
for (let i = 0; i < left.length; i++) {
|
|
396
|
-
const l = left[i] ?? 0;
|
|
397
|
-
const r = right[i] ?? 0;
|
|
398
|
-
const absL = Math.abs(l);
|
|
399
|
-
const absR = Math.abs(r);
|
|
400
|
-
if (absL > peakL) {
|
|
401
|
-
peakL = absL;
|
|
402
|
-
}
|
|
403
|
-
if (absR > peakR) {
|
|
404
|
-
peakR = absR;
|
|
405
|
-
}
|
|
406
|
-
sumL += l * l;
|
|
407
|
-
sumR += r * r;
|
|
408
|
-
sumLR += l * r;
|
|
409
|
-
}
|
|
410
|
-
const rmsL = Math.sqrt(sumL / Math.max(1, left.length));
|
|
411
|
-
const rmsR = Math.sqrt(sumR / Math.max(1, right.length));
|
|
412
|
-
const denominator = Math.sqrt(sumL * sumR);
|
|
413
|
-
const meter: SonareWorkletMeterSnapshot = {
|
|
414
|
-
type: 'meter',
|
|
415
|
-
targetId: 0,
|
|
416
|
-
frame: this.processedFrames,
|
|
417
|
-
peakDbL: toDb(peakL),
|
|
418
|
-
peakDbR: toDb(peakR),
|
|
419
|
-
rmsDbL: toDb(rmsL),
|
|
420
|
-
rmsDbR: toDb(rmsR),
|
|
421
|
-
correlation: denominator > 0 ? sumLR / denominator : 0,
|
|
422
|
-
truePeakDbL: toDb(peakL),
|
|
423
|
-
truePeakDbR: toDb(peakR),
|
|
424
|
-
momentaryLufs: Number.NaN,
|
|
425
|
-
shortTermLufs: Number.NaN,
|
|
426
|
-
integratedLufs: Number.NaN,
|
|
427
|
-
gainReductionDb: Number.NaN,
|
|
428
|
-
};
|
|
429
|
-
this.transport.onMeter?.(meter);
|
|
430
|
-
if (this.meterRing) {
|
|
431
|
-
this.writeMeterRing(meter);
|
|
432
|
-
} else {
|
|
433
|
-
this.transport.postMessage?.(meter);
|
|
434
|
-
}
|
|
435
|
-
}
|
|
436
|
-
|
|
437
|
-
private writeMeterRing(meter: SonareWorkletMeterSnapshot): void {
|
|
438
|
-
const ring = this.meterRing;
|
|
439
|
-
if (!ring) {
|
|
440
|
-
return;
|
|
441
|
-
}
|
|
442
|
-
const writeIndex = Atomics.load(ring.header, 0);
|
|
443
|
-
const offset = (writeIndex % ring.capacity) * SONARE_METER_RING_RECORD_FLOATS;
|
|
444
|
-
ring.records[offset] = encodeFrameLo(meter.frame);
|
|
445
|
-
ring.records[offset + 1] = encodeFrameHi(meter.frame);
|
|
446
|
-
ring.records[offset + 2] = meter.targetId;
|
|
447
|
-
ring.records[offset + 3] = meter.peakDbL;
|
|
448
|
-
ring.records[offset + 4] = meter.peakDbR;
|
|
449
|
-
ring.records[offset + 5] = meter.rmsDbL;
|
|
450
|
-
ring.records[offset + 6] = meter.rmsDbR;
|
|
451
|
-
ring.records[offset + 7] = meter.correlation;
|
|
452
|
-
ring.records[offset + 8] = meter.truePeakDbL;
|
|
453
|
-
ring.records[offset + 9] = meter.truePeakDbR;
|
|
454
|
-
ring.records[offset + 10] = meter.momentaryLufs;
|
|
455
|
-
ring.records[offset + 11] = meter.shortTermLufs;
|
|
456
|
-
ring.records[offset + 12] = meter.integratedLufs;
|
|
457
|
-
ring.records[offset + 13] = meter.gainReductionDb;
|
|
458
|
-
Atomics.store(ring.header, 0, writeIndex + 1);
|
|
459
|
-
// writeIndex is a free-running monotonic counter, so an overflow guard here
|
|
460
|
-
// would fire on essentially every write past the first `capacity` records
|
|
461
|
-
// and store an ever-growing value, not a dropped-record count. Readers
|
|
462
|
-
// already detect silent overrun via firstReadable = max(readIndex,
|
|
463
|
-
// writeIndex - capacity), so header slot 3 is left at its initial 0.
|
|
464
|
-
}
|
|
465
|
-
|
|
466
|
-
private publishSpectrum(left: Float32Array, right: Float32Array): void {
|
|
467
|
-
if (this.spectrumIntervalFrames <= 0) {
|
|
468
|
-
return;
|
|
469
|
-
}
|
|
470
|
-
if (this.processedFrames - this.lastSpectrumFrame < this.spectrumIntervalFrames) {
|
|
471
|
-
return;
|
|
472
|
-
}
|
|
473
|
-
this.lastSpectrumFrame = this.processedFrames;
|
|
474
|
-
this.computeSpectrum(left, right);
|
|
475
|
-
if (this.spectrumRing) {
|
|
476
|
-
this.writeSpectrumRing(this.processedFrames, this.spectrumBands);
|
|
477
|
-
return;
|
|
478
|
-
}
|
|
479
|
-
const spectrum: SonareWorkletSpectrumSnapshot = {
|
|
480
|
-
type: 'spectrum',
|
|
481
|
-
frame: this.processedFrames,
|
|
482
|
-
bands: new Float32Array(this.spectrumBands),
|
|
483
|
-
};
|
|
484
|
-
this.transport?.onSpectrum?.(spectrum);
|
|
485
|
-
this.transport?.postMessage?.(spectrum);
|
|
486
|
-
}
|
|
487
|
-
|
|
488
|
-
private computeSpectrum(left: Float32Array, right: Float32Array): void {
|
|
489
|
-
// Coarse per-render-quantum band energy, NOT a full FFT analyzer: each band
|
|
490
|
-
// is a single-bin DFT (bin = band + 1) evaluated over the current block of n
|
|
491
|
-
// samples. Bins at or above the block Nyquist (band + 1 > floor(n / 2))
|
|
492
|
-
// alias, so the evaluated band count is clamped to floor(n / 2) and any
|
|
493
|
-
// higher bands are pinned to the silence floor. Bin resolution is therefore
|
|
494
|
-
// tied to the render quantum (typically 128 samples); treat the output as a
|
|
495
|
-
// rough spectral tilt, not a precise spectrum.
|
|
496
|
-
const n = Math.max(1, Math.min(left.length, right.length));
|
|
497
|
-
const maxBand = Math.floor(n / 2);
|
|
498
|
-
for (let band = 0; band < this.spectrumBands.length; band++) {
|
|
499
|
-
if (band >= maxBand) {
|
|
500
|
-
this.spectrumBands[band] = magnitudeToDb(0);
|
|
501
|
-
continue;
|
|
502
|
-
}
|
|
503
|
-
const bin = band + 1;
|
|
504
|
-
let real = 0;
|
|
505
|
-
let imag = 0;
|
|
506
|
-
for (let i = 0; i < n; i++) {
|
|
507
|
-
const sample = 0.5 * ((left[i] ?? 0) + (right[i] ?? 0));
|
|
508
|
-
const phase = (-2 * Math.PI * bin * i) / n;
|
|
509
|
-
real += sample * Math.cos(phase);
|
|
510
|
-
imag += sample * Math.sin(phase);
|
|
511
|
-
}
|
|
512
|
-
this.spectrumBands[band] = magnitudeToDb((2 * Math.hypot(real, imag)) / n);
|
|
513
|
-
}
|
|
514
|
-
}
|
|
515
|
-
|
|
516
|
-
private writeSpectrumRing(frame: number, bands: Float32Array): void {
|
|
517
|
-
const ring = this.spectrumRing;
|
|
518
|
-
if (!ring) {
|
|
519
|
-
return;
|
|
520
|
-
}
|
|
521
|
-
const writeIndex = Atomics.load(ring.header, 0);
|
|
522
|
-
const offset = (writeIndex % ring.capacity) * ring.recordFloats;
|
|
523
|
-
ring.records[offset] = encodeFrameLo(frame);
|
|
524
|
-
ring.records[offset + 1] = encodeFrameHi(frame);
|
|
525
|
-
ring.records[offset + 2] = bands.length;
|
|
526
|
-
ring.records.set(bands.subarray(0, ring.bands), offset + 3);
|
|
527
|
-
Atomics.store(ring.header, 0, writeIndex + 1);
|
|
528
|
-
// See writeMeterRing: header slot 4 (the spectrum-ring overflow slot) is
|
|
529
|
-
// left at its initial 0; readers detect silent overrun via the
|
|
530
|
-
// firstReadable = max(readIndex, writeIndex - capacity) clamp. (Slot 3 here
|
|
531
|
-
// holds the band count and is still written at ring creation.)
|
|
532
|
-
}
|
|
533
|
-
}
|
|
534
|
-
|
|
535
|
-
/**
|
|
536
|
-
* AudioWorklet-style bridge for the DAW realtime engine facade.
|
|
537
|
-
*
|
|
538
|
-
* The default mode uses the existing `sonare.wasm` embind facade. The
|
|
539
|
-
* `sonare-rt` target is exposed as a selectable runtime target for hosts that
|
|
540
|
-
* load the dedicated Emscripten AudioWorklet module.
|
|
541
|
-
*/
|
|
542
|
-
export class SonareRealtimeEngineWorkletProcessor {
|
|
543
|
-
private static warnedChannelScratchOverflow = false;
|
|
544
|
-
readonly sampleRate: number;
|
|
545
|
-
readonly blockSize: number;
|
|
546
|
-
readonly channelCount: number;
|
|
547
|
-
readonly runtimeTarget: 'embind' | 'sonare-rt';
|
|
548
|
-
private engine: RealtimeEngine;
|
|
549
|
-
private closed = false;
|
|
550
|
-
private commandRing?: SonareEngineCommandRingBuffer;
|
|
551
|
-
private telemetryRing?: SonareEngineTelemetryRingBuffer;
|
|
552
|
-
private meterRing?: SharedMeterRingWriter;
|
|
553
|
-
private scopeRing?: SharedScopeRingWriter;
|
|
554
|
-
private transport?: WorkletTransport;
|
|
555
|
-
private meterIntervalFrames: number;
|
|
556
|
-
private lastMeterFrame = Number.NEGATIVE_INFINITY;
|
|
557
|
-
// Latest metronome gains/click length pushed via 'syncMetronome'. The
|
|
558
|
-
// SetMetronome command only toggles enabled state; the config arrives here.
|
|
559
|
-
private metronomeConfig: ResolvedMetronomeConfig = { ...DEFAULT_METRONOME_CONFIG };
|
|
560
|
-
// Zero-copy prepared realtime path: persistent per-channel views onto the
|
|
561
|
-
// engine's WASM-heap scratch (acquired once on the main thread via
|
|
562
|
-
// getChannelBuffer). process() writes the AudioWorklet input straight into
|
|
563
|
-
// these views, calls engine.processPrepared(frames) which runs the engine IN
|
|
564
|
-
// PLACE, then reads the same views back — no std::vector or JS Float32Array is
|
|
565
|
-
// allocated per render quantum (the old engine.process() round-tripped fresh
|
|
566
|
-
// arrays on both heaps every block, an RT-safety hazard).
|
|
567
|
-
private channelBuffers: Float32Array[];
|
|
568
|
-
private readonly liveClips = new Map<number, EngineClip>();
|
|
569
|
-
|
|
570
|
-
constructor(
|
|
571
|
-
options: SonareRealtimeEngineWorkletProcessorOptions = {},
|
|
572
|
-
transport?: WorkletTransport,
|
|
573
|
-
) {
|
|
574
|
-
this.sampleRate = options.sampleRate ?? 48000;
|
|
575
|
-
this.blockSize = options.blockSize ?? 128;
|
|
576
|
-
this.channelCount = Math.max(1, Math.floor(options.channelCount ?? 2));
|
|
577
|
-
this.runtimeTarget = options.runtimeTarget ?? 'embind';
|
|
578
|
-
if (this.runtimeTarget === 'sonare-rt') {
|
|
579
|
-
throw new Error(
|
|
580
|
-
'sonare-rt runtime is provided by the dedicated Emscripten AudioWorklet module; use SonareRealtimeEngineNode.create({ runtimeTarget: "sonare-rt", moduleUrl: ... }) to load it.',
|
|
581
|
-
);
|
|
582
|
-
}
|
|
583
|
-
this.transport = transport;
|
|
584
|
-
this.meterIntervalFrames = Math.max(0, Math.floor(options.meterIntervalFrames ?? 2048));
|
|
585
|
-
this.commandRing = options.commandSharedBuffer
|
|
586
|
-
? this.commandRingFromSharedBuffer(options.commandSharedBuffer, options.commandRingCapacity)
|
|
587
|
-
: undefined;
|
|
588
|
-
this.telemetryRing = options.telemetrySharedBuffer
|
|
589
|
-
? this.telemetryRingFromSharedBuffer(
|
|
590
|
-
options.telemetrySharedBuffer,
|
|
591
|
-
options.telemetryRingCapacity,
|
|
592
|
-
)
|
|
593
|
-
: undefined;
|
|
594
|
-
this.meterRing = options.meterSharedBuffer
|
|
595
|
-
? meterRingFromSharedBuffer(options.meterSharedBuffer, options.meterRingCapacity)
|
|
596
|
-
: undefined;
|
|
597
|
-
this.scopeRing = options.scopeSharedBuffer
|
|
598
|
-
? scopeRingFromSharedBuffer(
|
|
599
|
-
options.scopeSharedBuffer,
|
|
600
|
-
options.scopeRingCapacity,
|
|
601
|
-
options.scopeBands,
|
|
602
|
-
)
|
|
603
|
-
: undefined;
|
|
604
|
-
this.engine = new RealtimeEngine(this.sampleRate, this.blockSize);
|
|
605
|
-
// Allocate persistent WASM-heap scratch (worst case: channelCount channels x
|
|
606
|
-
// blockSize frames) and acquire the per-channel heap views once.
|
|
607
|
-
this.engine.prepareChannels(this.channelCount, this.blockSize);
|
|
608
|
-
this.channelBuffers = new Array(this.channelCount);
|
|
609
|
-
for (let ch = 0; ch < this.channelCount; ch++) {
|
|
610
|
-
this.channelBuffers[ch] = this.engine.getChannelBuffer(ch, this.blockSize);
|
|
611
|
-
}
|
|
612
|
-
// Arm the engine's scope producer only when a scope ring was provided. The
|
|
613
|
-
// band count follows the ring's record layout so writeScopeRing never
|
|
614
|
-
// overruns its slot.
|
|
615
|
-
if (this.scopeRing) {
|
|
616
|
-
const interval = Math.max(1, Math.floor(options.scopeIntervalFrames ?? this.blockSize));
|
|
617
|
-
this.engine.configureScopeTelemetry(interval, this.scopeRing.bands);
|
|
618
|
-
}
|
|
619
|
-
}
|
|
620
|
-
|
|
621
|
-
process(inputs: WorkletInput, outputs: WorkletOutput): boolean {
|
|
622
|
-
if (this.closed) {
|
|
623
|
-
return false;
|
|
624
|
-
}
|
|
625
|
-
const output = outputs[0];
|
|
626
|
-
const firstOutput = output?.[0];
|
|
627
|
-
if (!firstOutput) {
|
|
628
|
-
return true;
|
|
629
|
-
}
|
|
630
|
-
const frames = firstOutput.length;
|
|
631
|
-
if (frames > this.blockSize) {
|
|
632
|
-
for (const channel of output ?? []) {
|
|
633
|
-
channel.fill(0);
|
|
634
|
-
}
|
|
635
|
-
this.publishTelemetry();
|
|
636
|
-
return true;
|
|
637
|
-
}
|
|
638
|
-
|
|
639
|
-
this.drainCommands();
|
|
640
|
-
|
|
641
|
-
// Clamp `frames` to the pre-allocated scratch capacity. The earlier
|
|
642
|
-
// `frames > this.blockSize` branch already returns early, so this is
|
|
643
|
-
// defensive — but we warn once if it ever fires so the contract violation
|
|
644
|
-
// is visible.
|
|
645
|
-
let usableFrames = frames;
|
|
646
|
-
if (usableFrames > this.blockSize) {
|
|
647
|
-
if (!SonareRealtimeEngineWorkletProcessor.warnedChannelScratchOverflow) {
|
|
648
|
-
SonareRealtimeEngineWorkletProcessor.warnedChannelScratchOverflow = true;
|
|
649
|
-
// biome-ignore lint/suspicious/noConsole: realtime-safety diagnostic.
|
|
650
|
-
console.warn(
|
|
651
|
-
`SonareRealtimeEngineWorkletProcessor: requested ${usableFrames} frames ` +
|
|
652
|
-
`exceeds pre-allocated capacity ${this.blockSize}; clamping.`,
|
|
653
|
-
);
|
|
654
|
-
}
|
|
655
|
-
usableFrames = this.blockSize;
|
|
656
|
-
}
|
|
657
|
-
|
|
658
|
-
// Defend against WASM linear-memory growth detaching the cached heap views:
|
|
659
|
-
// if any view's backing ArrayBuffer has been detached (byteLength === 0),
|
|
660
|
-
// re-acquire all of them. This is a control-flow check (no allocation in the
|
|
661
|
-
// common case where memory did not grow).
|
|
662
|
-
if ((this.channelBuffers[0]?.byteLength ?? 0) === 0) {
|
|
663
|
-
this.reacquireChannelBuffers();
|
|
664
|
-
}
|
|
665
|
-
|
|
666
|
-
const input = inputs[0];
|
|
667
|
-
// Write the AudioWorklet input straight into the engine's WASM-heap views;
|
|
668
|
-
// no per-block heap allocation.
|
|
669
|
-
for (let ch = 0; ch < this.channelCount; ch++) {
|
|
670
|
-
const dst = this.channelBuffers[ch];
|
|
671
|
-
const source = input?.[ch];
|
|
672
|
-
if (source && source.length === usableFrames) {
|
|
673
|
-
dst.set(source.subarray(0, usableFrames));
|
|
674
|
-
} else {
|
|
675
|
-
dst.fill(0, 0, usableFrames);
|
|
676
|
-
}
|
|
677
|
-
}
|
|
678
|
-
|
|
679
|
-
// Run the engine in place over the prepared scratch (allocation-free).
|
|
680
|
-
this.engine.processPrepared(usableFrames);
|
|
681
|
-
|
|
682
|
-
for (let ch = 0; ch < output.length; ch++) {
|
|
683
|
-
const target = output[ch];
|
|
684
|
-
const source = this.channelBuffers[ch] ?? this.channelBuffers[0];
|
|
685
|
-
if (source) {
|
|
686
|
-
target.set(source.subarray(0, Math.min(target.length, usableFrames)));
|
|
687
|
-
if (target.length > usableFrames) {
|
|
688
|
-
target.fill(0, usableFrames);
|
|
689
|
-
}
|
|
690
|
-
} else {
|
|
691
|
-
target.fill(0);
|
|
692
|
-
}
|
|
693
|
-
}
|
|
694
|
-
this.publishTelemetry();
|
|
695
|
-
this.publishMeters();
|
|
696
|
-
this.publishScope();
|
|
697
|
-
return true;
|
|
698
|
-
}
|
|
699
|
-
|
|
700
|
-
private reacquireChannelBuffers(): void {
|
|
701
|
-
for (let ch = 0; ch < this.channelCount; ch++) {
|
|
702
|
-
this.channelBuffers[ch] = this.engine.getChannelBuffer(ch, this.blockSize);
|
|
703
|
-
}
|
|
704
|
-
}
|
|
705
|
-
|
|
706
|
-
receiveCommand(command: SonareEngineCommandRecord): void {
|
|
707
|
-
if (!this.closed) {
|
|
708
|
-
this.applyCommand(command);
|
|
709
|
-
}
|
|
710
|
-
}
|
|
711
|
-
|
|
712
|
-
// Applies an out-of-band control-plane sync message. Runs on the AudioWorklet
|
|
713
|
-
// global scope but OUTSIDE process() (the message-port callback), so the
|
|
714
|
-
// bulk/allocating engine setters (setClips/setMarkers) are safe here — they
|
|
715
|
-
// never run on the realtime render path. This is the audio-thread equivalent
|
|
716
|
-
// of the engine's control-thread RtPublisher setters.
|
|
717
|
-
receiveSync(message: SonareEngineSyncMessage): void {
|
|
718
|
-
if (this.closed) {
|
|
719
|
-
return;
|
|
720
|
-
}
|
|
721
|
-
switch (message.type) {
|
|
722
|
-
case 'syncClips':
|
|
723
|
-
this.liveClips.clear();
|
|
724
|
-
for (const clip of message.clips) {
|
|
725
|
-
if (clip.id !== undefined) {
|
|
726
|
-
this.liveClips.set(clip.id, clip);
|
|
727
|
-
}
|
|
728
|
-
}
|
|
729
|
-
this.engine.setClips(message.clips);
|
|
730
|
-
break;
|
|
731
|
-
case 'syncClipsDelta':
|
|
732
|
-
for (const clipId of message.removeIds) {
|
|
733
|
-
this.liveClips.delete(clipId);
|
|
734
|
-
}
|
|
735
|
-
for (const clip of message.upserts) {
|
|
736
|
-
if (clip.id !== undefined) {
|
|
737
|
-
this.liveClips.set(clip.id, clip);
|
|
738
|
-
}
|
|
739
|
-
}
|
|
740
|
-
this.engine.setClips(Array.from(this.liveClips.values()));
|
|
741
|
-
break;
|
|
742
|
-
case 'syncMidiClips':
|
|
743
|
-
this.engine.setMidiClips(message.clips);
|
|
744
|
-
break;
|
|
745
|
-
case 'syncMarkers':
|
|
746
|
-
this.engine.setMarkers(message.markers);
|
|
747
|
-
break;
|
|
748
|
-
case 'syncMetronome':
|
|
749
|
-
this.metronomeConfig = resolveMetronomeConfig(message.config);
|
|
750
|
-
this.engine.setMetronome(message.config);
|
|
751
|
-
break;
|
|
752
|
-
case 'syncAutomation':
|
|
753
|
-
this.engine.setAutomationLane(message.paramId, message.points);
|
|
754
|
-
break;
|
|
755
|
-
case 'syncTempo':
|
|
756
|
-
if (message.tempoSegments) {
|
|
757
|
-
this.engine.setTempoSegments(message.tempoSegments);
|
|
758
|
-
} else {
|
|
759
|
-
this.engine.setTempo(message.bpm);
|
|
760
|
-
}
|
|
761
|
-
if (message.timeSignatureSegments) {
|
|
762
|
-
this.engine.setTimeSignatureSegments(message.timeSignatureSegments);
|
|
763
|
-
} else {
|
|
764
|
-
this.engine.setTimeSignature(
|
|
765
|
-
message.timeSignature.numerator,
|
|
766
|
-
message.timeSignature.denominator,
|
|
767
|
-
);
|
|
768
|
-
}
|
|
769
|
-
break;
|
|
770
|
-
case 'syncMixer':
|
|
771
|
-
if (message.buses) {
|
|
772
|
-
this.engine.setTrackBuses(message.buses);
|
|
773
|
-
}
|
|
774
|
-
this.engine.setTrackLanes(message.lanes);
|
|
775
|
-
for (const strip of message.trackStrips ?? []) {
|
|
776
|
-
this.engine.setTrackStripJson(strip.trackId, strip.sceneJson);
|
|
777
|
-
}
|
|
778
|
-
for (const strip of message.busStrips ?? []) {
|
|
779
|
-
this.engine.setBusStripJson(strip.busId, strip.sceneJson);
|
|
780
|
-
}
|
|
781
|
-
if (message.masterStripJson) {
|
|
782
|
-
this.engine.setMasterStripJson(message.masterStripJson);
|
|
783
|
-
}
|
|
784
|
-
for (const binding of message.laneSidechains ?? []) {
|
|
785
|
-
this.engine.setLaneSidechain(binding.trackId, binding.insertIndex, binding.sourceTrackId);
|
|
786
|
-
}
|
|
787
|
-
break;
|
|
788
|
-
case 'syncCapture':
|
|
789
|
-
this.engine.setCaptureBuffer(message.channels, message.bufferFrames);
|
|
790
|
-
this.engine.setCaptureSource(message.source);
|
|
791
|
-
this.engine.setRecordOffsetSamples(message.recordOffsetSamples);
|
|
792
|
-
this.engine.setInputMonitor(message.inputMonitor.enabled, message.inputMonitor.gain);
|
|
793
|
-
break;
|
|
794
|
-
case 'syncTrackStripEqBand':
|
|
795
|
-
this.engine.setTrackStripEqBandJson(message.trackId, message.bandIndex, message.bandJson);
|
|
796
|
-
break;
|
|
797
|
-
case 'syncMasterStripEqBand':
|
|
798
|
-
this.engine.setMasterStripEqBandJson(message.bandIndex, message.bandJson);
|
|
799
|
-
break;
|
|
800
|
-
case 'syncTrackStripInsertBypassed':
|
|
801
|
-
this.engine.setTrackStripInsertBypassed(
|
|
802
|
-
message.trackId,
|
|
803
|
-
message.insertIndex,
|
|
804
|
-
message.bypassed,
|
|
805
|
-
message.resetOnBypass,
|
|
806
|
-
);
|
|
807
|
-
break;
|
|
808
|
-
case 'syncMasterStripInsertBypassed':
|
|
809
|
-
this.engine.setMasterStripInsertBypassed(
|
|
810
|
-
message.insertIndex,
|
|
811
|
-
message.bypassed,
|
|
812
|
-
message.resetOnBypass,
|
|
813
|
-
);
|
|
814
|
-
break;
|
|
815
|
-
case 'syncTrackStripInsertParamByName':
|
|
816
|
-
this.engine.setTrackStripInsertParamByName(
|
|
817
|
-
message.trackId,
|
|
818
|
-
message.insertIndex,
|
|
819
|
-
message.paramName,
|
|
820
|
-
message.value,
|
|
821
|
-
);
|
|
822
|
-
break;
|
|
823
|
-
case 'syncMasterStripInsertParamByName':
|
|
824
|
-
this.engine.setMasterStripInsertParamByName(
|
|
825
|
-
message.insertIndex,
|
|
826
|
-
message.paramName,
|
|
827
|
-
message.value,
|
|
828
|
-
);
|
|
829
|
-
break;
|
|
830
|
-
case 'syncTrackStripPan':
|
|
831
|
-
this.engine.setTrackStripPan(message.trackId, message.pan);
|
|
832
|
-
break;
|
|
833
|
-
case 'syncTrackStripPanLaw':
|
|
834
|
-
this.engine.setTrackStripPanLaw(message.trackId, message.panLaw);
|
|
835
|
-
break;
|
|
836
|
-
case 'syncTrackStripPanMode':
|
|
837
|
-
this.engine.setTrackStripPanMode(message.trackId, message.panMode);
|
|
838
|
-
break;
|
|
839
|
-
case 'syncTrackStripDualPan':
|
|
840
|
-
this.engine.setTrackStripDualPan(message.trackId, message.leftPan, message.rightPan);
|
|
841
|
-
break;
|
|
842
|
-
case 'syncTrackStripChannelDelaySamples':
|
|
843
|
-
this.engine.setTrackStripChannelDelaySamples(message.trackId, message.delaySamples);
|
|
844
|
-
break;
|
|
845
|
-
case 'syncBuiltinInstrument':
|
|
846
|
-
this.engine.setBuiltinInstrument(message.config, message.destinationId);
|
|
847
|
-
break;
|
|
848
|
-
case 'syncSynthInstrument':
|
|
849
|
-
this.engine.setSynthInstrument(message.patch, message.destinationId);
|
|
850
|
-
break;
|
|
851
|
-
case 'syncLoadSoundFont':
|
|
852
|
-
this.engine.loadSoundFont(message.data);
|
|
853
|
-
break;
|
|
854
|
-
case 'syncSf2Instrument':
|
|
855
|
-
this.engine.setSf2Instrument(message.config, message.destinationId);
|
|
856
|
-
break;
|
|
857
|
-
case 'syncMidiNoteOn':
|
|
858
|
-
this.engine.pushMidiNoteOn(
|
|
859
|
-
message.destinationId,
|
|
860
|
-
message.group,
|
|
861
|
-
message.channel,
|
|
862
|
-
message.note,
|
|
863
|
-
message.velocity,
|
|
864
|
-
message.renderFrame,
|
|
865
|
-
);
|
|
866
|
-
break;
|
|
867
|
-
case 'syncMidiNoteOff':
|
|
868
|
-
this.engine.pushMidiNoteOff(
|
|
869
|
-
message.destinationId,
|
|
870
|
-
message.group,
|
|
871
|
-
message.channel,
|
|
872
|
-
message.note,
|
|
873
|
-
message.velocity,
|
|
874
|
-
message.renderFrame,
|
|
875
|
-
);
|
|
876
|
-
break;
|
|
877
|
-
case 'syncMidiCc':
|
|
878
|
-
this.engine.pushMidiCc(
|
|
879
|
-
message.destinationId,
|
|
880
|
-
message.group,
|
|
881
|
-
message.channel,
|
|
882
|
-
message.controller,
|
|
883
|
-
message.value,
|
|
884
|
-
message.renderFrame,
|
|
885
|
-
);
|
|
886
|
-
break;
|
|
887
|
-
case 'syncMidiPanic':
|
|
888
|
-
this.engine.pushMidiPanic(message.renderFrame);
|
|
889
|
-
break;
|
|
890
|
-
}
|
|
891
|
-
}
|
|
892
|
-
|
|
893
|
-
receiveCaptureRequest(message: SonareEngineCaptureRequestMessage): void {
|
|
894
|
-
if (this.closed) {
|
|
895
|
-
return;
|
|
896
|
-
}
|
|
897
|
-
try {
|
|
898
|
-
if (message.op === 'status') {
|
|
899
|
-
const status = this.engine.captureStatus();
|
|
900
|
-
this.transport?.postMessage?.({
|
|
901
|
-
type: 'captureResponse',
|
|
902
|
-
requestId: message.requestId,
|
|
903
|
-
ok: true,
|
|
904
|
-
status: {
|
|
905
|
-
capturedFrames: status.capturedFrames,
|
|
906
|
-
overflowCount: status.overflowCount,
|
|
907
|
-
armed: status.armed,
|
|
908
|
-
punchEnabled: status.punchEnabled,
|
|
909
|
-
source: status.source,
|
|
910
|
-
recordOffsetSamples: status.recordOffsetSamples,
|
|
911
|
-
},
|
|
912
|
-
} satisfies SonareEngineCaptureResponseMessage);
|
|
913
|
-
return;
|
|
914
|
-
}
|
|
915
|
-
if (message.op === 'read') {
|
|
916
|
-
const captured = this.engine.capturedAudio();
|
|
917
|
-
const channels: number[][] = [];
|
|
918
|
-
for (let ch = 0; ch < captured.length; ch++) {
|
|
919
|
-
const source = captured[ch];
|
|
920
|
-
const copy: number[] = [];
|
|
921
|
-
for (let i = 0; i < source.length; i++) {
|
|
922
|
-
copy.push(Number(source[i]));
|
|
923
|
-
}
|
|
924
|
-
channels.push(copy);
|
|
925
|
-
}
|
|
926
|
-
this.transport?.postMessage?.({
|
|
927
|
-
type: 'captureResponse',
|
|
928
|
-
requestId: message.requestId,
|
|
929
|
-
ok: true,
|
|
930
|
-
channels,
|
|
931
|
-
} satisfies SonareEngineCaptureResponseMessage);
|
|
932
|
-
return;
|
|
933
|
-
}
|
|
934
|
-
this.engine.resetCapture();
|
|
935
|
-
this.transport?.postMessage?.({
|
|
936
|
-
type: 'captureResponse',
|
|
937
|
-
requestId: message.requestId,
|
|
938
|
-
ok: true,
|
|
939
|
-
} satisfies SonareEngineCaptureResponseMessage);
|
|
940
|
-
} catch (error) {
|
|
941
|
-
this.transport?.postMessage?.({
|
|
942
|
-
type: 'captureResponse',
|
|
943
|
-
requestId: message.requestId,
|
|
944
|
-
ok: false,
|
|
945
|
-
error: error instanceof Error ? error.message : String(error),
|
|
946
|
-
} satisfies SonareEngineCaptureResponseMessage);
|
|
947
|
-
}
|
|
948
|
-
}
|
|
949
|
-
|
|
950
|
-
receiveTransportRequest(message: SonareEngineTransportRequestMessage): void {
|
|
951
|
-
if (this.closed) {
|
|
952
|
-
return;
|
|
953
|
-
}
|
|
954
|
-
try {
|
|
955
|
-
this.transport?.postMessage?.({
|
|
956
|
-
type: 'transportResponse',
|
|
957
|
-
requestId: message.requestId,
|
|
958
|
-
ok: true,
|
|
959
|
-
state: this.engine.getTransportState(),
|
|
960
|
-
} satisfies SonareEngineTransportResponseMessage);
|
|
961
|
-
} catch (error) {
|
|
962
|
-
this.transport?.postMessage?.({
|
|
963
|
-
type: 'transportResponse',
|
|
964
|
-
requestId: message.requestId,
|
|
965
|
-
ok: false,
|
|
966
|
-
error: error instanceof Error ? error.message : String(error),
|
|
967
|
-
} satisfies SonareEngineTransportResponseMessage);
|
|
968
|
-
}
|
|
969
|
-
}
|
|
970
|
-
|
|
971
|
-
destroy(): void {
|
|
972
|
-
if (!this.closed) {
|
|
973
|
-
this.engine.destroy();
|
|
974
|
-
this.closed = true;
|
|
975
|
-
}
|
|
976
|
-
}
|
|
977
|
-
|
|
978
|
-
private drainCommands(): void {
|
|
979
|
-
if (!this.commandRing) {
|
|
980
|
-
return;
|
|
981
|
-
}
|
|
982
|
-
for (let i = 0; i < 64; i++) {
|
|
983
|
-
const command = popSonareEngineCommandRingBuffer(this.commandRing);
|
|
984
|
-
if (!command) {
|
|
985
|
-
return;
|
|
986
|
-
}
|
|
987
|
-
this.applyCommand(command);
|
|
988
|
-
}
|
|
989
|
-
}
|
|
990
|
-
|
|
991
|
-
private applyCommand(command: SonareEngineCommandRecord): void {
|
|
992
|
-
const sampleTime = Number(command.sampleTime ?? -1);
|
|
993
|
-
switch (command.type) {
|
|
994
|
-
case SonareEngineCommandType.SetParam:
|
|
995
|
-
// paramId is carried in targetId, the new value in argFloat (matches the
|
|
996
|
-
// SonareEngine.setParam producer). sampleTime is the render frame.
|
|
997
|
-
this.engine.setParameter(
|
|
998
|
-
Math.trunc(Number(command.targetId ?? 0)),
|
|
999
|
-
Number(command.argFloat ?? 0),
|
|
1000
|
-
sampleTime,
|
|
1001
|
-
);
|
|
1002
|
-
break;
|
|
1003
|
-
case SonareEngineCommandType.SetParamSmoothed:
|
|
1004
|
-
this.engine.setParameterSmoothed(
|
|
1005
|
-
Math.trunc(Number(command.targetId ?? 0)),
|
|
1006
|
-
Number(command.argFloat ?? 0),
|
|
1007
|
-
sampleTime,
|
|
1008
|
-
);
|
|
1009
|
-
break;
|
|
1010
|
-
case SonareEngineCommandType.TransportPlay:
|
|
1011
|
-
this.engine.play(sampleTime);
|
|
1012
|
-
break;
|
|
1013
|
-
case SonareEngineCommandType.TransportStop:
|
|
1014
|
-
this.engine.stop(sampleTime);
|
|
1015
|
-
break;
|
|
1016
|
-
case SonareEngineCommandType.TransportSeekSample:
|
|
1017
|
-
this.engine.seekSample(Number(command.argInt ?? 0), sampleTime);
|
|
1018
|
-
break;
|
|
1019
|
-
case SonareEngineCommandType.TransportSeekPpq:
|
|
1020
|
-
this.engine.seekPpq(Number(command.argFloat ?? 0), sampleTime);
|
|
1021
|
-
break;
|
|
1022
|
-
case SonareEngineCommandType.SetTempoMap:
|
|
1023
|
-
this.engine.setTempo(Number(command.argFloat ?? 120));
|
|
1024
|
-
break;
|
|
1025
|
-
case SonareEngineCommandType.SetLoop:
|
|
1026
|
-
this.engine.setLoop(
|
|
1027
|
-
Number(command.argFloat ?? 0),
|
|
1028
|
-
Number(command.argInt ?? 0) / 1_000_000,
|
|
1029
|
-
command.targetId !== 0,
|
|
1030
|
-
);
|
|
1031
|
-
break;
|
|
1032
|
-
case SonareEngineCommandType.ArmRecord:
|
|
1033
|
-
this.engine.armCapture(Boolean(command.argInt));
|
|
1034
|
-
break;
|
|
1035
|
-
case SonareEngineCommandType.Punch:
|
|
1036
|
-
// Both endpoints already arrive as samples (see SonareEngine.punch);
|
|
1037
|
-
// do NOT re-scale by sampleRate.
|
|
1038
|
-
this.engine.setCapturePunch(
|
|
1039
|
-
Number(command.argInt ?? 0),
|
|
1040
|
-
Math.max(0, Math.round(Number(command.argFloat ?? 0))),
|
|
1041
|
-
true,
|
|
1042
|
-
);
|
|
1043
|
-
break;
|
|
1044
|
-
case SonareEngineCommandType.SetMetronome:
|
|
1045
|
-
// Metronome config (beatGain/accentGain/clickSamples/clickSeconds) is
|
|
1046
|
-
// delivered out-of-band via the 'syncMetronome' message so it carries
|
|
1047
|
-
// the caller's full config; the command only toggles enabled state as a
|
|
1048
|
-
// sample-aligned fallback.
|
|
1049
|
-
this.engine.setMetronome({
|
|
1050
|
-
enabled: Boolean(command.argInt),
|
|
1051
|
-
beatGain: this.metronomeConfig.beatGain,
|
|
1052
|
-
accentGain: this.metronomeConfig.accentGain,
|
|
1053
|
-
clickSamples: this.metronomeConfig.clickSamples,
|
|
1054
|
-
});
|
|
1055
|
-
break;
|
|
1056
|
-
case SonareEngineCommandType.SeekMarker:
|
|
1057
|
-
// The realtime engine's markers are kept in sync via 'syncMarkers'
|
|
1058
|
-
// (RtPublisher-style swap), so a queued kSeekMarker resolves correctly.
|
|
1059
|
-
this.engine.seekMarker(Math.trunc(Number(command.targetId ?? 0)), sampleTime);
|
|
1060
|
-
break;
|
|
1061
|
-
case SonareEngineCommandType.SetSoloMute:
|
|
1062
|
-
this.engine.setSoloMute(
|
|
1063
|
-
Math.trunc(Number(command.targetId ?? 0)),
|
|
1064
|
-
Boolean((Number(command.argInt ?? 0) & 0x2) !== 0),
|
|
1065
|
-
Boolean((Number(command.argInt ?? 0) & 0x1) !== 0),
|
|
1066
|
-
sampleTime,
|
|
1067
|
-
);
|
|
1068
|
-
break;
|
|
1069
|
-
default:
|
|
1070
|
-
this.publishTelemetryRecord({
|
|
1071
|
-
type: SonareEngineTelemetryType.Error,
|
|
1072
|
-
error: SonareEngineTelemetryError.UnknownTarget,
|
|
1073
|
-
renderFrame: 0,
|
|
1074
|
-
timelineSample: 0,
|
|
1075
|
-
audibleTimelineSample: 0,
|
|
1076
|
-
graphLatencySamplesQ8: 0,
|
|
1077
|
-
value: Number(command.type),
|
|
1078
|
-
});
|
|
1079
|
-
break;
|
|
1080
|
-
}
|
|
1081
|
-
}
|
|
1082
|
-
|
|
1083
|
-
private publishTelemetry(): void {
|
|
1084
|
-
for (const item of this.engine.drainTelemetry(64)) {
|
|
1085
|
-
this.publishTelemetryRecord(telemetryFromEngine(item));
|
|
1086
|
-
}
|
|
1087
|
-
}
|
|
1088
|
-
|
|
1089
|
-
private publishTelemetryRecord(record: SonareEngineTelemetryRecord): void {
|
|
1090
|
-
if (this.telemetryRing) {
|
|
1091
|
-
writeSonareEngineTelemetryRingBuffer(this.telemetryRing, record);
|
|
1092
|
-
return;
|
|
1093
|
-
}
|
|
1094
|
-
this.transport?.postMessage?.(record);
|
|
1095
|
-
}
|
|
1096
|
-
|
|
1097
|
-
// Drains the engine meter telemetry queue into the stereo meter ring / transport.
|
|
1098
|
-
//
|
|
1099
|
-
// Shared-queue contract: `drainMeterTelemetry` and `drainMeterTelemetryWide`
|
|
1100
|
-
// pop the SAME single-consumer telemetry queue, so exactly ONE of them may run
|
|
1101
|
-
// per engine. The live worklet path owns the queue via the stereo drain below;
|
|
1102
|
-
// the worklet meter ring (SONARE_METER_RING_RECORD_FLOATS) is a fixed stereo
|
|
1103
|
-
// layout carrying planes 0/1 plus the correlation/LUFS summary. Per-plane
|
|
1104
|
-
// surround meters are NOT delivered over the live worklet ring — a host that
|
|
1105
|
-
// needs them must use the offline `drainMeterTelemetryWide()` API on a
|
|
1106
|
-
// non-worklet engine instance (do not also call it on a worklet-driven engine,
|
|
1107
|
-
// or the two drains will starve each other).
|
|
1108
|
-
private publishMeters(): void {
|
|
1109
|
-
if (this.meterIntervalFrames <= 0 || (!this.transport && !this.meterRing)) {
|
|
1110
|
-
return;
|
|
1111
|
-
}
|
|
1112
|
-
for (const item of this.engine.drainMeterTelemetry(64)) {
|
|
1113
|
-
const meter = meterFromEngine(item);
|
|
1114
|
-
if (
|
|
1115
|
-
meter.frame !== this.lastMeterFrame &&
|
|
1116
|
-
meter.frame - this.lastMeterFrame < this.meterIntervalFrames
|
|
1117
|
-
) {
|
|
1118
|
-
continue;
|
|
1119
|
-
}
|
|
1120
|
-
if (meter.frame !== this.lastMeterFrame) {
|
|
1121
|
-
this.lastMeterFrame = meter.frame;
|
|
1122
|
-
}
|
|
1123
|
-
// Prefer the lock-free SAB meter ring (matching the telemetry path and
|
|
1124
|
-
// SonareWorkletProcessor); only fall back to structured-clone postMessage
|
|
1125
|
-
// when no ring was provided, so we do not allocate/post from the audio
|
|
1126
|
-
// render callback in SAB mode.
|
|
1127
|
-
if (this.meterRing) {
|
|
1128
|
-
this.writeMeterRing(meter);
|
|
1129
|
-
} else {
|
|
1130
|
-
this.transport?.onMeter?.(meter);
|
|
1131
|
-
this.transport?.postMessage?.(meter);
|
|
1132
|
-
}
|
|
1133
|
-
}
|
|
1134
|
-
}
|
|
1135
|
-
|
|
1136
|
-
private writeMeterRing(meter: SonareWorkletMeterSnapshot): void {
|
|
1137
|
-
const ring = this.meterRing;
|
|
1138
|
-
if (!ring) {
|
|
1139
|
-
return;
|
|
1140
|
-
}
|
|
1141
|
-
const writeIndex = Atomics.load(ring.header, 0);
|
|
1142
|
-
const offset = (writeIndex % ring.capacity) * SONARE_METER_RING_RECORD_FLOATS;
|
|
1143
|
-
ring.records[offset] = encodeFrameLo(meter.frame);
|
|
1144
|
-
ring.records[offset + 1] = encodeFrameHi(meter.frame);
|
|
1145
|
-
ring.records[offset + 2] = meter.targetId;
|
|
1146
|
-
ring.records[offset + 3] = meter.peakDbL;
|
|
1147
|
-
ring.records[offset + 4] = meter.peakDbR;
|
|
1148
|
-
ring.records[offset + 5] = meter.rmsDbL;
|
|
1149
|
-
ring.records[offset + 6] = meter.rmsDbR;
|
|
1150
|
-
ring.records[offset + 7] = meter.correlation;
|
|
1151
|
-
ring.records[offset + 8] = meter.truePeakDbL;
|
|
1152
|
-
ring.records[offset + 9] = meter.truePeakDbR;
|
|
1153
|
-
ring.records[offset + 10] = meter.momentaryLufs;
|
|
1154
|
-
ring.records[offset + 11] = meter.shortTermLufs;
|
|
1155
|
-
ring.records[offset + 12] = meter.integratedLufs;
|
|
1156
|
-
ring.records[offset + 13] = meter.gainReductionDb;
|
|
1157
|
-
Atomics.store(ring.header, 0, writeIndex + 1);
|
|
1158
|
-
// writeIndex is a free-running monotonic counter, so an overflow guard here
|
|
1159
|
-
// would fire on essentially every write past the first `capacity` records
|
|
1160
|
-
// and store an ever-growing value, not a dropped-record count. Readers
|
|
1161
|
-
// already detect silent overrun via firstReadable = max(readIndex,
|
|
1162
|
-
// writeIndex - capacity), so header slot 3 is left at its initial 0.
|
|
1163
|
-
}
|
|
1164
|
-
|
|
1165
|
-
// Drains the engine's scope producer (FFT spectrum + goniometer points) into
|
|
1166
|
-
// the lock-free SAB scope ring. Only the embind runtime publishes scope
|
|
1167
|
-
// telemetry; the sonare-rt runtime owns its own transport. No allocation on
|
|
1168
|
-
// the render path: records are written field-by-field into the ring.
|
|
1169
|
-
private publishScope(): void {
|
|
1170
|
-
const ring = this.scopeRing;
|
|
1171
|
-
if (!ring) {
|
|
1172
|
-
return;
|
|
1173
|
-
}
|
|
1174
|
-
for (const item of this.engine.drainScopeTelemetry(64)) {
|
|
1175
|
-
this.writeScopeRing(ring, item);
|
|
1176
|
-
}
|
|
1177
|
-
}
|
|
1178
|
-
|
|
1179
|
-
private writeScopeRing(ring: SharedScopeRingWriter, record: EngineScopeTelemetry): void {
|
|
1180
|
-
const writeIndex = Atomics.load(ring.header, 0);
|
|
1181
|
-
const base = (writeIndex % ring.capacity) * ring.recordFloats;
|
|
1182
|
-
ring.records[base] = encodeFrameLo(record.renderFrame);
|
|
1183
|
-
ring.records[base + 1] = encodeFrameHi(record.renderFrame);
|
|
1184
|
-
ring.records[base + 2] = record.targetId;
|
|
1185
|
-
const bandCount = Math.min(ring.bands, record.bands.length);
|
|
1186
|
-
ring.records[base + 3] = bandCount;
|
|
1187
|
-
const pointCount = Math.min(ring.maxPoints, record.points.length);
|
|
1188
|
-
ring.records[base + 4] = pointCount;
|
|
1189
|
-
const bandsBase = base + SONARE_SCOPE_RING_RECORD_PREFIX_FLOATS;
|
|
1190
|
-
for (let i = 0; i < bandCount; i++) {
|
|
1191
|
-
ring.records[bandsBase + i] = record.bands[i];
|
|
1192
|
-
}
|
|
1193
|
-
const pointsBase = bandsBase + ring.bands;
|
|
1194
|
-
for (let i = 0; i < pointCount; i++) {
|
|
1195
|
-
const point = record.points[i];
|
|
1196
|
-
ring.records[pointsBase + 2 * i] = point.left;
|
|
1197
|
-
ring.records[pointsBase + 2 * i + 1] = point.right;
|
|
1198
|
-
}
|
|
1199
|
-
Atomics.store(ring.header, 0, writeIndex + 1);
|
|
1200
|
-
// Like writeMeterRing, writeIndex is a free-running monotonic counter; the
|
|
1201
|
-
// reader detects silent overrun via firstReadable, so the overflow slot
|
|
1202
|
-
// (header[5]) stays at its initial 0.
|
|
1203
|
-
}
|
|
1204
|
-
|
|
1205
|
-
private commandRingFromSharedBuffer(
|
|
1206
|
-
sharedBuffer: SharedArrayBuffer,
|
|
1207
|
-
fallbackCapacity?: number,
|
|
1208
|
-
): SonareEngineCommandRingBuffer {
|
|
1209
|
-
const ring = engineRingFromSharedBuffer(
|
|
1210
|
-
sharedBuffer,
|
|
1211
|
-
SONARE_ENGINE_COMMAND_RECORD_BYTES,
|
|
1212
|
-
fallbackCapacity,
|
|
1213
|
-
);
|
|
1214
|
-
return { sharedBuffer, header: ring.header, view: ring.view, capacity: ring.capacity };
|
|
1215
|
-
}
|
|
1216
|
-
|
|
1217
|
-
private telemetryRingFromSharedBuffer(
|
|
1218
|
-
sharedBuffer: SharedArrayBuffer,
|
|
1219
|
-
fallbackCapacity?: number,
|
|
1220
|
-
): SonareEngineTelemetryRingBuffer {
|
|
1221
|
-
const ring = engineRingFromSharedBuffer(
|
|
1222
|
-
sharedBuffer,
|
|
1223
|
-
SONARE_ENGINE_TELEMETRY_RECORD_BYTES,
|
|
1224
|
-
fallbackCapacity,
|
|
1225
|
-
);
|
|
1226
|
-
return { sharedBuffer, header: ring.header, view: ring.view, capacity: ring.capacity };
|
|
1227
|
-
}
|
|
1228
|
-
}
|
|
1229
|
-
|
|
1230
|
-
export class SonareRtRealtimeEngineRuntime {
|
|
1231
|
-
readonly sampleRate: number;
|
|
1232
|
-
readonly blockSize: number;
|
|
1233
|
-
readonly channelCount: number;
|
|
1234
|
-
private readonly module: SonareRtModule;
|
|
1235
|
-
private readonly memory: WebAssembly.Memory;
|
|
1236
|
-
private readonly engine: number;
|
|
1237
|
-
private readonly channelPointerTable: number;
|
|
1238
|
-
private readonly channelBuffers: number[];
|
|
1239
|
-
private readonly telemetryIntsPtr: number;
|
|
1240
|
-
private readonly telemetryFramesPtr: number;
|
|
1241
|
-
private readonly commandRing?: SonareEngineCommandRingBuffer;
|
|
1242
|
-
private readonly telemetryRing?: SonareEngineTelemetryRingBuffer;
|
|
1243
|
-
private metronomeConfig: ResolvedMetronomeConfig = { ...DEFAULT_METRONOME_CONFIG };
|
|
1244
|
-
private closed = false;
|
|
1245
|
-
|
|
1246
|
-
constructor(options: SonareRtRealtimeEngineRuntimeOptions) {
|
|
1247
|
-
this.module = options.module;
|
|
1248
|
-
this.memory = options.memory;
|
|
1249
|
-
this.sampleRate = options.sampleRate ?? 48000;
|
|
1250
|
-
this.blockSize = options.blockSize ?? 128;
|
|
1251
|
-
this.channelCount = Math.max(1, Math.floor(options.channelCount ?? 2));
|
|
1252
|
-
this.commandRing = options.commandSharedBuffer
|
|
1253
|
-
? this.commandRingFromSharedBuffer(options.commandSharedBuffer, options.commandRingCapacity)
|
|
1254
|
-
: undefined;
|
|
1255
|
-
this.telemetryRing = options.telemetrySharedBuffer
|
|
1256
|
-
? this.telemetryRingFromSharedBuffer(
|
|
1257
|
-
options.telemetrySharedBuffer,
|
|
1258
|
-
options.telemetryRingCapacity,
|
|
1259
|
-
)
|
|
1260
|
-
: undefined;
|
|
1261
|
-
|
|
1262
|
-
this.engine = this.module._sonare_rt_engine_create();
|
|
1263
|
-
if (this.engine <= 0) {
|
|
1264
|
-
throw new Error('failed to create sonare-rt engine');
|
|
1265
|
-
}
|
|
1266
|
-
if (
|
|
1267
|
-
this.module._sonare_rt_engine_prepare(
|
|
1268
|
-
this.engine,
|
|
1269
|
-
this.sampleRate,
|
|
1270
|
-
this.blockSize,
|
|
1271
|
-
1024,
|
|
1272
|
-
1024,
|
|
1273
|
-
) !== 1
|
|
1274
|
-
) {
|
|
1275
|
-
this.module._sonare_rt_engine_destroy(this.engine);
|
|
1276
|
-
throw new Error('failed to prepare sonare-rt engine');
|
|
1277
|
-
}
|
|
1278
|
-
this.channelPointerTable = this.module._malloc(
|
|
1279
|
-
this.channelCount * Uint32Array.BYTES_PER_ELEMENT,
|
|
1280
|
-
);
|
|
1281
|
-
this.channelBuffers = [];
|
|
1282
|
-
for (let ch = 0; ch < this.channelCount; ch++) {
|
|
1283
|
-
this.channelBuffers.push(
|
|
1284
|
-
this.module._malloc(this.blockSize * Float32Array.BYTES_PER_ELEMENT),
|
|
1285
|
-
);
|
|
1286
|
-
}
|
|
1287
|
-
this.telemetryIntsPtr = this.module._malloc(64 * 4 * Int32Array.BYTES_PER_ELEMENT);
|
|
1288
|
-
this.telemetryFramesPtr = this.module._malloc(64 * 3 * Float64Array.BYTES_PER_ELEMENT);
|
|
1289
|
-
this.writeChannelPointers();
|
|
1290
|
-
}
|
|
1291
|
-
|
|
1292
|
-
process(inputs: WorkletInput, outputs: WorkletOutput): boolean {
|
|
1293
|
-
if (this.closed) {
|
|
1294
|
-
return false;
|
|
1295
|
-
}
|
|
1296
|
-
const output = outputs[0];
|
|
1297
|
-
const firstOutput = output?.[0];
|
|
1298
|
-
if (!firstOutput) {
|
|
1299
|
-
return true;
|
|
1300
|
-
}
|
|
1301
|
-
const frames = firstOutput.length;
|
|
1302
|
-
if (frames > this.blockSize) {
|
|
1303
|
-
for (const channel of output) {
|
|
1304
|
-
channel.fill(0);
|
|
1305
|
-
}
|
|
1306
|
-
return true;
|
|
1307
|
-
}
|
|
1308
|
-
|
|
1309
|
-
this.drainCommands();
|
|
1310
|
-
const heap = new Float32Array(this.memory.buffer);
|
|
1311
|
-
const input = inputs[0];
|
|
1312
|
-
for (let ch = 0; ch < this.channelCount; ch++) {
|
|
1313
|
-
const ptr = this.channelBuffers[ch] ?? this.channelBuffers[0];
|
|
1314
|
-
const offset = ptr >> 2;
|
|
1315
|
-
const source = input?.[ch];
|
|
1316
|
-
if (source && source.length === frames) {
|
|
1317
|
-
heap.set(source, offset);
|
|
1318
|
-
} else {
|
|
1319
|
-
heap.fill(0, offset, offset + frames);
|
|
1320
|
-
}
|
|
1321
|
-
}
|
|
1322
|
-
|
|
1323
|
-
this.module._sonare_rt_engine_process(
|
|
1324
|
-
this.engine,
|
|
1325
|
-
this.channelPointerTable,
|
|
1326
|
-
this.channelCount,
|
|
1327
|
-
frames,
|
|
1328
|
-
);
|
|
1329
|
-
|
|
1330
|
-
for (let ch = 0; ch < output.length; ch++) {
|
|
1331
|
-
const target = output[ch];
|
|
1332
|
-
const ptr = this.channelBuffers[ch] ?? this.channelBuffers[0];
|
|
1333
|
-
target.set(heap.subarray(ptr >> 2, (ptr >> 2) + target.length));
|
|
1334
|
-
}
|
|
1335
|
-
this.publishTelemetry();
|
|
1336
|
-
return true;
|
|
1337
|
-
}
|
|
1338
|
-
|
|
1339
|
-
receiveCommand(command: SonareEngineCommandRecord): void {
|
|
1340
|
-
if (!this.closed) {
|
|
1341
|
-
this.applyCommand(command);
|
|
1342
|
-
}
|
|
1343
|
-
}
|
|
1344
|
-
|
|
1345
|
-
// Out-of-band control sync for the sonare-rt runtime. The sonare-rt C ABI
|
|
1346
|
-
// (src/wasm/rt_bindings.cpp) exposes set_metronome_enabled and seek_marker but
|
|
1347
|
-
// NOT set_clips / set_markers, so clip/marker mutations cannot be applied to a
|
|
1348
|
-
// live sonare-rt engine. We honor the metronome config and surface a clear
|
|
1349
|
-
// telemetry error for the unsupported clip/marker paths instead of silently
|
|
1350
|
-
// dropping them. The default 'embind' runtime wires all three fully.
|
|
1351
|
-
receiveSync(message: SonareEngineSyncMessage): void {
|
|
1352
|
-
if (this.closed) {
|
|
1353
|
-
return;
|
|
1354
|
-
}
|
|
1355
|
-
switch (message.type) {
|
|
1356
|
-
case 'syncMetronome':
|
|
1357
|
-
this.metronomeConfig = resolveMetronomeConfig(message.config);
|
|
1358
|
-
this.module._sonare_rt_engine_set_metronome_enabled(
|
|
1359
|
-
this.engine,
|
|
1360
|
-
message.config.enabled ? 1 : 0,
|
|
1361
|
-
this.metronomeConfig.beatGain,
|
|
1362
|
-
this.metronomeConfig.accentGain,
|
|
1363
|
-
this.metronomeConfig.clickSamples,
|
|
1364
|
-
);
|
|
1365
|
-
break;
|
|
1366
|
-
case 'syncTempo':
|
|
1367
|
-
this.module._sonare_rt_engine_set_tempo(this.engine, message.bpm);
|
|
1368
|
-
break;
|
|
1369
|
-
case 'syncClips':
|
|
1370
|
-
case 'syncClipsDelta':
|
|
1371
|
-
case 'syncMidiClips':
|
|
1372
|
-
case 'syncMarkers':
|
|
1373
|
-
case 'syncAutomation':
|
|
1374
|
-
case 'syncMixer':
|
|
1375
|
-
case 'syncCapture':
|
|
1376
|
-
case 'syncTrackStripEqBand':
|
|
1377
|
-
case 'syncMasterStripEqBand':
|
|
1378
|
-
case 'syncTrackStripInsertBypassed':
|
|
1379
|
-
case 'syncMasterStripInsertBypassed':
|
|
1380
|
-
case 'syncBuiltinInstrument':
|
|
1381
|
-
case 'syncSynthInstrument':
|
|
1382
|
-
case 'syncSf2Instrument':
|
|
1383
|
-
case 'syncLoadSoundFont':
|
|
1384
|
-
case 'syncMidiNoteOn':
|
|
1385
|
-
case 'syncMidiNoteOff':
|
|
1386
|
-
case 'syncMidiCc':
|
|
1387
|
-
case 'syncMidiPanic':
|
|
1388
|
-
// The sonare-rt C ABI exposes no set_clips / set_markers /
|
|
1389
|
-
// set_automation_lane / set_track_lanes, so these mutations cannot
|
|
1390
|
-
// reach a live sonare-rt engine. Surface a clear telemetry error rather
|
|
1391
|
-
// than silently dropping.
|
|
1392
|
-
if (this.telemetryRing) {
|
|
1393
|
-
writeSonareEngineTelemetryRingBuffer(this.telemetryRing, {
|
|
1394
|
-
type: SonareEngineTelemetryType.Error,
|
|
1395
|
-
error: SonareEngineTelemetryError.UnknownTarget,
|
|
1396
|
-
renderFrame: 0,
|
|
1397
|
-
timelineSample: 0,
|
|
1398
|
-
audibleTimelineSample: 0,
|
|
1399
|
-
graphLatencySamplesQ8: 0,
|
|
1400
|
-
value: 0,
|
|
1401
|
-
});
|
|
1402
|
-
}
|
|
1403
|
-
break;
|
|
1404
|
-
}
|
|
1405
|
-
}
|
|
1406
|
-
|
|
1407
|
-
receiveCaptureRequest(message: SonareEngineCaptureRequestMessage, port?: WorkletPort): void {
|
|
1408
|
-
if (this.closed) {
|
|
1409
|
-
return;
|
|
1410
|
-
}
|
|
1411
|
-
port?.postMessage?.({
|
|
1412
|
-
type: 'captureResponse',
|
|
1413
|
-
requestId: message.requestId,
|
|
1414
|
-
ok: false,
|
|
1415
|
-
error: 'Capture read-back is not supported by the sonare-rt runtime.',
|
|
1416
|
-
} satisfies SonareEngineCaptureResponseMessage);
|
|
1417
|
-
}
|
|
1418
|
-
|
|
1419
|
-
receiveTransportRequest(message: SonareEngineTransportRequestMessage, port?: WorkletPort): void {
|
|
1420
|
-
if (this.closed) {
|
|
1421
|
-
return;
|
|
1422
|
-
}
|
|
1423
|
-
port?.postMessage?.({
|
|
1424
|
-
type: 'transportResponse',
|
|
1425
|
-
requestId: message.requestId,
|
|
1426
|
-
ok: false,
|
|
1427
|
-
error: 'Transport state read-back is not supported by the sonare-rt runtime.',
|
|
1428
|
-
} satisfies SonareEngineTransportResponseMessage);
|
|
1429
|
-
}
|
|
1430
|
-
|
|
1431
|
-
destroy(): void {
|
|
1432
|
-
if (this.closed) {
|
|
1433
|
-
return;
|
|
1434
|
-
}
|
|
1435
|
-
this.module._free(this.telemetryFramesPtr);
|
|
1436
|
-
this.module._free(this.telemetryIntsPtr);
|
|
1437
|
-
for (const ptr of this.channelBuffers) {
|
|
1438
|
-
this.module._free(ptr);
|
|
1439
|
-
}
|
|
1440
|
-
this.module._free(this.channelPointerTable);
|
|
1441
|
-
this.module._sonare_rt_engine_destroy(this.engine);
|
|
1442
|
-
this.closed = true;
|
|
1443
|
-
}
|
|
1444
|
-
|
|
1445
|
-
private writeChannelPointers(): void {
|
|
1446
|
-
const pointers = new Uint32Array(this.memory.buffer);
|
|
1447
|
-
const offset = this.channelPointerTable >> 2;
|
|
1448
|
-
for (let ch = 0; ch < this.channelBuffers.length; ch++) {
|
|
1449
|
-
pointers[offset + ch] = this.channelBuffers[ch];
|
|
1450
|
-
}
|
|
1451
|
-
}
|
|
1452
|
-
|
|
1453
|
-
private drainCommands(): void {
|
|
1454
|
-
if (!this.commandRing) {
|
|
1455
|
-
return;
|
|
1456
|
-
}
|
|
1457
|
-
for (let i = 0; i < 64; i++) {
|
|
1458
|
-
const command = popSonareEngineCommandRingBuffer(this.commandRing);
|
|
1459
|
-
if (!command) {
|
|
1460
|
-
return;
|
|
1461
|
-
}
|
|
1462
|
-
this.applyCommand(command);
|
|
1463
|
-
}
|
|
1464
|
-
}
|
|
1465
|
-
|
|
1466
|
-
private applyCommand(command: SonareEngineCommandRecord): void {
|
|
1467
|
-
const sampleTime = toBigInt64(command.sampleTime, -1n);
|
|
1468
|
-
switch (command.type) {
|
|
1469
|
-
case SonareEngineCommandType.SetParam:
|
|
1470
|
-
case SonareEngineCommandType.SetParamSmoothed:
|
|
1471
|
-
// The sonare-rt C ABI (src/wasm/rt_bindings.cpp) does not export a
|
|
1472
|
-
// sonare_rt_engine_set_param entry point, so parameter automation has no
|
|
1473
|
-
// realtime transport on this runtime target. Surface a clear error
|
|
1474
|
-
// telemetry record (rather than silently dropping the command) so hosts
|
|
1475
|
-
// can detect the unsupported path; the embind runtime fully wires this.
|
|
1476
|
-
if (this.telemetryRing) {
|
|
1477
|
-
writeSonareEngineTelemetryRingBuffer(this.telemetryRing, {
|
|
1478
|
-
type: SonareEngineTelemetryType.Error,
|
|
1479
|
-
error: SonareEngineTelemetryError.UnknownTarget,
|
|
1480
|
-
renderFrame: 0,
|
|
1481
|
-
timelineSample: 0,
|
|
1482
|
-
audibleTimelineSample: 0,
|
|
1483
|
-
graphLatencySamplesQ8: 0,
|
|
1484
|
-
value: Number(command.type),
|
|
1485
|
-
});
|
|
1486
|
-
}
|
|
1487
|
-
break;
|
|
1488
|
-
case SonareEngineCommandType.TransportPlay:
|
|
1489
|
-
this.module._sonare_rt_engine_play(this.engine, sampleTime);
|
|
1490
|
-
break;
|
|
1491
|
-
case SonareEngineCommandType.TransportStop:
|
|
1492
|
-
this.module._sonare_rt_engine_stop(this.engine, sampleTime);
|
|
1493
|
-
break;
|
|
1494
|
-
case SonareEngineCommandType.TransportSeekSample:
|
|
1495
|
-
this.module._sonare_rt_engine_seek_sample(
|
|
1496
|
-
this.engine,
|
|
1497
|
-
toBigInt64(command.argInt, 0n),
|
|
1498
|
-
sampleTime,
|
|
1499
|
-
);
|
|
1500
|
-
break;
|
|
1501
|
-
case SonareEngineCommandType.TransportSeekPpq:
|
|
1502
|
-
this.module._sonare_rt_engine_seek_ppq(
|
|
1503
|
-
this.engine,
|
|
1504
|
-
Number(command.argFloat ?? 0),
|
|
1505
|
-
sampleTime,
|
|
1506
|
-
);
|
|
1507
|
-
break;
|
|
1508
|
-
case SonareEngineCommandType.SetTempoMap:
|
|
1509
|
-
this.module._sonare_rt_engine_set_tempo(this.engine, Number(command.argFloat ?? 120));
|
|
1510
|
-
break;
|
|
1511
|
-
case SonareEngineCommandType.SetLoop:
|
|
1512
|
-
this.module._sonare_rt_engine_set_loop(
|
|
1513
|
-
this.engine,
|
|
1514
|
-
Number(command.argFloat ?? 0),
|
|
1515
|
-
Number(command.argInt ?? 0) / 1_000_000,
|
|
1516
|
-
command.targetId ? 1 : 0,
|
|
1517
|
-
);
|
|
1518
|
-
break;
|
|
1519
|
-
case SonareEngineCommandType.ArmRecord:
|
|
1520
|
-
this.module._sonare_rt_engine_set_capture_armed(this.engine, command.argInt ? 1 : 0);
|
|
1521
|
-
break;
|
|
1522
|
-
case SonareEngineCommandType.Punch:
|
|
1523
|
-
// Both endpoints already arrive as samples (see SonareEngine.punch);
|
|
1524
|
-
// do NOT re-scale by sampleRate.
|
|
1525
|
-
this.module._sonare_rt_engine_set_capture_punch(
|
|
1526
|
-
this.engine,
|
|
1527
|
-
toBigInt64(command.argInt, 0n),
|
|
1528
|
-
BigInt(Math.max(0, Math.round(Number(command.argFloat ?? 0)))),
|
|
1529
|
-
1,
|
|
1530
|
-
);
|
|
1531
|
-
break;
|
|
1532
|
-
case SonareEngineCommandType.SetMetronome:
|
|
1533
|
-
this.module._sonare_rt_engine_set_metronome_enabled(
|
|
1534
|
-
this.engine,
|
|
1535
|
-
command.argInt ? 1 : 0,
|
|
1536
|
-
this.metronomeConfig.beatGain,
|
|
1537
|
-
this.metronomeConfig.accentGain,
|
|
1538
|
-
this.metronomeConfig.clickSamples,
|
|
1539
|
-
);
|
|
1540
|
-
break;
|
|
1541
|
-
case SonareEngineCommandType.SeekMarker:
|
|
1542
|
-
this.module._sonare_rt_engine_seek_marker(
|
|
1543
|
-
this.engine,
|
|
1544
|
-
Math.trunc(command.targetId ?? 0),
|
|
1545
|
-
sampleTime,
|
|
1546
|
-
);
|
|
1547
|
-
break;
|
|
1548
|
-
default:
|
|
1549
|
-
if (this.telemetryRing) {
|
|
1550
|
-
writeSonareEngineTelemetryRingBuffer(this.telemetryRing, {
|
|
1551
|
-
type: SonareEngineTelemetryType.Error,
|
|
1552
|
-
error: SonareEngineTelemetryError.UnknownTarget,
|
|
1553
|
-
renderFrame: 0,
|
|
1554
|
-
timelineSample: 0,
|
|
1555
|
-
audibleTimelineSample: 0,
|
|
1556
|
-
graphLatencySamplesQ8: 0,
|
|
1557
|
-
value: Number(command.type),
|
|
1558
|
-
});
|
|
1559
|
-
}
|
|
1560
|
-
break;
|
|
1561
|
-
}
|
|
1562
|
-
}
|
|
1563
|
-
|
|
1564
|
-
private publishTelemetry(): void {
|
|
1565
|
-
if (!this.telemetryRing) {
|
|
1566
|
-
this.module._sonare_rt_engine_drain_telemetry(
|
|
1567
|
-
this.engine,
|
|
1568
|
-
this.telemetryIntsPtr,
|
|
1569
|
-
this.telemetryFramesPtr,
|
|
1570
|
-
64,
|
|
1571
|
-
);
|
|
1572
|
-
return;
|
|
1573
|
-
}
|
|
1574
|
-
const count = this.module._sonare_rt_engine_drain_telemetry(
|
|
1575
|
-
this.engine,
|
|
1576
|
-
this.telemetryIntsPtr,
|
|
1577
|
-
this.telemetryFramesPtr,
|
|
1578
|
-
64,
|
|
1579
|
-
);
|
|
1580
|
-
const ints = new Int32Array(this.memory.buffer);
|
|
1581
|
-
const frames = new Float64Array(this.memory.buffer);
|
|
1582
|
-
const intBase = this.telemetryIntsPtr >> 2;
|
|
1583
|
-
const frameBase = this.telemetryFramesPtr >> 3;
|
|
1584
|
-
for (let i = 0; i < count; i++) {
|
|
1585
|
-
writeSonareEngineTelemetryRingBuffer(this.telemetryRing, {
|
|
1586
|
-
type: ints[intBase + i * 4],
|
|
1587
|
-
error: ints[intBase + i * 4 + 1],
|
|
1588
|
-
renderFrame: frames[frameBase + i * 3],
|
|
1589
|
-
timelineSample: frames[frameBase + i * 3 + 1],
|
|
1590
|
-
audibleTimelineSample: frames[frameBase + i * 3 + 2],
|
|
1591
|
-
graphLatencySamplesQ8: ints[intBase + i * 4 + 2],
|
|
1592
|
-
value: ints[intBase + i * 4 + 3],
|
|
1593
|
-
});
|
|
1594
|
-
}
|
|
1595
|
-
}
|
|
1596
|
-
|
|
1597
|
-
private commandRingFromSharedBuffer(
|
|
1598
|
-
sharedBuffer: SharedArrayBuffer,
|
|
1599
|
-
fallbackCapacity?: number,
|
|
1600
|
-
): SonareEngineCommandRingBuffer {
|
|
1601
|
-
const ring = engineRingFromSharedBuffer(
|
|
1602
|
-
sharedBuffer,
|
|
1603
|
-
SONARE_ENGINE_COMMAND_RECORD_BYTES,
|
|
1604
|
-
fallbackCapacity,
|
|
1605
|
-
);
|
|
1606
|
-
return { sharedBuffer, header: ring.header, view: ring.view, capacity: ring.capacity };
|
|
1607
|
-
}
|
|
1608
|
-
|
|
1609
|
-
private telemetryRingFromSharedBuffer(
|
|
1610
|
-
sharedBuffer: SharedArrayBuffer,
|
|
1611
|
-
fallbackCapacity?: number,
|
|
1612
|
-
): SonareEngineTelemetryRingBuffer {
|
|
1613
|
-
const ring = engineRingFromSharedBuffer(
|
|
1614
|
-
sharedBuffer,
|
|
1615
|
-
SONARE_ENGINE_TELEMETRY_RECORD_BYTES,
|
|
1616
|
-
fallbackCapacity,
|
|
1617
|
-
);
|
|
1618
|
-
return { sharedBuffer, header: ring.header, view: ring.view, capacity: ring.capacity };
|
|
1619
|
-
}
|
|
1620
|
-
}
|
|
1621
|
-
|
|
1622
|
-
export class SonareRealtimeEngineNode {
|
|
1623
|
-
readonly node: AudioWorkletNode;
|
|
1624
|
-
readonly capabilities: SonareRealtimeEngineNodeCapabilities;
|
|
1625
|
-
readonly commandRing?: SonareEngineCommandRingBuffer;
|
|
1626
|
-
readonly telemetryRing?: SonareEngineTelemetryRingBuffer;
|
|
1627
|
-
readonly meterRing?: SonareMeterRingBuffer;
|
|
1628
|
-
readonly scopeRing?: SonareScopeRingBuffer;
|
|
1629
|
-
readonly ready: Promise<void>;
|
|
1630
|
-
private telemetryReadIndex = 0;
|
|
1631
|
-
private meterReadIndex = 0;
|
|
1632
|
-
private scopeReadIndex = 0;
|
|
1633
|
-
private telemetryListeners = new Set<(telemetry: SonareEngineTelemetryRecord) => void>();
|
|
1634
|
-
private meterListeners = new Set<(meter: SonareWorkletMeterSnapshot) => void>();
|
|
1635
|
-
private scopeListeners = new Set<(scope: SonareWorkletScopeSnapshot) => void>();
|
|
1636
|
-
private captureRequestId = 1;
|
|
1637
|
-
private readonly captureRequests = new Map<
|
|
1638
|
-
number,
|
|
1639
|
-
{
|
|
1640
|
-
resolve: (response: SonareEngineCaptureResponseMessage) => void;
|
|
1641
|
-
reject: (reason?: unknown) => void;
|
|
1642
|
-
}
|
|
1643
|
-
>();
|
|
1644
|
-
private transportRequestId = 1;
|
|
1645
|
-
private readonly transportRequests = new Map<
|
|
1646
|
-
number,
|
|
1647
|
-
{
|
|
1648
|
-
resolve: (response: SonareEngineTransportResponseMessage) => void;
|
|
1649
|
-
reject: (reason?: unknown) => void;
|
|
1650
|
-
}
|
|
1651
|
-
>();
|
|
1652
|
-
private resolveReady!: () => void;
|
|
1653
|
-
private rejectReady!: (reason?: unknown) => void;
|
|
1654
|
-
private destroyed = false;
|
|
1655
|
-
|
|
1656
|
-
private constructor(
|
|
1657
|
-
node: AudioWorkletNode,
|
|
1658
|
-
capabilities: SonareRealtimeEngineNodeCapabilities,
|
|
1659
|
-
commandRing?: SonareEngineCommandRingBuffer,
|
|
1660
|
-
telemetryRing?: SonareEngineTelemetryRingBuffer,
|
|
1661
|
-
meterRing?: SonareMeterRingBuffer,
|
|
1662
|
-
scopeRing?: SonareScopeRingBuffer,
|
|
1663
|
-
) {
|
|
1664
|
-
this.node = node;
|
|
1665
|
-
this.capabilities = capabilities;
|
|
1666
|
-
this.commandRing = commandRing;
|
|
1667
|
-
this.telemetryRing = telemetryRing;
|
|
1668
|
-
this.meterRing = meterRing;
|
|
1669
|
-
this.scopeRing = scopeRing;
|
|
1670
|
-
this.ready = new Promise((resolve, reject) => {
|
|
1671
|
-
this.resolveReady = resolve;
|
|
1672
|
-
this.rejectReady = reject;
|
|
1673
|
-
});
|
|
1674
|
-
if (!capabilities.readyMessage) {
|
|
1675
|
-
this.resolveReady();
|
|
1676
|
-
}
|
|
1677
|
-
this.node.port.onmessage = (event: MessageEvent<unknown>) => {
|
|
1678
|
-
if (isEngineCaptureResponseMessage(event.data)) {
|
|
1679
|
-
const pending = this.captureRequests.get(event.data.requestId);
|
|
1680
|
-
if (pending) {
|
|
1681
|
-
this.captureRequests.delete(event.data.requestId);
|
|
1682
|
-
if (event.data.ok) {
|
|
1683
|
-
pending.resolve(event.data);
|
|
1684
|
-
} else {
|
|
1685
|
-
pending.reject(new Error(event.data.error ?? 'Capture request failed'));
|
|
1686
|
-
}
|
|
1687
|
-
}
|
|
1688
|
-
} else if (isEngineTransportResponseMessage(event.data)) {
|
|
1689
|
-
const pending = this.transportRequests.get(event.data.requestId);
|
|
1690
|
-
if (pending) {
|
|
1691
|
-
this.transportRequests.delete(event.data.requestId);
|
|
1692
|
-
if (event.data.ok) {
|
|
1693
|
-
pending.resolve(event.data);
|
|
1694
|
-
} else {
|
|
1695
|
-
pending.reject(new Error(event.data.error ?? 'Transport request failed'));
|
|
1696
|
-
}
|
|
1697
|
-
}
|
|
1698
|
-
} else if (isEngineTelemetryRecord(event.data)) {
|
|
1699
|
-
this.emitTelemetry(event.data);
|
|
1700
|
-
} else if (isMeterSnapshot(event.data)) {
|
|
1701
|
-
this.emitMeter(event.data);
|
|
1702
|
-
} else if (isRecord(event.data) && event.data.type === 'ready') {
|
|
1703
|
-
this.resolveReady();
|
|
1704
|
-
} else if (isRecord(event.data) && event.data.type === 'error') {
|
|
1705
|
-
this.rejectReady(new Error(String(event.data.message ?? 'AudioWorklet error')));
|
|
1706
|
-
}
|
|
1707
|
-
};
|
|
1708
|
-
}
|
|
1709
|
-
|
|
1710
|
-
static async create(
|
|
1711
|
-
context: BaseAudioContext,
|
|
1712
|
-
options: SonareRealtimeEngineNodeOptions = {},
|
|
1713
|
-
): Promise<SonareRealtimeEngineNode> {
|
|
1714
|
-
const runtimeTarget = options.runtimeTarget ?? 'embind';
|
|
1715
|
-
const processorName = options.processorName ?? 'sonare-realtime-engine-processor';
|
|
1716
|
-
const moduleUrl = options.moduleUrl;
|
|
1717
|
-
if (moduleUrl && context.audioWorklet?.addModule) {
|
|
1718
|
-
await context.audioWorklet.addModule(moduleUrl);
|
|
1719
|
-
}
|
|
1720
|
-
const detectedCapabilities =
|
|
1721
|
-
options.engineAbiVersion !== undefined
|
|
1722
|
-
? {
|
|
1723
|
-
engineAbiVersion: options.engineAbiVersion,
|
|
1724
|
-
expectedEngineAbiVersion: options.expectedEngineAbiVersion ?? options.engineAbiVersion,
|
|
1725
|
-
abiCompatible:
|
|
1726
|
-
options.engineAbiVersion ===
|
|
1727
|
-
(options.expectedEngineAbiVersion ?? options.engineAbiVersion),
|
|
1728
|
-
}
|
|
1729
|
-
: runtimeTarget === 'embind'
|
|
1730
|
-
? engineCapabilities()
|
|
1731
|
-
: undefined;
|
|
1732
|
-
if (options.requireAbiCompatible !== false && detectedCapabilities?.abiCompatible === false) {
|
|
1733
|
-
throw new Error(
|
|
1734
|
-
`Engine ABI mismatch: wasm=${detectedCapabilities.engineAbiVersion}, expected=${detectedCapabilities.expectedEngineAbiVersion}`,
|
|
1735
|
-
);
|
|
1736
|
-
}
|
|
1737
|
-
const sharedArrayBuffer = typeof globalThis.SharedArrayBuffer === 'function';
|
|
1738
|
-
const atomics = typeof globalThis.Atomics === 'object';
|
|
1739
|
-
const audioWorklet = typeof AudioWorkletNode !== 'undefined' || !!options.nodeFactory;
|
|
1740
|
-
const degradedReason =
|
|
1741
|
-
options.mode !== 'postMessage' && (!sharedArrayBuffer || !atomics)
|
|
1742
|
-
? 'SharedArrayBuffer or Atomics unavailable; using postMessage transport.'
|
|
1743
|
-
: undefined;
|
|
1744
|
-
const mode =
|
|
1745
|
-
options.mode === 'postMessage' || !sharedArrayBuffer || !atomics ? 'postMessage' : 'sab';
|
|
1746
|
-
if (options.mode === 'sab' && mode !== 'sab') {
|
|
1747
|
-
throw new Error(
|
|
1748
|
-
'SharedArrayBuffer mode requested but SharedArrayBuffer/Atomics are unavailable.',
|
|
1749
|
-
);
|
|
1750
|
-
}
|
|
1751
|
-
|
|
1752
|
-
const commandRing =
|
|
1753
|
-
mode === 'sab'
|
|
1754
|
-
? createSonareEngineCommandRingBuffer(options.commandRingCapacity ?? 128)
|
|
1755
|
-
: undefined;
|
|
1756
|
-
const telemetryRing =
|
|
1757
|
-
mode === 'sab'
|
|
1758
|
-
? createSonareEngineTelemetryRingBuffer(options.telemetryRingCapacity ?? 128)
|
|
1759
|
-
: undefined;
|
|
1760
|
-
// Meter ring: only the embind runtime publishes engine meters into a SAB
|
|
1761
|
-
// ring (the sonare-rt runtime owns its own meter transport). Lock-free
|
|
1762
|
-
// meter delivery matches the telemetry path and keeps the audio render
|
|
1763
|
-
// callback allocation-free in SAB mode.
|
|
1764
|
-
const meterRing =
|
|
1765
|
-
mode === 'sab' && runtimeTarget === 'embind'
|
|
1766
|
-
? createSonareMeterRingBuffer(options.meterRingCapacity ?? 128)
|
|
1767
|
-
: undefined;
|
|
1768
|
-
// Scope ring (FFT spectrum + goniometer): opt-in, embind-only. The
|
|
1769
|
-
// per-block FFT is heavier than the meter path, so it is created only when
|
|
1770
|
-
// the caller requests scope telemetry via scopeIntervalFrames > 0.
|
|
1771
|
-
const scopeIntervalFrames = Math.max(0, Math.floor(options.scopeIntervalFrames ?? 0));
|
|
1772
|
-
const scopeRing =
|
|
1773
|
-
mode === 'sab' && runtimeTarget === 'embind' && scopeIntervalFrames > 0
|
|
1774
|
-
? createSonareScopeRingBuffer(options.scopeRingCapacity ?? 64, options.scopeBands ?? 48)
|
|
1775
|
-
: undefined;
|
|
1776
|
-
const channelCount = Math.max(1, Math.floor(options.channelCount ?? 2));
|
|
1777
|
-
const processorOptions: SonareRealtimeEngineWorkletProcessorOptions = {
|
|
1778
|
-
runtimeTarget,
|
|
1779
|
-
rtModuleUrl: options.rtModuleUrl,
|
|
1780
|
-
rtWasmBinary: options.rtWasmBinary,
|
|
1781
|
-
sampleRate: options.sampleRate ?? context.sampleRate,
|
|
1782
|
-
blockSize: options.blockSize,
|
|
1783
|
-
channelCount,
|
|
1784
|
-
commandSharedBuffer: commandRing?.sharedBuffer,
|
|
1785
|
-
commandRingCapacity: commandRing?.capacity,
|
|
1786
|
-
telemetrySharedBuffer: telemetryRing?.sharedBuffer,
|
|
1787
|
-
telemetryRingCapacity: telemetryRing?.capacity,
|
|
1788
|
-
meterSharedBuffer: meterRing?.sharedBuffer,
|
|
1789
|
-
meterRingCapacity: meterRing?.capacity,
|
|
1790
|
-
scopeSharedBuffer: scopeRing?.sharedBuffer,
|
|
1791
|
-
scopeRingCapacity: scopeRing?.capacity,
|
|
1792
|
-
scopeBands: scopeRing?.bands,
|
|
1793
|
-
scopeIntervalFrames: scopeRing ? scopeIntervalFrames : undefined,
|
|
1794
|
-
wasmBinary: options.wasmBinary,
|
|
1795
|
-
initialSyncMessages: options.initialSyncMessages,
|
|
1796
|
-
initialCommands: options.initialCommands,
|
|
1797
|
-
};
|
|
1798
|
-
const factory =
|
|
1799
|
-
options.nodeFactory ??
|
|
1800
|
-
((ctx: BaseAudioContext, name: string, nodeOptions: AudioWorkletNodeOptions) =>
|
|
1801
|
-
new AudioWorkletNode(ctx, name, nodeOptions));
|
|
1802
|
-
const node = factory(context, processorName, {
|
|
1803
|
-
numberOfInputs: 1,
|
|
1804
|
-
numberOfOutputs: 1,
|
|
1805
|
-
outputChannelCount: [channelCount],
|
|
1806
|
-
processorOptions,
|
|
1807
|
-
});
|
|
1808
|
-
return new SonareRealtimeEngineNode(
|
|
1809
|
-
node,
|
|
1810
|
-
{
|
|
1811
|
-
mode,
|
|
1812
|
-
runtimeTarget,
|
|
1813
|
-
sharedArrayBuffer,
|
|
1814
|
-
atomics,
|
|
1815
|
-
audioWorklet,
|
|
1816
|
-
engineAbiVersion: detectedCapabilities?.engineAbiVersion,
|
|
1817
|
-
expectedEngineAbiVersion: detectedCapabilities?.expectedEngineAbiVersion,
|
|
1818
|
-
abiCompatible: detectedCapabilities?.abiCompatible,
|
|
1819
|
-
degradedReason,
|
|
1820
|
-
readyMessage:
|
|
1821
|
-
runtimeTarget === 'sonare-rt' ||
|
|
1822
|
-
(runtimeTarget === 'embind' && moduleUrl !== undefined && !options.nodeFactory),
|
|
1823
|
-
},
|
|
1824
|
-
commandRing,
|
|
1825
|
-
telemetryRing,
|
|
1826
|
-
meterRing,
|
|
1827
|
-
scopeRing,
|
|
1828
|
-
);
|
|
1829
|
-
}
|
|
1830
|
-
|
|
1831
|
-
play(sampleTime = -1): boolean {
|
|
1832
|
-
return this.sendCommand({ type: SonareEngineCommandType.TransportPlay, sampleTime });
|
|
1833
|
-
}
|
|
1834
|
-
|
|
1835
|
-
stop(sampleTime = -1): boolean {
|
|
1836
|
-
return this.sendCommand({ type: SonareEngineCommandType.TransportStop, sampleTime });
|
|
1837
|
-
}
|
|
1838
|
-
|
|
1839
|
-
seekSample(timelineSample: number, sampleTime = -1): boolean {
|
|
1840
|
-
return this.sendCommand({
|
|
1841
|
-
type: SonareEngineCommandType.TransportSeekSample,
|
|
1842
|
-
sampleTime,
|
|
1843
|
-
argInt: timelineSample,
|
|
1844
|
-
});
|
|
1845
|
-
}
|
|
1846
|
-
|
|
1847
|
-
seekPpq(ppq: number, sampleTime = -1): boolean {
|
|
1848
|
-
return this.sendCommand({
|
|
1849
|
-
type: SonareEngineCommandType.TransportSeekPpq,
|
|
1850
|
-
sampleTime,
|
|
1851
|
-
argFloat: ppq,
|
|
1852
|
-
});
|
|
1853
|
-
}
|
|
1854
|
-
|
|
1855
|
-
sendCommand(command: SonareEngineCommandRecord): boolean {
|
|
1856
|
-
if (this.destroyed) {
|
|
1857
|
-
return false;
|
|
1858
|
-
}
|
|
1859
|
-
if (this.commandRing) {
|
|
1860
|
-
return pushSonareEngineCommandRingBuffer(this.commandRing, command);
|
|
1861
|
-
}
|
|
1862
|
-
this.node.port.postMessage(command);
|
|
1863
|
-
return true;
|
|
1864
|
-
}
|
|
1865
|
-
|
|
1866
|
-
requestCaptureStatus(): Promise<EngineCaptureStatus> {
|
|
1867
|
-
return this.sendCaptureRequest('status').then((response) => {
|
|
1868
|
-
if (!response.status) {
|
|
1869
|
-
throw new Error('Capture status response is missing status.');
|
|
1870
|
-
}
|
|
1871
|
-
return response.status;
|
|
1872
|
-
});
|
|
1873
|
-
}
|
|
1874
|
-
|
|
1875
|
-
requestCapturedAudio(): Promise<Float32Array[]> {
|
|
1876
|
-
return this.sendCaptureRequest('read').then((response) =>
|
|
1877
|
-
(response.channels ?? []).map((channel) =>
|
|
1878
|
-
channel instanceof Float32Array ? channel : new Float32Array(channel),
|
|
1879
|
-
),
|
|
1880
|
-
);
|
|
1881
|
-
}
|
|
1882
|
-
|
|
1883
|
-
requestCaptureReset(): Promise<void> {
|
|
1884
|
-
return this.sendCaptureRequest('reset').then(() => undefined);
|
|
1885
|
-
}
|
|
1886
|
-
|
|
1887
|
-
requestTransportState(): Promise<EngineTransportState> {
|
|
1888
|
-
return this.sendTransportRequest().then((response) => {
|
|
1889
|
-
if (!response.state) {
|
|
1890
|
-
throw new Error('Transport state response is missing state.');
|
|
1891
|
-
}
|
|
1892
|
-
return response.state;
|
|
1893
|
-
});
|
|
1894
|
-
}
|
|
1895
|
-
|
|
1896
|
-
pollTelemetry(): SonareEngineTelemetryRecord[] {
|
|
1897
|
-
if (!this.telemetryRing) {
|
|
1898
|
-
return [];
|
|
1899
|
-
}
|
|
1900
|
-
const read = readSonareEngineTelemetryRingBuffer(this.telemetryRing, this.telemetryReadIndex);
|
|
1901
|
-
this.telemetryReadIndex = read.nextReadIndex;
|
|
1902
|
-
for (const telemetry of read.telemetry) {
|
|
1903
|
-
this.emitTelemetry(telemetry);
|
|
1904
|
-
}
|
|
1905
|
-
return read.telemetry;
|
|
1906
|
-
}
|
|
1907
|
-
|
|
1908
|
-
// Drains any meters published into the SAB meter ring (embind SAB mode) and
|
|
1909
|
-
// forwards them to onMeter listeners. In postMessage mode meters arrive via
|
|
1910
|
-
// node.port.onmessage instead, so this is a no-op then.
|
|
1911
|
-
pollMeters(): SonareWorkletMeterSnapshot[] {
|
|
1912
|
-
if (!this.meterRing) {
|
|
1913
|
-
return [];
|
|
1914
|
-
}
|
|
1915
|
-
const read = readSonareMeterRingBuffer(this.meterRing, this.meterReadIndex);
|
|
1916
|
-
this.meterReadIndex = read.nextReadIndex;
|
|
1917
|
-
for (const meter of read.meters) {
|
|
1918
|
-
this.emitMeter(meter);
|
|
1919
|
-
}
|
|
1920
|
-
return read.meters;
|
|
1921
|
-
}
|
|
1922
|
-
|
|
1923
|
-
// Drains scope telemetry (FFT spectrum + goniometer points) published into the
|
|
1924
|
-
// SAB scope ring and forwards each record to onScope listeners. A no-op unless
|
|
1925
|
-
// the node was created with scopeIntervalFrames > 0 (embind SAB mode).
|
|
1926
|
-
pollScope(): SonareWorkletScopeSnapshot[] {
|
|
1927
|
-
if (!this.scopeRing) {
|
|
1928
|
-
return [];
|
|
1929
|
-
}
|
|
1930
|
-
const read = readSonareScopeRingBuffer(this.scopeRing, this.scopeReadIndex);
|
|
1931
|
-
this.scopeReadIndex = read.nextReadIndex;
|
|
1932
|
-
for (const scope of read.scopes) {
|
|
1933
|
-
this.emitScope(scope);
|
|
1934
|
-
}
|
|
1935
|
-
return read.scopes;
|
|
1936
|
-
}
|
|
1937
|
-
|
|
1938
|
-
onTelemetry(callback: (telemetry: SonareEngineTelemetryRecord) => void): () => void {
|
|
1939
|
-
this.telemetryListeners.add(callback);
|
|
1940
|
-
return () => {
|
|
1941
|
-
this.telemetryListeners.delete(callback);
|
|
1942
|
-
};
|
|
1943
|
-
}
|
|
1944
|
-
|
|
1945
|
-
onMeter(callback: (meter: SonareWorkletMeterSnapshot) => void): () => void {
|
|
1946
|
-
this.meterListeners.add(callback);
|
|
1947
|
-
return () => {
|
|
1948
|
-
this.meterListeners.delete(callback);
|
|
1949
|
-
};
|
|
1950
|
-
}
|
|
1951
|
-
|
|
1952
|
-
onScope(callback: (scope: SonareWorkletScopeSnapshot) => void): () => void {
|
|
1953
|
-
this.scopeListeners.add(callback);
|
|
1954
|
-
return () => {
|
|
1955
|
-
this.scopeListeners.delete(callback);
|
|
1956
|
-
};
|
|
1957
|
-
}
|
|
1958
|
-
|
|
1959
|
-
destroy(): void {
|
|
1960
|
-
if (this.destroyed) {
|
|
1961
|
-
return;
|
|
1962
|
-
}
|
|
1963
|
-
this.destroyed = true;
|
|
1964
|
-
this.node.port.postMessage({ type: SonareEngineCommandType.TransportStop, sampleTime: -1 });
|
|
1965
|
-
this.node.disconnect();
|
|
1966
|
-
for (const pending of this.captureRequests.values()) {
|
|
1967
|
-
pending.reject(new Error('Realtime engine node is destroyed.'));
|
|
1968
|
-
}
|
|
1969
|
-
this.captureRequests.clear();
|
|
1970
|
-
for (const pending of this.transportRequests.values()) {
|
|
1971
|
-
pending.reject(new Error('Realtime engine node is destroyed.'));
|
|
1972
|
-
}
|
|
1973
|
-
this.transportRequests.clear();
|
|
1974
|
-
this.telemetryListeners.clear();
|
|
1975
|
-
this.meterListeners.clear();
|
|
1976
|
-
this.scopeListeners.clear();
|
|
1977
|
-
}
|
|
1978
|
-
|
|
1979
|
-
private emitTelemetry(telemetry: SonareEngineTelemetryRecord): void {
|
|
1980
|
-
for (const listener of this.telemetryListeners) {
|
|
1981
|
-
listener(telemetry);
|
|
1982
|
-
}
|
|
1983
|
-
}
|
|
1984
|
-
|
|
1985
|
-
private emitMeter(meter: SonareWorkletMeterSnapshot): void {
|
|
1986
|
-
for (const listener of this.meterListeners) {
|
|
1987
|
-
listener(meter);
|
|
1988
|
-
}
|
|
1989
|
-
}
|
|
1990
|
-
|
|
1991
|
-
private emitScope(scope: SonareWorkletScopeSnapshot): void {
|
|
1992
|
-
for (const listener of this.scopeListeners) {
|
|
1993
|
-
listener(scope);
|
|
1994
|
-
}
|
|
1995
|
-
}
|
|
1996
|
-
|
|
1997
|
-
private sendCaptureRequest(
|
|
1998
|
-
op: SonareEngineCaptureRequestMessage['op'],
|
|
1999
|
-
): Promise<SonareEngineCaptureResponseMessage> {
|
|
2000
|
-
if (this.destroyed) {
|
|
2001
|
-
return Promise.reject(new Error('Realtime engine node is destroyed.'));
|
|
2002
|
-
}
|
|
2003
|
-
const requestId = this.captureRequestId++;
|
|
2004
|
-
const promise = new Promise<SonareEngineCaptureResponseMessage>((resolve, reject) => {
|
|
2005
|
-
this.captureRequests.set(requestId, { resolve, reject });
|
|
2006
|
-
});
|
|
2007
|
-
this.node.port.postMessage({ type: 'captureRequest', requestId, op });
|
|
2008
|
-
return promise;
|
|
2009
|
-
}
|
|
2010
|
-
|
|
2011
|
-
private sendTransportRequest(): Promise<SonareEngineTransportResponseMessage> {
|
|
2012
|
-
if (this.destroyed) {
|
|
2013
|
-
return Promise.reject(new Error('Realtime engine node is destroyed.'));
|
|
2014
|
-
}
|
|
2015
|
-
const requestId = this.transportRequestId++;
|
|
2016
|
-
const promise = new Promise<SonareEngineTransportResponseMessage>((resolve, reject) => {
|
|
2017
|
-
this.transportRequests.set(requestId, { resolve, reject });
|
|
2018
|
-
});
|
|
2019
|
-
this.node.port.postMessage({ type: 'transportRequest', requestId, op: 'state' });
|
|
2020
|
-
return promise;
|
|
2021
|
-
}
|
|
2022
|
-
}
|
|
2023
|
-
|
|
2024
|
-
export class SonareEngine {
|
|
2025
|
-
readonly node: AudioWorkletNode;
|
|
2026
|
-
readonly capabilities: SonareRealtimeEngineNodeCapabilities;
|
|
2027
|
-
readonly transport: SonareEngineTransportFacade;
|
|
2028
|
-
private readonly realtimeNode: SonareRealtimeEngineNode;
|
|
2029
|
-
private readonly offlineEngine: RealtimeEngine;
|
|
2030
|
-
private readonly context: SuspendableAudioContext;
|
|
2031
|
-
private readonly sampleRate: number;
|
|
2032
|
-
private readonly offlineBlockSize: number;
|
|
2033
|
-
private readonly offlineChannelCount: number;
|
|
2034
|
-
private readonly automationLanes = new Map<number, EngineAutomationPoint[]>();
|
|
2035
|
-
private readonly clips = new Map<number, EngineClip>();
|
|
2036
|
-
private readonly midiClips = new Map<number, EngineMidiClipSchedule>();
|
|
2037
|
-
private readonly markers = new Map<number, EngineMarker>();
|
|
2038
|
-
private readonly trackLaneIds: number[] = [];
|
|
2039
|
-
private readonly trackSends = new Map<number, EngineTrackSend[]>();
|
|
2040
|
-
private readonly trackOutputBus = new Map<number, number>();
|
|
2041
|
-
private readonly laneSidechains = new Map<
|
|
2042
|
-
string,
|
|
2043
|
-
{ trackId: number; insertIndex: number; sourceTrackId: number }
|
|
2044
|
-
>();
|
|
2045
|
-
private readonly buses: EngineBus[] = [];
|
|
2046
|
-
private readonly trackStripJson = new Map<number, string>();
|
|
2047
|
-
private readonly busStripJson = new Map<number, string>();
|
|
2048
|
-
private masterStripJson: string | undefined;
|
|
2049
|
-
private captureConfig: Omit<SonareEngineSyncCaptureMessage, 'type'> | undefined;
|
|
2050
|
-
private tempoBpm = 120;
|
|
2051
|
-
private timeSignature = { numerator: 4, denominator: 4 };
|
|
2052
|
-
private tempoSegments: EngineTempoSegment[] = [{ startPpq: 0, bpm: 120 }];
|
|
2053
|
-
private timeSignatureSegments: EngineTimeSignatureSegment[] = [
|
|
2054
|
-
{ startPpq: 0, numerator: 4, denominator: 4 },
|
|
2055
|
-
];
|
|
2056
|
-
private latestTransportState: EngineTransportState | undefined;
|
|
2057
|
-
private nextClipId = 1;
|
|
2058
|
-
private nextMarkerId = 1;
|
|
2059
|
-
private transportPlaying = false;
|
|
2060
|
-
private readonly pendingInstrumentSync: SonareEngineInstrumentSyncMessage[] = [];
|
|
2061
|
-
private destroyed = false;
|
|
2062
|
-
|
|
2063
|
-
private constructor(
|
|
2064
|
-
context: BaseAudioContext,
|
|
2065
|
-
realtimeNode: SonareRealtimeEngineNode,
|
|
2066
|
-
offlineEngine: RealtimeEngine,
|
|
2067
|
-
sampleRate: number,
|
|
2068
|
-
offlineBlockSize: number,
|
|
2069
|
-
offlineChannelCount: number,
|
|
2070
|
-
) {
|
|
2071
|
-
this.context = context;
|
|
2072
|
-
this.realtimeNode = realtimeNode;
|
|
2073
|
-
this.offlineEngine = offlineEngine;
|
|
2074
|
-
this.node = realtimeNode.node;
|
|
2075
|
-
this.capabilities = realtimeNode.capabilities;
|
|
2076
|
-
this.sampleRate = sampleRate;
|
|
2077
|
-
this.offlineBlockSize = offlineBlockSize;
|
|
2078
|
-
this.offlineChannelCount = offlineChannelCount;
|
|
2079
|
-
this.transport = {
|
|
2080
|
-
play: (sampleTime = -1) => {
|
|
2081
|
-
const ok = this.realtimeNode.play(sampleTime);
|
|
2082
|
-
if (ok) {
|
|
2083
|
-
this.transportPlaying = true;
|
|
2084
|
-
}
|
|
2085
|
-
return ok;
|
|
2086
|
-
},
|
|
2087
|
-
stop: (sampleTime = -1) => {
|
|
2088
|
-
const ok = this.realtimeNode.stop(sampleTime);
|
|
2089
|
-
if (ok) {
|
|
2090
|
-
this.transportPlaying = false;
|
|
2091
|
-
this.flushPendingInstrumentSync();
|
|
2092
|
-
}
|
|
2093
|
-
return ok;
|
|
2094
|
-
},
|
|
2095
|
-
seekPpq: (ppq, sampleTime = -1) => {
|
|
2096
|
-
this.offlineEngine.seekPpq(ppq, sampleTime);
|
|
2097
|
-
return this.realtimeNode.seekPpq(ppq, sampleTime);
|
|
2098
|
-
},
|
|
2099
|
-
seekSeconds: (seconds, sampleTime = -1) => {
|
|
2100
|
-
const timelineSample = Math.max(0, Math.round(seconds * this.sampleRate));
|
|
2101
|
-
this.offlineEngine.seekSample(timelineSample, sampleTime);
|
|
2102
|
-
return this.realtimeNode.seekSample(timelineSample, sampleTime);
|
|
2103
|
-
},
|
|
2104
|
-
setTempo: (bpm) => this.setTempo(bpm),
|
|
2105
|
-
setTempoSegments: (segments) => this.setTempoSegments(segments),
|
|
2106
|
-
setLoop: (startPpq, endPpq, enabled = true) => this.setLoop(startPpq, endPpq, enabled),
|
|
2107
|
-
};
|
|
2108
|
-
}
|
|
2109
|
-
|
|
2110
|
-
static async create(
|
|
2111
|
-
context: BaseAudioContext,
|
|
2112
|
-
options: SonareEngineOptions = {},
|
|
2113
|
-
): Promise<SonareEngine> {
|
|
2114
|
-
const sampleRate = options.sampleRate ?? context.sampleRate;
|
|
2115
|
-
const blockSize = options.offlineBlockSize ?? options.blockSize ?? 128;
|
|
2116
|
-
const channelCount = Math.max(
|
|
2117
|
-
1,
|
|
2118
|
-
Math.floor(options.offlineChannelCount ?? options.channelCount ?? 2),
|
|
2119
|
-
);
|
|
2120
|
-
const realtimeNode = await SonareRealtimeEngineNode.create(context, options);
|
|
2121
|
-
const offlineEngine = options.offlineEngine ?? new RealtimeEngine(sampleRate, blockSize);
|
|
2122
|
-
return new SonareEngine(
|
|
2123
|
-
context,
|
|
2124
|
-
realtimeNode,
|
|
2125
|
-
offlineEngine,
|
|
2126
|
-
sampleRate,
|
|
2127
|
-
blockSize,
|
|
2128
|
-
channelCount,
|
|
2129
|
-
);
|
|
2130
|
-
}
|
|
2131
|
-
|
|
2132
|
-
async suspend(): Promise<void> {
|
|
2133
|
-
if (this.destroyed) {
|
|
2134
|
-
return;
|
|
2135
|
-
}
|
|
2136
|
-
await this.context.suspend?.();
|
|
2137
|
-
}
|
|
2138
|
-
|
|
2139
|
-
async resume(): Promise<void> {
|
|
2140
|
-
if (this.destroyed) {
|
|
2141
|
-
return;
|
|
2142
|
-
}
|
|
2143
|
-
await this.context.resume?.();
|
|
2144
|
-
}
|
|
2145
|
-
|
|
2146
|
-
setTempo(bpm: number): void {
|
|
2147
|
-
this.tempoBpm = bpm;
|
|
2148
|
-
this.tempoSegments = [{ startPpq: 0, bpm }];
|
|
2149
|
-
this.offlineEngine.setTempo(bpm);
|
|
2150
|
-
this.postTempoSync();
|
|
2151
|
-
this.realtimeNode.sendCommand({
|
|
2152
|
-
type: SonareEngineCommandType.SetTempoMap,
|
|
2153
|
-
sampleTime: -1,
|
|
2154
|
-
argFloat: bpm,
|
|
2155
|
-
});
|
|
2156
|
-
}
|
|
2157
|
-
|
|
2158
|
-
setTempoSegments(segments: readonly EngineTempoSegment[]): void {
|
|
2159
|
-
this.tempoSegments = segments.map((segment) => ({ ...segment }));
|
|
2160
|
-
this.tempoBpm = this.tempoSegments[0]?.bpm ?? this.tempoBpm;
|
|
2161
|
-
this.offlineEngine.setTempoSegments(this.tempoSegments);
|
|
2162
|
-
this.postTempoSync();
|
|
2163
|
-
}
|
|
2164
|
-
|
|
2165
|
-
setTimeSignature(numerator: number, denominator: number): void {
|
|
2166
|
-
this.timeSignature = { numerator, denominator };
|
|
2167
|
-
this.timeSignatureSegments = [{ startPpq: 0, numerator, denominator }];
|
|
2168
|
-
this.offlineEngine.setTimeSignature(numerator, denominator);
|
|
2169
|
-
this.postTempoSync();
|
|
2170
|
-
}
|
|
2171
|
-
|
|
2172
|
-
setTimeSignatureSegments(segments: readonly EngineTimeSignatureSegment[]): void {
|
|
2173
|
-
this.timeSignatureSegments = segments.map((segment) => ({ ...segment }));
|
|
2174
|
-
const first = this.timeSignatureSegments[0];
|
|
2175
|
-
if (first) {
|
|
2176
|
-
this.timeSignature = { numerator: first.numerator, denominator: first.denominator };
|
|
2177
|
-
}
|
|
2178
|
-
this.offlineEngine.setTimeSignatureSegments(this.timeSignatureSegments);
|
|
2179
|
-
this.postTempoSync();
|
|
2180
|
-
}
|
|
2181
|
-
|
|
2182
|
-
setLoop(startPpq: number, endPpq: number, enabled = true): boolean {
|
|
2183
|
-
this.offlineEngine.setLoop(startPpq, endPpq, enabled);
|
|
2184
|
-
// Transport precision contract: the SAB command record carries exactly one
|
|
2185
|
-
// Float64 lane (argFloat) and one Int64 lane (argInt). startPpq travels in
|
|
2186
|
-
// argFloat with full double precision, matching the offline engine; endPpq
|
|
2187
|
-
// is carried as micro-PPQ (round(endPpq * 1e6)) in the integer lane and
|
|
2188
|
-
// divided back by 1e6 on the consumer. Loop ENDS are therefore snapped to
|
|
2189
|
-
// the nearest 1e-6 PPQ over the realtime transport (max 5e-7 PPQ drift),
|
|
2190
|
-
// while loop STARTS and the offline path stay exact. This is intentional:
|
|
2191
|
-
// the record has no second free Float64 lane, and a micro-PPQ grid on the
|
|
2192
|
-
// loop end is well below audible/sample-accurate resolution at any tempo.
|
|
2193
|
-
return this.realtimeNode.sendCommand({
|
|
2194
|
-
type: SonareEngineCommandType.SetLoop,
|
|
2195
|
-
targetId: enabled ? 1 : 0,
|
|
2196
|
-
sampleTime: -1,
|
|
2197
|
-
argFloat: startPpq,
|
|
2198
|
-
argInt: Math.round(endPpq * 1_000_000),
|
|
2199
|
-
});
|
|
2200
|
-
}
|
|
2201
|
-
|
|
2202
|
-
countInEndSample(startSample: number, bars: number): number {
|
|
2203
|
-
return this.offlineEngine.countInEndSample(startSample, bars);
|
|
2204
|
-
}
|
|
2205
|
-
|
|
2206
|
-
async getTransportState(): Promise<EngineTransportState> {
|
|
2207
|
-
const state = await this.realtimeNode.requestTransportState();
|
|
2208
|
-
this.latestTransportState = state;
|
|
2209
|
-
return state;
|
|
2210
|
-
}
|
|
2211
|
-
|
|
2212
|
-
cachedTransportState(): EngineTransportState | undefined {
|
|
2213
|
-
return this.latestTransportState;
|
|
2214
|
-
}
|
|
2215
|
-
|
|
2216
|
-
setParam(nodeId: string, param: string | number, value: number): boolean {
|
|
2217
|
-
const paramId = this.resolveParamId(nodeId, param);
|
|
2218
|
-
// Mirror the change into the offline engine so a subsequent offline render
|
|
2219
|
-
// reflects the live value, then push a sample-accurate command to the
|
|
2220
|
-
// realtime runtime (mirrors setTempo/setLoop above).
|
|
2221
|
-
this.offlineEngine.setParameter(paramId, value);
|
|
2222
|
-
return this.realtimeNode.sendCommand({
|
|
2223
|
-
type: SonareEngineCommandType.SetParam,
|
|
2224
|
-
targetId: paramId,
|
|
2225
|
-
sampleTime: -1,
|
|
2226
|
-
argFloat: value,
|
|
2227
|
-
});
|
|
2228
|
-
}
|
|
2229
|
-
|
|
2230
|
-
scheduleParam(
|
|
2231
|
-
nodeId: string,
|
|
2232
|
-
param: string | number,
|
|
2233
|
-
ppq: number,
|
|
2234
|
-
value: number,
|
|
2235
|
-
curve: number | 'linear' | 'exponential' = 'linear',
|
|
2236
|
-
): void {
|
|
2237
|
-
const paramId = this.resolveParamId(nodeId, param);
|
|
2238
|
-
const lane = this.automationLanes.get(paramId) ?? [];
|
|
2239
|
-
lane.push({ ppq, value, curveToNext: this.curveCode(curve) });
|
|
2240
|
-
lane.sort((a, b) => a.ppq - b.ppq);
|
|
2241
|
-
this.automationLanes.set(paramId, lane);
|
|
2242
|
-
this.offlineEngine.setAutomationLane(paramId, lane);
|
|
2243
|
-
// Mirror the lane to the live worklet engine so scheduled automation plays
|
|
2244
|
-
// back in real time, not just in renderOffline(). Lanes can exceed the
|
|
2245
|
-
// fixed-size SAB command record, so they ride an out-of-band 'syncAutomation'
|
|
2246
|
-
// message applied outside process() (like syncClips/syncMarkers).
|
|
2247
|
-
this.postSync({ type: 'syncAutomation', paramId, points: lane });
|
|
2248
|
-
}
|
|
2249
|
-
|
|
2250
|
-
addAutomationPoint(
|
|
2251
|
-
laneId: string | number,
|
|
2252
|
-
ppq: number,
|
|
2253
|
-
value: number,
|
|
2254
|
-
curve: number | 'linear' | 'exponential' = 'linear',
|
|
2255
|
-
): void {
|
|
2256
|
-
this.scheduleParam('', laneId, ppq, value, curve);
|
|
2257
|
-
}
|
|
2258
|
-
|
|
2259
|
-
/**
|
|
2260
|
-
* Replaces the automation lane for `paramId` with the given breakpoints.
|
|
2261
|
-
*
|
|
2262
|
-
* Unlike scheduleParam (which appends a single point), this sets the whole
|
|
2263
|
-
* lane at once; an empty array clears the lane. The points are defensively
|
|
2264
|
-
* copied and sorted by ppq before being mirrored to the offline engine and
|
|
2265
|
-
* the live worklet engine.
|
|
2266
|
-
*
|
|
2267
|
-
* @param paramId Automation target id (registered parameter or a reserved
|
|
2268
|
-
* engine mixer target from automationParamId/busAutomationParamId).
|
|
2269
|
-
* @param points Lane breakpoints; order does not matter.
|
|
2270
|
-
*/
|
|
2271
|
-
setAutomationLane(paramId: number, points: ReadonlyArray<EngineAutomationPoint>): void {
|
|
2272
|
-
const sorted = points.map((point) => ({ ...point })).sort((a, b) => a.ppq - b.ppq);
|
|
2273
|
-
if (sorted.length === 0) {
|
|
2274
|
-
this.automationLanes.delete(paramId);
|
|
2275
|
-
} else {
|
|
2276
|
-
this.automationLanes.set(paramId, sorted);
|
|
2277
|
-
}
|
|
2278
|
-
this.offlineEngine.setAutomationLane(paramId, sorted);
|
|
2279
|
-
this.postSync({ type: 'syncAutomation', paramId, points: sorted });
|
|
2280
|
-
}
|
|
2281
|
-
|
|
2282
|
-
/**
|
|
2283
|
-
* Returns the automation target id for a mixer strip parameter.
|
|
2284
|
-
*
|
|
2285
|
-
* The id addresses the engine's reserved mixer namespace, so it can be fed
|
|
2286
|
-
* straight to setAutomationLane to automate a fader or pan without
|
|
2287
|
-
* registering a parameter.
|
|
2288
|
-
*
|
|
2289
|
-
* @param target Track id (declares a mixer lane on first use) or 'master'.
|
|
2290
|
-
* @param kind Strip parameter to address.
|
|
2291
|
-
* @returns Reserved engine parameter id for the strip parameter.
|
|
2292
|
-
*/
|
|
2293
|
-
automationParamId(target: string | number, kind: 'faderDb' | 'pan'): number {
|
|
2294
|
-
const paramKind = kind === 'pan' ? ENGINE_MIXER_PARAM_PAN : ENGINE_MIXER_PARAM_FADER_DB;
|
|
2295
|
-
if (target === 'master') {
|
|
2296
|
-
return engineMixerMasterTarget(paramKind);
|
|
2297
|
-
}
|
|
2298
|
-
return engineMixerLaneTarget(this.ensureTrackLane(target), paramKind);
|
|
2299
|
-
}
|
|
2300
|
-
|
|
2301
|
-
/**
|
|
2302
|
-
* Returns the automation target id for a bus fader.
|
|
2303
|
-
*
|
|
2304
|
-
* @param busId Bus id (declares the mixer bus on first use).
|
|
2305
|
-
* @returns Reserved engine parameter id for the bus fader gain (dB).
|
|
2306
|
-
*/
|
|
2307
|
-
busAutomationParamId(busId: number): number {
|
|
2308
|
-
return engineMixerBusTarget(this.ensureBus(busId), ENGINE_MIXER_PARAM_FADER_DB);
|
|
2309
|
-
}
|
|
2310
|
-
|
|
2311
|
-
/**
|
|
2312
|
-
* Returns the number of automation lanes installed on the engine, including
|
|
2313
|
-
* lanes whose breakpoint list is currently empty.
|
|
2314
|
-
*
|
|
2315
|
-
* @returns Engine-side automation lane count.
|
|
2316
|
-
*/
|
|
2317
|
-
automationLaneCount(): number {
|
|
2318
|
-
return this.offlineEngine.automationLaneCount();
|
|
2319
|
-
}
|
|
2320
|
-
|
|
2321
|
-
listParameters(): EngineParameterInfo[] {
|
|
2322
|
-
const parameters: EngineParameterInfo[] = [];
|
|
2323
|
-
for (let index = 0; index < this.offlineEngine.parameterCount(); index++) {
|
|
2324
|
-
parameters.push(this.offlineEngine.parameterInfoByIndex(index));
|
|
2325
|
-
}
|
|
2326
|
-
return parameters;
|
|
2327
|
-
}
|
|
2328
|
-
|
|
2329
|
-
setSoloMute(target: string | number, solo: boolean, mute: boolean): boolean {
|
|
2330
|
-
const laneIndex = this.ensureTrackLane(target);
|
|
2331
|
-
this.offlineEngine.setSoloMute(laneIndex, solo, mute);
|
|
2332
|
-
return this.realtimeNode.sendCommand({
|
|
2333
|
-
type: SonareEngineCommandType.SetSoloMute,
|
|
2334
|
-
targetId: laneIndex,
|
|
2335
|
-
sampleTime: -1,
|
|
2336
|
-
argInt: (mute ? 0x1 : 0) | (solo ? 0x2 : 0),
|
|
2337
|
-
});
|
|
2338
|
-
}
|
|
2339
|
-
|
|
2340
|
-
setStripGain(target: string | number, db: number): boolean {
|
|
2341
|
-
if (target === 'master') {
|
|
2342
|
-
const paramId = engineMixerMasterTarget(ENGINE_MIXER_PARAM_FADER_DB);
|
|
2343
|
-
this.offlineEngine.setParameter(paramId, db);
|
|
2344
|
-
return this.realtimeNode.sendCommand({
|
|
2345
|
-
type: SonareEngineCommandType.SetParamSmoothed,
|
|
2346
|
-
targetId: paramId,
|
|
2347
|
-
sampleTime: -1,
|
|
2348
|
-
argFloat: db,
|
|
2349
|
-
});
|
|
2350
|
-
}
|
|
2351
|
-
const laneIndex = this.ensureTrackLane(target);
|
|
2352
|
-
const paramId = engineMixerLaneTarget(laneIndex, ENGINE_MIXER_PARAM_FADER_DB);
|
|
2353
|
-
this.offlineEngine.setParameter(paramId, db);
|
|
2354
|
-
return this.realtimeNode.sendCommand({
|
|
2355
|
-
type: SonareEngineCommandType.SetParamSmoothed,
|
|
2356
|
-
targetId: paramId,
|
|
2357
|
-
sampleTime: -1,
|
|
2358
|
-
argFloat: db,
|
|
2359
|
-
});
|
|
2360
|
-
}
|
|
2361
|
-
|
|
2362
|
-
setStripPan(target: string | number, pan: number): boolean {
|
|
2363
|
-
if (target === 'master') {
|
|
2364
|
-
const paramId = engineMixerMasterTarget(ENGINE_MIXER_PARAM_PAN);
|
|
2365
|
-
this.offlineEngine.setParameter(paramId, pan);
|
|
2366
|
-
return this.realtimeNode.sendCommand({
|
|
2367
|
-
type: SonareEngineCommandType.SetParamSmoothed,
|
|
2368
|
-
targetId: paramId,
|
|
2369
|
-
sampleTime: -1,
|
|
2370
|
-
argFloat: pan,
|
|
2371
|
-
});
|
|
2372
|
-
}
|
|
2373
|
-
const laneIndex = this.ensureTrackLane(target);
|
|
2374
|
-
const paramId = engineMixerLaneTarget(laneIndex, ENGINE_MIXER_PARAM_PAN);
|
|
2375
|
-
this.offlineEngine.setParameter(paramId, pan);
|
|
2376
|
-
return this.realtimeNode.sendCommand({
|
|
2377
|
-
type: SonareEngineCommandType.SetParamSmoothed,
|
|
2378
|
-
targetId: paramId,
|
|
2379
|
-
sampleTime: -1,
|
|
2380
|
-
argFloat: pan,
|
|
2381
|
-
});
|
|
2382
|
-
}
|
|
2383
|
-
|
|
2384
|
-
/**
|
|
2385
|
-
* Declares the mixer track lanes in an explicit order.
|
|
2386
|
-
*
|
|
2387
|
-
* Lane indices are append-only: once a track id occupies a lane, its index
|
|
2388
|
-
* stays fixed for the engine's lifetime. The given list must therefore start
|
|
2389
|
-
* with the already-declared lane ids in their current order and may only
|
|
2390
|
-
* append new track ids after them. Entries carrying `sends` replace that
|
|
2391
|
-
* track's send list; entries without `sends` leave existing sends untouched.
|
|
2392
|
-
*
|
|
2393
|
-
* @param lanes Track ids or lane descriptors in the desired lane order.
|
|
2394
|
-
*/
|
|
2395
|
-
setTrackLanes(lanes: ReadonlyArray<number | EngineTrackLane>): void {
|
|
2396
|
-
const entries = lanes.map((lane) => (typeof lane === 'number' ? { trackId: lane } : lane));
|
|
2397
|
-
const ids: number[] = [];
|
|
2398
|
-
for (const entry of entries) {
|
|
2399
|
-
if (!Number.isInteger(entry.trackId) || entry.trackId <= 0) {
|
|
2400
|
-
throw new Error(`Invalid track id for mixer lane: ${String(entry.trackId)}`);
|
|
2401
|
-
}
|
|
2402
|
-
ids.push(entry.trackId);
|
|
2403
|
-
}
|
|
2404
|
-
if (new Set(ids).size !== ids.length) {
|
|
2405
|
-
throw new Error('Duplicate track id in mixer lane list');
|
|
2406
|
-
}
|
|
2407
|
-
for (let index = 0; index < this.trackLaneIds.length; index++) {
|
|
2408
|
-
if (ids[index] !== this.trackLaneIds[index]) {
|
|
2409
|
-
throw new Error(
|
|
2410
|
-
'Mixer lanes are append-only: keep existing lanes in order and only append new track ids',
|
|
2411
|
-
);
|
|
2412
|
-
}
|
|
2413
|
-
}
|
|
2414
|
-
for (const entry of entries) {
|
|
2415
|
-
if (entry.sends) {
|
|
2416
|
-
this.trackSends.set(
|
|
2417
|
-
entry.trackId,
|
|
2418
|
-
entry.sends.map((send) => ({ ...send })),
|
|
2419
|
-
);
|
|
2420
|
-
}
|
|
2421
|
-
if (entry.outputBusId !== undefined) {
|
|
2422
|
-
if (entry.outputBusId === 0) {
|
|
2423
|
-
this.trackOutputBus.delete(entry.trackId);
|
|
2424
|
-
} else {
|
|
2425
|
-
this.trackOutputBus.set(entry.trackId, entry.outputBusId);
|
|
2426
|
-
}
|
|
2427
|
-
}
|
|
2428
|
-
}
|
|
2429
|
-
this.trackLaneIds.splice(0, this.trackLaneIds.length, ...ids);
|
|
2430
|
-
this.syncMixer();
|
|
2431
|
-
}
|
|
2432
|
-
|
|
2433
|
-
/**
|
|
2434
|
-
* Routes a track lane's post-fader output into a declared bus instead of
|
|
2435
|
-
* the master mix (group/folder routing); busId 0 restores the master mix.
|
|
2436
|
-
*/
|
|
2437
|
-
setTrackOutputBus(target: string | number, busId: number): void {
|
|
2438
|
-
const laneIndex = this.ensureTrackLane(target);
|
|
2439
|
-
const trackId = this.trackLaneIds[laneIndex];
|
|
2440
|
-
if (busId === 0) {
|
|
2441
|
-
this.trackOutputBus.delete(trackId);
|
|
2442
|
-
} else {
|
|
2443
|
-
this.trackOutputBus.set(trackId, busId);
|
|
2444
|
-
}
|
|
2445
|
-
this.syncMixer();
|
|
2446
|
-
}
|
|
2447
|
-
|
|
2448
|
-
/**
|
|
2449
|
-
* Keys one insert of a lane strip from another lane's post-strip pre-fader
|
|
2450
|
-
* audio (ducking/sidechainRouter inserts). sourceTarget null removes the
|
|
2451
|
-
* binding.
|
|
2452
|
-
*/
|
|
2453
|
-
setLaneSidechain(
|
|
2454
|
-
target: string | number,
|
|
2455
|
-
insertIndex: number,
|
|
2456
|
-
sourceTarget: string | number | null,
|
|
2457
|
-
): void {
|
|
2458
|
-
const laneIndex = this.ensureTrackLane(target);
|
|
2459
|
-
const trackId = this.trackLaneIds[laneIndex];
|
|
2460
|
-
const key = `${trackId}:${insertIndex}`;
|
|
2461
|
-
let sourceTrackId = 0;
|
|
2462
|
-
if (sourceTarget !== null) {
|
|
2463
|
-
const sourceIndex = this.ensureTrackLane(sourceTarget);
|
|
2464
|
-
sourceTrackId = this.trackLaneIds[sourceIndex];
|
|
2465
|
-
}
|
|
2466
|
-
if (sourceTrackId === 0) {
|
|
2467
|
-
this.laneSidechains.delete(key);
|
|
2468
|
-
} else {
|
|
2469
|
-
this.laneSidechains.set(key, { trackId, insertIndex, sourceTrackId });
|
|
2470
|
-
}
|
|
2471
|
-
this.offlineEngine.setLaneSidechain(trackId, insertIndex, sourceTrackId);
|
|
2472
|
-
this.postSync({
|
|
2473
|
-
type: 'syncMixer',
|
|
2474
|
-
lanes: this.mixerLanes(),
|
|
2475
|
-
laneSidechains: [{ trackId, insertIndex, sourceTrackId }],
|
|
2476
|
-
});
|
|
2477
|
-
}
|
|
2478
|
-
|
|
2479
|
-
setSends(target: string | number, sends: EngineTrackSend[]): void {
|
|
2480
|
-
const laneIndex = this.ensureTrackLane(target);
|
|
2481
|
-
const trackId = this.trackLaneIds[laneIndex];
|
|
2482
|
-
this.trackSends.set(
|
|
2483
|
-
trackId,
|
|
2484
|
-
sends.map((send) => ({ ...send })),
|
|
2485
|
-
);
|
|
2486
|
-
this.syncMixer();
|
|
2487
|
-
}
|
|
2488
|
-
|
|
2489
|
-
setTrackBuses(buses: EngineBus[]): void {
|
|
2490
|
-
this.buses.splice(0, this.buses.length, ...buses.map((bus) => ({ ...bus })));
|
|
2491
|
-
this.syncMixer();
|
|
2492
|
-
}
|
|
2493
|
-
|
|
2494
|
-
setBusGain(busId: number, db: number): boolean {
|
|
2495
|
-
const busIndex = this.ensureBus(busId);
|
|
2496
|
-
this.buses[busIndex] = { ...this.buses[busIndex], busId, gainDb: db };
|
|
2497
|
-
this.offlineEngine.setTrackBuses(this.buses);
|
|
2498
|
-
const paramId = engineMixerBusTarget(busIndex, ENGINE_MIXER_PARAM_FADER_DB);
|
|
2499
|
-
this.offlineEngine.setParameter(paramId, db);
|
|
2500
|
-
return this.realtimeNode.sendCommand({
|
|
2501
|
-
type: SonareEngineCommandType.SetParamSmoothed,
|
|
2502
|
-
targetId: paramId,
|
|
2503
|
-
sampleTime: -1,
|
|
2504
|
-
argFloat: db,
|
|
2505
|
-
});
|
|
2506
|
-
}
|
|
2507
|
-
|
|
2508
|
-
setTrackStripJson(target: string | number, sceneJson: string): void {
|
|
2509
|
-
const laneIndex = this.ensureTrackLane(target);
|
|
2510
|
-
const trackId = this.trackLaneIds[laneIndex];
|
|
2511
|
-
this.offlineEngine.setTrackStripJson(trackId, sceneJson);
|
|
2512
|
-
this.trackStripJson.set(trackId, sceneJson);
|
|
2513
|
-
this.syncMixer();
|
|
2514
|
-
}
|
|
2515
|
-
|
|
2516
|
-
setTrackStripEqBand(target: string | number, bandIndex: number, band: EqBand | string): void {
|
|
2517
|
-
const laneIndex = this.ensureTrackLane(target);
|
|
2518
|
-
const trackId = this.trackLaneIds[laneIndex];
|
|
2519
|
-
const bandJson = typeof band === 'string' ? band : JSON.stringify(band);
|
|
2520
|
-
this.offlineEngine.setTrackStripEqBandJson(trackId, bandIndex, bandJson);
|
|
2521
|
-
this.postSync({ type: 'syncTrackStripEqBand', trackId, bandIndex, bandJson });
|
|
2522
|
-
}
|
|
2523
|
-
|
|
2524
|
-
setTrackStripInsertBypassed(
|
|
2525
|
-
target: string | number,
|
|
2526
|
-
insertIndex: number,
|
|
2527
|
-
bypassed: boolean,
|
|
2528
|
-
resetOnBypass = false,
|
|
2529
|
-
): void {
|
|
2530
|
-
const laneIndex = this.ensureTrackLane(target);
|
|
2531
|
-
const trackId = this.trackLaneIds[laneIndex];
|
|
2532
|
-
this.offlineEngine.setTrackStripInsertBypassed(trackId, insertIndex, bypassed, resetOnBypass);
|
|
2533
|
-
this.postSync({
|
|
2534
|
-
type: 'syncTrackStripInsertBypassed',
|
|
2535
|
-
trackId,
|
|
2536
|
-
insertIndex,
|
|
2537
|
-
bypassed,
|
|
2538
|
-
resetOnBypass,
|
|
2539
|
-
});
|
|
2540
|
-
}
|
|
2541
|
-
|
|
2542
|
-
setTrackStripInsertParamByName(
|
|
2543
|
-
target: string | number,
|
|
2544
|
-
insertIndex: number,
|
|
2545
|
-
paramName: string,
|
|
2546
|
-
value: number,
|
|
2547
|
-
): void {
|
|
2548
|
-
const laneIndex = this.ensureTrackLane(target);
|
|
2549
|
-
const trackId = this.trackLaneIds[laneIndex];
|
|
2550
|
-
this.offlineEngine.setTrackStripInsertParamByName(trackId, insertIndex, paramName, value);
|
|
2551
|
-
this.postSync({
|
|
2552
|
-
type: 'syncTrackStripInsertParamByName',
|
|
2553
|
-
trackId,
|
|
2554
|
-
insertIndex,
|
|
2555
|
-
paramName,
|
|
2556
|
-
value,
|
|
2557
|
-
});
|
|
2558
|
-
}
|
|
2559
|
-
|
|
2560
|
-
setTrackStripPan(target: string | number, pan: number): void {
|
|
2561
|
-
const trackId = this.trackLaneIds[this.ensureTrackLane(target)];
|
|
2562
|
-
this.offlineEngine.setTrackStripPan(trackId, pan);
|
|
2563
|
-
this.postSync({ type: 'syncTrackStripPan', trackId, pan });
|
|
2564
|
-
}
|
|
2565
|
-
|
|
2566
|
-
setTrackStripPanLaw(target: string | number, panLaw: PanLaw | number): void {
|
|
2567
|
-
const trackId = this.trackLaneIds[this.ensureTrackLane(target)];
|
|
2568
|
-
const code = panLawCode(panLaw);
|
|
2569
|
-
this.offlineEngine.setTrackStripPanLaw(trackId, code);
|
|
2570
|
-
this.postSync({ type: 'syncTrackStripPanLaw', trackId, panLaw: code });
|
|
2571
|
-
}
|
|
2572
|
-
|
|
2573
|
-
setTrackStripPanMode(target: string | number, panMode: PanMode | number): void {
|
|
2574
|
-
const trackId = this.trackLaneIds[this.ensureTrackLane(target)];
|
|
2575
|
-
const code = panModeCode(panMode);
|
|
2576
|
-
this.offlineEngine.setTrackStripPanMode(trackId, code);
|
|
2577
|
-
this.postSync({ type: 'syncTrackStripPanMode', trackId, panMode: code });
|
|
2578
|
-
}
|
|
2579
|
-
|
|
2580
|
-
setTrackStripDualPan(target: string | number, leftPan: number, rightPan: number): void {
|
|
2581
|
-
const trackId = this.trackLaneIds[this.ensureTrackLane(target)];
|
|
2582
|
-
this.offlineEngine.setTrackStripDualPan(trackId, leftPan, rightPan);
|
|
2583
|
-
this.postSync({ type: 'syncTrackStripDualPan', trackId, leftPan, rightPan });
|
|
2584
|
-
}
|
|
2585
|
-
|
|
2586
|
-
setTrackStripChannelDelaySamples(target: string | number, delaySamples: number): void {
|
|
2587
|
-
const trackId = this.trackLaneIds[this.ensureTrackLane(target)];
|
|
2588
|
-
this.offlineEngine.setTrackStripChannelDelaySamples(trackId, delaySamples);
|
|
2589
|
-
this.postSync({ type: 'syncTrackStripChannelDelaySamples', trackId, delaySamples });
|
|
2590
|
-
}
|
|
2591
|
-
|
|
2592
|
-
setStripEq(target: string | number, bandIndex: number, band: EqBand | string): void {
|
|
2593
|
-
if (target === 'master') {
|
|
2594
|
-
this.setMasterStripEqBand(bandIndex, band);
|
|
2595
|
-
return;
|
|
2596
|
-
}
|
|
2597
|
-
this.setTrackStripEqBand(target, bandIndex, band);
|
|
2598
|
-
}
|
|
2599
|
-
|
|
2600
|
-
setStripInsertBypassed(
|
|
2601
|
-
target: string | number,
|
|
2602
|
-
insertIndex: number,
|
|
2603
|
-
bypassed: boolean,
|
|
2604
|
-
resetOnBypass = false,
|
|
2605
|
-
): void {
|
|
2606
|
-
if (target === 'master') {
|
|
2607
|
-
this.setMasterStripInsertBypassed(insertIndex, bypassed, resetOnBypass);
|
|
2608
|
-
return;
|
|
2609
|
-
}
|
|
2610
|
-
this.setTrackStripInsertBypassed(target, insertIndex, bypassed, resetOnBypass);
|
|
2611
|
-
}
|
|
2612
|
-
|
|
2613
|
-
setStripInserts(target: string | number, sceneJson: string): void {
|
|
2614
|
-
if (target === 'master') {
|
|
2615
|
-
this.setMasterStripJson(sceneJson);
|
|
2616
|
-
return;
|
|
2617
|
-
}
|
|
2618
|
-
this.setTrackStripJson(target, sceneJson);
|
|
2619
|
-
}
|
|
2620
|
-
|
|
2621
|
-
setBusStripJson(busId: number, sceneJson: string): void {
|
|
2622
|
-
this.ensureBus(busId);
|
|
2623
|
-
this.offlineEngine.setBusStripJson(busId, sceneJson);
|
|
2624
|
-
this.busStripJson.set(busId, sceneJson);
|
|
2625
|
-
this.syncMixer();
|
|
2626
|
-
}
|
|
2627
|
-
|
|
2628
|
-
setMasterStripJson(sceneJson: string): void {
|
|
2629
|
-
this.offlineEngine.setMasterStripJson(sceneJson);
|
|
2630
|
-
this.masterStripJson = sceneJson;
|
|
2631
|
-
this.syncMixer();
|
|
2632
|
-
}
|
|
2633
|
-
|
|
2634
|
-
setMasterStripEqBand(bandIndex: number, band: EqBand | string): void {
|
|
2635
|
-
const bandJson = typeof band === 'string' ? band : JSON.stringify(band);
|
|
2636
|
-
this.offlineEngine.setMasterStripEqBandJson(bandIndex, bandJson);
|
|
2637
|
-
this.postSync({ type: 'syncMasterStripEqBand', bandIndex, bandJson });
|
|
2638
|
-
}
|
|
2639
|
-
|
|
2640
|
-
setMasterStripInsertBypassed(
|
|
2641
|
-
insertIndex: number,
|
|
2642
|
-
bypassed: boolean,
|
|
2643
|
-
resetOnBypass = false,
|
|
2644
|
-
): void {
|
|
2645
|
-
this.offlineEngine.setMasterStripInsertBypassed(insertIndex, bypassed, resetOnBypass);
|
|
2646
|
-
this.postSync({
|
|
2647
|
-
type: 'syncMasterStripInsertBypassed',
|
|
2648
|
-
insertIndex,
|
|
2649
|
-
bypassed,
|
|
2650
|
-
resetOnBypass,
|
|
2651
|
-
});
|
|
2652
|
-
}
|
|
2653
|
-
|
|
2654
|
-
setMasterStripInsertParamByName(insertIndex: number, paramName: string, value: number): void {
|
|
2655
|
-
this.offlineEngine.setMasterStripInsertParamByName(insertIndex, paramName, value);
|
|
2656
|
-
this.postSync({
|
|
2657
|
-
type: 'syncMasterStripInsertParamByName',
|
|
2658
|
-
insertIndex,
|
|
2659
|
-
paramName,
|
|
2660
|
-
value,
|
|
2661
|
-
});
|
|
2662
|
-
}
|
|
2663
|
-
|
|
2664
|
-
setStripInsertParamByName(
|
|
2665
|
-
target: string | number,
|
|
2666
|
-
insertIndex: number,
|
|
2667
|
-
paramName: string,
|
|
2668
|
-
value: number,
|
|
2669
|
-
): void {
|
|
2670
|
-
if (target === 'master') {
|
|
2671
|
-
this.setMasterStripInsertParamByName(insertIndex, paramName, value);
|
|
2672
|
-
return;
|
|
2673
|
-
}
|
|
2674
|
-
this.setTrackStripInsertParamByName(target, insertIndex, paramName, value);
|
|
2675
|
-
}
|
|
2676
|
-
|
|
2677
|
-
setMasterChain(sceneJson: string): void {
|
|
2678
|
-
this.setMasterStripJson(sceneJson);
|
|
2679
|
-
}
|
|
2680
|
-
|
|
2681
|
-
addClip(
|
|
2682
|
-
trackId: string | number,
|
|
2683
|
-
buffer: Float32Array[],
|
|
2684
|
-
startPpq: number,
|
|
2685
|
-
opts: Partial<Omit<EngineClip, 'channels' | 'startPpq'>> = {},
|
|
2686
|
-
): number {
|
|
2687
|
-
const id = opts.id ?? this.nextClipId++;
|
|
2688
|
-
const clip: EngineClip = {
|
|
2689
|
-
...opts,
|
|
2690
|
-
id,
|
|
2691
|
-
channels: buffer,
|
|
2692
|
-
startPpq,
|
|
2693
|
-
trackId: this.resolveTargetId(trackId),
|
|
2694
|
-
};
|
|
2695
|
-
this.ensureTrackLane(trackId);
|
|
2696
|
-
this.clips.set(id, clip);
|
|
2697
|
-
this.syncClipsDelta([clip], []);
|
|
2698
|
-
return id;
|
|
2699
|
-
}
|
|
2700
|
-
|
|
2701
|
-
removeClip(clipId: number): void {
|
|
2702
|
-
this.clips.delete(clipId);
|
|
2703
|
-
this.syncClipsDelta([], [clipId]);
|
|
2704
|
-
}
|
|
2705
|
-
|
|
2706
|
-
setMidiClips(clips: readonly EngineMidiClipSchedule[]): void {
|
|
2707
|
-
this.midiClips.clear();
|
|
2708
|
-
for (const clip of clips) {
|
|
2709
|
-
const id = clip.id ?? this.nextClipId++;
|
|
2710
|
-
this.midiClips.set(id, { ...clip, id, events: clip.events.map((event) => ({ ...event })) });
|
|
2711
|
-
}
|
|
2712
|
-
this.syncMidiClips();
|
|
2713
|
-
}
|
|
2714
|
-
|
|
2715
|
-
setBuiltinInstrument(
|
|
2716
|
-
trackId: string | number,
|
|
2717
|
-
config: { destinationId?: number } & Record<string, unknown> = {},
|
|
2718
|
-
): void {
|
|
2719
|
-
const destinationId = this.resolveTargetId(trackId);
|
|
2720
|
-
this.offlineEngine.setBuiltinInstrument(config, destinationId);
|
|
2721
|
-
this.postInstrumentSync({ type: 'syncBuiltinInstrument', destinationId, config });
|
|
2722
|
-
}
|
|
2723
|
-
|
|
2724
|
-
setSynthInstrument(trackId: string | number, patch: Record<string, unknown> | string = {}): void {
|
|
2725
|
-
const destinationId = this.resolveTargetId(trackId);
|
|
2726
|
-
this.offlineEngine.setSynthInstrument(patch, destinationId);
|
|
2727
|
-
this.postInstrumentSync({ type: 'syncSynthInstrument', destinationId, patch });
|
|
2728
|
-
}
|
|
2729
|
-
|
|
2730
|
-
loadSoundFont(data: Uint8Array): void {
|
|
2731
|
-
this.offlineEngine.loadSoundFont(data);
|
|
2732
|
-
this.postInstrumentSync({ type: 'syncLoadSoundFont', data });
|
|
2733
|
-
}
|
|
2734
|
-
|
|
2735
|
-
setSf2Instrument(
|
|
2736
|
-
trackId: string | number,
|
|
2737
|
-
config: { destinationId?: number; gain?: number; polyphony?: number } = {},
|
|
2738
|
-
): void {
|
|
2739
|
-
const destinationId = this.resolveTargetId(trackId);
|
|
2740
|
-
this.offlineEngine.setSf2Instrument(config, destinationId);
|
|
2741
|
-
this.postInstrumentSync({ type: 'syncSf2Instrument', destinationId, config });
|
|
2742
|
-
}
|
|
2743
|
-
|
|
2744
|
-
pushMidiNoteOn(
|
|
2745
|
-
trackId: string | number,
|
|
2746
|
-
group: number,
|
|
2747
|
-
channel: number,
|
|
2748
|
-
note: number,
|
|
2749
|
-
velocity: number,
|
|
2750
|
-
renderFrame = -1,
|
|
2751
|
-
): void {
|
|
2752
|
-
const destinationId = this.resolveTargetId(trackId);
|
|
2753
|
-
this.offlineEngine.pushMidiNoteOn(destinationId, group, channel, note, velocity, renderFrame);
|
|
2754
|
-
this.postSync({
|
|
2755
|
-
type: 'syncMidiNoteOn',
|
|
2756
|
-
destinationId,
|
|
2757
|
-
group,
|
|
2758
|
-
channel,
|
|
2759
|
-
note,
|
|
2760
|
-
velocity,
|
|
2761
|
-
renderFrame,
|
|
2762
|
-
});
|
|
2763
|
-
}
|
|
2764
|
-
|
|
2765
|
-
pushMidiNoteOff(
|
|
2766
|
-
trackId: string | number,
|
|
2767
|
-
group: number,
|
|
2768
|
-
channel: number,
|
|
2769
|
-
note: number,
|
|
2770
|
-
velocity = 0,
|
|
2771
|
-
renderFrame = -1,
|
|
2772
|
-
): void {
|
|
2773
|
-
const destinationId = this.resolveTargetId(trackId);
|
|
2774
|
-
this.offlineEngine.pushMidiNoteOff(destinationId, group, channel, note, velocity, renderFrame);
|
|
2775
|
-
this.postSync({
|
|
2776
|
-
type: 'syncMidiNoteOff',
|
|
2777
|
-
destinationId,
|
|
2778
|
-
group,
|
|
2779
|
-
channel,
|
|
2780
|
-
note,
|
|
2781
|
-
velocity,
|
|
2782
|
-
renderFrame,
|
|
2783
|
-
});
|
|
2784
|
-
}
|
|
2785
|
-
|
|
2786
|
-
pushMidiCc(
|
|
2787
|
-
trackId: string | number,
|
|
2788
|
-
group: number,
|
|
2789
|
-
channel: number,
|
|
2790
|
-
controller: number,
|
|
2791
|
-
value: number,
|
|
2792
|
-
renderFrame = -1,
|
|
2793
|
-
): void {
|
|
2794
|
-
const destinationId = this.resolveTargetId(trackId);
|
|
2795
|
-
this.offlineEngine.pushMidiCc(destinationId, group, channel, controller, value, renderFrame);
|
|
2796
|
-
this.postSync({
|
|
2797
|
-
type: 'syncMidiCc',
|
|
2798
|
-
destinationId,
|
|
2799
|
-
group,
|
|
2800
|
-
channel,
|
|
2801
|
-
controller,
|
|
2802
|
-
value,
|
|
2803
|
-
renderFrame,
|
|
2804
|
-
});
|
|
2805
|
-
}
|
|
2806
|
-
|
|
2807
|
-
pushMidiPanic(renderFrame = -1): void {
|
|
2808
|
-
this.offlineEngine.pushMidiPanic(renderFrame);
|
|
2809
|
-
this.postSync({ type: 'syncMidiPanic', renderFrame });
|
|
2810
|
-
}
|
|
2811
|
-
|
|
2812
|
-
configureCapture(options: {
|
|
2813
|
-
bufferFrames: number;
|
|
2814
|
-
channels?: number;
|
|
2815
|
-
source?: EngineCaptureStatus['source'];
|
|
2816
|
-
recordOffsetSamples?: number;
|
|
2817
|
-
inputMonitor?: { enabled: boolean; gain?: number };
|
|
2818
|
-
}): void {
|
|
2819
|
-
const bufferFrames = Math.trunc(options.bufferFrames);
|
|
2820
|
-
const channels = Math.trunc(options.channels ?? this.offlineChannelCount);
|
|
2821
|
-
const source = options.source ?? 'output';
|
|
2822
|
-
const recordOffsetSamples = Math.trunc(options.recordOffsetSamples ?? 0);
|
|
2823
|
-
const inputMonitor = {
|
|
2824
|
-
enabled: Boolean(options.inputMonitor?.enabled),
|
|
2825
|
-
gain: options.inputMonitor?.gain ?? 1,
|
|
2826
|
-
};
|
|
2827
|
-
this.offlineEngine.setCaptureBuffer(channels, bufferFrames);
|
|
2828
|
-
this.offlineEngine.setCaptureSource(source);
|
|
2829
|
-
this.offlineEngine.setRecordOffsetSamples(recordOffsetSamples);
|
|
2830
|
-
this.offlineEngine.setInputMonitor(inputMonitor.enabled, inputMonitor.gain);
|
|
2831
|
-
this.captureConfig = { bufferFrames, channels, source, recordOffsetSamples, inputMonitor };
|
|
2832
|
-
this.postSync({ type: 'syncCapture', ...this.captureConfig });
|
|
2833
|
-
}
|
|
2834
|
-
|
|
2835
|
-
armRecord(trackId: string | number, enabled: boolean): boolean {
|
|
2836
|
-
if (enabled && !this.captureConfig) {
|
|
2837
|
-
throw new Error('Capture buffer is not configured');
|
|
2838
|
-
}
|
|
2839
|
-
this.offlineEngine.armCapture(enabled);
|
|
2840
|
-
return this.realtimeNode.sendCommand({
|
|
2841
|
-
type: SonareEngineCommandType.ArmRecord,
|
|
2842
|
-
targetId: this.resolveTargetId(trackId),
|
|
2843
|
-
sampleTime: -1,
|
|
2844
|
-
argInt: enabled ? 1 : 0,
|
|
2845
|
-
});
|
|
2846
|
-
}
|
|
2847
|
-
|
|
2848
|
-
punch(inPpq: number, outPpq: number): boolean {
|
|
2849
|
-
const inSample = this.offlineEngine.sampleAtPpq(inPpq);
|
|
2850
|
-
const outSample = this.offlineEngine.sampleAtPpq(outPpq);
|
|
2851
|
-
this.offlineEngine.setCapturePunch(inSample, outSample, true);
|
|
2852
|
-
// Carry BOTH endpoints as already-converted SAMPLES so the realtime engine
|
|
2853
|
-
// agrees with the offline engine. The previous code sent the raw PPQ out
|
|
2854
|
-
// point and let the consumer multiply by sampleRate (treating PPQ as
|
|
2855
|
-
// seconds), which ignored tempo and produced a punch-out ~2x too large at
|
|
2856
|
-
// 120 BPM. argInt = in sample, argFloat = out sample (full-precision double).
|
|
2857
|
-
return this.realtimeNode.sendCommand({
|
|
2858
|
-
type: SonareEngineCommandType.Punch,
|
|
2859
|
-
sampleTime: -1,
|
|
2860
|
-
argInt: inSample,
|
|
2861
|
-
argFloat: outSample,
|
|
2862
|
-
});
|
|
2863
|
-
}
|
|
2864
|
-
|
|
2865
|
-
captureStatus(): Promise<EngineCaptureStatus> {
|
|
2866
|
-
return this.realtimeNode.requestCaptureStatus();
|
|
2867
|
-
}
|
|
2868
|
-
|
|
2869
|
-
capturedAudio(): Promise<Float32Array[]> {
|
|
2870
|
-
return this.realtimeNode.requestCapturedAudio();
|
|
2871
|
-
}
|
|
2872
|
-
|
|
2873
|
-
async resetCapture(): Promise<void> {
|
|
2874
|
-
this.offlineEngine.resetCapture();
|
|
2875
|
-
await this.realtimeNode.requestCaptureReset();
|
|
2876
|
-
}
|
|
2877
|
-
|
|
2878
|
-
setMetronome(opts: EngineMetronomeConfig): void {
|
|
2879
|
-
this.offlineEngine.setMetronome(opts);
|
|
2880
|
-
// The full config (beatGain/accentGain/clickSamples/clickSeconds) cannot fit
|
|
2881
|
-
// the fixed-size SAB command record, so it is delivered out-of-band; the
|
|
2882
|
-
// SetMetronome command then toggles enabled state on the audio thread.
|
|
2883
|
-
this.postSync({ type: 'syncMetronome', config: opts });
|
|
2884
|
-
this.realtimeNode.sendCommand({
|
|
2885
|
-
type: SonareEngineCommandType.SetMetronome,
|
|
2886
|
-
sampleTime: -1,
|
|
2887
|
-
argInt: opts.enabled ? 1 : 0,
|
|
2888
|
-
});
|
|
2889
|
-
}
|
|
2890
|
-
|
|
2891
|
-
addMarker(ppq: number, name = ''): number {
|
|
2892
|
-
const id = this.nextMarkerId++;
|
|
2893
|
-
this.markers.set(id, { id, ppq, name });
|
|
2894
|
-
this.syncMarkers();
|
|
2895
|
-
return id;
|
|
2896
|
-
}
|
|
2897
|
-
|
|
2898
|
-
/**
|
|
2899
|
-
* Replaces the whole marker set in one call.
|
|
2900
|
-
*
|
|
2901
|
-
* Entries without an `id` are assigned fresh ids; entries carrying an `id`
|
|
2902
|
-
* keep it (ids must be positive and unique within the list). Returns the
|
|
2903
|
-
* resolved markers in the order given, so a caller can map its own marker
|
|
2904
|
-
* identities to the engine ids used by `seekMarker`/`setLoopFromMarkers`.
|
|
2905
|
-
*
|
|
2906
|
-
* @param markers The full marker list (an empty list clears all markers).
|
|
2907
|
-
* @returns The markers with their resolved engine ids.
|
|
2908
|
-
*/
|
|
2909
|
-
setMarkers(markers: ReadonlyArray<{ ppq: number; name?: string; id?: number }>): EngineMarker[] {
|
|
2910
|
-
const resolved: EngineMarker[] = [];
|
|
2911
|
-
const seen = new Set<number>();
|
|
2912
|
-
for (const marker of markers) {
|
|
2913
|
-
if (!Number.isFinite(marker.ppq)) {
|
|
2914
|
-
throw new Error(`Invalid marker ppq: ${String(marker.ppq)}`);
|
|
2915
|
-
}
|
|
2916
|
-
if (marker.id !== undefined) {
|
|
2917
|
-
if (!Number.isInteger(marker.id) || marker.id <= 0) {
|
|
2918
|
-
throw new Error(`Invalid marker id: ${String(marker.id)}`);
|
|
2919
|
-
}
|
|
2920
|
-
if (seen.has(marker.id)) {
|
|
2921
|
-
throw new Error(`Duplicate marker id: ${marker.id}`);
|
|
2922
|
-
}
|
|
2923
|
-
}
|
|
2924
|
-
const id = marker.id ?? this.nextMarkerId++;
|
|
2925
|
-
seen.add(id);
|
|
2926
|
-
if (id >= this.nextMarkerId) {
|
|
2927
|
-
this.nextMarkerId = id + 1;
|
|
2928
|
-
}
|
|
2929
|
-
resolved.push({ id, ppq: marker.ppq, name: marker.name ?? '' });
|
|
2930
|
-
}
|
|
2931
|
-
this.markers.clear();
|
|
2932
|
-
for (const marker of resolved) {
|
|
2933
|
-
this.markers.set(marker.id, marker);
|
|
2934
|
-
}
|
|
2935
|
-
this.syncMarkers();
|
|
2936
|
-
return resolved.map((marker) => ({ ...marker }));
|
|
2937
|
-
}
|
|
2938
|
-
|
|
2939
|
-
markerCount(): number {
|
|
2940
|
-
return this.offlineEngine.markerCount();
|
|
2941
|
-
}
|
|
2942
|
-
|
|
2943
|
-
markerByIndex(index: number): EngineMarker {
|
|
2944
|
-
return this.offlineEngine.markerByIndex(index);
|
|
2945
|
-
}
|
|
2946
|
-
|
|
2947
|
-
marker(markerId: number): EngineMarker {
|
|
2948
|
-
return this.offlineEngine.marker(markerId);
|
|
2949
|
-
}
|
|
2950
|
-
|
|
2951
|
-
seekMarker(markerId: number): boolean {
|
|
2952
|
-
this.offlineEngine.seekMarker(markerId);
|
|
2953
|
-
// Forward to the live worklet engine. Its marker set is kept in sync via the
|
|
2954
|
-
// 'syncMarkers' message (see syncMarkers), so a queued kSeekMarker resolves
|
|
2955
|
-
// the marker id to its frame on the audio thread. Returns the sendCommand
|
|
2956
|
-
// result (previously this method always returned a misleading `false`).
|
|
2957
|
-
return this.realtimeNode.sendCommand({
|
|
2958
|
-
type: SonareEngineCommandType.SeekMarker,
|
|
2959
|
-
targetId: markerId,
|
|
2960
|
-
sampleTime: -1,
|
|
2961
|
-
});
|
|
2962
|
-
}
|
|
2963
|
-
|
|
2964
|
-
setLoopFromMarkers(startMarkerId: number, endMarkerId: number): boolean {
|
|
2965
|
-
this.offlineEngine.setLoopFromMarkers(startMarkerId, endMarkerId);
|
|
2966
|
-
const start = this.offlineEngine.marker(startMarkerId);
|
|
2967
|
-
const end = this.offlineEngine.marker(endMarkerId);
|
|
2968
|
-
return this.setLoop(start.ppq, end.ppq, true);
|
|
2969
|
-
}
|
|
2970
|
-
|
|
2971
|
-
async renderOffline(totalFrames: number): Promise<Float32Array[]> {
|
|
2972
|
-
const frames = Math.max(0, Math.floor(totalFrames));
|
|
2973
|
-
const inputs: Float32Array[] = [];
|
|
2974
|
-
for (let ch = 0; ch < this.offlineChannelCount; ch++) {
|
|
2975
|
-
inputs.push(new Float32Array(frames));
|
|
2976
|
-
}
|
|
2977
|
-
return this.offlineEngine.renderOffline(inputs, this.offlineBlockSize);
|
|
2978
|
-
}
|
|
2979
|
-
|
|
2980
|
-
onMeter(callback: (meter: SonareWorkletMeterSnapshot) => void): () => void {
|
|
2981
|
-
return this.realtimeNode.onMeter(callback);
|
|
2982
|
-
}
|
|
2983
|
-
|
|
2984
|
-
onScope(callback: (scope: SonareWorkletScopeSnapshot) => void): () => void {
|
|
2985
|
-
return this.realtimeNode.onScope(callback);
|
|
2986
|
-
}
|
|
2987
|
-
|
|
2988
|
-
onTelemetry(callback: (telemetry: SonareEngineTelemetryRecord) => void): () => void {
|
|
2989
|
-
return this.realtimeNode.onTelemetry(callback);
|
|
2990
|
-
}
|
|
2991
|
-
|
|
2992
|
-
pollTelemetry(): SonareEngineTelemetryRecord[] {
|
|
2993
|
-
return this.realtimeNode.pollTelemetry();
|
|
2994
|
-
}
|
|
2995
|
-
|
|
2996
|
-
pollMeters(): SonareWorkletMeterSnapshot[] {
|
|
2997
|
-
return this.realtimeNode.pollMeters();
|
|
2998
|
-
}
|
|
2999
|
-
|
|
3000
|
-
pollScope(): SonareWorkletScopeSnapshot[] {
|
|
3001
|
-
return this.realtimeNode.pollScope();
|
|
3002
|
-
}
|
|
3003
|
-
|
|
3004
|
-
destroy(): void {
|
|
3005
|
-
if (this.destroyed) {
|
|
3006
|
-
return;
|
|
3007
|
-
}
|
|
3008
|
-
this.destroyed = true;
|
|
3009
|
-
this.transport.stop();
|
|
3010
|
-
this.realtimeNode.pollTelemetry();
|
|
3011
|
-
this.realtimeNode.destroy();
|
|
3012
|
-
this.offlineEngine.destroy();
|
|
3013
|
-
}
|
|
3014
|
-
|
|
3015
|
-
private syncClipsDelta(upserts: EngineClip[], removeIds: number[]): void {
|
|
3016
|
-
const clips = Array.from(this.clips.values());
|
|
3017
|
-
this.offlineEngine.setClips(clips);
|
|
3018
|
-
this.postSync({
|
|
3019
|
-
type: 'syncClipsDelta',
|
|
3020
|
-
upserts,
|
|
3021
|
-
removeIds,
|
|
3022
|
-
});
|
|
3023
|
-
}
|
|
3024
|
-
|
|
3025
|
-
private syncMidiClips(): void {
|
|
3026
|
-
const clips = Array.from(this.midiClips.values());
|
|
3027
|
-
this.offlineEngine.setMidiClips(clips);
|
|
3028
|
-
this.postSync({ type: 'syncMidiClips', clips });
|
|
3029
|
-
}
|
|
3030
|
-
|
|
3031
|
-
private mixerLanes(): EngineTrackLane[] {
|
|
3032
|
-
return this.trackLaneIds.map((trackId) => {
|
|
3033
|
-
const sends = this.trackSends.get(trackId);
|
|
3034
|
-
const outputBusId = this.trackOutputBus.get(trackId);
|
|
3035
|
-
return {
|
|
3036
|
-
trackId,
|
|
3037
|
-
...(sends && sends.length > 0 ? { sends: sends.map((send) => ({ ...send })) } : {}),
|
|
3038
|
-
...(outputBusId !== undefined ? { outputBusId } : {}),
|
|
3039
|
-
};
|
|
3040
|
-
});
|
|
3041
|
-
}
|
|
3042
|
-
|
|
3043
|
-
private syncMixer(): void {
|
|
3044
|
-
const lanes = this.mixerLanes();
|
|
3045
|
-
const buses = this.buses.map((bus) => ({ ...bus }));
|
|
3046
|
-
this.offlineEngine.setTrackBuses(buses);
|
|
3047
|
-
if (lanes.length > 0) {
|
|
3048
|
-
this.offlineEngine.setTrackLanes(lanes);
|
|
3049
|
-
}
|
|
3050
|
-
const trackStrips = Array.from(this.trackStripJson, ([trackId, sceneJson]) => ({
|
|
3051
|
-
trackId,
|
|
3052
|
-
sceneJson,
|
|
3053
|
-
}));
|
|
3054
|
-
const busStrips = Array.from(this.busStripJson, ([busId, sceneJson]) => ({
|
|
3055
|
-
busId,
|
|
3056
|
-
sceneJson,
|
|
3057
|
-
}));
|
|
3058
|
-
this.postSync({
|
|
3059
|
-
type: 'syncMixer',
|
|
3060
|
-
lanes,
|
|
3061
|
-
buses,
|
|
3062
|
-
trackStrips,
|
|
3063
|
-
laneSidechains: Array.from(this.laneSidechains.values()),
|
|
3064
|
-
busStrips,
|
|
3065
|
-
masterStripJson: this.masterStripJson,
|
|
3066
|
-
});
|
|
3067
|
-
}
|
|
3068
|
-
|
|
3069
|
-
private syncMarkers(): void {
|
|
3070
|
-
const markers = Array.from(this.markers.values()).sort((a, b) => a.ppq - b.ppq);
|
|
3071
|
-
this.offlineEngine.setMarkers(markers);
|
|
3072
|
-
this.postSync({ type: 'syncMarkers', markers });
|
|
3073
|
-
}
|
|
3074
|
-
|
|
3075
|
-
private postInstrumentSync(message: SonareEngineInstrumentSyncMessage): void {
|
|
3076
|
-
if (this.destroyed) {
|
|
3077
|
-
return;
|
|
3078
|
-
}
|
|
3079
|
-
if (this.transportPlaying) {
|
|
3080
|
-
this.pendingInstrumentSync.push(message);
|
|
3081
|
-
return;
|
|
3082
|
-
}
|
|
3083
|
-
this.postSync(message);
|
|
3084
|
-
}
|
|
3085
|
-
|
|
3086
|
-
private flushPendingInstrumentSync(): void {
|
|
3087
|
-
if (this.destroyed || this.pendingInstrumentSync.length === 0) {
|
|
3088
|
-
return;
|
|
3089
|
-
}
|
|
3090
|
-
const pending = this.pendingInstrumentSync.splice(0);
|
|
3091
|
-
for (const message of pending) {
|
|
3092
|
-
this.postSync(message);
|
|
3093
|
-
}
|
|
3094
|
-
}
|
|
3095
|
-
|
|
3096
|
-
private postTempoSync(): void {
|
|
3097
|
-
this.postSync({
|
|
3098
|
-
type: 'syncTempo',
|
|
3099
|
-
bpm: this.tempoBpm,
|
|
3100
|
-
timeSignature: { ...this.timeSignature },
|
|
3101
|
-
tempoSegments: this.tempoSegments.map((segment) => ({ ...segment })),
|
|
3102
|
-
timeSignatureSegments: this.timeSignatureSegments.map((segment) => ({ ...segment })),
|
|
3103
|
-
});
|
|
3104
|
-
}
|
|
3105
|
-
|
|
3106
|
-
// Posts an out-of-band control-sync message to the worklet engine processor.
|
|
3107
|
-
// Sync messages use a string `type` so the worklet's message handler routes
|
|
3108
|
-
// them to receiveSync() (numeric `type` is reserved for SonareEngineCommandRecord).
|
|
3109
|
-
private postSync(message: SonareEngineSyncMessage): void {
|
|
3110
|
-
if (this.destroyed) {
|
|
3111
|
-
return;
|
|
3112
|
-
}
|
|
3113
|
-
this.realtimeNode.node.port.postMessage(message);
|
|
3114
|
-
}
|
|
3115
|
-
|
|
3116
|
-
private resolveParamId(nodeId: string, param: string | number): number {
|
|
3117
|
-
if (typeof param === 'number') {
|
|
3118
|
-
return param;
|
|
3119
|
-
}
|
|
3120
|
-
const byName = this.listParameters().find((info) => info.name === param);
|
|
3121
|
-
if (byName) {
|
|
3122
|
-
return byName.id;
|
|
3123
|
-
}
|
|
3124
|
-
return this.resolveTargetId(param || nodeId);
|
|
3125
|
-
}
|
|
3126
|
-
|
|
3127
|
-
private resolveTargetId(target: string | number): number {
|
|
3128
|
-
if (typeof target === 'number') {
|
|
3129
|
-
return target;
|
|
3130
|
-
}
|
|
3131
|
-
const parsed = Number.parseInt(target, 10);
|
|
3132
|
-
return Number.isFinite(parsed) ? parsed : 0;
|
|
3133
|
-
}
|
|
3134
|
-
|
|
3135
|
-
private ensureTrackLane(target: string | number): number {
|
|
3136
|
-
const trackId = this.resolveTargetId(target);
|
|
3137
|
-
if (!Number.isInteger(trackId) || trackId <= 0) {
|
|
3138
|
-
throw new Error(`Invalid track id for mixer lane: ${String(target)}`);
|
|
3139
|
-
}
|
|
3140
|
-
const existing = this.trackLaneIds.indexOf(trackId);
|
|
3141
|
-
if (existing >= 0) {
|
|
3142
|
-
return existing;
|
|
3143
|
-
}
|
|
3144
|
-
this.trackLaneIds.push(trackId);
|
|
3145
|
-
this.syncMixer();
|
|
3146
|
-
return this.trackLaneIds.length - 1;
|
|
3147
|
-
}
|
|
3148
|
-
|
|
3149
|
-
private ensureBus(busId: number): number {
|
|
3150
|
-
const resolved = Math.trunc(busId);
|
|
3151
|
-
if (!Number.isInteger(resolved) || resolved <= 0) {
|
|
3152
|
-
throw new Error(`Invalid bus id for mixer bus: ${String(busId)}`);
|
|
3153
|
-
}
|
|
3154
|
-
const existing = this.buses.findIndex((bus) => bus.busId === resolved);
|
|
3155
|
-
if (existing >= 0) {
|
|
3156
|
-
return existing;
|
|
3157
|
-
}
|
|
3158
|
-
this.buses.push({ busId: resolved });
|
|
3159
|
-
this.syncMixer();
|
|
3160
|
-
return this.buses.length - 1;
|
|
3161
|
-
}
|
|
3162
|
-
|
|
3163
|
-
private curveCode(curve: number | 'linear' | 'exponential'): number {
|
|
3164
|
-
if (typeof curve === 'number') {
|
|
3165
|
-
return curve;
|
|
3166
|
-
}
|
|
3167
|
-
return curve === 'exponential' ? 1 : 0;
|
|
3168
|
-
}
|
|
3169
|
-
}
|
|
3170
|
-
|
|
3171
|
-
export class SonareRealtimeVoiceChangerWorkletProcessor {
|
|
3172
|
-
private static warnedMonoOverflow = false;
|
|
3173
|
-
private static warnedInterleavedOverflow = false;
|
|
3174
|
-
private changer: RealtimeVoiceChanger;
|
|
3175
|
-
private readonly sampleRate: number;
|
|
3176
|
-
private readonly blockSize: number;
|
|
3177
|
-
private readonly channelCount: number;
|
|
3178
|
-
// WASM-heap typed-memory views, sized to the worst case (blockSize *
|
|
3179
|
-
// channelCount). Acquired on the main thread (constructor) so the
|
|
3180
|
-
// audio-thread process() never crosses an allocation boundary.
|
|
3181
|
-
private monoInput: Float32Array;
|
|
3182
|
-
private monoOutput: Float32Array;
|
|
3183
|
-
// Planar heap-backed views (one Float32Array per channel) used by the
|
|
3184
|
-
// multi-channel path. AudioWorklet inputs/outputs are already planar
|
|
3185
|
-
// Float32Arrays, so this avoids the per-sample interleave/deinterleave
|
|
3186
|
-
// passes that the older interleaved path needed.
|
|
3187
|
-
private planarChannels: Float32Array[];
|
|
3188
|
-
private destroyed = false;
|
|
3189
|
-
|
|
3190
|
-
constructor(options: SonareRealtimeVoiceChangerWorkletProcessorOptions = {}) {
|
|
3191
|
-
this.sampleRate = options.sampleRate ?? 48000;
|
|
3192
|
-
this.blockSize = options.blockSize ?? 128;
|
|
3193
|
-
this.channelCount = Math.max(1, Math.floor(options.channelCount ?? 1));
|
|
3194
|
-
this.changer = new RealtimeVoiceChanger(options.preset ?? 'neutral-monitor');
|
|
3195
|
-
this.changer.prepare(this.sampleRate, this.blockSize, this.channelCount);
|
|
3196
|
-
// Acquire WASM-heap views once, sized to the worst case. These are alive
|
|
3197
|
-
// for the lifetime of the changer; if the host requests more frames per
|
|
3198
|
-
// process() than blockSize, we clamp (see ensure*Capacity).
|
|
3199
|
-
this.monoInput = this.changer.getMonoInputBuffer(this.blockSize);
|
|
3200
|
-
this.monoOutput = this.changer.getMonoOutputBuffer(this.blockSize);
|
|
3201
|
-
this.planarChannels = [];
|
|
3202
|
-
if (this.channelCount > 1) {
|
|
3203
|
-
for (let ch = 0; ch < this.channelCount; ch++) {
|
|
3204
|
-
this.planarChannels.push(this.changer.getPlanarChannelBuffer(ch, this.blockSize));
|
|
3205
|
-
}
|
|
3206
|
-
}
|
|
3207
|
-
}
|
|
3208
|
-
|
|
3209
|
-
/**
|
|
3210
|
-
* Handles a control-plane message from the main thread. Runs on the
|
|
3211
|
-
* AudioWorklet global scope but OUTSIDE of `process()` (i.e. outside the
|
|
3212
|
-
* realtime audio callback), so it is safe to perform JSON parsing and
|
|
3213
|
-
* DSP coefficient recomputation here. `setConfig` MUST NOT be deferred
|
|
3214
|
-
* into `process()` because that would block the audio thread for longer
|
|
3215
|
-
* than one render quantum (e.g. 128 samples / 44.1 kHz = ~2.9 ms).
|
|
3216
|
-
*/
|
|
3217
|
-
receiveMessage(message: SonareRealtimeVoiceChangerMessage): void {
|
|
3218
|
-
if (this.destroyed) {
|
|
3219
|
-
return;
|
|
3220
|
-
}
|
|
3221
|
-
if (message.type === 'setConfig') {
|
|
3222
|
-
// Apply synchronously on the message-handler thread. `setConfig` may
|
|
3223
|
-
// allocate and parse JSON internally; doing it here keeps `process()`
|
|
3224
|
-
// realtime-safe.
|
|
3225
|
-
this.changer.setConfig(message.preset);
|
|
3226
|
-
} else if (message.type === 'reset') {
|
|
3227
|
-
this.changer.reset();
|
|
3228
|
-
} else if (message.type === 'destroy') {
|
|
3229
|
-
this.destroy();
|
|
3230
|
-
}
|
|
3231
|
-
}
|
|
3232
|
-
|
|
3233
|
-
process(inputs: WorkletInput, outputs: WorkletOutput): boolean {
|
|
3234
|
-
const output = outputs[0];
|
|
3235
|
-
if (this.destroyed || !output || output.length === 0) {
|
|
3236
|
-
return !this.destroyed;
|
|
3237
|
-
}
|
|
3238
|
-
|
|
3239
|
-
// The cached heap views can detach if WASM linear memory grows (the embind
|
|
3240
|
-
// module is built ALLOW_MEMORY_GROWTH). Re-acquire them if detached
|
|
3241
|
-
// (byteLength === 0) before touching them; in the common no-growth case this
|
|
3242
|
-
// is a cheap branch with no allocation.
|
|
3243
|
-
if (this.monoInput.byteLength === 0) {
|
|
3244
|
-
this.reacquireBuffers();
|
|
3245
|
-
}
|
|
3246
|
-
|
|
3247
|
-
const input = inputs[0];
|
|
3248
|
-
const requestedFrames = output[0]?.length ?? 0;
|
|
3249
|
-
const requestedChannels = Math.min(this.channelCount, output.length);
|
|
3250
|
-
if (requestedFrames === 0 || requestedChannels === 0) {
|
|
3251
|
-
return true;
|
|
3252
|
-
}
|
|
3253
|
-
|
|
3254
|
-
if (requestedChannels === 1) {
|
|
3255
|
-
// Clamp to the pre-allocated capacity; warn (once) if the host violated
|
|
3256
|
-
// the contract. We never reallocate on the audio thread.
|
|
3257
|
-
const frames = this.ensureMonoCapacity(requestedFrames);
|
|
3258
|
-
const source = input?.[0];
|
|
3259
|
-
if (source) {
|
|
3260
|
-
this.monoInput.set(source.subarray(0, frames));
|
|
3261
|
-
} else {
|
|
3262
|
-
this.monoInput.fill(0, 0, frames);
|
|
3263
|
-
}
|
|
3264
|
-
this.changer.processMonoInto(
|
|
3265
|
-
this.monoInput.subarray(0, frames),
|
|
3266
|
-
this.monoOutput.subarray(0, frames),
|
|
3267
|
-
);
|
|
3268
|
-
output[0].set(this.monoOutput.subarray(0, frames));
|
|
3269
|
-
return true;
|
|
3270
|
-
}
|
|
3271
|
-
|
|
3272
|
-
const frames = this.ensureInterleavedCapacity(requestedFrames, requestedChannels);
|
|
3273
|
-
const channels = requestedChannels;
|
|
3274
|
-
// Planar zero-copy path: AudioWorklet's input[ch] is already a
|
|
3275
|
-
// Float32Array per channel, so we set() straight into the heap-backed
|
|
3276
|
-
// planar view and processPreparedPlanar runs in place.
|
|
3277
|
-
for (let ch = 0; ch < channels; ch++) {
|
|
3278
|
-
const src = input?.[ch];
|
|
3279
|
-
const dst = this.planarChannels[ch];
|
|
3280
|
-
if (!dst) {
|
|
3281
|
-
continue;
|
|
3282
|
-
}
|
|
3283
|
-
if (src) {
|
|
3284
|
-
dst.set(src.subarray(0, frames));
|
|
3285
|
-
} else {
|
|
3286
|
-
dst.fill(0, 0, frames);
|
|
3287
|
-
}
|
|
3288
|
-
}
|
|
3289
|
-
this.changer.processPreparedPlanar(frames);
|
|
3290
|
-
for (let ch = 0; ch < channels; ch++) {
|
|
3291
|
-
const src = this.planarChannels[ch];
|
|
3292
|
-
if (src) {
|
|
3293
|
-
output[ch].set(src.subarray(0, frames));
|
|
3294
|
-
}
|
|
3295
|
-
// No `for frame` inner loop needed; output[ch] is a Float32Array.
|
|
3296
|
-
}
|
|
3297
|
-
return true;
|
|
3298
|
-
}
|
|
3299
|
-
|
|
3300
|
-
destroy(): void {
|
|
3301
|
-
if (this.destroyed) {
|
|
3302
|
-
return;
|
|
3303
|
-
}
|
|
3304
|
-
this.destroyed = true;
|
|
3305
|
-
this.changer.delete();
|
|
3306
|
-
}
|
|
3307
|
-
|
|
3308
|
-
// Re-acquires the cached WASM-heap views after a memory-growth detachment.
|
|
3309
|
-
// The underlying C++ vectors are pre-warmed (ensure_*_capacity ran at prepare
|
|
3310
|
-
// time), so getMono*/getPlanar* return fresh views onto the SAME storage
|
|
3311
|
-
// without reallocating it.
|
|
3312
|
-
private reacquireBuffers(): void {
|
|
3313
|
-
this.monoInput = this.changer.getMonoInputBuffer(this.blockSize);
|
|
3314
|
-
this.monoOutput = this.changer.getMonoOutputBuffer(this.blockSize);
|
|
3315
|
-
if (this.channelCount > 1) {
|
|
3316
|
-
for (let ch = 0; ch < this.channelCount; ch++) {
|
|
3317
|
-
this.planarChannels[ch] = this.changer.getPlanarChannelBuffer(ch, this.blockSize);
|
|
3318
|
-
}
|
|
3319
|
-
}
|
|
3320
|
-
}
|
|
3321
|
-
|
|
3322
|
-
/**
|
|
3323
|
-
* Returns the number of frames we can actually process given the
|
|
3324
|
-
* pre-allocated capacity. If the host requests more frames than the
|
|
3325
|
-
* worst-case block size declared at construction time, we clamp to the
|
|
3326
|
-
* available capacity and warn once — we MUST NOT reallocate on the
|
|
3327
|
-
* realtime audio thread.
|
|
3328
|
-
*/
|
|
3329
|
-
private ensureMonoCapacity(frames: number): number {
|
|
3330
|
-
const capacity = this.monoInput.length;
|
|
3331
|
-
if (frames <= capacity) {
|
|
3332
|
-
return frames;
|
|
3333
|
-
}
|
|
3334
|
-
if (!SonareRealtimeVoiceChangerWorkletProcessor.warnedMonoOverflow) {
|
|
3335
|
-
SonareRealtimeVoiceChangerWorkletProcessor.warnedMonoOverflow = true;
|
|
3336
|
-
// biome-ignore lint/suspicious/noConsole: realtime-safety diagnostic.
|
|
3337
|
-
console.warn(
|
|
3338
|
-
`SonareRealtimeVoiceChangerWorkletProcessor: requested ${frames} mono frames ` +
|
|
3339
|
-
`exceeds pre-allocated capacity ${capacity}; clamping. ` +
|
|
3340
|
-
'Increase blockSize at construction time to avoid this.',
|
|
3341
|
-
);
|
|
3342
|
-
}
|
|
3343
|
-
return capacity;
|
|
3344
|
-
}
|
|
3345
|
-
|
|
3346
|
-
/**
|
|
3347
|
-
* Same contract as ensureMonoCapacity but for the planar per-channel
|
|
3348
|
-
* scratch. Returns the number of frames that fit in the available capacity.
|
|
3349
|
-
*/
|
|
3350
|
-
private ensureInterleavedCapacity(frames: number, channels: number): number {
|
|
3351
|
-
const capacity = this.planarChannels[0]?.length ?? 0;
|
|
3352
|
-
if (frames <= capacity) {
|
|
3353
|
-
return frames;
|
|
3354
|
-
}
|
|
3355
|
-
if (!SonareRealtimeVoiceChangerWorkletProcessor.warnedInterleavedOverflow) {
|
|
3356
|
-
SonareRealtimeVoiceChangerWorkletProcessor.warnedInterleavedOverflow = true;
|
|
3357
|
-
// biome-ignore lint/suspicious/noConsole: realtime-safety diagnostic.
|
|
3358
|
-
console.warn(
|
|
3359
|
-
`SonareRealtimeVoiceChangerWorkletProcessor: requested ${frames}x${channels} ` +
|
|
3360
|
-
`planar frames exceeds pre-allocated capacity ${capacity}; clamping. ` +
|
|
3361
|
-
'Increase blockSize or channelCount at construction time to avoid this.',
|
|
3362
|
-
);
|
|
3363
|
-
}
|
|
3364
|
-
return capacity;
|
|
3365
|
-
}
|
|
3366
|
-
}
|
|
3367
|
-
|
|
3368
|
-
export function registerSonareWorkletProcessor(name = 'sonare-worklet-processor'): void {
|
|
3369
|
-
const scope = globalThis as unknown as {
|
|
3370
|
-
AudioWorkletProcessor?: new () => object;
|
|
3371
|
-
registerProcessor?: (processorName: string, processorCtor: unknown) => void;
|
|
3372
|
-
};
|
|
3373
|
-
if (!scope.AudioWorkletProcessor || !scope.registerProcessor) {
|
|
3374
|
-
throw new Error('AudioWorkletProcessor is not available in this context.');
|
|
3375
|
-
}
|
|
3376
|
-
const Base = scope.AudioWorkletProcessor;
|
|
3377
|
-
class RegisteredSonareWorkletProcessor extends Base {
|
|
3378
|
-
private bridge: SonareWorkletProcessor;
|
|
3379
|
-
readonly port?: WorkletPort;
|
|
3380
|
-
|
|
3381
|
-
constructor(options?: { processorOptions?: SonareWorkletProcessorOptions }) {
|
|
3382
|
-
super();
|
|
3383
|
-
const port = this.port;
|
|
3384
|
-
this.bridge = new SonareWorkletProcessor(options?.processorOptions ?? { sceneJson: '' }, {
|
|
3385
|
-
postMessage: (message) => port?.postMessage?.(message),
|
|
3386
|
-
});
|
|
3387
|
-
const onMessage = (event: { data: unknown }) => {
|
|
3388
|
-
if (isWorkletMessage(event.data)) {
|
|
3389
|
-
this.bridge.receiveMessage(event.data);
|
|
3390
|
-
}
|
|
3391
|
-
};
|
|
3392
|
-
if (port?.addEventListener) {
|
|
3393
|
-
port.addEventListener('message', onMessage);
|
|
3394
|
-
port.start?.();
|
|
3395
|
-
} else if (port) {
|
|
3396
|
-
port.onmessage = onMessage;
|
|
3397
|
-
}
|
|
3398
|
-
}
|
|
3399
|
-
|
|
3400
|
-
process(inputs: WorkletInput, outputs: WorkletOutput): boolean {
|
|
3401
|
-
return this.bridge.process(inputs, outputs);
|
|
3402
|
-
}
|
|
3403
|
-
}
|
|
3404
|
-
scope.registerProcessor(name, RegisteredSonareWorkletProcessor);
|
|
3405
|
-
}
|
|
3406
|
-
|
|
3407
|
-
export function registerSonareRealtimeVoiceChangerWorkletProcessor(
|
|
3408
|
-
name = 'sonare-realtime-voice-changer-processor',
|
|
3409
|
-
): void {
|
|
3410
|
-
const scope = globalThis as unknown as {
|
|
3411
|
-
AudioWorkletProcessor?: new () => object;
|
|
3412
|
-
registerProcessor?: (processorName: string, processorCtor: unknown) => void;
|
|
3413
|
-
};
|
|
3414
|
-
if (!scope.AudioWorkletProcessor || !scope.registerProcessor) {
|
|
3415
|
-
throw new Error('AudioWorkletProcessor is not available in this context.');
|
|
3416
|
-
}
|
|
3417
|
-
const Base = scope.AudioWorkletProcessor;
|
|
3418
|
-
class RegisteredSonareRealtimeVoiceChangerWorkletProcessor extends Base {
|
|
3419
|
-
private bridge: SonareRealtimeVoiceChangerWorkletProcessor;
|
|
3420
|
-
readonly port?: WorkletPort;
|
|
3421
|
-
|
|
3422
|
-
constructor(options?: {
|
|
3423
|
-
processorOptions?: SonareRealtimeVoiceChangerWorkletProcessorOptions;
|
|
3424
|
-
}) {
|
|
3425
|
-
super();
|
|
3426
|
-
const port = this.port;
|
|
3427
|
-
this.bridge = new SonareRealtimeVoiceChangerWorkletProcessor(options?.processorOptions ?? {});
|
|
3428
|
-
const onMessage = (event: { data: unknown }) => {
|
|
3429
|
-
if (isRealtimeVoiceChangerMessage(event.data)) {
|
|
3430
|
-
this.bridge.receiveMessage(event.data);
|
|
3431
|
-
}
|
|
3432
|
-
};
|
|
3433
|
-
if (port?.addEventListener) {
|
|
3434
|
-
port.addEventListener('message', onMessage);
|
|
3435
|
-
port.start?.();
|
|
3436
|
-
} else if (port) {
|
|
3437
|
-
port.onmessage = onMessage;
|
|
3438
|
-
}
|
|
3439
|
-
}
|
|
3440
|
-
|
|
3441
|
-
process(inputs: WorkletInput, outputs: WorkletOutput): boolean {
|
|
3442
|
-
return this.bridge.process(inputs, outputs);
|
|
3443
|
-
}
|
|
3444
|
-
}
|
|
3445
|
-
scope.registerProcessor(name, RegisteredSonareRealtimeVoiceChangerWorkletProcessor);
|
|
3446
|
-
}
|
|
3447
|
-
|
|
3448
|
-
export function registerSonareRealtimeEngineWorkletProcessor(
|
|
3449
|
-
name = 'sonare-realtime-engine-processor',
|
|
3450
|
-
): void {
|
|
3451
|
-
const scope = globalThis as unknown as {
|
|
3452
|
-
AudioWorkletProcessor?: new () => object;
|
|
3453
|
-
registerProcessor?: (processorName: string, processorCtor: unknown) => void;
|
|
3454
|
-
};
|
|
3455
|
-
if (!scope.AudioWorkletProcessor || !scope.registerProcessor) {
|
|
3456
|
-
throw new Error('AudioWorkletProcessor is not available in this context.');
|
|
3457
|
-
}
|
|
3458
|
-
const Base = scope.AudioWorkletProcessor;
|
|
3459
|
-
class RegisteredSonareRealtimeEngineWorkletProcessor extends Base {
|
|
3460
|
-
private bridge?: SonareRealtimeEngineWorkletProcessor;
|
|
3461
|
-
private rtBridge?: SonareRtRealtimeEngineRuntime;
|
|
3462
|
-
private readonly pendingMessages: unknown[] = [];
|
|
3463
|
-
readonly port?: WorkletPort;
|
|
3464
|
-
|
|
3465
|
-
constructor(options?: { processorOptions?: SonareRealtimeEngineWorkletProcessorOptions }) {
|
|
3466
|
-
super();
|
|
3467
|
-
const port = this.port;
|
|
3468
|
-
const processorOptions = options?.processorOptions ?? {};
|
|
3469
|
-
if (processorOptions.runtimeTarget === 'sonare-rt') {
|
|
3470
|
-
void this.initializeSonareRt(processorOptions, port);
|
|
3471
|
-
} else {
|
|
3472
|
-
void this.initializeEmbind(processorOptions, port);
|
|
3473
|
-
}
|
|
3474
|
-
const onMessage = (event: { data: unknown }) => {
|
|
3475
|
-
if (!this.bridge && !this.rtBridge) {
|
|
3476
|
-
if (this.pendingMessages.length < 1024) {
|
|
3477
|
-
this.pendingMessages.push(event.data);
|
|
3478
|
-
}
|
|
3479
|
-
return;
|
|
3480
|
-
}
|
|
3481
|
-
if (isEngineCommandRecord(event.data)) {
|
|
3482
|
-
this.bridge?.receiveCommand(event.data);
|
|
3483
|
-
this.rtBridge?.receiveCommand(event.data);
|
|
3484
|
-
} else if (isEngineSyncMessage(event.data)) {
|
|
3485
|
-
this.bridge?.receiveSync(event.data);
|
|
3486
|
-
this.rtBridge?.receiveSync(event.data);
|
|
3487
|
-
} else if (isEngineCaptureRequestMessage(event.data)) {
|
|
3488
|
-
this.bridge?.receiveCaptureRequest(event.data);
|
|
3489
|
-
this.rtBridge?.receiveCaptureRequest(event.data, port);
|
|
3490
|
-
} else if (isEngineTransportRequestMessage(event.data)) {
|
|
3491
|
-
this.bridge?.receiveTransportRequest(event.data);
|
|
3492
|
-
this.rtBridge?.receiveTransportRequest(event.data, port);
|
|
3493
|
-
}
|
|
3494
|
-
};
|
|
3495
|
-
if (port?.addEventListener) {
|
|
3496
|
-
port.addEventListener('message', onMessage);
|
|
3497
|
-
port.start?.();
|
|
3498
|
-
} else if (port) {
|
|
3499
|
-
port.onmessage = onMessage;
|
|
3500
|
-
}
|
|
3501
|
-
}
|
|
3502
|
-
|
|
3503
|
-
process(inputs: WorkletInput, outputs: WorkletOutput): boolean {
|
|
3504
|
-
if (this.rtBridge) {
|
|
3505
|
-
return this.rtBridge.process(inputs, outputs);
|
|
3506
|
-
}
|
|
3507
|
-
if (this.bridge) {
|
|
3508
|
-
return this.bridge.process(inputs, outputs);
|
|
3509
|
-
}
|
|
3510
|
-
const output = outputs[0];
|
|
3511
|
-
for (const channel of output ?? []) {
|
|
3512
|
-
channel.fill(0);
|
|
3513
|
-
}
|
|
3514
|
-
return true;
|
|
3515
|
-
}
|
|
3516
|
-
|
|
3517
|
-
private replayPendingMessages(port?: WorkletPort): void {
|
|
3518
|
-
const messages = this.pendingMessages.splice(0);
|
|
3519
|
-
for (const data of messages) {
|
|
3520
|
-
if (isEngineCommandRecord(data)) {
|
|
3521
|
-
this.bridge?.receiveCommand(data);
|
|
3522
|
-
this.rtBridge?.receiveCommand(data);
|
|
3523
|
-
} else if (isEngineSyncMessage(data)) {
|
|
3524
|
-
this.bridge?.receiveSync(data);
|
|
3525
|
-
this.rtBridge?.receiveSync(data);
|
|
3526
|
-
} else if (isEngineCaptureRequestMessage(data)) {
|
|
3527
|
-
this.bridge?.receiveCaptureRequest(data);
|
|
3528
|
-
this.rtBridge?.receiveCaptureRequest(data, port);
|
|
3529
|
-
} else if (isEngineTransportRequestMessage(data)) {
|
|
3530
|
-
this.bridge?.receiveTransportRequest(data);
|
|
3531
|
-
this.rtBridge?.receiveTransportRequest(data, port);
|
|
3532
|
-
}
|
|
3533
|
-
}
|
|
3534
|
-
}
|
|
3535
|
-
|
|
3536
|
-
private async initializeEmbind(
|
|
3537
|
-
options: SonareRealtimeEngineWorkletProcessorOptions,
|
|
3538
|
-
port?: WorkletPort,
|
|
3539
|
-
): Promise<void> {
|
|
3540
|
-
try {
|
|
3541
|
-
const initPromise = (
|
|
3542
|
-
globalThis as typeof globalThis & { SonareEmbindInitPromise?: Promise<void> }
|
|
3543
|
-
).SonareEmbindInitPromise;
|
|
3544
|
-
if (initPromise) {
|
|
3545
|
-
await initPromise;
|
|
3546
|
-
}
|
|
3547
|
-
if (!isInitialized()) {
|
|
3548
|
-
type EmbindModuleFactory = (options?: {
|
|
3549
|
-
locateFile?: (path: string, prefix: string) => string;
|
|
3550
|
-
wasmBinary?: ArrayBuffer | Uint8Array;
|
|
3551
|
-
}) => Promise<SonareModule>;
|
|
3552
|
-
const moduleFactory = (
|
|
3553
|
-
globalThis as typeof globalThis & {
|
|
3554
|
-
SonareEmbindModuleFactory?: EmbindModuleFactory;
|
|
3555
|
-
}
|
|
3556
|
-
).SonareEmbindModuleFactory;
|
|
3557
|
-
if (!moduleFactory) {
|
|
3558
|
-
throw new Error('embind realtime engine module is not initialized.');
|
|
3559
|
-
}
|
|
3560
|
-
await initSonareModule({
|
|
3561
|
-
locateFile: (path) => path,
|
|
3562
|
-
wasmBinary: options.wasmBinary,
|
|
3563
|
-
moduleFactory,
|
|
3564
|
-
});
|
|
3565
|
-
}
|
|
3566
|
-
this.bridge = new SonareRealtimeEngineWorkletProcessor(options, {
|
|
3567
|
-
postMessage: (message) => port?.postMessage?.(message),
|
|
3568
|
-
onMeter: (meter) => port?.postMessage?.(meter),
|
|
3569
|
-
});
|
|
3570
|
-
for (const message of options.initialSyncMessages ?? []) {
|
|
3571
|
-
this.bridge.receiveSync(message);
|
|
3572
|
-
}
|
|
3573
|
-
for (const command of options.initialCommands ?? []) {
|
|
3574
|
-
this.bridge.receiveCommand(command);
|
|
3575
|
-
}
|
|
3576
|
-
this.replayPendingMessages(port);
|
|
3577
|
-
port?.postMessage?.({ type: 'ready', runtimeTarget: 'embind' });
|
|
3578
|
-
} catch (error) {
|
|
3579
|
-
port?.postMessage?.({
|
|
3580
|
-
type: 'error',
|
|
3581
|
-
message: error instanceof Error ? error.message : String(error),
|
|
3582
|
-
});
|
|
3583
|
-
}
|
|
3584
|
-
}
|
|
3585
|
-
|
|
3586
|
-
private async initializeSonareRt(
|
|
3587
|
-
options: SonareRealtimeEngineWorkletProcessorOptions,
|
|
3588
|
-
port?: WorkletPort,
|
|
3589
|
-
): Promise<void> {
|
|
3590
|
-
try {
|
|
3591
|
-
if (!options.rtModuleUrl) {
|
|
3592
|
-
throw new Error('rtModuleUrl is required for sonare-rt AudioWorklet runtime.');
|
|
3593
|
-
}
|
|
3594
|
-
const rtModuleUrl = options.rtModuleUrl;
|
|
3595
|
-
const memory = new WebAssembly.Memory({ initial: 1024, maximum: 1024, shared: true });
|
|
3596
|
-
const globalFactory = (
|
|
3597
|
-
globalThis as typeof globalThis & {
|
|
3598
|
-
SonareRtModuleFactory?: (options?: {
|
|
3599
|
-
wasmMemory?: WebAssembly.Memory;
|
|
3600
|
-
wasmBinary?: ArrayBuffer | Uint8Array;
|
|
3601
|
-
locateFile?: (path: string) => string;
|
|
3602
|
-
}) => Promise<SonareRtModule>;
|
|
3603
|
-
}
|
|
3604
|
-
).SonareRtModuleFactory;
|
|
3605
|
-
const moduleFactory = globalFactory
|
|
3606
|
-
? { default: globalFactory }
|
|
3607
|
-
: ((await import(rtModuleUrl)) as {
|
|
3608
|
-
default: (options?: {
|
|
3609
|
-
wasmMemory?: WebAssembly.Memory;
|
|
3610
|
-
wasmBinary?: ArrayBuffer | Uint8Array;
|
|
3611
|
-
locateFile?: (path: string) => string;
|
|
3612
|
-
}) => Promise<SonareRtModule>;
|
|
3613
|
-
});
|
|
3614
|
-
const module = await moduleFactory.default({
|
|
3615
|
-
wasmMemory: memory,
|
|
3616
|
-
wasmBinary: options.rtWasmBinary,
|
|
3617
|
-
locateFile: (path) => rtModuleUrl.replace(/[^/]*$/, path),
|
|
3618
|
-
});
|
|
3619
|
-
this.rtBridge = new SonareRtRealtimeEngineRuntime({
|
|
3620
|
-
module,
|
|
3621
|
-
memory,
|
|
3622
|
-
sampleRate: options.sampleRate,
|
|
3623
|
-
blockSize: options.blockSize,
|
|
3624
|
-
channelCount: options.channelCount,
|
|
3625
|
-
commandSharedBuffer: options.commandSharedBuffer,
|
|
3626
|
-
commandRingCapacity: options.commandRingCapacity,
|
|
3627
|
-
telemetrySharedBuffer: options.telemetrySharedBuffer,
|
|
3628
|
-
telemetryRingCapacity: options.telemetryRingCapacity,
|
|
3629
|
-
});
|
|
3630
|
-
this.replayPendingMessages(port);
|
|
3631
|
-
port?.postMessage?.({ type: 'ready', runtimeTarget: 'sonare-rt' });
|
|
3632
|
-
} catch (error) {
|
|
3633
|
-
port?.postMessage?.({
|
|
3634
|
-
type: 'error',
|
|
3635
|
-
message: error instanceof Error ? error.message : String(error),
|
|
3636
|
-
});
|
|
3637
|
-
}
|
|
3638
|
-
}
|
|
3639
|
-
}
|
|
3640
|
-
scope.registerProcessor(name, RegisteredSonareRealtimeEngineWorkletProcessor);
|
|
3641
|
-
}
|
|
109
|
+
export {
|
|
110
|
+
registerSonareRealtimeVoiceChangerWorkletProcessor,
|
|
111
|
+
SonareRealtimeVoiceChangerWorkletProcessor,
|
|
112
|
+
} from './worklet/voice-changer-processor';
|