dopplertrack 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 MacCallister Higgins
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,149 @@
1
+ Metadata-Version: 2.4
2
+ Name: dopplertrack
3
+ Version: 0.1.0
4
+ Summary: Acoustic geolocation from inaudible aircraft Doppler curves matched against ADS-B flight tracks
5
+ Keywords: doppler,ads-b,geolocation,acoustics,osint,spectrogram
6
+ Author: MacCallister Higgins
7
+ Author-email: MacCallister Higgins <maccallister.higgins@gmail.com>
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Science/Research
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Topic :: Scientific/Engineering
14
+ Requires-Dist: matplotlib>=3.11.2
15
+ Requires-Dist: numpy>=2.5.4
16
+ Requires-Dist: pandas>=3.1.0
17
+ Requires-Dist: pillow>=13.0.0
18
+ Requires-Dist: requests>=2.34.2
19
+ Requires-Dist: scipy>=1.18.1
20
+ Requires-Dist: soundfile>=0.14.0
21
+ Requires-Python: >=3.12
22
+ Project-URL: Homepage, https://github.com/mjshiggins/dopplertrack
23
+ Project-URL: Issues, https://github.com/mjshiggins/dopplertrack/issues
24
+ Description-Content-Type: text/markdown
25
+
26
+ # dopplertrack
27
+
28
+ Acoustic geolocation from the Doppler curves of propeller aircraft.
29
+
30
+ Any outdoor (and many indoor) recordings contain faint, usually inaudible
31
+ tones from aircraft flying over: the propeller blade-pass fundamental and its
32
+ harmonics, between roughly 60 and 400 Hz. As the aircraft passes, the tone
33
+ sweeps down in frequency. The exact shape of that sweep depends on where the
34
+ listener was relative to the aircraft's real trajectory. With historical
35
+ ADS-B data for every flight that day, each candidate location on the ground
36
+ predicts a different curve, and the recording picks out the one that matches.
37
+
38
+ This is a reimplementation of the technique shown in colsto's video
39
+ ["I discovered a sound that leaks your exact location"](https://www.youtube.com/watch?v=UytYrhYLOKI).
40
+ No code was released with that video; this project is an open, automatic
41
+ version of the method. See `docs/superpowers/specs/` for the design.
42
+
43
+ ## Status
44
+
45
+ Proof of concept. Validated against the author's reference recording
46
+ (known answer: Oyster Bay, NY, 2026-09-08). See the results section below.
47
+
48
+ ## Pipeline
49
+
50
+ 1. `spectrogram` — decode any audio/video with ffmpeg, high-resolution STFT of
51
+ the 40–450 Hz band, whitened per frequency bin so only *changing* tones stand out.
52
+ 2. `fit` — fit an ideal straight-pass Doppler curve (f0, speed, CPA slant
53
+ distance, CPA time) to a time/frequency window. The fit seeds a search
54
+ with coarse parameter ranges.
55
+ 3. `extract-region` — pull every flight that touched a bounding box out of an
56
+ [adsb.lol](https://github.com/adsblol/globe_history_2026) daily archive and
57
+ drop known jets using ICAO Doc 8643 engine classes.
58
+ 4. `search` — for every grid point (200 m) and every flight, compute the
59
+ closest point of approach (CPA), its slant distance (using a DEM for the
60
+ observer's elevation) and the *heard* CPA time (propagation delay). Keep
61
+ points that hear an event-1-like pass, then exactly Δt later an
62
+ event-2-like pass, with no other prop flight in between. Model the exact
63
+ Doppler curve each survivor would hear from the real trajectory
64
+ (emission-time radial velocity) and score it against the spectrogram.
65
+ Event 1's placement absorbs measurement error; event 2's placement is
66
+ locked to the flight geometry.
67
+
68
+ ## Usage
69
+
70
+ ```bash
71
+ uv sync
72
+ uv run dopplertrack spectrogram recording.mp4 rec.npz
73
+ uv run dopplertrack fit rec.npz --window 2090 2200 --band 95 200 --harmonics 2 --out ev1.json
74
+ uv run dopplertrack fit rec.npz --window 3020 3130 --band 95 195 --out ev2.json
75
+ uv run dopplertrack extract-region /path/to/adsb-day --bbox 40.4 41.3 -74.4 -72.6 traces_ny.npz
76
+ uv run dopplertrack search rec.npz --events ev1.json ev2.json --traces traces_ny.npz \
77
+ --bbox 40.4 41.3 -74.4 -72.6 --spacing 200 --out results.csv
78
+ ```
79
+
80
+ `search --point LAT LON` scores a single known location instead of a grid,
81
+ which is the quickest way to validate the data and physics on a recording
82
+ whose location you already know.
83
+
84
+ ## Changes from the method in the video
85
+
86
+ The physics and the search strategy are the author's. What differs:
87
+
88
+ - **Automatic curve measurement.** The video measures each Doppler curve by
89
+ hand, sliding f0 / speed / distance / CPA-time until the overlay looks
90
+ right, then choosing "generous ranges" by eye. `fit` does this without a
91
+ person: the window is whitened per frequency bin so stationary tones
92
+ (mains hum, machinery) vanish, a vectorised grid search over all four
93
+ parameters maximises spectrogram brightness under the curve, and a
94
+ Nelder-Mead pass refines it. The ideal-curve fit still only seeds coarse
95
+ filters; final ranking uses real trajectories.
96
+ - **Harmonic-aware fitting and scoring.** When a flyover shows a second
97
+ harmonic, fundamental and harmonic are scored together, which constrains
98
+ the curve more tightly than one line.
99
+ - **Open, documented data path.** The video used a paid/scraped ADS-B feed
100
+ and did not release code. Here every input is a public dataset with a
101
+ loader: adsb.lol daily archives, ICAO Doc 8643 engine classes, AWS
102
+ terrain tiles.
103
+ - **Search engineering.** A KD-tree corridor around each flight path limits
104
+ CPA computation to grid points that could possibly hear it; distance math
105
+ is chunked so multi-hour tracks do not exhaust memory; the time-shift and
106
+ f0 sweeps of the locked scorer run as one array operation per candidate.
107
+ - **Single-event, known-time mode as a first-class command.** The video
108
+ demonstrates locating from one faint one-minute curve when the recording
109
+ time is known; `search-single` exposes that directly.
110
+
111
+ Kept unchanged on purpose: emission-time radial velocity (not
112
+ observation-time), propagation-delayed CPA times, slant distance with a DEM,
113
+ the inter-event offset filter, the silence filter, and locked two-event
114
+ scoring where event 1 absorbs measurement error and event 2 is placed purely
115
+ by flight geometry.
116
+
117
+ ## Data sources (all free)
118
+
119
+ | Need | Source |
120
+ |---|---|
121
+ | Historical ADS-B | adsb.lol `globe_history_YYYY` GitHub releases (~4 GB/day) |
122
+ | Aircraft engine class | OpenSky mirror of ICAO Doc 8643 |
123
+ | Elevation | AWS Terrain Tiles (Terrarium) |
124
+
125
+ ## Development
126
+
127
+ ```bash
128
+ uv run pytest
129
+ ```
130
+
131
+ ## Releasing
132
+
133
+ Releases are driven by git tags. Pushing a tag `vX.Y.Z` that matches the
134
+ version in `pyproject.toml` runs `.github/workflows/release.yml`, which runs
135
+ the tests, builds the sdist and wheel, attaches them to a GitHub Release with
136
+ generated notes, and publishes to PyPI through trusted publishing (no API
137
+ token stored anywhere).
138
+
139
+ ```bash
140
+ uv version 0.2.0 # bump pyproject.toml
141
+ git commit -am "Release 0.2.0"
142
+ git tag v0.2.0 && git push && git push --tags
143
+ ```
144
+
145
+ One-time setup for PyPI: on pypi.org, add a *pending publisher* for project
146
+ `dopplertrack` with owner `mjshiggins`, repository `dopplertrack`, workflow
147
+ `release.yml`, environment `pypi`, and create a `pypi` environment in the
148
+ GitHub repository settings. Until that exists the `pypi` job fails and the
149
+ GitHub Release still succeeds.
@@ -0,0 +1,124 @@
1
+ # dopplertrack
2
+
3
+ Acoustic geolocation from the Doppler curves of propeller aircraft.
4
+
5
+ Any outdoor (and many indoor) recordings contain faint, usually inaudible
6
+ tones from aircraft flying over: the propeller blade-pass fundamental and its
7
+ harmonics, between roughly 60 and 400 Hz. As the aircraft passes, the tone
8
+ sweeps down in frequency. The exact shape of that sweep depends on where the
9
+ listener was relative to the aircraft's real trajectory. With historical
10
+ ADS-B data for every flight that day, each candidate location on the ground
11
+ predicts a different curve, and the recording picks out the one that matches.
12
+
13
+ This is a reimplementation of the technique shown in colsto's video
14
+ ["I discovered a sound that leaks your exact location"](https://www.youtube.com/watch?v=UytYrhYLOKI).
15
+ No code was released with that video; this project is an open, automatic
16
+ version of the method. See `docs/superpowers/specs/` for the design.
17
+
18
+ ## Status
19
+
20
+ Proof of concept. Validated against the author's reference recording
21
+ (known answer: Oyster Bay, NY, 2026-09-08). See the results section below.
22
+
23
+ ## Pipeline
24
+
25
+ 1. `spectrogram` — decode any audio/video with ffmpeg, high-resolution STFT of
26
+ the 40–450 Hz band, whitened per frequency bin so only *changing* tones stand out.
27
+ 2. `fit` — fit an ideal straight-pass Doppler curve (f0, speed, CPA slant
28
+ distance, CPA time) to a time/frequency window. The fit seeds a search
29
+ with coarse parameter ranges.
30
+ 3. `extract-region` — pull every flight that touched a bounding box out of an
31
+ [adsb.lol](https://github.com/adsblol/globe_history_2026) daily archive and
32
+ drop known jets using ICAO Doc 8643 engine classes.
33
+ 4. `search` — for every grid point (200 m) and every flight, compute the
34
+ closest point of approach (CPA), its slant distance (using a DEM for the
35
+ observer's elevation) and the *heard* CPA time (propagation delay). Keep
36
+ points that hear an event-1-like pass, then exactly Δt later an
37
+ event-2-like pass, with no other prop flight in between. Model the exact
38
+ Doppler curve each survivor would hear from the real trajectory
39
+ (emission-time radial velocity) and score it against the spectrogram.
40
+ Event 1's placement absorbs measurement error; event 2's placement is
41
+ locked to the flight geometry.
42
+
43
+ ## Usage
44
+
45
+ ```bash
46
+ uv sync
47
+ uv run dopplertrack spectrogram recording.mp4 rec.npz
48
+ uv run dopplertrack fit rec.npz --window 2090 2200 --band 95 200 --harmonics 2 --out ev1.json
49
+ uv run dopplertrack fit rec.npz --window 3020 3130 --band 95 195 --out ev2.json
50
+ uv run dopplertrack extract-region /path/to/adsb-day --bbox 40.4 41.3 -74.4 -72.6 traces_ny.npz
51
+ uv run dopplertrack search rec.npz --events ev1.json ev2.json --traces traces_ny.npz \
52
+ --bbox 40.4 41.3 -74.4 -72.6 --spacing 200 --out results.csv
53
+ ```
54
+
55
+ `search --point LAT LON` scores a single known location instead of a grid,
56
+ which is the quickest way to validate the data and physics on a recording
57
+ whose location you already know.
58
+
59
+ ## Changes from the method in the video
60
+
61
+ The physics and the search strategy are the author's. What differs:
62
+
63
+ - **Automatic curve measurement.** The video measures each Doppler curve by
64
+ hand, sliding f0 / speed / distance / CPA-time until the overlay looks
65
+ right, then choosing "generous ranges" by eye. `fit` does this without a
66
+ person: the window is whitened per frequency bin so stationary tones
67
+ (mains hum, machinery) vanish, a vectorised grid search over all four
68
+ parameters maximises spectrogram brightness under the curve, and a
69
+ Nelder-Mead pass refines it. The ideal-curve fit still only seeds coarse
70
+ filters; final ranking uses real trajectories.
71
+ - **Harmonic-aware fitting and scoring.** When a flyover shows a second
72
+ harmonic, fundamental and harmonic are scored together, which constrains
73
+ the curve more tightly than one line.
74
+ - **Open, documented data path.** The video used a paid/scraped ADS-B feed
75
+ and did not release code. Here every input is a public dataset with a
76
+ loader: adsb.lol daily archives, ICAO Doc 8643 engine classes, AWS
77
+ terrain tiles.
78
+ - **Search engineering.** A KD-tree corridor around each flight path limits
79
+ CPA computation to grid points that could possibly hear it; distance math
80
+ is chunked so multi-hour tracks do not exhaust memory; the time-shift and
81
+ f0 sweeps of the locked scorer run as one array operation per candidate.
82
+ - **Single-event, known-time mode as a first-class command.** The video
83
+ demonstrates locating from one faint one-minute curve when the recording
84
+ time is known; `search-single` exposes that directly.
85
+
86
+ Kept unchanged on purpose: emission-time radial velocity (not
87
+ observation-time), propagation-delayed CPA times, slant distance with a DEM,
88
+ the inter-event offset filter, the silence filter, and locked two-event
89
+ scoring where event 1 absorbs measurement error and event 2 is placed purely
90
+ by flight geometry.
91
+
92
+ ## Data sources (all free)
93
+
94
+ | Need | Source |
95
+ |---|---|
96
+ | Historical ADS-B | adsb.lol `globe_history_YYYY` GitHub releases (~4 GB/day) |
97
+ | Aircraft engine class | OpenSky mirror of ICAO Doc 8643 |
98
+ | Elevation | AWS Terrain Tiles (Terrarium) |
99
+
100
+ ## Development
101
+
102
+ ```bash
103
+ uv run pytest
104
+ ```
105
+
106
+ ## Releasing
107
+
108
+ Releases are driven by git tags. Pushing a tag `vX.Y.Z` that matches the
109
+ version in `pyproject.toml` runs `.github/workflows/release.yml`, which runs
110
+ the tests, builds the sdist and wheel, attaches them to a GitHub Release with
111
+ generated notes, and publishes to PyPI through trusted publishing (no API
112
+ token stored anywhere).
113
+
114
+ ```bash
115
+ uv version 0.2.0 # bump pyproject.toml
116
+ git commit -am "Release 0.2.0"
117
+ git tag v0.2.0 && git push && git push --tags
118
+ ```
119
+
120
+ One-time setup for PyPI: on pypi.org, add a *pending publisher* for project
121
+ `dopplertrack` with owner `mjshiggins`, repository `dopplertrack`, workflow
122
+ `release.yml`, environment `pypi`, and create a `pypi` environment in the
123
+ GitHub repository settings. Until that exists the `pypi` job fails and the
124
+ GitHub Release still succeeds.
@@ -0,0 +1,44 @@
1
+ [project]
2
+ name = "dopplertrack"
3
+ version = "0.1.0"
4
+ description = "Acoustic geolocation from inaudible aircraft Doppler curves matched against ADS-B flight tracks"
5
+ readme = "README.md"
6
+ authors = [
7
+ { name = "MacCallister Higgins", email = "maccallister.higgins@gmail.com" }
8
+ ]
9
+ license = "MIT"
10
+ license-files = ["LICENSE"]
11
+ requires-python = ">=3.12"
12
+ keywords = ["doppler", "ads-b", "geolocation", "acoustics", "osint", "spectrogram"]
13
+ classifiers = [
14
+ "Development Status :: 3 - Alpha",
15
+ "Intended Audience :: Science/Research",
16
+ "Programming Language :: Python :: 3",
17
+ "Topic :: Scientific/Engineering",
18
+ ]
19
+
20
+ dependencies = [
21
+ "matplotlib>=3.11.2",
22
+ "numpy>=2.5.4",
23
+ "pandas>=3.1.0",
24
+ "pillow>=13.0.0",
25
+ "requests>=2.34.2",
26
+ "scipy>=1.18.1",
27
+ "soundfile>=0.14.0",
28
+ ]
29
+
30
+ [project.urls]
31
+ Homepage = "https://github.com/mjshiggins/dopplertrack"
32
+ Issues = "https://github.com/mjshiggins/dopplertrack/issues"
33
+
34
+ [project.scripts]
35
+ dopplertrack = "dopplertrack.cli:main"
36
+
37
+ [build-system]
38
+ requires = ["uv_build>=0.8.3,<0.9.0"]
39
+ build-backend = "uv_build"
40
+
41
+ [dependency-groups]
42
+ dev = [
43
+ "pytest>=9.2.0",
44
+ ]
@@ -0,0 +1,2 @@
1
+ def hello() -> str:
2
+ return "Hello from dopplertrack!"
@@ -0,0 +1,161 @@
1
+ """Read adsb.lol / readsb `globe_history` daily archives and classify aircraft."""
2
+ from __future__ import annotations
3
+
4
+ import csv
5
+ import gzip
6
+ import io
7
+ import json
8
+ import os
9
+ from concurrent.futures import ProcessPoolExecutor
10
+ from dataclasses import dataclass
11
+ from pathlib import Path
12
+
13
+ import numpy as np
14
+ import requests
15
+
16
+ from .geo import lla_to_enu
17
+
18
+ FT = 0.3048
19
+ KT = 0.514444
20
+ _DOC8643_URL = "https://opensky-network.org/datasets/metadata/doc8643AircraftTypes.csv"
21
+
22
+
23
+ def _cache_dir() -> Path:
24
+ p = Path(os.environ.get("DOPPLERTRACK_CACHE", Path.home() / ".cache" / "dopplertrack"))
25
+ p.mkdir(parents=True, exist_ok=True)
26
+ return p
27
+
28
+
29
+ class AircraftTypes:
30
+ """ICAO Doc 8643 designator -> engine class ('Piston', 'Turboprop', 'Jet', ...)."""
31
+
32
+ def __init__(self, path: str | Path | None = None):
33
+ p = Path(path) if path else _cache_dir() / "doc8643AircraftTypes.csv"
34
+ if not p.exists():
35
+ p.write_bytes(requests.get(_DOC8643_URL, timeout=120).content)
36
+ self.types: dict[str, dict] = {}
37
+ with open(p, newline="", encoding="utf-8") as fh:
38
+ for row in csv.DictReader(fh):
39
+ self.types.setdefault(row["Designator"], row)
40
+
41
+ def engine(self, designator: str | None) -> str | None:
42
+ r = self.types.get(designator or "")
43
+ return r["EngineType"] if r else None
44
+
45
+ def is_prop(self, designator: str | None, unknown_ok: bool = True) -> bool:
46
+ e = self.engine(designator)
47
+ if e is None:
48
+ return unknown_ok
49
+ return any(k in e for k in ("Piston", "Turboprop", "Electric"))
50
+
51
+
52
+ @dataclass
53
+ class Trace:
54
+ icao: str
55
+ reg: str | None
56
+ typ: str | None
57
+ desc: str | None
58
+ t: np.ndarray # unix seconds
59
+ lat: np.ndarray
60
+ lon: np.ndarray
61
+ alt: np.ndarray # metres MSL (geometric when available, else barometric)
62
+ gs: np.ndarray # m/s (nan if missing)
63
+ on_ground: np.ndarray
64
+
65
+ def __len__(self):
66
+ return len(self.t)
67
+
68
+ def segments(self, max_gap_s: float = 90.0) -> list[slice]:
69
+ """Index slices of contiguous airborne data."""
70
+ out = []
71
+ if len(self.t) == 0:
72
+ return out
73
+ brk = np.where((np.diff(self.t) > max_gap_s) | (self.on_ground[1:] != self.on_ground[:-1]))[0] + 1
74
+ starts = np.r_[0, brk]; ends = np.r_[brk, len(self.t)]
75
+ for s, e in zip(starts, ends):
76
+ if e - s >= 2 and not self.on_ground[s]:
77
+ out.append(slice(s, e))
78
+ return out
79
+
80
+ def resample(self, sl: slice, dt: float = 1.0):
81
+ """Uniformly resampled (t, lat, lon, alt) over one segment."""
82
+ t = self.t[sl]
83
+ tq = np.arange(t[0], t[-1], dt)
84
+ return (tq, np.interp(tq, t, self.lat[sl]), np.interp(tq, t, self.lon[sl]), np.interp(tq, t, self.alt[sl]))
85
+
86
+ def enu(self, sl: slice, lat0: float, lon0: float, dt: float = 1.0):
87
+ tq, la, lo, al = self.resample(sl, dt)
88
+ return tq, lla_to_enu(la, lo, al, lat0, lon0)
89
+
90
+
91
+ def parse_trace_file(path: str | Path) -> Trace | None:
92
+ raw = Path(path).read_bytes()
93
+ if raw[:2] == b"\x1f\x8b":
94
+ raw = gzip.decompress(raw)
95
+ d = json.loads(raw)
96
+ base = float(d.get("timestamp", 0.0))
97
+ rows = d.get("trace") or []
98
+ t, lat, lon, alt, gs, grd = [], [], [], [], [], []
99
+ for r in rows:
100
+ if r[1] is None or r[2] is None:
101
+ continue
102
+ a = r[3]
103
+ on_ground = a == "ground"
104
+ geom = r[10] if len(r) > 10 else None
105
+ if isinstance(geom, (int, float)):
106
+ am = geom * FT
107
+ elif isinstance(a, (int, float)):
108
+ am = a * FT
109
+ elif on_ground:
110
+ am = np.nan
111
+ else:
112
+ continue
113
+ t.append(base + r[0]); lat.append(r[1]); lon.append(r[2]); alt.append(am)
114
+ gs.append(r[4] * KT if isinstance(r[4], (int, float)) else np.nan); grd.append(on_ground)
115
+ if not t:
116
+ return None
117
+ return Trace(d.get("icao", Path(path).stem.split("_")[-1]), d.get("r"), d.get("t"), d.get("desc"),
118
+ np.array(t), np.array(lat), np.array(lon), np.array(alt), np.array(gs), np.array(grd))
119
+
120
+
121
+ def _scan_one(args):
122
+ path, bbox = args
123
+ try:
124
+ tr = parse_trace_file(path)
125
+ except Exception:
126
+ return None
127
+ if tr is None:
128
+ return None
129
+ lat0, lat1, lon0, lon1 = bbox
130
+ m = (tr.lat >= lat0) & (tr.lat <= lat1) & (tr.lon >= lon0) & (tr.lon <= lon1)
131
+ if not m.any():
132
+ return None
133
+ return tr
134
+
135
+
136
+ def load_region(archive_dir: str | Path, bbox: tuple[float, float, float, float],
137
+ workers: int | None = None) -> list[Trace]:
138
+ """All traces with at least one point inside bbox=(lat_min, lat_max, lon_min, lon_max)."""
139
+ files = sorted(Path(archive_dir).glob("traces/*/trace_full_*.json"))
140
+ out = []
141
+ with ProcessPoolExecutor(workers or os.cpu_count()) as ex:
142
+ for tr in ex.map(_scan_one, ((str(f), bbox) for f in files), chunksize=256):
143
+ if tr is not None:
144
+ out.append(tr)
145
+ return out
146
+
147
+
148
+ def save_traces(traces: list[Trace], path: str | Path):
149
+ np.savez_compressed(path, n=len(traces),
150
+ meta=np.array([json.dumps([t.icao, t.reg, t.typ, t.desc]) for t in traces]),
151
+ **{f"a{i}": np.stack([t.t, t.lat, t.lon, t.alt, t.gs, t.on_ground.astype(float)]) for i, t in enumerate(traces)})
152
+
153
+
154
+ def load_traces(path: str | Path) -> list[Trace]:
155
+ z = np.load(path, allow_pickle=False)
156
+ out = []
157
+ for i in range(int(z["n"])):
158
+ icao, reg, typ, desc = json.loads(str(z["meta"][i]))
159
+ a = z[f"a{i}"]
160
+ out.append(Trace(icao, reg, typ, desc, a[0], a[1], a[2], a[3], a[4], a[5].astype(bool)))
161
+ return out
@@ -0,0 +1,101 @@
1
+ """Audio decoding and whitened low-band spectrograms."""
2
+ from __future__ import annotations
3
+
4
+ import subprocess
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+
8
+ import numpy as np
9
+ import scipy.signal as ss
10
+
11
+
12
+ def load_audio(path: str | Path, sr: int = 4000) -> tuple[np.ndarray, int]:
13
+ """Decode any ffmpeg-readable file to mono float32 at `sr` Hz."""
14
+ cmd = ["ffmpeg", "-v", "error", "-i", str(path), "-ac", "1", "-ar", str(sr),
15
+ "-f", "f32le", "-acodec", "pcm_f32le", "-"]
16
+ raw = subprocess.run(cmd, check=True, capture_output=True).stdout
17
+ return np.frombuffer(raw, dtype=np.float32).copy(), sr
18
+
19
+
20
+ @dataclass
21
+ class Spectrogram:
22
+ t: np.ndarray # (T,) centre time of each frame, s
23
+ f: np.ndarray # (F,) bin frequencies, Hz
24
+ S: np.ndarray # (F, T) whitened power, dB above per-bin median
25
+
26
+ @property
27
+ def dt(self) -> float:
28
+ return float(self.t[1] - self.t[0])
29
+
30
+ @property
31
+ def df(self) -> float:
32
+ return float(self.f[1] - self.f[0])
33
+
34
+ def sample(self, t, f, fill=0.0):
35
+ """Bilinear sample at (t, f) pairs (arrays); outside the grid -> fill."""
36
+ t = np.asarray(t, float); f = np.asarray(f, float)
37
+ ti = (t - self.t[0]) / self.dt
38
+ fi = (f - self.f[0]) / self.df
39
+ ok = (ti >= 0) & (ti <= len(self.t) - 1) & (fi >= 0) & (fi <= len(self.f) - 1) & np.isfinite(ti) & np.isfinite(fi)
40
+ out = np.full(t.shape, fill, float)
41
+ ti = ti[ok]; fi = fi[ok]
42
+ i0 = np.floor(ti).astype(int); j0 = np.floor(fi).astype(int)
43
+ i1 = np.minimum(i0 + 1, len(self.t) - 1); j1 = np.minimum(j0 + 1, len(self.f) - 1)
44
+ wt = ti - i0; wf = fi - j0
45
+ v = (self.S[j0, i0] * (1 - wt) * (1 - wf) + self.S[j0, i1] * wt * (1 - wf)
46
+ + self.S[j1, i0] * (1 - wt) * wf + self.S[j1, i1] * wt * wf)
47
+ out[ok] = v
48
+ return out
49
+
50
+ def crop(self, t0, t1, f0=None, f1=None) -> "Spectrogram":
51
+ m = (self.t >= t0) & (self.t <= t1)
52
+ fm = np.ones_like(self.f, bool)
53
+ if f0 is not None: fm &= self.f >= f0
54
+ if f1 is not None: fm &= self.f <= f1
55
+ return Spectrogram(self.t[m], self.f[fm], self.S[np.ix_(fm, m)])
56
+
57
+ def save(self, path):
58
+ np.savez_compressed(path, t=self.t, f=self.f, S=self.S.astype(np.float32))
59
+
60
+ @classmethod
61
+ def load(cls, path) -> "Spectrogram":
62
+ z = np.load(path)
63
+ return cls(z["t"], z["f"], z["S"])
64
+
65
+
66
+ def compute_spectrogram(x: np.ndarray, sr: int, nperseg: int = 8192, hop_s: float = 0.25,
67
+ fmin: float = 40.0, fmax: float = 450.0,
68
+ whiten_window_s: float | None = 600.0) -> Spectrogram:
69
+ """High-resolution STFT in the low band, converted to dB and whitened.
70
+
71
+ Whitening subtracts, per frequency bin, the running median over
72
+ `whiten_window_s` (or the global median if None). This flattens mains
73
+ hum and stationary machinery so only *changing* tones stand out.
74
+ """
75
+ hop = max(1, int(round(hop_s * sr)))
76
+ f, t, Z = ss.stft(x, sr, nperseg=nperseg, noverlap=nperseg - hop, window="hann",
77
+ boundary=None, padded=False)
78
+ band = (f >= fmin) & (f <= fmax)
79
+ L = 10 * np.log10(np.abs(Z[band]) ** 2 + 1e-20)
80
+ f = f[band]
81
+ if whiten_window_s is None:
82
+ L = L - np.median(L, axis=1, keepdims=True)
83
+ else:
84
+ w = int(whiten_window_s / (hop / sr))
85
+ if w >= L.shape[1]:
86
+ L = L - np.median(L, axis=1, keepdims=True)
87
+ else:
88
+ # block-wise running median (cheap, adequate for slow drifts)
89
+ nb = int(np.ceil(L.shape[1] / w))
90
+ med = np.zeros_like(L)
91
+ centers = []
92
+ for b in range(nb):
93
+ sl = slice(b * w, min((b + 1) * w, L.shape[1]))
94
+ med[:, sl] = np.median(L[:, sl], axis=1, keepdims=True)
95
+ centers.append((sl.start + sl.stop) / 2)
96
+ # linearly blend block medians to avoid seams
97
+ cm = np.array([np.median(L[:, b * w:min((b + 1) * w, L.shape[1])], axis=1) for b in range(nb)]) # (nb, F)
98
+ idx = np.arange(L.shape[1])
99
+ med = np.stack([np.interp(idx, centers, cm[:, k]) for k in range(L.shape[0])])
100
+ L = L - med
101
+ return Spectrogram(t, f, L.astype(np.float32))