@libraz/libsonare 1.5.5 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/README.md +109 -20
  2. package/dist/analysis.d.ts +5809 -0
  3. package/dist/analysis.js +2574 -0
  4. package/dist/analysis.js.map +1 -0
  5. package/dist/index.d.ts +884 -55
  6. package/dist/index.js +1302 -292
  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 +734 -0
  16. package/dist/worker.js.map +1 -0
  17. package/dist/worklet.d.ts +628 -88
  18. package/dist/worklet.js +1816 -266
  19. package/dist/worklet.js.map +1 -1
  20. package/package.json +23 -11
  21. package/src/_chain_config.ts +5 -3
  22. package/src/analysis.ts +137 -0
  23. package/src/analysis_helpers.ts +40 -29
  24. package/src/audio.ts +7 -2
  25. package/src/clip_page_streamer.ts +62 -8
  26. package/src/codes.ts +87 -64
  27. package/src/effects_mastering.ts +1 -0
  28. package/src/effects_transform.ts +179 -10
  29. package/src/errors.ts +1 -0
  30. package/src/feature_core.ts +140 -2
  31. package/src/feature_music.ts +25 -3
  32. package/src/feature_pitch.ts +70 -1
  33. package/src/feature_spectral.ts +295 -7
  34. package/src/feature_spectrogram.ts +203 -3
  35. package/src/features.ts +16 -1
  36. package/src/index.ts +120 -8
  37. package/src/mastering_chain.ts +20 -8
  38. package/src/mastering_core.ts +5 -0
  39. package/src/mastering_dynamics.ts +7 -1
  40. package/src/metering.ts +6 -1
  41. package/src/mixer.ts +3 -3
  42. package/src/opfs_clip_pages.ts +26 -1
  43. package/src/project.ts +13 -0
  44. package/src/project_class.ts +204 -23
  45. package/src/project_internal.ts +38 -34
  46. package/src/project_types.ts +114 -11
  47. package/src/public_types.ts +68 -0
  48. package/src/public_types_mastering.ts +22 -0
  49. package/src/public_types_mixing.ts +27 -2
  50. package/src/public_types_music.ts +9 -0
  51. package/src/public_types_realtime.ts +23 -5
  52. package/src/public_types_spectral.ts +33 -0
  53. package/src/quick_analysis.ts +46 -10
  54. package/src/realtime_engine.ts +167 -6
  55. package/src/realtime_voice_changer.ts +51 -77
  56. package/src/sonare-analysis.js.d.ts +8 -0
  57. package/src/sonare.js.d.ts +340 -6
  58. package/src/stream_analyzer.ts +6 -1
  59. package/src/streaming_processors.ts +15 -0
  60. package/src/web_midi.ts +46 -11
  61. package/src/worker.ts +196 -0
  62. package/src/worker_client.ts +352 -0
  63. package/src/worker_protocol.ts +59 -0
  64. package/src/worklet/engine-automation.ts +2 -1
  65. package/src/worklet/engine-capture-facade.ts +13 -4
  66. package/src/worklet/engine-clips.ts +12 -3
  67. package/src/worklet/engine-node.ts +264 -21
  68. package/src/worklet/engine-offline.ts +7 -2
  69. package/src/worklet/engine-parameter-facade.ts +55 -5
  70. package/src/worklet/engine-processor.ts +435 -62
  71. package/src/worklet/engine-register.ts +9 -1
  72. package/src/worklet/engine-strips.ts +19 -3
  73. package/src/worklet/engine-sync.ts +2 -6
  74. package/src/worklet/engine-tempo-facade.ts +8 -6
  75. package/src/worklet/engine.ts +390 -13
  76. package/src/worklet/guards.ts +121 -8
  77. package/src/worklet/messages.ts +166 -8
  78. package/src/worklet/protocol.ts +305 -17
  79. package/src/worklet/voice-changer-processor.ts +12 -15
  80. package/src/worklet.ts +20 -1
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Internal structured-clone protocol for the offline Web Worker bridge.
3
+ *
4
+ * Keep this deliberately limited to one-shot operations. Embind handles own
5
+ * native resources and cannot safely cross a worker boundary.
6
+ */
7
+
8
+ export type OfflineWorkerOperation =
9
+ | 'analyze'
10
+ | 'detectBpm'
11
+ | 'detectKey'
12
+ | 'detectChords'
13
+ | 'masterAudio'
14
+ | 'masterAudioStereo';
15
+
16
+ export interface OfflineWorkerRunMessage {
17
+ type: 'sonare:offline-run';
18
+ id: number;
19
+ operation: OfflineWorkerOperation;
20
+ request: Record<string, unknown>;
21
+ /** A shared flag lets cancellation reach synchronous WASM progress callbacks. */
22
+ cancelBuffer?: SharedArrayBuffer;
23
+ }
24
+
25
+ export interface OfflineWorkerCancelMessage {
26
+ type: 'sonare:offline-cancel';
27
+ id: number;
28
+ }
29
+
30
+ export type OfflineWorkerRequestMessage = OfflineWorkerRunMessage | OfflineWorkerCancelMessage;
31
+
32
+ export interface OfflineWorkerProgressMessage {
33
+ type: 'sonare:offline-progress';
34
+ id: number;
35
+ progress: number;
36
+ stage: string;
37
+ }
38
+
39
+ export interface OfflineWorkerResultMessage {
40
+ type: 'sonare:offline-result';
41
+ id: number;
42
+ result: unknown;
43
+ }
44
+
45
+ export interface OfflineWorkerErrorMessage {
46
+ type: 'sonare:offline-error';
47
+ id: number;
48
+ error: {
49
+ name: string;
50
+ message: string;
51
+ code?: number;
52
+ codeName?: string;
53
+ };
54
+ }
55
+
56
+ export type OfflineWorkerResponseMessage =
57
+ | OfflineWorkerProgressMessage
58
+ | OfflineWorkerResultMessage
59
+ | OfflineWorkerErrorMessage;
@@ -31,7 +31,8 @@ export function scheduleParam(
31
31
  // Mirror the lane to the live worklet engine so scheduled automation plays
32
32
  // back in real time, not just in renderOffline(). Lanes can exceed the
33
33
  // fixed-size SAB command record, so they ride an out-of-band 'syncAutomation'
34
- // message applied outside process() (like syncClips/syncMarkers).
34
+ // message whose port handler publishes the bounded snapshot consumed by
35
+ // process() (like syncClips/syncMarkers).
35
36
  ctx.postSync({ type: 'syncAutomation', paramId, points: lane });
36
37
  }
