@libraz/libsonare 1.3.2 → 1.4.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/src/worklet.ts CHANGED
@@ -1,16 +1,33 @@
1
+ import { panLawCode, panModeCode } from './codes';
1
2
  import type {
2
3
  EngineAutomationPoint,
4
+ EngineBus,
5
+ EngineCaptureStatus,
3
6
  EngineClip,
4
7
  EngineMarker,
5
- EngineMeterTelemetry,
6
8
  EngineMetronomeConfig,
9
+ EngineMidiClipSchedule,
7
10
  EngineParameterInfo,
8
- EngineTelemetry,
11
+ EngineScopeTelemetry,
12
+ EngineTempoSegment,
13
+ EngineTimeSignatureSegment,
14
+ EngineTrackLane,
15
+ EngineTrackSend,
16
+ EngineTransportState,
17
+ EqBand,
9
18
  MixerRealtimeBuffer,
10
- RealtimeVoiceChangerConfigInput,
19
+ PanLaw,
20
+ PanMode,
11
21
  } from './index';
12
- import { engineCapabilities, Mixer, RealtimeEngine, RealtimeVoiceChanger } from './index';
13
- import type { AutomationCurve } from './public_types';
22
+ import {
23
+ engineCapabilities,
24
+ init as initSonareModule,
25
+ isInitialized,
26
+ Mixer,
27
+ RealtimeEngine,
28
+ RealtimeVoiceChanger,
29
+ } from './index';
30
+ import type { SonareModule } from './sonare.js';
14
31
  import type { SonareRtModule } from './sonare-rt';
15
32
 
16
33
  // With code-splitting disabled, the worklet bundle carries its own copy of the
@@ -20,100 +37,182 @@ import type { SonareRtModule } from './sonare-rt';
20
37
  // `index` module.
21
38
  export { init, isInitialized } from './index';
22
39
 
23
- export interface SonareWorkletProcessorOptions {
24
- sceneJson: string;
25
- sampleRate?: number;
26
- blockSize?: number;
27
- stripCount?: number;
28
- meterIntervalFrames?: number;
29
- meterSharedBuffer?: SharedArrayBuffer;
30
- meterRingCapacity?: number;
31
- spectrumIntervalFrames?: number;
32
- spectrumBands?: number;
33
- spectrumSharedBuffer?: SharedArrayBuffer;
34
- spectrumRingCapacity?: number;
35
- }
36
-
37
- export interface SonareRealtimeEngineWorkletProcessorOptions {
38
- runtimeTarget?: 'embind' | 'sonare-rt';
39
- rtModuleUrl?: string;
40
- rtWasmBinary?: ArrayBuffer | Uint8Array;
41
- sampleRate?: number;
42
- blockSize?: number;
43
- channelCount?: number;
44
- meterIntervalFrames?: number;
45
- commandSharedBuffer?: SharedArrayBuffer;
46
- commandRingCapacity?: number;
47
- telemetrySharedBuffer?: SharedArrayBuffer;
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;
63
- }
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
-
78
- export interface SonareRealtimeEngineNodeCapabilities {
79
- mode: 'sab' | 'postMessage';
80
- runtimeTarget: 'embind' | 'sonare-rt';
81
- sharedArrayBuffer: boolean;
82
- atomics: boolean;
83
- audioWorklet: boolean;
84
- engineAbiVersion?: number;
85
- expectedEngineAbiVersion?: number;
86
- abiCompatible?: boolean;
87
- degradedReason?: string;
88
- }
89
-
90
- export interface SonareRealtimeEngineNodeOptions
91
- extends SonareRealtimeEngineWorkletProcessorOptions {
92
- processorName?: string;
93
- moduleUrl?: string | URL;
94
- rtModuleUrl?: string;
95
- mode?: 'auto' | 'sab' | 'postMessage';
96
- engineAbiVersion?: number;
97
- expectedEngineAbiVersion?: number;
98
- requireAbiCompatible?: boolean;
99
- nodeFactory?: (
100
- context: BaseAudioContext,
101
- processorName: string,
102
- options: AudioWorkletNodeOptions,
103
- ) => AudioWorkletNode;
104
- }
105
-
106
- export interface SonareRtRealtimeEngineRuntimeOptions {
107
- module: SonareRtModule;
108
- memory: WebAssembly.Memory;
109
- sampleRate?: number;
110
- blockSize?: number;
111
- channelCount?: number;
112
- commandSharedBuffer?: SharedArrayBuffer;
113
- commandRingCapacity?: number;
114
- telemetrySharedBuffer?: SharedArrayBuffer;
115
- telemetryRingCapacity?: number;
116
- }
40
+ import type { WorkletInput, WorkletOutput } from './worklet/audio_types';
41
+ import {
42
+ isEngineCaptureRequestMessage,
43
+ isEngineCaptureResponseMessage,
44
+ isEngineCommandRecord,
45
+ isEngineSyncMessage,
46
+ isEngineTelemetryRecord,
47
+ isEngineTransportRequestMessage,
48
+ isEngineTransportResponseMessage,
49
+ isMeterSnapshot,
50
+ isRealtimeVoiceChangerMessage,
51
+ isWorkletMessage,
52
+ } from './worklet/guards';
53
+ import {
54
+ DEFAULT_METRONOME_CONFIG,
55
+ type ResolvedMetronomeConfig,
56
+ resolveMetronomeConfig,
57
+ type SonareEngineCaptureRequestMessage,
58
+ type SonareEngineCaptureResponseMessage,
59
+ type SonareEngineInstrumentSyncMessage,
60
+ type SonareEngineSyncCaptureMessage,
61
+ type SonareEngineSyncMessage,
62
+ type SonareEngineTransportFacade,
63
+ type SonareEngineTransportRequestMessage,
64
+ type SonareEngineTransportResponseMessage,
65
+ type SonareRealtimeEngineNodeCapabilities,
66
+ type SonareRealtimeEngineNodeOptions,
67
+ type SonareRealtimeEngineWorkletProcessorOptions,
68
+ type SonareRealtimeVoiceChangerMessage,
69
+ type SonareRealtimeVoiceChangerWorkletProcessorOptions,
70
+ type SonareRtRealtimeEngineRuntimeOptions,
71
+ type SonareWorkletMessage,
72
+ type SonareWorkletProcessorOptions,
73
+ type WorkletPort,
74
+ type WorkletTransport,
75
+ } from './worklet/messages';
76
+ // --- internal modules (split out of this file; bundled back into a single
77
+ // dist/worklet.js by tsup, so the public surface is unchanged) ---
78
+ import {
79
+ createSonareEngineCommandRingBuffer,
80
+ createSonareEngineTelemetryRingBuffer,
81
+ createSonareMeterRingBuffer,
82
+ createSonareScopeRingBuffer,
83
+ ENGINE_MIXER_PARAM_FADER_DB,
84
+ ENGINE_MIXER_PARAM_PAN,
85
+ encodeFrameHi,
86
+ encodeFrameLo,
87
+ engineMixerBusTarget,
88
+ engineMixerLaneTarget,
89
+ engineMixerMasterTarget,
90
+ engineRingFromSharedBuffer,
91
+ isRecord,
92
+ magnitudeToDb,
93
+ meterFromEngine,
94
+ meterRingFromSharedBuffer,
95
+ popSonareEngineCommandRingBuffer,
96
+ pushSonareEngineCommandRingBuffer,
97
+ readSonareEngineTelemetryRingBuffer,
98
+ readSonareMeterRingBuffer,
99
+ readSonareScopeRingBuffer,
100
+ type SharedMeterRingWriter,
101
+ type SharedScopeRingWriter,
102
+ type SharedSpectrumRingWriter,
103
+ SONARE_ENGINE_COMMAND_RECORD_BYTES,
104
+ SONARE_ENGINE_TELEMETRY_RECORD_BYTES,
105
+ SONARE_METER_RING_RECORD_FLOATS,
106
+ SONARE_SCOPE_RING_RECORD_PREFIX_FLOATS,
107
+ type SonareEngineCommandRecord,
108
+ type SonareEngineCommandRingBuffer,
109
+ SonareEngineCommandType,
110
+ SonareEngineTelemetryError,
111
+ type SonareEngineTelemetryRecord,
112
+ type SonareEngineTelemetryRingBuffer,
113
+ SonareEngineTelemetryType,
114
+ type SonareMeterRingBuffer,
115
+ type SonareScopeRingBuffer,
116
+ type SonareWorkletMeterSnapshot,
117
+ type SonareWorkletScopeSnapshot,
118
+ type SonareWorkletSpectrumSnapshot,
119
+ scopeRingFromSharedBuffer,
120
+ spectrumRingFromSharedBuffer,
121
+ telemetryFromEngine,
122
+ toBigInt64,
123
+ toDb,
124
+ writeSonareEngineTelemetryRingBuffer,
125
+ } from './worklet/protocol';
126
+
127
+ export type {
128
+ SonareEngineCaptureRequestMessage,
129
+ SonareEngineCaptureResponseMessage,
130
+ SonareEngineSyncAutomationMessage,
131
+ SonareEngineSyncBuiltinInstrumentMessage,
132
+ SonareEngineSyncCaptureMessage,
133
+ SonareEngineSyncClipsDeltaMessage,
134
+ SonareEngineSyncClipsMessage,
135
+ SonareEngineSyncLoadSoundFontMessage,
136
+ SonareEngineSyncMarkersMessage,
137
+ SonareEngineSyncMasterStripEqBandMessage,
138
+ SonareEngineSyncMasterStripInsertBypassedMessage,
139
+ SonareEngineSyncMessage,
140
+ SonareEngineSyncMetronomeMessage,
141
+ SonareEngineSyncMidiCcMessage,
142
+ SonareEngineSyncMidiClipsMessage,
143
+ SonareEngineSyncMidiNoteMessage,
144
+ SonareEngineSyncMidiPanicMessage,
145
+ SonareEngineSyncMixerMessage,
146
+ SonareEngineSyncSf2InstrumentMessage,
147
+ SonareEngineSyncSynthInstrumentMessage,
148
+ SonareEngineSyncTempoMessage,
149
+ SonareEngineSyncTrackStripEqBandMessage,
150
+ SonareEngineSyncTrackStripInsertBypassedMessage,
151
+ SonareEngineTransportFacade,
152
+ SonareEngineTransportRequestMessage,
153
+ SonareEngineTransportResponseMessage,
154
+ SonareRealtimeEngineNodeCapabilities,
155
+ SonareRealtimeEngineNodeOptions,
156
+ SonareRealtimeEngineWorkletProcessorOptions,
157
+ SonareRealtimeVoiceChangerDestroyMessage,
158
+ SonareRealtimeVoiceChangerMessage,
159
+ SonareRealtimeVoiceChangerResetMessage,
160
+ SonareRealtimeVoiceChangerSetConfigMessage,
161
+ SonareRealtimeVoiceChangerWorkletProcessorOptions,
162
+ SonareRtRealtimeEngineRuntimeOptions,
163
+ SonareWorkletDestroyMessage,
164
+ SonareWorkletMessage,
165
+ SonareWorkletProcessorOptions,
166
+ SonareWorkletScheduleInsertAutomationMessage,
167
+ SonareWorkletSetMeterIntervalMessage,
168
+ SonareWorkletTransportMessage,
169
+ } from './worklet/messages';
170
+ export {
171
+ createSonareEngineCommandRingBuffer,
172
+ createSonareEngineTelemetryRingBuffer,
173
+ createSonareMeterRingBuffer,
174
+ createSonareScopeRingBuffer,
175
+ createSonareSpectrumRingBuffer,
176
+ decodeFrame,
177
+ encodeFrameHi,
178
+ encodeFrameLo,
179
+ popSonareEngineCommandRingBuffer,
180
+ pushSonareEngineCommandRingBuffer,
181
+ readSonareEngineTelemetryRingBuffer,
182
+ readSonareMeterRingBuffer,
183
+ readSonareScopeRingBuffer,
184
+ readSonareSpectrumRingBuffer,
185
+ SONARE_ENGINE_COMMAND_RECORD_BYTES,
186
+ SONARE_ENGINE_RING_HEADER_INTS,
187
+ SONARE_ENGINE_TELEMETRY_RECORD_BYTES,
188
+ SONARE_METER_RING_HEADER_INTS,
189
+ SONARE_METER_RING_RECORD_FLOATS,
190
+ SONARE_SCOPE_RING_HEADER_INTS,
191
+ SONARE_SPECTRUM_RING_HEADER_INTS,
192
+ type SonareEngineCommandRecord,
193
+ type SonareEngineCommandRingBuffer,
194
+ SonareEngineCommandType,
195
+ SonareEngineTelemetryError,
196
+ type SonareEngineTelemetryRecord,
197
+ type SonareEngineTelemetryRingBuffer,
198
+ type SonareEngineTelemetryRingReadResult,
199
+ SonareEngineTelemetryType,
200
+ type SonareMeterRingBuffer,
201
+ type SonareMeterRingReadResult,
202
+ type SonareScopeRingBuffer,
203
+ type SonareScopeRingReadResult,
204
+ type SonareSpectrumRingBuffer,
205
+ type SonareSpectrumRingReadResult,
206
+ type SonareWorkletMeterSnapshot,
207
+ type SonareWorkletScopeSnapshot,
208
+ type SonareWorkletSpectrumSnapshot,
209
+ sonareEngineCommandRingBufferByteLength,
210
+ sonareEngineTelemetryRingBufferByteLength,
211
+ sonareMeterRingBufferByteLength,
212
+ sonareScopeRingBufferByteLength,
213
+ sonareSpectrumRingBufferByteLength,
214
+ writeSonareEngineTelemetryRingBuffer,
215
+ } from './worklet/protocol';
117
216
 
