Skip to content

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
@dataclass
class Take:
    """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.
    """

    path: Path
    start: float          # seconds since session day 0 midnight
    duration: float
    frames: int
    samplerate: int
    channels: int
    date: str
    clock: str
    order: str = "ambix"  # 'ambix' (W,Y,Z,X) or 'fuma' (W,X,Y,Z)
    mode: str = "ambix"   # 'ambix' | 'stereo' | 'mono' (from channel count)
    audio_path: Path | None = None  # readable WAV (== path unless transcoded)

    def __post_init__(self):
        if self.audio_path is None:
            self.audio_path = self.path

    @property
    def end(self) -> float:
        return self.start + self.duration

    @property
    def wyzx(self) -> tuple[int, int, int, int]:
        """Column indices of (W, Y, Z, X) for this take's convention."""
        return (0, 2, 3, 1) if self.order == "fuma" else (0, 1, 2, 3)

    def mono_ref(self, 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."""
        if self.mode in ("stereo", "binaural"):
            return 0.5 * (data[:, 0] + data[:, 1])
        if self.mode == "mono":
            return data[:, 0]
        return data[:, self.wyzx[0]]

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
def mono_ref(self, 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."""
    if self.mode in ("stereo", "binaural"):
        return 0.5 * (data[:, 0] + data[:, 1])
    if self.mode == "mono":
        return data[:, 0]
    return data[:, self.wyzx[0]]

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
@dataclass
class Session:
    """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.
    """

    folder: Path
    takes: list[Take] = field(default_factory=list)
    day0: _dt.date | None = None

    @property
    def duration(self) -> float:
        return sum(t.duration for t in self.takes)

    @property
    def name(self) -> str:
        return getattr(self, "_name", None) or self.folder.name

    def clock(self, t: float) -> str:
        """Absolute seconds -> 'DD Mon HH:MM:SS' string."""
        base = _dt.datetime.combine(self.day0, _dt.time())
        return (base + _dt.timedelta(seconds=t)).strftime("%d %b %H:%M:%S")

clock(t)

Absolute seconds -> 'DD Mon HH:MM:SS' string.

Source code in src/ambiscape/io.py
193
194
195
196
def clock(self, t: float) -> str:
    """Absolute seconds -> 'DD Mon HH:MM:SS' string."""
    base = _dt.datetime.combine(self.day0, _dt.time())
    return (base + _dt.timedelta(seconds=t)).strftime("%d %b %H:%M:%S")

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
def channel_mode(channels: int) -> str:
    """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).
    """
    if channels >= 4:
        return "ambix"
    if channels == 1:
        return "mono"
    return "stereo"

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
def read_bext(path: str | Path) -> dict:
    """Parse the BWF 'bext' chunk (pure python RIFF walk)."""
    out = {}
    with open(path, "rb") as f:
        riff, _size, wave = struct.unpack("<4sI4s", f.read(12))
        if riff != b"RIFF" or wave != b"WAVE":
            raise ValueError(f"{path}: not a RIFF/WAVE file")
        while True:
            hdr = f.read(8)
            if len(hdr) < 8:
                break
            cid, csize = struct.unpack("<4sI", hdr)
            if cid == b"bext":
                data = f.read(min(csize, 604))
                out["description"] = data[0:256].split(b"\0")[0].decode("ascii", "replace")
                out["originator"] = data[256:288].split(b"\0")[0].decode("ascii", "replace")
                out["date"] = data[320:330].decode("ascii", "replace").strip("\0 ")
                out["time"] = data[330:338].decode("ascii", "replace").strip("\0 ")
                out["time_reference"] = struct.unpack("<Q", data[338:346])[0]
                break
            f.seek(csize + (csize & 1), 1)
    return out

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
def channel_order(bext_description: str) -> str:
    """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.
    """
    trk = {}
    for line in bext_description.replace("\r", "\n").split("\n"):
        if line.startswith("zTRK") and "=" in line:
            k, v = line.split("=", 1)
            trk[int(k[4:])] = v.strip().upper()
    seq = [trk.get(i) for i in (1, 2, 3, 4)]
    if seq == ["W", "X", "Y", "Z"]:
        return "fuma"
    return "ambix"

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
def resolve_mode(channels: int, override: str | None) -> str:
    """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.
    """
    auto = channel_mode(channels)
    if not override:
        return auto
    ok = {"mono": channels == 1, "stereo": channels == 2,
          "binaural": channels == 2, "ambix": channels >= 4}
    return override if ok.get(override, False) else auto

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
def open_session(folder: str | Path) -> Session:
    """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.
    """
    folder = Path(folder)
    paths = sorted(p for p in folder.iterdir()
                   if p.suffix.lower() in _AUDIO_SUFFIXES and p.is_file())
    if not paths:
        raise FileNotFoundError(f"no audio files in {folder}")
    clock_offset = 0.0
    clock_offsets = {}
    mode_override = None
    cal = folder / "calibration.json"
    if cal.exists():
        import json
        c = json.loads(cal.read_text())
        clock_offset = float(c.get("clock_offset_s", 0.0))
        clock_offsets = {str(k): float(v)
                         for k, v in c.get("clock_offsets_s", {}).items()}
        mode_override = c.get("mode")            # e.g. "binaural" for ear signals
    unmatched = set(clock_offsets) - {p.name for p in paths}
    if unmatched:
        warnings.warn(f"calibration.json clock_offsets_s names no session "
                      f"file: {sorted(unmatched)}", stacklevel=2)

    sess = Session(folder=folder)
    metas = [(p, _probe_recording(p)) for p in paths]
    sess.day0 = min(_dt.date.fromisoformat(m["date"]) for _, m in metas)
    for p, _ in metas:
        sess.takes.append(_make_take(
            p, sess.day0,
            clock_offset + clock_offsets.get(p.name, 0.0),
            mode_override))
    sess.takes.sort(key=lambda t: t.start)
    return sess

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
def open_clips(folder: str | Path) -> Session:
    """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).
    """
    folder = Path(folder)
    paths = sorted(p for p in folder.iterdir()
                   if p.suffix.lower() in _AUDIO_SUFFIXES and p.is_file())
    if not paths:
        raise FileNotFoundError(f"no audio files in {folder}")
    sess = Session(folder=folder, day0=_dt.date(1970, 1, 1))
    cursor = 0.0
    skipped = []
    for p in paths:
        try:
            m = _probe_recording(p)
        except Exception as e:                                   # noqa: BLE001
            # a corpus folder is a batch, and one unreadable member should
            # not cost the other 364. Named, not swallowed.
            skipped.append((p.name, e))
            continue
        info = m["info"]
        c = int(cursor)
        sess.takes.append(Take(
            path=p, audio_path=m["audio_path"], start=cursor,
            duration=info.frames / info.samplerate, frames=info.frames,
            samplerate=info.samplerate, channels=info.channels,
            date=sess.day0.isoformat(),
            clock=f"{c // 3600:02d}:{c % 3600 // 60:02d}:{c % 60:02d}",
            order=m["order"], mode=channel_mode(info.channels)))
        cursor = sess.takes[-1].end
    if skipped:
        names = ", ".join(n for n, _ in skipped[:5])
        more = f" and {len(skipped) - 5} more" if len(skipped) > 5 else ""
        print(f"open_clips: skipped {len(skipped)} unreadable file(s): "
              f"{names}{more}")
    if not sess.takes:
        raise FileNotFoundError(
            f"no readable audio in {folder} "
            f"({len(skipped)} file(s) could not be opened)")
    return sess

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
def open_recording(path: str | Path, mode: str | None = None) -> Session:
    """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.
    """
    path = Path(path)
    if not path.is_file():
        raise FileNotFoundError(path)
    day0 = _dt.date.fromisoformat(_probe_recording(path)["date"])
    sess = Session(folder=path.parent, day0=day0)
    sess.takes.append(_make_take(path, day0, mode_override=mode))
    sess._name = path.stem
    return sess

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
def read_span(sess: Session, t0: float, dur: float, dtype="float32"):
    """Read [t0, t0+dur) seconds (session time) from whichever take covers it."""
    for tk in sess.takes:
        if tk.start <= t0 < tk.end:
            fs = tk.samplerate
            off = int((t0 - tk.start) * fs)
            n = min(int(dur * fs), tk.frames - off)
            with sf.SoundFile(str(tk.audio_path)) as f:
                f.seek(off)
                return f.read(n, dtype=dtype, always_2d=True), fs
    raise ValueError(f"t={t0} not covered by session {sess.name}")

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
def export_segment(sess: Session, t0: float, dur: float,
                   out_path: str | Path, stamp: bool = True) -> Path:
    """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.
    """
    out_path = Path(out_path)
    if stamp and sess.day0 is not None:
        when = (_dt.datetime.combine(sess.day0, _dt.time())
                + _dt.timedelta(seconds=t0))
        out_path = out_path.with_name(
            f"{when:%Y%m%d_%H%M%S} {out_path.name}")
    out_path.parent.mkdir(parents=True, exist_ok=True)
    for tk in sess.takes:
        if tk.start <= t0 < tk.end:
            fs = tk.samplerate
            off = int((t0 - tk.start) * fs)
            n = min(int(dur * fs), tk.frames - off)
            with sf.SoundFile(str(tk.audio_path)) as f:
                subtype = f.subtype
                dtype = "int16" if subtype == "PCM_16" else "int32"
                f.seek(off)
                data = f.read(n, dtype=dtype, always_2d=True)
            sf.write(str(out_path), data, fs, subtype=subtype)
            return out_path
    raise ValueError(f"t={t0} not covered by session {sess.name}")

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
def stereo_preview(x, wyzx=(0, 1, 2, 3), az_deg: float = 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``.
    """
    import numpy as np
    if mode == "mono" or x.shape[1] == 1:
        return np.repeat(x[:, :1], 2, axis=1)
    if mode == "stereo" or x.shape[1] < 4:
        return x[:, :2]
    W, Y = x[:, wyzx[0]], x[:, wyzx[1]]
    g = float(np.sin(np.radians(az_deg)))
    return np.stack([0.5 * (W + g * Y), 0.5 * (W - g * Y)], axis=1)

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
def a_weighting_sos(fs: int):
    """IEC 61672 A-weighting as SOS (bilinear transform of the analog filter)."""
    f1, f2, f3, f4 = 20.598997, 107.65265, 737.86223, 12194.217
    a1000 = 1.9997
    nums = [(2 * np.pi * f4) ** 2 * 10 ** (a1000 / 20), 0, 0, 0, 0]
    dens = np.polymul(
        np.polymul([1, 4 * np.pi * f4, (2 * np.pi * f4) ** 2],
                   [1, 4 * np.pi * f1, (2 * np.pi * f1) ** 2]),
        np.polymul([1, 2 * np.pi * f3], [1, 2 * np.pi * f2]),
    )
    b, a = signal.bilinear(nums, dens, fs)
    return signal.tf2sos(b, a)

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
def extract_take(take: Take, verbose: bool = False) -> dict:
    """Run the streaming extractor over one file; returns feature arrays."""
    fs = take.samplerate
    hop = int(HOP * fs / 48000)
    nfft = NFFT
    win = np.hanning(nfft).astype(np.float32)
    wsum2 = float((win ** 2).sum())
    freqs = np.fft.rfftfreq(nfft, 1 / fs)
    oct_idx = [np.where((freqs >= c / np.sqrt(2)) & (freqs < c * np.sqrt(2)))[0]
               for c in OCT_CENTERS]
    logf = np.geomspace(*LOGF_RANGE, N_LOGBANDS + 1)
    log_idx = np.clip(np.searchsorted(logf, freqs) - 1, -1, N_LOGBANDS - 1)
    doa_mask = (freqs >= DOA_BAND[0]) & (freqs <= DOA_BAND[1])
    spec_mask = (freqs >= 50) & (freqs <= 16000)
    a_sos = a_weighting_sos(fs)
    a_state = np.zeros((a_sos.shape[0], 2), dtype=np.float64)

    nsec = int(take.frames // fs)
    nfast = int(take.frames // int(FAST * fs))
    ffs = int(FAST * fs)
    hfs = int(HI_ENV * fs)
    nhi = int(take.frames // hfs)
    F = {
        "fast_db": np.zeros(nfast, np.float32),
        "fast_dba": np.zeros(nfast, np.float32),
        "env_hi": np.zeros(nhi, np.float32),
        "rms_w": np.zeros(nsec, np.float32),
        "peak": np.zeros(nsec, np.float32),
        "oct_pow": np.zeros((nsec, len(OCT_CENTERS)), np.float32),
        "centroid": np.zeros(nsec, np.float32),
        "flatness": np.zeros(nsec, np.float32),
        "logspec": np.zeros((nsec, N_LOGBANDS), np.float32),
        "I_band": np.zeros((nsec, len(OCT_CENTERS), 3), np.float32),
        "az": np.zeros(nsec, np.float32),
        "el": np.zeros(nsec, np.float32),
        "diffuse": np.zeros(nsec, np.float32),
    }
    mode = getattr(take, "mode", "ambix")
    if mode != "ambix":                 # direction is partial (2ch) or absent
        F["el"][:] = np.nan
        if mode == "mono":               # single channel: no direction at all
            F["az"][:] = np.nan
            F["diffuse"][:] = np.nan
            F["I_band"][:] = np.nan
        else:                            # stereo: balance az; binaural: ITD az
            F["I_band"][:] = np.nan
    nmin = -(-nsec // 60) if nsec else 0
    minspec = np.zeros((nmin, len(freqs)), np.float64)
    mincnt = np.zeros(nmin, np.int64)
    eps = 1e-20

    nch = take.channels
    carry = np.zeros((0, nch), np.float32)
    sec_base = 0
    fast_base = 0
    hi_base = 0
    with sf.SoundFile(str(take.audio_path)) as f:
        while True:
            block = f.read(60 * fs, dtype="float32", always_2d=True)
            if block.shape[0] == 0:
                break
            data = np.concatenate([carry, block]) if carry.shape[0] else block
            navail = data.shape[0]
            # mono reference: W (ambix), L/R mean (stereo), the channel (mono)
            if mode == "ambix":
                ref = data[:, take.wyzx[0]]
            elif mode in ("stereo", "binaural"):
                ref = 0.5 * (data[:, 0] + data[:, 1])
            else:
                ref = data[:, 0]
            nsec_blk = min(navail // fs, nsec - sec_base)
            nwin = (navail - nfft) // hop + 1 if navail >= nfft else 0
            if nsec_blk <= 0:
                break

            # fast levels on the mono reference (contiguous 125 ms frames)
            nfast_blk = min((nsec_blk * fs) // ffs, nfast - fast_base)
            wseg = ref[: nfast_blk * ffs].reshape(nfast_blk, ffs)
            F["fast_db"][fast_base:fast_base + nfast_blk] = 10 * np.log10(
                (wseg.astype(np.float64) ** 2).mean(1) + eps)
            wa, a_state = signal.sosfilt(a_sos, ref[: nfast_blk * ffs]
                                         .astype(np.float64), zi=a_state)
            F["fast_dba"][fast_base:fast_base + nfast_blk] = 10 * np.log10(
                (wa.reshape(nfast_blk, ffs) ** 2).mean(1) + eps)
            fast_base += nfast_blk

            # 20 ms broadband envelope (linear power, for modulation)
            nhi_blk = min((nsec_blk * fs) // hfs, nhi - hi_base)
            hseg = ref[: nhi_blk * hfs].reshape(nhi_blk, hfs)
            F["env_hi"][hi_base:hi_base + nhi_blk] = \
                (hseg.astype(np.float64) ** 2).mean(1)
            hi_base += nhi_blk

            if nwin > 0:
                idx = np.arange(nfft)[None, :] + hop * np.arange(nwin)[:, None]
                Wf = np.fft.rfft(ref[idx] * win)
                Pw = (Wf.real ** 2 + Wf.imag ** 2) / wsum2
                centers = (idx[:, 0] + nfft // 2) / fs
                if mode == "ambix":
                    iW, iY, iZ, iX = take.wyzx
                    Yf = np.fft.rfft(data[:, iY][idx] * win)
                    Zf = np.fft.rfft(data[:, iZ][idx] * win)
                    Xf = np.fft.rfft(data[:, iX][idx] * win)
                    IX = (Wf.conj() * Xf).real / wsum2
                    IY = (Wf.conj() * Yf).real / wsum2
                    IZ = (Wf.conj() * Zf).real / wsum2
                    Ev = (Xf.real ** 2 + Xf.imag ** 2 + Yf.real ** 2
                          + Yf.imag ** 2 + Zf.real ** 2 + Zf.imag ** 2) / wsum2
                elif mode in ("stereo", "binaural"):
                    Lf = np.fft.rfft(data[:, 0][idx] * win)
                    Rf = np.fft.rfft(data[:, 1][idx] * win)
                    PL = (Lf.real ** 2 + Lf.imag ** 2) / wsum2
                    PR = (Rf.real ** 2 + Rf.imag ** 2) / wsum2
                    CLR = (Lf.conj() * Rf) / wsum2      # complex cross-spectrum

            for s in range(nsec_blk):
                g = sec_base + s
                seg = data[s * fs:(s + 1) * fs]
                F["rms_w"][g] = np.sqrt((ref[s * fs:(s + 1) * fs]
                                         .astype(np.float64) ** 2).mean())
                F["peak"][g] = float(np.abs(seg).max())
                if nwin == 0:
                    continue
                sel = np.where((centers >= s) & (centers < s + 1))[0]
                if len(sel) == 0:
                    continue
                pw = Pw[sel].mean(0)
                for b, bi in enumerate(oct_idx):
                    F["oct_pow"][g, b] = pw[bi].sum()
                p = pw[spec_mask]
                F["centroid"][g] = float((freqs[spec_mask] * p).sum() / (p.sum() + eps))
                F["flatness"][g] = float(np.exp(np.log(p + eps).mean()) / (p.mean() + eps))
                np.add.at(F["logspec"][g], log_idx[log_idx >= 0], pw[log_idx >= 0])
                if mode == "ambix":
                    ix, iy, iz = (IX[sel].mean(0), IY[sel].mean(0),
                                  IZ[sel].mean(0))
                    ev = Ev[sel].mean(0)
                    for b, bi in enumerate(oct_idx):
                        F["I_band"][g, b] = (ix[bi].sum(), iy[bi].sum(),
                                             iz[bi].sum())
                    Ix, Iy, Iz = (ix[doa_mask].sum(), iy[doa_mask].sum(),
                                  iz[doa_mask].sum())
                    F["az"][g] = np.degrees(np.arctan2(Iy, Ix))
                    F["el"][g] = np.degrees(np.arctan2(Iz, np.hypot(Ix, Iy)))
                    etot = (pw[doa_mask].sum() + ev[doa_mask].sum()) / 2
                    inorm = float(np.sqrt(Ix ** 2 + Iy ** 2 + Iz ** 2))
                    F["diffuse"][g] = 1.0 - min(1.0, inorm / (etot + eps))
                elif mode == "stereo":
                    pl, pr = PL[sel].mean(0), PR[sel].mean(0)
                    clr = CLR[sel].mean(0)
                    sL, sR = float(pl[doa_mask].sum()), float(pr[doa_mask].sum())
                    # lateral balance -> +-90 deg (+ = left), coherence -> width
                    F["az"][g] = 90.0 * (sL - sR) / (sL + sR + eps)
                    coh = abs(clr[doa_mask].sum()) / (np.sqrt(sL * sR) + eps)
                    F["diffuse"][g] = 1.0 - min(1.0, float(coh))
                    for b, bi in enumerate(oct_idx):
                        F["I_band"][g, b] = (0.0, float(pl[bi].sum()
                                                        - pr[bi].sum()), 0.0)
                elif mode == "binaural":
                    # HRTF ear signals: level balance is head colouring, not
                    # direction, so azimuth comes from the interaural time
                    # difference instead (GCC-PHAT over the DOA band), and
                    # diffuseness from interaural coherence with the delay
                    # compensated -- magnitude coherence is invariant to
                    # per-channel linear filtering, so it survives the HRTF.
                    pl, pr = PL[sel].mean(0), PR[sel].mean(0)
                    clr = CLR[sel].mean(0)
                    sL, sR = float(pl[doa_mask].sum()), float(pr[doa_mask].sum())
                    phat = np.zeros_like(clr)
                    phat[doa_mask] = clr[doa_mask] / (np.abs(clr[doa_mask]) + eps)
                    r = np.fft.irfft(phat, nfft)
                    max_lag = max(1, int(round(ITD_MAX_S * fs)))
                    cand = np.concatenate([r[:max_lag + 1], r[-max_lag:]])
                    lag = int(np.argmax(cand))
                    lag = lag if lag <= max_lag else lag - (2 * max_lag + 1)
                    tau = lag / fs                     # + = right lags = left
                    F["az"][g] = float(np.degrees(np.arcsin(
                        np.clip(tau / ITD_MAX_S, -1.0, 1.0))))
                    comp = clr * np.exp(2j * np.pi * freqs * tau)
                    coh = abs(comp[doa_mask].sum()) / (np.sqrt(sL * sR) + eps)
                    F["diffuse"][g] = 1.0 - min(1.0, float(coh))
                minspec[g // 60] += pw
                mincnt[g // 60] += 1

            carry = data[nsec_blk * fs:].copy()
            sec_base += nsec_blk
            if sec_base >= nsec:
                break

    # Drop never-filled trailing frames. nfast/nhi count frames in the
    # fractional last second, but the block loop only fills whole-second
    # spans, so the preallocated zeros would survive as exactly 0 dBFS
    # (full scale) — a false full-scale click at the end of every take.
    F["fast_db"] = F["fast_db"][:fast_base]
    F["fast_dba"] = F["fast_dba"][:fast_base]
    F["env_hi"] = F["env_hi"][:hi_base]
    minspec[mincnt > 0] /= mincnt[mincnt > 0, None]
    F["minspec"] = minspec.astype(np.float32)
    F["freqs"] = freqs.astype(np.float32)
    F["logf"] = logf.astype(np.float32)
    F["start"] = np.float64(take.start)
    F["fs"] = np.int64(fs)
    F["fast_dt"] = np.float64(FAST)
    F["hi_dt"] = np.float64(HI_ENV)
    F["mode"] = np.str_(getattr(take, "mode", "ambix"))
    F["channels"] = np.int64(take.channels)
    return F

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
def extract_session(sess: Session, out_dir: str | Path, verbose=True) -> list[Path]:
    """Extract features for every take; save one npz per take. Returns paths."""
    out_dir = Path(out_dir)
    out_dir.mkdir(parents=True, exist_ok=True)
    paths = []
    for tk in sess.takes:
        out = out_dir / (tk.path.stem + ".npz")
        if not out.exists():
            F = extract_take(tk)
            np.savez_compressed(out, **F)
            if verbose:
                print(f"  extracted {tk.path.name} ({tk.duration:.0f}s)", flush=True)
        paths.append(out)
    return paths

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
def load_features(npz_paths: list[str | Path]) -> dict:
    """Concatenate per-take feature files onto one absolute time axis."""
    pairs = sorted(((np.load(str(p)), Path(p).stem) for p in npz_paths),
                   key=lambda pn: float(pn[0]["start"]))
    parts = [p for p, _ in pairs]
    out = {}
    out["t"] = np.concatenate([p["start"] + np.arange(len(p["rms_w"]))
                               for p in parts])
    # per-row take identity: overlapping takes (Zoom + phone on one clock)
    # interleave in time, so consumers must not slice rows by time range
    out["take_names"] = [name for _, name in pairs]
    out["take_of_row"] = np.concatenate(
        [np.full(len(p["rms_w"]), i, np.int32)
         for i, p in enumerate(parts)])
    fd = float(parts[0]["fast_dt"])
    # Caches written by 0.24.1 and earlier carry unfilled frames past the last whole
    # second of each take — preallocated zeros, i.e. exactly 0 dBFS (full
    # scale): a false click at every take boundary. Cap each take's fast
    # streams at the whole-second span actually filled by the extractor.
    kf = [min(len(p["fast_db"]), len(p["rms_w"]) * int(round(1 / fd)))
          for p in parts]
    out["t_fast"] = np.concatenate([p["start"] + fd * np.arange(k)
                                    for p, k in zip(parts, kf)])
    for key in ("fast_db", "fast_dba"):
        out[key] = np.concatenate([p[key][:k] for p, k in zip(parts, kf)])
    for k in ("rms_w", "peak", "oct_pow", "centroid",
              "flatness", "logspec", "I_band", "az", "el", "diffuse"):
        out[k] = np.concatenate([p[k] for p in parts])
    if all("env_hi" in p for p in parts):    # absent in pre-0.2 caches
        hd = float(parts[0]["hi_dt"])
        out["hi_dt"] = hd
        kh = [min(len(p["env_hi"]), len(p["rms_w"]) * int(round(1 / hd)))
              for p in parts]
        out["t_hi"] = np.concatenate(
            [p["start"] + hd * np.arange(k) for p, k in zip(parts, kh)])
        out["env_hi"] = np.concatenate([p["env_hi"][:k]
                                        for p, k in zip(parts, kh)])
    out["min_t"] = np.concatenate([p["start"] + 60 * np.arange(p["minspec"].shape[0])
                                   for p in parts])
    out["minspec"] = np.concatenate([p["minspec"] for p in parts])
    out["freqs"] = parts[0]["freqs"]
    out["logf"] = parts[0]["logf"]
    if "mode" in parts[0]:                   # absent in pre-0.13 caches
        out["mode"] = str(parts[0]["mode"])
        out["channels"] = int(parts[0]["channels"])
    return out

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
def 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.
    """
    return 10 * np.log10(np.maximum(x, eps))

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
def running_background(fast_db: np.ndarray, fast_dt: float, 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.
    """
    n = max(3, int(round(win_s / fast_dt)) | 1)
    return percentile_filter(fast_db, pct, size=n, mode="nearest")

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
def 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.
    """
    bg = running_background(fast_db, fast_dt)
    above = fast_db > bg + thresh_db
    events = []
    i, n = 0, len(above)
    min_len = max(1, int(round(min_dur / fast_dt)))
    while i < n:
        if above[i]:
            j = i
            while j + 1 < n and above[j + 1]:
                j += 1
            if j - i + 1 >= min_len:
                k = i + int(np.argmax(fast_db[i:j + 1]))
                events.append(dict(i0=i, i1=j, ipk=k,
                                   exceed=float(fast_db[k] - bg[k])))
            i = j + 1
        else:
            i += 1
    return events, bg

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
def 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.
    """
    x = np.asarray(fast_db, float)
    n = len(x)
    nb = max(1, int(round(min_before / fast_dt)))
    na = max(1, int(round(min_after / fast_dt)))
    if n < nb + na + 1:
        return []
    out, i = [], nb
    while i < n - na:
        before = x[i - nb:i]
        after = x[i:i + na]
        if not (np.isfinite(before).all() and np.isfinite(after).all()):
            i += 1
            continue
        lo, hi = float(np.median(before)), float(np.median(after))
        # steady before (a running machine holds a level), and clearly down
        if lo - hi >= drop_db and float(np.std(before)) <= drop_db / 2:
            out.append(dict(i=i, t_s=float(i * fast_dt),
                            drop_db=round(lo - hi, 1),
                            before_db=round(lo, 1), after_db=round(hi, 1)))
            i += nb            # one cessation per steady stretch
        else:
            i += 1
    return out

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
def trimmed_leq(level_db: np.ndarray, trim_pct: float = 5.0) -> float:
    """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".
    """
    x = np.asarray(level_db, np.float64)
    if x.size == 0:
        return float("nan")
    keep = x <= np.percentile(x, 100.0 - trim_pct)
    if not keep.any():                       # every frame at one level
        keep = np.ones_like(x, bool)
    return float(db(np.mean(10 ** (x[keep] / 10))))

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
def intermittency_ratio(level_db: np.ndarray, dt: float,
                        k_db: float = 3.0) -> float:
    """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).
    """
    p = 10 ** (np.asarray(level_db, np.float64) / 10)
    leq = db(p.mean())
    mask = level_db >= leq + k_db
    return float(100.0 * p[mask].sum() / (p.sum() + EPS))

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
def decay_metrics(x: np.ndarray, fs: int, bands=((250, 500), (500, 1000),
                  (1000, 2000), (2000, 4000), (4000, 8000)),
                  pre_roll: bool = True) -> dict:
    """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).
    """
    from scipy import signal as sg
    pk_i = int(np.abs(x).argmax())
    env_bb = sg.convolve(x ** 2, np.ones(480) / 480, "same")
    tail = 10 * np.log10(env_bb[pk_i:pk_i + 3 * fs] + 1e-15)
    run_min = np.minimum.accumulate(tail)
    re = np.flatnonzero((tail - run_min > 8) & (np.arange(len(tail)) > fs // 10))
    cut = int(re[0]) if len(re) else 2 * fs
    out = {}
    for lo, hi in bands:
        sos = sg.butter(4, [lo, hi], "bandpass", fs=fs, output="sos")
        y = sg.sosfilt(sos, x)
        env = sg.convolve(y ** 2, np.ones(240) / 240, "same")
        pk = int(env[max(0, pk_i - 2400):pk_i + 2400].argmax()) \
            + max(0, pk_i - 2400)
        if pk < fs // 4:
            continue
        if pre_roll:
            noise = float(np.median(env[:pk - fs // 8]))
        else:
            # Nothing of the room was recorded before the peak, so the only
            # floor available is the quietest part of what the IR contains.
            after = env[pk:]
            noise = float(np.percentile(after, 10)) if len(after) else 0.0
        dr = 10 * np.log10(env[pk] / (noise + EPS))
        if dr < 20:
            continue
        # Stop integrating where the decay meets the noise. Backward
        # integration sums everything after a point, so an integral that runs
        # to the end of the file folds the whole tail's noise into every
        # earlier value and flattens the curve. Subtracting the noise first is
        # not enough: `maximum(..., 0)` rectifies the residual, so what is
        # left is positive-biased and still accumulates. Measured on a
        # synthetic 0.6 s decay with a 45 dB floor, integrating two seconds
        # gave T60 = 4.67 s; stopping at the knee gives 0.62. ISO 3382 asks
        # for this truncation (Lundeby); the fit-range guard below is a
        # different thing and cannot repair a curve that is already wrong.
        be = env[pk:pk + cut]
        below = np.flatnonzero(be <= noise * 10 ** (KNEE_MARGIN_DB / 10))
        knee = int(below[0]) if len(below) else len(be)
        knee = max(knee, int(0.05 * fs))          # never fit on a stub
        seg = np.maximum(y[pk:pk + knee] ** 2 - noise, 0)
        sch = np.cumsum(seg[::-1])[::-1]
        sch_db = 10 * np.log10(sch / (sch[0] + EPS) + 1e-15)
        tax = np.arange(len(sch_db)) / fs
        # An impulse response that has been trimmed (archive material, or
        # any IR cut before its decay finished) ends while still well above
        # the fixed fits' lower limit: the dynamic-range guard cannot fire,
        # because the truncated file has no noise floor to measure. Level
        # of the last 20 ms re the peak says how far the decay was actually
        # observed; below that, T20/T30 would extrapolate off the end.
        tail = env[pk:pk + knee][-max(1, int(0.02 * fs)):]
        obs_db = 10 * np.log10(float(tail.mean()) / (env[pk] + EPS) + EPS)
        res = {"dr_db": round(float(dr), 0)}
        for key, hi_db, lo_db, need_dr in (
                ("T60", -5.0, max(-35.0, -dr + 8), 0.0),
                ("T20", -5.0, -25.0, 35.0),
                ("T30", -5.0, -35.0, 45.0),
                ("EDT", 0.0, -10.0, 0.0)):
            if dr < need_dr:
                continue
            if key == "T60":
                # T60's lower limit is adaptive, so as the dynamic range
                # approaches the 20 dB guard above the fitted range collapses:
                # at dr = 20 it is 7 dB wide and is extrapolated 8.5x to reach
                # 60. That lever turns ordinary curvature into a large error
                # in the reported time, and it under-reports -- a 0.6 s decay
                # cut to 0.20 s measured 0.34 s. T20 and T30 are immune
                # because ISO 3382 fixes their ranges at 20 and 30 dB; the
                # adaptive estimate needs the same kind of floor. Refusing is
                # right: a T60 is a claim about 60 dB of decay, and 7 dB of
                # evidence does not support one.
                res["fit_db"] = round(float(hi_db - lo_db), 0)
                if hi_db - lo_db < MIN_FIT_SPAN_DB:
                    continue
            if key in ("T20", "T30") and obs_db > lo_db:
                continue                    # range not present in the file
            m = (sch_db <= hi_db) & (sch_db >= lo_db)
            if m.sum() < 150:
                continue
            A = np.vstack([tax[m], np.ones(int(m.sum()))]).T
            slope, _ = np.linalg.lstsq(A, sch_db[m], rcond=None)[0]
            if slope < 0:
                res[key] = round(-60.0 / slope, 2)
        for key, ms in (("C50", 50), ("C80", 80)):
            i = int(ms * fs / 1000)
            if i < len(sch) and sch[i] > 0:
                res[key] = round(float(10 * np.log10(
                    (sch[0] - sch[i]) / (sch[i] + EPS) + EPS)), 1)
        i50 = int(0.05 * fs)
        if i50 < len(sch):
            res["D50"] = round(float((sch[0] - sch[i50]) / (sch[0] + EPS)), 2)
        if "T60" in res:
            out[f"{lo}-{hi}"] = res
    return out

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
def quietest_channel(x, fs: int, pct: float = 5.0, frame_s: float = 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])``.
    """
    a = np.asarray(x, float)
    if a.ndim == 1:
        a = a[:, None]
    n = max(1, int(frame_s * fs))
    floors = []
    for c in range(a.shape[1]):
        y = a[:, c] - a[:, c].mean()
        lv = np.array([10 * np.log10((y[i:i + n] ** 2).mean() + EPS)
                       for i in range(0, max(len(y) - n, 1), n)])
        floors.append(float(np.percentile(lv, pct)) if len(lv) else float("nan"))
    return int(np.nanargmin(floors)), floors

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
def track_noise_floor(level_db, dt: float, win_s: float = 120.0,
                      bias_db: float = _MIN_STAT_BIAS_DB) -> np.ndarray:
    """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.
    """
    p = 10.0 ** (np.asarray(level_db, float) / 10.0)
    n = max(1, int(round(win_s / dt)))
    if n >= len(p):
        return np.full(len(p), 10 * np.log10(p.min()) + bias_db)
    pad = np.pad(p, (n // 2, n - 1 - n // 2), mode="edge")
    win = np.lib.stride_tricks.sliding_window_view(pad, n)
    return 10 * np.log10(win.min(axis=1)[:len(p)]) + bias_db

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
def floor_corrected_level(level_db, dt: float,
                          margin_db: float = FLOOR_MARGIN_DB,
                          win_s: float = 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.
    """
    lvl = np.asarray(level_db, float)
    p = 10.0 ** (lvl / 10.0)
    floor_db = track_noise_floor(lvl, dt, win_s=win_s)
    nfloor = 10.0 ** (floor_db / 10.0)
    excess = p - nfloor
    measurable = excess > nfloor * (10 ** (margin_db / 10.0) - 1.0)
    sig = np.full(len(p), np.nan)
    sig[measurable] = 10.0 * np.log10(excess[measurable])
    return sig, floor_db, measurable

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
def summarize_floor_corrected(signal_db, measurable,
                              min_coverage: float = FLOOR_MIN_COVERAGE) -> dict:
    """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.
    """
    measurable = np.asarray(measurable, bool)
    cov = float(measurable.mean()) if measurable.size else 0.0
    if cov < min_coverage:
        return {"level_db": None, "coverage": round(cov, 4),
                "n_measurable": int(measurable.sum()),
                "reason": "below the noise floor too often to measure"}
    v = np.asarray(signal_db, float)[measurable]
    v = v[np.isfinite(v)]
    return {"level_db": round(float(10 * np.log10(np.mean(10 ** (v / 10)))), 2),
            "coverage": round(cov, 4), "n_measurable": int(v.size),
            "reason": ""}

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
def steady_sources(level_db, dt: float, short_s: float = 120.0,
                   long_s: float = 7200.0,
                   min_excess_db: float = MACHINE_MIN_EXCESS_DB) -> dict:
    """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.
    """
    lvl = np.asarray(level_db, float)
    short = track_noise_floor(lvl, dt, win_s=short_s)
    long = track_noise_floor(lvl, dt, win_s=long_s)
    excess = short - long
    running = excess > min_excess_db
    duty = float(running.mean())
    detected = bool(0.02 < duty < 0.98)
    return {
        "self_noise_db": round(float(np.median(long)), 2),
        "steady_excess_db": round(float(np.median(excess[running]))
                                  if running.any() else 0.0, 2),
        "machine_duty": round(duty, 3),
        "machine_detected": detected,
        # Not "there is one" but "one cannot be ruled out". With no cycling
        # found, a perfectly constant source is indistinguishable from the
        # recorder by level alone, and is therefore inside self_noise_db.
        "steady_source_unresolved": not detected,
    }

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
def cycle_band(period_s: float) -> str:
    """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.
    """
    from .timescales import band_of
    return band_of(period_s)

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
def cycle_spectrum(level_db, dt: float, min_period_s: float = 60.0,
                   max_period_s: float = 6 * 3600.0, n_periods: int = 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.
    """
    x = _prepare(level_db)
    if x is None:
        return np.array([]), np.array([])
    lo = max(min_period_s, 2 * dt)
    hi = min(max_period_s, len(x) * dt / 2.5)
    if hi <= lo:
        return np.array([]), np.array([])
    acf = _scaled_acf(x, dt, int(lo / dt), int(hi / dt))
    edges = np.geomspace(lo, hi, n_periods + 1)
    periods, strength = [], []
    for a, b in zip(edges[:-1], edges[1:]):
        i0, i1 = int(a / dt), max(int(a / dt) + 1, int(b / dt))
        seg = acf[i0:min(i1, len(acf))]
        if not len(seg):
            continue
        k = int(np.argmax(seg))
        periods.append((i0 + k) * dt)
        strength.append(float(seg[k]))
    return np.array(periods), np.clip(np.array(strength), 0.0, 1.0)

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
def dominant_cycles(level_db, dt: float, min_period_s: float = 60.0,
                    max_period_s: float = 6 * 3600.0, top: int = 3,
                    min_strength: float = CYCLE_MIN_STRENGTH,
                    min_prominence: float = 0.05) -> list[dict]:
    """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
    2*T* and 3*T*, 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.
    """
    x = _prepare(level_db)
    if x is None:
        return []
    lo_lag = max(1, int(max(min_period_s, 2 * dt) / dt))
    hi_lag = min(len(x) - 1, int(min(max_period_s, len(x) * dt / 2.5) / dt))
    if hi_lag <= lo_lag + 2:
        return []
    acf = _scaled_acf(x, dt, lo_lag, hi_lag)

    peaks = []
    for i in range(lo_lag + 1, hi_lag):
        if not (acf[i] >= acf[i - 1] and acf[i] > acf[i + 1]):
            continue
        if acf[i] < min_strength:
            continue
        # prominence against the troughs on either side, out to one period
        w = max(2, i // 2)
        left = acf[max(lo_lag, i - w):i].min()
        right = acf[i + 1:min(hi_lag, i + w) + 1].min()
        if acf[i] - max(left, right) < min_prominence:
            continue
        peaks.append({"period_s": round(i * dt, 1),
                      "strength": round(float(acf[i]), 3),
                      "band": cycle_band(i * dt)})

    peaks.sort(key=lambda c: c["period_s"])          # fundamentals first
    kept: list[dict] = []
    for c in peaks:
        if any(1.85 <= (r := c["period_s"] / k["period_s"]) <= 8.0
               and abs(r - round(r)) < 0.15 for k in kept):
            continue
        kept.append(c)

    kept.sort(key=lambda c: -c["strength"])
    seen, out = set(), []
    for c in kept:
        if c["band"] in seen:
            continue
        seen.add(c["band"])
        out.append(c)
    return out[:top]

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
def cycle_residual(level_db, dt: float, min_period_s: float = 60.0,
                   max_period_s: float = 6 * 3600.0,
                   n_sigma: float = 5.0, min_gap_s: float = 60.0) -> dict:
    """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.
    """
    x = np.asarray(level_db, float)
    ok = np.isfinite(x)
    if ok.sum() < 8:
        return {"period_s": None, "residual_std_db": 0.0, "anomalies": []}
    x = np.where(ok, x, np.nanmean(x[ok]))

    cycles = dominant_cycles(x, dt, min_period_s, max_period_s, top=1)
    if cycles:
        period_s = cycles[0]["period_s"]
        lag = max(2, int(round(period_s / dt)))
        phase = np.arange(len(x)) % lag
        # the room's usual behaviour at each point of the cycle
        expected = np.zeros(lag)
        for k in range(lag):
            expected[k] = np.median(x[phase == k])
        resid = x - expected[phase]
    else:
        period_s = None
        resid = x - np.median(x)

    # robust spread: a spike must not inflate the threshold that finds it
    mad = float(np.median(np.abs(resid - np.median(resid))))
    sigma = 1.4826 * mad if mad > 0 else float(resid.std())
    thresh = n_sigma * sigma

    anomalies, last_t = [], -np.inf
    for i in np.flatnonzero(np.abs(resid) > thresh):
        t_s = float(i * dt)
        if t_s - last_t < min_gap_s:
            continue
        last_t = t_s
        anomalies.append({"t_s": round(t_s, 1),
                          "excess_db": round(float(abs(resid[i]) - thresh), 2),
                          "direction": "up" if resid[i] > 0 else "down"})
    return {"period_s": period_s,
            "residual_std_db": round(float(sigma), 3),
            "anomalies": anomalies}

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
def cycle_drift(level_db, dt: float, min_period_s: float = 600.0,
                max_period_s: float = 6 * 3600.0, n_windows: int = 6,
                min_drift_pct: float = 8.0) -> dict:
    """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.
    """
    x = _prepare(level_db)
    if x is None:
        return {"period_s": None, "drifting": False, "direction": "",
                "drift_pct": 0.0, "periods_s": []}

    seg = len(x) // max(2, (n_windows + 1) // 2)
    if seg < int(4 * min_period_s / dt):
        seg = len(x)
    step = max(1, (len(x) - seg) // max(1, n_windows - 1)) if len(x) > seg else 1

    found = []
    for k in range(n_windows):
        i0 = k * step
        chunk = x[i0:i0 + seg]
        if len(chunk) < int(4 * min_period_s / dt):
            break
        c = dominant_cycles(chunk, dt, min_period_s, max_period_s, top=1)
        if c:
            found.append((i0 * dt + seg * dt / 2, c[0]["period_s"]))

    if len(found) < 3:
        return {"period_s": None if not found else round(
            float(np.median([p for _, p in found])), 1),
            "drifting": False, "direction": "", "drift_pct": 0.0,
            "periods_s": [round(p, 1) for _, p in found]}

    ts = np.array([t for t, _ in found], float)
    ps = np.array([p for _, p in found], float)
    med = float(np.median(ps))
    slope = float(np.polyfit(ts, ps, 1)[0])           # seconds of period per second
    #: Extrapolate across the whole series, not between the first and last
    #: window *centres*. The windows are wide and overlapping, so their
    #: centres span only about two thirds of the recording, and measuring the
    #: change between them under-reports what the period actually did: on a
    #: synthetic cycle lengthening by a true 25 %, the centre-to-centre figure
    #: is 15 %. `drift_pct` is meant to answer "how much has this machine's
    #: period changed over the recording", and that is the full span.
    change = slope * (len(x) - 1) * dt
    pct = 100.0 * change / med if med > 0 else 0.0
    drifting = abs(pct) >= min_drift_pct
    return {"period_s": round(med, 1),
            "drifting": bool(drifting),
            "direction": ("lengthening" if pct > 0 else "shortening") if drifting else "",
            "drift_pct": round(float(pct), 1),
            "periods_s": [round(p, 1) for _, p in found]}

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
def cycle_profile(level_db, dt: float, min_period_s: float = 60.0,
                  max_period_s: float = 3 * 86400.0) -> dict:
    """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.
    """
    cycles = dominant_cycles(level_db, dt, min_period_s, max_period_s, top=6)
    sub = [c for c in cycles if c["band"] in ("meso", "macro", "cyclic")]
    diurnal = [c for c in cycles if c["band"] == "circadian"]
    return {
        "cycles": cycles,
        "has_sub_daily_cycle": bool(sub),
        "diurnal_only": bool(diurnal and not sub),
        "stationary": not cycles,
    }

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
def series_onset(series, rise: float = 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.
    """
    s = np.asarray(series, float)
    s = s[np.isfinite(s)]
    if len(s) < 5:
        return None
    lo, hi = float(np.percentile(s, 5)), float(s.max())
    if hi <= lo:
        return None
    idx = np.flatnonzero(np.asarray(series, float) >= lo + rise * (hi - lo))
    return int(idx[0]) if len(idx) else None

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
def series_span(series, rise: float = 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.
    """
    s = np.asarray(series, float)
    s = s[np.isfinite(s)]
    if len(s) < 5:
        return None, None
    lo, hi = float(np.percentile(s, 5)), float(s.max())
    if hi <= lo:
        return None, None
    idx = np.flatnonzero(np.asarray(series, float) >= lo + rise * (hi - lo))
    return (int(idx[0]), int(idx[-1])) if len(idx) else (None, None)

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
def onset_lead(first, second, dt: float, rise: float = ONSET_RISE,
               first_rise: float | None = None,
               second_rise: float | None = None) -> dict:
    """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`.
    """
    #: The default is still one fraction for both series. Per-modality
    #: fractions are available and must be asked for, because only the motion
    #: one is validated --- see :data:`AUDIO_RISE`. A default that silently
    #: applied an unvalidated fraction would put it into every figure drawn
    #: from this function without anyone choosing it.
    first_rise = rise if first_rise is None else first_rise
    second_rise = rise if second_rise is None else second_rise
    i = series_onset(first, first_rise)
    j = series_onset(second, second_rise)
    if i is None or j is None:
        return {"lead_s": None, "first_onset_s": None, "second_onset_s": None,
                "leads": "", "reason": "one series has no onset to find"}
    lead = (j - i) * dt
    return {"lead_s": round(float(lead), 4),
            "first_onset_s": round(i * dt, 4),
            "second_onset_s": round(j * dt, 4),
            "first_rise": first_rise,
            "second_rise": second_rise,
            "leads": "first" if lead > 0 else ("second" if lead < 0 else "neither"),
            "reason": ""}

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
def floor_occupancy(F: dict, within_db: float = AT_FLOOR_WITHIN_DB,
                    pct: float = 5.0) -> dict:
    """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"}``.
    """
    op = np.asarray(F["oct_pow"], float)
    if op.ndim != 2 or not len(op):
        return {"at_floor_fraction": None, "floor_db": None,
                "median_above_floor_db": None}
    lvl = 10 * np.log10(op.sum(axis=1) + EPS)
    floor = float(np.percentile(lvl, pct))
    above = lvl - floor
    return {"at_floor_fraction": round(float((above <= within_db).mean()), 3),
            "floor_db": round(floor, 1),
            "median_above_floor_db": round(float(np.median(above)), 1)}

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
def floor_suspicion(F: dict, chunk_s: float = 300.0, pct: float = 10.0,
                    spread_thresh_db: float = FLOOR_SPREAD_THRESH_DB,
                    min_chunks: int = 6, hf_min_hz: float = 2000.0) -> dict:
    """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.
    """
    from .features import OCT_CENTERS
    out = {"floor_suspect": False, "floor_suspect_lo_hz": None,
           "floor_suspect_hi_hz": None, "floor_spread_db": None}
    op = F.get("oct_pow")
    if op is None or len(op) == 0:
        return out
    rows = max(1, int(round(chunk_s)))          # 1 s frames per chunk
    nchunk = len(op) // rows
    if nchunk < min_chunks:
        return out
    lvl = db(np.asarray(op[:nchunk * rows], np.float64))
    floors = np.percentile(lvl.reshape(nchunk, rows, lvl.shape[1]),
                           pct, axis=1)         # (nchunk, nband)
    spread = (np.percentile(floors, 50, axis=0)
              - np.percentile(floors, 5, axis=0))
    centers = np.asarray(OCT_CENTERS, float)[:lvl.shape[1]]
    nyq = float(F.get("fs", 48000)) / 2
    med = np.median(floors, axis=0)
    flagged = ((centers >= hf_min_hz) & (centers / np.sqrt(2) < nyq)
               & (spread < spread_thresh_db) & (med > -119.0))
    if flagged.any():
        idx = np.flatnonzero(flagged)
        out.update({
            "floor_suspect": True,
            "floor_suspect_lo_hz": int(round(centers[idx[0]] / np.sqrt(2))),
            "floor_suspect_hi_hz": int(round(min(centers[idx[-1]]
                                                 * np.sqrt(2), nyq))),
            "floor_spread_db": round(float(spread[idx].min()), 2),
        })
    return out

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
def circular_stats(az_deg, weights=None):
    """Energy-weighted circular mean (deg) and resultant length R."""
    from .circstats import mean_resultant
    mu, R = mean_resultant(np.radians(np.asarray(az_deg, float)), weights)
    return float(np.degrees(mu)), R

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
def summarize(F: dict) -> dict:
    """Session descriptor dict from concatenated features (see features.load_features)."""
    fast, fasta = F["fast_db"], F["fast_dba"]
    dt = float(np.median(np.diff(F["t_fast"]))) if len(F["t_fast"]) > 1 else 0.125
    leq = db(np.mean(10 ** (fast.astype(np.float64) / 10)))
    laeq = db(np.mean(10 ** (fasta.astype(np.float64) / 10)))
    l10, l50, l90 = (float(np.percentile(fast, q)) for q in (90, 50, 10))
    events, bg = detect_events(fast, dt)
    dur = float(len(F["t"]))  # 1 s per feature frame; robust across take gaps

    p = F["rms_w"].astype(np.float64) ** 2
    e_fg = p >= np.percentile(p, 75)
    e_bg = p <= np.percentile(p, 25)
    # direction is full 3-D (ambix), lateral-only (stereo), or absent (mono);
    # emit None for whatever this recording's channel layout cannot support
    az = np.asarray(F["az"], float)
    el = np.asarray(F["el"], float)
    psi = np.asarray(F["diffuse"], float)
    fin_az = np.isfinite(az)
    if fin_az.any():
        az_mean, R = circular_stats(az[fin_az], weights=p[fin_az])
        fg_az = e_fg & fin_az
        az_fg = (circular_stats(az[fg_az], weights=p[fg_az])[0]
                 if fg_az.any() else az_mean)
    else:
        az_mean = R = az_fg = None
    el_fg = (float(np.nanmedian(el[e_fg])) if np.isfinite(el[e_fg]).any()
             else None)
    has_psi = np.isfinite(psi).any()

    return {
        "duration_min": round(dur / 60, 1),
        "leq_dbfs": round(float(leq), 1),
        "laeq_dbfs": round(float(laeq), 1),
        "laeq_trim5_dbfs": round(trimmed_leq(fasta, 5.0), 1),
        "leq_minus_laeq_db": round(float(leq - laeq), 1),
        "L10": round(l10, 1), "L50": round(l50, 1), "L90": round(l90, 1),
        "dynamics_L10_L90": round(l10 - l90, 1),
        "events_per_min": round(len(events) / max(dur / 60, 1e-9), 1),
        "event_median_dur_s": round(float(np.median(
            [(e["i1"] - e["i0"] + 1) * dt for e in events])), 2) if events else None,
        "centroid_median_hz": int(np.median(F["centroid"])),
        "flatness_median": round(float(np.median(F["flatness"])), 3),
        "diffuseness_median": round(float(np.nanmedian(psi)), 2) if has_psi else None,
        "diffuseness_iqr": round(float(np.nanpercentile(psi, 75)
                                       - np.nanpercentile(psi, 25)), 2)
        if has_psi else None,
        "azimuth_mean_deg": round(az_mean, 0) if az_mean is not None else None,
        "azimuth_R": round(R, 2) if R is not None else None,
        "azimuth_fg_deg": round(az_fg, 0) if az_fg is not None else None,
        "elevation_fg_median_deg": round(el_fg, 0) if el_fg is not None else None,
        "n_events": len(events),
        "emergence_db": round(float(laeq - np.percentile(fasta, 10)), 1),
        "intermittency_ratio_pct": round(intermittency_ratio(fasta, dt), 1),
        **floor_suspicion(F),
    }

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
def decay_time(x: np.ndarray, fs: int, bands=((250, 500), (500, 1000),
               (1000, 2000), (2000, 4000), (4000, 8000))) -> dict:
    """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)}.
    """
    from scipy import signal as sg
    pk_i = int(np.abs(x).argmax())
    env_bb = sg.convolve(x ** 2, np.ones(480) / 480, "same")
    tail = 10 * np.log10(env_bb[pk_i:pk_i + 3 * fs] + 1e-15)
    run_min = np.minimum.accumulate(tail)
    re = np.flatnonzero((tail - run_min > 8) & (np.arange(len(tail)) > fs // 10))
    cut = int(re[0]) if len(re) else 2 * fs
    out = {}
    for lo, hi in bands:
        sos = sg.butter(4, [lo, hi], "bandpass", fs=fs, output="sos")
        y = sg.sosfilt(sos, x)
        env = sg.convolve(y ** 2, np.ones(240) / 240, "same")
        pk = int(env[max(0, pk_i - 2400):pk_i + 2400].argmax()) + max(0, pk_i - 2400)
        if pk < fs // 4:
            continue
        noise = float(np.median(env[:pk - fs // 8]))
        dr = 10 * np.log10(env[pk] / (noise + EPS))
        if dr < 20:
            continue
        seg = np.maximum(y[pk:pk + cut] ** 2 - noise, 0)
        sch = np.cumsum(seg[::-1])[::-1]
        sch_db = 10 * np.log10(sch / (sch[0] + EPS) + 1e-15)
        tax = np.arange(len(sch_db)) / fs
        lo_db = max(-35.0, -dr + 8)
        m = (sch_db <= -5) & (sch_db >= lo_db)
        if m.sum() < 150:
            continue
        A = np.vstack([tax[m], np.ones(int(m.sum()))]).T
        slope, _ = np.linalg.lstsq(A, sch_db[m], rcond=None)[0]
        if slope < 0:
            out[f"{lo}-{hi}"] = (round(-60.0 / slope, 2), round(float(dr), 0))
    return out

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
def transient_candidates(x: np.ndarray, fs: int, n_max: int = 60, min_rise_db: float = 12.0,
                         pre_s: float = 0.5, min_gap_s: float = 2.0) -> list[float]:
    """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.

    Args:
        x: Mono samples.
        fs (int): Sample rate.
        n_max (int): Most candidates to return. Defaults to 60.
        min_rise_db (float): Rise over the preceding level that counts. Defaults to 12 dB.
        pre_s (float): Length of the preceding window. Defaults to 0.5 s.
        min_gap_s (float): Minimum spacing between candidates. Defaults to 2 s.

    Returns:
        list: Candidate times in seconds, strongest first.
    """
    hop = max(int(fs * 0.01), 1)
    n = (len(x) - hop) // hop
    if n < 10:
        return []
    frames = np.lib.stride_tricks.as_strided(x, shape=(n, hop), strides=(x.strides[0] * hop, x.strides[0]))
    lvl = 20 * np.log10(np.sqrt((frames.astype(float) ** 2).mean(1)) + 1e-9)
    w = max(int(pre_s / 0.01), 2)
    pre = np.full(n, np.nan)
    for i in range(w, n):
        pre[i] = np.median(lvl[i - w:i])
    rise = lvl - pre
    order = np.argsort(np.nan_to_num(rise, nan=-1e9))[::-1]
    picks: list[float] = []
    dur = len(x) / fs
    for i in order:
        t = i * hop / fs
        if not np.isfinite(rise[i]) or rise[i] < min_rise_db or t < pre_s + 0.5 or t > dur - 2.0:
            continue
        if all(abs(t - p) > min_gap_s for p in picks):
            picks.append(float(t))
        if len(picks) >= n_max:
            break
    return picks

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

candidates (times), estimates (list of {"t", "band", "T60", "dr_db"}),

dict

T60_median and T60_iqr per band, n per band, and T60_mid (median over the

dict

500–2000 Hz bands) with n_mid. Empty lists and NaNs when nothing qualifies.

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
def decay_from_transients(x: np.ndarray, fs: int, bands=((250, 500), (500, 1000), (1000, 2000),
                          (2000, 4000), (4000, 8000)), n_max: int = 60, min_rise_db: float = 12.0,
                          pre_s: float = 0.5, excerpt_s: float = 2.0) -> dict:
    """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.

    Args:
        x: Mono samples.
        fs (int): Sample rate.
        bands: Octave bands as (lo, hi) pairs.
        n_max (int): Transients examined. Defaults to 60.
        min_rise_db (float): Rise that counts as a transient. Defaults to 12 dB.
        pre_s (float): Pre-roll before each transient in the excerpt. Defaults to 0.5 s.
        excerpt_s (float): Excerpt length. Defaults to 2 s.

    Returns:
        dict: ``candidates`` (times), ``estimates`` (list of ``{"t", "band", "T60", "dr_db"}``),
        ``T60_median`` and ``T60_iqr`` per band, ``n`` per band, and ``T60_mid`` (median over the
        500–2000 Hz bands) with ``n_mid``. Empty lists and NaNs when nothing qualifies.
    """
    cands = transient_candidates(x, fs, n_max=n_max, min_rise_db=min_rise_db, pre_s=pre_s)
    est = []
    for t in cands:
        a = int((t - pre_s) * fs)
        seg = x[a:a + int(excerpt_s * fs)]
        if len(seg) < int(excerpt_s * fs) * 0.9:
            continue
        try:
            d = decay_time(seg, fs, bands=bands)
        except Exception:
            continue
        for band, (t60, dr) in d.items():
            est.append({"t": t, "band": band, "T60": float(t60), "dr_db": float(dr)})
    out: dict = {"candidates": cands, "estimates": est, "T60_median": {}, "T60_iqr": {}, "n": {}}
    mids = []
    for lo, hi in bands:
        key = f"{lo}-{hi}"
        v = np.array([e["T60"] for e in est if e["band"] == key])
        out["n"][key] = int(len(v))
        out["T60_median"][key] = float(np.median(v)) if len(v) else float("nan")
        out["T60_iqr"][key] = float(np.percentile(v, 75) - np.percentile(v, 25)) if len(v) > 1 else float("nan")
        if key in ("500-1000", "1000-2000"):
            mids.extend(v.tolist())
    out["T60_mid"] = float(np.median(mids)) if mids else float("nan")
    out["n_mid"] = len(mids)
    return out

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
def pick_segments(F: dict, n=4, seg_s=600.0) -> list[dict]:
    """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.
    """
    t, fast = F["t_fast"], F["fast_db"]
    dt = float(np.median(np.diff(t)))
    win = max(1, int(seg_s / dt))
    if len(fast) < win:
        return [dict(kind="whole", t0=float(t[0]), dur=float(t[-1] - t[0]))]
    k = np.ones(win) / win
    m_lvl = np.convolve(10 ** (fast.astype(np.float64) / 10), k, "valid")
    var = np.convolve((fast - fast.mean()) ** 2, k, "valid")
    cands = [("quietest", float(t[int(np.argmin(m_lvl))])),
             ("most_active", float(t[int(np.argmax(var))])),
             ("typical", float(t[int(np.argmin(np.abs(
                 db(m_lvl) - np.median(db(m_lvl)))))]))]
    smooth = median_filter(fast, size=max(3, int(30 / dt)) | 1)
    jump = np.abs(np.diff(smooth))
    if jump.max() > 6:
        cands.append(("transition",
                      float(max(t[0], t[int(np.argmax(jump))] - seg_s / 2))))
    picks: list[dict] = []
    for kind, t0 in cands:
        same = next((p for p in picks if abs(p["t0"] - t0) <= dt), None)
        if same is None:
            picks.append(dict(kind=kind, t0=t0, dur=seg_s))
        else:
            same.setdefault("also", []).append(kind)
    return picks[:n]

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

Ssignal 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.

PMperceptually 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.

PCperceptually 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.

PDperceptually 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
@dataclass(frozen=True)
class Grounding:
    """One descriptor's evidence tier, with the reason and where it came from."""

    key: str
    tier: str
    why: str
    ref: str = ""

    def __post_init__(self):
        if self.tier not in TIERS:
            raise ValueError(f"unknown tier {self.tier!r} for {self.key!r}")

tier_of(key)

The evidence tier of a descriptor, or None if unregistered.

Source code in src/ambiscape/grounding.py
230
231
232
233
def tier_of(key: str) -> str | None:
    """The evidence tier of a descriptor, or ``None`` if unregistered."""
    g = GROUNDINGS.get(key)
    return g.tier if g else None

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
def unregistered(summary: dict) -> list[str]:
    """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.
    """
    return sorted(k for k in summary
                  if k not in GROUNDINGS and k not in EXEMPT)

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
def check(summary: dict) -> tuple[dict, list[str]]:
    """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.
    """
    cautions: list[str] = []
    for key in sorted(summary):
        g = GROUNDINGS.get(key)
        if g is not None and g.tier == "PD":
            cautions.append(f"{key}: {g.why}")
    n_pm = sum(1 for k in summary
               if (g := GROUNDINGS.get(k)) is not None and g.tier == "PM")
    if n_pm:
        cautions.append(
            f"{n_pm} further descriptors are perceptually motivated but "
            "unvalidated against listeners (tier PM); see ambiscape.grounding")
    out = dict(summary)
    out["grounding_cautions"] = cautions
    return out, cautions

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
def table() -> list[dict]:
    """The registry as rows, for printing, docs and report tables."""
    order = {t: i for i, t in enumerate(TIERS)}
    return [{"descriptor": g.key, "tier": g.tier, "meaning": TIERS[g.tier],
             "why": g.why, "ref": g.ref}
            for g in sorted(GROUNDINGS.values(),
                            key=lambda g: (order[g.tier], g.key))]

counts()

How many registered descriptors sit in each tier.

Source code in src/ambiscape/grounding.py
281
282
283
284
285
286
def counts() -> dict[str, int]:
    """How many registered descriptors sit in each tier."""
    out = {t: 0 for t in TIERS}
    for g in GROUNDINGS.values():
        out[g.tier] += 1
    return out

Impulse response & auralization

Sweep-based impulse response measurement and auralisation.

The measurement chain is Farina's exponential sine sweep (ESS) method:

  1. :func:exp_sweep generates 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.
  2. :func:deconvolve convolves 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.
  3. :func:ir_metrics, :func:sti, :func:iacc_early and :func:iacc_e3 characterise the room from the IR; :func:auralize convolves 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
def 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`).
    """
    from scipy.signal import oaconvolve
    n = int(round(duration * fs))
    t = np.arange(n) / fs
    L = duration / np.log(f1 / f0)
    sweep = np.sin(2 * np.pi * f0 * L * (np.exp(t / L) - 1.0))
    ni, no = int(round(fade_in * fs)), int(round(fade_out * fs))
    if ni:
        sweep[:ni] *= 0.5 * (1 - np.cos(np.pi * np.arange(ni) / ni))
    if no:
        sweep[n - no:] *= 0.5 * (1 - np.cos(np.pi * np.arange(no) / no))[::-1]
    sweep *= amplitude
    # time-reverse, then attenuate 6 dB/octave along the reversed time axis
    # (which runs high -> low frequency), undoing the sweep's pink energy
    inverse = sweep[::-1] * np.exp(-t / L)
    inverse /= np.abs(oaconvolve(sweep, inverse)).max()
    meta = {"kind": "ambiscape-sweep", "duration_s": duration,
            "f0_hz": f0, "f1_hz": f1, "fs": fs, "fade_in_s": fade_in,
            "fade_out_s": fade_out, "amplitude": amplitude}
    return sweep, inverse, meta

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
def inverse_from_meta(meta: dict) -> np.ndarray:
    """Regenerate the matched inverse filter from a sweep's sidecar dict."""
    _, inverse, _ = exp_sweep(
        duration=meta["duration_s"], f0=meta["f0_hz"], f1=meta["f1_hz"],
        fs=meta["fs"], fade_in=meta.get("fade_in_s", 0.1),
        fade_out=meta.get("fade_out_s", 0.02),
        amplitude=meta.get("amplitude", 0.5))
    return inverse

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
def write_sweep(out_path, duration=10.0, f0=40.0, f1=18000.0, fs=48000,
                amplitude=0.5) -> dict:
    """Write ``<out>.wav`` (the sweep), ``<out>_inverse.wav`` and a
    ``<out>.json`` parameter sidecar. Returns the paths + meta."""
    import soundfile as sf
    out_path = Path(out_path)
    sweep, inverse, meta = exp_sweep(duration=duration, f0=f0, f1=f1,
                                     fs=fs, amplitude=amplitude)
    inv_path = out_path.with_name(out_path.stem + "_inverse.wav")
    json_path = out_path.with_suffix(".json")
    sf.write(str(out_path), sweep.astype(np.float32), fs, subtype="FLOAT")
    sf.write(str(inv_path), inverse.astype(np.float32), fs, subtype="FLOAT")
    json_path.write_text(json.dumps(meta, indent=2))
    return {"sweep": out_path, "inverse": inv_path, "params": json_path,
            "meta": meta}

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
def deconvolve(rec: np.ndarray, inverse: np.ndarray) -> np.ndarray:
    """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.
    """
    from scipy.signal import oaconvolve
    rec = np.atleast_2d(np.asarray(rec, np.float64).T).T
    return np.stack([oaconvolve(rec[:, c], inverse)
                     for c in range(rec.shape[1])], axis=1)

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
def extract_ir(h: np.ndarray, fs: int, 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``.
    """
    h = np.atleast_2d(np.asarray(h, np.float64).T).T
    pk = int(np.abs(h).max(axis=1).argmax())
    i0 = max(0, pk - int(round(pre_ms * fs / 1000)))
    i1 = len(h) if dur is None else min(len(h), pk + int(round(dur * fs)))
    return h[i0:i1], pk - i0

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
def ir_metrics(ir: np.ndarray, fs: int, centers=OCTAVE_CENTERS) -> dict:
    """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.
    """
    from .analysis import decay_metrics
    x = np.asarray(ir, np.float64)
    if x.ndim > 1:
        x = x[:, 0]
    x = x / (np.abs(x).max() + EPS)     # metrics are level-invariant
    pre_roll = int(np.abs(x).argmax()) >= fs // 4
    if not pre_roll:
        # The estimator needs samples before the peak. Padding supplies them,
        # but they are silence, not room: `pre_roll=False` stops the noise
        # floor being read off the padding, which would report a dynamic
        # range near 200 dB and disable every guard downstream.
        x = np.concatenate([np.zeros(fs // 2), x])
    edges = _octave_edges(centers, fs)
    dm = decay_metrics(x, fs, bands=tuple((lo, hi) for _, lo, hi in edges),
                       pre_roll=pre_roll)
    return {str(c): dm[f"{lo}-{hi}"] for c, lo, hi in edges
            if f"{lo}-{hi}" in dm}

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
def sti(ir: np.ndarray, fs: int) -> dict:
    """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.
    """
    from scipy import signal as sg
    x = np.asarray(ir, np.float64)
    if x.ndim > 1:
        x = x[:, 0]
    t = np.arange(len(x)) / fs
    edges = _octave_edges(OCTAVE_CENTERS, fs)
    mti = {}
    for c, lo, hi in edges:
        sos = sg.butter(4, [lo, hi], "bandpass", fs=fs, output="sos")
        p = sg.sosfilt(sos, x) ** 2
        e = p.sum() + EPS
        ti = []
        for fm in _STI_FMOD:
            m = np.abs(np.sum(p * np.exp(-2j * np.pi * fm * t))) / e
            snr = np.clip(10 * np.log10(m / max(1 - m, EPS)), -15.0, 15.0)
            ti.append((snr + 15.0) / 30.0)
        mti[c] = float(np.mean(ti))
    m = [mti[c] for c, _, _ in edges]
    if len(m) < len(OCTAVE_CENTERS):        # fs too low for the 8 kHz band
        return {"sti": None, "mti": {str(c): round(v, 3)
                                     for c, v in mti.items()}}
    val = (sum(a * v for a, v in zip(_STI_ALPHA, m))
           - sum(b * np.sqrt(m[k] * m[k + 1])
                 for k, b in enumerate(_STI_BETA)))
    return {"sti": round(float(np.clip(val, 0.0, 1.0)), 3),
            "mti": {str(c): round(v, 3) for c, v in mti.items()}}

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
def iacc_early(ir: np.ndarray, fs: int, 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.
    """
    h = np.atleast_2d(np.asarray(ir, np.float64).T).T
    if h.shape[1] != 2:
        return None
    pk = int(np.abs(h).max(axis=1).argmax())
    n = int(round(window_ms * fs / 1000))
    seg = h[pk:pk + n]
    lag = int(round(max_lag_ms * fs / 1000))
    l, r = seg[:, 0], seg[:, 1]
    denom = np.sqrt((l ** 2).sum() * (r ** 2).sum()) + EPS
    cc = np.correlate(l, r, "full")[len(seg) - 1 - lag:len(seg) + lag]
    return round(float(np.abs(cc).max() / denom), 3)

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
def iacc_e3(ir: np.ndarray, fs: int, 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.
    """
    from scipy import signal as sg
    h = np.atleast_2d(np.asarray(ir, np.float64).T).T
    if h.shape[1] != 2:
        return None
    pk = int(np.abs(h).max(axis=1).argmax())
    seg = h[pk:pk + int(round(window_ms * fs / 1000))]
    lag = int(round(max_lag_ms * fs / 1000))
    edges = _octave_edges(centers, fs)
    iacc, signed = {}, {}
    for c, lo, hi in edges:
        sos = sg.butter(4, [lo, hi], "bandpass", fs=fs, output="sos")
        left, right = sg.sosfilt(sos, seg[:, 0]), sg.sosfilt(sos, seg[:, 1])
        denom = np.sqrt((left ** 2).sum() * (right ** 2).sum()) + EPS
        cc = (np.correlate(left, right, "full")
              [len(seg) - 1 - lag:len(seg) + lag] / denom)
        peak = cc[int(np.abs(cc).argmax())]
        iacc[str(c)] = round(float(abs(peak)), 3)
        signed[str(c)] = round(float(peak), 3)
    e3 = (round(float(np.mean(list(iacc.values()))), 3)
          if len(edges) == len(centers) else None)
    return {"iacc_e3": e3, "iacc": iacc, "iacc_signed": signed}

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
def partitioned_convolve(x: np.ndarray, h: np.ndarray,
                         block=8192) -> np.ndarray:
    """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``.
    """
    x = np.asarray(x, np.float64)
    h = np.asarray(h, np.float64)
    B, N = int(block), 2 * int(block)
    P = max(1, -(-len(h) // B))
    H = np.stack([np.fft.rfft(h[p * B:(p + 1) * B], N) for p in range(P)])
    n_out = len(x) + len(h) - 1
    n_blocks = -(-n_out // B)
    xpad = np.zeros((n_blocks + 1) * B)
    xpad[B:B + len(x)] = x
    fdl = np.zeros((P, N // 2 + 1), complex)
    out = np.empty(n_blocks * B)
    for k in range(n_blocks):
        fdl = np.roll(fdl, 1, axis=0)
        fdl[0] = np.fft.rfft(xpad[k * B:k * B + N])
        out[k * B:(k + 1) * B] = np.fft.irfft((fdl * H).sum(axis=0), N)[B:]
    return out[:n_out]

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
def auralize(dry: np.ndarray, fs: int, ir: np.ndarray, fs_ir: int,
             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.
    """
    from scipy.signal import resample_poly
    from math import gcd
    dry = np.atleast_2d(np.asarray(dry, np.float64).T).T
    ir = np.atleast_2d(np.asarray(ir, np.float64).T).T
    if fs_ir != fs:
        g = gcd(int(fs), int(fs_ir))
        ir = np.stack([resample_poly(ir[:, c], fs // g, fs_ir // g)
                       for c in range(ir.shape[1])], axis=1)
    cd, ci = dry.shape[1], ir.shape[1]
    if cd == ci:
        pairs = [(c, c) for c in range(cd)]
    elif cd == 1:
        pairs = [(0, c) for c in range(ci)]
    elif ci == 1:
        pairs = [(c, 0) for c in range(cd)]
    else:
        dry = dry.mean(axis=1, keepdims=True)
        pairs = [(0, c) for c in range(ci)]
    wet = np.stack([partitioned_convolve(dry[:, a], ir[:, b], block=block)
                    for a, b in pairs], axis=1)
    gain = 1.0
    if normalize == "match":
        gain = (np.abs(dry).max() + EPS) / (np.abs(wet).max() + EPS)
        wet *= gain
    return wet, round(float(20 * np.log10(gain + EPS)), 2)

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
def measure(recording, inverse=None, params=None, out_path=None,
            pre_ms=5.0, dur=None) -> dict:
    """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).
    """
    import soundfile as sf
    recording = Path(recording)
    rec, fs = sf.read(str(recording), dtype="float64", always_2d=True)
    if inverse is not None:
        inv, fs_inv = sf.read(str(inverse), dtype="float64")
        src = str(inverse)
    else:
        p = Path(params) if params else recording.parent / "sweep.json"
        if not p.exists():
            raise FileNotFoundError(
                "no inverse filter: give --inverse <wav> or --params "
                f"<json> (looked for {p})")
        meta = json.loads(p.read_text())
        inv, fs_inv, src = inverse_from_meta(meta), meta["fs"], str(p)
    if fs_inv != fs:
        raise ValueError(f"inverse fs {fs_inv} != recording fs {fs} — "
                         "measure and deconvolve at one rate")
    h = deconvolve(rec, inv)
    pk = int(np.abs(h).max(axis=1).argmax())    # direct sound in the buffer
    ir, direct = extract_ir(h, fs, pre_ms=pre_ms, dur=dur)
    peak = np.abs(ir).max() + EPS
    out_path = Path(out_path) if out_path else recording.parent / "ir.wav"
    sf.write(str(out_path), (ir * (0.5 / peak)).astype(np.float32), fs,
             subtype="FLOAT")
    doc = {"recording": recording.name, "inverse": src, "fs": fs,
           "channels": int(ir.shape[1]), "ir_s": round(len(ir) / fs, 3),
           # deconvolution delays everything by len(inv) − 1 samples
           "direct_in_recording_s": round((pk - len(inv) + 1) / fs, 3),
           "pre_ms": pre_ms, "peak_dbfs": -6.02,
           "gain_db": round(float(20 * np.log10(0.5 / peak)), 2),
           "bands": ir_metrics(ir, fs), **sti(ir, fs),
           "iacc_early": iacc_early(ir, fs), **(iacc_e3(ir, fs) or {}),
           "ir_path": str(out_path)}
    (out_path.parent / "impulse.json").write_text(
        json.dumps(doc, indent=2, default=float))
    return doc

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
def 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)."""
    with plt.rc_context(RC):
        segs = _gap_split(F["t"])
        widths = [F["t"][i1 - 1] - F["t"][i0] + 1 for i0, i1 in segs]
        fig, axes = plt.subplots(
            4, len(segs), figsize=(12, 9.5), dpi=130, sharey="row",
            gridspec_kw={"width_ratios": widths, "wspace": 0.04}, squeeze=False)

        tf_all, fast_all = F["t_fast"], F["fast_db"]
        dt = float(np.median(np.diff(tf_all))) if len(tf_all) > 1 else 0.125
        n_events_total = 0
        nonempty = F["logspec"].sum(0) > 0
        Sall = db(F["logspec"][:, nonempty])
        vmax = np.percentile(Sall, 99.5)
        fc = np.sqrt(F["logf"][:-1] * F["logf"][1:])[nonempty]
        p_all = F["rms_w"].astype(np.float64) ** 2

        for col, (i0, i1) in enumerate(segs):
            t = F["t"][i0:i1]
            fm = (tf_all >= t[0]) & (tf_all < t[-1] + 1)
            tf, fast = tf_all[fm], fast_all[fm]
            events, bg = detect_events(fast, dt)
            n_events_total += len(events)

            ax = axes[0][col]
            ax.plot(tf, fast, color=BLUE, lw=0.4, alpha=0.7)
            ax.plot(tf, bg, color=YELLOW, lw=1.4)
            for q, ls in ((90, ":"), (50, "-"), (10, ":")):
                ax.axhline(np.percentile(fast_all, q), color=MUT, lw=0.7, ls=ls)
            ax.set_xlim(t[0], t[-1])

            ax = axes[1][col]
            ax.pcolormesh(t, fc, Sall[i0:i1].T, cmap=SEQ, vmin=vmax - 65,
                          vmax=vmax, shading="auto", rasterized=True)
            ax.set_yscale("log")
            ax.set_ylim(25, 16000)
            ax.grid(False)

            ax = axes[2][col]
            nbins = 36
            nb_t = max(int((t[-1] - t[0]) / 30.0), 1)
            tb = np.linspace(t[0], t[-1] + 1, nb_t + 1)
            H = np.zeros((nbins, nb_t))
            azb = np.linspace(-180, 180, nbins + 1)
            ti = np.clip(np.searchsorted(tb, t) - 1, 0, nb_t - 1)
            ai = np.clip(np.searchsorted(azb, F["az"][i0:i1]) - 1, 0, nbins - 1)
            np.add.at(H, (ai, ti), p_all[i0:i1])
            ax.pcolormesh(tb[:-1], azb[:-1], db(H), cmap=SEQ, shading="auto",
                          rasterized=True, vmin=db(H).max() - 40,
                          vmax=db(H).max())
            ax.set_yticks([-180, -90, 0, 90, 180])
            ax.grid(False)

            ax = axes[3][col]
            d = F["diffuse"][i0:i1]
            ax.plot(t, d, color=GREEN, lw=0.5, alpha=0.55)
            k = min(121, max(3, len(t) // 50) | 1)
            ax.plot(t, np.convolve(d, np.ones(k) / k, "same"),
                    color=GREEN, lw=1.6)
            ax.set_ylim(0, 1)
            for row in range(4):
                _time_axis(axes[row][col], t, clock)
                if row < 3:
                    axes[row][col].tick_params(labelbottom=False)
            if clock and len(segs) > 1:
                axes[0][col].set_title(clock(t[0])[:6], loc="left",
                                       fontsize=8.5, color=SEC)

        axes[0][0].set_ylabel("fast level (dBFS)")
        axes[1][0].set_ylabel("frequency (Hz)")
        axes[2][0].set_ylabel("azimuth (°)\nanglegram")
        axes[3][0].set_ylabel("diffuseness ψ")
        fig.suptitle(f"{title} — level (blue), running background (yellow), "
                     f"L10/L50/L90 (grey); {n_events_total} events",
                     x=0.01, ha="left", fontsize=10, color=INK)
        fig.tight_layout(rect=(0, 0, 1, 0.985))
        fig.savefig(out_path, bbox_inches="tight")
        plt.close(fig)

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
def 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.
    """
    with plt.rc_context(RC):
        nonempty = F["logspec"].sum(0) > 0
        S = db(F["logspec"][:, nonempty])
        fc = np.sqrt(F["logf"][:-1] * F["logf"][1:])[nonempty]
        fig, ax = plt.subplots(figsize=(8, 4), dpi=130)
        p10, p50, p90 = (np.percentile(S, q, axis=0) for q in (10, 50, 90))
        ax.fill_between(fc, p10, p90, color=BLUE, alpha=0.18, lw=0)
        ax.plot(fc, p50, color=BLUE, lw=1.5)
        ax.plot(fc, p10, color=MUT, lw=0.8)
        ax.plot(fc, p90, color=MAGENTA, lw=1.0)
        if "minspec" in F and len(F["minspec"]):
            from .iso import prominent_tones
            for k, tn in enumerate(prominent_tones(F["minspec"],
                                                   F["freqs"])[:5]):
                ax.axvline(tn["f_hz"], color=YELLOW, lw=0.9, ls=":",
                           alpha=0.85, zorder=1)
                ax.annotate(f"{tn['f_hz']:.0f} Hz +{tn['dL_median_db']:.0f} dB",
                            (tn["f_hz"], 1.0 - 0.06 * k),
                            xycoords=("data", "axes fraction"),
                            color=SEC, fontsize=7, rotation=0,
                            xytext=(3, 0), textcoords="offset points")
        ax.annotate("90th pct (foreground)", (fc[-30], p90[-30]), color=MAGENTA,
                    fontsize=8, xytext=(0, 6), textcoords="offset points")
        ax.annotate("10th pct (background)", (fc[-30], p10[-30]), color=MUT,
                    fontsize=8, xytext=(0, -12), textcoords="offset points")
        ax.set_xscale("log")
        ax.set_xlabel("frequency (Hz)")
        ax.set_ylabel("PSD (dB)")
        ax.set_title(f"{title} — percentile LTAS", loc="left", fontsize=10)
        fig.tight_layout()
        fig.savefig(out_path, bbox_inches="tight")
        plt.close(fig)

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
def directogram(F, out_path, title=""):
    """Polar azimuth histograms: foreground (loudest 25 %) vs background
    (quietest 25 %) energy-weighted. ambiviz-style directogram."""
    with plt.rc_context(RC):
        p = F["rms_w"].astype(np.float64) ** 2
        fg, bgm = p >= np.percentile(p, 75), p <= np.percentile(p, 25)
        fig, ax = plt.subplots(figsize=(5, 5), dpi=130,
                               subplot_kw={"projection": "polar"})
        bins = np.linspace(-180, 180, 37)
        th = np.radians((bins[:-1] + bins[1:]) / 2)
        for m, c, lab in ((bgm, MUT, "background (quietest 25%)"),
                          (fg, BLUE, "foreground (loudest 25%)")):
            h, _ = np.histogram(F["az"][m], bins=bins, weights=p[m])
            ax.bar(th, h / (h.max() + 1e-20), width=np.radians(10) * 0.92,
                   color=c, alpha=0.65, label=lab)
        ax.set_theta_zero_location("N")
        ax.set_theta_direction(1)
        ax.set_thetagrids([0, 90, 180, 270],
                          ["front", "left", "rear", "right"], fontsize=8.5)
        ax.set_rticks([])
        ax.set_title(f"{title} — directogram", fontsize=10, pad=14)
        ax.legend(loc="lower left", bbox_to_anchor=(-0.1, -0.12),
                  frameon=False, fontsize=8)
        fig.tight_layout()
        fig.savefig(out_path, bbox_inches="tight")
        plt.close(fig)

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
def band_background(logspec: np.ndarray, win_s: float = 300.0,
                    pct: float = 10.0) -> np.ndarray:
    """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.
    """
    n = max(3, int(round(win_s)) | 1)
    return ndimage.percentile_filter(logspec, pct, size=(n, 1),
                                     mode="nearest")

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
def foreground(logspec: np.ndarray, bg: np.ndarray):
    """dB rise above the spectral background and per-second foreground
    fraction (share of total power more than 3 dB above background)."""
    rise_db = 10 * np.log10((logspec + EPS) / (bg + EPS))
    fg_mask = rise_db > 3.0
    frac = (logspec * fg_mask).sum(1) / (logspec.sum(1) + EPS)
    return rise_db, frac

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
def spectral_events(rise_db: np.ndarray, logf: np.ndarray,
                    thresh_db: float = 6.0, min_dur_s: float = 2.0,
                    min_bands: int = 2) -> list[dict]:
    """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.
    """
    lab, nlab = ndimage.label(rise_db > thresh_db)
    out = []
    for sl_t, sl_b in ndimage.find_objects(lab):
        dur = sl_t.stop - sl_t.start
        nb = sl_b.stop - sl_b.start
        if dur < min_dur_s or nb < min_bands:
            continue
        blob = rise_db[sl_t, sl_b]
        out.append({
            "t0_s": int(sl_t.start),
            "dur_s": int(dur),
            "f_lo_hz": round(float(logf[sl_b.start]), 1),
            "f_hi_hz": round(float(logf[min(sl_b.stop, len(logf) - 1)]), 1),
            "peak_rise_db": round(float(blob.max()), 1),
        })
    return sorted(out, key=lambda e: e["t0_s"])

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
def masking_index(F: dict, active: np.ndarray, quiet: np.ndarray) -> dict:
    """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.
    """
    ls = F["logspec"]
    logf = F["logf"]
    el = 10 * np.log10(np.median(ls[active], axis=0) + EPS) \
        - 10 * np.log10(np.median(ls[quiet], axis=0) + EPS)
    band = (logf[:-1] >= 250) & (logf[:-1] <= 8000)
    return {
        "floor_elevation_median_db": round(float(np.median(el[band])), 1),
        "floor_elevation_max_db": round(float(el[band].max()), 1),
        "bands_masked_gt6db_fraction": round(float((el[band] > 6).mean()), 2),
        "elevation_db_per_band": [round(float(v), 1) for v in el],
    }

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
def source_fingerprint(F: dict, active: np.ndarray, quiet: np.ndarray,
                       fmin: float = 25.0, fmax: float = 16000.0,
                       min_prom_db: float = 6.0, max_peaks: int = 20) -> dict:
    """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).
    """
    from scipy.ndimage import median_filter as _medf
    from scipy.signal import find_peaks as _find_peaks
    from .tonality import harmonic_sieve

    freqs = np.asarray(F["freqs"], float)
    S_a = F["minspec"][np.asarray(active, bool)].mean(0)
    S_q = F["minspec"][np.asarray(quiet, bool)].mean(0)
    m = (freqs >= fmin) & (freqs <= fmax)
    rise = 10 * np.log10((S_a[m] + EPS) / (S_q[m] + EPS))
    fsel = freqs[m]

    # hump: broad maximum of the smoothed rise
    smooth = _medf(rise, size=51, mode="nearest")
    i_max = int(np.argmax(smooth))
    # peaks: narrowband lines above the smoothed curve
    line = rise - smooth
    pk, props = _find_peaks(line, height=min_prom_db, distance=5)
    order = np.argsort(props["peak_heights"])[::-1][:max_peaks]
    keep = np.sort(pk[order])
    peaks = [{"f_hz": round(float(fsel[i]), 1),
              "rise_db": round(float(rise[i]), 1)} for i in keep]
    f0, h = harmonic_sieve(fsel[keep], 10 ** (rise[keep] / 10)) \
        if len(keep) else (None, 0.0)
    return {
        "freqs": fsel, "rise_db": rise,
        "rise_max_db": round(float(smooth[i_max]), 1),
        "rise_max_hz": round(float(fsel[i_max]), 1),
        "peaks": peaks,
        "comb": {"f0_hz": round(f0, 1) if f0 else None, "harmonicity": h},
    }

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
def summarize_foreground(F: dict, win_s: float = 300.0) -> dict:
    """Foreground descriptors for the analyze summary."""
    bg = band_background(F["logspec"], win_s=win_s)
    rise_db, frac = foreground(F["logspec"], bg)
    ev = spectral_events(rise_db, F["logf"])
    dur_min = max(len(frac) / 60.0, 1e-9)
    return {
        "fg_fraction_median": round(float(np.median(frac)), 2),
        "fg_fraction_p90": round(float(np.percentile(frac, 90)), 2),
        "spectral_events_per_min": round(len(ev) / dur_min, 1),
        "spectral_event_median_dur_s": (
            round(float(np.median([e["dur_s"] for e in ev])), 1)
            if ev else None),
    }

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
def band_level(F: dict, band=(250.0, 1000.0)) -> np.ndarray:
    """Per-second dB level in ``band`` (Hz) from the cached ``logspec``."""
    logf = np.asarray(F["logf"], float)
    fc = np.sqrt(logf[:-1] * logf[1:])
    m = (fc >= band[0]) & (fc <= band[1])
    return 10 * np.log10(F["logspec"][:, m].sum(1) + EPS)

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
def bimodal_threshold(level_db: np.ndarray) -> float:
    """Otsu's threshold on the level histogram: the split that best
    separates the two modes of an on/off timeline."""
    lo, hi = np.percentile(level_db, (0.5, 99.5))
    hist, edges = np.histogram(level_db, bins=128, range=(lo, hi))
    p = hist.astype(float) / max(hist.sum(), 1)
    centers = (edges[:-1] + edges[1:]) / 2
    w0 = np.cumsum(p)
    mu = np.cumsum(p * centers)
    mu_t = mu[-1]
    var = (mu_t * w0 - mu) ** 2 / (w0 * (1 - w0) + EPS)
    k = int(np.argmax(var))
    # between-class variance is flat across an empty inter-mode gap; the
    # midpoint of the two class means splits the gap centrally
    mu0 = mu[k] / (w0[k] + EPS)
    mu1 = (mu_t - mu[k]) / (1 - w0[k] + EPS)
    return float((mu0 + mu1) / 2)

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
def transition_profile(level_db: np.ndarray, segments: list, dt_s: float = 1.0,
                       settle_tol_db: float = 1.0, max_settle_s: float = 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.
    """
    x = np.asarray(level_db, float)
    out = []
    for a, b in zip(segments[:-1], segments[1:]):
        i = int(round(b["t0_s"] / dt_s))
        if i <= 0 or i >= len(x):
            continue
        lo, hi = float(a["median_db"]), float(b["median_db"])
        step = hi - lo
        if abs(step) < 1e-9:
            continue

        # abruptness: how long the level spends between 10% and 90% of the
        # step, searched in a window around the boundary
        w = int(round(min(max_settle_s, 30.0) / dt_s))
        seg = x[max(0, i - w):min(len(x), i + w)]
        lo_mark, hi_mark = lo + 0.1 * step, lo + 0.9 * step
        if step > 0:
            crossing = np.flatnonzero((seg >= lo_mark) & (seg <= hi_mark))
        else:
            crossing = np.flatnonzero((seg <= lo_mark) & (seg >= hi_mark))
        cross_s = float(len(crossing) * dt_s) if len(crossing) else 0.0

        # Settling: first index after the boundary from which the level
        # stays inside a band around the new state's median. The band is
        # the wider of the caller's tolerance and twice the new state's own
        # variability, because a tolerance tighter than the state's noise
        # would report that a steady state never settles -- which says
        # something about the tolerance, not about the room.
        tol = max(settle_tol_db, 2.0 * float(b.get("sd_db", 0.0) or 0.0))
        m = int(round(max_settle_s / dt_s))
        tail = x[i:min(len(x), i + m)]
        settle = None
        inside = np.abs(tail - hi) <= tol
        # "stays inside" as a fraction rather than as every sample: a
        # steady state that is merely noisy will throw the occasional
        # excursion past any band, and requiring perfection would report
        # that a settled room never settled.
        for k in range(len(inside)):
            if inside[k:].mean() >= 0.9:
                settle = float(k * dt_s)
                break

        out.append(dict(t_s=float(b["t0_s"]),
                        direction="onset" if step > 0 else "cessation",
                        step_db=round(step, 1),
                        crossing_s=round(cross_s, 1),
                        settle_s=None if settle is None else round(settle, 1),
                        settle_tol_db=round(tol, 1),
                        from_db=round(lo, 1), to_db=round(hi, 1)))
    return out

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
def state_segments(level_db: np.ndarray, thresh_db: float | None = None,
                   smooth_s: float = 11.0, hysteresis_db: float = 1.0,
                   min_dur_s: float = 30.0) -> list[dict]:
    """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).
    """
    x = np.asarray(level_db, float)
    k = max(3, int(round(smooth_s)) | 1)
    sm = median_filter(x, size=k, mode="nearest")
    th = bimodal_threshold(sm) if thresh_db is None else float(thresh_db)
    on = np.zeros(len(sm), bool)
    cur = sm[0] > th
    for i, v in enumerate(sm):
        if cur and v < th - hysteresis_db / 2:
            cur = False
        elif not cur and v > th + hysteresis_db / 2:
            cur = True
        on[i] = cur

    def bounds(mask):
        edges = np.flatnonzero(np.diff(mask.astype(int))) + 1
        return [0, *edges.tolist(), len(mask)]

    # merge runs shorter than min_dur_s into the surrounding state
    b = bounds(on)
    for i0, i1 in zip(b[:-1], b[1:]):
        if i1 - i0 < min_dur_s and i0 > 0 and i1 < len(on):
            on[i0:i1] = on[i0 - 1]
    b = bounds(on)
    segs = []
    for i0, i1 in zip(b[:-1], b[1:]):
        seg = x[i0:i1]
        segs.append({
            "state": "on" if on[i0] else "off",
            "t0_s": float(i0), "dur_s": float(i1 - i0),
            "median_db": round(float(np.median(seg)), 1),
            "sd_db": round(float(seg.std()), 2),
        })
    return segs

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
def switch_points(segments: list[dict]) -> list[dict]:
    """Transitions between consecutive segments: time and direction
    ('on' = machine starts, 'off' = machine stops)."""
    out = []
    for a, b in zip(segments[:-1], segments[1:]):
        out.append({"t_s": float(b["t0_s"]),
                    "direction": b["state"],
                    "step_db": round(b["median_db"] - a["median_db"], 1)})
    return out

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
def duty_cycle(segments: list[dict]) -> dict:
    """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."""
    on_starts = np.array([s["t0_s"] for s in segments if s["state"] == "on"])
    on_durs = np.array([s["dur_s"] for s in segments if s["state"] == "on"])
    if len(on_starts) < 2:
        return {"period_s": None, "duty": None,
                "n_cycles": int(len(on_starts))}
    period = float(np.median(np.diff(on_starts)))
    return {"period_s": round(period, 1),
            "duty": round(float(np.median(on_durs)) / period, 3),
            "n_cycles": int(len(on_starts))}

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
def cycle_series(segments: list[dict]) -> dict:
    """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.
    """
    on = [s for s in segments if s["state"] == "on"]
    starts = [float(s["t0_s"]) for s in on]
    period_s = [b - a for a, b in zip(starts[:-1], starts[1:])]

    # A run still on when the series ends has an unknown length, and a short
    # tail dragged into the on-time trend will invert it. The period series
    # is unaffected: it is measured onset to onset, so the last onset still
    # closes the previous interval.
    truncated = bool(segments and segments[-1]["state"] == "on")
    on_s = [float(s["dur_s"]) for s in (on[:-1] if truncated else on)]

    def trend(v):
        if len(v) < 3:
            return {"rho": None, "per_cycle": None, "spread": None}
        i = np.arange(len(v), dtype=float)
        y = np.asarray(v, float)
        if y.std() == 0:
            return {"rho": 0.0, "per_cycle": 0.0, "spread": 0.0}
        slope = float(np.polyfit(i, y, 1)[0])
        return {"rho": round(float(np.corrcoef(i, y)[0, 1]), 3),
                "per_cycle": round(slope, 2),
                "spread": round(float(y.max() - y.min()), 2)}

    return {"n_cycles": len(on),
            "on_s": [round(v, 1) for v in on_s],
            "period_s": [round(v, 1) for v in period_s],
            "on_trend": trend(on_s),
            "period_trend": trend(period_s),
            "truncated_final_run": truncated}

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
def bimodal_separation(level_db: np.ndarray,
                       min_separation_db: float = 2.0,
                       min_fraction: float = 0.02) -> dict:
    """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.
    """
    x = np.asarray(level_db, float)
    x = x[np.isfinite(x)]
    if x.size < 2 or x.max() - x.min() < EPS:
        return {"threshold_db": None, "separation_db": 0.0,
                "upper_fraction": 0.0, "lower_mean_db": None,
                "upper_mean_db": None, "bimodal": False}
    th = bimodal_threshold(x)
    upper, lower = x[x > th], x[x <= th]
    if not len(upper) or not len(lower):
        return {"threshold_db": round(float(th), 2), "separation_db": 0.0,
                "upper_fraction": float(len(upper)) / len(x),
                "lower_mean_db": None, "upper_mean_db": None,
                "bimodal": False}
    lo, hi = float(lower.mean()), float(upper.mean())
    frac = len(upper) / len(x)
    return {"threshold_db": round(float(th), 2),
            "separation_db": round(hi - lo, 2),
            "upper_fraction": round(frac, 4),
            "lower_mean_db": round(lo, 2),
            "upper_mean_db": round(hi, 2),
            "bimodal": bool(hi - lo >= min_separation_db
                            and min_fraction <= frac <= 1 - min_fraction)}

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 every step_s across 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):

  1. 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_prominence returns the per-harmonic list for exactly this reason.
  2. 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
def hum_peak(w: np.ndarray, fs: int, nominal: float = 50.0,
             search_hz: float = 0.2, nfft_mult: int = 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.
    """
    n = len(w)
    W = np.fft.rfft(w * np.hanning(n), n * nfft_mult)
    f = np.fft.rfftfreq(n * nfft_mult, 1 / fs)
    P = W.real ** 2 + W.imag ** 2
    m = (f >= nominal - search_hz) & (f <= nominal + search_hz)
    j = int(np.flatnonzero(m)[0] + np.argmax(P[m]))
    a, b, c = (np.log(P[j - 1] + EPS), np.log(P[j] + EPS),
               np.log(P[j + 1] + EPS))
    d = 0.5 * (a - c) / (a - 2 * b + c + EPS)
    floor = np.median(P[(f >= nominal - search_hz - 1.5)
                        & (f <= nominal + search_hz + 1.5)])
    return float(f[j] + d * (f[1] - f[0])), \
        float(10 * np.log10(P[j] / (floor + EPS)))

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
def enf_track(sess: Session, step_s: float = 300.0, win_s: float = 60.0,
              nominal: float = 50.0, search_hz: float = 0.2,
              harmonics=(1, 2), channel: int = 0) -> dict:
    """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}}``.
    """
    ts, fk, rk = [], {k: [] for k in harmonics}, {k: [] for k in harmonics}
    for tk in sess.takes:
        t = tk.start + 1.0
        while t + win_s <= tk.end:
            x, fs = read_span(sess, t, win_s)
            if x.shape[0] >= 0.9 * win_s * fs:
                w = x[:, channel].astype(np.float64)
                for k in harmonics:
                    f, r = hum_peak(w, fs, nominal=k * nominal,
                                    search_hz=k * search_hz)
                    fk[k].append(f / k)
                    rk[k].append(r)
                ts.append(t)
            t += step_s
    return {"t": np.array(ts),
            "f": {k: np.array(v) for k, v in fk.items()},
            "rise": {k: np.array(v) for k, v in rk.items()}}

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
def enf_summary(track: dict, nominal: float = 50.0,
                min_rise_db: float = 6.0) -> dict:
    """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.
    """
    ks = sorted(track["f"])
    k0 = ks[0]
    good = track["rise"][k0] >= min_rise_db
    f = track["f"][k0][good]
    out = {
        "n_windows": int(len(track["t"])),
        "coverage": round(float(good.mean()), 2) if len(good) else 0.0,
        "mean_hz": round(float(f.mean()), 4) if len(f) else None,
        "sd_mhz": round(float(f.std() * 1000), 1) if len(f) else None,
        "max_dev_mhz": round(float(np.abs(f - nominal).max() * 1000), 1)
        if len(f) else None,
        "median_rise_db": round(float(np.median(track["rise"][k0])), 1)
        if len(good) else None,
    }
    if len(ks) > 1:
        k1 = ks[1]
        both = good & (track["rise"][k1] >= min_rise_db)
        if both.any():
            d = np.abs(track["f"][k0][both] - track["f"][k1][both])
            out["harmonic_agreement_mhz"] = round(float(np.median(d) * 1000),
                                                  2)
    return out

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
def aci(F: dict, chunk_s: float = 300.0) -> float | None:
    """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).
    """
    S = np.asarray(F["logspec"], float)
    n = max(2, int(chunk_s))
    if S.shape[0] < n:
        return None
    vals = []
    for i0 in range(0, S.shape[0] - n + 1, n):
        c = S[i0:i0 + n]
        vals.append(float((np.abs(np.diff(c, axis=0)).sum(0)
                           / (c.sum(0) + EPS)).sum()))
    return float(np.mean(vals)) if vals else None

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
def adi_aei(F: dict, **kw):
    """Acoustic diversity (Shannon, normalised) and evenness (Gini)."""
    occ = _occupancy(F, **kw)
    p = occ / (occ.sum() + EPS)
    adi = float(-(p * np.log(p + EPS)).sum() / np.log(len(p) + EPS))
    x = np.sort(occ)
    n = len(x)
    gini = float((2 * np.arange(1, n + 1) - n - 1).dot(x)
                 / (n * x.sum() + EPS))
    return adi, gini

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
def ndsi(F: dict, anthro=(1000.0, 2000.0), bio=(2000.0, 8000.0)) -> float:
    """Normalized difference soundscape index in [−1, 1]."""
    fc = _band_centers(F["logf"])
    S = np.asarray(F["logspec"], float).mean(0)
    a = S[(fc >= anthro[0]) & (fc < anthro[1])].sum()
    b = S[(fc >= bio[0]) & (fc < bio[1])].sum()
    return float((b - a) / (b + a + EPS))

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
def bioacoustic_index(F: dict, band=(2000.0, 8000.0)) -> float:
    """Boelman BI: area of the mean band dB spectrum above its minimum."""
    fc = _band_centers(F["logf"])
    m = (fc >= band[0]) & (fc <= band[1])
    s = 10 * np.log10(np.asarray(F["logspec"], float).mean(0)[m] + EPS)
    return float((s - s.min()).sum())

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
def acoustic_entropy(F: dict) -> float:
    """Sueur H = spectral entropy × temporal entropy, in [0, 1]."""
    S = np.asarray(F["logspec"], float)
    ps = S.mean(0)
    ps = ps / (ps.sum() + EPS)
    hf = float(-(ps * np.log(ps + EPS)).sum() / np.log(len(ps)))
    env = np.asarray(F["rms_w"], float)
    pe = env / (env.sum() + EPS)
    ht = float(-(pe * np.log(pe + EPS)).sum() / np.log(len(pe)))
    return hf * ht

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
def indices(F: dict) -> dict:
    """The full battery as one dict."""
    adi_, aei_ = adi_aei(F)
    aci_ = aci(F)                     # None below one full chunk (5 min)
    return {
        "aci": None if aci_ is None else round(aci_, 1),
        "adi": round(adi_, 3),
        "aei": round(aei_, 3),
        "ndsi": round(ndsi(F), 3),
        "bi": round(bioacoustic_index(F), 1),
        "acoustic_entropy": round(acoustic_entropy(F), 3),
    }

summarize_ecology(F)

Alias of :func:indices for the analyze-summary pipeline.

Source code in src/ambiscape/ecology.py
167
168
169
def summarize_ecology(F: dict) -> dict:
    """Alias of :func:`indices` for the analyze-summary pipeline."""
    return indices(F)

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:

  1. 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.
  2. 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
def narrowband_activity(F: dict, band=BIRD_BAND, min_prom_db: float = 6.0,
                        min_peaks: int = 2) -> dict:
    """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.
    """
    from .tonality import tonal_peaks
    ms, freqs = np.asarray(F["minspec"], float), np.asarray(F["freqs"], float)
    counts = np.array([
        len(tonal_peaks(ms[i], freqs, fmin=band[0], fmax=band[1],
                        min_prom_db=min_prom_db)[0])
        for i in range(ms.shape[0])])
    return {
        "median_peaks_per_min": float(np.median(counts)) if len(counts) else 0.0,
        "max_peaks_per_min": int(counts.max()) if len(counts) else 0,
        "active_minute_fraction": round(float((counts >= min_peaks).mean()), 2)
        if len(counts) else 0.0,
        "per_min": counts,
    }

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
def band_temporal_entropy(F: dict, band=BIRD_BAND) -> float:
    """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).
    """
    env = _band_envelope(F, band)
    p = env / (env.sum() + EPS)
    return round(float(-(p * np.log(p + EPS)).sum() / np.log(len(p))), 3) \
        if len(p) > 1 else 0.0

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
def band_activity(F: dict, band=BIRD_BAND, k_db: float = 3.0,
                  bg_win_s: float = 300.0, min_dur_s: int = 1) -> dict:
    """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.
    """
    env_db = 10 * np.log10(_band_envelope(F, band) + EPS)
    n = max(3, int(round(bg_win_s)) | 1)
    bg = percentile_filter(env_db, 10, size=n, mode="nearest")
    active = env_db > bg + k_db
    lab, nlab = label(active)
    durs = [int((lab == i).sum()) for i in range(1, nlab + 1)]
    durs = [d for d in durs if d >= min_dur_s]
    dur_min = max(len(env_db) / 60.0, 1e-9)
    return {
        "active_fraction": round(float(active.mean()), 3),
        "event_rate_per_min": round(len(durs) / dur_min, 1),
        "event_median_dur_s": round(float(np.median(durs)), 1) if durs else None,
    }

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
def spatial_dispersion(F: dict, band=BIRD_BAND, nbins: int = 36,
                       limit_deg: float = 10.0) -> dict:
    """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.
    """
    w = _fg_weights(F, band)
    az, el = np.asarray(F["az"], float), np.asarray(F["el"], float)
    tot = w.sum() + EPS
    h, _ = np.histogram(az, bins=nbins, range=(-180, 180), weights=w)
    q = h / (h.sum() + EPS)
    ent = float(-(q * np.log(q + EPS)).sum() / np.log(nbins))
    return {
        "directional_entropy": round(ent, 3),
        "above_horizon_fraction": round(float(w[el > limit_deg].sum() / tot), 2),
        "below_horizon_fraction": round(float(w[el < -limit_deg].sum() / tot), 2),
    }

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
def summarize_biophony(F: dict, band=BIRD_BAND,
                       min_active_fraction: float = 0.02) -> dict:
    """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.
    """
    na = narrowband_activity(F, band)
    act = band_activity(F, band)
    active = act["active_fraction"] >= min_active_fraction
    sd = spatial_dispersion(F, band) if active else {}
    return {
        "bird_peaks_per_min": na["median_peaks_per_min"],
        "bird_active_minute_fraction": na["active_minute_fraction"],
        "bird_band_activity_pct": round(act["active_fraction"] * 100, 1),
        "bird_event_rate_per_min": act["event_rate_per_min"],
        "bird_temporal_entropy": band_temporal_entropy(F, band),
        "bird_directional_entropy": sd.get("directional_entropy"),
        "bird_above_horizon_fraction": sd.get("above_horizon_fraction"),
    }

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-F restricted to a set of time intervals, valid across every feature axis (1 s, 125 ms fast, 20 ms envelope, per-minute PSD) for all the summarize_* functions;
  • full_summary — the merged descriptor dict (level/event, spectral foreground, ecoacoustic, spatial, biophony) — the same set analyze writes, computed on any F;
  • 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
def intervals_from_mask(t: np.ndarray, mask: np.ndarray) -> list:
    """Contiguous ``[start, stop)`` intervals (in ``t`` units) of a boolean
    mask over the 1 s frames."""
    t = np.asarray(t, float)
    m = np.asarray(mask, bool)
    if not m.any():
        return []
    edges = np.flatnonzero(np.diff(m.astype(int)))
    starts = [0] if m[0] else []
    stops = []
    for e in edges:
        (stops if m[e] else starts).append(e + 1)
    if m[-1]:
        stops.append(len(m))
    return [(float(t[a]), float(t[b - 1]) + 1.0) for a, b in zip(starts, stops)]

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
def slice_features(F: dict, intervals: list) -> dict:
    """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.
    """
    out = {}
    m1 = _axis_mask(F["t"], intervals)
    out["t"] = F["t"][m1]
    for k in _SEC_KEYS:
        if k in F:
            out[k] = F[k][m1]
    if "t_fast" in F:
        mf = _axis_mask(F["t_fast"], intervals)
        out["t_fast"] = F["t_fast"][mf]
        for k in _FAST_KEYS:
            if k in F:
                out[k] = F[k][mf]
    if "t_hi" in F and "env_hi" in F:
        mh = _axis_mask(F["t_hi"], intervals)
        out["t_hi"] = F["t_hi"][mh]
        out["env_hi"] = F["env_hi"][mh]
    if "min_t" in F:
        mm = _axis_mask(F["min_t"], intervals)
        out["min_t"] = F["min_t"][mm]
        out["minspec"] = F["minspec"][mm]
    for k in _SCALAR_KEYS:
        if k in F:
            out[k] = F[k]
    return out

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
def full_summary(F: dict, check_windows: bool = True) -> dict:
    """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.
    """
    from . import (analysis, anthrophony, background, biophony, ecology,
                   geophony, iso, mechanical, spatial, timescales)
    s = analysis.summarize(F)
    s.update(background.summarize_foreground(F))
    s.update(ecology.summarize_ecology(F))
    s.update(spatial.summarize_spatial(F))
    s.update(biophony.summarize_biophony(F))
    s.update(mechanical.summarize_mechanical(F))
    s.update(anthrophony.summarize_anthrophony(F))
    s.update(geophony.summarize_geophony(F))
    s.update(iso.summarize_psycho(F))
    from .segmentation import nonstationarity
    s["nonstationarity"] = nonstationarity(F)
    if check_windows:
        s, low = timescales.check(s, float(len(F["t"])))
        if low:
            s["low_confidence"] = low
    return s

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
def resolve(F: dict, states: dict, min_frames: int = 30) -> dict:
    """``{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).
    """
    out = {}
    for label, intervals in states.items():
        sub = slice_features(F, intervals)
        if len(sub["t"]) >= min_frames:
            out[label] = full_summary(sub)
    return out

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
def machine_states(F: dict, band=(250.0, 1000.0), min_dur_s: float = 120.0,
                   labels=("machine_on", "machine_off")) -> dict:
    """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).
    """
    from .states import band_level, state_segments
    segs = state_segments(band_level(F, band), min_dur_s=min_dur_s)
    t = F["t"]
    n = len(t)
    states = {labels[0]: [], labels[1]: []}
    for s in segs:
        i0 = int(s["t0_s"])
        i1 = int(min(s["t0_s"] + s["dur_s"], n))
        if i1 <= i0:
            continue
        key = labels[0] if s["state"] == "on" else labels[1]
        states[key].append((float(t[i0]), float(t[i1 - 1]) + 1.0))
    return {k: v for k, v in states.items() if v}

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
def auto_states(F: dict, band=(250.0, 1000.0), min_dur_s: float = 180.0,
                min_step_db: float = 4.0, min_dur_frac: float = 0.05) -> dict:
    """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.
    """
    from .states import band_level
    st = machine_states(F, band=band, min_dur_s=min_dur_s)
    if len(st) < 2:
        return {}
    total = len(F["t"])
    floor = max(min_dur_s, min_dur_frac * total)
    if any(sum(b - a for a, b in v) < floor for v in st.values()):
        return {}
    lvl = band_level(F, band)
    meds = {}
    for label, intervals in st.items():
        m = _axis_mask(F["t"], intervals)
        meds[label] = float(np.median(lvl[m])) if m.any() else 0.0
    if max(meds.values()) - min(meds.values()) < min_step_db:
        return {}
    return st

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
def diel_states(F: dict, sess, night=(22, 6),
                labels=("night", "day")) -> dict:
    """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.
    """
    import datetime as _dt
    base = _dt.datetime.combine(sess.day0, _dt.time())
    hours = np.array([(base + _dt.timedelta(seconds=float(x))).hour
                      for x in F["t"]])
    lo, hi = night
    if lo < hi:
        night_mask = (hours >= lo) & (hours < hi)
    else:
        night_mask = (hours >= lo) | (hours < hi)
    out = {labels[0]: intervals_from_mask(F["t"], night_mask),
           labels[1]: intervals_from_mask(F["t"], ~night_mask)}
    return {k: v for k, v in out.items() if v}

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
def collect(corpus_dir: str | Path,
            pattern: str = "*/analysis/summary.json",
            include_states: bool = False) -> dict:
    """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.
    """
    corpus_dir = Path(corpus_dir)
    out = {}
    for p in sorted(corpus_dir.glob(pattern)):
        name = p.parent.parent.name
        try:
            out[name] = json.loads(p.read_text())
        except (json.JSONDecodeError, OSError):
            continue
        if include_states:
            sp = p.parent / "states.json"
            if sp.exists():
                try:
                    states = json.loads(sp.read_text()).get("states", {})
                except (json.JSONDecodeError, OSError):
                    states = {}
                for label, summ in states.items():
                    out[f"{name}::{label}"] = {
                        k: v for k, v in summ.items() if k != "intervals_s"}
    return out

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
def to_csv(collected: dict, path: str | Path, keys=None) -> Path:
    """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.
    """
    import csv
    path = Path(path)
    cols = _all_keys(collected, keys)
    with open(path, "w", newline="") as f:
        w = csv.writer(f)
        w.writerow(["session", *cols])
        for name, summary in collected.items():
            w.writerow([name, *("" if summary.get(k) is None else summary.get(k, "")
                                for k in cols)])
    return path

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
def to_markdown(collected: dict, keys=None, labels: dict | None = None) -> str:
    """Transposed Markdown table: one row per descriptor, one column per
    session (the consolidated-report layout). ``labels`` optionally maps
    descriptor keys to human labels."""
    names = list(collected)
    cols = _all_keys(collected, keys)
    labels = labels or {}
    head = "| Descriptor | " + " | ".join(names) + " |"
    sep = "|" + "---|" * (len(names) + 1)
    rows = [head, sep]
    for k in cols:
        cells = []
        for n in names:
            v = collected[n].get(k)
            cells.append("" if v is None else str(v))
        rows.append(f"| {labels.get(k, k)} | " + " | ".join(cells) + " |")
    return "\n".join(rows)

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
def rank(collected: dict, key: str, descending: bool = True) -> list:
    """(session, value) pairs sorted by ``key`` (numeric sessions only)."""
    vals = _values(collected, key)
    return sorted(vals.items(), key=lambda kv: kv[1], reverse=descending)

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
def outliers(collected: dict, key: str, z: float = 1.5) -> list:
    """(session, z-score) for sessions more than ``z`` SDs from the mean
    on ``key``, most extreme first — the cheap "what stands out" query."""
    vals = _values(collected, key)
    if len(vals) < 3:
        return []
    x = np.array(list(vals.values()))
    mu, sd = float(x.mean()), float(x.std())
    if sd == 0:
        return []
    scored = [(n, round((v - mu) / sd, 2)) for n, v in vals.items()]
    return sorted([s for s in scored if abs(s[1]) >= z],
                  key=lambda s: -abs(s[1]))

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 a date field, 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
def parse_date(name: str) -> _dt.date | None:
    """Parse a leading ``YYYY-MM-DD`` or ``YYYYMMDD`` date from a string."""
    for rx in (_ISO, _COMPACT):
        m = rx.search(name)
        if m:
            try:
                return _dt.date(int(m[1]), int(m[2]), int(m[3]))
            except ValueError:
                continue
    return None

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
def collect_series(corpus_dir: str | Path, keys=None,
                   pattern: str = "*/analysis/summary.json") -> dict:
    """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).
    """
    import json
    corpus_dir = Path(corpus_dir)
    rows = []
    for p in sorted(corpus_dir.glob(pattern)):
        name = p.parent.parent.name
        try:
            summ = json.loads(p.read_text())
        except (json.JSONDecodeError, OSError):
            continue
        d = None
        if isinstance(summ.get("date"), str):
            d = parse_date(summ["date"])
        if d is None:
            d = parse_date(name)
        if d is not None:
            rows.append((d, name, summ))
    rows.sort(key=lambda r: r[0])
    if keys is None:
        keys, seen = [], set()
        for _d, _n, s in rows:
            for k, v in s.items():
                if k not in seen and isinstance(v, (int, float)):
                    seen.add(k)
                    keys.append(k)
    series = {}
    for k in keys:
        series[k] = np.array([float(s[k]) if isinstance(s.get(k), (int, float))
                              else np.nan for _d, _n, s in rows])
    return {"dates": [r[0] for r in rows],
            "sessions": [r[1] for r in rows], "series": series}

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
def rolling_trend(dates, values, window_days: float = 365.0) -> np.ndarray:
    """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)."""
    t = _ordinals(dates)
    y = np.asarray(values, float)
    out = np.empty(len(y))
    for i in range(len(y)):
        m = np.abs(t - t[i]) <= window_days / 2
        out[i] = np.median(y[m])
    return out

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
def seasonal_climatology(dates, values):
    """Per-calendar-month mean and count of a series (month 1..12)."""
    ds, y = _finite(dates, values)
    months = np.array([d.month for d in ds])
    clim, counts = {}, {}
    for m in range(1, 13):
        sel = months == m
        counts[m] = int(sel.sum())
        clim[m] = float(y[sel].mean()) if sel.any() else np.nan
    return clim, counts

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
def decompose(dates, values, window_days: float = 365.0) -> dict:
    """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).
    """
    ds, y = _finite(dates, values)
    order = np.argsort(_ordinals(ds))
    ds = [ds[i] for i in order]
    y = y[order]
    trend = rolling_trend(ds, y, window_days)
    detr = y - trend
    clim, _counts = seasonal_climatology(ds, detr)
    vals = [v for v in clim.values() if np.isfinite(v)]
    center = float(np.mean(vals)) if vals else 0.0
    clim = {m: (v - center if np.isfinite(v) else 0.0) for m, v in clim.items()}
    months = np.array([d.month for d in ds])
    seasonal = np.array([clim[m] for m in months])
    return {"dates": ds, "trend": trend, "seasonal": seasonal,
            "residual": y - trend - seasonal, "values": y,
            "climatology": clim}

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
def trend_slope(dates, values) -> float:
    """Long-term linear change per year (least-squares slope × 365.25)."""
    ds, y = _finite(dates, values)
    if len(y) < 2:
        return 0.0
    t = _ordinals(ds)
    A = np.vstack([t - t[0], np.ones(len(t))]).T
    slope = np.linalg.lstsq(A, y, rcond=None)[0][0]
    return float(slope * 365.25)

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
def summarize_longitudinal(dates, values, window_days: float = 365.0) -> dict:
    """Trend/seasonal descriptors of one dated series."""
    ds, y = _finite(dates, values)
    if len(y) == 0:
        return {"n": 0}
    dec = decompose(ds, y, window_days)
    clim = {m: v for m, v in dec["climatology"].items()}
    finite_clim = {m: v for m, v in clim.items()
                   if seasonal_climatology(ds, y)[1][m] > 0}
    t = _ordinals(dec["dates"])
    return {
        "n": int(len(y)),
        "span_days": int(t[-1] - t[0]),
        "trend_per_year": round(trend_slope(ds, y), 3),
        "seasonal_amplitude": round(
            max(finite_clim.values()) - min(finite_clim.values()), 3)
        if finite_clim else 0.0,
        "peak_month": max(finite_clim, key=finite_clim.get)
        if finite_clim else None,
        "trough_month": min(finite_clim, key=finite_clim.get)
        if finite_clim else None,
        "residual_sd": round(float(np.std(dec["residual"])), 3),
    }

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
def render(dates, values, out_path, key: str = "", window_days: float = 365.0):
    """Two-panel figure: series + rolling trend over time, and the monthly
    climatology."""
    import matplotlib
    matplotlib.use("Agg")
    import matplotlib.pyplot as plt
    from .figures import RC, BLUE, YELLOW, MUT

    dec = decompose(dates, values, window_days)
    ds, y, trend = dec["dates"], dec["values"], dec["trend"]
    t = _ordinals(ds)
    clim = dec["climatology"]
    with plt.rc_context(RC):
        fig, ax = plt.subplots(1, 2, figsize=(12, 4), dpi=130,
                               gridspec_kw=dict(width_ratios=[2.6, 1]))
        ax[0].plot(t, y, ".", ms=3, color=BLUE, alpha=0.5, label=key or "value")
        ax[0].plot(t, trend, color=YELLOW, lw=2,
                   label=f"trend ({window_days:.0f}-day median)")
        yr = np.arange(np.ceil(ds[0].year), ds[-1].year + 1)
        ax[0].set_xticks([_dt.date(int(y_), 1, 1).toordinal() for y_ in yr],
                         [str(int(y_)) for y_ in yr])
        ax[0].set(ylabel=key or "descriptor",
                  title=f"{key} over time")
        ax[0].legend(fontsize=8)
        months = list(range(1, 13))
        ax[1].bar(months, [clim[m] for m in months], color=BLUE, alpha=0.7)
        ax[1].axhline(0, color=MUT, lw=0.7)
        ax[1].set_xticks(months, ["J", "F", "M", "A", "M", "J", "J", "A",
                                  "S", "O", "N", "D"], fontsize=8)
        ax[1].set(title="seasonal (monthly, detrended)")
        fig.tight_layout()
        fig.savefig(out_path, bbox_inches="tight")
        plt.close(fig)
    return out_path

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
def run_corpus(corpus_dir, out_dir, keys=None, window_days: float = 365.0) -> dict:
    """CLI driver: per-descriptor longitudinal summaries + a figure for each,
    writing ``longitudinal.json``."""
    import json
    out_dir = Path(out_dir)
    out_dir.mkdir(parents=True, exist_ok=True)
    s = collect_series(corpus_dir, keys=keys)
    doc = {"n_sessions": len(s["dates"]),
           "date_range": [s["dates"][0].isoformat(), s["dates"][-1].isoformat()]
           if s["dates"] else None,
           "descriptors": {}}
    for k, y in s["series"].items():
        if np.isfinite(y).sum() >= 3:
            doc["descriptors"][k] = summarize_longitudinal(s["dates"], y,
                                                           window_days)
    (out_dir / "longitudinal.json").write_text(json.dumps(doc, indent=2))
    return doc

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 - L90 and 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
def load_comparison(folders: list[str | Path]) -> list[dict]:
    """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.
    """
    out = []
    for folder in folders:
        folder = Path(folder)
        adir = folder / "analysis"
        paths = sorted((adir / "features").glob("*.npz"))
        if not paths:
            raise FileNotFoundError(
                f"no cached features in {adir} — run 'ambiscape analyze'")
        summary = {}
        sp = adir / "summary.json"
        if sp.exists():
            summary = json.loads(sp.read_text())
        states = None
        stp = adir / "states.json"
        if stp.exists():
            states = json.loads(stp.read_text()).get("states")
        date = parse_date(str(summary.get("date") or folder.name))
        out.append({"name": folder.name, "folder": folder, "date": date,
                    "F": load_features(paths), "summary": summary,
                    "states": states})
    return out

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
def in_intervals(t: np.ndarray, intervals) -> np.ndarray:
    """Boolean mask of ``t`` covered by ``[a, b)`` interval pairs."""
    m = np.zeros(len(t), bool)
    for a, b in intervals:
        m |= (t >= a) & (t < b)
    return m

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
def state_mask(sess: dict, state: str, min_s: float = 0.0) -> np.ndarray | None:
    """1 Hz mask for one named state; None if the session has no states."""
    if not sess["states"] or state not in sess["states"]:
        return None
    ivs = [iv for iv in sess["states"][state]["intervals_s"]
           if iv[1] - iv[0] >= min_s]
    return in_intervals(sess["F"]["t"], ivs)

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
def laeq_timeline(F: dict, bin_s: float = 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.
    """
    tf, x = F["t_fast"], F["fast_dba"]
    b0 = np.floor(tf[0] / bin_s)
    idx = (np.floor(tf / bin_s) - b0).astype(int)
    p = np.zeros(idx.max() + 1)
    n = np.zeros(idx.max() + 1)
    np.add.at(p, idx, 10 ** (x / 10))
    np.add.at(n, idx, 1)
    full = np.median(n[n > 0])
    la = 10 * np.log10(p / np.maximum(n, 1) + EPS)
    la[n < 0.75 * full] = np.nan
    t = (b0 + np.arange(len(p))) * bin_s + bin_s / 2
    return t, la

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
def clock_rows(sessions: list[dict]) -> list[list[tuple[int, float]]]:
    """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.
    """
    spans = []
    for i, s in enumerate(sessions):
        t = s["F"]["t"]
        if s["date"] is None:
            spans.append((None, None, i))
            continue
        o = s["date"].toordinal()
        spans.append((o + t[0] / 86400, o + t[-1] / 86400, i))
    rows, used = [], set()
    for a0, a1, i in sorted(spans, key=lambda x: (x[0] is None, x[0])):
        if i in used:
            continue
        if a0 is None:
            rows.append([(i, 0.0)])
            used.add(i)
            continue
        ref = sessions[i]["date"].toordinal()
        row, hi = [(i, 0.0)], a1
        used.add(i)
        for b0, b1, j in sorted(spans, key=lambda x: (x[0] is None, x[0])):
            if j in used or b0 is None:
                continue
            if b0 <= hi + 0.5:              # touches within 12 h: same row
                row.append((j, 24.0 * (sessions[j]["date"].toordinal() - ref)))
                hi = max(hi, b1)
                used.add(j)
        rows.append(row)
    return rows

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
def timeline_figure(sessions: list[dict], out_path: str | Path,
                    state: str = "machine_on", x0_hour: float | None = None,
                    colors=None):
    """Clock-aligned LAeq rows; intervals of ``state`` shaded per session."""
    import matplotlib
    matplotlib.use("Agg")
    import matplotlib.pyplot as plt
    colors = colors or _COLORS
    rows = clock_rows(sessions)
    lo = min(sessions[i]["F"]["t"][0] / 3600 + sh
             for row in rows for i, sh in row)
    hi = max(sessions[i]["F"]["t"][-1] / 3600 + sh
             for row in rows for i, sh in row)
    x0 = np.floor(lo) if x0_hour is None else x0_hour
    fig, axes = plt.subplots(len(rows), 1, figsize=(13, 3.2 * len(rows)),
                             sharey=True, squeeze=False)
    for ax, row in zip(axes[:, 0], rows):
        for i, shift in row:
            s = sessions[i]
            c = colors[i % len(colors)]
            t, la = laeq_timeline(s["F"])
            ax.plot(t / 3600 + shift - x0, la, color=c, lw=0.8,
                    label=s["name"])
            if s["states"] and state in s["states"]:
                for a, b in s["states"][state]["intervals_s"]:
                    if b - a > 300:
                        ax.axvspan(a / 3600 + shift - x0,
                                   b / 3600 + shift - x0,
                                   color=c, alpha=0.12, lw=0)
        ax.set_ylabel("LAeq 1-min (dBFS)")
        ax.set_xlim(0, np.ceil(hi) - x0)
        ticks = np.arange(0, np.ceil(hi) - x0 + 1, 2)
        ax.set_xticks(ticks)
        ax.set_xticklabels([f"{int((x0 + h) % 24):02d}" for h in ticks])
        ax.grid(alpha=0.25, lw=0.5)
        ax.legend(loc="upper right", frameon=False, fontsize=8)
    axes[-1, 0].set_xlabel(f"clock (h); shaded = {state}")
    fig.tight_layout()
    fig.savefig(out_path, dpi=150)
    plt.close(fig)
    return Path(out_path)

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
def band_centers(F: dict) -> np.ndarray:
    """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.
    """
    logf = F["logf"]
    if len(logf) == F["logspec"].shape[1] + 1:
        return np.sqrt(logf[:-1] * logf[1:])
    return logf

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
def ltas_by_state(sess: dict, min_state_s: float = 0.0) -> dict:
    """Median band spectra (dB) for the whole session and each state."""
    ls = sess["F"]["logspec"]
    out = {"all": 10 * np.log10(np.median(ls, 0) + EPS)}
    for name in (sess["states"] or {}):
        m = state_mask(sess, name, min_state_s)
        if m is not None and m.any():
            out[name] = 10 * np.log10(np.median(ls[m], 0) + EPS)
    return out

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
def ltas_figure(sessions: list[dict], out_path: str | Path,
                min_state_s: float = 0.0, fmin: float = 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.
    """
    import matplotlib
    matplotlib.use("Agg")
    import matplotlib.pyplot as plt
    colors = colors or _COLORS
    styles = ["-", "--", ":", "-."]
    fig, ax = plt.subplots(figsize=(9, 5.5))
    for i, s in enumerate(sessions):
        fc = band_centers(s["F"])
        sel = fc >= fmin
        curves = ltas_by_state(s, min_state_s)
        names = [n for n in curves if n != "all"] or ["all"]
        for j, name in enumerate(names):
            label = s["name"] if name == "all" else f"{s['name']}: {name}"
            ax.plot(fc[sel], curves[name][sel], styles[j % len(styles)],
                    color=colors[i % len(colors)], lw=1.6, label=label)
    ax.set_xscale("log")
    ax.set_xlim(fmin, fc[-1])
    ax.set_xlabel("Frequency (Hz)")
    ax.set_ylabel("Median band level (dBFS)")
    ax.grid(alpha=0.25, lw=0.5, which="both")
    ax.legend(frameon=False, fontsize=8)
    fig.tight_layout()
    fig.savefig(out_path, dpi=150)
    plt.close(fig)
    return Path(out_path)

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
def line_prominence(sess: dict, freqs_hz, mask_min=None,
                    halfwidth_hz: float = 15.0,
                    bg_halfwidth_hz: float = 40.0) -> dict:
    """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"}}``.
    """
    freqs = sess["F"]["freqs"]
    ms = sess["F"]["minspec"]
    if mask_min is not None:
        ms = ms[mask_min]
    spec = 10 * np.log10(np.median(ms, 0) + EPS)
    out = {}
    for f0 in freqs_hz:
        w = (freqs > f0 - halfwidth_hz) & (freqs < f0 + halfwidth_hz)
        bg = ((freqs > f0 - bg_halfwidth_hz) & (freqs < f0 + bg_halfwidth_hz)
              & ~w)
        out[f0] = {
            "peak_hz": round(float(freqs[w][np.argmax(spec[w])]), 1),
            "prominence_db": round(float(spec[w].max() - np.median(spec[bg])),
                                   1)}
    return out

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
def floor_difference(sess_a: dict, hours_a, sess_b: dict, 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)``.
    """
    def med(sess, hours):
        mt = sess["F"]["min_t"]
        m = (mt >= hours[0] * 3600) & (mt < hours[1] * 3600)
        if not m.any():
            raise ValueError(f"no minutes in {hours} for {sess['name']}")
        return 10 * np.log10(np.median(sess["F"]["minspec"][m], 0) + EPS)
    return sess_a["F"]["freqs"], med(sess_a, hours_a) - med(sess_b, hours_b)

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
def band_level(F: dict, f0: float, f1: float) -> np.ndarray:
    """1 Hz level (dB) of the summed log-spectrum bands inside [f0, f1]."""
    fc = band_centers(F)
    sel = (fc >= f0) & (fc < f1)
    return 10 * np.log10(F["logspec"][:, sel].sum(1) + EPS)

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
def band_timeline_figure(sessions: list[dict], out_path: str | Path,
                         f0: float, f1: float, hours=None,
                         smooth_s: int = 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.
    """
    import matplotlib
    matplotlib.use("Agg")
    import matplotlib.pyplot as plt
    from scipy.ndimage import median_filter
    colors = colors or _COLORS
    fig, ax = plt.subplots(figsize=(10, 4.5))
    for i, s in enumerate(sessions):
        t = s["F"]["t"] / 3600
        e = band_level(s["F"], f0, f1)
        m = np.ones(len(t), bool) if hours is None else \
            (t >= hours[0]) & (t <= hours[1])
        if not m.any():
            continue
        ax.plot(t[m], median_filter(e[m], min(smooth_s, int(m.sum()))),
                color=colors[i % len(colors)], lw=1.4, label=s["name"])
    ax.set_xlabel("clock (h; > 24 = day 2)")
    ax.set_ylabel(f"{f0:.0f}-{f1:.0f} Hz level (dB, running median)")
    ax.grid(alpha=0.25, lw=0.5)
    ax.legend(frameon=False, fontsize=8)
    fig.tight_layout()
    fig.savefig(out_path, dpi=150)
    plt.close(fig)
    return Path(out_path)

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
def azimuth_rose_figure(sessions: list[dict], out_path: str | Path,
                        fg_quantile: float = 0.75, nbins: int = 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.
    """
    import matplotlib
    matplotlib.use("Agg")
    import matplotlib.pyplot as plt
    colors = colors or _COLORS
    n = len(sessions)
    fig, axes = plt.subplots(1, n, figsize=(4.2 * n, 4.4),
                             subplot_kw={"projection": "polar"},
                             squeeze=False)
    bins = np.linspace(-np.pi, np.pi, nbins + 1)
    for i, (ax, s) in enumerate(zip(axes[0], sessions)):
        w = s["F"]["rms_w"] ** 2
        sel = w >= np.quantile(w, fg_quantile)
        hist, _ = np.histogram(np.deg2rad(s["F"]["az"][sel]), bins=bins,
                               weights=w[sel])
        hist /= hist.max() + EPS
        ax.bar((bins[:-1] + bins[1:]) / 2, hist, width=np.diff(bins),
               color=colors[i % len(colors)], alpha=0.85, lw=0)
        ax.set_theta_zero_location("N")
        ax.set_theta_direction(-1)
        ax.set_title(s["name"], fontsize=9)
        ax.set_yticklabels([])
    fig.suptitle("Foreground energy by azimuth (mic frame)", fontsize=11)
    fig.tight_layout()
    fig.savefig(out_path, dpi=150)
    plt.close(fig)
    return Path(out_path)

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
def duty_cycle(F: dict, t0: float, t1: float, f0: float = 63.0,
               f1: float = 500.0, period_range=(300.0, 5400.0),
               smooth_s: int = 61) -> dict:
    """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.
    """
    from scipy.ndimage import median_filter
    t = F["t"]
    m = (t >= t0) & (t < t1)
    e = band_level(F, f0, f1)[m]
    if len(e) < 4 * period_range[0]:
        raise ValueError("window too short for the requested period range")
    sm = median_filter(e, smooth_s)
    q10, q90 = np.quantile(sm, 0.1), np.quantile(sm, 0.9)
    on = sm > (q10 + q90) / 2
    x = on.astype(float) - on.mean()
    ac = np.correlate(x, x, "full")[len(x) - 1:]
    ac /= ac[0] + EPS
    lo, hi = int(period_range[0]), min(int(period_range[1]), len(ac) - 1)
    per = int(np.argmax(ac[lo:hi]) + lo)
    return {"period_min": round(per / 60, 1),
            "duty_pct": round(100 * float(on.mean()), 1),
            "acf_peak": round(float(ac[per]), 2),
            "swing_db": round(float(q90 - q10), 1)}

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
def xnode_day_matrix(day_levels: dict, bin_s: int = 300,
                     min_cover_s: int = 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.
    """
    names = sorted(day_levels)
    nsec = len(next(iter(day_levels.values())))
    nbin = nsec // bin_s
    A = np.full((len(names), nbin), np.nan)
    for i, n in enumerate(names):
        arr = np.asarray(day_levels[n], float)
        for b in range(nbin):
            seg = arr[b * bin_s:(b + 1) * bin_s]
            if np.isfinite(seg).sum() > min_cover_s:
                A[i, b] = 10 * np.log10(np.nanmean(10 ** (seg / 10)))
    med = np.array([np.nanmedian(np.asarray(day_levels[n], float))
                    for n in names])
    return names, A, A - med[:, None]

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
def xnode_floor(arr, pct: float = 5.0, floor_suspect: bool = False,
                adjust_db: float = 3.0) -> float:
    """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.
    """
    a = np.asarray(arr, float)
    a = a[np.isfinite(a)]
    if len(a) == 0:
        return np.nan
    return float(np.percentile(a, pct)) + (adjust_db if floor_suspect
                                           else 0.0)

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
def xnode_gain_offsets(floors_db: dict) -> dict:
    """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.
    """
    vals = [float(v) for v in floors_db.values() if np.isfinite(v)]
    if not vals:
        return {n: 0.0 for n in floors_db}
    ref = float(np.median(vals))
    return {n: (float(v) - ref if np.isfinite(v) else 0.0)
            for n, v in floors_db.items()}

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:

  1. margin rule — its gain-corrected level beats every other node's by more than margin_db. Uncalibrated nodes differ by sensor gain, so gain_offsets_db (see :func:xnode_gain_offsets) is subtracted from A first; a fractional-dB win says nothing about the sound, so near-ties stay unmarked instead of one node sweeping the night.
  2. floor rule — its absolute level A clears that node's noise floor (floors_db, e.g. from :func:xnode_floor, already floor_suspect-adjusted) by at least floor_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
def xnode_loudest(names: list, A: np.ndarray,
                  floors_db: dict | None = None, margin_db: float = 3.0,
                  floor_clear_db: float = 3.0,
                  gain_offsets_db: dict | None = None) -> list:
    """Loudest node per clock bin — only where the call is defensible.

    A bin is awarded to a node only when **both** hold:

    1. *margin rule* — its gain-corrected level beats every other node's
       by more than ``margin_db``. Uncalibrated nodes differ by sensor
       gain, so ``gain_offsets_db`` (see :func:`xnode_gain_offsets`) is
       subtracted from ``A`` first; a fractional-dB win says nothing about
       the sound, so near-ties stay unmarked instead of one node sweeping
       the night.
    2. *floor rule* — its absolute level ``A`` clears that node's noise
       floor (``floors_db``, e.g. from :func:`xnode_floor`, already
       ``floor_suspect``-adjusted) by at least ``floor_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.
    """
    off = np.array([(gain_offsets_db or {}).get(n, 0.0) for n in names],
                   float)
    G = np.asarray(A, float) - off[:, None]
    out = []
    for b in range(G.shape[1]):
        col = G[:, b]
        if np.isfinite(col).sum() < 2:
            out.append(None)
            continue
        order = np.argsort(np.where(np.isfinite(col), col, -np.inf))
        w, runner = order[-1], order[-2]
        if col[w] - col[runner] <= margin_db:
            out.append(None)
            continue
        if floors_db is not None:
            fl = floors_db.get(names[w], np.nan)
            if np.isfinite(fl) and not (A[w, b] >= fl + floor_clear_db):
                out.append(None)
                continue
        out.append(names[w])
    return out

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
def xnode_figure(names: list, H: np.ndarray, loudest: list,
                 out_path: str | Path, title: str = "",
                 labels: dict | None = None, margin_db: float = 3.0,
                 floor_clear_db: float = 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.
    """
    import matplotlib
    matplotlib.use("Agg")
    import matplotlib.pyplot as plt
    nbin = H.shape[1]
    fig = plt.figure(figsize=(12.8, 2.3 + 0.6 * len(names)), dpi=130)
    ax = _xnode_axes(fig, len(names))
    t = (np.arange(nbin) + 0.5) * 24 / nbin       # bin centres, hours
    t_edges = np.arange(nbin + 1) * 24 / nbin
    cmap = plt.get_cmap("magma").with_extremes(bad="0.88")  # no data: grey
    im = ax[0].pcolormesh(t_edges, np.arange(len(names) + 1),
                          np.ma.masked_invalid(H), cmap=cmap,
                          shading="flat")
    ax[0].set_yticks(np.arange(len(names)) + 0.5,
                     [(labels or {}).get(n, str(n)) for n in names])
    ax[0].tick_params(labelbottom=False)
    if title:
        ax[0].set_title(title)
    fig.colorbar(im, cax=ax[2], label="dB re day median")
    ld = np.array([names.index(v) if v is not None else np.nan
                   for v in loudest], float)
    awarded = int(np.isfinite(ld).sum())
    ax[1].scatter(t, ld, s=18, c=ld, cmap="tab10", vmin=0,
                  vmax=max(len(names), 10), zorder=3)
    for y in range(len(names)):                   # guides: a lone dot needs
        ax[1].axhline(y, color="0.92", lw=0.6, zorder=0)   # a row to sit on
    ax[1].set_yticks(range(len(names)), [str(n) for n in names])
    ax[1].tick_params(axis="y", labelsize=7)   # one unit tall, n rows in it
    ax[1].set_ylim(-0.5, len(names) - 0.5)
    ax[1].set_xlim(0, 24)
    ax[1].set_xticks(_hour_ticks())
    ax[1].set(xlabel="hour of day", ylabel="highest above\nits own floor")
    # left margin holds the node labels ("node 7 (living)"); too small and
    # they are silently clipped rather than shrunk
    longest = max((len(str((labels or {}).get(n, n))) for n in names),
                  default=6)
    fig.subplots_adjust(left=min(0.06 + 0.008 * longest, 0.22),
                        right=0.93, top=0.93, bottom=0.16)
    fig.text(0.5, 0.02,
             f"NOT which room is loudest: nodes are uncalibrated, so this "
             f"ranks each node's level above its own noise floor, which "
             f"favours the deepest-floored node. Marked only where the "
             f"inter-node margin exceeds "
             f"{margin_db:g} dB (normalized levels) and the level clears "
             f"the node's floor_suspect-adjusted noise floor by "
             f"{floor_clear_db:g} dB; blank = near-tie or near-floor. "
             f"Grey = no data. {awarded} of {nbin} bins awarded.",
             ha="center", fontsize=7, color="0.35")
    fig.savefig(out_path)
    plt.close(fig)
    return Path(out_path)

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
def run_compare(folders: list[str | Path], out_dir: str | Path,
                lines=None, band=None, hours=None,
                state: str = "machine_on") -> dict:
    """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.
    """
    out_dir = Path(out_dir)
    out_dir.mkdir(parents=True, exist_ok=True)
    sessions = load_comparison(folders)
    doc = {"sessions": [s["name"] for s in sessions],
           "pooled": {s["name"]: {k: s["summary"].get(k)
                                  for k in _TABLE_KEYS}
                      for s in sessions},
           "states": {s["name"]: {n: {k: v[k] for k in _TABLE_KEYS
                                      if k in v}
                                  for n, v in (s["states"] or {}).items()}
                      for s in sessions},
           "figures": {}}
    doc["figures"]["timelines"] = str(
        timeline_figure(sessions, out_dir / "compare_timelines.png",
                        state=state))
    doc["figures"]["ltas"] = str(
        ltas_figure(sessions, out_dir / "compare_ltas.png"))
    doc["figures"]["roses"] = str(
        azimuth_rose_figure(sessions, out_dir / "compare_roses.png"))
    if lines:
        doc["line_prominence"] = {}
        for s in sessions:
            m = None
            if s["states"] and state in s["states"]:
                m = in_intervals(s["F"]["min_t"],
                                 s["states"][state]["intervals_s"])
                m = m if m.any() else None
            doc["line_prominence"][s["name"]] = {
                str(f0): v for f0, v in
                line_prominence(s, lines, mask_min=m).items()}
    if band:
        doc["figures"]["band"] = str(
            band_timeline_figure(sessions, out_dir / "compare_band.png",
                                 band[0], band[1], hours=hours))
    (out_dir / "compare.json").write_text(json.dumps(doc, indent=1))
    return doc

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 --- the net_ rows folded into summary.json for 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
def load_network(folder: str | Path) -> list[dict]:
    """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.
    """
    folder = Path(folder)
    subs = sorted(p for p in folder.iterdir() if p.is_dir()
                  and list((p / "analysis" / "features").glob("*.npz")))
    if len(subs) < 2:
        raise FileNotFoundError(
            f"need at least two analysed node sessions under {folder} — "
            "run 'ambiscape analyze' in each node folder first")
    return load_comparison(subs)

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
def node_grid(nodes: list[dict]) -> tuple[np.ndarray, np.ndarray]:
    """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.
    """
    dt = float(FAST)
    d0 = nodes[0]["date"]
    shifts = [86400.0 * (s["date"].toordinal() - d0.toordinal())
              if d0 is not None and s["date"] is not None else 0.0
              for s in nodes]
    t0 = min(s["F"]["t_fast"][0] + sh for s, sh in zip(nodes, shifts))
    t1 = max(s["F"]["t_fast"][-1] + sh for s, sh in zip(nodes, shifts))
    n = int(round((t1 - t0) / dt)) + 1
    t = t0 + dt * np.arange(n)
    X = np.full((len(nodes), n), np.nan, np.float32)
    for row, (s, sh) in enumerate(zip(nodes, shifts)):
        idx = np.round((s["F"]["t_fast"] + sh - t0) / dt).astype(int)
        ok = (idx >= 0) & (idx < n)
        X[row, idx[ok]] = s["F"]["fast_dba"][ok]
    return t, X

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
def pairwise_coupling(t: np.ndarray, X: np.ndarray, win_s: float = 120.0,
                      max_lag_s: float = 4.0,
                      min_coverage: float = 0.9) -> dict:
    """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.
    """
    dt = float(t[1] - t[0])
    L = int(round(win_s / dt))
    K = int(round(max_lag_s / dt))
    if L <= 2 * K:
        raise ValueError("window shorter than the lag search range")
    nn = X.shape[0]
    nwin = X.shape[1] // L
    win_t = float(t[0]) + win_s * (np.arange(nwin) + 0.5)
    coupling = np.full((nwin, nn, nn), np.nan)
    lag = np.full((nwin, nn, nn), np.nan)
    ramp = np.arange(L) - (L - 1) / 2
    ramp_ss = float((ramp ** 2).sum())
    counts = L - np.abs(np.arange(-K, K + 1))
    for w in range(nwin):
        seg = X[:, w * L:(w + 1) * L].astype(float)
        pre = []
        for i in range(nn):
            x, m = seg[i], np.isfinite(seg[i])
            if m.mean() < min_coverage:
                pre.append(None)
                continue
            x = np.where(m, x, x[m].mean()) - x[m].mean()
            x = x - ramp * float((x * ramp).sum() / ramp_ss)   # detrend
            sd = float(np.sqrt((x ** 2).mean()))
            pre.append((x, sd) if sd > 1e-6 else None)
        for i in range(nn):
            for j in range(i + 1, nn):
                if pre[i] is None or pre[j] is None:
                    continue
                (xi, si), (xj, sj) = pre[i], pre[j]
                cc = np.correlate(xi, xj, "full")[L - 1 - K:L + K]
                r = cc / (counts * si * sj)
                p = int(np.argmax(r))
                k = float(p - K)
                if 0 < p < 2 * K:      # parabolic sub-sample refinement
                    den = r[p - 1] - 2 * r[p] + r[p + 1]
                    if den < 0:
                        k += 0.5 * float(r[p - 1] - r[p + 1]) / float(den)
                coupling[w, i, j] = coupling[w, j, i] = min(float(r[p]), 1.0)
                # peak at k = -s when x_j trails x_i by s samples
                lag[w, i, j] = -k * dt
                lag[w, j, i] = k * dt
    return {"win_t": win_t, "coupling": coupling, "lag_s": lag}

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
def follow_source(t: np.ndarray, X: np.ndarray, t0: float, t1: float,
                  names: list[str] | None = None,
                  baseline_s: float = 600.0, slice_s: float = 10.0,
                  min_rise_db: float = 6.0) -> dict:
    """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.
    """
    t = np.asarray(t, float)
    X = np.asarray(X, float)
    if X.ndim != 2 or X.shape[1] != len(t):
        raise ValueError("X must be (n_nodes, len(t)), as node_grid returns")
    if names is not None and len(names) != X.shape[0]:
        raise ValueError("names must have one entry per row of X")

    base_m = (t >= t0 - baseline_s) & (t < t0)
    with np.errstate(invalid="ignore"):
        ref = np.nanmedian(X[:, base_m], axis=1) if base_m.any() else np.full(
            X.shape[0], np.nan)

    slice_t, best, rise = [], [], []
    edges = np.arange(t0, t1, slice_s)
    for w0 in edges:
        m = (t >= w0) & (t < min(w0 + slice_s, t1))
        if m.sum() < 2:
            continue
        with np.errstate(invalid="ignore"):
            v = np.nanmedian(X[:, m], axis=1) - ref
        if not np.isfinite(v).any():
            continue
        i = int(np.nanargmax(v))
        slice_t.append(float(w0))
        best.append(i if np.isfinite(v[i]) and v[i] >= min_rise_db else -1)
        rise.append(float(v[i]) if np.isfinite(v[i]) else float("nan"))

    label = (lambda i: names[i]) if names is not None else (lambda i: i)
    itinerary: list[dict] = []
    for w0, i in zip(slice_t, best):
        if i < 0:
            continue
        if itinerary and itinerary[-1]["name"] == label(i):
            itinerary[-1]["t1"] = w0 + slice_s
        else:
            itinerary.append({"name": label(i), "t0": w0, "t1": w0 + slice_s})
    return {"slice_t": np.array(slice_t), "best": np.array(best, int),
            "rise_db": np.array(rise), "itinerary": itinerary,
            "n_changes": max(0, len(itinerary) - 1),
            "n_visited": len({s["name"] for s in itinerary})}

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
def graph_measures(coupling: np.ndarray, threshold: float = 0.35) -> dict:
    """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.
    """
    C = np.asarray(coupling, float)
    n = C.shape[1]
    valid = np.isfinite(C).any((1, 2))
    strength = np.where(np.isfinite(C), C, 0.0).sum(2)
    A = (np.nan_to_num(C, nan=-1.0) >= threshold).astype(float)
    density = A.sum((1, 2)) / (n * (n - 1))
    deg = A.sum(2)
    triplets = (deg * (deg - 1)).sum(1) / 2
    closed = np.einsum("wij,wjk,wki->w", A, A, A) / 2      # 3 × triangles
    trans = np.where(triplets > 0, closed / np.maximum(triplets, 1), np.nan)
    strength[~valid] = np.nan
    density[~valid] = np.nan
    trans[~valid] = np.nan
    return {"strength": strength, "density": density, "transitivity": trans}

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
def hourly_measures(win_t: np.ndarray, coupling: np.ndarray,
                    threshold: float = 0.35) -> dict:
    """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.
    """
    gm = graph_measures(coupling, threshold)
    hod = (win_t // 3600).astype(int) % 24
    valid = np.isfinite(gm["density"])
    hours = sorted(set(hod[valid].tolist()))
    dens, stre, cnt = [], [], []
    for h in hours:
        m = valid & (hod == h)
        dens.append(float(np.median(gm["density"][m])))
        stre.append(np.median(gm["strength"][m], 0))
        cnt.append(int(m.sum()))
    stre = np.array(stre) if stre else np.zeros((0, coupling.shape[1]))
    return {"hours": np.array(hours, int), "density": np.array(dens),
            "strength": stre,
            "hub": stre.argmax(1) if len(stre) else np.array([], int),
            "n_windows": np.array(cnt, int)}

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
def representative_hours(hourly: dict, n: int = 3) -> list[int]:
    """Up to ``n`` hours spanning the density range: quietest of the day,
    the median hour, and the busiest — the graph's states worth drawing."""
    hs, d = hourly["hours"], hourly["density"]
    if len(hs) <= n:
        return [int(h) for h in hs]
    order = np.argsort(d, kind="stable")
    picks = [order[0], order[len(order) // 2], order[-1]]
    out = []
    for p in picks:
        if int(hs[p]) not in out:
            out.append(int(hs[p]))
    return sorted(out)

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
def network_figure(names: list[str], res: dict, out_path: str | Path,
                   threshold: float = 0.35, hours: list[int] | None = None,
                   min_lag_s: float = 0.1) -> Path:
    """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.
    """
    import matplotlib
    matplotlib.use("Agg")
    import matplotlib.pyplot as plt
    hourly = hourly_measures(res["win_t"], res["coupling"], threshold)
    if hours is None:
        hours = representative_hours(hourly)
    hours = hours or [int(res["win_t"][0] // 3600) % 24]
    n = len(names)
    ang = np.pi / 2 - 2 * np.pi * np.arange(n) / n
    pos = np.stack([np.cos(ang), np.sin(ang)], 1)
    hod = (res["win_t"] // 3600).astype(int) % 24
    ncol = max(len(hours), 2)
    fig = plt.figure(figsize=(3.9 * ncol, 6.2))
    gs = fig.add_gridspec(2, ncol, height_ratios=[1.9, 1], hspace=0.25)
    for col, h in enumerate(hours):
        ax = fig.add_subplot(gs[0, col])
        m = hod == h
        C = _nanmed(res["coupling"][m]) if m.any() else \
            np.full((n, n), np.nan)
        Lg = _nanmed(res["lag_s"][m]) if m.any() else C
        for i in range(n):
            for j in range(i + 1, n):
                c = C[i, j]
                if not np.isfinite(c) or c < threshold:
                    continue
                ax.plot(pos[[i, j], 0], pos[[i, j], 1], color="0.6",
                        lw=0.5 + 5 * c, alpha=0.8, zorder=1,
                        solid_capstyle="round")
                if abs(Lg[i, j]) >= min_lag_s:
                    lead, trail = (i, j) if Lg[i, j] > 0 else (j, i)
                    mid = (pos[lead] + pos[trail]) / 2
                    d = pos[trail] - pos[lead]
                    d = d / (np.hypot(*d) + EPS)
                    ax.annotate("", xy=mid + 0.14 * d, xytext=mid - 0.14 * d,
                                arrowprops={"arrowstyle": "-|>",
                                            "color": "#1a4f8f", "lw": 1.4},
                                zorder=2)
                    ax.text(*(mid + 0.17 * np.array([-d[1], d[0]])),
                            f"{abs(Lg[i, j]):.2f} s", fontsize=7,
                            ha="center", va="center", color="#1a4f8f")
        strength = np.where(np.isfinite(C), C, 0.0).sum(1)
        ax.scatter(pos[:, 0], pos[:, 1],
                   s=200 + 900 * strength / (strength.max() + EPS),
                   c="#2a78d6", alpha=0.9, zorder=3, edgecolors="white")
        for i, name in enumerate(names):
            ax.annotate(name, pos[i] * 1.3, ha="center", va="center",
                        fontsize=8)
        ax.set_title(f"{h:02d}:00–{h + 1:02d}:00 ({int(m.sum())} windows)",
                     fontsize=9)
        ax.set_xlim(-1.6, 1.6)
        ax.set_ylim(-1.6, 1.6)
        ax.set_aspect("equal")
        ax.axis("off")
    gm = graph_measures(res["coupling"], threshold)
    axd = fig.add_subplot(gs[1, :])
    th = res["win_t"] / 3600
    axd.plot(th, gm["density"], ".", ms=3, color="0.65", label="per window")
    bins = np.unique((res["win_t"] // 3600).astype(int))
    med = [_nanmed(gm["density"][(res["win_t"] // 3600).astype(int) == b])
           for b in bins]
    axd.step(bins, med, where="post", lw=1.5, color="#2a78d6",
             label="hourly median")
    for b in bins:
        if int(b) % 24 in hours:
            axd.axvspan(b, b + 1, color="#2a78d6", alpha=0.08, lw=0)
    axd.set_xlabel("clock (h; > 24 = day 2); shaded = hours drawn above")
    axd.set_ylabel("edge density")
    axd.set_ylim(-0.05, 1.05)
    axd.grid(alpha=0.25, lw=0.5)
    axd.legend(frameon=False, fontsize=8, loc="upper right")
    fig.suptitle("Acoustic network — node size = strength, edge width = "
                 "coupling, arrows lead → lag", fontsize=11)
    fig.savefig(out_path, dpi=150, bbox_inches="tight")
    plt.close(fig)
    return Path(out_path)

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
def network_summary_keys(doc: dict) -> dict:
    """The ``net_`` rows folded into ``summary.json`` for the catalogue."""
    keys = {"net_n_nodes": len(doc["nodes"]),
            "net_density_median": doc["density_median"],
            "net_transitivity_median": doc["transitivity_median"],
            "net_hub_node": doc["hub_node"],
            "net_hub_strength": doc["strength_median"].get(doc["hub_node"])
            if doc["hub_node"] else None}
    if doc.get("strongest_pair"):
        sp = doc["strongest_pair"]
        keys["net_max_coupling"] = sp["coupling"]
        keys["net_max_pair"] = "→".join(sp["nodes"])
        keys["net_max_lag_s"] = sp["lag_s"]
    return keys

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
def run_network(folder: str | Path, out_dir: str | Path | None = None,
                win_s: float = 120.0, max_lag_s: float = 4.0,
                threshold: float = 0.35, min_coverage: float = 0.9) -> dict:
    """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.
    """
    folder = Path(folder)
    out = Path(out_dir) if out_dir else folder / "analysis"
    out.mkdir(parents=True, exist_ok=True)
    nodes = load_network(folder)
    names = [s["name"] for s in nodes]
    t, X = node_grid(nodes)
    res = pairwise_coupling(t, X, win_s=win_s, max_lag_s=max_lag_s,
                            min_coverage=min_coverage)
    gm = graph_measures(res["coupling"], threshold)
    n_valid = int(np.isfinite(gm["density"]).sum())
    if n_valid == 0:
        raise ValueError("no window with two overlapping nodes — check the "
                         "recorders share a clock and a time span")
    hourly = hourly_measures(res["win_t"], res["coupling"], threshold)
    C_med = _nanmed(res["coupling"])
    L_med = _nanmed(res["lag_s"])
    strength_med = _nanmed(gm["strength"])
    hub = int(np.nanargmax(strength_med))
    iu = np.triu_indices(len(names), 1)
    strongest = None
    if np.isfinite(C_med[iu]).any():
        b = int(np.nanargmax(C_med[iu]))
        i, j = int(iu[0][b]), int(iu[1][b])
        if np.isfinite(L_med[i, j]) and L_med[i, j] < 0:
            i, j = j, i                       # order leader first
        strongest = {"nodes": [names[i], names[j]],
                     "coupling": _r(C_med[i, j]),
                     "lag_s": _r(L_med[i, j])}
    doc = {"nodes": names,
           "params": {"win_s": win_s, "max_lag_s": max_lag_s,
                      "threshold": threshold,
                      "rate_hz": round(1.0 / float(FAST), 3)},
           "n_windows": n_valid,
           "coupling_median": [[_r(v) for v in row] for row in C_med],
           "lag_median_s": [[_r(v) for v in row] for row in L_med],
           "strength_median": {nm: _r(v)
                               for nm, v in zip(names, strength_med)},
           "hub_node": names[hub],
           "density_median": _r(_nanmed(gm["density"])),
           "transitivity_median": _r(_nanmed(gm["transitivity"])),
           "hourly": {f"{int(h):02d}": {
               "density_median": _r(d),
               "hub_node": names[int(hb)],
               "n_windows": int(c)}
               for h, d, hb, c in zip(hourly["hours"], hourly["density"],
                                      hourly["hub"], hourly["n_windows"])},
           "strongest_pair": strongest,
           "figures": {}}
    doc["figures"]["network"] = str(
        network_figure(names, res, out / "network.png", threshold=threshold))
    (out / "network.json").write_text(json.dumps(doc, indent=2))
    sp = out / "summary.json"
    summary = json.loads(sp.read_text()) if sp.exists() else {}
    summary.update(network_summary_keys(doc))
    sp.write_text(json.dumps(summary, indent=2))
    return doc

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 proxy gamma_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
def load_geometry(geometry) -> dict:
    """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.
    """
    if isinstance(geometry, (str, Path)):
        geometry = json.loads(Path(geometry).read_text())
    if isinstance(geometry, dict) and "pos" in geometry:
        return geometry                        # already loaded
    if isinstance(geometry, dict):
        mics, c = geometry.get("mics"), float(geometry.get("c", C_SOUND))
    else:
        mics, c = geometry, C_SOUND
    pos = np.asarray(mics, float)
    if pos.ndim == 1:                          # distances along one line
        pos = np.stack([pos, np.zeros_like(pos)], 1)
    if pos.ndim != 2 or pos.shape[1] != 2 or pos.shape[0] < 2:
        raise ValueError("geometry needs at least two [x, y] mic positions "
                         "in metres (or a flat list of on-axis positions)")
    span = pos - pos.mean(0)
    sv = np.linalg.svd(span, compute_uv=False)
    linear = bool(sv[1] <= 1e-9 + 1e-4 * sv[0])
    axis = pos[-1] - pos[0]
    axis = axis / (np.linalg.norm(axis) + EPS)
    return {"pos": pos, "c": c, "linear": linear, "axis": axis}

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
def tdoa(data: np.ndarray, fs: int, geometry, frame_s: float = 0.1,
         hop_s: float | None = None) -> dict:
    """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.
    """
    g = load_geometry(geometry)
    x = np.asarray(data, np.float64)
    if x.ndim != 2:
        raise ValueError("data must be (samples, channels)")
    n, nch = x.shape
    if nch != len(g["pos"]):
        raise ValueError(f"recording has {nch} channels but the geometry "
                         f"has {len(g['pos'])} mics")
    L = int(round(frame_s * fs))
    hop = int(round((hop_s if hop_s else frame_s / 2) * fs))
    if n < L:
        raise ValueError("recording shorter than one analysis frame")
    nf = 1 + (n - L) // hop
    win = np.hanning(L)
    nfft = 1 << int(np.ceil(np.log2(2 * L)))
    pairs = _pairs(nch)
    d = np.array([float(np.linalg.norm(g["pos"][i] - g["pos"][j]))
                  for i, j in pairs])
    K = np.clip(np.ceil(d / g["c"] * fs).astype(int) + 2, 2, nfft // 2 - 1)
    tau = np.full((nf, len(pairs)), np.nan)
    peak = np.zeros((nf, len(pairs)))
    prom = np.zeros((nf, len(pairs)))
    for f0 in range(0, nf, 256):                 # bounded memory per block
        f1 = min(f0 + 256, nf)
        idx = np.arange(L)[None, :] + hop * np.arange(f0, f1)[:, None]
        X = [np.fft.rfft(x[:, ch][idx] * win, nfft) for ch in range(nch)]
        rows = np.arange(f1 - f0)
        for p, (i, j) in enumerate(pairs):
            R = X[i] * np.conj(X[j])
            R /= np.abs(R) + EPS
            cc = np.fft.irfft(R, nfft)
            k = K[p]
            w = np.concatenate([cc[:, -k:], cc[:, :k + 1]], 1)  # lags -k..k
            pk = np.argmax(w, 1)
            v = w[rows, pk]
            lo = w[rows, np.maximum(pk - 1, 0)]
            hi = w[rows, np.minimum(pk + 1, 2 * k)]
            den = lo - 2 * v + hi
            adj = np.where((pk > 0) & (pk < 2 * k) & (den < 0),
                           0.5 * (lo - hi) / np.where(den == 0, 1.0, den),
                           0.0)
            tau[f0:f1, p] = (pk + adj - k) / fs
            peak[f0:f1, p] = v
            for off in range(-3, 4):             # blank the peak, keep rivals
                w[rows, np.clip(pk + off, 0, 2 * k)] = -np.inf
            prom[f0:f1, p] = np.clip(v - np.maximum(w.max(1), 0.0), 0.0, 1.0)
    return {"t": (L / 2 + hop * np.arange(nf)) / fs, "pairs": pairs,
            "d_m": d, "tau_s": tau, "peak": peak, "prominence": prom,
            "frame_s": L / fs, "hop_s": hop / fs, "fs": int(fs),
            "geometry": g}

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
def bearing(td: dict, geometry=None) -> dict:
    """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).
    """
    g = td.get("geometry") or load_geometry(geometry)
    if not g["linear"]:
        raise ValueError("bearing requires a linear array — for other "
                         "layouts work from the pair TDOAs directly")
    s = g["pos"] @ g["axis"]
    ds = np.array([s[i] - s[j] for i, j in td["pairs"]])   # signed spacing
    w = np.maximum(td["prominence"], 0.0)
    tau = np.where(np.isfinite(td["tau_s"]), td["tau_s"], 0.0)
    denom = (w * ds ** 2).sum(1)
    num = -g["c"] * (w * ds * tau).sum(1)
    valid = denom > EPS
    cos_t = np.where(valid, num / np.where(valid, denom, 1.0), np.nan)
    clipped = np.abs(cos_t) > 1.0
    cos_c = np.clip(cos_t, -1.0, 1.0)
    resid = np.sqrt((w * (tau + ds * cos_c[:, None] / g["c"]) ** 2).sum(1)
                    / (w.sum(1) + EPS))
    return {"t": td["t"], "bearing_deg": np.degrees(np.arccos(cos_c)),
            "cos_theta": cos_t,
            "confidence": np.median(td["prominence"], 1),
            "residual_s": resid, "clipped": clipped}

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
def bearing_figure(b: dict, out_path: str | Path,
                   title: str | None = None) -> Path:
    """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.
    """
    import matplotlib
    matplotlib.use("Agg")
    import matplotlib.pyplot as plt
    fig, ax = plt.subplots(figsize=(11, 3.6), dpi=130)
    conf = np.clip(b["confidence"], 0, 1)
    sc = ax.scatter(b["t"], b["bearing_deg"], c=conf, cmap="viridis",
                    s=6, vmin=0, vmax=max(0.5, float(conf.max()) or 0.5),
                    alpha=0.85, linewidths=0)
    for y, lab in ((0, "endfire"), (90, "broadside"), (180, "endfire")):
        ax.axhline(y, color="0.8", lw=0.7, ls="--")
        ax.text(1.002, y / 180, lab, transform=ax.transAxes, fontsize=7,
                va="center", color="0.4")
    ax.set(xlabel="time (s)", ylabel="bearing from array axis (°)",
           ylim=(-5, 185), yticks=(0, 45, 90, 135, 180),
           title=(title or "") + " — bearing track (front–back ambiguous; "
           "sides of the axis fold together)")
    fig.colorbar(sc, ax=ax, pad=0.06, label="confidence (GCC prominence)")
    ax.grid(alpha=0.2, lw=0.5)
    fig.tight_layout()
    fig.savefig(out_path)
    plt.close(fig)
    return Path(out_path)

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
def near_source_index(data: np.ndarray, fs: int,
                      band: tuple[float, float] = (500.0, 1000.0),
                      nperseg: int = 2048) -> float:
    """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.
    """
    x = np.asarray(data, np.float64)
    if x.ndim != 2 or x.shape[1] < 2 or x.shape[0] < nperseg:
        return float("nan")
    lo, hi = band
    vals = []
    for i in range(x.shape[1]):
        for j in range(i + 1, x.shape[1]):
            f, c = signal.coherence(x[:, i], x[:, j], fs=fs,
                                    nperseg=min(nperseg, x.shape[0]))
            m = (f >= lo) & (f <= hi)
            if m.any():
                vals.append(float(np.mean(c[m])))
    return float(np.mean(vals)) if vals else float("nan")

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
def coherence_profile(data: np.ndarray, fs: int, geometry,
                      win_s: float = 4.0, nperseg: int | None = None) -> dict:
    """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"}``.
    """
    g = load_geometry(geometry)
    x = np.asarray(data, np.float64)
    n, nch = x.shape
    if nch != len(g["pos"]):
        raise ValueError(f"recording has {nch} channels but the geometry "
                         f"has {len(g['pos'])} mics")
    W = int(round(win_s * fs))
    if n < W:                       # short take: measure the whole of it
        W = n
    nwin = n // W
    nper = int(nperseg) if nperseg else \
        int(np.clip(1 << int(np.log2(max(W / 8, 2))), 128, 1024))
    pairs = _pairs(nch)
    d = np.array([float(np.linalg.norm(g["pos"][i] - g["pos"][j]))
                  for i, j in pairs])
    f = np.fft.rfftfreq(nper, 1 / fs)
    msc = np.zeros((nwin, len(pairs), len(f)))
    lvl = np.zeros((nwin, len(pairs), len(f)))     # pair spectrum level
    for wi in range(nwin):
        seg = x[wi * W:(wi + 1) * W]
        psd = np.stack([signal.welch(seg[:, ch], fs, nperseg=nper)[1]
                        for ch in range(nch)])
        for p, (i, j) in enumerate(pairs):
            _, msc[wi, p] = signal.coherence(seg[:, i], seg[:, j], fs,
                                             nperseg=nper)
            lvl[wi, p] = np.sqrt(psd[i] * psd[j])
    msc_diff = np.sinc(2 * f[None, :] * d[:, None] / g["c"]) ** 2
    informative = (msc_diff < 0.5) & (f[None, :] > 0)
    excess = np.clip((msc - msc_diff[None]) / (1 - msc_diff[None] + EPS),
                     0.0, 1.0)
    gamma_pair = np.full((nwin, len(pairs)), np.nan)
    for p in range(len(pairs)):
        if informative[p].any():
            wgt = lvl[:, p, informative[p]]
            wgt = wgt / (wgt.sum(1, keepdims=True) + EPS)
            gamma_pair[:, p] = 1.0 - (excess[:, p, informative[p]]
                                      * wgt).sum(1)
    gamma = np.nanmedian(gamma_pair, 1) if np.isfinite(gamma_pair).any() \
        else np.full(nwin, np.nan)
    return {"win_t": W / fs * (np.arange(nwin) + 0.5), "f": f, "msc": msc,
            "msc_diffuse": msc_diff, "gamma_pair": gamma_pair,
            "gamma_array": gamma, "pairs": pairs, "d_m": d,
            "win_s": W / fs, "nperseg": nper}

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
def coherence_figure(c: dict, out_path: str | Path,
                     title: str | None = None) -> Path:
    """Coherence profile (median over windows, per pair, with the analytic
    diffuse curves dashed) over the ``gamma_array`` timeline."""
    import matplotlib
    matplotlib.use("Agg")
    import matplotlib.pyplot as plt
    fig, ax = plt.subplots(2, 1, figsize=(11, 6.2), dpi=130)
    med = np.median(c["msc"], 0)
    cmap = plt.get_cmap("viridis")
    order = np.argsort(c["d_m"])
    for rank, p in enumerate(order):
        col = cmap(rank / max(len(order) - 1, 1) * 0.85)
        i, j = c["pairs"][p]
        ax[0].plot(c["f"], med[p], color=col, lw=1.2,
                   label=f"{i}{j} ({100 * c['d_m'][p]:.0f} cm)")
        ax[0].plot(c["f"], c["msc_diffuse"][p], color=col, lw=0.8, ls="--")
    ax[0].set(xlabel="frequency (Hz)", ylabel="MSC", ylim=(0, 1.02),
              title=(title or "") + " — coherence vs the diffuse-field "
              "curve (dashed) per spacing")
    ax[0].legend(frameon=False, fontsize=7, ncol=2, title="pair")
    ax[0].grid(alpha=0.2, lw=0.5)
    ax[1].plot(c["win_t"], c["gamma_array"], ".-", ms=4, lw=1.0,
               color="#2a78d6")
    ax[1].set(xlabel="time (s)", ylabel="gamma_array", ylim=(-0.02, 1.02),
              title="diffuseness proxy (0 = direct wavefront, 1 = diffuse)")
    ax[1].grid(alpha=0.2, lw=0.5)
    fig.tight_layout()
    fig.savefig(out_path)
    plt.close(fig)
    return Path(out_path)

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
def load_plan(plan) -> list[dict]:
    """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.
    """
    if isinstance(plan, (str, Path)):
        plan = json.loads(Path(plan).read_text())
    nodes = plan["nodes"] if isinstance(plan, dict) else plan
    out = []
    for k, nd in enumerate(nodes):
        out.append({"name": str(nd.get("name", f"node{k}")),
                    "pos": np.asarray(nd["pos"], float),
                    "axis_deg": float(nd["axis_deg"])})
    if len(out) < 2:
        raise ValueError("triangulation needs at least two nodes")
    return out

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
def triangulate(bearings: list[dict], plan, grid_s: float = 1.0,
                min_conf: float = 0.2) -> dict:
    """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.
    """
    nodes = load_plan(plan)
    if len(bearings) != len(nodes):
        raise ValueError("one bearing stream per plan node, in plan order")
    t0 = max(float(b["t"][0]) for b in bearings)
    t1 = min(float(b["t"][-1]) for b in bearings)
    if t1 < t0:
        raise ValueError("bearing streams do not overlap in time")
    grid = t0 + grid_s * np.arange(int((t1 - t0) / grid_s) + 1)
    out = {k: [] for k in ("t", "xy", "residual_m", "angle_deg",
                           "confidence", "ambiguous")}
    for tg in grid:
        theta, conf, ok = [], [], True
        for b in bearings:
            k = int(np.argmin(np.abs(b["t"] - tg)))
            if (abs(b["t"][k] - tg) > grid_s / 2
                    or b["confidence"][k] < min_conf or b["clipped"][k]
                    or not np.isfinite(b["bearing_deg"][k])):
                ok = False
                break
            theta.append(float(b["bearing_deg"][k]))
            conf.append(float(b["confidence"][k]))
        if not ok:
            continue
        best = None
        for signs in product((1.0, -1.0), repeat=len(nodes)):
            us, res_n = [], 0.0
            A = np.zeros((2, 2))
            rhs = np.zeros(2)
            for nd, th, sg, w in zip(nodes, theta, signs, conf):
                phi = np.radians(nd["axis_deg"] + sg * th)
                u = np.array([np.cos(phi), np.sin(phi)])
                P = np.eye(2) - np.outer(u, u)
                A += w * P
                rhs += w * P @ nd["pos"]
                us.append(u)
            if abs(np.linalg.det(A)) < 1e-9:      # all rays parallel
                continue
            xy = np.linalg.solve(A, rhs)
            r2, wsum, behind = 0.0, 0.0, False
            for nd, u, w in zip(nodes, us, conf):
                v = xy - nd["pos"]
                if v @ u < 0:
                    behind = True
                r2 += w * float(v @ v - (v @ u) ** 2)
                wsum += w
            resid = np.sqrt(max(r2, 0.0) / wsum) + (1e6 if behind else 0.0)
            cross = min(abs(np.degrees(np.arcsin(np.clip(
                ua[0] * ub[1] - ua[1] * ub[0], -1, 1))))
                for a, ua in enumerate(us) for ub in us[a + 1:]) \
                if len(us) > 1 else 0.0
            cand = (resid, xy, cross)
            if best is None or resid < best[0][0]:
                best = (cand, best[0] if best else None)
            elif best[1] is None or resid < best[1][0]:
                best = (best[0], cand)
        if best is None or best[0][0] >= 1e6:
            continue
        resid, xy, cross = best[0]
        second = best[1][0] if best[1] else np.inf
        out["t"].append(float(tg))
        out["xy"].append(xy)
        out["residual_m"].append(float(resid))
        out["angle_deg"].append(float(cross))
        out["confidence"].append(min(conf))
        out["ambiguous"].append(bool(second <= 2.0 * resid + 0.05))
    return {"t": np.array(out["t"]),
            "xy": (np.array(out["xy"]).reshape(-1, 2)),
            "residual_m": np.array(out["residual_m"]),
            "angle_deg": np.array(out["angle_deg"]),
            "confidence": np.array(out["confidence"]),
            "ambiguous": np.array(out["ambiguous"], bool),
            "nodes": [nd["name"] for nd in nodes]}

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
def triangulate_figure(tri: dict, plan, out_path: str | Path,
                       title: str | None = None) -> Path:
    """Floor-plan scatter: node arrays with their axes, triangulated
    positions coloured by time; ambiguous fixes drawn hollow."""
    import matplotlib
    matplotlib.use("Agg")
    import matplotlib.pyplot as plt
    nodes = load_plan(plan)
    fig, ax = plt.subplots(figsize=(6.4, 6.0), dpi=130)
    for nd in nodes:
        u = np.array([np.cos(np.radians(nd["axis_deg"])),
                      np.sin(np.radians(nd["axis_deg"]))])
        ax.annotate("", xy=nd["pos"] + 0.4 * u, xytext=nd["pos"] - 0.4 * u,
                    arrowprops={"arrowstyle": "-|>", "color": "0.35",
                                "lw": 1.6})
        ax.plot(*nd["pos"], "s", ms=8, color="#1a4f8f")
        ax.annotate(nd["name"], nd["pos"], textcoords="offset points",
                    xytext=(6, 6), fontsize=8)
    if len(tri["t"]):
        solid = ~tri["ambiguous"]
        if solid.any():
            sc = ax.scatter(tri["xy"][solid, 0], tri["xy"][solid, 1],
                            c=tri["t"][solid], cmap="viridis", s=18,
                            zorder=3)
            fig.colorbar(sc, ax=ax, shrink=0.8, label="time (s)")
        if (~solid).any():
            ax.scatter(tri["xy"][~solid, 0], tri["xy"][~solid, 1],
                       facecolors="none", edgecolors="0.5", s=18, zorder=3,
                       label="ambiguous (mirror fits equally)")
            ax.legend(frameon=False, fontsize=8, loc="upper right")
    ax.set(xlabel="x (m)", ylabel="y (m)",
           title=(title or "") + " — triangulated positions "
           "(arrows = array axes)")
    ax.set_aspect("equal")
    ax.grid(alpha=0.25, lw=0.5)
    fig.tight_layout()
    fig.savefig(out_path)
    plt.close(fig)
    return Path(out_path)

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
def run_array(recording: str | Path, geometry, out_dir: str | Path | None
              = None, frame_s: float = 0.1, hop_s: float | None = None,
              win_s: float = 4.0, min_conf: float = 0.2) -> dict:
    """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`.
    """
    import soundfile as sf
    recording = Path(recording)
    out = Path(out_dir) if out_dir else recording.parent / "analysis"
    out.mkdir(parents=True, exist_ok=True)
    data, fs = sf.read(str(recording), dtype="float64", always_2d=True)
    g = load_geometry(geometry)
    td = tdoa(data, fs, g, frame_s=frame_s, hop_s=hop_s)
    cp = coherence_profile(data, fs, g, win_s=win_s)
    name = recording.stem
    doc = {"recording": recording.name, "n_mics": len(g["pos"]),
           "mics_m": [[round(float(v), 4) for v in p] for p in g["pos"]],
           "c_m_s": g["c"], "linear": g["linear"],
           "params": {"frame_s": td["frame_s"], "hop_s": td["hop_s"],
                      "coherence_win_s": cp["win_s"],
                      "nperseg": cp["nperseg"], "min_conf": min_conf},
           "n_frames": len(td["t"]),
           "tdoa_median_ms": {f"{i}-{j}": _r(1e3 * v)
                              for (i, j), v in zip(
                                  td["pairs"], np.nanmedian(td["tau_s"], 0))},
           "gamma_array_median": _r(np.nanmedian(cp["gamma_array"])),
           "gamma_array_iqr": _r(np.nanpercentile(cp["gamma_array"], 75)
                                 - np.nanpercentile(cp["gamma_array"], 25))
           if np.isfinite(cp["gamma_array"]).any() else None,
           "figures": {}}
    if g["linear"]:
        b = bearing(td)
        good = (b["confidence"] >= min_conf) & ~b["clipped"] \
            & np.isfinite(b["bearing_deg"])
        doc["bearing"] = {
            "median_deg": _r(np.median(b["bearing_deg"][good]), 1)
            if good.any() else None,
            "iqr_deg": _r(np.percentile(b["bearing_deg"][good], 75)
                          - np.percentile(b["bearing_deg"][good], 25), 1)
            if good.any() else None,
            "confident_fraction": _r(good.mean()),
            "confidence_median": _r(np.median(b["confidence"])),
            "note": "bearing from the array axis, 0-180 deg; the two "
                    "sides of the axis fold together (front-back "
                    "ambiguity) and endfire bearings are low-resolution"}
        doc["figures"]["bearing"] = str(bearing_figure(
            b, out / "array_bearing.png", title=name))
    else:
        doc["bearing"] = None
    doc["figures"]["coherence"] = str(coherence_figure(
        cp, out / "array_coherence.png", title=name))
    (out / "array.json").write_text(json.dumps(doc, indent=2))
    return doc

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
def luma(rgb) -> np.ndarray:
    """Rec.709 luma of an RGB frame, in [0, 1]."""
    x = _to_unit(rgb)
    return x @ np.array(_LUMA)

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
def frame_features(rgb, grid: int = 3) -> dict:
    """Visual descriptor of one RGB frame (uint8 0..255 or float 0..1)."""
    x = _to_unit(rgb)
    R, G, B = x[..., 0], x[..., 1], x[..., 2]
    lum = x @ np.array(_LUMA)
    mr, mg, mb = float(R.mean()), float(G.mean()), float(B.mean())
    s = mr + mg + mb + EPS

    mx = x.max(-1)
    mn = x.min(-1)
    sat = float(np.where(mx > 0, (mx - mn) / (mx + EPS), 0.0).mean())
    rg = R - G
    yb = 0.5 * (R + G) - B
    colourfulness = float(np.sqrt(rg.std() ** 2 + yb.std() ** 2)
                          + 0.3 * np.sqrt(rg.mean() ** 2 + yb.mean() ** 2))

    gm = _grid_means(lum, grid)
    p = gm.ravel() / (gm.sum() + EPS)
    spatial_entropy = float(-(p * np.log(p + EPS)).sum() / np.log(grid * grid))

    h, w = lum.shape
    ys, xs = np.mgrid[0:h, 0:w]
    tot = lum.sum() + EPS
    cx = float((lum * xs).sum() / tot / max(w - 1, 1))
    cy = float((lum * ys).sum() / tot / max(h - 1, 1))

    return {
        "brightness": round(float(lum.mean()), 4),
        "brightness_sd": round(float(lum.std()), 4),
        "r_frac": round(mr / s, 4), "g_frac": round(mg / s, 4),
        "b_frac": round(mb / s, 4),
        "warm_cool_ratio": round(mr / (mb + EPS), 4),
        "saturation": round(sat, 4),
        "colourfulness": round(colourfulness, 4),
        "spatial_entropy": round(spatial_entropy, 4),
        "bright_centroid_x": round(cx, 4),
        "bright_centroid_y": round(cy, 4),
    }

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
def frame_delta(prev_rgb, cur_rgb) -> float:
    """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.
    """
    return round(float(np.abs(luma(cur_rgb) - luma(prev_rgb)).mean()), 5)

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
def summarize_vision(features, motion=None) -> dict:
    """Roll a day of per-frame feature dicts into a ``vis_`` day summary."""
    rows = list(features)
    if not rows:
        return {"n_frames": 0}
    def col(k):
        return np.array([r[k] for r in rows if k in r], float)
    b = col("brightness")
    out = {
        "n_frames": len(rows),
        "vis_brightness_median": round(float(np.median(b)), 4),
        "vis_brightness_range": round(float(np.percentile(b, 90)
                                            - np.percentile(b, 10)), 4),
        "vis_colourfulness_median": round(float(np.median(col("colourfulness"))), 4),
        "vis_saturation_median": round(float(np.median(col("saturation"))), 4),
        "vis_warm_cool_median": round(float(np.median(col("warm_cool_ratio"))), 4),
        "vis_spatial_entropy_median": round(
            float(np.median(col("spatial_entropy"))), 4),
    }
    if motion is not None and len(motion):
        m = np.asarray(motion, float)
        out["vis_motion_mean"] = round(float(m.mean()), 5)
        out["vis_motion_p90"] = round(float(np.percentile(m, 90)), 5)
    return out

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
def analyze_frames(frames, times=None) -> dict:
    """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.
    """
    rows, motion, prev = [], [], None
    for i, fr in enumerate(frames):
        t = float(times[i]) if times is not None else float(i)
        row = {"t": round(t, 3), **frame_features(fr)}
        if prev is not None:
            row["motion"] = frame_delta(prev, fr)
            motion.append(row["motion"])
        rows.append(row)
        prev = fr
    return {"frames": rows, "summary": summarize_vision(rows, motion or None)}

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
def frame_series(source, fps: float = 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.
    """
    import subprocess
    from pathlib import Path
    p = Path(source)
    if p.is_dir():
        from PIL import Image
        exts = {".png", ".jpg", ".jpeg", ".bmp", ".tif", ".tiff"}
        files = sorted(q for q in p.iterdir() if q.suffix.lower() in exts)
        for i, q in enumerate(files):
            with Image.open(q) as im:
                yield i / fps, np.asarray(im.convert("RGB"))
        return
    import json as _json
    probe = subprocess.run(
        ["ffprobe", "-v", "error", "-select_streams", "v:0", "-show_entries",
         "stream=width,height", "-of", "json", str(p)],
        capture_output=True, text=True)
    st = _json.loads(probe.stdout)["streams"][0]
    w, h = int(st["width"]), int(st["height"])
    proc = subprocess.Popen(
        ["ffmpeg", "-v", "error", "-i", str(p), "-vf", f"fps={fps}",
         "-f", "rawvideo", "-pix_fmt", "rgb24", "-"], stdout=subprocess.PIPE)
    fsz, i = w * h * 3, 0
    try:
        while True:
            buf = proc.stdout.read(fsz)
            if len(buf) < fsz:
                break
            yield i / fps, np.frombuffer(buf, np.uint8).reshape(h, w, 3)
            i += 1
    finally:
        proc.stdout.close()
        proc.wait()

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
def render(result: dict, out_path, title: str = ""):
    """Brightness / colourfulness / motion over time -> a PNG."""
    import matplotlib
    matplotlib.use("Agg")
    import matplotlib.pyplot as plt
    rows = result["frames"]
    t = [r["t"] for r in rows]
    fig, ax = plt.subplots(3, 1, figsize=(9, 6), sharex=True)
    ax[0].plot(t, [r["brightness"] for r in rows], color="0.25")
    ax[0].set_ylabel("brightness")
    ax[1].plot(t, [r["colourfulness"] for r in rows], color="tab:orange")
    ax[1].set_ylabel("colourfulness")
    mt = [(r["t"], r["motion"]) for r in rows if "motion" in r]
    if mt:
        ax[2].plot([a for a, _ in mt], [b for _, b in mt], color="tab:red")
    ax[2].set_ylabel("motion")
    ax[2].set_xlabel("time (s)")
    fig.suptitle(f"visual features — {title}")
    fig.tight_layout()
    fig.savefig(out_path, dpi=110)
    plt.close(fig)
    return out_path

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
def run_video(source, out_dir, fps: float = 1.0, merge=None) -> dict:
    """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.
    """
    import json
    from pathlib import Path
    out_dir = Path(out_dir)
    out_dir.mkdir(parents=True, exist_ok=True)
    series = list(frame_series(source, fps))
    result = analyze_frames((f for _, f in series), [t for t, _ in series])
    (out_dir / "vision.json").write_text(json.dumps(result, indent=2))
    render(result, out_dir / "vision.png", title=Path(source).name)
    if merge:
        mp = Path(merge)
        if mp.exists():
            s = json.loads(mp.read_text())
            s.update(result["summary"])
            mp.write_text(json.dumps(s, indent=2))
    return result["summary"]

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 —

  1. 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);
  2. 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;
  3. 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
def load_motion(path: str | 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``.
    """
    import csv
    path = Path(path)
    text = path.read_text().strip().splitlines()
    head = text[0]
    delim = "\t" if "\t" in head else (";" if ";" in head else ",")
    rows = list(csv.reader(text, delimiter=delim))
    names = [_clean(c) for c in rows[0]]
    ti = next((i for i, nm in enumerate(names) if nm in _TIME_NAMES), 0)
    axes = []
    for ax in "xyz":
        i = next((i for i, nm in enumerate(names)
                  if i != ti and nm.endswith(ax)), None)
        axes.append(i)
    if any(i is None for i in axes):
        axes = [ti + 1, ti + 2, ti + 3]
    data = [r for r in rows[1:] if len(r) > max(ti, *axes) and r[ti].strip()]
    if len(data) < 8:
        raise ValueError(f"{path.name}: fewer than 8 motion samples")
    date0 = None
    first = data[0][ti].strip()
    try:
        float(first)
        t = np.array([float(r[ti]) for r in data])
    except ValueError:
        stamps = [_dt.datetime.fromisoformat(r[ti].strip().replace("Z", ""))
                  for r in data]
        date0 = stamps[0].date()
        t = np.array([(s.date() - date0).days * 86400.0
                      + s.hour * 3600 + s.minute * 60 + s.second
                      + s.microsecond / 1e6 for s in stamps])
    acc = np.array([[float(r[i]) for i in axes] for r in data])
    keep = np.argsort(t, kind="stable")
    return t[keep], acc[keep], {"date0": date0,
                                "columns": [rows[0][i] for i in axes]}

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
def join(sess, motion_path, F=None) -> dict:
    """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).
    """
    if F is None:
        from .features import load_features
        paths = sorted((Path(sess.folder) / "analysis" / "features")
                       .glob("*.npz"))
        if not paths:
            raise FileNotFoundError(
                f"no cached features under {sess.folder}/analysis — run "
                "'ambiscape analyze' first")
        F = load_features(paths)
    t_m, acc, meta = load_motion(motion_path)
    dt = float(np.median(np.diff(F["t_fast"])))
    ta0, ta1 = float(F["t_fast"][0]), float(F["t_fast"][-1]) + dt
    if meta["date0"] is not None and sess.day0 is not None:
        t_m = t_m + (meta["date0"] - sess.day0).days * 86400.0
    if t_m[-1] <= ta0 or t_m[0] >= ta1:      # disjoint: a relative clock
        t_m = t_m - t_m[0] + ta0
    tg, dt_m, qom_raw, h1, h2 = _micromotion(t_m, acc)

    t0, t1 = max(ta0, float(tg[0])), min(ta1, float(tg[-1]))
    n = int((t1 - t0) / dt)
    if n < 16:
        raise ValueError(
            f"audio and motion overlap for only {max(t1 - t0, 0):.1f} s — "
            "check the motion file's timestamps")
    tc = t0 + dt * np.arange(n)
    level = np.interp(tc + dt / 2, F["t_fast"] + dt / 2,
                      np.asarray(F["fast_db"], float))
    idx = np.floor((tg - t0) / dt).astype(int)
    ok = (idx >= 0) & (idx < n)
    idx = idx[ok]
    cnt = np.maximum(np.bincount(idx, minlength=n), 1)
    qom = np.bincount(idx, weights=qom_raw[ok], minlength=n) / cnt
    sxx = np.bincount(idx, weights=h1[ok] ** 2, minlength=n)
    syy = np.bincount(idx, weights=h2[ok] ** 2, minlength=n)
    sxy = np.bincount(idx, weights=h1[ok] * h2[ok], minlength=n)
    sway = 0.5 * np.degrees(np.arctan2(2 * sxy, sxx - syy))
    sway_pow = (sxx + syy) / cnt
    gaps = np.bincount(idx, minlength=n) == 0
    if gaps.any():                            # bridge motion drop-outs
        qom[gaps] = np.interp(tc[gaps], tc[~gaps], qom[~gaps])
        sway_pow[gaps] = 0.0
    az_sec = np.asarray(F["az"], float)
    az = np.full(n, np.nan)
    if np.isfinite(az_sec).any():
        fin = np.isfinite(az_sec)
        ts = np.asarray(F["t"], float)[fin] + 0.5
        rad = np.radians(az_sec[fin])         # interpolate on the circle
        az = np.degrees(np.arctan2(np.interp(tc + dt / 2, ts, np.sin(rad)),
                                   np.interp(tc + dt / 2, ts, np.cos(rad))))
    return {"t": tc, "dt": dt, "level_db": level.astype(float), "qom": qom,
            "sway_deg": sway, "sway_pow": sway_pow, "az_deg": az,
            "motion_fs_hz": round(1.0 / dt_m, 2), "n": n}

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
def temporal_correlation(level_db, qom, dt, n_surrogates=200,
                         min_shift_s=10.0, seed=0) -> dict:
    """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.
    """
    x = np.asarray(level_db, float)
    y = np.log10(np.asarray(qom, float) + EPS)
    x = x - x.mean()
    y = y - y.mean()
    denom = np.sqrt((x ** 2).sum() * (y ** 2).sum()) + EPS
    r = float((x * y).sum() / denom)
    p, null95 = _shift_p(
        r, lambda k: float((x * np.roll(y, k)).sum() / denom), len(x),
        n_surrogates, int(min_shift_s / dt), np.random.default_rng(seed))
    return {"r": round(r, 4), "p": round(p, 4), "null95": round(null95, 4),
            "n": len(x), "n_surrogates": n_surrogates}

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
def directional_correlation(az_deg, sway_deg, mask=None, dt=0.125,
                            n_surrogates=200, min_shift_s=10.0,
                            seed=0) -> dict:
    """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.
    """
    a = np.radians(np.asarray(az_deg, float))
    b = np.radians(2.0 * np.asarray(sway_deg, float))
    if mask is None:
        mask = np.ones(len(a), bool)
    mask = mask & np.isfinite(a) & np.isfinite(b)
    if mask.sum() < 16:
        return {"rho": None, "p": None, "n": int(mask.sum())}
    # `["r"]` since ambiscape 0.40.0: circ_corr re-exports micromotion and
    # returns {"r", "p", "n"}. The p it carries is parametric; the one this
    # function reports comes from the shift surrogates below, which is the
    # stricter test on serially correlated series and stays the one reported.
    rho = circ_corr(a[mask], b[mask])["r"]
    p, null95 = _shift_p(
        rho, lambda k: circ_corr(a[mask], np.roll(b, k)[mask])["r"],
        len(a), n_surrogates, int(min_shift_s / dt),
        np.random.default_rng(seed))
    return {"rho": round(rho, 4), "p": round(p, 4),
            "null95": round(null95, 4), "n": int(mask.sum()),
            "n_surrogates": n_surrogates}

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
def plv(audio_env, motion, dt, bands=BANDS, n_surrogates=200,
        min_shift_s=10.0, seed=0) -> list[dict]:
    """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.
    """
    rng = np.random.default_rng(seed)
    x = np.asarray(audio_env, float)
    y = np.asarray(motion, float)
    x = (x - x.mean()) / (x.std() + EPS)
    y = (y - y.mean()) / (y.std() + EPS)
    n = len(x)
    out = []
    for lo, hi in bands:
        if hi > 0.4 / dt or n * dt < 4.0 / lo:
            continue
        sos = signal.butter(3, (lo, hi), "band", fs=1.0 / dt, output="sos")
        ph_x = np.angle(signal.hilbert(signal.sosfiltfilt(sos, x)))
        ph_y = np.angle(signal.hilbert(signal.sosfiltfilt(sos, y)))
        v = float(np.abs(np.exp(1j * (ph_x - ph_y)).mean()))
        p, null95 = _shift_p(
            v, lambda k: float(np.abs(
                np.exp(1j * (ph_x - np.roll(ph_y, k))).mean())),
            n, n_surrogates, int(min_shift_s / dt), rng)
        out.append({"band_hz": [lo, hi], "plv": round(v, 4),
                    "p": round(p, 4), "null95": round(null95, 4)})
    return out

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
def render(J: dict, doc: dict, out_path, title="", clock=None):
    """Combined figure: aligned timelines, azimuth-vs-sway rose, PLV bars."""
    import matplotlib
    matplotlib.use("Agg")
    import matplotlib.pyplot as plt
    fig = plt.figure(figsize=(12.8, 7.2), dpi=130)
    gs = fig.add_gridspec(2, 2, height_ratios=[1, 1.15])
    tt = J["t"] - J["t"][0]

    ax0 = fig.add_subplot(gs[0, :])
    ax0.plot(tt, J["level_db"], color="#2a78d6", lw=0.8, label="fast level")
    ax0.set(ylabel="level (dBFS)",
            title=f"{title} — sound and motion on one clock "
                  f"(r {doc['temporal']['r']}, p {doc['temporal']['p']})")
    axq = ax0.twinx()
    axq.plot(tt, 10 * np.log10(J["qom"] + EPS), color="#d66a2a", lw=0.8,
             alpha=0.8, label="QoM")
    axq.set_ylabel("QoM (dB)", color="#d66a2a")
    ax0.set_xlabel("time (s)")
    if clock is not None:
        xt = ax0.get_xticks()
        ax0.set_xticks(xt)
        ax0.set_xticklabels([clock(J["t"][0] + x)[7:] for x in xt],
                            fontsize=8)
        ax0.set_xlim(tt[0], tt[-1])
        ax0.set_xlabel("")
    ax0.grid(alpha=0.2)

    ax1 = fig.add_subplot(gs[1, 0], projection="polar")
    dc = doc.get("directional") or {}
    if np.isfinite(J["az_deg"]).any():
        pw = 10 ** (J["level_db"] / 10)
        edges = np.radians(np.linspace(-180, 180, 25))
        ha, _ = np.histogram(np.radians(J["az_deg"]), bins=edges, weights=pw)
        hs, _ = np.histogram(np.radians(np.concatenate(
            [J["sway_deg"], J["sway_deg"] + 180])), bins=edges,
            weights=np.concatenate([J["sway_pow"]] * 2))
        cent = 0.5 * (edges[:-1] + edges[1:])
        w = edges[1] - edges[0]
        ax1.bar(cent, ha / (ha.max() + EPS), width=w, color="#2a78d6",
                alpha=0.55, label="audio azimuth energy")
        ax1.bar(cent, hs / (hs.max() + EPS), width=w, color="#d66a2a",
                alpha=0.55, label="sway direction (axial)")
        ax1.set_title("azimuth vs sway"
                      + (f" (rho {dc.get('rho')}, p {dc.get('p')})"
                         if dc.get("rho") is not None else ""), fontsize=10)
        ax1.set_theta_zero_location("N")
        ax1.set_theta_direction(1)
        ax1.set_thetagrids([0, 90, 180, 270],
                           ["front", "left", "rear", "right"], fontsize=8.5)
        ax1.set_rticks([])
        ax1.legend(fontsize=7, loc="lower left",
                   bbox_to_anchor=(-0.12, -0.12))
    else:
        ax1.text(0, 0, "no directional audio\n(mono input)",
                 ha="center", va="center")
        ax1.set_axis_off()

    ax2 = fig.add_subplot(gs[1, 1])
    pb = doc["plv"]
    if pb:
        xs = np.arange(len(pb))
        cols = ["#3d9970" if b["p"] < 0.05 else "0.7" for b in pb]
        ax2.bar(xs, [b["plv"] for b in pb], color=cols, width=0.7)
        ax2.plot(xs, [b["null95"] for b in pb], "_", ms=22, color="0.2",
                 label="surrogate 95%")
        ax2.set_xticks(xs, [f"{b['band_hz'][0]:g}{b['band_hz'][1]:g}"
                            for b in pb], fontsize=8)
        ax2.legend(fontsize=8)
    ax2.set(xlabel="modulation band (Hz)", ylabel="PLV", ylim=(0, 1),
            title="phase locking by band (green = p < 0.05)")
    ax2.grid(alpha=0.2, axis="y")
    fig.tight_layout()
    fig.savefig(out_path)
    plt.close(fig)
    return out_path

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
def analyze_entrainment(sess, motion_path, out_dir=None, F=None, bands=BANDS,
                        n_surrogates=200, seed=0) -> dict:
    """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.
    """
    import json
    out_dir = Path(out_dir) if out_dir else Path(sess.folder) / "analysis"
    out_dir.mkdir(parents=True, exist_ok=True)
    J = join(sess, motion_path, F=F)
    dt = J["dt"]
    tc = temporal_correlation(J["level_db"], J["qom"], dt,
                              n_surrogates=n_surrogates, seed=seed)
    pw = 10 ** (J["level_db"] / 10)
    pl = plv(pw, J["qom"], dt, bands=bands, n_surrogates=n_surrogates,
             seed=seed)
    dc = None
    if np.isfinite(J["az_deg"]).any():
        active = ((pw >= np.median(pw))
                  & (J["sway_pow"] >= np.median(J["sway_pow"])))
        dc = directional_correlation(J["az_deg"], J["sway_deg"], mask=active,
                                     dt=dt, n_surrogates=n_surrogates,
                                     seed=seed)
    best = max(pl, key=lambda b: b["plv"]) if pl else None
    summary = {
        "ent_overlap_min": round(J["n"] * dt / 60.0, 2),
        "ent_r_level_qom": tc["r"], "ent_r_p": tc["p"],
        "ent_az_sway_rho": dc["rho"] if dc else None,
        "ent_az_sway_p": dc["p"] if dc else None,
        "ent_plv_max": best["plv"] if best else None,
        "ent_plv_max_band_hz": (round(float(np.sqrt(
            best["band_hz"][0] * best["band_hz"][1])), 3) if best else None),
        "ent_plv_max_p": best["p"] if best else None,
    }
    doc = {
        "motion_file": str(Path(motion_path).name),
        "motion_fs_hz": J["motion_fs_hz"],
        "overlap_s": round(J["n"] * dt, 1),
        "temporal": tc, "directional": dc, "plv": pl,
        "summary": summary,
        "_method_note": (
            "Guo–Riaz–Jensenius crossmodal method: 125 ms fast level vs "
            "quantity of motion (jerk magnitude after 0.25 Hz gravity "
            "removal) on a common 8 Hz clock; Pearson r on dB/log series; "
            "Jammalamadaka–SenGupta circular correlation between audio "
            "azimuth and the angle-doubled principal axis of horizontal "
            "micromotion, on frames above median energy in both streams; "
            "per-band Hilbert PLV of envelope vs QoM. All p-values are "
            "two-sided against circular time-shift surrogates."),
    }
    render(J, doc, out_dir / "entrain.png", title=sess.name,
           clock=sess.clock)
    (out_dir / "entrain.json").write_text(
        json.dumps(doc, indent=2, default=float))
    sp = out_dir / "summary.json"
    if sp.exists():                          # the multimodal join
        s = json.loads(sp.read_text())
        s.update(summary)
        sp.write_text(json.dumps(s, indent=2))
    return doc

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
def mean_resultant(angles: np.ndarray, weights=None):
    """Weighted circular mean (rad) and resultant length R."""
    a = np.asarray(angles, float)
    w = np.ones_like(a) if weights is None else np.asarray(weights, float)
    z = (w * np.exp(1j * a)).sum() / (w.sum() + EPS)
    return float(np.angle(z)), float(np.abs(z))

circular_sd(R)

Circular standard deviation (rad) from a resultant length.

Source code in src/ambiscape/circstats.py
24
25
26
def circular_sd(R: float) -> float:
    """Circular standard deviation (rad) from a resultant length."""
    return float(np.sqrt(-2 * np.log(max(R, EPS))))

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
def rayleigh_p(R: float, n: int) -> float:
    """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.
    """
    nR = n * R
    return float(min(1.0, np.exp(
        np.sqrt(1 + 4 * n + 4 * (n * n - nR * nR)) - (1 + 2 * n))))

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
def circ_corr(a: np.ndarray, b: np.ndarray) -> dict:
    """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.
    """
    try:
        from micromotion.circular import circ_corr as _cc
    except ImportError as e:                                  # pragma: no cover
        raise ImportError(
            "ambiscape.circstats.circ_corr re-exports micromotion since "
            "0.40.0, which owns circular statistics in this family. Install "
            "it with `pip install micromotion`."
        ) from e
    return _cc(a, b)

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
def phase_stats(times: np.ndarray, period: float) -> dict:
    """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.
    """
    ph = 2 * np.pi * (np.asarray(times, float) / period % 1.0)
    mu, R = mean_resultant(ph)
    return {
        "mean_phase": float((mu / (2 * np.pi)) % 1.0),
        "R": round(R, 4),
        "circ_sd_s": round(circular_sd(R) / (2 * np.pi) * period, 4),
        "rayleigh_p": rayleigh_p(R, len(ph)),
        "n": int(len(ph)),
    }

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
def relative_phase(times: np.ndarray, ref_times: np.ndarray,
                   period: float) -> dict:
    """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.
    """
    ref = np.sort(np.asarray(ref_times, float))
    t = np.asarray(times, float)
    i = np.clip(np.searchsorted(ref, t) - 1, 0, len(ref) - 1)
    d = 2 * np.pi * (((t - ref[i]) / period) % 1.0)
    mu, R = mean_resultant(d)
    off = (mu / (2 * np.pi)) % 1.0
    return {
        "mean_offset_cycles": round(off, 4),
        "mean_offset_s": round(off * period, 4),
        "R": round(R, 4),
        "circ_sd_s": round(circular_sd(R) / (2 * np.pi) * period, 4),
        "n": int(len(t)),
    }

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
def modulation_spectrum(env: np.ndarray, dt: float, fmin: float, fmax: float,
                        n_bins: int = 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.
    """
    x = env.astype(np.float64) / (env.mean() + EPS) - 1.0
    nper = int(min(len(x), max(64, round(8.0 / (fmin * dt)))))
    f, P = signal.welch(x, fs=1.0 / dt, nperseg=nper,
                        noverlap=nper // 2, detrend="linear")
    grid = np.geomspace(fmin, fmax, n_bins)
    edges = np.geomspace(fmin, fmax, n_bins + 1)
    lo = np.searchsorted(f, edges[:-1])
    hi = np.searchsorted(f, edges[1:])
    near = np.clip(np.searchsorted(f, grid), 1, len(f) - 1)
    out = np.array([P[a:b].mean() if b > a else P[n]
                    for a, b, n in zip(lo, hi, near)])
    return grid, out

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
def profile(F: dict) -> dict:
    """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.
    """
    dur = float(len(F["t"]))
    out = {"scales": {}, "spectra": {}}
    fast_pow = 10 ** (F["fast_db"].astype(np.float64) / 10)  # dB -> lin power
    meso_dt = float(np.median(np.diff(F["t_fast"])))
    sources = {
        "meso": (fast_pow, meso_dt),
        "macro": (F["rms_w"].astype(np.float64) ** 2, 1.0),
    }
    if "env_hi" in F:
        sources["micro"] = (F["env_hi"], float(F["hi_dt"]))
    else:
        sources["micro"] = (fast_pow, meso_dt)
        out["micro_limited"] = "no env_hi in cache; micro band tops out at 4 Hz"
    for scale in SCALES:
        env, dt = sources[scale]
        lo, hi = BANDS[scale]
        lo = max(lo or 4.0 / dur, 4.0 / dur)
        clo = max(lo / EXT, 4.0 / dur)
        chi = min(hi * EXT, 0.45 / dt)
        if chi <= clo * 1.5:
            continue
        f, P = modulation_spectrum(env, dt, clo, chi)
        out["spectra"][scale] = {"freq_hz": [round(float(v), 5) for v in f],
                                 "power": [float(v) for v in P]}
        band = (f >= lo) & (f <= min(hi, chi))
        if band.any():
            out["scales"][scale] = _scale_stats(f[band], P[band])
    return out

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
def modulation_spectrogram(env: np.ndarray, dt: float, win_s: float = 600.0,
                           step_s: float = 120.0, fmin: float = 0.02,
                           fmax: float = 20.0, n_bins: int = 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).
    """
    nwin = int(win_s / dt)
    nstep = int(step_s / dt)
    fmax = min(fmax, 0.45 / dt)
    grid = np.geomspace(fmin, fmax, n_bins)
    ts, rows = [], []
    for i0 in range(0, len(env) - nwin + 1, nstep):
        f, P = modulation_spectrum(env[i0:i0 + nwin], dt, fmin, fmax, n_bins)
        rows.append(10 * np.log10((P + EPS) / (np.median(P) + EPS)))
        ts.append((i0 + nwin / 2) * dt)
    return np.array(ts), grid, np.array(rows)

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
def render(F: dict, prof: dict, out_path, title="", clock=None):
    """Combined figure: per-scale spectra + rhythm spectrogram."""
    import matplotlib
    matplotlib.use("Agg")
    import matplotlib.pyplot as plt
    fig, (ax0, ax1) = plt.subplots(
        2, 1, figsize=(12.8, 7.2), dpi=130,
        gridspec_kw=dict(height_ratios=[1, 1.3]))
    colors = {"micro": "#2a78d6", "meso": "#d66a2a", "macro": "#3d9970"}
    for scale in SCALES:
        sp = prof["spectra"].get(scale)
        if not sp:
            continue
        f = np.array(sp["freq_hz"])
        db = 10 * np.log10(np.array(sp["power"]) + EPS)
        lo, hi = BANDS[scale]
        band = (f >= (lo or 0.0)) & (f <= hi)
        # full curve faint (the half-decade overlap into neighbouring
        # scales), nominal band solid on top
        ax0.plot(f, db, color=colors[scale], lw=1.0, alpha=0.35)
        st = prof["scales"].get(scale)
        lab = f"{scale} (peak {st['peak_period_s']} s)" if st else scale
        ax0.plot(f[band], db[band], color=colors[scale], lw=1.6, label=lab)
    for edge in (0.01, 0.5):
        ax0.axvline(edge, color="0.75", lw=0.8, ls=":", zorder=0)
    ax0.set(xscale="log", xlabel="modulation frequency (Hz)",
            ylabel="PSD (dB re 1/Hz)\nunit-mean power envelope",
            title=f"{title} — envelope modulation spectrum "
            "(macro | meso | micro, shared normalisation)")
    ax0.legend(fontsize=8)
    ax0.grid(alpha=0.2, which="both")

    env, dt = (F["env_hi"], float(F["hi_dt"])) if "env_hi" in F else \
        (10 ** (F["fast_db"].astype(np.float64) / 10),
         float(np.median(np.diff(F["t_fast"]))))
    t0 = float(F["t_hi"][0] if "t_hi" in F else F["t_fast"][0])
    ts, mf, S = modulation_spectrogram(env, dt)
    if len(ts):
        pc = ax1.pcolormesh(t0 + ts, mf, S.T, cmap="magma", shading="auto",
                            vmin=0, vmax=max(6, np.percentile(S, 99)))
        ax1.set(yscale="log", ylabel="modulation frequency (Hz)",
                title="rhythm spectrogram (10 min windows, dB re window median)")
        if clock is not None:
            xt = ax1.get_xticks()
            ax1.set_xticks(xt)
            ax1.set_xticklabels([clock(x)[7:] for x in xt], fontsize=8)
            ax1.set_xlim(t0 + ts[0], t0 + ts[-1])
        fig.colorbar(pc, ax=ax1, pad=0.01)
    fig.tight_layout()
    fig.savefig(out_path)
    return out_path

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
def run_session(sess, out_dir) -> dict:
    """CLI driver: profile + figure + modulation.json."""
    import json
    from .features import load_features
    out_dir = Path(out_dir)
    F = load_features(sorted((out_dir / "features").glob("*.npz")))
    prof = profile(F)
    render(F, prof, out_dir / "modulation_profile.png", title=sess.name,
           clock=sess.clock)
    (out_dir / "modulation.json").write_text(json.dumps(prof, indent=2))
    return prof

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
def tonal_peaks(spec_row: np.ndarray, freqs: np.ndarray, 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.
    """
    m = (freqs >= fmin) & (freqs <= fmax)
    ls = 10 * np.log10(spec_row[m] + EPS)
    floor = median_filter(ls, size=101, mode="nearest")
    rise = ls - floor
    pk, props = find_peaks(rise, height=min_prom_db, distance=3)
    order = np.argsort(props["peak_heights"])[::-1][:max_n]
    keep = np.sort(pk[order])
    return freqs[m][keep], rise[keep], spec_row[m][keep]

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
def tonal_tracks(minspec: np.ndarray, freqs: np.ndarray, 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."""
    per_min = [tonal_peaks(minspec[i], freqs, **peak_kw)
               for i in range(minspec.shape[0])]
    open_tracks, done = [], []
    for mi, (fq, prom, _pw) in enumerate(per_min):
        used = np.zeros(len(fq), bool)
        for tr in list(open_tracks):
            cents = 1200 * np.abs(np.log2(fq / (tr["f"][-1] + EPS) + EPS))
            j = int(np.argmin(cents)) if len(cents) else -1
            if j >= 0 and cents[j] < tol_cents and not used[j]:
                tr["f"].append(float(fq[j]))
                tr["prom"].append(float(prom[j]))
                tr["m1"] = mi
                used[j] = True
            elif mi - tr["m1"] > 1:
                open_tracks.remove(tr)
                done.append(tr)
        for j in np.flatnonzero(~used):
            open_tracks.append(dict(f=[float(fq[j])], prom=[float(prom[j])],
                                    m0=mi, m1=mi))
    done += open_tracks
    out = []
    for tr in done:
        if tr["m1"] - tr["m0"] + 1 < min_minutes:
            continue
        f = np.array(tr["f"])
        out.append({
            "f_median_hz": round(float(np.median(f)), 1),
            "t0_min": tr["m0"], "t1_min": tr["m1"],
            "minutes": tr["m1"] - tr["m0"] + 1,
            "prominence_db": round(float(np.mean(tr["prom"])), 1),
            "drift_cents": round(float(1200 * np.log2(
                (f[-1] + EPS) / (f[0] + EPS))), 1),
        })
    return sorted(out, key=lambda t: -t["minutes"])

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
def group_tracks(tracks: list[dict], tol_cents: float = 60.0) -> list[dict]:
    """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.
    """
    if not tracks:
        return []
    order = sorted(tracks, key=lambda t: t["f_median_hz"])
    groups: list[list[dict]] = [[order[0]]]
    for t in order[1:]:
        ref = float(np.median([x["f_median_hz"] for x in groups[-1]]))
        if abs(1200 * np.log2(t["f_median_hz"] / ref)) <= tol_cents:
            groups[-1].append(t)
        else:
            groups.append([t])
    out = []
    for g in groups:
        mins = sum(x["minutes"] for x in g)
        out.append({
            "f_median_hz": round(float(np.median(
                [x["f_median_hz"] for x in g])), 1),
            "minutes": int(mins),
            "n_segments": len(g),
            "t0_min": min(x["t0_min"] for x in g),
            "t1_min": max(x["t1_min"] for x in g),
            "prominence_db": round(float(sum(
                x["prominence_db"] * x["minutes"] for x in g) / max(mins, 1)), 1),
        })
    return sorted(out, key=lambda t: -t["minutes"])

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
def harmonic_sieve(fq: np.ndarray, power: np.ndarray, 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.
    """
    if len(fq) == 0:
        return None, 0.0
    cands = np.concatenate([fq / k for k in range(1, 7)])
    cands = cands[(cands >= f0_min) & (cands <= f0_max)]
    best_f0, best = None, 0.0
    total = power.sum() + EPS
    for f0 in cands:
        k = np.clip(np.round(fq / f0), 1, max_harm)
        cents = 1200 * np.abs(np.log2(fq / (k * f0)))
        score = float(power[cents < tol_cents].sum() / total)
        if score > best:
            best_f0, best = float(f0), score
    return best_f0, round(best, 3)

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
def narrow_line_prominence(freqs: np.ndarray, spec_db: np.ndarray, f0: float,
                           half_hz: float = 0.35,
                           ring: tuple = (1.5, 6.0)) -> float | None:
    """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.
    """
    freqs = np.asarray(freqs, float)
    spec_db = np.asarray(spec_db, float)
    peak = (freqs >= f0 - half_hz) & (freqs <= f0 + half_hz)
    lo, hi = ring
    surround = (np.abs(freqs - f0) > lo) & (np.abs(freqs - f0) <= hi)
    if not peak.any() or surround.sum() < 8:
        return None
    return float(spec_db[peak].max() - np.median(spec_db[surround]))

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
def family_prominence(freqs: np.ndarray, spec_db: np.ndarray, f0: float,
                      band: tuple = (10.0, 350.0), min_harmonics: int = 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.
    """
    rungs = []
    n = 1
    while n * f0 <= band[1]:
        if n * f0 >= band[0]:
            rungs.append((n, n * f0,
                          narrow_line_prominence(freqs, spec_db, n * f0,
                                                 **line_kw)))
        n += 1
    vals = [v for _n, _f, v in rungs if v is not None]
    if len(vals) < min_harmonics:
        return None, rungs
    return float(np.mean(vals)), rungs

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
def family_percentile(freqs: np.ndarray, spec_db: np.ndarray, f0: float,
                      sweep: tuple = (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.
    """
    grid = np.arange(*sweep)
    scores = np.array([
        s if (s := family_prominence(freqs, spec_db, f, **family_kw)[0])
        is not None else np.nan for f in grid])
    score = family_prominence(freqs, spec_db, f0, **family_kw)[0]
    finite = scores[np.isfinite(scores)]
    pct = (100.0 * float((finite < score).mean())
           if score is not None and finite.size else None)
    return pct, score, grid, scores

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
def pitch_class_profile(minspec: np.ndarray, freqs: np.ndarray,
                        minutes=None, **peak_kw):
    """Tonal peak power folded onto 12 pitch classes (A4 = 440 Hz)."""
    pcp = np.zeros(12)
    rows = range(minspec.shape[0]) if minutes is None else minutes
    for i in rows:
        fq, _prom, pw = tonal_peaks(minspec[i], freqs, **peak_kw)
        if len(fq):
            pc = np.mod(np.round(12 * np.log2(fq / 440.0) + 69), 12).astype(int)
            np.add.at(pcp, pc, pw)
    return pcp / (pcp.sum() + EPS)

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
def run_session(sess, out_dir) -> dict:
    """Tonality timeline + per-minute tonalness/harmonicity + PCP + figure."""
    import json
    import matplotlib
    matplotlib.use("Agg")
    import matplotlib.pyplot as plt
    from .features import load_features

    out_dir = Path(out_dir)
    F = load_features(sorted((out_dir / "features").glob("*.npz")))
    minspec, freqs = F["minspec"], F["freqs"]
    nmin = minspec.shape[0]

    tracks = tonal_tracks(minspec, freqs)
    tonalness, harmonicity = np.zeros(nmin), np.full(nmin, np.nan)
    for i in range(nmin):
        fq, _prom, pw = tonal_peaks(minspec[i], freqs)
        band = (freqs >= 40) & (freqs <= 8000)
        tonalness[i] = float(pw.sum() / (minspec[i][band].sum() + EPS))
        if len(fq) >= 3:
            _f0, h = harmonic_sieve(fq, pw)
            harmonicity[i] = h
    pcp = pitch_class_profile(minspec, freqs)

    doc = {
        "tracks": tracks[:40],
        "tonalness_median": round(float(np.median(tonalness)), 3),
        "harmonicity_median": round(float(np.nanmedian(harmonicity)), 3),
        "inharmonicity_median": round(1 - float(np.nanmedian(harmonicity)), 3),
        "pitch_class_profile": {NOTE[i]: round(float(pcp[i]), 3)
                                for i in range(12)},
        "top_pitch_classes": [NOTE[i] for i in np.argsort(pcp)[::-1][:3]],
    }
    (out_dir / "tonality.json").write_text(json.dumps(doc, indent=2))

    fig, ax = plt.subplots(1, 2, figsize=(12.8, 4.6), dpi=130,
                           gridspec_kw=dict(width_ratios=[2.4, 1]))
    for tr in tracks:
        ax[0].plot([tr["t0_min"], tr["t1_min"] + 1],
                   [tr["f_median_hz"]] * 2, lw=max(0.8, tr["prominence_db"] / 6),
                   color="#2a78d6", alpha=0.7, solid_capstyle="butt")
    ax[0].set(yscale="log", xlabel="minute of session", ylabel="Hz",
              title=f"{sess.name} — tonal tracks (width = prominence)")
    ax[0].grid(alpha=0.2, which="both")
    ax[1].bar(range(12), pcp, color="#2a78d6")
    ax[1].set_xticks(range(12), NOTE, fontsize=8)
    ax[1].set(title="pitch-class profile", ylabel="tonal power share")
    ax[1].grid(alpha=0.2, axis="y")
    fig.tight_layout()
    fig.savefig(out_dir / "tonality.png")
    plt.close(fig)
    return doc

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:

  1. detect_partials — narrowband peaks from the cached per-minute mean PSD, contrasting source-active against quiet minutes;
  2. 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;
  3. cluster_partials — group partials into sources by correlating their half-wave-rectified log-envelope derivatives (strike-synchronous);
  4. pick_strikes — adaptive, strongest-first onset picking per source;
  5. rayleigh_period / period_track — point-process periodicity (resultant length over a period grid, with harmonics for multi-strike cycles);
  6. cycle_grid — repetition-with-variation statistics: per-cycle timing residuals and amplitudes for each position of the repeating pattern;
  7. 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
def detect_partials(F: dict, active: np.ndarray, quiet: np.ndarray,
                    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.
    """
    S_a = F["minspec"][active].mean(0)
    S_q = F["minspec"][quiet].mean(0)
    freqs = F["freqs"]
    m = (freqs >= band[0]) & (freqs <= band[1])
    ratio = 10 * np.log10((S_a[m] + EPS) / (S_q[m] + EPS))
    pk, props = find_peaks(ratio, height=min_rise_db, prominence=5.0,
                           distance=5)
    order = np.argsort(props["peak_heights"])[::-1][:max_n]
    keep = np.sort(pk[order])
    return freqs[m][keep], ratio[keep]

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
def partial_pass(take: Take, pfreq, nfft=4096, hop=960) -> dict:
    """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."""
    fs = take.samplerate
    hop = int(hop * fs / 48000)
    win = np.hanning(nfft).astype(np.float32)
    freqs = np.fft.rfftfreq(nfft, 1 / fs)
    pbin = np.array([int(round(f * nfft / fs)) for f in np.atleast_1d(pfreq)])
    psel = np.stack([pbin - 1, pbin, pbin + 1], 1)
    fmask = (freqs >= 400) & (freqs <= 4500)

    env, ix, iy, iz, odf = [], [], [], [], []
    prevL = None
    carry = np.zeros((0, take.channels), np.float32)
    ambix = getattr(take, "mode", "ambix") == "ambix"
    if ambix:
        iW, iY, iZ, iX = take.wyzx
    with sf.SoundFile(str(take.audio_path)) as f:
        while True:
            block = f.read(60 * fs, dtype="float32", always_2d=True)
            if block.shape[0] == 0:
                break
            data = np.concatenate([carry, block]) if carry.shape[0] else block
            nwin = (data.shape[0] - nfft) // hop + 1
            if nwin <= 0:
                carry = data
                continue
            idx = np.arange(nfft)[None, :] + hop * np.arange(nwin)[:, None]
            W = np.fft.rfft(take.mono_ref(data)[idx] * win)
            Pw = W.real ** 2 + W.imag ** 2
            env.append(Pw[:, psel].sum(2).astype(np.float32))
            if ambix:      # source azimuth needs the intensity vector
                Y = np.fft.rfft(data[:, iY][idx] * win)
                Z = np.fft.rfft(data[:, iZ][idx] * win)
                X = np.fft.rfft(data[:, iX][idx] * win)
                ix.append((W.conj() * X).real[:, psel].sum(2).astype(np.float32))
                iy.append((W.conj() * Y).real[:, psel].sum(2).astype(np.float32))
                iz.append((W.conj() * Z).real[:, psel].sum(2).astype(np.float32))
            else:          # stereo/mono: no direction for the pitched source
                z = np.zeros((idx.shape[0], psel.shape[0]), np.float32)
                ix.append(z); iy.append(z.copy()); iz.append(z.copy())
            L = np.log1p(1e6 * Pw[:, fmask])
            Lp = np.concatenate([prevL[None] if prevL is not None else L[:1], L])
            odf.append(np.maximum(np.diff(Lp, axis=0), 0).sum(1)
                       .astype(np.float32))
            prevL = L[-1]
            carry = data[nwin * hop:].copy()
    odf = np.concatenate(odf)
    return dict(t=(np.arange(len(odf)) * hop + nfft // 2) / fs,
                env=np.concatenate(env), ix=np.concatenate(ix),
                iy=np.concatenate(iy), iz=np.concatenate(iz),
                odf=odf, pfreq=np.atleast_1d(pfreq).astype(np.float64))

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
def cluster_partials(env: np.ndarray, 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)."""
    from scipy.cluster.hierarchy import fcluster, linkage
    e = env if mask is None else env[mask]
    dL = np.maximum(np.diff(np.log10(e + EPS), axis=0), 0)
    dL = maximum_filter1d(dL, 3, axis=0)
    C = np.corrcoef(dL.T)
    d = 1 - C
    np.fill_diagonal(d, 0)
    lab = fcluster(linkage(d[np.triu_indices_from(d, 1)], method="average"),
                   th, criterion="distance")
    groups = {}
    for i, l in enumerate(lab):
        groups.setdefault(l, []).append(i)
    out = [v for v in groups.values() if len(v) >= min_size]
    return sorted(out, key=len, reverse=True)

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
def source_odf(env: np.ndarray, cols) -> np.ndarray:
    """Mean rectified log-envelope derivative over one source's partials."""
    L = np.log10(env[:, cols] + EPS)
    return np.maximum(np.diff(L, axis=0, prepend=L[:1]), 0).mean(1)

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
def 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.
    """
    dt = float(np.median(np.diff(t)))
    med = median_filter(odf, int(8.0 / dt) | 1)
    mad = median_filter(np.abs(odf - med), int(8.0 / dt) | 1) + 1e-9
    z = (odf - med) / mad
    ismax = odf == maximum_filter1d(odf, 2 * int(0.3 / dt) + 1)
    ok = ismax & (z > k)
    if t_max is not None:
        ok &= t < t_max
    cand = np.flatnonzero(ok)
    taken = np.zeros(len(t), bool)
    guard = int(min_sep / dt)
    keep = []
    for i in cand[np.argsort(z[cand])[::-1]]:
        if not taken[max(0, i - guard):i + guard].any():
            keep.append(i)
            taken[i] = True
    return np.sort(t[np.array(keep, int)]) if keep else np.array([])

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
def 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."""
    x = odf if t_mask is None else odf[t_mask]
    y = x - x.mean()
    a = np.correlate(y, y, "full")[len(y) - 1:]
    a /= a[0] + EPS
    m = int(max_lag_s / dt)
    pk, props = find_peaks(a[:m], prominence=0.02)
    if not len(pk):
        return None, None
    vals = a[pk]
    near_max = pk[vals >= 0.9 * vals.max()]     # prefer the fundamental over
    cycle = float(near_max.min() * dt)          # its multiples
    strong = pk[vals >= rel * vals.max()]
    return cycle, float(strong.min() * dt)

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
def 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)."""
    R = np.zeros(len(grid))
    for i, P in enumerate(grid):
        ph = 2 * np.pi * times / P
        R[i] = sum(np.abs(np.exp(1j * h * ph).mean())
                   for h in range(1, harm + 1))
    return R

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
def best_period(times, lo=0.5, hi=8.0, step=5e-4, harm=2) -> float:
    """Grid-search ``rayleigh_period`` with parabolic refinement."""
    grid = np.arange(lo, hi, step)
    R = rayleigh_period(times, grid, harm)
    i = int(np.argmax(R))
    if 0 < i < len(R) - 1:
        d = (R[i - 1] - R[i + 1]) / (2 * (R[i - 1] - 2 * R[i] + R[i + 1]))
        return float(grid[i] + d * step)
    return float(grid[i])

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
def 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)."""
    ts, Ps = [], []
    g = np.arange(P0 - half_range, P0 + half_range, 5e-4)
    for w0 in np.arange(times.min(), times.max() - win, step):
        sel = times[(times >= w0) & (times < w0 + win)]
        if len(sel) < 20:
            continue
        ts.append(w0 + win / 2)
        Ps.append(g[np.argmax(rayleigh_period(sel, g, harm))])
    return np.array(ts), np.array(Ps)

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
def 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."""
    ph = (times / P) % 1.0
    h, e = np.histogram(ph, bins=100)
    main = e[np.argmax(h)] + 0.005
    ph0 = (ph - main) % 1.0
    out = []
    left = np.ones(len(times), bool)
    while left.any():
        h, e = np.histogram(ph0[left], bins=50, range=(0, 1))
        if h.max() < max(3, 0.02 * len(times)):
            break
        c = e[np.argmax(h)] + 0.01
        d = np.minimum(np.abs(ph0 - c), 1 - np.abs(ph0 - c))
        m = left & (d < width)
        out.append((float(c) % 1.0, m))
        left &= ~m
    return ph0, out

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
def cycle_grid(streams: dict, P: float, t_max: float) -> dict:
    """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.
    """
    names = list(streams)
    ref = streams[names[0]]
    t0 = float(np.median((ref / P) % 1.0)) * P
    ncyc = int((t_max - t0) / P)
    out = {"P": P, "t0": t0, "ncyc": ncyc, "streams": {}}
    for name in names:
        tk = streams[name]
        pos = float(np.median(((tk - t0) / P) % 1.0)) * P
        res = np.full(ncyc, np.nan)
        for s in tk:
            c = int(round((s - t0 - pos) / P))
            if 0 <= c < ncyc:
                r = s - (t0 + c * P + pos)
                if abs(r) < 0.45 * P and (np.isnan(res[c]) or
                                          abs(r) < abs(res[c])):
                    res[c] = r
        v = res[~np.isnan(res)]
        m = ~np.isnan(res)
        if not m.any():          # cluster never landed on the cycle grid
            continue
        g = ~np.isnan(res[:-1]) & ~np.isnan(res[1:])
        r1 = float(np.corrcoef(res[:-1][g], res[1:][g])[0, 1]) if g.sum() > 3 \
            else np.nan
        xi = np.interp(np.arange(ncyc), np.flatnonzero(m), res[m])
        slow = uniform_filter1d(xi, max(3, int(60.0 / P)))
        out["streams"][name] = dict(
            pos=pos, res=res, hit_rate=float(m.mean()),
            sd_ms=float(np.std(v) * 1e3), lag1=r1,
            slow_sd_ms=float(np.std(slow) * 1e3),
            fast_sd_ms=float(np.std(xi - slow) * 1e3))
    return out

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
def rise_spectrum(take: 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).
    """
    fs = take.samplerate
    win = np.hanning(nfft)
    freqs = np.fft.rfftfreq(nfft, 1 / fs)
    rng = np.random.default_rng(seed)
    sel = rng.choice(times, min(n_max, len(times)), replace=False)
    acc, n = np.zeros(len(freqs)), 0
    with sf.SoundFile(str(take.audio_path)) as f:
        for s in sel:
            i = int(s * fs)
            if i < nfft or i + nfft > f.frames:
                continue
            f.seek(i - nfft + int(0.02 * fs))
            x = take.mono_ref(f.read(2 * nfft, dtype="float64", always_2d=True))
            if len(x) < 2 * nfft:
                continue
            pre = np.abs(np.fft.rfft(x[:nfft] * win)) ** 2
            post = np.abs(np.fft.rfft(x[nfft:] * win)) ** 2
            acc += 10 * np.log10((post + EPS) / (pre + EPS))
            n += 1
    return freqs, acc / max(n, 1)

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
def 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."""
    import json
    from .features import load_features
    out_dir = Path(out_dir)
    fdir = out_dir / "features"
    F = load_features(sorted(fdir.glob("*.npz")))
    take = sess.takes[0]
    active, quiet, med = _activity_masks(F)
    if t_stop is None:
        sm = median_filter(10 * np.log10(F["oct_pow"][:, 5] + EPS), 31)
        if active.any() and quiet.any():
            thr = (np.median(sm[np.repeat(active, 60)[:len(sm)]])
                   + np.median(sm[np.repeat(quiet, 60)[:len(sm)]])) / 2
        else:                    # no active/quiet split: use the whole session
            thr = float(np.median(sm))
        idx = np.flatnonzero(sm > thr)
        if not idx.size:
            if verbose:
                print("  no active periodic section detected")
            summary = {"t_stop_s": None, "sources": []}
            (Path(out_dir) / "rhythm.json").write_text(
                json.dumps(summary, indent=1))
            return summary
        t_stop = float(idx.max())
    if verbose:
        print(f"  active section ends at {t_stop:.0f} s")
    prior_used = partials is not None
    if prior_used:
        pfreq, rise = np.asarray(partials, dtype=float), None
        if verbose:
            print(f"  informed prior: {len(pfreq)} partials "
                  f"({pfreq.min():.0f}-{pfreq.max():.0f} Hz)")
    else:
        pfreq, rise = detect_partials(F, active, quiet)
        if not len(pfreq):
            if verbose:
                print("  no salient partials in the active section")
            summary = {"t_stop_s": round(t_stop, 1), "sources": []}
            (Path(out_dir) / "rhythm.json").write_text(
                json.dumps(summary, indent=1))
            return summary
        if verbose:
            print(f"  {len(pfreq)} partials "
                  f"({pfreq.min():.0f}-{pfreq.max():.0f} Hz)")
    P = partial_pass(take, pfreq)
    t = P["t"]
    if groups is not None:
        named_groups = _resolve_groups(groups, pfreq)
    else:
        named_groups = [(chr(ord("A") + gi), cols) for gi, cols in
                        enumerate(cluster_partials(P["env"],
                                                   mask=t < t_stop)[:n_sources])]
    summary = {"t_stop_s": round(t_stop, 1), "sources": []}
    streams = {}
    for name, cols in named_groups:
        odf = source_odf(P["env"], cols)
        dt = float(np.median(np.diff(t)))
        cycle, min_gap = acf_structure(odf, dt, t_mask=t < t_stop)
        if cycle is None:
            continue
        if min_gap_floor is not None:
            min_gap = max(min_gap, float(min_gap_floor))
        s0 = pick_strikes(odf, t, min_sep=0.8 * min_gap, k=strike_k,
                          t_max=t_stop)
        if len(s0) < 20:
            continue
        Pbest = best_period(s0, lo=0.9 * cycle, hi=1.1 * cycle)
        ph0, clusters = phase_clusters(s0, Pbest)
        src = {"name": name,
               "partials_hz": [round(float(pfreq[c]), 1) for c in cols],
               "period_s": round(Pbest, 4),
               "n_strikes": int(len(s0)),
               "phase_clusters": [round(c, 3) for c, _ in clusters]}
        az, el, R = strike_doa(P, s0, cols)
        src["azimuth_deg"], src["elevation_deg"], src["az_R"] = \
            round(az, 1), round(el, 1), round(R, 2)
        for ci, (c, m) in enumerate(clusters):
            streams[f"{name}{ci}"] = s0[m]
        summary["sources"].append(src)
    # circular statistics per stream and inter-source phase locking
    if streams:
        from .circstats import phase_stats, relative_phase
        P0 = summary["sources"][0]["period_s"]
        summary["phase_stats"] = {n: phase_stats(tk, P0)
                                  for n, tk in streams.items()}
        prim = {n[0]: tk for n, tk in sorted(streams.items())
                if n.endswith("0")}
        names = sorted(prim)
        summary["phase_lock"] = {
            f"{b}_vs_{a}": relative_phase(prim[b], prim[a], P0)
            for a, b in zip(names, names[1:])}
    # flag phase clusters that coincide with another source's strikes
    # (cross-talk between partial groups, not independent strikes)
    for n, tk in streams.items():
        others = np.sort(np.concatenate(
            [v for m, v in streams.items() if m[0] != n[0]] or [np.array([])]))
        if not len(others) or not len(tk):
            continue
        i = np.clip(np.searchsorted(others, tk), 1, len(others) - 1)
        near = np.minimum(np.abs(tk - others[i - 1]), np.abs(tk - others[i]))
        frac = float((near < 0.06).mean())
        if frac > 0.5:
            summary.setdefault("crosstalk_suspects", {})[n] = round(frac, 2)
    if streams:
        P0 = summary["sources"][0]["period_s"]
        grid = cycle_grid(streams, P0, t_stop)
        summary["cycle"] = {
            n: {k: (round(v, 3) if isinstance(v, float) else v)
                for k, v in d.items() if k != "res"}
            for n, d in grid["streams"].items()}
        summary["cycle_period_s"] = P0
        _overview_figure(P, streams, grid, t_stop,
                         out_dir / "rhythm_overview.png", title=sess.name)
    if prior_used or groups is not None:
        summary["_prior"] = {
            "partials_hz": [round(float(x), 1) for x in pfreq],
            "groups": {n: [round(float(pfreq[c]), 1) for c in cols]
                       for n, cols in named_groups},
            "strike_k": strike_k, "min_gap_floor": min_gap_floor}
        summary["_method_note"] = (
            "informed-prior run: partial list and A/B grouping supplied as a "
            "fixed prior (blind detection unreliable at this distance/SNR); "
            f"strike threshold k={strike_k} MADs, "
            f"min intra-cycle gap floored at {min_gap_floor} s.")
    (out_dir / "rhythm.json").write_text(json.dumps(summary, indent=2))
    return summary

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
def partial_fm(take: Take, freq: float, period: float, t_max: float,
               nfft=8192, hop=960) -> dict:
    """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.
    """
    fs = take.samplerate
    hop = int(hop * fs / 48000)
    win = np.hanning(nfft).astype(np.float32)
    freqs = np.fft.rfftfreq(nfft, 1 / fs)
    b0 = int(round(freq * nfft / fs))
    sel = np.arange(b0 - 2, b0 + 3)
    fi, ei, tt = [], [], []
    carry = np.zeros((0,), np.float32)
    t_off = 0.0
    with sf.SoundFile(str(take.audio_path)) as f:
        while t_off < t_max:
            block = take.mono_ref(f.read(60 * fs, dtype="float32",
                                         always_2d=True))
            if block.shape[0] == 0:
                break
            data = np.concatenate([carry, block]) if carry.shape[0] else block
            nwin = (len(data) - nfft) // hop + 1
            if nwin <= 0:
                carry = data
                continue
            idx = np.arange(nfft)[None, :] + hop * np.arange(nwin)[:, None]
            S = np.abs(np.fft.rfft(data[idx] * win))[:, sel] ** 2
            e = S.sum(1)
            fi.append((S * freqs[sel]).sum(1) / (e + EPS))
            ei.append(e)
            tt.append(t_off + (idx[:, 0] + nfft // 2) / fs)
            t_off += nwin * hop / fs
            carry = data[nwin * hop:].copy()
    fi = np.concatenate(fi)
    e = np.concatenate(ei)
    tt = np.concatenate(tt)
    m = (tt < t_max) & (e > np.percentile(e, 60))    # ringing frames only
    cents = 1200 * np.log2(fi[m] / freq)
    w = e[m] / e[m].sum()

    def demod(P):
        # weighted least-squares sinusoid fit: unbiased even though the
        # energy gate samples the cycle unevenly
        ph = 2 * np.pi * tt[m] / P
        A = np.stack([np.sin(ph), np.cos(ph), np.ones_like(ph)], 1)
        Aw = A * w[:, None]
        coef = np.linalg.lstsq(Aw.T @ A, Aw.T @ cents, rcond=None)[0]
        return float(np.hypot(coef[0], coef[1]))

    return {
        "freq_hz": freq, "period_s": period,
        "fm_cents_at_cycle": round(demod(period), 2),
        "fm_cents_control": round(demod(1.37 * period), 2),
        "n_frames": int(m.sum()),
    }

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
def strike_doa(P: dict, times, cols, dur=0.25):
    """Median per-strike azimuth/elevation (deg) from the pass arrays,
    energy-integrated over ``dur`` seconds after each strike."""
    t = P["t"]
    dt = float(np.median(np.diff(t)))
    az, el = [], []
    for s in times:
        sl = slice(int(s / dt), min(int(s / dt) + int(dur / dt), len(t)))
        Ix = P["ix"][sl][:, cols].sum()
        Iy = P["iy"][sl][:, cols].sum()
        Iz = P["iz"][sl][:, cols].sum()
        az.append(np.degrees(np.arctan2(Iy, Ix)))
        el.append(np.degrees(np.arctan2(Iz, np.hypot(Ix, Iy))))
    az = np.array(az)
    R = float(np.abs(np.exp(1j * np.radians(az)).mean()))
    return float(np.median(az)), float(np.median(el)), R

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
def direct_diffuse_split(F: dict):
    """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).
    """
    I = np.linalg.norm(F["I_band"], axis=2)
    d = np.clip(I / (F["oct_pow"] + EPS), 0, 1)
    return d, np.median(d, axis=0)

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_deg with fit R^2 >= min_r2 is 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
def passby_events(F: dict, 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_deg`` with fit R^2 >=
    ``min_r2`` is a pass-by; the sweep sign gives the direction of travel
    (mic frame). Returns a list of dicts.
    """
    from .analysis import detect_events
    dt = float(np.median(np.diff(F["t_fast"])))
    events, _bg = detect_events(F["fast_db"], dt)
    t0_abs = float(F["t"][0])
    out = []
    for e in events:
        a = F["t_fast"][e["i0"]] - t0_abs
        b = F["t_fast"][e["i1"]] - t0_abs
        i0, i1 = int(a), int(np.ceil(b))
        if i1 - i0 < min_dur_s or i1 >= len(F["az"]):
            continue
        az = np.unwrap(np.radians(F["az"][i0:i1]))
        x = np.arange(len(az), dtype=float)
        A = np.vstack([x, np.ones_like(x)]).T
        coef, res, *_ = np.linalg.lstsq(A, az, rcond=None)
        tot = ((az - az.mean()) ** 2).sum()
        r2 = 1 - float(res[0]) / (tot + EPS) if len(res) else 0.0
        sweep = float(np.degrees(coef[0]) * len(az))
        if abs(sweep) >= min_sweep_deg and r2 >= min_r2:
            out.append({
                "t0_s": i0, "dur_s": i1 - i0,
                "sweep_deg": round(sweep, 1),
                "rate_deg_s": round(float(np.degrees(coef[0])), 1),
                "direction": "left-to-right" if sweep < 0 else "right-to-left",
                "r2": round(r2, 2),
            })
    return out

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
def azimuth_organization(F: dict, 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.
    """
    p = F["rms_w"].astype(np.float64) ** 2
    az = np.radians(F["az"])
    n, w, s = len(az), int(win_s), int(step_s)
    if n < w:            # take shorter than one window: measure the whole
        w = max(n, 1)    # take once rather than returning no data at all
    ts, Rs = [], []
    for i0 in range(0, n - w + 1, s):
        _mu, R = mean_resultant(az[i0:i0 + w], weights=p[i0:i0 + w])
        ts.append(float(F["t"][i0] + w / 2 - F["t"][0]))
        Rs.append(R)
    return np.array(ts), np.array(Rs)

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
def frame_reference_test(bearing_deg, heading_deg, weights=None,
                         control_deg=None) -> dict:
    """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'
    """
    b = np.radians(np.asarray(bearing_deg, float))
    h = np.radians(np.asarray(heading_deg, float))
    if b.shape != h.shape:
        raise ValueError(f"bearing and heading differ in shape: "
                         f"{b.shape} vs {h.shape}")
    w = np.ones_like(b) if weights is None else np.asarray(weights, float)
    ok = np.isfinite(b) & np.isfinite(h) & np.isfinite(w)
    b, h, w = b[ok], h[ok], w[ok]
    if b.size < 2:
        raise ValueError("need at least two finite bearing/heading pairs")
    n_eff = float(w.sum() ** 2 / ((w ** 2).sum() + EPS))
    r_chance = float(1.0 / np.sqrt(n_eff))
    _mu_r, r_rig = mean_resultant(b, weights=w)
    _mu_w, r_world = mean_resultant(b + h, weights=w)
    if max(r_rig, r_world) <= r_chance:
        frame = "neither"
    else:
        frame = "rig" if r_rig >= r_world else "world"
    out = {"R_rig": round(r_rig, 4), "R_world": round(r_world, 4),
           "ratio": round(float(r_rig / (r_world + EPS)), 2),
           "R_chance": round(r_chance, 4), "n": int(b.size),
           "n_effective": round(n_eff, 1), "frame": frame}
    if control_deg is not None:
        c = np.radians(np.asarray(control_deg, float))[ok]
        _mu_c, cr = mean_resultant(c, weights=w)
        _mu_cw, cw = mean_resultant(c + h, weights=w)
        out["control"] = {"R_rig": round(cr, 4), "R_world": round(cw, 4),
                          "ratio": round(float(cr / (cw + EPS)), 2)}
    return out

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
def directional_entropy(F: dict, nbins: int = 36) -> float:
    """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.
    """
    p = np.asarray(F["rms_w"], np.float64) ** 2
    h, _ = np.histogram(F["az"], bins=nbins, range=_az_span(F), weights=p)
    q = h / (h.sum() + EPS)
    return float(-(q * np.log(q + EPS)).sum() / np.log(nbins))

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
def horizon_fractions(F: dict, limit_deg: float = 10.0) -> dict:
    """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.
    """
    p = np.asarray(F["rms_w"], np.float64) ** 2
    el = np.asarray(F["el"], float)
    tot = p.sum() + EPS
    return {"above": round(float(p[el > limit_deg].sum() / tot), 2),
            "level": round(float(p[np.abs(el) <= limit_deg].sum() / tot), 2),
            "below": round(float(p[el < -limit_deg].sum() / tot), 2)}

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
def fg_bg_az_overlap(F: dict, nbins: int = 36) -> float:
    """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).
    """
    p = np.asarray(F["rms_w"], np.float64) ** 2
    fg = p >= np.percentile(p, 75)
    bg = p <= np.percentile(p, 25)
    hists = []
    for m in (fg, bg):
        h, _ = np.histogram(F["az"][m], bins=nbins, range=_az_span(F),
                            weights=p[m])
        hists.append(h / (h.sum() + EPS))
    return float(np.sqrt(hists[0] * hists[1]).sum())

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
def summarize_spatial(F: dict) -> dict:
    """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.
    """
    has_az = np.isfinite(np.asarray(F["az"], float)).any()
    has_el = np.isfinite(np.asarray(F["el"], float)).any()
    hf = horizon_fractions(F) if has_el else None
    return {
        "directional_entropy": round(directional_entropy(F), 3) if has_az else None,
        "above_horizon_fraction": hf["above"] if hf else None,
        "below_horizon_fraction": hf["below"] if hf else None,
        "fgbg_az_overlap": round(fg_bg_az_overlap(F), 2) if has_az else None,
    }

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
def run_session(sess, out_dir) -> dict:
    """CLI driver: split + pass-bys + R(t), figure + spatial.json."""
    import json
    import matplotlib
    matplotlib.use("Agg")
    import matplotlib.pyplot as plt
    from .features import load_features, OCT_CENTERS

    out_dir = Path(out_dir)
    F = load_features(sorted((out_dir / "features").glob("*.npz")))
    d, dmed = direct_diffuse_split(F)
    pb = passby_events(F)
    ts, Rs = azimuth_organization(F)
    doc = {
        "directness_median_per_octave": {
            str(int(c)): round(float(v), 2)
            for c, v in zip(OCT_CENTERS, dmed)},
        "azimuth_R_median": round(float(np.median(Rs)), 2),
        "azimuth_R_iqr": round(float(np.percentile(Rs, 75)
                                     - np.percentile(Rs, 25)), 2),
        "passbys": pb,
    }
    (out_dir / "spatial.json").write_text(json.dumps(doc, indent=2,
                                                     default=float))

    fig, ax = plt.subplots(2, 1, figsize=(12.8, 6.4), dpi=130, sharex=True)
    tt = F["t"] - F["t"][0]
    ax[0].pcolormesh(tt, np.arange(len(OCT_CENTERS)), d.T, cmap="magma",
                     vmin=0, vmax=1, shading="auto")
    ax[0].set_yticks(range(len(OCT_CENTERS)),
                     [str(int(c)) for c in OCT_CENTERS], fontsize=7)
    ax[0].set(ylabel="octave (Hz)",
              title=f"{sess.name} — directness per octave (1=plane wave, "
                    "0=diffuse)")
    ax[1].plot(ts, Rs, color="#2a78d6", lw=1.2)
    for e in pb:
        ax[1].axvspan(e["t0_s"], e["t0_s"] + e["dur_s"], color="#d66a2a",
                      alpha=0.3)
    ax[1].set(xlabel="time (s)", ylabel="azimuth R (60 s)", ylim=(0, 1),
              title="directional organization; shaded = pass-by events")
    ax[1].grid(alpha=0.2)
    fig.tight_layout()
    fig.savefig(out_dir / "spatial.png")
    plt.close(fig)
    return doc

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
def match_periods(times_abs: np.ndarray, periods=CIVIC_PERIODS) -> list[dict]:
    """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.
    """
    t = np.asarray(times_abs, float)
    out = []
    for P in periods:
        ph = 2 * np.pi * (t / P % 1.0)
        mu, R = mean_resultant(ph)
        out.append({
            "period_s": P,
            "phase_s": round(float((mu / (2 * np.pi)) % 1.0 * P), 1),
            "R": round(R, 3),
            "circ_sd_s": round(circular_sd(R) / (2 * np.pi) * P, 1),
            "rayleigh_p": rayleigh_p(R, len(t)),
            "n": int(len(t)),
            "n_cycles": int(np.ptp(t) // P) + 1,
        })
    return sorted(out, key=lambda d: -d["R"])

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
def grid_scan(F: dict, period_s: float, phase_s: float = 0.0,
              band=(300.0, 1500.0), win_s: float = 120.0,
              min_rise_db: float = 6.0, bg_win_s: float = 300.0) -> list[dict]:
    """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`).
    """
    from scipy.ndimage import percentile_filter
    from .states import band_level

    t = np.asarray(F["t"], float)
    lvl = band_level(F, band)
    n = max(3, int(round(bg_win_s)) | 1)
    rise = lvl - percentile_filter(lvl, 10, size=n, mode="nearest")
    first = np.ceil((t[0] - phase_s) / period_s) * period_s + phase_s
    out = []
    for tick in np.arange(first, t[-1], period_s):
        m = np.abs(t - tick) <= win_s / 2
        if not m.any():
            continue
        i = int(np.argmax(rise[m]))
        r = float(rise[m][i])
        out.append({
            "t_tick": float(tick),
            "detected": bool(r >= min_rise_db),
            "rise_db": round(r, 1),
            "offset_s": round(float(t[m][i] - tick), 1),
        })
    return out

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
def clock_offset(observed_abs: float, true_clock_s: float) -> float:
    """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).
    """
    return float(true_clock_s - observed_abs % 86400.0)

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
def run_session(sess, out_dir) -> dict:
    """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``.
    """
    import json
    from pathlib import Path
    from .analysis import detect_events
    from .features import load_features

    out_dir = Path(out_dir)
    F = load_features(sorted((out_dir / "features").glob("*.npz")))
    dt = float(np.median(np.diff(F["t_fast"])))
    events, _bg = detect_events(F["fast_db"], dt)
    t_ev = np.array([float(F["t_fast"][e["ipk"]]) for e in events])
    doc = {"events": match_periods(t_ev)[:4] if len(t_ev) >= 3 else []}
    (out_dir / "schedule.json").write_text(json.dumps(doc, indent=2))
    return doc

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
def event_fingerprint(take, t_onset: float, nfft=8192, decay_s=1.0):
    """Rise spectrum (dB, mel-ish bands) + per-band decay slope (dB/s)."""
    fs = take.samplerate
    win = np.hanning(nfft)
    freqs = np.fft.rfftfreq(nfft, 1 / fs)
    W, centers = _melish_matrix(freqs)
    i = int(t_onset * fs)
    n_dec = int(decay_s * fs / nfft)
    with sf.SoundFile(str(take.audio_path)) as f:
        if i < nfft or i + (n_dec + 1) * nfft > f.frames:
            return None, None, centers
        f.seek(i - nfft + int(0.02 * fs))
        x = take.mono_ref(f.read((n_dec + 2) * nfft, dtype="float64",
                                 always_2d=True))
    def spec(seg):
        return W @ (np.abs(np.fft.rfft(seg * win)) ** 2)
    pre = spec(x[:nfft])
    post = spec(x[nfft:2 * nfft])
    rise = 10 * np.log10((post + EPS) / (pre + EPS))
    tail = np.array([10 * np.log10(spec(x[(k + 1) * nfft:(k + 2) * nfft])
                                   + EPS) for k in range(n_dec + 1)])
    slope = np.polyfit(np.arange(n_dec + 1) * nfft / fs, tail, 1)[0]
    return rise, slope, centers

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
def cluster_events(fps: np.ndarray, th=0.35, min_size=2):
    """Average-linkage clustering of fingerprints by correlation distance.
    Returns labels (−1 = unclustered singleton)."""
    from scipy.cluster.hierarchy import fcluster, linkage
    if len(fps) < 2:
        return np.zeros(len(fps), int)
    C = np.corrcoef(fps)
    d = np.clip(1 - C, 0, 2)
    np.fill_diagonal(d, 0)
    lab = fcluster(linkage(d[np.triu_indices_from(d, 1)], "average"),
                   th, criterion="distance")
    out = np.full(len(fps), -1)
    for l in np.unique(lab):
        m = lab == l
        if m.sum() >= min_size:
            out[m] = l
    return out

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
def run_session(sess, out_dir, max_events=150) -> dict:
    """Fingerprint + cluster the session's spectral events."""
    import json
    import matplotlib
    matplotlib.use("Agg")
    import matplotlib.pyplot as plt
    from .background import band_background, foreground, spectral_events
    from .features import load_features

    out_dir = Path(out_dir)
    F = load_features(sorted((out_dir / "features").glob("*.npz")))
    take = sess.takes[0]
    bg = band_background(F["logspec"])
    rise_db, _frac = foreground(F["logspec"], bg)
    ev = spectral_events(rise_db, F["logf"])
    # one fingerprint per onset: merge blobs that start within 1 s
    seen, dedup = set(), []
    for e in ev:
        if e["t0_s"] not in seen:
            dedup.append(e)
            seen.update((e["t0_s"] - 1, e["t0_s"], e["t0_s"] + 1))
    ev = dedup
    if len(ev) > max_events:
        keep = np.argsort([-e["peak_rise_db"] for e in ev])[:max_events]
        ev = [ev[i] for i in sorted(keep)]
    fps, slopes, kept = [], [], []
    for e in ev:
        r, s, centers = event_fingerprint(take, float(e["t0_s"]))
        if r is not None:
            fps.append(r)
            slopes.append(s)
            kept.append(e)
    fps = np.array(fps)
    lab = cluster_events(fps) if len(fps) else np.array([], int)
    classes = []
    for l in sorted(set(lab.tolist()) - {-1}):
        m = lab == l
        classes.append({
            "n": int(m.sum()),
            "exemplar_t0_s": [kept[i]["t0_s"]
                              for i in np.flatnonzero(m)[:5]],
            "centroid_hz": int(np.exp((fps[m].mean(0) * np.log(centers)).sum()
                                      / (fps[m].mean(0).sum() + EPS))),
            # decay only over the bands the event actually excited
            "decay_median_db_s": round(float(np.median(
                [np.median(s[r > 6]) for r, s in
                 zip(fps[m], np.array(slopes)[m]) if (r > 6).any()] or
                [np.nan])), 1),
        })
    classes.sort(key=lambda c: -c["n"])
    doc = {"n_events_fingerprinted": len(fps),
           "n_classes": len(classes),
           "n_unclustered": int((lab == -1).sum()),
           "classes": classes}
    (out_dir / "timbre.json").write_text(json.dumps(doc, indent=2))

    if len(fps):
        order = np.argsort(lab)
        fig, ax = plt.subplots(figsize=(11.2, 5.2), dpi=130)
        pc = ax.pcolormesh(np.arange(len(fps)), centers, fps[order].T,
                           cmap="magma", shading="auto")
        for b in np.flatnonzero(np.diff(lab[order]) != 0):
            ax.axvline(b + 0.5, color="w", lw=0.8)
        ax.set(yscale="log", xlabel="event (grouped by class)",
               ylabel="Hz", title=f"{sess.name} — event rise-spectrum "
               f"fingerprints, {len(classes)} classes")
        fig.colorbar(pc, ax=ax, pad=0.01, label="rise (dB)")
        fig.tight_layout()
        fig.savefig(out_dir / "timbre.png")
        plt.close(fig)
    return doc

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
def 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.
    """
    import librosa
    fs = take.samplerate
    with sf.SoundFile(str(take.audio_path)) as f:
        f.seek(int(t0 * fs))
        n = f.frames - int(t0 * fs) if dur is None else int(dur * fs)
        x = f.read(n, dtype="float32", always_2d=True)
    return librosa.resample(take.mono_ref(x), orig_sr=fs, target_sr=sr), sr

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
def run_session(sess, out_dir, t0=0.0, dur=None) -> dict:
    """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`.
    """
    import json
    from pathlib import Path

    import matplotlib
    matplotlib.use("Agg")
    import matplotlib.pyplot as plt
    import numpy as np

    m = _require_musiscape()
    y, sr = load_w(sess.takes[0], t0, dur)
    tg, bpms = m.tempogram(y, sr)
    chroma = m.chromagram(y, sr)
    summary = {
        "pulse": m.pulse_clarity(y, sr),
        "fifths": m.fifths_center(chroma.mean(axis=1)),
        "tartyp": m.tartyp_profile(y, sr),
    }
    out = Path(out_dir)
    out.mkdir(parents=True, exist_ok=True)
    fig, ax = plt.subplots(2, 1, figsize=(9, 6))
    ax[0].imshow(tg, aspect="auto", origin="lower")
    ax[0].set_ylabel("tempo (BPM)")
    ax[1].imshow(chroma, aspect="auto", origin="lower")
    ax[1].set_ylabel("pitch class")
    fig.tight_layout()
    fig.savefig(out / "music.png", dpi=130)
    plt.close(fig)
    (out / "music.json").write_text(json.dumps(summary, indent=2, default=float))
    return summary

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's kind only 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
def parse_time(x) -> float:
    """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.
    """
    if isinstance(x, (int, float)):
        return float(x)
    parts = str(x).strip().split()
    day = int(parts[0]) if len(parts) == 2 else 0
    h, m, s = (int(v) for v in parts[-1].split(":"))
    return day * 86400 + h * 3600 + m * 60 + s

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
def load_annotations(folder: str | Path) -> dict:
    """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.
    """
    folder = Path(folder)
    for name in ("annotations.json", "annotations.yml", "annotations.yaml"):
        p = folder / name
        if p.exists():
            if p.suffix == ".json":
                return json.loads(p.read_text())
            import yaml  # optional dependency
            return yaml.safe_load(p.read_text())
    raise FileNotFoundError(f"no annotations.json/yml in {folder}")

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
def load_activities(path: str | Path, day0: _dt.date | None = None) -> list:
    """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.
    """
    rows = []
    with open(path, newline="") as f:
        for r in csv.DictReader(f, delimiter=";"):
            rows.append((r["Class"].strip(),
                         _parse_stamp(r["Start time"]),
                         _parse_stamp(r["Stop time"])))
    if not rows:
        return []
    if day0 is None:
        day0 = rows[0][1].date()
    base = _dt.datetime.combine(day0, _dt.time())
    return [{"class": c,
             "start": (a - base).total_seconds(),
             "stop": (b - base).total_seconds()} for c, a, b in rows]

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
def activity_suffix(spans, activities, max_classes: int = 3,
                    min_share: float = 0.05) -> str:
    """Label suffix naming the dominant concurrent activities by time share,
    e.g. ``" — during: absence 71%, sleeping 22%"``; empty when nothing
    overlaps."""
    top = [(c, s) for c, s in _overlap_shares(spans, activities)[:max_classes]
           if s >= min_share]
    if not top:
        return ""
    return " — during: " + ", ".join(f"{c} {round(100 * s)}%" for c, s in top)

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
def bed_name(lo: float, hi: float, n_spans: int) -> str:
    """Human name for a keynote bed: 'quiet bed, -60 to -54 dBFS, 23 spans'."""
    mid = (lo + hi) / 2
    desc = ("quiet bed" if mid <= -50 else
            "moderate bed" if mid <= -38 else "loud bed")
    lo_i, hi_i = round(lo), round(hi)
    rng = f"{lo_i} dBFS" if lo_i == hi_i else f"{lo_i} to {hi_i} dBFS"
    return f"{desc}, {rng}, {n_spans} span{'s' if n_spans != 1 else ''}"

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
def merge_keynote_beds(objects: list, max_beds: int = MAX_BED_LANES) -> list:
    """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.
    """
    auto = [o for o in objects
            if o.get("kind") == "keynote" and _is_auto(o)
            and o.get("spans") and _level_of(o) is not None]
    if len(auto) <= max_beds:
        return list(objects)
    beds: list[dict] = []
    for o in sorted(auto, key=_level_of):
        lv = _level_of(o)
        if beds and lv - beds[-1]["lo"] <= BED_BAND_DB:
            beds[-1]["objs"].append(o)
            beds[-1]["hi"] = lv
        else:
            beds.append({"lo": lv, "hi": lv, "objs": [o]})

    def dur(b):
        return sum(t1 - t0 for o in b["objs"] for t0, t1 in _spans_s(o))

    beds.sort(key=dur, reverse=True)
    keep, spill = beds[:max_beds], beds[max_beds:]
    merged = []
    for b in sorted(keep, key=lambda b: -(b["lo"] + b["hi"]) / 2):
        spans = [s for o in b["objs"] for s in o.get("spans", [])]
        merged.append({"name": bed_name(b["lo"], b["hi"], len(spans)),
                       "kind": "keynote", "spans": spans, "_auto": True})
    if spill:
        spans = [s for b in spill for o in b["objs"]
                 for s in o.get("spans", [])]
        merged.append({"name": f"other beds ({len(spans)} spans)",
                       "kind": "keynote", "spans": spans, "_auto": True})
    auto_ids = {id(o) for o in auto}
    rest = [o for o in objects if id(o) not in auto_ids]
    return merged + rest

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
def map_objects(ann: dict | None = None, F: dict | None = None,
                min_dur: float = None, max_dur: float = None) -> tuple:
    """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.
    """
    from .objects import OBJECT_MAX_S, OBJECT_MIN_S, extract_objects
    min_dur = OBJECT_MIN_S if min_dur is None else min_dur
    max_dur = OBJECT_MAX_S if max_dur is None else max_dur
    stats = {"n_detected": 0, "n_short": 0, "n_long": 0, "n_hand": 0,
             "n_regime": 0, "n_untyped": 0,
             "min_dur_s": min_dur, "max_dur_s": max_dur}
    objs: list = []
    if F is not None:
        r = extract_objects(F, min_dur=min_dur, max_dur=max_dur)
        for k in ("n_detected", "n_short", "n_long"):
            stats[k] = r[k]
        objs += r["objects"]
    for o in (ann or {}).get("objects", []):
        if o.get("_object"):
            continue                     # already extracted above
        if _is_auto(o) or not _is_object_scale(o, max_dur):
            stats["n_regime"] += 1
            continue
        if o.get("facture") not in FACTURES or o.get("mass") not in MASSES:
            stats["n_untyped"] += 1
            continue
        expanded = _expand_events(o)
        stats["n_hand"] += len(expanded)
        objs += expanded
    # Detected events inherit the hand annotation that covers them: an
    # event inside a named signal/soundmark/figure span plots in that
    # object's colour and name instead of as an anonymous incidental
    # figure. Keynote spans do not capture — a bed is not a source of
    # figures, and painting every event over it keynote-blue would say
    # the opposite of what the annotator meant.
    stats["n_attributed"] = 0
    named = [(parse_time(s[0]), parse_time(s[1]), o)
             for o in (ann or {}).get("objects", [])
             if not _is_auto(o) and o.get("spans")
             and o.get("kind") in ("signal", "soundmark", "figure")
             for s in o["spans"]]
    if named:
        for o in objs:
            if not o.get("_object"):
                continue
            t0 = float(o["spans"][0][0])
            for a, b, h in named:
                if a <= t0 <= b:
                    o["kind"] = h["kind"]
                    o["name"] = f"{h['name']}{o['name']}"
                    o["_attributed"] = True
                    stats["n_attributed"] += 1
                    break
    stats["n_untyped"] += sum(1 for o in objs
                              if o.get("facture") not in FACTURES
                              or o.get("mass") not in MASSES)
    return objs, stats

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
def schaeffer_map(source, out_path, title="", activities=None,
                  stats=None, max_points: int = 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.
    """
    if isinstance(source, dict):
        source, derived = map_objects(source)
        stats = derived if stats is None else stats
    objects = list(source)
    if stats is None:
        _, stats = map_objects({"objects": []})
        stats["n_untyped"] = sum(1 for o in objects
                                 if o.get("facture") not in FACTURES
                                 or o.get("mass") not in MASSES)
    with plt.rc_context(RC):
        fig, ax = plt.subplots(figsize=(9.6, 6.4), dpi=130)
        ax.grid(False)
        for i in range(5):
            ax.axhline(i - 0.5, color=GRID, lw=0.8, zorder=0)
            ax.axvline(i - 0.5, color=GRID, lw=0.8, zorder=0)
        # An object the annotator (or the detector) has not typed on both axes
        # cannot be placed without inventing the very coordinates that are
        # missing, so it is left off the grid and counted in the title instead.
        cells: dict[tuple, list] = {}
        for o in objects:
            if o.get("facture") not in FACTURES or o.get("mass") not in MASSES:
                continue
            key = (FACTURES.index(o["facture"]), MASSES.index(o["mass"]))
            cells.setdefault(key, []).append(o)
        n_placed = sum(len(v) for v in cells.values())
        draw, sampled = _subsample(cells, max_points)
        n_drawn = sum(len(v) for v in draw.values())
        size = 170 if n_placed <= MAX_POINT_LABELS else (
            60 if n_placed <= 60 else (22 if n_placed <= 400 else 9))
        alpha_of = _alpha_scale(objects)
        act_colors = _activity_colors(activities) if activities else {}
        kinds_seen, classes_seen = set(), set()
        bio_seen, ring_seen = False, False
        for (x, y), shown in sorted(draw.items()):
            n = len(cells[(x, y)])
            rng = np.random.default_rng(97 + 13 * x + 5 * y)
            m = len(shown)
            if m <= len(_OFFSETS):
                pos = _OFFSETS[:m]
            else:
                pos = list(zip(rng.uniform(-0.36, 0.36, m),
                               rng.uniform(-0.30, 0.30, m)))
            for o, (dx, dy) in zip(shown, pos):
                ring = "soundmark" in o and o.get("kind") != "soundmark"
                ring_seen |= ring
                bio_seen |= o.get("source") == "biophony"
                c, cls = _point_color(o, activities, act_colors)
                if cls:
                    classes_seen.add(cls)
                else:
                    kinds_seen.add(o.get("kind", "figure"))
                ax.scatter(x + dx, y + dy, s=size, marker=_marker(o),
                           color=c, zorder=3, alpha=alpha_of(o),
                           edgecolors=MAGENTA if ring else "none",
                           linewidths=2.2)
                text = _point_label(o, n, n_placed)
                if text and activities:
                    dom = _dominant_activity(o, activities)
                    if dom:
                        text += f" — during {dom}"
                if text:
                    ax.annotate(text, (x + dx, y + dy),
                                xytext=(0, -15), ha="center",
                                textcoords="offset points", fontsize=8.3,
                                color=INK, zorder=4)
            if n_placed > MAX_POINT_LABELS:
                ax.annotate(f"n={n}", (x + 0.45, y - 0.42), ha="right",
                            va="top", fontsize=8, color=SEC, zorder=5)
        ax.set_xticks(range(4), FACTURE_LABELS)
        ax.set_yticks(range(4), MASS_LABELS)
        ax.set_xlim(-0.5, 3.5)
        ax.set_ylim(3.5, -0.5)
        ax.set_xlabel("facture / temporal sustainment  (Schaeffer typology) →")
        ax.set_ylabel("← mass  (Schaeffer morphology)")
        by = ("coloured by dominant concurrent activity" if activities
              else "coloured by Schafer function")
        head = (f"{title}{n_placed} sound objects "
                f"({stats['min_dur_s']:g}{stats['max_dur_s']:g} s) in "
                f"Schaeffer's typo-morphology, {by}, opacity by level")
        line2 = _census_line(stats)
        if line2:
            head += "\n" + line2
        notes = []
        if any(_is_auto(o) for o in objects):
            notes.append("mass/facture: machine-drafted, listen to confirm")
        if activities:
            notes.append(ACTIVITY_NOTE)
        if sampled:
            notes.append(f"scatter shows {n_drawn} of {n_placed} objects "
                         "(stratified by cell); counts are complete")
        if notes:
            head += "\n" + " · ".join(notes)
        ax.set_title(head, loc="left", fontsize=10.5)
        names = {"keynote": "keynote (ground)", "signal": "signal (figure)",
                 "soundmark": "community soundmark",
                 "figure": "incidental figure"}
        handles = [Line2D([], [], marker="o", ls="none", color=KIND_COLOR[k],
                          label=names[k]) for k in names if k in kinds_seen]
        handles += [Line2D([], [], marker="s", ls="none", color=act_colors[c],
                           label=c) for c in sorted(classes_seen)]
        if ring_seen:
            handles.append(Line2D([], [], marker="o", ls="none", color=SURF,
                                  markeredgecolor=MAGENTA, markeredgewidth=2,
                                  label="dwelling soundmark (ring)"))
        if bio_seen:
            handles.append(Line2D([], [], marker="^", ls="none", color=GREEN,
                                  label="biophony (triangle)"))
        # outside the axes: on a dense map every cell carries points, and a
        # legend inside would sit on top of them
        ax.legend(handles=handles, loc="upper left", bbox_to_anchor=(1.02, 1.0),
                  frameon=False, fontsize=8, ncol=1)
        fig.tight_layout()
        fig.savefig(out_path, bbox_inches="tight")
        plt.close(fig)

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
def schafer_timeline(ann: dict, 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.
    """
    if layout not in ("auto", "activity", "acoustic"):
        raise ValueError(f"unknown timeline layout {layout!r}")
    if activities and layout != "acoustic":
        return _activity_timeline(ann, out_path, title=title,
                                  session=session, activities=activities,
                                  F=F)
    return _acoustic_timeline(ann, out_path, title=title, session=session,
                              activities=activities)

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
def render(folder: str | Path, 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.
    """
    lo, hi = object_window if object_window else (None, None)
    folder = Path(folder)
    ann = load_annotations(folder)
    out = Path(out_dir) if out_dir else folder / "analysis"
    out.mkdir(parents=True, exist_ok=True)
    if session is None:
        from .io import open_session
        try:
            session = open_session(folder)
        except (FileNotFoundError, ValueError):
            session = None
    acts = None
    if activities is not None and Path(activities).exists():
        acts = load_activities(
            activities, day0=session.day0 if session else None)
    F = None
    paths = sorted((out / "features").glob("*.npz"))
    if paths:
        from .features import load_features
        try:
            F = load_features(paths)
        except Exception:
            F = None           # figures still render, without the cache
    name = folder.name
    objs, stats = map_objects(ann, F, min_dur=lo, max_dur=hi)
    schaeffer_map(objs, out / "schaeffer_map.png", title=name,
                  activities=acts, stats=stats)
    schafer_timeline(ann, out / "schafer_timeline.png", title=name,
                     session=session, activities=acts, F=F, layout=layout)
    return out / "schaeffer_map.png", out / "schafer_timeline.png"

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_DB above 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:

  1. peak share >= 0.50 — most of what appeared is in peaks, so a pitch (or a harmonic series) is what one hears: tonic;
  2. peak share >= 0.20 — a pitch is audible over a continuum that carries most of the energy: tonic-complex;
  3. 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;
  4. 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
def object_mass(excess: np.ndarray, bands_per_octave: float = 10.0) -> tuple:
    """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_DB`` above 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:

    1. peak share >= 0.50 — most of what appeared is in peaks, so a pitch (or
       a harmonic series) is what one hears: **tonic**;
    2. peak share >= 0.20 — a pitch is audible over a continuum that carries
       most of the energy: **tonic-complex**;
    3. 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**;
    4. 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.
    """
    from scipy.ndimage import median_filter
    e = np.asarray(excess, np.float64)
    tot = float(e.sum())
    ev = {"peak_share": 0.0, "spread_oct": 0.0, "prominence_db": 0.0,
          "flatness": 0.0}
    if tot <= 0 or not np.isfinite(tot):
        return None, ev
    peak = float(e.max())
    floored = np.maximum(e, peak * 1e-4)
    lvl = 10 * np.log10(floored)
    win = max(3, int(round(bands_per_octave)) | 1)
    res = lvl - median_filter(lvl, size=win, mode="nearest")
    is_peak = res >= PEAK_PROMINENCE_DB
    share = float(e[is_peak].sum() / tot)
    u = np.arange(len(e)) / bands_per_octave        # position in octaves
    w = e / tot
    mu = float((w * u).sum())
    spread = float(np.sqrt(max((w * (u - mu) ** 2).sum(), 0.0)))
    flat = float(np.exp(np.log(floored).mean()) / (floored.mean() + EPS))
    ev = {"peak_share": round(share, 3), "spread_oct": round(spread, 2),
          "prominence_db": round(float(res.max()), 1),
          "flatness": round(flat, 3)}
    if share >= PEAK_SHARE_TONIC:
        return "tonic", ev
    if share >= PEAK_SHARE_COMPLEX:
        return "tonic-complex", ev
    if spread >= SPREAD_OCT_NOISE:
        return "noise", ev
    return "complex", ev

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:

  1. 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);
  2. iteration strength >= 0.35 over at least 0.4 s — energy is maintained by repetition, not continuously: iteration;
  3. attack <= 0.08 s and duration <= 1 s — all the energy arrives at once and nothing maintains it: impulse;
  4. 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
def object_facture(env: np.ndarray, dt: float, dur: float) -> tuple:
    """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:

    1. 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)**;
    2. iteration strength >= 0.35 over at least 0.4 s — energy is maintained by
       repetition, not continuously: **iteration**;
    3. attack <= 0.08 s and duration <= 1 s — all the energy arrives at once
       and nothing maintains it: **impulse**;
    4. otherwise — energy held continuously between a beginning and an end:
       **sustained (delimited)**.

    Returns ``(facture, evidence)``.
    """
    x = np.asarray(env, np.float64)
    ev = {"attack_s": None, "iter_strength": 0.0, "iter_rate_hz": None,
          "dur_s": round(float(dur), 2)}
    if dur >= SUSTAIN_MAX_S:
        return "unlimited", ev
    if len(x):
        top = float(x.max())
        hi = np.flatnonzero(x >= 0.9 * top)
        end = int(hi[0]) if len(hi) else int(np.argmax(x))
        below = np.flatnonzero(x[:end + 1] <= 0.1 * top)
        start = int(below[-1]) if len(below) else 0
        ev["attack_s"] = round((end - start) * dt, 3)
    strength, rate = _iteration_strength(x, dt)
    ev["iter_strength"] = round(strength, 3)
    ev["iter_rate_hz"] = round(rate, 1) if rate else None
    if dur >= ITER_MIN_S and strength >= ITER_ACF:
        return "iteration", ev
    if ev["attack_s"] is not None and ev["attack_s"] <= ATTACK_MAX_S \
            and dur <= IMPULSE_MAX_S:
        return "impulse", ev
    return "sustained", ev

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
def object_profile(env: np.ndarray, dt: float, dur: float | None = None,
                   eps: float = 1e-12, logspec: np.ndarray | None = None,
                   logf: np.ndarray | None = None) -> dict:
    """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.
    """
    e = np.asarray(env, float)
    e = e[np.isfinite(e)]
    n = len(e)
    if n < 3 or float(np.max(e)) <= eps:
        return {}
    dur = float(n * dt) if dur is None else float(dur)
    pk = int(np.argmax(e))
    peak = float(e[pk])

    # attack: 10-90% of the rise to the peak
    rise = e[:pk + 1]
    lo, hi = 0.1 * peak, 0.9 * peak
    idx = np.flatnonzero((rise >= lo) & (rise <= hi))
    attack = float(len(idx) * dt) if len(idx) else 0.0

    # decay: peak back down through 10% of it, if it gets there
    tail = e[pk:]
    below = np.flatnonzero(tail <= lo)
    decay = float(below[0] * dt) if len(below) else None

    t = np.arange(n) * dt
    energy = e ** 2
    tot = float(energy.sum())
    tc = float((t * energy).sum() / tot / max(dur, eps)) if tot > eps else None
    rms = float(np.sqrt(energy.mean()))
    crest = float(20 * np.log10(peak / max(rms, eps)))
    it_strength, it_hz = _iteration_strength(e, dt)   # (strength, rate)

    spectral = ({} if logspec is None or logf is None
                else object_spectrum(logspec, logf, dt, eps))
    return {"duration_s": round(dur, 3),
            **spectral,
            "attack_s": round(attack, 3),
            "decay_s": None if decay is None else round(decay, 3),
            "temporal_centroid": None if tc is None else round(tc, 3),
            "crest_db": round(crest, 1),
            "iteration_hz": None if not it_hz else round(float(it_hz), 2),
            "iteration_strength": (None if it_strength is None
                                   else round(float(it_strength), 3))}

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
def object_spectrum(logspec: np.ndarray, logf: np.ndarray, dt: float,
                    eps: float = 1e-12) -> dict:
    """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.
    """
    x = np.asarray(logspec, float)
    if x.ndim != 2 or x.shape[0] < 2:
        return {}
    f = np.asarray(logf, float)
    centres = np.sqrt(f[:-1] * f[1:])[:x.shape[1]]
    p = 10 ** (x[:, :len(centres)] / 10.0)
    tot = p.sum(1)
    live = tot > eps
    if live.sum() < 2:
        return {}
    p, tot = p[live], tot[live]
    n = p.shape[0]

    cen = (p * centres).sum(1) / tot
    brightness = float((cen * tot).sum() / tot.sum())

    third = max(1, n // 3)
    a = float((cen[:third] * tot[:third]).sum() / max(tot[:third].sum(), eps))
    b = float((cen[-third:] * tot[-third:]).sum() / max(tot[-third:].sum(), eps))
    drift = (float(np.log2(b / a)) if a > eps and b > eps else None)

    norm = p / tot[:, None]
    flux = float(np.abs(np.diff(norm, axis=0)).sum(1).mean() / max(dt, eps))

    return {"brightness_hz": round(brightness, 1),
            "brightness_drift_oct": None if drift is None else round(drift, 3),
            "flux_per_s": round(flux, 3),
            "n_frames": int(n)}

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
def extract_objects(F: dict, min_dur: float = OBJECT_MIN_S,
                    max_dur: float = OBJECT_MAX_S,
                    thresh_db: float = 8.0) -> dict:
    """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.
    """
    tf = np.asarray(F["t_fast"], float)
    fast = np.asarray(F["fast_db"], float)
    dt = float(np.median(np.diff(tf))) if len(tf) > 1 else 0.125
    events, _bg = detect_events(fast, dt, thresh_db=thresh_db)
    out = {"objects": [], "n_detected": len(events), "n_short": 0,
           "n_long": 0, "min_dur_s": min_dur, "max_dur_s": max_dur}
    if not events:
        return out

    logspec = np.asarray(F["logspec"], np.float64) if "logspec" in F else None
    t_sec = np.asarray(F["t"], float) if "t" in F else None
    bg_spec = _band_background(logspec) if logspec is not None else None
    from .features import LOGF_RANGE
    nband = logspec.shape[1] if logspec is not None else 0
    bpo = nband / np.log2(LOGF_RANGE[1] / LOGF_RANGE[0]) if nband else 10.0

    has_hi = "env_hi" in F and "t_hi" in F
    if has_hi:
        t_hi = np.asarray(F["t_hi"], float)
        env_hi = np.sqrt(np.maximum(np.asarray(F["env_hi"], np.float64), 0.0))
        hi_dt = float(F.get("hi_dt", 0.02))

    for e in events:
        t0, t1 = float(tf[e["i0"]]), float(tf[e["i1"]]) + dt
        dur = t1 - t0
        if dur < min_dur:
            out["n_short"] += 1
            continue
        if dur > max_dur:
            out["n_long"] += 1
            continue
        mass, mass_ev = None, {}
        if logspec is not None and t_sec is not None:
            r0 = _row_index(t_sec, t0, len(logspec))
            r1 = max(r0, _row_index(t_sec, t1 - 1e-3, len(logspec)))
            mass, mass_ev = object_mass(
                _excess_spectrum(logspec, bg_spec, r0, r1), bpo)
        if has_hi:
            # a little pre-roll, so the attack is measured from the silence
            # before the object rather than from the detector's threshold
            pre = int(round(0.1 / hi_dt))
            j0 = max(0, _row_index(t_hi, t0, len(env_hi)) - pre)
            j1 = max(j0 + 1, _row_index(t_hi, t1 - 1e-3, len(env_hi)))
            env, edt = env_hi[j0:j1 + 1], hi_dt
        else:                       # pre-0.2 cache: the 8 Hz fast level only
            env = 10 ** (fast[e["i0"]:e["i1"] + 1] / 20)
            edt = dt
        facture, fac_ev = object_facture(env, edt, dur)
        out["objects"].append({
            "name": f"object at {_clock(t0)}",
            "kind": "figure", "mass": mass, "facture": facture,
            "spans": [[t0, t1]],
            "_auto": True, "_object": True,
            "_level_dbfs": round(float(fast[e["ipk"]]), 1),
            "_exceed_db": round(float(e["exceed"]), 1),
            "_schaeffer": {**mass_ev, **fac_ev},
        })
    return out

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
def cell_counts(objects: list) -> dict:
    """``{(facture, mass): n}`` over typed objects — the map's full census."""
    out: dict[tuple, int] = {}
    for o in objects:
        key = (o.get("facture"), o.get("mass"))
        if None in key:
            continue
        out[key] = out.get(key, 0) + 1
    return out

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
def profile_clips(folder, verbose: bool = False) -> list[dict]:
    """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.
    """
    from .features import extract_take
    from .io import open_clips
    sess = open_clips(folder)
    out = []
    for tk in sess.takes:
        try:
            F = extract_take(tk)
        except Exception as e:                                   # noqa: BLE001
            if verbose:
                print(f"  {tk.path.name}: unreadable ({e})")
            continue
        env = np.sqrt(np.asarray(F["env_hi"], float))
        row = {"clip": tk.path.name}
        row.update(object_profile(env, float(F["hi_dt"]),
                                  logspec=F.get("logspec"),
                                  logf=F.get("logf")))
        out.append(row)
        if verbose:
            print(f"  {tk.path.name}: {row.get('duration_s')} s")
    return out

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
def schaeffer_hint(F: dict, a: float, b: float) -> dict:
    """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.
    """
    t = np.asarray(F["t"], float)
    sel = (t >= a) & (t <= b)
    if not sel.any():
        return {"mass": "TODO", "facture": "sustained"}
    flat = float(np.median(np.asarray(F["flatness"], float)[sel]))
    mass = ("tonic" if flat < 0.05 else "tonic-complex" if flat < 0.2
            else "complex" if flat < 0.5 else "noise")
    span = b - a
    total = float(t.max() - t.min()) if t.size > 1 else span
    facture = "unlimited" if total > 0 and span >= 0.8 * total else "sustained"
    lvl = 20 * np.log10(np.asarray(F["rms_w"], float)[sel] + 1e-9)
    rng = float(np.percentile(lvl, 90) - np.percentile(lvl, 10))
    return {"mass": mass, "facture": facture,
            "_schaeffer": {"flatness": round(flat, 3),
                           "level_range_db": round(rng, 1),
                           "dynamic": "varied" if rng > 6 else "unvaried"}}

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
def draft_annotations(F: dict, folder: str | Path,
                      out_name="annotations.draft.json",
                      session=None, tagger=None,
                      max_tagged: int | None = MAX_TAGGED) -> Path:
    """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.
    """
    folder = Path(folder)
    tf, fast = F["t_fast"], F["fast_db"]
    dt = float(np.median(np.diff(tf))) if len(tf) > 1 else 0.125
    objects = []
    tag = tagger if tagger is not None else _tagger(session)
    budget = float("inf") if max_tagged is None else max_tagged
    n_tagged = 0

    # --- steady-state keynote beds: regimes clustered by level similarity
    regs = []
    for i0, i1 in _gap_split(F["t"]):
        m = (tf >= F["t"][i0]) & (tf <= F["t"][i1 - 1] + 1)
        regs += _regimes(tf[m], fast[m], dt)
    # A level-only regime split misses same-level spectral changes (a walk's
    # gravel vs paved, a fan whose pitch moves without its level). With the
    # spectral features cached, split regimes at multivariate boundaries too.
    if "oct_pow" in F:
        from .segmentation import segment
        regs = _split_at(regs, segment(F), tf, fast)
    for bed in _beds(regs, F if "oct_pow" in F else None):
        spans = sorted(bed["regs"])
        n = len(spans)
        med = float(np.median([lvl for _, _, lvl in spans]))
        # duration-weighted modal mass, facture from the bed's session share
        hints = [schaeffer_hint(F, a, b) for a, b, _ in spans]
        w: dict[str, float] = {}
        for (a, b, _), h in zip(spans, hints):
            w[h["mass"]] = w.get(h["mass"], 0) + (b - a)
        mass = max(w, key=w.get)
        total = float(F["t"][-1] - F["t"][0]) if len(F["t"]) > 1 else 1.0
        share = sum(b - a for a, b, _ in spans) / max(total, 1.0)
        obj = {
            "name": bed_name(bed["lo"], bed["hi"], n)
                    if not bed.get("other") else f"other beds ({n} spans)",
            "kind": "keynote", "mass": mass,
            "facture": "unlimited" if share >= 0.8 else "sustained",
            "spans": [[_fmt(a), _fmt(b)] for a, b, _ in spans],
            "_auto": True,
            "_level_dbfs": round(med, 1),
        }
        flats = [h["_schaeffer"]["flatness"] for h in hints
                 if "_schaeffer" in h]
        if flats:
            obj["_schaeffer"] = {"flatness": round(float(np.median(flats)), 3),
                                 "level_band_db": [round(bed["lo"], 1),
                                                   round(bed["hi"], 1)]}
        # Two spectrally-split beds can share a level band and hence a
        # bed_name; a centroid suffix keeps same-level-different-spectrum
        # beds tellable apart in the draft.
        if "centroid" in F:
            import numpy as _np
            tt = _np.asarray(F["t"], float)
            sel = _np.zeros(len(tt), bool)
            for a, b, _ in spans:
                sel |= (tt >= a) & (tt < b)
            if sel.any() and any(o["name"].startswith(obj["name"])
                                 for o in objects):
                c = int(_np.median(_np.asarray(F["centroid"], float)[sel]))
                obj["name"] += f", centroid {c} Hz"
        if tag and n_tagged < budget:
            a, b, _ = max(spans, key=lambda r: r[1] - r[0])
            tags = tag((a + b) / 2)
            n_tagged += 1
            if tags:
                obj["_tags"] = tags
                top = max(tags, key=lambda t: t["p"])
                obj["label"] = (f"machine hint: {top['label']} "
                                "(PANNs, unverified)")
        objects.append(obj)

    # --- transient events with listening hints
    events, _bg = detect_events(fast, dt)
    events.sort(key=lambda e: -e["exceed"])
    listed = sorted(events[:MAX_EVENTS], key=lambda e: e["ipk"])
    details, times = [], []
    p = F["rms_w"].astype(np.float64) ** 2
    for e in listed:
        te = float(tf[e["ipk"]])
        si = int(np.clip(np.searchsorted(F["t"], te) - 1, 0, len(F["t"]) - 1))
        times.append(_fmt(te))
        hint = {
            "t": _fmt(te),
            "exceed_db": round(e["exceed"], 1),
            "level_dbfs": round(float(fast[e["ipk"]]), 1),
            "az": round(float(F["az"][si]), 0),
            "el": round(float(F["el"][si]), 0),
            "diffuseness": round(float(F["diffuse"][si]), 2),
        }
        if tag and n_tagged < budget:
            tags = tag(te)
            n_tagged += 1
            if tags:
                hint["tags"] = tags
        details.append(hint)
    if times:
        objects.append({
            "name": "events (unclassified)",
            "kind": "figure", "mass": "TODO", "facture": "impulse",
            "label": "TODO — split into named objects by listening",
            "events": times,
            "_hints": details,
        })

    doc = {
        "_instructions": (
            "DRAFT generated by ambiscape. Steady regimes are clustered into "
            "keynote beds (~6 dB level bands; all spans of a band on one "
            "object); mass/facture are auto-proposed from features (evidence "
            "under _schaeffer) and any 'machine hint' label is an unverified "
            "PANNs tag — confirm or correct everything by ear. For each "
            "object also set kind (keynote|signal|"
            "soundmark|figure) — mass is (tonic|tonic-complex|complex|noise), "
            "facture (impulse|iteration|sustained|unlimited); rename it; "
            "split 'events (unclassified)' "
            "into one object per sound type (the _hints give clock time, "
            "level, azimuth/elevation, diffuseness for each event); delete "
            "what you don't want; optionally add soundmark: community|"
            "dwelling, source: biophony, and a states list for lo-fi spans. "
            "Save as annotations.json and run: ambiscape taxonomy <folder>."),
        "objects": objects,
        "states": [{"label": "TODO e.g. LO-FI (drone masks the field)",
                    "span": ["HH:MM:SS", "HH:MM:SS"]}],
    }
    out = folder / out_name
    out.write_text(json.dumps(doc, indent=2))
    return out

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
def load_calibration(folder: str | Path) -> dict | None:
    """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.
    """
    p = Path(folder) / "calibration.json"
    if p.exists():
        return json.loads(p.read_text())
    return None

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
def take_offset(cal: dict | None, take_name: str) -> float | None:
    """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``.
    """
    if not cal:
        return None
    per = cal.get("dbfs_to_dbspl_takes", {})
    if take_name in per:
        return float(per[take_name])
    if "dbfs_to_dbspl" in cal:
        return float(cal["dbfs_to_dbspl"])
    return None

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
def derive_offset(F: dict, laeq_spl: float, t0: float | None = None,
                  dur: float | None = None) -> dict:
    """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.
    """
    t = np.asarray(F["t_fast"], np.float64)
    t = t - t[0]                              # relative to recording start
    dba = np.asarray(F["fast_dba"], np.float64)
    m = np.ones(len(t), bool)
    if t0 is not None:
        m &= t >= t0
    if dur is not None:
        m &= t < (t0 or 0.0) + dur
    if not m.any():
        raise ValueError("span selects no samples")
    laeq_dbfs = 10 * np.log10(np.mean(10 ** (dba[m] / 10)) + 1e-30)
    return {"dbfs_to_dbspl": round(float(laeq_spl - laeq_dbfs), 1),
            "laeq_dbfs": round(float(laeq_dbfs), 1),
            "laeq_spl": float(laeq_spl),
            "span_s": [float(t[m][0]), float(t[m][-1])]}

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
def write_calibration(folder: str | Path, offset: float, method: str = "",
                      take: str | None = None) -> Path:
    """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``.
    """
    import datetime
    p = Path(folder) / "calibration.json"
    cal = json.loads(p.read_text()) if p.exists() else {}
    if take is not None:
        cal.setdefault("dbfs_to_dbspl_takes", {})[take] = float(offset)
    else:
        cal["dbfs_to_dbspl"] = float(offset)
    if method:
        cal["method"] = method
    cal["date"] = datetime.date.today().isoformat()
    p.write_text(json.dumps(cal, indent=2))
    return p

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
def to_pascal(x: np.ndarray, dbfs_to_dbspl: float) -> np.ndarray:
    """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.
    """
    return x.astype(np.float64) * P_REF * 10 ** (dbfs_to_dbspl / 20)

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
def apply_calibration(summary: dict, cal: dict) -> dict:
    """Add dB SPL versions of the level descriptors to a summary dict."""
    off = float(cal["dbfs_to_dbspl"])
    out = dict(summary)
    for key in ("leq_dbfs", "laeq_dbfs", "L10", "L50", "L90"):
        if key in summary and summary[key] is not None:
            out[key.replace("_dbfs", "") + "_db_spl"] = round(summary[key] + off, 1)
    out["calibration"] = {"dbfs_to_dbspl": off,
                          "method": cal.get("method", "")}
    return out

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.
  • ambix first-order B-format -> the block is remapped to canonical AmbiX (W, Y, Z, X) via order, so a fuma (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
def binaural(x: np.ndarray, fs: int, order: str = "ambix",
             mode: str = "ambix") -> tuple[np.ndarray, str]:
    """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.
    * ``ambix`` first-order B-format -> the block is remapped to canonical
      AmbiX (W, Y, Z, X) via ``order``, so a ``fuma`` (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.
    """
    if mode == "mono" or x.shape[1] == 1:
        m = x[:, 0]
        return np.stack([m, m], axis=1), "mono-duplicated"
    if mode in ("stereo", "binaural") or x.shape[1] < 4:
        return np.ascontiguousarray(x[:, :2]), "stereo-passthrough"
    wyzx = (0, 2, 3, 1) if order == "fuma" else (0, 1, 2, 3)
    xw = x[:, list(wyzx)]  # canonical AmbiX W, Y, Z, X
    try:
        from ambiviz.ambisonics.binauralizer import binauralize  # type: ignore
        y = binauralize(xw.T, fs)  # ambiviz convention: channels first, AmbiX
        return np.asarray(y).T[:, :2], "ambiviz-hrir"
    except Exception:
        w, ych = xw[:, 0], xw[:, 1]
        left = 0.5 * (w + ych)
        right = 0.5 * (w - ych)
        return np.stack([left, right], axis=1), "cardioid-pair-fallback"

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
def mosqito_available() -> bool:
    """True if MoSQITo (the ``ambiscape[iso]`` extra) is importable."""
    try:
        import mosqito  # noqa: F401
        return True
    except ImportError:
        return False

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
def indicators(x_pa: np.ndarray, fs: int, rough_dur: float = 10.0) -> dict:
    """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.
    """
    try:
        from mosqito.sq_metrics import (loudness_zwtv,
                                        sharpness_din_from_loudness,
                                        roughness_dw)
    except ImportError as e:                      # optional dependency
        raise ImportError(
            "MoSQITo is required for ISO 532-1 loudness/sharpness/"
            "roughness: pip install 'ambiscape[iso]'") from e
    N, N_spec, _bark, _t = loudness_zwtv(x_pa, fs, field_type="diffuse")
    S = sharpness_din_from_loudness(N, N_spec)
    n_r = int(rough_dur * fs)
    mid = max(0, (len(x_pa) - n_r) // 2)
    R = np.atleast_1d(roughness_dw(x_pa[mid:mid + n_r], fs)[0])
    return {
        "N5_sone": round(float(np.percentile(N, 95)), 2),
        "N50_sone": round(float(np.percentile(N, 50)), 2),
        "sharpness_median_acum": round(float(np.median(S)), 2),
        "roughness_median_asper": round(float(np.median(R)), 3),
        "fluctuation_strength_vacil": round(
            fluctuation_strength(x_pa, fs), 3),
    }

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
def segment_indicators(sess, F: dict, folder: str | Path,
                       dur: float = 30.0, offset: float | None = None) -> dict:
    """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``.
    """
    from .analysis import pick_segments
    from .io import read_span

    cal = load_calibration(folder)
    has_spl = bool(cal and "dbfs_to_dbspl" in cal)
    if offset is None:
        offset = float(cal["dbfs_to_dbspl"]) if has_spl else ASSUMED_OFFSET
    calibrated = has_spl or offset != ASSUMED_OFFSET

    out = {"calibrated": calibrated, "dbfs_to_dbspl": offset,
           "field_type": "diffuse", "segments": {}}
    if not calibrated:
        out["warning"] = (f"no calibration.json — assumed offset "
                          f"{ASSUMED_OFFSET} dB; absolute values indicative only")
    for pick in pick_segments(F, seg_s=dur):
        try:
            x, fs = read_span(sess, pick["t0"], dur)
        except ValueError:
            continue
        got = len(x) / fs
        if got <= 0:
            continue
        tk = next((t for t in sess.takes
                   if t.start <= pick["t0"] < t.end), None)
        ears, method = binaural(
            x, fs,
            order=(tk.order if tk else "ambix"),
            mode=(tk.mode if tk else "ambix"))
        seg = {"t0": sess.clock(pick["t0"]), "dur_s": round(got, 2),
               "binaural_method": method}
        if got < dur - 1.0 / fs:            # read_span clamped to the take
            seg["dur_requested_s"] = dur
        if pick.get("also"):
            seg["also_kinds"] = list(pick["also"])
        # multi-device sessions: a per-take offset overrides the global one
        seg_offset = (take_offset(cal, tk.path.name)
                      if tk and calibrated else None) or offset
        for ch, name in ((0, "left"), (1, "right")):
            seg[name] = indicators(to_pascal(ears[:, ch], seg_offset), fs)
        seg["N5_sone_max_ear"] = max(seg["left"]["N5_sone"],
                                     seg["right"]["N5_sone"])
        out["segments"][pick["kind"]] = seg
    return out

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
def fluctuation_strength(x: np.ndarray, fs: int, fmin_mod: float = 0.25,
                         fmax_mod: float = 32.0, dl_cap: float = 30.0) -> float:
    """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.
    """
    ref = _fluctuation_reference(int(fs), float(fmin_mod), float(fmax_mod),
                                 float(dl_cap))
    if ref <= 0.0:
        return 0.0
    return float(_fluctuation_raw(x, fs, fmin_mod, fmax_mod, dl_cap) / ref)

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
def fluctuation_index(env: np.ndarray, dt: float, fmin: float = 0.25,
                      fmax: float = 20.0) -> float | None:
    """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.
    """
    from scipy import signal as sg
    env = np.asarray(env, np.float64)
    if len(env) < 64 or env.mean() <= 0.0:
        return None
    fmax = min(fmax, 0.45 / dt)
    if fmax <= fmin * 1.5:
        return None
    x = env / env.mean() - 1.0
    nper = int(min(len(x), max(64, round(8.0 / (fmin * dt)))))
    f, P = sg.welch(x, fs=1.0 / dt, nperseg=nper, noverlap=nper // 2,
                    detrend="linear")
    m = (f >= fmin) & (f <= fmax)
    if not m.any():
        return None
    w = _fluctuation_weight(f[m])
    return float(np.sqrt(max(np.trapezoid(P[m] * w ** 2, f[m]), 0.0)))

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
def critical_bandwidth(f_hz):
    """Zwicker & Terhardt (1980) critical bandwidth at ``f_hz``, in Hz."""
    f = np.asarray(f_hz, np.float64)
    return 25.0 + 75.0 * (1.0 + 1.4 * (f / 1000.0) ** 2) ** 0.69

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
def tone_prominence(spec_row: np.ndarray, freqs: np.ndarray,
                    fmin: float = 50.0, fmax: float = 10000.0,
                    min_dl_db: float = 6.0, max_n: int = 12) -> list[dict]:
    """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).
    """
    from scipy.ndimage import median_filter
    from scipy.signal import find_peaks
    spec = np.asarray(spec_row, np.float64)
    freqs = np.asarray(freqs, np.float64)
    if len(freqs) < 32 or spec.max() <= 0.0:
        return []
    df = float(freqs[1] - freqs[0])
    eps = spec.max() * 1e-12
    ls = 10 * np.log10(spec + eps)
    floor = median_filter(ls, size=min(101, 2 * (len(ls) // 2) - 1),
                          mode="nearest")
    # cheap pre-filter: a band ΔL of 6 dB implies a much larger per-bin rise
    cand, _ = find_peaks(ls - floor, height=min_dl_db, distance=3)
    cand = cand[(freqs[cand] >= fmin) & (freqs[cand] <= fmax)]
    idx = np.arange(len(freqs))
    tones = []
    for i in cand:
        f0 = float(freqs[i])
        hw = max(float(critical_bandwidth(f0)) / 2.0, 6 * df)
        band = (freqs >= f0 - hw) & (freqs <= f0 + hw)
        tone = band & (np.abs(idx - i) <= 3)         # Hann main lobe + slack
        noise = band & (np.abs(idx - i) > 5)         # guard bins excluded
        if noise.sum() < 4:
            continue
        med = float(np.median(spec[noise]))          # masking noise per bin
        p_tone = float(spec[tone].sum()) - med * int(tone.sum())
        p_noise = med * int(band.sum())              # noise level of the band
        if p_tone <= 0.0 or p_noise <= 0.0:
            continue
        dl = 10 * np.log10(p_tone / p_noise)
        if dl >= min_dl_db:
            tones.append({"f_hz": round(f0, 1), "dL_db": round(float(dl), 1)})
    tones.sort(key=lambda t: -t["dL_db"])
    return tones[:max_n]

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
def prominent_tones(minspec: np.ndarray, freqs: np.ndarray,
                    min_fraction: float = 0.1, tol_cents: float = 50.0,
                    **tone_kw) -> list[dict]:
    """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.
    """
    minspec = np.asarray(minspec, np.float64)
    freqs = np.asarray(freqs, np.float64)
    nrow = minspec.shape[0]
    if nrow == 0 or len(freqs) < 32:
        return []
    df = float(freqs[1] - freqs[0])
    groups: list[dict] = []
    for r in range(nrow):
        for t in tone_prominence(minspec[r], freqs, **tone_kw):
            for g in groups:
                fg = float(np.median(g["f"]))
                tol = max(fg * (2 ** (tol_cents / 1200.0) - 1.0), 2.5 * df)
                if abs(t["f_hz"] - fg) <= tol:
                    g["f"].append(t["f_hz"])
                    g["dl"].append(t["dL_db"])
                    g["rows"].add(r)
                    break
            else:
                groups.append({"f": [t["f_hz"]], "dl": [t["dL_db"]],
                               "rows": {r}})
    out = []
    for g in groups:
        frac = len(g["rows"]) / nrow
        if frac < min_fraction:
            continue
        out.append({"f_hz": round(float(np.median(g["f"])), 1),
                    "dL_median_db": round(float(np.median(g["dl"])), 1),
                    "dL_max_db": round(float(np.max(g["dl"])), 1),
                    "present_fraction": round(frac, 2),
                    "n_minutes": len(g["rows"])})
    return sorted(out, key=lambda t: -t["dL_median_db"])

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
def summarize_psycho(F: dict) -> dict:
    """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``.
    """
    out = {"tonal_prominence_db": None, "tonal_prominence_hz": None,
           "n_prominent_tones": 0, "fluctuation_index": None}
    if "minspec" in F and len(F["minspec"]):
        tones = prominent_tones(F["minspec"], F["freqs"])
        out["n_prominent_tones"] = len(tones)
        if tones:
            out["tonal_prominence_db"] = tones[0]["dL_median_db"]
            out["tonal_prominence_hz"] = tones[0]["f_hz"]
    if "env_hi" in F and "hi_dt" in F and len(F["env_hi"]):
        fi = fluctuation_index(F["env_hi"], float(F["hi_dt"]))
        out["fluctuation_index"] = round(fi, 3) if fi is not None else None
    return out

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 and NR_governing_hz names 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_class is "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
def room_criteria(oct_spl_db: dict) -> dict:
    """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 and
      ``NR_governing_hz`` names 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_class`` is "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.
    """
    spec = {float(k): float(v) for k, v in oct_spl_db.items()}

    nr_per = {f: (spec[f] - NR_A[f]) / NR_B[f] for f in NR_A if f in spec}
    f_gov = max(nr_per, key=nr_per.get)
    nr = nr_per[f_gov]

    nc = None
    ncs = sorted(NC_TABLE)
    per_band = []
    for i, f in enumerate(NC_FREQS):
        if f not in spec:
            continue
        levels = np.array([NC_TABLE[n][i] for n in ncs], float)
        per_band.append(float(np.interp(spec[f], levels, ncs)))
    if per_band:
        nc = max(per_band)

    rc = None
    rc_class = None
    if all(f in spec for f in (500.0, 1000.0, 2000.0)):
        rc = (spec[500.0] + spec[1000.0] + spec[2000.0]) / 3
        ref = {f: rc + 5 * np.log2(1000.0 / f) for f in spec}
        rumble = any(spec[f] > ref[f] + 5 for f in (31.5, 63.0, 125.0, 250.0)
                     if f in spec)
        hiss = any(spec[f] > ref[f] + 3 for f in (2000.0, 4000.0)
                   if f in spec)
        rc_class = ("RH" if rumble and hiss else
                    "R" if rumble else "H" if hiss else "N")

    return {"NR": round(nr, 1), "NR_governing_hz": int(f_gov),
            "NC": round(nc, 1) if nc is not None else None,
            "RC": round(rc, 1) if rc is not None else None,
            "RC_class": rc_class}

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
def background_octaves_db(F: dict, pct: float = 50.0,
                          offset_db: float = 0.0) -> dict:
    """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)."""
    from .features import OCT_CENTERS
    lv = 10 * np.log10(np.asarray(F["oct_pow"], float) + 1e-20) + offset_db
    return {c: float(np.percentile(lv[:, i], pct))
            for i, c in enumerate(OCT_CENTERS) if c <= 8000}

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
def read_responses(path: str | Path) -> dict:
    """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": [...]}``.
    """
    path = Path(path)
    with open(path, newline="") as f:
        reader = csv.DictReader(f)
        if reader.fieldnames is None:
            raise ValueError(f"{path} is empty")
        by_lower = {c.strip().lower(): c for c in reader.fieldnames}
        missing = [s for s in SCALES if s not in by_lower]
        if missing:
            raise ValueError(
                f"{path.name} lacks ISO 12913-2 scale column(s): "
                f"{', '.join(missing)}")
        id_col = next((by_lower[c] for c in _ID_COLUMNS if c in by_lower),
                      None)
        scale_cols = {s: by_lower[s] for s in SCALES}
        extra_cols = [c for c in reader.fieldnames
                      if c not in scale_cols.values() and c != id_col]
        rows, n_skipped = [], 0
        for i, rec in enumerate(reader):
            try:
                scales = {s: float(rec[c]) for s, c in scale_cols.items()}
            except (TypeError, ValueError):
                n_skipped += 1
                continue
            extras = {}
            for c in extra_cols:
                v = (rec.get(c) or "").strip()
                if not v:
                    continue
                try:
                    extras[c] = float(v)
                except ValueError:
                    extras[c] = v
            rid = (rec[id_col].strip() if id_col and (rec.get(id_col) or "")
                   .strip() else f"r{i + 1:02d}")
            rows.append({"id": rid, "scales": scales, "extras": extras})
    if not rows:
        raise ValueError(f"{path.name} has no complete response rows")
    lo, hi = detect_scale(rows)
    return {"respondents": rows,
            "scale": "5-point" if hi == 5.0 else "100-point",
            "range": (lo, hi), "n_skipped": n_skipped,
            "extra_keys": extra_cols}

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
def detect_scale(respondents: list) -> tuple:
    """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.
    """
    vals = np.array([v for r in respondents for v in r["scales"].values()])
    if vals.min() >= 1.0 and vals.max() <= 5.0:
        return (1.0, 5.0)
    if vals.min() >= 0.0 and vals.max() <= 100.0:
        return (0.0, 100.0)
    raise ValueError(f"scale values outside both 1–5 and 0–100: "
                     f"min {vals.min()}, max {vals.max()}")

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 lohi. 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
def coordinates(scales: dict, lo: float = 1.0, hi: float = 5.0) -> tuple:
    """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.
    """
    p, ch, v = scales["pleasant"], scales["chaotic"], scales["vibrant"]
    u, ca, a = scales["uneventful"], scales["calm"], scales["annoying"]
    e, m = scales["eventful"], scales["monotonous"]
    norm = (hi - lo) * (1 + np.sqrt(2))
    pl = ((p - a) + _COS45 * (ca - ch) + _COS45 * (v - m)) / norm
    ev = ((e - u) + _COS45 * (ch - ca) + _COS45 * (v - m)) / norm
    return float(pl), float(ev)

quadrant(pleasantness, eventfulness)

The circumplex quadrant label of a point.

Source code in src/ambiscape/survey.py
144
145
146
147
148
def quadrant(pleasantness: float, eventfulness: float) -> str:
    """The circumplex quadrant label of a point."""
    if eventfulness >= 0:
        return "vibrant" if pleasantness >= 0 else "chaotic"
    return "calm" if pleasantness >= 0 else "monotonous"

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
def ellipse_95(P: np.ndarray, E: np.ndarray) -> dict | None:
    """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.
    """
    if len(P) < 3:
        return None
    cov = np.cov(np.stack([P, E]))
    if not np.all(np.isfinite(cov)):
        return None
    eigval, eigvec = np.linalg.eigh(cov)          # ascending
    if eigval[-1] <= 0:
        return None
    eigval = np.clip(eigval, 0, None)
    ang = float(np.degrees(np.arctan2(eigvec[1, -1], eigvec[0, -1])))
    return {"width": round(2 * float(np.sqrt(_CHI2_95_2DF * eigval[-1])), 3),
            "height": round(2 * float(np.sqrt(_CHI2_95_2DF * eigval[0])), 3),
            "angle_deg": round(ang, 1)}

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
def summarize(responses: dict) -> dict:
    """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.
    """
    lo, hi = responses["range"]
    pts = []
    for r in responses["respondents"]:
        pl, ev = coordinates(r["scales"], lo, hi)
        pts.append({"id": r["id"], "pleasantness": round(pl, 3),
                    "eventfulness": round(ev, 3),
                    "scales": r["scales"], "extras": r["extras"]})
    P = np.array([p["pleasantness"] for p in pts])
    E = np.array([p["eventfulness"] for p in pts])
    disp = float(np.sqrt(np.mean((P - P.mean()) ** 2 + (E - E.mean()) ** 2)))
    doc = {
        "standard": "ISO 12913-2 Method A -> ISO/TS 12913-3 circumplex",
        "scale": responses["scale"], "n": len(pts),
        "n_skipped": responses["n_skipped"],
        "pleasantness_mean": round(float(P.mean()), 3),
        "eventfulness_mean": round(float(E.mean()), 3),
        "pleasantness_sd": round(float(P.std(ddof=1)), 3) if len(P) > 1
        else None,
        "eventfulness_sd": round(float(E.std(ddof=1)), 3) if len(E) > 1
        else None,
        "dispersion": round(disp, 3),
        "quadrant": quadrant(float(P.mean()), float(E.mean())),
        "ellipse_95": ellipse_95(P, E),
        "respondents": pts,
    }
    num_extras = {}
    for k in responses["extra_keys"]:
        vals = [p["extras"][k] for p in pts
                if isinstance(p["extras"].get(k), float)]
        if vals:
            num_extras[f"{k}_mean"] = round(float(np.mean(vals)), 3)
    if num_extras:
        doc["extras"] = num_extras
    return doc

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
def survey_summary_keys(doc: dict) -> dict:
    """The ``srv_`` rows folded into ``summary.json`` for the catalog."""
    out = {"srv_n": doc["n"],
           "srv_pleasantness_mean": doc["pleasantness_mean"],
           "srv_eventfulness_mean": doc["eventfulness_mean"],
           "srv_dispersion": doc["dispersion"],
           "srv_quadrant": doc["quadrant"]}
    if doc["pleasantness_sd"] is not None:
        out["srv_pleasantness_sd"] = doc["pleasantness_sd"]
        out["srv_eventfulness_sd"] = doc["eventfulness_sd"]
    return out

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
def vs_measurement(doc: dict, summary: dict) -> list:
    """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.
    """
    rows = []
    for keys, label, pkey in _VS_ROWS:
        key = next((k for k in keys if summary.get(k) is not None), None)
        if key is None:
            continue
        unit = " (dB SPL)" if key.endswith("_db_spl") else \
            " (dBFS)" if key in ("laeq_dbfs", "L90") else ""
        rows.append({"measured": label + unit, "value": summary[key],
                     "perceived": pkey.replace("_mean", ""),
                     "perceived_value": doc[pkey]})
    return rows

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
def vs_table(rows: list) -> str:
    """The rows of :func:`vs_measurement` as a small Markdown table."""
    out = ["| measured | value | perceived | value |", "|---|---|---|---|"]
    for r in rows:
        out.append(f"| {r['measured']} | {r['value']} | {r['perceived']} | "
                   f"{r['perceived_value']:+.3f} |")
    return "\n".join(out)

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
def render(doc: dict, out_path: str | Path, title: str = "") -> Path:
    """Circumplex plot: respondents, mean, 95% ellipse → a PNG."""
    import matplotlib
    matplotlib.use("Agg")
    import matplotlib.pyplot as plt
    from matplotlib.patches import Ellipse
    from .figures import RC, BLUE, MAGENTA, GRID, MUT, SEC

    pts = doc["respondents"]
    P = [p["pleasantness"] for p in pts]
    E = [p["eventfulness"] for p in pts]
    with plt.rc_context(RC):
        fig, ax = plt.subplots(figsize=(6, 6), dpi=130)
        ax.grid(False)
        ax.set_aspect("equal")
        th = np.linspace(0, 2 * np.pi, 200)
        ax.plot(np.cos(th), np.sin(th), color=GRID, lw=1.0)
        for r in (0.25, 0.5, 0.75):
            ax.plot(r * np.cos(th), r * np.sin(th), color=GRID, lw=0.5)
        for ang in range(0, 360, 45):
            a = np.radians(ang)
            ax.plot([0, np.cos(a)], [0, np.sin(a)], color=GRID, lw=0.5)
        for name, ang in zip(SCALES, range(0, 360, 45)):
            a = np.radians(ang)
            ax.annotate(name, (1.1 * np.cos(a), 1.1 * np.sin(a)),
                        ha="center", va="center", color=SEC, fontsize=9)
        ax.scatter(P, E, s=26, color=BLUE, alpha=0.65, lw=0, zorder=3)
        ell = doc.get("ellipse_95")
        if ell:
            ax.add_patch(Ellipse(
                (doc["pleasantness_mean"], doc["eventfulness_mean"]),
                ell["width"], ell["height"], angle=ell["angle_deg"],
                fill=False, color=MAGENTA, lw=1.2, ls="--", zorder=4))
        ax.scatter([doc["pleasantness_mean"]], [doc["eventfulness_mean"]],
                   s=90, color=MAGENTA, marker="D", zorder=5,
                   label=f"mean (n={doc['n']})")
        ax.set_xlim(-1.25, 1.25)
        ax.set_ylim(-1.25, 1.25)
        ax.set_xticks([-1, -0.5, 0, 0.5, 1])
        ax.set_yticks([-1, -0.5, 0, 0.5, 1])
        ax.tick_params(colors=MUT, labelsize=8)
        ax.spines[["left", "bottom"]].set_visible(False)
        ax.set_xlabel("pleasantness")
        ax.set_ylabel("eventfulness")
        ax.set_title(f"{title} — ISO 12913-3 circumplex "
                     f"({doc['scale']}, {doc['quadrant']})",
                     loc="left", fontsize=10)
        ax.legend(loc="lower left", frameon=False, fontsize=8)
        fig.tight_layout()
        fig.savefig(out_path, bbox_inches="tight")
        plt.close(fig)
    return Path(out_path)

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
def run_survey(folder: str | Path, responses_csv: str | Path,
               out_dir: str | Path | None = None) -> dict:
    """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.
    """
    folder = Path(folder)
    out = Path(out_dir) if out_dir else folder / "analysis"
    out.mkdir(parents=True, exist_ok=True)
    doc = summarize(read_responses(responses_csv))
    sp = out / "summary.json"
    summary = json.loads(sp.read_text()) if sp.exists() else {}
    rows = vs_measurement(doc, summary)
    if rows:
        doc["vs_measurement"] = rows
    summary.update(survey_summary_keys(doc))
    sp.write_text(json.dumps(summary, indent=2))
    (out / "survey.json").write_text(json.dumps(doc, indent=2))
    render(doc, out / "survey.png", title=folder.name)
    return doc

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
def read_annotations(path: str | Path) -> list[dict]:
    """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``.
    """
    rows = []
    for ln, line in enumerate(Path(path).read_text().splitlines(), start=1):
        line = line.strip()
        if not line:
            continue
        parts = [p.strip() for p in line.split(",")]
        if len(parts) not in (5, 6):
            raise ValueError(f"{path}:{ln}: expected 5 or 6 columns "
                             f"(frame, class, source, azimuth, elevation"
                             f"[, distance]), got {len(parts)}")
        try:
            frame, cls, src = int(parts[0]), int(parts[1]), int(parts[2])
            az, el = float(parts[3]), float(parts[4])
        except ValueError as e:
            raise ValueError(f"{path}:{ln}: non-numeric field: {line!r}") from e
        rows.append({
            "frame": frame,
            "class_id": cls,
            "class_name": CLASSES[cls] if 0 <= cls < len(CLASSES) else str(cls),
            "source": src,
            "azimuth": az,
            "elevation": el,
            "distance_cm": float(parts[5]) if len(parts) == 6 else None,
        })
    return rows

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
def single_source_frames(rows: list[dict]) -> tuple[dict, int]:
    """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).
    """
    counts = Counter(r["frame"] for r in rows)
    singles = {r["frame"]: r for r in rows if counts[r["frame"]] == 1}
    n_multi = sum(1 for f, c in counts.items() if c > 1)
    return singles, n_multi

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
def frame_azimuths(path: str | Path, frame_s: float = FRAME_S,
                   wyzx=(0, 1, 2, 3), band=(80.0, 3000.0),
                   method: str = "intensity", sub_s: float = 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.
    """
    import soundfile as sf
    from scipy.signal import butter, sosfilt

    with sf.SoundFile(str(path)) as f:
        fs, nch = f.samplerate, f.channels
        if nch < 4:
            raise ValueError(f"{path}: DOA validation needs 4-channel FOA, "
                             f"got {nch} channel(s)")
        spf = max(int(round(frame_s * fs)), 1)
        hi = min(band[1], 0.45 * fs)
        sos = butter(4, [band[0], hi], "bandpass", fs=fs, output="sos")
        zi = np.zeros((sos.shape[0], 2, nch))
        iw, iy, iz, ix = wyzx[0], wyzx[1], wyzx[2], wyzx[3]
        spsub = max(int(round(sub_s * fs)), 1)
        azs, ens, dfs = [], [], []
        while True:
            blk = f.read(spf * 600, dtype="float64", always_2d=True)
            if not len(blk):
                break
            blk, zi = sosfilt(sos, blk, axis=0, zi=zi)
            n = len(blk) // spf
            if n == 0:
                break
            blk = blk[:n * spf]
            W = blk[:, iw].reshape(n, spf)
            Y = blk[:, iy].reshape(n, spf)
            X = blk[:, ix].reshape(n, spf)
            Z = blk[:, iz].reshape(n, spf)
            ix_ = (W * X).sum(1)
            iy_ = (W * Y).sum(1)
            iz_ = (W * Z).sum(1)
            # Standard diffuseness: the intensity vector shrinks relative to
            # the energy as the field becomes isotropic.
            e = (W ** 2).sum(1) + (X ** 2 + Y ** 2 + Z ** 2).sum(1) / 3.0
            mag = np.sqrt(ix_ ** 2 + iy_ ** 2 + iz_ ** 2)
            dfs.append(np.clip(1.0 - mag / (e / 2.0 + EPS), 0.0, 1.0))
            ens.append((W ** 2).sum(1))
            if method == "intensity":
                azs.append(np.degrees(np.arctan2(iy_, ix_)))
            elif method == "energy":
                k = spf // spsub
                if k < 2:
                    azs.append(np.degrees(np.arctan2(iy_, ix_)))
                else:
                    m = k * spsub
                    Ws = W[:, :m].reshape(n, k, spsub)
                    Ys = Y[:, :m].reshape(n, k, spsub)
                    Xs = X[:, :m].reshape(n, k, spsub)
                    a = np.arctan2((Ws * Ys).sum(2), (Ws * Xs).sum(2))
                    w = (Ws ** 2).sum(2)
                    v = (w * np.exp(1j * a)).sum(1)
                    azs.append(np.degrees(np.angle(v)))
            else:
                raise ValueError(f"unknown method {method!r}; "
                                 "expected 'intensity' or 'energy'")
    if not azs:
        return np.zeros(0), np.zeros(0), np.zeros(0)
    return (np.concatenate(azs), np.concatenate(ens), np.concatenate(dfs))

wrap_deg(d)

Wrap angle difference(s) to (-180, 180] degrees.

Source code in src/ambiscape/starss.py
198
199
200
def wrap_deg(d):
    """Wrap angle difference(s) to (-180, 180] degrees."""
    return -((180.0 - np.asarray(d, float)) % 360.0 - 180.0)

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
def validate_clip(wav_path: str | Path, csv_path: str | Path,
                  frame_s: float = FRAME_S, wyzx=(0, 1, 2, 3),
                  method: str = "intensity") -> dict:
    """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).
    """
    rows = read_annotations(csv_path)
    singles, n_multi = single_source_frames(rows)
    az_est, _en, diff = frame_azimuths(wav_path, frame_s=frame_s, wyzx=wyzx,
                                       method=method)
    records = []
    for frame, row in sorted(singles.items()):
        if not 0 <= frame < len(az_est):
            continue        # label beyond the audio (annotation overrun)
        err = float(wrap_deg(az_est[frame] - row["azimuth"]))
        records.append({
            "frame": frame,
            "diffuseness": round(float(diff[frame]), 3),
            "class_name": row["class_name"],
            "label_az_deg": row["azimuth"],
            "est_az_deg": round(float(az_est[frame]), 1),
            "error_deg": round(err, 1),
        })
    return {"records": records,
            "n_frames_labelled": len({r["frame"] for r in rows}),
            "n_multi": n_multi}

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
def error_stats(records: list[dict]) -> dict:
    """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).
    """
    err = np.array([r["error_deg"] for r in records], float)
    ae = np.abs(err)
    mu, R = mean_resultant(np.radians(err))

    def _mi(a):
        return (round(float(np.median(a)), 1),
                round(float(np.percentile(a, 75) - np.percentile(a, 25)), 1))

    med, iqr = _mi(ae)
    per_class = {}
    for name in sorted({r["class_name"] for r in records}):
        sub = np.array([abs(r["error_deg"]) for r in records
                        if r["class_name"] == name])
        m, q = _mi(sub)
        per_class[name] = {"n": int(len(sub)), "median_abs_deg": m,
                           "iqr_deg": q}
    return {
        "n_frames": int(len(err)),
        "median_abs_deg": med,
        "iqr_deg": iqr,
        "bias_deg": round(float(np.degrees(mu)), 1),
        "circ_sd_deg": round(float(np.degrees(circular_sd(R))), 1),
        "within_20deg": round(float((ae <= 20).mean()), 2),
        "per_class": per_class,
    }

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.wavfold4_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
def validate_collection(folder: str | Path, ann_dir: str | Path,
                        frame_s: float = FRAME_S) -> dict:
    """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.
    """
    from .io import open_clips

    ann_dir = Path(ann_dir)
    sess = open_clips(folder)
    all_records, per_clip = [], {}
    n_multi = n_labelled = 0
    for tk in sess.takes:
        csv = ann_dir / (tk.path.stem + ".csv")
        if not csv.exists():
            warnings.warn(f"no annotation CSV for {tk.path.name} in "
                          f"{ann_dir}", stacklevel=2)
            continue
        v = validate_clip(tk.audio_path, csv, frame_s=frame_s, wyzx=tk.wyzx)
        n_multi += v["n_multi"]
        n_labelled += v["n_frames_labelled"]
        if v["records"]:
            per_clip[tk.path.stem] = error_stats(v["records"])
        all_records.extend(v["records"])
    if not all_records:
        raise FileNotFoundError(
            f"no labelled single-source frames scored: check that {ann_dir} "
            f"holds CSVs matching the clip stems in {folder}")
    return {
        "overall": error_stats(all_records),
        "n_frames_labelled": n_labelled,
        "n_multi_excluded": n_multi,
        "per_clip": per_clip,
        "records": all_records,
    }

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
def run_validation(folder: str | Path, ann_dir: str | Path,
                   out_dir: str | Path | None = None,
                   frame_s: float = FRAME_S) -> dict:
    """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).
    """
    import json

    folder = Path(folder)
    out = Path(out_dir) if out_dir else folder / "analysis"
    out.mkdir(parents=True, exist_ok=True)
    doc = validate_collection(folder, ann_dir, frame_s=frame_s)
    slim = {k: v for k, v in doc.items() if k != "records"}
    (out / "doavalidate.json").write_text(json.dumps(slim, indent=2,
                                                     default=float))
    o = doc["overall"]
    _figure(doc, out / "doavalidate.png",
            f"{folder.name} — energy DOA vs STARSS labels: "
            f"n={o['n_frames']} single-source frames, "
            f"median |err| {o['median_abs_deg']}°")
    return slim

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 draft to suggest object names in annotations.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.biophony for 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
def panns_available() -> bool:
    """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.
    """
    try:
        import panns_inference  # noqa: F401
        return True
    except ImportError:
        return False

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
def tag_window(x: np.ndarray, fs: int, top_k: int = 3,
               min_prob: float = 0.10) -> list[dict]:
    """AudioSet tags for one mono window via PANNs CNN14 (32 kHz input)."""
    global _panns_model
    from panns_inference import AudioTagging, labels
    if _panns_model is None:
        _panns_model = AudioTagging(checkpoint_path=None, device="cpu")
    y = _resample(x.astype(np.float32), fs, 32000)
    clip = np.clip(y, -1, 1)[None, :]
    clipwise, _emb = _panns_model.inference(clip)
    probs = np.asarray(clipwise)[0]
    order = np.argsort(probs)[::-1][:top_k]
    return [{"label": labels[i], "p": round(float(probs[i]), 2)}
            for i in order if probs[i] >= min_prob]

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
def tag_probabilities(x: np.ndarray, fs: int,
                      wanted: list[str] | tuple[str, ...] | None = None
                      ) -> dict[str, float]:
    """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.
    """
    from panns_inference import labels as _labels
    global _panns_model
    from panns_inference import AudioTagging
    if _panns_model is None:
        _panns_model = AudioTagging(checkpoint_path=None, device="cpu")
    if wanted is not None:
        unknown = [w for w in wanted if w not in _labels]
        if unknown:
            raise ValueError(f"not AudioSet labels: {unknown}")
    y = _resample(x.astype(np.float32), fs, 32000)
    clip = np.clip(y, -1, 1)[None, :]
    clipwise, _emb = _panns_model.inference(clip)
    probs = np.asarray(clipwise)[0]
    names = wanted if wanted is not None else _labels
    idx = {lab: i for i, lab in enumerate(_labels)}
    return {lab: float(probs[idx[lab]]) for lab in names}

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
def birdnet_available() -> bool:
    """Whether `birdnetlib` can be imported. Optional, like `panns_available`."""
    try:
        import birdnetlib  # noqa: F401
        return True
    except ImportError:
        return False

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
def birdnet_window(x: np.ndarray, fs: int, lat: float | None = None,
                   lon: float | None = None, week: int = -1,
                   min_conf: float = 0.25) -> list[dict]:
    """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``.
    """
    global _birdnet_analyzer
    import tempfile
    import soundfile as sf
    from birdnetlib import Recording
    from birdnetlib.analyzer import Analyzer
    if _birdnet_analyzer is None:
        _birdnet_analyzer = Analyzer()
    y = _resample(x.astype(np.float32), fs, 48000)
    with tempfile.NamedTemporaryFile(suffix=".wav", delete=True) as tmp:
        sf.write(tmp.name, np.clip(y, -1, 1), 48000)
        kw = dict(min_conf=min_conf, week_48=week)
        if lat is not None and lon is not None:
            kw.update(lat=lat, lon=lon)
        rec = Recording(_birdnet_analyzer, tmp.name, **kw)
        rec.analyze()
    return [{"species": d["scientific_name"],
             "common_name": d["common_name"],
             "confidence": round(float(d["confidence"]), 2)}
            for d in rec.detections]

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
def birdnet_session(sess, F=None, windows=None, win_s: float = 9.0,
                    hifi_max_diffuse: float | None = None,
                    lat: float | None = None, lon: float | None = None,
                    min_conf: float = 0.25) -> dict:
    """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.
    """
    from .io import read_span
    if windows is None:
        windows = []
        for tk in sess.takes:
            t = tk.start + 1.0
            while t + win_s <= tk.end:
                windows.append(t)
                t += win_s
    tally: dict[str, dict] = {}
    per_window = []
    for t0 in windows:
        if F is not None and hifi_max_diffuse is not None:
            i0 = int(np.searchsorted(F["t"], t0))
            i1 = int(np.searchsorted(F["t"], t0 + win_s))
            if i1 > i0 and float(np.median(F["diffuse"][i0:i1])) > \
                    hifi_max_diffuse:
                continue
        x, fs = read_span(sess, t0, win_s)
        dets = birdnet_window(x[:, 0], fs, lat=lat, lon=lon,
                              min_conf=min_conf)
        if dets:
            per_window.append({"t0_s": float(t0), "detections": dets})
            for d in dets:
                e = tally.setdefault(d["species"], {
                    "common_name": d["common_name"], "n": 0, "max_conf": 0.0})
                e["n"] += 1
                e["max_conf"] = max(e["max_conf"], d["confidence"])
    species = sorted(({"species": k, **v} for k, v in tally.items()),
                     key=lambda s: (-s["n"], -s["max_conf"]))
    return {"n_windows_analyzed": len(windows),
            "n_windows_with_birds": len(per_window),
            "n_species": len(species),
            "species": species, "windows": per_window}

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
def speech_fraction(x: np.ndarray, fs: int, normalize: bool = True) -> dict:
    """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.
    """
    import torch
    from silero_vad import load_silero_vad, get_speech_timestamps
    model = load_silero_vad()
    if normalize:
        x = _vad_input(x)
    y = _resample(np.asarray(x, np.float32), fs, 16000)
    ts = get_speech_timestamps(torch.from_numpy(np.ascontiguousarray(y)),
                               model, sampling_rate=16000)
    dur = len(y) / 16000
    speech = sum((t["end"] - t["start"]) for t in ts) / 16000
    return {"duration_s": round(dur, 1),
            "speech_s": round(speech, 1),
            "speech_fraction": round(speech / dur, 4) if dur else 0.0,
            "n_speech_segments": len(ts),
            "first_speech_at_s": round(ts[0]["start"] / 16000, 1) if ts else None}

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
def speech_gate(path: str | Path, threshold: float = 0.01) -> dict:
    """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).
    """
    import soundfile as sf
    x, fs = sf.read(str(path), dtype="float32", always_2d=True)
    res = speech_fraction(x[:, 0], fs)
    res["file"] = str(path)
    res["passes"] = res["speech_fraction"] <= threshold
    return res

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
def validate_bst_category(code: str, soundscape_only: bool = False) -> str:
    """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.
    """
    code = str(code).strip().lower()
    if code not in BST_CATEGORIES:
        raise ValueError(
            f"{code!r} is not a Broad Sound Taxonomy subcategory. "
            f"Valid codes: {', '.join(sorted(BST_CATEGORIES))}")
    if soundscape_only and code not in SOUNDSCAPE_CATEGORIES:
        raise ValueError(
            f"{code!r} is {BST_CATEGORIES[code]}, not a soundscape. "
            f"A whole-room recording takes one of: "
            f"{', '.join(SOUNDSCAPE_CATEGORIES)}")
    return code

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
def freesound_sidecar(wav_path: str | Path, bst_category: str,
                      licence: str = "CC BY 4.0",
                      tags: "list[str] | None" = None,
                      description: str | None = None,
                      speech_fraction: float | None = None,
                      soundscape_only: bool = True,
                      extra: dict | None = None) -> Path:
    """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.
    """
    import json

    wav_path = Path(wav_path)
    code = validate_bst_category(bst_category, soundscape_only=soundscape_only)
    doc = {
        "filename": wav_path.name,
        "bst_category": code,
        "bst_category_name": BST_CATEGORIES[code],
        "licence": licence,
        "tags": list(tags) if tags else [],
        "description": description,
    }
    if speech_fraction is not None:
        doc["speech_fraction"] = round(float(speech_fraction), 4)
        doc["privacy_review"] = float(speech_fraction) > 0.01
    if extra:
        doc.update(extra)
    doc["_taxonomy_note"] = (
        "bst_category is Freesound's Broad Sound Taxonomy "
        "(https://freesound.org/help/broad-sound-taxonomy/), mandatory on "
        "upload since April 2025. It classifies the file, not the sound: it "
        "is unrelated to the Schaeffer, Schafer and soundscape-ecology "
        "labels in ambiscape.taxonomy.")
    out = wav_path.parent / (wav_path.name + ".freesound.json")
    out.write_text(json.dumps(doc, indent=2) + "\n")
    return out

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
def export_take_tsv(npz_path: str | Path, out_dir: str | Path) -> Path:
    """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.
    """
    p = np.load(str(npz_path))
    logf = p["logf"]
    fc = np.sqrt(logf[:-1] * logf[1:])
    S = p["logspec"]
    tot = S.sum(1) + 1e-20
    low = S[:, fc < 250].sum(1) / tot
    high = S[:, fc > 2000].sum(1) / tot
    level = db(p["rms_w"].astype(np.float64) ** 2)
    out_dir = Path(out_dir)
    out_dir.mkdir(parents=True, exist_ok=True)
    out = out_dir / (Path(npz_path).stem + ".tsv")
    with open(out, "w") as f:
        f.write("Time\tlevel_dbfs\tcentroid_hz\tlow_frac\thigh_frac\n")
        for i in range(len(level)):
            f.write(f"{i}\t{level[i]:.1f}\t{p['centroid'][i]:.0f}\t"
                    f"{low[i]:.3f}\t{high[i]:.3f}\n")
    return out

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
def export_session(folder: str | Path) -> list[Path]:
    """Run `export_take_tsv` over every feature file in a session, into `deposit/`."""
    folder = Path(folder)
    fdir = folder / "analysis" / "features"
    outs = []
    for npz in sorted(fdir.glob("*.npz")):
        outs.append(export_take_tsv(npz, folder / "deposit"))
    return outs