37
38
 
@@ -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,9 @@
1
1
  import type { EngineCaptureStatus, EngineTransportState } from '../index';
2
2
  import { engineCapabilities } from '../index';
3
3
  import {
4
+ engineCaptureResponseRequestId,
5
+ isClipPageRequestMessage,
6
+ isEngineCaptureResponseForOperation,
4
7
  isEngineCaptureResponseMessage,
5
8
  isEngineTelemetryRecord,
6
9
  isEngineTransportResponseMessage,
@@ -9,7 +12,9 @@ import {
9
12
  } from './guards';
10
13
  import type {
11
14
  SonareEngineCaptureRequestMessage,
12
- SonareEngineCaptureResponseMessage,
15
+ SonareEngineCaptureResponseMessageInternal,
16
+ SonareEngineClipPageRequestMessage,
17
+ SonareEngineSyncErrorMessage,
13
18
  SonareEngineTransportResponseMessage,
14
19
  SonareRealtimeEngineNodeCapabilities,
15
20
  SonareRealtimeEngineNodeOptions,
@@ -17,26 +22,86 @@ import type {
17
22
  SonareWorkletExternalMidiEvent,
18
23
  } from './messages';
19
24
  import {
25
+ createSonareClipPageRequestRingBuffer,
20
26
  createSonareEngineCommandRingBuffer,
21
27
  createSonareEngineTelemetryRingBuffer,
28
+ createSonareExternalMidiRingBuffer,
22
29
  createSonareMeterRingBuffer,
23
30
  createSonareScopeRingBuffer,
24
31
  isRecord,
25
32
  pushSonareEngineCommandRingBuffer,
33
+ readSonareClipPageRequestRingBuffer,
26
34
  readSonareEngineTelemetryRingBuffer,
35
+ readSonareExternalMidiRingBuffer,
27
36
  readSonareMeterRingBuffer,
28
37
  readSonareScopeRingBuffer,
38
+ type SonareClipPageRequestRingBuffer,
29
39
  type SonareEngineCommandRecord,
30
40
  type SonareEngineCommandRingBuffer,
31
41
  SonareEngineCommandType,
32
42
  type SonareEngineTelemetryRecord,
33
43
  type SonareEngineTelemetryRingBuffer,
44
+ type SonareExternalMidiRingBuffer,
34
45
  type SonareMeterRingBuffer,
35
46
  type SonareScopeRingBuffer,
36
47
  type SonareWorkletMeterSnapshot,
37
48
  type SonareWorkletScopeSnapshot,
38
49
  } from './protocol';
39
50
 
51
+ function isFiniteInteger(value: number | bigint | undefined): boolean {
52
+ if (value === undefined) {
53
+ return true;
54
+ }
55
+ return typeof value === 'bigint' || (Number.isFinite(value) && Number.isSafeInteger(value));
56
+ }
57
+
58
+ function isTrackMonitorMode(value: number | bigint | undefined): boolean {
59
+ if (value === undefined) {
60
+ return false;
61
+ }
62
+ const mode = typeof value === 'bigint' ? Number(value) : value;
63
+ return typeof mode === 'number' && Number.isSafeInteger(mode) && mode >= 0 && mode <= 2;
64
+ }
65
+
66
+ /** A track-monitor command cannot infer a lane: its target is a uint32 index. */
67
+ function isTrackMonitorLaneIndex(value: number | undefined): boolean {
68
+ return (
69
+ typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 && value <= 0xffff_ffff
70
+ );
71
+ }
72
+
73
+ function isValidCommandRecord(command: SonareEngineCommandRecord): boolean {
74
+ const type = Number(command.type);
75
+ if (
76
+ !Number.isSafeInteger(type) ||
77
+ type < SonareEngineCommandType.SetParam ||
78
+ (type > SonareEngineCommandType.SeekMarker &&
79
+ type !== SonareEngineCommandType.SetTrackMonitorMode)
80
+ ) {
81
+ return false;
82
+ }
83
+ return (
84
+ (command.targetId === undefined || Number.isSafeInteger(command.targetId)) &&
85
+ (command.argFloat === undefined || Number.isFinite(command.argFloat)) &&
86
+ isFiniteInteger(command.argInt) &&
87
+ isFiniteInteger(command.sampleTime) &&
88
+ (type !== SonareEngineCommandType.SetTrackMonitorMode ||
89
+ (isTrackMonitorLaneIndex(command.targetId) && isTrackMonitorMode(command.argInt)))
90
+ );
91
+ }
92
+
93
+ const AUDIO_WORKLET_RENDER_QUANTUM = 128;
94
+
95
+ function workletBlockSize(blockSize: number | undefined): number {
96
+ const resolved = blockSize ?? AUDIO_WORKLET_RENDER_QUANTUM;
97
+ if (!Number.isSafeInteger(resolved) || resolved < AUDIO_WORKLET_RENDER_QUANTUM) {
98
+ throw new RangeError(
99
+ `blockSize must be an integer of at least ${AUDIO_WORKLET_RENDER_QUANTUM} frames`,
100
+ );
101
+ }
102
+ return resolved;
103
+ }
104
+
40
105
  export class SonareRealtimeEngineNode {
41
106
  readonly node: AudioWorkletNode;
42
107
  readonly capabilities: SonareRealtimeEngineNodeCapabilities;
@@ -44,19 +109,28 @@ export class SonareRealtimeEngineNode {
44
109
  readonly telemetryRing?: SonareEngineTelemetryRingBuffer;
45
110
  readonly meterRing?: SonareMeterRingBuffer;
46
111
  readonly scopeRing?: SonareScopeRingBuffer;
112
+ readonly clipPageRequestRing?: SonareClipPageRequestRingBuffer;
113
+ readonly externalMidiRing?: SonareExternalMidiRingBuffer;
47
114
  readonly ready: Promise<void>;
48
115
  private telemetryReadIndex = 0;
49
116
  private meterReadIndex = 0;
50
117
  private scopeReadIndex = 0;
118
+ private clipPageRequestDroppedRead = 0;
119
+ private ringPollTimer: ReturnType<typeof setInterval> | undefined;
51
120
  private telemetryListeners = new Set<(telemetry: SonareEngineTelemetryRecord) => void>();
52
121
  private meterListeners = new Set<(meter: SonareWorkletMeterSnapshot) => void>();
53
122
  private scopeListeners = new Set<(scope: SonareWorkletScopeSnapshot) => void>();
54
123
  private midiOutListeners = new Set<(events: SonareWorkletExternalMidiEvent[]) => void>();
124
+ private clipPageRequestListeners = new Set<
125
+ (message: SonareEngineClipPageRequestMessage) => void
126
+ >();
127
+ private syncErrorListeners = new Set<(message: SonareEngineSyncErrorMessage) => void>();
55
128
  private captureRequestId = 1;
56
129
  private readonly captureRequests = new Map<
57
130
  number,
58
131
  {
59
- resolve: (response: SonareEngineCaptureResponseMessage) => void;
132
+ op: SonareEngineCaptureRequestMessage['op'];
133
+ resolve: (response: SonareEngineCaptureResponseMessageInternal) => void;
60
134
  reject: (reason?: unknown) => void;
61
135
  }
62
136
  >();
@@ -79,6 +153,8 @@ export class SonareRealtimeEngineNode {
79
153
  telemetryRing?: SonareEngineTelemetryRingBuffer,
80
154
  meterRing?: SonareMeterRingBuffer,
81
155
  scopeRing?: SonareScopeRingBuffer,
156
+ clipPageRequestRing?: SonareClipPageRequestRingBuffer,
157
+ externalMidiRing?: SonareExternalMidiRingBuffer,
82
158
  ) {
83
159
  this.node = node;
84
160
  this.capabilities = capabilities;
@@ -86,6 +162,8 @@ export class SonareRealtimeEngineNode {
86
162
  this.telemetryRing = telemetryRing;
87
163
  this.meterRing = meterRing;
88
164
  this.scopeRing = scopeRing;
165
+ this.clipPageRequestRing = clipPageRequestRing;
166
+ this.externalMidiRing = externalMidiRing;
89
167
  this.ready = new Promise((resolve, reject) => {
90
168
  this.resolveReady = resolve;
91
169
  this.rejectReady = reject;
@@ -94,14 +172,19 @@ export class SonareRealtimeEngineNode {
94
172
  this.resolveReady();
95
173
  }
96
174
  this.node.port.onmessage = (event: MessageEvent<unknown>) => {
97
- if (isEngineCaptureResponseMessage(event.data)) {
98
- const pending = this.captureRequests.get(event.data.requestId);
175
+ const captureRequestId = engineCaptureResponseRequestId(event.data);
176
+ if (captureRequestId !== undefined) {
177
+ const pending = this.captureRequests.get(captureRequestId);
99
178
  if (pending) {
100
- this.captureRequests.delete(event.data.requestId);
101
- if (event.data.ok) {
179
+ this.captureRequests.delete(captureRequestId);
180
+ if (!isEngineCaptureResponseMessage(event.data)) {
181
+ pending.reject(new Error('Malformed capture response.'));
182
+ } else if (!isEngineCaptureResponseForOperation(event.data, pending.op)) {
183
+ pending.reject(new Error('Capture response does not match request operation.'));
184
+ } else if (event.data.ok) {
102
185
  pending.resolve(event.data);
103
186
  } else {
104
- pending.reject(new Error(event.data.error ?? 'Capture request failed'));
187
+ pending.reject(new Error(event.data.error));
105
188
  }
106
189
  }
107
190
  } else if (isEngineTransportResponseMessage(event.data)) {
@@ -120,6 +203,17 @@ export class SonareRealtimeEngineNode {
120
203
  this.emitMeter(event.data);
121
204
  } else if (isExternalMidiBatchMessage(event.data)) {
122
205
  this.emitMidiOut(event.data.events);
206
+ } else if (isClipPageRequestMessage(event.data)) {
207
+ this.emitClipPageRequests(event.data);
208
+ } else if (isRecord(event.data) && event.data.type === 'syncError') {
209
+ const syncError: SonareEngineSyncErrorMessage = {
210
+ type: 'syncError',
211
+ syncType: String(event.data.syncType) as SonareEngineSyncErrorMessage['syncType'],
212
+ message: String(event.data.message ?? 'AudioWorklet sync failed'),
213
+ };
214
+ for (const listener of this.syncErrorListeners) {
215
+ listener(syncError);
216
+ }
123
217
  } else if (isRecord(event.data) && event.data.type === 'ready') {
124
218
  this.resolveReady();
125
219
  } else if (isRecord(event.data) && event.data.type === 'error') {
@@ -128,10 +222,17 @@ export class SonareRealtimeEngineNode {
128
222
  };
129
223
  }
130
224
 
225
+ /** Subscribes to control-plane sync rejections reported by the worklet. */
226
+ onSyncError(listener: (message: SonareEngineSyncErrorMessage) => void): () => void {
227
+ this.syncErrorListeners.add(listener);
228
+ return () => this.syncErrorListeners.delete(listener);
229
+ }
230
+
131
231
  static async create(
132
232
  context: BaseAudioContext,
133
233
  options: SonareRealtimeEngineNodeOptions = {},
134
234
  ): Promise<SonareRealtimeEngineNode> {
235
+ const blockSize = workletBlockSize(options.blockSize);
135
236
  const processorName = options.processorName ?? 'sonare-realtime-engine-processor';
136
237
  const moduleUrl = options.moduleUrl;
137
238
  if (moduleUrl && context.audioWorklet?.addModule) {
@@ -188,10 +289,21 @@ export class SonareRealtimeEngineNode {
188
289
  mode === 'sab' && scopeIntervalFrames > 0
189
290
  ? createSonareScopeRingBuffer(options.scopeRingCapacity ?? 64, options.scopeBands ?? 48)
190
291
  : undefined;
292
+ // Paged-clip cache misses are a worklet-to-main-thread control signal. A
293
+ // dedicated SPSC SAB ring keeps process() free of both embind request
294
+ // objects and postMessage structured cloning.
295
+ const clipPageRequestRing =
296
+ mode === 'sab'
297
+ ? createSonareClipPageRequestRingBuffer(options.clipPageRequestRingCapacity ?? 128)
298
+ : undefined;
299
+ const externalMidiRing =
300
+ mode === 'sab'
301
+ ? createSonareExternalMidiRingBuffer(options.externalMidiRingCapacity ?? 256)
302
+ : undefined;
191
303
  const channelCount = Math.max(1, Math.floor(options.channelCount ?? 2));
192
304
  const processorOptions: SonareRealtimeEngineWorkletProcessorOptions = {
193
305
  sampleRate: options.sampleRate ?? context.sampleRate,
194
- blockSize: options.blockSize,
306
+ blockSize,
195
307
  channelCount,
196
308
  commandSharedBuffer: commandRing?.sharedBuffer,
197
309
  commandRingCapacity: commandRing?.capacity,
@@ -203,6 +315,10 @@ export class SonareRealtimeEngineNode {
203
315
  scopeRingCapacity: scopeRing?.capacity,
204
316
  scopeBands: scopeRing?.bands,
205
317
  scopeIntervalFrames: scopeRing ? scopeIntervalFrames : undefined,
318
+ clipPageRequestSharedBuffer: clipPageRequestRing?.sharedBuffer,
319
+ clipPageRequestRingCapacity: clipPageRequestRing?.capacity,
320
+ externalMidiSharedBuffer: externalMidiRing?.sharedBuffer,
321
+ externalMidiRingCapacity: externalMidiRing?.capacity,
206
322
  wasmBinary: options.wasmBinary,
207
323
  initialSyncMessages: options.initialSyncMessages,
208
324
  initialCommands: options.initialCommands,
@@ -225,16 +341,23 @@ export class SonareRealtimeEngineNode {
225
341
  sharedArrayBuffer,
226
342
  atomics,
227
343
  audioWorklet,
344
+ clipPageRequestsRealtimeSafe: mode === 'sab',
345
+ externalMidiRealtimeSafe: mode === 'sab',
228
346
  engineAbiVersion: detectedCapabilities?.engineAbiVersion,
229
347
  expectedEngineAbiVersion: detectedCapabilities?.expectedEngineAbiVersion,
230
348
  abiCompatible: detectedCapabilities?.abiCompatible,
231
349
  degradedReason,
232
- readyMessage: moduleUrl !== undefined && !options.nodeFactory,
350
+ // A processor posts ready/error irrespective of whether its module was
351
+ // loaded here or registered by the host beforehand. Waiting in both
352
+ // cases is the only way to surface a failed worklet-side WASM init.
353
+ readyMessage: true,
233
354
  },
234
355
  commandRing,
235
356
  telemetryRing,
236
357
  meterRing,
237
358
  scopeRing,
359
+ clipPageRequestRing,
360
+ externalMidiRing,
238
361
  );
239
362
  }
240
363
 
@@ -255,6 +378,9 @@ export class SonareRealtimeEngineNode {
255
378
  }
256
379
 
257
380
  seekPpq(ppq: number, sampleTime = -1): boolean {
381
+ if (!Number.isFinite(ppq) || !Number.isSafeInteger(sampleTime)) {
382
+ return false;
383
+ }
258
384
  return this.sendCommand({
259
385
  type: SonareEngineCommandType.TransportSeekPpq,
260
386
  sampleTime,
@@ -263,7 +389,7 @@ export class SonareRealtimeEngineNode {
263
389
  }
264
390
 
265
391
  sendCommand(command: SonareEngineCommandRecord): boolean {
266
- if (this.destroyed) {
392
+ if (this.destroyed || !isValidCommandRecord(command)) {
267
393
  return false;
268
394
  }
269
395
  if (this.commandRing) {
@@ -275,7 +401,7 @@ export class SonareRealtimeEngineNode {
275
401
 
276
402
  requestCaptureStatus(): Promise<EngineCaptureStatus> {
277
403
  return this.sendCaptureRequest('status').then((response) => {
278
- if (!response.status) {
404
+ if (!response.ok || !('status' in response)) {
279
405
  throw new Error('Capture status response is missing status.');
280
406
  }
281
407
  return response.status;
@@ -283,11 +409,12 @@ export class SonareRealtimeEngineNode {
283
409
  }
284
410
 
285
411
  requestCapturedAudio(): Promise<Float32Array[]> {
286
- return this.sendCaptureRequest('read').then((response) =>
287
- (response.channels ?? []).map((channel) =>
288
- channel instanceof Float32Array ? channel : new Float32Array(channel),
289
- ),
290
- );
412
+ return this.sendCaptureRequest('read').then((response) => {
413
+ if (!response.ok || !('channels' in response)) {
414
+ throw new Error('Capture read response is missing channels.');
415
+ }
416
+ return response.channels;
417
+ });
291
418
  }
292
419
 
293
420
  requestCaptureReset(): Promise<void> {
@@ -345,24 +472,77 @@ export class SonareRealtimeEngineNode {
345
472
  return read.scopes;
346
473
  }
347
474
 
475
+ /** Drain lowered MIDI-1 records from the SAB ring on the main thread. */
476
+ pollMidiOut(): SonareWorkletExternalMidiEvent[] {
477
+ if (!this.externalMidiRing) {
478
+ return [];
479
+ }
480
+ const read = readSonareExternalMidiRingBuffer(this.externalMidiRing);
481
+ const events = read.events.map((event) => {
482
+ const bytes: number[] = [];
483
+ for (let index = 0; index < event.byteCount; index++) {
484
+ bytes.push((event.byteWord >>> (8 * index)) & 0xff);
485
+ }
486
+ return {
487
+ destinationId: event.destinationId,
488
+ renderFrame: event.renderFrame,
489
+ bytes,
490
+ };
491
+ });
492
+ if (events.length > 0) {
493
+ this.emitMidiOut(events);
494
+ }
495
+ return events;
496
+ }
497
+
498
+ /**
499
+ * Drains bounded paged-clip misses from the SAB ring and forwards one batch
500
+ * to subscribers. In postMessage mode the legacy handler remains available,
501
+ * but that degraded path is not realtime-safe for OPFS streaming.
502
+ */
503
+ pollClipPageRequests(): SonareEngineClipPageRequestMessage | undefined {
504
+ if (!this.clipPageRequestRing) {
505
+ return undefined;
506
+ }
507
+ const read = readSonareClipPageRequestRingBuffer(this.clipPageRequestRing);
508
+ const dropped = (read.dropped - this.clipPageRequestDroppedRead) >>> 0;
509
+ this.clipPageRequestDroppedRead = read.dropped;
510
+ if (read.requests.length === 0 && dropped === 0) {
511
+ return undefined;
512
+ }
513
+ const message: SonareEngineClipPageRequestMessage = {
514
+ type: 'clipPageRequest',
515
+ requests: read.requests,
516
+ ...(dropped > 0 ? { dropped } : {}),
517
+ };
518
+ this.emitClipPageRequests(message);
519
+ return message;
520
+ }
521
+
348
522
  onTelemetry(callback: (telemetry: SonareEngineTelemetryRecord) => void): () => void {
349
523
  this.telemetryListeners.add(callback);
524
+ this.startRingPolling();
350
525
  return () => {
351
526
  this.telemetryListeners.delete(callback);
527
+ this.stopRingPollingIfUnused();
352
528
  };
353
529
  }
354
530
 
355
531
  onMeter(callback: (meter: SonareWorkletMeterSnapshot) => void): () => void {
356
532
  this.meterListeners.add(callback);
533
+ this.startRingPolling();
357
534
  return () => {
358
535
  this.meterListeners.delete(callback);
536
+ this.stopRingPollingIfUnused();
359
537
  };
360
538
  }
361
539
 
362
540
  onScope(callback: (scope: SonareWorkletScopeSnapshot) => void): () => void {
363
541
  this.scopeListeners.add(callback);
542
+ this.startRingPolling();
364
543
  return () => {
365
544
  this.scopeListeners.delete(callback);
545
+ this.stopRingPollingIfUnused();
366
546
  };
367
547
  }
368
548
 
@@ -373,8 +553,21 @@ export class SonareRealtimeEngineNode {
373
553
  */
374
554
  onMidiOut(callback: (events: SonareWorkletExternalMidiEvent[]) => void): () => void {
375
555
  this.midiOutListeners.add(callback);
556
+ this.startRingPolling();
376
557
  return () => {
377
558
  this.midiOutListeners.delete(callback);
559
+ this.stopRingPollingIfUnused();
560
+ };
561
+ }
562
+
563
+ /**
564
+ * Subscribe to bounded batches of paged-clip misses from the worklet. The
565
+ * callback runs on the main thread and may initiate asynchronous OPFS I/O.
566
+ */
567
+ onClipPageRequests(callback: (message: SonareEngineClipPageRequestMessage) => void): () => void {
568
+ this.clipPageRequestListeners.add(callback);
569
+ return () => {
570
+ this.clipPageRequestListeners.delete(callback);
378
571
  };
379
572
  }
380
573
 
@@ -383,7 +576,11 @@ export class SonareRealtimeEngineNode {
383
576
  return;
384
577
  }
385
578
  this.destroyed = true;
386
- this.node.port.postMessage({ type: SonareEngineCommandType.TransportStop, sampleTime: -1 });
579
+ if (this.ringPollTimer !== undefined) {
580
+ clearInterval(this.ringPollTimer);
581
+ this.ringPollTimer = undefined;
582
+ }
583
+ this.node.port.postMessage({ type: 'destroy' });
387
584
  this.node.disconnect();
388
585
  for (const pending of this.captureRequests.values()) {
389
586
  pending.reject(new Error('Realtime engine node is destroyed.'));
@@ -397,6 +594,7 @@ export class SonareRealtimeEngineNode {
397
594
  this.meterListeners.clear();
398
595
  this.scopeListeners.clear();
399
596
  this.midiOutListeners.clear();
597
+ this.clipPageRequestListeners.clear();
400
598
  }
401
599
 
402
600
  private emitTelemetry(telemetry: SonareEngineTelemetryRecord): void {
@@ -405,6 +603,38 @@ export class SonareRealtimeEngineNode {
405
603
  }
406
604
  }
407
605
 
606
+ private startRingPolling(): void {
607
+ if (
608
+ this.ringPollTimer !== undefined ||
609
+ (!this.telemetryRing && !this.meterRing && !this.scopeRing && !this.externalMidiRing)
610
+ ) {
611
+ return;
612
+ }
613
+ const poll = () => {
614
+ if (!this.destroyed) {
615
+ this.pollTelemetry();
616
+ this.pollMeters();
617
+ this.pollScope();
618
+ this.pollMidiOut();
619
+ }
620
+ };
621
+ poll();
622
+ this.ringPollTimer = setInterval(poll, 16);
623
+ }
624
+
625
+ private stopRingPollingIfUnused(): void {
626
+ if (
627
+ this.ringPollTimer !== undefined &&
628
+ this.telemetryListeners.size === 0 &&
629
+ this.meterListeners.size === 0 &&
630
+ this.scopeListeners.size === 0 &&
631
+ this.midiOutListeners.size === 0
632
+ ) {
633
+ clearInterval(this.ringPollTimer);
634
+ this.ringPollTimer = undefined;
635
+ }
636
+ }
637
+
408
638
  private emitMeter(meter: SonareWorkletMeterSnapshot): void {
409
639
  for (const listener of this.meterListeners) {
410
640
  listener(meter);
@@ -417,6 +647,12 @@ export class SonareRealtimeEngineNode {
417
647
  }
418
648
  }
419
649
 
650
+ private emitClipPageRequests(message: SonareEngineClipPageRequestMessage): void {
651
+ for (const listener of this.clipPageRequestListeners) {
652
+ listener(message);
653
+ }
654
+ }
655
+
420
656
  private emitScope(scope: SonareWorkletScopeSnapshot): void {
421
657
  for (const listener of this.scopeListeners) {
422
658
  listener(scope);
@@ -425,15 +661,22 @@ export class SonareRealtimeEngineNode {
425
661
 
426
662
  private sendCaptureRequest(
427
663
  op: SonareEngineCaptureRequestMessage['op'],
428
- ): Promise<SonareEngineCaptureResponseMessage> {
664
+ ): Promise<SonareEngineCaptureResponseMessageInternal> {
429
665
  if (this.destroyed) {
430
666
  return Promise.reject(new Error('Realtime engine node is destroyed.'));
431
667
  }
432
668
  const requestId = this.captureRequestId++;
433
- const promise = new Promise<SonareEngineCaptureResponseMessage>((resolve, reject) => {
434
- this.captureRequests.set(requestId, { resolve, reject });
669
+ let rejectRequest!: (reason?: unknown) => void;
670
+ const promise = new Promise<SonareEngineCaptureResponseMessageInternal>((resolve, reject) => {
671
+ rejectRequest = reject;
672
+ this.captureRequests.set(requestId, { op, resolve, reject });
435
673
  });
436
- this.node.port.postMessage({ type: 'captureRequest', requestId, op });
674
+ try {
675
+ this.node.port.postMessage({ type: 'captureRequest', requestId, op });
676
+ } catch (error) {
677
+ this.captureRequests.delete(requestId);
678
+ rejectRequest(error);
679
+ }
437
680
  return promise;
438
681
  }
439
682