@libraz/libsonare 1.5.4 → 1.6.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.
Files changed (79) hide show
  1. package/README.md +109 -20
  2. package/dist/analysis.d.ts +5730 -0
  3. package/dist/analysis.js +2464 -0
  4. package/dist/analysis.js.map +1 -0
  5. package/dist/index.d.ts +873 -63
  6. package/dist/index.js +1037 -265
  7. package/dist/index.js.map +1 -1
  8. package/dist/schemas/realtime-voice-changer-preset-pack.schema.json +27 -0
  9. package/dist/schemas/realtime-voice-changer-preset.schema.json +140 -0
  10. package/dist/sonare-analysis.js +2 -0
  11. package/dist/sonare-analysis.wasm +0 -0
  12. package/dist/sonare.js +1 -1
  13. package/dist/sonare.wasm +0 -0
  14. package/dist/worker.d.ts +65 -0
  15. package/dist/worker.js +707 -0
  16. package/dist/worker.js.map +1 -0
  17. package/dist/worklet.d.ts +596 -86
  18. package/dist/worklet.js +1658 -235
  19. package/dist/worklet.js.map +1 -1
  20. package/package.json +23 -11
  21. package/src/_chain_config.ts +5 -23
  22. package/src/analysis.ts +137 -0
  23. package/src/analysis_helpers.ts +29 -0
  24. package/src/audio.ts +5 -3
  25. package/src/clip_page_streamer.ts +62 -8
  26. package/src/codes.ts +51 -64
  27. package/src/effects_mastering.ts +1 -0
  28. package/src/effects_transform.ts +12 -6
  29. package/src/errors.ts +1 -0
  30. package/src/feature_core.ts +159 -3
  31. package/src/feature_music.ts +35 -16
  32. package/src/feature_pitch.ts +75 -6
  33. package/src/feature_spectral.ts +234 -8
  34. package/src/feature_spectrogram.ts +186 -5
  35. package/src/features.ts +16 -1
  36. package/src/index.ts +104 -7
  37. package/src/mastering_chain.ts +20 -8
  38. package/src/mastering_core.ts +7 -0
  39. package/src/mastering_dynamics.ts +7 -1
  40. package/src/metering.ts +51 -5
  41. package/src/mixer.ts +14 -1
  42. package/src/opfs_clip_pages.ts +26 -1
  43. package/src/project.ts +7 -0
  44. package/src/project_class.ts +74 -13
  45. package/src/project_internal.ts +24 -29
  46. package/src/project_types.ts +69 -10
  47. package/src/public_types.ts +68 -0
  48. package/src/public_types_mastering.ts +28 -0
  49. package/src/public_types_mixing.ts +11 -2
  50. package/src/public_types_music.ts +13 -0
  51. package/src/public_types_spectral.ts +43 -3
  52. package/src/quick_analysis.ts +48 -12
  53. package/src/realtime_engine.ts +162 -4
  54. package/src/realtime_voice_changer.ts +51 -77
  55. package/src/sonare-analysis.js.d.ts +8 -0
  56. package/src/sonare.js.d.ts +305 -9
  57. package/src/streaming_processors.ts +22 -1
  58. package/src/validation.ts +6 -0
  59. package/src/web_midi.ts +36 -4
  60. package/src/worker.ts +196 -0
  61. package/src/worker_client.ts +352 -0
  62. package/src/worker_protocol.ts +59 -0
  63. package/src/worklet/engine-automation.ts +2 -1
  64. package/src/worklet/engine-capture-facade.ts +13 -4
  65. package/src/worklet/engine-clips.ts +12 -3
  66. package/src/worklet/engine-node.ts +212 -4
  67. package/src/worklet/engine-offline.ts +7 -2
  68. package/src/worklet/engine-parameter-facade.ts +26 -5
  69. package/src/worklet/engine-processor.ts +383 -42
  70. package/src/worklet/engine-register.ts +8 -0
  71. package/src/worklet/engine-strips.ts +17 -1
  72. package/src/worklet/engine-sync.ts +2 -6
  73. package/src/worklet/engine-tempo-facade.ts +8 -6
  74. package/src/worklet/engine.ts +380 -13
  75. package/src/worklet/guards.ts +22 -1
  76. package/src/worklet/messages.ts +141 -9
  77. package/src/worklet/protocol.ts +303 -17
  78. package/src/worklet/voice-changer-processor.ts +12 -15
  79. package/src/worklet.ts +20 -1
