All posts

· Casimiro Ferreira· 10 min read

Cleaning Up Bad Audio: Denoising and Bandwidth Extension in audiosronnx

  • ONNX
  • denoising
  • bandwidth extension
  • speech

A recording can be bad in two different ways, and the fixes do not overlap.

The first way: background noise sits on top of the speech — traffic, a fan, room hum. The signal that matters is there; something else is mixed into it. Removing that is denoising.

The second way: the recording never captured the full signal in the first place. Telephone audio is sampled at 8,000 samples per second (8 kHz); a full-quality recording is usually 48 kHz. The sample rate sets the highest frequency a digital signal can represent, so an 8 kHz call has no content above 4 kHz at all — not quiet, not filtered, just never recorded. Making that audio sound full again means inventing plausible high frequencies that were never captured. That is bandwidth extension.

audiosronnx treats these as two different problems with two different entry points, because using the wrong one does the wrong thing. Run a bandwidth extender on a noisy signal and it will faithfully reconstruct a high-frequency version of the noise. Denoising has to happen first.

from audiosronnx import load_denoise, load_sr

clean, rate = load_denoise("dpdfnet").denoise("noisy_call.wav")   # remove noise
wide, _     = load_sr("lavasr").upscale(clean, rate)              # extend to 48 kHz

load_denoise() and load_sr() refuse each other’s engines — asking load_denoise for a bandwidth extender raises an error rather than silently doing the wrong job.

Where this actually helps

The obvious guess is that cleaning audio before speech recognition must improve the transcript. In practice that is not reliable. Modern recognizers are trained on large amounts of noisy, narrowband, real-world speech, so a recognizer often handles a noisy recording better than it handles the same recording after an enhancer has been through it. Enhancement is lossy. It removes what it judges to be noise, and it can take acoustic detail the recognizer was using along with it, or leave artifacts the recognizer has never heard in training. Whether it helps or hurts depends on the specific model, what it was trained on, and what is wrong with the recording. It has to be measured per model, not assumed.

The two places where these tools pay off consistently are both on the synthesis side.

The first is training data preparation. A text-to-speech voice inherits the character of its training audio, including the room it was recorded in. Hiss, hum, and a low sample rate in the corpus become hiss, hum, and a muffled quality in every sentence the finished voice ever speaks. Cleaning a corpus before training, and lifting it to a consistent 48 kHz, is work done once that improves every output afterward. This matters most for the languages with no studio corpus available, where the only recordings that exist were never made for speech synthesis.

The second is post-processing synthesized speech. A vocoder can leave a metallic edge or a band-limited quality, particularly for a model trained on a small or low-rate dataset. Running the output through a bandwidth extender lifts it without retraining anything.

A human listener is the third case, and the simplest one: a recording that a person has to sit through benefits from being cleaner, whatever a recognizer would have made of it.

The engine registry

audiosronnx ships ten denoisers and seven bandwidth extenders, all loadable by name through load_denoise() / load_sr(), all pure ONNX with no torch at runtime.

Denoisers:

EngineRateSizeLicense
dpdfnet (default)8/16/48 kHz8.7–14.9 MBApache-2.0
mossformer248 kHz229 MBApache-2.0
frcrn16 kHz57.5 MBApache-2.0
mpsenet16 kHz9.7 MBMIT
gtcrn16 kHz0.54 MBMIT
cmgan16 kHz7.8 MBMIT
metadenoiser16 kHz19–34 MBCC-BY-NC-4.0
mossformergan16 kHz17.7 MBApache-2.0
voicefixer44.1 kHz415 MBMIT
deepfilternet48 kHz~2 MBMIT

Bandwidth extenders:

EngineInputSizeLicense
lavasr (default)8–48 kHz~52 MBApache-2.0
novasr16 kHz~0.2 MBApache-2.0
flowhighany~200 MBMIT
hifiganbweany~4 MBMIT
apbweany (12 kHz band)~120 MBMIT
sidon16 kHz~410 MBMIT
callenhancer8–16 kHz~3 GB / ~1.3 GB int8CC-BY-NC-4.0

The smallest model in the library, gtcrn, is 0.54 MB. The largest, voicefixer, is 415 MB — nearly 800 times bigger, and it does a different job: it is a restoration model that handles noise, reverb, clipping and missing bandwidth together rather than one problem at a time.

Most weights are MIT or Apache-2.0. Two are not: metadenoiser and callenhancer ship under CC-BY-NC-4.0, non-commercial. That license covers the weights, not audio processed with them, and the library states it at every point of use — audiosronnx list reports it per engine. Nothing stops a caller from choosing metadenoiser for its time-domain architecture, but the choice has to be made knowingly.

The registry exists because no single model wins on every recording. dpdfnet is the default because it needs no extra dependencies and covers 8, 16 and 48 kHz from one model. mossformer2 is the best measured choice on fullband input. mossformergan posts the highest published PESQ score (3.47) among the shipped denoisers. gtcrn is the pick when the binding constraint is footprint, at 0.54 MB. On one test clip with broadband Gaussian noise, denoisers recovered 3.5 to 5.9 dB of SNR at a 19 dB input SNR, rising to 7.5–13.7 dB at a harder 5 dB input. That is a synthetic, hostile noise case: it ranks the engines consistently but says little about babble noise or codec artifacts, which is exactly why the registry keeps ten models rather than shipping only the winner.

