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.
@@ -0,0 +1,312 @@
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
+ const SILENCE = 0;
10
+ const POSSIBLE_SILENCE = 1;
11
+ const POSSIBLE_NOISE = 2;
12
+ const NOISE = 3;
13
+ const TRAILING_COLLECTION = 4;
14
+ export class StreamTokenizer {
15
+ constructor(validator, options) {
16
+ this.state = SILENCE;
17
+ this.data = [];
18
+ this.leadingBuffer = [];
19
+ this.contiguousToken = false;
20
+ this.initCount = 0;
21
+ this.silenceLength = 0;
22
+ this.startFrame = 0;
23
+ this.currentFrame = -1;
24
+ if (typeof validator === "function") {
25
+ this.isValid = validator;
26
+ }
27
+ else if (validator && typeof validator.isValid === "function") {
28
+ this.isValid = validator.isValid.bind(validator);
29
+ }
30
+ else {
31
+ throw new TypeError("'validator' must be a function or an object with an isValid method");
32
+ }
33
+ const { minLength, maxLength, maxContinuousSilence, maxLeadingSilence = 0, maxTrailingSilence = null, strictMinLength = false, initMin = 0, initMaxSilence = 0, } = options;
34
+ if (maxLength <= 0) {
35
+ throw new RangeError(`'maxLength' must be > 0 (value=${maxLength})`);
36
+ }
37
+ if (minLength <= 0 || minLength > maxLength) {
38
+ throw new RangeError(`'minLength' must be > 0 and <= 'maxLength' (value=${minLength})`);
39
+ }
40
+ if (maxContinuousSilence >= maxLength) {
41
+ throw new RangeError(`'maxContinuousSilence' must be < 'maxLength' ` +
42
+ `(value=${maxContinuousSilence})`);
43
+ }
44
+ if (initMin >= maxLength) {
45
+ throw new RangeError(`'initMin' must be < 'maxLength' (value=${initMin})`);
46
+ }
47
+ this.minLength = minLength;
48
+ this.maxLength = maxLength;
49
+ this.maxContinuousSilence = maxContinuousSilence;
50
+ this.maxLeadingSilence = maxLeadingSilence;
51
+ this.maxTrailingSilence = maxTrailingSilence;
52
+ this.strictMinLength = strictMinLength;
53
+ this.initMin = initMin;
54
+ this.initMaxSilence = initMaxSilence;
55
+ this.needsTrailingExtension =
56
+ maxTrailingSilence !== null &&
57
+ maxTrailingSilence > maxContinuousSilence;
58
+ }
59
+ /**
60
+ * Tokenize `source` frame by frame, yielding tokens as soon as their
61
+ * end is decided. Accepts both sync and async iterables.
62
+ */
63
+ async *tokenize(source) {
64
+ this.reinitialize();
65
+ for await (const frame of source) {
66
+ this.currentFrame += 1;
67
+ const token = this.process(frame);
68
+ if (token !== undefined) {
69
+ yield token;
70
+ }
71
+ }
72
+ this.currentFrame += 1;
73
+ const token = this.postProcess();
74
+ if (token !== undefined) {
75
+ yield token;
76
+ }
77
+ }
78
+ /** Convenience wrapper collecting all tokens into an array. */
79
+ async tokenizeAll(source) {
80
+ const tokens = [];
81
+ for await (const token of this.tokenize(source)) {
82
+ tokens.push(token);
83
+ }
84
+ return tokens;
85
+ }
86
+ reinitialize() {
87
+ this.contiguousToken = false;
88
+ this.data = [];
89
+ this.state = SILENCE;
90
+ this.currentFrame = -1;
91
+ this.initCount = 0;
92
+ this.silenceLength = 0;
93
+ this.startFrame = 0;
94
+ this.leadingBuffer = [];
95
+ }
96
+ pushLeading(frame) {
97
+ if (this.maxLeadingSilence <= 0) {
98
+ return;
99
+ }
100
+ this.leadingBuffer.push(frame);
101
+ if (this.leadingBuffer.length > this.maxLeadingSilence) {
102
+ this.leadingBuffer.shift();
103
+ }
104
+ }
105
+ startToken(frame) {
106
+ const leading = this.leadingBuffer;
107
+ this.leadingBuffer = [];
108
+ this.initCount = 1;
109
+ this.silenceLength = 0;
110
+ this.startFrame = this.currentFrame - leading.length;
111
+ this.data = [...leading, frame];
112
+ }
113
+ process(frame) {
114
+ const frameIsValid = this.isValid(frame);
115
+ if (this.state === SILENCE) {
116
+ if (frameIsValid) {
117
+ // seems we got a valid frame after a silence
118
+ this.startToken(frame);
119
+ if (this.initCount >= this.initMin) {
120
+ this.state = NOISE;
121
+ if (this.data.length >= this.maxLength) {
122
+ return this.processEndOfDetection(true);
123
+ }
124
+ }
125
+ else {
126
+ this.state = POSSIBLE_NOISE;
127
+ }
128
+ }
129
+ else {
130
+ this.pushLeading(frame);
131
+ }
132
+ return undefined;
133
+ }
134
+ if (this.state === POSSIBLE_NOISE) {
135
+ if (frameIsValid) {
136
+ this.silenceLength = 0;
137
+ this.initCount += 1;
138
+ this.data.push(frame);
139
+ if (this.initCount >= this.initMin) {
140
+ this.state = NOISE;
141
+ if (this.data.length >= this.maxLength) {
142
+ return this.processEndOfDetection(true);
143
+ }
144
+ }
145
+ }
146
+ else {
147
+ this.silenceLength += 1;
148
+ if (this.silenceLength > this.initMaxSilence ||
149
+ this.data.length + 1 >= this.maxLength) {
150
+ // either initMaxSilence or maxLength is reached before
151
+ // initCount, back to silence
152
+ this.data = [];
153
+ this.state = SILENCE;
154
+ }
155
+ else {
156
+ this.data.push(frame);
157
+ }
158
+ }
159
+ return undefined;
160
+ }
161
+ if (this.state === NOISE) {
162
+ if (frameIsValid) {
163
+ this.data.push(frame);
164
+ if (this.data.length >= this.maxLength) {
165
+ return this.processEndOfDetection(true);
166
+ }
167
+ }
168
+ else if (this.maxContinuousSilence <= 0) {
169
+ if (this.needsTrailingExtension) {
170
+ this.data.push(frame);
171
+ this.silenceLength = 1;
172
+ this.state = TRAILING_COLLECTION;
173
+ if (this.data.length >= this.maxLength) {
174
+ this.state = SILENCE;
175
+ return this.processEndOfDetection();
176
+ }
177
+ }
178
+ else {
179
+ this.state = SILENCE;
180
+ const token = this.processEndOfDetection();
181
+ this.pushLeading(frame);
182
+ return token;
183
+ }
184
+ }
185
+ else {
186
+ // this is the first silent frame following a valid one and it
187
+ // is tolerated
188
+ this.silenceLength = 1;
189
+ this.data.push(frame);
190
+ this.state = POSSIBLE_SILENCE;
191
+ if (this.data.length === this.maxLength) {
192
+ return this.processEndOfDetection(true);
193
+ // don't reset silenceLength because we still need to know
194
+ // the total number of silent frames
195
+ }
196
+ }
197
+ return undefined;
198
+ }
199
+ if (this.state === POSSIBLE_SILENCE) {
200
+ if (frameIsValid) {
201
+ this.data.push(frame);
202
+ this.silenceLength = 0;
203
+ this.state = NOISE;
204
+ if (this.data.length >= this.maxLength) {
205
+ return this.processEndOfDetection(true);
206
+ }
207
+ }
208
+ else {
209
+ if (this.silenceLength >= this.maxContinuousSilence) {
210
+ if (this.needsTrailingExtension &&
211
+ this.silenceLength < this.data.length) {
212
+ // continue collecting trailing beyond maxContinuousSilence
213
+ this.data.push(frame);
214
+ this.silenceLength += 1;
215
+ this.state = TRAILING_COLLECTION;
216
+ if (this.silenceLength >= this.maxTrailingSilence ||
217
+ this.data.length >= this.maxLength) {
218
+ this.state = SILENCE;
219
+ return this.processEndOfDetection();
220
+ }
221
+ }
222
+ else {
223
+ this.state = SILENCE;
224
+ if (this.silenceLength < this.data.length) {
225
+ const token = this.processEndOfDetection();
226
+ this.pushLeading(frame);
227
+ return token;
228
+ }
229
+ this.data = [];
230
+ this.silenceLength = 0;
231
+ this.pushLeading(frame);
232
+ }
233
+ }
234
+ else {
235
+ this.data.push(frame);
236
+ this.silenceLength += 1;
237
+ if (this.data.length >= this.maxLength) {
238
+ return this.processEndOfDetection(true);
239
+ // don't reset silenceLength because we still need to know
240
+ // the total number of silent frames
241
+ }
242
+ }
243
+ }
244
+ return undefined;
245
+ }
246
+ // state === TRAILING_COLLECTION
247
+ if (frameIsValid) {
248
+ // event is over: deliver with collected trailing, then start a
249
+ // new token from this valid frame
250
+ const token = this.processEndOfDetection();
251
+ this.startToken(frame);
252
+ this.state = this.initCount >= this.initMin ? NOISE : POSSIBLE_NOISE;
253
+ return token;
254
+ }
255
+ this.data.push(frame);
256
+ this.silenceLength += 1;
257
+ if (this.silenceLength >= this.maxTrailingSilence ||
258
+ this.data.length >= this.maxLength) {
259
+ this.state = SILENCE;
260
+ return this.processEndOfDetection();
261
+ }
262
+ return undefined;
263
+ }
264
+ postProcess() {
265
+ if (this.state === NOISE ||
266
+ this.state === POSSIBLE_SILENCE ||
267
+ this.state === TRAILING_COLLECTION) {
268
+ if (this.data.length > 0 && this.data.length > this.silenceLength) {
269
+ return this.processEndOfDetection();
270
+ }
271
+ }
272
+ return undefined;
273
+ }
274
+ processEndOfDetection(truncated = false) {
275
+ if (!truncated &&
276
+ this.maxTrailingSilence !== null &&
277
+ this.silenceLength > this.maxTrailingSilence) {
278
+ // Trim trailing silence beyond the allowed amount. Happens if
279
+ // maxContinuousSilence is reached or maxLength is reached at a
280
+ // silent frame. Trimmed frames are seeded into the leading
281
+ // buffer so they can become leading silence for the next token.
282
+ const excess = this.silenceLength - this.maxTrailingSilence;
283
+ const trimStart = Math.max(0, this.data.length - excess);
284
+ const trimmed = this.data.splice(trimStart);
285
+ for (const frame of trimmed) {
286
+ this.pushLeading(frame);
287
+ }
288
+ }
289
+ if (this.data.length >= this.minLength ||
290
+ (this.data.length > 0 && !this.strictMinLength && this.contiguousToken)) {
291
+ const token = {
292
+ frames: this.data,
293
+ start: this.startFrame,
294
+ end: this.startFrame + this.data.length - 1,
295
+ };
296
+ this.data = [];
297
+ if (truncated) {
298
+ // next token (if any) will start at currentFrame + 1 and is
299
+ // contiguous with the just delivered one
300
+ this.startFrame = this.currentFrame + 1;
301
+ this.contiguousToken = true;
302
+ }
303
+ else {
304
+ this.contiguousToken = false;
305
+ }
306
+ return token;
307
+ }
308
+ this.contiguousToken = false;
309
+ this.data = [];
310
+ return undefined;
311
+ }
312
+ }
package/dist/wav.d.ts ADDED
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Environment-free WAV (RIFF) codec: works in Node and in the browser
3
+ * without an AudioContext. Decodes PCM 8/16/24/32-bit integer and
4
+ * 32-bit IEEE float (including WAVE_FORMAT_EXTENSIBLE); encodes 16-bit
5
+ * PCM or 32-bit float.
6
+ */
7
+ export interface DecodedAudio {
8
+ channelData: Float32Array[];
9
+ sampleRate: number;
10
+ }
11
+ /** True if `data` starts with a RIFF/WAVE header. */
12
+ export declare function isWav(data: Uint8Array | ArrayBuffer): boolean;
13
+ /** Decode a WAV file into planar Float32Array channels in [-1, 1]. */
14
+ export declare function decodeWav(data: Uint8Array | ArrayBuffer): DecodedAudio;
15
+ export interface WavEncodeOptions {
16
+ /** 16 (PCM, default) or "float32" (IEEE float). */
17
+ bitDepth?: 16 | "float32";
18
+ }
19
+ /** Encode planar Float32Array channels as a WAV file. */
20
+ export declare function encodeWav(audio: {
21
+ channelData: readonly Float32Array[];
22
+ sampleRate: number;
23
+ }, options?: WavEncodeOptions): Uint8Array;
package/dist/wav.js ADDED
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Environment-free WAV (RIFF) codec: works in Node and in the browser
3
+ * without an AudioContext. Decodes PCM 8/16/24/32-bit integer and
4
+ * 32-bit IEEE float (including WAVE_FORMAT_EXTENSIBLE); encodes 16-bit
5
+ * PCM or 32-bit float.
6
+ */
7
+ const WAVE_FORMAT_PCM = 1;
8
+ const WAVE_FORMAT_IEEE_FLOAT = 3;
9
+ const WAVE_FORMAT_EXTENSIBLE = 0xfffe;
10
+ function toDataView(data) {
11
+ if (data instanceof ArrayBuffer) {
12
+ return new DataView(data);
13
+ }
14
+ return new DataView(data.buffer, data.byteOffset, data.byteLength);
15
+ }
16
+ /** True if `data` starts with a RIFF/WAVE header. */
17
+ export function isWav(data) {
18
+ const view = toDataView(data);
19
+ return (view.byteLength >= 12 &&
20
+ view.getUint32(0, false) === 0x52494646 && // "RIFF"
21
+ view.getUint32(8, false) === 0x57415645 // "WAVE"
22
+ );
23
+ }
24
+ /** Decode a WAV file into planar Float32Array channels in [-1, 1]. */
25
+ export function decodeWav(data) {
26
+ const view = toDataView(data);
27
+ if (!isWav(data)) {
28
+ throw new Error("not a RIFF/WAVE file");
29
+ }
30
+ let format = 0;
31
+ let channels = 0;
32
+ let sampleRate = 0;
33
+ let bitsPerSample = 0;
34
+ let dataOffset = -1;
35
+ let dataLength = 0;
36
+ let offset = 12;
37
+ while (offset + 8 <= view.byteLength) {
38
+ const chunkId = view.getUint32(offset, false);
39
+ const chunkSize = view.getUint32(offset + 4, true);
40
+ const body = offset + 8;
41
+ if (chunkId === 0x666d7420) {
42
+ // "fmt "
43
+ format = view.getUint16(body, true);
44
+ channels = view.getUint16(body + 2, true);
45
+ sampleRate = view.getUint32(body + 4, true);
46
+ bitsPerSample = view.getUint16(body + 14, true);
47
+ if (format === WAVE_FORMAT_EXTENSIBLE && chunkSize >= 40) {
48
+ // sub-format GUID starts with the actual format code
49
+ format = view.getUint16(body + 24, true);
50
+ }
51
+ }
52
+ else if (chunkId === 0x64617461) {
53
+ // "data"
54
+ dataOffset = body;
55
+ dataLength = Math.min(chunkSize, view.byteLength - body);
56
+ }
57
+ offset = body + chunkSize + (chunkSize % 2); // chunks are word-aligned
58
+ }
59
+ if (channels === 0 || sampleRate === 0) {
60
+ throw new Error("invalid WAV file: no fmt chunk");
61
+ }
62
+ if (dataOffset < 0) {
63
+ throw new Error("invalid WAV file: no data chunk");
64
+ }
65
+ const bytesPerSample = bitsPerSample / 8;
66
+ if (!((format === WAVE_FORMAT_PCM && [8, 16, 24, 32].includes(bitsPerSample)) ||
67
+ (format === WAVE_FORMAT_IEEE_FLOAT && bitsPerSample === 32))) {
68
+ throw new Error(`unsupported WAV format: format code ${format}, ` +
69
+ `${bitsPerSample} bits per sample`);
70
+ }
71
+ const frameCount = Math.floor(dataLength / (bytesPerSample * channels));
72
+ const channelData = Array.from({ length: channels }, () => new Float32Array(frameCount));
73
+ for (let i = 0; i < frameCount; i++) {
74
+ for (let c = 0; c < channels; c++) {
75
+ const at = dataOffset + (i * channels + c) * bytesPerSample;
76
+ let value;
77
+ if (format === WAVE_FORMAT_IEEE_FLOAT) {
78
+ value = view.getFloat32(at, true);
79
+ }
80
+ else if (bitsPerSample === 8) {
81
+ value = (view.getUint8(at) - 128) / 128;
82
+ }
83
+ else if (bitsPerSample === 16) {
84
+ value = view.getInt16(at, true) / 32768;
85
+ }
86
+ else if (bitsPerSample === 24) {
87
+ const raw = view.getUint8(at) |
88
+ (view.getUint8(at + 1) << 8) |
89
+ (view.getInt8(at + 2) << 16);
90
+ value = raw / 8388608;
91
+ }
92
+ else {
93
+ value = view.getInt32(at, true) / 2147483648;
94
+ }
95
+ channelData[c][i] = value;
96
+ }
97
+ }
98
+ return { channelData, sampleRate };
99
+ }
100
+ /** Encode planar Float32Array channels as a WAV file. */
101
+ export function encodeWav(audio, options = {}) {
102
+ const { channelData, sampleRate } = audio;
103
+ const bitDepth = options.bitDepth ?? 16;
104
+ const float = bitDepth === "float32";
105
+ const bytesPerSample = float ? 4 : 2;
106
+ const channels = channelData.length;
107
+ const frameCount = channelData[0]?.length ?? 0;
108
+ const dataSize = frameCount * channels * bytesPerSample;
109
+ const buffer = new ArrayBuffer(44 + dataSize);
110
+ const view = new DataView(buffer);
111
+ view.setUint32(0, 0x52494646, false); // "RIFF"
112
+ view.setUint32(4, 36 + dataSize, true);
113
+ view.setUint32(8, 0x57415645, false); // "WAVE"
114
+ view.setUint32(12, 0x666d7420, false); // "fmt "
115
+ view.setUint32(16, 16, true);
116
+ view.setUint16(20, float ? WAVE_FORMAT_IEEE_FLOAT : WAVE_FORMAT_PCM, true);
117
+ view.setUint16(22, channels, true);
118
+ view.setUint32(24, sampleRate, true);
119
+ view.setUint32(28, sampleRate * channels * bytesPerSample, true);
120
+ view.setUint16(32, channels * bytesPerSample, true);
121
+ view.setUint16(34, float ? 32 : 16, true);
122
+ view.setUint32(36, 0x64617461, false); // "data"
123
+ view.setUint32(40, dataSize, true);
124
+ let at = 44;
125
+ for (let i = 0; i < frameCount; i++) {
126
+ for (let c = 0; c < channels; c++) {
127
+ const value = channelData[c][i];
128
+ if (float) {
129
+ view.setFloat32(at, value, true);
130
+ }
131
+ else {
132
+ // symmetric scale, clamped to the int16 range: values on the
133
+ // int16 grid (v / 32768) roundtrip exactly
134
+ const scaled = Math.round(value * 32768);
135
+ view.setInt16(at, Math.max(-32768, Math.min(32767, scaled)), true);
136
+ }
137
+ at += bytesPerSample;
138
+ }
139
+ }
140
+ return new Uint8Array(buffer);
141
+ }
package/package.json ADDED
@@ -0,0 +1,71 @@
1
+ {
2
+ "name": "auditok",
3
+ "version": "0.1.0",
4
+ "description": "Audio activity detection and segmentation: turn audio into events with min/max duration and silence semantics. JavaScript port of the Python auditok package. Zero dependencies.",
5
+ "keywords": [
6
+ "audio",
7
+ "vad",
8
+ "voice-activity-detection",
9
+ "audio-activity-detection",
10
+ "segmentation",
11
+ "silence-detection",
12
+ "silence-removal",
13
+ "speech",
14
+ "tokenizer",
15
+ "webaudio"
16
+ ],
17
+ "author": "Amine Sehili <amine.sehili@gmail.com>",
18
+ "license": "MIT",
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "git+https://github.com/amsehili/auditok.js.git"
22
+ },
23
+ "homepage": "https://github.com/amsehili/auditok.js",
24
+ "bugs": {
25
+ "url": "https://github.com/amsehili/auditok.js/issues"
26
+ },
27
+ "type": "module",
28
+ "sideEffects": false,
29
+ "engines": {
30
+ "node": ">=18"
31
+ },
32
+ "exports": {
33
+ ".": {
34
+ "browser": {
35
+ "types": "./dist/browser/index.d.ts",
36
+ "default": "./dist/browser/index.js"
37
+ },
38
+ "node": {
39
+ "types": "./dist/node/index.d.ts",
40
+ "default": "./dist/node/index.js"
41
+ },
42
+ "default": {
43
+ "types": "./dist/index.d.ts",
44
+ "default": "./dist/index.js"
45
+ }
46
+ },
47
+ "./core": {
48
+ "types": "./dist/index.d.ts",
49
+ "default": "./dist/index.js"
50
+ }
51
+ },
52
+ "types": "./dist/index.d.ts",
53
+ "files": [
54
+ "dist",
55
+ "README.md",
56
+ "LICENSE"
57
+ ],
58
+ "scripts": {
59
+ "build": "tsc -p tsconfig.json",
60
+ "build:webrtcvad": "tsc -p webrtcvad/tsconfig.json",
61
+ "test": "vitest run",
62
+ "demo:lib": "npm run build && npm run build:webrtcvad && rm -rf docs/lib && mkdir -p docs/lib/browser docs/lib/webrtcvad && cp dist/*.js docs/lib/ && cp dist/browser/*.js docs/lib/browser/ && cp webrtcvad/dist/*.js docs/lib/webrtcvad/",
63
+ "demo:serve": "npx serve docs",
64
+ "prepublishOnly": "npm run build && npm test"
65
+ },
66
+ "devDependencies": {
67
+ "@types/node": "^18.19.0",
68
+ "typescript": "^5.5.0",
69
+ "vitest": "^3.2.7"
70
+ }
71
+ }