Changelog¶
The format is based on Keep a Changelog (categories: Added, Changed, Deprecated, Removed, Fixed, Security), and AudioTree follows Effort-based Versioning.
[Unreleased]¶
Nothing yet.
[1.0.0] - 2026-08-11¶
The first stable release, and a near-total rewrite of the public surface. 0.2.x was a handful
of grain data sources with class-based transforms; 1.0 is a typed AudioTree container with
two transform backends (NumPy for grain workers, JAX for jitted training loops), four dataset
constructors, and two writer/reader pairs with versioned on-disk formats. The public surface
is the set of names exported from the audiotree, audiotree.sources,
audiotree.transforms and audiotree.transforms.jax namespaces, pinned by
tests/test_public_api.py.
Assume nothing from 0.2.x still compiles. The field audio_data is now waveform,
loudness is now lufs (with replace_lufs() / normalize_lufs() / lufs_cutoff /
keep_lufs= to match), and the merged metadata dict is split — payload keys are extras, while the filepath/source provenance moves to a private _metadata container (spelled metadata on disk); every class-based transform is a snake_case function (VolumeNorm to
volume_norm); AudioDataSimpleSource / AudioDataBalancedSource / AudioDataBalancedDataset
are create_audio_dataset() and create_balanced_audio_dataset(); SaliencyParams is
ExcerptConfig; the NumPy transforms take an np.random.Generator rather than a
jax.random.key; and public callables are keyword-only past their first argument or two.
Two changes are silently corpus-affecting – they alter the data a pipeline produces
rather than raising, so re-render anything pre-rendered and re-check anything trained.
The dataset loaders used to read the first duration seconds of every file on every epoch
(the default is now a random offset), and the loudness meter under-reported excerpts shorter
than 400 ms and used two different K-weighting filters within one call. Each is detailed below.
Requires Python 3.11-3.14 on Linux or macOS; Windows works except for grain’s multiprocessing prefetch (google/grain#793). Artifacts written by a pre-1.0 development build carry no format header and are refused with a “re-render the dataset” message – none of those layouts ever shipped in a release, so there is no released data to migrate.
Breaking changes at a glance. Renames: waveform, lufs, extras, filepath=,
predicate=, shuffle_seed= / excerpt_seed=, ExcerptConfig, audiotree.sources.
Signatures: keyword-only past the leading positionals; transform constructors reject unknown
names; scope=["wet"] replaces scope={"wet": True}. Behaviour: the loudness meter,
split_seed=False, prob<1 and the per-group seeds all changed what they produce.
Removals: AudioTree.from_array, ReduceBatchTransform, num_records on the dataset
builders, and the post-mix shuffle.
Added¶
ExcerptConfig: The one object that says how an excerpt is chosen, exported from bothaudiotreeandaudiotree.sourcesand accepted asexcerpt=bycreate_audio_dataset(),create_balanced_audio_dataset(),AudioTree.excerpt()andAudioTree.loudest_excerpt(). A frozen dataclass with one namedstrategy—"random"(a uniform offset; the default),"start"(offset 0) or"loudest"(best-of-num_triessearch for a window clearinglufs_cutoff, default −40 LUFS) — plussearch("uniform","bias_early", or a dotted path to your own; it draws the offsets of both"random"and"loudest", and only"start"— which draws nothing — rejects it) andon_failure("keep"/"skip"/"raise"). Setting a knob the chosen strategy ignores raises at construction rather than being silently dropped. It replacesSaliencyParams; see Changed for the migration. The two built-in searchers live ataudiotree.core.search_uniform/search_bias_early, which are internal — refer to them by name (search="bias_early") rather than importing them.AudioTree.backendandAudioTree.device: Which array library a tree’s leaves belong to ("numpy","jax", or"mixed") and which JAX device they sit on (Noneon NumPy). A tree stops being homogeneous more easily than you would expect — a NumPy-namespace transform applied to a JAX tree hands back a NumPywaveform— and the failure is silent untiljax.jitstarts re-uploading a leaf every call.backendreports"mixed"rather than raising, so it is safe to log;deviceraises when the leaves genuinely disagree, because there is no honest single answer.jax.device_put/jax.device_getmove a tree; these say where it currently is.TreeDataSourcereleases its memmaps: it is a context manager with an explicitclose(), sowith TreeDataSource(d) as src: ...(or a manualclose()) drops every open mapping. It is a release, not a teardown — reading again reopens transparently,close()is idempotent, and a read in flight on another thread (grain’s prefetch pool) keeps its own snapshot of the handles, so it completes whole rather than silently returning an empty sample. This matters on Windows, where an open memmap blocks deletion: a run that read a dataset and then tried to remove it failed withPermissionError: [WinError 32]and nothing naming the holder.Optional extras:
audiotree[bagz]for string leaves inTreeWriter/TreeDataSourceand the windowed-LUFS cache;audiotree[progress]forAudioWriter(show_progress=True);audiotree[all]for both.[all]guards bagz behind environment markers so it stays installable everywhere, while an explicitpip install audiotree[bagz]fails loudly on the platforms bagz publishes no wheel for (see Changed).create_windowed_audio_dataset(): Length-aware window sampling that changes the unit of sampling from the file to a fixed-durationwindow. Each file is tiled intom_i = round(n_i ** alpha)evenly-spaced slots (wheren_iis its number ofhopwindows), all slots are flattened into one globally-shuffled index, and each draw loads a jittered excerpt from its slot. This gives even per-file coverage, length-proportional sampling, and diverse batches (no lingering on long files) simultaneously. Thealphaknob (in[0, 1]) tunes length bias:0= uniform per file,1= proportional to length,0.5=sqrt(length). Jittered offsets are confined to each file so windows never run past end-of-file. Exported fromaudiotree.sources.WindowConfigfor balanced datasets:create_balanced_audio_dataset()accepts awindow=WindowConfig(...)that builds every file-based group withcreate_windowed_audio_dataset()instead of one-excerpt-per-file. Groupweightsbalance across categories whilealphacontrols length bias within each, composing multiplicatively. Mutually exclusive with a customizedexcerpt.Windowed-loudness cache (bagz):
build_window_lufs_cache()runs a one-time preprocessing pass that computes per-file windowed integrated loudness (ITU-R BS.1770) and persists the ragged per-file LUFS arrays as Bagz records (no padding), plus durations and the measurementsample_rate/mono, viasave_window_lufs()/load_window_lufs(). Passinglufs_cache=tocreate_windowed_audio_dataset()(or viaWindowConfig) drops windows belowlufs_cutoffat build time, turning saliency into a one-time filter rather than a per-visit search. Loudness is computed on the CPU with the upstreamloudnesslibrary (loudness.integrated_loudness, the same kernelAudioTree.replace_lufs()uses for the integratedlufsof a NumPy waveform) — no JAX/GPU work, so the preprocessing is safe to run before forking grain workers and keeps the data-source layer free of GPU computation. The dataset verifies the cache’ssample_rate/monomatch the audio it loads and refuses to run on a mismatch; the cache also records each file’s byte size, so a file re-rendered in place after the build names itself at dataset construction instead of silently keeping the stale durations and loudness, and a file the cache has never seen (added after the build) is reported with the rebuild remedy rather than a bareKeyError. Curated corpora can skip this entirely (the default).scan_durations(): Header-only (no decode) per-file duration scan, cacheable and passable asdurations=soalpha/hop/durationretune without re-reading the corpus.peak_norm()transform: Peak-normalizes audio so its largest absolute value is1.0, dividing by the per-item peak (across channels and samples, clamped to a small epsilon). Unlikerescale_audio(), which only scales down audio exceeding[-1.0, 1.0], this always normalizes to a peak of1.0. Available in both the NumPy (audiotree.transforms) and JAX (audiotree.transforms.jax) backends.TreeWriterandTreeDataSource: A pytree-native writer and reader for memory-mapped datasets, both new in 1.0. Instead of manually extracting and reconstructing AudioTree fields,TreeWriterusesjax.tree_utilto decompose any pytree into leaves — each array leaf becomes its own memmap.bin, each string leaf its own Bagz file — and a JSON structure descriptor inmanifest.jsonenables exact reconstruction. It accepts AudioTree objects, dicts of arrays, dicts of AudioTrees, or nested combinations, all through a singlewrite()method (callopen()first, or use the writer as a context manager). The reader takes a directory path and implements Grain’sRandomAccessDataSourcewith a minimal interface (__len__,__getitem__).String leaf support in
TreeWriter/TreeDataSource:write()now acceptsstrorList[str]leaves alongside arrays and AudioTrees. Strings are stored in Bagz files (one per string leaf) with no length limits or truncation. Single-sample reads return a barestr, andAudioTree.batchnaturally collects them intoList[str]. Backed by Bagz, which is an optional extra: installaudiotree[bagz]to use string leaves. Datasets without string leaves need nothing extra, andexclude_prefixescan skip the string leaves of a dataset written elsewhere.Selective field loading via
exclude_prefixes:TreeDataSourceaccepts anexclude_prefixesparameter to skip loading specific leaves by dot-separated name prefix. For example,exclude_prefixes=["wet.waveform"]skips the audio memmap, andexclude_prefixes=["dry"]skips all leaves under thedrysubtree. Excluded leaves are omitted from the reconstructed pytree (AudioTree fields default toNone, extras keys are absent). Prefix matching uses exact-or-dot semantics ("dry"matches"dry"and"dry.waveform"but not"dryness"). Excluding the root leaf of a bare-array dataset (a dataset whose whole pytree is one array) leaves nothing to return and raises at construction, rather than handing the caller an internal sentinel object.load_into_memoryfor worker-shared data:TreeDataSourceacceptsload_into_memory=Trueto load all non-excluded array and string leaves into RAM at init time, so the source never opens a file again — not in this process and not in a worker. The store survives pickling, so it reaches grain’smp_prefetchworkers (which are spawned, not forked) by pickle, and a forked child by copy-on-write; either way the workers do no disk I/O. String leaves (bagz) are read into aList[str].__getitem__copies each sample out of the store rather than handing out a view, so one caller’s in-place write cannot rewrite the dataset for every reader after it.Graceful undershoot handling in
TreeWriter:expected_samplesis an allocation hint rather than an exact requirement. Onclose(), if fewer samples were written than allocated, the memmap files are truncated to the actual sample count viaos.truncate(), so an over-estimate costs no disk space and the reader still sees exactly the samples that were written. This avoids errors when the exact dataset size isn’t known ahead of time (e.g., split-dependent counts). Overshoot is governed separately by the keyword-onlyon_overflow=:"grow"(default) reallocates every leaf and keeps writing,"error"raises naming the written, allocated and offered counts, and"trim"writes what fits and warns. LikeAudioWriter, the writer refuses a directory that already holds a dataset unlessexist_ok=True.AudioTree.replace_extras(): Merges keyword arguments intoextras— syntactic sugar fortree.replace(extras={**tree.extras, **kwargs}). Passed keys overwrite same-named existing keys, all other keys are kept, and neither the original tree nor itsextrasdict is mutated.AudioTree.replace()itself stays strict (unknown field names still raise), so typos don’t silently become extras entries.AudioTree.write(): Saves a single-item tree (batch_size == 1) to an audio file viasoundfile, the inverse offrom_file(). Takes afilepath(format inferred from its extension) plus optional keyword-onlysubtype/format/endianpassthroughs and returns thePathwritten; it does not take a sample rate (usesself.sample_rate— callresample()first to change it). It raisesValueError— not a strippableassert— when the tree has nowaveform, when it is mini-batched (rank 4), or whenbatch_size != 1, so index or iterate the batch first (e.g.for item in tree: item.write(...)).AudioTree.__iter__: AudioTree is now a propercollections.abc.Iterable. Iterating yields one batch-of-1 AudioTree per item along the leading batch axis (e.g. a batch-16 tree yields 16 trees ofbatch_size == 1).forloops worked before via the sequence protocol, butisinstance(tree, Iterable)now returnsTrue.AudioTree.samplesproperty: Returns the number of samples in the waveform (waveform.shape[-1]).AudioTree.sourceproperty: Returns the source group name for each item in the batch. When usingcreate_balanced_audio_dataset()withsources={"music": [...], "speech": [...]}, each loaded AudioTree tracks which source group it came from.AudioTree.lufs_windows+ per-window loudness:replace_lufs()now also fills a newlufs_windowsfield alongsidelufs— the ungated K-weighted loudness of each analysis window (a loudness-over-time curve), shaped(*batch, num_windows). Unlikelufs(the gated integrated program loudness), every window is directly comparable and a fully silent window reads-inf, so a statistic like “fraction of windows above −30 LUFS” is meaningful.replace_lufs(lufs_window_sec=0.4, lufs_hop_sec=None)controls the window length (≥ 0.4s, the EBU momentary integration time) and the step between windows (defaults tolufs_window_secfor non-overlapping windows; a smaller hop overlaps them) — the same spellingWindowConfigandcreate_windowed_audio_dataset()use for the same quantities. The trailing partial window is dropped, so an excerpt shorter than one window yields an emptylufs_windows. The windowed curve is computed natively: NumPy uses exact ITU-R BS.1770 K-weighting IIR biquads viascipyon the CPU, and JAX usesjaxloudnorm’s FIR-approximated K-weighting so the whole batch stays on the accelerator (the integratedlufsstill comes from theloudnesslibrary on the NumPy path and a vmappedjaxloudnormmeter on the JAX one). Where the measurement runs and which kernel runs are separate keyword-only arguments:device=(None/"cpu"/"gpu"/"tpu", mirroringjax.jit’sbackend, or ajax.Device) andengine=(None/"numpy"/"jax").replace_lufs(device="gpu")runs the vmappedjaxloudnormkernel on the accelerator even for a NumPy waveform (much faster for large batches, since the CPU meter measures one item at a time) and still returns NumPy loudness, so you no longer need a manualjax.device_put/jax.device_getround-trip.Nonefor both (the default) uses the waveform’s own array library on its current device.lufs_windowsis a first-class field: it survivescreate()/from_file(), indexing, batching, and round-trips throughAudioWriter/from_manifestandTreeWriter/TreeDataSource. Transforms keep it consistent —volume_norm/volume_changeshift it by the applied gain, phase transforms preserve it underkeep_lufs=True, and length/energy/channel changes invalidate it. For hand-rolled waveform edits,AudioTree.clear_lufs()is the documented shorthand for dropping both cached fields (replace(lufs=None, lufs_windows=None)) so the nextreplace_lufs()measures the new audio.AudioTree.normalize_lufs(target_lufs): Normalizes audio to a target LUFS level. Computes loudness if not already set, scales audio, and updates thelufsandlufs_windowsfields (a constant gain shifts every window’s LUFS equally).AudioTree.create(): New classmethod with automatic audio dimensionality handling (1D→3D, 2D→3D) andfilepathparameter support.AudioTree.filter(): Takes apredicate(AudioTree) -> boolapplied to each batch item and returns a new AudioTree holding only the items it accepted.AudioTree.split(),AudioTree.reshape_mini_batches(),AudioTree.flatten_mini_batches(): Split an AudioTree into a list, and add or remove a mini-batch axis (useful fornnx.scan).sourceparameter onAudioTree.create():create()now accepts asource=kwarg (encoded into the library-managedmetadataprovenance container and read back via thesourceproperty), matchingfrom_file(). Unlikefrom_file(), which takes only a single string,create()accepts either a single string (tags the whole batch) or a list of strings with one source-group name per batch item — so a batched tree assembled outside the dataloaders (e.g. windows rendered from mixed materials) can still carry a correct per-item.source. Round-trips throughTreeWriter/TreeDataSource.pad_modeparameter: Added toAudioTree.from_file(). Options include"constant"(zero padding),None(don’t pad), and"wrap"(loop audio).JAX transforms module:
audiotree.transforms.jaxprovides JAX-native transforms for GPU/JIT training pipelines. Usesjax.random.keyand JAX operations throughout. It exportsvolume_norm,volume_change,invert_phase,swap_stereo,corrupt_phase,shift_phase,roll,trim,mono,stereo,rescale_audio,peak_norm,resample,identity, the sharedencode_with_codec/encode_latentscodec transforms, and the@random_transform/@map_transformdecorators.choose()is the one transform that stays NumPy-only.resample()transform: Resamples to a new sample rate in both the NumPy (audiotree.transforms) and JAX (audiotree.transforms.jax) backends, wrappingAudioTree.resample(so NumPy waveforms use librosa and JAX waveforms use the Julius port).Transform decorators: New
@random_transformand@map_transformfor building custom transforms from plain functions. They live inaudiotree.transforms.decoratorsand are re-exported fromaudiotree.transformsandaudiotree.transforms.jax, which is where you should import them from.@map_transformhandles the boilerplate for the keyword-onlyscopeandoutput_keyparameters;@random_transformaddsprobandsplit_seedon top and threads the RNG through.create_audio_dataset(): For creating simple audio datasets without balancing. Loads all files from one or more directories and applies grain’srandom_mapfor proper RNG seeding.num_epochs: int | Nonesets the pass count over the corpus (Nonefor an unbounded stream; defaults to1here and toNonefor the balanced builder).find_audio_files(): New public helper inaudiotree.sourcesfor listing audio files under one or more directories (skipping hidden files/dirs, filtering by extension). Each source may also be a glob pattern — any entry containing*,?, or[...]is expanded, with each match then recursed into (if a directory) or kept (if a file), so patterns like"/mnt/d/musdb18hq/train/*/mixture.wav"and recursive"/data/**/*.wav"work directly. The result is sorted and de-duplicated, so the file order — and therefore seeded shuffling — is deterministic across machines and filesystems (previously rawos.walkorder, which varied by filesystem).Saliency-aware excerpt loading:
create_audio_dataset()andcreate_balanced_audio_dataset()choose each excerpt inside grain’srandom_map, so a file visited again on a later epoch gets a fresh offset instead of the same one. How the offset is chosen is configured withexcerpt=ExcerptConfig(...)("random","start", or a best-of-num_tries"loudest"search), andAudioTree.loudest_excerpt(path, rng, excerpt=...)exposes that search directly on the container.AudioWriter: New class for writing AudioTree batches to disk as individual audio files plus amanifest.npzindex — one row per item carrying thefilepath/sourceprovenance columns,lufs, the other AudioTree label fields, and anyextras_*and tag columns. The manifest is rewritten atomically (temp file plusos.replace) everymanifest_everyitems (default 1000), so a render killed partway still leaves a readable prefix. The writer adopts the sample rate of the firstAudioTreewritten and raisesValueErrorif a later write differs, so one manifest never mixes rates; the default encoding subtype is the widest the container supports (FLOATfor WAV/AIFF/CAF/W64/RF64,PCM_24for FLAC) rather than libsndfile’s PCM_16, and an explicitly requested fixed-point subtype that clips raises aRuntimeWarningnaming the worst offender; and it refuses to write into a directory that already holds a dataset unlessexist_ok=True, so a finished render is never silently overwritten.write()commits per batch: every entry is validated before any audio lands, a mid-batch failure removes the files that call had written (no manifest row points at them) and leaves the index and manifest untouched, so a failedwrite()can be fixed and retried without duplicating items or orphaning audio.AudioDataSource: New class for reading AudioWriter outputs as a grain RandomAccessDataSource with metadata preservation and filtering capabilities.AudioDataSource(path)accepts the dataset directory (itsmanifest.npzis located inside, mirroringTreeDataSource) or an explicit manifest file path.New documentation guides: Balanced Datasets, Windowed Datasets, ArgBind Guide, Transform Chaining, Dict Batches, and Multiprocessing.
New tests: Balanced datasets (40 tests covering hierarchical directory structures, unbalanced group sizes, statistical accuracy of weight-based sampling, and edge cases), multiprocessing/multithreading (11), transform chaining (8), and
Dict[str, AudioTree]batch processing with scope (15).librosadependency (>=0.11.0): decoding inAudioTree.from_file(), the CPU (soxr) backend forAudioTree.resample()and the windowed-LUFS cache, and NumPy-based STFT operations in the phase transforms (alongsidelibrosaxfor the JAX side).Pre-commit + Ruff: Added a
.pre-commit-config.yamlrunning Ruff (lint with--fixand Black-compatible formatting) plus basic file-hygiene hooks.pre-commitis included in thedevextra; enable withpre-commit install.CI covers macOS and Python 3.14: 3.14 was advertised in the classifiers but unreachable while
bagzwas a hard dependency; the macOS jobs additionally prove the no-bagz path.@random_transform/@map_transformare exported fromaudiotree.transformsandaudiotree.transforms.jax. They are the documented way to write a custom transform, but lived only in the internalaudiotree.transforms.decoratorsmodule — so the advertised extension point was not importable from any public namespace. They are now public API.README rewrite: the README was seven lines; it now carries the field table, install (including the
bagzextra), a runnable quickstart, a comparison to Descript audiotools and torchaudio, and an explicit scope/non-goals list. The guides document the on-disk format rules (writer guide) and the reproducibility caveats (sources guide). The tagline “ML-framework-agnostic” is corrected in both the README and the PyPIdescription: JAX and Flax are hard requirements.on_read_error:create_audio_dataset,create_balanced_audio_datasetandAudioDataSourcetakeon_read_error: "raise" | "skip" | "warn", defaulting to"raise". One corrupt file in a 100k-file corpus used to end a multi-hour run with no way to survive it. The non-raising policies drop the item using grain’s own convention: the loader returnsNone, later.map()stages are never called on it, andto_iter_dataset()skips it, so batches refill with real audio and nothing synthetic enters the pipeline (under random access,ds[i]returnsNonefor a broken file). New public names, exported fromaudiotree.sources:AudioReadError(anOSErrorcarrying.file_path) andOnReadError. Every read failure now surfaces asAudioReadErrorwith the original as__cause__, so callers no longer have to know that librosa’s fallback raisesaudioread.NoBackendErrorwhile soundfile raises aRuntimeErrorsubclass.TreeWriter(on_overflow=...)and a neural-codec guide (docs/source/introduction/codecs.rst) —AudioCodecis public API and appeared in zero guides. Writing it surfaced thatLatentAudioCodechad never been exported, so every:class:role pointing at it was dead; both protocols are now exported fromaudiotree.transformsandaudiotree.transforms.jax.tests/test_seed_stability.pypins the exact(basename, offset)sequence a fixed seed produces, and the multiprocessing tests now comparemp_prefetchagainst the single-process stream. Shuffling, excerpt selection and seed derivation were all edited in this release and nothing would have noticed a change to any of them.tests/transforms/test_backend_parity.pycompares every transform exported by both backends, over mono and stereo. Every prior JAX transform test was mono, which is exactly why theshift_phasedivergence below went unseen.
Changed¶
Breaking — codec transforms take a codec object:
encode_with_codec(codec)andencode_latents(codec)now take a single codec object instead of a bareencoder_fn(and, forencode_with_codec, anum_codebookscount). Two structural protocols inaudiotree.transformsdescribe them:AudioCodec, whoseencode(AudioTree) -> codesproduces discrete tokens, andLatentAudioCodec, whoseencode_to_latent(AudioTree) -> latentsproduces continuous latents. They are deliberately separate and bothruntime_checkable, so an encode-only codec is expressible, a codec that does both simply satisfies both, andisinstance(codec, AudioCodec)is a real conformance check. The codec owns resampling, channel handling, and output shapes, so the transform no longer rearranges codes into a(batch, codebooks*channels, frames)packing:AudioTree.codesstores exactly what the codec returns; any state a codec keeps beyond the codes (such as a loudness-normalization factor its decoder needs back) is the codec’s own to manage. Both transforms remain idempotent (anAudioTreethat already hascodes/latentspasses through) and are now defined once and shared by theaudiotree.transformsandaudiotree.transforms.jaxnamespaces.Breaking — Renamed the loudness vocabulary to
lufs: TheAudioTree.loudnessfield is nowAudioTree.lufs, andAudioTree.replace_loudness()is nowreplace_lufs(). This affects attribute access and all keyword arguments (AudioTree(lufs=...),create(lufs=...),from_file(lufs=...),tree.replace(lufs=...)). The excerpt config’sloudness_cutoffis nowExcerptConfig.lufs_cutoff(see theSaliencyParams→ExcerptConfigrewrite below). Every new 1.0 surface follows the same spelling:normalize_lufs(),clear_lufs(),keep_lufs=,filter_by_lufs(), thelufsmanifest columns and leaves, and the windowed-LUFS cache.Breaking — Renamed
AudioTree.audio_datatoAudioTree.waveform: The primary audio field is now calledwaveform(matching torchaudio’s convention and avoiding ambiguity with sample counts). This affects all keyword arguments (AudioTree(waveform=...),tree.replace(waveform=...)) and attribute access. It also changes the on-disk leaf names produced byTreeWriter: new datasets are written withwaveform.bin(or e.g.,dry.waveform.binwhen nested) and matchingmanifest.jsonkeys. To migrate an existing dataset, rename each*audio_data.binfile to the corresponding*waveform.binand replace every occurrence ofaudio_datawithwaveformin itsmanifest.json.Breaking — Separate NumPy and JAX transform backends: Transforms now have two implementations:
audiotree.transforms(NumPy): For CPU-based grain data pipelines. Usesnp.random.Generator.audiotree.transforms.jax(new): For GPU/JIT training pipelines. Usesjax.random.key.
The base transforms in
audiotree.transformsnow expectnp.random.Generatorinstead ofjax.Arrayfor the RNG parameter. Usenp.random.default_rng(seed)instead ofjax.random.key(seed). For JAX-based transforms in jitted training loops, import fromaudiotree.transforms.jax.Breaking — Transform API redesign: Completely redesigned from class-based to function-based pattern. All transforms are now functions decorated with
@random_transformor@map_transform, enabling direct parameter binding with argbind. Parameters are now flat (e.g.,volume_norm.min_db: -20) instead of nested in aconfigdict. This allows CLI parameter binding (e.g.,--volume_norm.min_db=-20) which was not possible with the old API. All transform names changed from PascalCase to snake_case (e.g.,VolumeNorm→volume_norm,Trim→trim). Old class-based transforms removed with no backward compatibility. Migrated transforms:identity(),mono(),stereo(),volume_change(),volume_norm(),rescale_audio(),invert_phase(),swap_stereo(),corrupt_phase(),shift_phase(),roll(),trim(),choose(),encode_with_codec(),encode_latents(). The reserved parameters (prob,split_seed,scope,output_key) are keyword-only, and transform instances are picklable with a real__repr__(grain spawns its workers rather than forking).Breaking — Renamed module
audiotree.datasourcestoaudiotree.sources: To matchgrain.sources.Breaking — Refactored
create_balanced_audio_dataset(): Now uses grain’srandom_mappattern for saliency-based audio loading (see Fixed for the excerpt-diversity bug this resolves). Now supports mixing pre-constructed grain MapDatasets via thedatasetsparameter. Thesourcesparameter is now optional (at least one ofsourcesordatasetsmust be provided). Pre-constructed datasets passed viadatasetsmust already be repeated (call.repeat()before passing); if a finite dataset is passed,grain.MapDataset.mixwill truncate output to the shortest dataset length. File-based sources are automatically repeated internally.Breaking — Renamed
seedtoshuffle_seedand addedexcerpt_seed: Increate_audio_dataset()andcreate_balanced_audio_dataset(), theseedparameter has been replaced withshuffle_seed(controls file order) andexcerpt_seed(controls random excerpt selection). This allows creating datasets that visit files in the same order but load different random excerpts.excerpt_seed=Nonestill falls back toshuffle_seed, but the two streams are drawn as separate children of onenumpy.random.SeedSequencerather than being the same integer, so shuffle order and excerpt offsets stay decorrelated even under the fallback. Increate_balanced_audio_dataset(), each group’s seeds are derived from the group’s name —SeedSequence([base_seed, crc32(name), crc32(role)])— so adding or reordering a group leaves every other group’s stream untouched, and the shuffle and excerpt roles stay independent even when the two base seeds are equal.Level-, rate- and channel-changing
AudioTreemethods work on mini-batched trees: Afterreshape_mini_batches(), the waveform has shape(num_mini_batches, mini_batch_size, C, T).replace_lufs(),normalize_lufs(),to_mono(),to_stereo(), andresample()now handle any number of leading batch axes by flattening them around the underlying 3-D kernels and restoring them afterwards (lufscomes back shaped like the leading axes, e.g.,(num_mini_batches, mini_batch_size)). The methods whose meaning is genuinely per-item still require rank 3 and now say so by name —AudioTree.write()andAudioTree.create()raise naming the rank and the fix rather than silently treating the mini-batch axis as the batch.AudioTreearray fields typed asArrayLike: Thewaveform,lufs,lufs_windows,pitch,velocity,note_duration,codes, andlatentsfields (and the matchingcreate()/from_file()parameters) are now annotatedArrayLike = np.ndarray | jax.Arrayinstead ofnp.ndarray, so type checkers accept both NumPy and JAX arrays. Optional fields are explicitlyArrayLike | None, andAudioTree.create()now acceptswaveform=None(the dimensionality-normalizing reshape is skipped) for token-only trees built fromcodes/latents.core.pyusesfrom __future__ import annotations, so no annotation is evaluated at import time, andArrayLikewidens to the union only underTYPE_CHECKING(it is plainnp.ndarrayat runtime). Runtime behavior is otherwise unchanged.Enhanced loudness computation:
AudioTree.replace_lufs()now uses the loudness library for numpy arrays and jaxloudnorm for JAX arrays.AudioTree.resample()CPU backend: NumPy-backed waveforms now resample on CPU via librosa (soxr), and the result stays a NumPy array — previously a NumPy waveform was silently returned as a JAX array. JAX-backed waveforms still use the JAX/Julius port. The NumPy path’s output length is pinned to match the JAX backend (soxr can otherwise differ by a sample).zeros,rolloff, andfullapply to the JAX backend only.jaxloudnormdependency: Now installed from PyPI and pinned to>=0.3.2. The 0.3 line returns-infLUFS for digital silence (the mathematical limit of zero gated power), soAudioTree.replace_lufs()reports a well-defined value on silent JAX audio instead ofNaN; the floor is.2rather than.1becausejaxloudnorm<=0.3.1depended on the 2017pysoundfiledistribution, which installs its own top-levelsoundfile.pyand silently shadowed the realsoundfilein the lowest-version resolution.Loosened the
grainpin to>=0.2.15,<0.3(was==0.2.15): nothing in the package rides grain’s private API any more. The one class that did,ReduceBatchTransform, which subclassed agrain._srcbatch operation, is gone (see Removed); batching is nowAudioTree.batchoverjax.tree_util.tree_map. Both ends of the range are exercised in CI: thetest_lowest_directjob resolves 0.2.15 and the normal matrix takes the newest 0.2.x. Note: grain 0.2.17+ reads absl flags inside its multiprocessing prefetch, so scripts doing multiprocessing data loading outside anabsl.app.runentry point must callflags.FLAGS.mark_as_parsed()once at startup (the test suite does this intests/conftest.py).Filepath/source strings raise instead of truncating: Encoding a filepath or source string longer than
audiotree.core._str_max_lengthnow raisesValueErrorinstead of silently truncating it (which corrupted the round-tripped path). The default limit was also raised from 256 to 1024 characters.Updated examples: argbind_augmentations examples demonstrate new function-based transform API with simpler YAML configs and working CLI parameter binding.
Consolidated tests: Merged
test_roll.pyintotest_core.pyfor better organization.Breaking —
bagzis now an optional extra: installaudiotree[bagz]for string leaves inTreeWriter/TreeDataSourceand for the windowed-LUFS cache. It was a hard dependency gated onsys_platform == 'linux', but bagz publishes manylinux x86-64 wheels only and has no sdist, which madepip install audiotreeunsatisfiable on Linux aarch64 at every Python version and on Linux x86-64 at Python 3.14. Relatedly,exclude_prefixesnow actually escapes bagz: the import was previously required before the exclusion filter, so excluding every string leaf still raisedImportErrorand a Linux-written dataset could not be opened elsewhere at all — not even to read its waveforms.jax,flaxandabsl-pyare now declared dependencies: all three are imported at module scope but arrived only transitively viajaxloudnorm/librosax, soimport audiotreedepended on another package’s resolution choices.py.typed: the package now ships a PEP 561 marker, so its annotations are visible to downstream type checkers instead of being silently ignored.License metadata: declared via PEP 639 (
license = "MIT"pluslicense-files), which replaces the now-forbidden license classifier. Third-party notices for the julius-derived resampler and the pyloudnorm-derived loudness code are bundled underLICENSES/and ship in both the wheel and the sdist. Audio fixtures undertests/assets/are explicitly carved out of the MIT grant — the MUSDB18-HQ excerpt is CC BY-NC-SA 4.0 and cannot be relicensed.Breaking — transform constructors reject unknown parameters: a misspelled name (
volume_change(min_dB=40), capital B) was accepted and silently dropped, so the augmentation a config asked for simply never happened. It now raisesTypeErrorwith a suggestion. A config that appeared to work may now correctly fail.Breaking —
scope={'key': True}raises: a scope entry is matched one level above where it sits, so a bool directly under a top-level key selected every leaf rather than that one — silently augmenting the target signal in a dry/wet pipeline. Use the new list form,scope=["wet"](also["input.dry"]or[("input", "dry")]); the nested-dict sentinel form{"wet": {"scope": True}}is unchanged. An explicitly empty scope ([]or{}) now selects nothing rather than everything — “transform these subtrees: (none)” makes the transform a no-op instead of silently applying it to every leaf.Breaking — public callables are keyword-only past their leading positional arguments: signatures exposed up to 16 positional parameters (
AudioTree.from_file), freezing their order at 1.0. Most now take one or two positionally;create_balanced_audio_datasetis the widest at three (sources,weights,datasets).tests/test_public_api.pypins the per-callable budget, so adding, reordering or renaming a parameter cannot silently change what a positional call means.Breaking —
AudioTree.filter’s callback ispredicate, notfilter_fn, and the method is documented (it previously had no docstring and so was missing from the API reference).Dependency floors are now the versions that actually work: verified by resolving every direct dependency to its lowest allowed version and running the suite there, which the new
test_lowest_directCI job repeats.numpy>=1.24,scipy>=1.10andlibrosa>=0.10.1were unreachable fiction (the graph forces 2.1.3 / 1.16.2 / 0.11.0);jaxandflaxneeded raising to>=0.10.0and>=0.12.3, since older releases break on the funless@jax.jit(...)form and onnnx.jit.Breaking —
AudioTree.create/from_filetakefilepath=, notfilepaths=: the kwarg was the odd one out three ways — the property is.filepath, the manifest column isfilepath, and the siblingsource=is singular while also accepting a per-item list. The rule is that a plural name means many values per item (codes,latents), not many items;filepath,source,pitchandvelocityare one per item, and a list is just the batch axis.from_filereturns a batch of 1, so it accepts a single path or a one-item list; a longer list used to be stored as-is, silently misaligning the one-row-per-item provenance the moment such trees were batched, and now raises.create_audio_dataset(filepaths=...),scan_durations(filepaths)andprecompute_window_lufs(filepaths)keep the plural — those genuinely take many files.Breaking —
SaliencyParamsis nowExcerptConfig, built around a named strategy: the old object offered four spellings for three behaviors —saliency_params=Noneandenabled=Falseboth meant “offset 0”,enabled=True, lufs_cutoff=Nonemeant “random offset”, andenabled=True, lufs_cutoff=-40meant “search for a loud section”. Onestrategyfield now names them:"start","random"(the default) and"loudest". This fixes a silent data bug: because the dataset constructors defaulted toNone,create_audio_dataset(...)out of the box read the firstdurationseconds of every file on every epoch, andexcerpt_seedcontrolled nothing — while the docstring example claimed otherwise. The default is now a random offset.on_failuredecides what happens when no candidate clearslufs_cutoff."loudest"is a best-of-num_triessearch, not a filter, so it previously returned the quietest excerpt it found with nothing to distinguish that from success."keep"(default) preserves the old behavior,"skip"returnsNone(grain drops it atto_iter_dataset()), and"raise"raises naming the file.Knobs that do nothing now raise. Setting
lufs_cutoff,num_triesoron_failureunder a strategy other than"loudest", orsearchunder"start", was silent; it is now aValueErrorat construction. (searchis legal under"random", whose single offset it draws.)It is a plain frozen dataclass, not a
flax.struct.dataclass. It was registered as a JAX pytree whose leaves were[True, 8, -40.0, 'uniform']— including a string leaf — despite being pure CPU-side config that is never traced.search_functionis nowsearch, annotatedstr | Callablerather than lying withstr, and the two built-ins are module-levelsearch_uniform/search_bias_earlyinstead of staticmethods on the config.AudioTree.salient_excerpt()is nowAudioTree.loudest_excerpt(), takingexcerpt=.ExcerptConfigis exported fromaudiotree.sourcesas well asaudiotree— it is a sources concept and was missing from that namespace.create_balanced_audio_datasetrejectswindowtogether with a customizedexcerpt(previously with a non-Nonesaliency_params).
WindowParamsisWindowConfig, passed aswindow=(renamed after 1.0.0rc1, which shipped the old spellings): the class name matchesExcerptConfig, and thecreate_balanced_audio_dataset(window=...)keyword matchesexcerpt=.The merged
metadatadict is split: payload isAudioTree.extras, provenance moves to the privateAudioTree._metadatacontainer (finalized after 1.0.0rc1, which shipped the old merged dict — spelledmetadata, with user payload and the library’s bookkeeping side by side; no rc2 was ever published).extrasis the user’s dict of per-item array/string leaves that batch with the waveform — labels, embeddings, features — and it is entirely the user’s: the library plants no keys of its own there; every API spelling follows (create(extras=...),from_file(extras=...),tree.extras[...],replace_extras())._metadatais now a library-internal provenance container — private, as the underscore says, serialized on disk under the namemetadata— holding exactly the encodedfilepathandsourcearrays (fixed-width integer encodings that survive jit and batching) plus the plainoffsetarray (where in its source file each excerpt starts): users passfilepath=/source=/offset=tocreate()(or letfrom_fileand the loaders stamp them) and read the.filepath/.source/.offsetproperties, never the container itself, and its schema is closed — ametadatanode read off disk may contain onlyfilepath/source/offset, anything else is rejected by name, the same strict-validation stance as manifest columns. Each name is now honest —extrasis payload that trains with the audio, the per-item provenance container is data about the audio (where it came from) — and neither collides withTreeWriter(metadata=...), the true dataset-level metadata (the manifest’s top-level"metadata"key, read back viaTreeDataSource.get_metadata()), which keeps its name. On disk: aTreeWriterAudioTree node serializes both children (metadatawith thefilepath/source/offsetleaves,extraswith user leaves asextras.<key>), and anAudioWriterNPZ manifest storesfilepath,sourceandoffsetas dedicated bookkeeping columns, leaving theextras_<key>columns (masks__mask_extras_<key>) purely user payload. There is no read-side alias —TreeDataSourcerejects an unknown structure child by name, and the NPZ readers reject any unrecognized manifest column by name. For an rc1-era dataset the consequence is schema-shaped: a legacymetadatanode holding onlyfilepath/sourcereads correctly with its original meaning, while one holding user payload fails loudly at read and must be rewritten. The rewrite is mechanical: move payload leaf paths/files frommetadata.<key>toextras.<key>inmanifest.json(filepath/sourcestay put), and rename NPZ payload columnsmetadata_*/__mask_metadata_*toextras_*/__mask_extras_*andmetadata_sourcetosource(filepathwas already a dedicated column).Breaking — the three on-disk formats are versioned: a
TreeWriterdirectory, anAudioWriterNPZ manifest, and a windowed-LUFS cache each now carry a header (format,format_version,min_reader_version,producer), and every reader validates it. A major-version mismatch is refused; minor versions are additive so older readers keep working;min_reader_versionis what a writer raises when it adds a field readers must honor. Pointing a reader at the wrong kind of directory now fails by name instead of as aKeyError. PreviouslyTreeWriterstamped a"version": "2.0"that was never bumped for theaudio_data→waveformrename, the NPZ manifest had no version field at all, and the LUFS cache wrote a version its reader never checked (a hand-edited"99.0"loaded happily). Artifacts written by a pre-1.0 audiotree carry no header and are refused with an explicit “re-render the dataset” message — none of these layouts shipped in a release (1.0 is the first to containTreeWriter), so there is no released data to migrate.Breaking —
swap_stereodocumented “must be stereo” while no-opping on mono and reversing channel order for C>2. One behaviour now, identical in both backends.Derived fields invalidate in one place.
resample,to_monoandto_stereoclearedlufsbut leftcodesandlatentsstale — andencode_with_codecis idempotent, so resample-after-encode reused tokens describing the old audio. The transform layer had the same gap:volume_norm,volume_change,roll,trim, the phase transforms,rescale_audioandpeak_normall changed the waveform while preserving codec tokens for audio that no longer exists. One_invalidate_derived()helper now covers every length, rate, channel and energy changing operation on both surfaces;invert_phaseandswap_stereokeep the (genuinely unchanged) loudness but dropcodes/latents, which describe the un-negated samples and the un-swapped channel order.Public
asserts are exceptions. They vanish underpython -O, so every guarantee they enforced silently disappeared in a tuned training run.The two loudness engines agree closely, not exactly.
replace_lufs()measures with the exact ITU-R BS.1770 IIR meter on a NumPy waveform and withjaxloudnorm’s FIR-approximated K-weighting on a JAX one, choosing from the waveform’s own array library unless you passengine=. They are not bit-identical. Sincelufsis persisted as a manifest column and driveslufs_cutoffcorpus filtering, pinengine=explicitly when a corpus has to be reproducible across backends.Constructors and writers reject inconsistent input at the boundary, rather than accepting it and misbehaving later:
AudioTree.createrefuses afilepath/sourcelist whose length does not match the batch (it would otherwise misalign provenance);create_balanced_audio_datasetrefuses a group name present in bothsourcesanddatasets(it silently doubled that group’s share);create_windowed_audio_datasetrequires a positivedurationand, when loudness filtering is active, a positivelufs_window_sec(a non-positive value built a dataset of zero-length windows or filtered against a fabricated grid);AudioTree.createrejects a Python list/tuple of non-strings as anextrasleaf (jax.tree_utildescends into such a list, so indexing andfilterpassed it through at full length, silently misaligned with the batch — convert withnp.asarray; lists of strings remain a supported per-item leaf);rollrequiresmin_seconds <= max_secondson both backends (NumPy raised, JAX silently rolled every item bymin_seconds); andAudioTree.from_fileraises when a nonzerooffsetreads past the end of the file (padding the empty read out used to return full-duration silence stamped with an offset pointing at audio that does not exist — silent zero training data for any caller with an off-by-one, or seconds/samples confusion, in its own offset math); andAudioWriterrequires an{index}field in the filename pattern when writing audio (without it every item overwrote one file while the manifest recorded a row per lost item) and validates the manifest-column schema andfilepathcoverage on every write, so a drifting write raises at its own call — before its audio lands — instead of aborting at manifest save with the manifest unwritten.TreeWriter.writeis atomic and dtype-checked: it validates every leaf (batch size, per-sample shape, exact dtype, and string encodability) before touching any file, so a bad batch is rejected whole with nothing written, and a leaf whose dtype differs from the schema fixed on the first write now raises instead of being unsafe-cast into the memmap (anint16schema silently storing1e9read back as-13824). A failure during the commit poisons the writer soclose()cannot finalize a shifted dataset. Initialization is held to the same standard: the schema is recorded only after every leaf file is created and mapped (a failure creating a later leaf’s file used to leave more leaf names than memmaps, and the commit loop then silently dropped the trailing leaves of every subsequent write while reporting success — the files created so far are now removed and the writer poisoned); a 0-d array leaf is rejected by name (every array leaf needs a leading batch dimension) instead of dying in a bareIndexErrorafter files exist; and the constructor verifiesmetadata=is JSON-serializable up front, since it only meetsjson.dumpat manifest time — after the leaf data has committed — where a stray numpy scalar left an hours-long render with all its data and no manifest at all.The manifest format stores one type per column and refuses values it cannot round-trip: a column mixing logical types (e.g. a bool column containing an
int, or anintcolumn containing afloat) now raises rather than coercing (5 → True,2.5 → 2); a string or bytes value ending in a NUL raises (fixed-width<U/|Sstorage drops a trailing NUL, breaking the round-trip contract); and the column nametagsis reserved, sinceread_entriessynthesizes it from thetags_*columns.create_balanced_audio_datasetstamps the full provenance schema onto pre-builtdatasets:source=is set to the group name (overwriting anysourcethey carried), a missingfilepathis filled with empty strings, and a missingoffsetwithNaN— so an item built withAudioTree.create(which stamps none of the three) exposes the same per-itemmetadataschema as the file-based groups, and a batch spanning both no longer fails its pytree check on the missing keys — a failure that was intermittent, since single-group batches collated fine. The function’s own docstring example mixes the two. Whenchannelsis left to auto-probe (andmono=False), one file is probed and the count applied to every group, so two internally-consistent groups that disagree with each other (stereo music, mono speech) fail at load time with a filename instead of at batch time with a shape error that names nothing.Dataset seeds use their full width:
shuffle_seed/excerpt_seedwere masked to their low 32 bits before seedingnumpy.random.SeedSequence, so seeds differing only above bit 31 (e.g.1and2**32 + 1, or a 64-bit hash /time.time_ns()) produced byte-identical datasets. The full seed is now used; small-seed outputs are unchanged (masking a sub-2**32seed was a no-op), so existing runs reproduce.TreeDataSourcevalidates the whole manifest at construction, by name: beyond the format header and traversal checks, it now validatesstring_leavesentries (file type, in-directory path, existence), uses arbitrary-precision arithmetic for the leaf size check (an overflowingshape_per_samplecould wrap it to zero and pass), rejects a structure child colliding with a reader-set field such assample_rate, and requires the top-level keys — each a namedValueErrorat construction instead of an unnamedTypeError/KeyErrordeep in a grain worker. It also cross-checks container types and reference integrity:"leaves": []or adictnode withoutchildrenused to crash with a rawAttributeError/KeyErrorat first read, and a structure node referencing a leaf that exists in no leaf table silently dropped the field (a dangling root even leaked an internal sentinel object) — all are now named “Invalid manifest” errors at construction. The windowed-LUFS cache reader checks its required manifest keys the same way, before the bagz file is opened.A string
output_keyrequires exactly one in-scope leaf, andscopeentries reject unknown keys: with several in-scope leaves, a literaloutput_keymapped every result to the same name, so all but the dict-order-last transformed subtree was computed and silently discarded — costly with a codec transform; it now raises naming the colliding keys (use a callableoutput_keyfor several leaves). Non-AudioTree entries of a dict element (label arrays, ids) no longer participate in the rename at all: they used to count toward the single-leaf rule — so a stringoutput_keyon{"audio": tree, "labels": array}reported a spurious collision despite there being exactly one transformable leaf — and a callableoutput_keysilently duplicated each of them under the new name. A stringoutput_keyalso no longer costs picklability (it became alambdadefined inside__init__, which stdlib pickle refuses — quietly contradicting the picklable-transforms rule grain’s spawned workers rely on). Ascopedict entry carrying keys other than thescopesentinel — the shape one page of the docs wrongly taught as per-key parameter overrides — was silently misread as extra scope markers; unknown keys now raise, and the docs teach the real pattern (one scoped transform instance per key).Parameter validation happens at construction and identically on both backends: an inverted
min/maxrange involume_change/volume_norm/corrupt_phase/shift_phasemade NumPy raise numpy’s generic error per element while JAX silently clamped every draw to the minimum (a constant gain, no error); a clip shorter thanframe_lengthmade librosa quietly shrink the FFT while JAX crashed; andchoose()accepted weights that were negative or did not sum to 1, failing minutes later inside a grain worker. All three now raise the same namedValueErroron both backends, at construction where possible. Map-transform error messages stop advertisingprob/split_seed(which they reject), and a transform’sreprincludes non-defaultprob/split_seed/scope/output_keyinstead of rendering a constructor call that would rebuild a different transform.Breaking —
corrupt_phase/shift_phaserequirehop_factorin(0, 0.5]: above0.5the hann analysis windows no longer overlap-add to a constant, so evenamount=0.0— mathematically the identity — corrupted chunks of every frame athop_factor=1.0, and between0.5and1.0the NumPy and JAX backends silently disagreed on the tail samples (NumPy left the last partial hop unanalyzed; JAX padded and reconstructed it). Ahop_factorsmall enough to truncate to a zero-sample hop failed differently per backend (librosaParameterErrorvs. a bareZeroDivisionError). Both cases now raise the same namedValueErroron both backends, like the other transform parameter guards.volume_normtrusts a cachedlufs: it re-measured every element unconditionally, even when the tree arrived withlufspopulated — for instance restored from anAudioWritermanifest, whose whole point is to make re-measurement unnecessary — running the one-item-at-a-time CPU meter on every element of every epoch. It now measures only whenlufsis unset, the same rulenormalize_lufsandvolume_changealready follow; the cache is trustworthy because every operation that changes the audio clears it.AudioWriterpins each manifest column’s kind at its first write: a column whose values drifted in logical kind across writes (int rows, then a str row) passed everywrite()and aborted only at save/close — manifest unwritten, every WAV orphaned. The offendingwrite()now raises, naming the column and both kinds, before its audio lands; close-time validation stays as the backstop.create_balanced_audio_datasetrejects non-positive group weights by name:0, negative,NaNandinfweights previously fell through to grain’s mixer, which failed with errors naming neither the group nor the argument; omission, not zero-weighting, is the way to drop a group.AudioTree.excerptrequiresdurationhonestly: the parameter was annotatedOptional[float] = Noneand documented as optional while unconditionally raising onNone; it is now a required argument.
Removed¶
AudioDataSimpleSource,AudioDataBalancedSource,AudioDataBalancedDataset, andAudioDataSourceMixin: These deprecated classes are removed. Usecreate_audio_dataset()in place ofAudioDataSimpleSource, andcreate_balanced_audio_dataset()in place of the other three.AudioTree.from_array(): UseAudioTree.create()instead.num_recordsparameter: Removed fromcreate_audio_dataset()andcreate_balanced_audio_dataset(). Use.slice(slice(0, N))on the returned dataset instead. This simplifies the API and follows the principle of separation of concerns.Post-mix shuffle in
create_balanced_audio_dataset(): The automatic shuffle after mixing datasets has been removed. Theshuffleparameter now only controls whether files within each group are shuffled. If you need the mixed output shuffled, call.shuffle(seed=N)on the result. This change simplifies the API and gives users explicit control.Breaking —
ReduceBatchTransformis removed: it subclassed agrain._srcclass grain logs as deprecated and overrode three private members. Useds.to_iter_dataset().batch(n, batch_fn=AudioTree.batch)instead; the newAudioTree.batchcollation preserves the array library (JAX in, JAX out), so batching a device-resident tree is not a blocking host sync.
Fixed¶
The windowed-LUFS reduction no longer materializes every window: an 84.7 MB batch at the standard EBU setting (3 s window, 100 ms hop) allocated ~29× its input. It is a sliding sum, so it never needed the gather —
sliding_window_viewon NumPy,lax.reduce_windowon JAX. Peak RSS above baseline falls from 2451 MB to 338 MB (28.9× → 4.0× of input) and the reduction is 30× faster. It is also slightly more accurate than the gather it replaces: each window is summed over contiguous memory in ascending order, which reproduces a per-windowmean()exactly and sits 1.5e-06 from a float64 reference, where reducing the materialized(items, windows, span)copy blocks differently and drifts to 5e-05 on a multi-item batch (the two agree bit for bit only for a single mono item). JAX sits within 4.1e-06 relative of the NumPy path, i.e. float32 rounding rather than reordering.JAX
shift_phasedecorrelated stereo: it drew one phase angle per(batch, channel)where NumPy drew one per item, so a stereo pair whose channels start identical came apart —L-R maxdiff0.86, negatively correlated in half of the draws sampled. Both backends now rotate per item.corrupt_phasestill draws per channel on both, so the two transforms remain distinct.JAX
corrupt_phase/shift_phasezeroed the signal tail wheneverlength % hop_length != 0— 68 samples on a 1 s @ 44.1 kHz clip, an audible discontinuity. Corrected locally by padding to a hop multiple and trimming after.Mini-batched (rank-4) trees wrote nonsense instead of failing.
AudioWriter.writeread the mini-batch axis as the batch, shifting every axis below it: a 6-item(3, 2, 1, 800)tree produced three manifest rows claimingchannels=2, samples=1.write_audio=Truewas saved only by soundfile rejecting the shape; the manifest-only mode recorded it silently..filepath/.sourcenow decode per mini-batch rather than raising, and the operations with no rank-4 reading say which rank they got.TreeDataSourcevalidates leaf files against the manifest. A manifest claiming more samples than the.binholds surfaced as a barenp.memmaperror naming neither the manifest nor the leaf — and where memmap tolerated it, not at all. The bound is>=, not==, becauseflush()is public and a mid-write.binis legitimately longer.load_into_memory=Truehanded out writable aliases into the shared store, and returned empty samples in grain worker processes when the parent had read first.Breaking — two transform guards were
asserts, sopython -Oremoved them:BaseRandomTransform.__init__checked0 <= prob <= 1, andBaseTransformMixIn._post_processchecked that a transform with anoutput_keywas handed adict, both with a bareassert. Under-Othe first letprob=1.5through as “always” (andprob=-1as “never”), and the second let the rename path run on a non-dict element. Both now raise —ValueErrormatchingchoose()’s wording, andTypeErrornaming the type it actually got.Breaking —
TreeWriterwrote three thingsTreeDataSourcerefuses: a non-native-endian leaf recorded">f4"against a reader allow-list that accepts only native names; two leaves whose dot-joined paths collide ({"a.b": x}and{"a": {"b": y}}) shared onea.b.bin, soyvanished and both read back asx; and a leaf named"../escaped"wrote outside the dataset directory. Endianness is now normalized on write, colliding filenames raise naming both leaves, every write-side path goes through the samesafe_joinguard as the reader, and a dtype the reader would reject fails on the first write rather than after an hours-long render.Breaking — the loudness meter measured padding as if it were audio: excerpts shorter than one 400 ms BS.1770 gating block were zero-padded, diluting mean square by
dur / 0.4and under-reporting by exactly10*log10(dur/0.4)— a 0.2 s tone measured −6.05 LUFS where the truth is −3.01. The padded signal is now level-compensated before the meter, so the absolute and relative gates see the corrected level and the reading is duration-independent. Separately, the JAX FIR K-weighting was pinned at 512 taps regardless of sample rate and so could not realize the 38 Hz high-pass much above 16 kHz (agreement with the exact IIR path at 96 kHz improves from 1.52 dB to 0.008 dB), andlufsandlufs_windowswere computed with different K-weighting filters within one NumPy call — BS.1770 DeMan for the integrated path, pyloudnorm’s legacy RBJ approximation for the windowed one. Both now use BS.1770. These are corpus-affecting:lufsis a persisted manifest column and the input tolufs_cutofffiltering.Breaking —
split_seed=Falsemeant two different things per backend: JAX shared an immutable key, so every leaf drew identically; NumPy shared a statefulGenerator, so each leaf advanced the stream. A dry/wet pair identical on JAX came out 8.8 dB apart on NumPy — the grain/CPU default — silently training effect-modelling pipelines on decorrelated pairs.Breaking —
filter_by_tagandfilter_by_lufsdiscarded the receiver’s filter: both return anAudioDataSourceand read as a fluent API, but neitherfilter_fnnornum_recordswas carried onself, so each helper re-read the whole manifest from disk. Chainingfilter_by_lufsonto afilter_by_tagresult brought back exactly the records the user had excluded, and a constructor-suppliedfilter_fnwas dropped by a single helper call. Filters now compose.Breaking — per-group RNG seeds were bound to dict position: inserting or reordering a group in
sourcesswapped entire per-group streams, silently changing what every downstream run trained on. Seeds now derive from the group name.AudioTree.excerpt(search_function=)could not call either shipped searcher: it was invoked with four positional arguments whilesearch_uniform,search_bias_early, and everything_resolve_search_functionreturns need six, and passing a registered name failed with'str' object is not callable. Two incompatible searcher protocols lived in one module;excerptnow takes anExcerptConfiglikeloudest_excerpt, leaving one signature and one resolver.t[np.int64(i)]silently dropped the batch axis:__getitem__special-cased only the builtinint, sot[np.argmax(t.lufs)]— or any loop overnp.arange(n)— scalar-indexed every leaf and returned a tree whosebatch_sizereported the channel count. On a waveform-only tree nothing raised at all. Integer scalars of any array library now slice correctly;boolis excluded sot[True]no longer selects index 1. A Python list or boolean-mask key is normalized to an array once, up front, so it indexes a JAX-backed tree the same way it indexes a NumPy-backed one (JAX arrays reject a bare list key).create(filepath="a.wav")on a batch tagged only row 0 despite the docstring promising it tags the whole batch, sot[2].filepathwas empty and anyfilterdropping item 0 lost provenance entirely — which then landed in written manifests. Same forsource=.prob < 1was unusable in two more configurations: combined withoutput_keyit raised a pytree structure error on every seed while the constructor merely warned, and on the NumPy backend atbatch_size == 1it made the per-element pytree structure random, soAudioTree.batchdied downstream — the README’s canonicalrandom_map→batch(batch_fn=AudioTree.batch)shape.Dict, list, and tuple transform parameters: dict-valued parameters were silently replaced by their default (
_get_config_valcompared a dict’s inner key against the parameter name, and leaked that key into the sibling-parameter namespace); list and tuple parameters crashed outright.Decorated transforms could not have required parameters, and reported an “unexpected parameter” that the decorator’s own
__signature__advertised. The commonprob/split_seed/scope/output_keydocumentation was appended after the Example block, so none of the 28 transforms documented any of it. Each now has exactly the reserved parameters it accepts spliced into its ownArgs:block — all four for a random transform,scope/output_keyfor a map transform, which is also the only pair a map transform will accept.resamplecrashed on bf16/fp16, and on fp32 underjax_enable_x64— the sinc kernel took JAX’s global default dtype rather than the input’s. The equal-rate short-circuit also skipped every argument check, acceptingoutput_length=-5andfull=Truewithoutput_lengthonly for the files that happened to already be at the target rate. The convolution now runs atHIGHESTprecision and the kernel is cached.Silently-ignored arguments now raise: unknown
weightskeys,durationcombined withwindow, and an emptysourcesmapping (which failed withmin() iterable argument is empty). A source matching zero files warns instead of shrinking the corpus in silence,pathlib.Pathis accepted wherever astrpath is, and a mixed-channel corpus names the offending file and its channel count rather than failing opaquely at batch time.show_progress=Truewas a silent no-op:tqdmwas imported but declared in no dependency, extra, or group, and theImportErrorwas swallowed. It now raises with an install hint, and there is aprogressextra.Every guide snippet was executable fiction: the 1.0 renames landed in the source but not in the guides, which use
code-block:: pythonrather than doctests — so the docs build stayed green while three guides constructedExcerptConfig(enabled=True)and two passed aseed=that had been split in two.tests/test_docs_snippets.pynow extracts all 154 Python blocks underdocs/source/: every block must parse, and every call to a known audiotree callable is matched against the liveinspect.signature, so a renamed keyword fails even in a snippet that could never run for want of a corpus. Blocks that can run are then executed against a synthetic corpus built from the placeholder paths the guides themselves name; one that legitimately cannot opts out with a.. skip-snippet-exec:comment and a reason, and stays subject to every other check.The
examples/scripts run again, and CI runs them: all three were broken against the renamed loudness API (replace_loudness(),.loudness,filter_by_loudness), so a user’s first contact with the library tracebacked immediately, andtestpathsexcludedexamples/so nothing went red.writer_datasource_example.pyadditionally wrote one item without apitchfield, whichAudioWritercorrectly rejects.Silence no longer normalizes to an all-NaN waveform: an item whose measured loudness is not finite has no gain that reaches a target (
target - (-inf)is+inf, and0 * infisNaN).AudioTree.normalize_lufs()and thevolume_normtransform (both backends) scaled by that gain and then stamped the target LUFS on the result, so a silent item became an all-NaN waveform advertising itself as, say, −18 LUFS. This was never limited to digital silence: anything below the BS.1770 absolute gate reads-inf, which zero-padded short reads andtrim(mode="constant")produce routinely, and one such item silently poisons every batch it lands in. Non-finite items are now passed through unscaled and keep their-inf, so they stay identifiable.normalize_lufs()also gains an opt-inmax_gain_dbceiling so a very quiet but still measurable item is not amplified without bound.probis drawn per batch item, and works on the JAX backend:random.bernoulli(key, p=prob)was called with no shape, so one scalar draw was broadcast across the whole batch —prob=0.5on a batch of 32 augmented all 32 items or none. The marginal rate was correct, so this was invisible in aggregate while removing exactly the within-batch diversityprobexists to provide. Masking also went throughjax.tree.mapover the transformed and original trees, which requires identical treedefs, so any transform that nullslufs(shift_phase,corrupt_phase,roll) or adds an extras key crashed outright atprob < 1, as did every transform on a tree carrying the string-encodedfilepathprovenance thatfrom_filealways sets — makingprob < 1unusable in the JAX namespace on anything from the data loader. Selection is now per AudioTree leaf with a(B,)mask, canonicalizing a field that only one side populates so the output structure never depends on the draw. A transform that changes a field’s shape cannot be mixed item-by-item and now raises a clear error. Theprobkey is also reserved before the per-leaf keys are derived; previously, for a two-leaf tree, the Bernoulli draw reused a leaf’s transform key.AudioDataSourceno longer corrupts array fields, dtypes, or-1/NaN values: array-valued fields lost their batch axis, becauseAudioTree.from_fileonly adds one to a scalar —lufs_windowscame back(W,)andcodes(codebooks, frames), soAudioTree.batchconcatenated along the wrong axis and interleaved one item’s tokens into the next (a one-windowlufs_windowsdegraded to 0-d and made batching raise). Stored dtypes were re-cast, so an int32pitchread back float32 and an int32velocityread back int16. And-1,NaN, and""were treated as “missing” and dropped, though nothing inAudioWriterwrites them as sentinels: an item withvelocity=-1came back with a different pytree structure than its siblings and broke batching for the whole dataset. Every AudioTree field now gets an explicit batch axis on read and keeps its stored array and dtype. The derived fields (lufs,lufs_windows,codes,latents) are restored only when the source’ssample_rate/mono/durationleave the audio as it was written — they describe the written audio, and restoring them onto a resampled, mono-folded or trimmed load silently attached measurements of audio the caller was not getting. Annotation fields (pitch,velocity,note_duration) are restored either way.TreeWritervalidates leaf names on every write:write()checked only the leaf count and then zipped positionally, while leaf extraction walks dict keys in sorted order. Writing{"a", "c"}after{"a", "b"}therefore storedc’s data inb.binand the reader returned it under the key"b", with no error anywhere.sample_ratehad the same problem — captured once from the first write, so a later tree at a different rate was permanently mislabelled (AudioWriteralready raised on exactly that, so the two writers disagreed). The structure is now re-derived per write and must match, and the error names the differing leaves.TreeWriterpublishesmanifest.jsoneagerly: the manifest was written only at the bottom ofclose(), andflush()flushed the memmaps but not the manifest, so a multi-hour pre-render killed at 99% left valid.binfiles thatTreeDataSourcerefused to open at all. The manifest is now written as soon as the schema is known and refreshed fromflush(), via a temp file plusos.replaceso a concurrent reader never sees a partial one. A crashed render leaves a readable prefix.roll()invalidates the stale recordedoffset:AudioTree.from_filerecords the source-file time of sample 0 as theoffsetprovenance (read via.offset). Sincerollshifts the waveform along the time axis (in both"wrap"and"constant"modes), that offset no longer points at sample 0 and is now set toNone— mirroring howrollalready invalidateslufs_windowsin both modes, andlufsin"constant". The invalidation survivesprob < 1: the per-item blend used to carry the original metadata through untouched, quietly re-attaching the stale offset to exactly the items that were rolled. Trees without a recorded offset are unaffected. Applies to both the NumPy and JAX backends.Stale loudness after length/channel changes:
trim(),roll(mode="constant"), andAudioTree.to_stereo()(mono→stereo) now invalidate the cachedlufsfield, since changing the audio length, zero-padding, or duplicating a channel all change the integrated loudness.roll(mode="wrap"),invert_phase(), andswap_stereo()continue to preservelufsbecause they leave it unchanged.Stale loudness after phase transforms:
corrupt_phase()andshift_phase()now invalidate the cachedlufsfield by default. Their phase changes leave the magnitude spectrum (and thus energy) intact, so loudness is approximately unchanged, but the cached value is dropped to be safe. Passkeep_lufs=Trueto retain it.Excerpt diversity in
create_balanced_audio_dataset():record_keywas used as both an array index and an RNG seed, severely limiting excerpt diversity when datasets were repeated. Fixed by therandom_maprefactor (see Changed).Manifest readers restore the source
filepath:AudioWriterrecords each item’s source path as a top-levelfilepathmanifest column (not under anextras_prefix), but both readers previously dropped it.AudioTree.from_manifest()ignored the column entirely (soloaded.filepathcame back empty even though the paths were on disk), andAudioDataSourcereported the on-disk output path (or nothing, for manifest-only datasets) rather than the recorded source path. Both now restore the recorded source path into thefilepathprovenance, so.filepathmatches what was written and the two readers agree;from_manifestkeeps it aligned with the selected rows when afilter_fnis used. Manifests written without source paths are unaffected —from_manifestleaves.filepathempty, andAudioDataSourcestill falls back to the output audio path.output_keytransforms on dict elements: a transform built withscope=[...]plusoutput_key=...raisedIndexErroron the first batch whenever the scoped-out key sorted alphabetically first (jax rebuilds dicts in sorted-key order, so{"dry", "wet"}withscope=["wet"]failed deterministically on both backends), and when theoutput_keycollided with an existing key the merge silently kept the original instead of the transformed value (a pipeline regenerating"wet"each epoch trained on un-augmented audio). Both are fixed; the transformed value now wins for the key the user asked to write.AudioTree.split/filter/batchpreserve list-of-strings extras and work on token-only trees:splitsliced string-list extras (a supported leaf) character-wise rather than element-wise (["a.wav", "b.wav"]became["a.", "b."]), andfilter/batchthen crashed on such trees; separately, these batch-axis operations dereferencedwaveform.shapeand raisedAttributeErroron a token-only tree (waveform=None, data incodes/latents). Both now read the batch axis from whichever leaf is present and treat a string list as one leaf.AudioTree.from_filehonorsdurationwithout a target rate:target_lengthwas computed only when bothdurationandsample_ratewere given, sofrom_file(path, duration=...)at the file’s native rate returned a short file unpadded — despite the docstring’s unconditional promise — producing variable-length trees that crashed at batch time far from the cause. It now pads/trims todurationat the effective rate.Manifest round-trips array-valued and large/precise scalar columns: an array-valued string column came back as the literal repr string
"['alice' 'bob']"(bytes as a raw fixed-width buffer) because the decoder stringified sub-arrays; and Pythonint/floattags were stored asint32/float32, so an int>= 2**31raisedOverflowErrorat save time (losing the whole manifest) and afloat64value silently lost precision. Array cells are now returned intact, and bare Python scalars widen toint64/float64. A scalar column’s dtype is the common type of all its rows, not the first row’s: pinning the first width silently narrowed later wider floats (out-of-range values becameinfwith only a numpy warning) and raisedOverflowErroron later wider ints — atclose(), with every audio file already on disk and the manifest unwritten.int64next touint64, whose only common numpy type is a value-roundingfloat64, is refused by name.codec transforms are picklable:
encode_with_codec/encode_latentsreturned instances of closure-minted classes that could not be pickled, so they failed at spawn time in grain multiprocess workers — unlike every other transform. They are now module-level classes holding the codec as an attribute; behavior is unchanged.TreeWriter.closeno longer bricks a zero-size leaf: a leaf with a zero-size-per-sample shape (e.g.(batch, 0)) was truncated to 0 bytes at close, which cannot be memory-mapped, so the entire dataset raised “cannot mmap an empty file” at first access. Truncation is now floored at one byte, matching allocation.save_window_lufswrites its cache atomically: the bagz writer was closed with a bare call and the file written in place before the manifest, so a mid-write failure leaked the writer and could pair an oldmanifest.jsonwith a partiallufs.bagz. It now closes intry/finallyand writes to a temp path thenos.replace, matching the manifest’s atomic write.String extras leaves survive every batch-axis operation: a bare
strleaf (the batch-of-1 formTreeWriterdocuments andTreeDataSourceproduces) crashedAudioTree.batch— including the batch docstring’s own grain recipe — andList[str]leaves broket[[0, 2]]/t[mask]indexing, were retained in full by a keep-nothingfilter(), and stayed flat underreshape_mini_batchesso indexing a mini-batch misattributed provenance. Every batch-axis operation now recognizes both string-leaf forms through one shared helper trio, and a torture sweep of both forms through every operation (now a test module) surfaced and fixed three sibling sites: strings next to trees inbatch()were zipped into nested garbage, an emptied tree could not be re-filtered or re-batched, and a string leaf whose length disagreed with the batch slipped throughreshape_mini_batchesunchecked.filter/spliton an empty tree also no longer raiseZeroDivisionError, andsplit(0)says what is wrong.The NumPy loudness engine enforces the documented 5-channel limit:
replace_lufspromised aValueErrorabove five channels and the JAX engine raised one, but the NumPy engine silently computed a value; the check now runs before engine dispatch. Alufs_hop_secshort enough to round to zero samples raises aValueErrornaming the argument instead of a bareZeroDivisionErrorfrom inside the window math.Array-valued manifest columns enforce one kind per column: the ndarray branch of the column encoder checked only shape, so
np.stacksilently promoted mixed rows (int + float →float64, int + str → stringified'<U21'), violating the read-side contract that values keep the dtype they were written with; mixed kinds now raise naming the column, while widening within one kind (int32 + int64,<U3+<U8) remains allowed and lossless. Trailing-NUL string/bytes leaves inside list rows are rejected like their scalar counterparts.get_stats()["total_batches"]counts batches again: the timestamp was minted once per item, so counting distinct timestamps returned the item count;write()now stamps the whole batch once.
Security¶
Manifest-driven paths are confined to the dataset directory: a manifest travels with the data it describes, so a shared, mirrored or downloaded dataset hands the reader a file naming the paths it will open.
TreeDataSource,AudioDataSourceand the windowed-LUFS cache reader all concatenated a manifest-supplied filename onto a base directory with no validation, andpathlibdiscards the left operand when the right is absolute (Path("/data") / "/etc/passwd"is/etc/passwd) and does not normalize... A tamperedmanifest.jsontherefore returned the contents of any file the training process could read aswaveform, from where it flows into the model, the checkpoints and any pre-render output. All such joins now go through a guard that rejects absolute paths,..segments, and anything resolving outside the dataset directory (including via a symlink). The rest of the manifest is validated too —num_samples, each leaf’sshape_per_sampleanddtype(allow-listed to fixed-width numeric types), and everychildrenkey against the realAudioTreefields, which were previously splatted into the constructor as-is.Manifests are no longer unpickled: both readers loaded
manifest.npzwithallow_pickle=True. A manifest travels with the data it describes, so anyone who shares, mirrors or downloads a pre-rendered dataset hands the reader a file that gets unpickled — arbitrary code execution in every data worker. The flag was on only because string columns were stored as object arrays; the newaudiotree._manifeststores them as fixed-width<U(bytes as|S) with an explicit__mask_<column>presence mask, soallow_pickle=Falseworks. A side effect worth having: absence is now carried by the mask alone, so a stored-1,NaNor""round-trips as itself where the old reader dropped empty tag cells. A pre-1.0 pickled manifest is refused by name rather than executed.AudioTree.from_manifestno longer opens arbitrary files: it joined manifest-supplied filenames withaudio_dir / filename, andpathlibdiscards the left operand when the right is absolute and does not normalize..— so a tampered manifest returned the contents of any readable file aswaveform. The 1.0 traversal guard coveredsources/and missed this call site, becausefrom_manifestwas a second, divergent reader of the same file; it is now expressed on the shared parser and routed through_fs.safe_join.safe_join’s..check also consulted onlyPurePosixPath, to which..\..\etc\hostsis one opaque part; it checks both flavours now.Breaking —
ExcerptConfig.searchis resolved by name, noteval():loudest_excerptcalledeval()on a field deliberately typedstrso argbind can bind it from YAML, making a config file arbitrary code execution in every data worker. It was not even usable as an extension point, sinceevalran inaudiotree.core’s namespace and only the two built-ins ever resolved. Names are now looked up in a registry ("uniform","bias_early") with animportlibdotted-path fallback ("mypkg.offsets.my_search"), and resolution happens in__post_init__so a typo raises when the config is built rather than minutes into training. The default is"uniform". The pre-1.0 spellings ("SaliencyParams.search_uniform","SaliencyParams.search_bias_early") are not carried over —SaliencyParamsitself becameExcerptConfigandsearch_functionbecamesearch— so an existing config has to be updated to the short names.
[0.2.0] - 2025-02-17¶
jithas been removed in most places. We encourage users to jit as late as possible.New class:
AudioDataBalancedDataset, which is a grain Dataset, not a Data Source.AudioTreehas a.latentsproperty.New transform:
NeuralLatentEncodeTransform.Class
NeuralAudioCodecEncodeTransformhas been adjusted. The arg is nowencoder_fnand it takes anAudioTreeinstead of an audio data array.In an
AudioTree’s metadata, the offset and duration will now be 1D arrays instead of 0D arrays.cpuhas a kwarg has been removed in most places. You should think of AudioTrees as existing on CPU by default. If you pass them to a jitted function then they will be put on device.In,
AudioDataSimpleSourceandAudioDataBalancedSource,num_stepsarg is nownum_records. Also._filepathsproperty is now.filepaths.
[0.1.0] - 2024-08-22¶
Breaking changes:¶
SaliencyParamshas moved fromaudiotree.datasources.SaliencyParamstoaudiotree.SaliencyParams
Updates:¶
The code has been tested with device parallel sharding.
See the recent updates to DAC-JAX.
SaliencyParams has a new search_function parameter.
The two valid strings are SaliencyParams.search_uniform and SaliencyParams.search_early_bias.
You can also plug in your own Callable function.
[0.0.5] - 2024-08-08¶
First release.