@libraz/libsonare 1.5.0 → 1.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@libraz/libsonare",
3
- "version": "1.5.0",
3
+ "version": "1.5.1",
4
4
  "type": "module",
5
5
  "packageManager": "yarn@4.15.0",
6
6
  "description": "Audio analysis library for music information retrieval",
@@ -47,8 +47,12 @@ export interface ClipPageStreamerOptions {
47
47
  interface SourceState {
48
48
  source: ClipPageStreamSource;
49
49
  lastPage: number;
50
- /** Page indices currently supplied to the provider (the resident set). */
51
- resident: Set<number>;
50
+ /** Playback-window generation; advanced on reset or backward discontinuity. */
51
+ generation: number;
52
+ /** Most recently serviced playback frontier, or null before the first miss. */
53
+ lastFrontier: number | null;
54
+ /** Resident page -> generation, so in-flight stale fetches cannot alias. */
55
+ resident: Map<number, number>;
52
56
  }
53
57
 
54
58
  /**
@@ -92,18 +96,40 @@ export class ClipPageStreamer {
92
96
  throw new Error('pageFrames and numSamples must be positive');
93
97
  }
94
98
  const lastPage = Math.ceil(source.numSamples / source.pageFrames) - 1;
99
+ const previous = this.sources.get(source.clipId);
100
+ if (previous) {
101
+ this.resetState(previous);
102
+ }
95
103
  this.sources.set(source.clipId, {
96
104
  source,
97
105
  lastPage,
98
- resident: new Set(initialResidentPages),
106
+ generation: 0,
107
+ lastFrontier: null,
108
+ resident: new Map(Array.from(initialResidentPages, (page) => [page, 0])),
99
109
  });
100
110
  }
101
111
 
102
112
  /** Stop tracking a clip. Does not close its binding (the caller owns that). */
103
113
  removeSource(clipId: number): void {
114
+ const state = this.sources.get(clipId);
115
+ if (state) {
116
+ this.resetState(state);
117
+ }
104
118
  this.sources.delete(clipId);
105
119
  }
106
120
 
121
+ /**
122
+ * Explicitly start a new playback generation after a host seek/loop. Resident
123
+ * pages are evicted and any older in-flight fetch is cleared when it settles.
124
+ * The next miss establishes the new bounded window.
125
+ */
126
+ resetSource(clipId: number): void {
127
+ const state = this.sources.get(clipId);
128
+ if (state) {
129
+ this.resetState(state);
130
+ }
131
+ }
132
+
107
133
  /**
108
134
  * Drain pending page-miss requests, fetch the missing pages plus their
109
135
  * read-ahead window, and evict out-of-window pages. Resolves once this round's
@@ -113,9 +139,10 @@ export class ClipPageStreamer {
113
139
  if (this.closed) {
114
140
  return;
115
141
  }
116
- // Collapse this round's misses to the furthest-advanced frontier per clip:
117
- // multiple channels of one clip miss the same page, and a run of misses
118
- // walks forward, so only the latest position needs servicing.
142
+ // Collapse this round's misses to the latest observed frontier per clip.
143
+ // Queue order reflects playback order, so a backward seek/loop request that
144
+ // follows a stale high-page miss must win even though its page index is
145
+ // smaller. Multiple channel misses still collapse to one service window.
119
146
  const frontiers = new Map<number, number>();
120
147
  for (let drained = 0; drained < this.maxRequestsPerPump; ++drained) {
121
148
  const request = this.engine.popClipPageRequest();
@@ -127,10 +154,7 @@ export class ClipPageStreamer {
127
154
  continue;
128
155
  }
129
156
  const page = Math.floor(request.sample / state.source.pageFrames);
130
- const previous = frontiers.get(request.clipId);
131
- if (previous === undefined || page > previous) {
132
- frontiers.set(request.clipId, page);
133
- }
157
+ frontiers.set(request.clipId, page);
134
158
  }
135
159
 
136
160
  const fetches: Promise<unknown>[] = [];
@@ -151,18 +175,27 @@ export class ClipPageStreamer {
151
175
  }
152
176
  this.closed = true;
153
177
  for (const state of this.sources.values()) {
178
+ this.resetState(state);
154
179
  state.source.binding.close();
155
180
  }
156
181
  this.sources.clear();
157
182
  }
158
183
 
159
184
  private serviceFrontier(state: SourceState, frontier: number): Promise<unknown>[] {
185
+ // A lower frontier after a previously serviced high page is a seek/loop
186
+ // discontinuity. Advance generation before scheduling its new window so an
187
+ // older asynchronous fetch can never become resident in the new window.
188
+ if (state.lastFrontier !== null && frontier < state.lastFrontier) {
189
+ this.resetState(state);
190
+ }
191
+ state.lastFrontier = frontier;
192
+ const generation = state.generation;
160
193
  const low = Math.max(0, frontier - this.retainBehindPages);
161
194
  const high = Math.min(state.lastPage, frontier + this.readAheadPages);
162
195
 
163
196
  // Evict pages outside the window first so a burst of fetches never exceeds
164
197
  // the bound by transiently holding old pages alongside new ones.
165
- for (const page of state.resident) {
198
+ for (const page of state.resident.keys()) {
166
199
  if (page < low || page > high) {
167
200
  state.source.binding.provider.clear(page);
168
201
  state.resident.delete(page);
@@ -171,23 +204,29 @@ export class ClipPageStreamer {
171
204
 
172
205
  const fetches: Promise<unknown>[] = [];
173
206
  for (let page = low; page <= high; ++page) {
174
- if (state.resident.has(page)) {
207
+ if (state.resident.get(page) === generation) {
175
208
  continue;
176
209
  }
177
210
  // Mark resident eagerly so the same page is not fetched twice across
178
211
  // overlapping windows; drop it again if the fetch reports a miss.
179
- state.resident.add(page);
212
+ state.resident.set(page, generation);
180
213
  const pageIndex = page;
181
214
  fetches.push(
182
215
  state.source.binding.supplyPage(pageIndex).then(
183
216
  (ok) => {
184
- if (!ok) {
217
+ if (state.generation !== generation) {
218
+ // The fetch crossed a seek/reset boundary. supplyPage may have
219
+ // installed it after resetState's clear pass, so clear it again.
220
+ state.source.binding.provider.clear(pageIndex);
221
+ } else if (!ok && state.resident.get(pageIndex) === generation) {
185
222
  state.resident.delete(pageIndex);
186
223
  }
187
224
  return ok;
188
225
  },
189
226
  (error) => {
190
- state.resident.delete(pageIndex);
227
+ if (state.resident.get(pageIndex) === generation) {
228
+ state.resident.delete(pageIndex);
229
+ }
191
230
  throw error;
192
231
  },
193
232
  ),
@@ -195,6 +234,15 @@ export class ClipPageStreamer {
195
234
  }
196
235
  return fetches;
197
236
  }
237
+
238
+ private resetState(state: SourceState): void {
239
+ state.generation += 1;
240
+ state.lastFrontier = null;
241
+ for (const page of state.resident.keys()) {
242
+ state.source.binding.provider.clear(page);
243
+ }
244
+ state.resident.clear();
245
+ }
198
246
  }
199
247
 
200
248
  export interface OpfsClipStreamOptions extends OpfsClipPageProviderOptions {
@@ -40,9 +40,6 @@ export function voiceChange(
40
40
 
41
41
  /** Options for the offline {@link voiceChangeRealtime} convenience wrapper. */
42
42
  export interface VoiceChangeRealtimeOptions extends ValidateOptions {
43
- sampleRate?: number;
44
- /** Voice-changer preset id or full config object. */
45
- preset?: RealtimeVoiceChangerConfigInput;
46
43
  /** Channel count (1 = mono, 2 = interleaved stereo). */
47
44
  channels?: 1 | 2;
48
45
  /** Block size for the internal render loop (default 512). */
@@ -90,10 +87,16 @@ function latencyCompensatedVoiceChange(
90
87
  * and Node `voiceChangeRealtime` convenience wrappers. For mono, `samples` is a
91
88
  * plain mono buffer; for stereo, `samples` is interleaved (L0,R0,L1,R1,...).
92
89
  *
90
+ * @param samples - Audio samples (mono, or interleaved stereo when channels=2)
91
+ * @param sampleRate - Sample rate in Hz (default 48000, matching Python/Node)
92
+ * @param preset - Voice-changer preset id or full config object
93
+ * @param options - Channel count and block size ({@link VoiceChangeRealtimeOptions})
93
94
  * @returns The processed buffer (same layout/length as the input).
94
95
  */
95
96
  export function voiceChangeRealtime(
96
97
  samples: Float32Array,
98
+ sampleRate = 48000,
99
+ preset: RealtimeVoiceChangerConfigInput = 'neutral-monitor',
97
100
  options: VoiceChangeRealtimeOptions = {},
98
101
  ): Float32Array {
99
102
  assertSamples('voiceChangeRealtime', samples, options.validate !== false);
@@ -104,11 +107,8 @@ export function voiceChangeRealtime(
104
107
  if (channels === 2 && samples.length % 2 !== 0) {
105
108
  throw new Error('voiceChangeRealtime: stereo input length must be a multiple of 2.');
106
109
  }
107
- // 48000 matches the Python voice_change_realtime and Node voiceChangeRealtime
108
- // convenience wrappers (and the RealtimeVoiceChanger default).
109
- const sampleRate = options.sampleRate ?? 48000;
110
110
  const blockSize = Math.max(1, Math.floor(options.blockSize ?? 512));
111
- const changer = new RealtimeVoiceChanger(options.preset ?? 'neutral-monitor');
111
+ const changer = new RealtimeVoiceChanger(preset);
112
112
  try {
113
113
  changer.prepare(sampleRate, blockSize, channels);
114
114
  return latencyCompensatedVoiceChange(changer, samples, channels, blockSize);
@@ -366,19 +366,22 @@ export function fourierTempogram(
366
366
  * @param winLength - Window length in frames (default: 384)
367
367
  * @param sampleRate - Sample rate in Hz (default: 22050)
368
368
  * @param hopLength - Hop length (default: 512)
369
- * @returns Tempogram ratio features
369
+ * @param factors - Lag ratios to evaluate. When omitted or empty, the library
370
+ * default {0.5, 1, 2, 3, 4} is used.
371
+ * @returns Tempogram ratio features (one value per factor)
370
372
  */
371
373
  export function tempogramRatio(
372
374
  tempogramData: Float32Array,
373
375
  winLength = 384,
374
376
  sampleRate = 22050,
375
377
  hopLength = 512,
378
+ factors?: Float32Array | number[],
376
379
  options: GuardedOptions = {},
377
380
  ): Float32Array {
378
381
  assertSampleRate('tempogramRatio', sampleRate);
379
382
  assertSamples('tempogramRatio', tempogramData, options.validate !== false, 'tempogramData');
380
383
  validatePositiveIntegers('tempogramRatio', { winLength, hopLength });
381
- return requireModule().tempogramRatio(tempogramData, winLength, sampleRate, hopLength);
384
+ return requireModule().tempogramRatio(tempogramData, winLength, sampleRate, hopLength, factors);
382
385
  }
383
386
 
384
387
  /**
package/src/index.ts CHANGED
@@ -561,6 +561,18 @@ export function version(): string {
561
561
  return module.version();
562
562
  }
563
563
 
564
+ /**
565
+ * Aggregate native ABI version: the per-subsystem ABI macros folded into one
566
+ * 32-bit value. It bumps whenever any flat C POD layout changes, so callers can
567
+ * detect an incompatible prebuilt binary. Matches the Node/Python `abiVersion()`.
568
+ */
569
+ export function abiVersion(): number {
570
+ if (!module) {
571
+ throw new Error('Module not initialized. Call init() first.');
572
+ }
573
+ return module.abiVersion();
574
+ }
575
+
564
576
  export function engineAbiVersion(): number {
565
577
  if (!module) {
566
578
  throw new Error('Module not initialized. Call init() first.');
package/src/mixer.ts CHANGED
@@ -522,6 +522,14 @@ export class Mixer {
522
522
  return this.mixer.tailSamples();
523
523
  }
524
524
 
525
+ /**
526
+ * Reported latency (samples) of the compiled mixer graph, for aligning
527
+ * dry/wet material. Lazily compiles the routing graph if the topology is dirty.
528
+ */
529
+ latencySamples(): number {
530
+ return this.mixer.latencySamples();
531
+ }
532
+
525
533
  /**
526
534
  * Drain delayed / tail audio by processing a zero-input block of `numSamples`
527
535
  * frames after the host stops feeding strip inputs. Returns the mixed stereo
@@ -542,8 +542,10 @@ export class Project {
542
542
  * built-in oscillator synth so a MIDI-only arrangement bounces to audible
543
543
  * audio. Pass a {@link BuiltinSynthBinding} (or an array of them) to choose
544
544
  * the patch and MIDI destination; omit it (or pass `{}`) for one
545
- * default-destination sine patch. An explicitly empty array `[]` (or
546
- * `undefined` / `null`) produces zero bindings, so MIDI tracks render silently.
545
+ * default-destination sine patch. Because the parameter defaults to `{}`,
546
+ * omission and explicit `undefined` both create that one default binding.
547
+ * Use an explicitly empty array `[]` (or runtime `null`) for zero bindings,
548
+ * so MIDI tracks render silently.
547
549
  *
548
550
  * Like {@link bounce}, omitting `totalFrames` auto-derives the render length
549
551
  * from the arrangement plus the synth's release tail.
@@ -572,9 +574,10 @@ export class Project {
572
574
  * string (`'saw-lead'` / `'va:saw-lead'`; see {@link synthPresetNames}), or
573
575
  * an array of either; each object entry may carry a `destinationId` binding
574
576
  * convenience (default 0), which is not part of the NativeSynth patch itself.
575
- * An explicitly empty array (or `undefined` / `null`) produces zero bindings.
576
- * Unknown preset names throw. Deterministic for a fixed project + options +
577
- * patch.
577
+ * Because the parameter defaults to `{}`, omission and explicit `undefined`
578
+ * both create one default binding. Use an explicitly empty array `[]` (or
579
+ * runtime `null`) for zero bindings. Unknown preset names throw.
580
+ * Deterministic for a fixed project + options + patch.
578
581
  */
579
582
  bounceWithSynthInstrument(
580
583
  instrument: SynthPatch | string | ReadonlyArray<SynthPatch | string> = {},
@@ -624,8 +627,10 @@ export class Project {
624
627
  * honored. Programs the SoundFont does not cover — including bouncing with
625
628
  * no SoundFont loaded at all — play through the built-in synthesizer GM
626
629
  * fallback bank (the data-free floor; see {@link soundFontManifest} for the
627
- * per-program backend). An explicitly empty array `[]` (or `undefined` /
628
- * `null`) produces zero bindings, so MIDI tracks render silently.
630
+ * per-program backend). Because the parameter defaults to `{}`, omission and
631
+ * explicit `undefined` both create one default binding. Use an explicitly
632
+ * empty array `[]` (or runtime `null`) for zero bindings, so MIDI tracks
633
+ * render silently.
629
634
  */
630
635
  bounceWithSf2Instrument(
631
636
  instrument: Sf2InstrumentConfig | ReadonlyArray<Sf2InstrumentConfig> = {},
@@ -815,6 +820,11 @@ export class Project {
815
820
  return this.native.trackCount();
816
821
  }
817
822
 
823
+ /** Number of clips in the project. */
824
+ clipCount(): number {
825
+ return this.native.clipCount();
826
+ }
827
+
818
828
  /** Number of audio sources registered on the project. */
819
829
  sourceCount(): number {
820
830
  return this.native.sourceCount();
@@ -146,6 +146,7 @@ export interface WasmProject {
146
146
  markerByIndex: (index: number) => ProjectMarker;
147
147
  markerCount: () => number;
148
148
  trackCount: () => number;
149
+ clipCount: () => number;
149
150
  sourceCount: () => number;
150
151
  tempoSegmentCount: () => number;
151
152
  timeSignatureCount: () => number;
@@ -117,47 +117,47 @@ export interface RealtimeVoiceChangerPreset {
117
117
  export type RealtimeVoiceChangerConfigInput = VoicePresetId | RealtimeVoiceChangerPreset;
118
118
 
119
119
  /**
120
- * Flat (POD) realtime voice-changer configuration. Field names mirror the
121
- * C ABI `SonareRealtimeVoiceChangerConfig` / Python POD exactly (snake_case),
122
- * so a config can be round-tripped across bindings without renaming.
120
+ * Flat (POD) realtime voice-changer configuration. Keys are camelCase to match
121
+ * the Node addon getter, so a preset config reads identically across both JS
122
+ * surfaces (the underlying C ABI / Python POD uses the snake_case equivalents).
123
123
  */
124
124
  export interface RealtimeVoiceChangerPodConfig {
125
- input_gain_db: number;
126
- output_gain_db: number;
127
- wet_mix: number;
128
- retune_semitones: number;
129
- retune_mix: number;
130
- retune_grain_size: number;
131
- formant_factor: number;
132
- formant_amount: number;
133
- formant_body: number;
134
- formant_brightness: number;
135
- formant_nasal: number;
136
- eq_highpass_hz: number;
137
- eq_body_db: number;
138
- eq_presence_db: number;
139
- eq_air_db: number;
140
- gate_threshold_db: number;
141
- gate_attack_ms: number;
142
- gate_release_ms: number;
143
- gate_range_db: number;
144
- compressor_threshold_db: number;
145
- compressor_ratio: number;
146
- compressor_attack_ms: number;
147
- compressor_release_ms: number;
148
- compressor_makeup_gain_db: number;
149
- deesser_frequency_hz: number;
150
- deesser_threshold_db: number;
151
- deesser_ratio: number;
152
- deesser_range_db: number;
153
- reverb_mix: number;
154
- reverb_time_ms: number;
155
- reverb_damping: number;
156
- reverb_seed: number;
157
- limiter_ceiling_db: number;
158
- limiter_release_ms: number;
125
+ inputGainDb: number;
126
+ outputGainDb: number;
127
+ wetMix: number;
128
+ retuneSemitones: number;
129
+ retuneMix: number;
130
+ retuneGrainSize: number;
131
+ formantFactor: number;
132
+ formantAmount: number;
133
+ formantBody: number;
134
+ formantBrightness: number;
135
+ formantNasal: number;
136
+ eqHighpassHz: number;
137
+ eqBodyDb: number;
138
+ eqPresenceDb: number;
139
+ eqAirDb: number;
140
+ gateThresholdDb: number;
141
+ gateAttackMs: number;
142
+ gateReleaseMs: number;
143
+ gateRangeDb: number;
144
+ compressorThresholdDb: number;
145
+ compressorRatio: number;
146
+ compressorAttackMs: number;
147
+ compressorReleaseMs: number;
148
+ compressorMakeupGainDb: number;
149
+ deesserFrequencyHz: number;
150
+ deesserThresholdDb: number;
151
+ deesserRatio: number;
152
+ deesserRangeDb: number;
153
+ reverbMix: number;
154
+ reverbTimeMs: number;
155
+ reverbDamping: number;
156
+ reverbSeed: number;
157
+ limiterCeilingDb: number;
158
+ limiterReleaseMs: number;
159
159
  /** Non-zero enables the 4x-oversampled inter-sample-peak limiter (default enabled). */
160
- limiter_enable_isp_limiter: boolean;
160
+ limiterEnableIspLimiter: boolean;
161
161
  /** True-peak ceiling in dBTP applied by the ISP limiter (default -1.0). */
162
- limiter_isp_ceiling_dbtp: number;
162
+ limiterIspCeilingDbtp: number;
163
163
  }
@@ -26,45 +26,45 @@ export interface WasmKeyCandidateResult {
26
26
  correlation: number;
27
27
  }
28
28
 
29
- // Flat POD mirror of SonareRealtimeVoiceChangerConfig. Field names match the
30
- // C ABI / Python POD exactly (snake_case).
29
+ // Flat POD mirror of SonareRealtimeVoiceChangerConfig. Keys are camelCase to
30
+ // match the Node addon getter (the underlying C ABI / Python POD is snake_case).
31
31
  export interface WasmRealtimeVoiceChangerPodConfig {
32
- input_gain_db: number;
33
- output_gain_db: number;
34
- wet_mix: number;
35
- retune_semitones: number;
36
- retune_mix: number;
37
- retune_grain_size: number;
38
- formant_factor: number;
39
- formant_amount: number;
40
- formant_body: number;
41
- formant_brightness: number;
42
- formant_nasal: number;
43
- eq_highpass_hz: number;
44
- eq_body_db: number;
45
- eq_presence_db: number;
46
- eq_air_db: number;
47
- gate_threshold_db: number;
48
- gate_attack_ms: number;
49
- gate_release_ms: number;
50
- gate_range_db: number;
51
- compressor_threshold_db: number;
52
- compressor_ratio: number;
53
- compressor_attack_ms: number;
54
- compressor_release_ms: number;
55
- compressor_makeup_gain_db: number;
56
- deesser_frequency_hz: number;
57
- deesser_threshold_db: number;
58
- deesser_ratio: number;
59
- deesser_range_db: number;
60
- reverb_mix: number;
61
- reverb_time_ms: number;
62
- reverb_damping: number;
63
- reverb_seed: number;
64
- limiter_ceiling_db: number;
65
- limiter_release_ms: number;
66
- limiter_enable_isp_limiter: boolean;
67
- limiter_isp_ceiling_dbtp: number;
32
+ inputGainDb: number;
33
+ outputGainDb: number;
34
+ wetMix: number;
35
+ retuneSemitones: number;
36
+ retuneMix: number;
37
+ retuneGrainSize: number;
38
+ formantFactor: number;
39
+ formantAmount: number;
40
+ formantBody: number;
41
+ formantBrightness: number;
42
+ formantNasal: number;
43
+ eqHighpassHz: number;
44
+ eqBodyDb: number;
45
+ eqPresenceDb: number;
46
+ eqAirDb: number;
47
+ gateThresholdDb: number;
48
+ gateAttackMs: number;
49
+ gateReleaseMs: number;
50
+ gateRangeDb: number;
51
+ compressorThresholdDb: number;
52
+ compressorRatio: number;
53
+ compressorAttackMs: number;
54
+ compressorReleaseMs: number;
55
+ compressorMakeupGainDb: number;
56
+ deesserFrequencyHz: number;
57
+ deesserThresholdDb: number;
58
+ deesserRatio: number;
59
+ deesserRangeDb: number;
60
+ reverbMix: number;
61
+ reverbTimeMs: number;
62
+ reverbDamping: number;
63
+ reverbSeed: number;
64
+ limiterCeilingDb: number;
65
+ limiterReleaseMs: number;
66
+ limiterEnableIspLimiter: boolean;
67
+ limiterIspCeilingDbtp: number;
68
68
  }
69
69
 
70
70
  export interface WasmBeatResult {
@@ -194,6 +194,7 @@ export interface WasmStreamConfigDefaults {
194
194
  computeSpectral: boolean;
195
195
  emitEveryNFrames: number;
196
196
  magnitudeDownsample: number;
197
+ maxPendingFrames: number;
197
198
  keyUpdateIntervalSec: number;
198
199
  bpmUpdateIntervalSec: number;
199
200
  window: number;
@@ -1156,6 +1157,7 @@ export interface SonareModule {
1156
1157
  detectKeyCandidates: (samples: Float32Array, sampleRate: number) => WasmKeyCandidateResult[];
1157
1158
  hasFfmpegSupport: () => boolean;
1158
1159
  version: () => string;
1160
+ abiVersion: () => number;
1159
1161
  engineAbiVersion: () => number;
1160
1162
  voiceChangerAbiVersion: () => number;
1161
1163
  voiceCharacterPresetId: (preset: number) => string | null;
@@ -1948,6 +1950,7 @@ export interface SonareModule {
1948
1950
  winLength: number,
1949
1951
  sampleRate: number,
1950
1952
  hopLength: number,
1953
+ factors?: Float32Array | number[],
1951
1954
  ) => Float32Array;
1952
1955
  lufs: (samples: Float32Array, sampleRate: number) => WasmLufsResult;
1953
1956
  momentaryLufs: (samples: Float32Array, sampleRate: number) => Float32Array;
@@ -2024,6 +2027,7 @@ export interface SonareModule {
2024
2027
  computeSpectral: boolean,
2025
2028
  emitEveryNFrames: number,
2026
2029
  magnitudeDownsample: number,
2030
+ maxPendingFrames: number,
2027
2031
  keyUpdateIntervalSec: number,
2028
2032
  bpmUpdateIntervalSec: number,
2029
2033
  window: number,
@@ -2234,6 +2238,7 @@ export interface WasmMixer {
2234
2238
  vcaGroupCount: () => number;
2235
2239
  toSceneJson: () => string;
2236
2240
  tailSamples: () => number;
2241
+ latencySamples: () => number;
2237
2242
  drainTailStereo: (numSamples: number) => {
2238
2243
  left: Float32Array;
2239
2244
  right: Float32Array;
@@ -2297,6 +2302,8 @@ export interface WasmAnalyzerStats {
2297
2302
  totalFrames: number;
2298
2303
  totalSamples: number;
2299
2304
  durationSeconds: number;
2305
+ pendingFrames: number;
2306
+ droppedOutputFrames: number;
2300
2307
  estimate: WasmProgressiveEstimate;
2301
2308
  }
2302
2309
 
@@ -71,6 +71,7 @@ export class StreamAnalyzer {
71
71
  config.computeSpectral ?? defaults.computeSpectral,
72
72
  config.emitEveryNFrames ?? defaults.emitEveryNFrames,
73
73
  config.magnitudeDownsample ?? defaults.magnitudeDownsample,
74
+ config.maxPendingFrames ?? defaults.maxPendingFrames,
74
75
  config.keyUpdateIntervalSec ?? defaults.keyUpdateIntervalSec,
75
76
  config.bpmUpdateIntervalSec ?? defaults.bpmUpdateIntervalSec,
76
77
  config.window ?? defaults.window,
@@ -163,6 +164,8 @@ export class StreamAnalyzer {
163
164
  totalFrames: s.totalFrames,
164
165
  totalSamples: s.totalSamples,
165
166
  durationSeconds: s.durationSeconds,
167
+ pendingFrames: s.pendingFrames,
168
+ droppedOutputFrames: s.droppedOutputFrames,
166
169
  estimate: {
167
170
  bpm: s.estimate.bpm,
168
171
  bpmConfidence: s.estimate.bpmConfidence,
@@ -64,6 +64,8 @@ export interface AnalyzerStats {
64
64
  totalFrames: number;
65
65
  totalSamples: number;
66
66
  durationSeconds: number;
67
+ pendingFrames: number;
68
+ droppedOutputFrames: number;
67
69
  estimate: ProgressiveEstimate;
68
70
  }
69
71
 
@@ -159,6 +161,8 @@ export interface StreamConfig {
159
161
  computeSpectral?: boolean;
160
162
  emitEveryNFrames?: number;
161
163
  magnitudeDownsample?: number;
164
+ /** Maximum unread frames; overflow drops the oldest frame. */
165
+ maxPendingFrames?: number;
162
166
  keyUpdateIntervalSec?: number;
163
167
  bpmUpdateIntervalSec?: number;
164
168
  window?: number;