@libraz/libsonare 1.3.2 → 1.4.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@libraz/libsonare",
3
- "version": "1.3.2",
3
+ "version": "1.4.0",
4
4
  "type": "module",
5
5
  "packageManager": "yarn@4.15.0",
6
6
  "description": "Audio analysis library for music information retrieval",
package/src/codes.ts CHANGED
@@ -52,5 +52,10 @@ export function meterTapCode(tap: MeterTap | number): number {
52
52
  }
53
53
 
54
54
  export function sendTimingCode(timing: SendTiming | number): number {
55
- return timing === 'preFader' || timing === 0 ? 0 : 1;
55
+ // Mirrors SonareSendTiming: post-fader is 0 (so an omitted/zeroed value is
56
+ // post-fader), pre-fader is 1. A raw number is passed through as the C ABI int.
57
+ if (typeof timing === 'number') {
58
+ return timing;
59
+ }
60
+ return timing === 'preFader' ? 1 : 0;
56
61
  }
@@ -16,13 +16,15 @@ import type {
16
16
  PairProcessor,
17
17
  RealtimeVoiceChangerConfigInput,
18
18
  SoloProcessor,
19
+ SpectralEditOptions,
20
+ SpectralRegionOp,
19
21
  StereoAnalysis,
20
22
  StreamingPlatform,
21
23
  } from './public_types';
22
24
  import type { ProgressCallback } from './sonare.js';
23
25
  import { RealtimeVoiceChanger } from './streaming_mixing';
24
26
  import type { ValidateOptions } from './validation';
25
- import { assertSamples } from './validation';
27
+ import { assertSampleRate, assertSamples } from './validation';
26
28
 
