Scene collections
A SceneCollection is a container for AudioScene objects built for large-scale dataset generation. Rather than building and rendering scenes one at a time, it lets you generate hundreds or thousands of varied room-and-source combinations, inspect and filter the collection before rendering — for example by predicted SNR or reverberation time — and then render the scenes that meet your criteria in batch. The collection also supports Parquet serialization so you can persist and reload it across sessions.
There are two ways to create a SceneCollection: assembling it manually from individually built scenes, or generating it automatically from a set of rules using SceneGenerator.
For information on rendering the resulting collection, see Scene collection rendering.
Basic examples
Manual assembly
Create an empty SceneCollection by passing a TSDK client and a data loader, then add scenes with add_scene() or add_scenes(). Prefer add_scenes() when appending many scenes at once. The data loader must be created from the same IRCollection the scenes use, and controls where IR data is cached on disk.
Set the data loader once, on the SceneCollection itself, rather than on each individual AudioScene you build for it: when a scene is added via add_scene()/add_scenes(), the collection's own data loader overwrites whatever data loader that scene already had — including none at all — so every scene in the collection ends up sharing it.
IRCollection.get_data_loader() derives its source and receiver id mappings from that specific collection's simulations. A data loader built from a different IRCollection won't resolve the IRs the scenes actually reference, so it must come from the same collection the scenes' IRs were drawn from.
add_scene() is functionally equivalent to calling add_scenes() with a single-item list — but add_scenes() batches the data-loader propagation and schema resolution instead of repeating it per call, so prefer it when adding many scenes at once.
from pathlib import Path
from treble_tsdk import treble
import treble_tsdk.scene as sc
from treble_tsdk.collections.scene_collection import SceneCollection
tsdk = treble.TSDK()
ir_collection = tsdk.datasets.purchase("Treble10")
# The data loader controls where IR files are cached on disk between sessions.
data_loader = ir_collection.get_data_loader(work_dir=Path("<work_dir>") / "cache")
# Only needed for SNR prediction (see "Predicted scene SNR" below) — skip this if you
# don't plan to call enrich_with_predicted_snr() on the collection.
ir_collection.enrich_with_acoustic_parameters(acoustic_parameters=["spl"])
scene_collection = SceneCollection(
client=tsdk,
data_loader=data_loader,
)
scene_collection.add_scene(scene_1)
scene_collection.add_scenes([scene_2, scene_3])
See Audio scene for how to build individual AudioScene objects to add to the collection.
Automated generation with SceneGenerator
SceneGenerator automates collection creation at scale by combining a set of rules with an IRCollection to produce a SceneCollection of randomized scenes. Depending on the set of rules, each scene could have randomly assigned sources and receiver, track content, and listener orientation drawn from the ranges you define. The examples below assume you already have an IRCollection, a data_loader created from it, and at least one AudioDataset — see Audio scene for how to prepare these.
Source groups
SceneRules is the top-level container for all generation rules. Add SourceGroup entries to it, each describing one category of sources in the scene. A SourceGroup declares:
- a
TrackGeneratorto produce audio tracks - a
source_selectionPolars expression to filter which IRs are eligible for that group - source count constraints via
n_sourcesandmin_n_sources - a semantic tag via
group_tag - an allocation priority via
priority_on_ir_collection - a
duration_soverride for this group alone, in place of the scene's own duration - a
start_time_soffset, in seconds, for when this group's sources start relative to the scene - whether the same IR can be selected for more than one source in this group, via
sample_ir_with_replacement
The following example defines a speech group and a noise group, using the speech_dataset and hvac_dataset loaded in Dataset loading:
import polars as pl
from treble_tsdk.collections.ir_info import IRInfo
# The new column is attached to the IR collection dataframe and can be in source_selection later.
def is_noise_source(ir_info: IRInfo):
return ir_info.source.z > 1.9
ir_collection.add_column("IS_NOISE_SOURCE", is_noise_source)
# Only needed for SNR prediction (see "Predicted scene SNR" below) — skip this if you
# don't plan to call enrich_with_predicted_snr() on the collection.
speech_dataset.enrich_with_spl()
hvac_dataset.enrich_with_spl()
scene_rules = sc.SceneRules(duration_s=20)
# Speech group — three foreground talkers drawn from non-elevated sources.
speech_tracks = sc.TrackGenerator(
audio_dataset=speech_dataset,
rules=sc.ConversationRules.from_preset(
sc.ConversationRulesPresets.sequential_talkers_increased_overlap,
in_track_level_range_db_spl=(69, 70),
),
talker_identifier="speaker_id",
)
scene_rules.add_source_group(
sc.SourceGroup(
name="conversation",
tracks=speech_tracks,
source_selection=(~pl.col("IS_NOISE_SOURCE")), # exclude elevated/ceiling sources
n_sources=2,
min_n_sources=2, # skip the scene if fewer than 2 eligible IRs are available
group_tag=sc.GroupTag.TARGET,
)
)
# Noise group — elevated sources only.
noise_tracks = sc.TrackGenerator(
audio_dataset=hvac_dataset,
rules=sc.NoiseSourceRules(
free_field_level_db_spl=(55, 56),
reuse_single_sample=True, # use the same clip for all sources (e.g. music to loudspeakers in a bar)
),
)
scene_rules.add_source_group(
sc.SourceGroup(
name="noise",
tracks=noise_tracks,
source_selection=pl.col("IS_NOISE_SOURCE"),
min_n_sources=1,
group_tag=sc.GroupTag.BACKGROUND,
)
)
A SourceGroup with none of source_selection, priority_on_ir_collection, n_sources, or min_n_sources set is an "auto n_sources" group: it splits whatever IRs remain, after every other group has claimed its own, evenly across all such groups on that receiver.
Set priority_on_ir_collection (a GroupPriority: HIGH, MEDIUM, LOW, or NONE, default NONE) to control which group gets first pick of IRs when multiple groups compete for a limited or filtered pool. Groups are sorted by priority first, then by whether they set source_selection, then by whether they set an explicit n_sources, then by min_n_sources — each tier only breaking ties in the one before it. A group sorted earlier claims its IRs before groups sorted later, so give a group most likely to be starved (a narrow source_selection, or a high min_n_sources) a higher priority. The noise group defined above would claim its IRs before any lower-priority group with:
sc.SourceGroup(
name="noise",
tracks=noise_tracks,
source_selection=pl.col("IS_NOISE_SOURCE"), # a narrow, filtered pool of IRs
min_n_sources=1,
priority_on_ir_collection=sc.GroupPriority.HIGH, # claim its IRs before lower-priority groups
group_tag=sc.GroupTag.BACKGROUND,
)
For a group of sparse, discrete events (a door slam, a car horn) rather than continuous background, use TransientNoiseRules instead of NoiseSourceRules — see Transient (one-shot) track generation.
Listener rules
ListenerRules describes how scenes are captured at the receiver. Pass it to SceneRules.set_listener(). It accepts a device, an orientation, and device noise and filter specifications via DeviceSpecs.
orientation takes one of two forms: a fixed Rotation — the same one shown in Scene listener configuration for a single manually built scene — applied identically to every generated scene, or an OrientationRange that resolves to a different randomly sampled rotation per scene, as shown below. device and orientation must be set together — neither is allowed without the other.
device = tsdk.device_library.get_device_by_name("KEMAR051123_1")
device_specs = sc.DeviceSpecs(
noise_rules=sc.StaticNoiseRules.from_noise_type_and_level(
noise_type=sc.StaticNoiseType.mems_noise_profile, # generic spectral profile of MEMS mic
level_db_spl=(20, 25), # randomized per scene within this range
),
filter_definitions=[treble.ButterworthFilter(hp_order=2, hp_frequency=80)] # Emulate the microphone's frequency response
)
scene_rules.set_listener(
sc.ListenerRules(
device=device,
orientation=sc.OrientationRange( # Randomize orientation within a range
azimuth_range=(-180, 180),
elevation_range=(-10, 10),
roll_range=(0, 0),
),
device_specs=device_specs,
receiver_selection=pl.col("receiver_label")=="SpatialReceiver_1", # restrict which receivers are eligible
)
)
device_specs has no dependency on device being set: with no device set, the scene's receiver renders as a single mono channel, and noise_rules/filter_definitions still apply to that one channel, so a plain mono ListenerRules can still carry microphone self-noise or a frequency-response filter. receiver_selection filters which receivers in the IR collection are eligible for generation at all (None, the default, makes every receiver eligible). When more than one receiver is eligible, a new scene draws one at random from that eligible set each time it's generated — this is a consequence of allow_config_reuse defaulting to True on generate_scenes(); set it to False there instead if you want scenes to cycle through the eligible receivers in order rather than reusing them at random.
DeviceSpecs.filter_definitions takes a list[FilterDefinition] — the same pipeline shape used elsewhere in Postprocessing. Every filter in the list is applied, in order, to each device or microphone channel identically; it's not possible to give different channels different filters through this parameter.
StaticNoiseRules.from_noise_type_and_level() above targets an absolute level_db_spl. Use from_noise_type_and_microphone_snr() instead when the number on hand is a datasheet SNR spec rather than an acoustic level — it scales the noise so a 94 dB SPL, 1 kHz reference tone would sit microphone_snr_db dB above the noise floor after A-weighting, matching how microphone datasheets actually quote SNR. Pass profile_band_jitter_db_range to either constructor to jitter each band of the profile independently per scene, for realistic unit-to-unit variation instead of every generated scene sharing bit-identical noise shaping:
device_specs = sc.DeviceSpecs(
noise_rules=sc.StaticNoiseRules.from_noise_type_and_microphone_snr(
noise_type=sc.StaticNoiseType.mems_noise_profile,
microphone_snr_db=(60, 65), # from the microphone's datasheet
profile_band_jitter_db_range=(-1, 1), # per-band variation, sampled independently per scene
),
)
Scene collection generation
Pass the IRCollection and SceneRules to SceneGenerator, then call generate_scenes(). The generator iterates over receiver positions in the collection, assigns sources to IRs respecting priority and filter constraints, and randomizes track content for each scene. Use max_n_scenes to cap the number of scenes produced.
Pass a data_loader to SceneGenerator itself, not just to the SceneCollection it produces — the two are separate objects, so setting one doesn't set the other. SceneGenerator propagates its data_loader to every AudioScene and to the SceneCollection it builds, which is what makes IR data get cached to disk from the very first generate_scenes() call, rather than only once you separately set one on the resulting collection.
SceneGenerator requires the IRCollection to be unprocessed — it rejects a collection that already has device-render columns or processing_filters applied.
scene_generator = sc.SceneGenerator(
ir_collection=ir_collection,
scene_rules=scene_rules,
data_loader=data_loader, # propagates to every generated AudioScene and to the SceneCollection
)
# Generate 100 scenes
scene_collection = scene_generator.generate_scenes(max_n_scenes=100)
Pass random_seed to make the batch reproducible — the same seed, with the same ir_collection, scene_rules, and datasets, regenerates the exact same scenes: the same configurations, IRs, audio samples, levels, and orientations, not just a similar distribution:
scene_collection = scene_generator.generate_scenes(max_n_scenes=100, random_seed=42)
This reproducibility guarantee depends on the other inputs staying fixed too. A different row order in the IRCollection or in an AudioDataset — for example a HuggingFace dataset shard set that changed upstream, or a differently filtered ir_collection — indexes into different choices even with an identical random_seed, because the seed reproduces the sampling process, not a snapshot of what was sampled from.
generate_scenes() first plans every valid (receiver, simulation, per-group IR selection) configuration once, then draws scenes from that pool. allow_config_reuse (default True) controls how: with reuse allowed, each scene draws a configuration at random from the full pool, so the same configuration — including the same receiver — can be reused across scenes, and max_n_scenes is always reached as long as max_attempts isn't exhausted first. Set it to False to consume planned configurations in order instead, without reuse:
# Cycle through each valid configuration once, instead of reusing them at random.
scene_collection = scene_generator.generate_scenes(max_n_scenes=100, allow_config_reuse=False)
With allow_config_reuse=False, generation stops once the planned configurations run out, even if max_n_scenes hasn't been reached yet — the resulting collection can have fewer scenes than requested. If max_n_scenes exceeds the number of feasible configurations, it's silently capped down to that number, with a warning logged through the plain Python logger.warning(...) rather than into the structured log described below.
Pass generate_logs=True to see how much of the full design space a batch actually covered. Feasibility planning — the first step generate_scenes() performs — enumerates every valid (receiver, simulation, per-group IR selection) configuration once; that total count isn't exposed anywhere else, so reading it back requires the log:
scene_collection, log = scene_generator.generate_scenes(
max_n_scenes=200, random_seed=1, generate_logs=True,
)
planning_entry = next(e for e in log.entries if e.message == "Feasibility planning completed.")
n_feasible_configs = planning_entry.context["valid_candidates"] # total distinct configs found
n_generated = len(scene_collection) # how many were actually sampled
Report n_generated against n_feasible_configs alongside any other summary of a generated SceneCollection — it distinguishes "sampled 200 scenes broadly covering a 5,000-configuration space" from "sampled 200 out of only 210 configurations that exist," which changes how much diversity the collection actually has. This is especially relevant with allow_config_reuse=False, where hitting the ceiling silently shrinks the collection as described above.
log.warnings (entries with level == "warning") surfaces warnings raised while assembling individual scenes — for example a source group ending up with fewer tracks than its requested n_sources — each tagged with the simulation_id/receiver_id of the scene it happened in. log.print() gives a human-readable dump of every entry grouped by receiver and simulation.
generate_scenes() runs on one stateful, order-dependent sampling engine shared across the whole batch, so a single call can't be parallelized or split across processes without breaking both coverage and the random_seed guarantee above. To scale out generation across machines or processes, call generate_scenes(random_seed=seed_i) independently per batch with a different seed each time, then merge the resulting collections with add_scenes():
batches = [
scene_generator.generate_scenes(max_n_scenes=100, random_seed=seed)
for seed in (1, 2, 3)
]
merged = batches[0]
for batch in batches[1:]:
merged.add_scenes(batch.scenes)
The resulting SceneCollection supports indexing, slicing, iteration, and dataframe access:
scene_collection[0].plot()
scene_collection.dataframe.head(5)