@@ -15,11 +15,17 @@ type CaptureConfig = Omit<SonareEngineSyncCaptureMessage, 'type'>;
15
15
  export interface EngineCaptureContext {
16
16
  readonly offlineEngine: RealtimeEngine;
17
17
  readonly realtimeNode: SonareRealtimeEngineNode;
18
+ sendCommand(command: {
19
+ type: SonareEngineCommandType;
20
+ targetId?: number;
21
+ sampleTime?: number;
22
+ argFloat?: number;
23
+ argInt?: number;
24
+ }): boolean;
18
25
  readonly offlineChannelCount: number;
19
26
  postSync(message: SonareEngineSyncMessage): void;
20
27
  getCaptureConfig(): CaptureConfig | undefined;
21
28
  setCaptureConfig(config: CaptureConfig): void;
22
- resolveTargetId(target: string | number): number;
23
29
  }
24
30
 
25
31
  export function configureCapture(ctx: EngineCaptureContext, options: CaptureOptions): void {
@@ -37,13 +43,16 @@ export function armRecord(
37
43
  trackId: string | number,
38
44
  enabled: boolean,
39
45
  ): boolean {
46
+ if (trackId !== 0) {
47
+ throw new RangeError('Capture is global; armRecord only accepts trackId 0');
48
+ }
40
49
  if (enabled && !ctx.getCaptureConfig()) {
41
50
  throw new Error('Capture buffer is not configured');
42
51
  }
43
52
  ctx.offlineEngine.armCapture(enabled);
44
- return ctx.realtimeNode.sendCommand({
53
+ return ctx.sendCommand({
45
54
  type: SonareEngineCommandType.ArmRecord,
46
- targetId: ctx.resolveTargetId(trackId),
55
+ targetId: 0,
47
56
  sampleTime: -1,
48
57
  argInt: enabled ? 1 : 0,
49
58
  });
@@ -58,7 +67,7 @@ export function punch(ctx: EngineCaptureContext, inPpq: number, outPpq: number):
58
67
  // point and let the consumer multiply by sampleRate (treating PPQ as
59
68
  // seconds), which ignored tempo and produced a punch-out ~2x too large at
60
69
  // 120 BPM. argInt = in sample, argFloat = out sample (full-precision double).
61
- return ctx.realtimeNode.sendCommand({
70
+ return ctx.sendCommand({
62
71
  type: SonareEngineCommandType.Punch,
63
72
  sampleTime: -1,
64
73
  argInt: inSample,
@@ -1,4 +1,5 @@
1
1
  import type { EngineClip, EngineMidiClipSchedule, RealtimeEngine } from '../index';
2
+ import type { ClipPageProvider } from '../realtime_engine';
2
3
  import type { SonareEngineSyncMessage } from './messages';
3
4
 
4
5
  /**
@@ -14,6 +15,8 @@ export interface EngineClipContext {
14
15
  postSync(message: SonareEngineSyncMessage, transfer?: Transferable[]): void;
15
16
  resolveTargetId(target: string | number): number;
16
17
  ensureTrackLane(target: string | number): number;
18
+ /** Commits a provider already primed through the worklet's OPFS pull bridge. */
19
+ commitWorkletClipPageProvider(clip: EngineClip): boolean;
17
20
  }
18
21
 
19
22
  // Keep control-message work bounded even for long tempo-synced clips. The
@@ -25,15 +28,15 @@ const PREBAKED_CLIP_PAGE_FRAMES = 4_096;
25
28
  export function addClip(
26
29
  ctx: EngineClipContext,
27
30
  trackId: string | number,
28
- buffer: Float32Array[],
31
+ buffer: Float32Array[] | ClipPageProvider,
29
32
  startPpq: number,
30
- opts: Partial<Omit<EngineClip, 'channels' | 'startPpq'>> = {},
33
+ opts: Partial<Omit<EngineClip, 'channels' | 'pageProvider' | 'startPpq'>> = {},
31
34
  ): number {
32
35
  const id = opts.id ?? ctx.allocateClipId();
33
36
  const clip: EngineClip = {
34
37
  ...opts,
35
38
  id,
36
- channels: buffer,
39
+ ...(Array.isArray(buffer) ? { channels: buffer } : { pageProvider: buffer }),
37
40
  startPpq,
38
41
  trackId: ctx.resolveTargetId(trackId),
39
42
  };
@@ -88,6 +91,12 @@ function syncClipsDelta(ctx: EngineClipContext, upserts: EngineClip[], removeIds
88
91
  for (const clip of upserts) {
89
92
  const prepared = clip.id === undefined ? clip : (preparedById.get(clip.id) ?? clip);
90
93
  const channels = prepared.channels;
94
+ if (!channels && prepared.pageProvider !== undefined) {
95
+ if (ctx.commitWorkletClipPageProvider(prepared)) {
96
+ continue;
97
+ }
98
+ throw new Error('A pageProvider on SonareEngine must be created by attachOpfsClipStream().');
99
+ }
91
100
  if (
92
101
  prepared.id === undefined ||
93
102
  prepared.warpMode !== 'off' ||
@@ -1,6 +1,7 @@
1
1
  import type { EngineCaptureStatus, EngineTransportState } from '../index';
2
2
  import { engineCapabilities } from '../index';
3
3
  import {
4
+ isClipPageRequestMessage,
4
5
  isEngineCaptureResponseMessage,
5
6
  isEngineTelemetryRecord,
6
7
  isEngineTransportResponseMessage,
@@ -10,6 +11,8 @@ import {
10
11
  import type {
11
12
  SonareEngineCaptureRequestMessage,
12
13
  SonareEngineCaptureResponseMessage,
14
+ SonareEngineClipPageRequestMessage,
15
+ SonareEngineSyncErrorMessage,
13
16
  SonareEngineTransportResponseMessage,
14
17
  SonareRealtimeEngineNodeCapabilities,
15
18
  SonareRealtimeEngineNodeOptions,
@@ -17,26 +20,68 @@ import type {
17
20
  SonareWorkletExternalMidiEvent,
18
21
  } from './messages';
19
22
  import {
23
+ createSonareClipPageRequestRingBuffer,
20
24
  createSonareEngineCommandRingBuffer,
21
25
  createSonareEngineTelemetryRingBuffer,
26
+ createSonareExternalMidiRingBuffer,
22
27
  createSonareMeterRingBuffer,
23
28
  createSonareScopeRingBuffer,
24
29
  isRecord,
25
30
  pushSonareEngineCommandRingBuffer,
31
+ readSonareClipPageRequestRingBuffer,
26
32
  readSonareEngineTelemetryRingBuffer,
33
+ readSonareExternalMidiRingBuffer,
27
34
  readSonareMeterRingBuffer,
28
35
  readSonareScopeRingBuffer,
36
+ type SonareClipPageRequestRingBuffer,
29
37
  type SonareEngineCommandRecord,
30
38
  type SonareEngineCommandRingBuffer,
31
39
  SonareEngineCommandType,
32
40
  type SonareEngineTelemetryRecord,
33
41
  type SonareEngineTelemetryRingBuffer,
42
+ type SonareExternalMidiRingBuffer,
34
43
  type SonareMeterRingBuffer,
35
44
  type SonareScopeRingBuffer,
36
45
  type SonareWorkletMeterSnapshot,
37
46
  type SonareWorkletScopeSnapshot,
38
47
  } from './protocol';
39
48
 
49
+ function isFiniteInteger(value: number | bigint | undefined): boolean {
50
+ if (value === undefined) {
51
+ return true;
52
+ }
53
+ return typeof value === 'bigint' || (Number.isFinite(value) && Number.isSafeInteger(value));
54
+ }
55
+
56
+ function isValidCommandRecord(command: SonareEngineCommandRecord): boolean {
57
+ const type = Number(command.type);
58
+ if (
59
+ !Number.isSafeInteger(type) ||
60
+ type < SonareEngineCommandType.SetParam ||
61
+ type > SonareEngineCommandType.SeekMarker
62
+ ) {
63
+ return false;
64
+ }
65
+ return (
66
+ (command.targetId === undefined || Number.isSafeInteger(command.targetId)) &&
67
+ (command.argFloat === undefined || Number.isFinite(command.argFloat)) &&
68
+ isFiniteInteger(command.argInt) &&
69
+ isFiniteInteger(command.sampleTime)
70
+ );
71
+ }
72
+
73
+ const AUDIO_WORKLET_RENDER_QUANTUM = 128;
74
+
75
+ function workletBlockSize(blockSize: number | undefined): number {
76
+ const resolved = blockSize ?? AUDIO_WORKLET_RENDER_QUANTUM;
77
+ if (!Number.isSafeInteger(resolved) || resolved < AUDIO_WORKLET_RENDER_QUANTUM) {
78
+ throw new RangeError(
79
+ `blockSize must be an integer of at least ${AUDIO_WORKLET_RENDER_QUANTUM} frames`,
80
+ );
81
+ }
82
+ return resolved;
83
+ }
84
+
40
85
  export class SonareRealtimeEngineNode {
41
86
  readonly node: AudioWorkletNode;
42
87
  readonly capabilities: SonareRealtimeEngineNodeCapabilities;
@@ -44,14 +89,22 @@ export class SonareRealtimeEngineNode {
44
89
  readonly telemetryRing?: SonareEngineTelemetryRingBuffer;
45
90
  readonly meterRing?: SonareMeterRingBuffer;
46
91
  readonly scopeRing?: SonareScopeRingBuffer;
92
+ readonly clipPageRequestRing?: SonareClipPageRequestRingBuffer;
93
+ readonly externalMidiRing?: SonareExternalMidiRingBuffer;
47
94
  readonly ready: Promise<void>;
48
95
  private telemetryReadIndex = 0;
49
96
  private meterReadIndex = 0;
50
97
  private scopeReadIndex = 0;
98
+ private clipPageRequestDroppedRead = 0;
99
+ private ringPollTimer: ReturnType<typeof setInterval> | undefined;
51
100
  private telemetryListeners = new Set<(telemetry: SonareEngineTelemetryRecord) => void>();
52
101
  private meterListeners = new Set<(meter: SonareWorkletMeterSnapshot) => void>();
53
102
  private scopeListeners = new Set<(scope: SonareWorkletScopeSnapshot) => void>();
54
103
  private midiOutListeners = new Set<(events: SonareWorkletExternalMidiEvent[]) => void>();
104
+ private clipPageRequestListeners = new Set<
105
+ (message: SonareEngineClipPageRequestMessage) => void
106
+ >();
107
+ private syncErrorListeners = new Set<(message: SonareEngineSyncErrorMessage) => void>();
55
108
  private captureRequestId = 1;
56
109
  private readonly captureRequests = new Map<
57
110
  number,
@@ -79,6 +132,8 @@ export class SonareRealtimeEngineNode {
79
132
  telemetryRing?: SonareEngineTelemetryRingBuffer,
80
133
  meterRing?: SonareMeterRingBuffer,
81
134
  scopeRing?: SonareScopeRingBuffer,
135
+ clipPageRequestRing?: SonareClipPageRequestRingBuffer,
136
+ externalMidiRing?: SonareExternalMidiRingBuffer,
82
137
  ) {
83
138
  this.node = node;
84
139
  this.capabilities = capabilities;
@@ -86,6 +141,8 @@ export class SonareRealtimeEngineNode {
86
141
  this.telemetryRing = telemetryRing;
87
142
  this.meterRing = meterRing;
88
143
  this.scopeRing = scopeRing;
144
+ this.clipPageRequestRing = clipPageRequestRing;
145
+ this.externalMidiRing = externalMidiRing;
89
146
  this.ready = new Promise((resolve, reject) => {
90
147
  this.resolveReady = resolve;
91
148
  this.rejectReady = reject;
@@ -120,6 +177,17 @@ export class SonareRealtimeEngineNode {
120
177
  this.emitMeter(event.data);
121
178
  } else if (isExternalMidiBatchMessage(event.data)) {
122
179
  this.emitMidiOut(event.data.events);
180
+ } else if (isClipPageRequestMessage(event.data)) {
181
+ this.emitClipPageRequests(event.data);
182
+ } else if (isRecord(event.data) && event.data.type === 'syncError') {
183
+ const syncError: SonareEngineSyncErrorMessage = {
184
+ type: 'syncError',
185
+ syncType: String(event.data.syncType) as SonareEngineSyncErrorMessage['syncType'],
186
+ message: String(event.data.message ?? 'AudioWorklet sync failed'),
187
+ };
188
+ for (const listener of this.syncErrorListeners) {
189
+ listener(syncError);
190
+ }
123
191
  } else if (isRecord(event.data) && event.data.type === 'ready') {
124
192
  this.resolveReady();
125
193
  } else if (isRecord(event.data) && event.data.type === 'error') {
@@ -128,10 +196,17 @@ export class SonareRealtimeEngineNode {
128
196
  };
129
197
  }
130
198
 
199
+ /** Subscribes to control-plane sync rejections reported by the worklet. */
200
+ onSyncError(listener: (message: SonareEngineSyncErrorMessage) => void): () => void {
201
+ this.syncErrorListeners.add(listener);
202
+ return () => this.syncErrorListeners.delete(listener);
203
+ }
204
+
131
205
  static async create(
132
206
  context: BaseAudioContext,
133
207
  options: SonareRealtimeEngineNodeOptions = {},
134
208
  ): Promise<SonareRealtimeEngineNode> {
209
+ const blockSize = workletBlockSize(options.blockSize);
135
210
  const processorName = options.processorName ?? 'sonare-realtime-engine-processor';
136
211
  const moduleUrl = options.moduleUrl;
137
212
  if (moduleUrl && context.audioWorklet?.addModule) {
@@ -188,10 +263,21 @@ export class SonareRealtimeEngineNode {
188
263
  mode === 'sab' && scopeIntervalFrames > 0
189
264
  ? createSonareScopeRingBuffer(options.scopeRingCapacity ?? 64, options.scopeBands ?? 48)
190
265
  : undefined;
266
+ // Paged-clip cache misses are a worklet-to-main-thread control signal. A
267
+ // dedicated SPSC SAB ring keeps process() free of both embind request
268
+ // objects and postMessage structured cloning.
269
+ const clipPageRequestRing =
270
+ mode === 'sab'
271
+ ? createSonareClipPageRequestRingBuffer(options.clipPageRequestRingCapacity ?? 128)
272
+ : undefined;
273
+ const externalMidiRing =
274
+ mode === 'sab'
275
+ ? createSonareExternalMidiRingBuffer(options.externalMidiRingCapacity ?? 256)
276
+ : undefined;
191
277
  const channelCount = Math.max(1, Math.floor(options.channelCount ?? 2));
192
278
  const processorOptions: SonareRealtimeEngineWorkletProcessorOptions = {
193
279
  sampleRate: options.sampleRate ?? context.sampleRate,
194
- blockSize: options.blockSize,
280
+ blockSize,
195
281
  channelCount,
196
282
  commandSharedBuffer: commandRing?.sharedBuffer,
197
283
  commandRingCapacity: commandRing?.capacity,
@@ -203,6 +289,10 @@ export class SonareRealtimeEngineNode {
203
289
  scopeRingCapacity: scopeRing?.capacity,
204
290
  scopeBands: scopeRing?.bands,
205
291
  scopeIntervalFrames: scopeRing ? scopeIntervalFrames : undefined,
292
+ clipPageRequestSharedBuffer: clipPageRequestRing?.sharedBuffer,
293
+ clipPageRequestRingCapacity: clipPageRequestRing?.capacity,
294
+ externalMidiSharedBuffer: externalMidiRing?.sharedBuffer,
295
+ externalMidiRingCapacity: externalMidiRing?.capacity,
206
296
  wasmBinary: options.wasmBinary,
207
297
  initialSyncMessages: options.initialSyncMessages,
208
298
  initialCommands: options.initialCommands,
@@ -225,16 +315,23 @@ export class SonareRealtimeEngineNode {
225
315
  sharedArrayBuffer,
226
316
  atomics,
227
317
  audioWorklet,
318
+ clipPageRequestsRealtimeSafe: mode === 'sab',
319
+ externalMidiRealtimeSafe: mode === 'sab',
228
320
  engineAbiVersion: detectedCapabilities?.engineAbiVersion,
229
321
  expectedEngineAbiVersion: detectedCapabilities?.expectedEngineAbiVersion,
230
322
  abiCompatible: detectedCapabilities?.abiCompatible,
231
323
  degradedReason,
232
- readyMessage: moduleUrl !== undefined && !options.nodeFactory,
324
+ // A processor posts ready/error irrespective of whether its module was
325
+ // loaded here or registered by the host beforehand. Waiting in both
326
+ // cases is the only way to surface a failed worklet-side WASM init.
327
+ readyMessage: true,
233
328
  },
234
329
  commandRing,
235
330
  telemetryRing,
236
331
  meterRing,
237
332
  scopeRing,
333
+ clipPageRequestRing,
334
+ externalMidiRing,
238
335
  );
239
336
  }
240
337
 
@@ -255,6 +352,9 @@ export class SonareRealtimeEngineNode {
255
352
  }
256
353
 
257
354
  seekPpq(ppq: number, sampleTime = -1): boolean {
355
+ if (!Number.isFinite(ppq) || !Number.isSafeInteger(sampleTime)) {
356
+ return false;
357
+ }
258
358
  return this.sendCommand({
259
359
  type: SonareEngineCommandType.TransportSeekPpq,
260
360
  sampleTime,
@@ -263,7 +363,7 @@ export class SonareRealtimeEngineNode {
263
363
  }
264
364
 
265
365
  sendCommand(command: SonareEngineCommandRecord): boolean {
266
- if (this.destroyed) {
366
+ if (this.destroyed || !isValidCommandRecord(command)) {
267
367
  return false;
268
368
  }
269
369
  if (this.commandRing) {
@@ -345,24 +445,77 @@ export class SonareRealtimeEngineNode {
345
445
  return read.scopes;
346
446
  }
347
447
 
448
+ /** Drain lowered MIDI-1 records from the SAB ring on the main thread. */
449
+ pollMidiOut(): SonareWorkletExternalMidiEvent[] {
450
+ if (!this.externalMidiRing) {
451
+ return [];
452
+ }
453
+ const read = readSonareExternalMidiRingBuffer(this.externalMidiRing);
454
+ const events = read.events.map((event) => {
455
+ const bytes: number[] = [];
456
+ for (let index = 0; index < event.byteCount; index++) {
457
+ bytes.push((event.byteWord >>> (8 * index)) & 0xff);
458
+ }
459
+ return {
460
+ destinationId: event.destinationId,
461
+ renderFrame: event.renderFrame,
462
+ bytes,
463
+ };
464
+ });
465
+ if (events.length > 0) {
466
+ this.emitMidiOut(events);
467
+ }
468
+ return events;
469
+ }
470
+
471
+ /**
472
+ * Drains bounded paged-clip misses from the SAB ring and forwards one batch
473
+ * to subscribers. In postMessage mode the legacy handler remains available,
474
+ * but that degraded path is not realtime-safe for OPFS streaming.
475
+ */
476
+ pollClipPageRequests(): SonareEngineClipPageRequestMessage | undefined {
477
+ if (!this.clipPageRequestRing) {
478
+ return undefined;
479
+ }
480
+ const read = readSonareClipPageRequestRingBuffer(this.clipPageRequestRing);
481
+ const dropped = (read.dropped - this.clipPageRequestDroppedRead) >>> 0;
482
+ this.clipPageRequestDroppedRead = read.dropped;
483
+ if (read.requests.length === 0 && dropped === 0) {
484
+ return undefined;
485
+ }
486
+ const message: SonareEngineClipPageRequestMessage = {
487
+ type: 'clipPageRequest',
488
+ requests: read.requests,
489
+ ...(dropped > 0 ? { dropped } : {}),
490
+ };
491
+ this.emitClipPageRequests(message);
492
+ return message;
493
+ }
494
+
348
495
  onTelemetry(callback: (telemetry: SonareEngineTelemetryRecord) => void): () => void {
349
496
  this.telemetryListeners.add(callback);
497
+ this.startRingPolling();
350
498
  return () => {
351
499
  this.telemetryListeners.delete(callback);
500
+ this.stopRingPollingIfUnused();
352
501
  };
353
502
  }
354
503
 
355
504
  onMeter(callback: (meter: SonareWorkletMeterSnapshot) => void): () => void {
356
505
  this.meterListeners.add(callback);
506
+ this.startRingPolling();
357
507
  return () => {
358
508
  this.meterListeners.delete(callback);
509
+ this.stopRingPollingIfUnused();
359
510
  };
360
511
  }
361
512
 
362
513
  onScope(callback: (scope: SonareWorkletScopeSnapshot) => void): () => void {
363
514
  this.scopeListeners.add(callback);
515
+ this.startRingPolling();
364
516
  return () => {
365
517
  this.scopeListeners.delete(callback);
518
+ this.stopRingPollingIfUnused();
366
519
  };
367
520
  }
368
521
 
@@ -373,17 +526,33 @@ export class SonareRealtimeEngineNode {
373
526
  */
374
527
  onMidiOut(callback: (events: SonareWorkletExternalMidiEvent[]) => void): () => void {
375
528
  this.midiOutListeners.add(callback);
529
+ this.startRingPolling();
376
530
  return () => {
377
531
  this.midiOutListeners.delete(callback);
378
532
  };
379
533
  }
380
534
 
535
+ /**
536
+ * Subscribe to bounded batches of paged-clip misses from the worklet. The
537
+ * callback runs on the main thread and may initiate asynchronous OPFS I/O.
538
+ */
539
+ onClipPageRequests(callback: (message: SonareEngineClipPageRequestMessage) => void): () => void {
540
+ this.clipPageRequestListeners.add(callback);
541
+ return () => {
542
+ this.clipPageRequestListeners.delete(callback);
543
+ };
544
+ }
545
+
381
546
  destroy(): void {
382
547
  if (this.destroyed) {
383
548
  return;
384
549
  }
385
550
  this.destroyed = true;
386
- this.node.port.postMessage({ type: SonareEngineCommandType.TransportStop, sampleTime: -1 });
551
+ if (this.ringPollTimer !== undefined) {
552
+ clearInterval(this.ringPollTimer);
553
+ this.ringPollTimer = undefined;
554
+ }
555
+ this.node.port.postMessage({ type: 'destroy' });
387
556
  this.node.disconnect();
388
557
  for (const pending of this.captureRequests.values()) {
389
558
  pending.reject(new Error('Realtime engine node is destroyed.'));
@@ -397,6 +566,7 @@ export class SonareRealtimeEngineNode {
397
566
  this.meterListeners.clear();
398
567
  this.scopeListeners.clear();
399
568
  this.midiOutListeners.clear();
569
+ this.clipPageRequestListeners.clear();
400
570
  }
401
571
 
402
572
  private emitTelemetry(telemetry: SonareEngineTelemetryRecord): void {
@@ -405,6 +575,38 @@ export class SonareRealtimeEngineNode {
405
575
  }
406
576
  }
407
577
 
578
+ private startRingPolling(): void {
579
+ if (
580
+ this.ringPollTimer !== undefined ||
581
+ (!this.telemetryRing && !this.meterRing && !this.scopeRing && !this.externalMidiRing)
582
+ ) {
583
+ return;
584
+ }
585
+ const poll = () => {
586
+ if (!this.destroyed) {
587
+ this.pollTelemetry();
588
+ this.pollMeters();
589
+ this.pollScope();
590
+ this.pollMidiOut();
591
+ }
592
+ };
593
+ poll();
594
+ this.ringPollTimer = setInterval(poll, 16);
595
+ }
596
+
597
+ private stopRingPollingIfUnused(): void {
598
+ if (
599
+ this.ringPollTimer !== undefined &&
600
+ this.telemetryListeners.size === 0 &&
601
+ this.meterListeners.size === 0 &&
602
+ this.scopeListeners.size === 0 &&
603
+ this.midiOutListeners.size === 0
604
+ ) {
605
+ clearInterval(this.ringPollTimer);
606
+ this.ringPollTimer = undefined;
607
+ }
608
+ }
609
+
408
610
  private emitMeter(meter: SonareWorkletMeterSnapshot): void {
409
611
  for (const listener of this.meterListeners) {
410
612
  listener(meter);
@@ -417,6 +619,12 @@ export class SonareRealtimeEngineNode {
417
619
  }
418
620
  }
419
621
 
622
+ private emitClipPageRequests(message: SonareEngineClipPageRequestMessage): void {
623
+ for (const listener of this.clipPageRequestListeners) {
624
+ listener(message);
625
+ }
626
+ }
627
+
420
628
  private emitScope(scope: SonareWorkletScopeSnapshot): void {
421
629
  for (const listener of this.scopeListeners) {
422
630
  listener(scope);
@@ -47,6 +47,7 @@ export interface EngineTransportContext {
47
47
  seekSample(timelineSample: number, sampleTime?: number): boolean;
48
48
  };
49
49
  offlineEngine: RealtimeEngine;
50
+ flushOfflineMirror(): void;
50
51
  setTransportPlaying(playing: boolean): void;
51
52
  flushPendingInstrumentSync(): void;
52
53
  setTempo(bpm: number): void;
@@ -74,12 +75,16 @@ export function buildTransportFacade(ctx: EngineTransportContext): SonareEngineT
74
75
  },
75
76
  seekPpq: (ppq, sampleTime = -1) => {
76
77
  ctx.offlineEngine.seekPpq(ppq, sampleTime);
77
- return ctx.realtimeNode.seekPpq(ppq, sampleTime);
78
+ const ok = ctx.realtimeNode.seekPpq(ppq, sampleTime);
79
+ ctx.flushOfflineMirror();
80
+ return ok;
78
81
  },
79
82
  seekSeconds: (seconds, sampleTime = -1) => {
80
83
  const timelineSample = Math.max(0, Math.round(seconds * ctx.sampleRate));
81
84
  ctx.offlineEngine.seekSample(timelineSample, sampleTime);
82
- return ctx.realtimeNode.seekSample(timelineSample, sampleTime);
85
+ const ok = ctx.realtimeNode.seekSample(timelineSample, sampleTime);
86
+ ctx.flushOfflineMirror();
87
+ return ok;
83
88
  },
84
89
  setTempo: (bpm) => ctx.setTempo(bpm),
85
90
  setTempoSegments: (segments) => ctx.setTempoSegments(segments),
@@ -1,5 +1,5 @@
1
- import type { EngineParameterInfo, RealtimeEngine } from '../index';
2
- import type { SonareRealtimeEngineNode } from './engine-node';
1
+ import type { EngineAutomationPoint, EngineParameterInfo, RealtimeEngine } from '../index';
2
+ import type { SonareEngineSyncMessage } from './messages';
3
3
  import {
4
4
  ENGINE_MIXER_PARAM_FADER_DB,
5
5
  ENGINE_MIXER_PARAM_PAN,
@@ -17,7 +17,15 @@ import {
17
17
  */
18
18
  export interface EngineParameterContext {
19
19
  readonly offlineEngine: RealtimeEngine;
20
- readonly realtimeNode: SonareRealtimeEngineNode;
20
+ sendCommand(command: {
21
+ type: SonareEngineCommandType;
22
+ targetId?: number;
23
+ sampleTime?: number;
24
+ argFloat?: number;
25
+ argInt?: number;
26
+ }): boolean;
27
+ postSync(message: SonareEngineSyncMessage): void;
28
+ readonly automationLanes: Map<number, EngineAutomationPoint[]>;
21
29
  readonly trackLaneIds: number[];
22
30
  resolveParamId(nodeId: string, param: string | number): number;
23
31
  ensureTrackLane(target: string | number): number;
@@ -35,7 +43,7 @@ export function setParam(
35
43
  // reflects the live value, then push a sample-accurate command to the
36
44
  // realtime runtime (mirrors setTempo/setLoop above).
37
45
  ctx.offlineEngine.setParameter(paramId, value);
38
- return ctx.realtimeNode.sendCommand({
46
+ return ctx.sendCommand({
39
47
  type: SonareEngineCommandType.SetParam,
40
48
  targetId: paramId,
41
49
  sampleTime: -1,
@@ -51,7 +59,7 @@ export function setSoloMute(
51
59
  ): boolean {
52
60
  const laneIndex = ctx.ensureTrackLane(target);
53
61
  ctx.offlineEngine.setSoloMute(laneIndex, solo, mute);
54
- return ctx.realtimeNode.sendCommand({
62
+ return ctx.sendCommand({
55
63
  type: SonareEngineCommandType.SetSoloMute,
56
64
  targetId: laneIndex,
57
65
  sampleTime: -1,
@@ -170,3 +178,16 @@ export function listParameters(ctx: EngineParameterContext): EngineParameterInfo
170
178
  }
171
179
  return parameters;
172
180
  }
181
+
182
+ /** Registers a parameter on both the offline mirror and live worklet engine. */
183
+ export function addParameter(ctx: EngineParameterContext, info: EngineParameterInfo): void {
184
+ ctx.offlineEngine.addParameter(info);
185
+ ctx.postSync({ type: 'syncParameters', parameters: listParameters(ctx) });
186
+ }
187
+
188
+ /** Clears registered parameters and their automation lanes on both engines. */
189
+ export function clearParameters(ctx: EngineParameterContext): void {
190
+ ctx.offlineEngine.clearParameters();
191
+ ctx.automationLanes.clear();
192
+ ctx.postSync({ type: 'syncParameters', parameters: [] });
193
+ }