@libraz/libsonare 1.5.2 → 1.5.3

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 (46) hide show
  1. package/README.md +18 -3
  2. package/dist/index.d.ts +1147 -181
  3. package/dist/index.js +4826 -3941
  4. package/dist/index.js.map +1 -1
  5. package/dist/sonare.js +1 -1
  6. package/dist/sonare.wasm +0 -0
  7. package/dist/worklet.d.ts +29 -1
  8. package/dist/worklet.js +1067 -973
  9. package/dist/worklet.js.map +1 -1
  10. package/package.json +2 -1
  11. package/src/_chain_config.ts +46 -0
  12. package/src/audio.ts +24 -4
  13. package/src/effects_mastering.ts +25 -1
  14. package/src/effects_transform.ts +283 -43
  15. package/src/effects_voice_change.ts +46 -53
  16. package/src/feature_core.ts +316 -12
  17. package/src/feature_music.ts +357 -0
  18. package/src/feature_pitch.ts +61 -0
  19. package/src/feature_resample.ts +17 -2
  20. package/src/feature_spectral.ts +349 -2
  21. package/src/feature_spectrogram.ts +336 -0
  22. package/src/index.ts +130 -1
  23. package/src/mastering_chain.ts +305 -50
  24. package/src/mastering_core.ts +232 -16
  25. package/src/mastering_dynamics.ts +66 -10
  26. package/src/mastering_repair.ts +119 -7
  27. package/src/metering.ts +366 -81
  28. package/src/mixer.ts +9 -2
  29. package/src/mixing_oneshot.ts +25 -5
  30. package/src/module_state.ts +1 -1
  31. package/src/project_class.ts +17 -6
  32. package/src/project_internal.ts +10 -2
  33. package/src/project_types.ts +25 -2
  34. package/src/public_types_mastering.ts +114 -4
  35. package/src/public_types_spectral.ts +7 -0
  36. package/src/quick_analysis.ts +325 -117
  37. package/src/realtime_engine.ts +8 -0
  38. package/src/sonare.js.d.ts +31 -0
  39. package/src/stream_analyzer.ts +20 -1
  40. package/src/stream_types.ts +12 -1
  41. package/src/validation.ts +17 -2
  42. package/src/worklet/engine-clips.ts +67 -2
  43. package/src/worklet/engine-processor.ts +33 -5
  44. package/src/worklet/engine.ts +7 -3
  45. package/src/worklet/guards.ts +3 -0
  46. package/src/worklet/messages.ts +27 -0
@@ -587,6 +587,14 @@ export class RealtimeEngine {
587
587
  );
588
588
  }
589
589
 
590
+ /**
591
+ * Returns the PCM generated for a tempo-sync clip by the control-thread
592
+ * setter, or `null` when the clip did not require a tempo-sync bake.
593
+ */
594
+ prebakedClipChannels(clipId: number): Float32Array[] | null {
595
+ return this.native.prebakedClipChannels(clipId);
596
+ }
597
+
590
598
  clipCount(): number {
591
599
  return this.native.clipCount();
592
600
  }
@@ -470,8 +470,16 @@ export interface WasmMasteringResult {
470
470
  latencySamples?: number;
471
471
  }
472
472
 
473
+ export interface WasmStageGainReduction {
474
+ stage: string;
475
+ gainReductionDb: number;
476
+ }
477
+
473
478
  export interface WasmMasteringChainResult extends WasmMasteringResult {
474
479
  stages: string[];
480
+ outputTruePeakDbtp: number;
481
+ outputLra: number;
482
+ stageGainReductions: WasmStageGainReduction[];
475
483
  }
476
484
 
