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
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Amine SEHILI
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
# auditok.js
|
|
2
|
+
|
|
3
|
+
**Audio activity detection and segmentation for JavaScript** — the
|
|
4
|
+
[auditok](https://github.com/amsehili/auditok) Python package, ported to run
|
|
5
|
+
in Node and in the browser. Zero dependencies.
|
|
6
|
+
|
|
7
|
+
`auditok` finds where the sound is. It splits audio into *events* — regions
|
|
8
|
+
of acoustic activity shaped by duration and silence rules — rather than
|
|
9
|
+
emitting raw per-frame speech/no-speech booleans:
|
|
10
|
+
|
|
11
|
+
- **Event semantics, not frame booleans**: minimum and maximum event
|
|
12
|
+
duration, tolerated silence inside an event, leading/trailing silence
|
|
13
|
+
handling for natural onsets and fadeouts.
|
|
14
|
+
- **Offline file segmentation and online streaming** with the same API:
|
|
15
|
+
events are yielded as soon as their end is decided, while the input is
|
|
16
|
+
still being read, decoded or recorded.
|
|
17
|
+
- **Generic acoustic activity**, not speech-only — music, claps, beeps,
|
|
18
|
+
speech alike. For speech-specific detection, plug any frame-level VAD into
|
|
19
|
+
the tokenizer as a custom validator and get proper event shaping on top of
|
|
20
|
+
its decisions.
|
|
21
|
+
- **A few KB of plain JS** — no ONNX runtime, no model downloads, no native
|
|
22
|
+
modules.
|
|
23
|
+
|
|
24
|
+
The detection engine, energy scale and parameters are those of Python
|
|
25
|
+
auditok: thresholds are in dB relative to 16-bit full scale, and the test
|
|
26
|
+
suite replays fixtures generated by the Python implementation to keep the
|
|
27
|
+
two in lockstep — same tokens, same event boundaries, same estimated
|
|
28
|
+
thresholds.
|
|
29
|
+
|
|
30
|
+
**[Try it in your browser](https://amsehili.github.io/auditok.js/)** — drop
|
|
31
|
+
an audio file or record your microphone, tune the threshold, listen to the
|
|
32
|
+
detected events. Everything runs client-side.
|
|
33
|
+
|
|
34
|
+
## Install
|
|
35
|
+
|
|
36
|
+
```sh
|
|
37
|
+
npm install auditok
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Node ≥ 18. In Node, WAV files are parsed natively; every other format
|
|
41
|
+
(mp3, ogg, flac, aac, …) and microphone capture go through
|
|
42
|
+
[ffmpeg](https://ffmpeg.org/), which must be on the `PATH`. In the browser,
|
|
43
|
+
decoding and microphone input use the Web Audio API — nothing to install.
|
|
44
|
+
|
|
45
|
+
## Quick start (Node)
|
|
46
|
+
|
|
47
|
+
```js
|
|
48
|
+
import { load, split } from "auditok";
|
|
49
|
+
|
|
50
|
+
const audio = await load("audio.mp3"); // WAV parsed natively, rest via ffmpeg
|
|
51
|
+
|
|
52
|
+
for await (const event of split(audio)) {
|
|
53
|
+
console.log(`event: ${event.start.toFixed(3)}s -> ${event.end.toFixed(3)}s`);
|
|
54
|
+
}
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Split a large compressed file *while it is being decoded*:
|
|
58
|
+
|
|
59
|
+
```js
|
|
60
|
+
import { decodeFileStream, split } from "auditok";
|
|
61
|
+
|
|
62
|
+
const stream = decodeFileStream("podcast.mp3", { sampleRate: 16000 });
|
|
63
|
+
for await (const event of split(stream.chunks, { sampleRate: stream.sampleRate })) {
|
|
64
|
+
console.log(event.start, event.end);
|
|
65
|
+
}
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Detect events from the microphone (captured through ffmpeg):
|
|
69
|
+
|
|
70
|
+
```js
|
|
71
|
+
import { microphone, split } from "auditok";
|
|
72
|
+
|
|
73
|
+
const mic = microphone({ sampleRate: 16000 });
|
|
74
|
+
for await (const event of split(mic.chunks, { sampleRate: mic.sampleRate })) {
|
|
75
|
+
console.log(`heard something: ${event.start.toFixed(2)}s, ${event.duration.toFixed(2)}s`);
|
|
76
|
+
}
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Save events or processed audio as WAV:
|
|
80
|
+
|
|
81
|
+
```js
|
|
82
|
+
import { load, fixPauses, save } from "auditok";
|
|
83
|
+
|
|
84
|
+
const cleaned = await fixPauses(await load("interview.wav"), 0.4);
|
|
85
|
+
await save("cleaned.wav", cleaned);
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## Quick start (browser)
|
|
89
|
+
|
|
90
|
+
Same core API; `load` uses the browser's own decoder and `microphone` uses
|
|
91
|
+
getUserMedia + AudioWorklet:
|
|
92
|
+
|
|
93
|
+
```js
|
|
94
|
+
import { load, microphone, split } from "auditok";
|
|
95
|
+
|
|
96
|
+
// from an <input type="file"> or drag-and-drop
|
|
97
|
+
const audio = await load(fileInput.files[0]);
|
|
98
|
+
for await (const event of split(audio)) {
|
|
99
|
+
highlight(event.start, event.end);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// live from the microphone (call from a user gesture)
|
|
103
|
+
const mic = await microphone();
|
|
104
|
+
for await (const event of split(mic.chunks, { sampleRate: mic.sampleRate })) {
|
|
105
|
+
console.log("speech-like activity", event.start, event.end);
|
|
106
|
+
}
|
|
107
|
+
// later: mic.stop()
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
A Web Audio `AudioBuffer` can be passed to `split` directly.
|
|
111
|
+
|
|
112
|
+
## API
|
|
113
|
+
|
|
114
|
+
### `split(input, options)` → `AsyncGenerator<AudioRegion>`
|
|
115
|
+
|
|
116
|
+
`input` is one of: `Float32Array` (mono samples), `Float32Array[]` (planar
|
|
117
|
+
channels), an `AudioRegion`, `{ channelData, sampleRate }`, an
|
|
118
|
+
`AudioBuffer`, or a sync/async iterable of interleaved `Float32Array`
|
|
119
|
+
chunks (the streaming path). Bare samples and chunk streams need
|
|
120
|
+
`options.sampleRate`.
|
|
121
|
+
|
|
122
|
+
| Option | Default | Meaning |
|
|
123
|
+
| --- | --- | --- |
|
|
124
|
+
| `minDur` | `0.2` | Minimum event duration in seconds |
|
|
125
|
+
| `maxDur` | `5` | Maximum event duration (`null` = unlimited); longer events are truncated |
|
|
126
|
+
| `maxSilence` | `0.3` | Maximum continuous silence *within* an event — controls when an event ends |
|
|
127
|
+
| `maxLeadingSilence` | `0` | Silence kept before each event (natural attack) |
|
|
128
|
+
| `maxTrailingSilence` | `null` | Trailing silence kept at the end of each event: `null` keeps all (up to `maxSilence`), `0` drops it, larger values extend collection past the boundary (natural fadeout) |
|
|
129
|
+
| `strictMinDur` | `false` | Reject events shorter than `minDur` even right after a truncated event |
|
|
130
|
+
| `analysisWindow` | `0.05` | Analysis window duration in seconds |
|
|
131
|
+
| `energyThreshold` | `50` | Detection threshold in dB (relative to int16 full scale), used when no `validator` is given |
|
|
132
|
+
| `validator` | — | `"otsu"`, `"percentile"`, `"pXX"` or a function — see below |
|
|
133
|
+
| `useChannel` | `"any"` | Channel used for detection on multichannel audio: `"any"` (max over channels), `"mix"`, or a channel index |
|
|
134
|
+
|
|
135
|
+
`splitAll(input, options)` returns the events as an array.
|
|
136
|
+
|
|
137
|
+
### Automatic thresholding and custom validators
|
|
138
|
+
|
|
139
|
+
If `validator` is set it is the whole story and `energyThreshold` is
|
|
140
|
+
overlooked:
|
|
141
|
+
|
|
142
|
+
- `"otsu"` splits the energy histogram in two classes (balanced,
|
|
143
|
+
parameter-free);
|
|
144
|
+
- `"percentile"` (alias `"p10"`) or `"pXX"` reads the noise floor at the
|
|
145
|
+
XXth percentile of window energies and adds a 6 dB margin
|
|
146
|
+
(recall-oriented).
|
|
147
|
+
|
|
148
|
+
For in-memory input the threshold is estimated from the whole input before
|
|
149
|
+
detection starts. For chunk-stream input (e.g., a live microphone) it is
|
|
150
|
+
**calibrated on the first `calibrationDur` seconds** (default 3), clamped to
|
|
151
|
+
`minEnergyThreshold` (default 40 dB); the calibration audio is replayed, so
|
|
152
|
+
no data is lost. A digitally silent calibration window (muted microphone)
|
|
153
|
+
falls back to the floor and detection keeps running.
|
|
154
|
+
|
|
155
|
+
A **function** validator receives each analysis window as planar
|
|
156
|
+
per-channel samples and decides its validity — this is how you put proper
|
|
157
|
+
event shaping on top of any frame-level VAD (an energy rule of your own, a
|
|
158
|
+
WASM model, Silero frame decisions, …):
|
|
159
|
+
|
|
160
|
+
```js
|
|
161
|
+
for await (const event of split(audio, {
|
|
162
|
+
validator: ([samples]) => myFrameVad(samples), // true = active frame
|
|
163
|
+
maxSilence: 0.1,
|
|
164
|
+
})) { ... }
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
To detect *speech* specifically rather than any acoustic activity, the
|
|
168
|
+
[auditok-webrtcvad](https://www.npmjs.com/package/auditok-webrtcvad)
|
|
169
|
+
add-on (in this repository under `webrtcvad/`) provides the WebRTC voice
|
|
170
|
+
activity detector as such a validator — libfvad compiled to a ~30 KB
|
|
171
|
+
WASM binary, the counterpart of Python auditok's `validator="webrtc:N"`:
|
|
172
|
+
|
|
173
|
+
```js
|
|
174
|
+
import { createWebrtcValidator } from "auditok-webrtcvad";
|
|
175
|
+
|
|
176
|
+
const validator = await createWebrtcValidator({ sampleRate: 16000, mode: 1 });
|
|
177
|
+
for await (const event of split(audio, { validator, maxSilence: 0.1 })) { ... }
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
### `trim(input, options)` → `Promise<AudioRegion>`
|
|
181
|
+
|
|
182
|
+
Audio from the start of the first detection to the end of the last —
|
|
183
|
+
leading and trailing silence removed. Streams are read once and recorded
|
|
184
|
+
while being consumed.
|
|
185
|
+
|
|
186
|
+
### `fixPauses(input, silenceDuration, options)` → `Promise<AudioRegion>`
|
|
187
|
+
|
|
188
|
+
All detected events (never truncated) joined with exactly
|
|
189
|
+
`silenceDuration` seconds of silence between them.
|
|
190
|
+
|
|
191
|
+
### `AudioRegion`
|
|
192
|
+
|
|
193
|
+
What `split` yields: `channelData` (planar `Float32Array` per channel),
|
|
194
|
+
`sampleRate`, `start`, `end`, `duration`, `channels`, `sampleCount`, and
|
|
195
|
+
`toWav()` for a WAV-encoded `Uint8Array`.
|
|
196
|
+
|
|
197
|
+
### Lower level
|
|
198
|
+
|
|
199
|
+
- `StreamTokenizer` — the generic event-shaping state machine, usable over
|
|
200
|
+
any frame type;
|
|
201
|
+
- `calculateEnergy`, `windowEnergy`, `computeFrameEnergies`,
|
|
202
|
+
`estimateEnergyThreshold` — the energy/threshold toolbox;
|
|
203
|
+
- `decodeWav`, `encodeWav` — dependency-free WAV codec (Node and browser);
|
|
204
|
+
- Node: `load`, `save`, `decodeFileStream`, `microphone`;
|
|
205
|
+
- browser: `load`, `microphone`.
|
|
206
|
+
|
|
207
|
+
`import ... from "auditok/core"` gives the environment-free core only.
|
|
208
|
+
|
|
209
|
+
## Demo
|
|
210
|
+
|
|
211
|
+
The playground lives in `docs/` and is served by GitHub Pages straight from
|
|
212
|
+
the main branch — plain ES modules, no bundler. It imports the built
|
|
213
|
+
library from `docs/lib`; refresh that copy after changing the source with
|
|
214
|
+
`npm run demo:lib`. To try it locally, serve the folder with any static
|
|
215
|
+
server, e.g. `npm run demo:serve`.
|
|
216
|
+
|
|
217
|
+
## Relationship to Python auditok
|
|
218
|
+
|
|
219
|
+
Same algorithm, same parameters (camelCased), same dB scale — a threshold
|
|
220
|
+
that works with one implementation works with the other, and the
|
|
221
|
+
[Python documentation](https://auditok.readthedocs.io/) is a valid
|
|
222
|
+
reference for the detection semantics. Parity is enforced by fixtures
|
|
223
|
+
generated with the Python package (`tools/generate_fixtures.py`): the JS
|
|
224
|
+
test suite replays ~300 recorded cases through the tokenizer, `split` and
|
|
225
|
+
the threshold estimators.
|
|
226
|
+
|
|
227
|
+
The WebRTC VAD validator (`validator="webrtc:N"` in Python) is available
|
|
228
|
+
as the separate opt-in
|
|
229
|
+
[auditok-webrtcvad](https://www.npmjs.com/package/auditok-webrtcvad)
|
|
230
|
+
package, so the core keeps its size claim honest; its frame decisions are
|
|
231
|
+
parity-tested bit for bit against Python's `webrtcvad`. Not (yet) ported:
|
|
232
|
+
live threshold calibration for streams and the command-line tool.
|
|
233
|
+
|
|
234
|
+
## License
|
|
235
|
+
|
|
236
|
+
MIT — © Amine Sehili
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser entry point: everything from the core, plus decoding of
|
|
3
|
+
* compressed audio through the browser's own decoder
|
|
4
|
+
* (`decodeAudioData` handles mp3/aac/ogg/flac/wav in every browser)
|
|
5
|
+
* and microphone capture through getUserMedia + AudioWorklet. No
|
|
6
|
+
* external assets, no dependencies.
|
|
7
|
+
*/
|
|
8
|
+
import { AudioRegion } from "../region.js";
|
|
9
|
+
export * from "../index.js";
|
|
10
|
+
export interface BrowserLoadOptions {
|
|
11
|
+
/** Target sample rate for compressed formats (the browser resamples
|
|
12
|
+
* while decoding), default 16000. WAV files are parsed natively at
|
|
13
|
+
* their own rate unless `forceDecode` is set. */
|
|
14
|
+
sampleRate?: number;
|
|
15
|
+
/** Decode WAV files through the browser decoder too (resampling them
|
|
16
|
+
* to `sampleRate`) instead of parsing them natively. */
|
|
17
|
+
forceDecode?: boolean;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Load audio in the browser into an {@link AudioRegion}. Accepts a
|
|
21
|
+
* `File`/`Blob` (e.g. from a file input or drag-and-drop), an
|
|
22
|
+
* `ArrayBuffer`/`Uint8Array`, or a URL string (fetched with CORS
|
|
23
|
+
* rules applying).
|
|
24
|
+
*/
|
|
25
|
+
export declare function load(source: File | Blob | ArrayBuffer | Uint8Array | string, options?: BrowserLoadOptions): Promise<AudioRegion>;
|
|
26
|
+
export interface BrowserMicrophoneOptions {
|
|
27
|
+
/** Sample rate of the capture AudioContext, default 16000. */
|
|
28
|
+
sampleRate?: number;
|
|
29
|
+
}
|
|
30
|
+
export interface BrowserAudioStream {
|
|
31
|
+
/** Mono Float32Array chunks — feed this to `split`. */
|
|
32
|
+
chunks: AsyncIterable<Float32Array>;
|
|
33
|
+
sampleRate: number;
|
|
34
|
+
channels: 1;
|
|
35
|
+
/** Stop the capture and release the microphone. */
|
|
36
|
+
stop(): void;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Capture the microphone as a stream of mono Float32Array chunks via
|
|
40
|
+
* an AudioWorklet (the worklet module is inlined — no asset files).
|
|
41
|
+
* Must be called from a user gesture in most browsers. Call `stop()`
|
|
42
|
+
* to release the microphone.
|
|
43
|
+
*/
|
|
44
|
+
export declare function microphone(options?: BrowserMicrophoneOptions): Promise<BrowserAudioStream>;
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser entry point: everything from the core, plus decoding of
|
|
3
|
+
* compressed audio through the browser's own decoder
|
|
4
|
+
* (`decodeAudioData` handles mp3/aac/ogg/flac/wav in every browser)
|
|
5
|
+
* and microphone capture through getUserMedia + AudioWorklet. No
|
|
6
|
+
* external assets, no dependencies.
|
|
7
|
+
*/
|
|
8
|
+
import { AudioRegion } from "../region.js";
|
|
9
|
+
import { decodeWav, isWav } from "../wav.js";
|
|
10
|
+
export * from "../index.js";
|
|
11
|
+
/**
|
|
12
|
+
* Load audio in the browser into an {@link AudioRegion}. Accepts a
|
|
13
|
+
* `File`/`Blob` (e.g. from a file input or drag-and-drop), an
|
|
14
|
+
* `ArrayBuffer`/`Uint8Array`, or a URL string (fetched with CORS
|
|
15
|
+
* rules applying).
|
|
16
|
+
*/
|
|
17
|
+
export async function load(source, options = {}) {
|
|
18
|
+
let bytes;
|
|
19
|
+
if (typeof source === "string") {
|
|
20
|
+
const response = await fetch(source);
|
|
21
|
+
if (!response.ok) {
|
|
22
|
+
throw new Error(`failed to fetch '${source}': ${response.status}`);
|
|
23
|
+
}
|
|
24
|
+
bytes = await response.arrayBuffer();
|
|
25
|
+
}
|
|
26
|
+
else if (source instanceof Blob) {
|
|
27
|
+
bytes = await source.arrayBuffer();
|
|
28
|
+
}
|
|
29
|
+
else if (source instanceof Uint8Array) {
|
|
30
|
+
bytes = source.buffer.slice(source.byteOffset, source.byteOffset + source.byteLength);
|
|
31
|
+
}
|
|
32
|
+
else {
|
|
33
|
+
bytes = source;
|
|
34
|
+
}
|
|
35
|
+
if (options.forceDecode !== true && isWav(bytes)) {
|
|
36
|
+
const { channelData, sampleRate } = decodeWav(bytes);
|
|
37
|
+
return new AudioRegion(channelData, sampleRate);
|
|
38
|
+
}
|
|
39
|
+
const sampleRate = options.sampleRate ?? 16000;
|
|
40
|
+
const context = new OfflineAudioContext({
|
|
41
|
+
numberOfChannels: 1,
|
|
42
|
+
length: 1,
|
|
43
|
+
sampleRate,
|
|
44
|
+
});
|
|
45
|
+
const buffer = await context.decodeAudioData(bytes);
|
|
46
|
+
const channelData = Array.from({ length: buffer.numberOfChannels }, (_, c) => buffer.getChannelData(c));
|
|
47
|
+
return new AudioRegion(channelData, buffer.sampleRate);
|
|
48
|
+
}
|
|
49
|
+
const CAPTURE_WORKLET = `
|
|
50
|
+
class AuditokCapture extends AudioWorkletProcessor {
|
|
51
|
+
process(inputs) {
|
|
52
|
+
const input = inputs[0];
|
|
53
|
+
if (input && input[0] && input[0].length > 0) {
|
|
54
|
+
this.port.postMessage(input[0].slice());
|
|
55
|
+
}
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
registerProcessor("auditok-capture", AuditokCapture);
|
|
60
|
+
`;
|
|
61
|
+
/**
|
|
62
|
+
* Capture the microphone as a stream of mono Float32Array chunks via
|
|
63
|
+
* an AudioWorklet (the worklet module is inlined — no asset files).
|
|
64
|
+
* Must be called from a user gesture in most browsers. Call `stop()`
|
|
65
|
+
* to release the microphone.
|
|
66
|
+
*/
|
|
67
|
+
export async function microphone(options = {}) {
|
|
68
|
+
const sampleRate = options.sampleRate ?? 16000;
|
|
69
|
+
const media = await navigator.mediaDevices.getUserMedia({
|
|
70
|
+
audio: { channelCount: 1 },
|
|
71
|
+
});
|
|
72
|
+
const context = new AudioContext({ sampleRate });
|
|
73
|
+
const workletUrl = URL.createObjectURL(new Blob([CAPTURE_WORKLET], { type: "application/javascript" }));
|
|
74
|
+
try {
|
|
75
|
+
await context.audioWorklet.addModule(workletUrl);
|
|
76
|
+
}
|
|
77
|
+
finally {
|
|
78
|
+
URL.revokeObjectURL(workletUrl);
|
|
79
|
+
}
|
|
80
|
+
const sourceNode = context.createMediaStreamSource(media);
|
|
81
|
+
const captureNode = new AudioWorkletNode(context, "auditok-capture");
|
|
82
|
+
sourceNode.connect(captureNode);
|
|
83
|
+
const pending = [];
|
|
84
|
+
let notify;
|
|
85
|
+
let done = false;
|
|
86
|
+
captureNode.port.onmessage = (event) => {
|
|
87
|
+
pending.push(event.data);
|
|
88
|
+
notify?.();
|
|
89
|
+
};
|
|
90
|
+
async function* chunks() {
|
|
91
|
+
while (!done || pending.length > 0) {
|
|
92
|
+
if (pending.length === 0) {
|
|
93
|
+
await new Promise((resolve) => {
|
|
94
|
+
notify = resolve;
|
|
95
|
+
});
|
|
96
|
+
notify = undefined;
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
yield pending.shift();
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return {
|
|
103
|
+
chunks: chunks(),
|
|
104
|
+
sampleRate: context.sampleRate,
|
|
105
|
+
channels: 1,
|
|
106
|
+
stop() {
|
|
107
|
+
done = true;
|
|
108
|
+
captureNode.port.onmessage = null;
|
|
109
|
+
sourceNode.disconnect();
|
|
110
|
+
captureNode.disconnect();
|
|
111
|
+
for (const track of media.getTracks()) {
|
|
112
|
+
track.stop();
|
|
113
|
+
}
|
|
114
|
+
void context.close();
|
|
115
|
+
notify?.();
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* auditok core: audio activity detection and segmentation over
|
|
3
|
+
* Float32Array samples. Environment-free and dependency-free — this
|
|
4
|
+
* entry point works in Node, browsers, workers and edge runtimes.
|
|
5
|
+
*
|
|
6
|
+
* Importing the package root (`import ... from "auditok"`) adds the
|
|
7
|
+
* platform io helpers (file loading, microphone) on top of this core;
|
|
8
|
+
* `import ... from "auditok/core"` gives exactly this module.
|
|
9
|
+
*/
|
|
10
|
+
export { split, splitAll, trim, fixPauses, type AudioInput, type AudioFrame, type AudioBufferLike, type SplitOptions, } from "./split.js";
|
|
11
|
+
export { AudioRegion, makeSilence, concatRegions } from "./region.js";
|
|
12
|
+
export { StreamTokenizer, type FrameValidator, type Token, type StreamTokenizerOptions, } from "./tokenizer.js";
|
|
13
|
+
export { calculateEnergy, windowEnergy, computeFrameEnergies, estimateEnergyThreshold, EPSILON, INT16_SCALE, SILENCE_ENERGY, type ChannelSelection, type ThresholdMethod, type ThresholdMethodOptions, } from "./signal.js";
|
|
14
|
+
export { decodeWav, encodeWav, isWav, type DecodedAudio, type WavEncodeOptions, } from "./wav.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* auditok core: audio activity detection and segmentation over
|
|
3
|
+
* Float32Array samples. Environment-free and dependency-free — this
|
|
4
|
+
* entry point works in Node, browsers, workers and edge runtimes.
|
|
5
|
+
*
|
|
6
|
+
* Importing the package root (`import ... from "auditok"`) adds the
|
|
7
|
+
* platform io helpers (file loading, microphone) on top of this core;
|
|
8
|
+
* `import ... from "auditok/core"` gives exactly this module.
|
|
9
|
+
*/
|
|
10
|
+
export { split, splitAll, trim, fixPauses, } from "./split.js";
|
|
11
|
+
export { AudioRegion, makeSilence, concatRegions } from "./region.js";
|
|
12
|
+
export { StreamTokenizer, } from "./tokenizer.js";
|
|
13
|
+
export { calculateEnergy, windowEnergy, computeFrameEnergies, estimateEnergyThreshold, EPSILON, INT16_SCALE, SILENCE_ENERGY, } from "./signal.js";
|
|
14
|
+
export { decodeWav, encodeWav, isWav, } from "./wav.js";
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ffmpeg-backed decoding and capture for Node. ffmpeg is the single
|
|
3
|
+
* external tool auditok relies on outside WAV files — the same policy
|
|
4
|
+
* as the Python package: it decodes every compressed format and also
|
|
5
|
+
* captures audio devices, so the package itself keeps zero runtime
|
|
6
|
+
* dependencies.
|
|
7
|
+
*/
|
|
8
|
+
export interface FfmpegStreamOptions {
|
|
9
|
+
/** Output sample rate in Hz (ffmpeg resamples), default 16000. */
|
|
10
|
+
sampleRate?: number;
|
|
11
|
+
/** Output channel count (ffmpeg up/downmixes), default 1. */
|
|
12
|
+
channels?: number;
|
|
13
|
+
/** ffmpeg executable, default "ffmpeg" (must be on PATH). */
|
|
14
|
+
ffmpegPath?: string;
|
|
15
|
+
}
|
|
16
|
+
export interface AudioStream {
|
|
17
|
+
/** Interleaved Float32Array chunks — feed this to `split`. */
|
|
18
|
+
chunks: AsyncIterable<Float32Array>;
|
|
19
|
+
sampleRate: number;
|
|
20
|
+
channels: number;
|
|
21
|
+
/** Stop reading and terminate ffmpeg (for device capture). */
|
|
22
|
+
stop(): void;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Decode an audio file with ffmpeg into a stream of interleaved
|
|
26
|
+
* Float32Array chunks. Handles every format ffmpeg can read (mp3, ogg,
|
|
27
|
+
* flac, aac, video containers, ...), with optional resampling and
|
|
28
|
+
* channel mixing. The stream starts as soon as ffmpeg produces data,
|
|
29
|
+
* so `split` can emit events while the file is still being decoded.
|
|
30
|
+
*/
|
|
31
|
+
export declare function decodeFileStream(path: string, options?: FfmpegStreamOptions): AudioStream;
|
|
32
|
+
export interface MicrophoneOptions extends FfmpegStreamOptions {
|
|
33
|
+
/**
|
|
34
|
+
* Capture device. Defaults per platform: `"default"` with PulseAudio
|
|
35
|
+
* on Linux, `":default"` with AVFoundation on macOS. On Windows
|
|
36
|
+
* (DirectShow) there is no default: pass the device name shown by
|
|
37
|
+
* `ffmpeg -list_devices true -f dshow -i dummy`, without the
|
|
38
|
+
* `audio=` prefix.
|
|
39
|
+
*/
|
|
40
|
+
device?: string;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Capture the microphone through ffmpeg's device support (PulseAudio /
|
|
44
|
+
* AVFoundation / DirectShow). Returns a stream of interleaved
|
|
45
|
+
* Float32Array chunks; call `stop()` to end the capture.
|
|
46
|
+
*/
|
|
47
|
+
export declare function microphone(options?: MicrophoneOptions): AudioStream;
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ffmpeg-backed decoding and capture for Node. ffmpeg is the single
|
|
3
|
+
* external tool auditok relies on outside WAV files — the same policy
|
|
4
|
+
* as the Python package: it decodes every compressed format and also
|
|
5
|
+
* captures audio devices, so the package itself keeps zero runtime
|
|
6
|
+
* dependencies.
|
|
7
|
+
*/
|
|
8
|
+
import { spawn } from "node:child_process";
|
|
9
|
+
function runFfmpeg(inputArgs, options) {
|
|
10
|
+
const sampleRate = options.sampleRate ?? 16000;
|
|
11
|
+
const channels = options.channels ?? 1;
|
|
12
|
+
const ffmpegPath = options.ffmpegPath ?? "ffmpeg";
|
|
13
|
+
const args = [
|
|
14
|
+
"-hide_banner",
|
|
15
|
+
"-loglevel",
|
|
16
|
+
"error",
|
|
17
|
+
...inputArgs,
|
|
18
|
+
"-f",
|
|
19
|
+
"f32le",
|
|
20
|
+
"-acodec",
|
|
21
|
+
"pcm_f32le",
|
|
22
|
+
"-ac",
|
|
23
|
+
String(channels),
|
|
24
|
+
"-ar",
|
|
25
|
+
String(sampleRate),
|
|
26
|
+
"-",
|
|
27
|
+
];
|
|
28
|
+
const child = spawn(ffmpegPath, args, {
|
|
29
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
30
|
+
});
|
|
31
|
+
let stderr = "";
|
|
32
|
+
child.stderr.setEncoding("utf8");
|
|
33
|
+
child.stderr.on("data", (text) => {
|
|
34
|
+
stderr += text;
|
|
35
|
+
});
|
|
36
|
+
let stopped = false;
|
|
37
|
+
async function* chunks() {
|
|
38
|
+
// carry bytes that don't end on a float32 boundary to the next chunk
|
|
39
|
+
let carry = Buffer.alloc(0);
|
|
40
|
+
try {
|
|
41
|
+
for await (const data of child.stdout) {
|
|
42
|
+
const buffer = carry.length > 0 ? Buffer.concat([carry, data]) : data;
|
|
43
|
+
const usable = buffer.length - (buffer.length % 4);
|
|
44
|
+
carry = buffer.subarray(usable);
|
|
45
|
+
if (usable === 0) {
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
const samples = new Float32Array(usable / 4);
|
|
49
|
+
for (let i = 0; i < samples.length; i++) {
|
|
50
|
+
samples[i] = buffer.readFloatLE(i * 4);
|
|
51
|
+
}
|
|
52
|
+
yield samples;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
finally {
|
|
56
|
+
child.kill("SIGKILL");
|
|
57
|
+
}
|
|
58
|
+
const exitCode = await new Promise((resolve) => {
|
|
59
|
+
if (child.exitCode !== null || stopped) {
|
|
60
|
+
resolve(child.exitCode);
|
|
61
|
+
}
|
|
62
|
+
else {
|
|
63
|
+
child.once("close", resolve);
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
if (!stopped && exitCode !== 0) {
|
|
67
|
+
throw new Error(`ffmpeg exited with code ${exitCode}: ${stderr.trim()}`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return {
|
|
71
|
+
chunks: chunks(),
|
|
72
|
+
sampleRate,
|
|
73
|
+
channels,
|
|
74
|
+
stop() {
|
|
75
|
+
stopped = true;
|
|
76
|
+
child.kill("SIGTERM");
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Decode an audio file with ffmpeg into a stream of interleaved
|
|
82
|
+
* Float32Array chunks. Handles every format ffmpeg can read (mp3, ogg,
|
|
83
|
+
* flac, aac, video containers, ...), with optional resampling and
|
|
84
|
+
* channel mixing. The stream starts as soon as ffmpeg produces data,
|
|
85
|
+
* so `split` can emit events while the file is still being decoded.
|
|
86
|
+
*/
|
|
87
|
+
export function decodeFileStream(path, options = {}) {
|
|
88
|
+
return runFfmpeg(["-i", path], options);
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Capture the microphone through ffmpeg's device support (PulseAudio /
|
|
92
|
+
* AVFoundation / DirectShow). Returns a stream of interleaved
|
|
93
|
+
* Float32Array chunks; call `stop()` to end the capture.
|
|
94
|
+
*/
|
|
95
|
+
export function microphone(options = {}) {
|
|
96
|
+
let inputArgs;
|
|
97
|
+
const { device } = options;
|
|
98
|
+
switch (process.platform) {
|
|
99
|
+
case "linux":
|
|
100
|
+
inputArgs = ["-f", "pulse", "-i", device ?? "default"];
|
|
101
|
+
break;
|
|
102
|
+
case "darwin":
|
|
103
|
+
inputArgs = ["-f", "avfoundation", "-i", device ?? ":default"];
|
|
104
|
+
break;
|
|
105
|
+
case "win32":
|
|
106
|
+
if (device === undefined) {
|
|
107
|
+
throw new Error("on Windows, pass the capture device name (see " +
|
|
108
|
+
"`ffmpeg -list_devices true -f dshow -i dummy`)");
|
|
109
|
+
}
|
|
110
|
+
inputArgs = ["-f", "dshow", "-i", `audio=${device}`];
|
|
111
|
+
break;
|
|
112
|
+
default:
|
|
113
|
+
throw new Error(`microphone capture is not supported on platform ` +
|
|
114
|
+
`'${process.platform}'`);
|
|
115
|
+
}
|
|
116
|
+
return runFfmpeg(inputArgs, options);
|
|
117
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
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 { AudioRegion } from "../region.js";
|
|
7
|
+
import { type WavEncodeOptions } from "../wav.js";
|
|
8
|
+
import { type FfmpegStreamOptions } from "./ffmpeg.js";
|
|
9
|
+
export * from "../index.js";
|
|
10
|
+
export { decodeFileStream, microphone, type AudioStream, type FfmpegStreamOptions, type MicrophoneOptions, } from "./ffmpeg.js";
|
|
11
|
+
export interface LoadOptions extends FfmpegStreamOptions {
|
|
12
|
+
/**
|
|
13
|
+
* By default WAV files are parsed natively at their own sample rate
|
|
14
|
+
* and channel count, and other formats are decoded with ffmpeg
|
|
15
|
+
* (resampled to `sampleRate`, mixed to `channels`). Set to true to
|
|
16
|
+
* force the ffmpeg path for WAV files too, e.g. to resample them.
|
|
17
|
+
*/
|
|
18
|
+
forceFfmpeg?: boolean;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Load an audio file into an {@link AudioRegion}. WAV files are parsed
|
|
22
|
+
* natively; every other format is decoded with ffmpeg (which must be
|
|
23
|
+
* on the PATH). To detect events while a large compressed file is
|
|
24
|
+
* still being decoded, use {@link decodeFileStream} with `split`
|
|
25
|
+
* instead of loading it fully.
|
|
26
|
+
*/
|
|
27
|
+
export declare function load(path: string, options?: LoadOptions): Promise<AudioRegion>;
|
|
28
|
+
/** Save a region (or any `{ channelData, sampleRate }`) as a WAV file. */
|
|
29
|
+
export declare function save(path: string, audio: {
|
|
30
|
+
channelData: readonly Float32Array[];
|
|
31
|
+
sampleRate: number;
|
|
32
|
+
}, options?: WavEncodeOptions): Promise<void>;
|