Changelog¶
All notable changes to Kudio are documented here. The format follows Keep a Changelog, and the project adheres to Semantic Versioning.
[3.9.1] - 2026-10-24¶
Documentation and type annotations; nothing in the library behaves differently.
Changed¶
- Every public function and method the API reference shows has a type on
each parameter and on its return: 60 of them were missing some, so their
signatures on the site were incomplete.
lowercaseanduppercasehave docstrings. All of it is annotation only; every module already hadfrom __future__ import annotations, so nothing is evaluated at run time.
Documentation¶
- The Traditional Chinese site covers every guide page, the migration notes and the API overview, where it had the home page, Getting started and the command line. The API pages stay in English, since they are the docstrings.
- On the Chinese pages, a line break between Chinese and Latin text, a number or inline code lost its space when the build joined the lines, so the pages printed "Python裡" and "400 ms的": 56 places on the three pages already published.
- Guide pages that still described 3.7:
STFT.inverseandspectrogram_to_waveformrebuild every sample and refuse a wrong shape withFeatureError;RemoteStreamReaderputs a chunk that arrives in pieces back together (40 of 40 through a server cutting them into 1460-byte pieces, where 3.7.3 got one); the mel rows ofSpectrogramStream.freqsare the band centres;kudio loudnessmeasures a stereo file as stereo; andnormalize_lufspushing a peak past full scale raises aUserWarningrather than logging. - Claims the code never matched: the band-limited finding names the
bandwidth, not twice it;
spectral_gatekeeps its threshold on aquantileorinitialestimate rather than taking the noisy signal's spread;save_wave(d_sample=)is not mono only;trad_enhance's output is as long as its input; andsyn_extra_modestill reads each noise at the rate of the file it is mixed into. The home page described KudioEnhance as Keras on TensorFlow, which it has not been since its 0.2.0.
[3.9.0] - 2026-10-19¶
A minor version rather than a patch, because two results move: DNSMOS lays out its windows as DNS-Challenge's script does, and an integer array is read on its type's full scale wherever it goes in, which changes every number measured on one.
Changed — results that move¶
- DNSMOS cuts its windows as DNS-Challenge's
dnsmos_local.pydoes. A clip shorter than one 9.01 s window is doubled until it holds one, and every window of the result is scored: seven for a 2 s clip, where kudio filled a single window. Windows end inside the clip's last whole second, so 12.01 s gets three where it got four. The script works out each window's end in floating point, which leaves the windows starting at 7 to 23 s a sample short, and skips them; kudio keeps them, so the two agree on any clip that stays under 17 s once doubled.
Changed — interfaces¶
StreamEnhancer.flush()stops where the input stopped, so everything concatenated is as long as the input. 16000 samples in came back as 16383, the tail of the zero padding, and the guide said to trim it.CheckDevice.input_deviceraisesDeviceErrorrather than a plainRuntimeErrorthatexcept kudio.KudioErrorlet through.DeviceErroris aRuntimeErroras well, so code catching that still does.KudioConfig.load_configwarns when it falls back to the defaults: on a path that is not there, a folder it was given with nocfg.yamlin it, or a search that finds more than one. It said so atINFO, which nobody hears, so a misspelt file name ran on the defaults.
Deprecated¶
- The console colour helpers at the top level,
kudio.red,kudio.warn,kudio.error,kudio.info,kudio.h1and the rest ofkudio.util.colors, and the misspeltupercase.error,infoandwarnread as part of kudio's error handling, and a star import put them over a caller's own names. They still work, with aDeprecationWarning; usekudio.util.redand so on, anduppercase.
Fixed — integer audio¶
- Everything that measures or processes audio read an integer array as
plain numbers. An int16 clip measured 90.3 dB louder than the file it came
from in
loudnessandtrue_peak,audio_reportcalled it past full scale,snr,segmental_snrandsdiscored an int16 reference against a float clip 32768 times apart, and the channel effects,spectral_enhance,trad_enhance,apply_rir,StreamEnhancer,SpectrogramStream,melspectrogram,STFT.analyseanddnsmosworked on a signal 32768 times too loud;waveform_to_spectrogramandmfccrefused it. Every one reads integers on their type's full scale now, the way soundfile reads an integer file andgainalready did: int16 over 32768, int32 over 2**31, uint8 around 128, where an 8-bit file's silence sits, sotrim_silenceused to find none in one. What returns float32 still does;normalize_lufsandmatch_loudnessreturn the input's type, rounded and clipped, asnormalizedoes. - Filters, fades, crossfades and
remove_dccast integer audio straight back. A filter overshoots at an edge and an equal-power seam adds 3 dB, so a near-full-scale int16 square wave through a 2 kHz low-pass had 1194 samples wrap round to the other sign, and two int16 clips at 30000 crossfaded into a seam that was negative for 280 of its 320 samples, down to -32732. They round and clip within the type now, asgainandnormalizealready did.time_stretchandpitch_shiftrefused integer audio outright and take it the same way. add_noise_snrsquared int16 audio in int16, which wraps round, so a quarter-scale tone mixed at 10 dB came out at 55.9 dB, and cast the mixture back by truncating. It measures and mixes in float and returns the input's type, rounded and clipped.reverbtook the peak of int16 audio withabs, which leaves -32768 negative, so a clip with one sample pinned there came back at a peak of 20000 rather than 32768, andAudioDataManager.play_audio(volume=2)played an int16 20000 as -25536.
Fixed — corrupt, silent and short input¶
- A clip with NaN in it was still misread in places.
trim_silencecut it to nothing under librosa 0.11,vadleft the frames holding the bad samples out and judged the rest,compare_enhancersreturned a table in which every run had failed, andf0andspectral_enhancestopped on librosa's own error, which names neither the function nor the fix. Each refuses it withFeatureErrornow, and the command line reports it on one line with exit 2, wherekudio pitchprinted a traceback andloudness,vadandcompareexited 0.audio_reportstops warning about the samples it already names as its first finding. trad_enhancereturned NaN for a clip that opens with digital silence (768 samples of it here) or is silent throughout, and silence for a clip shorter than one 512-sample frame. The power has a floor under it, a short clip is refused, and stereo is refused by name rather than by numpy.wavelet_low_pass_filterreturned NaN for silence and one sample too many for an odd length.Synthesizerfailed without saying which file. An empty noise file divided by zero, and a file with NaN in it stopped on librosa's error; both are aSynthesisErrornaming the file.target_sr=0is refused before the output folder is emptied, not after.mfccandSTFT.analyseread a stereo array as thousands of two-sample signals and came back the wrong shape, andmelspectrogramon one failed with a numpy broadcast error; all three refuse it by name.denoise_wav2specdivided by the peak of a silent clip and stopped on librosa's error.
Fixed — elsewhere¶
check_pathglobbed for numbered siblings, soruns/exp[1]missed its ownruns/exp[1]2and handed that folder back as free.vad_frames(center=True)counted1 + n // hopframes, one more than the spectrogram has for an oddn_fft.- TPDF dither in
bit_depthused2 / 2**bitsas its step, half the grid's step at 2 bits; it is two of the grid's own steps wide at every depth. AudioDataManager.save_allsplit a mono clip into<name>_ch0.wavbeside its source when the manager was set to two channels; the clip's own channel count decides now.AudioBuffer.get_fixed_size_datalet a second capture take over the first one's state, and the second then failed with aTypeError; it raisesRuntimeErrorsaying a capture is running.LocalStreamReader.stream_start()andplay_audio()without a device raiseDeviceErrorrather than anAttributeError, andStreamRecorder.start()refuses to continue a take when the device reopens at another rate or channel count.load_manifestnames the row that lacksnoisyorclean(aValueError, where the dataclass raised aTypeError).LoadAudiocollects files the waycheck_inputdoes,.WAVincluded;data2xlsx(fit_column=True)sized the columns only when the workbook already existed;pesq_score_exeruns the binary with an argument list rather than a shell line, so a path with a space in it runs, and a binary that is not there is named in aFileNotFoundErrorrather than surfacing as anIndexError; the*_scoremethods say to calleval()first;compare_enhancersscores STOI even when PESQ finds no utterance.
Added¶
kudio.PA_FLOAT32andkudio.PA_INT16, thewav_formatcodesLocalStreamReadertakes, which onlykudio.core.streamhad.window=onfile_to_spectrogramandlogspec_from_files, which were Hamming only. It comes after their other arguments, so a call that passes those by position is unchanged.
Documentation¶
- The package description, the README's opening line and the site's English description say what the site's home page says: a toolkit for audio, speech most of all.
syn_extra_modeputsdBon both signs of an SNR (n5dB), wheresyn()writesn5. Both stay, since corpora on disk use them; its docstring and the synthesis guide say so and point at the manifest'ssnr_db.- The inspection guide still said DNSMOS averages the windows' raw outputs and maps the average onto the MOS scale. Each window has been mapped first since 3.8.0, as the code and the 3.8.0 entry below say.
[3.8.0] - 2026-10-15¶
A documentation site, and the fixes that writing it turned up. Every example
on the site was run against the library, and where the output and the
docstring disagreed, the library was usually the one at fault. A minor version
rather than a patch, because several of the fixes change numbers that existing
code may have stored: the quantile estimator, dither, DNSMOS, the report's
noise floor and RT60 on a recorded response all read differently now, and
spec_augment reads its input the other way round. Those are listed first.
Changed — results that move¶
spec_augmentreads(frames, bins), the layout of every spectrogram kudio makes. It read(freq, time), so on kudio's own spectrograms the "frequency" masks blanked time and the "time" masks blanked frequency. The defaultmask_value=Nonefills with the spectrogram's mean, as SpecAugment does: the old fill of 0 is a power of 1 in log10 power, louder than most of the cells it was meant to hide.- The
quantilenoise estimate sits at the noise, not 7.9 dB under it. Steady noise power in a bin is exponentially distributed, so its 15th percentile is 0.16 of its mean. The estimate is divided by-ln(1 - q)now, which takesquantilefrom the weakest of the four estimators on the test clips (2.8 dB of SI-SDR gained, averaged over the gain rules) to the best (7.8 to 8.4). On the CLI page's example,logmmseoverquantilegoes from 8.69 to 14.95 dB. bit_depth(dither=True)adds TPDF dither two steps wide, peak to peak. One step loosened the tie between the error and the signal without breaking it: a sine a quarter of a step high came back at 0.42 of its level on average, and comes back whole now. It costs 4.7 dB of SNR at 8 bits where one step cost 1.8, close to the 4.8 dB that tripling the noise power predicts.audio_reporttakes the DC offset out before reading the noise floor. An offset of 0.03 read as a floor of -30.5 dBFS and an SNR of 6 dB on a take whose real floor was -57.5 and SNR 31. When a tenth of the frames are exact zeros the floor is digital silence,-inf, with an SNR ofinf: pauses of zeros were left out of the percentiles, so a perfectly clean clip was reported at 0.7 dB.rt60measures from the onset, the first sample within 20 dB of the peak. A recorded response starts with the time the sound took to arrive, and 5 ms of it in front of a 0.6 s room dropped EDT from 0.55 to 0.25 s and failedreliable(). A response whose direct sound alone falls 10 dB has no EDT;reliable()judges it on its fit instead of failing it, and the summary says why EDT is missing.- DNSMOS maps each window through the polynomial before averaging, the
order DNS-Challenge's own script uses; with a quadratic the two orders
differ. Only
sig_bak_ovr.onnxis looked for (model_v8.onnxis the P.808 model, one score rather than three), and the personalised model inpDNSMOS/comes last in a search and warns when used. STFT.n_framesis right for an oddn_fft.n // hop + 1promised 126 frames for 32000 samples atn_fft=511andforwardgave 125.lowpassat or above Nyquist hands the clip back as a copy rather than raising, since there is nothing to remove. That includes the 8 kHz default at 16 kHz and below.
Changed — interfaces¶
- The command line reports an error on one line and exits 2. A file that
cannot be read or an argument the library refuses printed a traceback and
exited 1, the same code as a finding, so
kudio report missing.wav || echo ...could not tell the two apart.-vshows the traceback. Refusals and warnings go to standard error, warnings as one line each, and-vraises kudio's own logging only, not every library's. - Problems with the audio are warnings, not log lines. NaN or inf in a
loaded file,
normalize_lufspushing a peak past full scale, unknown keys in a manifest andsyn(overwrite=False)finding the folder full were logged, and thekudiologger has aNullHandler, so nobody who had not set up logging heard about them. Bookkeeping (a name taken, a folder emptied) stays in the log. AudioInfo.peakisNonewhen it was not measured (peak=False); it was 0.0, the peak of silence.- A missing PyAudio raises
DependencyError, like every other extra, rather than a plainImportErrorthatexcept kudio.KudioErrorlet through. It is still anImportError. sounddevice installed on a system without the PortAudio library raisesDeviceError, which names the system package, since pip cannot fix it. STFT.from_dictwants every key. A stored geometry withouthop_lengthanalysed at 256 without a word.f0'sframe_lengthdefaults toNone: 2048 samples, or the next power of two that holds more than two periods offmin, 4096 at 88.2 and 96 kHz, where the old default was refused. The check now asks for what librosa needs,2 * floor(sr / fmin) + 2samples; at exactly two periods the lowest pitches were lost and the rest looked fine.file_load(sr=0)raisesValueError, a mistake in the call, rather than anAudioIOErrorabout the file. ASynthesizerinput that does not exist raisesFileNotFoundErrorrather thanNotADirectoryError.melspectrogramreturns float32, and with a path andsr=resamples the file;srwas ignored for a path.Standardizer.savereturns the path it wrote,.npzincluded, andload("stats")findsstats.npz.copy_wavesreturns where each file went, and renames a collision. Two talkers'a.wavcopied into one folder left one file.AudioEvaluate.get_df()is an empty DataFrame before anything is scored, not an empty list.- Manifests are written with forward slashes, so one made on Windows names
real files on Linux and macOS, and
manifest()returns them that way. The noise-only files ofis_silence=Trueare left out of it: the clean file they were drawn for is not their reference. They are still in whatsyn()returns.
Fixed — channels last¶
kudio's multi-channel layout is (frames, channels), and numpy, scipy and
librosa read the last axis as time. Every place that handed such an array on
without saying which axis was time worked along the channels.
resample, andsave_wave(d_sample=)with it: a 1 s stereo clip came back(16000, 1), or as a 2 s mono file.highpass,lowpass,bandpassandbandstopfiltered across the channels withzero_phase=False, and with the default failed on scipy's padding of a two-sample "signal";time_stretchandpitch_shiftworked across them too, and a stereo second came back fromtime_stretchas one channel;trim_silencecut nothing, soconvert_folder(mono=False, trim_db=...)trimmed no stereo file; andreverbfailed on a stereo array.rt60,schroeder_curveandapply_rirread a stereo array as one interleaved signal: a two-column 0.6 s response measured 1.20 s with a fit of 1.000.f0read a stereo clip at half its pitch, 65 Hz for a voice at 126, andStreamEnhancer.processdenoised two channels as one. These take mono and say so now, as do the metrics, which scored both channels as one signal or failed with a numpy shape error depending on the metric, and the spectrogram functions, which took each frame for a two-sample signal.play_audiothrough PyAudio opened a stereo array as one channel and played it at half speed; a path is now read with soundfile, so FLAC and FLOAT WAV play too, and a 32-bit integer WAV no longer plays as float noise.Recorder.save(resample=)resampled the interleaved channels as one signal and stretched the take torecord_times, which it never quite is, moving every pitch.- The command line's
loudnessandnormalizemixed a stereo file to mono first. BS.1770 weights and sums the channels, so the mono mix of a file with matching channels read 3 LU low, andnormalizewrote mono.
Fixed — numbers that looked fine and were not¶
- A corrupt clip measured as silence. One NaN runs through the K-weighting
filter into every block after it, the gate dropped them all, and the clip
read
-infLUFS.loudness,true_peakandloudness_rangereturnnanwith a warning, a curve says how many windows it could not measure, andnormalize_lufsandmatch_loudnessrefuse the clip instead of handing it back untouched. - STOI's placeholder was averaged in as a score. pystoi returns 1e-5 for a
clip with under about 0.4 s of speech, even for a perfect copy. kudio scores
that
nanwith a warning,AudioEvaluateleaves it out of the mean, andeval_metricsreturnsNonefor it, as for any metric that cannot run. pesqcalled on its own skipped the length check every other metric has; and one fileAudioEvaluatecould not read or align ended the whole run, throwing away every score before it. That file getsnanand a warning now, and the list goes on.compare_enhancersscored any overlap of the reference, and took a keyword no method has (alpah=0.5) by running every method on its defaults. It applies the length check, and refuses the keyword.- A gain-rule parameter outside its range ran anyway.
gain_floor_db=10clipped every gain to 1 and returned the input as its own denoised version. The ranges inMETHODSare enforced byspectral_enhance,enhance_folderandStreamEnhancer. An unknownnoise_kwargskey is named by the estimator's public name, not a private class's. trad_enhancedropped the last overlap, leaving its final 256 samples at zero, and returned a different length from its input; it returns float32 of the input's length.wavelet_low_pass_filtertook its threshold from the signed maximum, so a clip below zero throughout got a negative threshold, which soft thresholding turns into a boost: its detail came back amplified rather than removed.STFT.inverseandsave_spectrogram_as_waverepeated one value over the lastlen(y) % hopsamples instead of rebuilding them, and accepted a spectrogram of the wrong shape.- Integer audio wrapped round.
gainturned an int16 20000 at +6 dB into -25631,normalizereturned int16 as zeros, andresampletruncated integers toward zero. They round and clip within the type now.save_wavedivided every integer type by 32768, writing half-scale int32 clipped and uint8 as near-silence; each type is scaled by its own full scale, andDOUBLEkeeps float64. PCM_24andPCM_U8came out a third and half a step low on average, because onlyPCM_16was rounded before libsndfile's flooring conversion. Every PCM width is rounded now.add_noise_snrwith constant noise returned the clean signal at any SNR, since the mean is removed before scaling. It raises, asSynthesizerdoes, and takes mono only.- A silent clean file made a silent mixture. The noise is scaled against
the speech, so
Synthesizerwrote a file of zeros labelled with an SNR. It raisesSynthesisErrornaming the file. - The
SpectrogramStreammel axis put its bands at the wrong frequencies:n_melspoints spread fromfmintofmax, where the filters are centred on the inner points ofn_mels + 2. Its docstring also promised dBFS, where a mel axis gives band power.
Fixed — files, folders and names¶
check_inputmatched.wavcase-sensitively on Linux and macOS, so a recorder'sZOOM0001.WAVwas found on Windows only, and named directly it was refused everywhere..txtlists are read as UTF-8, with or without a BOM, rather than in the system code page, where a list of Chinese names raisedUnicodeDecodeError.convert_folderandenhance_folderdropped an unusable entry without a word, so a list with a typo in it reported "1/1 written". It is a failure in the result now.enhance_foldermirrors a list from the folder its files share, asconvert_folderdoes, where it wrote them flat and renamed collisions.convert_folderleaves adstinsidesrcout of the input, rather than converting the last run's output again one folder deeper.kudio enhance --recursivefailed before reading a file in 3.7.3:enhance_folderdid not taken_fft. It takesn_fft,hop_length,windowandnoise_kwargslikespectral_enhance.check_file("newdir/")returnednewdir.wav. A trailing separator means a folder, whether or not it exists yet.Synthesizercopied two talkers' clean files to one name underRAW_DATA/; the copies mirror the layout like the mixtures.syn_extra_mode()resampled its output to the rate of the lastsyn()on the same object, takesis_pool=, and warns when clean files share a name. A requestedis_silenceshare rounds to at least one file, andrdn_choice_numabove the corpus size warns.load_labelsread Audacity's frequency rows as labels, the\lines that follow a label with a frequency range.channel_speccheckedburstandpacket_msonly on the first clip, afterSynthesizerhad emptied its output folder. They are checked when the link is described.channel_tagnamed the A-law telephone bandbanda_law; it istelephone_a, the preset's name.
Fixed — live audio¶
- A timed-out
FetchBuffercapture kept waiting on the buffer and took the next capture's frames.get_fixed_size_data(timeout=)ends the capture with the timeout, and a frame count of 2.5 or'3'is refused rather than read as 2 or 3. StreamRecorder.start()afterstop()failed on the closed PortAudio handle; it opens the stream again and carries on the take.RemoteStreamReaderdropped chunks that arrived split, because each further read asked for a whole chunk and ran into the next; a chunk of silence, all zero bytes, was taken for a text reply;start()before connecting spun a core forever; and a retry left the previous PortAudio instance running.CheckDeviceraised while describing a device it could not describe, from inside the message meant to explain the failure.
Fixed — the command line¶
kudio synthprints the seed it used, so a run without--seedcan be rebuilt, and--snrtakes fractions.--channel-lossand--channel-burstwithout--channelare refused rather than ignored.kudio convertapplied--lufsand--trim-dbto folders only, and ignored them on a single file without a message.kudio enhance --rateresampled in folder mode only.kudio compare --write-to --noisesnamed each file after the method, so every estimator overwrote the last; the estimator is in the name when several run.kudio pitchtakes the same automatic frame length asf0.kudio align --metricson two files too different in length to score stopped with a traceback after printing the delay; it says so and still prints the aligned score.kudio room --make FILEwith a file as well is refused rather than ignoring the file, andkudio normalizerefuses digital silence and a--peakoutside (0, 1].
Added¶
- A documentation site, at https://recklight.github.io/Kudio/: getting
started, ten guide pages from reading files to recording, the command line,
migrating from 2.x, and the API reference generated from the docstrings.
Every figure is drawn by a script in
tools/docs/, and CI builds the site with--stricton every push and publishes it frommaster. - The
[docs]extra installs what the site needs.
Documentation¶
- README: the error and logging sections say what is raised, warned and logged; the enhancement caveats and the spreads between gain rules and estimators are re-measured on a named clip; the PESQ note no longer claims one scale for "most papers"; the migration table lists the helpers that moved out of the top level.
[3.7.3] - 2026-10-14¶
Documentation and packaging metadata only; nothing in the library behaves differently.
- The README no longer says the Python badge is waiting for v3.0. 3.0.0 has been on PyPI since July.
[3.7.2] - 2026-10-11¶
Fixed — files destroyed in the modes meant to protect them¶
convert_folder(..., overwrite=False)could destroy a file it was never asked to touch.check_filechose its anti-collision name withPath.with_suffix, which cannot tell an extension from a dotted name, sotake.v2.wavwas renamed totake.wav-- over the top of a different recording -- and the log saidRenamed to: take.wavas though a collision had been avoided. This is the defect 3.7.0 fixed insideSynthesizer; three callers were still on the old pattern (convert_folder,enhance_folder,Recorder.save). A suffix is now replaced only when it is a real audio container, and every candidate name is checked before it is handed back.AudioDataManager.save_all()wrote two clips to one file and returnedTrue. Clips namedtakeandtake.v2both landed ontake.wav, and in the multi-channel branch so didtake.v2_ch0andtake.v2_ch1. Dotted names keep their own files now, and two clips that would still share one are refused before anything is written, naming both.
Fixed — numbers that looked fine and were not¶
split_pairsput the same example in more than one split. A manifest with a repeated row -- whichsnr_ratio=[0, 0.0]produces -- put 5 of 12 examples into both train and validation, so part of every score was measured on training data, and nothing said so. Exact repeats are now dropped with a warning that counts them; rows that list one mixture with conflicting fields are refused, because nothing can tell which is true.- The reference metrics trimmed any length mismatch without bound. 100
samples against 16000 scored
snr 119.70 dB-- a bit-perfect copy -- with 99.4 % of the reference never compared. Any mismatch now warns with both counts, and pastMAX_LENGTH_MISMATCH(0.5) it is refused, the way a sample-rate mismatch already was. 20 % short is still scored: a frame or two of framing slop is not a different recording. - A clip with NaN in it was reported as digital silence.
audio_reportread ten NaN samples in an audible tone as a muted microphone, and the CLI exited 0.problems()now names non-finite samples first and on their own,AudioReport.non_finite_samplescarries the count,save_waverefuses to write such a waveform in any subtype, andfile_loadlogs a warning naming the file. Loading is still allowed, because repairing a corpus means reading it -- and FLOAT corpora written by 3.7.0 and earlier can hold these. - A manifest could name the wrong clean file. When two different clean
files collapsed onto one output name, the manifest gave both as the
reference for the single mixture on disk, so training read a mislabeled
pair. The clean file that lost is now left out of the run and out of
manifest(), with a warning naming it. A repeated SNR is unaffected: there both rows describe the same request, and the file is right for both.
Fixed — a hang, and a seed lost¶
AudioBufferblocked forever on a frame count of zero or less, whichint(seconds * sr / chunk)produces for any request shorter than a chunk. No traceback, nothing to interrupt. It is refused now.FetchBufferon a timeout raisesTimeoutErrorrather than returningNone, which a caller could not tell from an empty capture.- An aborted
syn()orsyn_extra_mode()threw away the seed of the corpus on disk. The seed was drawn before the check that refuses to clear the output folder, so a refused run still overwrote.seed, and the one value that rebuilt the existing corpus was gone. It is drawn after the check now.
Fixed — packaging and documentation¶
- The setuptools build floor was below what
pyproject.tomlneeds. The licence is declared in the PEP 639 form, which setuptools reads only from 77.0.0; 68.2.2 and 76.1.0 both stop withconfiguration error: project.license must be valid exactly by one definition. Now>=77. numpy<2.5is nownumpy<2.6. The cap follows numba, and numba 0.67.0 moved to<2.6on 2026-08-11. numpy 2.5 needs Python 3.12, so the old cap bit only there -- as a resolution failure, or a quiet downgrade to 2.4.- The Changelog link on PyPI was a 404. It pointed at
main; the branch ismaster. kudio.util.visualraised a bareModuleNotFoundErrorinstead of theDependencyErrorthat nameskudio[viz].mkdocs buildfailed outright, with or without--strict. A docstring line inkudio.core.vadread as a cross-reference. It arrived with 3.7.1's expansion of the API Reference to 23 modules.- The sdist carried 28 of the 29 test files and left out
conftest.py, which six of the others import, so the shipped suite could not be collected.MANIFEST.ingraftstests/whole.
Added¶
kudio[check](pyloudnorm), so the BS.1770 loudness can be checked against a reference implementation -- a test no environment could run before.kudio[docs], the toolchainmkdocs.ymlasks for.- CI: a leg that installs the optional metric back-ends, so the
mir_evalandpystoicall sites run at all; and a release job that checks the built files withtwine check --strict, imports every module of the installed wheel from outside the source tree, and refuses a tag that does not matchkudio/_version.py.
[3.7.1] - 2026-10-07¶
Fixed — three ways 3.7.0 could still write a corpus nobody could use¶
Two of these are the same shape as the bugs 3.7.0 fixed: a mixture written with no speech in it, and a name that silently overwrites another.
-
A noise file with no variation produced an all-zero mixture. Scaling the noise to the requested SNR divides by its standard deviation, so digital silence or a constant level made every sample NaN, and int16 wrote NaN as zero. The mixture came out with no speech in it and nothing said — exactly the damage
is_silencewas doing before 3.7.0, reached a different way. One truncated file in a noise corpus was enough. Now aSynthesisErrorthat names the file and says what is wrong with it, because there is no gain that puts a constant at any SNR; a mixture that comes out non-finite is refused the same way. -
Noise-only file names joined two stems with nothing between them.
f"{clean.stem}{noise.stem}_n00.wav"madeab+canda+bcone file, and the de-duplication then kept whichever the RNG drew first. It was also the only output in the class that did not follow theclean_noise_snrshape a reader can parse. The separator is back, and two different pairs landing on one name now warn instead of one of them vanishing — the collapse of one pair's two SNRs, which is intended, is unchanged. -
syn()still returned numpy scalars for numpy input. 3.7.0's changelog said it did not, and the manifest path was indeed fixed, but the four-tuples themselves carriednp.int64wheneversnr_ratiocame fromnp.arange, sojson.dumpson them still raised. Normalised at the source, so every input form — list,arange,linspace, fractional — comes back as a plainintorfloat.
Fixed — the PESQ scale could not be reached from anything that reports it¶
3.7.0 added mode=/scale= to kudio.pesq because enhancement papers mostly
quote the raw narrowband P.862 score and kudio reported wideband MOS-LQO. But
eval_metrics did not pass them on and compare_enhancers went through
eval_metrics, so every table a user actually reads was stuck on the other
scale with no way to ask. _compute already took both arguments; nothing
carried them.
eval_metrics(sr, ref, deg, pesq_mode=, pesq_scale=)andcompare_enhancers(..., pesq_mode=, pesq_scale=), keyword-only with the existing defaults, so no caller's numbers move.
Fixed — packaging and documentation¶
[eval]pinsmir_eval<0.9.sdrcallsmir_eval.separation.bss_eval_sources, deprecated in 0.8 and removed in 0.9, while the floor allowed anything from 0.7 up. The bound is load-bearing, not caution.mkdocs.ymldeclareddocstring_style: googleand not one docstring in the library is Google-style — they all use:param x:. Every parameter block rendered in the API Reference as prose instead of a table. Nowsphinx.- The API Reference was missing most of the library. It listed nine modules;
room,align,loudness,pitch,stft,spectrogram,vad,channel,edit,filters,dataset,report,dnsmosandenhancewere absent, sovad_framesand everything else added since 3.3 was undocumented. 23 modules now. Synthesizer.clippedand.seedwere listed as new public API in 3.7.0 and appeared in no document; both are in the README's module table now.docs/V3_CHANGES.mdwas the last place still describing[eval]as containing pysepm.
Fixed — the test suite was carrying the bug it had just fixed¶
tests/conftest.py'swrite_wavstill used(wave * iinfo(int16).max).astype(int16)— the exact wrap-around this release fixed inSynthesizer. The shared noise fixture peaks at 1.207, so twelve of its samples came back with the sign flipped and every test mixing against it was mixing against clicks it never asked for. It writes throughsave_wavenow.- That fixture's clean files are 12 dB quieter. A mixture at its lowest SNR
peaked at 2.68, so twelve unrelated tests emitted the new clipping warning. A
warning that fires on a dozen of your own tests is one nobody reads; the
deliberate case has its own
loud_corpusfixture and still warns. Down from 14 warnings to 1.
[3.7.0] - 2026-10-06¶
A speech-enhancement project ran kudio's synthesis and evaluation end to end on
TIMIT and wrote down everything that went wrong. Nearly all of it was silent:
files that wrapped around instead of clipping, a reg corpus that met a
seventh of its noise x SNR combinations, a seed the process pool ignored, and a
PESQ that was on the wrong scale where it ran and NaN where it did not. This
release fixes those, and two worse problems found on the way: is_silence=True
wrote a dataset without a word of speech in it, and overwrite=True could
delete the corpus it was reading from.
Fixed — synthesis¶
-
is_silence=Truewrote a dataset with no speech in it. The flag was meant to add a 2% share of noise-only files, but it was handed to every file, so every mixture was written as noise alone.syn_extra_mode, which always sets it, has never produced a mixture with speech in it. Only the share is noise-only now, and it is drawn without replacement. -
A mixture past full scale wrapped around.
(y * 32767).astype(int16)turns 1.2 into -0.8 without a word, so a loud talker at a low SNR came out with clicks of the opposite sign. Files are now written throughsave_wave, which clips at full scale and rounds to the nearest step;synwarns with the count and the worst peak, andSynthesizer.clippedlists the files.Synthesizer(subtype=...)writes'PCM_24'or'FLOAT'instead, and'FLOAT'keeps what is past 1.0. -
regmode met onlylcm(noises, SNRs)of the combinations. The noise and SNR lists were cycled side by side, so 14 noises against 21 SNRs paired each noise with just 3 SNRs. The SNR now steps on by one at the end of every such cycle, so anynoises x SNRsconsecutive outputs hold every combination exactly once. When the two counts share no factor nothing changes: the assignment is exactly the cycle it was. -
seed=did not survive the process pool. The noise crop came from each worker's own global RNG, so a seeded run withis_pool=Truediffered from itself (77 of 210 files on a test corpus) and from a serial run. Each file now draws its crop, its room and its packet loss from the seed and its own path below the output folder -- the path rather than the name, because a mirrored tree repeats names and every TIMIT talker readssa1.syn()also no longer callsnp.random.seed(), which reset the caller's global RNG. -
overwrite=Truecould delete the corpus.syn()emptied the output folder withrmtree, assyn_extra_mode()always did, so anout_path=one level too high took the clean and noise files with it. Both now refuse, withSynthesisError, to empty a folder that holds an input file, the working directory or the home directory. An unknownmode=is refused before anything is deleted rather than after. -
Synthesizertakes what its docstring always said it took. The constructor demanded two folders, though the docstring promised anythingcheck_inputaccepts, so a subset -- TIMIT's SI and SX without SA -- had to be copied out first. A list or a.txtmanifest now works, mirrored below the folder its files share or belowroot=; an entry that is not an existing.wavis reported instead of skipped in silence. -
Fractional SNRs and dotted names.
with_suffixread.5dBas a file suffix, so 2.5 dB and 2.7 dB both became..._2.wavand one overwrote the other, and every output oftake.v2.wavwas calledtake.wav. A decimal point is writtenp(2p5dB,n2p5) and names are joined, not suffixed. Integer SNRs keep their names exactly. The manifest records 2.5 rather thanint(2.5), and output names that still collide -- a tree flattened withmkdir_parents=False-- get a warning. -
rdn_choice_numsamples without replacement; a repeat was written twice under one name.syn_extra_modekeeps its scratch files in a temporary directory rather than in./tmp_mixed, which it deleted on the way in.
Fixed — evaluation¶
-
PESQ depended on
pysepm, which is not on PyPI, and at 16 kHz failed on NumPy 2. pysepm returns(np.NaN, MOS-LQO)at 16 kHz, andnp.NaNis gone from NumPy 2, so every 16 kHz score raised;AudioEvaluatelogged each one and averaged them into NaN. PESQ now runs on thepesqpackage from PyPI, installed by the new extrakudio[pesq]. PyPI carries it as source only, so it needs a C compiler -- the MSVC Build Tools on Windows -- and it is left out of[eval]and[all]so that those still install anywhere. -
And it was not the PESQ most papers report. kudio reported P.862.2 wideband MOS-LQO at 16 kHz. Enhancement papers that say "PESQ" mostly mean the raw narrowband P.862 score, a different scale: 2.03 raw is about 1.65 MOS-LQO.
kudio.pesq(ref, deg, sr, mode=, scale=)takes the bandwidth ('nb','wb','auto') and the scale ('lqo', or'raw'by inverting P.862.1). The default is still what kudio reported, andAudioEvaluate(pesq_mode=, pesq_scale=)passes both through. -
AudioEvaluateread files withscipy.io.wavfile: int16 values, WAV only, and TIMIT's own NIST SPHERE refused. It reads withfile_loadnow -- float, mono, anything soundfile opens. Each file is read once per method instead of once per metric, the noisy baseline is scored once instead of once per method, lists that do not pair up are refused, andget_df()no longer adds another 'Baseline' row each time it is called. -
eval_metricslogged its install hints on every call, which per-file scoring turned into a wall of warnings. A missing back-end is reported once per process. A metric that cannot run on a pair -- PESQ off 8 and 16 kHz, or on silence -- comes backNoneand leaves the others standing, where one failure used to take every score with it.
Fixed — features and files¶
- Spectrograms of float32 audio came back float64 under NumPy 2. The
epsilon added to the spectrum was a numpy float64 scalar, and NumPy 2's
promotion rules let it widen the whole array: twice the memory for every
feature matrix. Back to float32. The values are unchanged, including the
-31.3 a digitally silent bin is floored at, and
norm=Truestill computes its statistics in float64. save_spectrogram_as_wavewrapped around past full scale, the same way the Synthesizer did. It writes throughsave_wavenow.save_waveclips and rounds fixed-point output itself. libsndfile's conversion floors, leaving every 16-bit sample half a step low, and wraps around unless clipping is switched on. int16 input is scaled by 1/32768, the step soundfile reads it back with, so a 16-bit array is written bit for bit (it was 1/32767).convert_folderflattened a list. Only a folder input was mirrored, so two talkers'sx133.wavcollided and one was renamedsx1332.wav, which reads like another sentence. A list or manifest is mirrored below the folder its files share, or below the newroot=.
Added¶
score(ref, deg, sr, metrics=None)returns{name: value}for any ofsnr,si_sdr,segsnr,sdi,pesq,stoi,estoi,sdr. Withmetrics=Noneit runs everything that can run here; a metric asked for by name that cannot -- not installed, or the wrong rate -- raises instead of quietly going missing.AudioEvaluate.per_file()-- every file's scores, one row per method and file, with the clean, noisy and scored paths (rowsholds the same as plain dicts). Means cannot be broken down by noise, SNR or talker; this can, by joining onnoisywith asave_manifestmanifest.sdi(ref, deg)-- the speech distortion index,sum((x - x_hat)**2) / sum(x**2):snron a linear scale, under the name the enhancement literature reports it by.ContextFrames(utterances, context=5)--stack_contextover a whole corpus, a batch at a time. Stacking ±5 frames up front makes a training set eleven times the size of its features (about 8 GB in the project that asked); this keeps the features once, padded at each utterance's own edges, andbank[batch]is row for rownp.vstack([stack_context(...)])[batch].vad_frames(y, sr, hop_length=256)--vadas one boolean per STFT frame, lined up with the rows ofwaveform_to_spectrogramandSTFT. Padding defaults to 0 here: it keeps a cut from clipping a consonant, but on a frame label it only marks the frames next to the speech as speech too.Synthesizer(root=, subtype=),Synthesizer.clipped,Synthesizer.seed(the base the last run drew from, so a run given no seed can be rebuilt),syn_extra_mode(seed=),convert_folder(root=).
Changed¶
- A dataset rebuilt from the same seed is not bit-identical to 3.6.0's.
Crops are drawn per file, the clean-file sample and the silence share come
from
default_rng(seed), 16-bit samples are rounded instead of truncated, andregpairings move where the counts share a factor. Rooms and packet loss of files directly in the output folder keep their 3.6.0 seeds; in a mirrored tree they are now seeded from the path, not the name. seed=Noneis now random for everything. In 3.6.0 it fixed the rooms and packet loss (as ifseed=0) while the crops varied. Now one base is drawn from numpy's global RNG -- sonp.random.seed()still reproduces a run -- and kept inSynthesizer.seed.syn()returns each SNR as it was given rather than as a numpy scalar, whichjsoncould not serialise;Pair.snr_dbis annotatedOptional[float].- The manifest names a link only on files that went down it, so the output of
syn_extra_mode, which applies none, no longer claims one. - In
AudioEvaluatea metric failing on one file gives NaN for that file with a warning; only PESQ did that before, and a failing STOI or SDR stopped the evaluation.
Not changed¶
n5beside5dBin output names: the asymmetry is old, but scripts and folders already depend on those names, and the manifest records each SNR exactly. Only the names that were broken -- fractional SNRs, dotted stems -- changed.- The -31.3 floor of a digitally silent bin. Every model trained on kudio's
log spectrogram has seen it, so changing it would move their inputs; clamp
it where it matters, e.g.
np.maximum(spec, -10).
Migration¶
# the PESQ enhancement papers quote: raw narrowband P.862
ev = kudio.AudioEvaluate(clean, noisy, pesq_mode="nb", pesq_scale="raw")
# a subset without copying it out, written without clipping
syx = kudio.Synthesizer(si_sx_files, "noise/", out_path="mixed",
root="TIMIT/TRAIN", subtype="FLOAT")
[3.6.0] - 2026-09-18¶
The chain between a talker and a file, end to end. 3.5 could describe a room and 3.5's late work could describe a link; neither could put a corpus through both, and packet loss was still scattered evenly in a way no real link loses it.
Added — burst loss¶
dropouts lost packets independently. Real links lose them in runs — a fade,
a queue overflowing, a handover — and a run is far harder to conceal than the
same number of packets scattered about, because there is no recent speech
left to repeat.
-
dropouts(y, sr, loss=0.05, burst=6)— one parameter, the mean run length in packets. The run lengths are geometric, which is exactly what Gilbert's two-state chain gives; what is not taken from Gilbert is its marginal, because that chain fixes the loss rate and then delivers 4.1% or 6.3% on any given file. The count stays exact, as it has been sincedropoutsexisted, because the number on a corpus label should be true of the corpus. Runs are kept at least one surviving packet apart, without which two runs could abut and the mean would drift above the one asked for. -
independent_burst(loss)— because the obvious guess about the default is wrong. Independent draws land side by side sometimes, so they already average1 / (1 - loss): 1.05 packets at 5%, 1.25 at 20%.burst=1is therefore a request for something less clustered than leaving it alone, not the same thing, which is why independent loss is spelledburst=None. -
A regime that cannot be built is refused rather than fudged: 80% of packets lost in runs of one needs four survivors for every loss and there are not four. Silently merging the runs would mean the burst length asked for was not the burst length delivered.
-
kudio channel --burst 6, and the CLI now reports the runs it made:20 packet(s) lost in 5 run(s) of 4.0.
Added — the link as one thing¶
Five functions in the order a link applies them, so a corpus builder, the command line and a GUI describe a link the same way instead of each inventing an argument order.
-
apply_channel(y, sr, spec, seed=)— band and rate, then the codec, then the converter, then the wire. The loss goes last because packets are lost from the encoded signal; running it first would let the resampler smear every hole into its neighbours. -
channel_spec(spec)normalises a preset name, a dict, or a preset with overrides ({'preset': 'voip', 'loss': 0.1}) into one dictionary, and returns{}— falsy — for "no link", so one check downstream means "is there a channel at all" instead of five that have to agree.CHANNEL_PRESETS:telephone,telephone_a,voip,mu_law,a_law,none. -
channel_tag(spec)writes it short and stable for a manifest or a label:telephone,mu_law+loss5%x4,8bit.
Added — the whole chain in Synthesizer¶
talker → room (rt60=) → + noise (snr_ratio=) → link (channel=) → file
Synthesizer(..., channel='telephone')runs the finished mixture down a link. The mixture, not the speech: a wire carries whatever reached the microphone, noise included, which is why it comes last. Packet loss is seeded per output file, so no two files lose the same packets and a rerun loses the same ones again.
The link is deliberately not an axis. snr_ratio and rt60 are swept
and multiply the output count; channel is constant for the run and does
not appear in the file names. A corpus is recorded over a phone line or it
is not — that is a property of the corpus rather than a dimension to cross
with the others, and four axes already multiply enough. To compare two
links, run the Synthesizer twice into two directories.
Synthesizer(..., write_targets=True)writes the reverberant-but-clean signal toOUT/TARGETS/, named to match its mixture. The clean file is still the dry target, so the default asks a model to undo the room as well as the noise; this is the same speech with the room left on, for training that should remove the noise and leave the room. The link does not reach it: asking a model to reproduce G.711 is not denoising.
Passing it without rt60= warns instead of writing, since with no room the
clean file already is that signal.
-
Pairgainedchannelandtarget, both optional and last, so an older manifest still loads and a newer one still loads in an older kudio.channelis the tag string rather than the spec dict it came from:Pairis frozen so thatsplit_pairscan check for overlap with a set, and a dict field would quietly have made it unhashable again. -
kudio synth --channel telephone --channel-loss 0.05 --channel-burst 6 --targets.
Added — the channel: what the transmission did to it¶
Speech arrives degraded in three ways and kudio could describe two. Noise is
additive, a room is convolutive, and the channel is neither: a codec
quantises, a link drops packets, a converter throws bits away. None of that
can be written as y + n or y * h, which is why none of it was here.
mu_law(y)/a_law(y)— the G.711 companding curves, quantised to 8 bits and back. These are the curves, not the standard's piecewise-linear segment layout: measured against Python's ownaudioop(the reference, and removed in 3.13, which is why it cannot be a dependency) the round-trip SNR agrees to 0.5 dB and the waveforms to 0.013 full scale.
The property that makes companding companding is asserted, not assumed. Logarithmic steps follow the signal down, so on this repo's fixture mu-law holds 37 dB of SNR across a 24 dB range of levels while linear 8-bit loses the textbook 6 dB per halving — 38.1 dB down to 15.1 dB. A wrong implementation would not have that.
-
bit_depth(y, bits=8, dither=False)— linear requantisation. A full-scale sine comes back at6.02 * bits + 1.76dB, matched to within a decibel from 4 bits to 16, which is the figure with no fudge in it. -
dropouts(y, sr, loss=, packet_ms=, conceal=)— packet loss at 20 ms, G.711 over RTP's packet. The loss rate is achieved exactly rather than drawn per packet, so a corpus labelled 5% carries 5%;return_spans=Truesays where the holes are;conceal='hold'repeats the last packet that arrived.
Worth knowing before choosing a concealment strategy: whether holding measures better or worse than a hole is decided by an accident, not by merit. When the talker's pitch period divides the packet the repeat lands in phase and is numerically exact; when it does not, it is a larger error signal than the silence it replaced. On a 20 ms packet a 150 Hz voice (3.00 cycles) scores +275 dB and a 133 Hz voice (2.66) scores worse than the hole. Anything picking a concealment strategy on a waveform metric is measuring that accident, and there are tests from both sides.
-
telephone(y, sr)— the lot, in the order a line does it: band-limit to 300-3400 Hz at the original rate, resample to 8 kHz, compand, come back. Same length and same rate as it went in, andaudio_reportafterwards says band-limited at about 3.4 kHz. -
kudio channel in.wav out.wav --telephone / --codec / --bits / --loss.
Added — rooms in Synthesizer¶
Synthesizer mixed clean × noise × SNR. It now takes rt60= as a fourth
axis, putting the speech through a room before the noise is mixed in --
the order a microphone meets them, which is also the only order in which the
SNR you asked for is the SNR you get, since it is then measured against the
reverberant speech.
-
Synthesizer(..., rt60=[0.3, 0.8], drr_db=3.0). Each output file gets its own room, seeded from the base seed and the output name, so the same request rebuilds the same dataset and two files sharing an RT60 still get different rooms. The room goes in the filename (..._rt300ms.wav). -
Synthesizer.manifest()→List[Pair], andPairgained an optionalrt60. The room travels there rather than insyn()'s return value, which is still the four-tuple it has always been: a return shape that changes when a keyword is passed is the bug this codebase has met most often, andfile_load's two channel layouts are in the notes as exactly that. -
kudio synth --rt60 0.3 0.6 --manifest out.json.
The clean file stays the training target, so a model trained on this data is asked to undo the room as well as the noise. That is the standard setup and worth choosing on purpose; keeping the room and removing only the noise needs the reverberant signal as the target, which this class does not write.
Added — reverberation, as a degradation and a measurement¶
kudio could degrade a recording with additive noise and take it back out again. Reverberation is the other kind and it is convolutive: the room adds nothing, it smears what was already there across the following half-second. Nothing here could describe that or measure it.
-
rir(sr, rt60=, drr_db=, seed=)— a synthetic room impulse response: exponentially decaying noise, the textbook model of a diffuse tail. Both knobs are solved for against the measurement's own definitions, so what you ask for is what comes back: RT60 within 3%, DRR within 0.01 dB. Seeded, so a dataset built from it can be rebuilt. -
apply_rir(y, ir)— put a clip in a room, keeping its length and its level by default. A clip that grows every time it is processed stops lining up with everything measured against it, and an augmentation that also changes the level is measuring two things at once. -
rt60(ir, sr)→Reverberation— T20, T30, EDT, C50, C80 and DRR to ISO 3382-1, all off one backward-integrated decay curve (schroeder_curve) so they cannot disagree about what the tail did. -
Reverberation.reliable()asks two questions, because the R² of the fitted decay only answers one of them. Backward integration makes any signal decay monotonically, and over a long clip that decay can fit a straight line well — this repo's speech fixture reaches R² 0.94 and comes back claiming a 3.9 second room. What gives it away is EDT and T30 disagreeing by a factor of eleven, where a real room has them within a few per cent. Truncated impulse responses are caught by the R² alone: cutting a 1 s room to 50 ms still plunges 35 dB, and reports 0.19 s. -
kudio roommeasures an impulse response,--makesynthesises one and--applyputs a file through one. Exits non-zero when the decay does not look like an impulse response.
Added — the alignment every reference metric assumes¶
si_sdr, snr, segmental_snr, PESQ and STOI all compare sample i of one
signal with sample i of the other. They trim to the shorter of the two and
assume the rest lines up, which is right for a process that returns what it
was given and silently wrong for anything with latency — a filter, a
resampler, a frame-based denoiser that pads.
Measured on a clip scored against itself:
| shift | SI-SDR |
|---|---|
| 0 | +145 dB |
| 1 sample | +10.6 dB |
| 1 ms | -10.1 dB |
| 10 ms | -11.1 dB |
One sample costs 134 dB. A millisecond scores worse than not processing at all, and nothing in the number says "these are misaligned" rather than "your denoiser destroyed the signal".
-
find_delay(ref, deg, sr)→Alignment— the offset to the nearest sample, by normalised cross-correlation.delayis signed (positive means deg starts later),apply()trims a pair to what they share, and a delay measured once on one passage can be applied to every file that came through the same chain. -
align(ref, deg, sr)— the pair, lined up and ready for a metric. -
The confidence is half of it. Cross-correlation always has a maximum, so it always returns a delay — including for two recordings that have nothing to do with each other.
Alignment.correlationseparates "the same take, 8 ms apart" from "two unrelated files", andalignrefuses belowMIN_CORRELATIONrather than putting a confident-looking number on nothing. Passmin_correlation=0to insist. -
Polarity is reported, not silently fixed. An inverted copy correlates at -1: it is the same take, and something in the chain flipped it. That is a finding, so
Alignment.invertednames it instead of correcting it on the way past. -
kudio align ref.wav deg.wav, with--metricsto show the score before and after,--write-tofor the aligned pair, and--max-secondsto bound the search. Exits non-zero when the two do not look like the same recording. Resamples first when the rates differ, because a sample offset is not a quantity yet if the two are not on the same clock.
Added — loudness over time¶
loudness() answers "how loud is this" with one number, which is what you
normalise against and useless for finding the moment somebody turned away from
the microphone.
loudness_over_time(y, sr, window=...)→LoudnessCurve— the two EBU Tech 3341 meters,MOMENTARY(400 ms) andSHORT_TERM(3 s), stepped by 100 ms. The curve carriesloudest(),quietest(),spans_above(),spans_below()and asummary(); times are window centres, so a curve drawn beside a waveform lines up with the audio that produced it.
These are ungated. Gating belongs to the integrated figure, where its job is to stop pauses dragging the average down. A curve with holes punched in it where the gate fired would describe a recording that stops existing between words.
-
loudness_range(y, sr)→ LU, to EBU Tech 3342: the 10th-to-95th percentile spread of the short-term loudness, after an absolute gate at -70 LUFS and a relative one 20 LU below the power-mean of the survivors — not the integrated measurement's 10. The percentiles are what stop a single door slam from setting the answer. -
true_peak(y, sr)→ dBTP, to BS.1770-4 Annex 2. The largest sample is not the largest value the signal reaches: samples are points on a curve that passes between them, and the curve overshoots. A full-scale sine at a quarter of the sample rate, phased so every sample lands at 0.707, reads -3.01 dBFS and +0.1 dBTP — and the converter, encoder or resampler that reconstructs it meets the second number. Oversampled to at least 192 kHz as the standard asks, in chunks, so a ten-minute file is not held four times over.
That case is in the test suite, which is the point of having it.
kudio loudnessnow prints range, true peak and the loudest and quietest moments with their timestamps.--over-timedraws the curve,--momentaryswitches windows, and--target -23exits non-zero when a file misses its delivery level by more than 1 LU.
Internal¶
K-weighted block power moved to one private helper (_block_power), which the
integrated measurement and both curves now share — they had begun to need the
same thing and written twice they would eventually have disagreed about what a
block contains.
[3.5.0] - 2026-09-10¶
Three things the toolkit could not do: say which part of a denoiser was doing the work, draw a spectrogram of audio that has not finished arriving, and say what note somebody is on.
trad_enhance was the whole of kudio's statistical enhancement: one function,
no parameters, one noise estimator welded to one gain rule. There was no way to
ask which part was doing the work, and no way to try a different one.
Added — enhancement as interchangeable parts¶
spectral_enhance(y, sr, method=…, noise=…)with seven gain rules —specsub,multiband,wiener,mmse_stsa,logmmse,omlsa,spectral_gate— over four noise estimators:mcra,quantile,initial,minimum.
Every method is the same three decisions: estimate the noise, estimate the a priori SNR, turn it into a gain. Separating them is what makes them comparable — hold the noise estimator still and the difference you hear is the gain rule.
The wiener / mmse_stsa / logmmse / omlsa family shares the
decision-directed a priori SNR of Ephraim & Malah, so what distinguishes them
really is only the gain function. Their known ordering —
Wiener ≤ log-MMSE ≤ MMSE-STSA, all three converging on Wiener at high SNR —
is asserted in the test suite, which is a stronger check on the formulas than
any citation.
The noise estimator is as large a lever as the gain rule. Across the full 7 x 4 grid on the suite's fixture: 5.8 dB of mean SI-SDR spread when the gain rule varies, 6.4 dB when the estimator does. Neither dominates, so a comparison that varies only the method is looking at half the problem.
compare_enhancers(y, sr, reference=None)→[EnhanceResult]— run several over identical audio and measure each: wall time, residual noise floor, loudness, and (given a reference) SNR / SI-SDR / PESQ / STOI plusdelta_snr_db, the improvement over doing nothing, which is the number that answers "did this help". A method that raises is reported witherrorset rather than taking the comparison down with it.
Both axes sweep. noises=[...] compares estimators, and giving both
compares the full grid — a run is a (method, estimator) pair, not a method,
because the two are separate choices of comparable size.
Anything callable can join the table. ('my model', fn) or a bare
function ranks a trained model, a wrapper around another library or a one-off
experiment beside the built-ins. "Is the model actually better than log-MMSE"
is the question people have, and two separate printouts is how it goes
unanswered. A custom entry brings its own noise handling, so it runs once
rather than being swept, and its noise column says own.
StreamEnhancer— the same four decision-directed methods, frame by frame, for audio that has not finished arriving.spectral_enhanceneeds the whole clip, which is useless for monitoring a microphone. Everything needed was already recursive, so the noise trackers and the a priori SNR estimator are now state plus an update (make_noise_tracker,DecisionDirected) shared by both paths rather than written twice.
One frame of latency (32 ms at the default), block-size independent, and
refuses quantile / specsub / multiband / spectral_gate rather than
quietly meaning something different from what those names mean offline.
-
enhance_folder(src, dst, method, report=True)— the denoising counterpart ofconvert_folder, mirroring the input's directory structure the same way.report=Truemeasures each file's noise floor before and after;mean_reduction_db()summarises it. -
crossfade/splice— join clips through a short overlap instead of butting them together, since a step in the waveform is a click. The result is shorter by the overlap, which is documented rather than papered over: anything tracking positions has to account for it.
The two default to different shapes, measured rather than assumed. A splice
joins two moments of the same recording, which are usually alike, and
equal-power over-shoots on alike material — on a sine cut at a period
boundary it raised the peak from 0.500 to 0.567 while linear left it at
0.500. So splice is linear and crossfade, which joins genuinely different
material, is equal-power.
-
Label/save_labels/load_labels— named time spans, as JSON or as an Audacity label track. Three columns of plain text that half the audio world can already read is worth more than a private format only kudio understands.kudio.vadoutput drops straight in. -
CLI:
kudio enhance --recursive, andkudio compare --noises allfor the full grid.
Timing is warmed up first: librosa's STFT is JIT-compiled and scipy's Bessel functions import lazily, so whichever method ran first was being charged ~3 s of one-time cost — in a table whose entire purpose is comparing methods.
-
METHODS/NOISE_ESTIMATORSdescribe every method and every parameter: range, default, step, unit and a sentence of help. KudioStudio builds its controls from this rather than restating it, so a parameter added here appears there without anyone editing the GUI. -
STFT.analyse()/STFT.synthesise()— the complex transform.forward()returns log-power and throws the phase away, which is right for a feature and useless for anything that has to reconstruct the signal. -
CLI:
kudio enhance in.wav out.wav --method logmmse --set q=0.4andkudio compare in.wav --reference clean.wav --write-to results/.
Added — audio that is still arriving¶
SpectrogramStream(sr, …)— a rolling log-magnitude spectrogram computed one column at a time.push(block)returns the columns that block completed;columns()is the window as an image, newest on the right.
The point is that each column is computed once, when its samples arrive. Re-running an offline STFT over the last few seconds several times a second — the usual workaround — recomputes about 240 columns to gain three.
Blocks may be any size: pushing the same audio in blocks of 1, 97, 512 or
4096 gives byte-identical columns, so the picture never depends on what the
device chose to hand over. Feed it StreamRecorder.drain(), never tail().
Columns follow the center=False convention — a stream cannot pad audio
it has not heard — and the suite checks them against
librosa.stft(…, center=False) rather than asserting the claim. Values are
dBFS normalised by the window, so a full-scale sine reads 0 dB at any
n_fft. n_mels= switches to a mel axis; 2-D input is refused rather than
flattened, since flattening interleaved channels analyses a signal nobody
recorded.
Added — fundamental frequency¶
f0(y, sr)→PitchTrack— pYIN (Mauch & Dixon 2014), with the voiced/unvoiced decision beside the contour.f0isNaNwherever the frame is unvoiced, because an estimator asked "what is the pitch here" always answers — for silence, for a slamming door, for thesin this — and a contour drawn through those answers is a picture of noise with a line through it.
PitchTrack carries median_hz, range_hz(), semitone_range (the unit
pitch is actually heard in), voiced_ratio, voiced_segments() and
to_labels(), which turns the voiced runs into kudio.Labels that
save_labels writes as an Audacity label track.
Three settings are refused rather than silently wrong: a frame_length
too short to hold two periods of fmin (which otherwise returns "unvoiced
everywhere", reading as a property of the recording), an fmax above
Nyquist, and fmin >= fmax.
kudio pitch file.wav— median, range in Hz and semitones, voiced percentage;--segmentslists the voiced spans and--labelswrites them as a label track. Exits non-zero when nothing is voiced, so it drops into a shell test the wayreportandvaddo.
Note on trad_enhance¶
Unchanged, and still exported. It is roughly specsub with MCRA and fixed
parameters, but it returns 128 samples fewer than its input; the new
methods return exactly as many, so their output lines up sample-for-sample with
what it came from.
[3.4.0] - 2026-08-15¶
Two gaps closed. kudio could analyse audio and augment it, but it could not edit it — no fade, no filter, no reversal — and it could not tell you where in a recording somebody is talking without a fixed silence threshold that real rooms defeat. Plus the folder-level helpers three downstream projects had each written for themselves.
Added — editing¶
fade_in/fade_out/fade(linear,cosine,exponential). The 10 ms default is the anti-click amount: long enough to remove the step at a cut, short enough not to be heard. Overlapping fades raise rather than silently multiplying the middle.reverse,remove_dc. The DC mean is accumulated in float64 — a float32 mean over a long clip carries enough rounding error to leave behind the offset it was asked to remove.highpass/lowpass/bandpass/bandstop/band_filter— Butterworth, zero-phase by default (sosfiltfilt). A filtered copy that is shifted in time no longer lines up with the original, which breaks an A/B, a spectrogram overlay and a training target all at once.wavelet_low_pass_filterinkudio.enhanceis a denoising algorithm that happens to be low-pass; these are the plain filters, and the two are not substitutes.
Added — voice activity¶
vad(y, sr)→ speech spans in seconds, plusvad_split,vad_trimandspeech_ratio.split_on_silencecuts on level alone, which is right for a studio take and wrong for anything with a noise floor: above some noise level no fixedtop_dbboth keeps the quiet consonants and drops the room.
This decides per frame on level relative to the recording's own noise floor, on spectral flatness (speech is harmonic and peaky; steady noise is flat), and on zero-crossing rate for the unvoiced fricatives — but the ZCR test only rescues frames adjacent to something voiced. On its own, an /s/ and a hiss are the same measurement, and a detector that fires on the air conditioning is not a detector. The cost of that rule, stated plainly in the docstring: a whisper has no voiced frames and is not detected.
Added — reference-free perception¶
dnsmos(y, sr, model_dir=)→DnsmosScore(sig, bak, ovrl), ITU-T P.835.audio_reportsays whether a recording is technically sound; this predicts what a listener would say about it. SIG and BAK move in opposite directions under denoising — aggressive suppression raises BAK and lowers SIG because it eats the speech with the noise — sosummary()names that trade rather than averaging it away.
The weights are not bundled. They belong to Microsoft's DNS-Challenge;
point model_dir (or $KUDIO_DNSMOS_DIR) at a checkout. pip install
kudio[dnsmos] adds onnxruntime. The published P.835 mapping coefficients are
included and attributed, overridable by a polyfit.json beside the model, and
polyfit=False returns the raw outputs.
Added — files and folders¶
audio_info(source)→AudioInfo(sr, channels, frames, duration, peak, subtype, format). Thekudio infoCLI computed this and printed it, KudioStudio's transport bar recomputed it and KudioEnhance checked rates — three implementations of one question.peak=Falsereads the header only.convert_folder(src, dst, ...)→ConvertResult— resample, re-encode, trim and normalise a whole tree. Output mirrors the input's directory structure, so two files with the same name in different subfolders do not collide.peak=andlufs=are mutually exclusive; normalising twice would undo the first one.Pair/save_manifest/load_manifest/split_pairs— dataset manifests, moved out of KudioEnhance because nothing in them is specific to denoising. Plain JSON, so another tool can read it without importing kudio.Pairis frozen and therefore hashable: split overlap is checked with sets, which silently catches nothing if the pairs are not.StreamRecorder— record for as long as you like while watching the level.record(seconds=…)is one blocking call: you commit to a duration up front and nothing is observable until it returns, which is fine for a script and useless for a person at a microphone.start()/level()/tail()/elapsed()/stop(), safe to poll from a UI timer, and nothing is dropped — unlikeAudioBuffer, which is a monitor and discards when full.LocalStreamReadergained anon_frame=callback, which is what makes the above possible without duplicating its device negotiation.
Added — CLI¶
kudio report (reference-free health check, exits 1 when something is wrong,
so it works in a shell test), kudio loudness, kudio normalize --lufs/--peak,
kudio vad [--split-to DIR], and kudio convert --recursive for folders.
kudio info now goes through audio_info and prints the encoding.
Internal¶
Framing moved to one private home (kudio.core._framing.frame_view); the
report and the VAD had begun to need identical framing, and written twice they
would eventually have disagreed about what frame 40 covers.
[3.3.0] - 2026-08-15¶
Two things a recording can be asked about without anything to compare it to:
how loud does this sound, and is this usable at all. Neither had an
answer in kudio before — normalize scales by the peak sample, and every
quality metric needs the clean signal beside the degraded one.
Added¶
loudness(y, sr)— gated integrated loudness to ITU-R BS.1770-4, in LUFS.normalizeandnormalize_dbscale by the peak sample, which says nothing about how loud something sounds: a clip with one stray transient peak-normalises to a whisper. Nothing in kudio could answer "how loud is this to a listener" until now.normalize_lufs(y, sr, lufs=-23.0)— normalise to a loudness target (-23 LUFS is EBU R 128). Logs a warning rather than silently limiting when the gain would push the signal past full scale.match_loudness(y, reference, sr, reference_sr=None)— scale one clip to another's loudness. An A/B comparison is not valid until this has been done: of two versions of a clip, the louder is reliably preferred whatever its quality, so an unmatched comparison partly measures level. Handles the two sides being at different sample rates, since loudness is a property of the sound rather than of the sampling.
The standard publishes its K-weighting coefficients at 48 kHz only, and the
textbook shelf/high-pass equations do not reproduce them. The filters are
instead carried to the s-plane and re-discretised at the requested rate through
a prewarped bilinear transform: exact at 48 kHz by construction, within
0.06 dB of the reference curve at 16 kHz and 0.21 dB at 8 kHz. Cross-checked
against pyloudnorm — worst disagreement 0.043 LU across 8/16/44.1/48 kHz —
by a test that runs whenever that package happens to be installed. It is not
a dependency; this is numpy and scipy only.
audio_report(y, sr)→AudioReport— the measurements you can make with no clean reference. Every existing metric (SNR, SI-SDR, PESQ, STOI) needs the clean signal next to the degraded one; synthetic datasets have that, real recordings never do. Reports level, true clipping, DC offset, silence ratio, noise floor, an SNR estimate and the bandwidth the content really occupies, plus.problems()— the findings in plain words.
Two details worth knowing. Clipping is a flat top, not a big number: float audio amplified past 1.0 still traces its waveform and has lost nothing, so it gets a separate, weaker warning than a run of samples pinned at the rail. And band-limit detection catches upsampled files — a clip labelled 16 kHz whose content stops at 3.8 kHz is 8 kHz audio wearing a bigger number, which quietly wastes half a dataset. Audio recorded at its stated rate has a noise floor spanning the whole band and reaches Nyquist; upsampled audio has nothing above the old one.
Fixed¶
file_load(mono=False)returned two different channel layouts. Thesr=Nonebranch reads through soundfile and gave(frames, channels); the resampling branch goes through librosa and gave(channels, frames). The layout therefore depended on whether you happened to ask for a resample, and nothing said so. Both now return channels last, matching soundfile and whatloudness()expects.
Mono loading — the default, and almost all use — is unaffected. If you were
loading multi-channel audio with an explicit sr, you were getting
(channels, frames) and will now get its transpose.
[3.2.0] - 2026-08-06¶
Building two packages on top of kudio surfaced a set of helpers that each of them had written for itself. Every one of them is a place where two components have to agree — on STFT geometry, on a sample rate, on normalisation statistics — and where, until now, they agreed only by convention.
Added¶
STFT— the STFT settings as an immutable value, withforward()/inverse()/n_bins/n_frames()/frame_times()/to_dict(). A spectrogram and the waveform reconstructed from it must sharen_fft,hop_length,win_lengthandwindow; passing those four to two separate functions and keeping them aligned by hand is how a producer and a consumer drift apart.to_dict()is meant to be stored next to a checkpoint, so a model and the geometry it was trained on travel together.Standardizer— per-column mean/std withfit/transform/inverseandsave/load. Normalisation statistics are as much a part of a trained model as its weights; a model reloaded without them produces confident nonsense.stack_context(frames, context, pad=)— context-frame stacking for any feature matrix. The logic already existed but was fused intowaveform_to_spectrogram(forward_backward=), out of reach for MFCC or mel features. That path now delegates here, so there is one implementation.frame_windows(frames, n_frames, pad=)— cut a feature matrix into fixed-length windows, tail padded rather than dropped.normalize(y, peak=)/normalize_db(y, dbfs=)— peak normalisation with the divide-by-zero guard built in.resample(y, orig_sr, target_sr)— the in-memory counterpart offile_load(..., sr=), which could only resample on the way in from disk. A no-op when the rates already match, so it is safe to call unconditionally.
Fixed¶
win_lengthno longer defaults to a hard-coded 512. It followsn_fft, the librosa convention.waveform_to_spectrogram(y, n_fft=256)previously raisedParameterError: Target size (256) must be at least input size (512)— a non-default FFT size simply did not work without also passingwin_length. Applies towaveform_to_spectrogram,spectrogram_to_waveform,file_to_spectrogram,logspec_from_filesandsave_spectrogram_as_wave.
Changed¶
- Following from that fix,
n_fftabove 512 with no explicitwin_lengthnow uses a window ofn_fftrather than a 512-sample window zero-padded ton_fft. Results change for those calls. Passwin_length=512to keep the old behaviour.
[3.1.0] - 2026-08-06¶
Never released to PyPI on its own — these changes ship as part of 3.2.0. Kept as a separate section because they are a distinct set of fixes.
Fixed¶
Synthesizerno longer resamples every mixture to 16 kHz. The output rate came from a hard-coded fallback, so an 8 kHz corpus silently produced 16 kHz mixtures — the dataset on disk disagreed with its source, and any model trained at the source rate then refused the files it was meant to consume. The default now keeps each clean file's own rate.- Noise is resampled to match its clean partner. Previously both sides were loaded at the same fixed rate, which hid the problem; with a rate-preserving default, a noise file recorded at a different rate would otherwise have been mixed in as-is.
Added¶
list_devices(kind=None)— devices as sounddevice sees them, which is the index spacerecord(device=…)accepts.CheckDeviceenumerates through PyAudio for the streaming classes, and the two bindings number devices independently: an index from one was never valid in the other, and nothing said so.kudio devicesnow prints both spaces, labelled.melspectrogram()accepts a waveform array as well as a path (arrays needsr=), matchingmfcc(y, sr). Previously it was file-only, which forced a round-trip through disk for in-memory audio.check_metrics_install()is exported from the top level; it reports which of PESQ / STOI / SDR are installed and was already the documented way to degrade gracefully, but callers had to reach intokudio.core.evaluatorfor it.
Changed¶
Synthesizer.syn(desired_sample=…)is renamedtarget_sr. The old name read like a sample count —kudio.core.featureusesdesired_samplesfor exactly that — while it actually meant a sample rate.desired_samplestill works and emits aDeprecationWarning.
Migration¶
Nothing to do if your audio was already 16 kHz. To keep the old behaviour on a corpus at another rate, ask for it explicitly:
[3.0.1] - 2026-07-11¶
Fixed¶
- Packaging: removed
pysepmfrom the[eval]extra.pysepmis not published on PyPI, sopip install kudio[eval]/kudio[all]failed to resolve. PESQ is now documented as an optional manual install (pip install https://github.com/schmiph2/pysepm/archive/master.zip);[eval]keepsmir_eval+pystoi(STOI/SDR), which the code already degrades around when PESQ is absent.
[3.0.0] - 2026-07-11¶
Major cleanup release: focus kudio into a lightweight, composable audio core.
Added¶
- Effects & augmentation (
kudio.effects):trim_silence,split_on_silence,time_stretch,pitch_shift,gain,random_gain,add_noise_snr,reverb,spec_augment(all seedable where random). - Dependency-free metrics:
si_sdr,snr,segmental_snr. - Exception hierarchy rooted at
KudioError(AudioIOError,FeatureError,DeviceError,SynthesisError,DependencyError). kudioCLI:devices,info,convert,synth,trim.record()(sounddevice backend),map_waves()parallel helper.save_wave(..., subtype=...)— 16/24-bit and float output via soundfile.Synthesizer.syn(..., seed=...)for reproducible synthesis.- Docs scaffold (mkdocs + mkdocstrings) and GitHub Actions CI/publish workflow.
Changed¶
- Dependencies slimmed:
matplotlib,pandas,openpyxlmoved to the[viz]/[data]extras.import kudiono longer pulls them. - Canonical API names with deprecation-warning aliases (see README table);
e.g.
w2s→waveform_to_spectrogram. file_loadreads via soundfile on the native-rate path (no librosa/numba).KudioConfigdefaults trimmed to audio-only (training keys removed).
Removed¶
- Deprecated compatibility shims
_config.py,_enh/,version.py,util/others.py.
[1.0.0] - 2026-07-11¶
First packaged release: a standalone, installable, MIT-licensed package.
Added¶
- Core audio API: file I/O (
file_load,save_wave,check_input), streaming/recording (Recorder,LocalStreamReader,RemoteStreamReader), thread-safe buffering (AudioBuffer,FetchBuffer). - Feature extraction and spectrogram <-> waveform conversion (
w2s,spec2wavform,w2mfcc,wav2mel, ...). - Noisy-data synthesis (
Synthesizer) and enhancement (trad_enhance,wavelet_low_pass_filter). - Speech-quality evaluation (
AudioEvaluate,eval_metrics: PESQ/STOI/SDR). - Typed configuration (
KudioConfig),py.typedmarker. - Optional extras:
audio(PyAudio/sounddevice),eval(mir_eval/pystoi),dev(pytest). 57-test suite.
Changed¶
- Modernized to librosa >= 0.10, numpy 2.x, pandas 2.x; replaced removed APIs
(
np.fromstring/tostring,DataFrame.append,scipy.signal.hanning,librosa.waveplot, ...). - Rewrote
AudioBufferonthreading.Condition/deque(was manipulating privateSemaphore._value). - Replaced
printwith the standardloggingmodule; heavy dependencies (PyAudio) are now imported lazily.
Removed¶
- Hidden AES license check that ran at import time (moved out of the package).
- Runtime
pip installcalls in the evaluation module.
Fixed¶
check_pathsibling-increment logic,Recorder.savebyte handling, and several latent shape/argument bugs.