@libraz/libsonare 1.2.1 → 1.2.3
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 +56 -4
- package/dist/index.d.ts +820 -296
- package/dist/index.js +770 -128
- package/dist/index.js.map +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 +101 -3
- package/dist/worklet.js +706 -69
- package/dist/worklet.js.map +1 -1
- package/package.json +6 -6
- package/src/index.ts +1764 -287
- package/src/public_types.ts +128 -0
- package/src/sonare.js.d.ts +580 -79
- package/src/stream_types.ts +4 -1
- package/src/worklet.ts +796 -66
- package/src/wasm_types.ts +0 -1259
package/src/worklet.ts
CHANGED
|
@@ -7,8 +7,9 @@ import type {
|
|
|
7
7
|
EngineParameterInfo,
|
|
8
8
|
EngineTelemetry,
|
|
9
9
|
MixerRealtimeBuffer,
|
|
10
|
+
RealtimeVoiceChangerConfigInput,
|
|
10
11
|
} from './index';
|
|
11
|
-
import { engineCapabilities, Mixer, RealtimeEngine } from './index';
|
|
12
|
+
import { engineCapabilities, Mixer, RealtimeEngine, RealtimeVoiceChanger } from './index';
|
|
12
13
|
import type { AutomationCurve } from './public_types';
|
|
13
14
|
import type { SonareRtModule } from './sonare-rt';
|
|
14
15
|
|
|
@@ -45,8 +46,35 @@ export interface SonareRealtimeEngineWorkletProcessorOptions {
|
|
|
45
46
|
commandRingCapacity?: number;
|
|
46
47
|
telemetrySharedBuffer?: SharedArrayBuffer;
|
|
47
48
|
telemetryRingCapacity?: number;
|
|
49
|
+
meterSharedBuffer?: SharedArrayBuffer;
|
|
50
|
+
meterRingCapacity?: number;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface SonareRealtimeVoiceChangerWorkletProcessorOptions {
|
|
54
|
+
preset?: RealtimeVoiceChangerConfigInput;
|
|
55
|
+
sampleRate?: number;
|
|
56
|
+
blockSize?: number;
|
|
57
|
+
channelCount?: number;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface SonareRealtimeVoiceChangerSetConfigMessage {
|
|
61
|
+
type: 'setConfig';
|
|
62
|
+
preset: RealtimeVoiceChangerConfigInput;
|
|
48
63
|
}
|
|
49
64
|
|
|
65
|
+
export interface SonareRealtimeVoiceChangerResetMessage {
|
|
66
|
+
type: 'reset';
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface SonareRealtimeVoiceChangerDestroyMessage {
|
|
70
|
+
type: 'destroy';
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export type SonareRealtimeVoiceChangerMessage =
|
|
74
|
+
| SonareRealtimeVoiceChangerSetConfigMessage
|
|
75
|
+
| SonareRealtimeVoiceChangerResetMessage
|
|
76
|
+
| SonareRealtimeVoiceChangerDestroyMessage;
|
|
77
|
+
|
|
50
78
|
export interface SonareRealtimeEngineNodeCapabilities {
|
|
51
79
|
mode: 'sab' | 'postMessage';
|
|
52
80
|
runtimeTarget: 'embind' | 'sonare-rt';
|
|
@@ -156,8 +184,33 @@ export type SonareWorkletTransportMessage =
|
|
|
156
184
|
| SonareEngineTelemetryRecord;
|
|
157
185
|
|
|
158
186
|
export const SONARE_METER_RING_HEADER_INTS = 4;
|
|
159
|
-
|
|
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;
|
|
160
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
|
+
}
|
|
161
214
|
export const SONARE_ENGINE_RING_HEADER_INTS = 5;
|
|
162
215
|
export const SONARE_ENGINE_COMMAND_RECORD_BYTES = 32;
|
|
163
216
|
export const SONARE_ENGINE_TELEMETRY_RECORD_BYTES = 48;
|
|
@@ -211,6 +264,29 @@ interface WorkletTransport {
|
|
|
211
264
|
onSpectrum?: (spectrum: SonareWorkletSpectrumSnapshot) => void;
|
|
212
265
|
}
|
|
213
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
|
+
|
|
214
290
|
export interface SonareMeterRingBuffer {
|
|
215
291
|
sharedBuffer: SharedArrayBuffer;
|
|
216
292
|
header: Int32Array;
|
|
@@ -244,6 +320,39 @@ export interface SonareEngineCommandRecord {
|
|
|
244
320
|
argInt?: number | bigint;
|
|
245
321
|
}
|
|
246
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
|
+
|
|
247
356
|
export interface SonareEngineTelemetryRecord {
|
|
248
357
|
type: SonareEngineTelemetryType | number;
|
|
249
358
|
error: SonareEngineTelemetryError | number;
|
|
@@ -317,6 +426,25 @@ function isEngineCommandRecord(value: unknown): value is SonareEngineCommandReco
|
|
|
317
426
|
return isRecord(value) && typeof value.type === 'number';
|
|
318
427
|
}
|
|
319
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
|
+
|
|
441
|
+
function isRealtimeVoiceChangerMessage(value: unknown): value is SonareRealtimeVoiceChangerMessage {
|
|
442
|
+
if (!isRecord(value) || typeof value.type !== 'string') {
|
|
443
|
+
return false;
|
|
444
|
+
}
|
|
445
|
+
return value.type === 'setConfig' || value.type === 'reset' || value.type === 'destroy';
|
|
446
|
+
}
|
|
447
|
+
|
|
320
448
|
function isEngineTelemetryRecord(value: unknown): value is SonareEngineTelemetryRecord {
|
|
321
449
|
return (
|
|
322
450
|
isRecord(value) &&
|
|
@@ -374,7 +502,7 @@ export function readSonareMeterRingBuffer(
|
|
|
374
502
|
const offset = (index % ring.capacity) * SONARE_METER_RING_RECORD_FLOATS;
|
|
375
503
|
meters.push({
|
|
376
504
|
type: 'meter',
|
|
377
|
-
frame: ring.records[offset],
|
|
505
|
+
frame: decodeFrame(ring.records[offset], ring.records[offset + 6]),
|
|
378
506
|
peakDbL: ring.records[offset + 1],
|
|
379
507
|
peakDbR: ring.records[offset + 2],
|
|
380
508
|
rmsDbL: ring.records[offset + 3],
|
|
@@ -388,9 +516,11 @@ export function readSonareMeterRingBuffer(
|
|
|
388
516
|
export function sonareSpectrumRingBufferByteLength(capacity: number, bands = 16): number {
|
|
389
517
|
const clampedCapacity = Math.max(1, Math.floor(capacity));
|
|
390
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.
|
|
391
521
|
return (
|
|
392
522
|
SONARE_SPECTRUM_RING_HEADER_INTS * Int32Array.BYTES_PER_ELEMENT +
|
|
393
|
-
clampedCapacity * (
|
|
523
|
+
clampedCapacity * (3 + clampedBands) * Float32Array.BYTES_PER_ELEMENT
|
|
394
524
|
);
|
|
395
525
|
}
|
|
396
526
|
|
|
@@ -423,7 +553,7 @@ export function readSonareSpectrumRingBuffer(
|
|
|
423
553
|
readIndex = 0,
|
|
424
554
|
): SonareSpectrumRingReadResult {
|
|
425
555
|
const writeIndex = Atomics.load(ring.header, 0);
|
|
426
|
-
const recordFloats = Atomics.load(ring.header, 2) ||
|
|
556
|
+
const recordFloats = Atomics.load(ring.header, 2) || 3 + ring.bands;
|
|
427
557
|
const bands = Atomics.load(ring.header, 3) || ring.bands;
|
|
428
558
|
const nextReadIndex = Math.max(0, Math.min(readIndex, writeIndex));
|
|
429
559
|
const firstReadable = Math.max(nextReadIndex, writeIndex - ring.capacity);
|
|
@@ -431,8 +561,12 @@ export function readSonareSpectrumRingBuffer(
|
|
|
431
561
|
for (let index = firstReadable; index < writeIndex; index++) {
|
|
432
562
|
const offset = (index % ring.capacity) * recordFloats;
|
|
433
563
|
const values = new Float32Array(bands);
|
|
434
|
-
values.set(ring.records.subarray(offset +
|
|
435
|
-
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
|
+
});
|
|
436
570
|
}
|
|
437
571
|
return { nextReadIndex: writeIndex, spectra };
|
|
438
572
|
}
|
|
@@ -587,7 +721,7 @@ function spectrumRingFromSharedBuffer(
|
|
|
587
721
|
const existingBands = Atomics.load(header, 3);
|
|
588
722
|
const capacity = Math.max(1, Math.floor(existingCapacity || fallbackCapacity || 1));
|
|
589
723
|
const bands = Math.max(1, Math.floor(existingBands || fallbackBands || 16));
|
|
590
|
-
const recordFloats =
|
|
724
|
+
const recordFloats = 3 + bands;
|
|
591
725
|
const minBytes = sonareSpectrumRingBufferByteLength(capacity, bands);
|
|
592
726
|
if (sharedBuffer.byteLength < minBytes) {
|
|
593
727
|
throw new Error('spectrumSharedBuffer is too small for the requested ring capacity.');
|
|
@@ -648,8 +782,10 @@ function writeEngineCommandRecord(
|
|
|
648
782
|
view.setUint32(offset, command.type, true);
|
|
649
783
|
view.setUint32(offset + 4, command.targetId ?? 0, true);
|
|
650
784
|
view.setBigInt64(offset + 8, toBigInt64(command.sampleTime, -1n), true);
|
|
651
|
-
|
|
652
|
-
|
|
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);
|
|
653
789
|
view.setBigInt64(offset + 24, toBigInt64(command.argInt, 0n), true);
|
|
654
790
|
}
|
|
655
791
|
|
|
@@ -658,7 +794,7 @@ function readEngineCommandRecord(view: DataView, offset: number): SonareEngineCo
|
|
|
658
794
|
type: view.getUint32(offset, true),
|
|
659
795
|
targetId: view.getUint32(offset + 4, true),
|
|
660
796
|
sampleTime: Number(view.getBigInt64(offset + 8, true)),
|
|
661
|
-
argFloat: view.
|
|
797
|
+
argFloat: view.getFloat64(offset + 16, true),
|
|
662
798
|
argInt: Number(view.getBigInt64(offset + 24, true)),
|
|
663
799
|
};
|
|
664
800
|
}
|
|
@@ -785,9 +921,14 @@ export class SonareWorkletProcessor {
|
|
|
785
921
|
return true;
|
|
786
922
|
}
|
|
787
923
|
const frames = leftOut.length;
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
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);
|
|
791
932
|
|
|
792
933
|
for (let strip = 0; strip < this.realtime.leftInputs.length; strip++) {
|
|
793
934
|
const input = inputs[strip];
|
|
@@ -795,12 +936,12 @@ export class SonareWorkletProcessor {
|
|
|
795
936
|
const right = input?.[1];
|
|
796
937
|
const leftTarget = this.realtime.leftInputs[strip];
|
|
797
938
|
const rightTarget = this.realtime.rightInputs[strip];
|
|
798
|
-
if (left && left.length
|
|
799
|
-
leftTarget.set(left);
|
|
800
|
-
if (right && right.length
|
|
801
|
-
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));
|
|
802
943
|
} else {
|
|
803
|
-
rightTarget.set(left);
|
|
944
|
+
rightTarget.set(left.subarray(0, usable));
|
|
804
945
|
}
|
|
805
946
|
} else {
|
|
806
947
|
leftTarget.fill(0);
|
|
@@ -808,14 +949,30 @@ export class SonareWorkletProcessor {
|
|
|
808
949
|
}
|
|
809
950
|
}
|
|
810
951
|
|
|
811
|
-
this.realtime.process(
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
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
|
+
}
|
|
815
966
|
}
|
|
816
|
-
this.processedFrames +=
|
|
817
|
-
this.publishMeter(
|
|
818
|
-
|
|
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
|
+
);
|
|
819
976
|
return true;
|
|
820
977
|
}
|
|
821
978
|
|
|
@@ -906,16 +1063,19 @@ export class SonareWorkletProcessor {
|
|
|
906
1063
|
}
|
|
907
1064
|
const writeIndex = Atomics.load(ring.header, 0);
|
|
908
1065
|
const offset = (writeIndex % ring.capacity) * SONARE_METER_RING_RECORD_FLOATS;
|
|
909
|
-
ring.records[offset] = meter.frame;
|
|
1066
|
+
ring.records[offset] = encodeFrameLo(meter.frame);
|
|
910
1067
|
ring.records[offset + 1] = meter.peakDbL;
|
|
911
1068
|
ring.records[offset + 2] = meter.peakDbR;
|
|
912
1069
|
ring.records[offset + 3] = meter.rmsDbL;
|
|
913
1070
|
ring.records[offset + 4] = meter.rmsDbR;
|
|
914
1071
|
ring.records[offset + 5] = meter.correlation;
|
|
1072
|
+
ring.records[offset + 6] = encodeFrameHi(meter.frame);
|
|
915
1073
|
Atomics.store(ring.header, 0, writeIndex + 1);
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
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.
|
|
919
1079
|
}
|
|
920
1080
|
|
|
921
1081
|
private publishSpectrum(left: Float32Array, right: Float32Array): void {
|
|
@@ -941,8 +1101,20 @@ export class SonareWorkletProcessor {
|
|
|
941
1101
|
}
|
|
942
1102
|
|
|
943
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.
|
|
944
1111
|
const n = Math.max(1, Math.min(left.length, right.length));
|
|
1112
|
+
const maxBand = Math.floor(n / 2);
|
|
945
1113
|
for (let band = 0; band < this.spectrumBands.length; band++) {
|
|
1114
|
+
if (band >= maxBand) {
|
|
1115
|
+
this.spectrumBands[band] = magnitudeToDb(0);
|
|
1116
|
+
continue;
|
|
1117
|
+
}
|
|
946
1118
|
const bin = band + 1;
|
|
947
1119
|
let real = 0;
|
|
948
1120
|
let imag = 0;
|
|
@@ -963,13 +1135,15 @@ export class SonareWorkletProcessor {
|
|
|
963
1135
|
}
|
|
964
1136
|
const writeIndex = Atomics.load(ring.header, 0);
|
|
965
1137
|
const offset = (writeIndex % ring.capacity) * ring.recordFloats;
|
|
966
|
-
ring.records[offset] = frame;
|
|
967
|
-
ring.records[offset + 1] =
|
|
968
|
-
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);
|
|
969
1142
|
Atomics.store(ring.header, 0, writeIndex + 1);
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
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.)
|
|
973
1147
|
}
|
|
974
1148
|
}
|
|
975
1149
|
|
|
@@ -981,6 +1155,7 @@ export class SonareWorkletProcessor {
|
|
|
981
1155
|
* load the dedicated Emscripten AudioWorklet module.
|
|
982
1156
|
*/
|
|
983
1157
|
export class SonareRealtimeEngineWorkletProcessor {
|
|
1158
|
+
private static warnedChannelScratchOverflow = false;
|
|
984
1159
|
readonly sampleRate: number;
|
|
985
1160
|
readonly blockSize: number;
|
|
986
1161
|
readonly channelCount: number;
|
|
@@ -989,9 +1164,21 @@ export class SonareRealtimeEngineWorkletProcessor {
|
|
|
989
1164
|
private closed = false;
|
|
990
1165
|
private commandRing?: SonareEngineCommandRingBuffer;
|
|
991
1166
|
private telemetryRing?: SonareEngineTelemetryRingBuffer;
|
|
1167
|
+
private meterRing?: SharedMeterRingWriter;
|
|
992
1168
|
private transport?: WorkletTransport;
|
|
993
1169
|
private meterIntervalFrames: number;
|
|
994
1170
|
private lastMeterFrame = Number.NEGATIVE_INFINITY;
|
|
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[];
|
|
995
1182
|
|
|
996
1183
|
constructor(
|
|
997
1184
|
options: SonareRealtimeEngineWorkletProcessorOptions = {},
|
|
@@ -1017,7 +1204,17 @@ export class SonareRealtimeEngineWorkletProcessor {
|
|
|
1017
1204
|
options.telemetryRingCapacity,
|
|
1018
1205
|
)
|
|
1019
1206
|
: undefined;
|
|
1207
|
+
this.meterRing = options.meterSharedBuffer
|
|
1208
|
+
? meterRingFromSharedBuffer(options.meterSharedBuffer, options.meterRingCapacity)
|
|
1209
|
+
: undefined;
|
|
1020
1210
|
this.engine = new RealtimeEngine(this.sampleRate, this.blockSize);
|
|
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);
|
|
1215
|
+
for (let ch = 0; ch < this.channelCount; ch++) {
|
|
1216
|
+
this.channelBuffers[ch] = this.engine.getChannelBuffer(ch, this.blockSize);
|
|
1217
|
+
}
|
|
1021
1218
|
}
|
|
1022
1219
|
|
|
1023
1220
|
process(inputs: WorkletInput, outputs: WorkletOutput): boolean {
|
|
@@ -1040,23 +1237,55 @@ export class SonareRealtimeEngineWorkletProcessor {
|
|
|
1040
1237
|
|
|
1041
1238
|
this.drainCommands();
|
|
1042
1239
|
|
|
1043
|
-
|
|
1240
|
+
// Clamp `frames` to the pre-allocated scratch capacity. The earlier
|
|
1241
|
+
// `frames > this.blockSize` branch already returns early, so this is
|
|
1242
|
+
// defensive — but we warn once if it ever fires so the contract violation
|
|
1243
|
+
// is visible.
|
|
1244
|
+
let usableFrames = frames;
|
|
1245
|
+
if (usableFrames > this.blockSize) {
|
|
1246
|
+
if (!SonareRealtimeEngineWorkletProcessor.warnedChannelScratchOverflow) {
|
|
1247
|
+
SonareRealtimeEngineWorkletProcessor.warnedChannelScratchOverflow = true;
|
|
1248
|
+
// biome-ignore lint/suspicious/noConsole: realtime-safety diagnostic.
|
|
1249
|
+
console.warn(
|
|
1250
|
+
`SonareRealtimeEngineWorkletProcessor: requested ${usableFrames} frames ` +
|
|
1251
|
+
`exceeds pre-allocated capacity ${this.blockSize}; clamping.`,
|
|
1252
|
+
);
|
|
1253
|
+
}
|
|
1254
|
+
usableFrames = this.blockSize;
|
|
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
|
+
|
|
1044
1265
|
const input = inputs[0];
|
|
1266
|
+
// Write the AudioWorklet input straight into the engine's WASM-heap views;
|
|
1267
|
+
// no per-block heap allocation.
|
|
1045
1268
|
for (let ch = 0; ch < this.channelCount; ch++) {
|
|
1269
|
+
const dst = this.channelBuffers[ch];
|
|
1046
1270
|
const source = input?.[ch];
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1271
|
+
if (source && source.length === usableFrames) {
|
|
1272
|
+
dst.set(source.subarray(0, usableFrames));
|
|
1273
|
+
} else {
|
|
1274
|
+
dst.fill(0, 0, usableFrames);
|
|
1050
1275
|
}
|
|
1051
|
-
channels.push(channel);
|
|
1052
1276
|
}
|
|
1053
1277
|
|
|
1054
|
-
|
|
1278
|
+
// Run the engine in place over the prepared scratch (allocation-free).
|
|
1279
|
+
this.engine.processPrepared(usableFrames);
|
|
1280
|
+
|
|
1055
1281
|
for (let ch = 0; ch < output.length; ch++) {
|
|
1056
1282
|
const target = output[ch];
|
|
1057
|
-
const source =
|
|
1283
|
+
const source = this.channelBuffers[ch] ?? this.channelBuffers[0];
|
|
1058
1284
|
if (source) {
|
|
1059
|
-
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
|
+
}
|
|
1060
1289
|
} else {
|
|
1061
1290
|
target.fill(0);
|
|
1062
1291
|
}
|
|
@@ -1066,12 +1295,44 @@ export class SonareRealtimeEngineWorkletProcessor {
|
|
|
1066
1295
|
return true;
|
|
1067
1296
|
}
|
|
1068
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
|
+
|
|
1069
1304
|
receiveCommand(command: SonareEngineCommandRecord): void {
|
|
1070
1305
|
if (!this.closed) {
|
|
1071
1306
|
this.applyCommand(command);
|
|
1072
1307
|
}
|
|
1073
1308
|
}
|
|
1074
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
|
+
|
|
1075
1336
|
destroy(): void {
|
|
1076
1337
|
if (!this.closed) {
|
|
1077
1338
|
this.engine.destroy();
|
|
@@ -1095,6 +1356,22 @@ export class SonareRealtimeEngineWorkletProcessor {
|
|
|
1095
1356
|
private applyCommand(command: SonareEngineCommandRecord): void {
|
|
1096
1357
|
const sampleTime = Number(command.sampleTime ?? -1);
|
|
1097
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;
|
|
1098
1375
|
case SonareEngineCommandType.TransportPlay:
|
|
1099
1376
|
this.engine.play(sampleTime);
|
|
1100
1377
|
break;
|
|
@@ -1121,20 +1398,31 @@ export class SonareRealtimeEngineWorkletProcessor {
|
|
|
1121
1398
|
this.engine.armCapture(Boolean(command.argInt));
|
|
1122
1399
|
break;
|
|
1123
1400
|
case SonareEngineCommandType.Punch:
|
|
1401
|
+
// Both endpoints already arrive as samples (see SonareEngine.punch);
|
|
1402
|
+
// do NOT re-scale by sampleRate.
|
|
1124
1403
|
this.engine.setCapturePunch(
|
|
1125
1404
|
Number(command.argInt ?? 0),
|
|
1126
|
-
Math.max(0, Math.round(Number(command.argFloat ?? 0)
|
|
1405
|
+
Math.max(0, Math.round(Number(command.argFloat ?? 0))),
|
|
1127
1406
|
true,
|
|
1128
1407
|
);
|
|
1129
1408
|
break;
|
|
1130
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.
|
|
1131
1414
|
this.engine.setMetronome({
|
|
1132
1415
|
enabled: Boolean(command.argInt),
|
|
1133
|
-
beatGain:
|
|
1134
|
-
accentGain:
|
|
1135
|
-
clickSamples:
|
|
1416
|
+
beatGain: this.metronomeConfig.beatGain,
|
|
1417
|
+
accentGain: this.metronomeConfig.accentGain,
|
|
1418
|
+
clickSamples: this.metronomeConfig.clickSamples,
|
|
1136
1419
|
});
|
|
1137
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;
|
|
1138
1426
|
default:
|
|
1139
1427
|
this.publishTelemetryRecord({
|
|
1140
1428
|
type: SonareEngineTelemetryType.Error,
|
|
@@ -1164,7 +1452,7 @@ export class SonareRealtimeEngineWorkletProcessor {
|
|
|
1164
1452
|
}
|
|
1165
1453
|
|
|
1166
1454
|
private publishMeters(): void {
|
|
1167
|
-
if (
|
|
1455
|
+
if (this.meterIntervalFrames <= 0 || (!this.transport && !this.meterRing)) {
|
|
1168
1456
|
return;
|
|
1169
1457
|
}
|
|
1170
1458
|
for (const item of this.engine.drainMeterTelemetry(64)) {
|
|
@@ -1173,11 +1461,41 @@ export class SonareRealtimeEngineWorkletProcessor {
|
|
|
1173
1461
|
continue;
|
|
1174
1462
|
}
|
|
1175
1463
|
this.lastMeterFrame = meter.frame;
|
|
1176
|
-
|
|
1177
|
-
|
|
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
|
+
}
|
|
1178
1474
|
}
|
|
1179
1475
|
}
|
|
1180
1476
|
|
|
1477
|
+
private writeMeterRing(meter: SonareWorkletMeterSnapshot): void {
|
|
1478
|
+
const ring = this.meterRing;
|
|
1479
|
+
if (!ring) {
|
|
1480
|
+
return;
|
|
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.
|
|
1497
|
+
}
|
|
1498
|
+
|
|
1181
1499
|
private commandRingFromSharedBuffer(
|
|
1182
1500
|
sharedBuffer: SharedArrayBuffer,
|
|
1183
1501
|
fallbackCapacity?: number,
|
|
@@ -1216,6 +1534,7 @@ export class SonareRtRealtimeEngineRuntime {
|
|
|
1216
1534
|
private readonly telemetryFramesPtr: number;
|
|
1217
1535
|
private readonly commandRing?: SonareEngineCommandRingBuffer;
|
|
1218
1536
|
private readonly telemetryRing?: SonareEngineTelemetryRingBuffer;
|
|
1537
|
+
private metronomeConfig: ResolvedMetronomeConfig = { ...DEFAULT_METRONOME_CONFIG };
|
|
1219
1538
|
private closed = false;
|
|
1220
1539
|
|
|
1221
1540
|
constructor(options: SonareRtRealtimeEngineRuntimeOptions) {
|
|
@@ -1311,6 +1630,54 @@ export class SonareRtRealtimeEngineRuntime {
|
|
|
1311
1630
|
return true;
|
|
1312
1631
|
}
|
|
1313
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
|
+
|
|
1314
1681
|
destroy(): void {
|
|
1315
1682
|
if (this.closed) {
|
|
1316
1683
|
return;
|
|
@@ -1349,6 +1716,25 @@ export class SonareRtRealtimeEngineRuntime {
|
|
|
1349
1716
|
private applyCommand(command: SonareEngineCommandRecord): void {
|
|
1350
1717
|
const sampleTime = toBigInt64(command.sampleTime, -1n);
|
|
1351
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;
|
|
1352
1738
|
case SonareEngineCommandType.TransportPlay:
|
|
1353
1739
|
this.module._sonare_rt_engine_play(this.engine, sampleTime);
|
|
1354
1740
|
break;
|
|
@@ -1384,10 +1770,12 @@ export class SonareRtRealtimeEngineRuntime {
|
|
|
1384
1770
|
this.module._sonare_rt_engine_set_capture_armed(this.engine, command.argInt ? 1 : 0);
|
|
1385
1771
|
break;
|
|
1386
1772
|
case SonareEngineCommandType.Punch:
|
|
1773
|
+
// Both endpoints already arrive as samples (see SonareEngine.punch);
|
|
1774
|
+
// do NOT re-scale by sampleRate.
|
|
1387
1775
|
this.module._sonare_rt_engine_set_capture_punch(
|
|
1388
1776
|
this.engine,
|
|
1389
1777
|
toBigInt64(command.argInt, 0n),
|
|
1390
|
-
BigInt(Math.
|
|
1778
|
+
BigInt(Math.max(0, Math.round(Number(command.argFloat ?? 0)))),
|
|
1391
1779
|
1,
|
|
1392
1780
|
);
|
|
1393
1781
|
break;
|
|
@@ -1395,9 +1783,9 @@ export class SonareRtRealtimeEngineRuntime {
|
|
|
1395
1783
|
this.module._sonare_rt_engine_set_metronome_enabled(
|
|
1396
1784
|
this.engine,
|
|
1397
1785
|
command.argInt ? 1 : 0,
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1786
|
+
this.metronomeConfig.beatGain,
|
|
1787
|
+
this.metronomeConfig.accentGain,
|
|
1788
|
+
this.metronomeConfig.clickSamples,
|
|
1401
1789
|
);
|
|
1402
1790
|
break;
|
|
1403
1791
|
case SonareEngineCommandType.SeekMarker:
|
|
@@ -1486,8 +1874,10 @@ export class SonareRealtimeEngineNode {
|
|
|
1486
1874
|
readonly capabilities: SonareRealtimeEngineNodeCapabilities;
|
|
1487
1875
|
readonly commandRing?: SonareEngineCommandRingBuffer;
|
|
1488
1876
|
readonly telemetryRing?: SonareEngineTelemetryRingBuffer;
|
|
1877
|
+
readonly meterRing?: SonareMeterRingBuffer;
|
|
1489
1878
|
readonly ready: Promise<void>;
|
|
1490
1879
|
private telemetryReadIndex = 0;
|
|
1880
|
+
private meterReadIndex = 0;
|
|
1491
1881
|
private telemetryListeners = new Set<(telemetry: SonareEngineTelemetryRecord) => void>();
|
|
1492
1882
|
private meterListeners = new Set<(meter: SonareWorkletMeterSnapshot) => void>();
|
|
1493
1883
|
private resolveReady!: () => void;
|
|
@@ -1499,11 +1889,13 @@ export class SonareRealtimeEngineNode {
|
|
|
1499
1889
|
capabilities: SonareRealtimeEngineNodeCapabilities,
|
|
1500
1890
|
commandRing?: SonareEngineCommandRingBuffer,
|
|
1501
1891
|
telemetryRing?: SonareEngineTelemetryRingBuffer,
|
|
1892
|
+
meterRing?: SonareMeterRingBuffer,
|
|
1502
1893
|
) {
|
|
1503
1894
|
this.node = node;
|
|
1504
1895
|
this.capabilities = capabilities;
|
|
1505
1896
|
this.commandRing = commandRing;
|
|
1506
1897
|
this.telemetryRing = telemetryRing;
|
|
1898
|
+
this.meterRing = meterRing;
|
|
1507
1899
|
this.ready = new Promise((resolve, reject) => {
|
|
1508
1900
|
this.resolveReady = resolve;
|
|
1509
1901
|
this.rejectReady = reject;
|
|
@@ -1574,6 +1966,14 @@ export class SonareRealtimeEngineNode {
|
|
|
1574
1966
|
mode === 'sab'
|
|
1575
1967
|
? createSonareEngineTelemetryRingBuffer(options.telemetryRingCapacity ?? 128)
|
|
1576
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;
|
|
1577
1977
|
const channelCount = Math.max(1, Math.floor(options.channelCount ?? 2));
|
|
1578
1978
|
const processorOptions: SonareRealtimeEngineWorkletProcessorOptions = {
|
|
1579
1979
|
runtimeTarget,
|
|
@@ -1586,6 +1986,8 @@ export class SonareRealtimeEngineNode {
|
|
|
1586
1986
|
commandRingCapacity: commandRing?.capacity,
|
|
1587
1987
|
telemetrySharedBuffer: telemetryRing?.sharedBuffer,
|
|
1588
1988
|
telemetryRingCapacity: telemetryRing?.capacity,
|
|
1989
|
+
meterSharedBuffer: meterRing?.sharedBuffer,
|
|
1990
|
+
meterRingCapacity: meterRing?.capacity,
|
|
1589
1991
|
};
|
|
1590
1992
|
const factory =
|
|
1591
1993
|
options.nodeFactory ??
|
|
@@ -1612,6 +2014,7 @@ export class SonareRealtimeEngineNode {
|
|
|
1612
2014
|
},
|
|
1613
2015
|
commandRing,
|
|
1614
2016
|
telemetryRing,
|
|
2017
|
+
meterRing,
|
|
1615
2018
|
);
|
|
1616
2019
|
}
|
|
1617
2020
|
|
|
@@ -1662,6 +2065,21 @@ export class SonareRealtimeEngineNode {
|
|
|
1662
2065
|
return read.telemetry;
|
|
1663
2066
|
}
|
|
1664
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
|
+
|
|
1665
2083
|
onTelemetry(callback: (telemetry: SonareEngineTelemetryRecord) => void): () => void {
|
|
1666
2084
|
this.telemetryListeners.add(callback);
|
|
1667
2085
|
return () => {
|
|
@@ -1797,6 +2215,15 @@ export class SonareEngine {
|
|
|
1797
2215
|
|
|
1798
2216
|
setLoop(startPpq: number, endPpq: number, enabled = true): boolean {
|
|
1799
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.
|
|
1800
2227
|
return this.realtimeNode.sendCommand({
|
|
1801
2228
|
type: SonareEngineCommandType.SetLoop,
|
|
1802
2229
|
targetId: enabled ? 1 : 0,
|
|
@@ -1807,10 +2234,17 @@ export class SonareEngine {
|
|
|
1807
2234
|
}
|
|
1808
2235
|
|
|
1809
2236
|
setParam(nodeId: string, param: string | number, value: number): boolean {
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
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
|
+
});
|
|
1814
2248
|
}
|
|
1815
2249
|
|
|
1816
2250
|
scheduleParam(
|
|
@@ -1826,6 +2260,11 @@ export class SonareEngine {
|
|
|
1826
2260
|
lane.sort((a, b) => a.ppq - b.ppq);
|
|
1827
2261
|
this.automationLanes.set(paramId, lane);
|
|
1828
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 });
|
|
1829
2268
|
}
|
|
1830
2269
|
|
|
1831
2270
|
addAutomationPoint(
|
|
@@ -1849,7 +2288,16 @@ export class SonareEngine {
|
|
|
1849
2288
|
void target;
|
|
1850
2289
|
void solo;
|
|
1851
2290
|
void mute;
|
|
1852
|
-
|
|
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
|
+
);
|
|
1853
2301
|
}
|
|
1854
2302
|
|
|
1855
2303
|
addClip(
|
|
@@ -1890,16 +2338,25 @@ export class SonareEngine {
|
|
|
1890
2338
|
const inSample = this.ppqToApproxSample(inPpq);
|
|
1891
2339
|
const outSample = this.ppqToApproxSample(outPpq);
|
|
1892
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).
|
|
1893
2346
|
return this.realtimeNode.sendCommand({
|
|
1894
2347
|
type: SonareEngineCommandType.Punch,
|
|
1895
2348
|
sampleTime: -1,
|
|
1896
2349
|
argInt: inSample,
|
|
1897
|
-
argFloat:
|
|
2350
|
+
argFloat: outSample,
|
|
1898
2351
|
});
|
|
1899
2352
|
}
|
|
1900
2353
|
|
|
1901
2354
|
setMetronome(opts: EngineMetronomeConfig): void {
|
|
1902
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 });
|
|
1903
2360
|
this.realtimeNode.sendCommand({
|
|
1904
2361
|
type: SonareEngineCommandType.SetMetronome,
|
|
1905
2362
|
sampleTime: -1,
|
|
@@ -1916,7 +2373,15 @@ export class SonareEngine {
|
|
|
1916
2373
|
|
|
1917
2374
|
seekMarker(markerId: number): boolean {
|
|
1918
2375
|
this.offlineEngine.seekMarker(markerId);
|
|
1919
|
-
|
|
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
|
+
});
|
|
1920
2385
|
}
|
|
1921
2386
|
|
|
1922
2387
|
async renderOffline(totalFrames: number): Promise<Float32Array[]> {
|
|
@@ -1940,6 +2405,10 @@ export class SonareEngine {
|
|
|
1940
2405
|
return this.realtimeNode.pollTelemetry();
|
|
1941
2406
|
}
|
|
1942
2407
|
|
|
2408
|
+
pollMeters(): SonareWorkletMeterSnapshot[] {
|
|
2409
|
+
return this.realtimeNode.pollMeters();
|
|
2410
|
+
}
|
|
2411
|
+
|
|
1943
2412
|
destroy(): void {
|
|
1944
2413
|
if (this.destroyed) {
|
|
1945
2414
|
return;
|
|
@@ -1952,11 +2421,29 @@ export class SonareEngine {
|
|
|
1952
2421
|
}
|
|
1953
2422
|
|
|
1954
2423
|
private syncClips(): void {
|
|
1955
|
-
|
|
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 });
|
|
1956
2431
|
}
|
|
1957
2432
|
|
|
1958
2433
|
private syncMarkers(): void {
|
|
1959
|
-
|
|
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);
|
|
1960
2447
|
}
|
|
1961
2448
|
|
|
1962
2449
|
private resolveParamId(nodeId: string, param: string | number): number {
|
|
@@ -1990,6 +2477,203 @@ export class SonareEngine {
|
|
|
1990
2477
|
}
|
|
1991
2478
|
}
|
|
1992
2479
|
|
|
2480
|
+
export class SonareRealtimeVoiceChangerWorkletProcessor {
|
|
2481
|
+
private static warnedMonoOverflow = false;
|
|
2482
|
+
private static warnedInterleavedOverflow = false;
|
|
2483
|
+
private changer: RealtimeVoiceChanger;
|
|
2484
|
+
private readonly sampleRate: number;
|
|
2485
|
+
private readonly blockSize: number;
|
|
2486
|
+
private readonly channelCount: number;
|
|
2487
|
+
// WASM-heap typed-memory views, sized to the worst case (blockSize *
|
|
2488
|
+
// channelCount). Acquired on the main thread (constructor) so the
|
|
2489
|
+
// audio-thread process() never crosses an allocation boundary.
|
|
2490
|
+
private monoInput: Float32Array;
|
|
2491
|
+
private monoOutput: Float32Array;
|
|
2492
|
+
// Planar heap-backed views (one Float32Array per channel) used by the
|
|
2493
|
+
// multi-channel path. AudioWorklet inputs/outputs are already planar
|
|
2494
|
+
// Float32Arrays, so this avoids the per-sample interleave/deinterleave
|
|
2495
|
+
// passes that the older interleaved path needed.
|
|
2496
|
+
private planarChannels: Float32Array[];
|
|
2497
|
+
private destroyed = false;
|
|
2498
|
+
|
|
2499
|
+
constructor(options: SonareRealtimeVoiceChangerWorkletProcessorOptions = {}) {
|
|
2500
|
+
this.sampleRate = options.sampleRate ?? 48000;
|
|
2501
|
+
this.blockSize = options.blockSize ?? 128;
|
|
2502
|
+
this.channelCount = Math.max(1, Math.floor(options.channelCount ?? 1));
|
|
2503
|
+
this.changer = new RealtimeVoiceChanger(options.preset ?? 'neutral-monitor');
|
|
2504
|
+
this.changer.prepare(this.sampleRate, this.blockSize, this.channelCount);
|
|
2505
|
+
// Acquire WASM-heap views once, sized to the worst case. These are alive
|
|
2506
|
+
// for the lifetime of the changer; if the host requests more frames per
|
|
2507
|
+
// process() than blockSize, we clamp (see ensure*Capacity).
|
|
2508
|
+
this.monoInput = this.changer.getMonoInputBuffer(this.blockSize);
|
|
2509
|
+
this.monoOutput = this.changer.getMonoOutputBuffer(this.blockSize);
|
|
2510
|
+
this.planarChannels = [];
|
|
2511
|
+
if (this.channelCount > 1) {
|
|
2512
|
+
for (let ch = 0; ch < this.channelCount; ch++) {
|
|
2513
|
+
this.planarChannels.push(this.changer.getPlanarChannelBuffer(ch, this.blockSize));
|
|
2514
|
+
}
|
|
2515
|
+
}
|
|
2516
|
+
}
|
|
2517
|
+
|
|
2518
|
+
/**
|
|
2519
|
+
* Handles a control-plane message from the main thread. Runs on the
|
|
2520
|
+
* AudioWorklet global scope but OUTSIDE of `process()` (i.e. outside the
|
|
2521
|
+
* realtime audio callback), so it is safe to perform JSON parsing and
|
|
2522
|
+
* DSP coefficient recomputation here. `setConfig` MUST NOT be deferred
|
|
2523
|
+
* into `process()` because that would block the audio thread for longer
|
|
2524
|
+
* than one render quantum (e.g. 128 samples / 44.1 kHz = ~2.9 ms).
|
|
2525
|
+
*/
|
|
2526
|
+
receiveMessage(message: SonareRealtimeVoiceChangerMessage): void {
|
|
2527
|
+
if (this.destroyed) {
|
|
2528
|
+
return;
|
|
2529
|
+
}
|
|
2530
|
+
if (message.type === 'setConfig') {
|
|
2531
|
+
// Apply synchronously on the message-handler thread. `setConfig` may
|
|
2532
|
+
// allocate and parse JSON internally; doing it here keeps `process()`
|
|
2533
|
+
// realtime-safe.
|
|
2534
|
+
this.changer.setConfig(message.preset);
|
|
2535
|
+
} else if (message.type === 'reset') {
|
|
2536
|
+
this.changer.reset();
|
|
2537
|
+
} else if (message.type === 'destroy') {
|
|
2538
|
+
this.destroy();
|
|
2539
|
+
}
|
|
2540
|
+
}
|
|
2541
|
+
|
|
2542
|
+
process(inputs: WorkletInput, outputs: WorkletOutput): boolean {
|
|
2543
|
+
const output = outputs[0];
|
|
2544
|
+
if (this.destroyed || !output || output.length === 0) {
|
|
2545
|
+
return !this.destroyed;
|
|
2546
|
+
}
|
|
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
|
+
|
|
2556
|
+
const input = inputs[0];
|
|
2557
|
+
const requestedFrames = output[0]?.length ?? 0;
|
|
2558
|
+
const requestedChannels = Math.min(this.channelCount, output.length);
|
|
2559
|
+
if (requestedFrames === 0 || requestedChannels === 0) {
|
|
2560
|
+
return true;
|
|
2561
|
+
}
|
|
2562
|
+
|
|
2563
|
+
if (requestedChannels === 1) {
|
|
2564
|
+
// Clamp to the pre-allocated capacity; warn (once) if the host violated
|
|
2565
|
+
// the contract. We never reallocate on the audio thread.
|
|
2566
|
+
const frames = this.ensureMonoCapacity(requestedFrames);
|
|
2567
|
+
const source = input?.[0];
|
|
2568
|
+
if (source) {
|
|
2569
|
+
this.monoInput.set(source.subarray(0, frames));
|
|
2570
|
+
} else {
|
|
2571
|
+
this.monoInput.fill(0, 0, frames);
|
|
2572
|
+
}
|
|
2573
|
+
this.changer.processMonoInto(
|
|
2574
|
+
this.monoInput.subarray(0, frames),
|
|
2575
|
+
this.monoOutput.subarray(0, frames),
|
|
2576
|
+
);
|
|
2577
|
+
output[0].set(this.monoOutput.subarray(0, frames));
|
|
2578
|
+
return true;
|
|
2579
|
+
}
|
|
2580
|
+
|
|
2581
|
+
const frames = this.ensureInterleavedCapacity(requestedFrames, requestedChannels);
|
|
2582
|
+
const channels = requestedChannels;
|
|
2583
|
+
// Planar zero-copy path: AudioWorklet's input[ch] is already a
|
|
2584
|
+
// Float32Array per channel, so we set() straight into the heap-backed
|
|
2585
|
+
// planar view and processPreparedPlanar runs in place.
|
|
2586
|
+
for (let ch = 0; ch < channels; ch++) {
|
|
2587
|
+
const src = input?.[ch];
|
|
2588
|
+
const dst = this.planarChannels[ch];
|
|
2589
|
+
if (!dst) {
|
|
2590
|
+
continue;
|
|
2591
|
+
}
|
|
2592
|
+
if (src) {
|
|
2593
|
+
dst.set(src.subarray(0, frames));
|
|
2594
|
+
} else {
|
|
2595
|
+
dst.fill(0, 0, frames);
|
|
2596
|
+
}
|
|
2597
|
+
}
|
|
2598
|
+
this.changer.processPreparedPlanar(frames);
|
|
2599
|
+
for (let ch = 0; ch < channels; ch++) {
|
|
2600
|
+
const src = this.planarChannels[ch];
|
|
2601
|
+
if (src) {
|
|
2602
|
+
output[ch].set(src.subarray(0, frames));
|
|
2603
|
+
}
|
|
2604
|
+
// No `for frame` inner loop needed; output[ch] is a Float32Array.
|
|
2605
|
+
}
|
|
2606
|
+
return true;
|
|
2607
|
+
}
|
|
2608
|
+
|
|
2609
|
+
destroy(): void {
|
|
2610
|
+
if (this.destroyed) {
|
|
2611
|
+
return;
|
|
2612
|
+
}
|
|
2613
|
+
this.destroyed = true;
|
|
2614
|
+
this.changer.delete();
|
|
2615
|
+
}
|
|
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
|
+
|
|
2631
|
+
/**
|
|
2632
|
+
* Returns the number of frames we can actually process given the
|
|
2633
|
+
* pre-allocated capacity. If the host requests more frames than the
|
|
2634
|
+
* worst-case block size declared at construction time, we clamp to the
|
|
2635
|
+
* available capacity and warn once — we MUST NOT reallocate on the
|
|
2636
|
+
* realtime audio thread.
|
|
2637
|
+
*/
|
|
2638
|
+
private ensureMonoCapacity(frames: number): number {
|
|
2639
|
+
const capacity = this.monoInput.length;
|
|
2640
|
+
if (frames <= capacity) {
|
|
2641
|
+
return frames;
|
|
2642
|
+
}
|
|
2643
|
+
if (!SonareRealtimeVoiceChangerWorkletProcessor.warnedMonoOverflow) {
|
|
2644
|
+
SonareRealtimeVoiceChangerWorkletProcessor.warnedMonoOverflow = true;
|
|
2645
|
+
// biome-ignore lint/suspicious/noConsole: realtime-safety diagnostic.
|
|
2646
|
+
console.warn(
|
|
2647
|
+
`SonareRealtimeVoiceChangerWorkletProcessor: requested ${frames} mono frames ` +
|
|
2648
|
+
`exceeds pre-allocated capacity ${capacity}; clamping. ` +
|
|
2649
|
+
'Increase blockSize at construction time to avoid this.',
|
|
2650
|
+
);
|
|
2651
|
+
}
|
|
2652
|
+
return capacity;
|
|
2653
|
+
}
|
|
2654
|
+
|
|
2655
|
+
/**
|
|
2656
|
+
* Same contract as ensureMonoCapacity but for the planar per-channel
|
|
2657
|
+
* scratch. Returns the number of frames that fit in the available capacity.
|
|
2658
|
+
*/
|
|
2659
|
+
private ensureInterleavedCapacity(frames: number, channels: number): number {
|
|
2660
|
+
const capacity = this.planarChannels[0]?.length ?? 0;
|
|
2661
|
+
if (frames <= capacity) {
|
|
2662
|
+
return frames;
|
|
2663
|
+
}
|
|
2664
|
+
if (!SonareRealtimeVoiceChangerWorkletProcessor.warnedInterleavedOverflow) {
|
|
2665
|
+
SonareRealtimeVoiceChangerWorkletProcessor.warnedInterleavedOverflow = true;
|
|
2666
|
+
// biome-ignore lint/suspicious/noConsole: realtime-safety diagnostic.
|
|
2667
|
+
console.warn(
|
|
2668
|
+
`SonareRealtimeVoiceChangerWorkletProcessor: requested ${frames}x${channels} ` +
|
|
2669
|
+
`planar frames exceeds pre-allocated capacity ${capacity}; clamping. ` +
|
|
2670
|
+
'Increase blockSize or channelCount at construction time to avoid this.',
|
|
2671
|
+
);
|
|
2672
|
+
}
|
|
2673
|
+
return capacity;
|
|
2674
|
+
}
|
|
2675
|
+
}
|
|
2676
|
+
|
|
1993
2677
|
export function registerSonareWorkletProcessor(name = 'sonare-worklet-processor'): void {
|
|
1994
2678
|
const scope = globalThis as unknown as {
|
|
1995
2679
|
AudioWorkletProcessor?: new () => object;
|
|
@@ -2029,6 +2713,47 @@ export function registerSonareWorkletProcessor(name = 'sonare-worklet-processor'
|
|
|
2029
2713
|
scope.registerProcessor(name, RegisteredSonareWorkletProcessor);
|
|
2030
2714
|
}
|
|
2031
2715
|
|
|
2716
|
+
export function registerSonareRealtimeVoiceChangerWorkletProcessor(
|
|
2717
|
+
name = 'sonare-realtime-voice-changer-processor',
|
|
2718
|
+
): void {
|
|
2719
|
+
const scope = globalThis as unknown as {
|
|
2720
|
+
AudioWorkletProcessor?: new () => object;
|
|
2721
|
+
registerProcessor?: (processorName: string, processorCtor: unknown) => void;
|
|
2722
|
+
};
|
|
2723
|
+
if (!scope.AudioWorkletProcessor || !scope.registerProcessor) {
|
|
2724
|
+
throw new Error('AudioWorkletProcessor is not available in this context.');
|
|
2725
|
+
}
|
|
2726
|
+
const Base = scope.AudioWorkletProcessor;
|
|
2727
|
+
class RegisteredSonareRealtimeVoiceChangerWorkletProcessor extends Base {
|
|
2728
|
+
private bridge: SonareRealtimeVoiceChangerWorkletProcessor;
|
|
2729
|
+
readonly port?: WorkletPort;
|
|
2730
|
+
|
|
2731
|
+
constructor(options?: {
|
|
2732
|
+
processorOptions?: SonareRealtimeVoiceChangerWorkletProcessorOptions;
|
|
2733
|
+
}) {
|
|
2734
|
+
super();
|
|
2735
|
+
const port = this.port;
|
|
2736
|
+
this.bridge = new SonareRealtimeVoiceChangerWorkletProcessor(options?.processorOptions ?? {});
|
|
2737
|
+
const onMessage = (event: { data: unknown }) => {
|
|
2738
|
+
if (isRealtimeVoiceChangerMessage(event.data)) {
|
|
2739
|
+
this.bridge.receiveMessage(event.data);
|
|
2740
|
+
}
|
|
2741
|
+
};
|
|
2742
|
+
if (port?.addEventListener) {
|
|
2743
|
+
port.addEventListener('message', onMessage);
|
|
2744
|
+
port.start?.();
|
|
2745
|
+
} else if (port) {
|
|
2746
|
+
port.onmessage = onMessage;
|
|
2747
|
+
}
|
|
2748
|
+
}
|
|
2749
|
+
|
|
2750
|
+
process(inputs: WorkletInput, outputs: WorkletOutput): boolean {
|
|
2751
|
+
return this.bridge.process(inputs, outputs);
|
|
2752
|
+
}
|
|
2753
|
+
}
|
|
2754
|
+
scope.registerProcessor(name, RegisteredSonareRealtimeVoiceChangerWorkletProcessor);
|
|
2755
|
+
}
|
|
2756
|
+
|
|
2032
2757
|
export function registerSonareRealtimeEngineWorkletProcessor(
|
|
2033
2758
|
name = 'sonare-realtime-engine-processor',
|
|
2034
2759
|
): void {
|
|
@@ -2060,6 +2785,10 @@ export function registerSonareRealtimeEngineWorkletProcessor(
|
|
|
2060
2785
|
const onMessage = (event: { data: unknown }) => {
|
|
2061
2786
|
if (isEngineCommandRecord(event.data)) {
|
|
2062
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);
|
|
2063
2792
|
}
|
|
2064
2793
|
};
|
|
2065
2794
|
if (port?.addEventListener) {
|
|
@@ -2092,6 +2821,7 @@ export function registerSonareRealtimeEngineWorkletProcessor(
|
|
|
2092
2821
|
if (!options.rtModuleUrl) {
|
|
2093
2822
|
throw new Error('rtModuleUrl is required for sonare-rt AudioWorklet runtime.');
|
|
2094
2823
|
}
|
|
2824
|
+
const rtModuleUrl = options.rtModuleUrl;
|
|
2095
2825
|
const memory = new WebAssembly.Memory({ initial: 1024, maximum: 1024, shared: true });
|
|
2096
2826
|
const globalFactory = (
|
|
2097
2827
|
globalThis as typeof globalThis & {
|
|
@@ -2104,7 +2834,7 @@ export function registerSonareRealtimeEngineWorkletProcessor(
|
|
|
2104
2834
|
).SonareRtModuleFactory;
|
|
2105
2835
|
const moduleFactory = globalFactory
|
|
2106
2836
|
? { default: globalFactory }
|
|
2107
|
-
: ((await import(
|
|
2837
|
+
: ((await import(rtModuleUrl)) as {
|
|
2108
2838
|
default: (options?: {
|
|
2109
2839
|
wasmMemory?: WebAssembly.Memory;
|
|
2110
2840
|
wasmBinary?: ArrayBuffer | Uint8Array;
|
|
@@ -2114,7 +2844,7 @@ export function registerSonareRealtimeEngineWorkletProcessor(
|
|
|
2114
2844
|
const module = await moduleFactory.default({
|
|
2115
2845
|
wasmMemory: memory,
|
|
2116
2846
|
wasmBinary: options.rtWasmBinary,
|
|
2117
|
-
locateFile: (path) =>
|
|
2847
|
+
locateFile: (path) => rtModuleUrl.replace(/[^/]*$/, path),
|
|
2118
2848
|
});
|
|
2119
2849
|
this.rtBridge = new SonareRtRealtimeEngineRuntime({
|
|
2120
2850
|
module,
|