@libraz/libsonare 1.6.0 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/README.md +2 -2
  2. package/dist/analysis.d.ts +88 -9
  3. package/dist/analysis.js +152 -42
  4. package/dist/analysis.js.map +1 -1
  5. package/dist/index.d.ts +129 -24
  6. package/dist/index.js +455 -140
  7. package/dist/index.js.map +1 -1
  8. package/dist/schemas/realtime-voice-changer-preset.schema.json +2 -2
  9. package/dist/sonare-analysis.js +1 -1
  10. package/dist/sonare-analysis.wasm +0 -0
  11. package/dist/sonare.js +1 -1
  12. package/dist/sonare.wasm +0 -0
  13. package/dist/worker.js +56 -29
  14. package/dist/worker.js.map +1 -1
  15. package/dist/worklet.d.ts +62 -10
  16. package/dist/worklet.js +193 -42
  17. package/dist/worklet.js.map +1 -1
  18. package/package.json +3 -3
  19. package/src/analysis_helpers.ts +34 -29
  20. package/src/audio.ts +7 -2
  21. package/src/codes.ts +47 -11
  22. package/src/effects_transform.ts +179 -10
  23. package/src/feature_music.ts +8 -1
  24. package/src/feature_spectral.ts +64 -2
  25. package/src/feature_spectrogram.ts +44 -3
  26. package/src/index.ts +21 -3
  27. package/src/mixer.ts +3 -3
  28. package/src/project.ts +6 -0
  29. package/src/project_class.ts +143 -16
  30. package/src/project_internal.ts +14 -5
  31. package/src/project_types.ts +45 -1
  32. package/src/public_types_mixing.ts +16 -0
  33. package/src/public_types_realtime.ts +23 -5
  34. package/src/quick_analysis.ts +21 -3
  35. package/src/realtime_engine.ts +14 -3
  36. package/src/sonare.js.d.ts +64 -0
  37. package/src/stream_analyzer.ts +6 -1
  38. package/src/web_midi.ts +10 -7
  39. package/src/worklet/engine-node.ts +54 -19
  40. package/src/worklet/engine-parameter-facade.ts +30 -1
  41. package/src/worklet/engine-processor.ts +52 -20
  42. package/src/worklet/engine-register.ts +1 -1
  43. package/src/worklet/engine-strips.ts +2 -2
  44. package/src/worklet/engine.ts +10 -0
  45. package/src/worklet/guards.ts +99 -7
  46. package/src/worklet/messages.ts +41 -1
  47. package/src/worklet/protocol.ts +4 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@libraz/libsonare",
3
- "version": "1.6.0",
3
+ "version": "1.7.0",
4
4
  "type": "module",
5
5
  "packageManager": "yarn@4.18.0",
6
6
  "description": "Audio analysis, mastering, mixing, and MIDI synthesis in WebAssembly",
@@ -38,7 +38,7 @@
38
38
  "build:wasm": "yarn build:wasm:full && yarn build:wasm:analysis",
39
39
  "build:wasm:full": "mkdir -p build-wasm/em-cache && EM_NODE_JS=\"$(mise which node 2>/dev/null || command -v node)\" EM_CACHE=$PWD/build-wasm/em-cache emcmake cmake -S ../.. -B build-wasm -DBUILD_WASM=ON -DSONARE_WASM_ANALYSIS_ONLY=OFF -DSONARE_WASM_OUTPUT_NAME=sonare -DBUILD_MASTERING=ON -DBUILD_MIXING=ON -DBUILD_GRAPH=ON -DBUILD_FX=ON -DBUILD_ACOUSTIC_SIM=ON -DBUILD_PITCH_EDITOR=ON -DBUILD_VOICE_CHANGER=ON -DBUILD_ARRANGEMENT=ON -DBUILD_ASSIST=ON -DCMAKE_BUILD_TYPE=Release && EM_NODE_JS=\"$(mise which node 2>/dev/null || command -v node)\" EM_CACHE=$PWD/build-wasm/em-cache cmake --build build-wasm",
40
40
  "build:wasm:analysis": "mkdir -p build-wasm-analysis/em-cache && EM_NODE_JS=\"$(mise which node 2>/dev/null || command -v node)\" EM_CACHE=$PWD/build-wasm-analysis/em-cache emcmake cmake -S ../.. -B build-wasm-analysis -DBUILD_WASM=ON -DSONARE_WASM_ANALYSIS_ONLY=ON -DSONARE_WASM_OUTPUT_NAME=sonare-analysis -DBUILD_MASTERING=OFF -DBUILD_MIXING=OFF -DBUILD_GRAPH=OFF -DBUILD_FX=OFF -DBUILD_ACOUSTIC_SIM=OFF -DBUILD_VOICE_CHANGER=OFF -DBUILD_ARRANGEMENT=OFF -DBUILD_ASSIST=OFF -DCMAKE_BUILD_TYPE=Release && EM_NODE_JS=\"$(mise which node 2>/dev/null || command -v node)\" EM_CACHE=$PWD/build-wasm-analysis/em-cache cmake --build build-wasm-analysis --target sonare",
