@libraz/libsonare 1.2.2 → 1.3.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 +38 -1
- package/dist/index.d.ts +1 -2722
- package/dist/index.js +3659 -1896
- package/dist/index.js.map +1 -1
- package/dist/sonare-rt-module.js +1 -1
- package/dist/sonare-rt.js +1 -1
- package/dist/sonare-rt.wasm +0 -0
- package/dist/sonare.js +1 -1
- package/dist/sonare.wasm +0 -0
- package/dist/worklet.d.ts +4827 -455
- package/dist/worklet.js +1076 -494
- package/dist/worklet.js.map +1 -1
- package/package.json +2 -1
- package/src/analysis_helpers.ts +152 -0
- package/src/audio.ts +493 -0
- package/src/codes.ts +56 -0
- package/src/effects_mastering.ts +964 -0
- package/src/feature_core.ts +248 -0
- package/src/feature_music.ts +419 -0
- package/src/feature_pitch.ts +80 -0
- package/src/feature_resample.ts +21 -0
- package/src/feature_spectral.ts +330 -0
- package/src/feature_spectrogram.ts +454 -0
- package/src/features.ts +84 -0
- package/src/index.ts +352 -4793
- package/src/live_audio.ts +45 -0
- package/src/metering.ts +380 -0
- package/src/mixer.ts +523 -0
- package/src/module_state.ts +14 -0
- package/src/opfs_clip_pages.ts +188 -0
- package/src/project.ts +1614 -0
- package/src/public_types.ts +244 -2
- package/src/quick_analysis.ts +508 -0
- package/src/realtime_engine.ts +667 -0
- package/src/realtime_voice_changer.ts +275 -0
- package/src/scale.ts +42 -0
- package/src/sonare.js.d.ts +386 -4
- package/src/stream_analyzer.ts +275 -0
- package/src/stream_types.ts +29 -1
- package/src/streaming_mixing.ts +18 -0
- package/src/streaming_processors.ts +335 -0
- package/src/validation.ts +82 -0
- package/src/web_midi.ts +367 -0
- package/src/worklet.ts +525 -81
package/src/worklet.ts
CHANGED
|
@@ -46,6 +46,8 @@ export interface SonareRealtimeEngineWorkletProcessorOptions {
|
|
|
46
46
|
commandRingCapacity?: number;
|
|
47
47
|
telemetrySharedBuffer?: SharedArrayBuffer;
|
|
48
48
|
telemetryRingCapacity?: number;
|
|
49
|
+
meterSharedBuffer?: SharedArrayBuffer;
|
|
50
|
+
meterRingCapacity?: number;
|
|
49
51
|
}
|
|
50
52
|
|
|
51
53
|
export interface SonareRealtimeVoiceChangerWorkletProcessorOptions {
|
|
@@ -182,8 +184,33 @@ export type SonareWorkletTransportMessage =
|
|
|
182
184
|
| SonareEngineTelemetryRecord;
|
|
183
185
|
|
|
184
186
|
export const SONARE_METER_RING_HEADER_INTS = 4;
|
|
185
|
-
|
|
187
|
+
// Record layout: [frameLo, peakDbL, peakDbR, rmsDbL, rmsDbR, correlation, frameHi].
|
|
188
|
+
// The sample-frame index is monotonically increasing and quickly exceeds the
|
|
189
|
+
// 2^24 exact-integer range of a single Float32 slot (~349 s at 48 kHz), so it is
|
|
190
|
+
// stored split across two Float32 lanes (low 24 bits + high bits) for exact
|
|
191
|
+
// reconstruction. See encodeFrameLo/encodeFrameHi/decodeFrame.
|
|
192
|
+
export const SONARE_METER_RING_RECORD_FLOATS = 7;
|
|
186
193
|
export const SONARE_SPECTRUM_RING_HEADER_INTS = 5;
|
|
194
|
+
|
|
195
|
+
/** Base for splitting a frame index into two exactly-representable Float32 lanes. */
|
|
196
|
+
const SONARE_FRAME_LANE_BASE = 0x1000000; // 2^24
|
|
197
|
+
|
|
198
|
+
/** Low 24 bits of a frame index (exact in Float32). */
|
|
199
|
+
export function encodeFrameLo(frame: number): number {
|
|
200
|
+
const f = Math.max(0, Math.floor(frame));
|
|
201
|
+
return f % SONARE_FRAME_LANE_BASE;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** High bits of a frame index above 2^24 (exact in Float32 up to ~2^48). */
|
|
205
|
+
export function encodeFrameHi(frame: number): number {
|
|
206
|
+
const f = Math.max(0, Math.floor(frame));
|
|
207
|
+
return Math.floor(f / SONARE_FRAME_LANE_BASE);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Reconstruct a frame index from its low/high Float32 lanes. */
|
|
211
|
+
export function decodeFrame(lo: number, hi: number): number {
|
|
212
|
+
return hi * SONARE_FRAME_LANE_BASE + lo;
|
|
213
|
+
}
|
|
187
214
|
export const SONARE_ENGINE_RING_HEADER_INTS = 5;
|
|
188
215
|
export const SONARE_ENGINE_COMMAND_RECORD_BYTES = 32;
|
|
189
216
|
export const SONARE_ENGINE_TELEMETRY_RECORD_BYTES = 48;
|
|
@@ -237,6 +264,29 @@ interface WorkletTransport {
|
|
|
237
264
|
onSpectrum?: (spectrum: SonareWorkletSpectrumSnapshot) => void;
|
|
238
265
|
}
|
|
239
266
|
|
|
267
|
+
interface ResolvedMetronomeConfig {
|
|
268
|
+
beatGain: number;
|
|
269
|
+
accentGain: number;
|
|
270
|
+
clickSamples: number;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Fallback metronome gains/click length used by the worklet consumer until the
|
|
274
|
+
// host posts a 'syncMetronome' config. Aligned with the embind setMetronome
|
|
275
|
+
// defaults (src/wasm/bindings.cpp) so offline and realtime metronomes match.
|
|
276
|
+
const DEFAULT_METRONOME_CONFIG: ResolvedMetronomeConfig = {
|
|
277
|
+
beatGain: 0.35,
|
|
278
|
+
accentGain: 0.7,
|
|
279
|
+
clickSamples: 96,
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
function resolveMetronomeConfig(config: EngineMetronomeConfig): ResolvedMetronomeConfig {
|
|
283
|
+
return {
|
|
284
|
+
beatGain: config.beatGain ?? DEFAULT_METRONOME_CONFIG.beatGain,
|
|
285
|
+
accentGain: config.accentGain ?? DEFAULT_METRONOME_CONFIG.accentGain,
|
|
286
|
+
clickSamples: config.clickSamples ?? DEFAULT_METRONOME_CONFIG.clickSamples,
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
|
|
240
290
|
export interface SonareMeterRingBuffer {
|
|
241
291
|
sharedBuffer: SharedArrayBuffer;
|
|
242
292
|
header: Int32Array;
|
|
@@ -270,6 +320,39 @@ export interface SonareEngineCommandRecord {
|
|
|
270
320
|
argInt?: number | bigint;
|
|
271
321
|
}
|
|
272
322
|
|
|
323
|
+
// Out-of-band control messages posted from the main-thread SonareEngine facade
|
|
324
|
+
// to the worklet engine processor over node.port. Unlike SonareEngineCommandRecord
|
|
325
|
+
// (a small POD POSTed/ringed every block) these carry bulk/structured payloads
|
|
326
|
+
// (clip audio buffers, marker lists, metronome config) that cannot fit the
|
|
327
|
+
// fixed-size SAB command record, so they are applied OUTSIDE process() — the
|
|
328
|
+
// audio-thread equivalent of the engine's control-thread RtPublisher setters.
|
|
329
|
+
export interface SonareEngineSyncClipsMessage {
|
|
330
|
+
type: 'syncClips';
|
|
331
|
+
clips: EngineClip[];
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
export interface SonareEngineSyncMarkersMessage {
|
|
335
|
+
type: 'syncMarkers';
|
|
336
|
+
markers: EngineMarker[];
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
export interface SonareEngineSyncMetronomeMessage {
|
|
340
|
+
type: 'syncMetronome';
|
|
341
|
+
config: EngineMetronomeConfig;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
export interface SonareEngineSyncAutomationMessage {
|
|
345
|
+
type: 'syncAutomation';
|
|
346
|
+
paramId: number;
|
|
347
|
+
points: EngineAutomationPoint[];
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
export type SonareEngineSyncMessage =
|
|
351
|
+
| SonareEngineSyncClipsMessage
|
|
352
|
+
| SonareEngineSyncMarkersMessage
|
|
353
|
+
| SonareEngineSyncMetronomeMessage
|
|
354
|
+
| SonareEngineSyncAutomationMessage;
|
|
355
|
+
|
|
273
356
|
export interface SonareEngineTelemetryRecord {
|
|
274
357
|
type: SonareEngineTelemetryType | number;
|
|
275
358
|
error: SonareEngineTelemetryError | number;
|
|
@@ -343,6 +426,18 @@ function isEngineCommandRecord(value: unknown): value is SonareEngineCommandReco
|
|
|
343
426
|
return isRecord(value) && typeof value.type === 'number';
|
|
344
427
|
}
|
|
345
428
|
|
|
429
|
+
function isEngineSyncMessage(value: unknown): value is SonareEngineSyncMessage {
|
|
430
|
+
if (!isRecord(value) || typeof value.type !== 'string') {
|
|
431
|
+
return false;
|
|
432
|
+
}
|
|
433
|
+
return (
|
|
434
|
+
value.type === 'syncClips' ||
|
|
435
|
+
value.type === 'syncMarkers' ||
|
|
436
|
+
value.type === 'syncMetronome' ||
|
|
437
|
+
value.type === 'syncAutomation'
|
|
438
|
+
);
|
|
439
|
+
}
|
|
440
|
+
|
|
346
441
|
function isRealtimeVoiceChangerMessage(value: unknown): value is SonareRealtimeVoiceChangerMessage {
|
|
347
442
|
if (!isRecord(value) || typeof value.type !== 'string') {
|
|
348
443
|
return false;
|
|
@@ -407,7 +502,7 @@ export function readSonareMeterRingBuffer(
|
|
|
407
502
|
const offset = (index % ring.capacity) * SONARE_METER_RING_RECORD_FLOATS;
|
|
408
503
|
meters.push({
|
|
409
504
|
type: 'meter',
|
|
410
|
-
frame: ring.records[offset],
|
|
505
|
+
frame: decodeFrame(ring.records[offset], ring.records[offset + 6]),
|
|
411
506
|
peakDbL: ring.records[offset + 1],
|
|
412
507
|
peakDbR: ring.records[offset + 2],
|
|
413
508
|
rmsDbL: ring.records[offset + 3],
|
|
@@ -421,9 +516,11 @@ export function readSonareMeterRingBuffer(
|
|
|
421
516
|
export function sonareSpectrumRingBufferByteLength(capacity: number, bands = 16): number {
|
|
422
517
|
const clampedCapacity = Math.max(1, Math.floor(capacity));
|
|
423
518
|
const clampedBands = Math.max(1, Math.floor(bands));
|
|
519
|
+
// Record layout: [frameLo, frameHi, bandCount, band0, band1, ...]. frame is
|
|
520
|
+
// split across two Float32 lanes for exact reconstruction beyond 2^24.
|
|
424
521
|
return (
|
|
425
522
|
SONARE_SPECTRUM_RING_HEADER_INTS * Int32Array.BYTES_PER_ELEMENT +
|
|
426
|
-
clampedCapacity * (
|
|
523
|
+
clampedCapacity * (3 + clampedBands) * Float32Array.BYTES_PER_ELEMENT
|
|
427
524
|
);
|
|
428
525
|
}
|
|
429
526
|
|
|
@@ -456,7 +553,7 @@ export function readSonareSpectrumRingBuffer(
|
|
|
456
553
|
readIndex = 0,
|
|
457
554
|
): SonareSpectrumRingReadResult {
|
|
458
555
|
const writeIndex = Atomics.load(ring.header, 0);
|
|
459
|
-
const recordFloats = Atomics.load(ring.header, 2) ||
|
|
556
|
+
const recordFloats = Atomics.load(ring.header, 2) || 3 + ring.bands;
|
|
460
557
|
const bands = Atomics.load(ring.header, 3) || ring.bands;
|
|
461
558
|
const nextReadIndex = Math.max(0, Math.min(readIndex, writeIndex));
|
|
462
559
|
const firstReadable = Math.max(nextReadIndex, writeIndex - ring.capacity);
|
|
@@ -464,8 +561,12 @@ export function readSonareSpectrumRingBuffer(
|
|
|
464
561
|
for (let index = firstReadable; index < writeIndex; index++) {
|
|
465
562
|
const offset = (index % ring.capacity) * recordFloats;
|
|
466
563
|
const values = new Float32Array(bands);
|
|
467
|
-
values.set(ring.records.subarray(offset +
|
|
468
|
-
spectra.push({
|
|
564
|
+
values.set(ring.records.subarray(offset + 3, offset + 3 + bands));
|
|
565
|
+
spectra.push({
|
|
566
|
+
type: 'spectrum',
|
|
567
|
+
frame: decodeFrame(ring.records[offset], ring.records[offset + 1]),
|
|
568
|
+
bands: values,
|
|
569
|
+
});
|
|
469
570
|
}
|
|
470
571
|
return { nextReadIndex: writeIndex, spectra };
|
|
471
572
|
}
|
|
@@ -620,7 +721,7 @@ function spectrumRingFromSharedBuffer(
|
|
|
620
721
|
const existingBands = Atomics.load(header, 3);
|
|
621
722
|
const capacity = Math.max(1, Math.floor(existingCapacity || fallbackCapacity || 1));
|
|
622
723
|
const bands = Math.max(1, Math.floor(existingBands || fallbackBands || 16));
|
|
623
|
-
const recordFloats =
|
|
724
|
+
const recordFloats = 3 + bands;
|
|
624
725
|
const minBytes = sonareSpectrumRingBufferByteLength(capacity, bands);
|
|
625
726
|
if (sharedBuffer.byteLength < minBytes) {
|
|
626
727
|
throw new Error('spectrumSharedBuffer is too small for the requested ring capacity.');
|
|
@@ -681,8 +782,10 @@ function writeEngineCommandRecord(
|
|
|
681
782
|
view.setUint32(offset, command.type, true);
|
|
682
783
|
view.setUint32(offset + 4, command.targetId ?? 0, true);
|
|
683
784
|
view.setBigInt64(offset + 8, toBigInt64(command.sampleTime, -1n), true);
|
|
684
|
-
|
|
685
|
-
|
|
785
|
+
// argFloat occupies a full 8-byte Float64 slot (replacing the old Float32 +
|
|
786
|
+
// 4-byte pad) so PPQ scalars carried here keep full double precision over the
|
|
787
|
+
// SAB transport, matching the engine's double-precision seek/loop contract.
|
|
788
|
+
view.setFloat64(offset + 16, command.argFloat ?? 0, true);
|
|
686
789
|
view.setBigInt64(offset + 24, toBigInt64(command.argInt, 0n), true);
|
|
687
790
|
}
|
|
688
791
|
|
|
@@ -691,7 +794,7 @@ function readEngineCommandRecord(view: DataView, offset: number): SonareEngineCo
|
|
|
691
794
|
type: view.getUint32(offset, true),
|
|
692
795
|
targetId: view.getUint32(offset + 4, true),
|
|
693
796
|
sampleTime: Number(view.getBigInt64(offset + 8, true)),
|
|
694
|
-
argFloat: view.
|
|
797
|
+
argFloat: view.getFloat64(offset + 16, true),
|
|
695
798
|
argInt: Number(view.getBigInt64(offset + 24, true)),
|
|
696
799
|
};
|
|
697
800
|
}
|
|
@@ -818,9 +921,14 @@ export class SonareWorkletProcessor {
|
|
|
818
921
|
return true;
|
|
819
922
|
}
|
|
820
923
|
const frames = leftOut.length;
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
924
|
+
// The mixer's realtime heap buffers are sized to blockSize. A render quantum
|
|
925
|
+
// that differs from blockSize (e.g. a future browser using a quantum other
|
|
926
|
+
// than 128, or a misconfigured blockSize) must NOT return false here:
|
|
927
|
+
// returning false permanently terminates the AudioWorkletProcessor and
|
|
928
|
+
// silently kills the node mid-stream. Instead degrade gracefully by
|
|
929
|
+
// processing min(frames, blockSize) and zero-filling any remainder, mirroring
|
|
930
|
+
// the sonare-rt processor's behaviour.
|
|
931
|
+
const usable = Math.min(frames, this.blockSize);
|
|
824
932
|
|
|
825
933
|
for (let strip = 0; strip < this.realtime.leftInputs.length; strip++) {
|
|
826
934
|
const input = inputs[strip];
|
|
@@ -828,12 +936,12 @@ export class SonareWorkletProcessor {
|
|
|
828
936
|
const right = input?.[1];
|
|
829
937
|
const leftTarget = this.realtime.leftInputs[strip];
|
|
830
938
|
const rightTarget = this.realtime.rightInputs[strip];
|
|
831
|
-
if (left && left.length
|
|
832
|
-
leftTarget.set(left);
|
|
833
|
-
if (right && right.length
|
|
834
|
-
rightTarget.set(right);
|
|
939
|
+
if (left && left.length >= usable) {
|
|
940
|
+
leftTarget.set(left.subarray(0, usable));
|
|
941
|
+
if (right && right.length >= usable) {
|
|
942
|
+
rightTarget.set(right.subarray(0, usable));
|
|
835
943
|
} else {
|
|
836
|
-
rightTarget.set(left);
|
|
944
|
+
rightTarget.set(left.subarray(0, usable));
|
|
837
945
|
}
|
|
838
946
|
} else {
|
|
839
947
|
leftTarget.fill(0);
|
|
@@ -841,14 +949,30 @@ export class SonareWorkletProcessor {
|
|
|
841
949
|
}
|
|
842
950
|
}
|
|
843
951
|
|
|
844
|
-
this.realtime.process(
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
rightOut
|
|
952
|
+
this.realtime.process(usable);
|
|
953
|
+
if (usable === frames) {
|
|
954
|
+
leftOut.set(this.realtime.outLeft.subarray(0, usable));
|
|
955
|
+
if (rightOut) {
|
|
956
|
+
rightOut.set(this.realtime.outRight.subarray(0, usable));
|
|
957
|
+
}
|
|
958
|
+
} else {
|
|
959
|
+
// frames > blockSize: fill the produced part and zero the remaining tail.
|
|
960
|
+
leftOut.fill(0);
|
|
961
|
+
leftOut.set(this.realtime.outLeft.subarray(0, usable));
|
|
962
|
+
if (rightOut) {
|
|
963
|
+
rightOut.fill(0);
|
|
964
|
+
rightOut.set(this.realtime.outRight.subarray(0, usable));
|
|
965
|
+
}
|
|
848
966
|
}
|
|
849
|
-
this.processedFrames +=
|
|
850
|
-
this.publishMeter(
|
|
851
|
-
|
|
967
|
+
this.processedFrames += usable;
|
|
968
|
+
this.publishMeter(
|
|
969
|
+
this.realtime.outLeft.subarray(0, usable),
|
|
970
|
+
this.realtime.outRight.subarray(0, usable),
|
|
971
|
+
);
|
|
972
|
+
this.publishSpectrum(
|
|
973
|
+
this.realtime.outLeft.subarray(0, usable),
|
|
974
|
+
this.realtime.outRight.subarray(0, usable),
|
|
975
|
+
);
|
|
852
976
|
return true;
|
|
853
977
|
}
|
|
854
978
|
|
|
@@ -939,16 +1063,19 @@ export class SonareWorkletProcessor {
|
|
|
939
1063
|
}
|
|
940
1064
|
const writeIndex = Atomics.load(ring.header, 0);
|
|
941
1065
|
const offset = (writeIndex % ring.capacity) * SONARE_METER_RING_RECORD_FLOATS;
|
|
942
|
-
ring.records[offset] = meter.frame;
|
|
1066
|
+
ring.records[offset] = encodeFrameLo(meter.frame);
|
|
943
1067
|
ring.records[offset + 1] = meter.peakDbL;
|
|
944
1068
|
ring.records[offset + 2] = meter.peakDbR;
|
|
945
1069
|
ring.records[offset + 3] = meter.rmsDbL;
|
|
946
1070
|
ring.records[offset + 4] = meter.rmsDbR;
|
|
947
1071
|
ring.records[offset + 5] = meter.correlation;
|
|
1072
|
+
ring.records[offset + 6] = encodeFrameHi(meter.frame);
|
|
948
1073
|
Atomics.store(ring.header, 0, writeIndex + 1);
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
1074
|
+
// writeIndex is a free-running monotonic counter, so an overflow guard here
|
|
1075
|
+
// would fire on essentially every write past the first `capacity` records
|
|
1076
|
+
// and store an ever-growing value, not a dropped-record count. Readers
|
|
1077
|
+
// already detect silent overrun via firstReadable = max(readIndex,
|
|
1078
|
+
// writeIndex - capacity), so header slot 3 is left at its initial 0.
|
|
952
1079
|
}
|
|
953
1080
|
|
|
954
1081
|
private publishSpectrum(left: Float32Array, right: Float32Array): void {
|
|
@@ -974,8 +1101,20 @@ export class SonareWorkletProcessor {
|
|
|
974
1101
|
}
|
|
975
1102
|
|
|
976
1103
|
private computeSpectrum(left: Float32Array, right: Float32Array): void {
|
|
1104
|
+
// Coarse per-render-quantum band energy, NOT a full FFT analyzer: each band
|
|
1105
|
+
// is a single-bin DFT (bin = band + 1) evaluated over the current block of n
|
|
1106
|
+
// samples. Bins at or above the block Nyquist (band + 1 > floor(n / 2))
|
|
1107
|
+
// alias, so the evaluated band count is clamped to floor(n / 2) and any
|
|
1108
|
+
// higher bands are pinned to the silence floor. Bin resolution is therefore
|
|
1109
|
+
// tied to the render quantum (typically 128 samples); treat the output as a
|
|
1110
|
+
// rough spectral tilt, not a precise spectrum.
|
|
977
1111
|
const n = Math.max(1, Math.min(left.length, right.length));
|
|
1112
|
+
const maxBand = Math.floor(n / 2);
|
|
978
1113
|
for (let band = 0; band < this.spectrumBands.length; band++) {
|
|
1114
|
+
if (band >= maxBand) {
|
|
1115
|
+
this.spectrumBands[band] = magnitudeToDb(0);
|
|
1116
|
+
continue;
|
|
1117
|
+
}
|
|
979
1118
|
const bin = band + 1;
|
|
980
1119
|
let real = 0;
|
|
981
1120
|
let imag = 0;
|
|
@@ -996,13 +1135,15 @@ export class SonareWorkletProcessor {
|
|
|
996
1135
|
}
|
|
997
1136
|
const writeIndex = Atomics.load(ring.header, 0);
|
|
998
1137
|
const offset = (writeIndex % ring.capacity) * ring.recordFloats;
|
|
999
|
-
ring.records[offset] = frame;
|
|
1000
|
-
ring.records[offset + 1] =
|
|
1001
|
-
ring.records
|
|
1138
|
+
ring.records[offset] = encodeFrameLo(frame);
|
|
1139
|
+
ring.records[offset + 1] = encodeFrameHi(frame);
|
|
1140
|
+
ring.records[offset + 2] = bands.length;
|
|
1141
|
+
ring.records.set(bands.subarray(0, ring.bands), offset + 3);
|
|
1002
1142
|
Atomics.store(ring.header, 0, writeIndex + 1);
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1143
|
+
// See writeMeterRing: header slot 4 (the spectrum-ring overflow slot) is
|
|
1144
|
+
// left at its initial 0; readers detect silent overrun via the
|
|
1145
|
+
// firstReadable = max(readIndex, writeIndex - capacity) clamp. (Slot 3 here
|
|
1146
|
+
// holds the band count and is still written at ring creation.)
|
|
1006
1147
|
}
|
|
1007
1148
|
}
|
|
1008
1149
|
|
|
@@ -1023,17 +1164,21 @@ export class SonareRealtimeEngineWorkletProcessor {
|
|
|
1023
1164
|
private closed = false;
|
|
1024
1165
|
private commandRing?: SonareEngineCommandRingBuffer;
|
|
1025
1166
|
private telemetryRing?: SonareEngineTelemetryRingBuffer;
|
|
1167
|
+
private meterRing?: SharedMeterRingWriter;
|
|
1026
1168
|
private transport?: WorkletTransport;
|
|
1027
1169
|
private meterIntervalFrames: number;
|
|
1028
1170
|
private lastMeterFrame = Number.NEGATIVE_INFINITY;
|
|
1029
|
-
//
|
|
1030
|
-
//
|
|
1031
|
-
|
|
1032
|
-
//
|
|
1033
|
-
|
|
1034
|
-
//
|
|
1035
|
-
//
|
|
1036
|
-
|
|
1171
|
+
// Latest metronome gains/click length pushed via 'syncMetronome'. The
|
|
1172
|
+
// SetMetronome command only toggles enabled state; the config arrives here.
|
|
1173
|
+
private metronomeConfig: ResolvedMetronomeConfig = { ...DEFAULT_METRONOME_CONFIG };
|
|
1174
|
+
// Zero-copy prepared realtime path: persistent per-channel views onto the
|
|
1175
|
+
// engine's WASM-heap scratch (acquired once on the main thread via
|
|
1176
|
+
// getChannelBuffer). process() writes the AudioWorklet input straight into
|
|
1177
|
+
// these views, calls engine.processPrepared(frames) which runs the engine IN
|
|
1178
|
+
// PLACE, then reads the same views back — no std::vector or JS Float32Array is
|
|
1179
|
+
// allocated per render quantum (the old engine.process() round-tripped fresh
|
|
1180
|
+
// arrays on both heaps every block, an RT-safety hazard).
|
|
1181
|
+
private channelBuffers: Float32Array[];
|
|
1037
1182
|
|
|
1038
1183
|
constructor(
|
|
1039
1184
|
options: SonareRealtimeEngineWorkletProcessorOptions = {},
|
|
@@ -1059,13 +1204,16 @@ export class SonareRealtimeEngineWorkletProcessor {
|
|
|
1059
1204
|
options.telemetryRingCapacity,
|
|
1060
1205
|
)
|
|
1061
1206
|
: undefined;
|
|
1207
|
+
this.meterRing = options.meterSharedBuffer
|
|
1208
|
+
? meterRingFromSharedBuffer(options.meterSharedBuffer, options.meterRingCapacity)
|
|
1209
|
+
: undefined;
|
|
1062
1210
|
this.engine = new RealtimeEngine(this.sampleRate, this.blockSize);
|
|
1063
|
-
//
|
|
1064
|
-
|
|
1065
|
-
this.
|
|
1211
|
+
// Allocate persistent WASM-heap scratch (worst case: channelCount channels x
|
|
1212
|
+
// blockSize frames) and acquire the per-channel heap views once.
|
|
1213
|
+
this.engine.prepareChannels(this.channelCount, this.blockSize);
|
|
1214
|
+
this.channelBuffers = new Array(this.channelCount);
|
|
1066
1215
|
for (let ch = 0; ch < this.channelCount; ch++) {
|
|
1067
|
-
this.
|
|
1068
|
-
this.channelScratchViews[ch] = this.channelScratch[ch];
|
|
1216
|
+
this.channelBuffers[ch] = this.engine.getChannelBuffer(ch, this.blockSize);
|
|
1069
1217
|
}
|
|
1070
1218
|
}
|
|
1071
1219
|
|
|
@@ -1093,39 +1241,51 @@ export class SonareRealtimeEngineWorkletProcessor {
|
|
|
1093
1241
|
// `frames > this.blockSize` branch already returns early, so this is
|
|
1094
1242
|
// defensive — but we warn once if it ever fires so the contract violation
|
|
1095
1243
|
// is visible.
|
|
1096
|
-
const scratchCapacity = this.channelScratch[0]?.length ?? 0;
|
|
1097
1244
|
let usableFrames = frames;
|
|
1098
|
-
if (usableFrames >
|
|
1245
|
+
if (usableFrames > this.blockSize) {
|
|
1099
1246
|
if (!SonareRealtimeEngineWorkletProcessor.warnedChannelScratchOverflow) {
|
|
1100
1247
|
SonareRealtimeEngineWorkletProcessor.warnedChannelScratchOverflow = true;
|
|
1101
1248
|
// biome-ignore lint/suspicious/noConsole: realtime-safety diagnostic.
|
|
1102
1249
|
console.warn(
|
|
1103
1250
|
`SonareRealtimeEngineWorkletProcessor: requested ${usableFrames} frames ` +
|
|
1104
|
-
`exceeds pre-allocated capacity ${
|
|
1251
|
+
`exceeds pre-allocated capacity ${this.blockSize}; clamping.`,
|
|
1105
1252
|
);
|
|
1106
1253
|
}
|
|
1107
|
-
usableFrames =
|
|
1254
|
+
usableFrames = this.blockSize;
|
|
1108
1255
|
}
|
|
1256
|
+
|
|
1257
|
+
// Defend against WASM linear-memory growth detaching the cached heap views:
|
|
1258
|
+
// if any view's backing ArrayBuffer has been detached (byteLength === 0),
|
|
1259
|
+
// re-acquire all of them. This is a control-flow check (no allocation in the
|
|
1260
|
+
// common case where memory did not grow).
|
|
1261
|
+
if ((this.channelBuffers[0]?.byteLength ?? 0) === 0) {
|
|
1262
|
+
this.reacquireChannelBuffers();
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1109
1265
|
const input = inputs[0];
|
|
1110
|
-
//
|
|
1111
|
-
//
|
|
1266
|
+
// Write the AudioWorklet input straight into the engine's WASM-heap views;
|
|
1267
|
+
// no per-block heap allocation.
|
|
1112
1268
|
for (let ch = 0; ch < this.channelCount; ch++) {
|
|
1113
|
-
const
|
|
1269
|
+
const dst = this.channelBuffers[ch];
|
|
1114
1270
|
const source = input?.[ch];
|
|
1115
1271
|
if (source && source.length === usableFrames) {
|
|
1116
|
-
|
|
1272
|
+
dst.set(source.subarray(0, usableFrames));
|
|
1117
1273
|
} else {
|
|
1118
|
-
|
|
1274
|
+
dst.fill(0, 0, usableFrames);
|
|
1119
1275
|
}
|
|
1120
|
-
this.channelScratchViews[ch] = scratch.subarray(0, usableFrames);
|
|
1121
1276
|
}
|
|
1122
1277
|
|
|
1123
|
-
|
|
1278
|
+
// Run the engine in place over the prepared scratch (allocation-free).
|
|
1279
|
+
this.engine.processPrepared(usableFrames);
|
|
1280
|
+
|
|
1124
1281
|
for (let ch = 0; ch < output.length; ch++) {
|
|
1125
1282
|
const target = output[ch];
|
|
1126
|
-
const source =
|
|
1283
|
+
const source = this.channelBuffers[ch] ?? this.channelBuffers[0];
|
|
1127
1284
|
if (source) {
|
|
1128
|
-
target.set(source.subarray(0, target.length));
|
|
1285
|
+
target.set(source.subarray(0, Math.min(target.length, usableFrames)));
|
|
1286
|
+
if (target.length > usableFrames) {
|
|
1287
|
+
target.fill(0, usableFrames);
|
|
1288
|
+
}
|
|
1129
1289
|
} else {
|
|
1130
1290
|
target.fill(0);
|
|
1131
1291
|
}
|
|
@@ -1135,12 +1295,44 @@ export class SonareRealtimeEngineWorkletProcessor {
|
|
|
1135
1295
|
return true;
|
|
1136
1296
|
}
|
|
1137
1297
|
|
|
1298
|
+
private reacquireChannelBuffers(): void {
|
|
1299
|
+
for (let ch = 0; ch < this.channelCount; ch++) {
|
|
1300
|
+
this.channelBuffers[ch] = this.engine.getChannelBuffer(ch, this.blockSize);
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1138
1304
|
receiveCommand(command: SonareEngineCommandRecord): void {
|
|
1139
1305
|
if (!this.closed) {
|
|
1140
1306
|
this.applyCommand(command);
|
|
1141
1307
|
}
|
|
1142
1308
|
}
|
|
1143
1309
|
|
|
1310
|
+
// Applies an out-of-band control-plane sync message. Runs on the AudioWorklet
|
|
1311
|
+
// global scope but OUTSIDE process() (the message-port callback), so the
|
|
1312
|
+
// bulk/allocating engine setters (setClips/setMarkers) are safe here — they
|
|
1313
|
+
// never run on the realtime render path. This is the audio-thread equivalent
|
|
1314
|
+
// of the engine's control-thread RtPublisher setters.
|
|
1315
|
+
receiveSync(message: SonareEngineSyncMessage): void {
|
|
1316
|
+
if (this.closed) {
|
|
1317
|
+
return;
|
|
1318
|
+
}
|
|
1319
|
+
switch (message.type) {
|
|
1320
|
+
case 'syncClips':
|
|
1321
|
+
this.engine.setClips(message.clips);
|
|
1322
|
+
break;
|
|
1323
|
+
case 'syncMarkers':
|
|
1324
|
+
this.engine.setMarkers(message.markers);
|
|
1325
|
+
break;
|
|
1326
|
+
case 'syncMetronome':
|
|
1327
|
+
this.metronomeConfig = resolveMetronomeConfig(message.config);
|
|
1328
|
+
this.engine.setMetronome(message.config);
|
|
1329
|
+
break;
|
|
1330
|
+
case 'syncAutomation':
|
|
1331
|
+
this.engine.setAutomationLane(message.paramId, message.points);
|
|
1332
|
+
break;
|
|
1333
|
+
}
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1144
1336
|
destroy(): void {
|
|
1145
1337
|
if (!this.closed) {
|
|
1146
1338
|
this.engine.destroy();
|
|
@@ -1164,6 +1356,22 @@ export class SonareRealtimeEngineWorkletProcessor {
|
|
|
1164
1356
|
private applyCommand(command: SonareEngineCommandRecord): void {
|
|
1165
1357
|
const sampleTime = Number(command.sampleTime ?? -1);
|
|
1166
1358
|
switch (command.type) {
|
|
1359
|
+
case SonareEngineCommandType.SetParam:
|
|
1360
|
+
// paramId is carried in targetId, the new value in argFloat (matches the
|
|
1361
|
+
// SonareEngine.setParam producer). sampleTime is the render frame.
|
|
1362
|
+
this.engine.setParameter(
|
|
1363
|
+
Math.trunc(Number(command.targetId ?? 0)),
|
|
1364
|
+
Number(command.argFloat ?? 0),
|
|
1365
|
+
sampleTime,
|
|
1366
|
+
);
|
|
1367
|
+
break;
|
|
1368
|
+
case SonareEngineCommandType.SetParamSmoothed:
|
|
1369
|
+
this.engine.setParameterSmoothed(
|
|
1370
|
+
Math.trunc(Number(command.targetId ?? 0)),
|
|
1371
|
+
Number(command.argFloat ?? 0),
|
|
1372
|
+
sampleTime,
|
|
1373
|
+
);
|
|
1374
|
+
break;
|
|
1167
1375
|
case SonareEngineCommandType.TransportPlay:
|
|
1168
1376
|
this.engine.play(sampleTime);
|
|
1169
1377
|
break;
|
|
@@ -1190,20 +1398,31 @@ export class SonareRealtimeEngineWorkletProcessor {
|
|
|
1190
1398
|
this.engine.armCapture(Boolean(command.argInt));
|
|
1191
1399
|
break;
|
|
1192
1400
|
case SonareEngineCommandType.Punch:
|
|
1401
|
+
// Both endpoints already arrive as samples (see SonareEngine.punch);
|
|
1402
|
+
// do NOT re-scale by sampleRate.
|
|
1193
1403
|
this.engine.setCapturePunch(
|
|
1194
1404
|
Number(command.argInt ?? 0),
|
|
1195
|
-
Math.max(0, Math.round(Number(command.argFloat ?? 0)
|
|
1405
|
+
Math.max(0, Math.round(Number(command.argFloat ?? 0))),
|
|
1196
1406
|
true,
|
|
1197
1407
|
);
|
|
1198
1408
|
break;
|
|
1199
1409
|
case SonareEngineCommandType.SetMetronome:
|
|
1410
|
+
// Metronome config (beatGain/accentGain/clickSamples/clickSeconds) is
|
|
1411
|
+
// delivered out-of-band via the 'syncMetronome' message so it carries
|
|
1412
|
+
// the caller's full config; the command only toggles enabled state as a
|
|
1413
|
+
// sample-aligned fallback.
|
|
1200
1414
|
this.engine.setMetronome({
|
|
1201
1415
|
enabled: Boolean(command.argInt),
|
|
1202
|
-
beatGain:
|
|
1203
|
-
accentGain:
|
|
1204
|
-
clickSamples:
|
|
1416
|
+
beatGain: this.metronomeConfig.beatGain,
|
|
1417
|
+
accentGain: this.metronomeConfig.accentGain,
|
|
1418
|
+
clickSamples: this.metronomeConfig.clickSamples,
|
|
1205
1419
|
});
|
|
1206
1420
|
break;
|
|
1421
|
+
case SonareEngineCommandType.SeekMarker:
|
|
1422
|
+
// The realtime engine's markers are kept in sync via 'syncMarkers'
|
|
1423
|
+
// (RtPublisher-style swap), so a queued kSeekMarker resolves correctly.
|
|
1424
|
+
this.engine.seekMarker(Math.trunc(Number(command.targetId ?? 0)), sampleTime);
|
|
1425
|
+
break;
|
|
1207
1426
|
default:
|
|
1208
1427
|
this.publishTelemetryRecord({
|
|
1209
1428
|
type: SonareEngineTelemetryType.Error,
|
|
@@ -1233,7 +1452,7 @@ export class SonareRealtimeEngineWorkletProcessor {
|
|
|
1233
1452
|
}
|
|
1234
1453
|
|
|
1235
1454
|
private publishMeters(): void {
|
|
1236
|
-
if (
|
|
1455
|
+
if (this.meterIntervalFrames <= 0 || (!this.transport && !this.meterRing)) {
|
|
1237
1456
|
return;
|
|
1238
1457
|
}
|
|
1239
1458
|
for (const item of this.engine.drainMeterTelemetry(64)) {
|
|
@@ -1242,9 +1461,39 @@ export class SonareRealtimeEngineWorkletProcessor {
|
|
|
1242
1461
|
continue;
|
|
1243
1462
|
}
|
|
1244
1463
|
this.lastMeterFrame = meter.frame;
|
|
1245
|
-
|
|
1246
|
-
|
|
1464
|
+
// Prefer the lock-free SAB meter ring (matching the telemetry path and
|
|
1465
|
+
// SonareWorkletProcessor); only fall back to structured-clone postMessage
|
|
1466
|
+
// when no ring was provided, so we do not allocate/post from the audio
|
|
1467
|
+
// render callback in SAB mode.
|
|
1468
|
+
if (this.meterRing) {
|
|
1469
|
+
this.writeMeterRing(meter);
|
|
1470
|
+
} else {
|
|
1471
|
+
this.transport?.onMeter?.(meter);
|
|
1472
|
+
this.transport?.postMessage?.(meter);
|
|
1473
|
+
}
|
|
1474
|
+
}
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
private writeMeterRing(meter: SonareWorkletMeterSnapshot): void {
|
|
1478
|
+
const ring = this.meterRing;
|
|
1479
|
+
if (!ring) {
|
|
1480
|
+
return;
|
|
1247
1481
|
}
|
|
1482
|
+
const writeIndex = Atomics.load(ring.header, 0);
|
|
1483
|
+
const offset = (writeIndex % ring.capacity) * SONARE_METER_RING_RECORD_FLOATS;
|
|
1484
|
+
ring.records[offset] = encodeFrameLo(meter.frame);
|
|
1485
|
+
ring.records[offset + 1] = meter.peakDbL;
|
|
1486
|
+
ring.records[offset + 2] = meter.peakDbR;
|
|
1487
|
+
ring.records[offset + 3] = meter.rmsDbL;
|
|
1488
|
+
ring.records[offset + 4] = meter.rmsDbR;
|
|
1489
|
+
ring.records[offset + 5] = meter.correlation;
|
|
1490
|
+
ring.records[offset + 6] = encodeFrameHi(meter.frame);
|
|
1491
|
+
Atomics.store(ring.header, 0, writeIndex + 1);
|
|
1492
|
+
// writeIndex is a free-running monotonic counter, so an overflow guard here
|
|
1493
|
+
// would fire on essentially every write past the first `capacity` records
|
|
1494
|
+
// and store an ever-growing value, not a dropped-record count. Readers
|
|
1495
|
+
// already detect silent overrun via firstReadable = max(readIndex,
|
|
1496
|
+
// writeIndex - capacity), so header slot 3 is left at its initial 0.
|
|
1248
1497
|
}
|
|
1249
1498
|
|
|
1250
1499
|
private commandRingFromSharedBuffer(
|
|
@@ -1285,6 +1534,7 @@ export class SonareRtRealtimeEngineRuntime {
|
|
|
1285
1534
|
private readonly telemetryFramesPtr: number;
|
|
1286
1535
|
private readonly commandRing?: SonareEngineCommandRingBuffer;
|
|
1287
1536
|
private readonly telemetryRing?: SonareEngineTelemetryRingBuffer;
|
|
1537
|
+
private metronomeConfig: ResolvedMetronomeConfig = { ...DEFAULT_METRONOME_CONFIG };
|
|
1288
1538
|
private closed = false;
|
|
1289
1539
|
|
|
1290
1540
|
constructor(options: SonareRtRealtimeEngineRuntimeOptions) {
|
|
@@ -1380,6 +1630,54 @@ export class SonareRtRealtimeEngineRuntime {
|
|
|
1380
1630
|
return true;
|
|
1381
1631
|
}
|
|
1382
1632
|
|
|
1633
|
+
receiveCommand(command: SonareEngineCommandRecord): void {
|
|
1634
|
+
if (!this.closed) {
|
|
1635
|
+
this.applyCommand(command);
|
|
1636
|
+
}
|
|
1637
|
+
}
|
|
1638
|
+
|
|
1639
|
+
// Out-of-band control sync for the sonare-rt runtime. The sonare-rt C ABI
|
|
1640
|
+
// (src/wasm/rt_bindings.cpp) exposes set_metronome_enabled and seek_marker but
|
|
1641
|
+
// NOT set_clips / set_markers, so clip/marker mutations cannot be applied to a
|
|
1642
|
+
// live sonare-rt engine. We honor the metronome config and surface a clear
|
|
1643
|
+
// telemetry error for the unsupported clip/marker paths instead of silently
|
|
1644
|
+
// dropping them. The default 'embind' runtime wires all three fully.
|
|
1645
|
+
receiveSync(message: SonareEngineSyncMessage): void {
|
|
1646
|
+
if (this.closed) {
|
|
1647
|
+
return;
|
|
1648
|
+
}
|
|
1649
|
+
switch (message.type) {
|
|
1650
|
+
case 'syncMetronome':
|
|
1651
|
+
this.metronomeConfig = resolveMetronomeConfig(message.config);
|
|
1652
|
+
this.module._sonare_rt_engine_set_metronome_enabled(
|
|
1653
|
+
this.engine,
|
|
1654
|
+
message.config.enabled ? 1 : 0,
|
|
1655
|
+
this.metronomeConfig.beatGain,
|
|
1656
|
+
this.metronomeConfig.accentGain,
|
|
1657
|
+
this.metronomeConfig.clickSamples,
|
|
1658
|
+
);
|
|
1659
|
+
break;
|
|
1660
|
+
case 'syncClips':
|
|
1661
|
+
case 'syncMarkers':
|
|
1662
|
+
case 'syncAutomation':
|
|
1663
|
+
// The sonare-rt C ABI exposes no set_clips / set_markers /
|
|
1664
|
+
// set_automation_lane, so these mutations cannot reach a live sonare-rt
|
|
1665
|
+
// engine. Surface a clear telemetry error rather than silently dropping.
|
|
1666
|
+
if (this.telemetryRing) {
|
|
1667
|
+
writeSonareEngineTelemetryRingBuffer(this.telemetryRing, {
|
|
1668
|
+
type: SonareEngineTelemetryType.Error,
|
|
1669
|
+
error: SonareEngineTelemetryError.UnknownTarget,
|
|
1670
|
+
renderFrame: 0,
|
|
1671
|
+
timelineSample: 0,
|
|
1672
|
+
audibleTimelineSample: 0,
|
|
1673
|
+
graphLatencySamplesQ8: 0,
|
|
1674
|
+
value: 0,
|
|
1675
|
+
});
|
|
1676
|
+
}
|
|
1677
|
+
break;
|
|
1678
|
+
}
|
|
1679
|
+
}
|
|
1680
|
+
|
|
1383
1681
|
destroy(): void {
|
|
1384
1682
|
if (this.closed) {
|
|
1385
1683
|
return;
|
|
@@ -1418,6 +1716,25 @@ export class SonareRtRealtimeEngineRuntime {
|
|
|
1418
1716
|
private applyCommand(command: SonareEngineCommandRecord): void {
|
|
1419
1717
|
const sampleTime = toBigInt64(command.sampleTime, -1n);
|
|
1420
1718
|
switch (command.type) {
|
|
1719
|
+
case SonareEngineCommandType.SetParam:
|
|
1720
|
+
case SonareEngineCommandType.SetParamSmoothed:
|
|
1721
|
+
// The sonare-rt C ABI (src/wasm/rt_bindings.cpp) does not export a
|
|
1722
|
+
// sonare_rt_engine_set_param entry point, so parameter automation has no
|
|
1723
|
+
// realtime transport on this runtime target. Surface a clear error
|
|
1724
|
+
// telemetry record (rather than silently dropping the command) so hosts
|
|
1725
|
+
// can detect the unsupported path; the embind runtime fully wires this.
|
|
1726
|
+
if (this.telemetryRing) {
|
|
1727
|
+
writeSonareEngineTelemetryRingBuffer(this.telemetryRing, {
|
|
1728
|
+
type: SonareEngineTelemetryType.Error,
|
|
1729
|
+
error: SonareEngineTelemetryError.UnknownTarget,
|
|
1730
|
+
renderFrame: 0,
|
|
1731
|
+
timelineSample: 0,
|
|
1732
|
+
audibleTimelineSample: 0,
|
|
1733
|
+
graphLatencySamplesQ8: 0,
|
|
1734
|
+
value: Number(command.type),
|
|
1735
|
+
});
|
|
1736
|
+
}
|
|
1737
|
+
break;
|
|
1421
1738
|
case SonareEngineCommandType.TransportPlay:
|
|
1422
1739
|
this.module._sonare_rt_engine_play(this.engine, sampleTime);
|
|
1423
1740
|
break;
|
|
@@ -1453,10 +1770,12 @@ export class SonareRtRealtimeEngineRuntime {
|
|
|
1453
1770
|
this.module._sonare_rt_engine_set_capture_armed(this.engine, command.argInt ? 1 : 0);
|
|
1454
1771
|
break;
|
|
1455
1772
|
case SonareEngineCommandType.Punch:
|
|
1773
|
+
// Both endpoints already arrive as samples (see SonareEngine.punch);
|
|
1774
|
+
// do NOT re-scale by sampleRate.
|
|
1456
1775
|
this.module._sonare_rt_engine_set_capture_punch(
|
|
1457
1776
|
this.engine,
|
|
1458
1777
|
toBigInt64(command.argInt, 0n),
|
|
1459
|
-
BigInt(Math.
|
|
1778
|
+
BigInt(Math.max(0, Math.round(Number(command.argFloat ?? 0)))),
|
|
1460
1779
|
1,
|
|
1461
1780
|
);
|
|
1462
1781
|
break;
|
|
@@ -1464,9 +1783,9 @@ export class SonareRtRealtimeEngineRuntime {
|
|
|
1464
1783
|
this.module._sonare_rt_engine_set_metronome_enabled(
|
|
1465
1784
|
this.engine,
|
|
1466
1785
|
command.argInt ? 1 : 0,
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1786
|
+
this.metronomeConfig.beatGain,
|
|
1787
|
+
this.metronomeConfig.accentGain,
|
|
1788
|
+
this.metronomeConfig.clickSamples,
|
|
1470
1789
|
);
|
|
1471
1790
|
break;
|
|
1472
1791
|
case SonareEngineCommandType.SeekMarker:
|
|
@@ -1555,8 +1874,10 @@ export class SonareRealtimeEngineNode {
|
|
|
1555
1874
|
readonly capabilities: SonareRealtimeEngineNodeCapabilities;
|
|
1556
1875
|
readonly commandRing?: SonareEngineCommandRingBuffer;
|
|
1557
1876
|
readonly telemetryRing?: SonareEngineTelemetryRingBuffer;
|
|
1877
|
+
readonly meterRing?: SonareMeterRingBuffer;
|
|
1558
1878
|
readonly ready: Promise<void>;
|
|
1559
1879
|
private telemetryReadIndex = 0;
|
|
1880
|
+
private meterReadIndex = 0;
|
|
1560
1881
|
private telemetryListeners = new Set<(telemetry: SonareEngineTelemetryRecord) => void>();
|
|
1561
1882
|
private meterListeners = new Set<(meter: SonareWorkletMeterSnapshot) => void>();
|
|
1562
1883
|
private resolveReady!: () => void;
|
|
@@ -1568,11 +1889,13 @@ export class SonareRealtimeEngineNode {
|
|
|
1568
1889
|
capabilities: SonareRealtimeEngineNodeCapabilities,
|
|
1569
1890
|
commandRing?: SonareEngineCommandRingBuffer,
|
|
1570
1891
|
telemetryRing?: SonareEngineTelemetryRingBuffer,
|
|
1892
|
+
meterRing?: SonareMeterRingBuffer,
|
|
1571
1893
|
) {
|
|
1572
1894
|
this.node = node;
|
|
1573
1895
|
this.capabilities = capabilities;
|
|
1574
1896
|
this.commandRing = commandRing;
|
|
1575
1897
|
this.telemetryRing = telemetryRing;
|
|
1898
|
+
this.meterRing = meterRing;
|
|
1576
1899
|
this.ready = new Promise((resolve, reject) => {
|
|
1577
1900
|
this.resolveReady = resolve;
|
|
1578
1901
|
this.rejectReady = reject;
|
|
@@ -1643,6 +1966,14 @@ export class SonareRealtimeEngineNode {
|
|
|
1643
1966
|
mode === 'sab'
|
|
1644
1967
|
? createSonareEngineTelemetryRingBuffer(options.telemetryRingCapacity ?? 128)
|
|
1645
1968
|
: undefined;
|
|
1969
|
+
// Meter ring: only the embind runtime publishes engine meters into a SAB
|
|
1970
|
+
// ring (the sonare-rt runtime owns its own meter transport). Lock-free
|
|
1971
|
+
// meter delivery matches the telemetry path and keeps the audio render
|
|
1972
|
+
// callback allocation-free in SAB mode.
|
|
1973
|
+
const meterRing =
|
|
1974
|
+
mode === 'sab' && runtimeTarget === 'embind'
|
|
1975
|
+
? createSonareMeterRingBuffer(options.meterRingCapacity ?? 128)
|
|
1976
|
+
: undefined;
|
|
1646
1977
|
const channelCount = Math.max(1, Math.floor(options.channelCount ?? 2));
|
|
1647
1978
|
const processorOptions: SonareRealtimeEngineWorkletProcessorOptions = {
|
|
1648
1979
|
runtimeTarget,
|
|
@@ -1655,6 +1986,8 @@ export class SonareRealtimeEngineNode {
|
|
|
1655
1986
|
commandRingCapacity: commandRing?.capacity,
|
|
1656
1987
|
telemetrySharedBuffer: telemetryRing?.sharedBuffer,
|
|
1657
1988
|
telemetryRingCapacity: telemetryRing?.capacity,
|
|
1989
|
+
meterSharedBuffer: meterRing?.sharedBuffer,
|
|
1990
|
+
meterRingCapacity: meterRing?.capacity,
|
|
1658
1991
|
};
|
|
1659
1992
|
const factory =
|
|
1660
1993
|
options.nodeFactory ??
|
|
@@ -1681,6 +2014,7 @@ export class SonareRealtimeEngineNode {
|
|
|
1681
2014
|
},
|
|
1682
2015
|
commandRing,
|
|
1683
2016
|
telemetryRing,
|
|
2017
|
+
meterRing,
|
|
1684
2018
|
);
|
|
1685
2019
|
}
|
|
1686
2020
|
|
|
@@ -1731,6 +2065,21 @@ export class SonareRealtimeEngineNode {
|
|
|
1731
2065
|
return read.telemetry;
|
|
1732
2066
|
}
|
|
1733
2067
|
|
|
2068
|
+
// Drains any meters published into the SAB meter ring (embind SAB mode) and
|
|
2069
|
+
// forwards them to onMeter listeners. In postMessage mode meters arrive via
|
|
2070
|
+
// node.port.onmessage instead, so this is a no-op then.
|
|
2071
|
+
pollMeters(): SonareWorkletMeterSnapshot[] {
|
|
2072
|
+
if (!this.meterRing) {
|
|
2073
|
+
return [];
|
|
2074
|
+
}
|
|
2075
|
+
const read = readSonareMeterRingBuffer(this.meterRing, this.meterReadIndex);
|
|
2076
|
+
this.meterReadIndex = read.nextReadIndex;
|
|
2077
|
+
for (const meter of read.meters) {
|
|
2078
|
+
this.emitMeter(meter);
|
|
2079
|
+
}
|
|
2080
|
+
return read.meters;
|
|
2081
|
+
}
|
|
2082
|
+
|
|
1734
2083
|
onTelemetry(callback: (telemetry: SonareEngineTelemetryRecord) => void): () => void {
|
|
1735
2084
|
this.telemetryListeners.add(callback);
|
|
1736
2085
|
return () => {
|
|
@@ -1866,6 +2215,15 @@ export class SonareEngine {
|
|
|
1866
2215
|
|
|
1867
2216
|
setLoop(startPpq: number, endPpq: number, enabled = true): boolean {
|
|
1868
2217
|
this.offlineEngine.setLoop(startPpq, endPpq, enabled);
|
|
2218
|
+
// Transport precision contract: the SAB command record carries exactly one
|
|
2219
|
+
// Float64 lane (argFloat) and one Int64 lane (argInt). startPpq travels in
|
|
2220
|
+
// argFloat with full double precision, matching the offline engine; endPpq
|
|
2221
|
+
// is carried as micro-PPQ (round(endPpq * 1e6)) in the integer lane and
|
|
2222
|
+
// divided back by 1e6 on the consumer. Loop ENDS are therefore snapped to
|
|
2223
|
+
// the nearest 1e-6 PPQ over the realtime transport (max 5e-7 PPQ drift),
|
|
2224
|
+
// while loop STARTS and the offline path stay exact. This is intentional:
|
|
2225
|
+
// the record has no second free Float64 lane, and a micro-PPQ grid on the
|
|
2226
|
+
// loop end is well below audible/sample-accurate resolution at any tempo.
|
|
1869
2227
|
return this.realtimeNode.sendCommand({
|
|
1870
2228
|
type: SonareEngineCommandType.SetLoop,
|
|
1871
2229
|
targetId: enabled ? 1 : 0,
|
|
@@ -1876,10 +2234,17 @@ export class SonareEngine {
|
|
|
1876
2234
|
}
|
|
1877
2235
|
|
|
1878
2236
|
setParam(nodeId: string, param: string | number, value: number): boolean {
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
2237
|
+
const paramId = this.resolveParamId(nodeId, param);
|
|
2238
|
+
// Mirror the change into the offline engine so a subsequent offline render
|
|
2239
|
+
// reflects the live value, then push a sample-accurate command to the
|
|
2240
|
+
// realtime runtime (mirrors setTempo/setLoop above).
|
|
2241
|
+
this.offlineEngine.setParameter(paramId, value);
|
|
2242
|
+
return this.realtimeNode.sendCommand({
|
|
2243
|
+
type: SonareEngineCommandType.SetParam,
|
|
2244
|
+
targetId: paramId,
|
|
2245
|
+
sampleTime: -1,
|
|
2246
|
+
argFloat: value,
|
|
2247
|
+
});
|
|
1883
2248
|
}
|
|
1884
2249
|
|
|
1885
2250
|
scheduleParam(
|
|
@@ -1895,6 +2260,11 @@ export class SonareEngine {
|
|
|
1895
2260
|
lane.sort((a, b) => a.ppq - b.ppq);
|
|
1896
2261
|
this.automationLanes.set(paramId, lane);
|
|
1897
2262
|
this.offlineEngine.setAutomationLane(paramId, lane);
|
|
2263
|
+
// Mirror the lane to the live worklet engine so scheduled automation plays
|
|
2264
|
+
// back in real time, not just in renderOffline(). Lanes can exceed the
|
|
2265
|
+
// fixed-size SAB command record, so they ride an out-of-band 'syncAutomation'
|
|
2266
|
+
// message applied outside process() (like syncClips/syncMarkers).
|
|
2267
|
+
this.postSync({ type: 'syncAutomation', paramId, points: lane });
|
|
1898
2268
|
}
|
|
1899
2269
|
|
|
1900
2270
|
addAutomationPoint(
|
|
@@ -1918,7 +2288,16 @@ export class SonareEngine {
|
|
|
1918
2288
|
void target;
|
|
1919
2289
|
void solo;
|
|
1920
2290
|
void mute;
|
|
1921
|
-
|
|
2291
|
+
// Per-track solo/mute is a Mixer concept (strip-indexed setSoloed/setMuted),
|
|
2292
|
+
// not an engine command: the WASM SonareEngine facade does not own a Mixer
|
|
2293
|
+
// node, so there is no realtime transport for solo/mute on this surface.
|
|
2294
|
+
// Throw a clear error instead of silently returning false (a silent no-op
|
|
2295
|
+
// would leave callers believing the change took effect). Apply solo/mute
|
|
2296
|
+
// directly on a Mixer instance (Mixer.setSoloed/Mixer.setMuted) instead.
|
|
2297
|
+
throw new Error(
|
|
2298
|
+
'SonareEngine.setSoloMute is not supported: solo/mute is a Mixer feature; ' +
|
|
2299
|
+
'use Mixer.setSoloed(stripIndex, ...) / Mixer.setMuted(stripIndex, ...) instead.',
|
|
2300
|
+
);
|
|
1922
2301
|
}
|
|
1923
2302
|
|
|
1924
2303
|
addClip(
|
|
@@ -1959,16 +2338,25 @@ export class SonareEngine {
|
|
|
1959
2338
|
const inSample = this.ppqToApproxSample(inPpq);
|
|
1960
2339
|
const outSample = this.ppqToApproxSample(outPpq);
|
|
1961
2340
|
this.offlineEngine.setCapturePunch(inSample, outSample, true);
|
|
2341
|
+
// Carry BOTH endpoints as already-converted SAMPLES so the realtime engine
|
|
2342
|
+
// agrees with the offline engine. The previous code sent the raw PPQ out
|
|
2343
|
+
// point and let the consumer multiply by sampleRate (treating PPQ as
|
|
2344
|
+
// seconds), which ignored tempo and produced a punch-out ~2x too large at
|
|
2345
|
+
// 120 BPM. argInt = in sample, argFloat = out sample (full-precision double).
|
|
1962
2346
|
return this.realtimeNode.sendCommand({
|
|
1963
2347
|
type: SonareEngineCommandType.Punch,
|
|
1964
2348
|
sampleTime: -1,
|
|
1965
2349
|
argInt: inSample,
|
|
1966
|
-
argFloat:
|
|
2350
|
+
argFloat: outSample,
|
|
1967
2351
|
});
|
|
1968
2352
|
}
|
|
1969
2353
|
|
|
1970
2354
|
setMetronome(opts: EngineMetronomeConfig): void {
|
|
1971
2355
|
this.offlineEngine.setMetronome(opts);
|
|
2356
|
+
// The full config (beatGain/accentGain/clickSamples/clickSeconds) cannot fit
|
|
2357
|
+
// the fixed-size SAB command record, so it is delivered out-of-band; the
|
|
2358
|
+
// SetMetronome command then toggles enabled state on the audio thread.
|
|
2359
|
+
this.postSync({ type: 'syncMetronome', config: opts });
|
|
1972
2360
|
this.realtimeNode.sendCommand({
|
|
1973
2361
|
type: SonareEngineCommandType.SetMetronome,
|
|
1974
2362
|
sampleTime: -1,
|
|
@@ -1985,7 +2373,15 @@ export class SonareEngine {
|
|
|
1985
2373
|
|
|
1986
2374
|
seekMarker(markerId: number): boolean {
|
|
1987
2375
|
this.offlineEngine.seekMarker(markerId);
|
|
1988
|
-
|
|
2376
|
+
// Forward to the live worklet engine. Its marker set is kept in sync via the
|
|
2377
|
+
// 'syncMarkers' message (see syncMarkers), so a queued kSeekMarker resolves
|
|
2378
|
+
// the marker id to its frame on the audio thread. Returns the sendCommand
|
|
2379
|
+
// result (previously this method always returned a misleading `false`).
|
|
2380
|
+
return this.realtimeNode.sendCommand({
|
|
2381
|
+
type: SonareEngineCommandType.SeekMarker,
|
|
2382
|
+
targetId: markerId,
|
|
2383
|
+
sampleTime: -1,
|
|
2384
|
+
});
|
|
1989
2385
|
}
|
|
1990
2386
|
|
|
1991
2387
|
async renderOffline(totalFrames: number): Promise<Float32Array[]> {
|
|
@@ -2009,6 +2405,10 @@ export class SonareEngine {
|
|
|
2009
2405
|
return this.realtimeNode.pollTelemetry();
|
|
2010
2406
|
}
|
|
2011
2407
|
|
|
2408
|
+
pollMeters(): SonareWorkletMeterSnapshot[] {
|
|
2409
|
+
return this.realtimeNode.pollMeters();
|
|
2410
|
+
}
|
|
2411
|
+
|
|
2012
2412
|
destroy(): void {
|
|
2013
2413
|
if (this.destroyed) {
|
|
2014
2414
|
return;
|
|
@@ -2021,11 +2421,29 @@ export class SonareEngine {
|
|
|
2021
2421
|
}
|
|
2022
2422
|
|
|
2023
2423
|
private syncClips(): void {
|
|
2024
|
-
|
|
2424
|
+
const clips = Array.from(this.clips.values());
|
|
2425
|
+
this.offlineEngine.setClips(clips);
|
|
2426
|
+
// Push the full clip set to the live worklet engine over the message port.
|
|
2427
|
+
// Clip audio buffers are too large for the fixed-size SAB command record, so
|
|
2428
|
+
// they ride a structured-clone 'syncClips' message applied OUTSIDE the audio
|
|
2429
|
+
// render callback (the audio-thread equivalent of an RtPublisher swap).
|
|
2430
|
+
this.postSync({ type: 'syncClips', clips });
|
|
2025
2431
|
}
|
|
2026
2432
|
|
|
2027
2433
|
private syncMarkers(): void {
|
|
2028
|
-
|
|
2434
|
+
const markers = Array.from(this.markers.values()).sort((a, b) => a.ppq - b.ppq);
|
|
2435
|
+
this.offlineEngine.setMarkers(markers);
|
|
2436
|
+
this.postSync({ type: 'syncMarkers', markers });
|
|
2437
|
+
}
|
|
2438
|
+
|
|
2439
|
+
// Posts an out-of-band control-sync message to the worklet engine processor.
|
|
2440
|
+
// Sync messages use a string `type` so the worklet's message handler routes
|
|
2441
|
+
// them to receiveSync() (numeric `type` is reserved for SonareEngineCommandRecord).
|
|
2442
|
+
private postSync(message: SonareEngineSyncMessage): void {
|
|
2443
|
+
if (this.destroyed) {
|
|
2444
|
+
return;
|
|
2445
|
+
}
|
|
2446
|
+
this.realtimeNode.node.port.postMessage(message);
|
|
2029
2447
|
}
|
|
2030
2448
|
|
|
2031
2449
|
private resolveParamId(nodeId: string, param: string | number): number {
|
|
@@ -2127,6 +2545,14 @@ export class SonareRealtimeVoiceChangerWorkletProcessor {
|
|
|
2127
2545
|
return !this.destroyed;
|
|
2128
2546
|
}
|
|
2129
2547
|
|
|
2548
|
+
// The cached heap views can detach if WASM linear memory grows (the embind
|
|
2549
|
+
// module is built ALLOW_MEMORY_GROWTH). Re-acquire them if detached
|
|
2550
|
+
// (byteLength === 0) before touching them; in the common no-growth case this
|
|
2551
|
+
// is a cheap branch with no allocation.
|
|
2552
|
+
if (this.monoInput.byteLength === 0) {
|
|
2553
|
+
this.reacquireBuffers();
|
|
2554
|
+
}
|
|
2555
|
+
|
|
2130
2556
|
const input = inputs[0];
|
|
2131
2557
|
const requestedFrames = output[0]?.length ?? 0;
|
|
2132
2558
|
const requestedChannels = Math.min(this.channelCount, output.length);
|
|
@@ -2188,6 +2614,20 @@ export class SonareRealtimeVoiceChangerWorkletProcessor {
|
|
|
2188
2614
|
this.changer.delete();
|
|
2189
2615
|
}
|
|
2190
2616
|
|
|
2617
|
+
// Re-acquires the cached WASM-heap views after a memory-growth detachment.
|
|
2618
|
+
// The underlying C++ vectors are pre-warmed (ensure_*_capacity ran at prepare
|
|
2619
|
+
// time), so getMono*/getPlanar* return fresh views onto the SAME storage
|
|
2620
|
+
// without reallocating it.
|
|
2621
|
+
private reacquireBuffers(): void {
|
|
2622
|
+
this.monoInput = this.changer.getMonoInputBuffer(this.blockSize);
|
|
2623
|
+
this.monoOutput = this.changer.getMonoOutputBuffer(this.blockSize);
|
|
2624
|
+
if (this.channelCount > 1) {
|
|
2625
|
+
for (let ch = 0; ch < this.channelCount; ch++) {
|
|
2626
|
+
this.planarChannels[ch] = this.changer.getPlanarChannelBuffer(ch, this.blockSize);
|
|
2627
|
+
}
|
|
2628
|
+
}
|
|
2629
|
+
}
|
|
2630
|
+
|
|
2191
2631
|
/**
|
|
2192
2632
|
* Returns the number of frames we can actually process given the
|
|
2193
2633
|
* pre-allocated capacity. If the host requests more frames than the
|
|
@@ -2345,6 +2785,10 @@ export function registerSonareRealtimeEngineWorkletProcessor(
|
|
|
2345
2785
|
const onMessage = (event: { data: unknown }) => {
|
|
2346
2786
|
if (isEngineCommandRecord(event.data)) {
|
|
2347
2787
|
this.bridge?.receiveCommand(event.data);
|
|
2788
|
+
this.rtBridge?.receiveCommand(event.data);
|
|
2789
|
+
} else if (isEngineSyncMessage(event.data)) {
|
|
2790
|
+
this.bridge?.receiveSync(event.data);
|
|
2791
|
+
this.rtBridge?.receiveSync(event.data);
|
|
2348
2792
|
}
|
|
2349
2793
|
};
|
|
2350
2794
|
if (port?.addEventListener) {
|