@libraz/libsonare 1.4.1 → 1.5.1

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 (66) hide show
  1. package/README.md +51 -20
  2. package/dist/index.d.ts +5416 -1
  3. package/dist/index.js +938 -583
  4. package/dist/index.js.map +1 -1
  5. package/dist/sonare.js +2 -2
  6. package/dist/sonare.wasm +0 -0
  7. package/dist/worklet.d.ts +1083 -5227
  8. package/dist/worklet.js +2683 -2451
  9. package/dist/worklet.js.map +1 -1
  10. package/package.json +4 -9
  11. package/src/clip_page_streamer.ts +298 -0
  12. package/src/effects_mastering.ts +85 -1089
  13. package/src/effects_transform.ts +286 -0
  14. package/src/effects_voice_change.ts +118 -0
  15. package/src/feature_music.ts +5 -2
  16. package/src/feature_spectrogram.ts +42 -2
  17. package/src/features.ts +1 -0
  18. package/src/index.ts +23 -0
  19. package/src/mastering_chain.ts +200 -0
  20. package/src/mastering_core.ts +248 -0
  21. package/src/mastering_dynamics.ts +105 -0
  22. package/src/mastering_repair.ts +161 -0
  23. package/src/mixer.ts +8 -0
  24. package/src/mixing_oneshot.ts +54 -0
  25. package/src/module_state.ts +1 -2
  26. package/src/project.ts +71 -1712
  27. package/src/project_class.ts +871 -0
  28. package/src/project_internal.ts +333 -0
  29. package/src/project_synth.ts +43 -0
  30. package/src/project_types.ts +570 -0
  31. package/src/public_types.ts +6 -1221
  32. package/src/public_types_acoustic.ts +115 -0
  33. package/src/public_types_mastering.ts +333 -0
  34. package/src/public_types_mixing.ts +97 -0
  35. package/src/public_types_music.ts +352 -0
  36. package/src/public_types_realtime.ts +163 -0
  37. package/src/public_types_spectral.ts +194 -0
  38. package/src/realtime_engine.ts +94 -0
  39. package/src/sonare.js.d.ts +117 -38
  40. package/src/stream_analyzer.ts +3 -0
  41. package/src/stream_types.ts +4 -0
  42. package/src/worklet/engine-automation.ts +73 -0
  43. package/src/worklet/engine-capture-facade.ts +80 -0
  44. package/src/worklet/engine-clips.ts +71 -0
  45. package/src/worklet/engine-markers.ts +93 -0
  46. package/src/worklet/engine-mixer-facade.ts +186 -0
  47. package/src/worklet/engine-node.ts +451 -0
  48. package/src/worklet/engine-offline.ts +162 -0
  49. package/src/worklet/engine-options.ts +13 -0
  50. package/src/worklet/engine-parameter-facade.ts +172 -0
  51. package/src/worklet/engine-processor.ts +764 -0
  52. package/src/worklet/engine-register.ts +136 -0
  53. package/src/worklet/engine-strips.ts +315 -0
  54. package/src/worklet/engine-sync.ts +94 -0
  55. package/src/worklet/engine-tempo-facade.ts +141 -0
  56. package/src/worklet/engine.ts +998 -0
  57. package/src/worklet/guards.ts +14 -1
  58. package/src/worklet/messages.ts +60 -20
  59. package/src/worklet/mixer-processor.ts +368 -0
  60. package/src/worklet/protocol.ts +3 -0
  61. package/src/worklet/voice-changer-processor.ts +246 -0
  62. package/src/worklet.ts +20 -3549
  63. package/dist/sonare-rt-module.js +0 -2
  64. package/dist/sonare-rt.js +0 -2
  65. package/dist/sonare-rt.wasm +0 -0
  66. package/src/sonare-rt.d.ts +0 -93