27
29
  function requireModule() {
28
30
  return getSonareModule();
@@ -339,6 +341,31 @@ export function normalize(
339
341
  return requireModule().normalize(samples, sampleRate, targetDb);
340
342
  }
341
343
 
344
+ /**
345
+ * Apply region-based spectral edits (gain/attenuate/mute/heal) to mono audio.
346
+ *
347
+ * Each op is a time x frequency rectangle applied in array order over a single
348
+ * STFT buffer, so a later op observes the result of earlier ops. The output has
349
+ * the same length and sample rate as the input; an empty `ops` list is an
350
+ * identity transform (within the iSTFT's own tolerance).
351
+ *
352
+ * @param samples - Audio samples (mono, float32)
353
+ * @param sampleRate - Sample rate in Hz
354
+ * @param ops - Region edit ops applied in order ({@link SpectralRegionOp})
355
+ * @param options - STFT + heal configuration ({@link SpectralEditOptions})
356
+ * @returns Edited audio
357
+ */
358
+ export function spectralEdit(
359
+ samples: Float32Array,
360
+ sampleRate: number,
361
+ ops: SpectralRegionOp[] = [],
362
+ options: SpectralEditOptions & ValidateOptions = {},
363
+ ): Float32Array {
364
+ assertSamples('spectralEdit', samples, options.validate !== false);
365
+ assertSampleRate('spectralEdit', sampleRate);
366
+ return requireModule().spectralEdit(samples, sampleRate, ops, options as Record<string, unknown>);
367
+ }
368
+
342
369
  /**
343
370
  * Apply mastering loudness normalization with a true-peak ceiling.
344
371
  *
@@ -392,6 +419,81 @@ export function masteringInsertParamNames(name: string): string[] {
392
419
  ).masteringInsertParamNames(name);
393
420
  }
394
421
 
422
+ /** One realtime-automatable parameter of an insert processor. */
423
+ export interface MasteringInsertParamInfo {
424
+ /** JSON-key parameter name, as used in scene insert params. */
425
+ name: string;
426
+ /** Integer param id for realtime automation lanes / MIDI-CC binding. */
427
+ id: number;
428
+ /** Whether the param can be changed live from the audio thread. */
429
+ rtSafe: boolean;
430
+ }
431
+
432
+ /**
433
+ * Returns the realtime-automatable parameter descriptors for an insert / FX
434
+ * processor: each entry maps a JSON-key parameter name to the integer id used by
435
+ * realtime automation and reports whether it is realtime-safe. Unlike
436
+ * {@link masteringInsertParamNames} (every construction key), this lists only the
437
+ * realtime-controllable subset — the keys accepted by
438
+ * {@link RealtimeEngine.setTrackStripInsertParamByName}. Returns an empty array
439
+ * for an unknown name or a processor with no automatable parameters.
440
+ *
441
+ * @param name - Insert processor name (see {@link masteringInsertNames}).
442
+ */
443
+ export function masteringInsertParamInfo(name: string): MasteringInsertParamInfo[] {
444
+ const json = (
445
+ requireModule() as unknown as { masteringInsertParamInfo: (name: string) => string }
446
+ ).masteringInsertParamInfo(name);
447
+ return JSON.parse(json) as MasteringInsertParamInfo[];
448
+ }
449
+
450
+ /**
451
+ * How a processor handles a buffer with more than two channels (a surround
452
+ * bed). "multichannel" processes every plane in one call; "stereoPairOnly"
453
+ * operates on the front L/R pair and passes any surround planes through dry.
454
+ * "perChannel"/"passthrough" are reserved and unused by the current catalog.
455
+ */
456
+ export type MasteringChannelPolicy =
457
+ | 'multichannel'
458
+ | 'stereoPairOnly'
459
+ | 'perChannel'
460
+ | 'passthrough';
461
+
462
+ /** One processor's realtime/offline/pair classification in the catalog. */
463
+ export interface MasteringProcessorCatalogEntry {
464
+ /** Processor id (the name used for scene inserts / named processors). */
465
+ id: string;
466
+ /**
467
+ * Primary classification, by precedence pair > realtime > offline: "pair" for
468
+ * two-input match.* processors, "realtime" for ids that build as a realtime
469
+ * scene insert, "offline" for whole-file-only processors.
470
+ */
471
+ kind: 'realtime' | 'offline' | 'pair';
472
+ /** True exactly for ids that always succeed as a realtime scene insert. */
473
+ realtimeInsertable: boolean;
474
+ /** True for processors with no mono implementation (stereo-only). */
475
+ stereoOnly: boolean;
476
+ /**
477
+ * How the mixer wraps the processor on a >2-channel (surround) bus insert:
478
+ * "multichannel" (one full-buffer call) or "stereoPairOnly" (front L/R pair,
479
+ * surround planes passed through dry).
480
+ */
481
+ channelPolicy: MasteringChannelPolicy;
482
+ }
483
+
484
+ /**
485
+ * Returns the machine-readable classification catalog for every named processor
486
+ * id, merging the offline registry, the realtime insert factory, and the pair
487
+ * registry. Lets a host filter a processor picker by realtime insertability
488
+ * instead of offering ids the realtime strip would reject.
489
+ */
490
+ export function masteringProcessorCatalog(): MasteringProcessorCatalogEntry[] {
491
+ const json = (
492
+ requireModule() as unknown as { masteringProcessorCatalog: () => string }
493
+ ).masteringProcessorCatalog();
494
+ return JSON.parse(json) as MasteringProcessorCatalogEntry[];
495
+ }
496
+
395
497
  export function masteringPairProcessorNames(): PairProcessor[] {
396
498
  return requireModule().masteringPairProcessorNames() as PairProcessor[];
397
499
  }
@@ -257,11 +257,21 @@ export function analyzeMelody(
257
257
  const fmin = options.fmin ?? 65.0;
258
258
  const fmax = options.fmax ?? 2093.0;
259
259
  validateFrequencyBounds('analyzeMelody', fmin, fmax);
260
+ // The melody tracker's fmin is a YIN pitch floor: 0 is meaningless, and the
261
+ // flat C ABI (sonare_analyze_melody) rejects it. validateFrequencyBounds only
262
+ // guards fmin >= 0, so enforce strict positivity here for parity.
263
+ if (fmin <= 0) {
264
+ throw new RangeError('analyzeMelody: fmin must be positive');
265
+ }
260
266
  validatePositiveIntegers('analyzeMelody', {
261
267
  frameLength: options.frameLength ?? 2048,
262
268
  hopLength: options.hopLength ?? 256,
263
269
  });
264
- assertFiniteScalar('analyzeMelody', options.threshold ?? 0.1, 'threshold');
270
+ const threshold = options.threshold ?? 0.1;
271
+ assertFiniteScalar('analyzeMelody', threshold, 'threshold');
272
+ if (threshold <= 0) {
273
+ throw new RangeError('analyzeMelody: threshold must be positive');
274
+ }
265
275
  return requireModule().analyzeMelody(
266
276
  samples,
267
277
  sampleRate,
@@ -371,7 +381,9 @@ export function tempogramRatio(
371
381
  * Measure loudness (EBU R128 / ITU-R BS.1770).
372
382
  *
373
383
  * @param samples - Audio samples (mono, float32)
374
- * @param sampleRate - Sample rate in Hz (default: 22050)
384
+ * @param sampleRate - Sample rate in Hz. The default (22050) is non-standard for
385
+ * audio; pass the buffer's actual rate, as K-weighting is sample-rate
386
+ * dependent and a wrong rate yields wrong loudness.
375
387
  * @returns Loudness measurement result
376
388
  */
377
389
  export function lufs(
@@ -388,7 +400,8 @@ export function lufs(
388
400
  * Compute the momentary loudness (LUFS) over time.
389
401
  *
390
402
  * @param samples - Audio samples (mono, float32)
391
- * @param sampleRate - Sample rate in Hz (default: 22050)
403
+ * @param sampleRate - Sample rate in Hz. The default (22050) is non-standard and
404
+ * K-weighting is sample-rate dependent; pass the buffer's actual rate.
392
405
  * @returns Momentary LUFS values over time
393
406
  */
394
407
  export function momentaryLufs(
@@ -405,7 +418,8 @@ export function momentaryLufs(
405
418
  * Compute the short-term loudness (LUFS) over time.
406
419
  *
407
420
  * @param samples - Audio samples (mono, float32)
408
- * @param sampleRate - Sample rate in Hz (default: 22050)
421
+ * @param sampleRate - Sample rate in Hz. The default (22050) is non-standard and
422
+ * K-weighting is sample-rate dependent; pass the buffer's actual rate.
409
423
  * @returns Short-term LUFS values over time
410
424
  */
411
425
  export function shortTermLufs(
@@ -218,6 +218,10 @@ export function hpssWithResidual(
218
218
  * Channel-weighted multichannel integrated loudness + LRA (ITU-R BS.1770 /
219
219
  * EBU R128) from an interleaved buffer of `frames * channels` samples. The
220
220
  * per-channel frame count is derived from the buffer length and `channels`.
221
+ *
222
+ * Pass the buffer's actual `sampleRate`: the default (22050) is non-standard for
223
+ * audio, and K-weighting is sample-rate dependent, so a wrong rate yields wrong
224
+ * loudness.
221
225
  */
222
226
  export function lufsInterleaved(
223
227
  samples: Float32Array,
@@ -231,7 +235,9 @@ export function lufsInterleaved(
231
235
  }
232
236
 
233
237
  /**
234
- * Standards-compliant EBU R128 loudness range (LRA) in LU.
238
+ * Standards-compliant EBU R128 loudness range (LRA) in LU. Pass the buffer's
239
+ * actual `sampleRate`: the default (22050) is non-standard and K-weighting is
240
+ * sample-rate dependent.
235
241
  */
236
242
  export function ebur128LoudnessRange(samples: Float32Array, sampleRate = 22050): number {
237
243
  return requireModule().ebur128LoudnessRange(samples, sampleRate);
package/src/index.ts CHANGED
@@ -43,6 +43,9 @@ export type {
43
43
  DereverbClassicalOptions,
44
44
  DynamicsResult,
45
45
  GateOptions,
46
+ MasteringChannelPolicy,
47
+ MasteringInsertParamInfo,
48
+ MasteringProcessorCatalogEntry,
46
49
  TransientShaperOptions,
47
50
  TrimSilenceMode,
48
51
  TrimSilenceOptions,
@@ -67,6 +70,7 @@ export {
67
70
  masteringDynamicsGate,
68
71
  masteringDynamicsTransientShaper,
69
72
  masteringInsertNames,
73
+ masteringInsertParamInfo,
70
74
  masteringInsertParamNames,
71
75
  masteringPairAnalysisNames,
72
76
  masteringPairAnalyze,
@@ -74,6 +78,7 @@ export {
74
78
  masteringPairProcessorNames,
75
79
  masteringPresetNames,
76
80
  masteringProcess,
81
+ masteringProcessorCatalog,
77
82
  masteringProcessorNames,
78
83
  masteringProcessStereo,
79
84
  masteringRepairDeclick,
@@ -95,6 +100,7 @@ export {
95
100
  pitchCorrectToMidi,
96
101
  pitchCorrectToMidiTimevarying,
97
102
  pitchShift,
103
+ spectralEdit,
98
104
  timeStretch,
99
105
  voiceChange,
100
106
  voiceChangeRealtime,
@@ -245,6 +251,7 @@ export type {
245
251
  ProjectLoopMode,
246
252
  ProjectLoopRecordingDesc,
247
253
  ProjectLoopRecordingResult,
254
+ ProjectMarker,
248
255
  ProjectMidiClipResult,
249
256
  ProjectMidiEvent,
250
257
  ProjectNotePairValidation,
@@ -268,6 +275,7 @@ export type {
268
275
  } from './project';
269
276
  export {
270
277
  EXPECTED_PROJECT_ABI_VERSION,
278
+ MarkerKind,
271
279
  Project,
272
280
  projectAbiVersion,
273
281
  SYNTH_BODY_TYPES,
@@ -348,6 +356,10 @@ export type {
348
356
  Section,
349
357
  SendTiming,
350
358
  SoloProcessor,
359
+ SpectralEditMode,
360
+ SpectralEditOptions,
361
+ SpectralEditWindow,
362
+ SpectralRegionOp,
351
363
  StereoAnalysis,
352
364
  StftPowerResult,
353
365
  StftResult,
@@ -401,6 +413,7 @@ export type {
401
413
  EngineAutomationPoint,
402
414
  EngineBounceOptions,
403
415
  EngineBounceResult,
416
+ EngineBus,
404
417
  EngineCapabilities,
405
418
  EngineCaptureStatus,
406
419
  EngineClip,
@@ -409,9 +422,17 @@ export type {
409
422
  EngineGraphSpec,
410
423
  EngineMarker,
411
424
  EngineMeterTelemetry,
425
+ EngineMeterTelemetryWide,
412
426
  EngineMetronomeConfig,
427
+ EngineMidiClipSchedule,
428
+ EngineMidiEvent,
413
429
  EngineParameterInfo,
430
+ EngineScopeTelemetry,
414
431
  EngineTelemetry,
432
+ EngineTempoSegment,
433
+ EngineTimeSignatureSegment,
434
+ EngineTrackLane,
435
+ EngineTrackSend,
415
436
  EngineTransportState,
416
437
  MidiCcBindOptions,
417
438
  } from './realtime_engine';
@@ -484,6 +505,11 @@ let initPromise: Promise<void> | null = null;
484
505
  */
485
506
  export async function init(options?: {
486
507
  locateFile?: (path: string, prefix: string) => string;
508
+ wasmBinary?: ArrayBuffer | Uint8Array;
509
+ moduleFactory?: (options?: {
510
+ locateFile?: (path: string, prefix: string) => string;
511
+ wasmBinary?: ArrayBuffer | Uint8Array;
512
+ }) => Promise<SonareModule>;
487
513
  }): Promise<void> {
488
514
  if (module) {
489
515
  return;
@@ -495,7 +521,7 @@ export async function init(options?: {
495
521
 
496
522
  initPromise = (async () => {
497
523
  try {
498
- const createModule = (await import('./sonare.js')).default;
524
+ const createModule = options?.moduleFactory ?? (await import('./sonare.js')).default;
499
525
  module = await createModule(options);
500
526
  setSonareModule(module);
501
527
  } catch (error) {
package/src/mixer.ts CHANGED
@@ -15,6 +15,7 @@ import type {
15
15
  PanLaw,
16
16
  PanMode,
17
17
  SendTiming,
18
+ SurroundPan,
18
19
  } from './public_types';
19
20
 
20
21
  export interface MixerRealtimeBuffer {
@@ -348,6 +349,14 @@ export class Mixer {
348
349
  this.mixer.setDualPan(stripIndex, leftPan, rightPan);
349
350
  }
350
351
 
352
+ /**
353
+ * Set the strip's surround pan position, used when it feeds a >2-channel bus.
354
+ * Stored on the scene; inert until the surround DSP path applies it.
355
+ */
356
+ setSurroundPan(stripIndex: number, pan: SurroundPan): void {
357
+ this.mixer.setSurroundPan(stripIndex, pan);
358
+ }
359
+
351
360
  /**
352
361
  * Add a send to a strip after construction.
353
362
  *
@@ -70,6 +70,7 @@ function makeSonareError(raw: SonareModule, thrown: number): SonareError {
70
70
  */
71
71
  function wrapModuleErrors(raw: SonareModule): SonareModule {
72
72
  const cache = new Map<PropertyKey, unknown>();
73
+ const objectCache = new WeakMap<object, unknown>();
73
74
  const convert = (error: unknown): never => {
74
75
  const ptr = nativeExceptionPtr(error);
75
76
  if (ptr !== null) {
@@ -77,6 +78,86 @@ function wrapModuleErrors(raw: SonareModule): SonareModule {
77
78
  }
78
79
  throw error;
79
80
  };
81
+
82
+ const wrapNativeObject = (value: unknown): unknown => {
83
+ if (value === null || typeof value !== 'object') {
84
+ return value;
85
+ }
86
+ if (ArrayBuffer.isView(value) || value instanceof ArrayBuffer || value instanceof Promise) {
87
+ return value;
88
+ }
89
+ const objectValue = value as object;
90
+ const cached = objectCache.get(objectValue);
91
+ if (cached) {
92
+ return cached;
93
+ }
94
+ const methodCache = new Map<PropertyKey, unknown>();
95
+ const wrapped = new Proxy(objectValue, {
96
+ get(target, prop, receiver) {
97
+ const member = Reflect.get(target, prop, receiver);
98
+ if (typeof member !== 'function') {
99
+ return member;
100
+ }
101
+ const cachedMethod = methodCache.get(prop);
102
+ if (cachedMethod) {
103
+ return cachedMethod;
104
+ }
105
+ const method = member as (...a: unknown[]) => unknown;
106
+ const wrappedMethod = (...args: unknown[]) => {
107
+ try {
108
+ return wrapNativeObject(Reflect.apply(method, target, args));
109
+ } catch (error) {
110
+ return convert(error);
111
+ }
112
+ };
113
+ methodCache.set(prop, wrappedMethod);
114
+ return wrappedMethod;
115
+ },
116
+ });
117
+ objectCache.set(objectValue, wrapped);
118
+ return wrapped;
119
+ };
120
+
121
+ const wrapFunction = (value: (...a: unknown[]) => unknown): unknown => {
122
+ const fnCache = new Map<PropertyKey, unknown>();
123
+ return new Proxy(value, {
124
+ get(target, prop, receiver) {
125
+ const member = Reflect.get(target, prop, receiver);
126
+ if (typeof member !== 'function') {
127
+ return member;
128
+ }
129
+ const cachedMember = fnCache.get(prop);
130
+ if (cachedMember) {
131
+ return cachedMember;
132
+ }
133
+ const fn = member as (...a: unknown[]) => unknown;
134
+ const wrappedMember = (...args: unknown[]) => {
135
+ try {
136
+ return wrapNativeObject(Reflect.apply(fn, target, args));
137
+ } catch (error) {
138
+ return convert(error);
139
+ }
140
+ };
141
+ fnCache.set(prop, wrappedMember);
142
+ return wrappedMember;
143
+ },
144
+ apply(t, thisArg, args) {
145
+ try {
146
+ return wrapNativeObject(Reflect.apply(t, thisArg, args as unknown[]));
147
+ } catch (error) {
148
+ return convert(error);
149
+ }
150
+ },
151
+ construct(t, args, newTarget) {
152
+ try {
153
+ return wrapNativeObject(Reflect.construct(t, args as unknown[], newTarget));
154
+ } catch (error) {
155
+ return convert(error) as object;
156
+ }
157
+ },
158
+ });
159
+ };
160
+
80
161
  return new Proxy(raw, {
81
162
  get(target, prop, receiver) {
82
163
  const value = Reflect.get(target, prop, receiver);
@@ -90,23 +171,7 @@ function wrapModuleErrors(raw: SonareModule): SonareModule {
90
171
  // Wrap as a Proxy (not a plain function) so embind class constructors
91
172
  // invoked via `new module.Foo(...)` keep their `[[Construct]]` behaviour
92
173
  // and prototype while still converting thrown native pointers.
93
- const fn = value as (...a: unknown[]) => unknown;
94
- const wrapped = new Proxy(fn, {
95
- apply(t, thisArg, args) {
96
- try {
97
- return Reflect.apply(t, thisArg, args as unknown[]);
98
- } catch (error) {
99
- return convert(error);
100
- }
101
- },
102
- construct(t, args, newTarget) {
103
- try {
104
- return Reflect.construct(t, args as unknown[], newTarget) as object;
105
- } catch (error) {
106
- return convert(error) as object;
107
- }
108
- },
109
- });
174
+ const wrapped = wrapFunction(value as (...a: unknown[]) => unknown);
110
175
  cache.set(prop, wrapped);
111
176
  return wrapped;
112
177
  },
@@ -29,10 +29,25 @@ interface PageResponse {
29
29
  }
30
30
 
31
31
  export const opfsClipPageWorkerSource = `
32
+ const sonareClipPageReadQueues = new Map();
33
+
34
+ function sonareEnqueueClipPageRead(key, task) {
35
+ const previous = sonareClipPageReadQueues.get(key) || Promise.resolve();
36
+ const next = previous.catch(() => undefined).then(task);
37
+ const queued = next.finally(() => {
38
+ if (sonareClipPageReadQueues.get(key) === queued) {
39
+ sonareClipPageReadQueues.delete(key);
40
+ }
41
+ });
42
+ sonareClipPageReadQueues.set(key, queued);
43
+ return next;
44
+ }
45
+
32
46
  self.onmessage = async (event) => {
33
47
  const message = event.data;
34
48
  if (!message || message.type !== 'sonare:read-clip-page') return;
35
49
  const { requestId, path, pageIndex, numChannels, numSamples, pageFrames, dataOffsetBytes = 0 } = message;
50
+ await sonareEnqueueClipPageRead(String(path), async () => {
36
51
  try {
37
52
  if (pageIndex < 0) {
38
53
  self.postMessage({ type: 'sonare:clip-page', requestId, pageIndex, ok: false });
@@ -95,6 +110,7 @@ self.onmessage = async (event) => {
95
110
  error: error instanceof Error ? error.message : String(error),
96
111
  });
97
112
  }
113
+ });
98
114
  };
99
115
  `;
100
116
 
@@ -119,6 +135,7 @@ export function createOpfsClipPageProvider(
119
135
  const ownsWorker = options.worker === undefined || options.terminateWorkerOnClose === true;
120
136
  let nextRequestId = 1;
121
137
  let closed = false;
138
+ let readQueue: Promise<void> = Promise.resolve();
122
139
  const pending = new Map<
123
140
  number,
124
141
  { resolve: (value: boolean) => void; reject: (reason: unknown) => void }
@@ -165,15 +182,32 @@ export function createOpfsClipPageProvider(
165
182
  const promise = new Promise<boolean>((resolve, reject) => {
166
183
  pending.set(requestId, { resolve, reject });
167
184
  });
168
- worker.postMessage({
169
- type: 'sonare:read-clip-page',
170
- requestId,
171
- path: options.path,
172
- pageIndex,
173
- numChannels: options.numChannels,
174
- numSamples: options.numSamples,
175
- pageFrames: options.pageFrames,
176
- dataOffsetBytes: options.dataOffsetBytes ?? 0,
185
+ readQueue = readQueue
186
+ .catch(() => undefined)
187
+ .then(() => {
188
+ if (closed) {
189
+ const entry = pending.get(requestId);
190
+ pending.delete(requestId);
191
+ entry?.reject(new Error('OpfsClipPageProvider is closed'));
192
+ return;
193
+ }
194
+ worker.postMessage({
195
+ type: 'sonare:read-clip-page',
196
+ requestId,
197
+ path: options.path,
198
+ pageIndex,
199
+ numChannels: options.numChannels,
200
+ numSamples: options.numSamples,
201
+ pageFrames: options.pageFrames,
202
+ dataOffsetBytes: options.dataOffsetBytes ?? 0,
203
+ });
204
+ return promise.then(
205
+ () => undefined,
206
+ () => undefined,
207
+ );
208
+ });
209
+ readQueue.catch(() => {
210
+ // The per-request promise carries the user-visible failure.
177
211
  });
178
212
  return promise;
179
213
  };
package/src/project.ts CHANGED
@@ -25,6 +25,34 @@ export interface ProjectBounceOptions {
25
25
  instrumentLatencySamples?: number;
26
26
  }
27
27
 
28
+ /**
29
+ * Marker kind ordinals. Mirrors `SonareMarkerKind` in `src/sonare_c_types.h`;
30
+ * the values are part of the ABI and must not be renumbered.
31
+ */
32
+ export const MarkerKind = {
33
+ marker: 0,
34
+ text: 1,
35
+ lyric: 2,
36
+ cuePoint: 3,
37
+ keySignature: 4,
38
+ } as const;
39
+
40
+ /** A project timeline marker with its kind and (for key signatures) the key. */
41
+ export interface ProjectMarker {
42
+ /** Stable marker id (0 when allocating a new id via {@link Project.setMarkerEx}). */
43
+ id: number;
44
+ /** Marker position in PPQ (quarter notes). */
45
+ ppq: number;
46
+ /** Marker label. */
47
+ name?: string;
48
+ /** {@link MarkerKind} ordinal (default 0 = marker). */
49
+ kind?: number;
50
+ /** Key signature only: -7..7 (sharps positive). */
51
+ keyFifths?: number;
52
+ /** Key signature only: false = major, true = minor. */
53
+ keyMinor?: boolean;
54
+ }
55
+
28
56
  /** Oscillator waveform for the built-in synth. */
29
57
  export type BuiltinSynthWaveform =
30
58
  | 'sine'
@@ -544,6 +572,10 @@ interface WasmProject {
544
572
  setWarpMap: (map: ProjectWarpMapDesc) => void;
545
573
  removeWarpMap: (warpRefId: number) => void;
546
574
  setTrackMidiDestination: (trackId: number, destinationId: number) => void;
575
+ setTrackGain: (trackId: number, gain: number) => void;
576
+ setTrackMute: (trackId: number, mute: boolean) => void;
577
+ setTrackSolo: (trackId: number, solo: boolean) => void;
578
+ setTrackPan: (trackId: number, pan: number) => void;
547
579
  undo: () => void;
548
580
  redo: () => void;
549
581
  setMidiEvents: (
@@ -627,6 +659,9 @@ interface WasmProject {
627
659
  getSampleRate: () => number;
628
660
  setMixerSceneJson: (sceneJson: string) => void;
629
661
  setMarker: (markerId: number, ppq: number, name: string) => number;
662
+ setMarkerEx: (marker: ProjectMarker) => number;
663
+ markerByIndex: (index: number) => ProjectMarker;
664
+ markerCount: () => number;
630
665
  trackCount: () => number;
631
666
  sourceCount: () => number;
632
667
  tempoSegmentCount: () => number;
@@ -1166,6 +1201,26 @@ export class Project {
1166
1201
  this.native.setTrackMidiDestination(trackId, destinationId);
1167
1202
  }
1168
1203
 
1204
+ /** Set a track's linear playback gain (1.0 = unity; >= 0) via an undoable edit. */
1205
+ setTrackGain(trackId: number, gain: number): void {
1206
+ this.native.setTrackGain(trackId, gain);
1207
+ }
1208
+
1209
+ /** Set a track's mute flag via an undoable edit (a muted track is silent). */
1210
+ setTrackMute(trackId: number, mute: boolean): void {
1211
+ this.native.setTrackMute(trackId, mute);
1212
+ }
1213
+
1214
+ /** Set a track's solo flag via an undoable edit (when any track is soloed, only soloed tracks sound). */
1215
+ setTrackSolo(trackId: number, solo: boolean): void {
1216
+ this.native.setTrackSolo(trackId, solo);
1217
+ }
1218
+
1219
+ /** Set a track's stereo balance in [-1, +1] (0 = center) via an undoable edit. */
1220
+ setTrackPan(trackId: number, pan: number): void {
1221
+ this.native.setTrackPan(trackId, pan);
1222
+ }
1223
+
1169
1224
  /** Undo the most recent edit. */
1170
1225
  undo(): void {
1171
1226
  this.native.undo();
@@ -1527,6 +1582,25 @@ export class Project {
1527
1582
  return this.native.setMarker(markerId, ppq, name);
1528
1583
  }
1529
1584
 
1585
+ /**
1586
+ * Add or replace a marker from a full {@link ProjectMarker}, including its
1587
+ * {@link MarkerKind} and (for key signatures) the key. Pass `id` 0 to allocate
1588
+ * a new id; returns the stable marker id.
1589
+ */
1590
+ setMarkerEx(marker: ProjectMarker): number {
1591
+ return this.native.setMarkerEx(marker);
1592
+ }
1593
+
1594
+ /** Read a project marker by index (0-based, in stored order). */
1595
+ markerByIndex(index: number): ProjectMarker {
1596
+ return this.native.markerByIndex(index);
1597
+ }
1598
+
1599
+ /** Number of markers in the project. */
1600
+ markerCount(): number {
1601
+ return this.native.markerCount();
1602
+ }
1603
+
1530
1604
  /** Number of tracks in the project. */
1531
1605
  trackCount(): number {
1532
1606
  return this.native.trackCount();