477
485
  export interface WasmMasteringStereoChainResult {
@@ -482,6 +490,9 @@ export interface WasmMasteringStereoChainResult {
482
490
  outputLufs: number;
483
491
  appliedGainDb: number;
484
492
  stages: string[];
493
+ outputTruePeakDbtp: number;
494
+ outputLra: number;
495
+ stageGainReductions: WasmStageGainReduction[];
485
496
  }
486
497
 
487
498
  export interface WasmMasteringStereoResult {
@@ -848,6 +859,7 @@ export interface WasmRealtimeEngine {
848
859
  graphNodeCount: () => number;
849
860
  graphConnectionCount: () => number;
850
861
  setClips: (clips: WasmEngineClip[]) => void;
862
+ prebakedClipChannels: (clipId: number) => Float32Array[] | null;
851
863
  clipCount: () => number;
852
864
  setTrackLanes: (lanes: Array<number | WasmEngineTrackLane>) => void;
853
865
  setLaneSidechain: (trackId: number, insertIndex: number, sourceTrackId: number) => void;
@@ -1412,12 +1424,25 @@ export interface SonareModule {
1412
1424
  offsetSample: number,
1413
1425
  stretchRatio: number,
1414
1426
  ) => Float32Array;
1427
+ noteMove: (
1428
+ samples: Float32Array,
1429
+ sampleRate: number,
1430
+ onsetSample: number,
1431
+ offsetSample: number,
1432
+ targetOnsetSample: number,
1433
+ ) => Float32Array;
1415
1434
  voiceChange: (
1416
1435
  samples: Float32Array,
1417
1436
  sampleRate: number,
1418
1437
  pitchSemitones: number,
1419
1438
  formantFactor: number,
1420
1439
  ) => Float32Array;
1440
+ voiceChangeRealtime: (
1441
+ samples: Float32Array,
1442
+ sampleRate: number,
1443
+ preset: string,
1444
+ channels: number,
1445
+ ) => Float32Array;
1421
1446
  normalize: (samples: Float32Array, sampleRate: number, targetDb: number) => Float32Array;
1422
1447
  mastering: (
1423
1448
  samples: Float32Array,
@@ -2336,6 +2361,8 @@ export interface WasmFrameBuffer {
2336
2361
  nFrames: number;
2337
2362
  /** Number of mel bands; flat `mel` is `[nFrames * nMels]` row-major. */
2338
2363
  nMels: number;
2364
+ nChroma: number;
2365
+ featureFlags: number;
2339
2366
  timestamps: Float32Array;
2340
2367
  mel: Float32Array;
2341
2368
  chroma: Float32Array;
@@ -2351,6 +2378,8 @@ export interface WasmFrameBuffer {
2351
2378
  export interface WasmStreamFramesU8 {
2352
2379
  nFrames: number;
2353
2380
  nMels: number;
2381
+ nChroma: number;
2382
+ featureFlags: number;
2354
2383
  timestamps: Float32Array;
2355
2384
  mel: Uint8Array;
2356
2385
  chroma: Uint8Array;
@@ -2363,6 +2392,8 @@ export interface WasmStreamFramesU8 {
2363
2392
  export interface WasmStreamFramesI16 {
2364
2393
  nFrames: number;
2365
2394
  nMels: number;
2395
+ nChroma: number;
2396
+ featureFlags: number;
2366
2397
  timestamps: Float32Array;
2367
2398
  mel: Int16Array;
2368
2399
  chroma: Int16Array;
@@ -39,6 +39,16 @@ export function streamAnalyzerConfigDefaults(): StreamConfigDefaults {
39
39
  * console.log('Key:', stats.estimate.key);
40
40
  * console.log('Chord progression:', stats.estimate.chordProgression);
41
41
  * ```
42
+ *
43
+ * The native analyzer supports one serialized producer (`process`,
44
+ * `processWithOffset`, or `finalize`) concurrently with one serialized consumer
45
+ * (`availableFrames`, a `readFrames*` method, `stats`, `frameCount`, or
46
+ * `currentTime`) when a threaded WASM host shares the native instance.
47
+ * Publication is allocation-free release/acquire. `reset`, setters, and
48
+ * deletion require both roles to be stopped. A full pending ring drops the
49
+ * newly produced output frame while analysis totals keep advancing. Ordinary
50
+ * browser builds remain single-threaded unless the host explicitly provisions
51
+ * shared-memory worker support.
42
52
  */
43
53
  export class StreamAnalyzer {
44
54
  private analyzer: WasmStreamAnalyzer;
@@ -54,6 +64,15 @@ export class StreamAnalyzer {
54
64
  'computeMagnitude is not supported because magnitude frames are not exposed by StreamAnalyzer read paths.',
55
65
  );
56
66
  }
67
+ if (
68
+ config.outputFormat !== undefined &&
69
+ (typeof config.outputFormat !== 'number' ||
70
+ !Number.isFinite(config.outputFormat) ||
71
+ !Number.isInteger(config.outputFormat) ||
72
+ config.outputFormat !== 0)
73
+ ) {
74
+ throw new TypeError('outputFormat must be the integer 0 (Float32)');
75
+ }
57
76
  const module = getSonareModule();
58
77
  const defaults = streamAnalyzerConfigDefaults();
59
78
  this.analyzer = new module.StreamAnalyzer(
@@ -101,7 +120,7 @@ export class StreamAnalyzer {
101
120
  }
102
121
 
103
122
  /**
104
- * Flush the final partial frame with zero-padding.
123
+ * Drain any high-rate resampler tail, then zero-pad the final partial frame.
105
124
  */
106
125
  finalize(): void {
107
126
  this.analyzer.finalize();
@@ -78,6 +78,10 @@ export interface FrameBuffer {
78
78
  nFrames: number;
79
79
  /** Number of mel bands; flat `mel` is `[nFrames * nMels]` row-major. */
80
80
  nMels: number;
81
+ /** Chroma stride: 12 when enabled, otherwise 0. */
82
+ nChroma: number;
83
+ /** MEL=1, CHROMA=2, ONSET=4, SPECTRAL=8. */
84
+ featureFlags: number;
81
85
  timestamps: Float32Array;
82
86
  /**
83
87
  * Mel spectrogram in LINEAR power (not dB) — the raw per-frame mel energies.
@@ -117,6 +121,9 @@ export interface StreamQuantizeConfig {
117
121
  export interface StreamFramesU8 {
118
122
  nFrames: number;
119
123
  nMels: number;
124
+ nChroma: number;
125
+ /** MEL=1, CHROMA=2, ONSET=4, SPECTRAL=8. */
126
+ featureFlags: number;
120
127
  timestamps: Float32Array;
121
128
  /** Row-major `[nFrames * nMels]` mel in dB, quantized over `[melDbMin, melDbMax]`. */
122
129
  mel: Uint8Array;
@@ -130,6 +137,9 @@ export interface StreamFramesU8 {
130
137
  export interface StreamFramesI16 {
131
138
  nFrames: number;
132
139
  nMels: number;
140
+ nChroma: number;
141
+ /** MEL=1, CHROMA=2, ONSET=4, SPECTRAL=8. */
142
+ featureFlags: number;
133
143
  timestamps: Float32Array;
134
144
  /** Row-major `[nFrames * nMels]` mel in dB, quantized over `[melDbMin, melDbMax]`. */
135
145
  mel: Int16Array;
@@ -163,13 +173,14 @@ export interface StreamConfig {
163
173
  computeSpectral?: boolean;
164
174
  emitEveryNFrames?: number;
165
175
  magnitudeDownsample?: number;
166
- /** Maximum unread frames; overflow drops the oldest frame. */
176
+ /** Maximum unread frames; overflow drops the newly produced frame. */
167
177
  maxPendingFrames?: number;
168
178
  /** Maximum retained chord and bar progression entries; overflow drops oldest. */
169
179
  maxProgressionEntries?: number;
170
180
  keyUpdateIntervalSec?: number;
171
181
  bpmUpdateIntervalSec?: number;
172
182
  window?: number;
183
+ /** @deprecated Must be 0 (Float32). Use readFramesU8/readFramesI16 explicitly. */
173
184
  outputFormat?: number;
174
185
  }
175
186
 
package/src/validation.ts CHANGED
@@ -14,6 +14,15 @@ export interface ValidateOptions {
14
14
  validate?: boolean;
15
15
  }
16
16
 
17
+ /**
18
+ * Offline-analysis sample-rate bounds, mirroring the C++ core limits
19
+ * (`sonare::kMinAudioSampleRate` / `kMaxAudioSampleRate` in `core/audio.h`).
20
+ * Every guarded WASM entry point rejects the same out-of-range rates the C ABI,
21
+ * Node, and Python surfaces do.
22
+ */
23
+ export const MIN_AUDIO_SAMPLE_RATE = 8000;
24
+ export const MAX_AUDIO_SAMPLE_RATE = 384000;
25
+
17
26
  function assertNonEmptySamples(
18
27
  fnName: string,
19
28
  samples: ArrayLike<number>,
@@ -58,8 +67,14 @@ export function assertFiniteScalar(fnName: string, value: number, argName: strin
58
67
  }
59
68
 
60
69
  export function assertSampleRate(fnName: string, sampleRate: number): void {
61
- if (!Number.isInteger(sampleRate) || sampleRate < 8000 || sampleRate > 384000) {
62
- throw new RangeError(`${fnName}: sampleRate out of supported range [8000, 384000]`);
70
+ if (
71
+ !Number.isInteger(sampleRate) ||
72
+ sampleRate < MIN_AUDIO_SAMPLE_RATE ||
73
+ sampleRate > MAX_AUDIO_SAMPLE_RATE
74
+ ) {
75
+ throw new RangeError(
76
+ `${fnName}: sampleRate out of supported range [${MIN_AUDIO_SAMPLE_RATE}, ${MAX_AUDIO_SAMPLE_RATE}]`,
77
+ );
63
78
  }
64
79
  }
65
80
 
@@ -11,11 +11,17 @@ export interface EngineClipContext {
11
11
  readonly clips: Map<number, EngineClip>;
12
12
  readonly midiClips: Map<number, EngineMidiClipSchedule>;
13
13
  allocateClipId(): number;
14
- postSync(message: SonareEngineSyncMessage): void;
14
+ postSync(message: SonareEngineSyncMessage, transfer?: Transferable[]): void;
15
15
  resolveTargetId(target: string | number): number;
16
16
  ensureTrackLane(target: string | number): number;
17
17
  }
18
18
 
19
+ // Keep control-message work bounded even for long tempo-synced clips. The
20
+ // worklet creates a native page provider, receives these transferable PCM
21
+ // chunks, and only schedules the clip after all pages arrive.
22
+ const PREBAKED_CLIP_PAGE_THRESHOLD = 16_384;
23
+ const PREBAKED_CLIP_PAGE_FRAMES = 4_096;
24
+
19
25
  export function addClip(
20
26
  ctx: EngineClipContext,
21
27
  trackId: string | number,
@@ -57,9 +63,68 @@ export function setMidiClips(
57
63
  function syncClipsDelta(ctx: EngineClipContext, upserts: EngineClip[], removeIds: number[]): void {
58
64
  const clips = Array.from(ctx.clips.values());
59
65
  ctx.offlineEngine.setClips(clips);
66
+ const preparedById = new Map<number, EngineClip>();
67
+ for (const clip of clips) {
68
+ if (clip.id === undefined) {
69
+ continue;
70
+ }
71
+ const bakedChannels = ctx.offlineEngine.prebakedClipChannels(clip.id);
72
+ preparedById.set(
73
+ clip.id,
74
+ bakedChannels === null
75
+ ? clip
76
+ : {
77
+ ...clip,
78
+ channels: bakedChannels,
79
+ clipOffsetSamples: 0,
80
+ lengthSamples: bakedChannels[0]?.length ?? 0,
81
+ loop: false,
82
+ warpMode: 'off',
83
+ warpAnchors: undefined,
84
+ },
85
+ );
86
+ }
87
+ const inlineUpserts: EngineClip[] = [];
88
+ for (const clip of upserts) {
89
+ const prepared = clip.id === undefined ? clip : (preparedById.get(clip.id) ?? clip);
90
+ const channels = prepared.channels;
91
+ if (
92
+ prepared.id === undefined ||
93
+ prepared.warpMode !== 'off' ||
94
+ !channels ||
95
+ channels.length === 0 ||
96
+ channels[0].length <= PREBAKED_CLIP_PAGE_THRESHOLD
97
+ ) {
98
+ inlineUpserts.push(prepared);
99
+ continue;
100
+ }
101
+ const numSamples = channels[0].length;
102
+ ctx.postSync({
103
+ type: 'syncClipPageProvider',
104
+ clipId: prepared.id,
105
+ clip: { ...prepared, channels: undefined, pageProvider: undefined },
106
+ numChannels: channels.length,
107
+ numSamples,
108
+ pageFrames: PREBAKED_CLIP_PAGE_FRAMES,
109
+ });
110
+ for (
111
+ let start = 0, pageIndex = 0;
112
+ start < numSamples;
113
+ start += PREBAKED_CLIP_PAGE_FRAMES, pageIndex++
114
+ ) {
115
+ const page = channels.map((channel) =>
116
+ channel.slice(start, start + PREBAKED_CLIP_PAGE_FRAMES),
117
+ );
118
+ ctx.postSync(
119
+ { type: 'syncClipPage', clipId: prepared.id, pageIndex, channels: page },
120
+ page.map((channel) => channel.buffer as Transferable),
121
+ );
122
+ }
123
+ ctx.postSync({ type: 'syncClipPageCommit', clipId: prepared.id });
124
+ }
60
125
  ctx.postSync({
61
126
  type: 'syncClipsDelta',
62
- upserts,
127
+ upserts: inlineUpserts,
63
128
  removeIds,
64
129
  });
65
130
  }
@@ -70,6 +70,8 @@ export class SonareRealtimeEngineWorkletProcessor {
70
70
  // arrays on both heaps every block, an RT-safety hazard).
71
71
  private channelBuffers: Float32Array[];
72
72
  private readonly liveClips = new Map<number, EngineClip>();
73
+ private readonly pagedClipProviders = new Map<number, number>();
74
+ private readonly pendingPagedClips = new Map<number, EngineClip>();
73
75
 
74
76
  constructor(
75
77
  options: SonareRealtimeEngineWorkletProcessorOptions = {},
@@ -208,11 +210,10 @@ export class SonareRealtimeEngineWorkletProcessor {
208
210
  }
209
211
  }
210
212
 
211
- // Applies an out-of-band control-plane sync message. Runs on the AudioWorklet
212
- // global scope but OUTSIDE process() (the message-port callback), so the
213
- // bulk/allocating engine setters (setClips/setMarkers) are safe here — they
214
- // never run on the realtime render path. This is the audio-thread equivalent
215
- // of the engine's control-thread RtPublisher setters.
213
+ // Applies an out-of-band control-plane sync message on the AudioWorklet
214
+ // thread. These handlers must remain bounded: expensive clip transforms are
215
+ // performed on the main-thread mirror, and long pre-baked PCM arrives in
216
+ // small pages before the final lightweight clip schedule is committed.
216
217
  receiveSync(message: SonareEngineSyncMessage): void {
217
218
  if (this.closed) {
218
219
  return;
@@ -238,6 +239,33 @@ export class SonareRealtimeEngineWorkletProcessor {
238
239
  }
239
240
  this.engine.setClips(Array.from(this.liveClips.values()));
240
241
  break;
242
+ case 'syncClipPageProvider': {
243
+ const provider = this.engine.createClipPageProvider(
244
+ message.numChannels,
245
+ message.numSamples,
246
+ message.pageFrames,
247
+ );
248
+ this.pagedClipProviders.set(message.clipId, provider.id);
249
+ this.pendingPagedClips.set(message.clipId, message.clip);
250
+ break;
251
+ }
252
+ case 'syncClipPage': {
253
+ const providerId = this.pagedClipProviders.get(message.clipId);
254
+ if (providerId !== undefined) {
255
+ this.engine.supplyClipPage(providerId, message.pageIndex, message.channels);
256
+ }
257
+ break;
258
+ }
259
+ case 'syncClipPageCommit': {
260
+ const providerId = this.pagedClipProviders.get(message.clipId);
261
+ const clip = this.pendingPagedClips.get(message.clipId);
262
+ if (providerId !== undefined && clip) {
263
+ this.liveClips.set(message.clipId, { ...clip, pageProvider: providerId });
264
+ this.pendingPagedClips.delete(message.clipId);
265
+ this.engine.setClips(Array.from(this.liveClips.values()));
266
+ }
267
+ break;
268
+ }
241
269
  case 'syncMidiClips':
242
270
  this.engine.setMidiClips(message.clips);
243
271
  break;
@@ -787,11 +787,15 @@ export class SonareEngine {
787
787
  // Posts an out-of-band control-sync message to the worklet engine processor.
788
788
  // Sync messages use a string `type` so the worklet's message handler routes
789
789
  // them to receiveSync() (numeric `type` is reserved for SonareEngineCommandRecord).
790
- private postSync(message: SonareEngineSyncMessage): void {
790
+ private postSync(message: SonareEngineSyncMessage, transfer?: Transferable[]): void {
791
791
  if (this.destroyed) {
792
792
  return;
793
793
  }
794
- this.realtimeNode.node.port.postMessage(message);
794
+ if (transfer && transfer.length > 0) {
795
+ this.realtimeNode.node.port.postMessage(message, transfer);
796
+ } else {
797
+ this.realtimeNode.node.port.postMessage(message);
798
+ }
795
799
  }
796
800
 
797
801
  // Collaborator surface handed to the mixer/routing free functions so they can
@@ -916,7 +920,7 @@ export class SonareEngine {
916
920
  clips: this.clips,
917
921
  midiClips: this.midiClips,
918
922
  allocateClipId: () => this.nextClipId++,
919
- postSync: (message) => this.postSync(message),
923
+ postSync: (message, transfer) => this.postSync(message, transfer),
920
924
  ensureTrackLane: (target) => this.ensureTrackLane(target),
921
925
  resolveTargetId: (target) => this.resolveTargetId(target),
922
926
  };
@@ -37,6 +37,9 @@ export function isEngineSyncMessage(value: unknown): value is SonareEngineSyncMe
37
37
  return (
38
38
  value.type === 'syncClips' ||
39
39
  value.type === 'syncClipsDelta' ||
40
+ value.type === 'syncClipPageProvider' ||
41
+ value.type === 'syncClipPage' ||
42
+ value.type === 'syncClipPageCommit' ||
40
43
  value.type === 'syncMidiClips' ||
41
44
  value.type === 'syncMarkers' ||
42
45
  value.type === 'syncMetronome' ||
@@ -219,6 +219,30 @@ export interface SonareEngineSyncClipsDeltaMessage {
219
219
  removeIds: number[];
220
220
  }
221
221
 
222
+ /** Begins a paged, pre-baked clip transfer. PCM pages follow in FIFO order. */
223
+ export interface SonareEngineSyncClipPageProviderMessage {
224
+ type: 'syncClipPageProvider';
225
+ clipId: number;
226
+ clip: EngineClip;
227
+ numChannels: number;
228
+ numSamples: number;
229
+ pageFrames: number;
230
+ }
231
+
232
+ /** Supplies one bounded PCM page for a pending pre-baked clip transfer. */
233
+ export interface SonareEngineSyncClipPageMessage {
234
+ type: 'syncClipPage';
235
+ clipId: number;
236
+ pageIndex: number;
237
+ channels: Float32Array[];
238
+ }
239
+
240
+ /** Makes a fully supplied paged clip visible to the audio engine. */
241
+ export interface SonareEngineSyncClipPageCommitMessage {
242
+ type: 'syncClipPageCommit';
243
+ clipId: number;
244
+ }
245
+
222
246
  export interface SonareEngineSyncMidiClipsMessage {
223
247
  type: 'syncMidiClips';
224
248
  clips: EngineMidiClipSchedule[];
@@ -433,6 +457,9 @@ export type SonareEngineInstrumentSyncMessage =
433
457
  export type SonareEngineSyncMessage =
434
458
  | SonareEngineSyncClipsMessage
435
459
  | SonareEngineSyncClipsDeltaMessage
460
+ | SonareEngineSyncClipPageProviderMessage
461
+ | SonareEngineSyncClipPageMessage
462
+ | SonareEngineSyncClipPageCommitMessage
436
463
  | SonareEngineSyncMidiClipsMessage
437
464
  | SonareEngineSyncMarkersMessage
438
465
  | SonareEngineSyncMetronomeMessage