@tensamin/audio 0.1.1 → 0.1.3
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/README.md +50 -3
- package/dist/chunk-6P2RDBW5.mjs +47 -0
- package/dist/chunk-EXH2PNUE.mjs +212 -0
- package/{src/vad/vad-state.ts → dist/chunk-JJASCVEW.mjs} +21 -33
- package/dist/chunk-OZ7KMC4S.mjs +46 -0
- package/dist/chunk-R5JVHKWA.mjs +98 -0
- package/dist/chunk-WBQAMGXK.mjs +0 -0
- package/dist/chunk-XMTQPMQ6.mjs +91 -0
- package/dist/chunk-XO6B3D4A.mjs +67 -0
- package/dist/context/audio-context.d.mts +32 -0
- package/dist/context/audio-context.d.ts +32 -0
- package/dist/context/audio-context.js +75 -0
- package/dist/context/audio-context.mjs +16 -0
- package/dist/extensibility/plugins.d.mts +9 -0
- package/dist/extensibility/plugins.d.ts +9 -0
- package/dist/extensibility/plugins.js +238 -0
- package/dist/extensibility/plugins.mjs +14 -0
- package/dist/index.d.mts +10 -216
- package/dist/index.d.ts +10 -216
- package/dist/index.js +298 -80
- package/dist/index.mjs +29 -352
- package/dist/livekit/integration.d.mts +11 -0
- package/dist/livekit/integration.d.ts +11 -0
- package/dist/livekit/integration.js +585 -0
- package/dist/livekit/integration.mjs +12 -0
- package/dist/noise-suppression/rnnoise-node.d.mts +10 -0
- package/dist/noise-suppression/rnnoise-node.d.ts +10 -0
- package/dist/noise-suppression/rnnoise-node.js +101 -0
- package/dist/noise-suppression/rnnoise-node.mjs +6 -0
- package/dist/pipeline/audio-pipeline.d.mts +6 -0
- package/dist/pipeline/audio-pipeline.d.ts +6 -0
- package/dist/pipeline/audio-pipeline.js +499 -0
- package/dist/pipeline/audio-pipeline.mjs +11 -0
- package/dist/types.d.mts +155 -0
- package/dist/types.d.ts +155 -0
- package/dist/types.js +18 -0
- package/dist/types.mjs +1 -0
- package/dist/vad/vad-node.d.mts +9 -0
- package/dist/vad/vad-node.d.ts +9 -0
- package/dist/vad/vad-node.js +122 -0
- package/dist/vad/vad-node.mjs +6 -0
- package/dist/vad/vad-state.d.mts +15 -0
- package/dist/vad/vad-state.d.ts +15 -0
- package/dist/vad/vad-state.js +83 -0
- package/dist/vad/vad-state.mjs +6 -0
- package/package.json +8 -5
- package/.github/workflows/publish.yml +0 -29
- package/bun.lock +0 -258
- package/src/context/audio-context.ts +0 -69
- package/src/extensibility/plugins.ts +0 -45
- package/src/index.ts +0 -8
- package/src/livekit/integration.ts +0 -61
- package/src/noise-suppression/rnnoise-node.ts +0 -62
- package/src/pipeline/audio-pipeline.ts +0 -154
- package/src/types.ts +0 -167
- package/src/vad/vad-node.ts +0 -78
- package/tsconfig.json +0 -46
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __create = Object.create;
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
7
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
8
|
+
var __export = (target, all) => {
|
|
9
|
+
for (var name in all)
|
|
10
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
11
|
+
};
|
|
12
|
+
var __copyProps = (to, from, except, desc) => {
|
|
13
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
14
|
+
for (let key of __getOwnPropNames(from))
|
|
15
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
16
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
17
|
+
}
|
|
18
|
+
return to;
|
|
19
|
+
};
|
|
20
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
21
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
22
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
23
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
24
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
25
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
26
|
+
mod
|
|
27
|
+
));
|
|
28
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
29
|
+
|
|
30
|
+
// src/noise-suppression/rnnoise-node.ts
|
|
31
|
+
var rnnoise_node_exports = {};
|
|
32
|
+
__export(rnnoise_node_exports, {
|
|
33
|
+
RNNoisePlugin: () => RNNoisePlugin
|
|
34
|
+
});
|
|
35
|
+
module.exports = __toCommonJS(rnnoise_node_exports);
|
|
36
|
+
var RNNoisePlugin = class {
|
|
37
|
+
name = "rnnoise-ns";
|
|
38
|
+
wasmBuffer = null;
|
|
39
|
+
async createNode(context, config) {
|
|
40
|
+
const { loadRnnoise, RnnoiseWorkletNode } = await import("@sapphi-red/web-noise-suppressor");
|
|
41
|
+
if (!config?.enabled) {
|
|
42
|
+
console.log("Noise suppression disabled, using passthrough node");
|
|
43
|
+
const pass = context.createGain();
|
|
44
|
+
return pass;
|
|
45
|
+
}
|
|
46
|
+
if (!config?.wasmUrl || !config?.simdUrl || !config?.workletUrl) {
|
|
47
|
+
const error = new Error(
|
|
48
|
+
`RNNoisePlugin requires 'wasmUrl', 'simdUrl', and 'workletUrl' to be configured. Please download the assets from @sapphi-red/web-noise-suppressor and provide the URLs in the config. Current config: wasmUrl=${config?.wasmUrl}, simdUrl=${config?.simdUrl}, workletUrl=${config?.workletUrl}
|
|
49
|
+
To disable noise suppression, set noiseSuppression.enabled to false.`
|
|
50
|
+
);
|
|
51
|
+
console.error(error.message);
|
|
52
|
+
throw error;
|
|
53
|
+
}
|
|
54
|
+
try {
|
|
55
|
+
if (!this.wasmBuffer) {
|
|
56
|
+
console.log("Loading RNNoise WASM binary...");
|
|
57
|
+
this.wasmBuffer = await loadRnnoise({
|
|
58
|
+
url: config.wasmUrl,
|
|
59
|
+
simdUrl: config.simdUrl
|
|
60
|
+
});
|
|
61
|
+
console.log("RNNoise WASM loaded successfully");
|
|
62
|
+
}
|
|
63
|
+
} catch (error) {
|
|
64
|
+
const err = new Error(
|
|
65
|
+
`Failed to load RNNoise WASM binary: ${error instanceof Error ? error.message : String(error)}`
|
|
66
|
+
);
|
|
67
|
+
console.error(err);
|
|
68
|
+
throw err;
|
|
69
|
+
}
|
|
70
|
+
const workletUrl = config.workletUrl;
|
|
71
|
+
try {
|
|
72
|
+
await context.audioWorklet.addModule(workletUrl);
|
|
73
|
+
console.log("RNNoise worklet loaded successfully");
|
|
74
|
+
} catch (e) {
|
|
75
|
+
const error = new Error(
|
|
76
|
+
`Failed to load RNNoise worklet from ${workletUrl}: ${e instanceof Error ? e.message : String(e)}. Ensure the workletUrl points to a valid RNNoise worklet script.`
|
|
77
|
+
);
|
|
78
|
+
console.error(error.message);
|
|
79
|
+
throw error;
|
|
80
|
+
}
|
|
81
|
+
try {
|
|
82
|
+
const node = new RnnoiseWorkletNode(context, {
|
|
83
|
+
wasmBinary: this.wasmBuffer,
|
|
84
|
+
maxChannels: 1
|
|
85
|
+
// Mono for now
|
|
86
|
+
});
|
|
87
|
+
console.log("RNNoise worklet node created successfully");
|
|
88
|
+
return node;
|
|
89
|
+
} catch (error) {
|
|
90
|
+
const err = new Error(
|
|
91
|
+
`Failed to create RNNoise worklet node: ${error instanceof Error ? error.message : String(error)}`
|
|
92
|
+
);
|
|
93
|
+
console.error(err);
|
|
94
|
+
throw err;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
99
|
+
0 && (module.exports = {
|
|
100
|
+
RNNoisePlugin
|
|
101
|
+
});
|
|
@@ -0,0 +1,499 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __create = Object.create;
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
7
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
8
|
+
var __export = (target, all) => {
|
|
9
|
+
for (var name in all)
|
|
10
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
11
|
+
};
|
|
12
|
+
var __copyProps = (to, from, except, desc) => {
|
|
13
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
14
|
+
for (let key of __getOwnPropNames(from))
|
|
15
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
16
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
17
|
+
}
|
|
18
|
+
return to;
|
|
19
|
+
};
|
|
20
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
21
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
22
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
23
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
24
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
25
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
26
|
+
mod
|
|
27
|
+
));
|
|
28
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
29
|
+
|
|
30
|
+
// src/pipeline/audio-pipeline.ts
|
|
31
|
+
var audio_pipeline_exports = {};
|
|
32
|
+
__export(audio_pipeline_exports, {
|
|
33
|
+
createAudioPipeline: () => createAudioPipeline
|
|
34
|
+
});
|
|
35
|
+
module.exports = __toCommonJS(audio_pipeline_exports);
|
|
36
|
+
var import_mitt = __toESM(require("mitt"));
|
|
37
|
+
|
|
38
|
+
// src/context/audio-context.ts
|
|
39
|
+
var sharedContext = null;
|
|
40
|
+
var activePipelines = 0;
|
|
41
|
+
function getAudioContext(options) {
|
|
42
|
+
if (typeof window === "undefined" || typeof AudioContext === "undefined") {
|
|
43
|
+
throw new Error(
|
|
44
|
+
"AudioContext is not supported in this environment (browser only)."
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
if (!sharedContext || sharedContext.state === "closed") {
|
|
48
|
+
sharedContext = new AudioContext(options);
|
|
49
|
+
}
|
|
50
|
+
return sharedContext;
|
|
51
|
+
}
|
|
52
|
+
function registerPipeline() {
|
|
53
|
+
activePipelines++;
|
|
54
|
+
}
|
|
55
|
+
function unregisterPipeline() {
|
|
56
|
+
activePipelines = Math.max(0, activePipelines - 1);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// src/noise-suppression/rnnoise-node.ts
|
|
60
|
+
var RNNoisePlugin = class {
|
|
61
|
+
name = "rnnoise-ns";
|
|
62
|
+
wasmBuffer = null;
|
|
63
|
+
async createNode(context, config) {
|
|
64
|
+
const { loadRnnoise, RnnoiseWorkletNode } = await import("@sapphi-red/web-noise-suppressor");
|
|
65
|
+
if (!config?.enabled) {
|
|
66
|
+
console.log("Noise suppression disabled, using passthrough node");
|
|
67
|
+
const pass = context.createGain();
|
|
68
|
+
return pass;
|
|
69
|
+
}
|
|
70
|
+
if (!config?.wasmUrl || !config?.simdUrl || !config?.workletUrl) {
|
|
71
|
+
const error = new Error(
|
|
72
|
+
`RNNoisePlugin requires 'wasmUrl', 'simdUrl', and 'workletUrl' to be configured. Please download the assets from @sapphi-red/web-noise-suppressor and provide the URLs in the config. Current config: wasmUrl=${config?.wasmUrl}, simdUrl=${config?.simdUrl}, workletUrl=${config?.workletUrl}
|
|
73
|
+
To disable noise suppression, set noiseSuppression.enabled to false.`
|
|
74
|
+
);
|
|
75
|
+
console.error(error.message);
|
|
76
|
+
throw error;
|
|
77
|
+
}
|
|
78
|
+
try {
|
|
79
|
+
if (!this.wasmBuffer) {
|
|
80
|
+
console.log("Loading RNNoise WASM binary...");
|
|
81
|
+
this.wasmBuffer = await loadRnnoise({
|
|
82
|
+
url: config.wasmUrl,
|
|
83
|
+
simdUrl: config.simdUrl
|
|
84
|
+
});
|
|
85
|
+
console.log("RNNoise WASM loaded successfully");
|
|
86
|
+
}
|
|
87
|
+
} catch (error) {
|
|
88
|
+
const err = new Error(
|
|
89
|
+
`Failed to load RNNoise WASM binary: ${error instanceof Error ? error.message : String(error)}`
|
|
90
|
+
);
|
|
91
|
+
console.error(err);
|
|
92
|
+
throw err;
|
|
93
|
+
}
|
|
94
|
+
const workletUrl = config.workletUrl;
|
|
95
|
+
try {
|
|
96
|
+
await context.audioWorklet.addModule(workletUrl);
|
|
97
|
+
console.log("RNNoise worklet loaded successfully");
|
|
98
|
+
} catch (e) {
|
|
99
|
+
const error = new Error(
|
|
100
|
+
`Failed to load RNNoise worklet from ${workletUrl}: ${e instanceof Error ? e.message : String(e)}. Ensure the workletUrl points to a valid RNNoise worklet script.`
|
|
101
|
+
);
|
|
102
|
+
console.error(error.message);
|
|
103
|
+
throw error;
|
|
104
|
+
}
|
|
105
|
+
try {
|
|
106
|
+
const node = new RnnoiseWorkletNode(context, {
|
|
107
|
+
wasmBinary: this.wasmBuffer,
|
|
108
|
+
maxChannels: 1
|
|
109
|
+
// Mono for now
|
|
110
|
+
});
|
|
111
|
+
console.log("RNNoise worklet node created successfully");
|
|
112
|
+
return node;
|
|
113
|
+
} catch (error) {
|
|
114
|
+
const err = new Error(
|
|
115
|
+
`Failed to create RNNoise worklet node: ${error instanceof Error ? error.message : String(error)}`
|
|
116
|
+
);
|
|
117
|
+
console.error(err);
|
|
118
|
+
throw err;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
// src/vad/vad-node.ts
|
|
124
|
+
var energyVadWorkletCode = `
|
|
125
|
+
class EnergyVadProcessor extends AudioWorkletProcessor {
|
|
126
|
+
constructor() {
|
|
127
|
+
super();
|
|
128
|
+
this.smoothing = 0.95;
|
|
129
|
+
this.energy = 0;
|
|
130
|
+
this.noiseFloor = 0.001;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
process(inputs, outputs, parameters) {
|
|
134
|
+
const input = inputs[0];
|
|
135
|
+
if (!input || !input.length) return true;
|
|
136
|
+
const channel = input[0];
|
|
137
|
+
|
|
138
|
+
// Calculate RMS
|
|
139
|
+
let sum = 0;
|
|
140
|
+
for (let i = 0; i < channel.length; i++) {
|
|
141
|
+
sum += channel[i] * channel[i];
|
|
142
|
+
}
|
|
143
|
+
const rms = Math.sqrt(sum / channel.length);
|
|
144
|
+
|
|
145
|
+
// Simple adaptive noise floor (very basic)
|
|
146
|
+
if (rms < this.noiseFloor) {
|
|
147
|
+
this.noiseFloor = this.noiseFloor * 0.99 + rms * 0.01;
|
|
148
|
+
} else {
|
|
149
|
+
this.noiseFloor = this.noiseFloor * 0.999 + rms * 0.001;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Calculate "probability" based on SNR
|
|
153
|
+
// This is a heuristic mapping from energy to 0-1
|
|
154
|
+
const snr = rms / (this.noiseFloor + 1e-6);
|
|
155
|
+
const probability = Math.min(1, Math.max(0, (snr - 1.5) / 10)); // Arbitrary scaling
|
|
156
|
+
|
|
157
|
+
this.port.postMessage({ probability });
|
|
158
|
+
|
|
159
|
+
return true;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
registerProcessor('energy-vad-processor', EnergyVadProcessor);
|
|
163
|
+
`;
|
|
164
|
+
var EnergyVADPlugin = class {
|
|
165
|
+
name = "energy-vad";
|
|
166
|
+
async createNode(context, config, onDecision) {
|
|
167
|
+
if (!config?.enabled) {
|
|
168
|
+
console.log("VAD disabled, using passthrough node");
|
|
169
|
+
const pass = context.createGain();
|
|
170
|
+
return pass;
|
|
171
|
+
}
|
|
172
|
+
const blob = new Blob([energyVadWorkletCode], {
|
|
173
|
+
type: "application/javascript"
|
|
174
|
+
});
|
|
175
|
+
const url = URL.createObjectURL(blob);
|
|
176
|
+
try {
|
|
177
|
+
await context.audioWorklet.addModule(url);
|
|
178
|
+
console.log("Energy VAD worklet loaded successfully");
|
|
179
|
+
} catch (e) {
|
|
180
|
+
const error = new Error(
|
|
181
|
+
`Failed to load Energy VAD worklet: ${e instanceof Error ? e.message : String(e)}`
|
|
182
|
+
);
|
|
183
|
+
console.error(error.message);
|
|
184
|
+
URL.revokeObjectURL(url);
|
|
185
|
+
throw error;
|
|
186
|
+
}
|
|
187
|
+
URL.revokeObjectURL(url);
|
|
188
|
+
let node;
|
|
189
|
+
try {
|
|
190
|
+
node = new AudioWorkletNode(context, "energy-vad-processor");
|
|
191
|
+
console.log("Energy VAD node created successfully");
|
|
192
|
+
} catch (e) {
|
|
193
|
+
const error = new Error(
|
|
194
|
+
`Failed to create Energy VAD node: ${e instanceof Error ? e.message : String(e)}`
|
|
195
|
+
);
|
|
196
|
+
console.error(error.message);
|
|
197
|
+
throw error;
|
|
198
|
+
}
|
|
199
|
+
node.port.onmessage = (event) => {
|
|
200
|
+
try {
|
|
201
|
+
const { probability } = event.data;
|
|
202
|
+
if (typeof probability === "number" && !isNaN(probability)) {
|
|
203
|
+
onDecision(probability);
|
|
204
|
+
} else {
|
|
205
|
+
console.warn("Invalid VAD probability received:", event.data);
|
|
206
|
+
}
|
|
207
|
+
} catch (error) {
|
|
208
|
+
console.error("Error in VAD message handler:", error);
|
|
209
|
+
}
|
|
210
|
+
};
|
|
211
|
+
node.port.onmessageerror = (event) => {
|
|
212
|
+
console.error("VAD port message error:", event);
|
|
213
|
+
};
|
|
214
|
+
return node;
|
|
215
|
+
}
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
// src/extensibility/plugins.ts
|
|
219
|
+
var nsPlugins = /* @__PURE__ */ new Map();
|
|
220
|
+
var vadPlugins = /* @__PURE__ */ new Map();
|
|
221
|
+
var defaultNs = new RNNoisePlugin();
|
|
222
|
+
nsPlugins.set(defaultNs.name, defaultNs);
|
|
223
|
+
var defaultVad = new EnergyVADPlugin();
|
|
224
|
+
vadPlugins.set(defaultVad.name, defaultVad);
|
|
225
|
+
function getNoiseSuppressionPlugin(name) {
|
|
226
|
+
if (!name) return defaultNs;
|
|
227
|
+
const plugin = nsPlugins.get(name);
|
|
228
|
+
if (!plugin) {
|
|
229
|
+
console.warn(
|
|
230
|
+
`Noise suppression plugin '${name}' not found, falling back to default.`
|
|
231
|
+
);
|
|
232
|
+
return defaultNs;
|
|
233
|
+
}
|
|
234
|
+
return plugin;
|
|
235
|
+
}
|
|
236
|
+
function getVADPlugin(name) {
|
|
237
|
+
if (!name) return defaultVad;
|
|
238
|
+
const plugin = vadPlugins.get(name);
|
|
239
|
+
if (!plugin) {
|
|
240
|
+
console.warn(`VAD plugin '${name}' not found, falling back to default.`);
|
|
241
|
+
return defaultVad;
|
|
242
|
+
}
|
|
243
|
+
return plugin;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// src/vad/vad-state.ts
|
|
247
|
+
var VADStateMachine = class {
|
|
248
|
+
config;
|
|
249
|
+
currentState = "silent";
|
|
250
|
+
lastSpeechTime = 0;
|
|
251
|
+
speechStartTime = 0;
|
|
252
|
+
frameDurationMs = 20;
|
|
253
|
+
// Assumed frame duration, updated by calls
|
|
254
|
+
constructor(config) {
|
|
255
|
+
this.config = {
|
|
256
|
+
enabled: config?.enabled ?? true,
|
|
257
|
+
pluginName: config?.pluginName ?? "energy-vad",
|
|
258
|
+
startThreshold: config?.startThreshold ?? 0.5,
|
|
259
|
+
stopThreshold: config?.stopThreshold ?? 0.4,
|
|
260
|
+
hangoverMs: config?.hangoverMs ?? 300,
|
|
261
|
+
preRollMs: config?.preRollMs ?? 200
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
updateConfig(config) {
|
|
265
|
+
this.config = { ...this.config, ...config };
|
|
266
|
+
}
|
|
267
|
+
processFrame(probability, timestamp) {
|
|
268
|
+
const { startThreshold, stopThreshold, hangoverMs } = this.config;
|
|
269
|
+
let newState = this.currentState;
|
|
270
|
+
if (this.currentState === "silent" || this.currentState === "speech_ending") {
|
|
271
|
+
if (probability >= startThreshold) {
|
|
272
|
+
newState = "speech_starting";
|
|
273
|
+
this.speechStartTime = timestamp;
|
|
274
|
+
this.lastSpeechTime = timestamp;
|
|
275
|
+
} else {
|
|
276
|
+
newState = "silent";
|
|
277
|
+
}
|
|
278
|
+
} else if (this.currentState === "speech_starting" || this.currentState === "speaking") {
|
|
279
|
+
if (probability >= stopThreshold) {
|
|
280
|
+
newState = "speaking";
|
|
281
|
+
this.lastSpeechTime = timestamp;
|
|
282
|
+
} else {
|
|
283
|
+
const timeSinceSpeech = timestamp - this.lastSpeechTime;
|
|
284
|
+
if (timeSinceSpeech < hangoverMs) {
|
|
285
|
+
newState = "speaking";
|
|
286
|
+
} else {
|
|
287
|
+
newState = "speech_ending";
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
if (newState === "speech_starting") newState = "speaking";
|
|
292
|
+
if (newState === "speech_ending") newState = "silent";
|
|
293
|
+
this.currentState = newState;
|
|
294
|
+
return {
|
|
295
|
+
isSpeaking: newState === "speaking",
|
|
296
|
+
probability,
|
|
297
|
+
state: newState
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
// src/pipeline/audio-pipeline.ts
|
|
303
|
+
async function createAudioPipeline(sourceTrack, config = {}) {
|
|
304
|
+
const context = getAudioContext();
|
|
305
|
+
registerPipeline();
|
|
306
|
+
const nsEnabled = config.noiseSuppression?.enabled !== false && Boolean(config.noiseSuppression?.wasmUrl && config.noiseSuppression?.simdUrl && config.noiseSuppression?.workletUrl);
|
|
307
|
+
const vadEnabled = config.vad?.enabled !== false;
|
|
308
|
+
const fullConfig = {
|
|
309
|
+
noiseSuppression: {
|
|
310
|
+
enabled: nsEnabled,
|
|
311
|
+
...config.noiseSuppression
|
|
312
|
+
},
|
|
313
|
+
vad: {
|
|
314
|
+
enabled: vadEnabled,
|
|
315
|
+
...config.vad
|
|
316
|
+
},
|
|
317
|
+
output: {
|
|
318
|
+
speechGain: 1,
|
|
319
|
+
silenceGain: vadEnabled ? 0 : 1,
|
|
320
|
+
// If no VAD, always output audio
|
|
321
|
+
gainRampTime: 0.02,
|
|
322
|
+
...config.output
|
|
323
|
+
},
|
|
324
|
+
livekit: { manageTrackMute: false, ...config.livekit }
|
|
325
|
+
};
|
|
326
|
+
console.log("Audio pipeline config:", {
|
|
327
|
+
noiseSuppression: fullConfig.noiseSuppression?.enabled,
|
|
328
|
+
vad: fullConfig.vad?.enabled,
|
|
329
|
+
output: fullConfig.output
|
|
330
|
+
});
|
|
331
|
+
if (!sourceTrack || sourceTrack.kind !== "audio") {
|
|
332
|
+
throw new Error("createAudioPipeline requires a valid audio MediaStreamTrack");
|
|
333
|
+
}
|
|
334
|
+
if (sourceTrack.readyState === "ended") {
|
|
335
|
+
throw new Error("Cannot create pipeline from an ended MediaStreamTrack");
|
|
336
|
+
}
|
|
337
|
+
const sourceStream = new MediaStream([sourceTrack]);
|
|
338
|
+
const sourceNode = context.createMediaStreamSource(sourceStream);
|
|
339
|
+
let nsNode;
|
|
340
|
+
let vadNode;
|
|
341
|
+
const emitter = (0, import_mitt.default)();
|
|
342
|
+
try {
|
|
343
|
+
const nsPlugin = getNoiseSuppressionPlugin(
|
|
344
|
+
fullConfig.noiseSuppression?.pluginName
|
|
345
|
+
);
|
|
346
|
+
nsNode = await nsPlugin.createNode(
|
|
347
|
+
context,
|
|
348
|
+
fullConfig.noiseSuppression
|
|
349
|
+
);
|
|
350
|
+
} catch (error) {
|
|
351
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
352
|
+
console.error("Failed to create noise suppression node:", err);
|
|
353
|
+
emitter.emit("error", err);
|
|
354
|
+
throw err;
|
|
355
|
+
}
|
|
356
|
+
const vadStateMachine = new VADStateMachine(fullConfig.vad);
|
|
357
|
+
try {
|
|
358
|
+
const vadPlugin = getVADPlugin(fullConfig.vad?.pluginName);
|
|
359
|
+
vadNode = await vadPlugin.createNode(
|
|
360
|
+
context,
|
|
361
|
+
fullConfig.vad,
|
|
362
|
+
(prob) => {
|
|
363
|
+
try {
|
|
364
|
+
const timestamp = context.currentTime * 1e3;
|
|
365
|
+
const newState = vadStateMachine.processFrame(prob, timestamp);
|
|
366
|
+
if (newState.state !== lastVadState.state || Math.abs(newState.probability - lastVadState.probability) > 0.1) {
|
|
367
|
+
emitter.emit("vadChange", newState);
|
|
368
|
+
lastVadState = newState;
|
|
369
|
+
updateGain(newState);
|
|
370
|
+
}
|
|
371
|
+
} catch (vadError) {
|
|
372
|
+
const err = vadError instanceof Error ? vadError : new Error(String(vadError));
|
|
373
|
+
console.error("Error in VAD callback:", err);
|
|
374
|
+
emitter.emit("error", err);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
);
|
|
378
|
+
} catch (error) {
|
|
379
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
380
|
+
console.error("Failed to create VAD node:", err);
|
|
381
|
+
emitter.emit("error", err);
|
|
382
|
+
throw err;
|
|
383
|
+
}
|
|
384
|
+
let lastVadState = {
|
|
385
|
+
isSpeaking: false,
|
|
386
|
+
probability: 0,
|
|
387
|
+
state: "silent"
|
|
388
|
+
};
|
|
389
|
+
const splitter = context.createGain();
|
|
390
|
+
sourceNode.connect(nsNode);
|
|
391
|
+
nsNode.connect(splitter);
|
|
392
|
+
splitter.connect(vadNode);
|
|
393
|
+
const delayNode = context.createDelay(1);
|
|
394
|
+
const preRollSeconds = (fullConfig.vad?.preRollMs ?? 200) / 1e3;
|
|
395
|
+
delayNode.delayTime.value = preRollSeconds;
|
|
396
|
+
const gainNode = context.createGain();
|
|
397
|
+
gainNode.gain.value = fullConfig.output?.silenceGain ?? 0;
|
|
398
|
+
const destination = context.createMediaStreamDestination();
|
|
399
|
+
try {
|
|
400
|
+
splitter.connect(delayNode);
|
|
401
|
+
delayNode.connect(gainNode);
|
|
402
|
+
gainNode.connect(destination);
|
|
403
|
+
} catch (error) {
|
|
404
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
405
|
+
console.error("Failed to wire audio pipeline:", err);
|
|
406
|
+
emitter.emit("error", err);
|
|
407
|
+
throw err;
|
|
408
|
+
}
|
|
409
|
+
function updateGain(state) {
|
|
410
|
+
try {
|
|
411
|
+
const { speechGain, silenceGain, gainRampTime } = fullConfig.output;
|
|
412
|
+
const targetGain = state.isSpeaking ? speechGain ?? 1 : silenceGain ?? 0;
|
|
413
|
+
const now = context.currentTime;
|
|
414
|
+
gainNode.gain.setTargetAtTime(targetGain, now, gainRampTime ?? 0.02);
|
|
415
|
+
} catch (error) {
|
|
416
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
417
|
+
console.error("Failed to update gain:", err);
|
|
418
|
+
emitter.emit("error", err);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
const audioTracks = destination.stream.getAudioTracks();
|
|
422
|
+
console.log("Destination stream tracks:", {
|
|
423
|
+
count: audioTracks.length,
|
|
424
|
+
tracks: audioTracks.map((t) => ({
|
|
425
|
+
id: t.id,
|
|
426
|
+
label: t.label,
|
|
427
|
+
enabled: t.enabled,
|
|
428
|
+
readyState: t.readyState
|
|
429
|
+
}))
|
|
430
|
+
});
|
|
431
|
+
if (audioTracks.length === 0) {
|
|
432
|
+
const err = new Error(
|
|
433
|
+
"Failed to create processed audio track: destination stream has no audio tracks. This may indicate an issue with the audio graph connection."
|
|
434
|
+
);
|
|
435
|
+
console.error(err);
|
|
436
|
+
emitter.emit("error", err);
|
|
437
|
+
throw err;
|
|
438
|
+
}
|
|
439
|
+
const processedTrack = audioTracks[0];
|
|
440
|
+
if (!processedTrack || processedTrack.readyState === "ended") {
|
|
441
|
+
const err = new Error("Processed audio track is invalid or ended");
|
|
442
|
+
console.error(err);
|
|
443
|
+
emitter.emit("error", err);
|
|
444
|
+
throw err;
|
|
445
|
+
}
|
|
446
|
+
console.log("Audio pipeline created successfully:", {
|
|
447
|
+
sourceTrack: {
|
|
448
|
+
id: sourceTrack.id,
|
|
449
|
+
label: sourceTrack.label,
|
|
450
|
+
readyState: sourceTrack.readyState
|
|
451
|
+
},
|
|
452
|
+
processedTrack: {
|
|
453
|
+
id: processedTrack.id,
|
|
454
|
+
label: processedTrack.label,
|
|
455
|
+
readyState: processedTrack.readyState
|
|
456
|
+
},
|
|
457
|
+
config: {
|
|
458
|
+
noiseSuppression: fullConfig.noiseSuppression?.enabled,
|
|
459
|
+
vad: fullConfig.vad?.enabled
|
|
460
|
+
}
|
|
461
|
+
});
|
|
462
|
+
function dispose() {
|
|
463
|
+
try {
|
|
464
|
+
sourceNode.disconnect();
|
|
465
|
+
nsNode.disconnect();
|
|
466
|
+
splitter.disconnect();
|
|
467
|
+
vadNode.disconnect();
|
|
468
|
+
delayNode.disconnect();
|
|
469
|
+
gainNode.disconnect();
|
|
470
|
+
destination.stream.getTracks().forEach((t) => t.stop());
|
|
471
|
+
unregisterPipeline();
|
|
472
|
+
} catch (error) {
|
|
473
|
+
console.error("Error during pipeline disposal:", error);
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
return {
|
|
477
|
+
processedTrack,
|
|
478
|
+
events: emitter,
|
|
479
|
+
get state() {
|
|
480
|
+
return lastVadState;
|
|
481
|
+
},
|
|
482
|
+
setConfig: (newConfig) => {
|
|
483
|
+
try {
|
|
484
|
+
if (newConfig.vad) {
|
|
485
|
+
vadStateMachine.updateConfig(newConfig.vad);
|
|
486
|
+
}
|
|
487
|
+
} catch (error) {
|
|
488
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
489
|
+
console.error("Failed to update config:", err);
|
|
490
|
+
emitter.emit("error", err);
|
|
491
|
+
}
|
|
492
|
+
},
|
|
493
|
+
dispose
|
|
494
|
+
};
|
|
495
|
+
}
|
|
496
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
497
|
+
0 && (module.exports = {
|
|
498
|
+
createAudioPipeline
|
|
499
|
+
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createAudioPipeline
|
|
3
|
+
} from "../chunk-EXH2PNUE.mjs";
|
|
4
|
+
import "../chunk-JJASCVEW.mjs";
|
|
5
|
+
import "../chunk-OZ7KMC4S.mjs";
|
|
6
|
+
import "../chunk-6P2RDBW5.mjs";
|
|
7
|
+
import "../chunk-XO6B3D4A.mjs";
|
|
8
|
+
import "../chunk-R5JVHKWA.mjs";
|
|
9
|
+
export {
|
|
10
|
+
createAudioPipeline
|
|
11
|
+
};
|