ambiscape 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Alexander Refsum Jensenius
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,96 @@
1
+ Metadata-Version: 2.4
2
+ Name: ambiscape
3
+ Version: 0.1.0
4
+ Summary: Long-duration first-order ambisonic soundscape analysis (streaming companion to ambiviz)
5
+ Author-email: Alexander Refsum Jensenius <a.r.jensenius@imv.uio.no>
6
+ License: MIT
7
+ Project-URL: Repository, https://github.com/fourMs/ambiscape
8
+ Project-URL: Issues, https://github.com/fourMs/ambiscape/issues
9
+ Project-URL: Author ORCID, https://orcid.org/0000-0001-6171-8743
10
+ Project-URL: Companion toolbox (ambiviz), https://github.com/fisheggg/ambiviz
11
+ Keywords: ambisonics,soundscape,field-recording,acoustics,spatial-audio,long-duration,ISO-12913
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Topic :: Multimedia :: Sound/Audio :: Analysis
15
+ Classifier: Intended Audience :: Science/Research
16
+ Requires-Python: >=3.10
17
+ Description-Content-Type: text/markdown
18
+ License-File: LICENSE
19
+ Requires-Dist: numpy>=1.24
20
+ Requires-Dist: scipy>=1.10
21
+ Requires-Dist: soundfile>=0.12
22
+ Requires-Dist: matplotlib>=3.7
23
+ Provides-Extra: viz
24
+ Requires-Dist: ambiviz>=0.0.1; extra == "viz"
25
+ Provides-Extra: iso
26
+ Requires-Dist: mosqito>=1.2; extra == "iso"
27
+ Provides-Extra: ml
28
+ Requires-Dist: panns-inference>=0.1; extra == "ml"
29
+ Requires-Dist: silero-vad>=5.0; extra == "ml"
30
+ Requires-Dist: torch>=2.0; extra == "ml"
31
+ Provides-Extra: dev
32
+ Requires-Dist: pytest>=7.0; extra == "dev"
33
+ Dynamic: license-file
34
+
35
+ # ambiscape
36
+
37
+ Analysis toolkit for long-duration first-order ambisonic soundscape recordings (Zoom H3-VR and other AmbiX/SN3D B-format sources).
38
+
39
+ Built as the streaming companion to [ambiviz](https://github.com/fisheggg/ambiviz): ambiviz renders rich spatial visuals (AEM spherical energy maps, anglegrams, directograms) from files it can load whole; ambiscape handles the other end of the problem: recordings of hours (tens of GB) that must be processed in a stream and produces session-level summaries, timelines, and short representative excerpts that ambiviz then visualises in detail. Plot names and conventions follow ambiviz where the two overlap.
40
+
41
+ ## What it does
42
+
43
+ - **Session model** — a folder of WAVs = one session; BWF `bext` timestamps (parsed natively, no ffmpeg needed) put all takes on an absolute clock,
44
+ including 2 GB recorder splits.
45
+ - **Streaming features** (constant memory, per take, cached as `.npz`) 125 ms fast level (unweighted + A-weighted), per-second octave-band powers, spectral centroid/flatness, 96-band log spectrogram, per-octave pseudo-intensity vectors, broadband DOA (azimuth/elevation), diffuseness ψ, and per-minute full-resolution spectra for narrowband hum tracking.
46
+ - **Descriptors** — Leq, LAeq, L10/L50/L90, dynamics, event statistics (+8 dB over a running 60 s 10th-percentile background, ≥ 0.25 s), circular direction statistics (mean azimuth, resultant length R), foreground/background energy-quartile splits.
47
+ - **Figures** — 4-panel session overview (level + background, log spectrogram, anglegram, ψ(t)), percentile LTAS, foreground/background directogram.
48
+ - **Room acoustics** — noise-aware truncated-Schroeder T60 from claps or incidental impulses (`analysis.decay_time`).
49
+ - **Segment selection** — quietest / most active/typical / transition windows for archiving, listening, or ambiviz rendering (`analysis.pick_segments`).
50
+ - **Reports** — auto-generated per-session `README.md` with metadata, descriptor table, and figures.
51
+ - **Taxonomy figures** — from a hand-authored `annotations.json` in the session folder (the interpretive layer instruments can't supply), render a **Schaeffer typo-morphology map** (objects on the facture × mass plane, colored by Schafer function) and **Schafer timeline** (keynote lanes, signal/soundmark events, lo-fi states shaded, gap-aware panels). Schema documented in `taxonomy.py`.
52
+ - **Calibration** — drop a `calibration.json` in the session folder (`{"dbfs_to_dbspl": 94.0, "method": "SPL app next to mic, pump running"}`) and `analyse` adds dB SPL versions of Leq/LAeq/L10/L50/L90 to the summary, making them ISO 1996-comparable.
53
+ - **ISO 12913-3 indicators** — `ambiscape iso <folder>` computes ISO 532-1 time-varying loudness (N5, N50), DIN 45692 sharpness and Daniel & Weber roughness (via [MoSQITo](https://github.com/Eomys/MoSQITo), validated here against the 1 kHz/60 dB ≙ 4 sone reference) per ear on a binaural render of each representative segment — ambiviz's HRIR binauralizer when installed, otherwise a documented ±90° cardioid-pair fallback. Uncalibrated sessions are computed with an assumed offset and flagged (ratios between segments stay meaningful; absolute sones don't). MoSQITo runs ~5× slower than real-time, hence 30 s segments and a 10 s roughness slice by default.
54
+ - **Draft annotations** — `ambiscape draft <folder>` pre-fills `annotations.draft.json` from the cached features: steady level regimes (fixed-reference change-point detection become keynote candidates with spans, and detected events are listed with listening hints (clock time, level, azimuth/elevation, diffuseness). You supply the ears: name the objects, fill mass/facture/kind, save as `annotations.json`.
55
+
56
+ ## Install
57
+
58
+ ```bash
59
+ pip install -e . # from this folder
60
+ pip install -e ".[viz]" # + ambiviz for AEM/segment visuals
61
+ ```
62
+
63
+ ## Use
64
+
65
+ ```bash
66
+ ambiscape probe "folder"
67
+ ambiscape analyze "folder" --notes "Living room, mic on table"
68
+ ambiscape draft "folder" # pre-fill annotations.draft.json
69
+ ambiscape taxonomy "folder" # needs <folder>/annotations.json
70
+ ambiscape iso "folder" # ISO 12913-3 indicators (MoSQITo)
71
+ ```
72
+
73
+ ```python
74
+ import ambiscape as asc
75
+
76
+ sess = asc.open_session("2026-07-15-Haarlem")
77
+ paths = asc.extract_session(sess, "features") # streams, caches npz
78
+ F = asc.load_features(paths) # one absolute time axis
79
+ print(asc.summarize(F))
80
+
81
+ x, fs = asc.read_span(sess, t0=4.0, dur=6.0) # raw audio anywhere
82
+ print(asc.decay_time(x[:, 0], fs)) # T60 from a clap at t≈4 s
83
+
84
+ asc.figures.overview(F, "overview.png", clock=sess.clock)
85
+ ```
86
+
87
+ ## Zoom H3-VR notes
88
+
89
+ The toolbox was built using Ambisonics recordings captured with a Zoom H3-VR. The H3-VR records B-format as either AmbiX (W,Y,Z,X) or FuMa (W,X,Y,Z); `directional_energy.py` defaults to `fuma` while ambiscape auto-detects the convention from the recorder's `zTRK` tags in the BWF `bext` chunk (`io.channel_order`). Processing AmbiX files as FuMa swaps X↔Y — a reflection of all azimuths about the ±45° diagonal, which a best-circular-shift correlation does *not* absorb. Worth verifying which mode the 2023 raw files used before comparing directional results across the two corpora.
90
+
91
+ ## Conventions
92
+
93
+ - AmbiX ACN channel order (W, Y, Z, X) as written by the H3-VR, SN3D.
94
+ - Azimuth: 0° = front (X+), +90° = left (Y+), ±180° = rear; elevation + up. All directions are mic-relative.
95
+ - Levels are dBFS (uncalibrated); within-session structure is exact; between-session absolute comparisons are indicative.
96
+ - Diffuseness ψ = 1 − 2‖⟨Re W*·v⟩‖ / ⟨|W|² + ‖v‖²⟩ (0 = plane wave, 1 = diffuse).
@@ -0,0 +1,62 @@
1
+ # ambiscape
2
+
3
+ Analysis toolkit for long-duration first-order ambisonic soundscape recordings (Zoom H3-VR and other AmbiX/SN3D B-format sources).
4
+
5
+ Built as the streaming companion to [ambiviz](https://github.com/fisheggg/ambiviz): ambiviz renders rich spatial visuals (AEM spherical energy maps, anglegrams, directograms) from files it can load whole; ambiscape handles the other end of the problem: recordings of hours (tens of GB) that must be processed in a stream and produces session-level summaries, timelines, and short representative excerpts that ambiviz then visualises in detail. Plot names and conventions follow ambiviz where the two overlap.
6
+
7
+ ## What it does
8
+
9
+ - **Session model** — a folder of WAVs = one session; BWF `bext` timestamps (parsed natively, no ffmpeg needed) put all takes on an absolute clock,
10
+ including 2 GB recorder splits.
11
+ - **Streaming features** (constant memory, per take, cached as `.npz`) 125 ms fast level (unweighted + A-weighted), per-second octave-band powers, spectral centroid/flatness, 96-band log spectrogram, per-octave pseudo-intensity vectors, broadband DOA (azimuth/elevation), diffuseness ψ, and per-minute full-resolution spectra for narrowband hum tracking.
12
+ - **Descriptors** — Leq, LAeq, L10/L50/L90, dynamics, event statistics (+8 dB over a running 60 s 10th-percentile background, ≥ 0.25 s), circular direction statistics (mean azimuth, resultant length R), foreground/background energy-quartile splits.
13
+ - **Figures** — 4-panel session overview (level + background, log spectrogram, anglegram, ψ(t)), percentile LTAS, foreground/background directogram.
14
+ - **Room acoustics** — noise-aware truncated-Schroeder T60 from claps or incidental impulses (`analysis.decay_time`).
15
+ - **Segment selection** — quietest / most active/typical / transition windows for archiving, listening, or ambiviz rendering (`analysis.pick_segments`).
16
+ - **Reports** — auto-generated per-session `README.md` with metadata, descriptor table, and figures.
17
+ - **Taxonomy figures** — from a hand-authored `annotations.json` in the session folder (the interpretive layer instruments can't supply), render a **Schaeffer typo-morphology map** (objects on the facture × mass plane, colored by Schafer function) and **Schafer timeline** (keynote lanes, signal/soundmark events, lo-fi states shaded, gap-aware panels). Schema documented in `taxonomy.py`.
18
+ - **Calibration** — drop a `calibration.json` in the session folder (`{"dbfs_to_dbspl": 94.0, "method": "SPL app next to mic, pump running"}`) and `analyse` adds dB SPL versions of Leq/LAeq/L10/L50/L90 to the summary, making them ISO 1996-comparable.
19
+ - **ISO 12913-3 indicators** — `ambiscape iso <folder>` computes ISO 532-1 time-varying loudness (N5, N50), DIN 45692 sharpness and Daniel & Weber roughness (via [MoSQITo](https://github.com/Eomys/MoSQITo), validated here against the 1 kHz/60 dB ≙ 4 sone reference) per ear on a binaural render of each representative segment — ambiviz's HRIR binauralizer when installed, otherwise a documented ±90° cardioid-pair fallback. Uncalibrated sessions are computed with an assumed offset and flagged (ratios between segments stay meaningful; absolute sones don't). MoSQITo runs ~5× slower than real-time, hence 30 s segments and a 10 s roughness slice by default.
20
+ - **Draft annotations** — `ambiscape draft <folder>` pre-fills `annotations.draft.json` from the cached features: steady level regimes (fixed-reference change-point detection become keynote candidates with spans, and detected events are listed with listening hints (clock time, level, azimuth/elevation, diffuseness). You supply the ears: name the objects, fill mass/facture/kind, save as `annotations.json`.
21
+
22
+ ## Install
23
+
24
+ ```bash
25
+ pip install -e . # from this folder
26
+ pip install -e ".[viz]" # + ambiviz for AEM/segment visuals
27
+ ```
28
+
29
+ ## Use
30
+
31
+ ```bash
32
+ ambiscape probe "folder"
33
+ ambiscape analyze "folder" --notes "Living room, mic on table"
34
+ ambiscape draft "folder" # pre-fill annotations.draft.json
35
+ ambiscape taxonomy "folder" # needs <folder>/annotations.json
36
+ ambiscape iso "folder" # ISO 12913-3 indicators (MoSQITo)
37
+ ```
38
+
39
+ ```python
40
+ import ambiscape as asc
41
+
42
+ sess = asc.open_session("2026-07-15-Haarlem")
43
+ paths = asc.extract_session(sess, "features") # streams, caches npz
44
+ F = asc.load_features(paths) # one absolute time axis
45
+ print(asc.summarize(F))
46
+
47
+ x, fs = asc.read_span(sess, t0=4.0, dur=6.0) # raw audio anywhere
48
+ print(asc.decay_time(x[:, 0], fs)) # T60 from a clap at t≈4 s
49
+
50
+ asc.figures.overview(F, "overview.png", clock=sess.clock)
51
+ ```
52
+
53
+ ## Zoom H3-VR notes
54
+
55
+ The toolbox was built using Ambisonics recordings captured with a Zoom H3-VR. The H3-VR records B-format as either AmbiX (W,Y,Z,X) or FuMa (W,X,Y,Z); `directional_energy.py` defaults to `fuma` while ambiscape auto-detects the convention from the recorder's `zTRK` tags in the BWF `bext` chunk (`io.channel_order`). Processing AmbiX files as FuMa swaps X↔Y — a reflection of all azimuths about the ±45° diagonal, which a best-circular-shift correlation does *not* absorb. Worth verifying which mode the 2023 raw files used before comparing directional results across the two corpora.
56
+
57
+ ## Conventions
58
+
59
+ - AmbiX ACN channel order (W, Y, Z, X) as written by the H3-VR, SN3D.
60
+ - Azimuth: 0° = front (X+), +90° = left (Y+), ±180° = rear; elevation + up. All directions are mic-relative.
61
+ - Levels are dBFS (uncalibrated); within-session structure is exact; between-session absolute comparisons are indicative.
62
+ - Diffuseness ψ = 1 − 2‖⟨Re W*·v⟩‖ / ⟨|W|² + ‖v‖²⟩ (0 = plane wave, 1 = diffuse).
@@ -0,0 +1,44 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "ambiscape"
7
+ version = "0.1.0"
8
+ description = "Long-duration first-order ambisonic soundscape analysis (streaming companion to ambiviz)"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "Alexander Refsum Jensenius", email = "a.r.jensenius@imv.uio.no" }]
13
+ keywords = ["ambisonics", "soundscape", "field-recording", "acoustics",
14
+ "spatial-audio", "long-duration", "ISO-12913"]
15
+ classifiers = [
16
+ "License :: OSI Approved :: MIT License",
17
+ "Programming Language :: Python :: 3",
18
+ "Topic :: Multimedia :: Sound/Audio :: Analysis",
19
+ "Intended Audience :: Science/Research",
20
+ ]
21
+ dependencies = [
22
+ "numpy>=1.24",
23
+ "scipy>=1.10",
24
+ "soundfile>=0.12",
25
+ "matplotlib>=3.7",
26
+ ]
27
+
28
+ [project.urls]
29
+ Repository = "https://github.com/fourMs/ambiscape"
30
+ Issues = "https://github.com/fourMs/ambiscape/issues"
31
+ "Author ORCID" = "https://orcid.org/0000-0001-6171-8743"
32
+ "Companion toolbox (ambiviz)" = "https://github.com/fisheggg/ambiviz"
33
+
34
+ [project.optional-dependencies]
35
+ viz = ["ambiviz>=0.0.1"] # rich spatial visuals (anglegram/directogram/AEM) on extracted segments
36
+ iso = ["mosqito>=1.2"] # ISO 532-1 loudness, sharpness, roughness (ambiscape iso)
37
+ ml = ["panns-inference>=0.1", "silero-vad>=5.0", "torch>=2.0"] # tags + speech gate
38
+ dev = ["pytest>=7.0"]
39
+
40
+ [project.scripts]
41
+ ambiscape = "ambiscape.cli:main"
42
+
43
+ [tool.setuptools.packages.find]
44
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,20 @@
1
+ """ambiscape — long-duration first-order ambisonic soundscape analysis.
2
+
3
+ Streaming companion to ambiviz (https://github.com/fisheggg/ambiviz):
4
+ ambiscape summarizes hours of AmbiX recordings (levels, spectra, events,
5
+ DOA/diffuseness timelines, reverberation, representative segments); ambiviz
6
+ renders rich spatial visuals (AEM, anglegram, directogram) on the short
7
+ excerpts ambiscape selects.
8
+ """
9
+ from .io import open_session, read_bext, read_span
10
+ from .features import extract_take, extract_session, load_features
11
+ from .analysis import summarize, detect_events, decay_time, pick_segments
12
+ from . import figures, taxonomy, iso
13
+
14
+ __version__ = "0.1.0"
15
+ __all__ = [
16
+ "open_session", "read_bext", "read_span",
17
+ "extract_take", "extract_session", "load_features",
18
+ "summarize", "detect_events", "decay_time", "pick_segments",
19
+ "figures", "taxonomy",
20
+ ]
@@ -0,0 +1,162 @@
1
+ """Session-level descriptors, event detection, and reverberation estimation.
2
+
3
+ Descriptor conventions follow the Intercontinental-database report
4
+ (2026-07-10): fast level = 125 ms RMS on W; events = fast level exceeding a
5
+ running background (10th percentile in a sliding 60 s window) by >= 8 dB for
6
+ >= 0.25 s; diffuseness/DOA from per-second pseudo-intensity vectors.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import numpy as np
11
+ from scipy.ndimage import percentile_filter, median_filter
12
+
13
+ EPS = 1e-20
14
+
15
+
16
+ def db(x, eps=1e-12):
17
+ return 10 * np.log10(np.maximum(x, eps))
18
+
19
+
20
+ def running_background(fast_db: np.ndarray, fast_dt: float, win_s=60.0, pct=10):
21
+ n = max(3, int(round(win_s / fast_dt)) | 1)
22
+ return percentile_filter(fast_db, pct, size=n, mode="nearest")
23
+
24
+
25
+ def detect_events(fast_db, fast_dt, thresh_db=8.0, min_dur=0.25):
26
+ """Return list of dicts (onset index, length, peak index, exceedance)."""
27
+ bg = running_background(fast_db, fast_dt)
28
+ above = fast_db > bg + thresh_db
29
+ events = []
30
+ i, n = 0, len(above)
31
+ min_len = max(1, int(round(min_dur / fast_dt)))
32
+ while i < n:
33
+ if above[i]:
34
+ j = i
35
+ while j + 1 < n and above[j + 1]:
36
+ j += 1
37
+ if j - i + 1 >= min_len:
38
+ k = i + int(np.argmax(fast_db[i:j + 1]))
39
+ events.append(dict(i0=i, i1=j, ipk=k,
40
+ exceed=float(fast_db[k] - bg[k])))
41
+ i = j + 1
42
+ else:
43
+ i += 1
44
+ return events, bg
45
+
46
+
47
+ def circular_stats(az_deg, weights=None):
48
+ """Energy-weighted circular mean (deg) and resultant length R."""
49
+ a = np.radians(np.asarray(az_deg, float))
50
+ w = np.ones_like(a) if weights is None else np.asarray(weights, float)
51
+ C = (w * np.cos(a)).sum() / (w.sum() + EPS)
52
+ S = (w * np.sin(a)).sum() / (w.sum() + EPS)
53
+ return float(np.degrees(np.arctan2(S, C))), float(np.hypot(C, S))
54
+
55
+
56
+ def summarize(F: dict) -> dict:
57
+ """Session descriptor dict from concatenated features (see features.load_features)."""
58
+ fast, fasta = F["fast_db"], F["fast_dba"]
59
+ dt = float(np.median(np.diff(F["t_fast"]))) if len(F["t_fast"]) > 1 else 0.125
60
+ leq = db(np.mean(10 ** (fast.astype(np.float64) / 10)))
61
+ laeq = db(np.mean(10 ** (fasta.astype(np.float64) / 10)))
62
+ l10, l50, l90 = (float(np.percentile(fast, q)) for q in (90, 50, 10))
63
+ events, bg = detect_events(fast, dt)
64
+ dur = float(len(F["t"])) # 1 s per feature frame; robust across take gaps
65
+
66
+ p = F["rms_w"].astype(np.float64) ** 2
67
+ e_fg = p >= np.percentile(p, 75)
68
+ e_bg = p <= np.percentile(p, 25)
69
+ az_mean, R = circular_stats(F["az"], weights=p)
70
+ az_fg, R_fg = circular_stats(F["az"][e_fg], weights=p[e_fg])
71
+ el_fg = float(np.median(F["el"][e_fg]))
72
+ psi = F["diffuse"]
73
+
74
+ return {
75
+ "duration_min": round(dur / 60, 1),
76
+ "leq_dbfs": round(float(leq), 1),
77
+ "laeq_dbfs": round(float(laeq), 1),
78
+ "leq_minus_laeq_db": round(float(leq - laeq), 1),
79
+ "L10": round(l10, 1), "L50": round(l50, 1), "L90": round(l90, 1),
80
+ "dynamics_L10_L90": round(l10 - l90, 1),
81
+ "events_per_min": round(len(events) / max(dur / 60, 1e-9), 1),
82
+ "event_median_dur_s": round(float(np.median(
83
+ [(e["i1"] - e["i0"] + 1) * dt for e in events])), 2) if events else None,
84
+ "centroid_median_hz": int(np.median(F["centroid"])),
85
+ "flatness_median": round(float(np.median(F["flatness"])), 3),
86
+ "diffuseness_median": round(float(np.median(psi)), 2),
87
+ "diffuseness_iqr": round(float(np.percentile(psi, 75)
88
+ - np.percentile(psi, 25)), 2),
89
+ "azimuth_mean_deg": round(az_mean, 0),
90
+ "azimuth_R": round(R, 2),
91
+ "azimuth_fg_deg": round(az_fg, 0),
92
+ "elevation_fg_median_deg": round(el_fg, 0),
93
+ "n_events": len(events),
94
+ }
95
+
96
+
97
+ def decay_time(x: np.ndarray, fs: int, bands=((250, 500), (500, 1000),
98
+ (1000, 2000), (2000, 4000), (4000, 8000))) -> dict:
99
+ """T60 estimates from an impulse via truncated Schroeder integration.
100
+
101
+ The decay is truncated at the first re-attack (envelope rising >= 8 dB
102
+ above its running minimum) and at the noise floor; a linear fit of
103
+ -5 dB .. max(-35 dB, floor + 8 dB) is extrapolated to 60 dB.
104
+ Returns {band: (T60, dynamic_range_db)}.
105
+ """
106
+ from scipy import signal as sg
107
+ pk_i = int(np.abs(x).argmax())
108
+ env_bb = sg.convolve(x ** 2, np.ones(480) / 480, "same")
109
+ tail = 10 * np.log10(env_bb[pk_i:pk_i + 3 * fs] + 1e-15)
110
+ run_min = np.minimum.accumulate(tail)
111
+ re = np.flatnonzero((tail - run_min > 8) & (np.arange(len(tail)) > fs // 10))
112
+ cut = int(re[0]) if len(re) else 2 * fs
113
+ out = {}
114
+ for lo, hi in bands:
115
+ sos = sg.butter(4, [lo, hi], "bandpass", fs=fs, output="sos")
116
+ y = sg.sosfilt(sos, x)
117
+ env = sg.convolve(y ** 2, np.ones(240) / 240, "same")
118
+ pk = int(env[max(0, pk_i - 2400):pk_i + 2400].argmax()) + max(0, pk_i - 2400)
119
+ if pk < fs // 4:
120
+ continue
121
+ noise = float(np.median(env[:pk - fs // 8]))
122
+ dr = 10 * np.log10(env[pk] / (noise + EPS))
123
+ if dr < 20:
124
+ continue
125
+ seg = np.maximum(y[pk:pk + cut] ** 2 - noise, 0)
126
+ sch = np.cumsum(seg[::-1])[::-1]
127
+ sch_db = 10 * np.log10(sch / (sch[0] + EPS) + 1e-15)
128
+ tax = np.arange(len(sch_db)) / fs
129
+ lo_db = max(-35.0, -dr + 8)
130
+ m = (sch_db <= -5) & (sch_db >= lo_db)
131
+ if m.sum() < 150:
132
+ continue
133
+ A = np.vstack([tax[m], np.ones(int(m.sum()))]).T
134
+ slope, _ = np.linalg.lstsq(A, sch_db[m], rcond=None)[0]
135
+ if slope < 0:
136
+ out[f"{lo}-{hi}"] = (round(-60.0 / slope, 2), round(float(dr), 0))
137
+ return out
138
+
139
+
140
+ def pick_segments(F: dict, n=4, seg_s=600.0) -> list[dict]:
141
+ """Suggest representative windows: quietest, most active, median-typical,
142
+ and (if present) the strongest state transition."""
143
+ t, fast = F["t_fast"], F["fast_db"]
144
+ dt = float(np.median(np.diff(t)))
145
+ win = max(1, int(seg_s / dt))
146
+ if len(fast) < win:
147
+ return [dict(kind="whole", t0=float(t[0]), dur=float(t[-1] - t[0]))]
148
+ k = np.ones(win) / win
149
+ m_lvl = np.convolve(10 ** (fast.astype(np.float64) / 10), k, "valid")
150
+ var = np.convolve((fast - fast.mean()) ** 2, k, "valid")
151
+ picks = []
152
+ for kind, idx in (("quietest", int(np.argmin(m_lvl))),
153
+ ("most_active", int(np.argmax(var))),
154
+ ("typical", int(np.argmin(np.abs(db(m_lvl) - np.median(db(m_lvl))))))):
155
+ picks.append(dict(kind=kind, t0=float(t[idx]), dur=seg_s))
156
+ smooth = median_filter(fast, size=max(3, int(30 / dt)) | 1)
157
+ jump = np.abs(np.diff(smooth))
158
+ if jump.max() > 6:
159
+ picks.append(dict(kind="transition",
160
+ t0=float(max(t[0], t[int(np.argmax(jump))] - seg_s / 2)),
161
+ dur=seg_s))
162
+ return picks[:n]
@@ -0,0 +1,170 @@
1
+ """Command-line interface.
2
+
3
+ ambiscape probe <session-folder> # metadata only
4
+ ambiscape analyze <session-folder> [-o DIR] # features + figures + README
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import argparse
9
+ import json
10
+ import sys
11
+ from pathlib import Path
12
+
13
+
14
+ def main(argv=None):
15
+ ap = argparse.ArgumentParser(prog="ambiscape",
16
+ description="Long-duration ambisonic soundscape analysis")
17
+ sub = ap.add_subparsers(dest="cmd", required=True)
18
+ p = sub.add_parser("probe", help="show session metadata")
19
+ p.add_argument("folder")
20
+ a = sub.add_parser("analyze", help="extract features, figures, README")
21
+ a.add_argument("folder")
22
+ a.add_argument("-o", "--out", default=None,
23
+ help="output dir (default <folder>/analysis)")
24
+ a.add_argument("--notes", default="", help="free-text session notes for README")
25
+ tx = sub.add_parser("taxonomy",
26
+ help="render Schaeffer map + Schafer timeline from "
27
+ "<folder>/annotations.json")
28
+ tx.add_argument("folder")
29
+ tx.add_argument("-o", "--out", default=None)
30
+ dr = sub.add_parser("draft",
31
+ help="pre-fill annotations.draft.json from detected "
32
+ "states and events (needs a prior analyze run)")
33
+ dr.add_argument("folder")
34
+ dp = sub.add_parser("deposit",
35
+ help="export non-identifying 1 Hz feature TSVs "
36
+ "(StillStanding365 schema) to <folder>/deposit/")
37
+ dp.add_argument("folder")
38
+ sg = sub.add_parser("speechgate",
39
+ help="silero-vad privacy check: fraction of speech "
40
+ "in WAV file(s) before publishing")
41
+ sg.add_argument("path", help="a WAV file or a folder of WAVs")
42
+ sg.add_argument("--threshold", type=float, default=0.01,
43
+ help="max allowed speech fraction (default 0.01)")
44
+ iso_p = sub.add_parser("iso",
45
+ help="ISO 12913-3 psychoacoustic indicators "
46
+ "(MoSQITo) on representative segments")
47
+ iso_p.add_argument("folder")
48
+ iso_p.add_argument("--dur", type=float, default=30.0,
49
+ help="seconds per segment (default 30)")
50
+ iso_p.add_argument("--offset", type=float, default=None,
51
+ help="dBFS->dB SPL offset override")
52
+ args = ap.parse_args(argv)
53
+
54
+ if args.cmd == "deposit":
55
+ from .deposit import export_session
56
+ outs = export_session(args.folder)
57
+ if not outs:
58
+ print("no cached features — run 'ambiscape analyze' first")
59
+ return 1
60
+ for o in outs:
61
+ print(f"wrote {o}")
62
+ return 0
63
+
64
+ if args.cmd == "speechgate":
65
+ from .ml import speech_gate
66
+ p = Path(args.path)
67
+ files = sorted(p.glob("*.wav")) + sorted(p.glob("*.WAV")) \
68
+ if p.is_dir() else [p]
69
+ ok = True
70
+ for f in files:
71
+ r = speech_gate(f, threshold=args.threshold)
72
+ verdict = "PASS" if r["passes"] else "FAIL"
73
+ ok &= r["passes"]
74
+ extra = ("" if r["passes"] else
75
+ f" (first speech at {r['first_speech_at_s']}s)")
76
+ print(f" {verdict} {f.name}: {r['speech_fraction']*100:.2f}% "
77
+ f"speech, {r['n_speech_segments']} segment(s){extra}")
78
+ return 0 if ok else 2
79
+
80
+ if args.cmd == "iso":
81
+ from .features import load_features
82
+ from .io import open_session
83
+ from . import iso as iso_mod
84
+ sess = open_session(args.folder)
85
+ fdir = Path(args.folder) / "analysis" / "features"
86
+ paths = sorted(fdir.glob("*.npz"))
87
+ if not paths:
88
+ print(f"no cached features in {fdir} — run 'ambiscape analyze' first")
89
+ return 1
90
+ F = load_features(paths)
91
+ res = iso_mod.segment_indicators(sess, F, args.folder,
92
+ dur=args.dur, offset=args.offset)
93
+ out = Path(args.folder) / "analysis" / "iso_indicators.json"
94
+ out.write_text(json.dumps(res, indent=2))
95
+ if not res["calibrated"]:
96
+ print("WARNING:", res["warning"])
97
+ for kind, seg in res["segments"].items():
98
+ print(f" {kind} @ {seg['t0']}: N5 {seg['N5_sone_max_ear']} sone "
99
+ f"(max ear), sharpness {seg['left']['sharpness_median_acum']}"
100
+ f"/{seg['right']['sharpness_median_acum']} acum "
101
+ f"[{seg['binaural_method']}]")
102
+ print(f"wrote {out}")
103
+ return 0
104
+
105
+ if args.cmd == "draft":
106
+ from .features import load_features
107
+ from .io import open_session
108
+ from .draft import draft_annotations
109
+ fdir = Path(args.folder) / "analysis" / "features"
110
+ paths = sorted(fdir.glob("*.npz"))
111
+ if not paths:
112
+ print(f"no cached features in {fdir} — run 'ambiscape analyze' first")
113
+ return 1
114
+ F = load_features(paths)
115
+ try:
116
+ sess = open_session(args.folder)
117
+ except (FileNotFoundError, ValueError):
118
+ sess = None
119
+ out = draft_annotations(F, args.folder, session=sess)
120
+ doc = json.loads(out.read_text())
121
+ n_obj = len(doc["objects"])
122
+ n_tag = sum(1 for o in doc["objects"] for h in o.get("_hints", [{}])
123
+ if "tags" in h) + sum(1 for o in doc["objects"]
124
+ if "_tags" in o)
125
+ print(f"wrote {out} ({n_obj} draft objects, {n_tag} PANNs-tagged "
126
+ f"windows) — edit, save as annotations.json, then run "
127
+ f"'ambiscape taxonomy'")
128
+ return 0
129
+
130
+ if args.cmd == "taxonomy":
131
+ from .taxonomy import render
132
+ paths = render(args.folder, out_dir=args.out)
133
+ for p in paths:
134
+ print(f"wrote {p}")
135
+ return 0
136
+
137
+ from .io import open_session
138
+ sess = open_session(args.folder)
139
+
140
+ if args.cmd == "probe":
141
+ print(f"session {sess.name}: {len(sess.takes)} take(s), "
142
+ f"{sess.duration/60:.1f} min total")
143
+ for tk in sess.takes:
144
+ print(f" {tk.path.name}: {tk.date} {tk.clock}, "
145
+ f"{tk.duration/60:.1f} min, {tk.channels}ch @{tk.samplerate}")
146
+ return 0
147
+
148
+ from . import analysis, features, figures, report
149
+ out = Path(args.out) if args.out else sess.folder / "analysis"
150
+ out.mkdir(parents=True, exist_ok=True)
151
+ print(f"analyzing {sess.name} ({sess.duration/60:.1f} min)")
152
+ paths = features.extract_session(sess, out / "features")
153
+ F = features.load_features(paths)
154
+ summary = analysis.summarize(F)
155
+ from .iso import load_calibration, apply_calibration
156
+ cal = load_calibration(sess.folder)
157
+ if cal:
158
+ summary = apply_calibration(summary, cal)
159
+ figures.overview(F, out / "overview.png", title=sess.name, clock=sess.clock)
160
+ figures.ltas_percentiles(F, out / "ltas_percentiles.png", title=sess.name)
161
+ figures.directogram(F, out / "directogram.png", title=sess.name)
162
+ report.write_readme(sess, summary, out, notes=args.notes)
163
+ for k, v in summary.items():
164
+ print(f" {k}: {v}")
165
+ print(f"wrote {out} and {sess.folder/'README.md'}")
166
+ return 0
167
+
168
+
169
+ if __name__ == "__main__":
170
+ sys.exit(main())
@@ -0,0 +1,52 @@
1
+ """Non-identifying feature export in the StillStanding365 deposit schema.
2
+
3
+ Writes one TSV per take with the columns used by the StillStanding365 Zenodo
4
+ deposit (``audio/{day}.tsv``): per-second ``Time``, ``level_dbfs``,
5
+ ``centroid_hz``, ``low_frac`` (< 250 Hz), ``high_frac`` (> 2 kHz). A 1 Hz
6
+ loudness/spectral envelope is far below speech timescales and carries no
7
+ intelligible content, so these files are safe to publish where raw audio is
8
+ not.
9
+
10
+ Method notes vs. the original ``extract_audio.py``: levels here come from the
11
+ W (omni) channel at native rate (the original used an ffmpeg 4-channel
12
+ downmix at 8 kHz — offsets of a few tenths of a dB are expected), and band
13
+ fractions are power fractions from the cached log-spectrogram (the original
14
+ used magnitude fractions of an 8 kHz FFT). Trends and dynamics are directly
15
+ comparable; absolute fraction values differ slightly by construction.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ from pathlib import Path
20
+
21
+ import numpy as np
22
+
23
+ from .analysis import db
24
+
25
+
26
+ def export_take_tsv(npz_path: str | Path, out_dir: str | Path) -> Path:
27
+ p = np.load(str(npz_path))
28
+ logf = p["logf"]
29
+ fc = np.sqrt(logf[:-1] * logf[1:])
30
+ S = p["logspec"]
31
+ tot = S.sum(1) + 1e-20
32
+ low = S[:, fc < 250].sum(1) / tot
33
+ high = S[:, fc > 2000].sum(1) / tot
34
+ level = db(p["rms_w"].astype(np.float64) ** 2)
35
+ out_dir = Path(out_dir)
36
+ out_dir.mkdir(parents=True, exist_ok=True)
37
+ out = out_dir / (Path(npz_path).stem + ".tsv")
38
+ with open(out, "w") as f:
39
+ f.write("Time\tlevel_dbfs\tcentroid_hz\tlow_frac\thigh_frac\n")
40
+ for i in range(len(level)):
41
+ f.write(f"{i}\t{level[i]:.1f}\t{p['centroid'][i]:.0f}\t"
42
+ f"{low[i]:.3f}\t{high[i]:.3f}\n")
43
+ return out
44
+
45
+
46
+ def export_session(folder: str | Path) -> list[Path]:
47
+ folder = Path(folder)
48
+ fdir = folder / "analysis" / "features"
49
+ outs = []
50
+ for npz in sorted(fdir.glob("*.npz")):
51
+ outs.append(export_take_tsv(npz, folder / "deposit"))
52
+ return outs