@libraz/libsonare 1.7.1 → 1.7.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@libraz/libsonare",
3
- "version": "1.7.1",
3
+ "version": "1.7.2",
4
4
  "type": "module",
5
5
  "packageManager": "yarn@4.18.0",
6
6
  "description": "Audio analysis, mastering, mixing, and MIDI synthesis in WebAssembly",
package/src/index.ts CHANGED
@@ -438,6 +438,9 @@ export type {
438
438
  ProjectMarker,
439
439
  ProjectMidiClipResult,
440
440
  ProjectMidiEvent,
441
+ ProjectMidiFxBakeRequest,
442
+ ProjectMidiFxBakeResult,
443
+ ProjectMidiFxPreviewRequest,
441
444
  ProjectNotePairValidation,
442
445
  ProjectSource,
443
446
  ProjectTrack,
package/src/project.ts CHANGED
@@ -40,6 +40,9 @@ export type {
40
40
  ProjectMidiCcBindingKind,
41
41
  ProjectMidiClipResult,
42
42
  ProjectMidiEvent,
43
+ ProjectMidiFxBakeRequest,
44
+ ProjectMidiFxBakeResult,
45
+ ProjectMidiFxPreviewRequest,
43
46
  ProjectMidiRouteConfig,
44
47
  ProjectMidiRouteResult,
45
48
  ProjectNotePairValidation,
@@ -35,6 +35,9 @@ import type {
35
35
  ProjectMidiCcBinding,
36
36
  ProjectMidiClipResult,
37
37
  ProjectMidiEvent,
38
+ ProjectMidiFxBakeRequest,
39
+ ProjectMidiFxBakeResult,
40
+ ProjectMidiFxPreviewRequest,
38
41
  ProjectMidiRouteConfig,
39
42
  ProjectMidiRouteResult,
40
43
  ProjectNotePairValidation,
@@ -52,6 +55,28 @@ import type {
52
55
  SynthPatch,
53
56
  } from './project_types';
54
57
 
58
+ /**
59
+ * Folds the positional and request call forms of `bakeMidiFx` into one shape,
60
+ * so defaults, validation and errors cannot diverge between them.
61
+ */
62
+ function normalizeMidiFxBakeRequest(
63
+ clipIdOrRequest: number | ProjectMidiFxBakeRequest,
64
+ configJson?: string,
65
+ ): Required<ProjectMidiFxBakeRequest> {
66
+ if (typeof clipIdOrRequest === 'number') {
67
+ return {
68
+ clipId: clipIdOrRequest,
69
+ configJson: configJson ?? '',
70
+ withSourceIndex: false,
71
+ };
72
+ }
73
+ return {
74
+ clipId: clipIdOrRequest.clipId,
75
+ configJson: clipIdOrRequest.configJson,
76
+ withSourceIndex: clipIdOrRequest.withSourceIndex ?? false,
77
+ };
78
+ }
79
+
55
80
  function validateAssistSidecarUint32(value: unknown, field: string): number {
56
81
  if (
57
82
  typeof value !== 'number' ||
@@ -584,8 +609,33 @@ export class Project {
584
609
  * Destructively bake a MIDI-FX chain into all stored events. Large clips are
585
610
  * drained without truncation; failure leaves the original clip unchanged.
586
611
  */
587
- bakeMidiFx(clipId: number, configJson: string): void {
588
- this.native.bakeMidiFx(clipId, configJson);
612
+ bakeMidiFx(clipId: number, configJson: string): void;
613
+ /**
614
+ * Request form. Setting `withSourceIndex` also returns per-event provenance,
615
+ * so a selection or an editorial annotation can be carried across the bake.
616
+ */
617
+ bakeMidiFx(request: ProjectMidiFxBakeRequest): ProjectMidiFxBakeResult;
618
+ bakeMidiFx(
619
+ clipIdOrRequest: number | ProjectMidiFxBakeRequest,
620
+ configJson?: string,
621
+ ): ProjectMidiFxBakeResult {
622
+ const request = normalizeMidiFxBakeRequest(clipIdOrRequest, configJson);
623
+ if (!request.withSourceIndex) {
624
+ this.native.bakeMidiFx(request.clipId, request.configJson);
625
+ return {};
626
+ }
627
+ return {
628
+ sourceIndex: this.native.bakeMidiFxWithSourceIndex(request.clipId, request.configJson),
629
+ };
630
+ }
631
+
632
+ /**
633
+ * Count the events {@link bakeMidiFx} would produce for this clip and
634
+ * configuration, without mutating the project. The transform is
635
+ * deterministic, so the count matches what the bake goes on to produce.
636
+ */
637
+ previewMidiFxCount(request: ProjectMidiFxPreviewRequest): number {
638
+ return this.native.previewMidiFxCount(request.clipId, request.configJson);
589
639
  }
590
640
 
591
641
  /** Backward alias for {@link bakeMidiFx}. */
@@ -85,6 +85,8 @@ export interface WasmProject {
85
85
  bank: number,
86
86
  ) => void;
87
87
  bakeMidiFx: (clipId: number, configJson: string) => void;
88
+ bakeMidiFxWithSourceIndex: (clipId: number, configJson: string) => Int32Array;
89
+ previewMidiFxCount: (clipId: number, configJson: string) => number;
88
90
  setMidiFx: (clipId: number, configJson: string) => void;
89
91
  validateMidiNotes: (clipId: number) => ProjectNotePairValidation;
90
92
  analyzeTempo: (audio: Float32Array, sampleRate: number) => ProjectTempoCandidate[];
@@ -665,6 +665,40 @@ export interface ProjectNotePairValidation {
665
665
  unmatchedNoteOffs: number;
666
666
  }
667
667
 
668
+ /**
669
+ * Request form of {@link Project.bakeMidiFx}. The positional
670
+ * `(clipId, configJson)` call stays supported and normalizes to this shape.
671
+ */
672
+ export interface ProjectMidiFxBakeRequest {
673
+ /** Target MIDI clip id. */
674
+ clipId: number;
675
+ /** MIDI-FX chain configuration as JSON. */
676
+ configJson: string;
677
+ /** Return per-event provenance in the result. Default false. */
678
+ withSourceIndex?: boolean;
679
+ }
680
+
681
+ /** Result of the request form of {@link Project.bakeMidiFx}. */
682
+ export interface ProjectMidiFxBakeResult {
683
+ /**
684
+ * One entry per transformed event in canonical order: the index of the input
685
+ * event it derives from, or -1 for an event with no originating input. Chord
686
+ * and arpeggiator fan-out makes several outputs share one source index, so a
687
+ * caller that treats the first output per index as the same event and the
688
+ * rest as newly generated can carry a selection across the bake. Present only
689
+ * when the request set `withSourceIndex`.
690
+ */
691
+ sourceIndex?: Int32Array;
692
+ }
693
+
694
+ /** Request form of {@link Project.previewMidiFxCount}. */
695
+ export interface ProjectMidiFxPreviewRequest {
696
+ /** Target MIDI clip id. */
697
+ clipId: number;
698
+ /** MIDI-FX chain configuration as JSON. */
699
+ configJson: string;
700
+ }
701
+
668
702
  /** One compile diagnostic (mirrors SonareProjectDiagnostic). */
669
703
  export interface ProjectDiagnostic {
670
704
  code: number;
@@ -823,6 +823,15 @@ export class RealtimeEngine {
823
823
  * Returns `-1` when the track, insert, or name is unknown. (The Python binding
824
824
  * raises a `SonareError` for an unknown id where Node/WASM return the `-1`
825
825
  * sentinel.)
826
+ *
827
+ * This trio is how a mastering processor gets time-varying automation: the
828
+ * `eq.*`, `dynamics.*`, `saturation.*`, `spectral.*`, `stereo.*`,
829
+ * `maximizer.*` and `multiband.*` processors are all available as strip
830
+ * inserts, so placing one on a strip and resolving its parameter here drives
831
+ * it at audio-block precision, live and offline alike. The whole-signal
832
+ * stages of the offline mastering chain (`repair.*`, `loudness`, and the
833
+ * match stages) have no insert form and no automation id: they buffer the
834
+ * entire signal by construction and do not run on the realtime path.
826
835
  */
827
836
  resolveTrackInsertAutomationId(trackId: number, insertIndex: number, paramName: string): number {
828
837
  return this.native.resolveTrackInsertAutomationId(trackId, insertIndex, paramName);
@@ -984,6 +993,36 @@ export class RealtimeEngine {
984
993
  this.native.processPrepared(numFrames);
985
994
  }
986
995
 
996
+ /**
997
+ * Allocates the cue-bus counterpart of {@link prepareChannels}. Needed only
998
+ * when PFL/AFL monitoring must reach a separate output: `processPrepared`
999
+ * folds the cue bus into the program output, while
1000
+ * {@link processPreparedWithMonitor} keeps the two apart. Call once, off the
1001
+ * audio thread, with at least as many channels as `prepareChannels` got.
1002
+ */
1003
+ prepareMonitorChannels(numChannels: number, maxFrames: number): void {
1004
+ this.native.prepareMonitorChannels(numChannels, maxFrames);
1005
+ }
1006
+
1007
+ /**
1008
+ * Returns a Float32Array view onto the persistent cue-bus scratch for one
1009
+ * channel (valid for up to `numFrames`). Read it after
1010
+ * {@link processPreparedWithMonitor}. Re-acquire after WASM memory growth.
1011
+ */
1012
+ getMonitorChannelBuffer(channel: number, numFrames: number): Float32Array {
1013
+ return this.native.getMonitorChannelBuffer(channel, numFrames);
1014
+ }
1015
+
1016
+ /**
1017
+ * Runs the engine in place over the prepared scratch, writing the cue bus to
1018
+ * the monitor scratch instead of folding it into the program output.
1019
+ * Allocation-free: safe on the AudioWorklet render thread after
1020
+ * `prepareChannels` and `prepareMonitorChannels`.
1021
+ */
1022
+ processPreparedWithMonitor(numFrames: number): void {
1023
+ this.native.processPreparedWithMonitor(numFrames);
1024
+ }
1025
+
987
1026
  processWithMonitor(channels: Float32Array[]): WasmEngineProcessWithMonitorResult {
988
1027
  return this.native.processWithMonitor(channels);
989
1028
  }
@@ -1114,6 +1114,9 @@ export interface WasmRealtimeEngine {
1114
1114
  prepareChannels: (numChannels: number, maxFrames: number) => void;
1115
1115
  getChannelBuffer: (channel: number, numFrames: number) => Float32Array;
1116
1116
  processPrepared: (numFrames: number) => void;
1117
+ prepareMonitorChannels: (numChannels: number, maxFrames: number) => void;
1118
+ getMonitorChannelBuffer: (channel: number, numFrames: number) => Float32Array;
1119
+ processPreparedWithMonitor: (numFrames: number) => void;
1117
1120
  processWithMonitor: (channels: Float32Array[]) => WasmEngineProcessWithMonitorResult;
1118
1121
  renderOffline: (channels: Float32Array[], blockSize: number) => Float32Array[];
1119
1122
  bounceOffline: (options: WasmEngineBounceOptions) => WasmEngineBounceResult;
@@ -49,6 +49,24 @@ const EQ_PHASE_MODES: Record<string, number> = {
49
49
  * Call {@link delete} (or use a `try/finally`) to release the underlying WASM
50
50
  * object — the embind handle is not garbage-collected automatically.
51
51
  *
52
+ * Reachable from the AudioWorklet realm through the `sonare/worklet` entry, but
53
+ * the realtime contract is the caller's to keep:
54
+ *
55
+ * - {@link prepare} builds the processors and allocates. Call it once from a
56
+ * message handler, never from `AudioWorkletProcessor.process()`.
57
+ * - {@link processMono}/{@link processStereo} return fresh arrays. On the render
58
+ * thread, reuse the returned reference for the block rather than retaining it.
59
+ * - An enabled `loudness` stage needs `loudnessStaticGainDb` measured offline,
60
+ * because whole-signal integrated LUFS cannot be measured block by block. Pass
61
+ * `loudnessStaticGainPeakDb` too and the static gain is clamped exactly as the
62
+ * offline chain clamps it, so the live preview matches the render.
63
+ * - {@link flush} output starts {@link latencySamples} samples early; discard
64
+ * that many leading samples when time alignment matters.
65
+ *
66
+ * The chain is a host-side stage, not an engine insert: it does not participate
67
+ * in the engine's PDC or bypass, so latency compensation against other engine
68
+ * outputs is also the caller's.
69
+ *
52
70
  * @example
53
71
  * ```typescript
54
72
  * const chain = new StreamingMasteringChain({ eq: { tiltDb: 1.0 } });
@@ -301,10 +301,12 @@ export class SonareRealtimeEngineNode {
301
301
  ? createSonareExternalMidiRingBuffer(options.externalMidiRingCapacity ?? 256)
302
302
  : undefined;
303
303
  const channelCount = Math.max(1, Math.floor(options.channelCount ?? 2));
304
+ const cueOutput = options.cueOutput === true;
304
305
  const processorOptions: SonareRealtimeEngineWorkletProcessorOptions = {
305
306
  sampleRate: options.sampleRate ?? context.sampleRate,
306
307
  blockSize,
307
308
  channelCount,
309
+ cueOutput,
308
310
  commandSharedBuffer: commandRing?.sharedBuffer,
309
311
  commandRingCapacity: commandRing?.capacity,
310
312
  telemetrySharedBuffer: telemetryRing?.sharedBuffer,
@@ -329,8 +331,10 @@ export class SonareRealtimeEngineNode {
329
331
  new AudioWorkletNode(ctx, name, nodeOptions));
330
332
  const node = factory(context, processorName, {
331
333
  numberOfInputs: 1,
332
- numberOfOutputs: 1,
333
- outputChannelCount: [channelCount],
334
+ // The cue bus needs its own output; a single-output node keeps the
335
+ // historical mix where process() folds the cue into the program.
336
+ numberOfOutputs: cueOutput ? 2 : 1,
337
+ outputChannelCount: cueOutput ? [channelCount, channelCount] : [channelCount],
334
338
  processorOptions,
335
339
  });
336
340
  return new SonareRealtimeEngineNode(
@@ -343,6 +347,7 @@ export class SonareRealtimeEngineNode {
343
347
  audioWorklet,
344
348
  clipPageRequestsRealtimeSafe: mode === 'sab',
345
349
  externalMidiRealtimeSafe: mode === 'sab',
350
+ cueOutput,
346
351
  engineAbiVersion: detectedCapabilities?.engineAbiVersion,
347
352
  expectedEngineAbiVersion: detectedCapabilities?.expectedEngineAbiVersion,
348
353
  abiCompatible: detectedCapabilities?.abiCompatible,
@@ -46,6 +46,30 @@ import {
46
46
  writeSonareEngineTelemetryRingBuffer,
47
47
  } from './protocol';
48
48
 
49
+ /**
50
+ * Copies one plane per output channel, zero-filling the tail past `frames` and
51
+ * any channel the source does not cover. Shared by the program and cue outputs
52
+ * so the two cannot drift in their padding behaviour. Allocation-free.
53
+ */
54
+ function copyPlanesToOutput(
55
+ output: Float32Array[],
56
+ planes: readonly Float32Array[],
57
+ frames: number,
58
+ ): void {
59
+ for (let ch = 0; ch < output.length; ch++) {
60
+ const target = output[ch];
61
+ const source = planes[ch] ?? planes[0];
62
+ if (source) {
63
+ target.set(source.subarray(0, Math.min(target.length, frames)));
64
+ if (target.length > frames) {
65
+ target.fill(0, frames);
66
+ }
67
+ } else {
68
+ target.fill(0);
69
+ }
70
+ }
71
+ }
72
+
49
73
  function captureTransferList(channels: readonly Float32Array[]): Transferable[] {
50
74
  const transfers: ArrayBuffer[] = [];
51
75
  const seen = new Set<ArrayBuffer>();
@@ -94,6 +118,11 @@ export class SonareRealtimeEngineWorkletProcessor {
94
118
  // allocated per render quantum (the old engine.process() round-tripped fresh
95
119
  // arrays on both heaps every block, an RT-safety hazard).
96
120
  private channelBuffers: Float32Array[];
121
+ // Cue-bus plane, allocated only when the host asked for a separate PFL/AFL
122
+ // output. Empty otherwise, so a single-output host pays no heap and keeps the
123
+ // historical behaviour where process() folds the cue into the program mix.
124
+ private monitorBuffers: Float32Array[] = [];
125
+ private readonly cueOutput: boolean;
97
126
  private readonly liveClips = new Map<number, EngineClip>();
98
127
  private readonly pagedClipProviders = new Map<number, number>();
99
128
  private readonly pagedClipPageFrames = new Map<number, number>();
@@ -160,6 +189,14 @@ export class SonareRealtimeEngineWorkletProcessor {
160
189
  for (let ch = 0; ch < this.channelCount; ch++) {
161
190
  this.channelBuffers[ch] = this.engine.getChannelBuffer(ch, this.blockSize);
162
191
  }
192
+ this.cueOutput = options.cueOutput === true;
193
+ if (this.cueOutput) {
194
+ this.engine.prepareMonitorChannels(this.channelCount, this.blockSize);
195
+ this.monitorBuffers = new Array(this.channelCount);
196
+ for (let ch = 0; ch < this.channelCount; ch++) {
197
+ this.monitorBuffers[ch] = this.engine.getMonitorChannelBuffer(ch, this.blockSize);
198
+ }
199
+ }
163
200
  // Arm the engine's scope producer only when a scope ring was provided. The
164
201
  // band count follows the ring's record layout so writeScopeRing never
165
202
  // overruns its slot.
@@ -213,6 +250,9 @@ export class SonareRealtimeEngineWorkletProcessor {
213
250
  if ((this.channelBuffers[0]?.byteLength ?? 0) === 0) {
214
251
  this.reacquireChannelBuffers();
215
252
  }
253
+ if (this.cueOutput && (this.monitorBuffers[0]?.byteLength ?? 0) === 0) {
254
+ this.reacquireMonitorBuffers();
255
+ }
216
256
 
217
257
  const input = inputs[0];
218
258
  // Write the AudioWorklet input straight into the engine's WASM-heap views;
@@ -227,19 +267,20 @@ export class SonareRealtimeEngineWorkletProcessor {
227
267
  }
228
268
  }
229
269
 
230
- // Run the engine in place over the prepared scratch (allocation-free).
231
- this.engine.processPrepared(usableFrames);
270
+ // Run the engine in place over the prepared scratch (allocation-free). The
271
+ // monitor variant keeps the cue bus out of the program planes so it can go
272
+ // to its own output; the plain call folds it in, as it always has.
273
+ if (this.cueOutput) {
274
+ this.engine.processPreparedWithMonitor(usableFrames);
275
+ } else {
276
+ this.engine.processPrepared(usableFrames);
277
+ }
232
278
 
233
- for (let ch = 0; ch < output.length; ch++) {
234
- const target = output[ch];
235
- const source = this.channelBuffers[ch] ?? this.channelBuffers[0];
236
- if (source) {
237
- target.set(source.subarray(0, Math.min(target.length, usableFrames)));
238
- if (target.length > usableFrames) {
239
- target.fill(0, usableFrames);
240
- }
241
- } else {
242
- target.fill(0);
279
+ copyPlanesToOutput(output, this.channelBuffers, usableFrames);
280
+ if (this.cueOutput) {
281
+ const cue = outputs[1];
282
+ if (cue) {
283
+ copyPlanesToOutput(cue, this.monitorBuffers, usableFrames);
243
284
  }
244
285
  }
245
286
  this.publishClipPageRequests();
@@ -256,6 +297,12 @@ export class SonareRealtimeEngineWorkletProcessor {
256
297
  }
257
298
  }
258
299
 
300
+ private reacquireMonitorBuffers(): void {
301
+ for (let ch = 0; ch < this.channelCount; ch++) {
302
+ this.monitorBuffers[ch] = this.engine.getMonitorChannelBuffer(ch, this.blockSize);
303
+ }
304
+ }
305
+
259
306
  receiveCommand(command: SonareEngineCommandRecord): void {
260
307
  if (!this.closed) {
261
308
  this.safeApplyCommand(command);
@@ -67,6 +67,13 @@ export interface SonareRealtimeEngineWorkletProcessorOptions {
67
67
  /** Lock-free worklet-to-main-thread MIDI-1 output ring. */
68
68
  externalMidiSharedBuffer?: SharedArrayBuffer;
69
69
  externalMidiRingCapacity?: number;
70
+ /**
71
+ * Route the PFL/AFL cue bus to the processor's SECOND output instead of
72
+ * folding it into the program output. Off by default, so an existing
73
+ * single-output host keeps its current mix sample for sample. The node must
74
+ * be constructed with two outputs for the cue to be audible.
75
+ */
76
+ cueOutput?: boolean;
70
77
  }
71
78
 
72
79
  export interface SonareRealtimeVoiceChangerWorkletProcessorOptions {
@@ -105,6 +112,11 @@ export interface SonareRealtimeEngineNodeCapabilities {
105
112
  clipPageRequestsRealtimeSafe: boolean;
106
113
  /** True when external MIDI uses the SAB output ring rather than postMessage. */
107
114
  externalMidiRealtimeSafe: boolean;
115
+ /**
116
+ * True when the node carries a second output fed by the PFL/AFL cue bus. When
117
+ * false the cue is folded into the program output, as it always was.
118
+ */
119
+ cueOutput: boolean;
108
120
  engineAbiVersion?: number;
109
121
  expectedEngineAbiVersion?: number;
110
122
  abiCompatible?: boolean;
package/src/worklet.ts CHANGED
@@ -10,6 +10,11 @@ export { attachOpfsClipStream } from './clip_page_streamer';
10
10
  // module singleton. Re-export the lifecycle so that realm can initialize its
11
11
  // own wasm instance, independent of the main-thread `index` module.
12
12
  export { init, isInitialized } from './index';
13
+ // Host-side mastering preview inside the worklet realm. Kept here rather than
14
+ // left main-thread-only so a live preview does not have to round-trip audio to
15
+ // the main thread; see the class doc for the prepare/loudness/latency contract.
16
+ export type { StreamingMasteringChainConfig } from './public_types';
17
+ export { StreamingMasteringChain } from './streaming_processors';
13
18
  export { SonareEngine } from './worklet/engine';
14
19
  export { SonareRealtimeEngineNode } from './worklet/engine-node';
15
20
  export type { SonareEngineOptions } from './worklet/engine-options';