audio-diag 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.
- audio_diag-0.1.0/LICENSE +21 -0
- audio_diag-0.1.0/MANIFEST.in +4 -0
- audio_diag-0.1.0/PKG-INFO +186 -0
- audio_diag-0.1.0/README.md +167 -0
- audio_diag-0.1.0/audio_diag/__init__.py +12 -0
- audio_diag-0.1.0/audio_diag/__main__.py +5 -0
- audio_diag-0.1.0/audio_diag/analyze.py +159 -0
- audio_diag-0.1.0/audio_diag/cli.py +87 -0
- audio_diag-0.1.0/audio_diag/decode.py +121 -0
- audio_diag-0.1.0/audio_diag/level2.py +237 -0
- audio_diag-0.1.0/audio_diag/loudness.py +230 -0
- audio_diag-0.1.0/audio_diag/profiles.py +33 -0
- audio_diag-0.1.0/audio_diag/report.py +94 -0
- audio_diag-0.1.0/audio_diag/report.schema.json +38 -0
- audio_diag-0.1.0/audio_diag/safety.py +91 -0
- audio_diag-0.1.0/audio_diag/spectral.py +204 -0
- audio_diag-0.1.0/audio_diag/timedomain.py +130 -0
- audio_diag-0.1.0/audio_diag/util.py +56 -0
- audio_diag-0.1.0/audio_diag.egg-info/PKG-INFO +186 -0
- audio_diag-0.1.0/audio_diag.egg-info/SOURCES.txt +33 -0
- audio_diag-0.1.0/audio_diag.egg-info/dependency_links.txt +1 -0
- audio_diag-0.1.0/audio_diag.egg-info/entry_points.txt +2 -0
- audio_diag-0.1.0/audio_diag.egg-info/requires.txt +1 -0
- audio_diag-0.1.0/audio_diag.egg-info/top_level.txt +1 -0
- audio_diag-0.1.0/pyproject.toml +36 -0
- audio_diag-0.1.0/report.schema.json +38 -0
- audio_diag-0.1.0/setup.cfg +4 -0
- audio_diag-0.1.0/tests/test_cli_robustness.py +190 -0
- audio_diag-0.1.0/tests/test_controlled.py +51 -0
- audio_diag-0.1.0/tests/test_corpus_expected.py +223 -0
- audio_diag-0.1.0/tests/test_ebu_runner.py +58 -0
- audio_diag-0.1.0/tests/test_ebu_tech3341.py +95 -0
- audio_diag-0.1.0/tests/test_ffmpeg_crosscheck.py +83 -0
- audio_diag-0.1.0/tests/test_finish.py +36 -0
- audio_diag-0.1.0/tests/test_profiles.py +30 -0
audio_diag-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Adrián Cancio / contributors
|
|
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,186 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: audio-diag
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Stereo-master audio diagnostics with explicit reliability tiers (Level 1, Level 2, Level 3).
|
|
5
|
+
Author: Adrián Cancio
|
|
6
|
+
License: MIT
|
|
7
|
+
Classifier: Programming Language :: Python :: 3
|
|
8
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Topic :: Multimedia :: Sound/Audio :: Analysis
|
|
14
|
+
Requires-Python: >=3.12
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
License-File: LICENSE
|
|
17
|
+
Requires-Dist: numpy
|
|
18
|
+
Dynamic: license-file
|
|
19
|
+
|
|
20
|
+
# audio-diag
|
|
21
|
+
|
|
22
|
+
Standalone Python CLI and library for stereo-master audio diagnostics with explicit reliability tiers. It operates completely offline, requires no external services or telemetry, and classifies measurements into three clear levels of trustworthiness.
|
|
23
|
+
|
|
24
|
+
- **Level 1 (reliable):** BS.1770-4 / EBU R128 loudness (integrated, momentary max, short-term max, LRA), sample peak, true peak (4x polyphase Kaiser sinc oversampling), sample-level clipping runs, DC offset, leading and trailing silence, 8-band energy distribution, spectral slope, per-band stereo correlation and mono compatibility.
|
|
25
|
+
- **Level 2 (heuristic hints):** Beat-window energy drops (dropouts vs. sustained breaks), kick-onset deviation from a BPM grid, section energy contrast, crest factor (global and FFT band projections). Always reported with `confidence: "heuristic"` and a warning; never treated as an automatic defect.
|
|
26
|
+
- **Level 3 (not determinable from a stereo master):** Kick fundamental frequency, kick envelope, kick/rumble ratio, sub masking, musical key. Always reports `{"status": "not_determinable", "reason": ...}`. No estimators exist for these from mixed stereo audio.
|
|
27
|
+
|
|
28
|
+
## Requirements
|
|
29
|
+
|
|
30
|
+
- Python >= 3.12
|
|
31
|
+
- `numpy`
|
|
32
|
+
- `ffmpeg` and `ffprobe` installed and available on `PATH`
|
|
33
|
+
|
|
34
|
+
System `ffmpeg` is used solely to decode audio into float32 PCM; all loudness, true-peak, spectral, and time-domain calculations are performed directly by audio-diag's internal NumPy implementation.
|
|
35
|
+
|
|
36
|
+
## Installation
|
|
37
|
+
|
|
38
|
+
Install via pip from source:
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
pip install .
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
For editable development:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
pip install -e .
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## CLI Usage
|
|
51
|
+
|
|
52
|
+
The package installs the `audio-diag` executable and can also be run via `python3 -m audio_diag`.
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
# Analyze an audio file
|
|
56
|
+
audio-diag analyze track.wav
|
|
57
|
+
|
|
58
|
+
# Pass tempo and declare intentional arrangement decisions
|
|
59
|
+
audio-diag analyze master.wav --bpm 155 --intent "break at bars 33-40" --intent "mono bass"
|
|
60
|
+
|
|
61
|
+
# Export machine-readable JSON and human-readable Markdown
|
|
62
|
+
audio-diag analyze master.wav --json report.json --md report.md
|
|
63
|
+
|
|
64
|
+
# Inspect the bundled JSON Schema
|
|
65
|
+
audio-diag schema
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
### Options
|
|
69
|
+
|
|
70
|
+
```
|
|
71
|
+
usage: audio_diag analyze [-h] [--bpm BPM] [--json OUT] [--md OUT]
|
|
72
|
+
[--intent TEXT] [--target-lufs TARGET_LUFS]
|
|
73
|
+
[--tolerance-lu TOLERANCE_LU] [--tp-max TP_MAX]
|
|
74
|
+
[--silence-dbfs SILENCE_DBFS] [--drop-db DROP_DB]
|
|
75
|
+
file
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
- `file`: Path to input audio file (opened strictly read-only).
|
|
79
|
+
- `--bpm N`: Tempo in beats per minute; enables grid-aligned Level 2 onset hints.
|
|
80
|
+
- `--json OUT`: Write formatted JSON report to `OUT` instead of stdout.
|
|
81
|
+
- `--md OUT`: Write Markdown report to `OUT` instead of stderr.
|
|
82
|
+
- `--intent TEXT`: Declare an intentional artistic choice (repeatable). Echoed in `intentional_decisions` and never reported as a defect.
|
|
83
|
+
- `--target-lufs N`: Integrated loudness target in LUFS (default: `-8.0`).
|
|
84
|
+
- `--tolerance-lu N`: Loudness tolerance in LU (default: `2.0`).
|
|
85
|
+
- `--tp-max N`: Maximum true peak in dBTP (default: `-0.3`).
|
|
86
|
+
- `--silence-dbfs N`: Threshold for digital silence detection (default: `-60.0`).
|
|
87
|
+
- `--drop-db N`: Energy drop threshold for dropout/break detection (default: `6.0`).
|
|
88
|
+
|
|
89
|
+
### Path Safety and Safeguards
|
|
90
|
+
|
|
91
|
+
- The input file is opened strictly read-only (`O_RDONLY`).
|
|
92
|
+
- The CLI refuses any output path that matches the input file (resolving symlinks and hardlinks).
|
|
93
|
+
- The CLI refuses to write output inside protected directories:
|
|
94
|
+
- Standard user music directories (`~/Music`, `~/Música`).
|
|
95
|
+
- Directories configured via the `AUDIO_DIAG_PROTECTED_DIRS` environment variable (colon-separated).
|
|
96
|
+
- Exit codes: `0` on success, `2` on invalid arguments, decoding failure, or unsafe output paths.
|
|
97
|
+
|
|
98
|
+
## Python API
|
|
99
|
+
|
|
100
|
+
```python
|
|
101
|
+
from audio_diag import analyze, load_schema, to_markdown, DecodeError
|
|
102
|
+
|
|
103
|
+
# Analyze an audio file
|
|
104
|
+
try:
|
|
105
|
+
report = analyze("master.wav", bpm=158, intents=["break at bars 33-40"])
|
|
106
|
+
except DecodeError as err:
|
|
107
|
+
print(f"Decoding failed: {err}")
|
|
108
|
+
|
|
109
|
+
# Access Level 1 metrics
|
|
110
|
+
loudness = report["level1"]["loudness"]
|
|
111
|
+
print(f"Integrated LUFS: {loudness['integrated_lufs']}")
|
|
112
|
+
print(f"True Peak dBTP: {report['level1']['peaks']['true_peak_dbtp']}")
|
|
113
|
+
|
|
114
|
+
# Render Markdown report
|
|
115
|
+
md_text = to_markdown(report)
|
|
116
|
+
|
|
117
|
+
# Load the JSON Schema definition
|
|
118
|
+
schema = load_schema()
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
### Direct PCM Measurement
|
|
122
|
+
|
|
123
|
+
Measure in-memory float32 PCM samples without decoding from disk:
|
|
124
|
+
|
|
125
|
+
```python
|
|
126
|
+
import numpy as np
|
|
127
|
+
from audio_diag.loudness import measure
|
|
128
|
+
|
|
129
|
+
# samples: 2D numpy array of shape (num_samples, channels)
|
|
130
|
+
stats = measure(samples, sample_rate=48000)
|
|
131
|
+
print(stats["integrated_lufs"], stats["true_peak_dbtp"])
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
### Spectral Profiles and Dispersion
|
|
135
|
+
|
|
136
|
+
Compare relative spectral balance across tracks using gain-normalized power profiles:
|
|
137
|
+
|
|
138
|
+
```python
|
|
139
|
+
from audio_diag.profiles import profile, compare
|
|
140
|
+
|
|
141
|
+
p1 = profile(analyze("track1.wav"))
|
|
142
|
+
p2 = profile(analyze("track2.wav"))
|
|
143
|
+
dispersion = compare([p1, p2])
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
Profiles normalize by total spectral power ($P_{band} / P_{total}$), canceling gain differences. Comparisons produce observed minimum and maximum envelopes per band rather than an artificial average or arbitrary pass/fail gate.
|
|
147
|
+
|
|
148
|
+
## Schema & Report Invariants
|
|
149
|
+
|
|
150
|
+
- Schema definition is bundled as `report.schema.json` and accessible via `audio_diag.load_schema()`.
|
|
151
|
+
- Standard keys: `tool`, `version`, `file`, `thresholds`, `level1`, `level2`, `level3`, `intentional_decisions`, `warnings`, `verdicts`.
|
|
152
|
+
- Finite JSON: values are never `NaN` or `inf`. Missing or invalid numbers are represented as `null` with an accompanying `<metric>_reason` explanation.
|
|
153
|
+
- Lossy inputs (e.g. MP3, Opus, AAC) set `file.lossy_source: true`. True peak, crest factor, and absolute loudness are explicitly marked `"not valid for calibration (lossy source)"` because lossy codecs alter peaks and levels dynamically.
|
|
154
|
+
|
|
155
|
+
## Metric Summary
|
|
156
|
+
|
|
157
|
+
- **Loudness:** ITU-R BS.1770-4 / EBU R128 biquad K-weighting, 400 ms blocks with 75% overlap, -70 LUFS absolute and -10 LU relative gating. Maxima evaluated on a 10 ms grid. LRA uses 3 s windows, -20 LU relative gate, P10-P95 span.
|
|
158
|
+
- **True Peak:** 4x polyphase Kaiser-windowed sinc oversampling filter (32 taps).
|
|
159
|
+
- **Clipping:** Identifies consecutive runs of full-scale samples (`|x| >= 1.0 - 1e-6`) per channel.
|
|
160
|
+
- **Bands:** Relative energy across 8 sub-bands computed via Hann-windowed periodogram (8192-point FFT, 50% overlap).
|
|
161
|
+
- **Mono Compatibility:** Inter-channel correlation and mono energy ratio $10 \log_{10} \frac{E[((L+R)/2)^2]}{E[(L^2+R^2)/2]}$ per frequency band.
|
|
162
|
+
|
|
163
|
+
## Testing
|
|
164
|
+
|
|
165
|
+
The test suite runs with Python's standard `unittest`:
|
|
166
|
+
|
|
167
|
+
```bash
|
|
168
|
+
# Run the entire test suite
|
|
169
|
+
python3 -m unittest discover
|
|
170
|
+
|
|
171
|
+
# Run specific test modules
|
|
172
|
+
python3 -m unittest tests.test_cli_robustness
|
|
173
|
+
python3 -m unittest tests.test_corpus_expected
|
|
174
|
+
python3 -m unittest tests.test_ebu_tech3341
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
- `test_corpus_expected`: Verifies measurements against analytically calculated mathematical ground truths (sine LUFS via K-weighting filter transfer function, pink noise LUFS via Parseval's theorem, exact clipping runs, synthesized click trains).
|
|
178
|
+
- `test_ebu_tech3341`: Reconstructs EBU Tech 3341 compliance signals and asserts tolerances according to the specification ($\pm 0.1$ LU).
|
|
179
|
+
- `test_ffmpeg_crosscheck`: Cross-checks loudness and true-peak results against system `ffmpeg -af ebur128=peak=true`.
|
|
180
|
+
- `test_cli_robustness`: Verifies CLI exit codes, error handling, path traversal and overwrite protection, memory limits, and schema output.
|
|
181
|
+
|
|
182
|
+
Test audio signals are generated deterministically in memory or temporary directories and are not committed to source control.
|
|
183
|
+
|
|
184
|
+
## License
|
|
185
|
+
|
|
186
|
+
MIT License. Copyright (c) 2026 Adrián Cancio / contributors. See `LICENSE` for details.
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
# audio-diag
|
|
2
|
+
|
|
3
|
+
Standalone Python CLI and library for stereo-master audio diagnostics with explicit reliability tiers. It operates completely offline, requires no external services or telemetry, and classifies measurements into three clear levels of trustworthiness.
|
|
4
|
+
|
|
5
|
+
- **Level 1 (reliable):** BS.1770-4 / EBU R128 loudness (integrated, momentary max, short-term max, LRA), sample peak, true peak (4x polyphase Kaiser sinc oversampling), sample-level clipping runs, DC offset, leading and trailing silence, 8-band energy distribution, spectral slope, per-band stereo correlation and mono compatibility.
|
|
6
|
+
- **Level 2 (heuristic hints):** Beat-window energy drops (dropouts vs. sustained breaks), kick-onset deviation from a BPM grid, section energy contrast, crest factor (global and FFT band projections). Always reported with `confidence: "heuristic"` and a warning; never treated as an automatic defect.
|
|
7
|
+
- **Level 3 (not determinable from a stereo master):** Kick fundamental frequency, kick envelope, kick/rumble ratio, sub masking, musical key. Always reports `{"status": "not_determinable", "reason": ...}`. No estimators exist for these from mixed stereo audio.
|
|
8
|
+
|
|
9
|
+
## Requirements
|
|
10
|
+
|
|
11
|
+
- Python >= 3.12
|
|
12
|
+
- `numpy`
|
|
13
|
+
- `ffmpeg` and `ffprobe` installed and available on `PATH`
|
|
14
|
+
|
|
15
|
+
System `ffmpeg` is used solely to decode audio into float32 PCM; all loudness, true-peak, spectral, and time-domain calculations are performed directly by audio-diag's internal NumPy implementation.
|
|
16
|
+
|
|
17
|
+
## Installation
|
|
18
|
+
|
|
19
|
+
Install via pip from source:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
pip install .
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
For editable development:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
pip install -e .
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## CLI Usage
|
|
32
|
+
|
|
33
|
+
The package installs the `audio-diag` executable and can also be run via `python3 -m audio_diag`.
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
# Analyze an audio file
|
|
37
|
+
audio-diag analyze track.wav
|
|
38
|
+
|
|
39
|
+
# Pass tempo and declare intentional arrangement decisions
|
|
40
|
+
audio-diag analyze master.wav --bpm 155 --intent "break at bars 33-40" --intent "mono bass"
|
|
41
|
+
|
|
42
|
+
# Export machine-readable JSON and human-readable Markdown
|
|
43
|
+
audio-diag analyze master.wav --json report.json --md report.md
|
|
44
|
+
|
|
45
|
+
# Inspect the bundled JSON Schema
|
|
46
|
+
audio-diag schema
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
### Options
|
|
50
|
+
|
|
51
|
+
```
|
|
52
|
+
usage: audio_diag analyze [-h] [--bpm BPM] [--json OUT] [--md OUT]
|
|
53
|
+
[--intent TEXT] [--target-lufs TARGET_LUFS]
|
|
54
|
+
[--tolerance-lu TOLERANCE_LU] [--tp-max TP_MAX]
|
|
55
|
+
[--silence-dbfs SILENCE_DBFS] [--drop-db DROP_DB]
|
|
56
|
+
file
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
- `file`: Path to input audio file (opened strictly read-only).
|
|
60
|
+
- `--bpm N`: Tempo in beats per minute; enables grid-aligned Level 2 onset hints.
|
|
61
|
+
- `--json OUT`: Write formatted JSON report to `OUT` instead of stdout.
|
|
62
|
+
- `--md OUT`: Write Markdown report to `OUT` instead of stderr.
|
|
63
|
+
- `--intent TEXT`: Declare an intentional artistic choice (repeatable). Echoed in `intentional_decisions` and never reported as a defect.
|
|
64
|
+
- `--target-lufs N`: Integrated loudness target in LUFS (default: `-8.0`).
|
|
65
|
+
- `--tolerance-lu N`: Loudness tolerance in LU (default: `2.0`).
|
|
66
|
+
- `--tp-max N`: Maximum true peak in dBTP (default: `-0.3`).
|
|
67
|
+
- `--silence-dbfs N`: Threshold for digital silence detection (default: `-60.0`).
|
|
68
|
+
- `--drop-db N`: Energy drop threshold for dropout/break detection (default: `6.0`).
|
|
69
|
+
|
|
70
|
+
### Path Safety and Safeguards
|
|
71
|
+
|
|
72
|
+
- The input file is opened strictly read-only (`O_RDONLY`).
|
|
73
|
+
- The CLI refuses any output path that matches the input file (resolving symlinks and hardlinks).
|
|
74
|
+
- The CLI refuses to write output inside protected directories:
|
|
75
|
+
- Standard user music directories (`~/Music`, `~/Música`).
|
|
76
|
+
- Directories configured via the `AUDIO_DIAG_PROTECTED_DIRS` environment variable (colon-separated).
|
|
77
|
+
- Exit codes: `0` on success, `2` on invalid arguments, decoding failure, or unsafe output paths.
|
|
78
|
+
|
|
79
|
+
## Python API
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
from audio_diag import analyze, load_schema, to_markdown, DecodeError
|
|
83
|
+
|
|
84
|
+
# Analyze an audio file
|
|
85
|
+
try:
|
|
86
|
+
report = analyze("master.wav", bpm=158, intents=["break at bars 33-40"])
|
|
87
|
+
except DecodeError as err:
|
|
88
|
+
print(f"Decoding failed: {err}")
|
|
89
|
+
|
|
90
|
+
# Access Level 1 metrics
|
|
91
|
+
loudness = report["level1"]["loudness"]
|
|
92
|
+
print(f"Integrated LUFS: {loudness['integrated_lufs']}")
|
|
93
|
+
print(f"True Peak dBTP: {report['level1']['peaks']['true_peak_dbtp']}")
|
|
94
|
+
|
|
95
|
+
# Render Markdown report
|
|
96
|
+
md_text = to_markdown(report)
|
|
97
|
+
|
|
98
|
+
# Load the JSON Schema definition
|
|
99
|
+
schema = load_schema()
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
### Direct PCM Measurement
|
|
103
|
+
|
|
104
|
+
Measure in-memory float32 PCM samples without decoding from disk:
|
|
105
|
+
|
|
106
|
+
```python
|
|
107
|
+
import numpy as np
|
|
108
|
+
from audio_diag.loudness import measure
|
|
109
|
+
|
|
110
|
+
# samples: 2D numpy array of shape (num_samples, channels)
|
|
111
|
+
stats = measure(samples, sample_rate=48000)
|
|
112
|
+
print(stats["integrated_lufs"], stats["true_peak_dbtp"])
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
### Spectral Profiles and Dispersion
|
|
116
|
+
|
|
117
|
+
Compare relative spectral balance across tracks using gain-normalized power profiles:
|
|
118
|
+
|
|
119
|
+
```python
|
|
120
|
+
from audio_diag.profiles import profile, compare
|
|
121
|
+
|
|
122
|
+
p1 = profile(analyze("track1.wav"))
|
|
123
|
+
p2 = profile(analyze("track2.wav"))
|
|
124
|
+
dispersion = compare([p1, p2])
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
Profiles normalize by total spectral power ($P_{band} / P_{total}$), canceling gain differences. Comparisons produce observed minimum and maximum envelopes per band rather than an artificial average or arbitrary pass/fail gate.
|
|
128
|
+
|
|
129
|
+
## Schema & Report Invariants
|
|
130
|
+
|
|
131
|
+
- Schema definition is bundled as `report.schema.json` and accessible via `audio_diag.load_schema()`.
|
|
132
|
+
- Standard keys: `tool`, `version`, `file`, `thresholds`, `level1`, `level2`, `level3`, `intentional_decisions`, `warnings`, `verdicts`.
|
|
133
|
+
- Finite JSON: values are never `NaN` or `inf`. Missing or invalid numbers are represented as `null` with an accompanying `<metric>_reason` explanation.
|
|
134
|
+
- Lossy inputs (e.g. MP3, Opus, AAC) set `file.lossy_source: true`. True peak, crest factor, and absolute loudness are explicitly marked `"not valid for calibration (lossy source)"` because lossy codecs alter peaks and levels dynamically.
|
|
135
|
+
|
|
136
|
+
## Metric Summary
|
|
137
|
+
|
|
138
|
+
- **Loudness:** ITU-R BS.1770-4 / EBU R128 biquad K-weighting, 400 ms blocks with 75% overlap, -70 LUFS absolute and -10 LU relative gating. Maxima evaluated on a 10 ms grid. LRA uses 3 s windows, -20 LU relative gate, P10-P95 span.
|
|
139
|
+
- **True Peak:** 4x polyphase Kaiser-windowed sinc oversampling filter (32 taps).
|
|
140
|
+
- **Clipping:** Identifies consecutive runs of full-scale samples (`|x| >= 1.0 - 1e-6`) per channel.
|
|
141
|
+
- **Bands:** Relative energy across 8 sub-bands computed via Hann-windowed periodogram (8192-point FFT, 50% overlap).
|
|
142
|
+
- **Mono Compatibility:** Inter-channel correlation and mono energy ratio $10 \log_{10} \frac{E[((L+R)/2)^2]}{E[(L^2+R^2)/2]}$ per frequency band.
|
|
143
|
+
|
|
144
|
+
## Testing
|
|
145
|
+
|
|
146
|
+
The test suite runs with Python's standard `unittest`:
|
|
147
|
+
|
|
148
|
+
```bash
|
|
149
|
+
# Run the entire test suite
|
|
150
|
+
python3 -m unittest discover
|
|
151
|
+
|
|
152
|
+
# Run specific test modules
|
|
153
|
+
python3 -m unittest tests.test_cli_robustness
|
|
154
|
+
python3 -m unittest tests.test_corpus_expected
|
|
155
|
+
python3 -m unittest tests.test_ebu_tech3341
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
- `test_corpus_expected`: Verifies measurements against analytically calculated mathematical ground truths (sine LUFS via K-weighting filter transfer function, pink noise LUFS via Parseval's theorem, exact clipping runs, synthesized click trains).
|
|
159
|
+
- `test_ebu_tech3341`: Reconstructs EBU Tech 3341 compliance signals and asserts tolerances according to the specification ($\pm 0.1$ LU).
|
|
160
|
+
- `test_ffmpeg_crosscheck`: Cross-checks loudness and true-peak results against system `ffmpeg -af ebur128=peak=true`.
|
|
161
|
+
- `test_cli_robustness`: Verifies CLI exit codes, error handling, path traversal and overwrite protection, memory limits, and schema output.
|
|
162
|
+
|
|
163
|
+
Test audio signals are generated deterministically in memory or temporary directories and are not committed to source control.
|
|
164
|
+
|
|
165
|
+
## License
|
|
166
|
+
|
|
167
|
+
MIT License. Copyright (c) 2026 Adrián Cancio / contributors. See `LICENSE` for details.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""audio_diag: stereo-master audio measurements in three reliability levels.
|
|
2
|
+
|
|
3
|
+
Public API: analyze(path, bpm=None, intents=(), thresholds=None) -> dict,
|
|
4
|
+
loudness.measure(samples, sample_rate), load_schema() -> dict, DecodeError.
|
|
5
|
+
"""
|
|
6
|
+
__version__ = '0.1.0'
|
|
7
|
+
|
|
8
|
+
from .decode import DecodeError # noqa: E402
|
|
9
|
+
from .analyze import analyze, DEFAULT_THRESHOLDS # noqa: E402
|
|
10
|
+
from .report import load_schema, to_markdown # noqa: E402
|
|
11
|
+
|
|
12
|
+
__all__ = ['analyze', 'DecodeError', 'DEFAULT_THRESHOLDS', 'load_schema', 'to_markdown', '__version__']
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""Public analysis entry point: analyze(path, ...) -> JSON-safe dict."""
|
|
2
|
+
import math
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
|
|
7
|
+
from . import __version__
|
|
8
|
+
from . import decode, level2
|
|
9
|
+
from .loudness import LoudnessMeter
|
|
10
|
+
from .spectral import SpectrumAccumulator
|
|
11
|
+
from .timedomain import DEFAULT_SILENCE_DBFS, TimeDomainAccumulator
|
|
12
|
+
from .util import sanitize
|
|
13
|
+
|
|
14
|
+
DEFAULT_THRESHOLDS = {
|
|
15
|
+
'target_lufs': -8.0,
|
|
16
|
+
'target_tolerance_lu': 2.0,
|
|
17
|
+
'true_peak_max_dbtp': -0.3,
|
|
18
|
+
'silence_dbfs': DEFAULT_SILENCE_DBFS,
|
|
19
|
+
'energy_drop_db': 6.0,
|
|
20
|
+
}
|
|
21
|
+
NOT_VALID = 'not valid for calibration (lossy source)'
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def analyze(path, bpm=None, intents=(), thresholds=None, chunk_frames=decode.CHUNK_FRAMES):
|
|
25
|
+
"""Analyze an audio file (read-only). Returns a strict-JSON-safe dict.
|
|
26
|
+
|
|
27
|
+
Raises audio_diag.decode.DecodeError if the file cannot be decoded.
|
|
28
|
+
thresholds: optional dict overriding DEFAULT_THRESHOLDS keys.
|
|
29
|
+
"""
|
|
30
|
+
th = dict(DEFAULT_THRESHOLDS)
|
|
31
|
+
th.update(thresholds or {})
|
|
32
|
+
if bpm is not None:
|
|
33
|
+
bpm = float(bpm)
|
|
34
|
+
if not (math.isfinite(bpm) and 20 <= bpm <= 400):
|
|
35
|
+
raise ValueError('bpm must be a finite number between 20 and 400')
|
|
36
|
+
intents = [str(i) for i in (intents or ())]
|
|
37
|
+
import hashlib
|
|
38
|
+
try:
|
|
39
|
+
with open(path, 'rb') as source:
|
|
40
|
+
sha256 = hashlib.file_digest(source, 'sha256').hexdigest()
|
|
41
|
+
except OSError as exc:
|
|
42
|
+
raise decode.DecodeError(f'cannot read input: {exc}') from exc
|
|
43
|
+
meta = decode.probe(path)
|
|
44
|
+
meta['sha256'] = sha256
|
|
45
|
+
warnings = []
|
|
46
|
+
src_ch = meta['channels']
|
|
47
|
+
ch = 1 if src_ch == 1 else 2
|
|
48
|
+
if src_ch > 2:
|
|
49
|
+
warnings.append(f'{src_ch}-channel source was downmixed to stereo by ffmpeg before analysis')
|
|
50
|
+
sr = meta['sample_rate']
|
|
51
|
+
loud = LoudnessMeter(sr, ch)
|
|
52
|
+
spec = SpectrumAccumulator(sr, ch)
|
|
53
|
+
td = TimeDomainAccumulator(sr, ch, th['silence_dbfs'])
|
|
54
|
+
feat = level2.FeatureAccumulator(sr)
|
|
55
|
+
for block in decode.stream(path, ch, warnings, chunk_frames):
|
|
56
|
+
loud.update(block)
|
|
57
|
+
spec.update(block)
|
|
58
|
+
td.update(block)
|
|
59
|
+
feat.update(block)
|
|
60
|
+
lm = loud.finish()
|
|
61
|
+
t = td.result()
|
|
62
|
+
duration = t['duration_s']
|
|
63
|
+
sp = spec.result()
|
|
64
|
+
lossy = bool(meta['lossy_source'])
|
|
65
|
+
meta = dict(meta)
|
|
66
|
+
meta['duration_s'] = duration # decoded duration is authoritative
|
|
67
|
+
meta['analyzed_channels'] = ch
|
|
68
|
+
file = {'path': os.fspath(path), **meta}
|
|
69
|
+
|
|
70
|
+
loudness = {k: lm.get(k) for k in ('integrated_lufs', 'momentary_max_lufs', 'short_term_max_lufs', 'lra_lu')}
|
|
71
|
+
for k in list(loudness):
|
|
72
|
+
if lm.get(k + '_reason'):
|
|
73
|
+
loudness[k + '_reason'] = lm[k + '_reason']
|
|
74
|
+
loudness['definition'] = ('independent numpy BS.1770-4 / EBU R128: K-weighting, 400 ms blocks (75 % overlap), '
|
|
75
|
+
'-70 LUFS absolute and -10 LU relative gates; LRA from 3 s blocks, -20 LU gate, '
|
|
76
|
+
'P10-P95')
|
|
77
|
+
peaks = dict(t['peaks'])
|
|
78
|
+
peaks['true_peak_dbtp'] = lm['true_peak_dbtp']
|
|
79
|
+
if lm.get('true_peak_dbtp_reason'):
|
|
80
|
+
peaks['true_peak_dbtp_reason'] = lm['true_peak_dbtp_reason']
|
|
81
|
+
names = list(t['peaks']['per_channel'])
|
|
82
|
+
for n, v in zip(names, lm['true_peak_dbtp_per_channel']):
|
|
83
|
+
peaks['per_channel'][n]['true_peak_dbtp'] = v
|
|
84
|
+
peaks['true_peak_definition'] = '4x oversampled (polyphase Kaiser-windowed sinc, 32 taps) peak in dBTP'
|
|
85
|
+
if lossy:
|
|
86
|
+
loudness['validity'] = NOT_VALID
|
|
87
|
+
peaks['true_peak_validity'] = NOT_VALID
|
|
88
|
+
level1 = {
|
|
89
|
+
'loudness': loudness,
|
|
90
|
+
'peaks': peaks,
|
|
91
|
+
'dc_offset': t['dc_offset'],
|
|
92
|
+
'silence': t['silence'],
|
|
93
|
+
'clipping': t['clipping'],
|
|
94
|
+
'bands': {'definition': 'energy_db = band mean-square relative to the total of the full-range signal '
|
|
95
|
+
'(L/R power averaged); energy_db_rel_max relative to the loudest band',
|
|
96
|
+
'bands': sp['bands']},
|
|
97
|
+
'spectral_slope': sp['slope'],
|
|
98
|
+
'stereo': sp['stereo'] if sp['stereo'] else {
|
|
99
|
+
'status': 'not_applicable', 'reason': 'mono source: no stereo metrics'},
|
|
100
|
+
}
|
|
101
|
+
if t['nonfinite_samples']:
|
|
102
|
+
warnings.append(f"{t['nonfinite_samples']} non-finite samples were treated as 0")
|
|
103
|
+
if duration < 1.0:
|
|
104
|
+
warnings.append('file shorter than 1 s: gated loudness metrics may be unavailable')
|
|
105
|
+
if t['silence'].get('total_silent'):
|
|
106
|
+
warnings.append('file is silent (everything below the silence threshold)')
|
|
107
|
+
if lossy:
|
|
108
|
+
warnings.append('lossy source: true peak, crest factor and absolute loudness are ' + NOT_VALID)
|
|
109
|
+
|
|
110
|
+
l2 = level2.analyze_level2(feat, sr, bpm, {n: v['sample_peak_dbfs'] for n, v in t['peaks']['per_channel'].items()},
|
|
111
|
+
t['rms_dbfs'], lossy, intents, drop_db=th['energy_drop_db'])
|
|
112
|
+
l2['crest_factor']['per_band_db'] = {
|
|
113
|
+
name: (20 * math.log10(spec.band_peak[name]) -
|
|
114
|
+
10 * math.log10(spec.band_square[name] / spec.band_samples))
|
|
115
|
+
if spec.band_square[name] > 1e-20 and spec.band_samples else None
|
|
116
|
+
for name in sp['bands']}
|
|
117
|
+
l2['crest_factor']['band_definition'] = ('Peak/RMS of brick-wall FFT band projections of '
|
|
118
|
+
'8192-sample Hann-windowed frames, 50% overlap, pooled channels; window and frame-boundary '
|
|
119
|
+
'effects make this heuristic, not equivalent to continuous bandpass filtering.')
|
|
120
|
+
powers = [10 ** (v / 10) for v in t['rms_dbfs'].values() if v is not None]
|
|
121
|
+
l2['crest_factor']['global_db'] = (peaks['sample_peak_dbfs'] - 10 * math.log10(sum(powers) / ch)
|
|
122
|
+
if powers and peaks['sample_peak_dbfs'] is not None else None)
|
|
123
|
+
if lossy:
|
|
124
|
+
l2['crest_factor']['validity'] = NOT_VALID
|
|
125
|
+
result = {
|
|
126
|
+
'tool': 'audio-diag', 'version': __version__, 'file': file,
|
|
127
|
+
'thresholds': th, 'level1': level1, 'level2': l2, 'level3': level2.level3(),
|
|
128
|
+
'intentional_decisions': [{'text': i, 'note': 'declared by the user; never reported as a defect'}
|
|
129
|
+
for i in intents],
|
|
130
|
+
'warnings': warnings,
|
|
131
|
+
'verdicts': _verdicts(level1, th, lossy),
|
|
132
|
+
}
|
|
133
|
+
return sanitize(result)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _verdicts(l1, th, lossy):
|
|
137
|
+
v = {}
|
|
138
|
+
i = l1['loudness']['integrated_lufs']
|
|
139
|
+
if i is None:
|
|
140
|
+
v['loudness_target'] = {'status': 'unknown', 'reason': l1['loudness'].get('integrated_lufs_reason',
|
|
141
|
+
'no integrated loudness')}
|
|
142
|
+
else:
|
|
143
|
+
delta = i - th['target_lufs']
|
|
144
|
+
v['loudness_target'] = {'status': 'pass' if abs(delta) <= th['target_tolerance_lu'] else 'fail',
|
|
145
|
+
'integrated_lufs': i, 'target_lufs': th['target_lufs'],
|
|
146
|
+
'tolerance_lu': th['target_tolerance_lu'], 'delta_lu': delta}
|
|
147
|
+
tp = l1['peaks']['true_peak_dbtp']
|
|
148
|
+
if tp is None:
|
|
149
|
+
v['true_peak'] = {'status': 'unknown', 'reason': l1['peaks'].get('true_peak_dbtp_reason', 'silent')}
|
|
150
|
+
else:
|
|
151
|
+
v['true_peak'] = {'status': 'pass' if tp <= th['true_peak_max_dbtp'] else 'fail',
|
|
152
|
+
'true_peak_dbtp': tp, 'max_dbtp': th['true_peak_max_dbtp']}
|
|
153
|
+
c = l1['clipping']
|
|
154
|
+
v['clipping'] = {'status': 'pass' if c['runs_min_len_2'] == 0 else 'fail',
|
|
155
|
+
'runs_min_len_2': c['runs_min_len_2'], 'clipped_samples': c['clipped_samples']}
|
|
156
|
+
if lossy:
|
|
157
|
+
for k in ('loudness_target', 'true_peak'):
|
|
158
|
+
v[k]['note'] = NOT_VALID
|
|
159
|
+
return v
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Command line: python3 -m audio_diag analyze FILE [options].
|
|
2
|
+
|
|
3
|
+
stdout: JSON (or nothing when --json OUT is given); stderr: markdown report
|
|
4
|
+
(or nothing when --md OUT is given). Exit 0 ok, 2 usage/decode/output error.
|
|
5
|
+
"""
|
|
6
|
+
import argparse
|
|
7
|
+
import json
|
|
8
|
+
import sys
|
|
9
|
+
|
|
10
|
+
from .analyze import DEFAULT_THRESHOLDS, analyze
|
|
11
|
+
from .decode import DecodeError
|
|
12
|
+
from .report import load_schema, to_markdown
|
|
13
|
+
from .safety import OutputRefused, check_output_path, write_text_atomic
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class _Parser(argparse.ArgumentParser):
|
|
17
|
+
def error(self, message):
|
|
18
|
+
self.print_usage(sys.stderr)
|
|
19
|
+
sys.stderr.write(f'audio_diag: error: {message}\n')
|
|
20
|
+
raise SystemExit(2)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def build_parser():
|
|
24
|
+
p = _Parser(prog='audio_diag', description='Stereo-master audio diagnostics.')
|
|
25
|
+
sub = p.add_subparsers(dest='command', required=True)
|
|
26
|
+
a = sub.add_parser('analyze', help='analyze an audio file (read-only)')
|
|
27
|
+
a.add_argument('file')
|
|
28
|
+
a.add_argument('--bpm', type=float, help='tempo, enables bpm-dependent level-2 hints')
|
|
29
|
+
a.add_argument('--json', metavar='OUT', help='write JSON here instead of stdout')
|
|
30
|
+
a.add_argument('--md', metavar='OUT', help='write markdown here instead of stderr')
|
|
31
|
+
a.add_argument('--intent', action='append', default=[], metavar='TEXT',
|
|
32
|
+
help='declare an intentional decision (repeatable); never reported as a defect')
|
|
33
|
+
a.add_argument('--target-lufs', type=float, default=DEFAULT_THRESHOLDS['target_lufs'])
|
|
34
|
+
a.add_argument('--tolerance-lu', type=float, default=DEFAULT_THRESHOLDS['target_tolerance_lu'])
|
|
35
|
+
a.add_argument('--tp-max', type=float, default=DEFAULT_THRESHOLDS['true_peak_max_dbtp'],
|
|
36
|
+
help='maximum true peak in dBTP')
|
|
37
|
+
a.add_argument('--silence-dbfs', type=float, default=DEFAULT_THRESHOLDS['silence_dbfs'])
|
|
38
|
+
a.add_argument('--drop-db', type=float, default=DEFAULT_THRESHOLDS['energy_drop_db'])
|
|
39
|
+
sub.add_parser('schema', help='print report JSON schema')
|
|
40
|
+
return p
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def main(argv=None):
|
|
44
|
+
try:
|
|
45
|
+
args = build_parser().parse_args(argv)
|
|
46
|
+
except SystemExit as e:
|
|
47
|
+
return 2 if e.code not in (0, None) else 0
|
|
48
|
+
if args.command == 'schema':
|
|
49
|
+
schema = load_schema()
|
|
50
|
+
sys.stdout.write(json.dumps(schema, indent=2) + '\n')
|
|
51
|
+
return 0
|
|
52
|
+
try:
|
|
53
|
+
for out in (args.json, args.md):
|
|
54
|
+
if out:
|
|
55
|
+
check_output_path(out, args.file)
|
|
56
|
+
if args.json and args.md:
|
|
57
|
+
from os.path import realpath
|
|
58
|
+
if realpath(args.json) == realpath(args.md):
|
|
59
|
+
raise OutputRefused('--json and --md point at the same file')
|
|
60
|
+
except OutputRefused as exc:
|
|
61
|
+
sys.stderr.write(f'audio_diag: error: {exc}\n')
|
|
62
|
+
return 2
|
|
63
|
+
th = {'target_lufs': args.target_lufs, 'target_tolerance_lu': args.tolerance_lu,
|
|
64
|
+
'true_peak_max_dbtp': args.tp_max, 'silence_dbfs': args.silence_dbfs, 'energy_drop_db': args.drop_db}
|
|
65
|
+
try:
|
|
66
|
+
result = analyze(args.file, bpm=args.bpm, intents=args.intent, thresholds=th)
|
|
67
|
+
except DecodeError as exc:
|
|
68
|
+
sys.stderr.write(f'audio_diag: error: {exc}\n')
|
|
69
|
+
return 2
|
|
70
|
+
except ValueError as exc:
|
|
71
|
+
sys.stderr.write(f'audio_diag: error: {exc}\n')
|
|
72
|
+
return 2
|
|
73
|
+
text = json.dumps(result, indent=2, allow_nan=False, ensure_ascii=False) + '\n'
|
|
74
|
+
md = to_markdown(result)
|
|
75
|
+
try:
|
|
76
|
+
if args.json:
|
|
77
|
+
write_text_atomic(args.json, text, args.file)
|
|
78
|
+
else:
|
|
79
|
+
sys.stdout.write(text)
|
|
80
|
+
if args.md:
|
|
81
|
+
write_text_atomic(args.md, md, args.file)
|
|
82
|
+
else:
|
|
83
|
+
sys.stderr.write(md)
|
|
84
|
+
except (OutputRefused, OSError) as exc:
|
|
85
|
+
sys.stderr.write(f'audio_diag: error: cannot write output: {exc}\n')
|
|
86
|
+
return 2
|
|
87
|
+
return 0
|