41
- "check:wasm-size": "python3 ../../tools/check_wasm_size.py --baseline wasm-size-baseline.json --artifact sonare.wasm=dist/sonare.wasm --artifact sonare-analysis.wasm=dist/sonare-analysis.wasm",
41
+ "check:wasm-size": "python3 ../../tools/check_wasm_size.py --baseline wasm-size-budget.json --artifact sonare.wasm=dist/sonare.wasm --artifact sonare-analysis.wasm=dist/sonare-analysis.wasm",
42
42
  "bench:wasm:isp": "mkdir -p build-wasm-bench/em-cache /tmp/libsonare-wasm-bench && EM_CACHE=$PWD/build-wasm-bench/em-cache emcmake cmake -S ../.. -B build-wasm-bench -DBUILD_WASM=ON -DBUILD_MASTERING=ON -DBUILD_BENCH=ON -DBUILD_TESTING=OFF -DBUILD_CLI=OFF -DCMAKE_BUILD_TYPE=Release && EM_CACHE=$PWD/build-wasm-bench/em-cache cmake --build build-wasm-bench --target sonare_mastering_isp_bench && cp build-wasm-bench/bin/sonare_mastering_isp_bench.js /tmp/libsonare-wasm-bench/sonare_mastering_isp_bench.cjs && cp build-wasm-bench/bin/sonare_mastering_isp_bench.wasm /tmp/libsonare-wasm-bench/sonare_mastering_isp_bench.wasm && node /tmp/libsonare-wasm-bench/sonare_mastering_isp_bench.cjs",
43
43
  "bench:wasm:phase-vocoder": "mkdir -p build-wasm-bench/em-cache /tmp/libsonare-wasm-bench && EM_CACHE=$PWD/build-wasm-bench/em-cache emcmake cmake -S ../.. -B build-wasm-bench -DBUILD_WASM=ON -DBUILD_BENCH=ON -DBUILD_TESTING=OFF -DBUILD_CLI=OFF -DCMAKE_BUILD_TYPE=Release && EM_CACHE=$PWD/build-wasm-bench/em-cache cmake --build build-wasm-bench --target sonare_phase_vocoder_streaming_bench && cp build-wasm-bench/bin/sonare_phase_vocoder_streaming_bench.js /tmp/libsonare-wasm-bench/sonare_phase_vocoder_streaming_bench.cjs && cp build-wasm-bench/bin/sonare_phase_vocoder_streaming_bench.wasm /tmp/libsonare-wasm-bench/sonare_phase_vocoder_streaming_bench.wasm && node /tmp/libsonare-wasm-bench/sonare_phase_vocoder_streaming_bench.cjs",
44
44
  "build:js": "tsup && node scripts/copy-schemas.mjs",
@@ -47,7 +47,7 @@
47
47
  "lint:fix": "biome check --write src/ tests/",
48
48
  "format": "biome format --write src/ tests/",
49
49
  "test": "vitest run",
50
- "test:types": "tsc --noEmit --ignoreConfig --strict --module ESNext --moduleResolution bundler --target ES2020 tests/typing/analysis-entry.ts",
50
+ "test:types": "tsc --noEmit --ignoreConfig --strict --module ESNext --moduleResolution bundler --target ES2020 tests/typing/analysis-entry.ts tests/typing/worklet-capture-response.ts",
51
51
  "test:browser": "node scripts/audio-worklet-smoke.mjs",
52
52
  "test:browser:worker": "node scripts/offline-worker-smoke.mjs",
53
53
  "test:browser:boundary": "node scripts/boundary-smoke.mjs",
@@ -1,3 +1,4 @@
1
+ import { resolveEnumOrdinal } from './codes';
1
2
  import type {
2
3
  AnalysisResult,
3
4
  ChordAnalysisResult,
@@ -48,6 +49,37 @@ export function convertKeyCandidate(wasm: WasmKeyCandidateResult): KeyCandidate
48
49
  };
49
50
  }
50
51
 
