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.
Files changed (35) hide show
  1. audio_diag-0.1.0/LICENSE +21 -0
  2. audio_diag-0.1.0/MANIFEST.in +4 -0
  3. audio_diag-0.1.0/PKG-INFO +186 -0
  4. audio_diag-0.1.0/README.md +167 -0
  5. audio_diag-0.1.0/audio_diag/__init__.py +12 -0
  6. audio_diag-0.1.0/audio_diag/__main__.py +5 -0
  7. audio_diag-0.1.0/audio_diag/analyze.py +159 -0
  8. audio_diag-0.1.0/audio_diag/cli.py +87 -0
  9. audio_diag-0.1.0/audio_diag/decode.py +121 -0
  10. audio_diag-0.1.0/audio_diag/level2.py +237 -0
  11. audio_diag-0.1.0/audio_diag/loudness.py +230 -0
  12. audio_diag-0.1.0/audio_diag/profiles.py +33 -0
  13. audio_diag-0.1.0/audio_diag/report.py +94 -0
  14. audio_diag-0.1.0/audio_diag/report.schema.json +38 -0
  15. audio_diag-0.1.0/audio_diag/safety.py +91 -0
  16. audio_diag-0.1.0/audio_diag/spectral.py +204 -0
  17. audio_diag-0.1.0/audio_diag/timedomain.py +130 -0
  18. audio_diag-0.1.0/audio_diag/util.py +56 -0
  19. audio_diag-0.1.0/audio_diag.egg-info/PKG-INFO +186 -0
  20. audio_diag-0.1.0/audio_diag.egg-info/SOURCES.txt +33 -0
  21. audio_diag-0.1.0/audio_diag.egg-info/dependency_links.txt +1 -0
  22. audio_diag-0.1.0/audio_diag.egg-info/entry_points.txt +2 -0
  23. audio_diag-0.1.0/audio_diag.egg-info/requires.txt +1 -0
  24. audio_diag-0.1.0/audio_diag.egg-info/top_level.txt +1 -0
  25. audio_diag-0.1.0/pyproject.toml +36 -0
  26. audio_diag-0.1.0/report.schema.json +38 -0
  27. audio_diag-0.1.0/setup.cfg +4 -0
  28. audio_diag-0.1.0/tests/test_cli_robustness.py +190 -0
  29. audio_diag-0.1.0/tests/test_controlled.py +51 -0
  30. audio_diag-0.1.0/tests/test_corpus_expected.py +223 -0
  31. audio_diag-0.1.0/tests/test_ebu_runner.py +58 -0
  32. audio_diag-0.1.0/tests/test_ebu_tech3341.py +95 -0
  33. audio_diag-0.1.0/tests/test_ffmpeg_crosscheck.py +83 -0
  34. audio_diag-0.1.0/tests/test_finish.py +36 -0
  35. audio_diag-0.1.0/tests/test_profiles.py +30 -0
@@ -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,4 @@
1
+ include LICENSE
2
+ include README.md
3
+ include report.schema.json
4
+ recursive-include audio_diag *.json
@@ -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,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
@@ -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