API reference
Sessions and I/O
Session discovery and metadata for soundscape recordings.
A session is a folder of audio files from one recording occasion. Files whose timestamps chain end-to-start (recorder 2 GB splits) are treated as one continuous take; otherwise they are separate takes on a common absolute timeline (seconds since the session's first midnight).
Inputs need not be four-channel AmbiX. A file's channel count picks a
processing mode: ambix (>= 4 ch, first-order B-format, full 3-D
direction), stereo (2 ch, a lateral left/right cue and a coherence-based
width), binaural (2 ch declared via calibration.json, an ITD-based
lateral cue and interaural coherence), or mono (1 ch, no direction). Containers libsndfile cannot open
(a phone's AAC .m4a, say) are transcoded to WAV with ffmpeg on ingest,
and a recording's start time is taken from its BWF timestamp if present, else
a YYMMDD_HHMMSS / YYYYMMDD_HHMMSS stamp in the filename, else the
file's modification time.
Take
dataclass
One recorded file, with the two things a reader cannot get from the audio itself.
start is seconds from the session's day-0 midnight rather than from the file, so takes
from different recorders sit on one timeline; order is the channel convention, AmbiX
(W, Y, Z, X) or FuMa (W, X, Y, Z), which no WAV header states and which a wrong guess
turns into a mirrored horizontal bearing that no rotation can undo. Use wyzx to index
channels rather than assuming either.
Source code in src/ambiscape/io.py
125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 | |
wyzx
property
Column indices of (W, Y, Z, X) for this take's convention.
mono_ref(data)
Mono reference column from an (n, ch) block for this take's mode: the W channel (ambix), the L/R mean (stereo/binaural), or the lone channel (mono). The single signal every level/spectral/MIR feature runs on.
Source code in src/ambiscape/io.py
161 162 163 164 165 166 167 168 169 170 | |
Session
dataclass
A folder of takes read as one recording, with its own day-0 midnight.
A session is the unit everything downstream is computed over, because an overnight recording arrives as many files whose individual start times mean nothing on their own.
Source code in src/ambiscape/io.py
173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 | |
clock(t)
Absolute seconds -> 'DD Mon HH:MM:SS' string.
Source code in src/ambiscape/io.py
193 194 195 196 | |
channel_mode(channels)
Map a channel count to a processing mode.
mono (1), stereo (2), ambix (>= 4, first-order B-format).
Three channels are treated as stereo on the first two (rare; a
best-effort fallback).
Source code in src/ambiscape/io.py
51 52 53 54 55 56 57 58 59 60 61 62 | |
read_bext(path)
Parse the BWF 'bext' chunk (pure python RIFF walk).
Source code in src/ambiscape/io.py
65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 | |
channel_order(bext_description)
Detect B-format convention from the H3-VR's zTRK tags in the bext description: 'ambix' (W,Y,Z,X) or 'fuma' (W,X,Y,Z). Defaults to 'ambix' when no tags are present.
THE DEFAULT IS A GUESS, AND A WRONG GUESS LOOKS LIKE DATA. Swapping two of the three directional channels does not produce noise; it produces a bearing series that is smooth, plausible and wrong, and nothing downstream will object to it. Two independent re-decodes of one year of the same ambisonic recordings differed by a median of about 48 degrees per day and returned year-long directional concentrations of 0.815 and 0.393 — same audio, same nominal convention, both series entirely presentable — and which of them was faithful to the field the microphone saw was never settled. So a decode is not validated by producing a sensible-looking result.
What does validate one is an outside fact: a source at a known bearing,
a pass-by whose direction of travel is known, or a
:func:ambiscape.spatial.frame_reference_test against an independent
heading. Orientation is a separate question from channel order and needs
the same treatment. A recorder inverted, or set to its upside-down mode
and then corrected a second time, mirrors the horizontal plane; a mirror
is not a rotation, so no rotational alignment search will find it, and
the search will report a poor fit rather than a fault.
Source code in src/ambiscape/io.py
89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 | |
resolve_mode(channels, override)
Channel-count mode, unless a compatible explicit override is given.
binaural (2-channel ear signals from in-ear mics or a dummy head)
cannot be told from stereo by channel count, so it is only ever
selected explicitly -- via calibration.json "mode" or
open_recording(mode=...). An override that contradicts the channel
count (e.g. "binaural" on a 4-channel file) is ignored.
Source code in src/ambiscape/io.py
317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 | |
open_session(folder)
Scan a session folder of one or more recordings.
Any supported audio (WAV/FLAC/MP3 natively, AAC .m4a etc. via
ffmpeg) is accepted; channel count sets each take's mode (ambix / stereo
/ mono). Start times come from BWF timestamps, filename stamps, or file
mtimes (see the module docstring). If calibration.json contains
clock_offset_s, that many seconds are added to every take's start
time — the fix for a recorder whose clock was found to be off (positive
offset = clock was slow). clock_offsets_s maps individual filenames
to additional per-take offsets for multi-device sessions.
Source code in src/ambiscape/io.py
350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 | |
open_clips(folder)
Open a folder of dataset clips as takes on a synthetic clock.
Corpus clips — a DCASE STARSS fold, a folder of contributed excerpts —
typically carry no BWF bext chunk and no filename timestamp, so
:func:open_session would fall back to file modification times: download
times, which say nothing about capture and usually pile every clip onto
one meaningless, overlapping stretch of timeline. Here the clips are
instead chained end-to-end in sorted filename order from midnight of a
nominal day 0 (1970-01-01). Positions on the session timeline are
deterministic and reproducible across machines, but clock-of-day
readings carry no meaning for these takes. calibration.json is not
consulted (there is no real clock to correct).
Source code in src/ambiscape/io.py
395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 | |
open_recording(path, mode=None)
Open a single recording as a one-take session ("scene").
mode forces the processing mode when the channel count is ambiguous --
chiefly "binaural" for a 2-channel ear-signal recording, which would
otherwise be read as stereo. Ignored if it contradicts the channel
count.
The folder-as-session model of :func:open_session assumes every file in
a folder belongs to one recording occasion on a shared clock. A
contributed corpus is often the opposite: one folder per recordist, each
holding many independent one-off scenes from different places and dates.
This opens exactly one file as its own session (day0 = that file's date),
so each scene can go through the full pipeline on its own. Accepts any
supported audio (transcoding compressed containers); the session name is
the file stem.
Source code in src/ambiscape/io.py
447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 | |
read_span(sess, t0, dur, dtype='float32')
Read [t0, t0+dur) seconds (session time) from whichever take covers it.
Source code in src/ambiscape/io.py
474 475 476 477 478 479 480 481 482 483 484 | |
export_segment(sess, t0, dur, out_path, stamp=True)
Bit-exact excerpt [t0, t0+dur) to a WAV, in the take's channel count.
Samples are copied in the readable source's own PCM subtype (no float
round trip), so the excerpt is archival: the representative segments of a
report stay citable against the raw takes (or, for a transcoded input,
against its decoded WAV). The span must lie within one take (recorder
2 GB splits chain seamlessly only in read_span's float path).
The filename is prefixed with the excerpt's wall clock as
YYYYMMDD_HHMMSS, which is the stamp :func:open_session reads. A
folder of exports is then itself a session, on the clock it was cut
from, rather than a set of files dated to whenever they were written.
Pass stamp=False for an exact filename.
Returns the path actually written, which is not out_path when a
stamp was added.
Source code in src/ambiscape/io.py
487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 | |
stereo_preview(x, wyzx=(0, 1, 2, 3), az_deg=90.0, mode='ambix')
Two-channel decode of a block for listenable previews.
ambix: side-facing cardioids at ±az_deg in the horizontal plane,
0.5 * (W ± sin(az) * Y) (SN3D). stereo: the first two channels
pass through unchanged. mono: the single channel is duplicated.
Returns an (n, 2) float array — write it with soundfile.
Source code in src/ambiscape/io.py
528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 | |
Feature extraction
Streaming per-second feature extraction from soundscape recordings.
Designed for arbitrarily long recordings: files are read in 60-s blocks and never held in memory. Per second: broadband and A-weighted fast levels (125 ms), octave-band powers, spectral centroid/flatness, a 96-band log-frequency spectrogram row, per-octave pseudo-intensity vectors, broadband DOA (azimuth, elevation) and diffuseness. Per minute: full-resolution mean PSD (for narrowband hum tracking and fingerprinting).
The level and spectral features run on a single mono reference: the W channel for AmbiX (ACN W,Y,Z,X, as written by the Zoom H3-VR), the L/R mean for stereo, or the lone channel for mono. Direction depends on the mode:
- ambix (>= 4 ch): full 3-D pseudo-intensity — azimuth, elevation, diffuseness, and a per-octave intensity vector.
- stereo (2 ch): a lateral left/right cue only. Azimuth is the energy balance mapped to +-90 deg (+ = left, 0 = centre; no front/back or elevation), and "diffuseness" is one minus the inter-channel coherence (a point source at the centre reads coherent/near-zero, a decorrelated ambient field reads diffuse/near-one). Elevation is undefined (NaN).
- binaural (2 ch, declared): HRTF ear signals. The level balance is head colouring rather than direction, so azimuth comes from the interaural time difference (GCC-PHAT over the DOA band, Woodworth-limited to +-90 deg, + = left) and diffuseness from delay-compensated interaural coherence. No elevation or front/back; no intensity vector.
- mono (1 ch): no direction at all — azimuth, elevation, diffuseness and the intensity vector are NaN.
a_weighting_sos(fs)
IEC 61672 A-weighting as SOS (bilinear transform of the analog filter).
Source code in src/ambiscape/features.py
50 51 52 53 54 55 56 57 58 59 60 61 | |
extract_take(take, verbose=False)
Run the streaming extractor over one file; returns feature arrays.
Source code in src/ambiscape/features.py
64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 | |
extract_session(sess, out_dir, verbose=True)
Extract features for every take; save one npz per take. Returns paths.
Source code in src/ambiscape/features.py
273 274 275 276 277 278 279 280 281 282 283 284 285 286 | |
load_features(npz_paths)
Concatenate per-take feature files onto one absolute time axis.
Source code in src/ambiscape/features.py
289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 | |
Descriptors, events, reverberation
Session-level descriptors, event detection, and reverberation estimation.
Descriptor conventions follow the Intercontinental-database report (2026-07-10): fast level = 125 ms RMS on W; events = fast level exceeding a running background (10th percentile in a sliding 60 s window) by >= 8 dB for
= 0.25 s; diffuseness/DOA from per-second pseudo-intensity vectors.
db(x, eps=1e-12)
Power to decibels, floored at eps so a silent block returns a number.
The floor is what keeps a log-scaled figure from running to negative infinity where a recorder was switched off; it is a plotting convenience rather than a measurement.
Source code in src/ambiscape/analysis.py
16 17 18 19 20 21 22 | |
running_background(fast_db, fast_dt, win_s=60.0, pct=10)
The quiet floor a level series sits on: a low percentile in a sliding window.
pct = 10 by default, so it follows the quietest tenth of each win_s window rather
than the mean, which an event would drag upward. This is what detect_events measures
exceedance against, and it is why an event count is comparable between recorders that
were never calibrated against one another.
Source code in src/ambiscape/analysis.py
25 26 27 28 29 30 31 32 33 34 | |
detect_events(fast_db, fast_dt, thresh_db=8.0, min_dur=0.25)
Return list of dicts (onset index, length, peak index, exceedance).
The threshold is against the running background, not an absolute level, which is what makes the count comparable across recorders that are not calibrated against one another.
A steady source produces almost no events, however loud it is. The background tracker absorbs anything continuous, so the rate counts how often a room changes rather than how much is in it. Measured across twelve SINS nodes against hand annotations: watching television gives 33.2 events a minute and a vacuum cleaner 5.2, while the vacuum cleaner is by some way the louder of the two. Sleeping gives 0.06 and an empty room 0.24, so the floor of the scale behaves; it is the top that inverts. Read a low rate as "little changes here", never as "little happens here".
The rate is not independent of level either --- Spearman +0.53 against median exceedance on that corpus --- so it is a partly separate axis rather than an orthogonal one.
Source code in src/ambiscape/analysis.py
37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 | |
detect_cessations(fast_db, fast_dt, drop_db=6.0, min_before=60.0, min_after=5.0)
Moments when a sustained level stops: figure-by-absence.
A level-threshold detector finds sounds that start. It cannot find the event that happens when a sound ends, and indoors that is often the louder event of the two: a ventilation plant runs for nine hours and nobody attends to it, then it switches off and the birds, the clock and the water come back. Nothing rose; the ground fell, and the room changed character in two seconds.
A cessation is recorded where a level held steady for at least
min_before seconds, fell by at least drop_db, and stayed down
for at least min_after. Returns a list of dicts with the index, the
size of the drop, and the level either side.
The asymmetry is the point. Attention is captured by change, not by level, and half the changes in a continuously occupied room are departures rather than arrivals.
Source code in src/ambiscape/analysis.py
77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 | |
trimmed_leq(level_db, trim_pct=5.0)
Energy mean in dB with the loudest trim_pct of frames discarded.
An energy average is a mean of squared pressure, so it is decided by
the loudest frames it contains: in a quiet room a handful of them can
outweigh every other frame together. The trimmed level answers the
companion question — what the average would be without that handful —
and a large gap between the two says the plain average describes a few
moments rather than the span. Reported next to laeq_dbfs in every
session summary; see the descriptor guide's "Reading energy averages".
Source code in src/ambiscape/analysis.py
122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 | |
intermittency_ratio(level_db, dt, k_db=3.0)
Intermittency ratio IR (Wunderli et al. 2016), in percent.
The share of total sound energy carried by "events": frames whose
level exceeds the whole-period Leq by k_db (3 dB per the original
definition, there on 1 s LAeq frames — here on the fast frames, which
is equivalent for events longer than the frame). IR ≈ 0 for steady
scenes (drones, dense traffic), high for scenes whose energy arrives
in distinct events (rail, church bells, sparse traffic).
Source code in src/ambiscape/analysis.py
142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 | |
decay_metrics(x, fs, bands=((250, 500), (500, 1000), (1000, 2000), (2000, 4000), (4000, 8000)), pre_roll=True)
T60, EDT, C50, C80 (dB) and D50 per octave band from an impulse.
pre_roll=False says the samples before the peak are not a recording
of the room but silence prepended by a caller so this estimator can run
on a trimmed IR. The noise floor is then taken from the quietest part of
the decay itself rather than from that silence. Read off the padding it
comes out near 200 dB, which silently satisfies every dynamic-range
guard below and lets T20/T30 be fitted over noise.
Same truncated-Schroeder machinery as :func:decay_time (which is
kept unchanged — its output feeds frozen corpus reports), plus the
standard companions: EDT from the 0…−10 dB fit (perceived
reverberance), clarity C50/C80 = 10·log10 of the early/late energy
ratio at 50/80 ms, and definition D50 = early fraction at 50 ms.
When the dynamic range allows (ISO 3382: floor at least 10 dB below
the fit end) and the decay was observed that far before the signal
ends, the fixed-range extrapolations T20 (−5…−25 dB) and T30
(−5…−35 dB) are reported alongside the adaptive-range T60. The second
condition matters for trimmed impulse responses, whose absent noise
floor leaves the range guard unable to fire.
T60 is additionally refused when the fitted range collapses: its lower
limit is adaptive, so near the dynamic-range guard the fit can span only
a few dB and still be extrapolated to 60. fit_db reports how wide the
range actually was, and MIN_FIT_SPAN_DB is the narrowest accepted.
Returns {band: {"T60", "T20", "T30", "EDT", "C50", "C80", "D50",
"dr_db", "fit_db"}} (T20/T30 present only when supported by the
range; T60 absent when fit_db is below the minimum).
Source code in src/ambiscape/analysis.py
163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 | |
quietest_channel(x, fs, pct=5.0, frame_s=1.0)
Which capsule of a multi-microphone node has the most room to measure in.
A node's capsules share a housing, a preamp and a gain setting, so they should agree. When one does not — a blocked port, a damaged capsule — it reaches the same peaks as its siblings but on a raised floor, which costs dynamic range on every descriptor computed from it. Reading channel 0 by convention is then a coin toss.
Measured in the SINS network: node 9's four capsules reached the same 98th-percentile level within 0.7 dB, while channel 0's floor sat 5.5 dB higher than the other three. Analysing channel 0 halved that node's apparent dynamics and pushed the fraction of time it spent at its own floor from about 75 % to 96 %, which is most of what made it look broken.
Run over that corpus it is decisive where it matters and indifferent where it does not: on node 9 it picks the same channel on every minute tried, away from the raised one; on a node whose capsules agree within 2 dB it picks whichever is marginally lower, which is of no consequence.
Returns (index, floors_db) — the channel with the lowest floor, and
every channel's floor, so the caller can see how much the choice matters.
A mono signal returns (0, [floor]).
Source code in src/ambiscape/analysis.py
290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 | |
track_noise_floor(level_db, dt, win_s=120.0, bias_db=_MIN_STAT_BIAS_DB)
The recorder's own floor, followed over time rather than fixed once.
A single floor figure per session cannot be right when the floor moves: a floor-dominated node in the SINS corpus swings 10.7 dB between night and midday as its electronics warm, which is larger than most of the differences such a figure would be used to interpret.
Minimum statistics, after Martin (2001): the running minimum of power
over a window long enough to contain a genuine gap in the source. The
minimum of a fluctuating estimate sits below the mean of the noise it
estimates, so it is lifted by bias_db rather than left to
under-subtract.
win_s is the one judgement. Too short and speech or music is
mistaken for floor; too long and real drift is smoothed away. Two
minutes suits domestic recordings, where gaps are frequent.
A source that never stops is a floor. The method finds the quietest
moment in each window, so anything continuous throughout — ventilation,
a fridge, traffic hum — is absorbed into the estimate and subtracted
away. That is the correct reading of "what is the recorder's own
contribution" only when the steady sound is the recorder. Where a room
has a genuine constant, this measures everything above it and reports
the constant as floor. Say so when reporting, or widen win_s past
the longest expected silence and accept the loss of drift tracking.
Source code in src/ambiscape/analysis.py
338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 | |
floor_corrected_level(level_db, dt, margin_db=FLOOR_MARGIN_DB, win_s=120.0)
Level of the source alone, and where there is no source to measure.
Returns (signal_db, floor_db, measurable). Noise adds as energy, so
the floor is subtracted in power; subtracting decibels is a category
error that happens to look plausible.
Frames whose excess over the floor falls short of margin_db are
returned as nan and marked unmeasurable. They are not zero and not
the floor: they carry no information about the source, and giving them a
value invents one. Clamping them instead is actively harmful — it turns
a bias in level into a bias in sampling, because the frames that
survive are the loud ones, and an average over survivors then reports a
quiet span as loud. That error made a living room's midday read quieter
than its night before this function existed.
Source code in src/ambiscape/analysis.py
375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 | |
summarize_floor_corrected(signal_db, measurable, min_coverage=FLOOR_MIN_COVERAGE)
An energy mean over the measurable frames, with its coverage attached.
coverage is not a footnote. A level computed over 4 % of a session
and one computed over 96 % are different kinds of statement, and the
level alone cannot tell them apart — in the SINS corpus a node reading a
plausible 6.6 dB below the living room turned out to clear its own floor
on 4 % of frames. Below min_coverage no level is returned at all,
because there is nothing there to average.
Source code in src/ambiscape/analysis.py
404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 | |
steady_sources(level_db, dt, short_s=120.0, long_s=7200.0, min_excess_db=MACHINE_MIN_EXCESS_DB)
Separate a machine that cycles from the recorder that never stops.
:func:track_noise_floor over a short window calls anything steady a
floor, which is wrong for the sources this toolbox is usually pointed at.
A fridge, a ventilation plant, a circulation pump: steady for minutes,
and the object of study rather than the noise.
What distinguishes them from the recorder's own contribution is that
they turn off. Self-noise does not. So the floor is estimated twice —
over minutes, which absorbs a running machine, and over hours, which does
not, because the machine's off-phase falls inside the window. Where the
short floor sits materially above the long one, the difference is
machinery, and machine_duty says how much of the time it runs.
Returns self_noise_db (the long-window floor, what never stops),
steady_excess_db (how far the steady sound rises above it while
running), machine_duty, machine_detected, and
inseparable_steady_source.
That last flag is the honest case. A plant that runs continuously for
longer than long_s cannot be told from self-noise by level alone — by
this method or any other that only sees one number per frame — so it is
flagged rather than quietly subtracted. Seeing it means either the source
genuinely never stops, or long_s is shorter than its off-phase. A
domestic fridge cycles over roughly three quarters of an hour, so two
hours is a safe default; a building's ventilation may run all day, and no
window will separate it.
How to tell that long_s is too short. It does not fail loudly.
machine_duty is understated first — a fridge running 60 % of the time
reported at 17 % — and only then does self_noise_db climb toward the
machine's own level. If the duty looks implausibly low for a machine you
can hear in the recording, lengthen the window before believing the
floor.
Source code in src/ambiscape/analysis.py
431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 | |
cycle_band(period_s)
Which rung of the ladder a period sits on.
Delegates to the descriptor registry so that a period and a descriptor window are named by the same scheme.
Source code in src/ambiscape/analysis.py
495 496 497 498 499 500 501 502 | |
cycle_spectrum(level_db, dt, min_period_s=60.0, max_period_s=6 * 3600.0, n_periods=192)
How strongly a level series repeats, across a ladder of periods.
The premise is that what never changes cannot be used. A recorder's own hiss is stationary; a room is not. A fridge turns over in tens of minutes, a ventilation plant in hours, a household in a day, a heating system in a season. So "signal or noise?" is really a question about periodicity, asked at every timescale at once, and the period that answers it also names the thing.
Each period is judged on a version of the series smoothed to suit it —
see :func:_scaled_acf. Returns (periods_s, strength) in 0–1, log
spaced, each grid point carrying the strongest lag in its bin so a sharp
peak survives being summarised. nan samples are tolerated.
.. warning::
Validated at the cyclic band; provisional above it. On real
recordings this reliably recovers machinery — two rooms of a domestic
network agreeing on a 62-minute cycle. At circadian and longer it
is not yet trustworthy: over six days of real data it returned a
48-hour harmonic rather than the 24-hour fundamental, and a spurious
two-hour peak on a floor-dominated node. Six days is five repetitions of a
daily cycle, which is thin, and the estimate is sensitive to how the
series is smoothed. Treat any period beyond a few hours as a
hypothesis to check by other means, and prefer a direct test — how a
quantity varies by hour of day — for anything circadian.
Working on the level series rather than the waveform is deliberate. What repeats at these scales is loudness, not pressure, and the level series survives coding, resampling and even a change of recorder.
Source code in src/ambiscape/analysis.py
561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 | |
dominant_cycles(level_db, dt, min_period_s=60.0, max_period_s=6 * 3600.0, top=3, min_strength=CYCLE_MIN_STRENGTH, min_prominence=0.05)
The periods a level series actually repeats at, strongest first.
Peaks of the autocorrelation, each reported with the band it belongs to. An empty list means the series is stationary at every scale asked about — which, for a recording of a room, is a statement about the recorder rather than about the room.
Two things have to be got right or this measures the wrong quantity.
Prominence, not height. Autocorrelation is high at short lag for anything that varies slowly, so a single 24-hour swing scores well at a ten-minute lag purely by being smooth. That is not a ten-minute cycle. A peak has to stand clear of the troughs around it, which is what distinguishes repeating from merely drifting.
Harmonics are not findings. A cycle of period T also correlates at 2T and 3T, so a peak near a low integer multiple of an accepted shorter period is dropped. The bound matters: a day is 32 fridge cycles long and is emphatically its own thing.
Source code in src/ambiscape/analysis.py
614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 | |
cycle_residual(level_db, dt, min_period_s=60.0, max_period_s=6 * 3600.0, n_sigma=5.0, min_gap_s=60.0)
What the room did not repeat — anomaly as the complement of rhythm.
A rhythm and an anomaly are opposite readings of one series, and the difference matters practically. An outlier detector run on a kitchen flags the fridge thirty times a day, because every start is a step change; it is a perfectly good detector answering the wrong question. The fridge is not an anomaly, it is the room's normal behaviour, and what makes it normal is precisely that it repeats.
So: find the strongest cycle, fold the series onto its phase to get what the room usually does at that point in the cycle, subtract it, and look at what survives. A spike has no period and cannot be folded away, so it stands out in the residual. A machine can, and does not.
Returns the period used, the residual's spread, and any excursions past
n_sigma robust deviations, each with its time and how far past the
threshold it went.
Three things this does not do, worth knowing before trusting it. It models one cycle, not several at once. It treats a change in the cycle — a fridge whose period drifts as it fails — as residual rather than as the more interesting finding it usually is. And with no cycle found it falls back to the plain series, where any slow drift will read as anomalous.
Source code in src/ambiscape/analysis.py
681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 | |
cycle_drift(level_db, dt, min_period_s=600.0, max_period_s=6 * 3600.0, n_windows=6, min_drift_pct=8.0)
Is the rhythm itself changing? The case between a spike and a cycle.
Three detectors already exist for a room and none of them sees this. An event detector sees a fridge start. An outlier detector sees the same start and calls it anomalous thirty times a day. A cycle finder sees the period and calls it normal. But a compressor that is beginning to fail does not produce anomalies and does not stop cycling — its period drifts, and that is what whoever owns the building would want to know.
It is not an anomaly, because nothing is out of the ordinary from one moment to the next, and it is not the rhythm, because the rhythm is no longer what it was.
The series is split into overlapping windows, the dominant period found in each, and a trend fitted across them. Returns the median period, whether it is drifting, the direction, and the drift as a percentage of the median.
Needs a long recording: several windows, each holding several cycles, so perhaps twenty periods end to end. For a domestic fridge that is most of a day; for a ventilation plant, a week.
drift_pct is the change across the whole recording, and is accurate to
about a percentage point: on synthetic machines whose period truly moves
by +34.5, -34.5 and 0 per cent it returns +35.2, -34.7 and -0.3. Until
2026-08-12 it was measured between the first and last window centres,
which span roughly two thirds of the series, so it under-read by about a
third and the tests --- which asserted a direction and a lower bound ---
all passed anyway.
Source code in src/ambiscape/analysis.py
748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 | |
cycle_profile(level_db, dt, min_period_s=60.0, max_period_s=3 * 86400.0)
What kind of thing is cycling here — a room, or the recorder?
Periodicity alone does not separate them: a converter warms and cools with the building, so a node sitting at its own floor can carry a daily cycle that has nothing to do with the room. That premise is well supported on the SINS corpus by an hour-of-day analysis. Node 9 is the least sensitive recorder in that deployment, in a bedroom nobody enters by day, and it sits on its own noise floor for the great majority of the week; its hourly profile still swings 10.7 dB, peaking at 14:00 and troughing at 04:00--06:00, and node 10's profile correlates with it at r = 0.97. A node that is reporting its own floor almost all the time, whose profile peaks at midday, is most simply read as a floor that warms with the building.
Note what that argument is not. An earlier version of this docstring called node 9 a dead channel that hears nothing, which would have made the case decisive. It is not dead --- that call was made and retracted within the project: node 9 separates activity classes by 31.6 dB (sleeping -62, dressing -50, vacuum cleaner -28) and reads 13.7 dB of per-second spread during a labelled vacuum span. So some part of its 10.7 dB could in principle be room signal, and the thermal reading is an inference from the at-floor fraction and the phase rather than a measurement of temperature.
What is not established is that this function can be used to apply that. Run over the whole SINS week at 10 s across all twelve nodes (2026-08-13), it returns stationary at every scale for the bedroom, WC, hall and bathroom nodes --- including the ones the hourly analysis shows swinging 7.8 to 10.7 dB daily --- while returning a circadian cycle with nothing faster underneath for all seven living-room nodes, which are the busiest in the deployment. So on this corpus the function misses the daily cycles that are there and reports the pattern that was supposed to mark a recorder on the rooms that are plainly occupied.
Two consequences. Do not infer recorder from a diurnal_only
verdict here; the shape is a description, not an attribution. And do not
read an empty result as "no daily cycle" --- check with an hour-of-day
fold, which is what the corpus evidence above rests on.
has_sub_daily_cycle reports whether faster marks are present, which
remains a useful fact in its own right.
A related known limitation. On these nodes the function returns a
single peak near 47.9 h and classifies it circadian, where modulation
on the same recordings puts the rhythm within one bin of 24 h. The wiki's
Multi-Recorder-Networks page records this as a 48-hour harmonic returned
in place of the fundamental, with the advice to use a by-hour test for
anything circadian; that advice stands, and the two failures are probably
the same one.
Source code in src/ambiscape/analysis.py
828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 | |
series_onset(series, rise=ONSET_RISE)
First index where a series passes rise of its own floor-to-peak range.
Deliberately scale-free. It is used to compare series in different units — a motion measure in pixels against an acoustic energy — and any rule with an absolute threshold would compare the units instead.
The default is tuned for a difference, not for an absolute onset.
A quarter of a 40 dB floor-to-peak range is 30 dB below the peak, which on
a recording of a sound-producing action is reached by the action's own
small noises — an object picked up, a step, a hand on a surface — well
before the sound the clip is of. Measured on the Sound Actions clips,
where the lead-in sits a median 40 dB below the event peak but 5.5 dB
above the clip floor and carries such transients, the default returns a
median 1.78 s early against another algorithmic onset --- this same
function at 0.25 on a linear energy series --- and agrees within a quarter
second on 17 % of those; rise=0.75 lands +0.01 s and agrees on 77 %.
That comparison is between two algorithms and not against a person, and
the two passes used different representations (dB against linear energy),
on which the same fraction lands tens of frames apart. Read it as a
consistency check between two conventions, not as accuracy.
So the choice is not noise versus signal but which sound is the onset. The default finds the first thing audible above the floor; a higher rise finds the event the clip was cut for.
The two modalities appear to want opposite fractions, and only one half of that is established. The motion side is: on the same corpus, marked by eye from video frames, blind to every computed value, 0.10 and 0.25 land within a median 0.06 s of what a viewer calls the beginning, while 0.50 and 0.75 are late on every clip checked, by a median 0.46 s and 0.66 s. Seven clips with a markable onset, one observer, marks good to about 0.1 s — enough for the direction, not for the decimals. The asymmetry has a cause: audio carries a noise floor a low fraction triggers on, and motion has a genuinely still lead-in a high fraction sits through until the movement is already large.
:func:onset_lead applies the same rule to both series, which is what
keeps a lead comparable across modalities — and therefore leaves it wrong
for at least one of them, whichever fraction is chosen. How much of the
bias survives the subtraction depends on the two series having similar
shapes; on this material they do not, so the cancellation is an accident
of how far two errors match rather than a property of the rule. Prefer a
fraction validated against the modality it is applied to, and where two
are used, say that the lead is a difference between two
differently-defined onsets.
The high end is a single frame, which is the rule's other weakness.
hi is s.max(), the most outlier-prone statistic available, and it
sets the crossing level for the whole series. Varying only rise
between 0.20 and 0.30 across 365 clips moves the onset by more than half a
second on 31 of them and by more than a second on 9, worst case 5.08 s.
The clips that move are those whose activity builds rather than starts,
where the crossing level falls in a region the series passes through
slowly. Substituting the 90th percentile for the maximum roughly halves
that — 31 clips to 19 over half a second — for a median shift of 0.08 s,
and does not fix it, because on those clips there is no single moment to
find.
Source code in src/ambiscape/analysis.py
910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 946 947 948 949 950 951 952 953 954 955 956 957 958 959 960 961 962 963 964 965 966 967 968 969 970 971 972 973 974 975 976 977 | |
series_span(series, rise=ONSET_RISE)
First and last index above rise of a series' floor-to-peak range.
:func:series_onset answers where something begins. This answers where it
begins and stops, by the same scale-free rule and against the same
floor-to-peak range, so the two ends are measured on one convention.
The second index is what a suffix needs. In Godøy's decomposition of a sound-producing action the excitation has a prefix before the attack and a suffix after it, and a measurement of the suffix is a measurement of when the action stopped, not of when its sound decayed below a threshold in absolute terms. Applied to a motion series it gives the moment the body came to rest.
Every caveat on :func:series_onset applies to the first index unchanged,
and the mirror of it applies to the second: at a low rise the last
crossing is the last small noise rather than the end of the event.
Censoring is the caller's problem and is not optional. A series whose
first index is 0, or whose last is len(series) - 1, was cut before the
event began or after it ended, and the corresponding duration is a lower
bound rather than a measurement. On hand-trimmed material this is common:
of 365 Sound Actions clips, 11 begin already in motion and 22 are still in
motion at the last frame. Averaging those in biases a prefix-against-suffix
comparison in exactly the direction such a comparison is about.
Returns (first, last), or (None, None) when the series is too short
or flat for the range to be defined.
Source code in src/ambiscape/analysis.py
980 981 982 983 984 985 986 987 988 989 990 991 992 993 994 995 996 997 998 999 1000 1001 1002 1003 1004 1005 1006 1007 1008 1009 1010 1011 1012 1013 1014 1015 1016 1017 | |
onset_lead(first, second, dt, rise=ONSET_RISE, first_rise=None, second_rise=None)
How far one series begins before another — an action before its sound.
A sound-producing action starts well before the sound it produces: an
intention becomes neural and then muscular activity, then motion in the
arm and the object, and only at the end an acoustic attack. A sound object
therefore *embeds* an action, and the silence in front of the attack is
not empty — it is where the action already is.
Measured on 180 clips of the Sound Actions corpus, giving `first` a
quantity-of-motion series from the video and `second` the audio energy on
the same time grid: motion leads sound by a **median 0.72 s**, and does so
in **84 %** of clips. The remaining sixth is worth keeping in view rather
than treating as error — an object already moving when it is struck, or an
action that happens out of frame, genuinely has no visible lead.
**One fraction is applied to both series by default**, which keeps the
units out of the comparison at the cost of a fraction that cannot suit
both: audio fires early on its own noise floor at a low value, motion
fires late at a high one. Pass ``first_rise`` and ``second_rise`` to give
each modality its own.
That option exists and is deliberately not the default, because only the
motion fraction has been checked against a person. :data:`AUDIO_RISE` sets
out why the usual justification for 0.75 does not survive inspection. A
lead measured with two fractions is a difference between two
*differently-defined* onsets, which is defensible once both are validated
and misleading before.
Returns ``lead_s`` (positive when `first` begins earlier), the two onset
times, the fraction used for each, and which one led.
This is the seam between the toolboxes rather than a video function: pass
a motion series computed wherever motion is computed. It is what makes
audio–video analysis more than two analyses side by side — the lead is a
property of the *action*, and neither modality carries it alone.
lead_s is the output this was validated on. first_onset_s and
second_onset_s are returned for inspection and are not reliable
onset times at the default rise: on the Sound Actions clips they sit a
median 1.78 s early, firing on the action's own handling noises rather
than on the sound the clip is of. Raise rise before reading them as
times — see :func:series_onset.
Source code in src/ambiscape/analysis.py
1020 1021 1022 1023 1024 1025 1026 1027 1028 1029 1030 1031 1032 1033 1034 1035 1036 1037 1038 1039 1040 1041 1042 1043 1044 1045 1046 1047 1048 1049 1050 1051 1052 1053 1054 1055 1056 1057 1058 1059 1060 1061 1062 1063 1064 1065 1066 1067 1068 1069 1070 1071 1072 1073 1074 1075 1076 1077 1078 1079 1080 1081 1082 1083 1084 1085 | |
floor_occupancy(F, within_db=AT_FLOOR_WITHIN_DB, pct=5.0)
How much of a session sits at its own noise floor.
:func:floor_suspicion asks whether a band's floor is self-noise. It
is the right question and it has a limit: across a whole sensor network
it can fire for every node, because every recorder's top octaves are its
own hiss during quiet hours. It therefore cannot separate "this band is
the recorder" from "this room was empty all week".
This asks the second question. Each second's broadband level is compared
against the session's own pct-percentile floor, and the fraction
within within_db of it is returned. A living room in use spends
little time there; a bedroom occupied only to sleep spends most of the
week there. That is a description of how a room is used, not a fault in
the recorder — a distinction this corpus cost several hours to learn,
when a microphone in a mostly-empty bedroom was diagnosed as dead.
Being each session against its own floor, the measure does not move with recording gain, and so is comparable across uncalibrated instruments in a way an absolute level is not.
Measured across the SINS network, one week each: the living-room and kitchen nodes sit at their own floor 28-56 % of the time (median 2.4-9.8 dB above it), while the bedroom node sits there 96 % of the time, a median of 0.4 dB above. One number, and it names the room.
Returns {"at_floor_fraction", "floor_db", "median_above_floor_db"}.
Source code in src/ambiscape/analysis.py
1088 1089 1090 1091 1092 1093 1094 1095 1096 1097 1098 1099 1100 1101 1102 1103 1104 1105 1106 1107 1108 1109 1110 1111 1112 1113 1114 1115 1116 1117 1118 1119 1120 1121 1122 1123 1124 1125 1126 | |
floor_suspicion(F, chunk_s=300.0, pct=10.0, spread_thresh_db=FLOOR_SPREAD_THRESH_DB, min_chunks=6, hf_min_hz=2000.0)
Flag high-frequency band floors that look like recorder self-noise.
A genuine room background breathes: its low-percentile level moves with the day, the weather and the building. A microphone's self-noise floor does not — it is abnormally flat over time (and typically spectrally smooth). The consequence is that any L90-derived descriptor weighted towards the top of the spectrum (LA90 in particular) can be measuring the recorder rather than the room.
The figures this default was originally justified by came from a single living room. Re-measured across all 84 node-sessions of the SINS corpus (2026-08-13), the median spread of this statistic per octave band is:
32 Hz 5.17 | 63 4.54 | 125 2.76 | 250 2.96 | 500 1.85
1 kHz 1.54 | 2 kHz 0.51 | 4 kHz 0.34 | 8 kHz 0.29
which supports the check but not the margin the docstring used to claim.
"Every band below 1 kHz varies by 2.4–5.3 dB" was true of that one room
and is not true of the corpus: 500 Hz and 1 kHz sit at 1.85 and 1.54.
What the corpus does support is the separation above hf_min_hz,
where the flaggable bands sit at 0.29–0.51 against a 1.5 dB threshold.
The check is correspondingly conservative at 2 kHz, whose 95th
percentile reaches 1.67 and so escapes flagging in a minority of
sessions.
A caution for anyone using this on SINS itself: the threshold was chosen from that corpus, so a flag here is not independent evidence about it. Report 20 of the Sound Spaces series establishes the same conclusion from measurements that do not pass through this function, and cites those rather than this flag, for exactly that reason.
The check works on the cached 1 s octave-band powers: the session is
cut into chunk_s chunks, each chunk's pct-percentile band level
is that chunk's floor, and the temporal spread of the floor is taken as
the median minus the 5th-percentile chunk floor — a low-tail statistic,
so chunks whose floor is raised by activity (television, dishes) do not
hide a pinned quiet-time floor. A band centred at or above
hf_min_hz whose spread is below spread_thresh_db is suspect.
The 1.5 dB default sits between the SINS self-noise band (≤ 0.8 dB
over a week) and the quietest genuinely acoustic bands there
(≥ 2.4 dB), with at least 0.7 dB of margin to each side. Bands with no
content below the Nyquist frequency, and sessions shorter than
min_chunks chunks (30 min at the defaults), are never flagged.
This is an annotation, not a correction: no descriptor value changes.
Returns floor_suspect (bool), the affected band range
floor_suspect_lo_hz/floor_suspect_hi_hz (band edges, Hz), and
floor_spread_db (the smallest spread among the flagged bands);
the last three are None when nothing is flagged.
Source code in src/ambiscape/analysis.py
1129 1130 1131 1132 1133 1134 1135 1136 1137 1138 1139 1140 1141 1142 1143 1144 1145 1146 1147 1148 1149 1150 1151 1152 1153 1154 1155 1156 1157 1158 1159 1160 1161 1162 1163 1164 1165 1166 1167 1168 1169 1170 1171 1172 1173 1174 1175 1176 1177 1178 1179 1180 1181 1182 1183 1184 1185 1186 1187 1188 1189 1190 1191 1192 1193 1194 1195 1196 1197 1198 1199 1200 1201 1202 1203 1204 1205 1206 1207 1208 1209 1210 1211 | |
circular_stats(az_deg, weights=None)
Energy-weighted circular mean (deg) and resultant length R.
Source code in src/ambiscape/analysis.py
1214 1215 1216 1217 1218 | |
summarize(F)
Session descriptor dict from concatenated features (see features.load_features).
Source code in src/ambiscape/analysis.py
1221 1222 1223 1224 1225 1226 1227 1228 1229 1230 1231 1232 1233 1234 1235 1236 1237 1238 1239 1240 1241 1242 1243 1244 1245 1246 1247 1248 1249 1250 1251 1252 1253 1254 1255 1256 1257 1258 1259 1260 1261 1262 1263 1264 1265 1266 1267 1268 1269 1270 1271 1272 1273 1274 1275 1276 | |
decay_time(x, fs, bands=((250, 500), (500, 1000), (1000, 2000), (2000, 4000), (4000, 8000)))
T60 estimates from an impulse via truncated Schroeder integration.
The decay is truncated at the first re-attack (envelope rising >= 8 dB above its running minimum) and at the noise floor; a linear fit of -5 dB .. max(-35 dB, floor + 8 dB) is extrapolated to 60 dB. Returns {band: (T60, dynamic_range_db)}.
Source code in src/ambiscape/analysis.py
1279 1280 1281 1282 1283 1284 1285 1286 1287 1288 1289 1290 1291 1292 1293 1294 1295 1296 1297 1298 1299 1300 1301 1302 1303 1304 1305 1306 1307 1308 1309 1310 1311 1312 1313 1314 1315 1316 1317 1318 1319 | |
transient_candidates(x, fs, n_max=60, min_rise_db=12.0, pre_s=0.5, min_gap_s=2.0)
Times of the sharpest level rises in a recording, for blind decay estimation.
A transient counts when the 10 ms RMS level exceeds the median level of the preceding
pre_s seconds by min_rise_db; the strongest are kept, at least min_gap_s
apart, so one applause does not supply every candidate.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
x
|
ndarray
|
Mono samples. |
required |
fs
|
int
|
Sample rate. |
required |
n_max
|
int
|
Most candidates to return. Defaults to 60. |
60
|
min_rise_db
|
float
|
Rise over the preceding level that counts. Defaults to 12 dB. |
12.0
|
pre_s
|
float
|
Length of the preceding window. Defaults to 0.5 s. |
0.5
|
min_gap_s
|
float
|
Minimum spacing between candidates. Defaults to 2 s. |
2.0
|
Returns:
| Name | Type | Description |
|---|---|---|
list |
list[float]
|
Candidate times in seconds, strongest first. |
Source code in src/ambiscape/analysis.py
1322 1323 1324 1325 1326 1327 1328 1329 1330 1331 1332 1333 1334 1335 1336 1337 1338 1339 1340 1341 1342 1343 1344 1345 1346 1347 1348 1349 1350 1351 1352 1353 1354 1355 1356 1357 1358 1359 1360 1361 1362 1363 | |
decay_from_transients(x, fs, bands=((250, 500), (500, 1000), (1000, 2000), (2000, 4000), (4000, 8000)), n_max=60, min_rise_db=12.0, pre_s=0.5, excerpt_s=2.0)
Blind reverberation estimates from the transients in an ordinary recording.
:func:decay_time wants an impulse. A concert or a session has no impulse but hundreds of
sharp onsets, each followed by a decay that is the room's until the next sound arrives.
This picks the sharpest of them (:func:transient_candidates), runs :func:decay_time on
an excerpt around each, and reports the per-band distribution. The result is a coarse
estimate: a decay can only be read where the music stops after the transient, so dense,
continuous material biases it upward, and the interquartile range says how much to trust
the median. It is the honest number when no measured impulse response exists; measure one
(ambiscape sweep and ambiscape impulse) when you can.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
x
|
ndarray
|
Mono samples. |
required |
fs
|
int
|
Sample rate. |
required |
bands
|
Octave bands as (lo, hi) pairs. |
((250, 500), (500, 1000), (1000, 2000), (2000, 4000), (4000, 8000))
|
|
n_max
|
int
|
Transients examined. Defaults to 60. |
60
|
min_rise_db
|
float
|
Rise that counts as a transient. Defaults to 12 dB. |
12.0
|
pre_s
|
float
|
Pre-roll before each transient in the excerpt. Defaults to 0.5 s. |
0.5
|
excerpt_s
|
float
|
Excerpt length. Defaults to 2 s. |
2.0
|
Returns:
| Name | Type | Description |
|---|---|---|
dict |
dict
|
|
dict
|
|
|
dict
|
500–2000 Hz bands) with |
Source code in src/ambiscape/analysis.py
1366 1367 1368 1369 1370 1371 1372 1373 1374 1375 1376 1377 1378 1379 1380 1381 1382 1383 1384 1385 1386 1387 1388 1389 1390 1391 1392 1393 1394 1395 1396 1397 1398 1399 1400 1401 1402 1403 1404 1405 1406 1407 1408 1409 1410 1411 1412 1413 1414 1415 1416 1417 1418 1419 | |
pick_segments(F, n=4, seg_s=600.0)
Suggest representative windows: quietest, most active, median-typical, and (if present) the strongest state transition.
Kinds can coincide: a session barely longer than one window has only
one window to offer, and a stationary room has no most-active minute
to distinguish from its quietest one. Coincident kinds are returned
once, the window keeping the first kind's name and listing the others
under also — so the degeneracy is visible rather than presented as
several identical "representative" segments.
Source code in src/ambiscape/analysis.py
1422 1423 1424 1425 1426 1427 1428 1429 1430 1431 1432 1433 1434 1435 1436 1437 1438 1439 1440 1441 1442 1443 1444 1445 1446 1447 1448 1449 1450 1451 1452 1453 1454 1455 1456 1457 | |
Evidence tiers (signal vs perception)
What kind of evidence a descriptor is: signal, or something about a listener.
Every number this toolbox returns is a fact about a waveform. Some of them are also meant as facts about hearing, and the distance between those two things is where this project has made its worst mistakes. Four claims were withdrawn in a single month, and every one of them was a perceptual quantity read off a signal statistic: acoustic "zones" from a speech fraction, a "dead" channel from a level, a building's rhythm from one day's periodicity, a reverberation time from material containing no free decay. None was a coding error. Each was a translation nobody had written down.
This module writes it down. It is the companion to :mod:ambiscape.timescales,
and deliberately the same shape: a registry, a check, a table of what is not yet
covered. Where the timescale registry answers over how long is this valid,
this one answers what is it evidence about.
The tiers
S — signal only. Defined by the mathematics of the waveform. No
listener anywhere. It may claim "this is what is in the recording" and nothing
about audibility, salience or annoyance.
PM — perceptually motivated. Designed by analogy with hearing but not
validated against listeners in this domain. The analogy is a hypothesis. Most
of the event and source-category descriptors live here, because "an event is a
departure from the background" is a good guess about noticing that no one here
has tested.
PC — perceptually calibrated. The transform embeds a measured property
of hearing, usually from listening experiments codified in a standard:
A-weighting is an equal-loudness contour, octave bands are roughly critical
bands. Note that calibrated is not the same as right: A-weighting is known to
misrepresent exactly the low-frequency steady sources this project is full of.
PD — perceptually defined. The quantity only exists as a fact about a
listener, and the signal measure is a proxy that can simply be wrong.
Foreground and background are the standard case: a background is not a level,
it is a relation between a sound and someone not attending to it, and the level
is a stand-in. A PD number is evidence about the proxy until somebody asks
a listener.
The tier is not a quality ranking. S is not worse than PC; a spectral
centroid is an excellent measurement of a spectral centroid. The tier says what
may be concluded, and the only real error is concluding one tier's worth of
thing from another tier's number.
Grounding
dataclass
One descriptor's evidence tier, with the reason and where it came from.
Source code in src/ambiscape/grounding.py
62 63 64 65 66 67 68 69 70 71 72 73 | |
tier_of(key)
The evidence tier of a descriptor, or None if unregistered.
Source code in src/ambiscape/grounding.py
230 231 232 233 | |
unregistered(summary)
Summary keys with neither a tier nor an exemption.
Coverage is honest rather than assumed: a key nobody has classified shows
up here instead of silently defaulting to S, which would be the
convenient answer and the wrong one.
Source code in src/ambiscape/grounding.py
236 237 238 239 240 241 242 243 244 | |
check(summary)
Annotate a summary with its perceptual cautions.
Returns the summary with a grounding_cautions list added, and that
list. A caution is raised for every PD quantity present, because those
are the numbers most easily mistaken for the perceptual fact they stand in
for. PM quantities are counted but not itemised: there are many of
them, and warning on each would train the reader to ignore the warning.
Source code in src/ambiscape/grounding.py
247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 | |
table()
The registry as rows, for printing, docs and report tables.
Source code in src/ambiscape/grounding.py
272 273 274 275 276 277 278 | |
counts()
How many registered descriptors sit in each tier.
Source code in src/ambiscape/grounding.py
281 282 283 284 285 286 | |
Impulse response & auralization
Sweep-based impulse response measurement and auralisation.
The measurement chain is Farina's exponential sine sweep (ESS) method:
- :func:
exp_sweepgenerates a logarithmic sweep plus its matched inverse filter (the time-reversed sweep with a −6 dB/octave amplitude envelope, scaled so sweep ⊛ inverse peaks at exactly 1). Play the sweep in the room, record it. - :func:
deconvolveconvolves the recording with the inverse filter. Harmonic-distortion products land before the linear impulse response (the point of the ESS method), so trimming everything earlier than a few milliseconds before the direct-sound peak (:func:extract_ir) removes both pre-ringing and loudspeaker distortion. - :func:
ir_metrics, :func:sti, :func:iacc_earlyand :func:iacc_e3characterise the room from the IR; :func:auralizeconvolves dry material with it (uniformly partitioned FFT convolution).
For anything compared against the concert-hall literature use
:func:iacc_e3, not :func:iacc_early: published hall values are IACC_E3,
the mean of the 500, 1000 and 2000 Hz octave bands, and the broadband
figure is a different quantity that low-frequency content moves around.
Headroom: sweeps are written at peak −6 dBFS (amplitude=0.5) so a
playback chain with a mild bass boost or resonance does not clip; the
deconvolution normalisation is documented per function.
STI here is the indirect method of IEC 60268-16: modulation transfer functions computed from the measured IR (Schroeder's integral), which assumes the measurement itself is noise-free and the room is linear and time-invariant. Ambient noise and masking corrections are NOT applied, so the value is an upper bound describing reverberant smearing only — an occupied or noisy room will have a lower effective STI.
exp_sweep(duration=10.0, f0=40.0, f1=18000.0, fs=48000, fade_in=0.1, fade_out=0.02, amplitude=0.5)
Exponential sine sweep and matched inverse filter (Farina 2000).
The sweep spends equal time per octave from f0 to f1 over
duration seconds, with raised-cosine fades (fade_in seconds at
the start, fade_out at the end) so the loudspeaker is not stepped,
and peak amplitude amplitude (default 0.5 = −6 dBFS of headroom
against playback-chain resonances). The inverse filter is the
time-reversed sweep weighted by the exponential envelope that whitens
the pink energy distribution, scaled so that sweep ⊛ inverse is a
unit-peak impulse at index len(sweep) − 1.
Returns (sweep, inverse, meta) where meta is a JSON-ready dict
of the generation parameters (enough to regenerate the inverse with
:func:inverse_from_meta).
Source code in src/ambiscape/impulse.py
75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 | |
inverse_from_meta(meta)
Regenerate the matched inverse filter from a sweep's sidecar dict.
Source code in src/ambiscape/impulse.py
113 114 115 116 117 118 119 120 | |
write_sweep(out_path, duration=10.0, f0=40.0, f1=18000.0, fs=48000, amplitude=0.5)
Write <out>.wav (the sweep), <out>_inverse.wav and a
<out>.json parameter sidecar. Returns the paths + meta.
Source code in src/ambiscape/impulse.py
123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 | |
deconvolve(rec, inverse)
Linear convolution of a recorded sweep with the inverse filter.
rec is (n,) or (n, ch); returns the full (n + len(inverse) − 1, ch)
deconvolution buffer. With the inverse from :func:exp_sweep, feeding
the pristine sweep back in yields a unit impulse, so the amplitude of
the result is the recording's own level referenced to that unit — no
further scaling is applied here.
Source code in src/ambiscape/impulse.py
142 143 144 145 146 147 148 149 150 151 152 153 154 | |
extract_ir(h, fs, pre_ms=5.0, dur=None)
Trim a deconvolution buffer to the impulse response.
The direct sound is the largest absolute peak across channels; the IR
keeps pre_ms milliseconds before it (so the onset is intact) and
discards everything earlier — deconvolution pre-ringing and the
harmonic-distortion images, which the ESS method places before the
linear response. dur caps the kept tail in seconds (default: to the
end of the buffer). Returns (ir, direct_index) with
direct_index the peak position inside ir.
Source code in src/ambiscape/impulse.py
157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 | |
ir_metrics(ir, fs, centers=OCTAVE_CENTERS)
Octave-band T60/T20/T30, EDT, C50/C80, D50 from an impulse response.
A thin wrapper over :func:ambiscape.analysis.decay_metrics (the same
truncated-Schroeder machinery used for clap-based estimates): a trimmed
IR starts at its peak, so half a second of silent pre-roll is prepended
to satisfy that function's noise-floor estimation, and the edge-labelled
bands are relabelled by octave centre. Multichannel IRs are analysed on
channel 0 (the omni/W channel of a B-format IR).
Returns {centre_hz: {"T60", "T20", "T30", "EDT", "C50", "C80",
"D50", "dr_db", "fit_db"}} (T20/T30 only when the dynamic range
supports them and the decay was observed that far before the file ends —
a pre-trimmed archive IR commonly yields T60 and EDT but neither). An IR
cut so short that even the adaptive T60 range collapses yields no T60
either; fit_db says how much decay the estimate rested on.
Source code in src/ambiscape/impulse.py
177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 | |
sti(ir, fs)
Speech Transmission Index from an IR (IEC 60268-16 indirect method).
Per octave band (125 Hz – 8 kHz), the modulation transfer function at the 14 standard modulation frequencies is Schroeder's integral
m(fm) = |∫ h²(t) e^{−j2πfm t} dt| / ∫ h²(t) dt,
converted to an effective SNR (clipped to ±15 dB), a transmission index, and band MTIs, combined with the male-speech alpha/beta weights of IEC 60268-16:2011.
Assumptions (documented, not corrected for): the measurement is noise-free (no ambient-noise term — the MTF denominator is signal energy only), no level-dependent auditory masking, no absolute-speech- level term. The result is an upper bound: reverberant smearing only.
Returns {"sti": float, "mti": {centre_hz: float}}. Multichannel
IRs use channel 0.
Source code in src/ambiscape/impulse.py
212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 | |
iacc_early(ir, fs, window_ms=80.0, max_lag_ms=1.0)
Early interaural cross-correlation of a stereo/binaural IR.
IACC_E per ISO 3382-1: the maximum of the normalised cross-correlation
between the two channels over the first window_ms (default 0–80 ms
after the direct sound), searched over lags of ±max_lag_ms
(default 1 ms). Broadband (no octave filtering). 1 = the two ears hear
identical signals; low values = spatially decorrelated early sound.
Returns None unless the IR has exactly two channels.
Source code in src/ambiscape/impulse.py
260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 | |
iacc_e3(ir, fs, window_ms=80.0, max_lag_ms=1.0, centers=IACC_E3_CENTERS)
Octave-band early IACC and the IACC_E3 average.
:func:iacc_early is broadband; the concert-hall literature reports
IACC_E3, the mean of the 500, 1000 and 2000 Hz octave bands. The two
are not the same quantity — low-frequency content moves the broadband
value — so comparing a broadband number against published hall values
compares different things. Use this one for any such comparison.
Per band, ISO 3382-1: the maximum of the modulus of the normalised
interaural cross-correlation over lags of ±max_lag_ms, within the
first window_ms after the direct sound.
iacc_signed carries the signed correlation at that same lag, which
is the one thing the modulus discards: a negative value means the ears
receive anti-phase sound, perceptually very different from the strong
correlation an IACC near 1 otherwise implies. It is a diagnostic, not
an ISO quantity.
Returns {"iacc_e3", "iacc": {centre: v}, "iacc_signed": {centre: v}},
with iacc_e3 None when the sample rate cannot carry all three bands.
Returns None unless the IR has exactly two channels.
Source code in src/ambiscape/impulse.py
283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 | |
partitioned_convolve(x, h, block=8192)
Uniformly partitioned FFT convolution (overlap-save, mono in/out).
The IR is split into block-sample partitions whose spectra sit in a
frequency-domain delay line; the input streams through in block
hops with 2×block FFTs, so memory and per-block cost stay constant
for arbitrarily long IRs. Output equals full linear convolution:
length len(x) + len(h) − 1.
Source code in src/ambiscape/impulse.py
332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 | |
auralize(dry, fs, ir, fs_ir, block=8192, normalize='match')
Convolve dry audio with a room impulse response.
Sample rates: if fs_ir != fs the IR is resampled (polyphase) to the
dry material's rate — the dry audio is never resampled.
Channel policy: equal channel counts convolve pairwise; a mono dry signal fans out through each IR channel (mono source, N-channel room); a mono IR is applied to each dry channel; any other mismatch mono-sums the dry signal first and fans out through the IR channels.
Normalisation policy: raw convolution gain is arbitrary (it scales
with the IR's level), so normalize="match" (default) rescales the
wet result so its absolute peak equals the dry input's peak — the
output is clip-safe iff the input was, and A/B comparisons sit at
comparable levels. normalize=None keeps the raw convolution.
Returns (wet, gain_db) where wet has length
len(dry) + len(ir) − 1 and gain_db is the applied make-up gain.
Source code in src/ambiscape/impulse.py
360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 | |
measure(recording, inverse=None, params=None, out_path=None, pre_ms=5.0, dur=None)
Full measurement pass: recorded sweep → ir.wav + metrics dict.
inverse is the matched inverse filter WAV from :func:write_sweep;
alternatively params names the sweep's JSON sidecar and the inverse
is regenerated bit-identically from it. With neither given, a
sweep.json next to the recording is tried. The saved ir.wav is
float32, rescaled to peak 0.5 (−6 dBFS; the applied gain is logged in
impulse.json, and all reported metrics are level-invariant).
Source code in src/ambiscape/impulse.py
410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 | |
Figures
Session figures.
Names follow ambiviz conventions where the plots correspond (https://github.com/fisheggg/ambiviz): the azimuth-vs-time panel is an anglegram and the polar energy histogram a directogram, computed here from streaming per-second pseudo-intensity features rather than a full AEM, so they scale to many-hour recordings. For rich spherical maps (AEM) of short excerpts, export a segment and use ambiviz directly.
overview(F, out_path, title='', clock=None)
4-row overview: fast level + background; log spectrogram; anglegram (energy-weighted azimuth x time); diffuseness. Takes separated by more than 10 minutes get their own column (width ~ duration).
Source code in src/ambiscape/figures.py
57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 | |
ltas_percentiles(F, out_path, title='')
10/50/90th percentile long-term spectra (background vs foreground).
Persistent DIN 45681-style prominent tones (ventilation hums, appliance whines) are marked with their frequency and decibel prominence ΔL.
Source code in src/ambiscape/figures.py
140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 | |
directogram(F, out_path, title='')
Polar azimuth histograms: foreground (loudest 25 %) vs background (quietest 25 %) energy-weighted. ambiviz-style directogram.
Source code in src/ambiscape/figures.py
180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 | |
Spectral foreground
Per-band running background and spectral foreground decomposition.
The broadband event detector in :mod:analysis misses band-limited events
riding on a loud bed in other bands (distant bells over traffic move their
octave a few dB while the broadband level barely changes). This module works
on the cached 1 Hz log-band spectrogram (F["logspec"], 96 bands):
band_background— running low-percentile background per band;foreground— dB exceedance and the per-second foreground fraction (share of total power sitting above the spectral background);spectral_events— connected spectro-temporal regions of exceedance (time x band blobs), each with onset, duration, band span, and peak rise;summarize_foreground— session descriptors appended to the analyze summary and README.
All functions are pure array transforms on cached features — no audio pass.
band_background(logspec, win_s=300.0, pct=10.0)
Running pct-percentile background per log band.
logspec is the (nsec, nband) power array from the cached features;
the window is in seconds (= rows). Returns the same shape.
Source code in src/ambiscape/background.py
26 27 28 29 30 31 32 33 34 35 | |
foreground(logspec, bg)
dB rise above the spectral background and per-second foreground fraction (share of total power more than 3 dB above background).
Source code in src/ambiscape/background.py
38 39 40 41 42 43 44 | |
spectral_events(rise_db, logf, thresh_db=6.0, min_dur_s=2.0, min_bands=2)
Connected regions of band-wise exceedance as event dicts.
A spectral event is a blob in the (time x band) plane where the rise
exceeds thresh_db, lasting >= min_dur_s and spanning >=
min_bands bands. Returns onset/duration (s), band span (Hz), and
peak rise (dB), sorted by onset.
Source code in src/ambiscape/background.py
47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 | |
masking_index(F, active, quiet)
How much a dominant source hides the rest of the field — the "lo-fi" claim as a number.
active/quiet are boolean second-masks (source on / off). Per log
band, the floor elevation is the rise of the active-state median level
above the quiet-state median: ambient sounds in that band must now
exceed the elevated typical floor to be audible. Returns the median and
maximum elevation over 250 Hz–8 kHz, the fraction of bands elevated by
more than 6 dB, and the per-band curve.
Source code in src/ambiscape/background.py
75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 | |
source_fingerprint(F, active, quiet, fmin=25.0, fmax=16000.0, min_prom_db=6.0, max_peaks=20)
Spectral fingerprint of a source: active-minus-quiet mean PSDs.
active/quiet are boolean masks over the minutes of
F["minspec"] (source clearly on / clearly off, e.g. from
:func:ambiscape.states.state_segments). The rise curve is the dB
difference of the two mean spectra — the source's own spectrum with the
room ambience subtracted. Narrowband peaks of the rise are extracted and
passed through the harmonic sieve, so a blade-pass or compressor comb
reports its base frequency.
Returns dict: freqs/rise_db (the full curve), rise_max_db/
rise_max_hz (the turbulence hump), peaks (list of
{f_hz, rise_db}), and comb ({f0_hz, harmonicity} of the peak
set, f0_hz None when there are no peaks).
Source code in src/ambiscape/background.py
99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 | |
summarize_foreground(F, win_s=300.0)
Foreground descriptors for the analyze summary.
Source code in src/ambiscape/background.py
149 150 151 152 153 154 155 156 157 158 159 160 161 162 | |
Machine states
Machine states: on/off segmentation, switch points, and duty cycles.
Domestic and mechanical sources (ventilation, fridges, pumps, HVAC) show up in a soundscape as a state — a band-limited floor that is either present or absent — rather than as events. This module segments a band-level timeline into those states from the cached features, no audio pass:
band_level— per-second dB level in a frequency band from the cached log-band spectrogram (the "machine band" of a source, e.g. 250–1000 Hz for a ventilation unit);state_segments— two-state (on/off) segmentation of that level with an automatic bimodal threshold, hysteresis, and a minimum duration, each segment carrying its median level and within-state stability (SD);switch_points— the transitions between segments (the 07:53:55 switch-off moments);duty_cycle— cycle statistics of a cycling machine (a fridge's ~24 min period at ~50 % duty): period, duty fraction, cycle count;cycle_series— the same cycles as series rather than medians, because a machine's on-time and its period have different causes and can move independently;bimodal_separation— whether the timeline has two modes at all, which has to be asked before any of the above is believed.
Typical use: segs = state_segments(band_level(F, (250, 1000))) and mask
other analyses (fingerprints, masking, taxonomy states) by segment.
Ask bimodal_separation first whenever the machine may be faint. The
segmentation always returns something.
band_level(F, band=(250.0, 1000.0))
Per-second dB level in band (Hz) from the cached logspec.
Source code in src/ambiscape/states.py
38 39 40 41 42 43 | |
bimodal_threshold(level_db)
Otsu's threshold on the level histogram: the split that best separates the two modes of an on/off timeline.
Source code in src/ambiscape/states.py
46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 | |
transition_profile(level_db, segments, dt_s=1.0, settle_tol_db=1.0, max_settle_s=120.0)
Characterise the boundaries between steady states, not the states.
A machine starting or stopping is itself a sound action, and it has the morphology of one: abrupt, then settling. A refrigerator does not fade in. It strikes, clatters for a moment, and subsides into the steady hum that will be ignored for the next eleven minutes. Heard on its own that is an impulse followed by a sustain, which is to say a sound object in Schaeffer's sense, arriving involuntarily in a room rather than deliberately in front of a microphone.
That matters for attention, because the transition is where a background briefly becomes a figure and then returns to being a background. The steady states either side are what a level summary describes; the crossings between them are what anybody in the room actually notices, and until now nothing here measured them.
For each boundary in segments returns the direction, the size of
the step, how abruptly it happened (the 10--90 % crossing time) and how
long the level took to settle within settle_tol_db of its new
median. None for a settling time means it had not settled within
max_settle_s, which is a finding rather than a gap: a transition
that never settles is not a machine changing state.
Source code in src/ambiscape/states.py
65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 | |
state_segments(level_db, thresh_db=None, smooth_s=11.0, hysteresis_db=1.0, min_dur_s=30.0)
Two-state segmentation of a 1 Hz band-level timeline.
The level is median-smoothed over smooth_s; the threshold defaults
to the bimodal (Otsu) split of the histogram — pass thresh_db when
the timeline is not clearly bimodal. Hysteresis of hysteresis_db
around the threshold suppresses chatter, and segments shorter than
min_dur_s are merged into their neighbors. Returns segments in time
order as dicts: state ('on'/'off'), t0_s/dur_s (seconds into the
timeline), median_db, and sd_db (within-state stability of the raw
level — a running machine is steady, ambience is not).
Source code in src/ambiscape/states.py
142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 | |
switch_points(segments)
Transitions between consecutive segments: time and direction ('on' = machine starts, 'off' = machine stops).
Source code in src/ambiscape/states.py
191 192 193 194 195 196 197 198 199 | |
duty_cycle(segments)
Cycle statistics of a cycling machine from its state segments: median period (consecutive on-starts), duty fraction (median on-time over period), and the number of complete cycles observed.
Source code in src/ambiscape/states.py
202 203 204 205 206 207 208 209 210 211 212 213 214 | |
cycle_series(segments)
On-time and period per cycle, and whether either is trending.
The two halves of a thermostat cycle have different causes and can move
independently. A compressor runs until the cabinet reaches its set point,
which takes about as long each time and is a property of the appliance;
it then waits until the cabinet drifts back, which takes longer as the
room cools and is a property of the room. A duty fraction is their ratio
and hides both. duty_cycle returns that ratio and a median period,
cycle_drift a median and a percentage; neither shows one half holding
while the other moves.
A domestic refrigerator over one night: on-time 7.6 to 8.5 minutes, period 30.5 to 38.0.
Returns on_s (one per on-segment) and period_s (one per
consecutive pair of on-starts, so one shorter), each with the Pearson
correlation against cycle number and the change per cycle from a linear
fit. A correlation near zero with a real spread is a machine whose
interval is set by something that is not changing; a high correlation on
the period with none on the on-time is the signature above.
Read the trend against the spread, not on its own: a period that moves by a quarter of itself and an on-time that moves by a minute can both correlate at 0.9, and only one of them matters.
Source code in src/ambiscape/states.py
217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 | |
bimodal_separation(level_db, min_separation_db=2.0, min_fraction=0.02)
Does this timeline have two modes at all?
bimodal_threshold is Otsu's method and will always return a number.
Asked for a split of a level series with only one populated mode -- a
machine too faint to clear the room, a recorder in a room the machine is
not in -- it returns a value inside that single mode, state_segments
then divides noise, and duty_cycle reports a period for a machine
that was never detected. Nothing in the chain says anything is wrong.
The failure is quiet and easy to reach. One refrigerator, two rooms of
the same house on comparable nights: in the kitchen it separates the
timeline by 8.4 dB and segments into twelve cycles; in the living room it
contributes 0.6 dB, and the same call returns a single segment spanning
the night from which duty_cycle reports one cycle.
Returns the two class means either side of the Otsu split, their
separation in dB, the fraction of the series in the upper class, and
bimodal, which is False when the separation is under
min_separation_db or either class holds less than min_fraction of
the series. False does not prove the machine is absent -- only that a
two-state split of this timeline is not evidence that it is present.
Source code in src/ambiscape/states.py
274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 | |
Electric network frequency (ENF)
Electric network frequency (ENF) traces from mains hum.
Buildings hum at the mains frequency and its harmonics (50 Hz nominal in Europe; magnetostriction is strongest at 100 Hz), and the grid's actual frequency wanders by tens of millihertz as load and generation balance. A long indoor recording therefore carries a continuous, involuntary log of the grid — usable as a session descriptor (how electrified is this room?), as a source separator (a "50 Hz" line that does not follow the grid is a rotor, not electricity), and forensically: matched against published grid-frequency archives, an ENF trace timestamps a recording independently of the recorder clock.
hum_peak— sub-millihertz line frequency in one mono window (zero-padded FFT + parabolic interpolation) with its rise over the local spectral floor;enf_track— the trace: windows everystep_sacross a whole session, one or more harmonics, all scaled to the fundamental;enf_summary— mean/SD/max deviation, coverage, and cross-harmonic agreement — the latter is the authenticity check (independent acoustic lines reporting the same electrical frequency).
Needs raw audio (one streaming pass over the W channel); the cached per-minute spectra are far too coarse (5.9 Hz bins) for millihertz work.
CHANGING nominal IS NOT A NEUTRAL ACT, and the trap it opens cost a
published claim. Railway supplies invite it: the Nordic countries, Germany,
Austria and Switzerland electrify at 16⅔ Hz, so nominal=16.667 looks like
the obvious way to ask whether a recording was made on such a train. It is
not, because 16⅔ has 50 Hz as its third harmonic and 100 Hz as its sixth.
A family built on 16⅔ therefore contains the European mains family inside it,
and any recording with mains in it will score well on "the Nordic railway
supply" — including a hotel foyer with no train within a kilometre, which is
the control that settled it on 2026-08-11. The Stavanger train's own 16.7 Hz
fundamental sat 3 dB below the noise around it, as did its second, third and
fourth harmonics; the score was carried entirely by 100 Hz, which is mains.
Two rules follow, and they generalise past railways to any hypothesised family (a shaft rate, a chopper frequency, a fan's blade-pass):
- Check the rungs, not the mean. A family whose fundamental and low
harmonics are absent while one high harmonic is strong is not a family.
:func:
ambiscape.tonality.family_prominencereturns the per-harmonic list for exactly this reason. - Rank the hypothesis against the alternatives, with
:func:
ambiscape.tonality.family_percentile, and run a control recording that cannot contain the source. Presence is not evidence; being exceptional is.
hum_peak(w, fs, nominal=50.0, search_hz=0.2, nfft_mult=4)
Frequency and floor-rise of the strongest line near nominal.
Zero-padded FFT of the Hann-windowed mono signal, parabolic
interpolation of the log-power peak within nominal ± search_hz.
Returns (freq_hz, rise_db); rise is measured against the median
power in a ±1.5 Hz-widened neighbourhood, so a genuine line scores
high even on a rumble shoulder.
Source code in src/ambiscape/enf.py
58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 | |
enf_track(sess, step_s=300.0, win_s=60.0, nominal=50.0, search_hz=0.2, harmonics=(1, 2), channel=0)
Track the mains hum across a whole session.
One window of win_s every step_s, per take (windows start 1 s
into each take and reads shorter than 90 % of the window are skipped —
recorder 2 GB splits overlap by a fraction of a second, so a read at an
exact take start can return a sliver of the previous file). Each
harmonic k is searched at k*nominal ± k*search_hz and reported
scaled to the fundamental.
Returns {"t": absolute seconds, "f": {k: freq_hz/k}, "rise":
{k: rise_db}}.
Source code in src/ambiscape/enf.py
83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 | |
enf_summary(track, nominal=50.0, min_rise_db=6.0)
Descriptors of an ENF trace.
Statistics use only windows where the first harmonic rises
min_rise_db above the floor; coverage is the fraction of
windows that qualify. harmonic_agreement_mhz is the median absolute
difference between the first two tracked harmonics (fundamental-scaled)
where both are detected — millihertz-level agreement authenticates the
line as electrical.
COVERAGE IS NOT A PROXY FOR WHERE THE RECORDING WAS MADE, however reasonable that sounds — mains hum means mains nearby, so more hum should mean more indoors. Tested on 365 daily recordings sorted into seven kinds of place, the group medians ran from 0.590 down to 0.475, a total range of 0.115 against a within-group interquartile width of 0.260: the differences between kinds of place were 2.3 times smaller than the differences within them, and Kruskal--Wallis returned H = 3.7 at p = 0.72. Outdoor and semi-open sessions ranked sixth of seven, in among the indoor groups rather than below them. The measurement itself was in excellent health over the same year — the grid recovered on 364 of 365 days at a median 49.9913 Hz — so this is a good measurement of the wrong thing, which is the kind that survives review.
What coverage does track is the recording: gain, wind, the recorder's
own noise floor, how much of the window something was leaning on the
microphone. It is a quality figure wearing a location figure's clothes.
It is also tied to win_s, step_s and min_rise_db, being a
fraction of windows clearing a threshold, so two coverages compare only
where all three match. And a single day's zero deserves a look at the
file before it is believed: the most extreme reading of that year came
from a WAV whose header declared 690 seconds over 379 MB of audio and
returned no frames at all to libsndfile without raising anything — on an
outdoor day, so the artefact was the one number that made the story come
out the way it was expected to.
Source code in src/ambiscape/enf.py
117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 | |
Ecoacoustic indices
Ecoacoustic indices from the cached log-band spectrogram.
The standard soundscape-ecology battery, so sessions are reportable in the idiom global acoustic-monitoring corpora expect. All computed from the cached 1 Hz features — no audio pass:
- ACI (acoustic complexity, Pieretti et al. 2011): per-band temporal
variation |ΔP|/ΣP summed over bands, averaged over 5-min chunks —
sensitive to biophonic modulation, blind to steady drones, and
undefined (
None) for recordings shorter than one chunk; - ADI / AEI (diversity / evenness, Villanueva-Rivera et al. 2011): Shannon entropy / Gini coefficient of the occupancy of 1 kHz bins (fraction of cells above a threshold re the band maximum);
- NDSI (Kasten et al. 2012): (biophony − anthrophony) / (biophony + anthrophony) with the conventional bands 2–8 kHz vs 1–2 kHz, in [−1, 1];
- BI (bioacoustic index, Boelman et al. 2007): area of the mean 2–8 kHz dB spectrum above its minimum;
- acoustic entropy H (Sueur et al. 2008): spectral entropy × temporal (envelope) entropy, in [0, 1].
INDOORS, SOME OF THESE FAIL AND SOME DO NOT, and which is which was measured rather than reasoned. Three dawn and dusk choruses against synthetic ventilation noise (Jensenius 2026, When ventilation outperforms the dawn chorus):
=================== ================ ============= index ventilation choruses =================== ================ ============= ADI 0.977 0.927–0.968 bird-band Ht 0.998 0.701–0.913 NDSI −0.139 0.707–0.997 acoustic entropy H 0.387 0.492–0.610 =================== ================ =============
ADI and bird-band temporal entropy rate a duct above every chorus, and Ht gives the fan the highest value in the whole comparison, because a stationary signal is perfectly uniform in time. NDSI, H, AEI and BI are not fooled by this material.
The division is not about which band an index looks at. The indices that fail read occupancy and time — how many cells are busy, how evenly spread across the hours — and a stationary broadband source saturates both whatever its spectral tilt. The ones that resist read spectral shape, and duct noise falling steadily with frequency is neither bright nor flat. Expect the same split for any steady mechanical source; expect NDSI to fail as well wherever the machine's own energy sits inside the bio band, which is the 4 kHz hiss case an earlier version of this note wrongly generalised from.
Scale is the other warning. Over 14 node-days of an inhabited home, ADI moves less across an entire week — 0.031 on one node — than it does between two microphones standing metres apart in the same room, 0.036. A descriptor whose weekly variation is smaller than its disagreement between two positions in one room is not measuring the week.
Report them for comparability with outdoor corpora, read the occupancy-and-
time pair as "is anything steady here", and go to :mod:ambiscape.biophony,
which measures structure rather than energy, before reading any of them as
life.
aci(F, chunk_s=300.0)
Acoustic complexity index, mean over chunk_s chunks.
ACI accumulates |ΔP| over a whole chunk, so its magnitude is a
function of the chunk length: values are comparable only between
recordings analysed with complete chunks of the same size. A
recording shorter than one chunk therefore has no ACI, and None
is returned — a numeric zero would be indistinguishable from a
measured minimum (clip corpora of 5–30 s are the common case).
Source code in src/ambiscape/ecology.py
73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 | |
adi_aei(F, **kw)
Acoustic diversity (Shannon, normalised) and evenness (Gini).
Source code in src/ambiscape/ecology.py
112 113 114 115 116 117 118 119 120 121 | |
ndsi(F, anthro=(1000.0, 2000.0), bio=(2000.0, 8000.0))
Normalized difference soundscape index in [−1, 1].
Source code in src/ambiscape/ecology.py
124 125 126 127 128 129 130 | |
bioacoustic_index(F, band=(2000.0, 8000.0))
Boelman BI: area of the mean band dB spectrum above its minimum.
Source code in src/ambiscape/ecology.py
133 134 135 136 137 138 | |
acoustic_entropy(F)
Sueur H = spectral entropy × temporal entropy, in [0, 1].
Source code in src/ambiscape/ecology.py
141 142 143 144 145 146 147 148 149 150 | |
indices(F)
The full battery as one dict.
Source code in src/ambiscape/ecology.py
153 154 155 156 157 158 159 160 161 162 163 164 | |
summarize_ecology(F)
Alias of :func:indices for the analyze-summary pipeline.
Source code in src/ambiscape/ecology.py
167 168 169 | |
Biophony
Biophony measures: capturing nature and animal sounds by structure.
The ecoacoustic battery in :mod:ambiscape.ecology (NDSI, BI, ADI) reads
energy in a band; it cannot tell a dawn chorus from a ventilation hiss.
Biophony is distinguished by how it is structured — narrowband, tonal,
bursty in time, and (in an ambisonic recording) arriving from many elevated
bearings at once. This module measures that structure from the cached
features, no audio pass:
narrowband_activity— persistent narrow spectral peaks in the bird band per minute (from the per-minute high-resolution PSD): birdsong is narrowband and tonal, wind and machines are broadband;band_temporal_entropy— Sueur temporal entropy of the bird-band envelope: structured vocalisation concentrates energy in time (low Ht), a steady noise floor spreads it evenly (Ht → 1);band_activity— fraction of seconds and event rate where the bird band rises above its own running background (Towsey-style acoustic activity), restricted to the biophony band;spatial_dispersion— the ambisonic layer no other corpus tool has: the directional entropy and above-horizon energy fraction of the bird-band foreground — a chorus of many birds from many elevated directions is unmistakable, and it cross-checks a suspicious NDSI.
summarize_biophony returns the descriptor set for the analyze summary.
Caveats: these are acoustic-structure proxies, not detections. A tonal alarm, a whistling kettle, or a squealing fan belt can mimic biophonic structure. The default band (2–11 kHz) targets temperate birdsong; widen it (insects reach 8–16 kHz, many mammals sit below 2 kHz) per habitat.
AND DO NOT TREAT BIRDNET AS THE CONFIRMATION, which is what this note used to
say. :func:ambiscape.ml.birdnet_session ([ml] extra) is a useful second
opinion with an indoor failure mode of its own, measured on 2026-08-11: run
over a university corridor in January it returned eight Great Bittern
detections, the best at 0.87 confidence. A bittern booms at roughly
150–200 Hz, which is where a ventilation plant lives. Gray Heron, Tawny Owl
and Red-throated Loon came back from other empty interiors the same day. The
classifier does not merely miss faint birds indoors; it returns confident
low-frequency species where none were, so a detection count is not evidence
that anything was alive.
MACHINERY IS ONE MECHANISM AND NOT THE MECHANISM, which this note claimed until 2026-08-13. A second corpus — 37 sessions across a year, indoors and out — put a Long-eared Owl in 24 of them, 65 per cent, in daylight, in a city, in spring and summer. The obvious story was the one above: a hooting owl and a ventilation plant occupy the same octave. Tested against the sessions' mechanical index, the association ran the other way and strongly. Owl sessions had a median mechanical index of 0.066 against 0.292 without, a factor of 4.5 at Mann--Whitney p = 0.00039. The false detections lived in the quiet, where a low floor lets faint and ambiguous material through, not in the noise. Both mechanisms are real; neither predicts the other, so name the one that was tested rather than the one that was plausible.
Two cheap checks the machinery story would not have caught:
- Read the rate, not the entry. A species that is regionally unremarkable becomes a finding or an artefact depending on how often it appears. Long-eared Owls do live around that city; they are not in 24 different locations across two seasons in the middle of the day. A plausible species at an implausible rate is the signature.
- Check the calendar against the range. In the same list a Common Swift arrived in an October session, and swifts have left the country by the end of August. One detection out of season disqualifies itself on a fact nothing acoustic can rescue.
The fix is a control, not a threshold. Run the same settings over recordings
from comparable rooms and the same recorder that certainly contain no birds —
a plant room, a corridor at night, a toilet — and treat every species that
comes back as confusable with that building's machinery. Include quiet
controls as well as loud ones, now that the quiet is known to be where the
worse rate sits. Say what the control removed rather than quietly reporting
the remainder. It is the :mod:ambiscape.enf lesson in another key: a test
that any drone can pass is not a test.
A low confidence threshold is defensible and a low threshold on its own is not. The owl list was taken at 0.25 deliberately, on the argument that a low threshold with an explicit validation step beats a high one that hides its own failures — which is sound, and only sound while the validation step actually happens. Until it does, a species list is a list of candidates.
narrowband_activity(F, band=BIRD_BAND, min_prom_db=6.0, min_peaks=2)
Per-minute count of narrowband tonal peaks in band.
Uses :func:ambiscape.tonality.tonal_peaks on each row of the cached
per-minute PSD. Returns median peaks/min, the per-minute counts, and
the fraction of minutes with at least min_peaks (an "active"
biophonic minute).
On its own this is a weak biophony discriminator and must not be read
as a bird count: (a) minute-averaging smears frequency-swept birdsong,
so a busy dawn chorus shows only a modest per-minute peak count while
its max and active_minute_fraction spike; (b) steady machine
harmonics are narrowband too and score just as high. What separates
birds from machines is the combination with the temporal
(:func:band_temporal_entropy) and spatial
(:func:spatial_dispersion) measures — biophony is narrowband and
bursty and spread across elevated bearings; a machine tone is
narrowband but steady, low, and directional.
Source code in src/ambiscape/biophony.py
104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 | |
band_temporal_entropy(F, band=BIRD_BAND)
Sueur temporal entropy Ht of the bird-band envelope, in [0, 1].
Low = energy concentrated in time (structured vocalisation); near 1 = even over time (steady band, no biophonic events).
Source code in src/ambiscape/biophony.py
139 140 141 142 143 144 145 146 147 148 | |
band_activity(F, band=BIRD_BAND, k_db=3.0, bg_win_s=300.0, min_dur_s=1)
Acoustic activity of the bird band above its running background.
The band envelope (dB) is compared to a running 10th-percentile
background over bg_win_s; seconds exceeding it by k_db are
active. Returns the active-second fraction, event rate per minute
(runs of >= min_dur_s active seconds), and median event duration.
Source code in src/ambiscape/biophony.py
151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 | |
spatial_dispersion(F, band=BIRD_BAND, nbins=36, limit_deg=10.0)
Directional spread and elevation of the bird-band foreground.
The azimuth histogram is weighted by the per-second bird-band
foreground energy (band level above its running background), so only
seconds carrying biophonic energy contribute. Returns the normalised
directional entropy (0 = one bearing, 1 = all around) and the fraction
of that foreground energy arriving from above limit_deg elevation
(birds aloft) versus at/below the horizon.
Source code in src/ambiscape/biophony.py
184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 | |
summarize_biophony(F, band=BIRD_BAND, min_active_fraction=0.02)
Biophony descriptors for the analyze summary.
The spatial biophony descriptors are only meaningful when the bird
band actually carries foreground energy: in a quiet, birdless room a
trickle of high-frequency energy that happens to arrive from above would
otherwise read as above_horizon_fraction = 1.0, a false positive.
When the band-active fraction is below min_active_fraction the
directional and horizon descriptors are therefore reported as None
rather than as spurious numbers.
Source code in src/ambiscape/biophony.py
208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 | |
State-resolved descriptors
State-resolved descriptors: summarise each state of a session separately.
A single descriptor row for a multi-state session is a duration-weighted average of things that never coexisted — the Haarlem loft's row is dominated by the 9-hour air-pump night and barely reflects the hi-fi afternoon. This module slices the cached features by time and runs the full summary pipeline on each state, so "vent on" and "vent off" (or day / night, or any supplied intervals) get their own complete descriptor set.
slice_features— a sub-Frestricted to a set of time intervals, valid across every feature axis (1 s, 125 ms fast, 20 ms envelope, per-minute PSD) for all thesummarize_*functions;full_summary— the merged descriptor dict (level/event, spectral foreground, ecoacoustic, spatial, biophony) — the same setanalyzewrites, computed on anyF;resolve—{state: full_summary}for a dict of named intervals;machine_states/diel_states— auto-discover the states from a machine band (via :func:ambiscape.states.state_segments) or the wall clock (day / night).
Event detection and percentiles run per state, so an interval need not be contiguous; a state shorter than a few frames is skipped.
intervals_from_mask(t, mask)
Contiguous [start, stop) intervals (in t units) of a boolean
mask over the 1 s frames.
Source code in src/ambiscape/resolve.py
35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 | |
slice_features(F, intervals)
Restrict cached features to intervals (absolute seconds).
Returns a sub-F with every time-indexed array masked to the
intervals and every scalar/axis array copied through — accepted by all
the summarize_* functions.
Source code in src/ambiscape/resolve.py
59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 | |
full_summary(F, check_windows=True)
The complete analyze descriptor set for any F (no calibration).
Each descriptor is checked against the observation window it needs
(:mod:ambiscape.timescales) before the summary is returned. A
descriptor below a hard window is set to None, because below it
the quantity does not exist: the complexity index has no complete
300 s chunk to average and would otherwise return a confident zero
that no measurement produced. A descriptor below a soft window is
kept and listed in low_confidence, which names the key, the window
it needed and the window it had.
This is the choke point on purpose. Every descriptor that reaches
summary.json, and from there the deposits, the catalogue and the
reports, passes through here --- including the per-state summaries of
:func:resolve, where segments are short and the problem is worst.
check_windows=False returns the raw set, for a caller that wants
what the computation produced rather than what it supports.
Source code in src/ambiscape/resolve.py
92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 | |
resolve(F, states, min_frames=30)
{state: full_summary} for a dict of {label: intervals}.
States whose sliced 1 s length is below min_frames are skipped
(too short for stable percentiles/events).
Source code in src/ambiscape/resolve.py
132 133 134 135 136 137 138 139 140 141 142 143 | |
machine_states(F, band=(250.0, 1000.0), min_dur_s=120.0, labels=('machine_on', 'machine_off'))
Auto-discover on/off states from a machine band.
Segments the band level with :func:ambiscape.states.state_segments
and returns {labels[0]: on-intervals, labels[1]: off-intervals}
in absolute seconds (empty sides dropped).
Source code in src/ambiscape/resolve.py
146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 | |
auto_states(F, band=(250.0, 1000.0), min_dur_s=180.0, min_step_db=4.0, min_dur_frac=0.05)
Machine on/off states only if the session is genuinely two-state.
The gate for automatic use in analyze: returns the
:func:machine_states split when both states last at least
min_dur_s (and min_dur_frac of the session) and the band-level
step between them is at least min_step_db; otherwise {} (a
single-state session gets no state rows). Prevents spurious splitting of
a flat, steady soundscape.
Source code in src/ambiscape/resolve.py
169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 | |
diel_states(F, sess, night=(22, 6), labels=('night', 'day'))
Split a session into night / day by the wall clock.
night is (start_hour, end_hour) wrapping midnight; uses the
session's day0 to turn absolute seconds into hour-of-day.
Source code in src/ambiscape/resolve.py
198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 | |
Corpus catalogue
Corpus aggregation: one cross-session table from cached summaries.
Every ambiscape analyze writes <session>/analysis/summary.json.
This module collects them across a corpus folder into one table — CSV for
analysis, a transposed Markdown table (descriptor rows, session columns) for
a consolidated report — plus simple ranking and outlier helpers. No audio,
no features: it reads only the cached summaries, so a whole corpus
aggregates in milliseconds.
The Markdown layout follows the Intercontinental-database
CONSOLIDATED.md convention. Sessions with differing descriptor sets
(older caches, optional modules) are handled by taking the union of keys
and leaving blanks where a session lacks one.
collect(corpus_dir, pattern='*/analysis/summary.json', include_states=False)
Map session name → summary dict for every summary under corpus_dir.
The session name is the top-level folder (the parent of analysis/).
Unreadable or malformed files are skipped. With include_states, each
session's analysis/states.json (if present) contributes extra
"<session>::<state>" rows right after the pooled session row — the
state-resolved corpus view.
Source code in src/ambiscape/catalog.py
23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 | |
to_csv(collected, path, keys=None)
Write a session-per-row CSV (union of keys, blanks for missing).
Uses the standard :mod:csv writer, so any field containing a comma,
quote, or newline is quoted correctly (a value like "interior,
morning" round-trips) — consistent with every other CSV the toolkit
emits.
Source code in src/ambiscape/catalog.py
66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 | |
to_markdown(collected, keys=None, labels=None)
Transposed Markdown table: one row per descriptor, one column per
session (the consolidated-report layout). labels optionally maps
descriptor keys to human labels.
Source code in src/ambiscape/catalog.py
86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 | |
rank(collected, key, descending=True)
(session, value) pairs sorted by key (numeric sessions only).
Source code in src/ambiscape/catalog.py
114 115 116 117 | |
outliers(collected, key, z=1.5)
(session, z-score) for sessions more than z SDs from the mean
on key, most extreme first — the cheap "what stands out" query.
Source code in src/ambiscape/catalog.py
120 121 122 123 124 125 126 127 128 129 130 131 132 | |
Longitudinal analysis
Longitudinal analysis: how a place sounds across weeks, months, a year.
The unit here is the dated session summary, not the audio. A year-long
study is best run as many short sessions---one a day, say, as in the
StillStanding archive---each analysed to a small summary.json; a year is
then 365 tiny rows, so the longitudinal analysis is inherently out-of-core
however large the underlying audio was. (A single continuous multi-month
recording exceeds what the in-memory feature pipeline can hold; the
supported path for year-scale work is to segment it into per-day sessions
first.)
collect_series--- read every session's summary under a corpus, ordered by date (from adatefield, else parsed from the folder name), into per-descriptor time series;decompose--- additive split of one descriptor into a slow trend (day-windowed rolling median), a repeating seasonal component (monthly climatology of the detrended series), and the residual;seasonal_climatology/trend_slope--- the two components on their own (per-month means; long-term change per year);summarize_longitudinal--- trend per year, seasonal amplitude, peak and trough months, span;render--- a figure: the descriptor over time with its trend, plus the monthly climatology.
Everything is numpy-only. The motivating example is already in the StillStanding data: bird mentions peak in July and fall to zero in winter--- not because the birds leave, but because the windows close.
parse_date(name)
Parse a leading YYYY-MM-DD or YYYYMMDD date from a string.
Source code in src/ambiscape/longitudinal.py
41 42 43 44 45 46 47 48 49 50 | |
collect_series(corpus_dir, keys=None, pattern='*/analysis/summary.json')
Dated, date-ordered descriptor time series from a corpus of sessions.
Each session's date comes from the summary's date field if present,
otherwise from a date parsed out of the session folder name; sessions
with no resolvable date are skipped. Returns {"dates": [date, ...],
"sessions": [name, ...], "series": {key: np.array}} with all arrays in
date order. keys limits the descriptors (default: the union across
sessions).
Source code in src/ambiscape/longitudinal.py
53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 | |
rolling_trend(dates, values, window_days=365.0)
Day-windowed rolling median: a slow trend robust to spikes and to
irregular sampling (each point is the median of all points within
window_days of it).
Source code in src/ambiscape/longitudinal.py
106 107 108 109 110 111 112 113 114 115 116 | |
seasonal_climatology(dates, values)
Per-calendar-month mean and count of a series (month 1..12).
Source code in src/ambiscape/longitudinal.py
119 120 121 122 123 124 125 126 127 128 | |
decompose(dates, values, window_days=365.0)
Additive decomposition: trend + seasonal (monthly) + residual.
Returns date-ordered arrays plus the monthly climatology (mean of the
detrended series per calendar month, mean-centered so the seasonal
component sums to ~0).
Source code in src/ambiscape/longitudinal.py
131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 | |
trend_slope(dates, values)
Long-term linear change per year (least-squares slope × 365.25).
Source code in src/ambiscape/longitudinal.py
155 156 157 158 159 160 161 162 163 | |
summarize_longitudinal(dates, values, window_days=365.0)
Trend/seasonal descriptors of one dated series.
Source code in src/ambiscape/longitudinal.py
166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 | |
render(dates, values, out_path, key='', window_days=365.0)
Two-panel figure: series + rolling trend over time, and the monthly climatology.
Source code in src/ambiscape/longitudinal.py
191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 | |
run_corpus(corpus_dir, out_dir, keys=None, window_days=365.0)
CLI driver: per-descriptor longitudinal summaries + a figure for each,
writing longitudinal.json.
Source code in src/ambiscape/longitudinal.py
227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 | |
Cross-session and cross-node comparison
Cross-session comparison: the same place on different days.
The catalog answers "how do my places differ"; this module answers "how
did this place differ between visits" — two or more analysed sessions of
one room laid onto a common clock so that machines, weather, parties and
silences can be read against each other. Everything works from the cached
1 Hz features and summary.json/states.json of a prior analyze
run; no audio is reopened.
load_comparison--- features + summary + states for each session;laeq_timeline--- per-minute LAeq with NaN gaps between takes;clock_rows--- group sessions that share (or bridge) calendar days onto common clock-aligned rows;timeline_figure--- LAeq rows on a shared hour-of-day axis, detected states shaded;ltas_by_state/ltas_figure--- median band spectra split by each session's detected states, overlaid;line_prominence--- how strongly a known tonal line (a machine fingerprint) stands out of the per-minute minimum spectrum;band_level/band_timeline_figure--- one frequency band on the clock axis across sessions (dawn chorus, rain hiss, party bass);azimuth_rose_figure--- foreground energy by azimuth, side by side (mic frames differ between visits: compare shapes, not directions);floor_difference--- median minimum-spectrum difference between two time windows: the detector for near-floor sources (a quiet fan's shelf);duty_cycle--- period, duty and regularity of a cycling source (a fridge) from a band-level autocorrelation;xnode_day_matrix/xnode_floor/xnode_gain_offsets/xnode_loudest/xnode_figure--- several uncalibrated nodes of one building on one day clock: binned heatmap rows (dB above each node's own day median), a per-node noise floor, gain offsets read from those floors, and a loudest-room timeline that ranks on gain-corrected level and only speaks when the margin is real and the level is above the floor;run_compare--- orchestrate the above into figures + compare.json.
Times follow the feature axis: seconds since midnight of each session's first calendar day (so hour 28.5 is 04:30 on day 2). The motivating corpus is the Haarlem loft: the same room four days apart swapped a loud ventilation drone for rain, a Saturday-night party, and its quietest recorded floor.
What an uncalibrated network can be asked. xnode_loudest is the
cautionary case and its own docstring carries the detail: it ranks each node's
excess over its own floor, so a deeper floor wins regardless of what the node
heard, and every figure built on it is withdrawn. The general lesson from that
deployment, where twelve nodes differed in speech-band sensitivity by a factor
of 2.4, is that such a network answers questions whose answers are shapes and
not questions whose answers are magnitudes:
- Differences taken within one node travel between nodes. The dynamic range
L10 - L90and the phase of a daily cycle both cancel the node's gain, and both work here: seven nodes in one room agree on peak hour to within half an hour while disagreeing 2.4-fold on sensitivity. - Absolute levels do not travel, and nothing built from them across nodes does.
- To follow a source between rooms, compare each node's rise against its own earlier baseline, never one node's level against another's. Validated against hand annotations on that corpus: the loudest-room sequence during a vacuuming session recovers the annotated walk through hall, bathroom, WC and bedroom in the right order, and adds the hall transits between rooms that the ground truth does not separately record.
load_comparison(folders)
Features + summary + states per analysed session.
Each entry: {"name", "folder", "date" (day-0 date or None),
"F" (features), "summary", "states" (or None)}. Sessions without a
feature cache raise — run ambiscape analyze first.
Source code in src/ambiscape/compare.py
78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 | |
in_intervals(t, intervals)
Boolean mask of t covered by [a, b) interval pairs.
Source code in src/ambiscape/compare.py
108 109 110 111 112 113 | |
state_mask(sess, state, min_s=0.0)
1 Hz mask for one named state; None if the session has no states.
Source code in src/ambiscape/compare.py
116 117 118 119 120 121 122 | |
laeq_timeline(F, bin_s=60.0)
(bin centres s, LAeq per bin) from the fast A-weighted track.
Bins with under 75 % coverage (the gaps between takes) are NaN so a plotted line breaks instead of bridging silence.
Source code in src/ambiscape/compare.py
128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 | |
clock_rows(sessions)
Group sessions onto clock-aligned rows: [[(index, shift_h), ...]].
Sessions whose calendar spans touch or overlap share a row; a session's shift is 24 h per day its day-0 lies after the row's reference day, so a night session (day 0) and the following day session (day 1) line up end to end. Sessions without a resolvable date each get their own row.
Source code in src/ambiscape/compare.py
148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 | |
timeline_figure(sessions, out_path, state='machine_on', x0_hour=None, colors=None)
Clock-aligned LAeq rows; intervals of state shaded per session.
Source code in src/ambiscape/compare.py
186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 | |
band_centers(F)
Geometric centre frequencies of the log-spaced spectrum bands.
logf holds the band edges (one more than the logspec width);
the centres are the geometric means of consecutive edges.
Source code in src/ambiscape/compare.py
232 233 234 235 236 237 238 239 240 241 | |
ltas_by_state(sess, min_state_s=0.0)
Median band spectra (dB) for the whole session and each state.
Source code in src/ambiscape/compare.py
244 245 246 247 248 249 250 251 252 | |
ltas_figure(sessions, out_path, min_state_s=0.0, fmin=90.0, colors=None)
Overlay per-state median spectra of every session on one axis.
Sessions keep their colour; the whole-session curve is drawn only when a session has no states, and states are distinguished by line style.
Source code in src/ambiscape/compare.py
255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 | |
line_prominence(sess, freqs_hz, mask_min=None, halfwidth_hz=15.0, bg_halfwidth_hz=40.0)
Prominence of known tonal lines in the per-minute minimum spectrum.
For each target frequency: the peak within ±halfwidth_hz against the
median of the surrounding ±bg_halfwidth_hz ring, on the median
min-spectrum (optionally restricted to mask_min minutes). A machine
fingerprint that survived the night keeps several dB of prominence; a
machine that never ran leaves ≲ 1 dB. Returns
{f0: {"peak_hz", "prominence_db"}}.
Source code in src/ambiscape/compare.py
289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 | |
floor_difference(sess_a, hours_a, sess_b, hours_b)
Median minimum-spectrum difference between two time windows (dB).
hours_* are (h0, h1) on each session's own clock axis (h > 24 =
day 2). This is the near-floor detector: a source too quiet for a level
step — a ventilation fan on its low setting — still shows as a
band-limited shelf of the A-minus-B difference. Returns
(freqs, diff_db).
Source code in src/ambiscape/compare.py
318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 | |
band_level(F, f0, f1)
1 Hz level (dB) of the summed log-spectrum bands inside [f0, f1].
Source code in src/ambiscape/compare.py
339 340 341 342 343 | |
band_timeline_figure(sessions, out_path, f0, f1, hours=None, smooth_s=301, colors=None)
One frequency band on the clock axis across sessions.
The band picks the phenomenon: 2–8 kHz for dawn chorus or rain hiss,
100–300 Hz for party bass. hours=(h0, h1) restricts the clock
window (h > 24 = day 2); smooth_s is a running-median width.
Source code in src/ambiscape/compare.py
346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 | |
azimuth_rose_figure(sessions, out_path, fg_quantile=0.75, nbins=36, colors=None)
Foreground energy by azimuth, one polar panel per session.
Foreground = seconds in the top 1 - fg_quantile of W energy. Mic
frames usually differ between visits: compare the shapes (one machine
lobe vs energy from everywhere), not absolute directions.
Source code in src/ambiscape/compare.py
383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 | |
duty_cycle(F, t0, t1, f0=63.0, f1=500.0, period_range=(300.0, 5400.0), smooth_s=61)
Period, duty and regularity of a cycling source in one band.
Threshold the smoothed band level midway between its 10th and 90th
percentiles, autocorrelate the on/off square wave, and report the
strongest period inside period_range. An acf_peak above ~0.2
marks a real cycler (a fridge); below ~0.1 there is no legible cycle —
which is itself a finding when the same appliance was legible from
another mic position.
Source code in src/ambiscape/compare.py
423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 | |
xnode_day_matrix(day_levels, bin_s=300, min_cover_s=30)
Clock-binned day rows for 2+ nodes of one building.
day_levels maps a node name to a full-day 1 Hz level array (dB,
NaN where the node was not recording; typically 86400 long). Returns
(names, A, H): names sorted, A[i, b] the power-mean level
of node i in clock bin b (absolute, the node's own dB scale)
and H = A - day median per row — the display normalization for
uncalibrated nodes, whose raw dB are not comparable across
instruments. Bins with under min_cover_s finite seconds are NaN.
Source code in src/ambiscape/compare.py
458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 | |
xnode_floor(arr, pct=5.0, floor_suspect=False, adjust_db=3.0)
A node's day noise floor: low percentile of its finite 1 Hz levels.
When the session's analysis flagged floor_suspect (the recorded
floor is pinned at the recorder's self-noise, so the acoustic floor
is somewhere below it and small excursions above it measure the
recorder, not the room), the floor is raised by adjust_db so the
loudest-room rule demands that much more clearance before trusting a
near-floor bin.
Source code in src/ambiscape/compare.py
485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 | |
xnode_gain_offsets(floors_db)
Per-node gain offsets estimated from the nodes' own noise floors.
Nodes of one building hear the same diffuse field when the place is empty, so their measured floors should agree; the spread between them is read here as sensor gain and returned as a per-node offset from the median floor. Subtracting these makes absolute levels comparable across uncalibrated recorders, which is what deciding which room is loudest requires.
Pass floors measured without the floor_suspect adjustment of
:func:xnode_floor: that adjustment is a deliberate handicap for the
floor rule, and inheriting it here would charge a suspect node 3 dB of
gain it does not have.
The estimate conflates gain with position. A node in a genuinely quieter corner also shows a lower floor and is credited with gain it does not have. Separating the two needs a source every node hears, or the assumption --- defensible in a small dwelling at four in the morning, and worth testing --- that an empty building is diffuse enough for position not to matter.
Source code in src/ambiscape/compare.py
504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 | |
xnode_loudest(names, A, floors_db=None, margin_db=3.0, floor_clear_db=3.0, gain_offsets_db=None)
Loudest node per clock bin — only where the call is defensible.
A bin is awarded to a node only when both hold:
- margin rule — its gain-corrected level beats every other node's
by more than
margin_db. Uncalibrated nodes differ by sensor gain, sogain_offsets_db(see :func:xnode_gain_offsets) is subtracted fromAfirst; a fractional-dB win says nothing about the sound, so near-ties stay unmarked instead of one node sweeping the night. - floor rule — its absolute level
Aclears that node's noise floor (floors_db, e.g. from :func:xnode_floor, alreadyfloor_suspect-adjusted) by at leastfloor_clear_db: the winner must actually be hearing sound, not its own floor.
The margin rule works on levels, not on the display normalization
H returned beside A by :func:xnode_day_matrix. The two
answer different questions, and ranking on H answers the wrong
one: H is each node's level minus that node's own day median, so
the largest H belongs to the node whose day departs furthest from
its own baseline --- the peakiest node, which may be the quietest one
in the building. A node with a low median and a sharp evening will
take every awarded bin from louder neighbours. H stays correct for
the heatmap, where each row is read against its own baseline.
.. warning::
This ranks on level above each node's own floor, not on level.
xnode_gain_offsets returns floor_i − median(floors), and the
median term is the same for every node, so it cancels in the
comparison: what is compared is A_i − floor_i. That is correct
only if the floors differ by gain. Where they differ because one
room is genuinely quieter, the quieter room is credited with gain it
does not have and wins bins it should not.
Measured on the SINS network: the correlation between a node's floor depth and the bins it is awarded is r = −0.81, and the two deepest-floored nodes take 37 awards each while every other node takes between 0 and 7. Synthetically, a node at −55 dBFS with a −80 dB floor beats a node at −40 dBFS with a −60 dB floor.
This is the pathology the paragraph above warns about for H,
reinstated with the floor in place of the day median. Deciding which
room is actually loudest needs a common reference — calibration, or a
source every node hears — which uncalibrated nodes do not supply.
Read the strip as which node stood highest above its own floor, and
nothing more.
Returns one entry per bin: the winning name, or None (bins with < 2 finite nodes, near-ties, and near-floor bins) — rendered empty.
Source code in src/ambiscape/compare.py
534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 | |
xnode_figure(names, H, loudest, out_path, title='', labels=None, margin_db=3.0, floor_clear_db=3.0)
Day heatmap (dB re each node's day median) + loudest-room strip.
Bins without data (a node not yet recording, a gap between takes) are
a neutral grey, never a colour of their own. The strip marks only the
bins :func:xnode_loudest awarded; the two rules are stated in the
figure's caption line.
Source code in src/ambiscape/compare.py
635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 | |
run_compare(folders, out_dir, lines=None, band=None, hours=None, state='machine_on')
Compare analysed sessions of one place; write figures + compare.json.
Always: clock-aligned LAeq timelines, per-state LTAS overlay, azimuth
roses, and a pooled + state-resolved descriptor table. Optional:
lines (tonal-line prominence per session, e.g. a machine
fingerprint) and band/hours (a band timeline, e.g. dawn
chorus). Returns the compare.json document.
Source code in src/ambiscape/compare.py
709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 | |
Multi-room acoustic network
Multi-recorder acoustic network: one building heard from many rooms.
Ambisonics puts several capsules at one point and asks from which direction; an acoustic network puts one recorder in each of several rooms of a building on a common clock (the SINS deployment style) and asks through which fabric: how strongly, and with what delay, does activity in one room appear in the others. The rooms become nodes, the walls, doors and corridors become edges, and the building reads as a graph whose shape changes over the day — a closed door thins an edge, a shared ventilation run thickens one, and the room everything couples to is the acoustic hub.
Everything works from the cached 8 Hz fast A-weighted level streams of a
prior analyze run on each node session; no audio is reopened.
load_network--- every analysed node session under one folder;node_grid--- all fast-level streams on one uniform clock grid;pairwise_coupling--- windowed, lag-searched cross-correlation of the level envelopes: per-window coupling (adjacency) + lag matrices;graph_measures--- numpy-only graph readings per window: node strength (the hub measure), edge density, transitivity;hourly_measures--- the same resolved by hour of day;network_figure--- house graphs at representative hours (node size = strength, edge width = coupling, arrows = lag direction) over a density-of-the-day timeline;network_summary_keys--- thenet_rows folded intosummary.jsonfor the catalogue;run_network--- orchestrate the above into network.json + network.png.
Times follow the feature axis: seconds since midnight of day 0 (nodes dated
on later days are shifted by whole days onto the first node's axis). Lags
are antisymmetric with a fixed sign convention: lag_s[i, j] > 0 means
node i leads — sound appears at i first and at j roughly
lag_s[i, j] seconds later.
load_network(folder)
Analysed node sessions: every subfolder of folder with features.
A node is any direct subfolder holding an analysis/features cache
(one recorder, one room); nodes are returned sorted by name in the
:func:ambiscape.compare.load_comparison session format. Fewer than
two such subfolders raise — run ambiscape analyze in each first.
Source code in src/ambiscape/network.py
58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 | |
node_grid(nodes)
Fast A-weighted levels of every node on one uniform clock grid.
Returns (t, X): t in seconds since midnight of day 0 at the
fast rate (8 Hz), X of shape (n_nodes, len(t)) in dBFS with
NaN wherever a node has no coverage (before its first take, between
takes, after its last). Nodes whose resolved date differs from the
first node's are shifted by whole days onto the same axis, so a
deployment logged across midnight still lines up sample by sample.
Source code in src/ambiscape/network.py
76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 | |
pairwise_coupling(t, X, win_s=120.0, max_lag_s=4.0, min_coverage=0.9)
Windowed cross-correlation of level envelopes with lag search.
The grid is cut into non-overlapping win_s windows; in each, every
node pair's mean- and trend-removed dB envelopes are cross-correlated
over lags of ±max_lag_s and the peak is kept. Returns
{"win_t", "coupling", "lag_s"}: window centres (s), the peak
normalised correlation per window and pair (symmetric, NaN diagonal —
the per-window adjacency), and the lag at that peak refined by
parabolic interpolation (antisymmetric; lag_s[w, i, j] > 0 means
node i leads node j). A pair is NaN in any window where either
node covers less than min_coverage of it or holds a constant level.
Source code in src/ambiscape/network.py
106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 | |
follow_source(t, X, t0, t1, names=None, baseline_s=600.0, slice_s=10.0, min_rise_db=6.0)
Follow a moving source from room to room across the network.
A person carrying a vacuum cleaner, a radio, or a conversation walks
through a dwelling, and which node hears it best changes as they go. This
returns that itinerary: for each slice_s of the interval
[t0, t1], the node with the largest rise, and the run-length-encoded
sequence of rooms visited.
The comparison is each node against its own past, never against another
node. Every node's rise is its level in the slice minus its own median
over baseline_s immediately before t0. That is not fastidiousness:
:func:ambiscape.compare.xnode_loudest ranks each node's excess over its
own floor and therefore rewards a deep floor rather than a loud room,
and every figure built on it has been withdrawn. A rise against a node's
own recent baseline carries no gain, so rises are comparable across nodes
where levels are not — which is what makes this usable on a network that
was never calibrated.
A slice whose best rise falls below min_rise_db is left out of the
itinerary rather than assigned to whichever node happened to be highest;
the source is not audible anywhere, and guessing would invent a location.
Returns {"slice_t", "best", "rise_db", "itinerary", "n_changes",
"n_visited"}, where best holds an index into X (or the matching
entry of names) per slice and itinerary is a list of
{"name", "t0", "t1"} runs.
Validated against hand annotations on the SINS deployment: for a vacuuming session the recovered sequence reproduces the annotated walk through hall, bathroom, WC and bedroom in the right order, and adds the hall transits between rooms that the annotator did not record separately because they labelled the room being cleaned rather than every doorway crossed.
What it cannot do. It reports the loudest node, not a position: two rooms either side of one wall may swap for reasons of coupling rather than movement, and a stationary source that merely gets louder will not be distinguished from one that approaches. Read it as an itinerary over rooms, not as a trajectory in metres.
Source code in src/ambiscape/network.py
169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 | |
graph_measures(coupling, threshold=0.35)
Per-window graph measures from the coupling stack (numpy only).
Returns {"strength" (nwin, n), "density" (nwin,), "transitivity"
(nwin,)}. Strength is a node's summed coupling to all others (its
weighted degree — the acoustic-hub reading); density the fraction of
pairs whose coupling reaches threshold; transitivity the
closed-triplet ratio of the thresholded graph (NaN where no node has
two edges). Windows without any finite pair are NaN throughout.
Source code in src/ambiscape/network.py
258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 | |
hourly_measures(win_t, coupling, threshold=0.35)
Graph measures resolved by hour of day.
Returns {"hours", "density", "strength", "hub", "n_windows"}:
wall-clock hours (0–23, ascending) that hold at least one valid
window, the median density over each hour's windows, the median
strength per node (rows follow hours), the index of the strongest
node per hour, and the window count per hour. Days repeat onto the
same 24 hours, so a week-long deployment yields one composite day.
Source code in src/ambiscape/network.py
284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 | |
representative_hours(hourly, n=3)
Up to n hours spanning the density range: quietest of the day,
the median hour, and the busiest — the graph's states worth drawing.
Source code in src/ambiscape/network.py
312 313 314 315 316 317 318 319 320 321 322 323 324 | |
network_figure(names, res, out_path, threshold=0.35, hours=None, min_lag_s=0.1)
House graphs at representative hours + a density-of-the-day timeline.
Top row: one graph per hour — nodes on a circle, node size = median
strength, edge width = median coupling (drawn from threshold up),
an arrowhead pointing from the leading room to the lagging one
wherever the median lag exceeds min_lag_s (labelled in seconds).
Bottom: per-window density dots with an hourly median step, the drawn
hours shaded.
Source code in src/ambiscape/network.py
330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 | |
network_summary_keys(doc)
The net_ rows folded into summary.json for the catalogue.
Source code in src/ambiscape/network.py
422 423 424 425 426 427 428 429 430 431 432 433 434 435 | |
run_network(folder, out_dir=None, win_s=120.0, max_lag_s=4.0, threshold=0.35, min_coverage=0.9)
Analyse the acoustic network of one building; write JSON + figure.
folder holds one analysed session per recorder (one room each, on
a common clock). Output goes to out_dir (default
<folder>/analysis): network.json (median coupling and lag
matrices, per-node strength, hub, density and transitivity, hourly
breakdown), network.png (house graphs + density timeline), and
net_ keys folded into summary.json there (created if absent),
so the building joins the catalogue as one row. Returns the
network.json document.
Source code in src/ambiscape/network.py
443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 | |
Spaced-microphone array
Spaced-microphone array analysis: TDOAs, bearings, coherence, triangulation.
The toolbox's three spatial paradigms differ in where the microphones sit.
:mod:ambiscape.spatial reads a soundfield sampled at one point (co-located
ambisonic capsules) and asks from which direction; :mod:ambiscape.network
reads one microphone per room and asks through which fabric; array sits
between them — a handful of spaced omnis in one room (the SINS nodes' linear
four-MEMS arrays) whose wavefront arrival-time differences and inter-channel
coherence carry direction and diffuseness where no soundfield microphone was
present.
load_geometry— mic positions in metres from inline coordinates or a small JSON file;tdoa— pairwise GCC-PHAT time-difference-of-arrival per frame, with the GCC peak height and its prominence over the runner-up;bearing— frame-wise bearing for a linear array from a weighted least-squares fit across the pair TDOAs, with a confidence stream;near_source_index— geometry-free inter-channel coherence in one band, for arrays whose capsule spacing is not documented; a calibration-free reading of how directional a moment is, and explicitly not occupancy;coherence_profile— inter-channel magnitude-squared coherence versus frequency per window, against the analytic diffuse-field curve for each spacing, and the per-window diffuseness proxygamma_array;triangulate— least-squares intersection of bearing streams from two or more nodes on a floor plan, to coarse source positions;bearing_figure/coherence_figure/triangulate_figure— the matching figures;run_array— CLI driver: one multichannel WAV in, JSON + figures out.
Conventions. tau_s[frame, pair] = t_i - t_j for pair (i, j):
positive when the wavefront reaches mic j first. Bearings are measured
from the array axis (the unit vector from the first mic towards the last):
0° and 180° are the endfire directions, 90° is broadside. A linear array
cannot tell the two sides of its axis apart (front–back ambiguity: only the
cone angle is observable), and near endfire the bearing loses resolution
because the delay–angle mapping flattens (d tau / d theta -> 0), so
endfire estimates are wide even at high confidence. gamma_array is a
direct/diffuse proxy built from coherence deviations between spaced
omnis; it is deliberately named apart from the first-order-ambisonic
diffuseness psi (diffuse in the feature cache), which is an
energetic soundfield measure at a single point — the two agree in tendency,
not in value.
load_geometry(geometry)
Microphone geometry: positions in metres + speed of sound.
Accepts a dict, a path to a JSON file, or a bare coordinate sequence.
The JSON/dict form is {"mics": [[x, y], ...], "c": 343.0} (c
optional); mics may also be a flat list of numbers, read as
positions along one line. Returns {"pos" (n, 2), "c", "linear",
"axis"} where axis is the unit vector from the first mic towards
the last (the bearing reference) and linear says whether all mics
sit on one line — the geometry :func:bearing requires.
Source code in src/ambiscape/array.py
60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 | |
tdoa(data, fs, geometry, frame_s=0.1, hop_s=None)
Pairwise GCC-PHAT time differences of arrival per frame.
data is (samples, channels) with one channel per geometry mic.
Each frame (frame_s long, hopped by hop_s, default half a
frame) is Hann-windowed, and for every mic pair the PHAT-weighted
cross-power spectrum is inverted to a generalised cross-correlation;
the peak is searched only over physically possible lags (± spacing /
c, a small margin added) and refined by parabolic interpolation.
Returns {"t", "pairs", "d_m", "tau_s", "peak", "prominence"}:
frame centres in seconds, the (i, j) pair list with spacings,
tau_s[frame, pair] = t_i - t_j (positive = the wavefront reaches
mic j first), the GCC peak height (1 for a perfect single delay,
near 0 for decorrelated channels), and the peak's prominence over the
strongest rival lag outside ±3 samples — the confidence base.
Source code in src/ambiscape/array.py
100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 | |
bearing(td, geometry=None)
Frame-wise bearing of a linear array from the pair TDOAs.
For mics on one line with axis u, a plane wave from cone angle
theta (measured from u) gives tau_ij = -(s_i - s_j) *
cos(theta) / c with s the on-axis positions, so each frame's
cos(theta) is a prominence-weighted least-squares fit across all
pairs. Returns {"t", "bearing_deg", "cos_theta", "confidence",
"residual_s", "clipped"}: bearings in [0, 180]° (0/180 = endfire,
90 = broadside — the two sides of the axis are indistinguishable),
the median GCC prominence across pairs as confidence, the RMS TDOA
residual of the fit, and a flag for frames whose fitted cos(theta)
fell outside [-1, 1] (clipped to the nearest endfire; treat those
bearings as unreliable whatever the confidence says).
Source code in src/ambiscape/array.py
173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 | |
bearing_figure(b, out_path, title=None)
Bearing track: time × bearing, colour = confidence.
Endfire rows (0° and 180°) and broadside (90°) are marked; the scatter fades with the GCC-prominence confidence, so unreliable frames recede.
Source code in src/ambiscape/array.py
210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 | |
near_source_index(data, fs, band=(500.0, 1000.0), nperseg=2048)
How directional this moment is, for an array of unknown geometry.
Mean magnitude-squared coherence over every channel pair, averaged
across band. A source close to the array reaches its microphones with
one fixed delay and correlates strongly between them; a diffuse or
reverberant field arrives from everywhere at once and does not. High means
something is sounding near the array, low means the array is hearing a
room.
Unlike :func:coherence_profile this takes no geometry and returns no
diffuse-field reference, which is the point: it is for the common case of
an archive that does not document where its capsules sit. Averaging over
all pairs means no assumption is made about which channel is where. The
price is that the reading is comparable only against itself and against
other arrays of the same build --- there is no absolute scale here.
What makes it worth having on an uncalibrated deployment. It is a ratio between two channels of one device, so that device's gain divides out. On a network where every level statistic is a statement about a sensor until proven otherwise, this is the one measure that can be compared across nodes with no calibration at all: on the SINS corpus a single fixed threshold, with no per-node tuning, separates loud activity from an empty room at 95.5 % across twelve uncalibrated nodes.
It is not an occupancy detector, and the failure is not subtle. Measured over 749 labelled minutes of that corpus, a television playing to an empty room gives the highest median of any class (0.717), above a vacuum cleaner (0.678) and above every class with a person in the room; a person working quietly gives 0.365 against 0.247 for an empty room. What is detected is a near sound source. A loudspeaker is one and a silent person is not. Anything built on this that says "somebody is here" will say it of an empty room with the radio on.
band must stay below the array's spatial aliasing limit, c / 2d
for spacing d. Where the spacing is unknown, staying under about 2 kHz
is safe for capsules a few centimetres apart, and a band that is too high
shows up as coherence collapsing towards zero for every input alike.
Returns the mean coherence in band, or nan if the input is too
short for one Welch segment or has fewer than two channels.
Source code in src/ambiscape/array.py
244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 | |
coherence_profile(data, fs, geometry, win_s=4.0, nperseg=None)
Inter-channel coherence versus frequency per window, and diffuseness.
In each non-overlapping win_s window the magnitude-squared
coherence of every mic pair is estimated (Welch, nperseg samples,
default a power of two near one eighth of the window so each estimate
averages ~15 segments; fewer segments bias coherence upward). The
analytic diffuse-field curve for spaced omnis, sinc(2 f d / c)^2,
is attached per pair.
Diffuseness: per window and pair, the energy-weighted mean over the
informative band (where the diffuse curve has fallen below 0.5;
weights are the pair's cross-channel spectrum level, so bands the
scene does not excite carry no vote) of the measured coherence's
excess over the diffuse curve, normalised to [0, 1], gives a
directness reading; gamma_array is one minus its median across
pairs. 0 = a coherent wavefront crosses the array (direct field),
1 = coherence at or below the diffuse prediction. This is a PROXY
built from spaced-omni coherence — kept deliberately distinct from
the ambisonic diffuseness psi, which measures the energetic
isotropy of a soundfield at one point. Pairs too closely spaced to
fall below 0.5 within the bandwidth contribute NaN.
Returns {"win_t", "f", "msc" (nwin, npairs, nf), "msc_diffuse"
(npairs, nf), "gamma_pair" (nwin, npairs), "gamma_array" (nwin,),
"pairs", "d_m"}.
Source code in src/ambiscape/array.py
303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 | |
coherence_figure(c, out_path, title=None)
Coherence profile (median over windows, per pair, with the analytic
diffuse curves dashed) over the gamma_array timeline.
Source code in src/ambiscape/array.py
376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 | |
load_plan(plan)
Floor plan: node positions and array orientations in one frame.
Accepts a dict, a JSON file path, or a list of node dicts. Format::
{"nodes": [
{"name": "living", "pos": [0.0, 0.0], "axis_deg": 0.0},
{"name": "kitchen", "pos": [4.0, 3.0], "axis_deg": 90.0}
]}
pos in metres in the floor-plan frame; axis_deg is the world
direction of the node's array axis (first mic towards last),
anticlockwise from +x. Returns the node list with pos as arrays.
Source code in src/ambiscape/array.py
412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 | |
triangulate(bearings, plan, grid_s=1.0, min_conf=0.2)
Least-squares intersection of node bearing streams on a floor plan.
bearings holds one :func:bearing result per plan node, in plan
order, on a shared clock. On a common grid_s grid, each node
contributes its nearest confident frame (confidence >= min_conf,
not clipped); each linear-array bearing then admits two world rays,
axis_deg ± bearing (the front–back ambiguity), and every sign
combination is solved as a weighted least-squares line intersection.
The combination with the smallest RMS perpendicular residual wins;
rays that would place the source behind a node are rejected. When
the runner-up combination fits almost as well — mirror-symmetric
layouts, e.g. parallel array axes, make the two sides genuinely
indistinguishable — the point is flagged ambiguous and the
front–back ambiguity is NOT resolved; only geometry that breaks the
symmetry can resolve it.
Returns {"t", "xy" (n, 2), "residual_m", "angle_deg", "confidence",
"ambiguous", "nodes"}: grid times with a solution, positions,
the RMS ray-to-point distance (the coarse uncertainty; treat the
position as a blob of about that radius), the intersection angle
between the two best-separated rays (small = poorly conditioned),
the minimum node confidence used, and the ambiguity flag.
Source code in src/ambiscape/array.py
439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 | |
triangulate_figure(tri, plan, out_path, title=None)
Floor-plan scatter: node arrays with their axes, triangulated positions coloured by time; ambiguous fixes drawn hollow.
Source code in src/ambiscape/array.py
538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 | |
run_array(recording, geometry, out_dir=None, frame_s=0.1, hop_s=None, win_s=4.0, min_conf=0.2)
Analyse one multichannel array recording; write JSON + figures.
Runs :func:tdoa, :func:bearing (linear geometries) and
:func:coherence_profile on recording, and writes array.json,
array_bearing.png and array_coherence.png to out_dir
(default <recording dir>/analysis). Bearing summary statistics
pool only the confident, unclipped frames (confidence >=
min_conf). Returns the array.json document. Triangulation
across several nodes is a library call — see :func:triangulate.
Source code in src/ambiscape/array.py
587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 | |
Visual features
Per-frame visual features: the light/vision analogue of the audio deposit.
Scope, against the other three toolboxes. This module is about light ---
how bright a room is, how that is distributed across the frame, and how both
move through a day. It is not video analysis in general: MGT owns pixels, as
its own _soundscape.py puts it, and motion from video is mg_motion there.
The one function here that touches motion, :func:frame_delta, is a proxy and
says so.
The AMBIENT project treats a room as an audio-visual subject, and a room's
look has a diurnal rhythm just as its sound does. This module extracts a
compact descriptor from a single video frame so a camera can log visual
behaviour rather than store imagery --- the same "features, not recordings"
privacy stance as :mod:ambiscape.capture. It is numpy-only (no camera or
OpenCV dependency); frame grabbing lives in the capture rig, the feature
definitions live here so they are versioned and tested with ambiscape.
Per frame (:func:frame_features):
- brightness / brightness_sd --- mean and spread of Rec.709 luma (0 = dark, 1 = bright): the room's overall light level and its unevenness;
- r_frac / g_frac / b_frac, warm_cool_ratio --- colour balance and a warm/cool proxy (daylight vs incandescent vs the blue of a screen);
- saturation, colourfulness --- how colourful the scene is (Hasler--Susstrunk colourfulness), near zero for a grey room;
- spatial_entropy --- entropy of a coarse brightness grid: 1 when the room is evenly lit, low when light is concentrated (a lamp, a window) --- the visual analogue of acoustic diffuseness;
- bright_centroid_x / _y --- the luma-weighted centre of light in the frame (0..1): where the light comes from, a visual direction-of-arrival.
:func:frame_delta is a motion proxy (mean absolute luma change between two
frames); :func:summarize_vision rolls a day of per-frame features into a
vis_-prefixed summary that joins the audio summary.json.
For full video analysis (motion, pose, 360° handling) use MGT-python (https://github.com/fourMs/MGT-python); this module deliberately stays a lightweight, dependency-free companion.
luma(rgb)
Rec.709 luma of an RGB frame, in [0, 1].
Source code in src/ambiscape/vision.py
59 60 61 62 | |
frame_features(rgb, grid=3)
Visual descriptor of one RGB frame (uint8 0..255 or float 0..1).
Source code in src/ambiscape/vision.py
73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 | |
frame_delta(prev_rgb, cur_rgb)
Mean absolute luma change between two frames (motion proxy, 0..1).
A proxy, and MGT owns the real thing. Quantity of motion from video
belongs to the Musical Gestures Toolbox --- mg_motion, which thresholds,
normalises per clip and works at the recording's own resolution --- and
micromotion owns motion from markers and accelerometers. This function
exists because :mod:ambiscape.vision is about a room's light over a
day, and a summary of light wants a crude "did anything move" alongside
it without pulling in a video toolbox.
Use it for that and nothing else. For any measurement a result rests on,
use MGT. Measured against mg_motion on a Sound Actions clip at 320x180
it correlates 0.995, which is close enough to be tempting and is not a
reason to prefer it: it has no threshold, so sensor noise and compression
shimmer contribute, and it is on whatever grid the caller happens to pass.
That last point is worth more than it looks. The same comparison against a hand-rolled pass at 160x90 gives 0.74, so between the two tests the dominant difference is resolution, not the threshold --- downsampling is itself a low-pass filter on motion. Anyone choosing a grid for a motion measure is choosing an answer.
Source code in src/ambiscape/vision.py
113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 | |
summarize_vision(features, motion=None)
Roll a day of per-frame feature dicts into a vis_ day summary.
Source code in src/ambiscape/vision.py
139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 | |
analyze_frames(frames, times=None)
Per-frame features + motion + vis_ summary for an iterable of frames.
frames yields (H, W, 3) uint8/float RGB arrays; times (optional)
gives each frame's timestamp in seconds on the session clock. Returns
{"frames": [...], "summary": {...}} -- each per-frame row carries t
so the visual stream lines up with the audio feature timeline.
Source code in src/ambiscape/vision.py
167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 | |
frame_series(source, fps=1.0)
Yield (t_seconds, rgb) from a video file or a folder of images.
A directory is read as its sorted image files (needs Pillow); any other
path is decoded with ffmpeg at fps frames/second (raw RGB piped in
memory -- no imagery written to disk), matching the module's
features-not-recordings stance.
Source code in src/ambiscape/vision.py
187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 | |
render(result, out_path, title='')
Brightness / colourfulness / motion over time -> a PNG.
Source code in src/ambiscape/vision.py
229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 | |
run_video(source, out_dir, fps=1.0, merge=None)
CLI driver: a video's visual features -> vision.json + vision.png.
If merge points at an existing summary.json (the audio analysis of
the same room and occasion), the vis_ keys are folded into it, so one
row describes the audio-visual scene -- the multimodal join.
Source code in src/ambiscape/vision.py
253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 | |
Sound–motion entrainment
Sound–motion entrainment: joining the soundscape with a body-motion series.
The AMBIENT project records rooms as audio-visual and bodily subjects: an ambisonic recorder on the desk and an accelerometer on the body. This module performs the missing sound–motion join, following the crossmodal method of Guo, Riaz & Jensenius (CMMR 2025): the audio session and a body-motion time series are resampled onto one common clock and three entrainment measures are computed —
- temporal correlation between the 125 ms fast level and the quantity of motion (QoM), with a permutation p-value from circular time-shift surrogates (shuffling would destroy autocorrelation and overstate significance);
- directional correlation between the audio's azimuthal energy and the
direction of horizontal micromotion — the Jammalamadaka–SenGupta circular
correlation from :mod:
ambiscape.circstats, rotation-invariant so the mic frame and the sensor frame need not be aligned; - phase-locking value (PLV) between the audio's envelope modulation and the motion oscillation, per modulation band from 0.1 to 4 Hz, again with circular-shift surrogates.
Motion input is a device-agnostic CSV/TSV: one timestamp column (ISO 8601,
or plain seconds) plus accelerometer x/y/z columns, any consistent unit
(g or m/s²) — the constant gravity component is removed internally and every
measure is scale-free, so the unit never enters a result. ISO timestamps are
placed on the session's absolute clock (both devices are assumed set to the
same local time; use calibration.json clock offsets for a drifting
recorder); a plain seconds column that does not overlap the audio span is
taken as relative and aligned to the start of the audio. QoM is computed
as jerk magnitude after gravity removal, per Riaz's micromotion method
(gravity = a 0.25 Hz low-pass of the acceleration), and the horizontal plane
is defined by the gravity estimate itself, so the sensor can sit at any
orientation. Sway direction is the per-frame principal axis of horizontal
micromotion — an axial quantity (period 180°), so it is angle-doubled
before circular correlation with the (full-circle) audio azimuth; that
convention, and the use of dB level vs log-QoM for the Pearson correlation
(both compressive, per the paper), are the two design choices most worth a
reviewer's eye.
analyze_entrainment writes entrain.json + entrain.png and folds
ent_-prefixed descriptors into an existing summary.json (the same
multimodal-join move as :mod:ambiscape.vision).
load_motion(path)
Parse a motion CSV/TSV into (t_seconds, accel[n, 3], meta).
The delimiter is sniffed (tab, semicolon, or comma); the timestamp column
is the first column named like time/timestamp/t, else column
0; the x/y/z columns are the first columns whose cleaned names end in
x/y/z (acc_x, ax, X (g) all match), else the
three columns after the timestamp. ISO 8601 timestamps come back as
seconds since midnight of the first sample's date, with
meta["date0"] set so callers can move them onto a session clock;
numeric timestamps pass through with meta["date0"] = None.
Source code in src/ambiscape/entrain.py
66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 | |
join(sess, motion_path, F=None)
Resample audio features and motion onto one common 8 Hz clock.
F is a loaded feature cache (:func:ambiscape.features.load_features);
if omitted it is loaded from <session>/analysis/features (run
ambiscape analyze first). Returns a dict of aligned series over the
overlapping span, at the fast-level rate (125 ms): t (absolute
session seconds), dt, level_db (125 ms fast level), qom
(mean jerk magnitude per frame), sway_deg (principal axis of
horizontal micromotion, axial, −90..90), sway_pow (horizontal
micromotion energy per frame), and az_deg (audio azimuth
interpolated to the common clock; all-NaN for mono input).
Source code in src/ambiscape/entrain.py
141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 | |
temporal_correlation(level_db, qom, dt, n_surrogates=200, min_shift_s=10.0, seed=0)
Pearson r between fast level (dB) and log-QoM, surrogate-tested.
Both series are compressed (dB and log) so a single loud event or jolt does not dominate the correlation. The p-value is two-sided against circular time-shift surrogates of the motion series.
Source code in src/ambiscape/entrain.py
238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 | |
directional_correlation(az_deg, sway_deg, mask=None, dt=0.125, n_surrogates=200, min_shift_s=10.0, seed=0)
Circular correlation between audio azimuth and sway direction.
The sway direction is axial (a principal axis, period 180°), so it is
angle-doubled to the full circle before the Jammalamadaka–SenGupta
coefficient is taken; rotation invariance means the two frames need no
alignment (the sign of rho is still frame-handedness dependent — judge
coupling by |rho| and p). mask selects the frames that enter the
statistic (e.g. frames where both streams carry energy); surrogates
roll the full sway series before masking, preserving its rhythm.
ROTATION INVARIANCE DOES NOT EXTEND TO REFLECTION, and judging by |rho| puts the blind spot exactly where a handedness error lands. Mirroring one of the two series --- sending every angle a to -a --- leaves the coefficient's magnitude untouched and flips only its sign. That is algebra rather than an approximation, and it holds to machine precision. So the frame-free comfort this function offers stops at rotations: a recorder mounted upside down writes Y inverted, its horizontal bearing atan2(Y, X) becomes atan2(-Y, X), and that mirror is invisible here at every offset. Nor can a search over rotations find it, since a reflection is not in the set being searched, and such a search returns a poor best fit rather than a complaint. Establish handedness from the rig --- how the microphone was mounted, and whether the recorder already compensated --- never from the statistic.
A COUPLING FOUND HERE IS ONLY THE SOUNDSCAPE'S IF THE AZIMUTH IS. An
audio bearing from a recorder travelling on the same body as the sensor
is in part a measurement of that body's posture, and will correlate with
the body's motion for reasons that have nothing to do with the room.
:func:ambiscape.spatial.frame_reference_test settles that question,
and it is worth settling before this one is asked.
Needs micromotion, since ambiscape 0.40.0: the coefficient comes from
:func:ambiscape.circstats.circ_corr, which re-exports
:mod:micromotion.circular rather than keeping a second copy of the same
formula. pip install ambiscape[circular]. Everything else in this
module runs without it.
Source code in src/ambiscape/entrain.py
259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 | |
plv(audio_env, motion, dt, bands=BANDS, n_surrogates=200, min_shift_s=10.0, seed=0)
Phase-locking value per modulation band between envelope and motion.
Both series are z-scored, band-passed (zero-phase Butterworth), and Hilbert-transformed; PLV = |mean e^{i(φ_audio − φ_motion)}| in [0, 1]. Bands the record is too short for (fewer than four cycles of the low edge) or too slow for (above 0.4/dt) are skipped. Each band carries a surrogate p (circular shift of the motion phase series) and the null's 95th percentile — the significance floor drawn in the figure.
Source code in src/ambiscape/entrain.py
320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 | |
render(J, doc, out_path, title='', clock=None)
Combined figure: aligned timelines, azimuth-vs-sway rose, PLV bars.
Source code in src/ambiscape/entrain.py
354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 | |
analyze_entrainment(sess, motion_path, out_dir=None, F=None, bands=BANDS, n_surrogates=200, seed=0)
Full sound–motion entrainment analysis of one session.
Joins the session's cached features with the motion file, computes the
three measures, writes entrain.png + entrain.json under
out_dir (default <session>/analysis), folds the ent_
summary rows into an existing summary.json there, and returns the
document.
Source code in src/ambiscape/entrain.py
432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 | |
Circular statistics
Circular statistics, shared by spatial (azimuth) and rhythm (phase) code.
Angles are radians throughout; the degree-facing wrapper lives in
:func:ambiscape.analysis.circular_stats, and period-facing helpers here
convert times to phases. The resultant length R in [0, 1] measures
concentration; circular SD = sqrt(-2 ln R); the Rayleigh test gives the
probability of R under uniformity (p ~ exp(-n R^2), adequate for n >= 10).
mean_resultant(angles, weights=None)
Weighted circular mean (rad) and resultant length R.
Source code in src/ambiscape/circstats.py
16 17 18 19 20 21 | |
circular_sd(R)
Circular standard deviation (rad) from a resultant length.
Source code in src/ambiscape/circstats.py
24 25 26 | |
rayleigh_p(R, n)
Rayleigh-test p-value for the uniformity null.
Wilkie's (1983) approximation, which is what micromotion.circular uses
and what CircStat and the later editions of Zar report. This module used
Zar's earlier series expansion, exp(-z) (1 + (2z - z^2)/4n), until
2026-08-12; the two are both published approximations of the same test and
they disagreed on about a fifth of random cases.
micromotion owns circular statistics in this family of toolboxes. It
carries the fuller theory --- axial tests, circular-linear correlation,
the V-test --- and what remains here is the time-series end,
:func:phase_stats and :func:relative_phase, plus the primitives those
need. The primitives are kept rather than imported so that ambiscape does
not take a dependency for six short functions, and
tests/test_circstats_agreement.py asserts they still agree with
micromotion whenever it is installed. Agreement that is asserted is
agreement that survives; agreement that is merely intended is what
produced the disagreement above.
Source code in src/ambiscape/circstats.py
29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 | |
circ_corr(a, b)
Jammalamadaka–SenGupta circular–circular correlation, from micromotion.
sum sin(a - ā) sin(b - b̄) / sqrt(sum sin²(a - ā) sum sin²(b - b̄))
with ā, b̄ the circular means. In [-1, 1]; invariant under rotations of
either variable, so two angle series may live in different reference
frames (a mic's azimuth and a body-worn sensor's sway direction).
Returns a dict, not a float, since 0.40.0. This function used to carry
its own copy of the arithmetic and return the coefficient alone, while
:func:micromotion.circular.circ_corr returned {"r", "p", "n"} from
the same formula. The values agreed to 1e-12 and the signatures did not,
so a caller who swapped the import got an object where a number was
expected — the failure mode that a shared name is supposed to prevent.
micromotion owns circular statistics in this family, so this is now a
re-export and the dict is the shape.
Callers wanting the coefficient want circ_corr(a, b)["r"]. The p
that comes with it is worth having: the local version offered no
significance at all, and a correlation without one invites being read as
though it had one.
micromotion is imported lazily, in the same way :mod:ambiscape.music
reaches musiscape, so that ambiscape stays importable without it and the
failure names its own remedy.
Source code in src/ambiscape/circstats.py
54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 | |
phase_stats(times, period)
Circular statistics of event times folded at period.
Returns mean phase (cycles), R, circular SD in seconds, and the Rayleigh p-value — the standard summary for one strike stream.
Source code in src/ambiscape/circstats.py
91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 | |
relative_phase(times, ref_times, period)
Phase of each event relative to the preceding reference event.
The per-event lock between two streams sharing one period: mean offset (cycles and seconds), R, and circular SD in seconds. R near 1 means the two streams are phase-locked at strike level.
Source code in src/ambiscape/circstats.py
108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 | |
Modulation profile
Environmental rhythm: multi-scale envelope modulation profile.
Soundscapes are rhythmic on very different time scales at once — strike patterns (micro), traffic waves and surf (meso), duty cycles of machines and human activity (macro). This module measures all three from cached envelopes, no audio pass:
- micro (0.5–20 Hz) from the 20 ms broadband envelope (
env_hi, extractor ≥ 0.2 caches; older caches fall back to the 8 Hz fast level, which limits micro to < 4 Hz); - meso (0.01–0.5 Hz) from the 125 ms fast level;
- macro (below 0.01 Hz, floor set by session length) from the 1 s RMS.
All three scales are computed the same way: the source stream is converted to a linear-power envelope, normalised to unit mean, and its Welch power spectral density taken. One normalisation, so the per-scale curves live on a single comparable dB axis; each scale is additionally computed half a decade past its nominal band edges, so neighbouring scales overlap and their agreement where they meet is visible rather than assumed.
profile returns, per scale, a log-frequency modulation spectrum with the
dominant modulation frequency, its prominence, and the band modulation depth
(all three statistics taken within the nominal band, not the overlap).
The macro scale needs a record longer than the period you are asking
about, and says nothing when it does not have one. Each scale's band starts
at four cycles over the record, so a session of one day cannot resolve the
day: its macro band begins at a period of 21600 s and the diurnal peak is
simply outside it. Nothing errors and no warning is issued --- the returned
peak is just the strongest thing in a band that excludes what you were looking
for. To measure a daily or weekly rhythm, concatenate the sessions onto one
absolute clock first and call modulation_spectrum on that; the Sound Spaces
series does this in notes/scripts/sins_macro_modulation.py, which also
shows the two controls such a stitched record needs (the spectrum of the
coverage mask alone, and a permutation null).
modulation_spectrogram computes the windowed version — the "rhythm
spectrogram of the day" — and render writes the combined figure.
modulation_spectrum(env, dt, fmin, fmax, n_bins=48)
Welch modulation spectrum of a linear-power envelope, log-resampled.
The envelope is normalised to zero-mean unit-mean (x = env/mean − 1) so spectra are comparable across levels; returns (freqs, power density). Welch bins are averaged into each log-grid cell (nearest bin where a cell is empty), which keeps the estimate stable even when the record only allows a single Welch segment.
Source code in src/ambiscape/modulation.py
55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 | |
profile(F)
Three-scale modulation profile from cached features.
Every scale is a unit-mean linear-power envelope PSD (see
modulation_spectrum), so the three spectra share one normalisation
and are directly comparable in level. Each is computed EXT past its
nominal band edges (clipped to the record length and the stream's
Nyquist); statistics are taken within the nominal band only.
Source code in src/ambiscape/modulation.py
93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 | |
modulation_spectrogram(env, dt, win_s=600.0, step_s=120.0, fmin=0.02, fmax=20.0, n_bins=64)
Windowed modulation spectra: the rhythm spectrogram of the session.
Returns (t_centers, mod_freqs, S) with S in dB relative to each window's median (so rhythmic structure reads as ridges regardless of level).
Source code in src/ambiscape/modulation.py
132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 | |
render(F, prof, out_path, title='', clock=None)
Combined figure: per-scale spectra + rhythm spectrogram.
Source code in src/ambiscape/modulation.py
152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 | |
run_session(sess, out_dir)
CLI driver: profile + figure + modulation.json.
Source code in src/ambiscape/modulation.py
205 206 207 208 209 210 211 212 213 214 215 | |
Tonality and harmonicity
Tonalness timeline, harmonic sieve, and inharmonicity.
Works entirely from the cached per-minute mean PSD (minspec):
tonal_peaks— prominent narrowband components per minute (dB above a running spectral floor), the raw material for everything below;tonal_tracks— peaks linked across minutes into tracks (a hum, a bell partial, a beep), each with duration, median frequency, and cents drift: the tonalness timeline;group_tracks— those segments regrouped into lines, for a source that pauses or changes speed and so appears as several tracks;harmonic_sieve— best f0 explaining a minute's peak set as a harmonic series; the unexplained remainder is the inharmonic tonal content. Voices, engines, and music score high harmonicity; bells score low (their partial series 1 : 2 : 2.4 : 3 : 4 is not harmonic);pitch_class_profile— tonal peak energy folded onto the 12 pitch classes: what "key" the soundscape hums in.
run_session writes tonality.json + tonality.png.
tonal_peaks(spec_row, freqs, fmin=40.0, fmax=8000.0, min_prom_db=8.0, max_n=40)
Prominent narrowband peaks in one mean spectrum.
The floor is a wide median filter on the log spectrum; peaks must rise
min_prom_db above it. Returns (freq, prominence_db, power) arrays.
Source code in src/ambiscape/tonality.py
33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 | |
tonal_tracks(minspec, freqs, tol_cents=40.0, min_minutes=2, **peak_kw)
Link per-minute peaks into tracks. Returns a list of dicts sorted by duration (longest first): median freq, span of minutes, mean prominence, and total drift in cents.
Source code in src/ambiscape/tonality.py
50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 | |
group_tracks(tracks, tol_cents=60.0)
Group track segments that are the same line, interrupted.
:func:tonal_tracks answers "how long was a line continuously present at
this frequency". A source that pauses, or shifts frequency and comes back,
is therefore several tracks --- correct as tracking and misleading as a
description of the source. A dishwasher's circulation pump, which changes
speed between programme phases, appears as five separate tracks.
This regroups them: segments whose median frequencies lie within
tol_cents become one line, regardless of the gaps between them.
Returns one entry per line with f_median_hz, minutes (the total
across its segments), n_segments, t0_min and t1_min spanning
the first to the last, and prominence_db averaged over segments
weighted by their length.
Grouping by frequency alone is the assumption to be aware of: two
unrelated sources that happen to share a frequency are merged, and one
source that moves further than tol_cents between segments is not. Use
it to count lines, not to attribute them.
Across a sensor network it separates a building from a room, which is the second use it has earned. A machine bolted to a structure radiates a stable line and reaches rooms through the fabric, so matching the grouped lines between nodes says which steady sources belong to the building and which to one room. On the twelve SINS nodes, nine line groups below 1 kHz reach two or more rooms that are behind doors from one another --- 53, 98, 107, 121, 342, 391, 473, 504 and 652 Hz --- and cannot have travelled between them through the air; the bedroom separately carries five lines between 45 and 84 Hz that nobody else hears, which are its own.
A line shared by every node may be the recorders rather than the building. Identical hardware shares its artefacts. The most widely shared line in that corpus sits at 7,937 Hz in all five rooms across eleven nodes, and it is sensor self-noise: it falls in the band where every node of that deployment is already flagged floor-suspect. Split the candidates by band before reading a shared line as a shared source, and treat anything inside the known self-noise region as the recorder until shown otherwise.
Source code in src/ambiscape/tonality.py
91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 | |
harmonic_sieve(fq, power, f0_min=60.0, f0_max=1200.0, tol_cents=35.0, max_harm=12)
Best f0 explaining the peak set as harmonics k*f0.
Candidate f0s are every peak divided by k = 1..6; the score is the
power-weighted fraction of peaks within tol_cents of a harmonic.
Returns (f0, harmonicity in [0,1]) — harmonicity is the explained power
fraction; 1 − harmonicity is the inharmonicity index.
The defaults are tuned for voices and music, and mislead on machinery. Three of them, each demonstrated on a dishwasher's circulation pump whose peak structure puts its shaft near 46 Hz:
f0_min at 60 Hz sits above many machine shaft rates. On that pump it
excludes the fundamental and returns its second harmonic, 91.9 Hz. Lower
it for anything mechanical.
tol_cents is proportional, so 35 cents is ±5.6 Hz at 275 Hz and
±17 Hz at 825 Hz — wide enough that a candidate can collect high
harmonics by coincidence. The default returns 68.6 Hz at harmonicity
0.73; tightening to 8 cents returns 46.0 Hz at 0.45. The looser fit
scores higher while explaining fewer of the strong low peaks, so a
harmonicity figure means little without the tolerance beside it.
max_harm at 12 is short for a machine comb; the same pump is
tracked to k = 26.
None of this is a defect in the sieve. It is that "which harmonic series is this" has more than one defensible answer, and the parameters choose between them rather than merely tuning precision. Report them.
Source code in src/ambiscape/tonality.py
156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 | |
narrow_line_prominence(freqs, spec_db, f0, half_hz=0.35, ring=(1.5, 6.0))
How far the peak within ±half_hz of f0 stands over its ring.
The surround is the median of ring Hz either side, excluding the line
itself. Keeping the ring narrow is the point: a broad rumble lifts the
surround as much as the peak and so scores near zero, and only a genuine
narrowband line scores high. None when the spectrum does not reach.
:func:ambiscape.compare.line_prominence is the same rule at session
scale, on the per-minute minimum spectrum with ±15/40 Hz windows. That
resolution cannot separate 50 Hz from 60 Hz, let alone track a supply
line; this one is for a fine spectrum computed for the purpose.
Zero is not the no-source value, and how far above zero it sits depends on how much you averaged. Comparing a maximum over the peak window against a median over the ring is biased upward on any noisy spectrum, so pure white noise scores well above nothing. Measured, as the mean over six trials of a three-rung family on white noise:
======== ========================== windows family prominence, no source ======== ========================== 1 5.1 dB 4 2.6 dB 16 1.5 dB 64 0.9 dB 256 0.4 dB ======== ==========================
Establish this floor for your own window count before calling anything
present. A family scoring 1.7 dB off roughly forty averaged windows —
which is what the 16⅔ Hz railway claim in :mod:ambiscape.enf came to —
is sitting on the floor, not above it.
Source code in src/ambiscape/tonality.py
202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 | |
family_prominence(freqs, spec_db, f0, band=(10.0, 350.0), min_harmonics=3, **line_kw)
(mean prominence of f0's harmonics in band, the per-rung list).
The mean rather than the sum, so a low fundamental is not rewarded merely
for fitting more harmonics into the band; and (None, rungs) below
min_harmonics, so a lone peak never reports as a family.
Read the list, not the mean. The mean is what makes this method
dangerous on its own: a single strong high harmonic will carry a family
whose fundamental and low rungs are missing, and the summary will look
respectable. That is not a hypothetical — see :mod:ambiscape.enf for the
16⅔ Hz railway-supply case, where rungs 1 to 4 sat below their
surrounding noise and the score came entirely from 100 Hz, which is mains.
A family missing its bottom is not a family.
Source code in src/ambiscape/tonality.py
248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 | |
family_percentile(freqs, spec_db, f0, sweep=(12.0, 60.0, 0.05), **family_kw)
Where f0's family score ranks among every fundamental in sweep.
Returns (percentile, score, grid, scores). This is the gate that lets
a hypothesised family fail: asking whether a family is present is a
question every recording answers yes to, because every spectrum has energy
at every frequency and a broad hump has to peak somewhere. Asking whether
it is exceptional among the alternatives is answerable. A family that is
merely present ranks mid-sweep; one that characterises the recording ranks
at the top.
Two things it cannot do. It cannot separate a fundamental from its own
multiples and divisors — if 16⅔ scores well then 33⅓ and 8⅓ will too, so
inspect grid/scores rather than quoting one percentile. And it
cannot tell a real source from a confound that shares its harmonics: a
percentile is a statement about this recording, and only a control
recording that cannot contain the source turns it into evidence.
Source code in src/ambiscape/tonality.py
279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 | |
pitch_class_profile(minspec, freqs, minutes=None, **peak_kw)
Tonal peak power folded onto 12 pitch classes (A4 = 440 Hz).
Source code in src/ambiscape/tonality.py
309 310 311 312 313 314 315 316 317 318 319 | |
run_session(sess, out_dir)
Tonality timeline + per-minute tonalness/harmonicity + PCP + figure.
Source code in src/ambiscape/tonality.py
322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 | |
Strike-level rhythm
Strike-level rhythm analysis of quasi-periodic pitched sources (bells, machines, signals) in long ambisonic recordings.
The 1 Hz features in :mod:features are too coarse for strike rhythm, so this
module makes one extra streaming pass at ~20 ms resolution, restricted to the
narrowband partials of the sources of interest:
detect_partials— narrowband peaks from the cached per-minute mean PSD, contrasting source-active against quiet minutes;partial_pass— streaming STFT pass storing, per frame, the power envelope and pseudo-intensity (for DOA) at each partial, plus a broadband spectral-flux onset function;cluster_partials— group partials into sources by correlating their half-wave-rectified log-envelope derivatives (strike-synchronous);pick_strikes— adaptive, strongest-first onset picking per source;rayleigh_period/period_track— point-process periodicity (resultant length over a period grid, with harmonics for multi-strike cycles);cycle_grid— repetition-with-variation statistics: per-cycle timing residuals and amplitudes for each position of the repeating pattern;rise_spectrum— strike-triggered post/pre spectral rise, the tonal content of one rhythmic position (also exposes cross-talk: a stream whose rise spectrum shows another source's partials is leakage, not a strike).
All statistics are on the W channel except pseudo-intensity, which follows the AmbiX ACN (W, Y, Z, X) convention used throughout ambiscape.
detect_partials(F, active, quiet, band=(350.0, 4500.0), min_rise_db=6.0, max_n=30)
Narrowband partials of the active-state source(s).
active/quiet are boolean masks over the minutes of F["minspec"]
(source clearly present / clearly absent). Returns (freqs, rise_db)
sorted by frequency.
Source code in src/ambiscape/rhythm.py
44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 | |
partial_pass(take, pfreq, nfft=4096, hop=960)
Streaming STFT pass; per ~20 ms frame: 3-bin power envelope and pseudo-intensity at each partial, plus a 400-4500 Hz log-flux onset function. Returns dict of arrays keyed t/env/ix/iy/iz/odf/pfreq.
Source code in src/ambiscape/rhythm.py
66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 | |
cluster_partials(env, mask=None, th=0.75, min_size=3)
Group partial columns into sources: correlate rectified log-envelope
derivatives (with ±1-frame jitter tolerance), average-linkage cluster.
Returns a list of column-index lists, largest first; singletons and
groups below min_size are dropped (assign them afterwards with
strike-triggered statistics if needed).
Source code in src/ambiscape/rhythm.py
123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 | |
source_odf(env, cols)
Mean rectified log-envelope derivative over one source's partials.
Source code in src/ambiscape/rhythm.py
145 146 147 148 | |
pick_strikes(odf, t, min_sep=0.5, k=1.5, t_max=None)
Adaptive strongest-first onset picking.
Candidates are local maxima exceeding a running median by k MADs;
they are accepted strongest-first subject to a min_sep guard (set it
just below the shortest true inter-onset interval). Returns strike times.
Source code in src/ambiscape/rhythm.py
151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 | |
acf_structure(odf, dt, t_mask=None, max_lag_s=8.0, rel=0.25)
Cycle period and shortest intra-cycle gap from the ODF autocorrelation.
Returns (cycle_period, min_gap): the lag of the strongest ACF peak, and
the shortest peak lag whose value exceeds rel x the strongest —
use 0.8 * min_gap as the pick_strikes separation guard. Estimating
the cycle from the ACF first keeps the later Rayleigh refinement off
subharmonics.
Source code in src/ambiscape/rhythm.py
179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 | |
rayleigh_period(times, grid, harm=2)
Resultant length of the strike point process folded at each candidate
period, summed over harm harmonics (multi-strike cycles).
Source code in src/ambiscape/rhythm.py
202 203 204 205 206 207 208 209 210 | |
best_period(times, lo=0.5, hi=8.0, step=0.0005, harm=2)
Grid-search rayleigh_period with parabolic refinement.
Source code in src/ambiscape/rhythm.py
213 214 215 216 217 218 219 220 221 | |
period_track(times, P0, win=150.0, step=30.0, half_range=0.05, harm=2)
Sliding-window period estimates around P0; returns (t, P).
Source code in src/ambiscape/rhythm.py
224 225 226 227 228 229 230 231 232 233 234 | |
phase_clusters(times, P, width=0.12)
Split one stream into phase clusters at period P (histogram modes).
Returns (phase0, list of (center, mask)): phases are relative to the
dominant cluster.
Source code in src/ambiscape/rhythm.py
237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 | |
cycle_grid(streams, P, t_max)
Per-cycle timing residuals and hit rates for named event streams sharing one cycle period.
streams maps name -> strike times. The cycle phase reference is the
first stream. Returns per-stream position (s into cycle), residual array
(NaN = missed cycle), and summary stats: timing sd, lag-1 autocorrelation,
slow wander vs cycle-to-cycle sd, hit rate.
Source code in src/ambiscape/rhythm.py
261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 | |
rise_spectrum(take, times, nfft=8192, n_max=150, seed=1)
Strike-triggered mean post/pre log-spectral rise (dB) on W.
The tonal fingerprint of one rhythmic position. If a stream's rise spectrum reproduces another source's partials, that stream is cross-talk rather than a distinct strike. Returns (freqs, rise_db).
Source code in src/ambiscape/rhythm.py
305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 | |
run_session(sess, out_dir, n_sources=2, t_stop=None, verbose=True, partials=None, groups=None, strike_k=1.5, min_gap_floor=None)
Full rhythm pipeline for a single-take session; writes
rhythm_overview.png and rhythm.json, returns the summary dict.
Informed-prior mode (for recordings where blind partial detection fails —
e.g. a distant source under loud foreground): pass partials (a fixed
list of partial frequencies in Hz) to bypass detect_partials, and/or
groups ({name: [freq, ...]} or a list of frequency lists) to bypass
the blind A/B clustering. strike_k sets the onset-picking threshold in
MADs; min_gap_floor floors the ACF-derived minimum intra-cycle gap (s),
both of which stabilise picking when the strike ODF is noisy.
Source code in src/ambiscape/rhythm.py
371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 | |
partial_fm(take, freq, period, t_max, nfft=8192, hop=960)
Frequency modulation of one partial at the cycle rate.
A genuinely swinging bell Doppler-shifts its partials by a few cents at the swing period; a chimed (hammer-struck) bell does not. Tracks the instantaneous frequency of the partial (5-bin spectral centroid around its bin, energy-gated), then complex-demodulates at 1/period and at an off-rate control (1/(1.37*period)). Returns FM depth in cents at both; a swing verdict needs depth well above the control.
Source code in src/ambiscape/rhythm.py
566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 | |
strike_doa(P, times, cols, dur=0.25)
Median per-strike azimuth/elevation (deg) from the pass arrays,
energy-integrated over dur seconds after each strike.
Source code in src/ambiscape/rhythm.py
629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 | |
Spatial dynamics
Spatial dynamics at three time scales.
From the cached per-second spatial features (pseudo-intensity per octave, DOA, diffuseness) — no audio pass:
direct_diffuse_split— per-octave directness (1 − diffuseness proxy) per second: the spatial analogue of foreground/background;passby_events— level events whose azimuth sweeps monotonically through the event: moving sources, with sweep rate and direction;azimuth_organization— windowed, energy-weighted circular concentration R(t): how directionally organised the scene is over time.
Every azimuth here is in the recorder's own frame, and
frame_reference_test is the check that says whether that matters: a
recorder that travels with its subject reports its own geometry in every
room it visits, and no amount of correct decoding turns that into a
property of the places.
run_session writes spatial.json + spatial.png.
direct_diffuse_split(F)
Per-octave directness in [0, 1]: |pseudo-intensity| / band power.
Uses the cached I_band (re W*X etc. per octave) and oct_pow.
A plane wave scores near 1, a diffuse field near 0. Returns
(directness[nsec, nband], per-band medians).
Source code in src/ambiscape/spatial.py
32 33 34 35 36 37 38 39 40 41 | |
passby_events(F, min_dur_s=4, min_sweep_deg=25.0, min_r2=0.7)
Level events whose azimuth sweeps steadily: moving sources.
Detects events with :func:ambiscape.analysis.detect_events, then fits
a line to the unwrapped per-second azimuth across each event lasting
=
min_dur_s. A sweep of >=min_sweep_degwith fit R^2 >=min_r2is a pass-by; the sweep sign gives the direction of travel (mic frame). Returns a list of dicts.
Source code in src/ambiscape/spatial.py
44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 | |
azimuth_organization(F, win_s=60.0, step_s=20.0)
Windowed energy-weighted circular concentration of the azimuth.
Returns (t_centers, R): R near 1 = one dominant direction, near 0 = directionally disorganised. Window in seconds (per-second features).
R is computed in the recorder's frame, which is the only frame the audio
knows about. On a fixed recorder that is also the room's frame and R
describes the scene; on a recorder that moves with a person or a vehicle
it describes the rig, and a high R then says the recorder holds its
pose, not that the place has a direction. :func:frame_reference_test
separates the two where a heading series exists.
Source code in src/ambiscape/spatial.py
82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 | |
frame_reference_test(bearing_deg, heading_deg, weights=None, control_deg=None)
Is a bearing series fixed to the recorder, or to the world?
bearing_deg is a direction of arrival as the recorder reports it,
one value per session or per window; heading_deg is where the
recorder's nose pointed in world coordinates at the same moments, from
a compass, a magnetometer, or a written-down orientation. The world
bearing is bearing + heading, and the test is simply the circular
concentration R of each series:
- concentrated in the recorder's frame and dispersed in the world's — the quantity is a property of the rig, and every place it visited returns the same answer;
- concentrated in the world's frame — a real direction out there, a motorway or a prevailing wind, and the recorder happened to move;
- concentrated in neither — no stable bearing at either scale.
Returns R_rig, R_world, their ratio, R_chance, n and a
frame label of "rig", "world" or "neither". R_chance
is 1 / sqrt(n), the root-mean-square resultant of n uniformly
random angles, which is what an R has to beat before it means anything
at all; with weights, n is the effective count
(sum w)^2 / sum w^2.
Pass a positive control. control_deg is a second bearing series,
in the same rig frame, that is known or expected to behave differently —
the direction of the operator's own sway, a source that was carried
along, a bearing to something fixed. Its two R values come back under
control. Without one, a test that answers "rig" cannot be told from
a method that always answers "rig", and the difference is the whole
result.
WHY THIS EXISTS. A first-order recorder worn on a body was carried through 300 recording days and seven kinds of place — corridors, living rooms, an auditorium, a train — and its loudest bearing sat at R = 0.813 in the rig's frame against 0.268 in compass coordinates, chance being 0.058. The location groups' mean bearings spanned 18 degrees between them: a corridor, a lecture hall and a moving train were returning the same direction, because the direction was the recorder's. The positive control, the wearer's own sway axis, gave 0.490 against 0.194 and so ruled out a method that answers "rig" whatever it is fed.
Re-decoding the same audio with the correct channel convention softened the numbers to 0.393 against 0.144 and widened the group spread to 45 degrees, in a physically sensible order — so a wrong decode makes this worse but is not the cause of it, and a correct decode does not clear the rig from the measurement. Ask the frame question before interpreting any bearing; no decode answers it.
rng = np.random.default_rng(0) heading = rng.uniform(-180, 180, 500) frame_reference_test(np.zeros(500), heading)["frame"] 'rig'
Source code in src/ambiscape/spatial.py
108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 | |
directional_entropy(F, nbins=36)
Normalized Shannon entropy of the energy-weighted azimuth histogram.
"How many directions does this place sound from": 0 = all energy from one bearing, 1 = energy spread evenly around the horizon — the spatial analogue of an acoustic diversity index, and something only an ambisonic corpus can report.
"This place" is the claim to be careful with: the histogram is built in
the recorder's frame, so on a rig that moves with its subject the answer
describes the rig's habitual pose and is the same in every room.
:func:frame_reference_test is the check.
Source code in src/ambiscape/spatial.py
214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 | |
horizon_fractions(F, limit_deg=10.0)
Energy fractions arriving from above / around / below the horizon.
Uses the per-second broadband DOA elevation, energy-weighted. A room heard from a couch splits mechanics on walls (above) from footsteps and structure-borne paths (level/below); outdoors it separates birds and building services from ground traffic.
Source code in src/ambiscape/spatial.py
233 234 235 236 237 238 239 240 241 242 243 244 245 246 | |
fg_bg_az_overlap(F, nbins=36)
Bhattacharyya overlap of foreground vs background azimuth energy.
Foreground = loudest 25 % of seconds, background = quietest 25 % (the corpus convention). 1 = the foreground comes from where the background hums (one-source rooms), 0 = figure and ground occupy different directions (a street heard past a courtyard fountain).
Source code in src/ambiscape/spatial.py
249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 | |
summarize_spatial(F)
Spatial descriptors for the analyze summary.
Azimuth-based measures (directional entropy, fg/bg overlap) are reported for ambix and stereo (lateral) but not mono; elevation-based measures (horizon fractions) only for ambix, since neither stereo nor mono resolves elevation.
Source code in src/ambiscape/spatial.py
268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 | |
run_session(sess, out_dir)
CLI driver: split + pass-bys + R(t), figure + spatial.json.
Source code in src/ambiscape/spatial.py
287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 | |
Schedule matching
Schedule matching: test event/strike streams against civic time grids.
Bells, chimes, and sirens follow wall-clock schedules — hourly strikes,
quarter-hour chimes, fixed evening ringing. Given event times on the
session's absolute clock, match_periods folds them at candidate civic
periods and scores each with circular statistics; clock_offset turns a
known schedule into a recorder-clock correction (the workflow behind
clock_offset_s in calibration.json).
Times must be absolute seconds (session clock, i.e. take.start +
offset into the take), otherwise grid phases are meaningless.
match_periods(times_abs, periods=CIVIC_PERIODS)
Fold events at each candidate period; score alignment.
Returns one dict per period — phase of the grid the events cluster on
(seconds past the grid tick), R, circular SD, Rayleigh p — sorted by R.
A meaningful match needs both high R and enough events spread over
several grid cycles (n_cycles); R is trivially 1 when all events
fall inside one cycle.
Source code in src/ambiscape/schedule.py
22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 | |
grid_scan(F, period_s, phase_s=0.0, band=(300.0, 1500.0), win_s=120.0, min_rise_db=6.0, bg_win_s=300.0)
Targeted scan of every tick of a civic grid for band-limited strikes.
The complement of :func:match_periods: instead of asking which grid an
event stream fits, look at each tick of a known grid (every quarter
hour, every hour + phase_s) for energy in a band — a church clock in
the bell band, whether or not the broadband detector heard it. Uses the
cached features (band level above a running bg_win_s low-percentile
background), so the scan is instant.
Returns one dict per tick inside the feature timeline: t_tick
(absolute seconds), detected, rise_db (peak exceedance within
win_s centered on the tick), and offset_s of that peak from the
tick — a consistent nonzero offset across ticks is recorder-clock error
(see :func:clock_offset).
Source code in src/ambiscape/schedule.py
48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 | |
clock_offset(observed_abs, true_clock_s)
Recorder-clock correction from one event of known wall-clock time.
observed_abs is the event's time on the recorder clock (absolute
session seconds), true_clock_s the known true time (seconds since
midnight). Returns the clock_offset_s value for
calibration.json (positive = recorder clock was slow).
Source code in src/ambiscape/schedule.py
90 91 92 93 94 95 96 97 98 | |
run_session(sess, out_dir)
CLI driver: match cached event streams against civic periods.
Uses broadband events (always) and rhythm strikes when a prior
ambiscape rhythm run left rhythm.json phase data; writes
schedule.json.
Source code in src/ambiscape/schedule.py
101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 | |
Event timbre templates
Event timbre templates: recurring event classes without machine learning.
Every transient event gets a spectral fingerprint — the strike-triggered
post/pre rise spectrum (what appeared) plus a per-band decay slope
(how it faded). Fingerprints are clustered by correlation distance into
template classes: "the same sound again" across a whole session, fully
transparent and corpus-comparable. Complements PANNs tagging ([ml]).
run_session fingerprints the session's spectral events (see
:mod:background), clusters them, and writes timbre.json +
timbre.png (class templates + counts + exemplar times).
event_fingerprint(take, t_onset, nfft=8192, decay_s=1.0)
Rise spectrum (dB, mel-ish bands) + per-band decay slope (dB/s).
Source code in src/ambiscape/timbre.py
37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 | |
cluster_events(fps, th=0.35, min_size=2)
Average-linkage clustering of fingerprints by correlation distance. Returns labels (−1 = unclustered singleton).
Source code in src/ambiscape/timbre.py
62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 | |
run_session(sess, out_dir, max_events=150)
Fingerprint + cluster the session's spectral events.
Source code in src/ambiscape/timbre.py
81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 | |
MIR views (librosa)
Bridge to musiscape: music analysis on an ambiscape session.
The analysis moved to musiscape on 2026-08-12. It had lived here while
musiscape --- the music toolbox --- imported six of its symbols across three
modules, and no library code in this package ever used it; one CLI subcommand
did. tempogram, chromagram, dominant_period, pulse_clarity,
fifths_center, tonal_center_spread and tartyp_profile are now
:mod:musiscape.music, and their circular statistics come from
:mod:micromotion.circular, which owns them.
What stays here is what could not travel: the two functions that know what an
ambiscape :class:Session is. load_w pulls a take's mono reference through
the same downmix the rest of this pipeline uses, and run_session drives the
figure and summary for a session folder. They are an adapter, in the same
sense as musicalgestures._soundscape is an adapter the other way --- MGT
owns pixels, ambiscape owns samples, musiscape owns music, and each crossing
is one small module that says so.
musiscape is an optional dependency. ambiscape does not require it, and this module raises a plain instruction if it is missing rather than failing somewhere deeper. Anyone who only wants the analysis should call musiscape directly and never come through here.
load_w(take, t0=0.0, dur=None, sr=22050)
Mono reference of a take (W / L-R mean / channel), resampled to sr.
Reads the take's decoded audio (so a transcoded .m4a works too) and
downmixes per the take's mode --- MIR runs on the same mono reference the
rest of the pipeline uses. This is the half of the old module that knows
about takes, which is why it stayed.
Source code in src/ambiscape/music.py
42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 | |
run_session(sess, out_dir, t0=0.0, dur=None)
Tempogram and chromagram figure plus summary for a session's first take.
The session handling is this module's; every number in the summary comes
from :mod:musiscape.music.
Source code in src/ambiscape/music.py
59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 | |
Taxonomy
Figures for two separate sound taxonomies, from per-session annotations.
Two traditions are represented here and their founders have nearly the same name. They ask different questions and neither constrains the other, so keep them apart.
Pierre Schaeffer (Traité des objets musicaux, 1966) asks what a sound IS in itself, heard through
reduced listening and classified by its internal make-up. Here that is mass and facture.
R. Murray Schafer (The Soundscape, 1977) asks what a sound DOES in a place, classified by the role
it plays for the people living among it. Here that is kind and soundmark.
A third scheme is present and belongs to neither. source (biophony, geophony, anthrophony) is
soundscape ecology, after Krause and Pijanowski, and classifies by physical origin.
One object carries all three labels independently. A ventilation drone is noise/unlimited to
Schaeffer, a keynote to Schafer, and anthrophony to soundscape ecology.
The two figures work at different timescales, and this is not incidental. A Schafer keynote is a
level that persists for minutes or hours — the ground of a place, heard as its condition. A Schaeffer
sound object is an event of roughly half a second to five seconds, short enough to be held whole in
one act of attention. Plotting steady-state regimes on the typo-morphology plane would conflate the
two: it would ask of an eight-hour ventilation bed the question Schaeffer asks of a single closing
door. So the map is built from detected events (see :mod:ambiscape.objects) and the timeline from
regimes, and neither borrows the other's unit.
The annotation file (annotations.json or .yml in the session folder) is hand-authored:
instruments detect when things sound, but assigning a sound to any of the three schemes is an
interpretive act. This module turns that interpretation into two figures, one per tradition:
schaeffer_map— sound objects on the facture x mass plane, which is Schaeffer's question. One point per object, extracted from the session's detected events and typed on both axes from its own spectral and temporal signature; hand-authored objects of the same scale join them. Keynote regimes never appear. Points are coloured by Schafer'skindonly so you can see whether the two schemes happen to agree in a given corpus; the colouring carries no classificatory weight;schafer_timeline— the session clock. Two layouts. The acoustic-first layout (the only one without activity data) gives one lane per hand-authored object: keynote spans as bars, events as markers, lo-fi states shaded. Hi-fi and lo-fi are Schafer's terms too. Machine-drafted steady-state regimes are merged into a bounded set of keynote-bed lanes (see :func:merge_keynote_beds) so the figure height does not grow with the regime count. The activity-first layout (the default whenever activities are provided) inverts this: the human activities become the organising structure, one lane per activity class, each span's fill coloured by its measured fast level in dB re the day median (same palette as the cross-node day figures), with the machine keynote-bed structure compacted to a single strip.
Annotation schema (JSON; YAML accepted if PyYAML is installed)::
{
"objects": [
{"name": "air-pump drone",
"label": "air-pump drone (130 Hz comb, 9 h)", # optional
"kind": "keynote", # keynote|signal|soundmark|figure
"soundmark": "dwelling", # optional: community|dwelling
"source": "anthrophony", # optional: ...|biophony|geophony
"mass": "noise", # tonic|tonic-complex|complex|noise
"facture": "unlimited", # impulse|iteration|sustained|unlimited
"spans": [["23:01:36", "1 07:53:55"]], # and/or
"events": ["1 04:42:51"]},
...
],
"states": [
{"label": "LO-FI (drone masks the field)",
"span": ["23:01:36", "1 07:53:55"]}
]
}
Times are "[D ]HH:MM:SS" where the optional leading integer D is days
after the session's first day (or plain seconds as a number).
Independently of all three schemes, both figures can overlay human-annotated
activities (what the people in the space were doing: cooking, sleeping,
absence...) from a dataset ground-truth CSV in the SINS format
(Class;Start time;Stop time with absolute timestamps, semicolon-separated;
Dekkers et al. 2017). These are data, not machine inference: captions
attribute them to the dataset, and they are never conflated with the
machine-drafted mass/facture judgements. See :func:load_activities and the
activities parameter of :func:render, :func:schaeffer_map and
:func:schafer_timeline.
parse_time(x)
Seconds from a number, HH:MM:SS, or D HH:MM:SS for a session past midnight.
The day field exists because these recordings run overnight, so an annotation at 01:00 can be later than one at 23:00 and a bare clock time would sort them the wrong way.
Source code in src/ambiscape/taxonomy.py
127 128 129 130 131 132 133 134 135 136 137 138 | |
load_annotations(folder)
Read annotations.json, .yml or .yaml from a session folder.
Raises FileNotFoundError rather than returning an empty dict, because an annotation file that is silently absent produces a figure with nothing on it and no way to tell why.
Source code in src/ambiscape/taxonomy.py
141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 | |
load_activities(path, day0=None)
Human activity ground truth from a SINS-style CSV, on the session clock.
The file is semicolon-separated with a Class;Start time;Stop time
header and absolute timestamps (Dekkers et al. 2017). Each row becomes
{"class": str, "start": float, "stop": float} with times in seconds
since midnight of day0 — pass the session's day0 so the spans land
on the same clock as the annotation spans; without it the date of the
first row is used.
Source code in src/ambiscape/taxonomy.py
168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 | |
activity_suffix(spans, activities, max_classes=3, min_share=0.05)
Label suffix naming the dominant concurrent activities by time share,
e.g. " — during: absence 71%, sleeping 22%"; empty when nothing
overlaps.
Source code in src/ambiscape/taxonomy.py
209 210 211 212 213 214 215 216 217 218 | |
bed_name(lo, hi, n_spans)
Human name for a keynote bed: 'quiet bed, -60 to -54 dBFS, 23 spans'.
Source code in src/ambiscape/taxonomy.py
374 375 376 377 378 379 380 381 | |
merge_keynote_beds(objects, max_beds=MAX_BED_LANES)
Cluster machine-drafted keynote regimes into level beds for the timeline.
A domestic day yields 60+ steady-state regimes; one lane each gave a
mostly-empty staircase thousands of pixels tall. Here auto-drafted
keynotes (and only those — hand-authored objects always keep their own
lane) are grouped into beds of similar level (~BED_BAND_DB-wide
bands), one lane per bed carrying all of its spans, capped at
max_beds lanes by total duration with the remainder pooled into
"other beds". Returns the list unchanged when there is nothing to merge.
Source code in src/ambiscape/taxonomy.py
388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 | |
map_objects(ann=None, F=None, min_dur=None, max_dur=None)
The sound objects a Schaeffer map plots, and the census behind them.
Two sources, both at object scale. From F (a
:func:~ambiscape.features.load_features dict) come the session's
detected events, filtered to the object duration window and typed on both
axes by :func:ambiscape.objects.extract_objects. From ann come the
hand-authored entries that are themselves object-scale — events, or spans
no longer than the window — expanded one point per event. Machine-drafted
keynote regimes are counted and set aside: they are Schafer's material.
Returns (objects, stats), where stats carries every count the
caption needs: n_detected, n_short, n_long, n_hand,
n_regime, n_untyped, and the window actually used.
Source code in src/ambiscape/taxonomy.py
475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 | |
schaeffer_map(source, out_path, title='', activities=None, stats=None, max_points=MAX_SCATTER)
Sound objects on the facture x mass grid — one point per object.
source is a list of sound objects (as returned by
:func:map_objects), or an annotation dict, which is passed through
:func:map_objects with no feature cache so that only its hand-authored
object-scale entries are plotted. Keynote regimes are never plotted: a
multi-minute level bed is Schafer's unit, not Schaeffer's, and it is on
the timeline where it belongs.
Every object is one point, jittered inside its cell so that density is
visible, with the cell's full count printed at its corner. Points are
coloured by Schafer function — or, with activities (from
:func:load_activities), by each point's dominant concurrent activity,
with the same class colours as the timeline — and their opacity tracks the
object's level, so the loud objects in a crowded cell stand out from the
quiet ones. A session of tens of thousands of objects is subsampled for
the scatter (max_points, stratified by cell, stated in the caption)
while the printed counts stay complete. Objects few enough to name carry
their labels, and on sparse maps also the activity they occurred during.
stats is the census dict from :func:map_objects; when omitted it is
derived from source. The caption keeps the provenances apart:
mass/facture are machine-drafted listening proposals, the activities are
dataset ground truth.
Source code in src/ambiscape/taxonomy.py
589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 | |
schafer_timeline(ann, out_path, title='', session=None, activities=None, F=None, layout='auto')
Schafer timeline of the session, in one of two layouts.
layout="acoustic" (and any layout without activities) is the
lane timeline of annotated objects, lo-fi states shaded: machine-drafted
steady-state regimes are merged into keynote beds by
:func:merge_keynote_beds, so the lane count — and with it the figure
height — stays bounded however many regimes a long session proposes.
With activities it gains a compact ribbon of coloured activity spans
along the top and each keynote-bed label its dominant concurrent
activities by time share ("quiet bed, -60 to -54 dBFS, 23 spans —
during: absence 71%, sleeping 22%").
Whenever activities (from :func:load_activities) are given and
layout is "auto" (default) or "activity", the layout inverts:
the human activities become the organising structure. One lane per
activity class (longest first, minor classes pooled into "other"), each
span's fill coloured by its measured fast level in dB re the day median
(F, a :func:~ambiscape.features.load_features dict; same palette as
the cross-node day figures), lane labels carrying the acoustic summary
("watching tv — 2.1 h, median −41 dBFS"). Hand-authored objects keep
their lanes and markers, the machine keynote-bed structure is compacted
to a single strip coloured by band, and the events lane sits at the foot.
Source code in src/ambiscape/taxonomy.py
796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 | |
render(folder, out_dir=None, session=None, activities=None, layout='auto', object_window=None)
Load annotations from a session folder and write both figures.
activities is an optional path to a SINS-style activity CSV
(Class;Start time;Stop time, semicolon-separated, absolute
timestamps); when given and present, the human-annotated activities are
aligned to the session clock (via the session's day0) and the
timeline switches to the activity-first layout (layout="acoustic"
keeps the acoustic-first lane timeline with the activity ribbon), with
span level colouring and lane level stats drawn from the session's
cached features when available. A missing file leaves both figures
exactly as without it.
The map is built from the session's cached features whenever they are
present: the detected events are extracted as sound objects and typed on
Schaeffer's two axes (see :func:map_objects). Without a feature cache
the map falls back to whatever object-scale entries the annotation file
itself carries. object_window is an optional (min_s, max_s) pair
overriding the 0.2–8 s duration window that decides what counts as a sound
object.
Source code in src/ambiscape/taxonomy.py
1104 1105 1106 1107 1108 1109 1110 1111 1112 1113 1114 1115 1116 1117 1118 1119 1120 1121 1122 1123 1124 1125 1126 1127 1128 1129 1130 1131 1132 1133 1134 1135 1136 1137 1138 1139 1140 1141 1142 1143 1144 1145 1146 1147 1148 1149 1150 1151 1152 1153 1154 1155 | |
Sound objects
Sound objects: event-level extraction and Schaeffer typing.
Schaeffer's objet sonore is a perceptual unit. It is what the ear can hold whole in one act of attention — a door closing, a kettle's rattle, a two-second tone. In the Traité des objets musicaux (1966) that horizon is on the order of half a second to five seconds; below it there is nothing to hear as a shape, above it attention stops holding the whole and starts following a texture.
A multi-minute steady level regime is therefore not a sound object at all. It is Schafer's keynote: a ground that persists for minutes or hours, heard as the condition of the place rather than as an event in it. Placing regimes on the typo-morphology plane conflates two traditions and two timescales; this module supplies what the plane actually asks for.
The unit of analysis here is the detected event — the fast level rising at
least 8 dB above its running background for at least 0.25 s (see
:func:ambiscape.analysis.detect_events). Events whose duration falls inside
the object window (:data:OBJECT_MIN_S to :data:OBJECT_MAX_S, 0.2–8 s by
default) are taken as candidate sound objects; the rest are counted and
reported, never silently dropped. Each surviving object is then characterised
on Schaeffer's two axes from its own signature in the cached features:
- mass — the spectral axis, tonic / tonic-complex / complex / noise, from
the object's excess spectrum (what appeared over the running band
background), via :func:
object_mass; - facture — the temporal axis, impulse / iteration / sustained (delimited)
/ sustained (unlimited), from the object's own amplitude envelope, via
:func:
object_facture.
Both rules are written out in the two functions' docstrings and their thresholds are module constants, so any proposal can be traced to the number that produced it. They remain machine-drafted proposals: no public domestic corpus carries object-level ground truth against which they could be scored (activity labels are minutes long, an order of magnitude coarser than an object), so a typing here is a suggestion to confirm by listening, in the same spirit as the rest of the draft stage.
Everything runs on the cached feature arrays — no audio pass — so a full domestic day (tens of thousands of events) types in seconds.
object_mass(excess, bands_per_octave=10.0)
Schaeffer mass from an object's excess spectrum, with its evidence.
excess is linear power per log-frequency band, background already
removed (:func:_excess_spectrum). Two quantities are read off it, both
deliberately blind to the object's overall spectral tilt — brightness is
not mass, and a bright hiss must not be mistaken for a high note:
- peak share — the fraction of the object's energy sitting in narrow
spectral peaks. A band counts as a peak when it stands
PEAK_PROMINENCE_DBabove the running median of its own octave. This is peakiness and harmonicity in one number: a single partial counts, and so does every member of a harmonic series, while a continuum of any shape counts for nothing; - spread — the energy-weighted standard deviation of log2 frequency, in octaves. A pitch has almost none; a knock has a fraction of an octave; hiss, rush and rustle spread across the spectrum whatever their tilt.
The rules, applied in order:
- peak share >= 0.50 — most of what appeared is in peaks, so a pitch (or a harmonic series) is what one hears: tonic;
- peak share >= 0.20 — a pitch is audible over a continuum that carries most of the energy: tonic-complex;
- spread >= 1.2 octaves — no pitch, and the energy is spread wide enough to hear as a band of noise rather than as a body: noise;
- otherwise — energy in one or a few narrow regions, none of them a pitch: complex.
The evidence also carries the peak's prominence in dB and a flatness reading (the Wiener entropy of the excess spectrum, floored 40 dB below its peak: 0 for a single band, 1 for a perfectly even spectrum). Neither enters a rule; both are worth having beside the others when listening back.
Returns (mass, evidence); (None, evidence) when nothing rose above
the background, in which case there is no object spectrum to type.
Source code in src/ambiscape/objects.py
81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 | |
object_facture(env, dt, dur)
Schaeffer facture from an object's amplitude envelope, with evidence.
env is the object's own amplitude envelope (linear, one value every
dt seconds; the 20 ms broadband envelope of the feature cache when it
is available) and dur the object's duration in seconds. Two things are
read off it: the attack time, the conventional 10-to-90 per cent rise
of the envelope towards its peak, and the iteration strength, the
normalised envelope autocorrelation at its best repetition lag between
ITER_LO_HZ and ITER_HI_HZ (see :func:_iteration_strength).
The rules, applied in order:
- duration >= 5 s — the sustainment outlasts what attention holds whole; the object has no audible end within its own present. This is Schaeffer's excentric case: sustained (unlimited);
- iteration strength >= 0.35 over at least 0.4 s — energy is maintained by repetition, not continuously: iteration;
- attack <= 0.08 s and duration <= 1 s — all the energy arrives at once and nothing maintains it: impulse;
- otherwise — energy held continuously between a beginning and an end: sustained (delimited).
Returns (facture, evidence).
Source code in src/ambiscape/objects.py
178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 | |
object_profile(env, dt, dur=None, eps=1e-12, logspec=None, logf=None)
Morphology of one sound object, as numbers rather than as a type.
:func:object_facture already measures an attack time and an iteration
strength and then discards both, keeping only the label they imply.
That is a loss: two objects can share a facture and differ audibly, and
the numbers behind the label are what a comparison needs --- whether two
takes of the same action match, whether a machine's onset resembles a
deliberate one, whether an object is front-loaded or back-loaded.
This is a meso-band descriptor set, in the sense of
:mod:ambiscape.timescales: everything here is defined on a single
object of roughly 0.2 to 8 s and none of it needs a minute of audio.
The session-scale descriptors do, which is why a folder of short clips
returns almost nothing from analyze.
env is the object's amplitude envelope (linear, one value every
dt seconds). Returns:
duration_s
length of the envelope.
attack_s
the 10-to-90 per cent rise towards the peak, as in facture typing.
decay_s
the fall from the peak back through 10 per cent of it, or None
when the object ends before decaying --- a sound cut off rather
than allowed to finish.
temporal_centroid
where the energy sits along the object, from 0 at the very start to
1 at the very end. An impulse is front-loaded and lands near 0.2; a
held sound sits near 0.5. This separates impulsive from sustained
without reference to the typology.
crest_db
peak over RMS. High for a single strike, low for a steady texture.
iteration_hz / iteration_strength
best repetition rate in the envelope and how strongly it repeats,
from the same measurement that types iterative objects.
Pass logspec and logf --- the object's own rows of the cached
log-frequency spectrogram and its band edges --- to include the spectral
morphology from :func:object_spectrum in the same dict.
Source code in src/ambiscape/objects.py
226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 | |
object_spectrum(logspec, logf, dt, eps=1e-12)
Spectral morphology of one object: where it sits, where it goes.
The companion to :func:object_profile, which measures an object's
envelope and says nothing about its spectrum. Both are meso-band
descriptors in the sense of :mod:ambiscape.timescales --- defined on a
single object of roughly 0.2 to 8 s, needing no minute of audio, which is
what the session-scale centroid and flux both require.
logspec is the object's own rows of the cached log-frequency
spectrogram and logf its band edges. Returns:
brightness_hz
energy-weighted spectral centroid over the whole object.
brightness_drift_oct
the centroid of the last third against the first third, in octaves.
Negative is an object growing duller as it decays, which is what a
struck resonant body does; positive is one growing brighter, which a
kettle approaching the boil does. This is the duration-aware
quantity: it is a shape rather than a level, so it compares objects
of different lengths without either being normalised away.
Measured against listener typing on 334 labelled clips of everyday
sound actions, impulsive objects drift −0.25 octaves and iterative
ones −0.01, in the predicted direction but weakly: p = 0.03 with a
rank-biserial effect of −0.16. It describes an object; it does not
type one.
flux_per_s
mean absolute frame-to-frame change of the power-normalised
spectrum, per second. Near zero for a held tone; large for a
clattering or scraping object whose spectrum churns. Reported per
second rather than per frame so it does not depend on the hop.
On the same 334 clips it does not separate facture at all --- 39.5
per second for impulsive objects against 39.7 for iterative, p = 0.56
--- and neither does ``brightness_hz``. Both describe what an object
sounds like rather than which of Schaeffer's classes it falls in, and
should not be used for typing.
n_frames
how many spectrogram rows the object had. Below about five the other
three are indicative only, and a caller reporting them should say so.
Source code in src/ambiscape/objects.py
311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 | |
extract_objects(F, min_dur=OBJECT_MIN_S, max_dur=OBJECT_MAX_S, thresh_db=8.0)
Sound objects of a session, typed on Schaeffer's two axes.
Runs :func:ambiscape.analysis.detect_events on the cached fast level,
keeps the events whose duration lies in [min_dur, max_dur] — the
perceptual window in which something can be held whole in attention — and
types each survivor with :func:object_mass and :func:object_facture.
Returns {"objects": [...], "n_detected", "n_short", "n_long",
"min_dur_s", "max_dur_s"}, so the events that were not objects stay
visible and countable. Each object is an annotation-shaped dict —
mass, facture, kind (always "figure": what a sound object
does in the place is a separate, human judgement), a one-element spans
on the session clock, its level and exceedance, _auto set, and the
numbers behind both typings under _schaeffer — so it drops straight
into the taxonomy figures.
Source code in src/ambiscape/objects.py
410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 | |
cell_counts(objects)
{(facture, mass): n} over typed objects — the map's full census.
Source code in src/ambiscape/objects.py
495 496 497 498 499 500 501 502 503 | |
profile_clips(folder, verbose=False)
Profile every clip in a folder as one sound object each.
A corpus of short clips is the case the session-scale descriptors cannot
serve: almost everything in a session summary needs a minute of audio and
returns None for a six-second recording. Here each clip is taken whole
as one object and given the meso-band descriptors --- the envelope set
from :func:object_profile and the spectral set from
:func:object_spectrum.
Taking the clip whole is the assumption to be aware of. It is right for a
corpus of single actions, each recorded deliberately, and wrong for a clip
that happens to contain three events; for those, run
:func:extract_objects over a session instead and let the detector find
the boundaries.
Returns one dict per clip, in sorted filename order, each carrying
clip and the two descriptor sets. n_frames will be small --- the
cached log spectrogram has one row per second, so a six-second clip gives
six --- and the spectral fields should be read as indicative below about
five.
Source code in src/ambiscape/objects.py
505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 | |
Draft annotations
Draft-annotation generator.
Pre-fills annotations.draft.json for a session from its cached features:
steady level regimes are clustered into a handful of keynote beds (one
object per ~6 dB level band, carrying all of that band's spans), detected
transient events become one unclassified "figure" object whose entries carry
listening hints (clock time, exceedance, azimuth/elevation, diffuseness).
mass and facture — the two Schaeffer axes the taxonomy map plots —
are now pre-proposed from the features (see :func:schaeffer_hint), with the
evidence under _schaeffer; the fields that still need a human ear
(kind, soundmark status, the object name) stay "TODO". Confirm by ear,
rename, delete, then save as annotations.json and run ambiscape
taxonomy.
schaeffer_hint(F, a, b)
Propose Schaeffer typo-morphology for a [a, b]-second span from features.
Turns the machine's mass and facture guesses (the axes the taxonomy
map plots) into pre-fills, with the evidence surfaced under _schaeffer:
- mass from median spectral flatness (0 tonal → 1 noisy): tonic / tonic-complex / complex / noise;
- facture (sustainment) from continuity: a ground that fills most of the session reads as unlimited, a bounded steady regime as sustained;
- a dynamic hint (unvaried / varied) from the span's level spread.
These are suggestions for the human annotator to confirm, not decisions.
Source code in src/ambiscape/draft.py
185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 | |
draft_annotations(F, folder, out_name='annotations.draft.json', session=None, tagger=None, max_tagged=MAX_TAGGED)
Draft an annotation file from the features, for a person to correct.
It proposes the steady beds and the events it can find and names them where a tagger is
available. The output is annotations.draft.json and never annotations.json, because
a draft that overwrote the human-checked file would lose the only part of this pipeline
that is not reproducible.
Source code in src/ambiscape/draft.py
217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 | |
Calibration and ISO indicators
ISO 12913-3-style psychoacoustic indicators + level calibration.
Calibration
A session is calibrated by <folder>/calibration.json::
{"dbfs_to_dbspl": 94.0,
"method": "SPL app next to mic, air pump running, LAeq 42 dB",
"date": "2026-07-16"}
dbfs_to_dbspl is the offset O such that a signal at −X dBFS corresponds
to (O − X) dB SPL. With it, dBFS descriptors become dB SPL and waveforms
convert to pascals for psychoacoustic metrics.
The same file may carry clock_offset_s — seconds added to every take's
start time when the recorder clock was found to be off (positive = clock was
slow; e.g. calibrated against a known external time reference). Applied in
:func:ambiscape.io.open_session, so all clock-labelled outputs agree. Both
keys are optional.
Indicators (via MoSQITo, optional dependency)
ISO 532-1 time-varying loudness (N5, N50), DIN 45692 sharpness, and
Daniel & Weber roughness, computed per ear on a binaural render of the
B-format signal. If ambiviz (with its HRIR-based binauralizer) is
installed it is used; otherwise a documented fallback renders a
back-to-back cardioid pair at ±90° — a pseudo-binaural approximation
without pinna/ILD spectral cues. Uncalibrated sessions are computed with an
assumed offset and flagged: absolute sone/acum values are then indicative
only (their ratios between segments remain meaningful).
Beyond the MoSQITo set (pure numpy/scipy, always available)
MoSQITo (≤ 1.2.x) provides no fluctuation strength, so
:func:fluctuation_strength implements the Fastl & Zwicker
envelope-modulation approximation (~4 Hz weighting) — clearly not a
standardised metric — and :func:fluctuation_index is its cheap
broadband companion on the cached 20 ms envelope. :func:tone_prominence
/ :func:prominent_tones detect DIN 45681-style prominent tones
(spectral peak vs masking-band level, ΔL in dB) in the per-minute mean
spectra — the ventilation/appliance-hum detector.
:func:summarize_psycho folds both into the analyze summary.
load_calibration(folder)
The session's calibration.json, or None if it has none.
None is the ordinary case rather than an error: an uncalibrated session still yields every ratio-based descriptor, and only the dB SPL levels are unavailable.
Source code in src/ambiscape/iso.py
56 57 58 59 60 61 62 63 64 65 | |
take_offset(cal, take_name)
The dbfs->dbspl offset for one take: per-take map, else global.
Multi-device sessions (a Zoom and a phone running side by side) need
different offsets per take; dbfs_to_dbspl_takes maps take filenames
to offsets, falling back to the session-wide dbfs_to_dbspl.
Source code in src/ambiscape/iso.py
68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 | |
derive_offset(F, laeq_spl, t0=None, dur=None)
Derive dbfs_to_dbspl from a field SPL-meter reading.
laeq_spl is the LAeq in dB(A) read off a meter (or phone app) held
at the microphone position over some span of the recording; t0 /
dur bound that span in seconds from the start of the recording
(defaults: all of it). The recording's own LAeq over the same span
comes from the cached A-weighted fast levels, and the offset is simply
their difference: a signal at −X dBFS corresponds to (offset − X)
dB SPL.
Source code in src/ambiscape/iso.py
85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 | |
write_calibration(folder, offset, method='', take=None)
Write/merge an offset into <folder>/calibration.json.
Existing keys (clock_offset_s etc.) are preserved. With take,
the offset lands in the per-take map dbfs_to_dbspl_takes;
otherwise it becomes the session-wide dbfs_to_dbspl.
Source code in src/ambiscape/iso.py
114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 | |
to_pascal(x, dbfs_to_dbspl)
Full-scale samples to pascals, given the session's dBFS-to-dB SPL offset.
The offset is the whole of the calibration; get it wrong and every level moves by a constant while every ratio stays right.
Source code in src/ambiscape/iso.py
136 137 138 139 140 141 142 | |
apply_calibration(summary, cal)
Add dB SPL versions of the level descriptors to a summary dict.
Source code in src/ambiscape/iso.py
145 146 147 148 149 150 151 152 153 154 | |
binaural(x, fs, order='ambix', mode='ambix')
Two-channel ear signals from a block, for the ISO psychoacoustic metrics.
The treatment follows the take's mode so every input type is handled:
mono(or a 1-column block) -> the lone channel is duplicated to both ears.stereo/binaural(or < 4 columns) -> the first two channels are already a left/right pair (binaural ear signals or a stereo mix) and pass through unchanged.ambixfirst-order B-format -> the block is remapped to canonical AmbiX (W, Y, Z, X) viaorder, so afuma(W, X, Y, Z) take is decoded correctly, then binauralised with ambiviz's HRIR decoder, falling back to a +-90 deg cardioid pair (0.5 * (W +- Y), no pinna cues).
order is consulted only for ambix input. Returns an (n, 2) array and
the method name.
Source code in src/ambiscape/iso.py
157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 | |
mosqito_available()
True if MoSQITo (the ambiscape[iso] extra) is importable.
Source code in src/ambiscape/iso.py
194 195 196 197 198 199 200 | |
indicators(x_pa, fs, rough_dur=10.0)
ISO 532-1 loudness (N5/N50), DIN 45692 sharpness, D&W roughness, and (approximate) fluctuation strength for one calibrated (pascal) channel.
MoSQITo runs ~5x slower than realtime, and roughness is the costliest
metric; it is therefore computed on a central rough_dur-second slice
(roughness is a texture measure and stabilises within seconds).
Fluctuation strength is not in MoSQITo (≤ 1.2.x) and comes from the
local :func:fluctuation_strength approximation instead.
Raises ImportError naming the extra when MoSQITo is missing.
Source code in src/ambiscape/iso.py
203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 | |
segment_indicators(sess, F, folder, dur=30.0, offset=None)
Compute per-ear indicators on representative segments.
Segments come from analysis.pick_segments (typical / quietest /
most_active / transition); dur seconds from the start of each.
dur_s reports the audio actually delivered, which is shorter than
dur when the take ends first (read_span clamps); the request is
then kept alongside it as dur_requested_s. Kinds that resolve to
the same window — a session only one segment long, or a stationary
room with no distinct most-active window — are computed once and the
coincident kinds listed under also_kinds.
Source code in src/ambiscape/iso.py
239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 | |
fluctuation_strength(x, fs, fmin_mod=0.25, fmax_mod=32.0, dl_cap=30.0)
Fluctuation strength, vacil — an approximation, not a standard.
MoSQITo (≤ 1.2.x) offers no fluctuation strength, so this follows the
Fastl & Zwicker envelope-modulation model in spirit: per Zwicker
critical band, the envelope level depth ΔL (5th–95th percentile of the
< 32 Hz band envelope, capped at dl_cap dB) is weighted by the
band-pass modulation-frequency weighting 2/(f/4 + 4/f) that peaks
at 4 Hz, and summed over Bark bands. The sum is scaled so that the
classic reference — a 1 kHz tone, 100 % amplitude-modulated at 4 Hz —
reads 1 vacil.
It is not an implementation of any standard (none exists for fluctuation strength): masking-based envelope depth, the level dependence, and interaction effects are all simplified, so treat absolute values as indicative and comparisons between recordings made with the same pipeline as the meaningful output. Needs ≥ ~2 s of signal; steady signals read ≈ 0.
Source code in src/ambiscape/iso.py
376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 | |
fluctuation_index(env, dt, fmin=0.25, fmax=20.0)
Broadband fluctuation index from a cached power envelope (unitless).
The 4 Hz-weighted modulation depth of the unit-mean envelope:
sqrt(∫ P(f) · w(f)² df) with the same Fastl-style weighting as
:func:fluctuation_strength, computed from the cached 20 ms broadband
envelope (env_hi) so analyze needs no audio pass. A relative
index that tracks fluctuation strength (≈ 0 steady drone, high for
~4 Hz wobble), not vacil: no critical-band split, no absolute
anchoring. Returns None when the envelope is too short.
Source code in src/ambiscape/iso.py
403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 | |
critical_bandwidth(f_hz)
Zwicker & Terhardt (1980) critical bandwidth at f_hz, in Hz.
Source code in src/ambiscape/iso.py
435 436 437 438 | |
tone_prominence(spec_row, freqs, fmin=50.0, fmax=10000.0, min_dl_db=6.0, max_n=12)
DIN 45681-style prominent tones in one mean power spectrum.
For each narrowband spectral peak, the decibel prominence
ΔL = L_tone − L_noise compares the tone power (main-lobe bins,
noise-corrected) against the masking-noise level in the surrounding
critical band (median bin level × band width, i.e. the level the band
would have without the tone). Tones with ΔL ≥ min_dl_db (default
6 dB, the decisive audibility criterion of DIN 45681) are returned as
{"f_hz", "dL_db"}, strongest first.
This follows the method of DIN 45681 (tone vs masking-band level)
but is not a certified implementation: no frequency-dependent masking
index, no uncertainty term. Pure numpy/scipy; expects a linear power
spectrum on a uniform frequency grid (a minspec row).
Source code in src/ambiscape/iso.py
441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 | |
prominent_tones(minspec, freqs, min_fraction=0.1, tol_cents=50.0, **tone_kw)
Time-aggregated prominent tones across the per-minute spectra.
Runs :func:tone_prominence per minute and groups detections within
tol_cents (or 2.5 bins at low frequency) into persistent tones.
Tones present in at least min_fraction of the minutes are returned
as {"f_hz", "dL_median_db", "dL_max_db", "present_fraction",
"n_minutes"}, strongest first — a ventilation hum shows up as one
high-fraction line, a passing siren does not.
Source code in src/ambiscape/iso.py
495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 | |
summarize_psycho(F)
Psychoacoustic summary keys from cached features (no audio pass).
Adds to the analyze summary: the strongest persistent DIN
45681-style tone (tonal_prominence_db / _hz, None when the
scene has no prominent tone), the count of persistent tones, and the
broadband :func:fluctuation_index. All are level-difference or
normalised quantities, meaningful without SPL calibration. Degrades
gracefully (None / 0) when the cache predates minspec/env_hi.
Source code in src/ambiscape/iso.py
540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 | |
room_criteria(oct_spl_db)
NR, NC, and RC ratings of an octave-band SPL spectrum.
oct_spl_db maps octave centre frequency (Hz) to band SPL (dB).
Ratings are only physically meaningful for calibrated levels
(dbfs_to_dbspl in calibration.json); on uncalibrated dBFS they
are relative numbers, comparable within one recorder+gain setup only.
- NR (ISO/R 1996 Noise Rating): analytic curves
L = a + b*NR; the rating is the highest per-band NR value andNR_governing_hznames the band that sets it. - NC (ANSI S12.2 Noise Criterion): tangency against the tabulated curves, linearly interpolated per band (63 Hz–8 kHz).
- RC (Blazier Room Criterion, simplified): arithmetic mean of the
500/1000/2000 Hz levels; the reference line has a −5 dB/octave slope
through (1 kHz, RC).
RC_classis "R" (rumble) when any 31.5–250 Hz band exceeds the line by > 5 dB, "H" (hiss) when any 2–4 kHz band exceeds it by > 3 dB, "RH" for both, "N" (neutral) otherwise.
Source code in src/ambiscape/iso.py
587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 | |
background_octaves_db(F, pct=50.0, offset_db=0.0)
Per-octave percentile level (dB) from cached features, for
:func:room_criteria. offset_db is the dBFS→dB SPL calibration
offset (0 keeps uncalibrated dBFS).
Source code in src/ambiscape/iso.py
642 643 644 645 646 647 648 649 650 | |
Perceptual survey (ISO 12913-2)
ISO 12913-2 Method A questionnaire → ISO/TS 12913-3 circumplex.
The rest of the toolbox measures a soundscape; this module asks people. ISO 12913-2 (Method A) has respondents rate eight perceived affective qualities — pleasant, chaotic, vibrant, uneventful, calm, annoying, eventful, monotonous — each on a 5-point Likert scale (or a 100-point slider in the common digital variant). ISO/TS 12913-3 projects the eight ratings onto a two-dimensional circumplex::
Pleasantness = (p − a) + cos45°·(ca − ch) + cos45°·(v − m)
Eventfulness = (e − u) + cos45°·(ch − ca) + cos45°·(v − m)
normalised by ρ·(1 + √2) (ρ = the coded scale range: 4 for a 1–5
scale, 100 for a 0–100 slider), so both coordinates land in [−1, +1].
The quadrants carry the familiar labels: vibrant (+P, +E), chaotic
(−P, +E), monotonous (−P, −E), calm (+P, −E).
Input is a plain CSV, one row per respondent, headed by the eight scale names (case-insensitive, any column order). The coded scale is auto-detected: values within 1–5 read as 5-point, anything larger as a 0–100 slider. Extra columns (an id column, appropriateness or loudness ratings, free text) ride along untouched and numeric extras are averaged into the summary.
:func:run_survey writes survey.json + a circumplex survey.png
into the session's analysis dir and folds srv_-prefixed keys (mean
pleasantness/eventfulness, n, dispersion) into summary.json — the
same join as the vis_ keys from :mod:ambiscape.vision — so
ambiscape catalog can rank a corpus perceptually next to the
acoustic descriptors. When the session already has an acoustic summary,
the returned doc also carries a short perception-vs-measurement table
(LAeq vs pleasantness, events/min vs eventfulness).
The usual honesty note applies (see the acoustics guide): this supports 12913-2 data handling and reporting, it does not make a survey protocol-conformant by itself.
read_responses(path)
Parse a Method-A response CSV.
Requires all eight :data:SCALES as columns (case-insensitive, any
order). One of respondent/participant/id/subject
(if present) names each row; otherwise rows are numbered. All other
columns are kept as extras (numeric ones parsed to float). Rows with
a missing or non-numeric scale value are skipped and counted.
Returns {"respondents": [{"id", "scales", "extras"}, ...],
"scale": "5-point"|"100-point", "range": (lo, hi), "n_skipped": int,
"extra_keys": [...]}.
Source code in src/ambiscape/survey.py
55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 | |
detect_scale(respondents)
Coded scale range (lo, hi) from the pooled scale values.
Everything within 1–5 reads as the printed 5-point Likert form; anything larger as the 0–100 digital slider. Out-of-range values (negative, or above 100) raise.
Source code in src/ambiscape/survey.py
112 113 114 115 116 117 118 119 120 121 122 123 124 125 | |
coordinates(scales, lo=1.0, hi=5.0)
One respondent's ISO/TS 12913-3 (pleasantness, eventfulness).
scales maps the eight scale names to ratings coded on
lo–hi. Both coordinates are normalised to [−1, +1] by
(hi − lo)·(1 + √2) per the TS.
Source code in src/ambiscape/survey.py
128 129 130 131 132 133 134 135 136 137 138 139 140 141 | |
quadrant(pleasantness, eventfulness)
The circumplex quadrant label of a point.
Source code in src/ambiscape/survey.py
144 145 146 147 148 | |
ellipse_95(P, E)
95% covariance ellipse of the respondent cloud (needs n ≥ 3).
Returns full axis lengths width/height (major/minor) and the
major axis' angle_deg counter-clockwise from the pleasantness
axis; None when the cloud is too small or degenerate.
Source code in src/ambiscape/survey.py
151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 | |
summarize(responses)
Per-respondent circumplex points + pooled statistics.
responses is :func:read_responses output. Returns the
survey.json document: points, mean, sd, dispersion (RMS distance
of respondents from the mean point), quadrant of the mean, the 95%
ellipse, and means of any numeric extra columns.
Source code in src/ambiscape/survey.py
173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 | |
survey_summary_keys(doc)
The srv_ rows folded into summary.json for the catalog.
Source code in src/ambiscape/survey.py
217 218 219 220 221 222 223 224 225 226 227 | |
vs_measurement(doc, summary)
Perception-vs-measurement rows from an acoustic summary.json.
Pairs each available acoustic descriptor with the perceptual
coordinate it is classically regressed against (LAeq and L90 vs
pleasantness, event rate vs eventfulness). Returns a list of
{"measured", "value", "perceived", "perceived_value"} rows —
empty when the summary carries none of the keys.
Source code in src/ambiscape/survey.py
241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 | |
vs_table(rows)
The rows of :func:vs_measurement as a small Markdown table.
Source code in src/ambiscape/survey.py
263 264 265 266 267 268 269 | |
render(doc, out_path, title='')
Circumplex plot: respondents, mean, 95% ellipse → a PNG.
Source code in src/ambiscape/survey.py
272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 | |
run_survey(folder, responses_csv, out_dir=None)
CLI driver: response CSV → survey.json + survey.png +
srv_ keys in summary.json.
folder is the session folder (no audio is read); output goes to
out_dir (default <folder>/analysis). An existing acoustic
summary.json gains the srv_ keys and contributes the
perception-vs-measurement rows (doc["vs_measurement"]); without
one, a summary carrying only the srv_ keys is written so the
session still joins the catalog.
Source code in src/ambiscape/survey.py
326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 | |
DCASE STARSS validation
DCASE STARSS clip collections: annotation reading and DOA validation.
The STARSS datasets (Sony-TAu Realistic Spatial Soundscapes, the DCASE sound-event localisation and detection task data) distribute real recorded scenes as first-order ambisonic WAV clips (24 kHz, 16 bit, ACN/SN3D) with a per-clip annotation CSV. Each headerless CSV row labels one active source in one 100 ms frame::
frame, class, source, azimuth, elevation[, distance]
with the frame number an integer index of 100 ms intervals, class an index
into the 13 STARSS sound-event classes (:data:CLASSES), source an
integer distinguishing simultaneous instances of a class, azimuth in
[-180, 180] degrees increasing counter-clockwise (0 = front, +90 = left),
elevation in [-90, 90] degrees, and distance in cm (STARSS23; the STARSS22
metadata has no distance column, so both five- and six-column rows are
accepted). The counter-clockwise azimuth convention matches ambiscape's own
pseudo-intensity azimuth (atan2(Iy, Ix) over ACN/SN3D W, Y, X), so
labelled and estimated azimuths compare directly.
:func:run_validation drives ambiscape doavalidate: every clip in a
folder (opened with :func:ambiscape.io.open_clips — STARSS clips carry no
BWF timestamps) is paired with its annotation CSV by file stem, the clip's
per-frame energy azimuth is compared with the labelled azimuth on
single-source frames only, and circular error statistics are reported
overall and per class, with an error-rose / per-class figure.
Multi-source frames are excluded by design: the energy azimuth is one broadband direction per frame, and with two or more simultaneous sources the pseudo-intensity vector points at an energy-weighted mixture of them, so its deviation from either label measures the mixture, not the estimator.
read_annotations(path)
Parse one STARSS annotation CSV into a list of row dicts.
Rows are headerless frame, class, source, azimuth, elevation with an
optional trailing distance (cm). Returns dicts with keys frame,
class_id, class_name, source, azimuth, elevation,
distance_cm (None when absent). Blank lines are skipped; any other
column count raises ValueError.
Source code in src/ambiscape/starss.py
55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 | |
single_source_frames(rows)
Frames labelled with exactly one active source.
Returns (frame -> row, n_multi) where n_multi counts the frames
excluded for carrying two or more simultaneous labels (see the module
docstring for why those cannot test a single energy direction).
Source code in src/ambiscape/starss.py
91 92 93 94 95 96 97 98 99 100 101 | |
frame_azimuths(path, frame_s=FRAME_S, wyzx=(0, 1, 2, 3), band=(80.0, 3000.0), method='intensity', sub_s=0.01)
Per-frame azimuth of a FOA clip, on the label grid.
Streams the clip in blocks, band-passes all channels to band (the
corpus DOA band, capped below Nyquist), and returns (az_deg, energy,
diffuseness) with one value per complete frame_s frame; a trailing
partial frame is dropped, as it has no label. Azimuth is in degrees,
counter-clockwise positive, 0 = front, which is the STARSS convention.
Two estimators, neither with a parameter fitted to any corpus:
method="intensity"
The pseudo-intensity azimuth over the whole frame,
atan2(sum W*Y, sum W*X). Every sample counts equally, so a frame
that is mostly reverberant tail is dominated by the tail.
method="energy"
The same azimuth computed on sub_s sub-frames and combined as a
circular mean weighted by each sub-frame's energy. The direct sound
of an event carries more energy than the reverberation after it, so
this asks where the loudest part of the frame came from rather than
where the frame came from on average. It is the natural second
estimator and it is not obviously better: weighting by energy also
weights toward whichever source is loudest when two overlap.
diffuseness is 1 - |I| / E per frame, with I the
pseudo-intensity vector and E the total energy in the standard
convention. It runs from 0 for a single plane wave to 1 for an isotropic
field, and is returned so a caller can withhold an estimate where the
field carries no usable direction --- a choice this function deliberately
does not make on the caller's behalf.
Source code in src/ambiscape/starss.py
104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 | |
wrap_deg(d)
Wrap angle difference(s) to (-180, 180] degrees.
Source code in src/ambiscape/starss.py
198 199 200 | |
validate_clip(wav_path, csv_path, frame_s=FRAME_S, wyzx=(0, 1, 2, 3), method='intensity')
Compare one clip's energy azimuth with its labels, frame by frame.
Only single-source frames are scored (see the module docstring).
Returns records (one dict per scored frame: frame, class_name,
label azimuth, estimated azimuth, signed circular error_deg),
n_frames_labelled (distinct labelled frames), and n_multi
(frames excluded as multi-source).
Source code in src/ambiscape/starss.py
203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 | |
error_stats(records)
Circular error statistics over scored frames.
Median and IQR of the absolute circular error, circular bias (mean of the signed error) with circular SD, the fraction of frames within 20 degrees, and a per-class breakdown (n, median, IQR).
Source code in src/ambiscape/starss.py
236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 | |
validate_collection(folder, ann_dir, frame_s=FRAME_S)
Validate every clip in folder against CSVs in ann_dir.
Clips are opened with :func:ambiscape.io.open_clips (synthetic clock;
STARSS clips carry no BWF timestamps) and paired with annotations by
file stem (fold4_room23_mix001.wav ↔ fold4_room23_mix001.csv).
Clips without a matching CSV are skipped with a warning. Returns overall
statistics, per-clip statistics, and the pooled per-frame records.
Source code in src/ambiscape/starss.py
270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 | |
run_validation(folder, ann_dir, out_dir=None, frame_s=FRAME_S)
CLI driver: validate, write doavalidate.json + doavalidate.png.
The JSON keeps the statistics but not the pooled per-frame records (which can run to hundreds of thousands of rows on a full fold).
Source code in src/ambiscape/starss.py
352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 | |
Machine listening
Machine-listening helpers (optional [ml] extra).
- PANNs (CNN14, AudioSet, 527 classes) tags 10-s windows around detected
events and steady states; used by
ambiscape draftto suggest object names inannotations.draft.json. - silero-vad estimates the fraction of speech in a file or span — the privacy gate to run on every excerpt before publishing (Freesound etc.).
- BirdNET (
birdnetlib) identifies bird species in 3-s windows — the species layer for biophony, best run on the hi-fi windows the drone-free soundscape exposes (see :mod:ambiscape.biophonyfor the no-ML structural measures it confirms).
All models are trained on 16/32 kHz mono internet audio: the W (omni) channel is downmixed and resampled, spatial information is not used, and low-SNR domestic material is out of distribution — treat tags as suggestions to confirm by ear, not ground truth.
panns_available()
Whether panns_inference can be imported, so tagging can be skipped rather than fail.
The model packages are optional dependencies: the analysis runs without them and simply omits the tags.
Source code in src/ambiscape/ml.py
36 37 38 39 40 41 42 43 44 45 46 | |
tag_window(x, fs, top_k=3, min_prob=0.1)
AudioSet tags for one mono window via PANNs CNN14 (32 kHz input).
Source code in src/ambiscape/ml.py
49 50 51 52 53 54 55 56 57 58 59 60 61 62 | |
tag_probabilities(x, fs, wanted=None)
Probability of each named AudioSet class for one mono window.
:func:tag_window returns the few labels that came top and cleared a
threshold, which is what naming an object wants. Asking a specific
question needs the opposite: the probability of a class you name, whether
or not it reached the top three. "How much speech and how much music is in
this window" is that kind of question, and a window can be plainly musical
while Music sits fourth behind three instrument labels.
wanted is a list of AudioSet label strings; omit it for all 527.
Unknown labels raise rather than returning silently empty, because a typo
in a class name is otherwise indistinguishable from a class that never
fires.
The caveat on the module applies with force here. These are AudioSet posteriors from a model trained on internet video, read off a domestic recording at some distance from the source; they order windows usefully and they are not calibrated probabilities of anything. Compare them against each other, not against 0.5.
Source code in src/ambiscape/ml.py
65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 | |
birdnet_available()
Whether birdnetlib can be imported. Optional, like panns_available.
Source code in src/ambiscape/ml.py
106 107 108 109 110 111 112 | |
birdnet_window(x, fs, lat=None, lon=None, week=-1, min_conf=0.25)
BirdNET species detections for one mono window (48 kHz input).
Analyzes the W channel resampled to 48 kHz. lat/lon and
week (1–48, ISO-ish) enable BirdNET's location/season species
filter — pass the session's coordinates to cut false positives.
Returns [{"species", "common_name", "confidence"}] above
min_conf. Requires the [ml] extra plus birdnetlib.
Source code in src/ambiscape/ml.py
118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 | |
birdnet_session(sess, F=None, windows=None, win_s=9.0, hifi_max_diffuse=None, lat=None, lon=None, min_conf=0.25)
Run BirdNET across a session, optionally only on hi-fi windows.
windows is an explicit list of absolute start seconds; if omitted,
the session is tiled in win_s steps. When F (cached features)
and hifi_max_diffuse are given, windows whose median diffuseness
exceeds the threshold are skipped — a cheap "is the room masked?" gate
so BirdNET runs where birds are actually legible, not under a drone.
Returns per-window detections and an aggregated species tally.
Source code in src/ambiscape/ml.py
150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 | |
speech_fraction(x, fs, normalize=True)
silero-vad speech statistics for one mono signal.
normalize scales the input to a fixed RMS first, so the result
describes speech rather than recording gain — see :func:_vad_input.
Pass normalize=False to reproduce numbers computed before 0.29.0.
Source code in src/ambiscape/ml.py
227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 | |
speech_gate(path, threshold=0.01)
Privacy gate for a WAV file (any channel count; W/ch0 is analysed).
Returns the speech statistics plus a pass/fail verdict against
threshold (default: fail if more than 1 % of the file is speech).
Source code in src/ambiscape/ml.py
251 252 253 254 255 256 257 258 259 260 261 262 | |
Deposit export
What a session publishes: non-identifying feature TSVs, and upload metadata.
Two things leave a session folder. The first is a 1 Hz feature deposit in the
StillStanding365 schema, described below. The second is an audio excerpt bound
for a public repository, and since April 2025 Freesound will not accept one
without a category from its Broad Sound Taxonomy, so
:func:freesound_sidecar writes that category and the rest of the upload
metadata beside the WAV rather than leaving it to be typed into a web form.
Writes one TSV per take with the columns used by the StillStanding365 Zenodo
deposit (audio/{day}.tsv): per-second Time, level_dbfs,
centroid_hz, low_frac (< 250 Hz), high_frac (> 2 kHz). A 1 Hz
loudness/spectral envelope is far below speech timescales and carries no
intelligible content, so these files are safe to publish where raw audio is
not.
Method notes vs. the original extract_audio.py: levels here come from the
W (omni) channel at native rate (the original used an ffmpeg 4-channel
downmix at 8 kHz — offsets of a few tenths of a dB are expected), and band
fractions are power fractions from the cached log-spectrogram (the original
used magnitude fractions of an 8 kHz FFT). Trends and dynamics are directly
comparable; absolute fraction values differ slightly by construction.
validate_bst_category(code, soundscape_only=False)
Return code if it is a Broad Sound Taxonomy subcategory, else raise.
Set soundscape_only to reject the music, speech, instrument-sample and
sound-effect branches: a ten-minute recording of a room is a soundscape,
and a category from another branch in that position is a mistake worth
catching before the upload rather than after it.
Source code in src/ambiscape/deposit.py
82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 | |
freesound_sidecar(wav_path, bst_category, licence='CC BY 4.0', tags=None, description=None, speech_fraction=None, soundscape_only=True, extra=None)
Write <wav>.freesound.json, the upload metadata for one excerpt.
Freesound has required a Broad Sound Taxonomy category on every upload since April 2025, and it is also a search facet, so the category decides whether anyone finds the file. Recording it in a sidecar rather than choosing it in the upload form keeps the choice reproducible and reviewable across a pack of excerpts.
speech_fraction is the result of the privacy gate (ambiscape
speechgate, silero-vad). It is stored rather than enforced here, because
what counts as an acceptable fraction is a judgement about the recording
and not a property of the format; a value above 0.01 is written with a
privacy_review flag so a pack cannot be uploaded without someone
looking at it.
Source code in src/ambiscape/deposit.py
103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 | |
export_take_tsv(npz_path, out_dir)
One take's per-second features as a plain TSV: level, centroid, low and high share.
The deposit format, written so that a reader who has neither this package nor numpy can
use the analysis: four columns, one row a second, no compression and no pickling. The
.npz beside it keeps the full resolution.
Source code in src/ambiscape/deposit.py
153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 | |
export_session(folder)
Run export_take_tsv over every feature file in a session, into deposit/.
Source code in src/ambiscape/deposit.py
179 180 181 182 183 184 185 186 | |