Integrating Peito Denoise
Peito Denoise is a real-time AI noise suppressor for the browser, Electron and the Capacitor WebView. This page takes you from installing the package to a processed audio track in your app.
Installation
The package installs from npm. onnxruntime-web is a peer dependency required by the dfn3 neural backend:
npm i peito-denoise onnxruntime-web
The package ships the JS bundle, types and the AudioWorklet processor. Heavy assets (models, wasm) are not included — you host them yourself (see below).
Assets
Copy into your static folder (for example, public/):
| What | From | To |
|---|---|---|
| AudioWorklet processor | node_modules/peito-denoise/dist/denoise-processor.js |
public/denoise-processor.js |
| DFN3 models (~8.5 MB) | model distribution (*.onnx + config.ini) |
public/models/dfn3/ |
| ORT: ESM module + wasm | node_modules/onnxruntime-web/dist/ (ort.bundle.min.mjs and ort-wasm-simd-threaded.jsep.{wasm,mjs}) |
public/ort/ |
The easiest way is to copy the entire onnxruntime-web/dist/ directory into public/ort/.
ORT loads in the worker by a URL, not a bare specifier. A module worker doesn't
resolve import 'onnxruntime-web' and doesn't support import maps, so the
worker imports ORT from /ort/ort.bundle.min.mjs and the wasm from /ort/
(same origin). Both paths are overridable: assets.ortUrl (ESM module) and
assets.cdnUrl (wasm directory). That's exactly why ort.bundle.min.mjs
is required in your static serving — without it dfn3 won't start.
Worker chunk. dfn3 inference runs in a separate module worker
(dist/assets/denoise-worker-<hash>.js). Bundlers with built-in
worker support (Vite) pick it up automatically. When serving statically by hand,
copy the entire dist/assets/ directory — the name contains a hash and changes
between versions.
Why the worklet lives in public/ instead of being imported into the bundle: an AudioWorklet
is loaded only by URL (audioWorklet.addModule) as a separate request,
it never ends up in the bundle, so the build hash is not
added to /denoise-processor.js — and that keeps the build from breaking. If you need cache-busting from your bundler,
get the hashed asset URL and pass it to workletUrl:
// Vite: the file lands in assets with a hash, no need to copy into public/
import workletUrl from 'peito-denoise/denoise-processor.js?url';
const node = new DenoiseNode({ backend: 'dfn3', workletUrl });
// Webpack 5 — likewise:
const workletUrl = new URL('peito-denoise/denoise-processor.js', import.meta.url).href;
COOP/COEP headers
The pipeline uses SharedArrayBuffer (a lock-free ring between the AudioWorklet and the worker),
so the page needs cross-origin isolation. The server must send:
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
On a real domain HTTPS is mandatory: SharedArrayBuffer requires a secure context
(http://localhost is the exception, everything works there). If third-party resources (CDN, S3)
are blocked by COEP — set Cross-Origin-Resource-Policy: cross-origin on them
or use COEP: credentialless.
Example for the Vite dev server:
// vite.config.ts
export default defineConfig({
server: {
headers: {
'Cross-Origin-Opener-Policy': 'same-origin',
'Cross-Origin-Embedder-Policy': 'require-corp',
},
},
});
Diagnostics. If isolation is missing, init() throws
PeitoError(E_COOP_COEP) before the activation request — the symptom
is "the script loaded, but no activation fetch went out." Check up front:
if (!crossOriginIsolated) {
// no COOP/COEP — SharedArrayBuffer is unavailable, the pipeline won't come up
}
import { detectCapabilities } from 'peito-denoise';
console.log(detectCapabilities()); // sharedArrayBuffer, crossOriginIsolated, simd, threads…
Quick start
Minimal integration: microphone → denoiser → speakers. Call init() after a user gesture (a click), otherwise the browser won't let you start an AudioContext.
import { DenoiseNode } from 'peito-denoise';
const node = new DenoiseNode({
backend: 'dfn3', // neural model; 'spectral' — DSP without assets
workletUrl: '/denoise-processor.js', // the copied worklet (this is the default)
});
await node.init();
// graph: any source → denoiser → anywhere
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const src = node.audioContext.createMediaStreamSource(stream);
node.connectSource(src);
node.connect(node.audioContext.destination);
Backends: dfn3 (DeepFilterNet3, neural network, best speech quality), spectral (DSP gate, ~24 dB on stationary noise, no assets), passthrough (bypass, for A/B).
On weak CPUs enable multithreaded wasm inference — threads: 2–4 in the options
(see the reference): dfn3 frame time drops roughly in half.
MediaStreamTrack → clean track
For WebRTC calls it's more convenient to get a processed MediaStreamTrack and hand it to the PeerConnection:
const rawTrack = stream.getAudioTracks()[0];
const cleanTrack = node.createProcessedTrack(rawTrack);
// then as usual:
pc.addTrack(cleanTrack, new MediaStream([cleanTrack]));
LiveKit
An adapter for TrackProcessor. createLiveKitProcessor takes
the same options object as DenoiseNode — activation
(otherwise dfn3 gets no model) and context (a running context) are
required. The adapter calls start() itself after initialization.
import { createLiveKitProcessor } from 'peito-denoise';
// create the running context within a user gesture (a click on "Call")
const ctx = new AudioContext({ sampleRate: 48000 });
await ctx.resume();
const proc = createLiveKitProcessor({
backend: 'dfn3',
suppressionLevel: 70,
activation: {
key: 'pk_…', // key from the dashboard
activateUrl: 'https://peito.ru/api/activate', // or your proxy (see CORS below)
},
context: ctx, // without it the worklet emits silence/raw audio
});
await localAudioTrack.setProcessor(proc); // the adapter calls start() itself
// metrics and errors — via proc.denoiser
proc.denoiser?.on('metrics', (m) => updateUi(m));
proc.denoiser?.on('error', (e) => console.warn(e.code, e.where, e.error));
Why the "Hello World" from the old docs didn't run: without activation dfn3
has no model; without a running context the worklet emits silence/raw audio ("activation
is there, but the noise isn't cut"); without serving the assets (worklet, worker, ORT) the
pipeline won't load. All three are required.
CORS and proxying. In 0.2.0 activateUrl defaults to an absolute URL
(https://peito.ru/api/activate) — the Peito server sends CORS headers, so it's
reachable directly from the browser. If you want to proxy activation through your own backend,
override activation.activateUrl and activation.refreshUrl.
Activation serves the decrypted models over /api/assets?…; they are cached
after the first load (see Preload and cache).
Runtime control
Every parameter can be changed on the fly, without rebuilding the graph:
node.setSuppression(80); // suppression strength 0..100
node.setMix(0.5); // 0 = dry (raw), 1 = wet (processed)
node.setBypass(true); // instant bypass for A/B comparison
node.switchBackend('spectral'); // hot-swap the backend inside the worker
node.setParam('highpassHz', 0); // disable the input high-pass (default 80 Hz)
node.pause(); // worker idles — saves CPU
await node.start(); // resume (+ brings the AudioContext back up)
node.destroy(); // full resource cleanup
AutoGain
Brings quiet speech up to a target level: a VAD gate (doesn't amplify noise during pauses), per-sample smoothing and a lookahead limiter — no clicks, no clipping.
node.setAutoGain({
enabled: true,
targetDb: -18, // target speech level
maxGainDb: 24, // boost ceiling
});
Input high-pass (wind, rumble, DC)
Ahead of the model sits a high-pass filter (2nd-order Butterworth, 80 Hz by default): it cuts everything that lives below speech and that the neural network handles poorly — wind into the capsule, handling noise, mains hum, DC offset. At 40 Hz the attenuation is −12 dB, at 20 Hz −24 dB; from 300 Hz up the filter is transparent, speech is untouched.
new DenoiseNode({ backend: 'dfn3', highpassHz: 100 }); // custom cutoff frequency
node.setParam('highpassHz', 0); // disable on the fly
The filter is applied before the split into raw/processed: the dry/wet mix and the emergency fail-open avoid level jumps at low frequencies. No model can remove the residual mid-frequency turbulence of strong wind without eating speech — only a windscreen/pop filter on the microphone helps there.
Preload and cache
Assets are cached in CacheStorage, so the download happens
at most once, and restarting the denoiser is near-instant.
- Models via activation. Encrypted model blobs (~8.5 MB) are cached by a stable key on the first activation. A re-activation or a page reload takes them from the cache — without re-downloading; only the fast in-memory re-decryption runs again. (The decryption key is ephemeral, no plaintext models are stored.)
- Self-hosted models (
assets.modelUrl) and ORT wasm are cached by URL in the sameCacheStorage.
preloadDenoise() warms the cache ahead of time (wasm + ORT + self-hosted models),
so the first init() starts from the cache and works offline:
import { preloadDenoise, clearDenoiseCache } from 'peito-denoise';
await preloadDenoise({
onProgress: (p) => console.log(`${p.loaded} / ${p.total}`),
});
// clear both caches (assets + models), e.g. when models are updated
await clearDenoiseCache();
Speed bottom line: the first activation downloads the models once; every subsequent start (re-activation, tab reload) brings dfn3 up from the cache in tens of milliseconds. The cache is invalidated automatically when the encryption key is rotated on the server.
Observability
The event bus gives live metrics and artifact detectors — problems show up in telemetry, not "by ear":
node.on('metrics', (e) => {
// reductionDb, inferMs p50/p95, latency, VAD, under/overruns…
console.log('suppression:', e.data.reductionDb, 'dB');
});
node.on('artifact', (e) => console.warn('artifact:', e.kind, e.frame));
node.on('error', (e) => console.error(e));
node.on('backendSwitch', (e) => console.log('fail-open/recovery', e));
// last N seconds of raw/processed — ready-made WAV blobs for offline repro
const { raw, processed } = await node.dumpWav(); // { raw: Blob, processed: Blob }
Detectors: clip, click (glitch), musical (musical noise),
oversuppress (model eating speech), nan/inf, dead (silence).
A built-in watchdog — fail-open: if the worker goes quiet, the node automatically switches to raw audio (instead of silence) and recovers on its own when the worker comes back to life.
Debug panel
A separate entry point — it never ends up in the production bundle unless imported:
import { createDebugPanel } from 'peito-denoise/panel';
const panel = createDebugPanel(node, document.getElementById('debug'));
// metrics with zones, detector lamps, log, start/pause/bypass
panel.destroy(); // remove the panel
Licensing
The main path for all plans (Trial = Pro by features, differing in duration and domains) is
online activation: the client exchanges a short public key (pk_…
from the dashboard) for a session token, the server checks the domain,
the term and revocation, and serves encrypted models over short-lived links. Without a successful
activation the neural backend gets no weights — you can't "skip the check" on the client.
const node = new DenoiseNode({
backend: 'dfn3',
activation: { key: 'pk_…' }, // defaults to → https://peito.ru/api/activate
});
await node.init(); // activation rejected → init() throws
node.session; // current session token
activateUrl and refreshUrl default to the Peito
server (https://peito.ru/api/…) — the library is embedded into third-party
products, so the default is absolute. Override these fields only if
you proxy activation through your own backend.
From there the library renews the session in the background on its own (refresh at ~60% of TTL,
default TTL 15 minutes). If a renewal fails, it re-activates with the key
(models are not re-downloaded). If that also fails (key revoked, term expired, server
unreachable) — the denoiser disables in fail-open: audio keeps flowing, but without
processing; re-activation attempts run once a minute, and on success processing turns back
on. Both transitions are visible in the backendSwitch event.
Additionally, the key can be verified offline too — via an ECDSA P-256 signature (WebCrypto): domains (including wildcards), term, feature set. Works with no network, but can't see a key revocation before the term expires — use it as a complement to activation or for environments with no network.
const node = new DenoiseNode({
backend: 'dfn3',
license: {
key: MY_LICENSE_KEY,
mode: 'enforce', // blocks init() without a valid key; 'warn' — only an error event
},
});
await node.init();
await node.hasFeature('dfn3'); // check a feature from the license
Errors and codes
Every pipeline error is coded: each has a stable E_* code.
Errors surface in three ways, and by default you don't need to wire anything
up to see them:
- Console by default. The library logs errors to the console itself
(
console.errorfor fatal,console.warnfor the rest). The level is set bydebug.verbosity('error'by default;'silent'disables output,'info'/'debug'add backend switches and the self-test). - Fatal ones throw. On paths where the denoiser cannot start,
init()rejects with aPeitoErrorthat has a.codefield. Previously such failures could pass silently — nowawait init()throws. - The
errorevent. For observability, all errors are also emitted as an event with fields{ code, where, error, fatal }.
import { DenoiseNode, PeitoError, PeitoErrorCode } from 'peito-denoise';
const node = new DenoiseNode({
backend: 'dfn3',
activation: { key: 'pk_…' },
debug: { verbosity: 'error' }, // 'silent' | 'error' | 'info' | 'debug'
});
// runtime errors (fail-open, watchdog, license refresh, artifact detectors)
node.on('error', (e) => {
console.warn(e.code, e.where, e.error, e.fatal);
if (e.code === PeitoErrorCode.LICENSE_REFRESH) notifyUser('license expired');
});
try {
await node.init(); // fatal paths throw PeitoError
} catch (e) {
if (e instanceof PeitoError && e.code === PeitoErrorCode.COOP_COEP) {
// no cross-origin isolation — check your COOP/COEP headers
}
throw e;
}
Full list of codes:
| Code | Class | When it happens and what to do |
|---|---|---|
E_COOP_COEP | init · fatal | No SharedArrayBuffer / cross-origin isolation. Serve the page over HTTPS with COOP: same-origin and COEP: require-corp headers. |
E_WORKLET_LOAD | init · fatal | Failed to load the AudioWorklet processor. Check workletUrl and that denoise-processor.js is actually served. |
E_WORKER_INIT | init · fatal | The worker could not bring up the backend: wasm/model failed to load or backend.init threw. Check assets/activation and model availability. |
E_WORKER_TIMEOUT | init · fatal | The worker did not report ready within initTimeoutMs. Slow network / large model — raise initTimeoutMs or preload assets (preloadDenoise). |
E_WORKER_CRASH | runtime · fatal | Unhandled worker error at runtime. See error; a common cause is an incompatible ort/model build. |
E_ACTIVATION | init · fatal | Online activation rejected/unavailable: wrong or revoked key, foreign domain, network error. Check the key and activateUrl. |
E_LICENSE_INVALID | init · fatal in enforce | Offline license verification failed (signature, domain, term, features). In enforce mode startup is blocked; in warn — event only. |
E_SAMPLE_RATE | runtime · warning | The provided AudioContext sampleRate ≠ expected. Create the context with the right sampleRate or don't pass your own. |
E_SELFTEST | runtime · warning | The post-init self-test produced invalid output (NaN, clipping or silence). The backend/model is likely faulty. |
E_PROCESS_FRAME | runtime · warning | Exception while processing an audio frame. The frame is skipped; if it repeats, check the backend. |
E_BACKEND_SWITCH | runtime · warning | Runtime backend switch failed. The previous backend is torn down — call switchBackend again or recreate the node. |
E_ACTIVATION_ANOMALY | runtime · warning | The server flagged activation as anomalous (one key across many domains). Processing continues — check how the key is distributed. |
E_LICENSE_REFRESH | runtime · warning | Failed to refresh the session and reactivate the key. The denoiser failed open (raw audio); retries continue once a minute. |
E_WATCHDOG | runtime · warning | The worker stopped advancing frames for longer than watchdogMs. Temporary fail-open to raw audio; processing returns when the worker revives. |
Custom backend (your own model)
RNNoise, DTLN, GTCRN or an arbitrary ONNX plug in as a plugin — no need to touch the pipeline.
Implement the DenoiseBackend interface and register it:
import { registerBackend } from 'peito-denoise';
import type { DenoiseBackend } from 'peito-denoise';
class MyOnnxBackend implements DenoiseBackend {
// init, processFrame(input, out), setParam, reset, destroy,
// optionally lastDebug() — mask/VAD for the spectrogram and detectors
}
registerBackend('onnx', () => new MyOnnxBackend()); // name — from BackendName ('rnnoise', 'dtln', 'gtcrn', 'onnx')
const node = new DenoiseNode({ backend: 'onnx' });
DenoiseNode options reference
| Option | Default | Description |
|---|---|---|
backend | — | Backend: 'dfn3' | 'spectral' | 'passthrough' | custom. |
workletUrl | /denoise-processor.js | URL of the AudioWorklet processor. |
context | created | An existing AudioContext, if you want to bring your own. |
suppressionLevel | 60 | Suppression strength, 0..100. |
mix | 1 | Dry/wet mix, 0..1. |
bypass | false | Instant model bypass (raw audio to the output), for A/B. |
sampleRate | 48000 | Graph sample rate. dfn3 runs at 48 kHz. |
assets | /models/dfn3/, /ort/ | Paths: modelUrl (models), cdnUrl (ORT wasm directory), ortUrl (ORT ESM module, default ${cdnUrl}ort.bundle.min.mjs). |
threads | 1 | wasm inference threads. On weak CPUs 2–4 noticeably lower frame time; requires COOP/COEP (which is mandatory anyway). |
highpassHz | 80 | High-pass on the input: cuts wind into the mic, handling noise and DC before the model. 0 — disable. |
activation | — | Online activation: { key, activateUrl?, refreshUrl? }. Required for dfn3 (serves the model). URLs default to the Peito server. |
license | no check | Offline verification: key + enforce/warn mode, optional onlineCheck. |
initTimeoutMs | 20000 | Worker-ready timeout in init(). On expiry — PeitoError(E_WORKER_TIMEOUT). |
fallback | — | Backend degradation chain, e.g. ['dfn3','spectral']. |
debug | { verbosity: 'error' } | Console logging: 'silent' | 'error' | 'info' | 'debug'; metricsHz. |
captureSeconds | 8 | How many seconds to hold for dumpWav(). |
Still have questions? Check out the live demo — it runs the whole pipeline with metrics and spectrograms, or start with a Trial license.