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/LICENSE +21 -0
- package/README.md +236 -0
- package/dist/browser/index.d.ts +44 -0
- package/dist/browser/index.js +118 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +14 -0
- package/dist/node/ffmpeg.d.ts +47 -0
- package/dist/node/ffmpeg.js +117 -0
- package/dist/node/index.d.ts +32 -0
- package/dist/node/index.js +54 -0
- package/dist/region.d.ts +31 -0
- package/dist/region.js +81 -0
- package/dist/signal.d.ts +63 -0
- package/dist/signal.js +240 -0
- package/dist/split.d.ts +121 -0
- package/dist/split.js +414 -0
- package/dist/tokenizer.d.ts +82 -0
- package/dist/tokenizer.js +312 -0
- package/dist/wav.d.ts +23 -0
- package/dist/wav.js +141 -0
- package/package.json +71 -0
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Node entry point: everything from the core, plus file loading
|
|
3
|
+
* (native WAV parser, ffmpeg for everything else), microphone capture
|
|
4
|
+
* through ffmpeg, and WAV saving. Zero npm runtime dependencies.
|
|
5
|
+
*/
|
|
6
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
7
|
+
import { AudioRegion } from "../region.js";
|
|
8
|
+
import { decodeWav, encodeWav, isWav, } from "../wav.js";
|
|
9
|
+
import { decodeFileStream } from "./ffmpeg.js";
|
|
10
|
+
export * from "../index.js";
|
|
11
|
+
export { decodeFileStream, microphone, } from "./ffmpeg.js";
|
|
12
|
+
/**
|
|
13
|
+
* Load an audio file into an {@link AudioRegion}. WAV files are parsed
|
|
14
|
+
* natively; every other format is decoded with ffmpeg (which must be
|
|
15
|
+
* on the PATH). To detect events while a large compressed file is
|
|
16
|
+
* still being decoded, use {@link decodeFileStream} with `split`
|
|
17
|
+
* instead of loading it fully.
|
|
18
|
+
*/
|
|
19
|
+
export async function load(path, options = {}) {
|
|
20
|
+
if (options.forceFfmpeg !== true) {
|
|
21
|
+
const bytes = await readFile(path);
|
|
22
|
+
if (isWav(bytes)) {
|
|
23
|
+
const { channelData, sampleRate } = decodeWav(bytes);
|
|
24
|
+
return new AudioRegion(channelData, sampleRate);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
const stream = decodeFileStream(path, options);
|
|
28
|
+
const chunks = [];
|
|
29
|
+
let total = 0;
|
|
30
|
+
for await (const chunk of stream.chunks) {
|
|
31
|
+
chunks.push(chunk);
|
|
32
|
+
total += chunk.length;
|
|
33
|
+
}
|
|
34
|
+
const { channels, sampleRate } = stream;
|
|
35
|
+
const sampleCount = Math.floor(total / channels);
|
|
36
|
+
const channelData = Array.from({ length: channels }, () => new Float32Array(sampleCount));
|
|
37
|
+
let sample = 0;
|
|
38
|
+
let channelCursor = 0;
|
|
39
|
+
for (const chunk of chunks) {
|
|
40
|
+
for (let i = 0; i < chunk.length && sample < sampleCount; i++) {
|
|
41
|
+
channelData[channelCursor][sample] = chunk[i];
|
|
42
|
+
channelCursor += 1;
|
|
43
|
+
if (channelCursor === channels) {
|
|
44
|
+
channelCursor = 0;
|
|
45
|
+
sample += 1;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return new AudioRegion(channelData, sampleRate);
|
|
50
|
+
}
|
|
51
|
+
/** Save a region (or any `{ channelData, sampleRate }`) as a WAV file. */
|
|
52
|
+
export async function save(path, audio, options) {
|
|
53
|
+
await writeFile(path, encodeWav(audio, options));
|
|
54
|
+
}
|
package/dist/region.d.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AudioRegion: a chunk of audio with a position in time. What `split`
|
|
3
|
+
* yields and what `trim`/`fixPauses` return.
|
|
4
|
+
*/
|
|
5
|
+
import { type WavEncodeOptions } from "./wav.js";
|
|
6
|
+
export declare class AudioRegion {
|
|
7
|
+
/** Planar samples, one Float32Array per channel, values nominally in
|
|
8
|
+
* [-1, 1]. */
|
|
9
|
+
readonly channelData: readonly Float32Array[];
|
|
10
|
+
readonly sampleRate: number;
|
|
11
|
+
/** Start time in seconds relative to the beginning of the input this
|
|
12
|
+
* region was detected in (0 for regions built directly). */
|
|
13
|
+
readonly start: number;
|
|
14
|
+
constructor(channelData: readonly Float32Array[], sampleRate: number, start?: number);
|
|
15
|
+
get channels(): number;
|
|
16
|
+
/** Number of samples per channel. */
|
|
17
|
+
get sampleCount(): number;
|
|
18
|
+
/** Duration in seconds. */
|
|
19
|
+
get duration(): number;
|
|
20
|
+
/** End time in seconds (`start + duration`). */
|
|
21
|
+
get end(): number;
|
|
22
|
+
/** Encode the region as a WAV file (16-bit PCM by default). */
|
|
23
|
+
toWav(options?: WavEncodeOptions): Uint8Array;
|
|
24
|
+
}
|
|
25
|
+
/** A region of digital silence of the given duration. */
|
|
26
|
+
export declare function makeSilence(duration: number, sampleRate?: number, channels?: number): AudioRegion;
|
|
27
|
+
/**
|
|
28
|
+
* Concatenate `regions` into one region, inserting `gap` (e.g. a
|
|
29
|
+
* silence built with {@link makeSilence}) between consecutive regions.
|
|
30
|
+
*/
|
|
31
|
+
export declare function concatRegions(regions: readonly AudioRegion[], gap?: AudioRegion): AudioRegion;
|
package/dist/region.js
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AudioRegion: a chunk of audio with a position in time. What `split`
|
|
3
|
+
* yields and what `trim`/`fixPauses` return.
|
|
4
|
+
*/
|
|
5
|
+
import { encodeWav } from "./wav.js";
|
|
6
|
+
export class AudioRegion {
|
|
7
|
+
constructor(channelData, sampleRate, start = 0) {
|
|
8
|
+
if (channelData.length === 0) {
|
|
9
|
+
throw new RangeError("'channelData' must contain at least one channel");
|
|
10
|
+
}
|
|
11
|
+
const length = channelData[0].length;
|
|
12
|
+
for (const channel of channelData) {
|
|
13
|
+
if (channel.length !== length) {
|
|
14
|
+
throw new RangeError("all channels must have the same length");
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
if (!(sampleRate > 0)) {
|
|
18
|
+
throw new RangeError(`'sampleRate' must be > 0, given: ${sampleRate}`);
|
|
19
|
+
}
|
|
20
|
+
this.channelData = channelData;
|
|
21
|
+
this.sampleRate = sampleRate;
|
|
22
|
+
this.start = start;
|
|
23
|
+
}
|
|
24
|
+
get channels() {
|
|
25
|
+
return this.channelData.length;
|
|
26
|
+
}
|
|
27
|
+
/** Number of samples per channel. */
|
|
28
|
+
get sampleCount() {
|
|
29
|
+
return this.channelData[0].length;
|
|
30
|
+
}
|
|
31
|
+
/** Duration in seconds. */
|
|
32
|
+
get duration() {
|
|
33
|
+
return this.sampleCount / this.sampleRate;
|
|
34
|
+
}
|
|
35
|
+
/** End time in seconds (`start + duration`). */
|
|
36
|
+
get end() {
|
|
37
|
+
return this.start + this.duration;
|
|
38
|
+
}
|
|
39
|
+
/** Encode the region as a WAV file (16-bit PCM by default). */
|
|
40
|
+
toWav(options) {
|
|
41
|
+
return encodeWav(this, options);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
/** A region of digital silence of the given duration. */
|
|
45
|
+
export function makeSilence(duration, sampleRate = 16000, channels = 1) {
|
|
46
|
+
const sampleCount = Math.round(duration * sampleRate);
|
|
47
|
+
const channelData = Array.from({ length: channels }, () => new Float32Array(sampleCount));
|
|
48
|
+
return new AudioRegion(channelData, sampleRate);
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Concatenate `regions` into one region, inserting `gap` (e.g. a
|
|
52
|
+
* silence built with {@link makeSilence}) between consecutive regions.
|
|
53
|
+
*/
|
|
54
|
+
export function concatRegions(regions, gap) {
|
|
55
|
+
if (regions.length === 0) {
|
|
56
|
+
throw new RangeError("'regions' must contain at least one region");
|
|
57
|
+
}
|
|
58
|
+
const { sampleRate, channels } = regions[0];
|
|
59
|
+
const parts = [];
|
|
60
|
+
let totalSamples = 0;
|
|
61
|
+
regions.forEach((region, i) => {
|
|
62
|
+
if (region.sampleRate !== sampleRate || region.channels !== channels) {
|
|
63
|
+
throw new RangeError("all regions must have the same sample rate and channel count");
|
|
64
|
+
}
|
|
65
|
+
if (i > 0 && gap !== undefined) {
|
|
66
|
+
parts.push(gap.channelData);
|
|
67
|
+
totalSamples += gap.sampleCount;
|
|
68
|
+
}
|
|
69
|
+
parts.push(region.channelData);
|
|
70
|
+
totalSamples += region.sampleCount;
|
|
71
|
+
});
|
|
72
|
+
const channelData = Array.from({ length: channels }, () => new Float32Array(totalSamples));
|
|
73
|
+
let offset = 0;
|
|
74
|
+
for (const part of parts) {
|
|
75
|
+
for (let c = 0; c < channels; c++) {
|
|
76
|
+
channelData[c].set(part[c], offset);
|
|
77
|
+
}
|
|
78
|
+
offset += part[0].length;
|
|
79
|
+
}
|
|
80
|
+
return new AudioRegion(channelData, sampleRate);
|
|
81
|
+
}
|
package/dist/signal.d.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Signal-level operations: window energy and automatic threshold
|
|
3
|
+
* estimation.
|
|
4
|
+
*
|
|
5
|
+
* Samples are Float32Array values nominally in [-1, 1] (the Web Audio
|
|
6
|
+
* convention). Energies are expressed in dB relative to 16-bit full
|
|
7
|
+
* scale — samples are scaled by 32768 before the log — so thresholds
|
|
8
|
+
* mean exactly the same thing as in Python auditok, and its
|
|
9
|
+
* documentation transfers 1:1.
|
|
10
|
+
*/
|
|
11
|
+
export declare const EPSILON = 1e-10;
|
|
12
|
+
/** Amplitude scale applied before the log so that dB values are relative
|
|
13
|
+
* to int16 full scale, like in Python auditok. */
|
|
14
|
+
export declare const INT16_SCALE = 32768;
|
|
15
|
+
/** Energy assigned to an all-zero (digitally silent) window:
|
|
16
|
+
* 20 * log10(EPSILON) = -200 dB. A sentinel, not a measurement. */
|
|
17
|
+
export declare const SILENCE_ENERGY: number;
|
|
18
|
+
/** Channel used for energy computation on multichannel audio:
|
|
19
|
+
* "any" (default) takes the maximum energy over channels, "mix"
|
|
20
|
+
* averages the channels into one before computing the energy, an
|
|
21
|
+
* integer selects one channel (negative counts from the end). */
|
|
22
|
+
export type ChannelSelection = "any" | "mix" | number;
|
|
23
|
+
/**
|
|
24
|
+
* Compute the energy of `samples` as
|
|
25
|
+
* `20 * log10(sqrt(mean(x^2)))` with `x` on the int16 amplitude scale.
|
|
26
|
+
* An all-zero window yields {@link SILENCE_ENERGY} (-200 dB).
|
|
27
|
+
*/
|
|
28
|
+
export declare function calculateEnergy(samples: ArrayLike<number>): number;
|
|
29
|
+
/**
|
|
30
|
+
* Energy of one analysis window given as planar channel data,
|
|
31
|
+
* aggregated over channels according to `useChannel`.
|
|
32
|
+
*/
|
|
33
|
+
export declare function windowEnergy(channelData: readonly Float32Array[], useChannel?: ChannelSelection): number;
|
|
34
|
+
/**
|
|
35
|
+
* Energy of each analysis window of `channelData`, one value per whole
|
|
36
|
+
* window of `frameSamples` samples. A final partial window, if any, is
|
|
37
|
+
* ignored. This is what automatic threshold estimation runs on; the
|
|
38
|
+
* energy formula and channel aggregation are exactly those used by the
|
|
39
|
+
* tokenizer's energy validator, so an estimated threshold means the
|
|
40
|
+
* same thing at detection time.
|
|
41
|
+
*/
|
|
42
|
+
export declare function computeFrameEnergies(channelData: readonly Float32Array[], frameSamples: number, useChannel?: ChannelSelection): Float64Array;
|
|
43
|
+
export type ThresholdMethod = "otsu" | "percentile";
|
|
44
|
+
export interface ThresholdMethodOptions {
|
|
45
|
+
/** Percentile read as the noise floor (method "percentile" only),
|
|
46
|
+
* default 10. */
|
|
47
|
+
percentile?: number;
|
|
48
|
+
/** dB margin added to the noise floor (method "percentile" only),
|
|
49
|
+
* default 6. */
|
|
50
|
+
margin?: number;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Estimate an energy threshold from analysis-window energies (as
|
|
54
|
+
* returned by {@link computeFrameEnergies}). The returned value is on
|
|
55
|
+
* the same dB scale as `energyThreshold` and can be passed to `split`
|
|
56
|
+
* directly.
|
|
57
|
+
*
|
|
58
|
+
* All-zero windows carry the -200 dB silence sentinel and are excluded
|
|
59
|
+
* from the estimate; if nothing else remains the input is entirely
|
|
60
|
+
* digitally silent and the function returns `Infinity` (every window
|
|
61
|
+
* must fail validation — there is nothing to detect).
|
|
62
|
+
*/
|
|
63
|
+
export declare function estimateEnergyThreshold(frameEnergies: ArrayLike<number>, method?: ThresholdMethod, options?: ThresholdMethodOptions): number;
|
package/dist/signal.js
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Signal-level operations: window energy and automatic threshold
|
|
3
|
+
* estimation.
|
|
4
|
+
*
|
|
5
|
+
* Samples are Float32Array values nominally in [-1, 1] (the Web Audio
|
|
6
|
+
* convention). Energies are expressed in dB relative to 16-bit full
|
|
7
|
+
* scale — samples are scaled by 32768 before the log — so thresholds
|
|
8
|
+
* mean exactly the same thing as in Python auditok, and its
|
|
9
|
+
* documentation transfers 1:1.
|
|
10
|
+
*/
|
|
11
|
+
export const EPSILON = 1e-10;
|
|
12
|
+
/** Amplitude scale applied before the log so that dB values are relative
|
|
13
|
+
* to int16 full scale, like in Python auditok. */
|
|
14
|
+
export const INT16_SCALE = 32768;
|
|
15
|
+
/** Energy assigned to an all-zero (digitally silent) window:
|
|
16
|
+
* 20 * log10(EPSILON) = -200 dB. A sentinel, not a measurement. */
|
|
17
|
+
export const SILENCE_ENERGY = 20 * Math.log10(EPSILON);
|
|
18
|
+
/**
|
|
19
|
+
* Compute the energy of `samples` as
|
|
20
|
+
* `20 * log10(sqrt(mean(x^2)))` with `x` on the int16 amplitude scale.
|
|
21
|
+
* An all-zero window yields {@link SILENCE_ENERGY} (-200 dB).
|
|
22
|
+
*/
|
|
23
|
+
export function calculateEnergy(samples) {
|
|
24
|
+
const n = samples.length;
|
|
25
|
+
if (n === 0) {
|
|
26
|
+
return SILENCE_ENERGY;
|
|
27
|
+
}
|
|
28
|
+
let sumSquares = 0;
|
|
29
|
+
for (let i = 0; i < n; i++) {
|
|
30
|
+
const x = samples[i];
|
|
31
|
+
sumSquares += x * x;
|
|
32
|
+
}
|
|
33
|
+
const rms = Math.sqrt(sumSquares / n) * INT16_SCALE;
|
|
34
|
+
return 20 * Math.log10(Math.max(rms, EPSILON));
|
|
35
|
+
}
|
|
36
|
+
function resolveChannelIndex(index, channels) {
|
|
37
|
+
const selected = index < 0 ? index + channels : index;
|
|
38
|
+
if (!Number.isInteger(index) || selected < 0 || selected >= channels) {
|
|
39
|
+
throw new RangeError(`Selected channel must be >= -channels and < channels, given: ${index}`);
|
|
40
|
+
}
|
|
41
|
+
return selected;
|
|
42
|
+
}
|
|
43
|
+
function mixChannels(channelData, start, end) {
|
|
44
|
+
const mixed = new Float32Array(end - start);
|
|
45
|
+
for (const channel of channelData) {
|
|
46
|
+
for (let i = start; i < end; i++) {
|
|
47
|
+
mixed[i - start] += channel[i];
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
const channels = channelData.length;
|
|
51
|
+
for (let i = 0; i < mixed.length; i++) {
|
|
52
|
+
mixed[i] /= channels;
|
|
53
|
+
}
|
|
54
|
+
return mixed;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Energy of one analysis window given as planar channel data,
|
|
58
|
+
* aggregated over channels according to `useChannel`.
|
|
59
|
+
*/
|
|
60
|
+
export function windowEnergy(channelData, useChannel = "any") {
|
|
61
|
+
if (channelData.length === 1) {
|
|
62
|
+
return calculateEnergy(channelData[0]);
|
|
63
|
+
}
|
|
64
|
+
if (useChannel === "any") {
|
|
65
|
+
let maxEnergy = -Infinity;
|
|
66
|
+
for (const channel of channelData) {
|
|
67
|
+
const energy = calculateEnergy(channel);
|
|
68
|
+
if (energy > maxEnergy) {
|
|
69
|
+
maxEnergy = energy;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return maxEnergy;
|
|
73
|
+
}
|
|
74
|
+
if (useChannel === "mix") {
|
|
75
|
+
return calculateEnergy(mixChannels(channelData, 0, channelData[0].length));
|
|
76
|
+
}
|
|
77
|
+
const index = resolveChannelIndex(useChannel, channelData.length);
|
|
78
|
+
return calculateEnergy(channelData[index]);
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Energy of each analysis window of `channelData`, one value per whole
|
|
82
|
+
* window of `frameSamples` samples. A final partial window, if any, is
|
|
83
|
+
* ignored. This is what automatic threshold estimation runs on; the
|
|
84
|
+
* energy formula and channel aggregation are exactly those used by the
|
|
85
|
+
* tokenizer's energy validator, so an estimated threshold means the
|
|
86
|
+
* same thing at detection time.
|
|
87
|
+
*/
|
|
88
|
+
export function computeFrameEnergies(channelData, frameSamples, useChannel = "any") {
|
|
89
|
+
if (!Number.isInteger(frameSamples) || frameSamples <= 0) {
|
|
90
|
+
throw new RangeError(`'frameSamples' must be a positive integer, given: ${frameSamples}`);
|
|
91
|
+
}
|
|
92
|
+
const totalSamples = channelData[0]?.length ?? 0;
|
|
93
|
+
const nFrames = Math.floor(totalSamples / frameSamples);
|
|
94
|
+
const energies = new Float64Array(nFrames);
|
|
95
|
+
for (let i = 0; i < nFrames; i++) {
|
|
96
|
+
const start = i * frameSamples;
|
|
97
|
+
const end = start + frameSamples;
|
|
98
|
+
const frame = channelData.map((channel) => channel.subarray(start, end));
|
|
99
|
+
energies[i] = windowEnergy(frame, useChannel);
|
|
100
|
+
}
|
|
101
|
+
return energies;
|
|
102
|
+
}
|
|
103
|
+
/** Equal-width histogram matching numpy.histogram: `bins` bins over
|
|
104
|
+
* [min, max], the last bin including its right edge. */
|
|
105
|
+
function histogram(values, bins, min, max) {
|
|
106
|
+
const hist = new Float64Array(bins);
|
|
107
|
+
const edges = new Float64Array(bins + 1);
|
|
108
|
+
for (let i = 0; i <= bins; i++) {
|
|
109
|
+
edges[i] = min + ((max - min) * i) / bins;
|
|
110
|
+
}
|
|
111
|
+
const scale = bins / (max - min);
|
|
112
|
+
for (let i = 0; i < values.length; i++) {
|
|
113
|
+
let bin = Math.floor((values[i] - min) * scale);
|
|
114
|
+
if (bin >= bins) {
|
|
115
|
+
bin = bins - 1;
|
|
116
|
+
}
|
|
117
|
+
else if (bin < 0) {
|
|
118
|
+
bin = 0;
|
|
119
|
+
}
|
|
120
|
+
hist[bin] += 1;
|
|
121
|
+
}
|
|
122
|
+
return { hist, edges };
|
|
123
|
+
}
|
|
124
|
+
/** Otsu's method: split the energy histogram in two classes maximizing
|
|
125
|
+
* the between-class variance. Parameter-free; assumes the energy
|
|
126
|
+
* distribution is roughly bimodal (background vs. activity). */
|
|
127
|
+
function estimateThresholdOtsu(energies, bins = 128) {
|
|
128
|
+
let min = Infinity;
|
|
129
|
+
let max = -Infinity;
|
|
130
|
+
for (const e of energies) {
|
|
131
|
+
if (e < min)
|
|
132
|
+
min = e;
|
|
133
|
+
if (e > max)
|
|
134
|
+
max = e;
|
|
135
|
+
}
|
|
136
|
+
const { hist, edges } = histogram(energies, bins, min, max);
|
|
137
|
+
const centers = new Float64Array(bins);
|
|
138
|
+
for (let i = 0; i < bins; i++) {
|
|
139
|
+
centers[i] = (edges[i] + edges[i + 1]) / 2;
|
|
140
|
+
}
|
|
141
|
+
let total = 0;
|
|
142
|
+
let totalMass = 0;
|
|
143
|
+
for (let i = 0; i < bins; i++) {
|
|
144
|
+
total += hist[i];
|
|
145
|
+
totalMass += hist[i] * centers[i];
|
|
146
|
+
}
|
|
147
|
+
// between-class variance at each split point (split after bin i)
|
|
148
|
+
const betweenVar = new Float64Array(bins - 1);
|
|
149
|
+
let weight0 = 0;
|
|
150
|
+
let cumMass = 0;
|
|
151
|
+
for (let i = 0; i < bins - 1; i++) {
|
|
152
|
+
weight0 += hist[i];
|
|
153
|
+
cumMass += hist[i] * centers[i];
|
|
154
|
+
const weight1 = total - weight0;
|
|
155
|
+
if (weight0 === 0 || weight1 === 0) {
|
|
156
|
+
betweenVar[i] = -1; // matches numpy's nan_to_num(nan=-1)
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
const mu0 = cumMass / weight0;
|
|
160
|
+
const mu1 = (totalMass - cumMass) / weight1;
|
|
161
|
+
betweenVar[i] = weight0 * weight1 * (mu0 - mu1) ** 2;
|
|
162
|
+
}
|
|
163
|
+
let best = -Infinity;
|
|
164
|
+
for (let i = 0; i < betweenVar.length; i++) {
|
|
165
|
+
if (betweenVar[i] > best)
|
|
166
|
+
best = betweenVar[i];
|
|
167
|
+
}
|
|
168
|
+
// empty bins between the two modes make the between-class variance
|
|
169
|
+
// exactly flat over the gap; take the middle of the plateau (max
|
|
170
|
+
// margin) rather than the leftmost point
|
|
171
|
+
const candidates = [];
|
|
172
|
+
for (let i = 0; i < betweenVar.length; i++) {
|
|
173
|
+
if (betweenVar[i] === best)
|
|
174
|
+
candidates.push(i);
|
|
175
|
+
}
|
|
176
|
+
const split = candidates[(candidates.length - 1) >> 1];
|
|
177
|
+
return edges[split + 1];
|
|
178
|
+
}
|
|
179
|
+
/** Linear-interpolation percentile, matching numpy.percentile. */
|
|
180
|
+
function percentileOf(sorted, percentile) {
|
|
181
|
+
const rank = ((sorted.length - 1) * percentile) / 100;
|
|
182
|
+
const lo = Math.floor(rank);
|
|
183
|
+
const frac = rank - lo;
|
|
184
|
+
if (lo + 1 >= sorted.length) {
|
|
185
|
+
return sorted[sorted.length - 1];
|
|
186
|
+
}
|
|
187
|
+
return sorted[lo] + frac * (sorted[lo + 1] - sorted[lo]);
|
|
188
|
+
}
|
|
189
|
+
/** Noise floor (low percentile of window energies) plus a margin. */
|
|
190
|
+
function estimateThresholdPercentile(energies, percentile, margin) {
|
|
191
|
+
const sorted = [...energies].sort((a, b) => a - b);
|
|
192
|
+
return percentileOf(sorted, percentile) + margin;
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Estimate an energy threshold from analysis-window energies (as
|
|
196
|
+
* returned by {@link computeFrameEnergies}). The returned value is on
|
|
197
|
+
* the same dB scale as `energyThreshold` and can be passed to `split`
|
|
198
|
+
* directly.
|
|
199
|
+
*
|
|
200
|
+
* All-zero windows carry the -200 dB silence sentinel and are excluded
|
|
201
|
+
* from the estimate; if nothing else remains the input is entirely
|
|
202
|
+
* digitally silent and the function returns `Infinity` (every window
|
|
203
|
+
* must fail validation — there is nothing to detect).
|
|
204
|
+
*/
|
|
205
|
+
export function estimateEnergyThreshold(frameEnergies, method = "otsu", options = {}) {
|
|
206
|
+
if (frameEnergies.length === 0) {
|
|
207
|
+
throw new RangeError("Cannot estimate an energy threshold from an empty energy array " +
|
|
208
|
+
"(input audio shorter than one analysis window?)");
|
|
209
|
+
}
|
|
210
|
+
const energies = [];
|
|
211
|
+
for (let i = 0; i < frameEnergies.length; i++) {
|
|
212
|
+
if (frameEnergies[i] > SILENCE_ENERGY) {
|
|
213
|
+
energies.push(frameEnergies[i]);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
if (energies.length === 0) {
|
|
217
|
+
// the input is entirely digitally silent: there is no energy
|
|
218
|
+
// distribution to estimate from and nothing to detect
|
|
219
|
+
return Infinity;
|
|
220
|
+
}
|
|
221
|
+
let min = Infinity;
|
|
222
|
+
let max = -Infinity;
|
|
223
|
+
for (const e of energies) {
|
|
224
|
+
if (e < min)
|
|
225
|
+
min = e;
|
|
226
|
+
if (e > max)
|
|
227
|
+
max = e;
|
|
228
|
+
}
|
|
229
|
+
if (min === max) {
|
|
230
|
+
return min;
|
|
231
|
+
}
|
|
232
|
+
if (method === "otsu") {
|
|
233
|
+
return estimateThresholdOtsu(energies);
|
|
234
|
+
}
|
|
235
|
+
if (method === "percentile") {
|
|
236
|
+
return estimateThresholdPercentile(energies, options.percentile ?? 10, options.margin ?? 6);
|
|
237
|
+
}
|
|
238
|
+
throw new RangeError(`Unknown threshold estimation method '${method}', expected ` +
|
|
239
|
+
`"otsu" or "percentile"`);
|
|
240
|
+
}
|
package/dist/split.d.ts
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
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 } from "./region.js";
|
|
8
|
+
import { type ChannelSelection } from "./signal.js";
|
|
9
|
+
/** One analysis window as planar per-channel samples. This is what a
|
|
10
|
+
* custom validator function receives. */
|
|
11
|
+
export type AudioFrame = readonly Float32Array[];
|
|
12
|
+
/** Minimal structural type for a Web Audio `AudioBuffer`. */
|
|
13
|
+
export interface AudioBufferLike {
|
|
14
|
+
numberOfChannels: number;
|
|
15
|
+
sampleRate: number;
|
|
16
|
+
getChannelData(channel: number): Float32Array;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Audio input accepted by {@link split}, {@link trim} and
|
|
20
|
+
* {@link fixPauses}:
|
|
21
|
+
*
|
|
22
|
+
* - `Float32Array`: mono samples (requires `sampleRate` in options);
|
|
23
|
+
* - `Float32Array[]`: planar channels of equal length (requires
|
|
24
|
+
* `sampleRate`);
|
|
25
|
+
* - {@link AudioRegion} or any `{ channelData, sampleRate }` object;
|
|
26
|
+
* - a Web Audio `AudioBuffer`;
|
|
27
|
+
* - a sync or async iterable of `Float32Array` chunks of *interleaved*
|
|
28
|
+
* samples (requires `sampleRate`; `channels` defaults to 1). This is
|
|
29
|
+
* the streaming path: events are yielded while the input is still
|
|
30
|
+
* being read.
|
|
31
|
+
*/
|
|
32
|
+
export type AudioInput = Float32Array | Float32Array[] | AudioRegion | {
|
|
33
|
+
channelData: readonly Float32Array[];
|
|
34
|
+
sampleRate: number;
|
|
35
|
+
} | AudioBufferLike | Iterable<Float32Array> | AsyncIterable<Float32Array>;
|
|
36
|
+
export interface SplitOptions {
|
|
37
|
+
/** Sample rate in Hz. Required when the input does not carry its
|
|
38
|
+
* own (bare samples or chunk streams). */
|
|
39
|
+
sampleRate?: number;
|
|
40
|
+
/** Number of interleaved channels for chunk-stream input, default 1.
|
|
41
|
+
* Ignored for in-memory input, which is planar. */
|
|
42
|
+
channels?: number;
|
|
43
|
+
/** Minimum duration in seconds of a detected event, default 0.2. */
|
|
44
|
+
minDur?: number;
|
|
45
|
+
/** Maximum duration in seconds of an event, default 5; longer events
|
|
46
|
+
* are truncated. `null` or `Infinity` disables the limit. */
|
|
47
|
+
maxDur?: number | null;
|
|
48
|
+
/** Maximum duration of continuous silence allowed *within* an event,
|
|
49
|
+
* default 0.3. Controls when an event ends. */
|
|
50
|
+
maxSilence?: number;
|
|
51
|
+
/** Silence in seconds to retain before each event (natural attack),
|
|
52
|
+
* default 0. */
|
|
53
|
+
maxLeadingSilence?: number;
|
|
54
|
+
/** Trailing silence in seconds to keep at the end of each event.
|
|
55
|
+
* `null` (default) keeps all trailing silence up to `maxSilence`;
|
|
56
|
+
* `0` drops it; a value larger than `maxSilence` keeps collecting
|
|
57
|
+
* past the event boundary (natural fadeout). */
|
|
58
|
+
maxTrailingSilence?: number | null;
|
|
59
|
+
/** Reject events shorter than `minDur` even when contiguous with a
|
|
60
|
+
* truncated event, default false. */
|
|
61
|
+
strictMinDur?: boolean;
|
|
62
|
+
/** Duration in seconds of the analysis window, default 0.05. */
|
|
63
|
+
analysisWindow?: number;
|
|
64
|
+
/** Energy threshold in dB for detection, default 50. Used when no
|
|
65
|
+
* `validator` is given. Same scale as Python auditok: dB relative to
|
|
66
|
+
* int16 full scale. */
|
|
67
|
+
energyThreshold?: number;
|
|
68
|
+
/**
|
|
69
|
+
* Frame validation strategy; if set, it is the whole story and
|
|
70
|
+
* `energyThreshold` is overlooked. A string selects automatic
|
|
71
|
+
* threshold estimation: `"otsu"`, `"percentile"` (alias of `"p10"`)
|
|
72
|
+
* or `"pXX"` (noise floor read at the XXth percentile of window
|
|
73
|
+
* energies, plus a 6 dB margin). For in-memory input the threshold
|
|
74
|
+
* is estimated from the whole input before detection starts; for
|
|
75
|
+
* chunk-stream input it is calibrated on the first `calibrationDur`
|
|
76
|
+
* seconds (clamped to `minEnergyThreshold`), and the calibration
|
|
77
|
+
* audio is replayed so no data is lost. A function is called with
|
|
78
|
+
* each analysis window (planar per-channel samples) and returns
|
|
79
|
+
* whether the window is valid.
|
|
80
|
+
*/
|
|
81
|
+
validator?: string | ((frame: AudioFrame) => boolean);
|
|
82
|
+
/** Duration in seconds of chunk-stream audio used to calibrate the
|
|
83
|
+
* threshold when `validator` is an estimation string, default 3.
|
|
84
|
+
* Ignored for in-memory input, which is estimated as a whole. */
|
|
85
|
+
calibrationDur?: number;
|
|
86
|
+
/** Lower bound in dB for a threshold calibrated on a chunk stream,
|
|
87
|
+
* default 40. Guards against a too-quiet calibration window; also
|
|
88
|
+
* used as the threshold when the calibration audio is digitally
|
|
89
|
+
* silent (e.g., a muted microphone), so detection keeps running. */
|
|
90
|
+
minEnergyThreshold?: number;
|
|
91
|
+
/** Channel used for energy computation on multichannel audio:
|
|
92
|
+
* `"any"` (default, maximum over channels), `"mix"` or a channel
|
|
93
|
+
* index. */
|
|
94
|
+
useChannel?: ChannelSelection;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Split audio into events. Returns an async generator of
|
|
98
|
+
* {@link AudioRegion} objects, yielded as soon as each event's end is
|
|
99
|
+
* decided — for chunk-stream input, while the input is still being
|
|
100
|
+
* read.
|
|
101
|
+
*/
|
|
102
|
+
export declare function split(input: AudioInput, options?: SplitOptions): AsyncGenerator<AudioRegion, void, void>;
|
|
103
|
+
/** Like {@link split}, but collects all events into an array. */
|
|
104
|
+
export declare function splitAll(input: AudioInput, options?: SplitOptions): Promise<AudioRegion[]>;
|
|
105
|
+
type TrimFixPausesOptions = Omit<SplitOptions, "maxDur" | "strictMinDur">;
|
|
106
|
+
/**
|
|
107
|
+
* Detect audio activity in `input` and return the audio between the
|
|
108
|
+
* start of the first detection and the end of the last, removing
|
|
109
|
+
* leading and trailing silence. Chunk-stream input is read once and
|
|
110
|
+
* recorded as it is consumed. Returns an empty region (zero duration)
|
|
111
|
+
* if no activity is detected.
|
|
112
|
+
*/
|
|
113
|
+
export declare function trim(input: AudioInput, options?: TrimFixPausesOptions): Promise<AudioRegion>;
|
|
114
|
+
/**
|
|
115
|
+
* Normalize pauses: detect events (never truncated) and join them
|
|
116
|
+
* with exactly `silenceDuration` seconds of silence between them.
|
|
117
|
+
* Events shorter than `minDur` are discarded. Returns an empty region
|
|
118
|
+
* (zero duration) if no events are detected.
|
|
119
|
+
*/
|
|
120
|
+
export declare function fixPauses(input: AudioInput, silenceDuration: number, options?: TrimFixPausesOptions): Promise<AudioRegion>;
|
|
121
|
+
export {};
|