@audio/denoise-debleed
v0.3.0
Published
De-bleed: a source's spill taken out of another microphone, given that source's own track. A Kalman filter cancels it through the room path it learns, a Wiener gain takes the residual it predicts
Readme
@audio/denoise-debleed

De-bleed: a source's spill taken out of another microphone, given that source's own track. A Kalman filter cancels it through the room path it learns, a Wiener gain takes the residual it predicts
npm install @audio/denoise-debleedimport debleed from '@audio/denoise-debleed'Takes out of a microphone the bleed of a source whose own track you have: the click track leaking from a singer's headphones, the guitar amp in the vocal mic, the co-host's voice in the other host's mic, drums in a piano mic. The bleed is that track through the room, a delay of tens of ms and the room's response, changing as people move; the wanted sound plays over it nearly all the time. It comes out in two stages.
Cancellation: a partitioned-block frequency-domain Kalman filter (Enzner & Vary, Signal Processing 2006; Kuech, Mabande & Enzner, ICASSP 2014) learns the path from the reference to the mic, span seconds of it in 10.7 ms partitions (512 samples at 44.1 and 48 kHz), and subtracts the bleed it predicts. Its step in each bin is its uncertainty about the path over that uncertainty plus the wanted sound's power: it hardly moves while the wanted voice sings over the bleed, the double talk that throws an echo canceller's NLMS off, and learns at once where the bleed outweighs it. The wanted sound's power is the error's less the residual the filter expects, smoothed over blocks, so a click's own block, all bleed, does not pass for the voice under it. The path is a slowly wandering state (AR(1), memory ~5 s), so the filter follows a moving source. It learns from the reference exactly as it predicts from it, and each partition's update is held to its 512 taps every block. Suppression: what cancellation leaves, a path not yet learned, movement faster than the filter follows, the room's tail past span, has a power the filter knows (its uncertainty times the reference's power, plus the decaying tail), and a Wiener gain against it (decision-directed, Ephraim & Malah 1984), floored at attenuation, takes it from what remains. With no reference energy the output is the input, bit for bit.
What the filter assumes of the path before hearing it, its prior, sets how fast it learns and how much residual the suppressor expects: a room's decay and a level per band. The decay starts where the bleed does: the batch call first reads the bleed's lead from the whole take, the mic's cross-correlation with each source at lags up to span, and where its peak stands 16 times over its median at the lags past the span (an unheard source's stays under 14.3 on the tuning half), the first lag at half the peak; the partitions before it are held 30 dB down. 0.2.0 started the decay at no delay: a source 25 ms away fell where the prior was 5 dB down while the empty partitions before it took up the voice (a click track 25 ms away under a voice: 10.1 dB of it taken, now 19.9). The stream cannot look ahead and starts the decay at once. Where the source first sounds in a band (10 dB over the least it has had there), the level starts at the most it can be, the mic's energy over the source's across the next span; from then on it is learned from the coherence of the mic with the bleed predicted (Carter 1973's magnitude-squared coherence, debiased): coherence ignores the prediction's level, so a path the filter has barely begun to learn already reads at its level, and the wanted sound, incoherent with the reference, is not taken for bleed. As a level comes down, what the filter learned under the looser prior comes down with it. No level predicts more bleed than the mic holds, the mic's power over the source's while the source sounds: a bound blind to the tracks' gains (a click track recorded 10 dB under its bleed is learned like any other), which also keeps two mics that hear each other from taking the wanted voice: while that voice sounds alone the reference holds only its echo, which predicts it only through a path louder than the mic. The batch call runs twice, the second pass from where the first ended (its levels and its filter), so a static path is cancelled from the first sample and a level learned down where nothing bleeds leaves the wanted sound alone. The stream cannot look ahead, and a level started at its bound would take a voice the source never reached in the first seconds, so it learns the same way but cancels and suppresses a band only once the evidence proves a path there: the power its prediction, made before the block was heard, takes off the mic, two spreads over nothing (a filter fit to an unrelated voice adds power instead). Until then the band passes as it came; with no band open the stream is the input, sample for sample. A click or a drum proves itself in a second; a talker under a louder voice takes seconds, so the stream takes less of a co-host than the batch call, and less than 0.1.0's stream did. Stream latency 2·512 − 1 samples at 44.1 and 48 kHz (23 and 21 ms); the batch call runs at about 10× real time at 48 kHz on one core (the lead and two passes; 0.2.0's 12 % faster), the stream at about 22×.
debleed(vocal, clickTrack, { fs: 48000 }) // in place
debleed(hostA, [coHostL, coHostR], { fs: 48000 }) // a stereo reference
let write = debleed({ fs: 48000 }) // stream: write(chunk, refChunk) → the samples done, write() → the restIn audio, the reference is the op's second bus, as the ducker's key: a.debleed({ key: clickTrack }); the op renders the whole take with the batch call.
| Param | Default | |
|---|---|---|
| attenuation | -20 | dB, the most the residual is turned down; 0 cancels only; read every block |
| span | 0.3 | s of room path the filter learns: the delay and the early room; the tail past it is suppressed as a decay |
| blockSize | 10.7 ms | the partition and hop, a power of two |
Measured (node scripts/debleed.js test, then python scripts/debleed.py test): bleed made from real recordings, 14 s takes, three per kind and level. The wanted sound: VoiceBank voices (p232, p257) and Spoken Wikipedia narrations, or MUSDB18 vocals. The bleed: another VoiceBank voice (co-host), a click track, MUSDB18 drums, its "other" stem (guitars, keys), through MIT IR Survey rooms (the odd-numbered responses; the defaults were chosen on the even ones, VoiceBank's training speakers and MUSDB18's training previews), 1–30 ms away, −30, −18 or −6 dB under the wanted sound's level; the op gets the source as its own track recorded it, at another gain and tilt, with its own noise. A static path, and a moving one: the source sways ±0.5 ms over 6–10 s, the gain drifts ±1.5 dB, a second room comes in to 30 % over the take. Each system runs on the take and on the take with the bleed inverted (Hagerman & Olofsson 2004), which splits its output into the wanted sound as it left it and the bleed it left. Bleed removed, dB; the wanted sound's SI-SDR after it (the takes' distortion pooled), all and per band; musical noise, the log kurtosis ratio of the bleed left over the bleed (Uemura et al. 2008: 0 for a gain that scales it, more for isolated peaks):
| static path | co-host −30 / −18 / −6 | click | drums | other | wanted SI-SDR | <300 Hz | 0.3–1k | 1–3k | 3–8k | >8k | kurtosis |
|---|---|---|---|---|---:|---:|---:|---:|---:|---:|---:|
| debleed | 11.9 / 16.7 / 16.0 | 12.9 / 14.8 / 16.1 | 12.1 / 13.0 / 17.0 | 9.7 / 11.4 / 25.4 | 23.2 | 21.7 | 23.2 | 21.6 | 21.8 | 24.4 | 0.13 |
| stream | 2.2 / 4.7 / 11.7 | 2.6 / 5.9 / 8.4 | 2.7 / 5.5 / 8.7 | 1.9 / 6.2 / 15.1 | 22.7 | 20.6 | 23.1 | 20.7 | 21.9 | 24.3 | 0.60 |
| time-aligned subtraction | 6.4 / 1.6 / 2.3 | 1.9 / 9.0 / 2.1 | 0.7 / 2.2 / 2.0 | 0.4 / 1.1 / 5.1 | 32.6 | 28.8 | 35.8 | 26.5 | 32.3 | 37.3 | −0.12 |
| reference Wiener | 4.8 / 5.3 / 9.9 | 5.3 / 11.5 / 10.9 | 5.9 / 4.7 / 6.2 | 3.8 / 5.9 / 9.4 | 20.7 | 20.5 | 20.1 | 19.3 | 20.2 | 26.7 | −0.11 |
| Speex MDF + suppressor | −4.0 / 6.7 / 8.5 | 3.1 / 6.8 / 6.5 | 0.7 / 6.4 / 10.7 | 4.1 / 9.0 / 16.4 | 7.2 | 1.6 | 12.0 | 16.0 | 5.5 | −23.6 | 0.60 |
| WebRTC AEC3 | −5.9 / 8.9 / 10.8 | −8.3 / 3.6 / 10.4 | −5.2 / 1.7 / 10.5 | −5.0 / 2.6 / 11.5 | −4.1 | −24.4 | 2.8 | −14.9 | −44.7 | −43.1 | 1.00 |
| moving path | co-host −30 / −18 / −6 | click | drums | other | wanted SI-SDR | <300 Hz | 0.3–1k | 1–3k | 3–8k | >8k | kurtosis |
|---|---|---|---|---|---:|---:|---:|---:|---:|---:|---:|
| debleed | 5.1 / 8.0 / 9.6 | 4.2 / 5.6 / 5.0 | 7.0 / 5.9 / 7.1 | 3.5 / 5.7 / 8.9 | 23.7 | 22.0 | 23.3 | 23.5 | 25.8 | 26.6 | 0.16 |
| stream | 1.1 / 4.2 / 7.7 | 0.1 / 0.6 / 0.5 | 1.8 / 3.5 / 5.4 | 0.7 / 3.7 / 7.4 | 22.8 | 20.3 | 22.7 | 22.9 | 25.2 | 25.6 | 0.21 |
| time-aligned subtraction | 0.4 / 0.3 / 0.3 | 0.0 / 0.2 / 0.0 | 0.6 / 0.1 / 0.0 | 0.0 / 0.0 / 0.0 | 29.6 | 31.2 | 28.5 | 28.3 | 31.4 | 26.1 | −0.01 |
| reference Wiener | 4.4 / 3.2 / 7.6 | 2.6 / 3.9 / 1.8 | 6.4 / 3.2 / 3.9 | 3.0 / 3.0 / 3.9 | 23.0 | 21.4 | 22.5 | 24.7 | 28.8 | 30.8 | −0.06 |
| Speex MDF + suppressor | −0.5 / 3.3 / 4.3 | −0.0 / 0.1 / −0.8 | 2.4 / 3.8 / 5.9 | 1.6 / 4.0 / 6.1 | 7.0 | 1.3 | 11.7 | 15.7 | 7.9 | −23.6 | 0.25 |
| WebRTC AEC3 | −3.1 / 6.3 / 10.1 | 2.8 / 4.2 / 6.3 | −6.0 / 3.5 / 8.1 | −5.1 / 1.9 / 8.4 | −4.1 | −33.6 | 2.6 | −25.3 | −21.9 | −36.7 | 0.80 |
PESQ (wideband) / STOI of the voices (co-host and click takes) against the wanted voice, scored at 16 kHz as scripts/speech.py does:
| | static −30 dB | −18 dB | −6 dB | moving −30 dB | −18 dB | −6 dB |
|---|---|---|---|---|---|---|
| input | 3.28 / 0.989 | 2.23 / 0.960 | 1.49 / 0.868 | 3.29 / 0.989 | 2.24 / 0.960 | 1.52 / 0.869 |
| debleed | 4.14 / 0.996 | 3.61 / 0.984 | 2.43 / 0.940 | 3.77 / 0.993 | 2.85 / 0.974 | 1.76 / 0.910 |
| stream | 3.49 / 0.992 | 2.94 / 0.975 | 2.14 / 0.930 | 3.35 / 0.990 | 2.44 / 0.967 | 1.62 / 0.898 |
| time-aligned subtraction | 3.55 / 0.992 | 2.69 / 0.970 | 1.55 / 0.894 | 3.32 / 0.989 | 2.25 / 0.960 | 1.52 / 0.870 |
| reference Wiener | 3.74 / 0.992 | 3.02 / 0.974 | 1.99 / 0.913 | 3.57 / 0.991 | 2.57 / 0.968 | 1.63 / 0.895 |
| Speex MDF + suppressor | 3.14 / 0.984 | 2.74 / 0.968 | 1.87 / 0.920 | 3.01 / 0.981 | 2.20 / 0.955 | 1.50 / 0.874 |
| WebRTC AEC3 | 1.73 / 0.852 | 1.53 / 0.823 | 1.31 / 0.738 | 1.63 / 0.835 | 1.53 / 0.823 | 1.28 / 0.733 |
The wanted sound alone, its source's track present but never heard by the mic: the batch call adds an error 44.1 dB under it on average (34.6 at most); the stream one take bit for bit, the other eleven 56.9 dB under on average (35.5 at most; 0.1.0's stream 38.5 and 33.9). With a silent reference every take comes back bit for bit.
Time-aligned subtraction is the delay and gain of Clifford & Reiss (DAFx 2011): one tap cannot follow a room, and a moving one not at all. The reference Wiener is Kokkinis, Reiss & Mourjopoulos's form (IEEE TASLP 2012): the reference's power through |H|² read from the whole take, a gain against it; it takes the bleed's average spectrum, not the bleed in each cell. Speex's MDF echo canceller (speexdsp 1.2.1, 20 ms frames, 0.3 s tail) with its residual-echo suppressor, and WebRTC's AEC3 (through LiveKit's AudioProcessingModule), are telephone echo cancellers: they protect a far end, not the near voice, and in a near voice that never stops they cancel and suppress it (their wanted SI-SDR; their output is nonlinear, so the inversion splits it only roughly). FFmpeg's anlms and arls would not converge on a 10-sample delay in our setup and are left out. iZotope RX 12 below.
The tuning half (even-numbered rooms, other speakers and songs): the batch call took 10.0–19.1 dB of bleed static and 3.6–11.1 moving (0.2.0: 9.1–18.6 and 3.5–10.4; 0.1.0: 7.6–15.3 and 2.5–10.7), the wanted SI-SDR 22.9 and 24.0 (0.2.0: 23.0 and 24.0; 0.1.0: 23.1 and 24.1), PESQ at −18 dB 2.33 → 3.70 static and 2.32 → 3.02 moving (0.2.0: 3.68 and 2.96); the stream 1.5–10.7 and 0.0–7.8 (0.1.0: 3.5–16.1 and 1.2–12.0) at an SI-SDR of 21.5 and 21.4 (0.1.0: 24.1 and 24.0), its error on the wanted sound alone −49.9 dB on average, −30.7 at most (0.1.0: −36.5 and −29.5). Without its gate the stream took 6.0–17.6 and 3.6–12.4 at 20.4 and 20.7, but took a wanted voice the source never reached in its first seconds (−17.2 dB at most on the tuning half, −2.1 on the test half: a MUSDB18 vocal under its own song's "other" stem, its first two seconds made louder than the voice); gates read from the mic's cross-spectrum with each partition (an F test) or from the coherence with the prediction opened on a click only after 2 s, and a fixed share of power taken could never open on a co-host 11 dB under the voice. Version 0.1.0 learned slowly or not at all wherever its source was sparse, line-level or talking all the time: a click track 8.6 dB under a voice through a three-tap path of +10 dB, 3.1 dB taken by the batch call and 1.3 by the stream (now 30.5 by the inversion, the voice's error −24.5 dB). Six faults, found on that click and on the tuning half, each fixed at its cause: the filter learned from the reference less its noise floor, and minimum statistics, which skip digital silence, read a click track's clicks as its floor, so it learned from a signal other than the one it predicted from (the canceller alone, with no wanted voice, took 11.5 dB of a talking co-host with it and 25.4 without); the way back (the crosstalk-resistant second filter of Mirchandani, Zinser & Evans, predicting the reference from the output) learned the reference from the bleed left in the output, which the reference itself predicts, and the canceller alone took 9.2 dB of a click with no voice under it with that filter, 16.5 without; the wanted sound's power, floored at a tenth of the error, read a click's own block, all bleed, as voice, so a click moved the filter about a seventh of a full step; one partition per block held to its taps let the wrap of a full step ring (the canceller alone on a metronome: 3.5 dB in turn, 9.4 every block); the level started at −30 dB and was capped at 0 dB, which a line-level source's path exceeds; and the second pass started empty (the canceller alone on the click: 19.4 dB from where the first ended, 10.6 from nothing). Tried there as well: the a posteriori error as the wanted sound's power (Enzner & Vary's) took the voice under a constant co-host; the prior read from the mic's cross-spectrum with each partition's frames overestimated the path by 10–20 dB in the first second (the frames overlap and a voice is its own echo); starting the level 10 or 30 dB under its bound gave the click back its slowness and little of the voice; a block's least weight 0.02 (0.1.0's) left the level high where nothing bleeds, the batch call's error on the wanted sound alone −32.4 dB, now −39.0 at 1–2 dB less removal. The gradient held to B taps every block costs no more than 0.1.0's alternating form with its second filter.
Against iZotope RX 12 Advanced's De-bleed (2026-10; node bench/rx/debleed.mjs in audio, debleed through audio's op, the whole take): RX 12's De-bleed plugin takes no reference (one bus in, VST3 and AU; its reference-driven Classic mode belongs to the RX Audio Editor, and the plugin set to it through its state passes the sound through untouched), so it gets the mic alone, and its trained separator keeps the instrument picked (Vocals, Guitar, Drums, …) and turns the rest down by its amount. Seven cases, the wanted sound ← the source: a voice ← a click track, a voice ← a co-host (each mic hearing the other), MUSDB18 vocals ← the song's drums, its backing from the singer's headphones (0.5–1.5 ms, high-passed at 150 Hz), its "other" stem; the "other" stem ← the drums ("guitar"); the drums ← the vocals ("kit"); the rooms, delays, levels and paths as above, two 14 s takes per case, level and path, the test half. RX at its defaults (20 %) and at the amount of the best SI-SDR on the tuning half per case and level (20–100 %; Piano no better than Guitar for "guitar"). The wanted sound's SI-SDR (dB) at a bleed of −30 / −18 / −6 dB:
| static path | click | co-host | drums | phones | other | guitar | kit |
|---|---|---|---|---|---|---|---|
| input | 34.6 / 22.1 / 8.4 | 28.8 / 16.8 / 1.1 | 32.5 / 18.3 / 6.6 | 32.1 / 20.2 / 8.4 | 24.3 / 11.4 / 0.0 | 30.0 / 17.4 / 3.0 | 19.8 / 9.9 / −2.0 |
| RX, 20 % | 25.1 / 20.8 / 14.8 | 27.0 / 16.8 / 1.1 | 35.1 / 23.1 / 14.8 | 33.0 / 22.9 / 12.9 | 27.2 / 15.6 / 5.9 | 29.1 / 20.1 / 6.3 | 23.7 / 14.2 / 3.4 |
| RX, tuned | 25.1 / 20.4 / 16.9 | 27.0 / 16.8 / 1.1 | 35.3 / 23.8 / 16.9 | 33.0 / 23.2 / 13.7 | 27.4 / 16.3 / 7.0 | 29.1 / 20.1 / 6.3 | 23.7 / 15.0 / 6.0 |
| debleed | 35.2 / 31.1 / 25.0 | 35.2 / 26.7 / 7.8 | 37.1 / 29.0 / 21.1 | 35.1 / 27.3 / 16.9 | 30.1 / 22.4 / 12.9 | 30.5 / 24.6 / 14.6 | 30.4 / 24.0 / 12.4 |
| moving path | click | co-host | drums | phones | other | guitar | kit |
|---|---|---|---|---|---|---|---|
| input | 34.8 / 23.6 / 7.9 | 24.8 / 15.6 / 0.9 | 29.8 / 19.0 / 4.5 | 33.0 / 19.9 / 8.6 | 25.2 / 13.4 / 1.0 | 27.1 / 16.8 / 7.3 | 25.7 / 10.3 / 4.5 |
| RX, 20 % | 25.4 / 23.0 / 15.3 | 23.9 / 15.5 / 0.9 | 33.5 / 25.2 / 13.3 | 33.9 / 22.7 / 12.5 | 27.3 / 17.1 / 7.0 | 27.1 / 19.8 / 13.5 | 25.4 / 17.0 / 9.1 |
| RX, tuned | 25.4 / 22.5 / 18.1 | 23.9 / 15.5 / 0.9 | 33.9 / 26.4 / 16.0 | 33.8 / 23.0 / 13.1 | 27.4 / 17.6 / 8.2 | 27.1 / 19.8 / 13.5 | 25.4 / 18.2 / 9.8 |
| debleed | 33.0 / 26.5 / 12.2 | 28.9 / 23.4 / 7.7 | 34.9 / 25.3 / 12.6 | 34.1 / 23.8 / 13.6 | 27.6 / 18.3 / 8.1 | 27.5 / 22.3 / 14.4 | 29.4 / 19.5 / 10.5 |
Bleed removed (the inversion; RX's separator is not linear, so for it roughly): static, RX tuned 0.1–12.4 dB, debleed 7.0–28.1; moving, RX tuned 0.0–13.7, debleed 3.0–9.5. The wanted sound alone, its source's track present but never heard: RX adds an error 25.7–31.6 dB under a voice (its separator takes a voice's lows, below 300 Hz, as bleed: −9 to −10 dB there on a narration) and 28.1–47.3 under music, debleed 39.9 and 49.7 under a voice and 31.5–40.8 under music. A co-host is a voice to RX: it takes none of it. RX is ahead where the source moves and the bleed is loud (−6 dB: a click 18.1 against 12.2, drums 16.0 against 12.6, "other" 8.2 against 8.1; drums at −18: 26.4 against 25.3) and on music alone. A source swaying ±0.5 ms turns its path's phase by up to ±π at 1 kHz within seconds: the canceller, which follows a path over about 5 s, cannot, and the suppressor's residual, the filter's own uncertainty, does not see it; RX's separator never looks at the path. On the tuning half, given the sway (the reference delayed with the source), the canceller takes 11.4 dB of a moving click at −18 dB instead of 1.3 (SI-SDR 31.0; RX tuned 26.6), and given the residual's true power the suppressor reaches 29.4: what moving bleed needs is a model of the motion, not another stage. Tried on the tuning half and left out: a path memory matched to the filter's own changes (covariance matching, Myers & Tapley 1976), a memory that shortens with frequency as a swaying delay's would, a delay tracked by Gauss–Newton on the prediction's slope (it slips cycles on a narrowband click), the residual read from the error's coherence with the prediction or regressed on its power (Valin 2007), a band gate from the whole take's evidence with suppression alone where it stays shut, a deeper floor (−30 dB): each lost elsewhere what it won (the kit case, moving, at −6 dB: 6.1 dB SI-SDR lost) or changed nothing. 0.2.0 differed from 0.3.0 where the bleed's lead was read: a moving click at −18 / −6 dB 25.9 / 11.4, a co-host at −18 dB 26.3 static and 22.4 moving.
Use when: you have the bleeding source's own track: a click or backing track, the amp's own close mic, the other mic of a pair, the drums' close mics. Not for: bleed with no track of its own (denoise it). A path the reference cannot predict, an overdriven amp's DI as the reference of its speaker's sound: the suppressor alone acts on it.
Part of @audio/denoise, the denoise family umbrella. This README is generated from the umbrella docs.
MIT © audiojs