52
+ // Consolidated spelling -> ordinal tables, resolved through the same
53
+ // resolveEnumOrdinal() primitive as panModeCode/panLawCode/etc. in ./codes.
54
+ // An unmapped string or an out-of-range numeric ordinal throws RangeError
55
+ // instead of silently resolving to `undefined` (which used to reach the WASM
56
+ // layer as a mistyped argument) or passing an unvalidated raw number through.
57
+ const KEY_MODE_VALUES: Readonly<Record<string, number>> = {
58
+ major: Mode.Major,
59
+ minor: Mode.Minor,
60
+ dorian: Mode.Dorian,
61
+ phrygian: Mode.Phrygian,
62
+ lydian: Mode.Lydian,
63
+ mixolydian: Mode.Mixolydian,
64
+ locrian: Mode.Locrian,
65
+ };
66
+
67
+ const KEY_PROFILE_VALUES: Readonly<Record<KeyProfileName, number>> = {
68
+ ks: KeyProfileValues.KrumhanslSchmuckler,
69
+ krumhansl: KeyProfileValues.KrumhanslSchmuckler,
70
+ temperley: KeyProfileValues.Temperley,
71
+ shaath: KeyProfileValues.Shaath,
72
+ keyfinder: KeyProfileValues.Shaath,
73
+ 'faraldo-edmt': KeyProfileValues.FaraldoEDMT,
74
+ edmt: KeyProfileValues.FaraldoEDMT,
75
+ 'faraldo-edma': KeyProfileValues.FaraldoEDMA,
76
+ edma: KeyProfileValues.FaraldoEDMA,
77
+ 'faraldo-edmm': KeyProfileValues.FaraldoEDMM,
78
+ edmm: KeyProfileValues.FaraldoEDMM,
79
+ 'bellman-budge': KeyProfileValues.BellmanBudge,
80
+ bellman: KeyProfileValues.BellmanBudge,
81
+ };
82
+
51
83
  export function keyModeValues(modes: KeyDetectionOptions['modes'] | undefined): number[] {
52
84
  if (!modes) {
53
85
  return [];
@@ -66,41 +98,14 @@ export function keyModeValues(modes: KeyDetectionOptions['modes'] | undefined):
66
98
  Mode.Locrian,
67
99
  ];
68
100
  }
69
- const names = {
70
- major: Mode.Major,
71
- minor: Mode.Minor,
72
- dorian: Mode.Dorian,
73
- phrygian: Mode.Phrygian,
74
- lydian: Mode.Lydian,
75
- mixolydian: Mode.Mixolydian,
76
- locrian: Mode.Locrian,
77
- } as const;
78
- return modes.map((mode) => (typeof mode === 'number' ? mode : names[mode]));
101
+ return modes.map((mode) => resolveEnumOrdinal(mode, KEY_MODE_VALUES, 'key mode'));
79
102
  }
80
103
 
81
104
  export function keyProfileValue(profile: KeyDetectionOptions['profile'] | undefined): number {
82
105
  if (profile === undefined) {
83
106
  return -1;
84
107
  }
85
- if (typeof profile === 'number') {
86
- return profile;
87
- }
88
- const names: Record<KeyProfileName, number> = {
89
- ks: KeyProfileValues.KrumhanslSchmuckler,
90
- krumhansl: KeyProfileValues.KrumhanslSchmuckler,
91
- temperley: KeyProfileValues.Temperley,
92
- shaath: KeyProfileValues.Shaath,
93
- keyfinder: KeyProfileValues.Shaath,
94
- 'faraldo-edmt': KeyProfileValues.FaraldoEDMT,
95
- edmt: KeyProfileValues.FaraldoEDMT,
96
- 'faraldo-edma': KeyProfileValues.FaraldoEDMA,
97
- edma: KeyProfileValues.FaraldoEDMA,
98
- 'faraldo-edmm': KeyProfileValues.FaraldoEDMM,
99
- edmm: KeyProfileValues.FaraldoEDMM,
100
- 'bellman-budge': KeyProfileValues.BellmanBudge,
101
- bellman: KeyProfileValues.BellmanBudge,
102
- };
103
- return names[profile];
108
+ return resolveEnumOrdinal(profile, KEY_PROFILE_VALUES, 'key profile');
104
109
  }
105
110
 
106
111
  export function convertChordAnalysisResult(wasm: WasmChordAnalysisResult): ChordAnalysisResult {
package/src/audio.ts CHANGED
@@ -239,9 +239,14 @@ export class Audio {
239
239
  }
240
240
  }
241
241
 
242
- /** The raw audio samples. */
242
+ /**
243
+ * A copy of the raw audio samples. Mirrors Node's `getData()` contract: the
244
+ * returned array is independent of the Audio's internal buffer, so mutating
245
+ * it (or transferring it to a Worker) does not affect subsequent facade
246
+ * calls, which all read the internal snapshot directly.
247
+ */
243
248
  get data(): Float32Array {
244
- return this._samples;
249
+ return this._samples.slice();
245
250
  }
246
251
 
247
252
  /** Number of samples. */
