@audio/denoise
v0.11.1
Published
Noise reduction and restoration: gate, spectral subtraction, Wiener, OM-LSA, dehum, declick, decrackle, declip, dewind, deplosive, deesser, debreath, dereverb, debleed, dewow, desqueak
Readme
@audio/denoise

Try it in the browser: Noise remover. Runs on this package, nothing is uploaded.
Single-pass noise reduction. 17 specialised methods + an auto-classifier.
| | Domain | Targets | Quality | CPU | Best for | |---|---|---|---|---|---| | denoise | meta | auto | — | varies | "just clean it" | | gate | time | silence | ★ | very low | hard cut at threshold | | dehum | time | mains hum, steady tones | ★★★★ | low | 50/60 Hz + harmonics; a whine, a test tone | | specsub | freq | broadband stationary | ★★ | medium | baseline | | wiener | freq | broadband stationary | ★★★ | medium | general broadband | | omlsa | freq | broadband non-stationary | ★★★★ | high | speech in changing noise | | declick | time | impulses | ★★★★ | medium | vinyl ticks, edit clicks, mouth clicks | | decrackle | time | dense impulses | ★★★ | medium | shellac crackle | | declip | time + freq | hard clipping, soft saturation | ★★★★ | high | clipped speech and music, rails found by itself, also through MP3/AAC; saturation's curve fitted blind | | dewind | freq | wind, LF rumble | ★★★ | medium | wind under a voice, its harmonics kept | | deplosive | time | LF bursts | ★★★★ | low | mic plosives (p, b) | | deesser | time | sibilance | ★★★★ | low | voice (s, sh) | | debreath | time | breaths | ★★★★ | low | breaths between phrases | | desilence | time | pauses | ★★★ | low | remove / shorten / trim silence, split by pause | | dereverb | freq | late reverb | ★★★ | high | speech in a room | | debleed | freq | bleed of a source with its own track | ★★★ | high | click track, amp or co-host in a mic | | dewow | time + freq | pitch drift | ★★★ | medium | disc wow (33⅓ / 45 / 78 rpm) in music; tape by its pilot tone | | desqueak | time + freq | guitar squeaks, picks, amp noise | ★★★ | medium | finger squeaks on wound strings, harsh picks, amp hiss |
For broader DSP needs use stretch, shift, pitch, beat.
Usage
npm install @audio/denoiseimport { denoise, dehum, wiener, declick } from '@audio/denoise'
let cleaned = denoise(samples) // auto-classify + dispatch
let unhummed = dehum(samples, { freq: 60 }) // explicit method
let { out, plan } = denoise(samples, { returnPlan: true }) // see what was chosen// Streaming — pass options first, then call repeatedly with chunks.
let write = wiener({ fs: 48000 })
write(block1)
write(block2)
write() // → flush remaining samplesMono
Float32Arrayin/out. State lives on theparamsobject; pass the same one across calls and biquad memory / spectral history persists. For stereo, process channels independently.
denoise
Finds the defect a recording carries and routes it to the method for it. Each route needs evidence; with none, nothing is removed and the sound comes back as it was ('none').
denoise(data) // → cleaned Float32Array, or a copy: nothing to remove
denoise(data, { returnPlan: true }) // → { out, plan }
denoise(data, { force: 'wiener' }) // skip classifier
classify(data, fs) // → { method, scores }, the plan alone| Param | Default | |
|---|---|---|
| fs | 44100 | Sample rate |
| force | — | One of 'none' \| 'dehum' \| 'declick' \| 'dewind' \| 'deesser' \| 'dereverb' \| 'omlsa' \| 'wiener' |
| returnPlan | false | Return { out, plan } with classifier scores + chosen method |
Routing, in priority order (one STFT sweep, frames ≈ 46 ms, hop ¼; over 6 min, 16 spans of it spread across):
- hum →
dehum: dehum's own measurement finds a mains series, and the lines it finds, A-weighted (IEC 61672-1), lie within 50 dB of the program's A-weighted level (HUM_LEVEL, −50; a series further under goes unheard and is left). A lone 60 Hz line 30 dB under a voice is there, and the ear hears 60 Hz 26 dB less than 1 kHz. - clicks →
declick: over 1 a second (CLICK_RATE) of impulses standing 32× out of the AR(30) error's local scale (the median of its 1.5 ms block RMS over ±12 ms, as declick judges), alone: no like of half its size 2.5–40 ms either side (a voice's pulses, creak down to 25 Hz), and no sound 10 dB louder after it than before (a plosive's burst, a note's attack). - sibilance →
deesser: 5–9 kHz over 0.2–2 kHz power over 8. - noise bed →
omlsa: a floor within 25 dB of the program (BED_SNR; the program: frames within 40 dB of the loudest), shown in its pauses or in steady bands. The pauses are runs of 0.15 s or more within 6 dB of the frame level's 10th percentile that hold noise or hold the floor; the bed is their median level. Noise: flat and without lines in the bands from 300 Hz. The floor: a bed lies under the program all the time, so in each band the pauses sit where the band sinks to over the whole take (its 5th percentile), no further over it than Gaussian noise's own spread puts them (1.645 · 4.34/√(n/1.5) dB over n bins) plus 3 dB for the bed's slow wander (the bands' median), and they hold no partials that come and go (median persistence under 0.1; babble reads 0.04–0.08). A line held through every pause and across the notes, an engine's, a fan's, a mains-like tone, is part of the floor; a quiet passage of music isn't: its notes come and go, and its pauses stand over the floors other passages sink to. Steady bands: a band whose quieter half varies no more than twice what Gaussian noise over its bins would, without lines. A line is a band's fine structure (log power less its ±4-bin mean) correlated with the frame N back, which shares no sample: a partial holds its bins, noise draws them anew. - wind →
dewind: in over a tenth of the 0.15 s blocks, the low end (< 200 Hz) within 20 dB of the program, 6 dB over the 300–2000 Hz band, and without lines, where no bed shows. Wind is turbulence at the microphone, without a period (Nelke & Vary, IWAENC 2014); a bass line or a voice's low end repeats at its pitch. - steady tones →
dehum: dehum'stones()finds lines held through the program's pauses and under it, their phase turning as one line's (a whine, a test tone; see dehum), A-weighted within 50 dB of the program, as hum is. - otherwise
none.
Where tones() finds steady tones and another route is taken (clicks, sibilance, a bed, wind), dehum runs first: a gain floored per bin takes a line standing over the bed down by its floor at most (speech, a 7 kHz line at 0.3 and white noise at 0.01: wiener alone 10.5 dB SNR, dehum then wiener 24.2). force runs the forced method alone.
Every bed goes to omlsa, and a bed outranks wind, by what each reducer made of the noisy speech the router sends them (node scripts/detect.js tune reduce, python scripts/detect.py score tune; PESQ wideband, STOI, DNSMOS SIG at 16 kHz as scripts/speech.py scores):
| noise (takes) | PESQ: noisy · wiener · omlsa · dewind | STOI, the same | SIG, the same |
|---|---|---|---|
| VoiceBank+DEMAND, its ten training noises (504) | 1.47 · 1.84 · 1.83 · 1.68 | 0.841 · 0.833 · 0.842 · 0.828 | 3.00 · 3.18 · 3.18 · 2.98 |
| of them car (43) | 2.43 · 3.27 · 3.32 · 3.06 | 0.959 · 0.954 · 0.961 · 0.949 | 3.49 · 3.50 · 3.50 · 3.45 |
| DEMAND washing machine, field, park, river, hallway (126) | 1.77 · 2.25 · 2.26 · 1.98 | 0.883 · 0.879 · 0.887 · 0.870 | 3.28 · 3.42 · 3.43 · 3.27 |
| white, pink noise 0–20 dB (168) | 1.42 · 1.86 · 1.85 · 1.62 | 0.809 · 0.811 · 0.820 · 0.787 | 3.20 · 3.33 · 3.32 · 3.15 |
| six-talker babble 0–20 dB (84) | 1.53 · 1.57 · 1.57 · 1.55 | 0.761 · 0.753 · 0.756 · 0.752 | 2.78 · 2.94 · 2.93 · 2.76 |
| recorded and generated wind 0, 10 dB (84) | 1.51 · 1.99 · 1.98 · 2.07 | 0.891 · 0.889 · 0.896 · 0.886 | 3.34 · 3.35 · 3.32 · 3.26 |
| synthetic wind gusts 0, 10 dB (28) | 1.48 · 1.52 · 1.56 · 1.88 | 0.871 · 0.867 · 0.870 · 0.874 | 3.22 · 3.17 · 3.10 · 3.20 |
Over all 994 takes omlsa kept STOI 0.007 over wiener's (paired, ±0.001 at 95%; on each noise but six-talker babble and the synthetic gusts, its interval clear of 0) at the same PESQ (−0.001 ± 0.006) and SIG (−0.008 ± 0.012); wiener's edge, BAK and OVRL on stationary noise (white, pink, speech-shaped: OVRL 0.02–0.09), lies in the noise left, not the speech. 0.4 sent a bed seen in steady bands to wiener. A steady low rumble is a bed: on a car, traffic, a metro, a field, a park omlsa took it 0.1–0.3 PESQ and 0.01–0.02 STOI over dewind, where 0.4 sent it, wind outranking the bed. Recorded and generated wind split them: dewind leads PESQ by 0.09, omlsa STOI by 0.010 and SIG by 0.06; synthetic gusts with calms between are dewind's, and still go to it where their calms show no bed, about half (the + wind gusts rows below). Run once on test's 1628 takes (score test): STOI +0.008 ± 0.000 again, PESQ −0.006 ± 0.005 and SIG −0.010 ± 0.008 (on VoiceBank+DEMAND's test noises +0.002 and −0.013; dewind there 2.25 to omlsa's 2.36), recorded wind as on tune (dewind PESQ 1.93, omlsa 1.74; STOI 0.885 and 0.902, SIG 3.14 and 3.24).
0.3 had no none: every recording went somewhere. Its floor statistic (the spread of a rolling-minimum frame energy) read a program's own dynamics as a wandering noise bed, so clean speech and music went to omlsa; a bass-heavy mix went to dewind (low over mid power above 3); clean VoiceBank went to declick, its impulse count firing on lip smacks and plosive bursts. A blind SNR estimate can't stand in for the bed, each assuming a speech-like program: on the training material below, clean music read (median) 5.4 dB by WADA-SNR (Kim & Stern, Interspeech 2008), 15.4 dB by a NIST STNR-like frame histogram and 7.0 dB by minimum statistics (Martin 2001), under the noisy VoiceBank takes' 13.2, 22.3 and 13.0. A bed has to show itself, where the program pauses or never reaches.
node scripts/detect.js tune|test routes labelled material and prints what each class went to (sources and mixtures in the script); the share routed right, 0.4.1 → 0.5.0, thresholds chosen on tune (VoiceBank+DEMAND's training subset, DEMAND's washing machine, field, park, river and hallway in the first half of each recording, Spoken Wikipedia narrations, repair/ music, VocalSet singers m1 f2 m2 f1, even Slakh mixes, MUSDB18 train, scripts/wind.py's tune half), test run once (the VoiceBank+DEMAND test set, the second half of each DEMAND recording, ten other narrations, other singers, tracks and winds):
| material | wanted | tune | test |
|---|---|---:|---:|
| clean speech, VoiceBank (504 · 824) | none | 97 → 97% | 95 → 95% |
| narrations (20 · 20) | none | 90 → 90% | 85 → 85% |
| clean music (62 · 56) | none | 98 → 97% | 93 → 91% |
| VoiceBank+DEMAND noisy (504 · 824) | a reducer | 75 → 91% | 59 → 76% |
| speech + DEMAND 2.5–17.5 dB under (126 · 206) | a reducer | 55 → 79% | 63 → 78% |
| speech + white, pink noise 0–20 dB under | a reducer | 95 → 96% | 100 → 100% |
| speech + babble 0–20 dB under | a reducer | 76 → 79% | 80 → 80% |
| music + white noise 10–20 dB under | a reducer | 92 → 92% | 89 → 89% |
| music + pink noise 10–20 dB under | a reducer | 68 → 73% | 68 → 70% |
| music + DEMAND 10, 20 dB under (124 · 112) | a reducer | 27 → 34% | 28 → 32% |
| speech + recorded wind 0, 10 dB under (84 · 138) | dewind or a reducer | 92 → 92% | 91 → 91% |
| speech, music + mains hum 20 dB under | dehum | 83 → 83% | 81 → 81% |
| speech, music + clicks at 5× | declick | 94 → 94% | 90 → 90% |
| speech + wind gusts 0–10 dB under | dewind | 93 → 64% | 89 → 54% |
| music + wind gusts 0–10 dB under | dewind | 38 → 33% | 50 → 45% |
| all scored | | 80.3 → 86.0% | 77.8 → 83.5% |
Per noise, the noisy speech routed to a reducer (VoiceBank+DEMAND's training and test sets hold different noises):
| tune | | test | | |---|---:|---|---:| | babble | 79 → 80% | bus | 38 → 55% | | cafeteria | 64 → 81% | cafe | 86 → 95% | | car | 33 → 77% | living room | 64 → 92% | | kitchen | 90 → 98% | office | 46 → 51% | | meeting | 71 → 91% | public square | 60 → 90% | | metro | 55 → 92% | | | | restaurant | 95 → 100% | | | | speech-shaped | 100 → 100% | | | | station | 98 → 98% | | | | traffic | 58 → 100% | | |
| DEMAND under speech | tune | test | |---|---:|---:| | washing machine | 15 → 15% | 21 → 21% | | field | 24 → 100% | 29 → 71% | | park | 84 → 96% | 83 → 100% | | river | 100 → 100% | 100 → 100% | | hallway | 52 → 84% | 80 → 100% |
Material with nothing to remove (tune 586, test 900): 565 and 854 come back untouched (0.4.1: 566, 855). Most of the rest goes to declick on VoiceBank's lip smacks (14 and 37 takes, 32–124× out of the error); to omlsa (5 and 6): three VoiceBank takes and one narration in each set whose floor reads 8–25 dB under the voice (audio-lena's: 22 dB), a GuitarSet mic take and a VocalSet one 19–25 dB over theirs, two MUSDB18 mixes (wiener's in 0.4.1). Missed: a bed further under the program than BED_SNR, where VoiceBank's own clean takes sit (their room 28–41 dB under): DEMAND's bus and office noise holds its power under 63 Hz, so the test set's bus and office takes at 12.5 and 17.5 dB read 26–33 dB in the pauses, as do the training set's car at 15 dB, and the washing machine's drum at 16 Hz leaves almost nothing above; babble and cafeteria that wander more than 3 dB pause to pause; a bed under a dense mix, masked where its power lies. ~10–18 ms per second of sound.
Most of the test set's misses are those: bus and office at 12.5 and 17.5 dB go to a reducer in 0–52 % of takes, the rest in 75–100 %. Their noise holds 88 % (bus) and 95 % (office) of its power under 40 Hz (every 3rd test take, noisy less clean); above 63 Hz it lies 26–33 dB under the voice, where VoiceBank's clean takes keep their own room (10th–90th percentile 30–40 dB). On the training material a bar low enough to take them does not pay: over 351 tuning takes (VoiceBank+DEMAND's training set, every 6th utterance clean and noisy, and the speech of audio's bench/rx/assistant.mjs tuning split, whose brown and rumble beds sit as low), routing every bed read under T dB to omlsa changes PESQ by +40.4 summed at 25 dB, +41.7 at 28, +34.8 at 31, +19.0 at 34 (STOI +0.24, +0.22, +0.03, −0.29): omlsa takes a clean take's room 15 dB down and 0.40 of its PESQ. Taking a bed only to 40 dB under the program (ACX's noise floor under its loudness; threshold 4) slows the fall, +33.4 at 31 and +27.0 at 34, but never reaches 25 dB's; the pauses' power at 20–63 Hz, 15–20 dB under the voice in the test set's bus and office takes against 28 in its clean ones, is 18 dB or less in 5 % of the training speakers' clean takes. Under the chain's 40 Hz highpass (@audio/chain) the bulk of those beds goes anyway.
dereverb has no reliable single-pass signature, so auto-mode never selects it — reach it explicitly via denoise(data, { force: 'dereverb' }) or dereverb().
Tonal & narrowband
dehum
Follows the mains phase, then subtracts the hum along it: each harmonic of the mains frequency, to 8 kHz where buzz carries it, is estimated as a slowly varying sinusoid and taken out, so what goes with the hum is the program within a fraction of a hertz of each line, and less of it where the program is loud there. Without hum the audio comes back untouched. Notches (0.2.0) took whatever their band held, a band that grows with the harmonic (Q 30: 1.7 Hz at 50 Hz, 33 Hz at 1 kHz), an orchestra's G2 two hertz under the 100 Hz line among it; and as the mains frequency wanders (±0.02–0.2 Hz, h times that at harmonic h) narrow ones miss the upper harmonics.
dehum(data, { fs }) // 50 or 60 Hz, found; nothing without hum
dehum(data, { fs, freq: 60 }) // the 60 Hz series, its exact frequency tracked
dehum(data, { fs, freq: 60, harmonics: 4 }) // remove 60–240 Hz as told
dehum(data, { fs, steady: true }) // a buzz held through the take, an amp's under a guitar
dehum(data, { fs, tones: [15734] }) // this tone, as told (true, the default: those found)
dehum(data, { fs, tones: false }) // the mains series alone, as 0.6
measure(data, fs) // → { f0, harmonics } or null (import from @audio/denoise-dehum)
tones(data, fs) // → [{ freq, level, … }] the steady tones found (import alike)| Param | Default | |
|---|---|---|
| freq | found | Fundamental, Hz. Omitted: the 50 or 60 Hz series, whichever is found. Given: that series, its frequency tracked from there (within ±0.4 %, ±drift Hz with adaptive) |
| harmonics | to 8 kHz | Remove h = 1…harmonics as told. Omitted: every harmonic to 1 kHz and each line above it that stands out, to 8 kHz |
| steady | false | The hum held through the take: a buzz under an instrument, no edit in it. Each line one phasor through the take, the mains phase refined against them (below) |
| tones | true | Steady tones off the mains series, held through the take: those tones() finds (below); an array: those frequencies, Hz, as told; false: none |
Hum is found by measure(), one Fourier transform over the signal (its first 80–90 s, taken down to 3 kHz), where a hum line, steady for minutes, gathers into a peak 1/T wide while speech spreads: the fundamental 20 dB over the median of the ±8 Hz around it, or two of the first six harmonics 15 dB, each 6 dB over any other peak within 3 Hz, all harmonics of one fundamental. Under music a line 20 dB under the program is too faint for that, so the band to 1 kHz is also tracked along each series (below) and its lines weighed where the program is quiet around them: hum is there when two or more of the first 20 harmonics stand out by 18 dB, are no more than 60 dB under the signal and stand alone, 6 dB over any other peak within 3 Hz, a third at least of those that stand out (a bar of music repeated exactly is a comb of lines 1/bar apart, lines at 50 and 60 Hz among them, few alone). A faster pattern spaces its teeth past 3 Hz, and where its instrument rings a tooth stands over the next: a kick at 200 bpm repeats every 15 periods of 50 Hz and 18 of 60, a comb every 3.33 Hz with teeth on both series, and in the MUSDB18 preview of Dark Ride's "Burning Bridges" its 50 Hz tooth stands 7 dB over the next one; the take has no pause to hear the line persist in, and 0.5.1 took it for hum (the mix out at 6.0 dB SDR). Neither the line's level nor its neighbours tell such a tooth from hum; the program's comb does, by all its teeth: for each pattern of 1/8 s to 2 s that spans a whole number of mains periods (its comb runs through the series), the share of the positions of its other lines, 40 Hz to 1 kHz, that hold a peak, less the share midway between them, where chance puts as many. The kick scores 0.30 (synthetic kicks 0.44–0.50, two BabySlakh mixes rendered from MIDI at an exact tempo 0.33 and 0.53); the 143 other MUSDB18 previews 0.16 at most, the other 18 BabySlakh mixes 0.20, 200 VocalSet takes, 120 GuitarSet takes, ten readings and five DEMAND noises 0.06. Over a quarter, in a take with no pause, the series is the program's and is left. Through 468 clean takes (MUSDB18's 94 training and 50 test previews, Brahms, Vibe Ace, the Nutcracker and the trumpet whole, 200 VocalSet takes, 120 GuitarSet takes, 30 s each: node bench/rx/dehum.mjs clean in audio) 0.5.2 changes none; 0.5.1 changed that one. Hum under such a pattern is left too where the take has no pause; with one, its lines and the pattern's within a fraction of a hertz of them go together (59.95 Hz hum 20 dB under the 200 bpm kick after 2 s of room tone: the mix out at 2.0 dB SDR, 0.5.1 alike). A tone held right on a mains line through a take with no pause is taken for hum.
The mains phase is tracked first from the harmonics to 1 kHz (each harmonic's phasor per Hann frame four mains cycles long, fitted over 2 s, their turn combined by h²·SNR over 8 s: Hajj-Ahmad, Garg & Wu 2013), then the signal is resampled so that the tracked mains period spans 2^k samples (computed order tracking: Fyfe & Munck 1997). There every harmonic sits on one bin of a 2^k-point transform of each frame however the mains wander, and the phase is refined from the turn between frames of the harmonics, to 16, 64, … 8 kHz, whose lines stand out alone (a partial beside a line turns it with the program), each weighted by its line's power over the program's there and smoothed by Rauch–Tung–Striebel at the likeliest rate of wander and noise scale; a refinement is kept only if it draws the lines tighter (their coherent power grows). Each harmonic's phasors are fitted over 2 s by weighted local-linear least squares (normalized convolution, Knutsson & Westin 1993), each frame weighted by the inverse of the program's power around the line there, so a passing voice or note is bridged from the frames around it rather than averaged in. Under a dense mix there is no frame to bridge from, and a fit over 2 s takes the music near every line, more of it than a faint hum (0.5.0: buzz 30 dB under music came out 7 dB worse than it went in); so the fit is kept within 30 % of the hum bridged through the program: the line's phasors, with any steady tone beside it taken out, followed as a slow random walk by Rauch–Tung–Striebel with the program's power in each frame as its noise, which carries the hum across a loud passage from where it stands alone instead of taking the music in. A line counts only if it persists where the program falls silent, at a tenth of its power elsewhere at least (a note held on a line goes quiet with the music; a held synth note at exactly 50 Hz was taken for hum). The sinusoids are resynthesized period by period, brought back to the original samples (Kaiser-windowed sinc) and subtracted (harmonic subtraction, as power-line interference is taken out of ECG: Levkov et al. 2005).
Where the hum itself jumps (an edit's splice turns its phase, a level step, hum switched on or off), every line's phasors before and after a frame differ beyond their spread at once, a note beside one line moving that line alone: the tracking does not integrate across the jump, each stretch is fitted on its own frames, and the hum switches from one stretch's to the next at the sample that best splits the signal between them. A note within ~0.5 Hz of a line for seconds is taken for hum. Whole clip (streaming: false), a second at least to find hum; shorter, it removes the harmonics of a given freq as told and passes the audio through without one. About a tenth of real time (0.4.0: a hundredth), most of it in the 2 s fits.
steady (0.6.0) is for a hum that holds still through the take: an amp's ground loop under a guitar. A fit over 2 s takes in what the program puts near a line for seconds, and a chord's attack, moving many lines at once, reads as the hum's jump: on GuitarSet takes under a buzz 35 dB down (audio's bench/rx/guitar.mjs, players 00–02), 0.5.2 took the buzz 4.3 dB down and the guitar to 39.3 dB SDR. Steady, each line is one phasor through the take, its mean weighted to where the program around the line is quiet; the program's power there is read from the line's magnitude, not its phase, so a phase still off does not pass for program. Held, the lines show the mains phase's error frame by frame against them, all at once (Newton's steps on the held comb's correlation, smoothed by Rauch–Tung–Striebel at the likeliest rate of wander): from the trace, the phase is refined against the comb to harmonics 2, 4, … 64, then to 8 kHz twice, each step holding the higher lines tighter (on one of those takes, from 0.5 rad off the true phase at its start to under 0.001 rad throughout). A line goes when its held phasor stands 10 dB over its own spread and holds no more than twice the power the line has where the program falls silent (a note on the line, which the take's quiet does not hold), each frame's by its expected coherence, e^(−h²σ²/2) with σ² the phase's variance there: where the program masked the hum and the phase was bridged, the upper lines go less. The dense mix of the test suite under that buzz, wandering ±0.02 Hz: 48.0 dB of it down, the music at 78.4 dB SDR (by default 3.7 and 32.6).
Steady tones (0.7.0). Off the mains series a line held through a take is interference too: a whine, a test tone, a monitor's 15.7 kHz, an electrical or fan line. A held note can be as exact in frequency and level (an organ, a synth), so a line is taken only for what a note does not do through a take whose program comes and goes. tones() reads one short-time transform (Hann, ~0.19 s, hop half; 2048 frames at most, spread over a longer take), takes as candidates the peaks of the power each bin holds in four frames of five, 12 dB over the median of the bins 4–40 away, and keeps a line that
- holds through the pauses: the frames where all but the lines sits within 6 dB of its 10th percentile, half a second of them at least; it stands 6 dB over its bins' floor in 95 % of them, its level there within 3 dB (90th over 10th percentile). A take whose program never moves 10 dB (90th over 10th percentile) is left: nothing passes over its lines to tell them from notes;
- is never gone under the program: its five bins hold a quarter of its power in the pauses in 90 % of all frames. In a mix the quietest frames can be where the bass plays alone: in the MUSDB18 preview of Grants' "PunchDrunk" a 220 Hz line held 92 % of the pauses, its level within 1.6 dB and its turns' mean resultant length 0.994, and was there in 32 % of the frames;
- turns as one line: between consecutive pauses holding it, the mean resultant length of its phasor's turns is 0.9 at least (their spread under ~0.8 Hz), and its phase breaks off the turn (by over π/3) in 2 % of the transitions at most, across up to a second of the program too. A note struck again on the same pitch starts at a new phase, a kick tuned to it too (van Waterschoot & Moonen's interframe peak persistence, Proc. IEEE 99(2), 2011, a feedback detector's mark, read on the phase rather than the magnitude);
- stands within 60 dB of the take; under a found mains series, off its harmonics (±0.5 %);
- is one source (0.7.1), the lines kept together one harmonic series: each a whole multiple of a fundamental at the lowest line or an octave under it (a fundamental too faint to stand is missed), within 0.5 cents plus the lines' turn spread. One interference makes one line, or one periodic wave's series (a whine, a fan, a scan); lines off one series held alike through the take are the notes of a held chord, and none goes (equal temperament's fifth sits 1.96 cents off 3:2, its major third 13.7 off 5:4). A chord held under percussion passes every mark above: the hits never pass over its lines, so they hold through every pause, are never gone and turn as lines. 0.7.0 took @audio/chain's triad (220, 277.18, 329.63 Hz at 0.55 peak, a marimba-like hit once a second) whole once the take was tilted +3 dB/octave or more, the tilt lifting the hits until the program came and went 12–30 dB; of 1344 such takes (2–4 notes, sines or partials 1–6, under that hit, a snare or a kick 24 dB under the chord's peak to level with it, tilted 0–9 dB/octave, 4–16 s), 0.7.0 found lines in 938, 0.7.1 in none. A whine found in part still reads as one: under speech its fundamental can be missed while its 2nd–4th harmonics stand, a fundamental an octave under the 2nd; one whose 3rd and 4th alone stand would be left (of 120 takes of a whine with harmonics 1–4, at 180.3, 1234.5 and 4100 Hz, 10–50 dB under VoiceBank speech, every set of lines found reads as one series).
Each is subtracted as a harmonic is: its frequency read from its turn, its phasor per 80 ms Hann frame, weighted by the program's power around it, bridged through the program by Rauch–Tung–Striebel at the likeliest rate of drift (up to a turn of ~0.1 rad a frame: a whine wandering ±0.3 Hz), resynthesized and taken out. What goes of the program is what lies within a fraction of a hertz of the line. A note held through the whole take, struck before it starts and released after it ends, under a melody, is a tone by every mark the take offers and goes (the synthetic drone below), as do two notes an octave apart or a just fifth, one series; a chord in equal temperament is left; a tone switched on or off inside the take is left, as is one that glides, and two unrelated tones found together (off one series, as a chord's notes are).
Measured by node scripts/tones.mjs (case, synth, music, speech; thresholds chosen on its tune split: VoiceBank's training speakers, the odd-numbered VocalSet singers, every third MUSDB18 training preview, odd BabySlakh mixes, GuitarSet; then read once on the test split, below). Test.js's steady line, audio-lena's first 4 s with a 7 kHz sine at 0.3 and white noise at 0.01: speech SNR −3.8 dB as 0.6 leaves it (and denoise() routed it to none); 22.8 dB through dehum (with the line gone exactly, the noise alone leaves 22.8); 24.2 then through wiener, where a notch at 7 kHz, told, then wiener reaches 23.8. Tones under VoiceBank+DEMAND's test speakers (4 clips of 4 utterances, ~10–14 s), by phase inversion; tone down / speech SDR, dB, the mean over the clips where found, against a notch (Q 30) told each frequency:
| tone under speech | −10 dB | −30 dB | −50 dB | notch, told | |---|---|---|---|---| | 97 Hz | 92.2 / 60.4 (4 of 4) | not found | not found | 22.8 / 25.3 | | 440 Hz | 96.1 / 60.5 (4) | 79.6 / 72.6 (3) | not found | 29.4 / 14.3 | | 1 kHz | 85.0 / 64.5 (4) | 80.4 / 72.7 (4) | 45.8 / 82.1 (1) | 32.9 / 25.8 | | 3.15 kHz | 67.2 / 69.9 (4) | 89.3 / 77.8 (4) | 57.3 / 86.9 (2) | 37.8 / 31.5 | | 7 kHz | 60.1 / 72.0 (4) | 89.4 / 83.9 (4) | 86.7 / 96.5 (3) | 40.8 / 36.8 | | 12 kHz | 57.8 / 79.1 (4) | 76.9 / 84.8 (4) | 76.5 / 92.9 (4) | 41.8 / 41.9 | | 15.734 kHz | 57.5 / 82.7 (4) | 72.3 / 86.0 (4) | 81.0 / 92.6 (4) | 41.2 / 47.4 |
A whine at 1234.5 Hz with harmonics 2–4, 20 dB under: 84.9 / 66.8 (notch 34.6 / 21.6); a 3 kHz tone wandering ±0.3 Hz: 33.5 / 55.2 (36.7 / 31.3); a 2 kHz tone switched on halfway: left. Held notes, nothing added: 15 synthetic takes (organ chords with rests, held sines, a pedal held 85 % of the take under a melody, strings with vibrato, piano), 62 VocalSet long tones (straight, messa, pp, forte, trill, trillo, inhaled), 50 MUSDB18 test previews, 10 BabySlakh mixes, 7 GuitarSet takes and the four pieces: none changed by the tones (one VocalSet take, f8_long_inhaled_a, changed by the hum lines as 0.6 changes it, bit for bit); on the tune split, 142 takes, none. A drone held from the first sample to the last under a melody goes, 56 dB of it. Held chords under percussion (24 a split since 0.7.1: 2–4 notes, sines or partials 1–6, under a tonal hit, a snare or a kick, tilted 0–9 dB/octave, 4–16 s): 0.7.0's tones changed 20 of the test split's and 21 of the tune split's, 0.7.1's none; 3 and 2 change by the hum lines as 0.6 changes them, bit for bit, a partial on a mains harmonic in a take with no pause (F at 349.23 Hz, 7 × 49.89; A2's fifth partial, 550 Hz, 11 × 50). VoiceBank+DEMAND test set: of 824 clean utterances none changed, of 824 noisy one (p232_381, the bus noise's 38.2 Hz engine line, PESQ 2.211 → 2.204); denoise() routes every utterance as before, PESQ 2.30 → 2.30 (that utterance 2.292 → 2.290). Tune split, of 504: one changed (p228_229, the metro noise's 2374.6 Hz whine; through denoise() 2.472 → 2.491).
Measured by node scripts/dehum.js, 0.4.0 → 0.5.1 (0.5.2 alike, every cell): the hum (mains: 12 harmonics at −6 dB per octave; buzz: odd-heavy to 8 kHz, odd h at h^−½, even at 0.3·h^−½, rolling off over 3 kHz; levels drifting ±10 %, f0 0.05 Hz off nominal and wandering) and the program told apart by phase inversion (Hagerman & Olofsson 2004). 50 Hz hum 20 dB under the program, wandering ±0.02 Hz; program SDR per band where the band holds 1 % of the program:
| material | hum down, dB | program SDR, dB | <100 Hz | 100–300 | 300–1k | 1–4k | >4k | |---|---:|---:|---:|---:|---:|---:|---:| | speech | 45.5 → 41.7 | 38.5 → 41.7 | 28.9 → 28.9 | 39.2 → 42.3 | 39.2 → 45.1 | 83.0 → 83.8 | – → – | | narration | 43.5 → 38.6 | 42.7 → 46.8 | – → – | 38.8 → 40.0 | 42.9 → 48.1 | 64.5 → 85.9 | 73.9 → 89.0 | | vibeace | no hum found → 33.3 | no hum found → 32.6 | no hum found → 32.3 | no hum found → 32.8 | no hum found → 32.4 | no hum found → 74.8 | no hum found → – | | brahms | 42.2 → 24.4 | 25.7 → 31.4 | 30.2 → 34.0 | 17.9 → 22.3 | 27.0 → 36.9 | 57.8 → 84.5 | 50.2 → 77.5 | | nutcracker | 41.6 → 26.4 | 27.6 → 33.2 | 23.5 → 27.9 | 30.7 → 35.2 | 26.8 → 32.9 | 63.6 → 77.4 | – → – | | trumpet | 51.0 → 47.3 | 47.9 → 49.4 | – → – | – → – | 42.2 → 43.6 | 82.3 → 94.8 | – → – |
Hum down / program SDR, dB:
| material | 60 Hz | 50 Hz, ±0.05 Hz | 50 Hz, 30 dB under | 60 Hz buzz to 8 kHz | 50 Hz buzz, ±0.2 Hz | |---|---|---|---|---|---| | speech | 46.3 / 42.1 → 45.4 / 44.5 | 36.4 / 38.5 → 35.5 / 41.5 | 38.7 / 38.6 → 35.1 / 43.9 | 6.7 / 42.1 → 28.9 / 36.2 | 5.2 / 37.3 → 7.8 / 36.4 | | narration | 43.6 / 44.0 → 39.9 / 45.8 | 33.8 / 42.8 → 36.8 / 46.8 | 42.3 / 42.9 → 31.8 / 50.4 | 6.7 / 43.9 → 26.4 / 40.2 | no hum found → no hum found | | vibeace | no hum found → 33.7 / 32.3 | no hum found → 29.0 / 32.5 | no hum found → no hum found | no hum found → 22.1 / 33.8 | no hum found → 3.6 / 26.8 | | brahms | 41.7 / 29.0 → 25.1 / 34.0 | 27.8 / 25.7 → 16.1 / 31.5 | 33.9 / 25.7 → 13.2 / 32.9 | 6.5 / 29.0 → 11.8 / 31.9 | 5.0 / 25.7 → 4.3 / 32.9 | | nutcracker | 40.7 / 24.3 → 20.4 / 35.0 | 33.2 / 27.9 → 23.1 / 32.9 | no hum found → no hum found | 2.9 / 26.1 → 13.6 / 31.4 | 5.2 / 28.3 → 6.2 / 33.3 | | trumpet | 51.9 / 47.8 → 50.5 / 50.2 | 46.0 / 47.7 → 42.0 / 48.9 | 48.4 / 48.0 → 39.9 / 49.0 | 6.1 / 48.0 → 39.0 / 43.9 | 6.2 / 45.8 → 20.6 / 38.2 |
An edited take (eight stretches cut out, from a pause to a pause in the speech, anywhere in the music, the hum's phase jumping at each cut), then the hum's level stepping four times among off, −10, −5, 0 and +5 dB; hum down within a second of a cut or step / elsewhere, and the program SDR, dB:
| material | within 1 s | elsewhere | program SDR | |---|---:|---:|---:| | speech | 6.5 → 7.7 | 41.1 → 41.2 | 38.3 → 38.7 | | narration | no hum found → 24.6 | no hum found → 35.3 | no hum found → 41.9 | | brahms | 9.5 → 7.9 | 37.0 → 15.1 | 25.0 → 30.7 | | nutcracker | 9.7 → 9.1 | 36.0 → 13.9 | 28.0 → 34.8 |
| material | within 1 s | elsewhere | program SDR | |---|---:|---:|---:| | speech | 14.7 → 18.9 | 40.9 → 28.8 | 38.4 → 39.8 | | narration | 15.0 → 23.6 | 41.0 → 34.7 | 42.5 → 43.7 | | brahms | 13.9 → 14.1 | 40.5 → 26.7 | 25.8 → 32.0 | | nutcracker | 15.0 → 17.7 | 40.7 → 24.0 | 27.7 → 34.5 |
Hum alone, 30 s, ±0.02 Hz: 56 → 61 dB down; buzz to 8 kHz alone: 6.4 → 62.2. Clean speech, narration and music: no sample changed. Under music 0.5.1 takes less hum than 0.4.0 and far less of the music: Brahms loses 24 dB of its hum, not 42, and keeps 31.4 dB of program SDR, not 25.7, its 100–300 Hz band 22.3, not 17.9; what is left of the hum is 44 dB under the orchestra. Vibe Ace's hum, among the track's own steady lines, is found now (0.4.0 left it): 33 dB down. A generator's ±0.2 Hz wander under a voice is followed by 8 dB only.
Against iZotope RX 12 De-hum (2026-10, node bench/rx/dehum.mjs in audio; RX hosted through Pedalboard, a new instance per render, its Learn on 3 s of the hum alone before each take as its manual has it): 21 test clips (6 VoiceBank speech clips of eight utterances, 4 Spoken Wikipedia readings, Brahms, Vibe Ace, the Nutcracker, the trumpet, 7 MUSDB18 test previews), six conditions (50 Hz mains, 60 Hz buzz to 8 kHz, 50 Hz buzz wandering ±0.2 Hz, level steps, six splices, 60 Hz buzz 30 dB under), RX's knobs chosen on a separate split (VoiceBank training speakers, other readings, MUSDB18 training previews): its best, Dynamic at sensitivity 1 and Q 10000, beat its defaults (sensitivity 5: 18.6 dB mean SDR on the tuning split) and every Static setting (7–12 dB: its notches, 16 harmonics at Q 300–3000, leave an error 3–10 dB under a white program's 40–1000 Hz band). Mean over conditions and materials, hum down / SDR against the clean program / PESQ (speech and readings):
| | RX defaults | RX tuned | 0.4.0 | 0.5.2 | |---|---:|---:|---:|---:| | hum down, dB | 7.7 | 3.2 | 11.8 | 17.7 | | SDR, dB | 18.9 | 25.4 | 29.4 | 34.9 | | PESQ | 3.06 | 2.80 | 3.27 | 4.01 |
RX's learned Dynamic profile acts as a gate: it takes the hum in the room tone 54–60 dB down and leaves it under the speech, where it takes as much of the voice (18.9 dB mean SDR, the input's 22.6). Tuned, it barely touches the take (3 dB of hum), the best SDR it reaches. 0.5.2 (every score as 0.5.1's) is over RX tuned in SDR in all 18 condition × material cells (buzz 30 dB under music: 35.9 against 32.2, input 31.3), and every clip comes out within 0.2 dB of its input SDR or better. Hum as recorded (MIR-1K singing, DEMAND's washing machine and hallway, five readings, a VoiceBank take): RX's select-all Learn takes its lines up to 18 dB down and the program with them (the rest of the spectrum 1.5–20 dB SDR); dehum takes them 0–14 dB down and leaves the rest 21–60 dB. VocalSet's 60 Hz lines, more than 60 dB under the singing and barely over its room tone, are left as they are (0.4.0 took them 0–7 dB down; RX up to 23, the rest of the spectrum 0.2–12.5 dB).
Use when: mains buzz, ground-loop hum, fixed tonal interference: a whine, a test tone, an electrical or fan line.
Not for: broadband noise (use wiener/omlsa); tones that glide or switch on and off inside the take (use spectral methods).
dewind
Takes wind out from under a voice or an instrument and leaves their harmonics. Wind is turbulence at the microphone: noise under a few hundred Hz with no period, in gusts (Nelke & Vary, IWAENC 2014), where a voice's low end is a row of harmonics. A high-pass can only take everything under its cutoff, the voice's low harmonics with the wind; here each STFT bin under cutoff is weighed against a wind spectrum read from the frame itself.
The frame is the power of two nearest 85 ms (4096 samples at 44.1 and 48 kHz), so a 100 Hz voice's harmonics stand 8 bins apart with valleys between them. The wind spectrum is the periodogram's morphological opening over 5 bins: every peak narrower than that is cut, and a steady harmonic under a Hann window is 4 bins wide, so the harmonics go and the broad wind stays (the inverse harmonic mask of Nelke, Naylor & Vary, ICASSP 2015, without a pitch track). Over 100 Hz it is held to 4× the least it has been over the last 1.5 s relative to its level under 100 Hz (minimum statistics, Martin 2001): wind keeps its shape while it gusts, and a voice's valleys, onsets and unvoiced sounds don't pass for it. A voiced harmonic whose pitch moves within the frame spreads wider than 5 bins and would pass for floor too (a male voice's fundamental, under 100 Hz, where that cap doesn't reach): under a peak standing 6 dB over the floor, wider than 5 bins and 15 dB over the minima either side of it, the floor is bridged between those minima, in dB. The gain is OM-LSA's form (Cohen & Berdugo 2001): a Wiener gain on the decision-directed a priori SNR, raised to the speech presence probability, which takes a fixed 15 dB prior (Gerkmann & Hendriks 2012) and an a priori absence of 0.2 on a harmonic (a peak 6 dB over the wind), 0.9 elsewhere. So a harmonic keeps what of it stands over the wind, and the wind's own random peaks don't come through as musical noise.
It runs while wind blows: the 20–300 Hz band aperiodic (its normalized autocorrelation, taken through the frame's spectrum over the window's, under ½ at 2.5–25 ms; a harmonic H in noise N reads H / (H + N), Boersma 1993) and its noise over the 300–2000 Hz band, floored 20 dB under its peak over the last seconds, three frames in a row (a 150 ms gap between words shows it); then held 1 s, through the words, whose low end hides it. A bass line, a kick drum's body or a voice's low end repeats; a room's quiet rumble in a pause is no wind next to a voice. With no wind the output equals the input, sample for sample, frameSize − 1 samples later (85 ms at 48 kHz, the manifest's declared latency).
dewind(data, { fs: 48000 }) // in place
let write = dewind({ fs: 48000 }) // stream: write(chunk) → the samples done, write() → the rest| Param | Default | |
|---|---|---|
| cutoff | 8000 | Hz, the top of the band wind is taken from; read every frame. Most wind lies under 500 Hz, strong wind rushes to several kHz |
| attenuation | -20 | dB, the most a bin is turned down; 0 takes nothing |
| frameSize | 85 ms | STFT frame, a power of two; the hop a quarter |
python scripts/wind.py fetch, then node scripts/lowend.js dewind puts clean speech and music through it, then speech with wind: synthetic (Gaussian noise shaped and gusting as Nelke & Vary measure it), generated (the SC-Wind-Noise-Generator, Mirabilii et al., IWAENC 2022: spectrum and gusts by wind speed) and recorded (twelve CC0 recordings of wind on a microphone, freesound.org), half of the generated and recorded tuning the defaults, half below. VoiceBank+DEMAND test utterances (every fourth: p232 male, p257 female), ten Spoken Wikipedia narrations, the music repair uses, 0.3.0 → now; voiced frames thinned: the voiced 10 ms frames, from the first to the last within 20 dB of the take's loudest, whose level under 250 Hz fell by more than 3 dB; their periodic part: whose periodic energy there (r·E, Boersma 1993) did. 0.3.0's figures (male 9.1 %, female 16.2 %) also counted the room tone before and after the words, flagged voiced by its hum, whose rumble going is no thinning:
| | untouched | voiced frames thinned | their periodic part | 20–63 Hz | 63–125 Hz | 125–250 Hz | |---|---:|---:|---:|---:|---:|---:| | speech, male | 0% → 0% | 4.8% → 3.2% | 3.0% → 1.4% | −1.7 → −1.3 | −0.5 → −0.2 | −0.1 → 0.0 | | speech, female | 0% → 0% | 7.9% → 7.9% | 3.0% → 3.0% | −4.5 → −4.5 | −0.7 → −0.7 | 0.0 → 0.0 | | narrations | 20% → 20% | 5.9% → 4.8% | 5.1% → 4.0% | −0.7 → −0.6 | −0.3 → −0.2 | −0.2 → −0.1 | | audio-lena | 100% → 100% | 0.0% → 0.0% | 0.0% → 0.0% | 0.0 → 0.0 | 0.0 → 0.0 | 0.0 → 0.0 | | Vibe Ace (jazz) | 0% → 0% | 1.2% → 1.1% | 0.9% → 0.8% | −0.2 → −0.2 | −0.1 → −0.1 | −0.1 → −0.1 | | Brahms (strings) | 100% → 100% | 0.0% → 0.0% | 0.0% → 0.0% | 0.0 → 0.0 | 0.0 → 0.0 | 0.0 → 0.0 | | Nutcracker | 100% → 100% | 0.0% → 0.0% | 0.0% → 0.0% | 0.0 → 0.0 | 0.0 → 0.0 | 0.0 → 0.0 | | trumpet | 100% → 100% | 0.0% → 0.0% | 0.0% → 0.0% | 0.0 → 0.0 | 0.0 → 0.0 | 0.0 → 0.0 | | bass line | 100% → 100% | 0.0% → 0.0% | 0.0% → 0.0% | 0.0 → 0.0 | 0.0 → 0.0 | 0.0 → 0.0 |
A recording's own low rumble, where it outweighs the mid band in a pause, reads as wind: VoiceBank's room tone before each take engages it for the take's first second, and narrations recorded at home in their pauses. The rumble is noise and goes. Of a voice under it, 0.3.0 thinned mostly a male fundamental moving in pitch, spread wider than the opening under 100 Hz, where the cap doesn't reach; now bridged, its periodic part thins half as often. What still thins: onsets, whose low end an 85 ms frame smears, and, in female takes, frames whose low end under the fundamental is mostly that rumble. Orchestral music, a trumpet and a bass line come back untouched; a jazz track's drums engage it for moments.
Wind at a speech-to-wind ratio of +10, 0 and −10 dB, the error to the clean speech taken away (dB):
| | +10 dB | 0 dB | −10 dB | |---|---:|---:|---:| | synthetic | 5.1 → 5.4 | 9.9 → 10.0 | 13.1 → 13.3 | | generated | 4.4 → 4.8 | 8.3 → 8.4 | 11.3 → 11.4 | | recorded | 3.8 → 4.1 | 7.5 → 7.6 | 10.2 → 10.3 |
The wind removed and the speech kept, by phase inversion (Hagerman & Olofsson, Acta Acustica 2004: the op on s + n and on s − n, ŝ = (y₊ + y₋)/2, n̂ = (y₊ − y₋)/2), dB:
| | wind removed, +10 dB | 0 dB | −10 dB | speech kept, +10 dB | 0 dB | −10 dB | |---|---:|---:|---:|---:|---:|---:| | synthetic | 9.3 → 9.2 | 14.0 → 14.1 | 15.0 → 15.2 | −0.3 → −0.2 | −0.8 → −0.7 | −1.8 → −1.7 | | generated | 8.8 → 8.6 | 12.4 → 12.3 | 13.3 → 13.4 | −0.4 → −0.3 | −1.2 → −1.1 | −3.0 → −2.9 | | recorded | 8.0 → 7.8 | 10.8 → 10.8 | 11.4 → 11.5 | −0.4 → −0.3 | −1.1 → −1.0 | −2.1 → −2.1 |
PESQ (wideband), STOI and DNSMOS P.835 of the same outputs, the three winds together (python scripts/wind.py score DIR/now test; scored at 16 kHz as scripts/speech.py does):
| | PESQ | STOI | SIG | BAK | OVRL | |---|---:|---:|---:|---:|---:| | +10 dB: input | 1.76 | 0.939 | 3.51 | 3.36 | 2.89 | | 0.3.0 | 2.30 | 0.935 | 3.34 | 3.61 | 2.88 | | now | 2.40 | 0.932 | 3.33 | 3.67 | 2.89 | | 0 dB: input | 1.21 | 0.874 | 3.25 | 2.44 | 2.31 | | 0.3.0 | 1.58 | 0.875 | 3.20 | 3.19 | 2.58 | | now | 1.68 | 0.869 | 3.16 | 3.31 | 2.59 | | −10 dB: input | 1.06 | 0.754 | 2.06 | 1.43 | 1.43 | | 0.3.0 | 1.16 | 0.756 | 2.83 | 2.41 | 2.08 | | now | 1.21 | 0.750 | 2.88 | 2.65 | 2.19 | | all: input | 1.34 | 0.856 | 2.94 | 2.41 | 2.21 | | 0.3.0 | 1.68 | 0.855 | 3.12 | 3.07 | 2.51 | | now | 1.76 | 0.850 | 3.12 | 3.21 | 2.56 |
STOI barely moves: its bands begin at 150 Hz, above most of the wind. Wind on a microphone rushes on above 1.5 kHz, 0.3.0's cutoff, where a voice's consonants and the upper harmonics stand over it: taken up to 8 kHz, it goes 5–11 dB down at 2–4 kHz and 4–9 dB at 4–8 kHz, the speech there 0.1–1.6 dB (on the tuning material); PESQ gains 0.08, BAK 0.14, STOI loses 0.005, from a gain that now moves over the upper bands as well. Clean speech and music lose under 0.07 dB above 1 kHz. In light wind DNSMOS's SIG drops 0.18; an ideal Wiener gain from the wind's own spectrum under 1.5 kHz lost 0.12 there on the tuning material.
The wind goes under the words too, between and under the harmonics; what a voice loses is mostly the harmonics the wind buries (its low end at −10 dB), and onsets.
Use when: wind on a microphone under a voice or an instrument, gusting or steady; a recording's low rumble (traffic, a fan) where it outweighs the mid band in the pauses.
Not for: a lone thump (deplosive), a steady low tone or hum, which has a period (dehum, highpass), or broadband noise (omlsa, wiener). Air rushing over the whole band is taken only under cutoff. A neural model (@audio/neural-denoise's DeepFilterNet3) takes more of the wind and keeps more of the voice; dewind leaves a sound with no aperiodic low end as it is (music, a voice with no rumble under it), streams at a frame's delay, and needs no model.
deplosive
Takes a close-mic p or b out: a pressure pulse that rises out of nothing under 80 Hz and has no period, where a voice's or a bass note's low end repeats at its pitch. A pop begins when the 3 ms envelope of the band under 80 Hz jumps over 3× its 30 ms average while standing over triggerRatio× the band over 120 Hz (a voice's fundamental and up), and holds while that lasts and the low band stays aperiodic: its normalized autocorrelation's peak at a 2.5–25 ms lag (Talkin 1995) under ½, where a harmonic part would lead (Boersma 1993). The voice band is floored 6 dB under its peak over the last seconds: a room's rumble in a pause is no pop, and a pop reaches the voice's level. Removal takes the sound's own part under crossover away, x − w·LP(x), LP linear-phase: a pulse through a causal filter leaves the filter's response to its edges behind (with the pops' spans known, a duck to a causal 2nd-order high-pass at 100 Hz takes a median 12 dB of their error, the linear-phase band at 120 Hz 21). LP runs at ~2 kHz (three box sums decimate, a Kaiser-windowed sinc low-passes, linear interpolation returns), and its half-length is the look-ahead: the output is latency(fs) samples late (~14 ms, declared to the host), the duck starts 4 ms before where the pop's low band rose and is held to its end. With no pop the output is the input, sample for sample, that late.
deplosive(data, { fs: 48000 }) // in place, aligned
let write = deplosive({ fs: 48000 }) // stream: each block back whole, latency(48000) samples late; write() flushes| Param | Default | |
|---|---|---|
| triggerRatio | 1 | the band under 80 Hz over the band over 120 Hz that a pop must exceed |
| attenuation | -40 | dB, how far the band under crossover goes in a pop |
| crossover | 120 | Hz, the band a pop is taken from |
| attack | 0.0005 | s |
| release | 0.03 | s |
Against iZotope RX 12 De-plosive, node bench/rx/deplosive.mjs in audio (2026-10): VoiceBank clean speech (2 test speakers, 11.4 min), pops added at stop bursts found on it (Liu 1996's +b landmark), six in ten: a one-sided pressure pulse of 20–80 ms rising in 1–4 ms (as the narrations' own pops do), under 200 Hz, 0.3–2× the speech peak. Every setting chosen on 28 training speakers; RX tuned: sensitivity 1, strength 5, frequency limit 125 Hz (defaults 5, 5, 200). The error to the clean speech taken away over each pop and the 100 ms after it; SNR to the clean speech over the reels with pops; on the speech alone, its voiced frames:
| | pops: error taken away, median · 10th pct | with pops: SNR to clean | clean, voiced frames: SNR to input | voiced frames thinned > 3 dB under 250 Hz | |---|---:|---:|---:|---:| | RX 12 defaults | 21.8 · 11.1 dB | 19.1 dB | 22.8 dB | 4.9 % | | RX 12 tuned | 21.9 · 13.4 dB | 22.0 dB | 46.8 dB | 0.0 % | | 0.2.0 | 5.8 · 0.8 dB | 9.1 dB | 39.9 dB | 0.1 % | | 0.3.0 | 22.5 · 16.3 dB | 24.6 dB | 53.4 dB | 0.0 % |
0.2.0 missed one pop in ten (a pop under a word's level never stood 4× over the band above 200 Hz) and took 6 dB of the rest. node scripts/lowend.js deplosive puts clean speech and music through it, then speech with pops (half-sine pressure pulses of 20–60 ms under 150 Hz before each word that follows a pause): VoiceBank+DEMAND test utterances, Spoken Wikipedia narrations, the music repair uses, 0.2.0 → 0.3.0; voiced frames thinned: the voiced 10 ms frames whose level under 250 Hz fell by more than 3 dB:
| | voiced frames thinned | 20–63 Hz | 63–125 Hz | 125–250 Hz | |---|---:|---:|---:|---:| | speech, male | 0.7% → 0.2% | −0.1 → 0.0 | 0.0 → 0.0 | 0.0 → 0.0 | | speech, female | 2.2% → 0.7% | −0.2 → −0.1 | 0.0 → 0.0 | 0.0 → 0.0 | | narrations | 0.0% → 0.1% | −0.2 → −0.2 | 0.0 → 0.0 | 0.0 → 0.0 | | audio-lena | 0.0% → 0.0% | 0.0 → 0.0 | 0.0 → 0.0 | 0.0 → 0.0 | | Vibe Ace (jazz) | 0.4% → 1.6% | 0.0 → −0.1 | 0.0 → −0.2 | 0.0 → −0.1 | | Brahms (strings) | 0.1% → 0.0% | 0.0 → 0.0 | 0.0 → 0.0 | 0.0 → 0.0 | | Nutcracker | 0.1% → 0.1% | 0.0 → 0.0 | 0.0 → 0.0 | 0.0 → 0.0 | | trumpet | 0.0% → 0.0% | 0.0 → 0.0 | 0.0 → 0.0 | 0.0 → 0.0 | | bass line | 0.0% → 0.0% | −0.1 → 0.0 | 0.0 → 0.0 | 0.0 → 0.0 |
Pops peaking at 0.5, 1 and 2× the utterance's peak, the error to the clean speech taken away over each pop and the 150 ms after it (dB):
| | 0.5× | 1× | 2× | |---|---:|---:|---:| | speech, male | 11.0 → 23.3 | 13.2 → 28.1 | 14.5 → 31.3 | | speech, female | 12.6 → 25.8 | 14.2 → 30.6 | 15.0 → 33.6 |
Use when: mic plosives (p, b, t) producing low-frequency thuds.
Not for: a kick drum: it is a pop to this (a jazz mix loses 3 dB under 250 Hz on 1.6 % of its frames, 0.2.0: 0.4 %); a live path that cannot wait 14 ms.
deesser
Sibilance reduction, @audio/dynamics-deesser 0.4 behind this family's seconds-based options (2026-07 near-dupe merge), every setting the kernel's unless given. An 's' is told by its shape, not its level: the band over split (3.5 kHz) against the voice body under it, in dB, read 5 ms ahead so the cut is in place as the 's' begins; how far it rises over threshold sets the cut, held within range. By default (split) only that band moves, through a linear-phase split, so nothing under it changes; mode: 'band' cuts a bell at fc, 'broadband' the whole sound. Measured against iZotope RX 12 De-ess in the kernel's README: harsh sibilants 12.5 dB of the error gone (RX tuned 12.1).
deesser(data, { fs }) // the band over 3.5 kHz, the kernel's threshold (0 dB) and range (−8 dB)
deesser(data, { fs, mode: 'band', fc: 7500, Q: 1 }) // a bell at 7.5 kHz alone| Param | Default | |
|---|---|---|
| mode | 'split' | 'split' \| 'band' \| 'broadband' |
| split | 3500 | Hz, where split mode's band starts |
| fc | 6500 | Hz, band mode's centre (freq still accepted) |
| Q | 1.4 | band mode's width |
| threshold | 0 | dB of the sibilance band over the voice body where the cut starts; not a level |
| range | -8 | dB, the deepest cut |
| ratio | 4 | Compression ratio above threshold |
| attack | 0.001 | s, how fast the cut engages |
| release | 0.015 | s, how slowly it recovers |
| lookahead | 5 | ms read ahead (the output stays aligned) |
Use when: voice post-production with hot s/sh; vocal bus de-essing.
Broadband & spectral
specsub
Power spectral subtraction with over-subtraction and a spectral floor (Berouti, Schwartz & Makhoul 1979): |Ŝ|² = |Y|² − α·N̂ where that stays above β·N̂, else β·N̂. Over-subtraction takes out the noise's peaks that plain subtraction leaves as musical tones; the floor, a fraction of the noise estimate, fills the valleys with a steady bed that masks what is left. α follows the frame's SNR (4 − 3/20·SNR: 4.75 at −5 dB down to 1 at 20 dB) unless fixed. The noise PSD is tracked by minimum statistics (Martin 2001) over a 1.5 s window, in batch and stream alike, unless a profile or a noise-only stretch is given; a held note, a sustained vowel or a chord is kept out of it (@audio/noise-estimate's partials, as omlsa has it: clean music cut by more than 3 dB 10.3 → 3.2 %, sung long tones 36.9 → 0.0 %, Slakh mixes 13.1 → 0.8 %; estimator: { partials: false } turns it off). processor(opts) is the gain as an @audio/stft frame process, for a host running its own frames.
specsub(data, { fs }) // α(SNR), β 0.05, noise tracked
specsub(data, { fs, alpha: 2 }) // fixed over-subtraction
specsub(data, { fs, noiseFrames: 6 }) // noise from the first 6 frames| Param | Default | |
|---|---|---|
| alpha | α(SNR) | Fixed over-subtraction factor; omitted (or 0): Berouti's α(SNR) |
| beta | 0.05 | Spectral floor, a fraction of the noise estimate: higher leaves less musical noise and more noise |
| frameSize | power of two nearest 32 ms | STFT frame: 512 at 16 and 22.05 kHz, 1024 at 44.1, 2048 at 48 (frame(fs)) |
| hopSize | frameSize/4 | OLA hop |
| profile | tracked | Noise PSD (Float64Array, frameSize/2+1 bins) |
| estimator | | Minimum statistics options (@audio/noise-estimate minStats); { partials: false }: held notes learned as noise, as before |
| noiseFrames / profileFrom / profileTo | | A noise-only stretch to average for the profile |
Use when: quick baseline; offline cleanup with a known noise-only preamble.
Not for: musical-noise-sensitive material — use wiener or omlsa.
wiener
MMSE log-spectral amplitude (Ephraim & Malah 1985) or Wiener (Scalart & Filho 1996) gain on the decision-directed a priori SNR ξ = α·Â²(l−1)/λ(l−1) + (1−α)·max(γ−1, 0) (Ephraim & Malah 1984, eq. 51). α = 0.98 is theirs for an 8 ms frame step (§VI: 256 samples at 8 kHz, a new frame every 64) and is rescaled to the actual step as αΔt/8 ms, so the a priori SNR's memory holds in seconds; applied per frame until 0.3, it ran 1.8× longer at 48 kHz (10.7 ms steps) than at 44.1 kHz (5.8 ms). The floor ξmin stays −15 dB: −25 dB, Cohen's and Loizou's, left more musical noise on steady white and pink noise (log kurtosis ratio 0.98 and 1.49, against 0.53 and 1.01) and cost PESQ and SIG on the training speech (scripts/speech.mjs). The noise PSD λ is tracked by minimum statistics (Martin 2001) over a 1.5 s window, in batch and stream alike, unless a profile or a noise-only stretch is given; a held note, a sustained vowel or a chord is kept out of it (@audio/noise-estimate's partials, as omlsa has it: clean music cut by more than 3 dB 14.5 → 3.4 %, sung long tones 36.3 → 0.0 %, Slakh mixes 26.6 → 3.3 %; estimator: { partials: false } turns it off). processor(opts) is the gain as an @audio/stft frame process, for a host running its own frames.
wiener(data, { fs }) // LSA gain, noise tracked
wiener(data, { fs, rule: 'wiener' }) // Wiener gain
wiener(data, { fs, noiseFrames: 6 }) // noise from the first 6 frames| Param | Default | |
|---|---|---|
| rule | 'mmse-lsa' | 'wiener' or 'mmse-lsa' (log-spectral, less musical noise) |
| alpha | 0.98 | Decision-directed smoothing per 8 ms of frame step, rescaled to the actual step (alias of alphaDD) |
| xiMin | 10^−1.5 | A priori SNR floor (−15 dB) |
| frameSize | power of two nearest 32 ms | STFT frame: 512 at 16 and 22.05 kHz, 1024 at 44.1, 2048 at 48 (frame(fs)) |
| hopSize | frameSize/4 | OLA hop |
| profile | tracked | Noise PSD (Float64Array, frameSize/2+1 bins) |
| estimator | | Minimum statistics options (@audio/noise-estimate minStats); { partials: false }: held notes learned as noise, as before |
| noiseFrames / profileFrom / profileTo | | A noise-only stretch to average for the profile |
Use when: transparent broadband denoise; the "safe default" for stationary noise.
omlsa
Optimally-Modified Log-Spectral Amplitude estimator (Cohen & Berdugo 2001) with IMCRA noise estimation (Cohen 2003). The log-spectral amplitude gain when speech is present, weighed against a floor by the speech presence probability p: G = max(G_H1, G_min)^p · G_min^(1−p) (eq. 16 with GH1 floored at Gmin: as written it took a bin below Gmin wherever GH1 was under it, to −18 dB at Gmin −12, so gMin was not the floor). The a priori SNR is decision-directed on GH1 (eq. 18), the a priori speech absence is estimated from the spread of an a priori SNR over time and neighbouring bins (§4), the noise spectrum comes from IMCRA. As Cohen's own omlsa.m, a bin whose speech absence reaches 0.9 counts as noise: noise that happens to peak keeps Gmin and leaves no musical tone, so what remains of the noise is the noise, Gmin quieter. Time constants, the decision-directed α among them, are set for 8 ms frames and rescaled to the actual frame step (omlsa.m rescales by the reciprocal, which lengthens them at 48 kHz).
α sets how soon ξ follows a word's start after a pause, and how much gain a noise peak gets. Until 0.3 it was 0.98 per frame, whatever the frame (0.985 per 8 ms at 48 kHz, 0.972 at 44.1), and a word's second and third frames after a pause lost 10 and 5 dB. It is now the lowest value per 8 ms that leaves steady white and pink noise without musical noise: 0.97 tracking the noise (log kurtosis ratio 0.00 at Gmin −15 and −25 dB; 0.96 left 1.16 and 0.49 at −25, the paper's 0.92 0.99 and 1.81 at −15), 0.95 on a learned noise (omlsa.m's; 0.92 left 0.19 and 0.42). The paper's cap q ≤ qmax = 0.95 (Table 1) in place of the gate kept a word's second frame after a pause another 3 dB but left musical noise on steady noise (0.24 and 0.46 at α 0.97), so the gate stays. The table under Speech has what each keeps. scripts/reference.py holds a numpy version written from the papers; on 16 kHz VoiceBank frames it gives omlsa.m's noise track, speech absence and gains to the last bit (but for near-empty bins, where omlsa.m's absolute 1e-10 floors bind), and test.js holds this code to it.
The a priori SNR the speech absence is read from is smoothed in the cepstrum (Breithaupt, Gerkmann & Martin 2008): the speech power's maximum-likelihood estimate λ·max(γ−1, ξml,min), its cepstrum smoothed over time per quefrency, hardly where speech lives (the envelope's low quefrencies, the pitch's peak), much where a lone noise peak lives, and back. The decision-directed ξ (Cohen's, qFrom: 'dd') lags a word's start after a pause, and takes a short burst of noise the tracker cannot follow for speech; the cepstral one follows a word's envelope at once and smooths a lone spectral peak away. Its constants are Table 1's, per 16 ms of frame step, but under 1.25 ms of quefrency Gerkmann & Hendriks's (ICASSP 2012: 0 and 0.2, where the 2008 paper's 0.5 and 0.7 lag an onset as the decision-directed ξ does) and above it 0.9: the least that leaves steady white and pink noise on a learned noise free of musical noise (0.85 left 0.22 on pink). On the training speech, tracked: PESQ 1.823 → 1.825, STOI 0.832 → 0.834, OVRL 2.525 → 2.543, BAK 3.00 → 3.04, musical noise 0.82 → 0.80; 40 ms tone bursts in steady noise pass at −6.5 dB (were −2.6). The cepstral ξ as the gain's own ξ as well lost voiced speech where the pitch glides (SIG −0.04), so the gain keeps the decision-directed one.
A held note, a sustained vowel or a chord is not learned as the noise. IMCRA, as minimum statistics, takes whatever holds a bin for about a second as noise: clean music through omlsa lost 9.9 % of its time-frequency energy by more than 3 dB, a sung long tone 8.8 %, a synthesized band 22.6 %. @audio/noise-estimate's partials reads, each frame, where a partial stands, a peak over the spectrum's morphological floor (the inverse harmonic mask of Nelke, Naylor & Vary, ICASSP 2015) held 0.3 s within ±1 bin and 10 dB over what its bin held while free, and there holds the noise at that memory; elsewhere, and on noise alone, the tracker's estimate is as it was, to the bit. A line steady from the take's start (hum, a fan, an engine) has no free frame and stays noise; one that starts mid-take is held up to 60 s, then learned, as is a note held longer; a note sounding from the first frame is learned until its bin is once free of it. Music cut 9.9 → 3.0 %, sung long tones 8.8 → 0.0 %, Slakh mixes 22.6 → 2.3 %; on the training speech PESQ 1.825 → 1.823, OVRL 2.543 → 2.542, musical noise 0.80 → 0.81 (Speech has the test set). estimator: { partials: false } turns it off.
A noise that holds still can be learned instead: profile, the noise's PSD (noiseProfile of a stretch where it plays alone), or, in the batch form, noiseFrames/profileFrom/profileTo naming that stretch. The noise is then held (known of @audio/noise-estimate), and p reads the observation: γ averaged over 105.5 Hz and over 543 Hz of the frame, at fixed priors (Gerkmann, Breithaupt & Martin 2008: the averaged γ is χ² with r degrees of freedom from the Hann window's correlation across bins, ξfix the a priori SNR that minimizes false alarms plus misses, eq. 13; at 48 kHz r 5.7 and 24, ξfix 9.4 and 5.1 dB), and 0 where the estimated q reaches 0.9, as before. Until 0.4 it read γ in each bin alone, at ξH1 = 15 dB (Gerkmann & Hendriks 2012), and speech 0–5 dB over the noise lost 7.8 dB on the training speech, where the MMSE Wiener gain keeps 3.9. The paper averages over 64 ms of frames as well; presence then outlasted a sound by those frames, and in the half second after music stopped the noise came through in tones (log kurtosis ratio 0.45 at Gmin −12 dB, 1.77 at −20): over the frame alone. On the training speech, Gmin −12 dB: PESQ 1.861 → 1.867, OVRL 2.564 → 2.572, BAK 3.05 → 3.08, musical noise 0.53 → 0.46, speech 0–5 dB over the noise −7.8 → −5.1 dB, the noise taken in pauses 9.7 → 10.1 dB. Steady noise under speech and music goes exactly Gmin down, with no musical noise from −12 to −20 dB (log kurtosis ratio 0.00); in the half second after music stops, 0.04 at −12 dB and 0.40 at −20 (0.5: 0.05 and 0.61, 0.3: 0.11 and 1.00; scripts/broadband.mjs). Held, the frame is the power of two at or above 32 ms (frame(fs, true); a profile without a frameSize brings its own, 2(K − 1) for K bins): 2048 at 44.1 kHz as at 48, where the nearest power of two, the tracked frame, left 1024 and 43 Hz bins, half the resolution 48 kHz gets. Nothing is tracked on a held noise, so the frame's length goes to frequency, and a partial under the noise is parted from it. 0.5 → 0.6,