118
217
  export interface SonareEngineOptions extends SonareRealtimeEngineNodeOptions {
119
218
  offlineEngine?: RealtimeEngine;
@@ -121,739 +220,11 @@ export interface SonareEngineOptions extends SonareRealtimeEngineNodeOptions {
121
220
  offlineChannelCount?: number;
122
221
  }
123
222
 
124
- export interface SonareEngineTransportFacade {
125
- play(sampleTime?: number): boolean;
126
- stop(sampleTime?: number): boolean;
127
- seekPpq(ppq: number, sampleTime?: number): boolean;
128
- seekSeconds(seconds: number, sampleTime?: number): boolean;
129
- setTempo(bpm: number): void;
130
- setLoop(startPpq: number, endPpq: number, enabled?: boolean): boolean;
131
- }
132
-
133
223
  type SuspendableAudioContext = BaseAudioContext & {
134
224
  suspend?: () => Promise<void>;
135
225
  resume?: () => Promise<void>;
136
226
  };
137
227
 
138
- type WorkletInput = readonly (readonly Float32Array[])[];
139
- type WorkletOutput = Float32Array[][];
140
-
141
- export interface SonareWorkletScheduleInsertAutomationMessage {
142
- type: 'scheduleInsertAutomation';
143
- stripIndex: number;
144
- insertIndex: number;
145
- paramId: number;
146
- value: number;
147
- samplePos?: number;
148
- curve?: AutomationCurve;
149
- }
150
-
151
- export interface SonareWorkletSetMeterIntervalMessage {
152
- type: 'setMeterInterval';
153
- frames: number;
154
- }
155
-
156
- export interface SonareWorkletDestroyMessage {
157
- type: 'destroy';
158
- }
159
-
160
- export type SonareWorkletMessage =
161
- | SonareWorkletScheduleInsertAutomationMessage
162
- | SonareWorkletSetMeterIntervalMessage
163
- | SonareWorkletDestroyMessage;
164
-
165
- export interface SonareWorkletMeterSnapshot {
166
- type: 'meter';
167
- frame: number;
168
- peakDbL: number;
169
- peakDbR: number;
170
- rmsDbL: number;
171
- rmsDbR: number;
172
- correlation: number;
173
- }
174
-
175
- export interface SonareWorkletSpectrumSnapshot {
176
- type: 'spectrum';
177
- frame: number;
178
- bands: Float32Array;
179
- }
180
-
181
- export type SonareWorkletTransportMessage =
182
- | SonareWorkletMeterSnapshot
183
- | SonareWorkletSpectrumSnapshot
184
- | SonareEngineTelemetryRecord;
185
-
186
- export const SONARE_METER_RING_HEADER_INTS = 4;
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;
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
- }
214
- export const SONARE_ENGINE_RING_HEADER_INTS = 5;
215
- export const SONARE_ENGINE_COMMAND_RECORD_BYTES = 32;
216
- export const SONARE_ENGINE_TELEMETRY_RECORD_BYTES = 48;
217
-
218
- export enum SonareEngineCommandType {
219
- SetParam = 0,
220
- SetParamSmoothed = 1,
221
- TransportPlay = 2,
222
- TransportStop = 3,
223
- TransportSeekSample = 4,
224
- TransportSeekPpq = 5,
225
- SetTempoMap = 6,
226
- SetLoop = 7,
227
- SwapGraph = 8,
228
- SwapAutomation = 9,
229
- SetSoloMute = 10,
230
- AddClip = 11,
231
- RemoveClip = 12,
232
- ArmRecord = 13,
233
- Punch = 14,
234
- SetMetronome = 15,
235
- SetMarker = 16,
236
- SeekMarker = 17,
237
- }
238
-
239
- export enum SonareEngineTelemetryType {
240
- ProcessBlock = 0,
241
- Error = 1,
242
- }
243
-
244
- export enum SonareEngineTelemetryError {
245
- None = 0,
246
- CommandQueueOverflow = 1,
247
- PendingCommandOverflow = 2,
248
- BoundaryOverflow = 3,
249
- TelemetryOverflow = 4,
250
- CaptureOverflow = 5,
251
- MaxBlockExceeded = 6,
252
- UnknownTarget = 7,
253
- NonRealtimeSafeParameter = 8,
254
- NotPrepared = 9,
255
- NonQueueableCommand = 10,
256
- AutomationBindTargetOverflow = 11,
257
- StaleAutomationLanes = 12,
258
- SmoothedParameterCapacity = 13,
259
- }
260
-
261
- interface WorkletTransport {
262
- postMessage?: (message: SonareWorkletTransportMessage) => void;
263
- onMeter?: (meter: SonareWorkletMeterSnapshot) => void;
264
- onSpectrum?: (spectrum: SonareWorkletSpectrumSnapshot) => void;
265
- }
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
-
290
- export interface SonareMeterRingBuffer {
291
- sharedBuffer: SharedArrayBuffer;
292
- header: Int32Array;
293
- records: Float32Array;
294
- capacity: number;
295
- }
296
-
297
- export interface SonareMeterRingReadResult {
298
- nextReadIndex: number;
299
- meters: SonareWorkletMeterSnapshot[];
300
- }
301
-
302
- export interface SonareSpectrumRingBuffer {
303
- sharedBuffer: SharedArrayBuffer;
304
- header: Int32Array;
305
- records: Float32Array;
306
- capacity: number;
307
- bands: number;
308
- }
309
-
310
- export interface SonareSpectrumRingReadResult {
311
- nextReadIndex: number;
312
- spectra: SonareWorkletSpectrumSnapshot[];
313
- }
314
-
315
- export interface SonareEngineCommandRecord {
316
- type: SonareEngineCommandType | number;
317
- targetId?: number;
318
- sampleTime?: number | bigint;
319
- argFloat?: number;
320
- argInt?: number | bigint;
321
- }
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
-
356
- export interface SonareEngineTelemetryRecord {
357
- type: SonareEngineTelemetryType | number;
358
- error: SonareEngineTelemetryError | number;
359
- renderFrame: number;
360
- timelineSample: number;
361
- audibleTimelineSample: number;
362
- graphLatencySamplesQ8: number;
363
- value: number;
364
- }
365
-
366
- export interface SonareEngineCommandRingBuffer {
367
- sharedBuffer: SharedArrayBuffer;
368
- header: Int32Array;
369
- view: DataView;
370
- capacity: number;
371
- }
372
-
373
- export interface SonareEngineTelemetryRingBuffer {
374
- sharedBuffer: SharedArrayBuffer;
375
- header: Int32Array;
376
- view: DataView;
377
- capacity: number;
378
- }
379
-
380
- export interface SonareEngineTelemetryRingReadResult {
381
- nextReadIndex: number;
382
- telemetry: SonareEngineTelemetryRecord[];
383
- }
384
-
385
- interface SharedMeterRingWriter {
386
- header: Int32Array;
387
- records: Float32Array;
388
- capacity: number;
389
- }
390
-
391
- interface SharedSpectrumRingWriter {
392
- header: Int32Array;
393
- records: Float32Array;
394
- capacity: number;
395
- bands: number;
396
- recordFloats: number;
397
- }
398
-
399
- interface WorkletPort {
400
- postMessage?: (message: unknown) => void;
401
- onmessage?: (event: { data: unknown }) => void;
402
- addEventListener?: (type: 'message', listener: (event: { data: unknown }) => void) => void;
403
- start?: () => void;
404
- }
405
-
406
- function toDb(value: number): number {
407
- return value > 0 ? 20 * Math.log10(value) : Number.NEGATIVE_INFINITY;
408
- }
409
-
410
- function isRecord(value: unknown): value is Record<string, unknown> {
411
- return typeof value === 'object' && value !== null;
412
- }
413
-
414
- function isWorkletMessage(value: unknown): value is SonareWorkletMessage {
415
- if (!isRecord(value) || typeof value.type !== 'string') {
416
- return false;
417
- }
418
- return (
419
- value.type === 'scheduleInsertAutomation' ||
420
- value.type === 'setMeterInterval' ||
421
- value.type === 'destroy'
422
- );
423
- }
424
-
425
- function isEngineCommandRecord(value: unknown): value is SonareEngineCommandRecord {
426
- return isRecord(value) && typeof value.type === 'number';
427
- }
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
-
448
- function isEngineTelemetryRecord(value: unknown): value is SonareEngineTelemetryRecord {
449
- return (
450
- isRecord(value) &&
451
- typeof value.type === 'number' &&
452
- typeof value.error === 'number' &&
453
- typeof value.renderFrame === 'number' &&
454
- typeof value.timelineSample === 'number' &&
455
- typeof value.audibleTimelineSample === 'number' &&
456
- typeof value.graphLatencySamplesQ8 === 'number' &&
457
- typeof value.value === 'number'
458
- );
459
- }
460
-
461
- function isMeterSnapshot(value: unknown): value is SonareWorkletMeterSnapshot {
462
- return (
463
- isRecord(value) &&
464
- value.type === 'meter' &&
465
- typeof value.frame === 'number' &&
466
- typeof value.peakDbL === 'number' &&
467
- typeof value.peakDbR === 'number' &&
468
- typeof value.rmsDbL === 'number' &&
469
- typeof value.rmsDbR === 'number' &&
470
- typeof value.correlation === 'number'
471
- );
472
- }
473
-
474
- export function sonareMeterRingBufferByteLength(capacity: number): number {
475
- const clampedCapacity = Math.max(1, Math.floor(capacity));
476
- return (
477
- SONARE_METER_RING_HEADER_INTS * Int32Array.BYTES_PER_ELEMENT +
478
- clampedCapacity * SONARE_METER_RING_RECORD_FLOATS * Float32Array.BYTES_PER_ELEMENT
479
- );
480
- }
481
-
482
- export function createSonareMeterRingBuffer(capacity = 128): SonareMeterRingBuffer {
483
- const clampedCapacity = Math.max(1, Math.floor(capacity));
484
- const sharedBuffer = new SharedArrayBuffer(sonareMeterRingBufferByteLength(clampedCapacity));
485
- const ring = meterRingFromSharedBuffer(sharedBuffer, clampedCapacity);
486
- Atomics.store(ring.header, 0, 0);
487
- Atomics.store(ring.header, 1, clampedCapacity);
488
- Atomics.store(ring.header, 2, SONARE_METER_RING_RECORD_FLOATS);
489
- Atomics.store(ring.header, 3, 0);
490
- return { sharedBuffer, header: ring.header, records: ring.records, capacity: ring.capacity };
491
- }
492
-
493
- export function readSonareMeterRingBuffer(
494
- ring: SonareMeterRingBuffer,
495
- readIndex = 0,
496
- ): SonareMeterRingReadResult {
497
- const writeIndex = Atomics.load(ring.header, 0);
498
- const nextReadIndex = Math.max(0, Math.min(readIndex, writeIndex));
499
- const firstReadable = Math.max(nextReadIndex, writeIndex - ring.capacity);
500
- const meters: SonareWorkletMeterSnapshot[] = [];
501
- for (let index = firstReadable; index < writeIndex; index++) {
502
- const offset = (index % ring.capacity) * SONARE_METER_RING_RECORD_FLOATS;
503
- meters.push({
504
- type: 'meter',
505
- frame: decodeFrame(ring.records[offset], ring.records[offset + 6]),
506
- peakDbL: ring.records[offset + 1],
507
- peakDbR: ring.records[offset + 2],
508
- rmsDbL: ring.records[offset + 3],
509
- rmsDbR: ring.records[offset + 4],
510
- correlation: ring.records[offset + 5],
511
- });
512
- }
513
- return { nextReadIndex: writeIndex, meters };
514
- }
515
-
516
- export function sonareSpectrumRingBufferByteLength(capacity: number, bands = 16): number {
517
- const clampedCapacity = Math.max(1, Math.floor(capacity));
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.
521
- return (
522
- SONARE_SPECTRUM_RING_HEADER_INTS * Int32Array.BYTES_PER_ELEMENT +
523
- clampedCapacity * (3 + clampedBands) * Float32Array.BYTES_PER_ELEMENT
524
- );
525
- }
526
-
527
- export function createSonareSpectrumRingBuffer(
528
- capacity = 128,
529
- bands = 16,
530
- ): SonareSpectrumRingBuffer {
531
- const clampedCapacity = Math.max(1, Math.floor(capacity));
532
- const clampedBands = Math.max(1, Math.floor(bands));
533
- const sharedBuffer = new SharedArrayBuffer(
534
- sonareSpectrumRingBufferByteLength(clampedCapacity, clampedBands),
535
- );
536
- const ring = spectrumRingFromSharedBuffer(sharedBuffer, clampedCapacity, clampedBands);
537
- Atomics.store(ring.header, 0, 0);
538
- Atomics.store(ring.header, 1, clampedCapacity);
539
- Atomics.store(ring.header, 2, ring.recordFloats);
540
- Atomics.store(ring.header, 3, clampedBands);
541
- Atomics.store(ring.header, 4, 0);
542
- return {
543
- sharedBuffer,
544
- header: ring.header,
545
- records: ring.records,
546
- capacity: ring.capacity,
547
- bands: ring.bands,
548
- };
549
- }
550
-
551
- export function readSonareSpectrumRingBuffer(
552
- ring: SonareSpectrumRingBuffer,
553
- readIndex = 0,
554
- ): SonareSpectrumRingReadResult {
555
- const writeIndex = Atomics.load(ring.header, 0);
556
- const recordFloats = Atomics.load(ring.header, 2) || 3 + ring.bands;
557
- const bands = Atomics.load(ring.header, 3) || ring.bands;
558
- const nextReadIndex = Math.max(0, Math.min(readIndex, writeIndex));
559
- const firstReadable = Math.max(nextReadIndex, writeIndex - ring.capacity);
560
- const spectra: SonareWorkletSpectrumSnapshot[] = [];
561
- for (let index = firstReadable; index < writeIndex; index++) {
562
- const offset = (index % ring.capacity) * recordFloats;
563
- const values = new Float32Array(bands);
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
- });
570
- }
571
- return { nextReadIndex: writeIndex, spectra };
572
- }
573
-
574
- export function sonareEngineCommandRingBufferByteLength(capacity: number): number {
575
- const clampedCapacity = Math.max(1, Math.floor(capacity));
576
- return (
577
- SONARE_ENGINE_RING_HEADER_INTS * Int32Array.BYTES_PER_ELEMENT +
578
- clampedCapacity * SONARE_ENGINE_COMMAND_RECORD_BYTES
579
- );
580
- }
581
-
582
- export function sonareEngineTelemetryRingBufferByteLength(capacity: number): number {
583
- const clampedCapacity = Math.max(1, Math.floor(capacity));
584
- return (
585
- SONARE_ENGINE_RING_HEADER_INTS * Int32Array.BYTES_PER_ELEMENT +
586
- clampedCapacity * SONARE_ENGINE_TELEMETRY_RECORD_BYTES
587
- );
588
- }
589
-
590
- export function createSonareEngineCommandRingBuffer(capacity = 128): SonareEngineCommandRingBuffer {
591
- const clampedCapacity = Math.max(1, Math.floor(capacity));
592
- const sharedBuffer = new SharedArrayBuffer(
593
- sonareEngineCommandRingBufferByteLength(clampedCapacity),
594
- );
595
- const ring = engineRingFromSharedBuffer(
596
- sharedBuffer,
597
- SONARE_ENGINE_COMMAND_RECORD_BYTES,
598
- clampedCapacity,
599
- );
600
- return { sharedBuffer, header: ring.header, view: ring.view, capacity: ring.capacity };
601
- }
602
-
603
- export function createSonareEngineTelemetryRingBuffer(
604
- capacity = 128,
605
- ): SonareEngineTelemetryRingBuffer {
606
- const clampedCapacity = Math.max(1, Math.floor(capacity));
607
- const sharedBuffer = new SharedArrayBuffer(
608
- sonareEngineTelemetryRingBufferByteLength(clampedCapacity),
609
- );
610
- const ring = engineRingFromSharedBuffer(
611
- sharedBuffer,
612
- SONARE_ENGINE_TELEMETRY_RECORD_BYTES,
613
- clampedCapacity,
614
- );
615
- return { sharedBuffer, header: ring.header, view: ring.view, capacity: ring.capacity };
616
- }
617
-
618
- export function pushSonareEngineCommandRingBuffer(
619
- ring: SonareEngineCommandRingBuffer,
620
- command: SonareEngineCommandRecord,
621
- ): boolean {
622
- const writeIndex = Atomics.load(ring.header, 0);
623
- const readIndex = Atomics.load(ring.header, 1);
624
- if (writeIndex - readIndex >= ring.capacity) {
625
- Atomics.add(ring.header, 4, 1);
626
- return false;
627
- }
628
- writeEngineCommandRecord(
629
- ring.view,
630
- recordOffset(writeIndex, ring.capacity, SONARE_ENGINE_COMMAND_RECORD_BYTES),
631
- command,
632
- );
633
- Atomics.store(ring.header, 0, writeIndex + 1);
634
- return true;
635
- }
636
-
637
- export function popSonareEngineCommandRingBuffer(
638
- ring: SonareEngineCommandRingBuffer,
639
- ): SonareEngineCommandRecord | null {
640
- const readIndex = Atomics.load(ring.header, 1);
641
- const writeIndex = Atomics.load(ring.header, 0);
642
- if (readIndex >= writeIndex) {
643
- return null;
644
- }
645
- const command = readEngineCommandRecord(
646
- ring.view,
647
- recordOffset(readIndex, ring.capacity, SONARE_ENGINE_COMMAND_RECORD_BYTES),
648
- );
649
- Atomics.store(ring.header, 1, readIndex + 1);
650
- return command;
651
- }
652
-
653
- export function writeSonareEngineTelemetryRingBuffer(
654
- ring: SonareEngineTelemetryRingBuffer,
655
- telemetry: SonareEngineTelemetryRecord,
656
- ): void {
657
- const writeIndex = Atomics.load(ring.header, 0);
658
- writeEngineTelemetryRecord(
659
- ring.view,
660
- recordOffset(writeIndex, ring.capacity, SONARE_ENGINE_TELEMETRY_RECORD_BYTES),
661
- telemetry,
662
- );
663
- Atomics.store(ring.header, 0, writeIndex + 1);
664
- if (writeIndex + 1 > ring.capacity) {
665
- Atomics.store(ring.header, 4, writeIndex + 1 - ring.capacity);
666
- }
667
- }
668
-
669
- export function readSonareEngineTelemetryRingBuffer(
670
- ring: SonareEngineTelemetryRingBuffer,
671
- readIndex = 0,
672
- ): SonareEngineTelemetryRingReadResult {
673
- const writeIndex = Atomics.load(ring.header, 0);
674
- const nextReadIndex = Math.max(0, Math.min(readIndex, writeIndex));
675
- const firstReadable = Math.max(nextReadIndex, writeIndex - ring.capacity);
676
- const telemetry: SonareEngineTelemetryRecord[] = [];
677
- for (let index = firstReadable; index < writeIndex; index++) {
678
- telemetry.push(
679
- readEngineTelemetryRecord(
680
- ring.view,
681
- recordOffset(index, ring.capacity, SONARE_ENGINE_TELEMETRY_RECORD_BYTES),
682
- ),
683
- );
684
- }
685
- return { nextReadIndex: writeIndex, telemetry };
686
- }
687
-
688
- function meterRingFromSharedBuffer(
689
- sharedBuffer: SharedArrayBuffer,
690
- fallbackCapacity?: number,
691
- ): SharedMeterRingWriter {
692
- const headerBytes = SONARE_METER_RING_HEADER_INTS * Int32Array.BYTES_PER_ELEMENT;
693
- const header = new Int32Array(sharedBuffer, 0, SONARE_METER_RING_HEADER_INTS);
694
- const existingCapacity = Atomics.load(header, 1);
695
- const capacity = Math.max(1, Math.floor(existingCapacity || fallbackCapacity || 1));
696
- const minBytes = sonareMeterRingBufferByteLength(capacity);
697
- if (sharedBuffer.byteLength < minBytes) {
698
- throw new Error('meterSharedBuffer is too small for the requested ring capacity.');
699
- }
700
- Atomics.store(header, 1, capacity);
701
- Atomics.store(header, 2, SONARE_METER_RING_RECORD_FLOATS);
702
- return {
703
- header,
704
- records: new Float32Array(
705
- sharedBuffer,
706
- headerBytes,
707
- capacity * SONARE_METER_RING_RECORD_FLOATS,
708
- ),
709
- capacity,
710
- };
711
- }
712
-
713
- function spectrumRingFromSharedBuffer(
714
- sharedBuffer: SharedArrayBuffer,
715
- fallbackCapacity?: number,
716
- fallbackBands?: number,
717
- ): SharedSpectrumRingWriter {
718
- const headerBytes = SONARE_SPECTRUM_RING_HEADER_INTS * Int32Array.BYTES_PER_ELEMENT;
719
- const header = new Int32Array(sharedBuffer, 0, SONARE_SPECTRUM_RING_HEADER_INTS);
720
- const existingCapacity = Atomics.load(header, 1);
721
- const existingBands = Atomics.load(header, 3);
722
- const capacity = Math.max(1, Math.floor(existingCapacity || fallbackCapacity || 1));
723
- const bands = Math.max(1, Math.floor(existingBands || fallbackBands || 16));
724
- const recordFloats = 3 + bands;
725
- const minBytes = sonareSpectrumRingBufferByteLength(capacity, bands);
726
- if (sharedBuffer.byteLength < minBytes) {
727
- throw new Error('spectrumSharedBuffer is too small for the requested ring capacity.');
728
- }
729
- Atomics.store(header, 1, capacity);
730
- Atomics.store(header, 2, recordFloats);
731
- Atomics.store(header, 3, bands);
732
- return {
733
- header,
734
- records: new Float32Array(sharedBuffer, headerBytes, capacity * recordFloats),
735
- capacity,
736
- bands,
737
- recordFloats,
738
- };
739
- }
740
-
741
- function engineRingFromSharedBuffer(
742
- sharedBuffer: SharedArrayBuffer,
743
- recordBytes: number,
744
- fallbackCapacity?: number,
745
- ): { header: Int32Array; view: DataView; capacity: number } {
746
- const headerBytes = SONARE_ENGINE_RING_HEADER_INTS * Int32Array.BYTES_PER_ELEMENT;
747
- const header = new Int32Array(sharedBuffer, 0, SONARE_ENGINE_RING_HEADER_INTS);
748
- const existingCapacity = Atomics.load(header, 2);
749
- const capacity = Math.max(1, Math.floor(existingCapacity || fallbackCapacity || 1));
750
- const minBytes = headerBytes + capacity * recordBytes;
751
- if (sharedBuffer.byteLength < minBytes) {
752
- throw new Error('engine SharedArrayBuffer is too small for the requested ring capacity.');
753
- }
754
- Atomics.store(header, 2, capacity);
755
- Atomics.store(header, 3, recordBytes);
756
- return {
757
- header,
758
- view: new DataView(sharedBuffer, headerBytes, capacity * recordBytes),
759
- capacity,
760
- };
761
- }
762
-
763
- function recordOffset(index: number, capacity: number, recordBytes: number): number {
764
- return (index % capacity) * recordBytes;
765
- }
766
-
767
- function toBigInt64(value: number | bigint | undefined, fallback: bigint): bigint {
768
- if (typeof value === 'bigint') {
769
- return value;
770
- }
771
- if (typeof value === 'number') {
772
- return BigInt(Math.trunc(value));
773
- }
774
- return fallback;
775
- }
776
-
777
- function writeEngineCommandRecord(
778
- view: DataView,
779
- offset: number,
780
- command: SonareEngineCommandRecord,
781
- ): void {
782
- view.setUint32(offset, command.type, true);
783
- view.setUint32(offset + 4, command.targetId ?? 0, true);
784
- view.setBigInt64(offset + 8, toBigInt64(command.sampleTime, -1n), true);
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);
789
- view.setBigInt64(offset + 24, toBigInt64(command.argInt, 0n), true);
790
- }
791
-
792
- function readEngineCommandRecord(view: DataView, offset: number): SonareEngineCommandRecord {
793
- return {
794
- type: view.getUint32(offset, true),
795
- targetId: view.getUint32(offset + 4, true),
796
- sampleTime: Number(view.getBigInt64(offset + 8, true)),
797
- argFloat: view.getFloat64(offset + 16, true),
798
- argInt: Number(view.getBigInt64(offset + 24, true)),
799
- };
800
- }
801
-
802
- function writeEngineTelemetryRecord(
803
- view: DataView,
804
- offset: number,
805
- telemetry: SonareEngineTelemetryRecord,
806
- ): void {
807
- view.setUint32(offset, telemetry.type, true);
808
- view.setUint32(offset + 4, telemetry.error, true);
809
- view.setBigInt64(offset + 8, BigInt(Math.trunc(telemetry.renderFrame)), true);
810
- view.setBigInt64(offset + 16, BigInt(Math.trunc(telemetry.timelineSample)), true);
811
- view.setBigInt64(offset + 24, BigInt(Math.trunc(telemetry.audibleTimelineSample)), true);
812
- view.setInt32(offset + 32, telemetry.graphLatencySamplesQ8, true);
813
- view.setUint32(offset + 36, telemetry.value, true);
814
- view.setBigInt64(offset + 40, 0n, true);
815
- }
816
-
817
- function readEngineTelemetryRecord(view: DataView, offset: number): SonareEngineTelemetryRecord {
818
- return {
819
- type: view.getUint32(offset, true),
820
- error: view.getUint32(offset + 4, true),
821
- renderFrame: Number(view.getBigInt64(offset + 8, true)),
822
- timelineSample: Number(view.getBigInt64(offset + 16, true)),
823
- audibleTimelineSample: Number(view.getBigInt64(offset + 24, true)),
824
- graphLatencySamplesQ8: view.getInt32(offset + 32, true),
825
- value: view.getUint32(offset + 36, true),
826
- };
827
- }
828
-
829
- function telemetryFromEngine(telemetry: EngineTelemetry): SonareEngineTelemetryRecord {
830
- return {
831
- type: telemetry.type,
832
- error: telemetry.error,
833
- renderFrame: telemetry.renderFrame,
834
- timelineSample: telemetry.timelineSample,
835
- audibleTimelineSample: telemetry.audibleTimelineSample,
836
- graphLatencySamplesQ8: telemetry.graphLatencySamplesQ8,
837
- value: telemetry.value,
838
- };
839
- }
840
-
841
- function meterFromEngine(meter: EngineMeterTelemetry): SonareWorkletMeterSnapshot {
842
- return {
843
- type: 'meter',
844
- frame: meter.renderFrame,
845
- peakDbL: meter.peakDbL,
846
- peakDbR: meter.peakDbR,
847
- rmsDbL: meter.rmsDbL,
848
- rmsDbR: meter.rmsDbR,
849
- correlation: meter.correlation,
850
- };
851
- }
852
-
853
- function magnitudeToDb(value: number): number {
854
- return value > 1.0e-12 ? 20 * Math.log10(value) : -120;
855
- }
856
-
857
228
  /**
858
229
  * AudioWorklet-style mixer bridge backed by the package's single `sonare.wasm`.
859
230
  *
@@ -1041,12 +412,19 @@ export class SonareWorkletProcessor {
1041
412
  const denominator = Math.sqrt(sumL * sumR);
1042
413
  const meter: SonareWorkletMeterSnapshot = {
1043
414
  type: 'meter',
415
+ targetId: 0,
1044
416
  frame: this.processedFrames,
1045
417
  peakDbL: toDb(peakL),
1046
418
  peakDbR: toDb(peakR),
1047
419
  rmsDbL: toDb(rmsL),
1048
420
  rmsDbR: toDb(rmsR),
1049
421
  correlation: denominator > 0 ? sumLR / denominator : 0,
422
+ truePeakDbL: toDb(peakL),
423
+ truePeakDbR: toDb(peakR),
424
+ momentaryLufs: Number.NaN,
425
+ shortTermLufs: Number.NaN,
426
+ integratedLufs: Number.NaN,
427
+ gainReductionDb: Number.NaN,
1050
428
  };
1051
429
  this.transport.onMeter?.(meter);
1052
430
  if (this.meterRing) {
@@ -1064,12 +442,19 @@ export class SonareWorkletProcessor {
1064
442
  const writeIndex = Atomics.load(ring.header, 0);
1065
443
  const offset = (writeIndex % ring.capacity) * SONARE_METER_RING_RECORD_FLOATS;
1066
444
  ring.records[offset] = encodeFrameLo(meter.frame);
1067
- ring.records[offset + 1] = meter.peakDbL;
1068
- ring.records[offset + 2] = meter.peakDbR;
1069
- ring.records[offset + 3] = meter.rmsDbL;
1070
- ring.records[offset + 4] = meter.rmsDbR;
1071
- ring.records[offset + 5] = meter.correlation;
1072
- ring.records[offset + 6] = encodeFrameHi(meter.frame);
445
+ ring.records[offset + 1] = encodeFrameHi(meter.frame);
446
+ ring.records[offset + 2] = meter.targetId;
447
+ ring.records[offset + 3] = meter.peakDbL;
448
+ ring.records[offset + 4] = meter.peakDbR;
449
+ ring.records[offset + 5] = meter.rmsDbL;
450
+ ring.records[offset + 6] = meter.rmsDbR;
451
+ ring.records[offset + 7] = meter.correlation;
452
+ ring.records[offset + 8] = meter.truePeakDbL;
453
+ ring.records[offset + 9] = meter.truePeakDbR;
454
+ ring.records[offset + 10] = meter.momentaryLufs;
455
+ ring.records[offset + 11] = meter.shortTermLufs;
456
+ ring.records[offset + 12] = meter.integratedLufs;
457
+ ring.records[offset + 13] = meter.gainReductionDb;
1073
458
  Atomics.store(ring.header, 0, writeIndex + 1);
1074
459
  // writeIndex is a free-running monotonic counter, so an overflow guard here
1075
460
  // would fire on essentially every write past the first `capacity` records
@@ -1165,6 +550,7 @@ export class SonareRealtimeEngineWorkletProcessor {
1165
550
  private commandRing?: SonareEngineCommandRingBuffer;
1166
551
  private telemetryRing?: SonareEngineTelemetryRingBuffer;
1167
552
  private meterRing?: SharedMeterRingWriter;
553
+ private scopeRing?: SharedScopeRingWriter;
1168
554
  private transport?: WorkletTransport;
1169
555
  private meterIntervalFrames: number;
1170
556
  private lastMeterFrame = Number.NEGATIVE_INFINITY;
@@ -1179,6 +565,7 @@ export class SonareRealtimeEngineWorkletProcessor {
1179
565
  // allocated per render quantum (the old engine.process() round-tripped fresh
1180
566
  // arrays on both heaps every block, an RT-safety hazard).
1181
567
  private channelBuffers: Float32Array[];
568
+ private readonly liveClips = new Map<number, EngineClip>();
1182
569
 
1183
570
  constructor(
1184
571
  options: SonareRealtimeEngineWorkletProcessorOptions = {},
@@ -1207,6 +594,13 @@ export class SonareRealtimeEngineWorkletProcessor {
1207
594
  this.meterRing = options.meterSharedBuffer
1208
595
  ? meterRingFromSharedBuffer(options.meterSharedBuffer, options.meterRingCapacity)
1209
596
  : undefined;
597
+ this.scopeRing = options.scopeSharedBuffer
598
+ ? scopeRingFromSharedBuffer(
599
+ options.scopeSharedBuffer,
600
+ options.scopeRingCapacity,
601
+ options.scopeBands,
602
+ )
603
+ : undefined;
1210
604
  this.engine = new RealtimeEngine(this.sampleRate, this.blockSize);
1211
605
  // Allocate persistent WASM-heap scratch (worst case: channelCount channels x
1212
606
  // blockSize frames) and acquire the per-channel heap views once.
@@ -1215,6 +609,13 @@ export class SonareRealtimeEngineWorkletProcessor {
1215
609
  for (let ch = 0; ch < this.channelCount; ch++) {
1216
610
  this.channelBuffers[ch] = this.engine.getChannelBuffer(ch, this.blockSize);
1217
611
  }
612
+ // Arm the engine's scope producer only when a scope ring was provided. The
613
+ // band count follows the ring's record layout so writeScopeRing never
614
+ // overruns its slot.
615
+ if (this.scopeRing) {
616
+ const interval = Math.max(1, Math.floor(options.scopeIntervalFrames ?? this.blockSize));
617
+ this.engine.configureScopeTelemetry(interval, this.scopeRing.bands);
618
+ }
1218
619
  }
1219
620
 
1220
621
  process(inputs: WorkletInput, outputs: WorkletOutput): boolean {
@@ -1292,6 +693,7 @@ export class SonareRealtimeEngineWorkletProcessor {
1292
693
  }
1293
694
  this.publishTelemetry();
1294
695
  this.publishMeters();
696
+ this.publishScope();
1295
697
  return true;
1296
698
  }
1297
699
 
@@ -1318,8 +720,28 @@ export class SonareRealtimeEngineWorkletProcessor {
1318
720
  }
1319
721
  switch (message.type) {
1320
722
  case 'syncClips':
723
+ this.liveClips.clear();
724
+ for (const clip of message.clips) {
725
+ if (clip.id !== undefined) {
726
+ this.liveClips.set(clip.id, clip);
727
+ }
728
+ }
1321
729
  this.engine.setClips(message.clips);
1322
730
  break;
731
+ case 'syncClipsDelta':
732
+ for (const clipId of message.removeIds) {
733
+ this.liveClips.delete(clipId);
734
+ }
735
+ for (const clip of message.upserts) {
736
+ if (clip.id !== undefined) {
737
+ this.liveClips.set(clip.id, clip);
738
+ }
739
+ }
740
+ this.engine.setClips(Array.from(this.liveClips.values()));
741
+ break;
742
+ case 'syncMidiClips':
743
+ this.engine.setMidiClips(message.clips);
744
+ break;
1323
745
  case 'syncMarkers':
1324
746
  this.engine.setMarkers(message.markers);
1325
747
  break;
@@ -1330,6 +752,219 @@ export class SonareRealtimeEngineWorkletProcessor {
1330
752
  case 'syncAutomation':
1331
753
  this.engine.setAutomationLane(message.paramId, message.points);
1332
754
  break;
755
+ case 'syncTempo':
756
+ if (message.tempoSegments) {
757
+ this.engine.setTempoSegments(message.tempoSegments);
758
+ } else {
759
+ this.engine.setTempo(message.bpm);
760
+ }
761
+ if (message.timeSignatureSegments) {
762
+ this.engine.setTimeSignatureSegments(message.timeSignatureSegments);
763
+ } else {
764
+ this.engine.setTimeSignature(
765
+ message.timeSignature.numerator,
766
+ message.timeSignature.denominator,
767
+ );
768
+ }
769
+ break;
770
+ case 'syncMixer':
771
+ if (message.buses) {
772
+ this.engine.setTrackBuses(message.buses);
773
+ }
774
+ this.engine.setTrackLanes(message.lanes);
775
+ for (const strip of message.trackStrips ?? []) {
776
+ this.engine.setTrackStripJson(strip.trackId, strip.sceneJson);
777
+ }
778
+ for (const strip of message.busStrips ?? []) {
779
+ this.engine.setBusStripJson(strip.busId, strip.sceneJson);
780
+ }
781
+ if (message.masterStripJson) {
782
+ this.engine.setMasterStripJson(message.masterStripJson);
783
+ }
784
+ for (const binding of message.laneSidechains ?? []) {
785
+ this.engine.setLaneSidechain(binding.trackId, binding.insertIndex, binding.sourceTrackId);
786
+ }
787
+ break;
788
+ case 'syncCapture':
789
+ this.engine.setCaptureBuffer(message.channels, message.bufferFrames);
790
+ this.engine.setCaptureSource(message.source);
791
+ this.engine.setRecordOffsetSamples(message.recordOffsetSamples);
792
+ this.engine.setInputMonitor(message.inputMonitor.enabled, message.inputMonitor.gain);
793
+ break;
794
+ case 'syncTrackStripEqBand':
795
+ this.engine.setTrackStripEqBandJson(message.trackId, message.bandIndex, message.bandJson);
796
+ break;
797
+ case 'syncMasterStripEqBand':
798
+ this.engine.setMasterStripEqBandJson(message.bandIndex, message.bandJson);
799
+ break;
800
+ case 'syncTrackStripInsertBypassed':
801
+ this.engine.setTrackStripInsertBypassed(
802
+ message.trackId,
803
+ message.insertIndex,
804
+ message.bypassed,
805
+ message.resetOnBypass,
806
+ );
807
+ break;
808
+ case 'syncMasterStripInsertBypassed':
809
+ this.engine.setMasterStripInsertBypassed(
810
+ message.insertIndex,
811
+ message.bypassed,
812
+ message.resetOnBypass,
813
+ );
814
+ break;
815
+ case 'syncTrackStripInsertParamByName':
816
+ this.engine.setTrackStripInsertParamByName(
817
+ message.trackId,
818
+ message.insertIndex,
819
+ message.paramName,
820
+ message.value,
821
+ );
822
+ break;
823
+ case 'syncMasterStripInsertParamByName':
824
+ this.engine.setMasterStripInsertParamByName(
825
+ message.insertIndex,
826
+ message.paramName,
827
+ message.value,
828
+ );
829
+ break;
830
+ case 'syncTrackStripPan':
831
+ this.engine.setTrackStripPan(message.trackId, message.pan);
832
+ break;
833
+ case 'syncTrackStripPanLaw':
834
+ this.engine.setTrackStripPanLaw(message.trackId, message.panLaw);
835
+ break;
836
+ case 'syncTrackStripPanMode':
837
+ this.engine.setTrackStripPanMode(message.trackId, message.panMode);
838
+ break;
839
+ case 'syncTrackStripDualPan':
840
+ this.engine.setTrackStripDualPan(message.trackId, message.leftPan, message.rightPan);
841
+ break;
842
+ case 'syncTrackStripChannelDelaySamples':
843
+ this.engine.setTrackStripChannelDelaySamples(message.trackId, message.delaySamples);
844
+ break;
845
+ case 'syncBuiltinInstrument':
846
+ this.engine.setBuiltinInstrument(message.config, message.destinationId);
847
+ break;
848
+ case 'syncSynthInstrument':
849
+ this.engine.setSynthInstrument(message.patch, message.destinationId);
850
+ break;
851
+ case 'syncLoadSoundFont':
852
+ this.engine.loadSoundFont(message.data);
853
+ break;
854
+ case 'syncSf2Instrument':
855
+ this.engine.setSf2Instrument(message.config, message.destinationId);
856
+ break;
857
+ case 'syncMidiNoteOn':
858
+ this.engine.pushMidiNoteOn(
859
+ message.destinationId,
860
+ message.group,
861
+ message.channel,
862
+ message.note,
863
+ message.velocity,
864
+ message.renderFrame,
865
+ );
866
+ break;
867
+ case 'syncMidiNoteOff':
868
+ this.engine.pushMidiNoteOff(
869
+ message.destinationId,
870
+ message.group,
871
+ message.channel,
872
+ message.note,
873
+ message.velocity,
874
+ message.renderFrame,
875
+ );
876
+ break;
877
+ case 'syncMidiCc':
878
+ this.engine.pushMidiCc(
879
+ message.destinationId,
880
+ message.group,
881
+ message.channel,
882
+ message.controller,
883
+ message.value,
884
+ message.renderFrame,
885
+ );
886
+ break;
887
+ case 'syncMidiPanic':
888
+ this.engine.pushMidiPanic(message.renderFrame);
889
+ break;
890
+ }
891
+ }
892
+
893
+ receiveCaptureRequest(message: SonareEngineCaptureRequestMessage): void {
894
+ if (this.closed) {
895
+ return;
896
+ }
897
+ try {
898
+ if (message.op === 'status') {
899
+ const status = this.engine.captureStatus();
900
+ this.transport?.postMessage?.({
901
+ type: 'captureResponse',
902
+ requestId: message.requestId,
903
+ ok: true,
904
+ status: {
905
+ capturedFrames: status.capturedFrames,
906
+ overflowCount: status.overflowCount,
907
+ armed: status.armed,
908
+ punchEnabled: status.punchEnabled,
909
+ source: status.source,
910
+ recordOffsetSamples: status.recordOffsetSamples,
911
+ },
912
+ } satisfies SonareEngineCaptureResponseMessage);
913
+ return;
914
+ }
915
+ if (message.op === 'read') {
916
+ const captured = this.engine.capturedAudio();
917
+ const channels: number[][] = [];
918
+ for (let ch = 0; ch < captured.length; ch++) {
919
+ const source = captured[ch];
920
+ const copy: number[] = [];
921
+ for (let i = 0; i < source.length; i++) {
922
+ copy.push(Number(source[i]));
923
+ }
924
+ channels.push(copy);
925
+ }
926
+ this.transport?.postMessage?.({
927
+ type: 'captureResponse',
928
+ requestId: message.requestId,
929
+ ok: true,
930
+ channels,
931
+ } satisfies SonareEngineCaptureResponseMessage);
932
+ return;
933
+ }
934
+ this.engine.resetCapture();
935
+ this.transport?.postMessage?.({
936
+ type: 'captureResponse',
937
+ requestId: message.requestId,
938
+ ok: true,
939
+ } satisfies SonareEngineCaptureResponseMessage);
940
+ } catch (error) {
941
+ this.transport?.postMessage?.({
942
+ type: 'captureResponse',
943
+ requestId: message.requestId,
944
+ ok: false,
945
+ error: error instanceof Error ? error.message : String(error),
946
+ } satisfies SonareEngineCaptureResponseMessage);
947
+ }
948
+ }
949
+
950
+ receiveTransportRequest(message: SonareEngineTransportRequestMessage): void {
951
+ if (this.closed) {
952
+ return;
953
+ }
954
+ try {
955
+ this.transport?.postMessage?.({
956
+ type: 'transportResponse',
957
+ requestId: message.requestId,
958
+ ok: true,
959
+ state: this.engine.getTransportState(),
960
+ } satisfies SonareEngineTransportResponseMessage);
961
+ } catch (error) {
962
+ this.transport?.postMessage?.({
963
+ type: 'transportResponse',
964
+ requestId: message.requestId,
965
+ ok: false,
966
+ error: error instanceof Error ? error.message : String(error),
967
+ } satisfies SonareEngineTransportResponseMessage);
1333
968
  }
1334
969
  }
1335
970
 
@@ -1423,6 +1058,14 @@ export class SonareRealtimeEngineWorkletProcessor {
1423
1058
  // (RtPublisher-style swap), so a queued kSeekMarker resolves correctly.
1424
1059
  this.engine.seekMarker(Math.trunc(Number(command.targetId ?? 0)), sampleTime);
1425
1060
  break;
1061
+ case SonareEngineCommandType.SetSoloMute:
1062
+ this.engine.setSoloMute(
1063
+ Math.trunc(Number(command.targetId ?? 0)),
1064
+ Boolean((Number(command.argInt ?? 0) & 0x2) !== 0),
1065
+ Boolean((Number(command.argInt ?? 0) & 0x1) !== 0),
1066
+ sampleTime,
1067
+ );
1068
+ break;
1426
1069
  default:
1427
1070
  this.publishTelemetryRecord({
1428
1071
  type: SonareEngineTelemetryType.Error,
@@ -1451,16 +1094,32 @@ export class SonareRealtimeEngineWorkletProcessor {
1451
1094
  this.transport?.postMessage?.(record);
1452
1095
  }
1453
1096
 
1097
+ // Drains the engine meter telemetry queue into the stereo meter ring / transport.
1098
+ //
1099
+ // Shared-queue contract: `drainMeterTelemetry` and `drainMeterTelemetryWide`
1100
+ // pop the SAME single-consumer telemetry queue, so exactly ONE of them may run
1101
+ // per engine. The live worklet path owns the queue via the stereo drain below;
1102
+ // the worklet meter ring (SONARE_METER_RING_RECORD_FLOATS) is a fixed stereo
1103
+ // layout carrying planes 0/1 plus the correlation/LUFS summary. Per-plane
1104
+ // surround meters are NOT delivered over the live worklet ring — a host that
1105
+ // needs them must use the offline `drainMeterTelemetryWide()` API on a
1106
+ // non-worklet engine instance (do not also call it on a worklet-driven engine,
1107
+ // or the two drains will starve each other).
1454
1108
  private publishMeters(): void {
1455
1109
  if (this.meterIntervalFrames <= 0 || (!this.transport && !this.meterRing)) {
1456
1110
  return;
1457
1111
  }
1458
1112
  for (const item of this.engine.drainMeterTelemetry(64)) {
1459
1113
  const meter = meterFromEngine(item);
1460
- if (meter.frame - this.lastMeterFrame < this.meterIntervalFrames) {
1114
+ if (
1115
+ meter.frame !== this.lastMeterFrame &&
1116
+ meter.frame - this.lastMeterFrame < this.meterIntervalFrames
1117
+ ) {
1461
1118
  continue;
1462
1119
  }
1463
- this.lastMeterFrame = meter.frame;
1120
+ if (meter.frame !== this.lastMeterFrame) {
1121
+ this.lastMeterFrame = meter.frame;
1122
+ }
1464
1123
  // Prefer the lock-free SAB meter ring (matching the telemetry path and
1465
1124
  // SonareWorkletProcessor); only fall back to structured-clone postMessage
1466
1125
  // when no ring was provided, so we do not allocate/post from the audio
@@ -1482,12 +1141,19 @@ export class SonareRealtimeEngineWorkletProcessor {
1482
1141
  const writeIndex = Atomics.load(ring.header, 0);
1483
1142
  const offset = (writeIndex % ring.capacity) * SONARE_METER_RING_RECORD_FLOATS;
1484
1143
  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);
1144
+ ring.records[offset + 1] = encodeFrameHi(meter.frame);
1145
+ ring.records[offset + 2] = meter.targetId;
1146
+ ring.records[offset + 3] = meter.peakDbL;
1147
+ ring.records[offset + 4] = meter.peakDbR;
1148
+ ring.records[offset + 5] = meter.rmsDbL;
1149
+ ring.records[offset + 6] = meter.rmsDbR;
1150
+ ring.records[offset + 7] = meter.correlation;
1151
+ ring.records[offset + 8] = meter.truePeakDbL;
1152
+ ring.records[offset + 9] = meter.truePeakDbR;
1153
+ ring.records[offset + 10] = meter.momentaryLufs;
1154
+ ring.records[offset + 11] = meter.shortTermLufs;
1155
+ ring.records[offset + 12] = meter.integratedLufs;
1156
+ ring.records[offset + 13] = meter.gainReductionDb;
1491
1157
  Atomics.store(ring.header, 0, writeIndex + 1);
1492
1158
  // writeIndex is a free-running monotonic counter, so an overflow guard here
1493
1159
  // would fire on essentially every write past the first `capacity` records
@@ -1496,16 +1162,56 @@ export class SonareRealtimeEngineWorkletProcessor {
1496
1162
  // writeIndex - capacity), so header slot 3 is left at its initial 0.
1497
1163
  }
1498
1164
 
1499
- private commandRingFromSharedBuffer(
1500
- sharedBuffer: SharedArrayBuffer,
1501
- fallbackCapacity?: number,
1502
- ): SonareEngineCommandRingBuffer {
1503
- const ring = engineRingFromSharedBuffer(
1504
- sharedBuffer,
1505
- SONARE_ENGINE_COMMAND_RECORD_BYTES,
1506
- fallbackCapacity,
1507
- );
1508
- return { sharedBuffer, header: ring.header, view: ring.view, capacity: ring.capacity };
1165
+ // Drains the engine's scope producer (FFT spectrum + goniometer points) into
1166
+ // the lock-free SAB scope ring. Only the embind runtime publishes scope
1167
+ // telemetry; the sonare-rt runtime owns its own transport. No allocation on
1168
+ // the render path: records are written field-by-field into the ring.
1169
+ private publishScope(): void {
1170
+ const ring = this.scopeRing;
1171
+ if (!ring) {
1172
+ return;
1173
+ }
1174
+ for (const item of this.engine.drainScopeTelemetry(64)) {
1175
+ this.writeScopeRing(ring, item);
1176
+ }
1177
+ }
1178
+
1179
+ private writeScopeRing(ring: SharedScopeRingWriter, record: EngineScopeTelemetry): void {
1180
+ const writeIndex = Atomics.load(ring.header, 0);
1181
+ const base = (writeIndex % ring.capacity) * ring.recordFloats;
1182
+ ring.records[base] = encodeFrameLo(record.renderFrame);
1183
+ ring.records[base + 1] = encodeFrameHi(record.renderFrame);
1184
+ ring.records[base + 2] = record.targetId;
1185
+ const bandCount = Math.min(ring.bands, record.bands.length);
1186
+ ring.records[base + 3] = bandCount;
1187
+ const pointCount = Math.min(ring.maxPoints, record.points.length);
1188
+ ring.records[base + 4] = pointCount;
1189
+ const bandsBase = base + SONARE_SCOPE_RING_RECORD_PREFIX_FLOATS;
1190
+ for (let i = 0; i < bandCount; i++) {
1191
+ ring.records[bandsBase + i] = record.bands[i];
1192
+ }
1193
+ const pointsBase = bandsBase + ring.bands;
1194
+ for (let i = 0; i < pointCount; i++) {
1195
+ const point = record.points[i];
1196
+ ring.records[pointsBase + 2 * i] = point.left;
1197
+ ring.records[pointsBase + 2 * i + 1] = point.right;
1198
+ }
1199
+ Atomics.store(ring.header, 0, writeIndex + 1);
1200
+ // Like writeMeterRing, writeIndex is a free-running monotonic counter; the
1201
+ // reader detects silent overrun via firstReadable, so the overflow slot
1202
+ // (header[5]) stays at its initial 0.
1203
+ }
1204
+
1205
+ private commandRingFromSharedBuffer(
1206
+ sharedBuffer: SharedArrayBuffer,
1207
+ fallbackCapacity?: number,
1208
+ ): SonareEngineCommandRingBuffer {
1209
+ const ring = engineRingFromSharedBuffer(
1210
+ sharedBuffer,
1211
+ SONARE_ENGINE_COMMAND_RECORD_BYTES,
1212
+ fallbackCapacity,
1213
+ );
1214
+ return { sharedBuffer, header: ring.header, view: ring.view, capacity: ring.capacity };
1509
1215
  }
1510
1216
 
1511
1217
  private telemetryRingFromSharedBuffer(
@@ -1657,12 +1363,32 @@ export class SonareRtRealtimeEngineRuntime {
1657
1363
  this.metronomeConfig.clickSamples,
1658
1364
  );
1659
1365
  break;
1366
+ case 'syncTempo':
1367
+ this.module._sonare_rt_engine_set_tempo(this.engine, message.bpm);
1368
+ break;
1660
1369
  case 'syncClips':
1370
+ case 'syncClipsDelta':
1371
+ case 'syncMidiClips':
1661
1372
  case 'syncMarkers':
1662
1373
  case 'syncAutomation':
1374
+ case 'syncMixer':
1375
+ case 'syncCapture':
1376
+ case 'syncTrackStripEqBand':
1377
+ case 'syncMasterStripEqBand':
1378
+ case 'syncTrackStripInsertBypassed':
1379
+ case 'syncMasterStripInsertBypassed':
1380
+ case 'syncBuiltinInstrument':
1381
+ case 'syncSynthInstrument':
1382
+ case 'syncSf2Instrument':
1383
+ case 'syncLoadSoundFont':
1384
+ case 'syncMidiNoteOn':
1385
+ case 'syncMidiNoteOff':
1386
+ case 'syncMidiCc':
1387
+ case 'syncMidiPanic':
1663
1388
  // 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.
1389
+ // set_automation_lane / set_track_lanes, so these mutations cannot
1390
+ // reach a live sonare-rt engine. Surface a clear telemetry error rather
1391
+ // than silently dropping.
1666
1392
  if (this.telemetryRing) {
1667
1393
  writeSonareEngineTelemetryRingBuffer(this.telemetryRing, {
1668
1394
  type: SonareEngineTelemetryType.Error,
@@ -1678,6 +1404,30 @@ export class SonareRtRealtimeEngineRuntime {
1678
1404
  }
1679
1405
  }
1680
1406
 
1407
+ receiveCaptureRequest(message: SonareEngineCaptureRequestMessage, port?: WorkletPort): void {
1408
+ if (this.closed) {
1409
+ return;
1410
+ }
1411
+ port?.postMessage?.({
1412
+ type: 'captureResponse',
1413
+ requestId: message.requestId,
1414
+ ok: false,
1415
+ error: 'Capture read-back is not supported by the sonare-rt runtime.',
1416
+ } satisfies SonareEngineCaptureResponseMessage);
1417
+ }
1418
+
1419
+ receiveTransportRequest(message: SonareEngineTransportRequestMessage, port?: WorkletPort): void {
1420
+ if (this.closed) {
1421
+ return;
1422
+ }
1423
+ port?.postMessage?.({
1424
+ type: 'transportResponse',
1425
+ requestId: message.requestId,
1426
+ ok: false,
1427
+ error: 'Transport state read-back is not supported by the sonare-rt runtime.',
1428
+ } satisfies SonareEngineTransportResponseMessage);
1429
+ }
1430
+
1681
1431
  destroy(): void {
1682
1432
  if (this.closed) {
1683
1433
  return;
@@ -1875,11 +1625,30 @@ export class SonareRealtimeEngineNode {
1875
1625
  readonly commandRing?: SonareEngineCommandRingBuffer;
1876
1626
  readonly telemetryRing?: SonareEngineTelemetryRingBuffer;
1877
1627
  readonly meterRing?: SonareMeterRingBuffer;
1628
+ readonly scopeRing?: SonareScopeRingBuffer;
1878
1629
  readonly ready: Promise<void>;
1879
1630
  private telemetryReadIndex = 0;
1880
1631
  private meterReadIndex = 0;
1632
+ private scopeReadIndex = 0;
1881
1633
  private telemetryListeners = new Set<(telemetry: SonareEngineTelemetryRecord) => void>();
1882
1634
  private meterListeners = new Set<(meter: SonareWorkletMeterSnapshot) => void>();
1635
+ private scopeListeners = new Set<(scope: SonareWorkletScopeSnapshot) => void>();
1636
+ private captureRequestId = 1;
1637
+ private readonly captureRequests = new Map<
1638
+ number,
1639
+ {
1640
+ resolve: (response: SonareEngineCaptureResponseMessage) => void;
1641
+ reject: (reason?: unknown) => void;
1642
+ }
1643
+ >();
1644
+ private transportRequestId = 1;
1645
+ private readonly transportRequests = new Map<
1646
+ number,
1647
+ {
1648
+ resolve: (response: SonareEngineTransportResponseMessage) => void;
1649
+ reject: (reason?: unknown) => void;
1650
+ }
1651
+ >();
1883
1652
  private resolveReady!: () => void;
1884
1653
  private rejectReady!: (reason?: unknown) => void;
1885
1654
  private destroyed = false;
@@ -1890,21 +1659,43 @@ export class SonareRealtimeEngineNode {
1890
1659
  commandRing?: SonareEngineCommandRingBuffer,
1891
1660
  telemetryRing?: SonareEngineTelemetryRingBuffer,
1892
1661
  meterRing?: SonareMeterRingBuffer,
1662
+ scopeRing?: SonareScopeRingBuffer,
1893
1663
  ) {
1894
1664
  this.node = node;
1895
1665
  this.capabilities = capabilities;
1896
1666
  this.commandRing = commandRing;
1897
1667
  this.telemetryRing = telemetryRing;
1898
1668
  this.meterRing = meterRing;
1669
+ this.scopeRing = scopeRing;
1899
1670
  this.ready = new Promise((resolve, reject) => {
1900
1671
  this.resolveReady = resolve;
1901
1672
  this.rejectReady = reject;
1902
1673
  });
1903
- if (capabilities.runtimeTarget !== 'sonare-rt') {
1674
+ if (!capabilities.readyMessage) {
1904
1675
  this.resolveReady();
1905
1676
  }
1906
1677
  this.node.port.onmessage = (event: MessageEvent<unknown>) => {
1907
- if (isEngineTelemetryRecord(event.data)) {
1678
+ if (isEngineCaptureResponseMessage(event.data)) {
1679
+ const pending = this.captureRequests.get(event.data.requestId);
1680
+ if (pending) {
1681
+ this.captureRequests.delete(event.data.requestId);
1682
+ if (event.data.ok) {
1683
+ pending.resolve(event.data);
1684
+ } else {
1685
+ pending.reject(new Error(event.data.error ?? 'Capture request failed'));
1686
+ }
1687
+ }
1688
+ } else if (isEngineTransportResponseMessage(event.data)) {
1689
+ const pending = this.transportRequests.get(event.data.requestId);
1690
+ if (pending) {
1691
+ this.transportRequests.delete(event.data.requestId);
1692
+ if (event.data.ok) {
1693
+ pending.resolve(event.data);
1694
+ } else {
1695
+ pending.reject(new Error(event.data.error ?? 'Transport request failed'));
1696
+ }
1697
+ }
1698
+ } else if (isEngineTelemetryRecord(event.data)) {
1908
1699
  this.emitTelemetry(event.data);
1909
1700
  } else if (isMeterSnapshot(event.data)) {
1910
1701
  this.emitMeter(event.data);
@@ -1974,6 +1765,14 @@ export class SonareRealtimeEngineNode {
1974
1765
  mode === 'sab' && runtimeTarget === 'embind'
1975
1766
  ? createSonareMeterRingBuffer(options.meterRingCapacity ?? 128)
1976
1767
  : undefined;
1768
+ // Scope ring (FFT spectrum + goniometer): opt-in, embind-only. The
1769
+ // per-block FFT is heavier than the meter path, so it is created only when
1770
+ // the caller requests scope telemetry via scopeIntervalFrames > 0.
1771
+ const scopeIntervalFrames = Math.max(0, Math.floor(options.scopeIntervalFrames ?? 0));
1772
+ const scopeRing =
1773
+ mode === 'sab' && runtimeTarget === 'embind' && scopeIntervalFrames > 0
1774
+ ? createSonareScopeRingBuffer(options.scopeRingCapacity ?? 64, options.scopeBands ?? 48)
1775
+ : undefined;
1977
1776
  const channelCount = Math.max(1, Math.floor(options.channelCount ?? 2));
1978
1777
  const processorOptions: SonareRealtimeEngineWorkletProcessorOptions = {
1979
1778
  runtimeTarget,
@@ -1988,6 +1787,13 @@ export class SonareRealtimeEngineNode {
1988
1787
  telemetryRingCapacity: telemetryRing?.capacity,
1989
1788
  meterSharedBuffer: meterRing?.sharedBuffer,
1990
1789
  meterRingCapacity: meterRing?.capacity,
1790
+ scopeSharedBuffer: scopeRing?.sharedBuffer,
1791
+ scopeRingCapacity: scopeRing?.capacity,
1792
+ scopeBands: scopeRing?.bands,
1793
+ scopeIntervalFrames: scopeRing ? scopeIntervalFrames : undefined,
1794
+ wasmBinary: options.wasmBinary,
1795
+ initialSyncMessages: options.initialSyncMessages,
1796
+ initialCommands: options.initialCommands,
1991
1797
  };
1992
1798
  const factory =
1993
1799
  options.nodeFactory ??
@@ -2011,10 +1817,14 @@ export class SonareRealtimeEngineNode {
2011
1817
  expectedEngineAbiVersion: detectedCapabilities?.expectedEngineAbiVersion,
2012
1818
  abiCompatible: detectedCapabilities?.abiCompatible,
2013
1819
  degradedReason,
1820
+ readyMessage:
1821
+ runtimeTarget === 'sonare-rt' ||
1822
+ (runtimeTarget === 'embind' && moduleUrl !== undefined && !options.nodeFactory),
2014
1823
  },
2015
1824
  commandRing,
2016
1825
  telemetryRing,
2017
1826
  meterRing,
1827
+ scopeRing,
2018
1828
  );
2019
1829
  }
2020
1830
 
@@ -2053,6 +1863,36 @@ export class SonareRealtimeEngineNode {
2053
1863
  return true;
2054
1864
  }
2055
1865
 
1866
+ requestCaptureStatus(): Promise<EngineCaptureStatus> {
1867
+ return this.sendCaptureRequest('status').then((response) => {
1868
+ if (!response.status) {
1869
+ throw new Error('Capture status response is missing status.');
1870
+ }
1871
+ return response.status;
1872
+ });
1873
+ }
1874
+
1875
+ requestCapturedAudio(): Promise<Float32Array[]> {
1876
+ return this.sendCaptureRequest('read').then((response) =>
1877
+ (response.channels ?? []).map((channel) =>
1878
+ channel instanceof Float32Array ? channel : new Float32Array(channel),
1879
+ ),
1880
+ );
1881
+ }
1882
+
1883
+ requestCaptureReset(): Promise<void> {
1884
+ return this.sendCaptureRequest('reset').then(() => undefined);
1885
+ }
1886
+
1887
+ requestTransportState(): Promise<EngineTransportState> {
1888
+ return this.sendTransportRequest().then((response) => {
1889
+ if (!response.state) {
1890
+ throw new Error('Transport state response is missing state.');
1891
+ }
1892
+ return response.state;
1893
+ });
1894
+ }
1895
+
2056
1896
  pollTelemetry(): SonareEngineTelemetryRecord[] {
2057
1897
  if (!this.telemetryRing) {
2058
1898
  return [];
@@ -2080,6 +1920,21 @@ export class SonareRealtimeEngineNode {
2080
1920
  return read.meters;
2081
1921
  }
2082
1922
 
1923
+ // Drains scope telemetry (FFT spectrum + goniometer points) published into the
1924
+ // SAB scope ring and forwards each record to onScope listeners. A no-op unless
1925
+ // the node was created with scopeIntervalFrames > 0 (embind SAB mode).
1926
+ pollScope(): SonareWorkletScopeSnapshot[] {
1927
+ if (!this.scopeRing) {
1928
+ return [];
1929
+ }
1930
+ const read = readSonareScopeRingBuffer(this.scopeRing, this.scopeReadIndex);
1931
+ this.scopeReadIndex = read.nextReadIndex;
1932
+ for (const scope of read.scopes) {
1933
+ this.emitScope(scope);
1934
+ }
1935
+ return read.scopes;
1936
+ }
1937
+
2083
1938
  onTelemetry(callback: (telemetry: SonareEngineTelemetryRecord) => void): () => void {
2084
1939
  this.telemetryListeners.add(callback);
2085
1940
  return () => {
@@ -2094,6 +1949,13 @@ export class SonareRealtimeEngineNode {
2094
1949
  };
2095
1950
  }
2096
1951
 
1952
+ onScope(callback: (scope: SonareWorkletScopeSnapshot) => void): () => void {
1953
+ this.scopeListeners.add(callback);
1954
+ return () => {
1955
+ this.scopeListeners.delete(callback);
1956
+ };
1957
+ }
1958
+
2097
1959
  destroy(): void {
2098
1960
  if (this.destroyed) {
2099
1961
  return;
@@ -2101,8 +1963,17 @@ export class SonareRealtimeEngineNode {
2101
1963
  this.destroyed = true;
2102
1964
  this.node.port.postMessage({ type: SonareEngineCommandType.TransportStop, sampleTime: -1 });
2103
1965
  this.node.disconnect();
1966
+ for (const pending of this.captureRequests.values()) {
1967
+ pending.reject(new Error('Realtime engine node is destroyed.'));
1968
+ }
1969
+ this.captureRequests.clear();
1970
+ for (const pending of this.transportRequests.values()) {
1971
+ pending.reject(new Error('Realtime engine node is destroyed.'));
1972
+ }
1973
+ this.transportRequests.clear();
2104
1974
  this.telemetryListeners.clear();
2105
1975
  this.meterListeners.clear();
1976
+ this.scopeListeners.clear();
2106
1977
  }
2107
1978
 
2108
1979
  private emitTelemetry(telemetry: SonareEngineTelemetryRecord): void {
@@ -2116,6 +1987,38 @@ export class SonareRealtimeEngineNode {
2116
1987
  listener(meter);
2117
1988
  }
2118
1989
  }
1990
+
1991
+ private emitScope(scope: SonareWorkletScopeSnapshot): void {
1992
+ for (const listener of this.scopeListeners) {
1993
+ listener(scope);
1994
+ }
1995
+ }
1996
+
1997
+ private sendCaptureRequest(
1998
+ op: SonareEngineCaptureRequestMessage['op'],
1999
+ ): Promise<SonareEngineCaptureResponseMessage> {
2000
+ if (this.destroyed) {
2001
+ return Promise.reject(new Error('Realtime engine node is destroyed.'));
2002
+ }
2003
+ const requestId = this.captureRequestId++;
2004
+ const promise = new Promise<SonareEngineCaptureResponseMessage>((resolve, reject) => {
2005
+ this.captureRequests.set(requestId, { resolve, reject });
2006
+ });
2007
+ this.node.port.postMessage({ type: 'captureRequest', requestId, op });
2008
+ return promise;
2009
+ }
2010
+
2011
+ private sendTransportRequest(): Promise<SonareEngineTransportResponseMessage> {
2012
+ if (this.destroyed) {
2013
+ return Promise.reject(new Error('Realtime engine node is destroyed.'));
2014
+ }
2015
+ const requestId = this.transportRequestId++;
2016
+ const promise = new Promise<SonareEngineTransportResponseMessage>((resolve, reject) => {
2017
+ this.transportRequests.set(requestId, { resolve, reject });
2018
+ });
2019
+ this.node.port.postMessage({ type: 'transportRequest', requestId, op: 'state' });
2020
+ return promise;
2021
+ }
2119
2022
  }
2120
2023
 
2121
2024
  export class SonareEngine {
@@ -2130,9 +2033,31 @@ export class SonareEngine {
2130
2033
  private readonly offlineChannelCount: number;
2131
2034
  private readonly automationLanes = new Map<number, EngineAutomationPoint[]>();
2132
2035
  private readonly clips = new Map<number, EngineClip>();
2036
+ private readonly midiClips = new Map<number, EngineMidiClipSchedule>();
2133
2037
  private readonly markers = new Map<number, EngineMarker>();
2038
+ private readonly trackLaneIds: number[] = [];
2039
+ private readonly trackSends = new Map<number, EngineTrackSend[]>();
2040
+ private readonly trackOutputBus = new Map<number, number>();
2041
+ private readonly laneSidechains = new Map<
2042
+ string,
2043
+ { trackId: number; insertIndex: number; sourceTrackId: number }
2044
+ >();
2045
+ private readonly buses: EngineBus[] = [];
2046
+ private readonly trackStripJson = new Map<number, string>();
2047
+ private readonly busStripJson = new Map<number, string>();
2048
+ private masterStripJson: string | undefined;
2049
+ private captureConfig: Omit<SonareEngineSyncCaptureMessage, 'type'> | undefined;
2050
+ private tempoBpm = 120;
2051
+ private timeSignature = { numerator: 4, denominator: 4 };
2052
+ private tempoSegments: EngineTempoSegment[] = [{ startPpq: 0, bpm: 120 }];
2053
+ private timeSignatureSegments: EngineTimeSignatureSegment[] = [
2054
+ { startPpq: 0, numerator: 4, denominator: 4 },
2055
+ ];
2056
+ private latestTransportState: EngineTransportState | undefined;
2134
2057
  private nextClipId = 1;
2135
2058
  private nextMarkerId = 1;
2059
+ private transportPlaying = false;
2060
+ private readonly pendingInstrumentSync: SonareEngineInstrumentSyncMessage[] = [];
2136
2061
  private destroyed = false;
2137
2062
 
2138
2063
  private constructor(
@@ -2152,8 +2077,21 @@ export class SonareEngine {
2152
2077
  this.offlineBlockSize = offlineBlockSize;
2153
2078
  this.offlineChannelCount = offlineChannelCount;
2154
2079
  this.transport = {
2155
- play: (sampleTime = -1) => this.realtimeNode.play(sampleTime),
2156
- stop: (sampleTime = -1) => this.realtimeNode.stop(sampleTime),
2080
+ play: (sampleTime = -1) => {
2081
+ const ok = this.realtimeNode.play(sampleTime);
2082
+ if (ok) {
2083
+ this.transportPlaying = true;
2084
+ }
2085
+ return ok;
2086
+ },
2087
+ stop: (sampleTime = -1) => {
2088
+ const ok = this.realtimeNode.stop(sampleTime);
2089
+ if (ok) {
2090
+ this.transportPlaying = false;
2091
+ this.flushPendingInstrumentSync();
2092
+ }
2093
+ return ok;
2094
+ },
2157
2095
  seekPpq: (ppq, sampleTime = -1) => {
2158
2096
  this.offlineEngine.seekPpq(ppq, sampleTime);
2159
2097
  return this.realtimeNode.seekPpq(ppq, sampleTime);
@@ -2164,6 +2102,7 @@ export class SonareEngine {
2164
2102
  return this.realtimeNode.seekSample(timelineSample, sampleTime);
2165
2103
  },
2166
2104
  setTempo: (bpm) => this.setTempo(bpm),
2105
+ setTempoSegments: (segments) => this.setTempoSegments(segments),
2167
2106
  setLoop: (startPpq, endPpq, enabled = true) => this.setLoop(startPpq, endPpq, enabled),
2168
2107
  };
2169
2108
  }
@@ -2205,7 +2144,10 @@ export class SonareEngine {
2205
2144
  }
2206
2145
 
2207
2146
  setTempo(bpm: number): void {
2147
+ this.tempoBpm = bpm;
2148
+ this.tempoSegments = [{ startPpq: 0, bpm }];
2208
2149
  this.offlineEngine.setTempo(bpm);
2150
+ this.postTempoSync();
2209
2151
  this.realtimeNode.sendCommand({
2210
2152
  type: SonareEngineCommandType.SetTempoMap,
2211
2153
  sampleTime: -1,
@@ -2213,6 +2155,30 @@ export class SonareEngine {
2213
2155
  });
2214
2156
  }
2215
2157
 
2158
+ setTempoSegments(segments: readonly EngineTempoSegment[]): void {
2159
+ this.tempoSegments = segments.map((segment) => ({ ...segment }));
2160
+ this.tempoBpm = this.tempoSegments[0]?.bpm ?? this.tempoBpm;
2161
+ this.offlineEngine.setTempoSegments(this.tempoSegments);
2162
+ this.postTempoSync();
2163
+ }
2164
+
2165
+ setTimeSignature(numerator: number, denominator: number): void {
2166
+ this.timeSignature = { numerator, denominator };
2167
+ this.timeSignatureSegments = [{ startPpq: 0, numerator, denominator }];
2168
+ this.offlineEngine.setTimeSignature(numerator, denominator);
2169
+ this.postTempoSync();
2170
+ }
2171
+
2172
+ setTimeSignatureSegments(segments: readonly EngineTimeSignatureSegment[]): void {
2173
+ this.timeSignatureSegments = segments.map((segment) => ({ ...segment }));
2174
+ const first = this.timeSignatureSegments[0];
2175
+ if (first) {
2176
+ this.timeSignature = { numerator: first.numerator, denominator: first.denominator };
2177
+ }
2178
+ this.offlineEngine.setTimeSignatureSegments(this.timeSignatureSegments);
2179
+ this.postTempoSync();
2180
+ }
2181
+
2216
2182
  setLoop(startPpq: number, endPpq: number, enabled = true): boolean {
2217
2183
  this.offlineEngine.setLoop(startPpq, endPpq, enabled);
2218
2184
  // Transport precision contract: the SAB command record carries exactly one
@@ -2233,6 +2199,20 @@ export class SonareEngine {
2233
2199
  });
2234
2200
  }
2235
2201
 
2202
+ countInEndSample(startSample: number, bars: number): number {
2203
+ return this.offlineEngine.countInEndSample(startSample, bars);
2204
+ }
2205
+
2206
+ async getTransportState(): Promise<EngineTransportState> {
2207
+ const state = await this.realtimeNode.requestTransportState();
2208
+ this.latestTransportState = state;
2209
+ return state;
2210
+ }
2211
+
2212
+ cachedTransportState(): EngineTransportState | undefined {
2213
+ return this.latestTransportState;
2214
+ }
2215
+
2236
2216
  setParam(nodeId: string, param: string | number, value: number): boolean {
2237
2217
  const paramId = this.resolveParamId(nodeId, param);
2238
2218
  // Mirror the change into the offline engine so a subsequent offline render
@@ -2276,6 +2256,68 @@ export class SonareEngine {
2276
2256
  this.scheduleParam('', laneId, ppq, value, curve);
2277
2257
  }
2278
2258
 
2259
+ /**
2260
+ * Replaces the automation lane for `paramId` with the given breakpoints.
2261
+ *
2262
+ * Unlike scheduleParam (which appends a single point), this sets the whole
2263
+ * lane at once; an empty array clears the lane. The points are defensively
2264
+ * copied and sorted by ppq before being mirrored to the offline engine and
2265
+ * the live worklet engine.
2266
+ *
2267
+ * @param paramId Automation target id (registered parameter or a reserved
2268
+ * engine mixer target from automationParamId/busAutomationParamId).
2269
+ * @param points Lane breakpoints; order does not matter.
2270
+ */
2271
+ setAutomationLane(paramId: number, points: ReadonlyArray<EngineAutomationPoint>): void {
2272
+ const sorted = points.map((point) => ({ ...point })).sort((a, b) => a.ppq - b.ppq);
2273
+ if (sorted.length === 0) {
2274
+ this.automationLanes.delete(paramId);
2275
+ } else {
2276
+ this.automationLanes.set(paramId, sorted);
2277
+ }
2278
+ this.offlineEngine.setAutomationLane(paramId, sorted);
2279
+ this.postSync({ type: 'syncAutomation', paramId, points: sorted });
2280
+ }
2281
+
2282
+ /**
2283
+ * Returns the automation target id for a mixer strip parameter.
2284
+ *
2285
+ * The id addresses the engine's reserved mixer namespace, so it can be fed
2286
+ * straight to setAutomationLane to automate a fader or pan without
2287
+ * registering a parameter.
2288
+ *
2289
+ * @param target Track id (declares a mixer lane on first use) or 'master'.
2290
+ * @param kind Strip parameter to address.
2291
+ * @returns Reserved engine parameter id for the strip parameter.
2292
+ */
2293
+ automationParamId(target: string | number, kind: 'faderDb' | 'pan'): number {
2294
+ const paramKind = kind === 'pan' ? ENGINE_MIXER_PARAM_PAN : ENGINE_MIXER_PARAM_FADER_DB;
2295
+ if (target === 'master') {
2296
+ return engineMixerMasterTarget(paramKind);
2297
+ }
2298
+ return engineMixerLaneTarget(this.ensureTrackLane(target), paramKind);
2299
+ }
2300
+
2301
+ /**
2302
+ * Returns the automation target id for a bus fader.
2303
+ *
2304
+ * @param busId Bus id (declares the mixer bus on first use).
2305
+ * @returns Reserved engine parameter id for the bus fader gain (dB).
2306
+ */
2307
+ busAutomationParamId(busId: number): number {
2308
+ return engineMixerBusTarget(this.ensureBus(busId), ENGINE_MIXER_PARAM_FADER_DB);
2309
+ }
2310
+
2311
+ /**
2312
+ * Returns the number of automation lanes installed on the engine, including
2313
+ * lanes whose breakpoint list is currently empty.
2314
+ *
2315
+ * @returns Engine-side automation lane count.
2316
+ */
2317
+ automationLaneCount(): number {
2318
+ return this.offlineEngine.automationLaneCount();
2319
+ }
2320
+
2279
2321
  listParameters(): EngineParameterInfo[] {
2280
2322
  const parameters: EngineParameterInfo[] = [];
2281
2323
  for (let index = 0; index < this.offlineEngine.parameterCount(); index++) {
@@ -2285,19 +2327,355 @@ export class SonareEngine {
2285
2327
  }
2286
2328
 
2287
2329
  setSoloMute(target: string | number, solo: boolean, mute: boolean): boolean {
2288
- void target;
2289
- void solo;
2290
- void mute;
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.',
2330
+ const laneIndex = this.ensureTrackLane(target);
2331
+ this.offlineEngine.setSoloMute(laneIndex, solo, mute);
2332
+ return this.realtimeNode.sendCommand({
2333
+ type: SonareEngineCommandType.SetSoloMute,
2334
+ targetId: laneIndex,
2335
+ sampleTime: -1,
2336
+ argInt: (mute ? 0x1 : 0) | (solo ? 0x2 : 0),
2337
+ });
2338
+ }
2339
+
2340
+ setStripGain(target: string | number, db: number): boolean {
2341
+ if (target === 'master') {
2342
+ const paramId = engineMixerMasterTarget(ENGINE_MIXER_PARAM_FADER_DB);
2343
+ this.offlineEngine.setParameter(paramId, db);
2344
+ return this.realtimeNode.sendCommand({
2345
+ type: SonareEngineCommandType.SetParamSmoothed,
2346
+ targetId: paramId,
2347
+ sampleTime: -1,
2348
+ argFloat: db,
2349
+ });
2350
+ }
2351
+ const laneIndex = this.ensureTrackLane(target);
2352
+ const paramId = engineMixerLaneTarget(laneIndex, ENGINE_MIXER_PARAM_FADER_DB);
2353
+ this.offlineEngine.setParameter(paramId, db);
2354
+ return this.realtimeNode.sendCommand({
2355
+ type: SonareEngineCommandType.SetParamSmoothed,
2356
+ targetId: paramId,
2357
+ sampleTime: -1,
2358
+ argFloat: db,
2359
+ });
2360
+ }
2361
+
2362
+ setStripPan(target: string | number, pan: number): boolean {
2363
+ if (target === 'master') {
2364
+ const paramId = engineMixerMasterTarget(ENGINE_MIXER_PARAM_PAN);
2365
+ this.offlineEngine.setParameter(paramId, pan);
2366
+ return this.realtimeNode.sendCommand({
2367
+ type: SonareEngineCommandType.SetParamSmoothed,
2368
+ targetId: paramId,
2369
+ sampleTime: -1,
2370
+ argFloat: pan,
2371
+ });
2372
+ }
2373
+ const laneIndex = this.ensureTrackLane(target);
2374
+ const paramId = engineMixerLaneTarget(laneIndex, ENGINE_MIXER_PARAM_PAN);
2375
+ this.offlineEngine.setParameter(paramId, pan);
2376
+ return this.realtimeNode.sendCommand({
2377
+ type: SonareEngineCommandType.SetParamSmoothed,
2378
+ targetId: paramId,
2379
+ sampleTime: -1,
2380
+ argFloat: pan,
2381
+ });
2382
+ }
2383
+
2384
+ /**
2385
+ * Declares the mixer track lanes in an explicit order.
2386
+ *
2387
+ * Lane indices are append-only: once a track id occupies a lane, its index
2388
+ * stays fixed for the engine's lifetime. The given list must therefore start
2389
+ * with the already-declared lane ids in their current order and may only
2390
+ * append new track ids after them. Entries carrying `sends` replace that
2391
+ * track's send list; entries without `sends` leave existing sends untouched.
2392
+ *
2393
+ * @param lanes Track ids or lane descriptors in the desired lane order.
2394
+ */
2395
+ setTrackLanes(lanes: ReadonlyArray<number | EngineTrackLane>): void {
2396
+ const entries = lanes.map((lane) => (typeof lane === 'number' ? { trackId: lane } : lane));
2397
+ const ids: number[] = [];
2398
+ for (const entry of entries) {
2399
+ if (!Number.isInteger(entry.trackId) || entry.trackId <= 0) {
2400
+ throw new Error(`Invalid track id for mixer lane: ${String(entry.trackId)}`);
2401
+ }
2402
+ ids.push(entry.trackId);
2403
+ }
2404
+ if (new Set(ids).size !== ids.length) {
2405
+ throw new Error('Duplicate track id in mixer lane list');
2406
+ }
2407
+ for (let index = 0; index < this.trackLaneIds.length; index++) {
2408
+ if (ids[index] !== this.trackLaneIds[index]) {
2409
+ throw new Error(
2410
+ 'Mixer lanes are append-only: keep existing lanes in order and only append new track ids',
2411
+ );
2412
+ }
2413
+ }
2414
+ for (const entry of entries) {
2415
+ if (entry.sends) {
2416
+ this.trackSends.set(
2417
+ entry.trackId,
2418
+ entry.sends.map((send) => ({ ...send })),
2419
+ );
2420
+ }
2421
+ if (entry.outputBusId !== undefined) {
2422
+ if (entry.outputBusId === 0) {
2423
+ this.trackOutputBus.delete(entry.trackId);
2424
+ } else {
2425
+ this.trackOutputBus.set(entry.trackId, entry.outputBusId);
2426
+ }
2427
+ }
2428
+ }
2429
+ this.trackLaneIds.splice(0, this.trackLaneIds.length, ...ids);
2430
+ this.syncMixer();
2431
+ }
2432
+
2433
+ /**
2434
+ * Routes a track lane's post-fader output into a declared bus instead of
2435
+ * the master mix (group/folder routing); busId 0 restores the master mix.
2436
+ */
2437
+ setTrackOutputBus(target: string | number, busId: number): void {
2438
+ const laneIndex = this.ensureTrackLane(target);
2439
+ const trackId = this.trackLaneIds[laneIndex];
2440
+ if (busId === 0) {
2441
+ this.trackOutputBus.delete(trackId);
2442
+ } else {
2443
+ this.trackOutputBus.set(trackId, busId);
2444
+ }
2445
+ this.syncMixer();
2446
+ }
2447
+
2448
+ /**
2449
+ * Keys one insert of a lane strip from another lane's post-strip pre-fader
2450
+ * audio (ducking/sidechainRouter inserts). sourceTarget null removes the
2451
+ * binding.
2452
+ */
2453
+ setLaneSidechain(
2454
+ target: string | number,
2455
+ insertIndex: number,
2456
+ sourceTarget: string | number | null,
2457
+ ): void {
2458
+ const laneIndex = this.ensureTrackLane(target);
2459
+ const trackId = this.trackLaneIds[laneIndex];
2460
+ const key = `${trackId}:${insertIndex}`;
2461
+ let sourceTrackId = 0;
2462
+ if (sourceTarget !== null) {
2463
+ const sourceIndex = this.ensureTrackLane(sourceTarget);
2464
+ sourceTrackId = this.trackLaneIds[sourceIndex];
2465
+ }
2466
+ if (sourceTrackId === 0) {
2467
+ this.laneSidechains.delete(key);
2468
+ } else {
2469
+ this.laneSidechains.set(key, { trackId, insertIndex, sourceTrackId });
2470
+ }
2471
+ this.offlineEngine.setLaneSidechain(trackId, insertIndex, sourceTrackId);
2472
+ this.postSync({
2473
+ type: 'syncMixer',
2474
+ lanes: this.mixerLanes(),
2475
+ laneSidechains: [{ trackId, insertIndex, sourceTrackId }],
2476
+ });
2477
+ }
2478
+
2479
+ setSends(target: string | number, sends: EngineTrackSend[]): void {
2480
+ const laneIndex = this.ensureTrackLane(target);
2481
+ const trackId = this.trackLaneIds[laneIndex];
2482
+ this.trackSends.set(
2483
+ trackId,
2484
+ sends.map((send) => ({ ...send })),
2300
2485
  );
2486
+ this.syncMixer();
2487
+ }
2488
+
2489
+ setTrackBuses(buses: EngineBus[]): void {
2490
+ this.buses.splice(0, this.buses.length, ...buses.map((bus) => ({ ...bus })));
2491
+ this.syncMixer();
2492
+ }
2493
+
2494
+ setBusGain(busId: number, db: number): boolean {
2495
+ const busIndex = this.ensureBus(busId);
2496
+ this.buses[busIndex] = { ...this.buses[busIndex], busId, gainDb: db };
2497
+ this.offlineEngine.setTrackBuses(this.buses);
2498
+ const paramId = engineMixerBusTarget(busIndex, ENGINE_MIXER_PARAM_FADER_DB);
2499
+ this.offlineEngine.setParameter(paramId, db);
2500
+ return this.realtimeNode.sendCommand({
2501
+ type: SonareEngineCommandType.SetParamSmoothed,
2502
+ targetId: paramId,
2503
+ sampleTime: -1,
2504
+ argFloat: db,
2505
+ });
2506
+ }
2507
+
2508
+ setTrackStripJson(target: string | number, sceneJson: string): void {
2509
+ const laneIndex = this.ensureTrackLane(target);
2510
+ const trackId = this.trackLaneIds[laneIndex];
2511
+ this.offlineEngine.setTrackStripJson(trackId, sceneJson);
2512
+ this.trackStripJson.set(trackId, sceneJson);
2513
+ this.syncMixer();
2514
+ }
2515
+
2516
+ setTrackStripEqBand(target: string | number, bandIndex: number, band: EqBand | string): void {
2517
+ const laneIndex = this.ensureTrackLane(target);
2518
+ const trackId = this.trackLaneIds[laneIndex];
2519
+ const bandJson = typeof band === 'string' ? band : JSON.stringify(band);
2520
+ this.offlineEngine.setTrackStripEqBandJson(trackId, bandIndex, bandJson);
2521
+ this.postSync({ type: 'syncTrackStripEqBand', trackId, bandIndex, bandJson });
2522
+ }
2523
+
2524
+ setTrackStripInsertBypassed(
2525
+ target: string | number,
2526
+ insertIndex: number,
2527
+ bypassed: boolean,
2528
+ resetOnBypass = false,
2529
+ ): void {
2530
+ const laneIndex = this.ensureTrackLane(target);
2531
+ const trackId = this.trackLaneIds[laneIndex];
2532
+ this.offlineEngine.setTrackStripInsertBypassed(trackId, insertIndex, bypassed, resetOnBypass);
2533
+ this.postSync({
2534
+ type: 'syncTrackStripInsertBypassed',
2535
+ trackId,
2536
+ insertIndex,
2537
+ bypassed,
2538
+ resetOnBypass,
2539
+ });
2540
+ }
2541
+
2542
+ setTrackStripInsertParamByName(
2543
+ target: string | number,
2544
+ insertIndex: number,
2545
+ paramName: string,
2546
+ value: number,
2547
+ ): void {
2548
+ const laneIndex = this.ensureTrackLane(target);
2549
+ const trackId = this.trackLaneIds[laneIndex];
2550
+ this.offlineEngine.setTrackStripInsertParamByName(trackId, insertIndex, paramName, value);
2551
+ this.postSync({
2552
+ type: 'syncTrackStripInsertParamByName',
2553
+ trackId,
2554
+ insertIndex,
2555
+ paramName,
2556
+ value,
2557
+ });
2558
+ }
2559
+
2560
+ setTrackStripPan(target: string | number, pan: number): void {
2561
+ const trackId = this.trackLaneIds[this.ensureTrackLane(target)];
2562
+ this.offlineEngine.setTrackStripPan(trackId, pan);
2563
+ this.postSync({ type: 'syncTrackStripPan', trackId, pan });
2564
+ }
2565
+
2566
+ setTrackStripPanLaw(target: string | number, panLaw: PanLaw | number): void {
2567
+ const trackId = this.trackLaneIds[this.ensureTrackLane(target)];
2568
+ const code = panLawCode(panLaw);
2569
+ this.offlineEngine.setTrackStripPanLaw(trackId, code);
2570
+ this.postSync({ type: 'syncTrackStripPanLaw', trackId, panLaw: code });
2571
+ }
2572
+
2573
+ setTrackStripPanMode(target: string | number, panMode: PanMode | number): void {
2574
+ const trackId = this.trackLaneIds[this.ensureTrackLane(target)];
2575
+ const code = panModeCode(panMode);
2576
+ this.offlineEngine.setTrackStripPanMode(trackId, code);
2577
+ this.postSync({ type: 'syncTrackStripPanMode', trackId, panMode: code });
2578
+ }
2579
+
2580
+ setTrackStripDualPan(target: string | number, leftPan: number, rightPan: number): void {
2581
+ const trackId = this.trackLaneIds[this.ensureTrackLane(target)];
2582
+ this.offlineEngine.setTrackStripDualPan(trackId, leftPan, rightPan);
2583
+ this.postSync({ type: 'syncTrackStripDualPan', trackId, leftPan, rightPan });
2584
+ }
2585
+
2586
+ setTrackStripChannelDelaySamples(target: string | number, delaySamples: number): void {
2587
+ const trackId = this.trackLaneIds[this.ensureTrackLane(target)];
2588
+ this.offlineEngine.setTrackStripChannelDelaySamples(trackId, delaySamples);
2589
+ this.postSync({ type: 'syncTrackStripChannelDelaySamples', trackId, delaySamples });
2590
+ }
2591
+
2592
+ setStripEq(target: string | number, bandIndex: number, band: EqBand | string): void {
2593
+ if (target === 'master') {
2594
+ this.setMasterStripEqBand(bandIndex, band);
2595
+ return;
2596
+ }
2597
+ this.setTrackStripEqBand(target, bandIndex, band);
2598
+ }
2599
+
2600
+ setStripInsertBypassed(
2601
+ target: string | number,
2602
+ insertIndex: number,
2603
+ bypassed: boolean,
2604
+ resetOnBypass = false,
2605
+ ): void {
2606
+ if (target === 'master') {
2607
+ this.setMasterStripInsertBypassed(insertIndex, bypassed, resetOnBypass);
2608
+ return;
2609
+ }
2610
+ this.setTrackStripInsertBypassed(target, insertIndex, bypassed, resetOnBypass);
2611
+ }
2612
+
2613
+ setStripInserts(target: string | number, sceneJson: string): void {
2614
+ if (target === 'master') {
2615
+ this.setMasterStripJson(sceneJson);
2616
+ return;
2617
+ }
2618
+ this.setTrackStripJson(target, sceneJson);
2619
+ }
2620
+
2621
+ setBusStripJson(busId: number, sceneJson: string): void {
2622
+ this.ensureBus(busId);
2623
+ this.offlineEngine.setBusStripJson(busId, sceneJson);
2624
+ this.busStripJson.set(busId, sceneJson);
2625
+ this.syncMixer();
2626
+ }
2627
+
2628
+ setMasterStripJson(sceneJson: string): void {
2629
+ this.offlineEngine.setMasterStripJson(sceneJson);
2630
+ this.masterStripJson = sceneJson;
2631
+ this.syncMixer();
2632
+ }
2633
+
2634
+ setMasterStripEqBand(bandIndex: number, band: EqBand | string): void {
2635
+ const bandJson = typeof band === 'string' ? band : JSON.stringify(band);
2636
+ this.offlineEngine.setMasterStripEqBandJson(bandIndex, bandJson);
2637
+ this.postSync({ type: 'syncMasterStripEqBand', bandIndex, bandJson });
2638
+ }
2639
+
2640
+ setMasterStripInsertBypassed(
2641
+ insertIndex: number,
2642
+ bypassed: boolean,
2643
+ resetOnBypass = false,
2644
+ ): void {
2645
+ this.offlineEngine.setMasterStripInsertBypassed(insertIndex, bypassed, resetOnBypass);
2646
+ this.postSync({
2647
+ type: 'syncMasterStripInsertBypassed',
2648
+ insertIndex,
2649
+ bypassed,
2650
+ resetOnBypass,
2651
+ });
2652
+ }
2653
+
2654
+ setMasterStripInsertParamByName(insertIndex: number, paramName: string, value: number): void {
2655
+ this.offlineEngine.setMasterStripInsertParamByName(insertIndex, paramName, value);
2656
+ this.postSync({
2657
+ type: 'syncMasterStripInsertParamByName',
2658
+ insertIndex,
2659
+ paramName,
2660
+ value,
2661
+ });
2662
+ }
2663
+
2664
+ setStripInsertParamByName(
2665
+ target: string | number,
2666
+ insertIndex: number,
2667
+ paramName: string,
2668
+ value: number,
2669
+ ): void {
2670
+ if (target === 'master') {
2671
+ this.setMasterStripInsertParamByName(insertIndex, paramName, value);
2672
+ return;
2673
+ }
2674
+ this.setTrackStripInsertParamByName(target, insertIndex, paramName, value);
2675
+ }
2676
+
2677
+ setMasterChain(sceneJson: string): void {
2678
+ this.setMasterStripJson(sceneJson);
2301
2679
  }
2302
2680
 
2303
2681
  addClip(
@@ -2312,19 +2690,152 @@ export class SonareEngine {
2312
2690
  id,
2313
2691
  channels: buffer,
2314
2692
  startPpq,
2693
+ trackId: this.resolveTargetId(trackId),
2315
2694
  };
2695
+ this.ensureTrackLane(trackId);
2316
2696
  this.clips.set(id, clip);
2317
- this.syncClips();
2318
- void trackId;
2697
+ this.syncClipsDelta([clip], []);
2319
2698
  return id;
2320
2699
  }
2321
2700
 
2322
2701
  removeClip(clipId: number): void {
2323
2702
  this.clips.delete(clipId);
2324
- this.syncClips();
2703
+ this.syncClipsDelta([], [clipId]);
2704
+ }
2705
+
2706
+ setMidiClips(clips: readonly EngineMidiClipSchedule[]): void {
2707
+ this.midiClips.clear();
2708
+ for (const clip of clips) {
2709
+ const id = clip.id ?? this.nextClipId++;
2710
+ this.midiClips.set(id, { ...clip, id, events: clip.events.map((event) => ({ ...event })) });
2711
+ }
2712
+ this.syncMidiClips();
2713
+ }
2714
+
2715
+ setBuiltinInstrument(
2716
+ trackId: string | number,
2717
+ config: { destinationId?: number } & Record<string, unknown> = {},
2718
+ ): void {
2719
+ const destinationId = this.resolveTargetId(trackId);
2720
+ this.offlineEngine.setBuiltinInstrument(config, destinationId);
2721
+ this.postInstrumentSync({ type: 'syncBuiltinInstrument', destinationId, config });
2722
+ }
2723
+
2724
+ setSynthInstrument(trackId: string | number, patch: Record<string, unknown> | string = {}): void {
2725
+ const destinationId = this.resolveTargetId(trackId);
2726
+ this.offlineEngine.setSynthInstrument(patch, destinationId);
2727
+ this.postInstrumentSync({ type: 'syncSynthInstrument', destinationId, patch });
2728
+ }
2729
+
2730
+ loadSoundFont(data: Uint8Array): void {
2731
+ this.offlineEngine.loadSoundFont(data);
2732
+ this.postInstrumentSync({ type: 'syncLoadSoundFont', data });
2733
+ }
2734
+
2735
+ setSf2Instrument(
2736
+ trackId: string | number,
2737
+ config: { destinationId?: number; gain?: number; polyphony?: number } = {},
2738
+ ): void {
2739
+ const destinationId = this.resolveTargetId(trackId);
2740
+ this.offlineEngine.setSf2Instrument(config, destinationId);
2741
+ this.postInstrumentSync({ type: 'syncSf2Instrument', destinationId, config });
2742
+ }
2743
+
2744
+ pushMidiNoteOn(
2745
+ trackId: string | number,
2746
+ group: number,
2747
+ channel: number,
2748
+ note: number,
2749
+ velocity: number,
2750
+ renderFrame = -1,
2751
+ ): void {
2752
+ const destinationId = this.resolveTargetId(trackId);
2753
+ this.offlineEngine.pushMidiNoteOn(destinationId, group, channel, note, velocity, renderFrame);
2754
+ this.postSync({
2755
+ type: 'syncMidiNoteOn',
2756
+ destinationId,
2757
+ group,
2758
+ channel,
2759
+ note,
2760
+ velocity,
2761
+ renderFrame,
2762
+ });
2763
+ }
2764
+
2765
+ pushMidiNoteOff(
2766
+ trackId: string | number,
2767
+ group: number,
2768
+ channel: number,
2769
+ note: number,
2770
+ velocity = 0,
2771
+ renderFrame = -1,
2772
+ ): void {
2773
+ const destinationId = this.resolveTargetId(trackId);
2774
+ this.offlineEngine.pushMidiNoteOff(destinationId, group, channel, note, velocity, renderFrame);
2775
+ this.postSync({
2776
+ type: 'syncMidiNoteOff',
2777
+ destinationId,
2778
+ group,
2779
+ channel,
2780
+ note,
2781
+ velocity,
2782
+ renderFrame,
2783
+ });
2784
+ }
2785
+
2786
+ pushMidiCc(
2787
+ trackId: string | number,
2788
+ group: number,
2789
+ channel: number,
2790
+ controller: number,
2791
+ value: number,
2792
+ renderFrame = -1,
2793
+ ): void {
2794
+ const destinationId = this.resolveTargetId(trackId);
2795
+ this.offlineEngine.pushMidiCc(destinationId, group, channel, controller, value, renderFrame);
2796
+ this.postSync({
2797
+ type: 'syncMidiCc',
2798
+ destinationId,
2799
+ group,
2800
+ channel,
2801
+ controller,
2802
+ value,
2803
+ renderFrame,
2804
+ });
2805
+ }
2806
+
2807
+ pushMidiPanic(renderFrame = -1): void {
2808
+ this.offlineEngine.pushMidiPanic(renderFrame);
2809
+ this.postSync({ type: 'syncMidiPanic', renderFrame });
2810
+ }
2811
+
2812
+ configureCapture(options: {
2813
+ bufferFrames: number;
2814
+ channels?: number;
2815
+ source?: EngineCaptureStatus['source'];
2816
+ recordOffsetSamples?: number;
2817
+ inputMonitor?: { enabled: boolean; gain?: number };
2818
+ }): void {
2819
+ const bufferFrames = Math.trunc(options.bufferFrames);
2820
+ const channels = Math.trunc(options.channels ?? this.offlineChannelCount);
2821
+ const source = options.source ?? 'output';
2822
+ const recordOffsetSamples = Math.trunc(options.recordOffsetSamples ?? 0);
2823
+ const inputMonitor = {
2824
+ enabled: Boolean(options.inputMonitor?.enabled),
2825
+ gain: options.inputMonitor?.gain ?? 1,
2826
+ };
2827
+ this.offlineEngine.setCaptureBuffer(channels, bufferFrames);
2828
+ this.offlineEngine.setCaptureSource(source);
2829
+ this.offlineEngine.setRecordOffsetSamples(recordOffsetSamples);
2830
+ this.offlineEngine.setInputMonitor(inputMonitor.enabled, inputMonitor.gain);
2831
+ this.captureConfig = { bufferFrames, channels, source, recordOffsetSamples, inputMonitor };
2832
+ this.postSync({ type: 'syncCapture', ...this.captureConfig });
2325
2833
  }
2326
2834
 
2327
2835
  armRecord(trackId: string | number, enabled: boolean): boolean {
2836
+ if (enabled && !this.captureConfig) {
2837
+ throw new Error('Capture buffer is not configured');
2838
+ }
2328
2839
  this.offlineEngine.armCapture(enabled);
2329
2840
  return this.realtimeNode.sendCommand({
2330
2841
  type: SonareEngineCommandType.ArmRecord,
@@ -2335,8 +2846,8 @@ export class SonareEngine {
2335
2846
  }
2336
2847
 
2337
2848
  punch(inPpq: number, outPpq: number): boolean {
2338
- const inSample = this.ppqToApproxSample(inPpq);
2339
- const outSample = this.ppqToApproxSample(outPpq);
2849
+ const inSample = this.offlineEngine.sampleAtPpq(inPpq);
2850
+ const outSample = this.offlineEngine.sampleAtPpq(outPpq);
2340
2851
  this.offlineEngine.setCapturePunch(inSample, outSample, true);
2341
2852
  // Carry BOTH endpoints as already-converted SAMPLES so the realtime engine
2342
2853
  // agrees with the offline engine. The previous code sent the raw PPQ out
@@ -2351,6 +2862,19 @@ export class SonareEngine {
2351
2862
  });
2352
2863
  }
2353
2864
 
2865
+ captureStatus(): Promise<EngineCaptureStatus> {
2866
+ return this.realtimeNode.requestCaptureStatus();
2867
+ }
2868
+
2869
+ capturedAudio(): Promise<Float32Array[]> {
2870
+ return this.realtimeNode.requestCapturedAudio();
2871
+ }
2872
+
2873
+ async resetCapture(): Promise<void> {
2874
+ this.offlineEngine.resetCapture();
2875
+ await this.realtimeNode.requestCaptureReset();
2876
+ }
2877
+
2354
2878
  setMetronome(opts: EngineMetronomeConfig): void {
2355
2879
  this.offlineEngine.setMetronome(opts);
2356
2880
  // The full config (beatGain/accentGain/clickSamples/clickSeconds) cannot fit
@@ -2371,6 +2895,59 @@ export class SonareEngine {
2371
2895
  return id;
2372
2896
  }
2373
2897
 
2898
+ /**
2899
+ * Replaces the whole marker set in one call.
2900
+ *
2901
+ * Entries without an `id` are assigned fresh ids; entries carrying an `id`
2902
+ * keep it (ids must be positive and unique within the list). Returns the
2903
+ * resolved markers in the order given, so a caller can map its own marker
2904
+ * identities to the engine ids used by `seekMarker`/`setLoopFromMarkers`.
2905
+ *
2906
+ * @param markers The full marker list (an empty list clears all markers).
2907
+ * @returns The markers with their resolved engine ids.
2908
+ */
2909
+ setMarkers(markers: ReadonlyArray<{ ppq: number; name?: string; id?: number }>): EngineMarker[] {
2910
+ const resolved: EngineMarker[] = [];
2911
+ const seen = new Set<number>();
2912
+ for (const marker of markers) {
2913
+ if (!Number.isFinite(marker.ppq)) {
2914
+ throw new Error(`Invalid marker ppq: ${String(marker.ppq)}`);
2915
+ }
2916
+ if (marker.id !== undefined) {
2917
+ if (!Number.isInteger(marker.id) || marker.id <= 0) {
2918
+ throw new Error(`Invalid marker id: ${String(marker.id)}`);
2919
+ }
2920
+ if (seen.has(marker.id)) {
2921
+ throw new Error(`Duplicate marker id: ${marker.id}`);
2922
+ }
2923
+ }
2924
+ const id = marker.id ?? this.nextMarkerId++;
2925
+ seen.add(id);
2926
+ if (id >= this.nextMarkerId) {
2927
+ this.nextMarkerId = id + 1;
2928
+ }
2929
+ resolved.push({ id, ppq: marker.ppq, name: marker.name ?? '' });
2930
+ }
2931
+ this.markers.clear();
2932
+ for (const marker of resolved) {
2933
+ this.markers.set(marker.id, marker);
2934
+ }
2935
+ this.syncMarkers();
2936
+ return resolved.map((marker) => ({ ...marker }));
2937
+ }
2938
+
2939
+ markerCount(): number {
2940
+ return this.offlineEngine.markerCount();
2941
+ }
2942
+
2943
+ markerByIndex(index: number): EngineMarker {
2944
+ return this.offlineEngine.markerByIndex(index);
2945
+ }
2946
+
2947
+ marker(markerId: number): EngineMarker {
2948
+ return this.offlineEngine.marker(markerId);
2949
+ }
2950
+
2374
2951
  seekMarker(markerId: number): boolean {
2375
2952
  this.offlineEngine.seekMarker(markerId);
2376
2953
  // Forward to the live worklet engine. Its marker set is kept in sync via the
@@ -2384,6 +2961,13 @@ export class SonareEngine {
2384
2961
  });
2385
2962
  }
2386
2963
 
2964
+ setLoopFromMarkers(startMarkerId: number, endMarkerId: number): boolean {
2965
+ this.offlineEngine.setLoopFromMarkers(startMarkerId, endMarkerId);
2966
+ const start = this.offlineEngine.marker(startMarkerId);
2967
+ const end = this.offlineEngine.marker(endMarkerId);
2968
+ return this.setLoop(start.ppq, end.ppq, true);
2969
+ }
2970
+
2387
2971
  async renderOffline(totalFrames: number): Promise<Float32Array[]> {
2388
2972
  const frames = Math.max(0, Math.floor(totalFrames));
2389
2973
  const inputs: Float32Array[] = [];
@@ -2397,6 +2981,10 @@ export class SonareEngine {
2397
2981
  return this.realtimeNode.onMeter(callback);
2398
2982
  }
2399
2983
 
2984
+ onScope(callback: (scope: SonareWorkletScopeSnapshot) => void): () => void {
2985
+ return this.realtimeNode.onScope(callback);
2986
+ }
2987
+
2400
2988
  onTelemetry(callback: (telemetry: SonareEngineTelemetryRecord) => void): () => void {
2401
2989
  return this.realtimeNode.onTelemetry(callback);
2402
2990
  }
@@ -2409,6 +2997,10 @@ export class SonareEngine {
2409
2997
  return this.realtimeNode.pollMeters();
2410
2998
  }
2411
2999
 
3000
+ pollScope(): SonareWorkletScopeSnapshot[] {
3001
+ return this.realtimeNode.pollScope();
3002
+ }
3003
+
2412
3004
  destroy(): void {
2413
3005
  if (this.destroyed) {
2414
3006
  return;
@@ -2420,14 +3012,58 @@ export class SonareEngine {
2420
3012
  this.offlineEngine.destroy();
2421
3013
  }
2422
3014
 
2423
- private syncClips(): void {
3015
+ private syncClipsDelta(upserts: EngineClip[], removeIds: number[]): void {
2424
3016
  const clips = Array.from(this.clips.values());
2425
3017
  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 });
3018
+ this.postSync({
3019
+ type: 'syncClipsDelta',
3020
+ upserts,
3021
+ removeIds,
3022
+ });
3023
+ }
3024
+
3025
+ private syncMidiClips(): void {
3026
+ const clips = Array.from(this.midiClips.values());
3027
+ this.offlineEngine.setMidiClips(clips);
3028
+ this.postSync({ type: 'syncMidiClips', clips });
3029
+ }
3030
+
3031
+ private mixerLanes(): EngineTrackLane[] {
3032
+ return this.trackLaneIds.map((trackId) => {
3033
+ const sends = this.trackSends.get(trackId);
3034
+ const outputBusId = this.trackOutputBus.get(trackId);
3035
+ return {
3036
+ trackId,
3037
+ ...(sends && sends.length > 0 ? { sends: sends.map((send) => ({ ...send })) } : {}),
3038
+ ...(outputBusId !== undefined ? { outputBusId } : {}),
3039
+ };
3040
+ });
3041
+ }
3042
+
3043
+ private syncMixer(): void {
3044
+ const lanes = this.mixerLanes();
3045
+ const buses = this.buses.map((bus) => ({ ...bus }));
3046
+ this.offlineEngine.setTrackBuses(buses);
3047
+ if (lanes.length > 0) {
3048
+ this.offlineEngine.setTrackLanes(lanes);
3049
+ }
3050
+ const trackStrips = Array.from(this.trackStripJson, ([trackId, sceneJson]) => ({
3051
+ trackId,
3052
+ sceneJson,
3053
+ }));
3054
+ const busStrips = Array.from(this.busStripJson, ([busId, sceneJson]) => ({
3055
+ busId,
3056
+ sceneJson,
3057
+ }));
3058
+ this.postSync({
3059
+ type: 'syncMixer',
3060
+ lanes,
3061
+ buses,
3062
+ trackStrips,
3063
+ laneSidechains: Array.from(this.laneSidechains.values()),
3064
+ busStrips,
3065
+ masterStripJson: this.masterStripJson,
3066
+ });
2431
3067
  }
2432
3068
 
2433
3069
  private syncMarkers(): void {
@@ -2436,6 +3072,37 @@ export class SonareEngine {
2436
3072
  this.postSync({ type: 'syncMarkers', markers });
2437
3073
  }
2438
3074
 
3075
+ private postInstrumentSync(message: SonareEngineInstrumentSyncMessage): void {
3076
+ if (this.destroyed) {
3077
+ return;
3078
+ }
3079
+ if (this.transportPlaying) {
3080
+ this.pendingInstrumentSync.push(message);
3081
+ return;
3082
+ }
3083
+ this.postSync(message);
3084
+ }
3085
+
3086
+ private flushPendingInstrumentSync(): void {
3087
+ if (this.destroyed || this.pendingInstrumentSync.length === 0) {
3088
+ return;
3089
+ }
3090
+ const pending = this.pendingInstrumentSync.splice(0);
3091
+ for (const message of pending) {
3092
+ this.postSync(message);
3093
+ }
3094
+ }
3095
+
3096
+ private postTempoSync(): void {
3097
+ this.postSync({
3098
+ type: 'syncTempo',
3099
+ bpm: this.tempoBpm,
3100
+ timeSignature: { ...this.timeSignature },
3101
+ tempoSegments: this.tempoSegments.map((segment) => ({ ...segment })),
3102
+ timeSignatureSegments: this.timeSignatureSegments.map((segment) => ({ ...segment })),
3103
+ });
3104
+ }
3105
+
2439
3106
  // Posts an out-of-band control-sync message to the worklet engine processor.
2440
3107
  // Sync messages use a string `type` so the worklet's message handler routes
2441
3108
  // them to receiveSync() (numeric `type` is reserved for SonareEngineCommandRecord).
@@ -2465,16 +3132,40 @@ export class SonareEngine {
2465
3132
  return Number.isFinite(parsed) ? parsed : 0;
2466
3133
  }
2467
3134
 
3135
+ private ensureTrackLane(target: string | number): number {
3136
+ const trackId = this.resolveTargetId(target);
3137
+ if (!Number.isInteger(trackId) || trackId <= 0) {
3138
+ throw new Error(`Invalid track id for mixer lane: ${String(target)}`);
3139
+ }
3140
+ const existing = this.trackLaneIds.indexOf(trackId);
3141
+ if (existing >= 0) {
3142
+ return existing;
3143
+ }
3144
+ this.trackLaneIds.push(trackId);
3145
+ this.syncMixer();
3146
+ return this.trackLaneIds.length - 1;
3147
+ }
3148
+
3149
+ private ensureBus(busId: number): number {
3150
+ const resolved = Math.trunc(busId);
3151
+ if (!Number.isInteger(resolved) || resolved <= 0) {
3152
+ throw new Error(`Invalid bus id for mixer bus: ${String(busId)}`);
3153
+ }
3154
+ const existing = this.buses.findIndex((bus) => bus.busId === resolved);
3155
+ if (existing >= 0) {
3156
+ return existing;
3157
+ }
3158
+ this.buses.push({ busId: resolved });
3159
+ this.syncMixer();
3160
+ return this.buses.length - 1;
3161
+ }
3162
+
2468
3163
  private curveCode(curve: number | 'linear' | 'exponential'): number {
2469
3164
  if (typeof curve === 'number') {
2470
3165
  return curve;
2471
3166
  }
2472
3167
  return curve === 'exponential' ? 1 : 0;
2473
3168
  }
2474
-
2475
- private ppqToApproxSample(ppq: number): number {
2476
- return Math.max(0, Math.round(((ppq * 60) / 120) * this.sampleRate));
2477
- }
2478
3169
  }
2479
3170
 
2480
3171
  export class SonareRealtimeVoiceChangerWorkletProcessor {
@@ -2768,6 +3459,7 @@ export function registerSonareRealtimeEngineWorkletProcessor(
2768
3459
  class RegisteredSonareRealtimeEngineWorkletProcessor extends Base {
2769
3460
  private bridge?: SonareRealtimeEngineWorkletProcessor;
2770
3461
  private rtBridge?: SonareRtRealtimeEngineRuntime;
3462
+ private readonly pendingMessages: unknown[] = [];
2771
3463
  readonly port?: WorkletPort;
2772
3464
 
2773
3465
  constructor(options?: { processorOptions?: SonareRealtimeEngineWorkletProcessorOptions }) {
@@ -2777,18 +3469,27 @@ export function registerSonareRealtimeEngineWorkletProcessor(
2777
3469
  if (processorOptions.runtimeTarget === 'sonare-rt') {
2778
3470
  void this.initializeSonareRt(processorOptions, port);
2779
3471
  } else {
2780
- this.bridge = new SonareRealtimeEngineWorkletProcessor(processorOptions, {
2781
- postMessage: (message) => port?.postMessage?.(message),
2782
- onMeter: (meter) => port?.postMessage?.(meter),
2783
- });
3472
+ void this.initializeEmbind(processorOptions, port);
2784
3473
  }
2785
3474
  const onMessage = (event: { data: unknown }) => {
3475
+ if (!this.bridge && !this.rtBridge) {
3476
+ if (this.pendingMessages.length < 1024) {
3477
+ this.pendingMessages.push(event.data);
3478
+ }
3479
+ return;
3480
+ }
2786
3481
  if (isEngineCommandRecord(event.data)) {
2787
3482
  this.bridge?.receiveCommand(event.data);
2788
3483
  this.rtBridge?.receiveCommand(event.data);
2789
3484
  } else if (isEngineSyncMessage(event.data)) {
2790
3485
  this.bridge?.receiveSync(event.data);
2791
3486
  this.rtBridge?.receiveSync(event.data);
3487
+ } else if (isEngineCaptureRequestMessage(event.data)) {
3488
+ this.bridge?.receiveCaptureRequest(event.data);
3489
+ this.rtBridge?.receiveCaptureRequest(event.data, port);
3490
+ } else if (isEngineTransportRequestMessage(event.data)) {
3491
+ this.bridge?.receiveTransportRequest(event.data);
3492
+ this.rtBridge?.receiveTransportRequest(event.data, port);
2792
3493
  }
2793
3494
  };
2794
3495
  if (port?.addEventListener) {
@@ -2813,6 +3514,75 @@ export function registerSonareRealtimeEngineWorkletProcessor(
2813
3514
  return true;
2814
3515
  }
2815
3516
 
3517
+ private replayPendingMessages(port?: WorkletPort): void {
3518
+ const messages = this.pendingMessages.splice(0);
3519
+ for (const data of messages) {
3520
+ if (isEngineCommandRecord(data)) {
3521
+ this.bridge?.receiveCommand(data);
3522
+ this.rtBridge?.receiveCommand(data);
3523
+ } else if (isEngineSyncMessage(data)) {
3524
+ this.bridge?.receiveSync(data);
3525
+ this.rtBridge?.receiveSync(data);
3526
+ } else if (isEngineCaptureRequestMessage(data)) {
3527
+ this.bridge?.receiveCaptureRequest(data);
3528
+ this.rtBridge?.receiveCaptureRequest(data, port);
3529
+ } else if (isEngineTransportRequestMessage(data)) {
3530
+ this.bridge?.receiveTransportRequest(data);
3531
+ this.rtBridge?.receiveTransportRequest(data, port);
3532
+ }
3533
+ }
3534
+ }
3535
+
3536
+ private async initializeEmbind(
3537
+ options: SonareRealtimeEngineWorkletProcessorOptions,
3538
+ port?: WorkletPort,
3539
+ ): Promise<void> {
3540
+ try {
3541
+ const initPromise = (
3542
+ globalThis as typeof globalThis & { SonareEmbindInitPromise?: Promise<void> }
3543
+ ).SonareEmbindInitPromise;
3544
+ if (initPromise) {
3545
+ await initPromise;
3546
+ }
3547
+ if (!isInitialized()) {
3548
+ type EmbindModuleFactory = (options?: {
3549
+ locateFile?: (path: string, prefix: string) => string;
3550
+ wasmBinary?: ArrayBuffer | Uint8Array;
3551
+ }) => Promise<SonareModule>;
3552
+ const moduleFactory = (
3553
+ globalThis as typeof globalThis & {
3554
+ SonareEmbindModuleFactory?: EmbindModuleFactory;
3555
+ }
3556
+ ).SonareEmbindModuleFactory;
3557
+ if (!moduleFactory) {
3558
+ throw new Error('embind realtime engine module is not initialized.');
3559
+ }
3560
+ await initSonareModule({
3561
+ locateFile: (path) => path,
3562
+ wasmBinary: options.wasmBinary,
3563
+ moduleFactory,
3564
+ });
3565
+ }
3566
+ this.bridge = new SonareRealtimeEngineWorkletProcessor(options, {
3567
+ postMessage: (message) => port?.postMessage?.(message),
3568
+ onMeter: (meter) => port?.postMessage?.(meter),
3569
+ });
3570
+ for (const message of options.initialSyncMessages ?? []) {
3571
+ this.bridge.receiveSync(message);
3572
+ }
3573
+ for (const command of options.initialCommands ?? []) {
3574
+ this.bridge.receiveCommand(command);
3575
+ }
3576
+ this.replayPendingMessages(port);
3577
+ port?.postMessage?.({ type: 'ready', runtimeTarget: 'embind' });
3578
+ } catch (error) {
3579
+ port?.postMessage?.({
3580
+ type: 'error',
3581
+ message: error instanceof Error ? error.message : String(error),
3582
+ });
3583
+ }
3584
+ }
3585
+
2816
3586
  private async initializeSonareRt(
2817
3587
  options: SonareRealtimeEngineWorkletProcessorOptions,
2818
3588
  port?: WorkletPort,
@@ -2857,6 +3627,7 @@ export function registerSonareRealtimeEngineWorkletProcessor(
2857
3627
  telemetrySharedBuffer: options.telemetrySharedBuffer,
2858
3628
  telemetryRingCapacity: options.telemetryRingCapacity,
2859
3629
  });
3630
+ this.replayPendingMessages(port);
2860
3631
  port?.postMessage?.({ type: 'ready', runtimeTarget: 'sonare-rt' });
2861
3632
  } catch (error) {
2862
3633
  port?.postMessage?.({