package/src/codes.ts CHANGED
@@ -1,4 +1,17 @@
1
- import type { AutomationCurve, MeterTap, PanLaw, PanMode, SendTiming } from './public_types';
1
+ import type { AutomationCurve, MeterTap, PanLawInput, PanMode, SendTiming } from './public_types';
2
+
3
+ /** Resolve a numeric ordinal in an inclusive range without coercion. */
4
+ export function resolveOrdinalInRange(
5
+ value: unknown,
6
+ min: number,
7
+ max: number,
8
+ enumName: string,
9
+ ): number {
10
+ if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < min || value > max) {
11
+ throw new RangeError(`Invalid ${enumName}: ${String(value)}`);
12
+ }
13
+ return value;
14
+ }
2
15
 
3
16
  /** Resolve a public enum spelling or ordinal without permitting unknown values. */
4
17
  export function resolveEnumOrdinal(
@@ -7,10 +20,17 @@ export function resolveEnumOrdinal(
7
20
  enumName: string,
8
21
  ): number {
9
22
  if (typeof value === 'number') {
10
- if (!Number.isSafeInteger(value) || !Object.values(values).includes(value)) {
23
+ const ordinals = Object.values(values);
24
+ const ordinal = resolveOrdinalInRange(
25
+ value,
26
+ Math.min(...ordinals),
27
+ Math.max(...ordinals),
28
+ enumName,
29
+ );
30
+ if (!ordinals.includes(ordinal)) {
11
31
  throw new RangeError(`Invalid ${enumName}: ${String(value)}`);
12
32
  }
13
- return value;
33
+ return ordinal;
14
34
  }
15
35
  if (typeof value === 'string') {
16
36
  const ordinal = values[value];
@@ -27,15 +47,24 @@ const AUTOMATION_CURVE_VALUES = {
27
47
  hold: 2,
28
48
  's-curve': 3,
29
49
  } as const;
30
- const PAN_LAW_VALUES = {
31
- const3dB: 0,
32
- 'const4.5dB': 1,
33
- const6dB: 2,
34
- linear0dB: 3,
50
+ const PAN_LAW_VALUES: Readonly<Record<string, number>> = {
51
+ const3db: 0,
52
+ 'const-3db': 0,
53
+ '-3db': 0,
54
+ 'const4.5db': 1,
55
+ 'const-4.5db': 1,
56
+ '-4.5db': 1,
57
+ const6db: 2,
58
+ 'const-6db': 2,
59
+ '-6db': 2,
60
+ linear0db: 3,
61
+ 'linear-0db': 3,
62
+ linear: 3,
63
+ '0db': 3,
35
64
  } as const;
36
65
  const PAN_MODE_VALUES = {
37
66
  balance: 0,
38
- pan: 0,
67
+ pan: 1,
39
68
  stereopan: 1,
40
69
  'stereo-pan': 1,
41
70
  dualpan: 2,
@@ -43,13 +72,15 @@ const PAN_MODE_VALUES = {
43
72
  } as const;
44
73
  const METER_TAP_VALUES = { preFader: 0, postFader: 1 } as const;
45
74
  const SEND_TIMING_VALUES = { postFader: 0, preFader: 1 } as const;
75
+ const TRACK_MONITOR_MODE_VALUES = { off: 0, pfl: 1, afl: 2 } as const;
46
76
 
47
77
  export function automationCurveCode(curve: AutomationCurve): number {
48
78
  return resolveEnumOrdinal(curve, AUTOMATION_CURVE_VALUES, 'automation curve');
49
79
  }
50
80
 
51
- export function panLawCode(panLaw: PanLaw | number): number {
52
- return resolveEnumOrdinal(panLaw, PAN_LAW_VALUES, 'pan law');
81
+ export function panLawCode(panLaw: PanLawInput): number {
82
+ const normalized = typeof panLaw === 'string' ? panLaw.toLowerCase().replace(/_/g, '-') : panLaw;
83
+ return resolveEnumOrdinal(normalized, PAN_LAW_VALUES, 'pan law');
53
84
  }
54
85
 
55
86
  export function panModeCode(panMode: PanMode | number): number {
@@ -69,3 +100,8 @@ export function sendTimingCode(timing: SendTiming | number): number {
69
100
  // matching the sibling enum-code helpers and Node's sendTimingValue.
70
101
  return resolveEnumOrdinal(timing, SEND_TIMING_VALUES, 'send timing');
71
102
  }
103
+
104
+ /** Resolve a per-track PFL/AFL monitor mode to its C-ABI ordinal. */
105
+ export function trackMonitorModeCode(mode: unknown): number {
106
+ return resolveEnumOrdinal(mode, TRACK_MONITOR_MODE_VALUES, 'track monitor mode');
107
+ }
@@ -14,12 +14,65 @@ function requireModule() {
14
14
  return getSonareModule();
15
15
  }
16
16
 
17
+ function resolveEffectFftOptions(
18
+ fnName: string,
19
+ nFft: unknown,
20
+ hopLength: unknown,
21
+ ): { nFft: number; hopLength: number } {
22
+ const resolvedNFft = nFft === undefined ? 2048 : nFft;
23
+ const resolvedHopLength = hopLength === undefined ? 512 : hopLength;
24
+ if (typeof resolvedNFft !== 'number' || !Number.isInteger(resolvedNFft)) {
25
+ throw new TypeError(`${fnName}: nFft must be an integer`);
26
+ }
27
+ if (resolvedNFft < 2 || resolvedNFft > 2 ** 30) {
28
+ throw new RangeError(`${fnName}: nFft must be an even power of two >= 2`);
29
+ }
30
+ if ((resolvedNFft & (resolvedNFft - 1)) !== 0) {
31
+ throw new RangeError(`${fnName}: nFft must be an even power of two >= 2`);
32
+ }
33
+ if (typeof resolvedHopLength !== 'number' || !Number.isInteger(resolvedHopLength)) {
34
+ throw new TypeError(`${fnName}: hopLength must be an integer`);
35
+ }
36
+ if (resolvedHopLength <= 0 || resolvedHopLength > 2 ** 31 - 1) {
37
+ throw new RangeError(`${fnName}: hopLength must be a positive integer`);
38
+ }
39
+ return { nFft: resolvedNFft, hopLength: resolvedHopLength };
40
+ }
41
+
42
+ export type NormalizeMode = 'peak' | 'rms';
43
+
44
+ function resolveNormalizeMode(value: unknown): NormalizeMode {
45
+ if (value === undefined) {
46
+ return 'peak';
47
+ }
48
+ if (typeof value !== 'string') {
49
+ throw new TypeError("normalize: mode must be the string 'peak' or 'rms'");
50
+ }
51
+ if (value !== 'peak' && value !== 'rms') {
52
+ throw new RangeError("normalize: mode must be the string 'peak' or 'rms'");
53
+ }
54
+ return value;
55
+ }
56
+
57
+ function resolveHardMask(value: unknown, fnName: string): boolean {
58
+ if (value === undefined) {
59
+ return false;
60
+ }
61
+ if (typeof value !== 'boolean') {
62
+ throw new TypeError(`${fnName}: hardMask must be a boolean`);
63
+ }
64
+ return value;
65
+ }
66
+
17
67
  /** Canonical request form for HPSS. */
18
68
  export interface HpssRequest {
19
69
  samples: Float32Array;
20
70
  sampleRate?: number;
21
71
  kernelHarmonic?: number;
22
72
  kernelPercussive?: number;
73
+ nFft?: number;
74
+ hopLength?: number;
75
+ hardMask?: boolean;
23
76
  }
24
77
 
25
78
  export interface HarmonicRequest extends ValidateOptions {
@@ -36,12 +89,16 @@ export interface TimeStretchRequest extends ValidateOptions {
36
89
  samples: Float32Array;
37
90
  sampleRate?: number;
38
91
  rate: number;
92
+ nFft?: number;
93
+ hopLength?: number;
39
94
  }
40
95
 
41
96
  export interface PitchShiftRequest extends ValidateOptions {
42
97
  samples: Float32Array;
43
98
  sampleRate?: number;
44
99
  semitones: number;
100
+ nFft?: number;
101
+ hopLength?: number;
45
102
  }
46
103
 
47
104
  export interface PitchCorrectToMidiRequest extends ValidateOptions {
@@ -81,6 +138,7 @@ export interface NormalizeRequest extends ValidateOptions {
81
138
  samples: Float32Array;
82
139
  sampleRate?: number;
83
140
  targetDb?: number;
141
+ mode?: NormalizeMode;
84
142
  }
85
143
 
86
144
  export interface SpectralEditRequest extends SpectralEditOptions, ValidateOptions {
@@ -108,22 +166,33 @@ export function hpss(
108
166
  sampleRate?: number,
109
167
  kernelHarmonic?: number,
110
168
  kernelPercussive?: number,
169
+ nFft?: number,
170
+ hopLength?: number,
171
+ hardMask?: boolean,
111
172
  ): HpssResult;
112
173
  export function hpss(
113
174
  samples: Float32Array | HpssRequest,
114
175
  sampleRate = 22050,
115
176
  kernelHarmonic = 31,
116
177
  kernelPercussive = 31,
178
+ nFft?: number,
179
+ hopLength?: number,
180
+ hardMask?: boolean,
117
181
  ): HpssResult {
118
182
  const request =
119
183
  samples instanceof Float32Array
120
- ? { samples, sampleRate, kernelHarmonic, kernelPercussive }
184
+ ? { samples, sampleRate, kernelHarmonic, kernelPercussive, nFft, hopLength, hardMask }
121
185
  : samples;
122
- return requireModule().hpss(
186
+ const fftOptions = resolveEffectFftOptions('hpss', request.nFft, request.hopLength);
187
+ const resolvedHardMask = resolveHardMask(request.hardMask, 'hpss');
188
+ return requireModule().hpssEx(
123
189
  request.samples,
124
190
  request.sampleRate ?? 22050,
125
191
  request.kernelHarmonic ?? 31,
126
192
  request.kernelPercussive ?? 31,
193
+ fftOptions.nFft,
194
+ fftOptions.hopLength,
195
+ resolvedHardMask,
127
196
  );
128
197
  }
129
198
 
@@ -188,18 +257,56 @@ export function timeStretch(
188
257
  rate: number,
189
258
  options?: ValidateOptions,
190
259
  ): Float32Array;
260
+ export function timeStretch(
261
+ samples: Float32Array,
262
+ sampleRate: number,
263
+ rate: number,
264
+ nFft?: number,
265
+ hopLength?: number,
266
+ options?: ValidateOptions,
267
+ ): Float32Array;
191
268
  export function timeStretch(
192
269
  samples: Float32Array | TimeStretchRequest,
193
270
  sampleRate?: number,
194
271
  rate?: number,
272
+ nFftOrOptions?: number | ValidateOptions,
273
+ hopLength?: number,
195
274
  options: ValidateOptions = {},
196
275
  ): Float32Array {
276
+ if (
277
+ nFftOrOptions !== undefined &&
278
+ nFftOrOptions !== null &&
279
+ typeof nFftOrOptions !== 'number' &&
280
+ typeof nFftOrOptions !== 'object'
281
+ ) {
282
+ throw new TypeError('timeStretch: nFft must be an integer or options object');
283
+ }
284
+ if (nFftOrOptions === null) {
285
+ throw new TypeError('timeStretch: nFft must be an integer or options object');
286
+ }
287
+ const positionalOptions =
288
+ typeof nFftOrOptions === 'object' && nFftOrOptions !== null ? nFftOrOptions : options;
289
+ const positionalNFft = typeof nFftOrOptions === 'number' ? nFftOrOptions : undefined;
197
290
  const request: TimeStretchRequest =
198
291
  samples instanceof Float32Array
199
- ? { samples, sampleRate, rate: rate as number, ...options }
292
+ ? {
293
+ samples,
294
+ sampleRate,
295
+ rate: rate as number,
296
+ nFft: positionalNFft,
297
+ hopLength,
298
+ ...positionalOptions,
299
+ }
200
300
  : samples;
201
301
  assertSamples('timeStretch', request.samples, request.validate !== false);
202
- return requireModule().timeStretch(request.samples, request.sampleRate ?? 22050, request.rate);
302
+ const fftOptions = resolveEffectFftOptions('timeStretch', request.nFft, request.hopLength);
303
+ return requireModule().timeStretchEx(
304
+ request.samples,
305
+ request.sampleRate ?? 22050,
306
+ request.rate,
307
+ fftOptions.nFft,
308
+ fftOptions.hopLength,
309
+ );
203
310
  }
204
311
 
205
312
  /**
@@ -217,21 +324,55 @@ export function pitchShift(
217
324
  semitones: number,
218
325
  options?: ValidateOptions,
219
326
  ): Float32Array;
327
+ export function pitchShift(
328
+ samples: Float32Array,
329
+ sampleRate: number,
330
+ semitones: number,
331
+ nFft?: number,
332
+ hopLength?: number,
333
+ options?: ValidateOptions,
334
+ ): Float32Array;
220
335
  export function pitchShift(
221
336
  samples: Float32Array | PitchShiftRequest,
222
337
  sampleRate?: number,
223
338
  semitones?: number,
339
+ nFftOrOptions?: number | ValidateOptions,
340
+ hopLength?: number,
224
341
  options: ValidateOptions = {},
225
342
  ): Float32Array {
343
+ if (
344
+ nFftOrOptions !== undefined &&
345
+ nFftOrOptions !== null &&
346
+ typeof nFftOrOptions !== 'number' &&
347
+ typeof nFftOrOptions !== 'object'
348
+ ) {
349
+ throw new TypeError('pitchShift: nFft must be an integer or options object');
350
+ }
351
+ if (nFftOrOptions === null) {
352
+ throw new TypeError('pitchShift: nFft must be an integer or options object');
353
+ }
354
+ const positionalOptions =
355
+ typeof nFftOrOptions === 'object' && nFftOrOptions !== null ? nFftOrOptions : options;
356
+ const positionalNFft = typeof nFftOrOptions === 'number' ? nFftOrOptions : undefined;
226
357
  const request: PitchShiftRequest =
227
358
  samples instanceof Float32Array
228
- ? { samples, sampleRate, semitones: semitones as number, ...options }
359
+ ? {
360
+ samples,
361
+ sampleRate,
362
+ semitones: semitones as number,
363
+ nFft: positionalNFft,
364
+ hopLength,
365
+ ...positionalOptions,
366
+ }
229
367
  : samples;
230
368
  assertSamples('pitchShift', request.samples, request.validate !== false);
231
- return requireModule().pitchShift(
369
+ const fftOptions = resolveEffectFftOptions('pitchShift', request.nFft, request.hopLength);
370
+ return requireModule().pitchShiftEx(
232
371
  request.samples,
233
372
  request.sampleRate ?? 22050,
234
373
  request.semitones,
374
+ fftOptions.nFft,
375
+ fftOptions.hopLength,
235
376
  );
236
377
  }
237
378
 
@@ -466,11 +607,13 @@ export function noteMove(
466
607
  }
467
608
 
468
609
  /**
469
- * Normalize audio to target peak level.
610
+ * Normalize audio to a target peak or RMS level.
470
611
  *
471
612
  * @param samples - Audio samples (mono, float32)
472
613
  * @param sampleRate - Sample rate in Hz (default: 22050)
473
- * @param targetDb - Finite target at or below 0 dBFS (default: 0 dB = full scale)
614
+ * @param targetDb - Finite target at or below 0 dBFS (default: 0 dB = full scale).
615
+ * For `mode: 'peak'`, this is the peak target; for `mode: 'rms'`, this is the RMS target.
616
+ * @param mode - Normalization mode: `'peak'` (default) or `'rms'`.
474
617
  * @returns Normalized audio
475
618
  */
476
619
  export function normalize(request: NormalizeRequest): Float32Array;
@@ -480,19 +623,45 @@ export function normalize(
480
623
  targetDb?: number,
481
624
  options?: ValidateOptions,
482
625
  ): Float32Array;
626
+ export function normalize(
627
+ samples: Float32Array,
628
+ sampleRate: number,
629
+ targetDb?: number,
630
+ mode?: NormalizeMode,
631
+ options?: ValidateOptions,
632
+ ): Float32Array;
483
633
  export function normalize(
484
634
  samples: Float32Array | NormalizeRequest,
485
635
  sampleRate?: number,
486
636
  targetDb = 0.0,
637
+ modeOrOptions: NormalizeMode | ValidateOptions = 'peak',
487
638
  options: ValidateOptions = {},
488
639
  ): Float32Array {
640
+ if (
641
+ modeOrOptions !== undefined &&
642
+ modeOrOptions !== null &&
643
+ typeof modeOrOptions !== 'string' &&
644
+ typeof modeOrOptions !== 'object'
645
+ ) {
646
+ throw new TypeError("normalize: mode must be the string 'peak' or 'rms'");
647
+ }
648
+ if (modeOrOptions === null) {
649
+ throw new TypeError("normalize: mode must be the string 'peak' or 'rms'");
650
+ }
651
+ const positionalOptions =
652
+ typeof modeOrOptions === 'object' && modeOrOptions !== null ? modeOrOptions : options;
653
+ const positionalMode = typeof modeOrOptions === 'string' ? modeOrOptions : undefined;
489
654
  const request: NormalizeRequest =
490
- samples instanceof Float32Array ? { samples, sampleRate, targetDb, ...options } : samples;
655
+ samples instanceof Float32Array
656
+ ? { samples, sampleRate, targetDb, mode: positionalMode, ...positionalOptions }
657
+ : samples;
491
658
  assertSamples('normalize', request.samples, request.validate !== false);
492
- return requireModule().normalize(
659
+ const mode = resolveNormalizeMode(request.mode);
660
+ return requireModule().normalizeEx(
493
661
  request.samples,
494
662
  request.sampleRate ?? 22050,
495
663
  request.targetDb ?? 0.0,
664
+ mode,
496
665
  );
497
666
  }
498
667
 
@@ -103,6 +103,7 @@ export interface NnlsChromaRequest extends GuardedOptions {
103
103
  enableStftBlend?: boolean;
104
104
  stftBlendWeight?: number;
105
105
  stftBlendNFft?: number;
106
+ hopLength?: number;
106
107
  }
107
108
 
108
109
  function validateMusicSamples(
@@ -156,12 +157,18 @@ export function nnlsChroma(
156
157
  return nnlsChroma(samples.samples, samples.sampleRate, samples);
157
158
  }
158
159
  validateMusicSamples('nnlsChroma', samples, sampleRate, options);
159
- return requireModule().nnlsChroma(
160
+ const hopLength = options.hopLength === undefined ? 512 : options.hopLength;
161
+ assertPositiveInteger('nnlsChroma', hopLength, 'hopLength');
162
+ if (hopLength > 2 ** 31 - 1) {
163
+ throw new RangeError('nnlsChroma: hopLength must fit in a signed 32-bit integer');
164
+ }
165
+ return requireModule().nnlsChromaEx(
160
166
  samples,
161
167
  sampleRate,
162
168
  options.enableStftBlend ?? true,
163
169
  options.stftBlendWeight ?? 0.55,
164
170
  options.stftBlendNFft ?? 4096,
171
+ hopLength,
165
172
  );
166
173
  }
167
174
 
@@ -19,6 +19,41 @@ function requireModule() {
19
19
  return getSonareModule();
20
20
  }
21
21
 
22
+ function resolveEffectFftOptions(
23
+ fnName: string,
24
+ nFft: unknown,
25
+ hopLength: unknown,
26
+ ): { nFft: number; hopLength: number } {
27
+ const resolvedNFft = nFft === undefined ? 2048 : nFft;
28
+ const resolvedHopLength = hopLength === undefined ? 512 : hopLength;
29
+ if (typeof resolvedNFft !== 'number' || !Number.isInteger(resolvedNFft)) {
30
+ throw new TypeError(`${fnName}: nFft must be an integer`);
31
+ }
32
+ if (resolvedNFft < 2 || resolvedNFft > 2 ** 30) {
33
+ throw new RangeError(`${fnName}: nFft must be an even power of two >= 2`);
34
+ }
35
+ if ((resolvedNFft & (resolvedNFft - 1)) !== 0) {
36
+ throw new RangeError(`${fnName}: nFft must be an even power of two >= 2`);
37
+ }
38
+ if (typeof resolvedHopLength !== 'number' || !Number.isInteger(resolvedHopLength)) {
39
+ throw new TypeError(`${fnName}: hopLength must be an integer`);
40
+ }
41
+ if (resolvedHopLength <= 0 || resolvedHopLength > 2 ** 31 - 1) {
42
+ throw new RangeError(`${fnName}: hopLength must be a positive integer`);
43
+ }
44
+ return { nFft: resolvedNFft, hopLength: resolvedHopLength };
45
+ }
46
+
47
+ function resolveHardMask(fnName: string, value: unknown): boolean {
48
+ if (value === undefined) {
49
+ return false;
50
+ }
51
+ if (typeof value !== 'boolean') {
52
+ throw new TypeError(`${fnName}: hardMask must be a boolean`);
53
+ }
54
+ return value;
55
+ }
56
+
22
57
  /** Canonical request form for frame-based spectral feature extraction. */
23
58
  export interface SpectralFrameRequest {
24
59
  samples: Float32Array;
@@ -166,6 +201,9 @@ export interface HpssWithResidualRequest {
166
201
  sampleRate?: number;
167
202
  kernelHarmonic?: number;
168
203
  kernelPercussive?: number;
204
+ nFft?: number;
205
+ hopLength?: number;
206
+ hardMask?: boolean;
169
207
  }
170
208
  export interface LufsInterleavedRequest extends ValidateOptions {
171
209
  samples: Float32Array;
@@ -542,18 +580,42 @@ export function hpssWithResidual(
542
580
  sampleRate?: number,
543
581
  kernelHarmonic?: number,
544
582
  kernelPercussive?: number,
583
+ nFft?: number,
584
+ hopLength?: number,
585
+ hardMask?: boolean,
545
586
  ): WasmHpssWithResidualResult;
546
587
  export function hpssWithResidual(
547
588
  samples: Float32Array | HpssWithResidualRequest,
548
589
  sampleRate = 22050,
549
590
  kernelHarmonic = 31,
550
591
  kernelPercussive = 31,
592
+ nFft?: number,
593
+ hopLength?: number,
594
+ hardMask?: boolean,
551
595
  ): WasmHpssWithResidualResult {
552
596
  if (!(samples instanceof Float32Array)) {
553
597
  const r = samples;
554
- return hpssWithResidual(r.samples, r.sampleRate, r.kernelHarmonic, r.kernelPercussive);
598
+ return hpssWithResidual(
599
+ r.samples,
600
+ r.sampleRate,
601
+ r.kernelHarmonic,
602
+ r.kernelPercussive,
603
+ r.nFft,
604
+ r.hopLength,
605
+ r.hardMask,
606
+ );
555
607
  }
556
- return requireModule().hpssWithResidual(samples, sampleRate, kernelHarmonic, kernelPercussive);
608
+ const fftOptions = resolveEffectFftOptions('hpssWithResidual', nFft, hopLength);
609
+ const resolvedHardMask = resolveHardMask('hpssWithResidual', hardMask);
610
+ return requireModule().hpssWithResidualEx(
611
+ samples,
612
+ sampleRate,
613
+ kernelHarmonic,
614
+ kernelPercussive,
615
+ fftOptions.nFft,
616
+ fftOptions.hopLength,
617
+ resolvedHardMask,
618
+ );
557
619
  }
558
620
 
559
621
  /**