@voltkit-rtc/client 0.8.0 → 0.9.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/CHANGELOG.md +29 -0
- package/README.md +102 -0
- package/dist/cjs/diagnostics.d.ts +1 -1
- package/dist/cjs/diagnostics.js +1 -1
- package/dist/cjs/effects-core.d.ts +95 -0
- package/dist/cjs/effects-core.d.ts.map +1 -0
- package/dist/cjs/effects-core.js +154 -0
- package/dist/cjs/effects-core.js.map +1 -0
- package/dist/cjs/effects-gl.d.ts +79 -0
- package/dist/cjs/effects-gl.d.ts.map +1 -0
- package/dist/cjs/effects-gl.js +351 -0
- package/dist/cjs/effects-gl.js.map +1 -0
- package/dist/cjs/index.d.ts +57 -0
- package/dist/cjs/index.d.ts.map +1 -1
- package/dist/cjs/index.js +278 -64
- package/dist/cjs/index.js.map +1 -1
- package/dist/cjs/noise-suppression.d.ts +44 -0
- package/dist/cjs/noise-suppression.d.ts.map +1 -0
- package/dist/cjs/noise-suppression.js +103 -0
- package/dist/cjs/noise-suppression.js.map +1 -0
- package/dist/cjs/processors.d.ts +34 -0
- package/dist/cjs/processors.d.ts.map +1 -0
- package/dist/cjs/processors.js +10 -0
- package/dist/cjs/processors.js.map +1 -0
- package/dist/cjs/video-effects.d.ts +93 -0
- package/dist/cjs/video-effects.d.ts.map +1 -0
- package/dist/cjs/video-effects.js +493 -0
- package/dist/cjs/video-effects.js.map +1 -0
- package/dist/esm/diagnostics.d.ts +1 -1
- package/dist/esm/diagnostics.js +1 -1
- package/dist/esm/effects-core.d.ts +95 -0
- package/dist/esm/effects-core.d.ts.map +1 -0
- package/dist/esm/effects-core.js +142 -0
- package/dist/esm/effects-core.js.map +1 -0
- package/dist/esm/effects-gl.d.ts +79 -0
- package/dist/esm/effects-gl.d.ts.map +1 -0
- package/dist/esm/effects-gl.js +347 -0
- package/dist/esm/effects-gl.js.map +1 -0
- package/dist/esm/index.d.ts +57 -0
- package/dist/esm/index.d.ts.map +1 -1
- package/dist/esm/index.js +278 -64
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/noise-suppression.d.ts +44 -0
- package/dist/esm/noise-suppression.d.ts.map +1 -0
- package/dist/esm/noise-suppression.js +99 -0
- package/dist/esm/noise-suppression.js.map +1 -0
- package/dist/esm/processors.d.ts +34 -0
- package/dist/esm/processors.d.ts.map +1 -0
- package/dist/esm/processors.js +9 -0
- package/dist/esm/processors.js.map +1 -0
- package/dist/esm/video-effects.d.ts +93 -0
- package/dist/esm/video-effects.d.ts.map +1 -0
- package/dist/esm/video-effects.js +488 -0
- package/dist/esm/video-effects.js.map +1 -0
- package/package.json +31 -1
- package/src/diagnostics.ts +1 -1
- package/src/effects-core.ts +177 -0
- package/src/effects-gl.ts +384 -0
- package/src/index.ts +276 -63
- package/src/noise-suppression.ts +135 -0
- package/src/processors.ts +35 -0
- package/src/video-effects.ts +552 -0
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AI noise suppression for the microphone (docs/implementation-progress/RTC_PERF_PROGRESS.md P3-4): what Agora's AI
|
|
3
|
+
* Denoiser, LiveKit's Krisp filter and ZEGOCLOUD's AI noise suppression do, with RNNoise -- the open neural-network
|
|
4
|
+
* denoiser Jitsi uses -- through `@sapphi-red/web-noise-suppressor` (MIT): a Web Audio worklet in the browser's
|
|
5
|
+
* audio thread. Fans, traffic and room rumble fall away (measured in Chromium: rumble-like noise down by more than
|
|
6
|
+
* 60 dB, steady white hiss by 7-12 dB); voices stay.
|
|
7
|
+
*
|
|
8
|
+
* Imported on its own (`@voltkit-rtc/client/noise-suppression`), so an app that does not use it never ships it:
|
|
9
|
+
*
|
|
10
|
+
* import { createNoiseSuppressor } from '@voltkit-rtc/client/noise-suppression';
|
|
11
|
+
* await client.setAudioProcessor(createNoiseSuppressor());
|
|
12
|
+
*
|
|
13
|
+
* The worklet (64 kB) and the model (150 kB) are fetched when it starts: from jsDelivr, at the exact version this
|
|
14
|
+
* SDK was tested with, unless you serve them yourself (`workletUrl`, `wasmUrl`, `simdWasmUrl`) -- copy them from
|
|
15
|
+
* `node_modules/@sapphi-red/web-noise-suppressor/dist` to your own site for a strict Content-Security-Policy.
|
|
16
|
+
*/
|
|
17
|
+
import type { AudioTrackProcessor } from './processors.js';
|
|
18
|
+
|
|
19
|
+
/** The package version the default URLs point at; equal to this SDK's pinned dependency (a test checks it). */
|
|
20
|
+
export const NOISE_SUPPRESSOR_VERSION = '0.4.1';
|
|
21
|
+
const CDN = `https://cdn.jsdelivr.net/npm/@sapphi-red/web-noise-suppressor@${NOISE_SUPPRESSOR_VERSION}/dist`;
|
|
22
|
+
|
|
23
|
+
export interface NoiseSuppressorOptions {
|
|
24
|
+
/** Where `rnnoise/workletProcessor.js` is served. Default: jsDelivr, pinned. */
|
|
25
|
+
workletUrl?: string;
|
|
26
|
+
/** Where `rnnoise.wasm` is served. Default: jsDelivr, pinned. */
|
|
27
|
+
wasmUrl?: string;
|
|
28
|
+
/** Where `rnnoise_simd.wasm` is served (used where the browser supports SIMD). Default: jsDelivr, pinned. */
|
|
29
|
+
simdWasmUrl?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** What the processor needs from the browser; stood in for by tests. */
|
|
33
|
+
export interface NoiseSuppressorDeps {
|
|
34
|
+
createContext(): AudioContext;
|
|
35
|
+
loadModel(urls: { url: string; simdUrl: string }): Promise<ArrayBuffer>;
|
|
36
|
+
createNode(context: AudioContext, wasmBinary: ArrayBuffer): Promise<DenoiseNode> | DenoiseNode;
|
|
37
|
+
createStream(tracks: MediaStreamTrack[]): MediaStream;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
type DenoiseNode = AudioNode & { destroy?(): void };
|
|
41
|
+
type Library = typeof import('@sapphi-red/web-noise-suppressor');
|
|
42
|
+
let library: Promise<Library> | null = null;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The library is loaded on first use, not with this module: it extends `AudioWorkletNode` as it loads, which exists
|
|
46
|
+
* only in browsers, so a static import would break a server render (Next.js) or Node.
|
|
47
|
+
*/
|
|
48
|
+
const load = (): Promise<Library> =>
|
|
49
|
+
(library ??= import('@sapphi-red/web-noise-suppressor').catch((err: unknown) => {
|
|
50
|
+
library = null;
|
|
51
|
+
throw err;
|
|
52
|
+
}));
|
|
53
|
+
|
|
54
|
+
const browserDeps: NoiseSuppressorDeps = {
|
|
55
|
+
// RNNoise works on 48 kHz audio.
|
|
56
|
+
createContext: () => new AudioContext({ sampleRate: 48000 }),
|
|
57
|
+
loadModel: async (urls) => (await load()).loadRnnoise(urls),
|
|
58
|
+
createNode: async (context, wasmBinary) => new (await load()).RnnoiseWorkletNode(context, { wasmBinary, maxChannels: 1 }),
|
|
59
|
+
createStream: (tracks) => new MediaStream(tracks),
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/** An `AudioTrackProcessor` for `client.setAudioProcessor()` that removes background noise with RNNoise. */
|
|
63
|
+
export function createNoiseSuppressor(options: NoiseSuppressorOptions = {}, deps: NoiseSuppressorDeps = browserDeps): AudioTrackProcessor {
|
|
64
|
+
let context: AudioContext | null = null;
|
|
65
|
+
let ready: Promise<ArrayBuffer> | null = null;
|
|
66
|
+
let graph: { source: AudioNode; node: DenoiseNode; output: MediaStreamTrack } | null = null;
|
|
67
|
+
let resumeOnGesture: (() => void) | null = null;
|
|
68
|
+
|
|
69
|
+
const release = () => {
|
|
70
|
+
if (!graph) return;
|
|
71
|
+
try {
|
|
72
|
+
graph.source.disconnect();
|
|
73
|
+
graph.node.disconnect();
|
|
74
|
+
graph.node.destroy?.();
|
|
75
|
+
} catch {
|
|
76
|
+
// Already gone.
|
|
77
|
+
}
|
|
78
|
+
graph.output.stop();
|
|
79
|
+
graph = null;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/** A context made without a tap starts suspended: no sound would go out until the page is touched. */
|
|
83
|
+
const keepRunning = (ctx: AudioContext) => {
|
|
84
|
+
void ctx.resume?.().catch(() => undefined);
|
|
85
|
+
if (ctx.state === 'running' || typeof document === 'undefined' || resumeOnGesture) return;
|
|
86
|
+
resumeOnGesture = () => {
|
|
87
|
+
void ctx.resume().catch(() => undefined);
|
|
88
|
+
if (ctx.state === 'running' && resumeOnGesture) {
|
|
89
|
+
for (const type of ['pointerdown', 'keydown'] as const) document.removeEventListener(type, resumeOnGesture, { capture: true });
|
|
90
|
+
resumeOnGesture = null;
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
for (const type of ['pointerdown', 'keydown'] as const) document.addEventListener(type, resumeOnGesture, { capture: true });
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
name: 'noise-suppression',
|
|
98
|
+
async process(track: MediaStreamTrack): Promise<MediaStreamTrack> {
|
|
99
|
+
const ctx = (context ??= deps.createContext());
|
|
100
|
+
ready ??= Promise.all([
|
|
101
|
+
deps.loadModel({ url: options.wasmUrl ?? `${CDN}/rnnoise.wasm`, simdUrl: options.simdWasmUrl ?? `${CDN}/rnnoise_simd.wasm` }),
|
|
102
|
+
ctx.audioWorklet.addModule(options.workletUrl ?? `${CDN}/rnnoise/workletProcessor.js`),
|
|
103
|
+
]).then(([model]) => model);
|
|
104
|
+
let model: ArrayBuffer;
|
|
105
|
+
try {
|
|
106
|
+
model = await ready;
|
|
107
|
+
} catch (err) {
|
|
108
|
+
ready = null; // try again next time (a network blip)
|
|
109
|
+
throw err;
|
|
110
|
+
}
|
|
111
|
+
const node = await deps.createNode(ctx, model);
|
|
112
|
+
release();
|
|
113
|
+
const source = ctx.createMediaStreamSource(deps.createStream([track]));
|
|
114
|
+
const destination = ctx.createMediaStreamDestination();
|
|
115
|
+
source.connect(node);
|
|
116
|
+
node.connect(destination);
|
|
117
|
+
const output = destination.stream.getAudioTracks()[0];
|
|
118
|
+
if (!output) throw new Error('the audio graph gave no track');
|
|
119
|
+
graph = { source, node, output };
|
|
120
|
+
keepRunning(ctx);
|
|
121
|
+
return output;
|
|
122
|
+
},
|
|
123
|
+
async destroy(): Promise<void> {
|
|
124
|
+
release();
|
|
125
|
+
if (resumeOnGesture && typeof document !== 'undefined') {
|
|
126
|
+
for (const type of ['pointerdown', 'keydown'] as const) document.removeEventListener(type, resumeOnGesture, { capture: true });
|
|
127
|
+
}
|
|
128
|
+
resumeOnGesture = null;
|
|
129
|
+
const ctx = context;
|
|
130
|
+
context = null;
|
|
131
|
+
ready = null;
|
|
132
|
+
await ctx?.close().catch(() => undefined);
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Track processors (docs/implementation-progress/RTC_PERF_PROGRESS.md P3-4, P3-5): the published microphone or
|
|
3
|
+
* camera sent through something that changes it first -- noise suppression, a blurred or replaced background, beauty.
|
|
4
|
+
* LiveKit (`setProcessor`: Krisp, background blur), Agora and TRTC (extensions and plugins) do it the same way: the
|
|
5
|
+
* device's track goes in, a processed track goes out and is published in its place; the device's own track stays the
|
|
6
|
+
* one watched for unplugging, given new constraints and released at the end.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** Something the microphone passes through before it is sent. */
|
|
10
|
+
export interface AudioTrackProcessor {
|
|
11
|
+
/** A short name, for diagnostics. */
|
|
12
|
+
readonly name: string;
|
|
13
|
+
/**
|
|
14
|
+
* Process `track` and return the track to send. Called again whenever the microphone's track changes (another
|
|
15
|
+
* device, a reconnect): the processor then lets go of what it made for the previous one.
|
|
16
|
+
*/
|
|
17
|
+
process(track: MediaStreamTrack): Promise<MediaStreamTrack>;
|
|
18
|
+
/** Let go of everything (the processor was removed, or the session ended). */
|
|
19
|
+
destroy(): Promise<void> | void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Something the camera passes through before it is sent (and shown in `client.local`). */
|
|
23
|
+
export interface VideoTrackProcessor {
|
|
24
|
+
/** A short name, for diagnostics. */
|
|
25
|
+
readonly name: string;
|
|
26
|
+
/**
|
|
27
|
+
* Process `track` and return the track to send, at the same size and frame rate. Called again when the camera's
|
|
28
|
+
* track changes (another camera, `replaceVideoTrack`): the processor then lets go of what it made for the previous
|
|
29
|
+
* one. A picture the SDK no longer sends is stopped by the SDK; stop drawing it once its `readyState` is 'ended',
|
|
30
|
+
* and skip the work while it is switched off (`enabled` false: the camera is paused).
|
|
31
|
+
*/
|
|
32
|
+
process(track: MediaStreamTrack): Promise<MediaStreamTrack>;
|
|
33
|
+
/** Let go of everything (the processor was removed, or the session ended). */
|
|
34
|
+
destroy(): Promise<void> | void;
|
|
35
|
+
}
|