Use generate_scene(duration_s=None, random_seed=None, scene_listener=None) to produce a single AudioScene directly, without going through a SceneCollection. It's simpler than generate_scenes() but doesn't reuse a precomputed feasibility plan across calls:
scene = scene_generator.generate_scene(random_seed=1)
Predicted scene SNR
enrich_with_predicted_snr() estimates the broadband SNR for each scene without rendering. It operates on per-band octave SPL values already attached to the source samples and IRs, so it runs fast and avoids the full convolution step. Use it to sanity-check your scene configuration, filter out degenerate setups, or select source–IR combinations likely to fall within a target SNR range.
This requires the IR collection and every audio dataset feeding a track to already be enriched — ir_collection.enrich_with_acoustic_parameters() in Manual assembly above, and speech_dataset.enrich_with_spl()/hvac_dataset.enrich_with_spl() in Source groups above. Then pass a source group name, track index, or list of track indices as target_selection:
# Add a predicted SNR column to the collection dataframe.
scene_collection.enrich_with_predicted_snr(target_selection="conversation")
# Retrieve the predicted SNR for a specific scene index.
scene_collection.get_predicted_snr(scene_id=scene_collection[0].id, target_selection="conversation")
The prediction is estimated for a single channel represented by the Mono IR, not for the device's captured signal. It does not account for the device IR or device filters. Device noise is accounted for, but averaged over all device microphones into that single channel.
Call plot() on the SceneCollection to display a histogram of the predicted SNR across all scenes:
scene_collection.plot()

