auditok 0.1.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/dist/split.js ADDED
@@ -0,0 +1,414 @@
1
+ /**
2
+ * High-level API: split audio into events, trim leading/trailing
3
+ * silence, normalize pauses. Environment-free — inputs are samples
4
+ * (in-memory or streamed); the io layers (Node, browser) only provide
5
+ * ways to obtain them.
6
+ */
7
+ import { AudioRegion, concatRegions, makeSilence } from "./region.js";
8
+ import { computeFrameEnergies, estimateEnergyThreshold, windowEnergy, } from "./signal.js";
9
+ import { StreamTokenizer } from "./tokenizer.js";
10
+ const DEFAULT_ANALYSIS_WINDOW = 0.05;
11
+ const DEFAULT_ENERGY_THRESHOLD = 50;
12
+ const DEFAULT_CALIBRATION_DUR = 3;
13
+ const DEFAULT_MIN_ENERGY_THRESHOLD = 40;
14
+ const EPSILON = 1e-10;
15
+ function isAudioBufferLike(input) {
16
+ return (typeof input.getChannelData === "function" &&
17
+ typeof input.sampleRate === "number");
18
+ }
19
+ function normalizeInput(input, options) {
20
+ const optionRate = options.sampleRate;
21
+ if (input instanceof Float32Array) {
22
+ if (optionRate === undefined) {
23
+ throw new TypeError("'sampleRate' is required for Float32Array input");
24
+ }
25
+ return { kind: "memory", channelData: [input], sampleRate: optionRate };
26
+ }
27
+ if (Array.isArray(input)) {
28
+ if (optionRate === undefined) {
29
+ throw new TypeError("'sampleRate' is required for planar Float32Array[] input");
30
+ }
31
+ const length = input[0]?.length;
32
+ if (input.length === 0 || input.some((c) => c.length !== length)) {
33
+ throw new RangeError("planar input must be a non-empty array of equal-length " +
34
+ "channels; to stream chunks, pass a generator or any " +
35
+ "non-array iterable");
36
+ }
37
+ return { kind: "memory", channelData: input, sampleRate: optionRate };
38
+ }
39
+ if (input instanceof AudioRegion) {
40
+ return {
41
+ kind: "memory",
42
+ channelData: input.channelData,
43
+ sampleRate: input.sampleRate,
44
+ };
45
+ }
46
+ if (typeof input === "object" && input !== null) {
47
+ if (isAudioBufferLike(input)) {
48
+ const channelData = Array.from({ length: input.numberOfChannels }, (_, c) => input.getChannelData(c));
49
+ return { kind: "memory", channelData, sampleRate: input.sampleRate };
50
+ }
51
+ const withData = input;
52
+ if (withData.channelData !== undefined) {
53
+ const sampleRate = withData.sampleRate ?? optionRate;
54
+ if (sampleRate === undefined) {
55
+ throw new TypeError("input has 'channelData' but no 'sampleRate'");
56
+ }
57
+ return { kind: "memory", channelData: withData.channelData, sampleRate };
58
+ }
59
+ if (Symbol.asyncIterator in input ||
60
+ Symbol.iterator in input) {
61
+ if (optionRate === undefined) {
62
+ throw new TypeError("'sampleRate' is required for chunk-stream input");
63
+ }
64
+ return {
65
+ kind: "stream",
66
+ chunks: input,
67
+ sampleRate: optionRate,
68
+ channels: options.channels ?? 1,
69
+ };
70
+ }
71
+ }
72
+ throw new TypeError("unsupported input; expected Float32Array, Float32Array[], " +
73
+ "AudioRegion, { channelData, sampleRate }, AudioBuffer or an " +
74
+ "iterable of Float32Array chunks");
75
+ }
76
+ /** Analysis windows over in-memory planar data, as zero-copy views.
77
+ * The final partial window, if any, is included. */
78
+ function* memoryFrames(channelData, frameSamples) {
79
+ const total = channelData[0].length;
80
+ for (let start = 0; start < total; start += frameSamples) {
81
+ const end = Math.min(start + frameSamples, total);
82
+ yield channelData.map((channel) => channel.subarray(start, end));
83
+ }
84
+ }
85
+ /** Analysis windows over a stream of interleaved chunks. The final
86
+ * partial window, if any, is included. */
87
+ async function* streamFrames(chunks, channels, frameSamples) {
88
+ let frame = Array.from({ length: channels }, () => new Float32Array(frameSamples));
89
+ let filled = 0; // samples per channel currently in `frame`
90
+ let channelCursor = 0; // interleaving position within the current sample
91
+ for await (const chunk of chunks) {
92
+ for (let i = 0; i < chunk.length; i++) {
93
+ frame[channelCursor][filled] = chunk[i];
94
+ channelCursor += 1;
95
+ if (channelCursor === channels) {
96
+ channelCursor = 0;
97
+ filled += 1;
98
+ if (filled === frameSamples) {
99
+ yield frame;
100
+ frame = Array.from({ length: channels }, () => new Float32Array(frameSamples));
101
+ filled = 0;
102
+ }
103
+ }
104
+ }
105
+ }
106
+ if (filled > 0) {
107
+ yield frame.map((channel) => channel.subarray(0, filled));
108
+ }
109
+ }
110
+ const PERCENTILE_VALIDATOR_RE = /^p([1-9][0-9]?)$/;
111
+ function parseValidatorName(name) {
112
+ if (name === "otsu") {
113
+ return { method: "otsu" };
114
+ }
115
+ if (name === "percentile") {
116
+ return { method: "percentile" };
117
+ }
118
+ const match = PERCENTILE_VALIDATOR_RE.exec(name);
119
+ if (match !== null) {
120
+ return { method: "percentile", percentile: Number(match[1]) };
121
+ }
122
+ throw new RangeError(`unknown validator '${name}'; expected "otsu", "percentile" or ` +
123
+ `"pXX" with XX in [1, 99]`);
124
+ }
125
+ /** Port of Python auditok's duration-to-window-count conversion. */
126
+ function durationToWindowCount(duration, windowDur, round, epsilon = 0) {
127
+ if (duration < 0) {
128
+ throw new RangeError(`duration (${duration}) must be >= 0`);
129
+ }
130
+ if (duration === 0) {
131
+ return 0;
132
+ }
133
+ return round(duration / windowDur + epsilon);
134
+ }
135
+ /** Read the first `nFrames` analysis windows of a stream, estimate the
136
+ * energy threshold from them (clamped to `floor`), then replay them and
137
+ * continue with the rest — no data is lost to calibration. Mirrors
138
+ * Python auditok's live calibration. */
139
+ async function* calibrateFrames(frames, calibration) {
140
+ // manual iteration: a `break` out of for-await would close `frames`
141
+ const iterator = frames[Symbol.asyncIterator]();
142
+ const buffered = [];
143
+ const energies = [];
144
+ while (buffered.length < calibration.nFrames) {
145
+ const { value, done } = await iterator.next();
146
+ if (done === true) {
147
+ break;
148
+ }
149
+ buffered.push(value);
150
+ energies.push(windowEnergy(value, calibration.useChannel));
151
+ }
152
+ if (buffered.length === 0) {
153
+ throw new Error("no audio data could be read for energy threshold calibration");
154
+ }
155
+ const estimate = estimateEnergyThreshold(energies, calibration.method, calibration.percentile === undefined
156
+ ? {}
157
+ : { percentile: calibration.percentile });
158
+ // an infinite estimate means the calibration window is digitally
159
+ // silent (e.g., a muted microphone): fall back to the floor and
160
+ // keep listening
161
+ calibration.setThreshold(Number.isFinite(estimate)
162
+ ? Math.max(estimate, calibration.floor)
163
+ : calibration.floor);
164
+ yield* buffered;
165
+ while (true) {
166
+ const { value, done } = await iterator.next();
167
+ if (done === true) {
168
+ return;
169
+ }
170
+ yield value;
171
+ }
172
+ }
173
+ function planTokenization(input, options) {
174
+ const { minDur = 0.2, maxDur = 5, maxSilence = 0.3, maxLeadingSilence = 0, maxTrailingSilence = null, strictMinDur = false, analysisWindow = DEFAULT_ANALYSIS_WINDOW, useChannel = "any", } = options;
175
+ if (minDur <= 0) {
176
+ throw new RangeError(`'minDur' (${minDur}) must be > 0`);
177
+ }
178
+ const effectiveMaxDur = maxDur === null || maxDur === Infinity ? Infinity : maxDur;
179
+ if (effectiveMaxDur <= 0) {
180
+ throw new RangeError(`'maxDur' (${maxDur}) must be > 0`);
181
+ }
182
+ if (maxSilence < 0) {
183
+ throw new RangeError(`'maxSilence' (${maxSilence}) must be >= 0`);
184
+ }
185
+ if (analysisWindow <= 0) {
186
+ throw new RangeError(`'analysisWindow' (${analysisWindow}) must be > 0`);
187
+ }
188
+ const normalized = normalizeInput(input, options);
189
+ const { sampleRate } = normalized;
190
+ const frameSamples = Math.floor(analysisWindow * sampleRate);
191
+ if (frameSamples === 0) {
192
+ throw new RangeError(`too small 'analysisWindow' (${analysisWindow}) for sampling rate ` +
193
+ `(${sampleRate}); it should cover at least one sample`);
194
+ }
195
+ const windowDur = frameSamples / sampleRate;
196
+ let validator;
197
+ let calibration;
198
+ const givenValidator = options.validator;
199
+ if (typeof givenValidator === "function") {
200
+ validator = givenValidator;
201
+ }
202
+ else {
203
+ let threshold;
204
+ if (typeof givenValidator === "string") {
205
+ const { method, percentile } = parseValidatorName(givenValidator);
206
+ if (normalized.kind === "memory") {
207
+ const energies = computeFrameEnergies(normalized.channelData, frameSamples, useChannel);
208
+ threshold = estimateEnergyThreshold(energies, method, percentile === undefined ? {} : { percentile });
209
+ }
210
+ else {
211
+ // chunk stream: calibrate on the first `calibrationDur`
212
+ // seconds; `threshold` is assigned by `calibrateFrames` before
213
+ // the tokenizer sees any frame
214
+ const calibrationDur = options.calibrationDur ?? DEFAULT_CALIBRATION_DUR;
215
+ if (calibrationDur <= 0) {
216
+ throw new RangeError(`'calibrationDur' (${calibrationDur}) must be > 0`);
217
+ }
218
+ threshold = NaN;
219
+ calibration = {
220
+ method,
221
+ percentile,
222
+ nFrames: Math.max(1, Math.ceil(calibrationDur / windowDur)),
223
+ floor: options.minEnergyThreshold ?? DEFAULT_MIN_ENERGY_THRESHOLD,
224
+ useChannel,
225
+ setThreshold: (value) => {
226
+ threshold = value;
227
+ },
228
+ };
229
+ }
230
+ }
231
+ else if (givenValidator === undefined) {
232
+ threshold = options.energyThreshold ?? DEFAULT_ENERGY_THRESHOLD;
233
+ }
234
+ else {
235
+ throw new TypeError("'validator' must be a string naming an estimation method or a " +
236
+ "function");
237
+ }
238
+ validator = (frame) => windowEnergy(frame, useChannel) >= threshold;
239
+ }
240
+ const minLength = durationToWindowCount(minDur, windowDur, Math.ceil);
241
+ const maxLength = effectiveMaxDur === Infinity
242
+ ? Infinity
243
+ : durationToWindowCount(effectiveMaxDur, windowDur, Math.floor, EPSILON);
244
+ const maxContinuousSilence = durationToWindowCount(maxSilence, windowDur, Math.floor, EPSILON);
245
+ const maxLeadingSilenceFrames = durationToWindowCount(maxLeadingSilence, windowDur, Math.floor, EPSILON);
246
+ const maxTrailingSilenceFrames = maxTrailingSilence === null || maxTrailingSilence === undefined
247
+ ? null
248
+ : durationToWindowCount(maxTrailingSilence, windowDur, Math.floor, EPSILON);
249
+ if (minLength > maxLength) {
250
+ throw new RangeError(`'minDur' (${minDur} sec.) results in ${minLength} analysis ` +
251
+ `window(s) which is higher than the number of analysis windows ` +
252
+ `for 'maxDur' (${maxLength})`);
253
+ }
254
+ if (maxContinuousSilence >= maxLength) {
255
+ throw new RangeError(`'maxSilence' (${maxSilence} sec.) results in ` +
256
+ `${maxContinuousSilence} analysis window(s) which is higher or ` +
257
+ `equal to the number of analysis windows for 'maxDur' ` +
258
+ `(${maxLength})`);
259
+ }
260
+ const tokenizer = new StreamTokenizer(validator, {
261
+ minLength,
262
+ maxLength,
263
+ maxContinuousSilence,
264
+ maxLeadingSilence: maxLeadingSilenceFrames,
265
+ maxTrailingSilence: maxTrailingSilenceFrames,
266
+ strictMinLength: strictMinDur,
267
+ });
268
+ return { normalized, frameSamples, windowDur, tokenizer, calibration };
269
+ }
270
+ function framesToRegion(frames, sampleRate, start) {
271
+ const channels = frames[0].length;
272
+ let totalSamples = 0;
273
+ for (const frame of frames) {
274
+ totalSamples += frame[0].length;
275
+ }
276
+ const channelData = Array.from({ length: channels }, () => new Float32Array(totalSamples));
277
+ let offset = 0;
278
+ for (const frame of frames) {
279
+ for (let c = 0; c < channels; c++) {
280
+ channelData[c].set(frame[c], offset);
281
+ }
282
+ offset += frame[0].length;
283
+ }
284
+ return new AudioRegion(channelData, sampleRate, start);
285
+ }
286
+ /**
287
+ * Split audio into events. Returns an async generator of
288
+ * {@link AudioRegion} objects, yielded as soon as each event's end is
289
+ * decided — for chunk-stream input, while the input is still being
290
+ * read.
291
+ */
292
+ export async function* split(input, options = {}) {
293
+ const { normalized, frameSamples, windowDur, tokenizer, calibration } = planTokenization(input, options);
294
+ let frames;
295
+ if (normalized.kind === "memory") {
296
+ frames = memoryFrames(normalized.channelData, frameSamples);
297
+ }
298
+ else {
299
+ frames = streamFrames(normalized.chunks, normalized.channels, frameSamples);
300
+ if (calibration !== undefined) {
301
+ frames = calibrateFrames(frames, calibration);
302
+ }
303
+ }
304
+ for await (const token of tokenizer.tokenize(frames)) {
305
+ yield framesToRegion(token.frames, normalized.sampleRate, token.start * windowDur);
306
+ }
307
+ }
308
+ /** Like {@link split}, but collects all events into an array. */
309
+ export async function splitAll(input, options = {}) {
310
+ const regions = [];
311
+ for await (const region of split(input, options)) {
312
+ regions.push(region);
313
+ }
314
+ return regions;
315
+ }
316
+ function rejectFixedOptions(options, functionName) {
317
+ for (const name of ["maxDur", "strictMinDur"]) {
318
+ if (name in options) {
319
+ throw new TypeError(`${functionName}() does not accept '${name}'; events are never ` +
320
+ `truncated`);
321
+ }
322
+ }
323
+ }
324
+ /**
325
+ * Detect audio activity in `input` and return the audio between the
326
+ * start of the first detection and the end of the last, removing
327
+ * leading and trailing silence. Chunk-stream input is read once and
328
+ * recorded as it is consumed. Returns an empty region (zero duration)
329
+ * if no activity is detected.
330
+ */
331
+ export async function trim(input, options = {}) {
332
+ rejectFixedOptions(options, "trim");
333
+ const normalized = normalizeInput(input, options);
334
+ let source = input;
335
+ const recorded = [];
336
+ if (normalized.kind === "stream") {
337
+ const chunkSource = normalized.chunks;
338
+ source = (async function* record() {
339
+ for await (const chunk of chunkSource) {
340
+ recorded.push(chunk);
341
+ yield chunk;
342
+ }
343
+ })();
344
+ }
345
+ let first;
346
+ let last;
347
+ for await (const region of split(source, {
348
+ ...options,
349
+ maxDur: null,
350
+ strictMinDur: false,
351
+ })) {
352
+ first ?? (first = region);
353
+ last = region;
354
+ }
355
+ const { sampleRate } = normalized;
356
+ let channelData;
357
+ if (normalized.kind === "memory") {
358
+ channelData = normalized.channelData;
359
+ }
360
+ else {
361
+ let total = 0;
362
+ for (const chunk of recorded) {
363
+ total += chunk.length;
364
+ }
365
+ const { channels } = normalized;
366
+ const sampleCount = Math.floor(total / channels);
367
+ const planar = Array.from({ length: channels }, () => new Float32Array(sampleCount));
368
+ let sample = 0;
369
+ let channelCursor = 0;
370
+ for (const chunk of recorded) {
371
+ for (let i = 0; i < chunk.length && sample < sampleCount; i++) {
372
+ planar[channelCursor][sample] = chunk[i];
373
+ channelCursor += 1;
374
+ if (channelCursor === channels) {
375
+ channelCursor = 0;
376
+ sample += 1;
377
+ }
378
+ }
379
+ }
380
+ channelData = planar;
381
+ }
382
+ if (first === undefined || last === undefined) {
383
+ return new AudioRegion(channelData.map(() => new Float32Array(0)), sampleRate);
384
+ }
385
+ const from = Math.round(first.start * sampleRate);
386
+ const to = Math.min(Math.round(last.end * sampleRate), channelData[0].length);
387
+ return new AudioRegion(channelData.map((channel) => channel.slice(from, to)), sampleRate, first.start);
388
+ }
389
+ /**
390
+ * Normalize pauses: detect events (never truncated) and join them
391
+ * with exactly `silenceDuration` seconds of silence between them.
392
+ * Events shorter than `minDur` are discarded. Returns an empty region
393
+ * (zero duration) if no events are detected.
394
+ */
395
+ export async function fixPauses(input, silenceDuration, options = {}) {
396
+ rejectFixedOptions(options, "fixPauses");
397
+ if (silenceDuration < 0) {
398
+ throw new RangeError(`'silenceDuration' (${silenceDuration}) must be >= 0`);
399
+ }
400
+ const regions = await splitAll(input, {
401
+ ...options,
402
+ maxDur: null,
403
+ strictMinDur: false,
404
+ });
405
+ if (regions.length === 0) {
406
+ const normalized = normalizeInput(input, options);
407
+ const channels = normalized.kind === "memory"
408
+ ? normalized.channelData.length
409
+ : normalized.channels;
410
+ return new AudioRegion(Array.from({ length: channels }, () => new Float32Array(0)), normalized.sampleRate);
411
+ }
412
+ const gap = makeSilence(silenceDuration, regions[0].sampleRate, regions[0].channels);
413
+ return concatRegions(regions, gap);
414
+ }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * StreamTokenizer: the event-shaping state machine at the heart of
3
+ * auditok, ported line for line from the Python implementation. It is
4
+ * generic over the frame type — frames can be analysis windows of
5
+ * audio, characters, booleans from another VAD's frame decisions —
6
+ * and turns per-frame valid/invalid decisions into tokens with
7
+ * min/max length and silence semantics.
8
+ */
9
+ export type FrameValidator<T> = ((frame: T) => boolean) | {
10
+ isValid(frame: T): boolean;
11
+ };
12
+ export interface Token<T> {
13
+ frames: T[];
14
+ /** Index of the first frame of the token in the input stream. */
15
+ start: number;
16
+ /** Index of the last frame of the token in the input stream. */
17
+ end: number;
18
+ }
19
+ export interface StreamTokenizerOptions {
20
+ /** Minimum number of frames in a valid token, including any
21
+ * tolerated non-valid frames within the token. */
22
+ minLength: number;
23
+ /** Maximum number of frames in a valid token. Use Infinity for no
24
+ * limit. */
25
+ maxLength: number;
26
+ /** Maximum number of consecutive non-valid frames within a token. */
27
+ maxContinuousSilence: number;
28
+ /** Maximum number of non-valid frames to retain immediately before
29
+ * the first valid frame of each token; the token's start position is
30
+ * adjusted backward accordingly. Default 0. */
31
+ maxLeadingSilence?: number;
32
+ /** Maximum number of trailing non-valid frames to keep at the end of
33
+ * each token. `null` (default) keeps all trailing silence up to
34
+ * `maxContinuousSilence`; `0` drops it entirely; a value larger than
35
+ * `maxContinuousSilence` keeps collecting silent frames past the
36
+ * event boundary (stopping early on a valid frame or end of data). */
37
+ maxTrailingSilence?: number | null;
38
+ /** Reject tokens shorter than `minLength` even when they immediately
39
+ * follow a token delivered at `maxLength`. Default false. */
40
+ strictMinLength?: boolean;
41
+ /** Minimum number of consecutive valid frames required before
42
+ * tolerating non-valid ones. Default 0 (rarely needed; prefer
43
+ * `maxLeadingSilence`). */
44
+ initMin?: number;
45
+ /** Maximum tolerated consecutive non-valid frames before reaching
46
+ * `initMin`. Default 0. */
47
+ initMaxSilence?: number;
48
+ }
49
+ export declare class StreamTokenizer<T> {
50
+ readonly minLength: number;
51
+ readonly maxLength: number;
52
+ readonly maxContinuousSilence: number;
53
+ readonly maxLeadingSilence: number;
54
+ readonly maxTrailingSilence: number | null;
55
+ readonly strictMinLength: boolean;
56
+ readonly initMin: number;
57
+ readonly initMaxSilence: number;
58
+ private readonly isValid;
59
+ private readonly needsTrailingExtension;
60
+ private state;
61
+ private data;
62
+ private leadingBuffer;
63
+ private contiguousToken;
64
+ private initCount;
65
+ private silenceLength;
66
+ private startFrame;
67
+ private currentFrame;
68
+ constructor(validator: FrameValidator<T>, options: StreamTokenizerOptions);
69
+ /**
70
+ * Tokenize `source` frame by frame, yielding tokens as soon as their
71
+ * end is decided. Accepts both sync and async iterables.
72
+ */
73
+ tokenize(source: Iterable<T> | AsyncIterable<T>): AsyncGenerator<Token<T>, void, void>;
74
+ /** Convenience wrapper collecting all tokens into an array. */
75
+ tokenizeAll(source: Iterable<T> | AsyncIterable<T>): Promise<Token<T>[]>;
76
+ private reinitialize;
77
+ private pushLeading;
78
+ private startToken;
79
+ private process;
80
+ private postProcess;
81
+ private processEndOfDetection;
82
+ }