cmgan is the clearest case of a model kept on purpose despite losing: it is dominated on both PESQ and SNR by gtcrn, at fourteen times the size, and it stays anyway — so that published results built against cmgan stay reproducible and a distinct architecture remains available to compare against.

On the bandwidth-extension side, sidon and callenhancer do a different job from lavasr or novasr: instead of adding a plausible high band on top of the existing signal, they resynthesize speech from scratch through a neural vocoder, which can repair codec damage a band extender cannot touch — at far higher computational cost. callenhancer is trained specifically on telephony audio, which is why its weights carry the non-commercial license.

What did not make it in

audiosronnx ships an engine only when it exports to a single static ONNX graph, runs on CPU through onnxruntime, carries a clear license, and has been validated end to end against the original implementation — not just against the raw model, since a graph that matches the network but not its surrounding normalization produces audio that sounds fine and is quietly wrong.

The project’s docs/not-shipped.md documents every candidate it evaluated and rejected, with the specific reason, which makes it one of the more useful documents in the repository because it shows the actual boundaries of what “pure ONNX, CPU-only” can do today rather than asserting them.

Iterative samplers have no static graph to export

Diffusion and flow-matching models run a network many times per utterance, with a loop whose length is not fixed at export time. AudioSR (a roughly 6 GB latent-diffusion pipeline with a separate VAE, LDM and vocoder) and SGMSE both fall here. SGMSE’s own 2025 streaming follow-up only reaches real time on a consumer GPU, let alone CPU.

Location-variable convolutions look disqualifying and mostly are not

resemble-enhance was long rejected in this document over LVCNet, the vocoder’s location-variable convolution, on the theory that kernels predicted per position through unfold and einsum cannot fold into a static graph. Tested directly, that turned out to be wrong: both ops have ONNX equivalents. The actual failure is a separate, well-understood tracing error (“ONNX export of convolution for kernel of unknown shape”) already solved elsewhere in the codebase for BigVGAN’s resamplers. What still keeps resemble-enhance out is scale: four networks including a CFM ODE sampler and an autoencoder, at 44.1 kHz. That is a scope decision, not an impossibility.

Some models have nothing trained to export

RNNoise ships as hand-written C, not a graph in a trainable framework, so porting it would mean retraining an equivalent network from scratch. Fast-ULCNet publishes only architecture code, no checkpoint at all.

A restrictive license is a labelling decision, not automatic disqualification

That is exactly why callenhancer and metadenoiser ship. What is disqualifying is weights published with no license at all: mdctGAN was rejected for exactly that, on top of a torch.fft-based front end that does not export reliably.

torch.stft called inside the model is a real structural blocker

NU-Wave2’s sampler loop is not the problem; that could run in numpy outside the graph, the same way every other engine’s STFT does. What blocks it is that its forward method calls torch.stft and torch.istft internally, which the library deliberately keeps out of every graph it ships, and which is also the operator that exports least reliably in general. Fixing it would mean splitting the model at the transform boundary, real restructuring rather than an operator swap.

Reproducing a model’s architecture is not the same as reproducing its output

LiSenNet is 56 K parameters, under 300 KB, which would make it the smallest engine in the library. Its publicly available ONNX port runs and produces plausible-looking attenuated audio, but measured end to end it destroys the signal: −10.8 dB SNR at 11 dB input. Reproducing the port’s own reference implementation exactly gives the identical negative result, which means the reference implementation itself does not match the front end its own documentation describes. There is no correct target to validate against yet.

These rejections are rarely about size or speed. Each has a specific, narrow cause: an unsupported operator with an exact replacement (torch.complex has no ONNX op, but atan2(im, re) computes the same phase angle), a tensor built from an input’s runtime shape that a tracer cannot pin down, or a transform placed on the wrong side of a graph boundary.

Confirming the output actually got better

An ONNX file that runs is not proof that a recording improved. Two different failure modes look identical from the outside: a denoiser that mutes speech along with the noise, and a bandwidth extender that adds a high band with the wrong harmonic content, both produce audio that plays back without error and can even sound cleaner to a casual listen.

The sibling library speechonnxmetrics turns that judgment into a number instead of an impression. It scores audio on MOS (Mean Opinion Score, a 1–5 rating of perceived quality) two ways: no-reference neural predictors like DNSMOS and UTMOS, which score a recording with no clean original to compare against, and intrusive metrics like STOI and SI-SDR, which need the clean reference and measure how close the output actually is to it.

import speechonnxmetrics as s

s.score("clean.wav", ["utmos"])
# -> {'utmos': 4.41}

s.score("denoised.wav", ["stoi", "si_sdr"], ref="clean.wav")
# -> {'stoi': 0.66, 'si_sdr': -26.9}

Run before and after a denoiser or extender and the shape of a real comparison falls out: DNSMOS or UTMOS on the raw and processed audio to see whether perceived quality moved at all, and — when a clean reference exists, which it does for synthetic noise tests but rarely for a real phone call — SI-SDR or STOI to see whether the processed signal actually converged toward it rather than just sounding different. That is the same discipline behind the SNR figures in the denoiser table above: a number attached to a specific noise condition, not an adjective. The broader family of pure-ONNX libraries this fits into, including speechonnxmetrics itself, is covered in A Family of Pure-ONNX Speech Libraries.

Cleaning up audio for a training corpus, for a synthesized voice, or for a person who has to listen to it is a distinct engineering problem, with its own trade-offs between models and its own list of approaches that did not survive contact with a real signal.

Questions about applying this to a specific pipeline: get in touch.