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.
- ambiscape-0.1.0/LICENSE +21 -0
- ambiscape-0.1.0/PKG-INFO +96 -0
- ambiscape-0.1.0/README.md +62 -0
- ambiscape-0.1.0/pyproject.toml +44 -0
- ambiscape-0.1.0/setup.cfg +4 -0
- ambiscape-0.1.0/src/ambiscape/__init__.py +20 -0
- ambiscape-0.1.0/src/ambiscape/analysis.py +162 -0
- ambiscape-0.1.0/src/ambiscape/cli.py +170 -0
- ambiscape-0.1.0/src/ambiscape/deposit.py +52 -0
- ambiscape-0.1.0/src/ambiscape/draft.py +177 -0
- ambiscape-0.1.0/src/ambiscape/features.py +203 -0
- ambiscape-0.1.0/src/ambiscape/figures.py +190 -0
- ambiscape-0.1.0/src/ambiscape/io.py +136 -0
- ambiscape-0.1.0/src/ambiscape/iso.py +137 -0
- ambiscape-0.1.0/src/ambiscape/ml.py +84 -0
- ambiscape-0.1.0/src/ambiscape/report.py +59 -0
- ambiscape-0.1.0/src/ambiscape/taxonomy.py +266 -0
- ambiscape-0.1.0/src/ambiscape.egg-info/PKG-INFO +96 -0
- ambiscape-0.1.0/src/ambiscape.egg-info/SOURCES.txt +21 -0
- ambiscape-0.1.0/src/ambiscape.egg-info/dependency_links.txt +1 -0
- ambiscape-0.1.0/src/ambiscape.egg-info/entry_points.txt +2 -0
- ambiscape-0.1.0/src/ambiscape.egg-info/requires.txt +18 -0
- ambiscape-0.1.0/src/ambiscape.egg-info/top_level.txt +1 -0
ambiscape-0.1.0/LICENSE
ADDED
|
@@ -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.
|
ambiscape-0.1.0/PKG-INFO
ADDED
|
@@ -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,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
|