The predicted SNR has an expected error of −0.2 dB mean and 1.5 dB standard deviation relative to the SNR measured from rendered audio.
The main sources of deviation are:
- Mono-channel, no device transfer function/HRTF. The prediction is computed for a single (mono) channel only, never the true multi-channel signal an actual device would capture. It does not model the device's own transfer function/HRTF at all — it does fold in
SceneListener.filter_definitions, but only as one uniform per-band gain applied equally to every track, not a real per-channel response. When the listener has more than one device microphone, each microphone's noise profile/level is averaged into a single channel before being added as noise power. Bias direction: either direction. - Speech RMS over full sample duration. If speech samples contain leading, trailing, or inter-sentence silence, the prediction underestimates speech level and produces a lower SNR than the rendered value. Bias direction: prediction underestimates SNR. Trimming leading and trailing silence reduces this error but does not eliminate it entirely.
- Non-stationary noise. The prediction uses the long-term average SPL of each noise source. If the noise level during speech-active periods differs from that average (e.g., music with loud and quiet passages), the estimate will be off. Bias direction: either direction.
- Partial temporal overlap. When a noise source is active for only part of the scene, its contribution to noise RMS is reduced in proportion to its overlap with the speech-active window. This compounds the stationarity issue described above and partial overlap might amplify the stationarity error. Bias direction: either direction.
- Reverberant tails. The rendered signal includes IR decay after a source stops; the prediction treats source activity as ending at block boundaries. This affects both the noise contribution and the P.56 speech-activity mask, which can extend into the reverberant tail. Bias direction: either direction.
- Octave-band spectral approximation. Source and IR spectra are represented as piecewise-constant over octave bands. Within-band variation (e.g., low-frequency room modes, spectrally shaped noise) introduces error. Bias direction: either direction.
- Multiple speech-type noise sources. When speech is used as noise (e.g., babble or a competing talker), all of the above effects compound. Bias direction: prediction is highly likely to underestimate SNR.
- Incoherent power summation. The prediction accumulates noise power additively, assuming all sources are uncorrelated. When sources are positioned close together, their IRs may be spatially correlated, or the room may add correlation through shared early reflections or room modes, increasing the actual noise level above the prediction. Bias direction: either direction.
- Band-count mismatch (GA-only IRs). For IRs produced by a GA-only simulation, the 63 Hz octave band is skipped in the SPL calculations, resulting in one fewer band than in hybrid simulations. The missing band affects both the noise and target level estimates. Bias direction: either direction.
enrich_with_spl() processes every audio file in the dataset and can be slow for large datasets. If you reuse the same dataset across multiple sessions, save the enriched dataset to Parquet after the first run and load it from there on subsequent runs:
# First run: load, enrich and save.
noise_dataset = sc.AudioDataset.from_huggingface(
repo_id="agkphysics/AudioSet",
config="full",
split="bal_train",
audio_loader_class=sc.AudioSetAudioLoader,
schema_mapping={"id": "video_id"},
max_parquet_files=1,
)
noise_dataset.enrich_with_spl()
parquet_path = "./noise_dataset.parquet"
noise_dataset.dataframe.write_parquet(parquet_path)
# Subsequent runs: load directly.
noise_dataset = sc.AudioDataset(
parquet_path,
audio_loader=sc.AudioSetAudioLoader(parquet_url=parquet_path, id_column="id"),
)
Filter by predicted SNR
Once the collection is enriched with predicted SNR, call filter_by_predicted_snr() to keep only the scenes that fall within a target SNR range for a given source group. Pass the same target_selection used during enrichment:
scene_collection = scene_collection.filter_by_predicted_snr(
min_db=15,
max_db=25,
target_selection="conversation",
)
Advanced examples
Sampling ranges
Every range-shaped parameter across the rule classes above — SceneRules.duration_s, SourceGroup.duration_s, ConversationRules.in_track_level_range_db_spl, NoiseSourceRules.free_field_level_db_spl, OrientationRange's angle ranges, and more — accepts a plain tuple[float, float] (flat/uniform sampling) or a single number (a fixed constant). Pass a Uniform or ScaledBeta instance directly instead when you need a discrete step grid or a distribution shaped toward the center of the range:
ScaledBeta is a Beta distribution rescaled onto [low, high] instead of the Beta distribution's native [0, 1] range, so a/b control the same shape (both above 1 biases toward the center, both below 1 biases toward the edges) while low/high set the actual sampled range:
from treble_tsdk.collections.distributions import ScaledBeta
conversation_rules = sc.ConversationRules(
# Biased toward the middle of the range (a=b=2.0 is the default), instead of flat sampling.
in_track_level_range_db_spl=ScaledBeta(low=60, high=70),
overlap_range=(0.2, 0.5),
)
Both Uniform and ScaledBeta accept an optional step argument to quantize sampled values onto a discrete grid — for example, step=1 for whole numbers.
Analyzing generation coverage
Pass generate_logs=True to generate_scenes() to see how much of the possible design space a batch actually covers. Feasibility planning enumerates every valid (receiver, simulation, per-group IR selection) configuration once before sampling from it — that count is the denominator for "how broadly did this batch sample":
scene_collection, log = scene_generator.generate_scenes(
max_n_scenes=200, random_seed=1, generate_logs=True,
)
planning_entry = next(e for e in log.entries if e.message == "Feasibility planning completed.")
n_feasible_configs = planning_entry.context["valid_candidates"]
n_generated = len(scene_collection)
Comparing n_generated to n_feasible_configs distinguishes "sampled 200 scenes broadly covering a 5,000-configuration space" from "sampled 200 out of only 210 configurations that exist" — the latter has far less diversity than the scene count alone suggests.
log.warnings surfaces everything that went wrong while assembling individual scenes, each tagged with the simulation_id/receiver_id it happened in — this is also where to look when n_generated comes back lower than max_n_scenes. Two distinct situations show up there:
- A group came up short but the scene still generated: a source group produced fewer tracks than its requested
n_sources, while still meetingmin_n_sources, so the scene renders with fewer sources in that group than asked for. - The scene itself was skipped and never made it into the collection — a group fell below its
min_n_sources, or no tracks ended up mapped to any IR at all. This is the reasonn_generatedcan fall short ofmax_n_sceneseven beforemax_attemptsruns out.
log.print() dumps every entry grouped by receiver and simulation.
Reading per-IR metadata across a generated collection
A custom column added to the IRCollection with add_column() (see Custom metadata columns) carries through to every track drawn from that IR, once scenes have been generated or added. Read it back per scene the same way as any built-in column:
scene_collection[0].track_map[0].ir.dataframe_row["distance_to_nearest_wall"]
Or across the whole collection at once, from the IRs list-of-struct column on the collection's own dataframe — useful for building a training manifest directly from the dataframe instead of iterating scenes in Python:
scene_collection.dataframe.select(
pl.col("IRs").list.eval(pl.element().struct.field("distance_to_nearest_wall"))
)
The IRs column's struct schema is fixed from the first batch of scenes added to the collection. Enrich the IR collection with every column you need before generating or adding scenes — a column added to the IR collection afterward doesn't retroactively appear.
Saving and reloading a collection
write_parquet() serializes the whole collection — the generation recipe (source groups, tracks, IR references, filters), plus rendered audio and metrics if render()/render_to_disk() have already run — to a single portable file:
scene_collection.write_parquet("scene_collection.parquet")
Reload it with tsdk.collections.scene_collection_from_file() (also accepts CSV, NDJSON, JSON, and IPC):
scene_collection = tsdk.collections.scene_collection_from_file("scene_collection.parquet")
This is what makes it practical to split scene generation and rendering across different machines or people, or to checkpoint a long-running batch. Whether remote optimization had already run before saving doesn't matter — that state round-trips through the parquet file too, and calling start_remote_optimization() again on a reloaded, already-optimized collection is a safe no-op rather than an error.