@@ -0,0 +1,451 @@
1
+ import type { EngineCaptureStatus, EngineTransportState } from '../index';
2
+ import { engineCapabilities } from '../index';
3
+ import {
4
+ isEngineCaptureResponseMessage,
5
+ isEngineTelemetryRecord,
6
+ isEngineTransportResponseMessage,
7
+ isExternalMidiBatchMessage,
8
+ isMeterSnapshot,
9
+ } from './guards';
10
+ import type {
11
+ SonareEngineCaptureRequestMessage,
12
+ SonareEngineCaptureResponseMessage,
13
+ SonareEngineTransportResponseMessage,
14
+ SonareRealtimeEngineNodeCapabilities,
15
+ SonareRealtimeEngineNodeOptions,
16
+ SonareRealtimeEngineWorkletProcessorOptions,
17
+ SonareWorkletExternalMidiEvent,
18
+ } from './messages';
19
+ import {
20
+ createSonareEngineCommandRingBuffer,
21
+ createSonareEngineTelemetryRingBuffer,
22
+ createSonareMeterRingBuffer,
23
+ createSonareScopeRingBuffer,
24
+ isRecord,
25
+ pushSonareEngineCommandRingBuffer,
26
+ readSonareEngineTelemetryRingBuffer,
27
+ readSonareMeterRingBuffer,
28
+ readSonareScopeRingBuffer,
29
+ type SonareEngineCommandRecord,
30
+ type SonareEngineCommandRingBuffer,
31
+ SonareEngineCommandType,
32
+ type SonareEngineTelemetryRecord,
33
+ type SonareEngineTelemetryRingBuffer,
34
+ type SonareMeterRingBuffer,
35
+ type SonareScopeRingBuffer,
36
+ type SonareWorkletMeterSnapshot,
37
+ type SonareWorkletScopeSnapshot,
38
+ } from './protocol';
39
+
40
+ export class SonareRealtimeEngineNode {
41
+ readonly node: AudioWorkletNode;
42
+ readonly capabilities: SonareRealtimeEngineNodeCapabilities;
43
+ readonly commandRing?: SonareEngineCommandRingBuffer;
44
+ readonly telemetryRing?: SonareEngineTelemetryRingBuffer;
45
+ readonly meterRing?: SonareMeterRingBuffer;
46
+ readonly scopeRing?: SonareScopeRingBuffer;
47
+ readonly ready: Promise<void>;
48
+ private telemetryReadIndex = 0;
49
+ private meterReadIndex = 0;
50
+ private scopeReadIndex = 0;
51
+ private telemetryListeners = new Set<(telemetry: SonareEngineTelemetryRecord) => void>();
52
+ private meterListeners = new Set<(meter: SonareWorkletMeterSnapshot) => void>();
53
+ private scopeListeners = new Set<(scope: SonareWorkletScopeSnapshot) => void>();
54
+ private midiOutListeners = new Set<(events: SonareWorkletExternalMidiEvent[]) => void>();
55
+ private captureRequestId = 1;
56
+ private readonly captureRequests = new Map<
57
+ number,
58
+ {
59
+ resolve: (response: SonareEngineCaptureResponseMessage) => void;
60
+ reject: (reason?: unknown) => void;
61
+ }
62
+ >();
63
+ private transportRequestId = 1;
64
+ private readonly transportRequests = new Map<
65
+ number,
66
+ {
67
+ resolve: (response: SonareEngineTransportResponseMessage) => void;
68
+ reject: (reason?: unknown) => void;
69
+ }
70
+ >();
71
+ private resolveReady!: () => void;
72
+ private rejectReady!: (reason?: unknown) => void;
73
+ private destroyed = false;
74
+
75
+ private constructor(
76
+ node: AudioWorkletNode,
77
+ capabilities: SonareRealtimeEngineNodeCapabilities,
78
+ commandRing?: SonareEngineCommandRingBuffer,
79
+ telemetryRing?: SonareEngineTelemetryRingBuffer,
80
+ meterRing?: SonareMeterRingBuffer,
81
+ scopeRing?: SonareScopeRingBuffer,
82
+ ) {
83
+ this.node = node;
84
+ this.capabilities = capabilities;
85
+ this.commandRing = commandRing;
86
+ this.telemetryRing = telemetryRing;
87
+ this.meterRing = meterRing;
88
+ this.scopeRing = scopeRing;
89
+ this.ready = new Promise((resolve, reject) => {
90
+ this.resolveReady = resolve;
91
+ this.rejectReady = reject;
92
+ });
93
+ if (!capabilities.readyMessage) {
94
+ this.resolveReady();
95
+ }
96
+ this.node.port.onmessage = (event: MessageEvent<unknown>) => {
97
+ if (isEngineCaptureResponseMessage(event.data)) {
98
+ const pending = this.captureRequests.get(event.data.requestId);
99
+ if (pending) {
100
+ this.captureRequests.delete(event.data.requestId);
101
+ if (event.data.ok) {
102
+ pending.resolve(event.data);
103
+ } else {
104
+ pending.reject(new Error(event.data.error ?? 'Capture request failed'));
105
+ }
106
+ }
107
+ } else if (isEngineTransportResponseMessage(event.data)) {
108
+ const pending = this.transportRequests.get(event.data.requestId);
109
+ if (pending) {
110
+ this.transportRequests.delete(event.data.requestId);
111
+ if (event.data.ok) {
112
+ pending.resolve(event.data);
113
+ } else {
114
+ pending.reject(new Error(event.data.error ?? 'Transport request failed'));
115
+ }
116
+ }
117
+ } else if (isEngineTelemetryRecord(event.data)) {
118
+ this.emitTelemetry(event.data);
119
+ } else if (isMeterSnapshot(event.data)) {
120
+ this.emitMeter(event.data);
121
+ } else if (isExternalMidiBatchMessage(event.data)) {
122
+ this.emitMidiOut(event.data.events);
123
+ } else if (isRecord(event.data) && event.data.type === 'ready') {
124
+ this.resolveReady();
125
+ } else if (isRecord(event.data) && event.data.type === 'error') {
126
+ this.rejectReady(new Error(String(event.data.message ?? 'AudioWorklet error')));
127
+ }
128
+ };
129
+ }
130
+
131
+ static async create(
132
+ context: BaseAudioContext,
133
+ options: SonareRealtimeEngineNodeOptions = {},
134
+ ): Promise<SonareRealtimeEngineNode> {
135
+ const processorName = options.processorName ?? 'sonare-realtime-engine-processor';
136
+ const moduleUrl = options.moduleUrl;
137
+ if (moduleUrl && context.audioWorklet?.addModule) {
138
+ await context.audioWorklet.addModule(moduleUrl);
139
+ }
140
+ const detectedCapabilities =
141
+ options.engineAbiVersion !== undefined
142
+ ? {
143
+ engineAbiVersion: options.engineAbiVersion,
144
+ expectedEngineAbiVersion: options.expectedEngineAbiVersion ?? options.engineAbiVersion,
145
+ abiCompatible:
146
+ options.engineAbiVersion ===
147
+ (options.expectedEngineAbiVersion ?? options.engineAbiVersion),
148
+ }
149
+ : engineCapabilities();
150
+ if (options.requireAbiCompatible !== false && detectedCapabilities?.abiCompatible === false) {
151
+ throw new Error(
152
+ `Engine ABI mismatch: wasm=${detectedCapabilities.engineAbiVersion}, expected=${detectedCapabilities.expectedEngineAbiVersion}`,
153
+ );
154
+ }
155
+ const sharedArrayBuffer = typeof globalThis.SharedArrayBuffer === 'function';
156
+ const atomics = typeof globalThis.Atomics === 'object';
157
+ const audioWorklet = typeof AudioWorkletNode !== 'undefined' || !!options.nodeFactory;
158
+ const degradedReason =
159
+ options.mode !== 'postMessage' && (!sharedArrayBuffer || !atomics)
160
+ ? 'SharedArrayBuffer or Atomics unavailable; using postMessage transport.'
161
+ : undefined;
162
+ const mode =
163
+ options.mode === 'postMessage' || !sharedArrayBuffer || !atomics ? 'postMessage' : 'sab';
164
+ if (options.mode === 'sab' && mode !== 'sab') {
165
+ throw new Error(
166
+ 'SharedArrayBuffer mode requested but SharedArrayBuffer/Atomics are unavailable.',
167
+ );
168
+ }
169
+
170
+ const commandRing =
171
+ mode === 'sab'
172
+ ? createSonareEngineCommandRingBuffer(options.commandRingCapacity ?? 128)
173
+ : undefined;
174
+ const telemetryRing =
175
+ mode === 'sab'
176
+ ? createSonareEngineTelemetryRingBuffer(options.telemetryRingCapacity ?? 128)
177
+ : undefined;
178
+ // Meter ring: the engine publishes meters into a SAB ring. Lock-free meter
179
+ // delivery matches the telemetry path and keeps the audio render callback
180
+ // allocation-free in SAB mode.
181
+ const meterRing =
182
+ mode === 'sab' ? createSonareMeterRingBuffer(options.meterRingCapacity ?? 128) : undefined;
183
+ // Scope ring (FFT spectrum + goniometer): opt-in. The per-block FFT is
184
+ // heavier than the meter path, so it is created only when the caller
185
+ // requests scope telemetry via scopeIntervalFrames > 0.
186
+ const scopeIntervalFrames = Math.max(0, Math.floor(options.scopeIntervalFrames ?? 0));
187
+ const scopeRing =
188
+ mode === 'sab' && scopeIntervalFrames > 0
189
+ ? createSonareScopeRingBuffer(options.scopeRingCapacity ?? 64, options.scopeBands ?? 48)
190
+ : undefined;
191
+ const channelCount = Math.max(1, Math.floor(options.channelCount ?? 2));
192
+ const processorOptions: SonareRealtimeEngineWorkletProcessorOptions = {
193
+ sampleRate: options.sampleRate ?? context.sampleRate,
194
+ blockSize: options.blockSize,
195
+ channelCount,
196
+ commandSharedBuffer: commandRing?.sharedBuffer,
197
+ commandRingCapacity: commandRing?.capacity,
198
+ telemetrySharedBuffer: telemetryRing?.sharedBuffer,
199
+ telemetryRingCapacity: telemetryRing?.capacity,
200
+ meterSharedBuffer: meterRing?.sharedBuffer,
201
+ meterRingCapacity: meterRing?.capacity,
202
+ scopeSharedBuffer: scopeRing?.sharedBuffer,
203
+ scopeRingCapacity: scopeRing?.capacity,
204
+ scopeBands: scopeRing?.bands,
205
+ scopeIntervalFrames: scopeRing ? scopeIntervalFrames : undefined,
206
+ wasmBinary: options.wasmBinary,
207
+ initialSyncMessages: options.initialSyncMessages,
208
+ initialCommands: options.initialCommands,
209
+ };
210
+ const factory =
211
+ options.nodeFactory ??
212
+ ((ctx: BaseAudioContext, name: string, nodeOptions: AudioWorkletNodeOptions) =>
213
+ new AudioWorkletNode(ctx, name, nodeOptions));
214
+ const node = factory(context, processorName, {
215
+ numberOfInputs: 1,
216
+ numberOfOutputs: 1,
217
+ outputChannelCount: [channelCount],
218
+ processorOptions,
219
+ });
220
+ return new SonareRealtimeEngineNode(
221
+ node,
222
+ {
223
+ mode,
224
+ runtimeTarget: 'embind',
225
+ sharedArrayBuffer,
226
+ atomics,
227
+ audioWorklet,
228
+ engineAbiVersion: detectedCapabilities?.engineAbiVersion,
229
+ expectedEngineAbiVersion: detectedCapabilities?.expectedEngineAbiVersion,
230
+ abiCompatible: detectedCapabilities?.abiCompatible,
231
+ degradedReason,
232
+ readyMessage: moduleUrl !== undefined && !options.nodeFactory,
233
+ },
234
+ commandRing,
235
+ telemetryRing,
236
+ meterRing,
237
+ scopeRing,
238
+ );
239
+ }
240
+
241
+ play(sampleTime = -1): boolean {
242
+ return this.sendCommand({ type: SonareEngineCommandType.TransportPlay, sampleTime });
243
+ }
244
+
245
+ stop(sampleTime = -1): boolean {
246
+ return this.sendCommand({ type: SonareEngineCommandType.TransportStop, sampleTime });
247
+ }
248
+
249
+ seekSample(timelineSample: number, sampleTime = -1): boolean {
250
+ return this.sendCommand({
251
+ type: SonareEngineCommandType.TransportSeekSample,
252
+ sampleTime,
253
+ argInt: timelineSample,
254
+ });
255
+ }
256
+
257
+ seekPpq(ppq: number, sampleTime = -1): boolean {
258
+ return this.sendCommand({
259
+ type: SonareEngineCommandType.TransportSeekPpq,
260
+ sampleTime,
261
+ argFloat: ppq,
262
+ });
263
+ }
264
+
265
+ sendCommand(command: SonareEngineCommandRecord): boolean {
266
+ if (this.destroyed) {
267
+ return false;
268
+ }
269
+ if (this.commandRing) {
270
+ return pushSonareEngineCommandRingBuffer(this.commandRing, command);
271
+ }
272
+ this.node.port.postMessage(command);
273
+ return true;
274
+ }
275
+
276
+ requestCaptureStatus(): Promise<EngineCaptureStatus> {
277
+ return this.sendCaptureRequest('status').then((response) => {
278
+ if (!response.status) {
279
+ throw new Error('Capture status response is missing status.');
280
+ }
281
+ return response.status;
282
+ });
283
+ }
284
+
285
+ 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
+ );
291
+ }
292
+
293
+ requestCaptureReset(): Promise<void> {
294
+ return this.sendCaptureRequest('reset').then(() => undefined);
295
+ }
296
+
297
+ requestTransportState(): Promise<EngineTransportState> {
298
+ return this.sendTransportRequest().then((response) => {
299
+ if (!response.state) {
300
+ throw new Error('Transport state response is missing state.');
301
+ }
302
+ return response.state;
303
+ });
304
+ }
305
+
306
+ pollTelemetry(): SonareEngineTelemetryRecord[] {
307
+ if (!this.telemetryRing) {
308
+ return [];
309
+ }
310
+ const read = readSonareEngineTelemetryRingBuffer(this.telemetryRing, this.telemetryReadIndex);
311
+ this.telemetryReadIndex = read.nextReadIndex;
312
+ for (const telemetry of read.telemetry) {
313
+ this.emitTelemetry(telemetry);
314
+ }
315
+ return read.telemetry;
316
+ }
317
+
318
+ // Drains any meters published into the SAB meter ring (embind SAB mode) and
319
+ // forwards them to onMeter listeners. In postMessage mode meters arrive via
320
+ // node.port.onmessage instead, so this is a no-op then.
321
+ pollMeters(): SonareWorkletMeterSnapshot[] {
322
+ if (!this.meterRing) {
323
+ return [];
324
+ }
325
+ const read = readSonareMeterRingBuffer(this.meterRing, this.meterReadIndex);
326
+ this.meterReadIndex = read.nextReadIndex;
327
+ for (const meter of read.meters) {
328
+ this.emitMeter(meter);
329
+ }
330
+ return read.meters;
331
+ }
332
+
333
+ // Drains scope telemetry (FFT spectrum + goniometer points) published into the
334
+ // SAB scope ring and forwards each record to onScope listeners. A no-op unless
335
+ // the node was created with scopeIntervalFrames > 0 (embind SAB mode).
336
+ pollScope(): SonareWorkletScopeSnapshot[] {
337
+ if (!this.scopeRing) {
338
+ return [];
339
+ }
340
+ const read = readSonareScopeRingBuffer(this.scopeRing, this.scopeReadIndex);
341
+ this.scopeReadIndex = read.nextReadIndex;
342
+ for (const scope of read.scopes) {
343
+ this.emitScope(scope);
344
+ }
345
+ return read.scopes;
346
+ }
347
+
348
+ onTelemetry(callback: (telemetry: SonareEngineTelemetryRecord) => void): () => void {
349
+ this.telemetryListeners.add(callback);
350
+ return () => {
351
+ this.telemetryListeners.delete(callback);
352
+ };
353
+ }
354
+
355
+ onMeter(callback: (meter: SonareWorkletMeterSnapshot) => void): () => void {
356
+ this.meterListeners.add(callback);
357
+ return () => {
358
+ this.meterListeners.delete(callback);
359
+ };
360
+ }
361
+
362
+ onScope(callback: (scope: SonareWorkletScopeSnapshot) => void): () => void {
363
+ this.scopeListeners.add(callback);
364
+ return () => {
365
+ this.scopeListeners.delete(callback);
366
+ };
367
+ }
368
+
369
+ /**
370
+ * Subscribe to external-MIDI batches drained from the engine (one call per
371
+ * render block that produced events), already lowered to MIDI 1.0 bytes for a
372
+ * Web MIDI output port. Returns an unsubscribe function.
373
+ */
374
+ onMidiOut(callback: (events: SonareWorkletExternalMidiEvent[]) => void): () => void {
375
+ this.midiOutListeners.add(callback);
376
+ return () => {
377
+ this.midiOutListeners.delete(callback);
378
+ };
379
+ }
380
+
381
+ destroy(): void {
382
+ if (this.destroyed) {
383
+ return;
384
+ }
385
+ this.destroyed = true;
386
+ this.node.port.postMessage({ type: SonareEngineCommandType.TransportStop, sampleTime: -1 });
387
+ this.node.disconnect();
388
+ for (const pending of this.captureRequests.values()) {
389
+ pending.reject(new Error('Realtime engine node is destroyed.'));
390
+ }
391
+ this.captureRequests.clear();
392
+ for (const pending of this.transportRequests.values()) {
393
+ pending.reject(new Error('Realtime engine node is destroyed.'));
394
+ }
395
+ this.transportRequests.clear();
396
+ this.telemetryListeners.clear();
397
+ this.meterListeners.clear();
398
+ this.scopeListeners.clear();
399
+ this.midiOutListeners.clear();
400
+ }
401
+
402
+ private emitTelemetry(telemetry: SonareEngineTelemetryRecord): void {
403
+ for (const listener of this.telemetryListeners) {
404
+ listener(telemetry);
405
+ }
406
+ }
407
+
408
+ private emitMeter(meter: SonareWorkletMeterSnapshot): void {
409
+ for (const listener of this.meterListeners) {
410
+ listener(meter);
411
+ }
412
+ }
413
+
414
+ private emitMidiOut(events: SonareWorkletExternalMidiEvent[]): void {
415
+ for (const listener of this.midiOutListeners) {
416
+ listener(events);
417
+ }
418
+ }
419
+
420
+ private emitScope(scope: SonareWorkletScopeSnapshot): void {
421
+ for (const listener of this.scopeListeners) {
422
+ listener(scope);
423
+ }
424
+ }
425
+
426
+ private sendCaptureRequest(
427
+ op: SonareEngineCaptureRequestMessage['op'],
428
+ ): Promise<SonareEngineCaptureResponseMessage> {
429
+ if (this.destroyed) {
430
+ return Promise.reject(new Error('Realtime engine node is destroyed.'));
431
+ }
432
+ const requestId = this.captureRequestId++;
433
+ const promise = new Promise<SonareEngineCaptureResponseMessage>((resolve, reject) => {
434
+ this.captureRequests.set(requestId, { resolve, reject });
435
+ });
436
+ this.node.port.postMessage({ type: 'captureRequest', requestId, op });
437
+ return promise;
438
+ }
439
+
440
+ private sendTransportRequest(): Promise<SonareEngineTransportResponseMessage> {
441
+ if (this.destroyed) {
442
+ return Promise.reject(new Error('Realtime engine node is destroyed.'));
443
+ }
444
+ const requestId = this.transportRequestId++;
445
+ const promise = new Promise<SonareEngineTransportResponseMessage>((resolve, reject) => {
446
+ this.transportRequests.set(requestId, { resolve, reject });
447
+ });
448
+ this.node.port.postMessage({ type: 'transportRequest', requestId, op: 'state' });
449
+ return promise;
450
+ }
451
+ }
@@ -0,0 +1,162 @@
1
+ import type { EngineCaptureStatus, EngineMarker, EngineTrackLane, RealtimeEngine } from '../index';
2
+ import type { SonareEngineSyncCaptureMessage, SonareEngineTransportFacade } from './messages';
3
+
4
+ /** Capture configuration options accepted by the engine's `configureCapture`. */
5
+ export interface CaptureOptions {
6
+ bufferFrames: number;
7
+ channels?: number;
8
+ source?: EngineCaptureStatus['source'];
9
+ recordOffsetSamples?: number;
10
+ inputMonitor?: { enabled: boolean; gain?: number };
11
+ }
12
+
13
+ /**
14
+ * Normalizes capture options into the resolved config carried by the
15
+ * `syncCapture` message, applying integer truncation and defaults.
16
+ *
17
+ * @param options Raw capture options.
18
+ * @param defaultChannels Channel count to use when `options.channels` is unset.
19
+ */
20
+ export function buildCaptureConfig(
21
+ options: CaptureOptions,
22
+ defaultChannels: number,
23
+ ): Omit<SonareEngineSyncCaptureMessage, 'type'> {
24
+ return {
25
+ bufferFrames: Math.trunc(options.bufferFrames),
26
+ channels: Math.trunc(options.channels ?? defaultChannels),
27
+ source: options.source ?? 'output',
28
+ recordOffsetSamples: Math.trunc(options.recordOffsetSamples ?? 0),
29
+ inputMonitor: {
30
+ enabled: Boolean(options.inputMonitor?.enabled),
31
+ gain: options.inputMonitor?.gain ?? 1,
32
+ },
33
+ };
34
+ }
35
+
36
+ /**
37
+ * Collaborator surface the transport facade needs from the owning engine: the
38
+ * realtime node (sample-accurate command transport), the offline engine it
39
+ * mirrors, and the tempo/loop setters plus playing-state bookkeeping.
40
+ */
41
+ export interface EngineTransportContext {
42
+ readonly sampleRate: number;
43
+ realtimeNode: {
44
+ play(sampleTime?: number): boolean;
45
+ stop(sampleTime?: number): boolean;
46
+ seekPpq(ppq: number, sampleTime?: number): boolean;
47
+ seekSample(timelineSample: number, sampleTime?: number): boolean;
48
+ };
49
+ offlineEngine: RealtimeEngine;
50
+ setTransportPlaying(playing: boolean): void;
51
+ flushPendingInstrumentSync(): void;
52
+ setTempo(bpm: number): void;
53
+ setTempoSegments(segments: readonly { startPpq: number; bpm: number }[]): void;
54
+ setLoop(startPpq: number, endPpq: number, enabled?: boolean): boolean;
55
+ }
56
+
57
+ /** Builds the public transport facade that fans control to both engines. */
58
+ export function buildTransportFacade(ctx: EngineTransportContext): SonareEngineTransportFacade {
59
+ return {
60
+ play: (sampleTime = -1) => {
61
+ const ok = ctx.realtimeNode.play(sampleTime);
62
+ if (ok) {
63
+ ctx.setTransportPlaying(true);
64
+ }
65
+ return ok;
66
+ },
67
+ stop: (sampleTime = -1) => {
68
+ const ok = ctx.realtimeNode.stop(sampleTime);
69
+ if (ok) {
70
+ ctx.setTransportPlaying(false);
71
+ ctx.flushPendingInstrumentSync();
72
+ }
73
+ return ok;
74
+ },
75
+ seekPpq: (ppq, sampleTime = -1) => {
76
+ ctx.offlineEngine.seekPpq(ppq, sampleTime);
77
+ return ctx.realtimeNode.seekPpq(ppq, sampleTime);
78
+ },
79
+ seekSeconds: (seconds, sampleTime = -1) => {
80
+ const timelineSample = Math.max(0, Math.round(seconds * ctx.sampleRate));
81
+ ctx.offlineEngine.seekSample(timelineSample, sampleTime);
82
+ return ctx.realtimeNode.seekSample(timelineSample, sampleTime);
83
+ },
84
+ setTempo: (bpm) => ctx.setTempo(bpm),
85
+ setTempoSegments: (segments) => ctx.setTempoSegments(segments),
86
+ setLoop: (startPpq, endPpq, enabled = true) => ctx.setLoop(startPpq, endPpq, enabled),
87
+ };
88
+ }
89
+
90
+ /**
91
+ * Validates and normalizes an append-only mixer-lane declaration.
92
+ *
93
+ * Lane indices are append-only: the new list must start with the already
94
+ * declared lane ids in their current order and may only append new track ids.
95
+ * Returns the normalized lane entries (numbers coerced to descriptors) and the
96
+ * resulting ordered id list.
97
+ *
98
+ * @throws if any track id is invalid, ids are duplicated, or the existing lane
99
+ * order is not preserved.
100
+ */
101
+ export function normalizeTrackLanes(
102
+ existing: readonly number[],
103
+ lanes: ReadonlyArray<number | EngineTrackLane>,
104
+ ): { entries: EngineTrackLane[]; ids: number[] } {
105
+ const entries = lanes.map((lane) => (typeof lane === 'number' ? { trackId: lane } : lane));
106
+ const ids: number[] = [];
107
+ for (const entry of entries) {
108
+ if (!Number.isInteger(entry.trackId) || entry.trackId <= 0) {
109
+ throw new Error(`Invalid track id for mixer lane: ${String(entry.trackId)}`);
110
+ }
111
+ ids.push(entry.trackId);
112
+ }
113
+ if (new Set(ids).size !== ids.length) {
114
+ throw new Error('Duplicate track id in mixer lane list');
115
+ }
116
+ for (let index = 0; index < existing.length; index++) {
117
+ if (ids[index] !== existing[index]) {
118
+ throw new Error(
119
+ 'Mixer lanes are append-only: keep existing lanes in order and only append new track ids',
120
+ );
121
+ }
122
+ }
123
+ return { entries, ids };
124
+ }
125
+
126
+ /**
127
+ * Resolves a marker set, assigning fresh ids to entries without one and
128
+ * validating explicit ids (positive, unique).
129
+ *
130
+ * @param markers The marker list to resolve.
131
+ * @param nextMarkerId The id counter to draw fresh ids from.
132
+ * @returns The resolved markers and the advanced id counter.
133
+ * @throws on a non-finite ppq, an invalid id, or a duplicate id.
134
+ */
135
+ export function resolveMarkerSet(
136
+ markers: ReadonlyArray<{ ppq: number; name?: string; id?: number }>,
137
+ nextMarkerId: number,
138
+ ): { resolved: EngineMarker[]; nextMarkerId: number } {
139
+ const resolved: EngineMarker[] = [];
140
+ const seen = new Set<number>();
141
+ let counter = nextMarkerId;
142
+ for (const marker of markers) {
143
+ if (!Number.isFinite(marker.ppq)) {
144
+ throw new Error(`Invalid marker ppq: ${String(marker.ppq)}`);
145
+ }
146
+ if (marker.id !== undefined) {
147
+ if (!Number.isInteger(marker.id) || marker.id <= 0) {
148
+ throw new Error(`Invalid marker id: ${String(marker.id)}`);
149
+ }
150
+ if (seen.has(marker.id)) {
151
+ throw new Error(`Duplicate marker id: ${marker.id}`);
152
+ }
153
+ }
154
+ const id = marker.id ?? counter++;
155
+ seen.add(id);
156
+ if (id >= counter) {
157
+ counter = id + 1;
158
+ }
159
+ resolved.push({ id, ppq: marker.ppq, name: marker.name ?? '' });
160
+ }
161
+ return { resolved, nextMarkerId: counter };
162
+ }
@@ -0,0 +1,13 @@
1
+ import type { RealtimeEngine } from '../index';
2
+ import type { SonareRealtimeEngineNodeOptions } from './messages';
3
+
4
+ export interface SonareEngineOptions extends SonareRealtimeEngineNodeOptions {
5
+ offlineEngine?: RealtimeEngine;
6
+ offlineBlockSize?: number;
7
+ offlineChannelCount?: number;
8
+ }
9
+
10
+ export type SuspendableAudioContext = BaseAudioContext & {
11
+ suspend?: () => Promise<void>;
12
+ resume?: () => Promise<void>;
13
+ };