@libraz/libsonare 1.5.5 → 1.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +109 -20
- package/dist/analysis.d.ts +5809 -0
- package/dist/analysis.js +2574 -0
- package/dist/analysis.js.map +1 -0
- package/dist/index.d.ts +884 -55
- package/dist/index.js +1302 -292
- package/dist/index.js.map +1 -1
- package/dist/schemas/realtime-voice-changer-preset-pack.schema.json +27 -0
- package/dist/schemas/realtime-voice-changer-preset.schema.json +140 -0
- package/dist/sonare-analysis.js +2 -0
- package/dist/sonare-analysis.wasm +0 -0
- package/dist/sonare.js +1 -1
- package/dist/sonare.wasm +0 -0
- package/dist/worker.d.ts +65 -0
- package/dist/worker.js +734 -0
- package/dist/worker.js.map +1 -0
- package/dist/worklet.d.ts +628 -88
- package/dist/worklet.js +1816 -266
- package/dist/worklet.js.map +1 -1
- package/package.json +23 -11
- package/src/_chain_config.ts +5 -3
- package/src/analysis.ts +137 -0
- package/src/analysis_helpers.ts +40 -29
- package/src/audio.ts +7 -2
- package/src/clip_page_streamer.ts +62 -8
- package/src/codes.ts +87 -64
- package/src/effects_mastering.ts +1 -0
- package/src/effects_transform.ts +179 -10
- package/src/errors.ts +1 -0
- package/src/feature_core.ts +140 -2
- package/src/feature_music.ts +25 -3
- package/src/feature_pitch.ts +70 -1
- package/src/feature_spectral.ts +295 -7
- package/src/feature_spectrogram.ts +203 -3
- package/src/features.ts +16 -1
- package/src/index.ts +120 -8
- package/src/mastering_chain.ts +20 -8
- package/src/mastering_core.ts +5 -0
- package/src/mastering_dynamics.ts +7 -1
- package/src/metering.ts +6 -1
- package/src/mixer.ts +3 -3
- package/src/opfs_clip_pages.ts +26 -1
- package/src/project.ts +13 -0
- package/src/project_class.ts +204 -23
- package/src/project_internal.ts +38 -34
- package/src/project_types.ts +114 -11
- package/src/public_types.ts +68 -0
- package/src/public_types_mastering.ts +22 -0
- package/src/public_types_mixing.ts +27 -2
- package/src/public_types_music.ts +9 -0
- package/src/public_types_realtime.ts +23 -5
- package/src/public_types_spectral.ts +33 -0
- package/src/quick_analysis.ts +46 -10
- package/src/realtime_engine.ts +167 -6
- package/src/realtime_voice_changer.ts +51 -77
- package/src/sonare-analysis.js.d.ts +8 -0
- package/src/sonare.js.d.ts +340 -6
- package/src/stream_analyzer.ts +6 -1
- package/src/streaming_processors.ts +15 -0
- package/src/web_midi.ts +46 -11
- package/src/worker.ts +196 -0
- package/src/worker_client.ts +352 -0
- package/src/worker_protocol.ts +59 -0
- package/src/worklet/engine-automation.ts +2 -1
- package/src/worklet/engine-capture-facade.ts +13 -4
- package/src/worklet/engine-clips.ts +12 -3
- package/src/worklet/engine-node.ts +264 -21
- package/src/worklet/engine-offline.ts +7 -2
- package/src/worklet/engine-parameter-facade.ts +55 -5
- package/src/worklet/engine-processor.ts +435 -62
- package/src/worklet/engine-register.ts +9 -1
- package/src/worklet/engine-strips.ts +19 -3
- package/src/worklet/engine-sync.ts +2 -6
- package/src/worklet/engine-tempo-facade.ts +8 -6
- package/src/worklet/engine.ts +390 -13
- package/src/worklet/guards.ts +121 -8
- package/src/worklet/messages.ts +166 -8
- package/src/worklet/protocol.ts +305 -17
- package/src/worklet/voice-changer-processor.ts +12 -15
- package/src/worklet.ts +20 -1
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { EngineClip
|
|
1
|
+
import type { EngineClip } from '../index';
|
|
2
2
|
import { RealtimeEngine } from '../index';
|
|
3
3
|
import type { WorkletInput, WorkletOutput } from './audio_types';
|
|
4
4
|
import {
|
|
@@ -6,7 +6,7 @@ import {
|
|
|
6
6
|
type ResolvedMetronomeConfig,
|
|
7
7
|
resolveMetronomeConfig,
|
|
8
8
|
type SonareEngineCaptureRequestMessage,
|
|
9
|
-
type
|
|
9
|
+
type SonareEngineCaptureResponseMessageInternal,
|
|
10
10
|
type SonareEngineSyncMessage,
|
|
11
11
|
type SonareEngineTransportRequestMessage,
|
|
12
12
|
type SonareEngineTransportResponseMessage,
|
|
@@ -14,12 +14,18 @@ import {
|
|
|
14
14
|
type WorkletTransport,
|
|
15
15
|
} from './messages';
|
|
16
16
|
import {
|
|
17
|
+
clipPageRequestRingFromSharedBuffer,
|
|
17
18
|
encodeFrameHi,
|
|
18
19
|
encodeFrameLo,
|
|
19
20
|
engineRingFromSharedBuffer,
|
|
21
|
+
externalMidiRingFromSharedBuffer,
|
|
20
22
|
meterFromEngine,
|
|
21
23
|
meterRingFromSharedBuffer,
|
|
22
24
|
popSonareEngineCommandRingBuffer,
|
|
25
|
+
pushSonareClipPageRequestRingBuffer,
|
|
26
|
+
pushSonareExternalMidiRingBuffer,
|
|
27
|
+
type SharedClipPageRequestRingWriter,
|
|
28
|
+
type SharedExternalMidiRingWriter,
|
|
23
29
|
type SharedMeterRingWriter,
|
|
24
30
|
type SharedScopeRingWriter,
|
|
25
31
|
SONARE_ENGINE_COMMAND_RECORD_BYTES,
|
|
@@ -36,9 +42,26 @@ import {
|
|
|
36
42
|
type SonareWorkletMeterSnapshot,
|
|
37
43
|
scopeRingFromSharedBuffer,
|
|
38
44
|
telemetryFromEngine,
|
|
45
|
+
writeInt64Words,
|
|
39
46
|
writeSonareEngineTelemetryRingBuffer,
|
|
40
47
|
} from './protocol';
|
|
41
48
|
|
|
49
|
+
function captureTransferList(channels: readonly Float32Array[]): Transferable[] {
|
|
50
|
+
const transfers: ArrayBuffer[] = [];
|
|
51
|
+
const seen = new Set<ArrayBuffer>();
|
|
52
|
+
for (const channel of channels) {
|
|
53
|
+
const buffer = channel.buffer;
|
|
54
|
+
if (!(buffer instanceof ArrayBuffer)) {
|
|
55
|
+
throw new TypeError('capture response channels must use plain ArrayBuffers');
|
|
56
|
+
}
|
|
57
|
+
if (!seen.has(buffer)) {
|
|
58
|
+
seen.add(buffer);
|
|
59
|
+
transfers.push(buffer);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return transfers;
|
|
63
|
+
}
|
|
64
|
+
|
|
42
65
|
/**
|
|
43
66
|
* AudioWorklet-style bridge for the DAW realtime engine facade.
|
|
44
67
|
*
|
|
@@ -55,6 +78,8 @@ export class SonareRealtimeEngineWorkletProcessor {
|
|
|
55
78
|
private telemetryRing?: SonareEngineTelemetryRingBuffer;
|
|
56
79
|
private meterRing?: SharedMeterRingWriter;
|
|
57
80
|
private scopeRing?: SharedScopeRingWriter;
|
|
81
|
+
private clipPageRequestRing?: SharedClipPageRequestRingWriter;
|
|
82
|
+
private externalMidiRing?: SharedExternalMidiRingWriter;
|
|
58
83
|
private transport?: WorkletTransport;
|
|
59
84
|
private meterIntervalFrames: number;
|
|
60
85
|
private lastMeterFrame = Number.NEGATIVE_INFINITY;
|
|
@@ -71,7 +96,15 @@ export class SonareRealtimeEngineWorkletProcessor {
|
|
|
71
96
|
private channelBuffers: Float32Array[];
|
|
72
97
|
private readonly liveClips = new Map<number, EngineClip>();
|
|
73
98
|
private readonly pagedClipProviders = new Map<number, number>();
|
|
99
|
+
private readonly pagedClipPageFrames = new Map<number, number>();
|
|
74
100
|
private readonly pendingPagedClips = new Map<number, EngineClip>();
|
|
101
|
+
// The worklet drains at most this many distinct page misses per render
|
|
102
|
+
// quantum. The fixed scalar buffers avoid retaining an unbounded queue on
|
|
103
|
+
// the audio thread; any remaining native requests stay queued for the next
|
|
104
|
+
// quantum and render silence meanwhile.
|
|
105
|
+
private readonly clipPageRequestClipIds = new Float64Array(64);
|
|
106
|
+
private readonly clipPageRequestPageIndices = new Float64Array(64);
|
|
107
|
+
private clipPageRequestOverflowReported = 0;
|
|
75
108
|
|
|
76
109
|
constructor(
|
|
77
110
|
options: SonareRealtimeEngineWorkletProcessorOptions = {},
|
|
@@ -101,7 +134,25 @@ export class SonareRealtimeEngineWorkletProcessor {
|
|
|
101
134
|
options.scopeBands,
|
|
102
135
|
)
|
|
103
136
|
: undefined;
|
|
104
|
-
this.
|
|
137
|
+
this.clipPageRequestRing = options.clipPageRequestSharedBuffer
|
|
138
|
+
? clipPageRequestRingFromSharedBuffer(
|
|
139
|
+
options.clipPageRequestSharedBuffer,
|
|
140
|
+
options.clipPageRequestRingCapacity,
|
|
141
|
+
)
|
|
142
|
+
: undefined;
|
|
143
|
+
this.externalMidiRing = options.externalMidiSharedBuffer
|
|
144
|
+
? externalMidiRingFromSharedBuffer(
|
|
145
|
+
options.externalMidiSharedBuffer,
|
|
146
|
+
options.externalMidiRingCapacity,
|
|
147
|
+
)
|
|
148
|
+
: undefined;
|
|
149
|
+
this.engine = new RealtimeEngine(
|
|
150
|
+
this.sampleRate,
|
|
151
|
+
this.blockSize,
|
|
152
|
+
1024,
|
|
153
|
+
1024,
|
|
154
|
+
this.channelCount,
|
|
155
|
+
);
|
|
105
156
|
// Allocate persistent WASM-heap scratch (worst case: channelCount channels x
|
|
106
157
|
// blockSize frames) and acquire the per-channel heap views once.
|
|
107
158
|
this.engine.prepareChannels(this.channelCount, this.blockSize);
|
|
@@ -191,6 +242,7 @@ export class SonareRealtimeEngineWorkletProcessor {
|
|
|
191
242
|
target.fill(0);
|
|
192
243
|
}
|
|
193
244
|
}
|
|
245
|
+
this.publishClipPageRequests();
|
|
194
246
|
this.publishTelemetry();
|
|
195
247
|
this.publishMeters();
|
|
196
248
|
this.publishScope();
|
|
@@ -206,7 +258,7 @@ export class SonareRealtimeEngineWorkletProcessor {
|
|
|
206
258
|
|
|
207
259
|
receiveCommand(command: SonareEngineCommandRecord): void {
|
|
208
260
|
if (!this.closed) {
|
|
209
|
-
this.
|
|
261
|
+
this.safeApplyCommand(command);
|
|
210
262
|
}
|
|
211
263
|
}
|
|
212
264
|
|
|
@@ -218,35 +270,68 @@ export class SonareRealtimeEngineWorkletProcessor {
|
|
|
218
270
|
if (this.closed) {
|
|
219
271
|
return;
|
|
220
272
|
}
|
|
273
|
+
try {
|
|
274
|
+
this.applySync(message);
|
|
275
|
+
} catch (error) {
|
|
276
|
+
this.transport?.postMessage?.({
|
|
277
|
+
type: 'syncError',
|
|
278
|
+
syncType: message.type,
|
|
279
|
+
message: error instanceof Error ? error.message : String(error),
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
private applySync(message: SonareEngineSyncMessage): void {
|
|
221
285
|
switch (message.type) {
|
|
222
|
-
case '
|
|
286
|
+
case 'destroy':
|
|
287
|
+
this.destroy();
|
|
288
|
+
return;
|
|
289
|
+
case 'syncClips': {
|
|
290
|
+
// Commit native state before touching the worklet-side mirrors. A
|
|
291
|
+
// rejected embind call must leave a later delta based on the previous
|
|
292
|
+
// clip set, not on a half-applied replacement.
|
|
293
|
+
this.engine.setClips(message.clips);
|
|
294
|
+
this.clearPagedClipProviders();
|
|
223
295
|
this.liveClips.clear();
|
|
224
296
|
for (const clip of message.clips) {
|
|
225
297
|
if (clip.id !== undefined) {
|
|
226
298
|
this.liveClips.set(clip.id, clip);
|
|
227
299
|
}
|
|
228
300
|
}
|
|
229
|
-
this.engine.setClips(message.clips);
|
|
230
301
|
break;
|
|
231
|
-
|
|
302
|
+
}
|
|
303
|
+
case 'syncClipsDelta': {
|
|
304
|
+
const nextClips = new Map(this.liveClips);
|
|
232
305
|
for (const clipId of message.removeIds) {
|
|
233
|
-
|
|
306
|
+
nextClips.delete(clipId);
|
|
234
307
|
}
|
|
235
308
|
for (const clip of message.upserts) {
|
|
236
309
|
if (clip.id !== undefined) {
|
|
237
|
-
|
|
310
|
+
nextClips.set(clip.id, clip);
|
|
238
311
|
}
|
|
239
312
|
}
|
|
240
|
-
this.engine.setClips(Array.from(
|
|
313
|
+
this.engine.setClips(Array.from(nextClips.values()));
|
|
314
|
+
for (const clipId of message.removeIds) {
|
|
315
|
+
this.removePagedClipProvider(clipId);
|
|
316
|
+
}
|
|
317
|
+
this.liveClips.clear();
|
|
318
|
+
for (const [clipId, clip] of nextClips) {
|
|
319
|
+
this.liveClips.set(clipId, clip);
|
|
320
|
+
}
|
|
241
321
|
break;
|
|
322
|
+
}
|
|
242
323
|
case 'syncClipPageProvider': {
|
|
243
324
|
const provider = this.engine.createClipPageProvider(
|
|
244
325
|
message.numChannels,
|
|
245
326
|
message.numSamples,
|
|
246
327
|
message.pageFrames,
|
|
247
328
|
);
|
|
329
|
+
this.removePagedClipProvider(message.clipId);
|
|
248
330
|
this.pagedClipProviders.set(message.clipId, provider.id);
|
|
249
|
-
this.
|
|
331
|
+
this.pagedClipPageFrames.set(message.clipId, message.pageFrames);
|
|
332
|
+
if (message.clip) {
|
|
333
|
+
this.pendingPagedClips.set(message.clipId, message.clip);
|
|
334
|
+
}
|
|
250
335
|
break;
|
|
251
336
|
}
|
|
252
337
|
case 'syncClipPage': {
|
|
@@ -256,16 +341,34 @@ export class SonareRealtimeEngineWorkletProcessor {
|
|
|
256
341
|
}
|
|
257
342
|
break;
|
|
258
343
|
}
|
|
344
|
+
case 'syncClipPageClear': {
|
|
345
|
+
const providerId = this.pagedClipProviders.get(message.clipId);
|
|
346
|
+
if (providerId !== undefined) {
|
|
347
|
+
this.engine.clearClipPage(providerId, message.pageIndex);
|
|
348
|
+
}
|
|
349
|
+
break;
|
|
350
|
+
}
|
|
259
351
|
case 'syncClipPageCommit': {
|
|
260
352
|
const providerId = this.pagedClipProviders.get(message.clipId);
|
|
261
|
-
const clip = this.pendingPagedClips.get(message.clipId);
|
|
353
|
+
const clip = message.clip ?? this.pendingPagedClips.get(message.clipId);
|
|
262
354
|
if (providerId !== undefined && clip) {
|
|
263
|
-
|
|
355
|
+
const nextClip = { ...clip, pageProvider: providerId };
|
|
356
|
+
const nextClips = new Map(this.liveClips);
|
|
357
|
+
nextClips.set(message.clipId, nextClip);
|
|
358
|
+
this.engine.setClips(Array.from(nextClips.values()));
|
|
359
|
+
this.liveClips.set(message.clipId, nextClip);
|
|
264
360
|
this.pendingPagedClips.delete(message.clipId);
|
|
265
|
-
this.engine.setClips(Array.from(this.liveClips.values()));
|
|
266
361
|
}
|
|
267
362
|
break;
|
|
268
363
|
}
|
|
364
|
+
case 'syncClipPageDestroy': {
|
|
365
|
+
const nextClips = new Map(this.liveClips);
|
|
366
|
+
nextClips.delete(message.clipId);
|
|
367
|
+
this.engine.setClips(Array.from(nextClips.values()));
|
|
368
|
+
this.liveClips.delete(message.clipId);
|
|
369
|
+
this.removePagedClipProvider(message.clipId);
|
|
370
|
+
break;
|
|
371
|
+
}
|
|
269
372
|
case 'syncMidiClips':
|
|
270
373
|
this.engine.setMidiClips(message.clips);
|
|
271
374
|
break;
|
|
@@ -273,12 +376,23 @@ export class SonareRealtimeEngineWorkletProcessor {
|
|
|
273
376
|
this.engine.setMarkers(message.markers);
|
|
274
377
|
break;
|
|
275
378
|
case 'syncMetronome':
|
|
276
|
-
|
|
277
|
-
|
|
379
|
+
// Do not publish the cached config until the native engine accepted it.
|
|
380
|
+
// Otherwise a rejected sync would corrupt the command-side fallback.
|
|
381
|
+
{
|
|
382
|
+
const config = resolveMetronomeConfig(message.config);
|
|
383
|
+
this.engine.setMetronome(message.config);
|
|
384
|
+
this.metronomeConfig = config;
|
|
385
|
+
}
|
|
278
386
|
break;
|
|
279
387
|
case 'syncAutomation':
|
|
280
388
|
this.engine.setAutomationLane(message.paramId, message.points);
|
|
281
389
|
break;
|
|
390
|
+
case 'syncParameters':
|
|
391
|
+
this.engine.clearParameters();
|
|
392
|
+
for (const info of message.parameters) {
|
|
393
|
+
this.engine.addParameter(info);
|
|
394
|
+
}
|
|
395
|
+
break;
|
|
282
396
|
case 'syncTempo':
|
|
283
397
|
if (message.tempoSegments) {
|
|
284
398
|
this.engine.setTempoSegments(message.tempoSegments);
|
|
@@ -425,6 +539,9 @@ export class SonareRealtimeEngineWorkletProcessor {
|
|
|
425
539
|
message.renderFrame,
|
|
426
540
|
);
|
|
427
541
|
break;
|
|
542
|
+
case 'syncMidiUmp':
|
|
543
|
+
this.engine.pushMidiUmp(message.destinationId, message.word0, message.renderFrame);
|
|
544
|
+
break;
|
|
428
545
|
case 'syncMidiSysex':
|
|
429
546
|
this.engine.pushMidiSysex(message.destinationId, message.data, message.renderFrame);
|
|
430
547
|
break;
|
|
@@ -437,6 +554,45 @@ export class SonareRealtimeEngineWorkletProcessor {
|
|
|
437
554
|
case 'syncExternalMidiClock':
|
|
438
555
|
this.engine.setExternalMidiClockEnabled(message.enabled);
|
|
439
556
|
break;
|
|
557
|
+
case 'syncMidiInputSource':
|
|
558
|
+
this.engine.setMidiInputSource(message.destinationId ?? 0);
|
|
559
|
+
break;
|
|
560
|
+
case 'syncClearMidiInputSource':
|
|
561
|
+
this.engine.clearMidiInputSource();
|
|
562
|
+
break;
|
|
563
|
+
case 'syncMidiCcBinding':
|
|
564
|
+
this.engine.bindMidiCc(message.channel, message.controller, message.paramId, {
|
|
565
|
+
minValue: message.minValue,
|
|
566
|
+
maxValue: message.maxValue,
|
|
567
|
+
});
|
|
568
|
+
break;
|
|
569
|
+
case 'syncMidiInputNoteOn':
|
|
570
|
+
this.engine.pushMidiInputNoteOn(
|
|
571
|
+
message.group,
|
|
572
|
+
message.channel,
|
|
573
|
+
message.data0,
|
|
574
|
+
message.data1,
|
|
575
|
+
message.portTimeSamples,
|
|
576
|
+
);
|
|
577
|
+
break;
|
|
578
|
+
case 'syncMidiInputNoteOff':
|
|
579
|
+
this.engine.pushMidiInputNoteOff(
|
|
580
|
+
message.group,
|
|
581
|
+
message.channel,
|
|
582
|
+
message.data0,
|
|
583
|
+
message.data1,
|
|
584
|
+
message.portTimeSamples,
|
|
585
|
+
);
|
|
586
|
+
break;
|
|
587
|
+
case 'syncMidiInputCc':
|
|
588
|
+
this.engine.pushMidiInputCc(
|
|
589
|
+
message.group,
|
|
590
|
+
message.channel,
|
|
591
|
+
message.data0,
|
|
592
|
+
message.data1,
|
|
593
|
+
message.portTimeSamples,
|
|
594
|
+
);
|
|
595
|
+
break;
|
|
440
596
|
}
|
|
441
597
|
}
|
|
442
598
|
|
|
@@ -459,26 +615,24 @@ export class SonareRealtimeEngineWorkletProcessor {
|
|
|
459
615
|
source: status.source,
|
|
460
616
|
recordOffsetSamples: status.recordOffsetSamples,
|
|
461
617
|
},
|
|
462
|
-
} satisfies
|
|
618
|
+
} satisfies SonareEngineCaptureResponseMessageInternal);
|
|
463
619
|
return;
|
|
464
620
|
}
|
|
465
621
|
if (message.op === 'read') {
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
channels,
|
|
481
|
-
} satisfies SonareEngineCaptureResponseMessage);
|
|
622
|
+
// Embind's outer val::array is array-like but not structured-cloneable
|
|
623
|
+
// in every host. Copy only that tiny container; keep each native
|
|
624
|
+
// Float32Array intact so the channel payload remains transferable.
|
|
625
|
+
const channels = Array.from(this.engine.capturedAudio());
|
|
626
|
+
const transfer = captureTransferList(channels);
|
|
627
|
+
this.transport?.postMessage?.(
|
|
628
|
+
{
|
|
629
|
+
type: 'captureResponse',
|
|
630
|
+
requestId: message.requestId,
|
|
631
|
+
ok: true,
|
|
632
|
+
channels,
|
|
633
|
+
} satisfies SonareEngineCaptureResponseMessageInternal,
|
|
634
|
+
transfer,
|
|
635
|
+
);
|
|
482
636
|
return;
|
|
483
637
|
}
|
|
484
638
|
this.engine.resetCapture();
|
|
@@ -486,14 +640,14 @@ export class SonareRealtimeEngineWorkletProcessor {
|
|
|
486
640
|
type: 'captureResponse',
|
|
487
641
|
requestId: message.requestId,
|
|
488
642
|
ok: true,
|
|
489
|
-
} satisfies
|
|
643
|
+
} satisfies SonareEngineCaptureResponseMessageInternal);
|
|
490
644
|
} catch (error) {
|
|
491
645
|
this.transport?.postMessage?.({
|
|
492
646
|
type: 'captureResponse',
|
|
493
647
|
requestId: message.requestId,
|
|
494
648
|
ok: false,
|
|
495
649
|
error: error instanceof Error ? error.message : String(error),
|
|
496
|
-
} satisfies
|
|
650
|
+
} satisfies SonareEngineCaptureResponseMessageInternal);
|
|
497
651
|
}
|
|
498
652
|
}
|
|
499
653
|
|
|
@@ -520,6 +674,7 @@ export class SonareRealtimeEngineWorkletProcessor {
|
|
|
520
674
|
|
|
521
675
|
destroy(): void {
|
|
522
676
|
if (!this.closed) {
|
|
677
|
+
this.clearPagedClipProviders();
|
|
523
678
|
this.engine.destroy();
|
|
524
679
|
this.closed = true;
|
|
525
680
|
}
|
|
@@ -534,7 +689,25 @@ export class SonareRealtimeEngineWorkletProcessor {
|
|
|
534
689
|
if (!command) {
|
|
535
690
|
return;
|
|
536
691
|
}
|
|
692
|
+
this.safeApplyCommand(command);
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
private safeApplyCommand(command: SonareEngineCommandRecord): void {
|
|
697
|
+
try {
|
|
537
698
|
this.applyCommand(command);
|
|
699
|
+
} catch {
|
|
700
|
+
// A malformed control command must never escape AudioWorklet.process():
|
|
701
|
+
// Web Audio permanently stops invoking a processor that throws there.
|
|
702
|
+
this.publishTelemetryRecord({
|
|
703
|
+
type: SonareEngineTelemetryType.Error,
|
|
704
|
+
error: SonareEngineTelemetryError.InvalidCommand,
|
|
705
|
+
renderFrame: 0,
|
|
706
|
+
timelineSample: 0,
|
|
707
|
+
audibleTimelineSample: 0,
|
|
708
|
+
graphLatencySamplesQ8: 0,
|
|
709
|
+
value: Number(command.type),
|
|
710
|
+
});
|
|
538
711
|
}
|
|
539
712
|
}
|
|
540
713
|
|
|
@@ -569,9 +742,6 @@ export class SonareRealtimeEngineWorkletProcessor {
|
|
|
569
742
|
case SonareEngineCommandType.TransportSeekPpq:
|
|
570
743
|
this.engine.seekPpq(Number(command.argFloat ?? 0), sampleTime);
|
|
571
744
|
break;
|
|
572
|
-
case SonareEngineCommandType.SetTempoMap:
|
|
573
|
-
this.engine.setTempo(Number(command.argFloat ?? 120));
|
|
574
|
-
break;
|
|
575
745
|
case SonareEngineCommandType.SetLoop:
|
|
576
746
|
this.engine.setLoop(
|
|
577
747
|
Number(command.argFloat ?? 0),
|
|
@@ -601,6 +771,7 @@ export class SonareRealtimeEngineWorkletProcessor {
|
|
|
601
771
|
beatGain: this.metronomeConfig.beatGain,
|
|
602
772
|
accentGain: this.metronomeConfig.accentGain,
|
|
603
773
|
clickSamples: this.metronomeConfig.clickSamples,
|
|
774
|
+
clickSeconds: this.metronomeConfig.clickSeconds,
|
|
604
775
|
});
|
|
605
776
|
break;
|
|
606
777
|
case SonareEngineCommandType.SeekMarker:
|
|
@@ -616,6 +787,24 @@ export class SonareRealtimeEngineWorkletProcessor {
|
|
|
616
787
|
sampleTime,
|
|
617
788
|
);
|
|
618
789
|
break;
|
|
790
|
+
case SonareEngineCommandType.SetTrackMonitorMode: {
|
|
791
|
+
const rawMode = command.argInt;
|
|
792
|
+
const mode = typeof rawMode === 'bigint' ? Number(rawMode) : rawMode;
|
|
793
|
+
if (typeof mode !== 'number' || !Number.isSafeInteger(mode) || mode < 0 || mode > 2) {
|
|
794
|
+
throw new RangeError(`Invalid track monitor mode: ${String(rawMode)}`);
|
|
795
|
+
}
|
|
796
|
+
const laneIndex = command.targetId;
|
|
797
|
+
if (
|
|
798
|
+
typeof laneIndex !== 'number' ||
|
|
799
|
+
!Number.isSafeInteger(laneIndex) ||
|
|
800
|
+
laneIndex < 0 ||
|
|
801
|
+
laneIndex > 0xffff_ffff
|
|
802
|
+
) {
|
|
803
|
+
throw new RangeError(`Invalid track monitor lane index: ${String(laneIndex)}`);
|
|
804
|
+
}
|
|
805
|
+
this.engine.setTrackMonitorMode(laneIndex, mode as 0 | 1 | 2, sampleTime);
|
|
806
|
+
break;
|
|
807
|
+
}
|
|
619
808
|
default:
|
|
620
809
|
this.publishTelemetryRecord({
|
|
621
810
|
type: SonareEngineTelemetryType.Error,
|
|
@@ -631,11 +820,36 @@ export class SonareRealtimeEngineWorkletProcessor {
|
|
|
631
820
|
}
|
|
632
821
|
|
|
633
822
|
private publishTelemetry(): void {
|
|
823
|
+
const ring = this.telemetryRing;
|
|
824
|
+
if (ring) {
|
|
825
|
+
for (let count = 0; count < 64 && this.engine.popTelemetryToScratch(); count++) {
|
|
826
|
+
this.writeTelemetryScratch(ring);
|
|
827
|
+
}
|
|
828
|
+
return;
|
|
829
|
+
}
|
|
634
830
|
for (const item of this.engine.drainTelemetry(64)) {
|
|
635
831
|
this.publishTelemetryRecord(telemetryFromEngine(item));
|
|
636
832
|
}
|
|
637
833
|
}
|
|
638
834
|
|
|
835
|
+
private writeTelemetryScratch(ring: SonareEngineTelemetryRingBuffer): void {
|
|
836
|
+
const writeIndex = Atomics.load(ring.header, 0);
|
|
837
|
+
const offset = (writeIndex % ring.capacity) * SONARE_ENGINE_TELEMETRY_RECORD_BYTES;
|
|
838
|
+
ring.view.setUint32(offset, this.engine.telemetryScratchType(), true);
|
|
839
|
+
ring.view.setUint32(offset + 4, this.engine.telemetryScratchError(), true);
|
|
840
|
+
writeInt64Words(ring.view, offset + 8, this.engine.telemetryScratchRenderFrame());
|
|
841
|
+
writeInt64Words(ring.view, offset + 16, this.engine.telemetryScratchTimelineSample());
|
|
842
|
+
writeInt64Words(ring.view, offset + 24, this.engine.telemetryScratchAudibleTimelineSample());
|
|
843
|
+
ring.view.setInt32(offset + 32, this.engine.telemetryScratchGraphLatencySamplesQ8(), true);
|
|
844
|
+
ring.view.setUint32(offset + 36, this.engine.telemetryScratchValue(), true);
|
|
845
|
+
ring.view.setUint32(offset + 40, 0, true);
|
|
846
|
+
ring.view.setUint32(offset + 44, 0, true);
|
|
847
|
+
Atomics.store(ring.header, 0, writeIndex + 1);
|
|
848
|
+
if (writeIndex + 1 > ring.capacity) {
|
|
849
|
+
Atomics.store(ring.header, 4, writeIndex + 1 - ring.capacity);
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
|
|
639
853
|
private publishTelemetryRecord(record: SonareEngineTelemetryRecord): void {
|
|
640
854
|
if (this.telemetryRing) {
|
|
641
855
|
writeSonareEngineTelemetryRingBuffer(this.telemetryRing, record);
|
|
@@ -659,6 +873,22 @@ export class SonareRealtimeEngineWorkletProcessor {
|
|
|
659
873
|
if (this.meterIntervalFrames <= 0 || (!this.transport && !this.meterRing)) {
|
|
660
874
|
return;
|
|
661
875
|
}
|
|
876
|
+
if (this.meterRing) {
|
|
877
|
+
for (let count = 0; count < 64 && this.engine.popMeterTelemetryToScratch(); count++) {
|
|
878
|
+
const frame = this.engine.meterScratchRenderFrame();
|
|
879
|
+
if (
|
|
880
|
+
frame !== this.lastMeterFrame &&
|
|
881
|
+
frame - this.lastMeterFrame < this.meterIntervalFrames
|
|
882
|
+
) {
|
|
883
|
+
continue;
|
|
884
|
+
}
|
|
885
|
+
if (frame !== this.lastMeterFrame) {
|
|
886
|
+
this.lastMeterFrame = frame;
|
|
887
|
+
}
|
|
888
|
+
this.writeMeterScratch(this.meterRing);
|
|
889
|
+
}
|
|
890
|
+
return;
|
|
891
|
+
}
|
|
662
892
|
for (const item of this.engine.drainMeterTelemetry(64)) {
|
|
663
893
|
const meter = meterFromEngine(item);
|
|
664
894
|
if (
|
|
@@ -683,6 +913,19 @@ export class SonareRealtimeEngineWorkletProcessor {
|
|
|
683
913
|
}
|
|
684
914
|
}
|
|
685
915
|
|
|
916
|
+
private writeMeterScratch(ring: SharedMeterRingWriter): void {
|
|
917
|
+
const writeIndex = Atomics.load(ring.header, 0);
|
|
918
|
+
const offset = (writeIndex % ring.capacity) * SONARE_METER_RING_RECORD_FLOATS;
|
|
919
|
+
const frame = Number(this.engine.meterScratchRenderFrame());
|
|
920
|
+
ring.records[offset] = encodeFrameLo(frame);
|
|
921
|
+
ring.records[offset + 1] = encodeFrameHi(frame);
|
|
922
|
+
ring.records[offset + 2] = this.engine.meterScratchTargetId();
|
|
923
|
+
for (let field = 0; field < 11; field++) {
|
|
924
|
+
ring.records[offset + 3 + field] = this.engine.meterScratchValue(field);
|
|
925
|
+
}
|
|
926
|
+
Atomics.store(ring.header, 0, writeIndex + 1);
|
|
927
|
+
}
|
|
928
|
+
|
|
686
929
|
private writeMeterRing(meter: SonareWorkletMeterSnapshot): void {
|
|
687
930
|
const ring = this.meterRing;
|
|
688
931
|
if (!ring) {
|
|
@@ -720,19 +963,59 @@ export class SonareRealtimeEngineWorkletProcessor {
|
|
|
720
963
|
if (!ring) {
|
|
721
964
|
return;
|
|
722
965
|
}
|
|
723
|
-
for (
|
|
724
|
-
this.
|
|
966
|
+
for (let count = 0; count < 64 && this.engine.popScopeTelemetryToScratch(); count++) {
|
|
967
|
+
this.writeScopeScratch(ring);
|
|
725
968
|
}
|
|
726
969
|
}
|
|
727
970
|
|
|
971
|
+
private writeScopeScratch(ring: SharedScopeRingWriter): void {
|
|
972
|
+
const writeIndex = Atomics.load(ring.header, 0);
|
|
973
|
+
const base = (writeIndex % ring.capacity) * ring.recordFloats;
|
|
974
|
+
const frame = Number(this.engine.scopeScratchRenderFrame());
|
|
975
|
+
ring.records[base] = encodeFrameLo(frame);
|
|
976
|
+
ring.records[base + 1] = encodeFrameHi(frame);
|
|
977
|
+
ring.records[base + 2] = this.engine.scopeScratchTargetId();
|
|
978
|
+
const bandCount = Math.min(ring.bands, this.engine.scopeScratchBandCount());
|
|
979
|
+
ring.records[base + 3] = bandCount;
|
|
980
|
+
const pointCount = Math.min(ring.maxPoints, this.engine.scopeScratchPointCount());
|
|
981
|
+
ring.records[base + 4] = pointCount;
|
|
982
|
+
const bandsBase = base + SONARE_SCOPE_RING_RECORD_PREFIX_FLOATS;
|
|
983
|
+
for (let i = 0; i < bandCount; i++) {
|
|
984
|
+
ring.records[bandsBase + i] = this.engine.scopeScratchBand(i);
|
|
985
|
+
}
|
|
986
|
+
const pointsBase = bandsBase + ring.bands;
|
|
987
|
+
for (let i = 0; i < pointCount; i++) {
|
|
988
|
+
ring.records[pointsBase + 2 * i] = this.engine.scopeScratchPointLeft(i);
|
|
989
|
+
ring.records[pointsBase + 2 * i + 1] = this.engine.scopeScratchPointRight(i);
|
|
990
|
+
}
|
|
991
|
+
Atomics.store(ring.header, 0, writeIndex + 1);
|
|
992
|
+
}
|
|
993
|
+
|
|
728
994
|
// Drains queued external-MIDI events (already lowered to MIDI 1.0 bytes) and
|
|
729
995
|
// forwards them to the main thread for delivery to Web MIDI output ports.
|
|
730
996
|
// One batch per render block; skipped entirely when nothing is queued, so an
|
|
731
997
|
// all-internal project never allocates or posts here.
|
|
732
998
|
private publishExternalMidi(): void {
|
|
999
|
+
const ring = this.externalMidiRing;
|
|
1000
|
+
if (ring) {
|
|
1001
|
+
for (let count = 0; count < 256 && this.engine.popExternalMidiToScratch(); count++) {
|
|
1002
|
+
pushSonareExternalMidiRingBuffer(
|
|
1003
|
+
ring,
|
|
1004
|
+
this.engine.externalMidiScratchDestinationId(),
|
|
1005
|
+
this.engine.externalMidiScratchRenderFrame(),
|
|
1006
|
+
this.engine.externalMidiScratchByteWord(),
|
|
1007
|
+
this.engine.externalMidiScratchByteCount(),
|
|
1008
|
+
);
|
|
1009
|
+
this.engine.consumeExternalMidiScratch();
|
|
1010
|
+
}
|
|
1011
|
+
return;
|
|
1012
|
+
}
|
|
733
1013
|
if (!this.transport?.postMessage) {
|
|
734
1014
|
return;
|
|
735
1015
|
}
|
|
1016
|
+
if (this.engine.externalMidiPendingCount() === 0) {
|
|
1017
|
+
return;
|
|
1018
|
+
}
|
|
736
1019
|
const events = this.engine.drainExternalMidi(256);
|
|
737
1020
|
if (events.length === 0) {
|
|
738
1021
|
return;
|
|
@@ -740,30 +1023,120 @@ export class SonareRealtimeEngineWorkletProcessor {
|
|
|
740
1023
|
this.transport.postMessage({ type: 'externalMidi', events });
|
|
741
1024
|
}
|
|
742
1025
|
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
const
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
const bandsBase = base + SONARE_SCOPE_RING_RECORD_PREFIX_FLOATS;
|
|
754
|
-
for (let i = 0; i < bandCount; i++) {
|
|
755
|
-
ring.records[bandsBase + i] = record.bands[i];
|
|
1026
|
+
/**
|
|
1027
|
+
* Forward a bounded batch of native page misses to the main thread. This
|
|
1028
|
+
* deliberately runs after audio rendering: a cache miss is silence for this
|
|
1029
|
+
* block, and OPFS I/O must never delay `process()`.
|
|
1030
|
+
*/
|
|
1031
|
+
private publishClipPageRequests(): void {
|
|
1032
|
+
const ring = this.clipPageRequestRing;
|
|
1033
|
+
const transport = this.transport;
|
|
1034
|
+
if (!ring && !transport?.postMessage) {
|
|
1035
|
+
return;
|
|
756
1036
|
}
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
1037
|
+
let count = 0;
|
|
1038
|
+
let drained = 0;
|
|
1039
|
+
while (drained < this.clipPageRequestClipIds.length) {
|
|
1040
|
+
drained += 1;
|
|
1041
|
+
let clipId: number;
|
|
1042
|
+
let sample: number;
|
|
1043
|
+
if (ring) {
|
|
1044
|
+
// This scalar scratch API is intentionally used only on the SAB path:
|
|
1045
|
+
// it avoids embind materialising one JS object for every page miss in
|
|
1046
|
+
// AudioWorklet process().
|
|
1047
|
+
if (!this.engine.popClipPageRequestToScratch()) {
|
|
1048
|
+
break;
|
|
1049
|
+
}
|
|
1050
|
+
clipId = this.engine.clipPageRequestScratchClipId();
|
|
1051
|
+
sample = this.engine.clipPageRequestScratchSample();
|
|
1052
|
+
} else {
|
|
1053
|
+
// postMessage fallback is explicitly non-RT-safe; retain the public
|
|
1054
|
+
// object-returning API only for that degraded control-plane path.
|
|
1055
|
+
const request = this.engine.popClipPageRequest();
|
|
1056
|
+
if (!request) {
|
|
1057
|
+
break;
|
|
1058
|
+
}
|
|
1059
|
+
clipId = request.clipId;
|
|
1060
|
+
sample = request.sample;
|
|
1061
|
+
}
|
|
1062
|
+
const pageFrames = this.pagedClipPageFrames.get(clipId);
|
|
1063
|
+
if (!pageFrames) {
|
|
1064
|
+
continue;
|
|
1065
|
+
}
|
|
1066
|
+
const pageIndex = Math.floor(sample / pageFrames);
|
|
1067
|
+
let duplicate = false;
|
|
1068
|
+
for (let i = 0; i < count; ++i) {
|
|
1069
|
+
if (
|
|
1070
|
+
this.clipPageRequestClipIds[i] === clipId &&
|
|
1071
|
+
this.clipPageRequestPageIndices[i] === pageIndex
|
|
1072
|
+
) {
|
|
1073
|
+
duplicate = true;
|
|
1074
|
+
break;
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
if (!duplicate) {
|
|
1078
|
+
this.clipPageRequestClipIds[count] = clipId;
|
|
1079
|
+
this.clipPageRequestPageIndices[count] = pageIndex;
|
|
1080
|
+
count += 1;
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
const overflowCount = this.engine.clipPageRequestOverflowCount();
|
|
1084
|
+
// The native counter is uint32_t; preserve a correct delta across its
|
|
1085
|
+
// defined unsigned wraparound instead of emitting a negative count.
|
|
1086
|
+
const dropped = (overflowCount - this.clipPageRequestOverflowReported) >>> 0;
|
|
1087
|
+
this.clipPageRequestOverflowReported = overflowCount;
|
|
1088
|
+
if (ring) {
|
|
1089
|
+
// Preserve native-queue loss in the same cumulative counter used for a
|
|
1090
|
+
// full SAB request ring. No object, structured clone, or postMessage is
|
|
1091
|
+
// created on this path.
|
|
1092
|
+
if (dropped > 0) {
|
|
1093
|
+
Atomics.add(ring.header, 4, dropped);
|
|
1094
|
+
}
|
|
1095
|
+
for (let i = 0; i < count; ++i) {
|
|
1096
|
+
pushSonareClipPageRequestRingBuffer(
|
|
1097
|
+
ring,
|
|
1098
|
+
this.clipPageRequestClipIds[i],
|
|
1099
|
+
this.clipPageRequestPageIndices[i],
|
|
1100
|
+
);
|
|
1101
|
+
}
|
|
1102
|
+
return;
|
|
1103
|
+
}
|
|
1104
|
+
if (count === 0 && dropped === 0) {
|
|
1105
|
+
return;
|
|
1106
|
+
}
|
|
1107
|
+
if (!transport?.postMessage) {
|
|
1108
|
+
return;
|
|
1109
|
+
}
|
|
1110
|
+
// A message is necessarily allocated by postMessage; the hot path's
|
|
1111
|
+
// resident queue remains fixed-size and no message is built without a miss.
|
|
1112
|
+
const requests = new Array<{ clipId: number; pageIndex: number }>(count);
|
|
1113
|
+
for (let i = 0; i < count; ++i) {
|
|
1114
|
+
requests[i] = {
|
|
1115
|
+
clipId: this.clipPageRequestClipIds[i],
|
|
1116
|
+
pageIndex: this.clipPageRequestPageIndices[i],
|
|
1117
|
+
};
|
|
1118
|
+
}
|
|
1119
|
+
transport.postMessage({
|
|
1120
|
+
type: 'clipPageRequest',
|
|
1121
|
+
requests,
|
|
1122
|
+
...(dropped > 0 ? { dropped } : {}),
|
|
1123
|
+
});
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
private removePagedClipProvider(clipId: number): void {
|
|
1127
|
+
const providerId = this.pagedClipProviders.get(clipId);
|
|
1128
|
+
if (providerId !== undefined) {
|
|
1129
|
+
this.engine.destroyClipPageProvider(providerId);
|
|
1130
|
+
}
|
|
1131
|
+
this.pagedClipProviders.delete(clipId);
|
|
1132
|
+
this.pagedClipPageFrames.delete(clipId);
|
|
1133
|
+
this.pendingPagedClips.delete(clipId);
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
private clearPagedClipProviders(): void {
|
|
1137
|
+
for (const clipId of this.pagedClipProviders.keys()) {
|
|
1138
|
+
this.removePagedClipProvider(clipId);
|
|
762
1139
|
}
|
|
763
|
-
Atomics.store(ring.header, 0, writeIndex + 1);
|
|
764
|
-
// Like writeMeterRing, writeIndex is a free-running monotonic counter; the
|
|
765
|
-
// reader detects silent overrun via firstReadable, so the overflow slot
|
|
766
|
-
// (header[5]) stays at its initial 0.
|
|
767
1140
|
}
|
|
768
1141
|
|
|
769
1142
|
private commandRingFromSharedBuffer(
|