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/):

WhatFromTo
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 DenoiseNodeactivation (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.

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:

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:

CodeClassWhen it happens and what to do
E_COOP_COEPinit · fatalNo SharedArrayBuffer / cross-origin isolation. Serve the page over HTTPS with COOP: same-origin and COEP: require-corp headers.
E_WORKLET_LOADinit · fatalFailed to load the AudioWorklet processor. Check workletUrl and that denoise-processor.js is actually served.
E_WORKER_INITinit · fatalThe worker could not bring up the backend: wasm/model failed to load or backend.init threw. Check assets/activation and model availability.
E_WORKER_TIMEOUTinit · fatalThe worker did not report ready within initTimeoutMs. Slow network / large model — raise initTimeoutMs or preload assets (preloadDenoise).
E_WORKER_CRASHruntime · fatalUnhandled worker error at runtime. See error; a common cause is an incompatible ort/model build.
E_ACTIVATIONinit · fatalOnline activation rejected/unavailable: wrong or revoked key, foreign domain, network error. Check the key and activateUrl.
E_LICENSE_INVALIDinit · fatal in enforceOffline license verification failed (signature, domain, term, features). In enforce mode startup is blocked; in warn — event only.
E_SAMPLE_RATEruntime · warningThe provided AudioContext sampleRate ≠ expected. Create the context with the right sampleRate or don't pass your own.
E_SELFTESTruntime · warningThe post-init self-test produced invalid output (NaN, clipping or silence). The backend/model is likely faulty.
E_PROCESS_FRAMEruntime · warningException while processing an audio frame. The frame is skipped; if it repeats, check the backend.
E_BACKEND_SWITCHruntime · warningRuntime backend switch failed. The previous backend is torn down — call switchBackend again or recreate the node.
E_ACTIVATION_ANOMALYruntime · warningThe server flagged activation as anomalous (one key across many domains). Processing continues — check how the key is distributed.
E_LICENSE_REFRESHruntime · warningFailed to refresh the session and reactivate the key. The denoiser failed open (raw audio); retries continue once a minute.
E_WATCHDOGruntime · warningThe 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

OptionDefaultDescription
backendBackend: 'dfn3' | 'spectral' | 'passthrough' | custom.
workletUrl/denoise-processor.jsURL of the AudioWorklet processor.
contextcreatedAn existing AudioContext, if you want to bring your own.
suppressionLevel60Suppression strength, 0..100.
mix1Dry/wet mix, 0..1.
bypassfalseInstant model bypass (raw audio to the output), for A/B.
sampleRate48000Graph 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).
threads1wasm inference threads. On weak CPUs 2–4 noticeably lower frame time; requires COOP/COEP (which is mandatory anyway).
highpassHz80High-pass on the input: cuts wind into the mic, handling noise and DC before the model. 0 — disable.
activationOnline activation: { key, activateUrl?, refreshUrl? }. Required for dfn3 (serves the model). URLs default to the Peito server.
licenseno checkOffline verification: key + enforce/warn mode, optional onlineCheck.
initTimeoutMs20000Worker-ready timeout in init(). On expiry — PeitoError(E_WORKER_TIMEOUT).
fallbackBackend degradation chain, e.g. ['dfn3','spectral'].
debug{ verbosity: 'error' }Console logging: 'silent' | 'error' | 'info' | 'debug'; metricsHz.
captureSeconds8How 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.