bandcheck 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,29 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Boobalan Arjunan
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.
22
+
23
+ ---
24
+ This project depends on third-party libraries (numpy, scipy, soundfile,
25
+ librosa, pyloudnorm, scikit-learn, tqdm, langdetect, matplotlib, and
26
+ optionally openai-whisper, torch/torchaudio). Each carries its own license
27
+ (mostly BSD/MIT/Apache-2.0) -- check requirements.txt and each package's own
28
+ license before redistributing, especially if your GTV program has its own
29
+ licensing rules.
@@ -0,0 +1,109 @@
1
+ Metadata-Version: 2.4
2
+ Name: bandcheck
3
+ Version: 0.1.0
4
+ Summary: Find audio files whose real frequency range is lower than their sample rate claims (upsampled or lossy-encoded).
5
+ Author-email: Boobalan Arjunan <boobalan_arjunan@composermind.com>
6
+ Maintainer-email: Boobalan Arjunan <boobalan_arjunan@composermind.com>
7
+ License: MIT
8
+ Project-URL: Homepage, https://github.com/BoobalanArjunan/Bandcheck
9
+ Project-URL: Issues, https://github.com/BoobalanArjunan/Bandcheck/issues
10
+ Project-URL: Source, https://github.com/BoobalanArjunan/Bandcheck
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Topic :: Multimedia :: Sound/Audio :: Analysis
14
+ Requires-Python: >=3.9
15
+ Description-Content-Type: text/markdown
16
+ License-File: LICENSE
17
+ Requires-Dist: numpy>=1.23
18
+ Requires-Dist: scipy>=1.9
19
+ Requires-Dist: soundfile>=0.12
20
+ Provides-Extra: dev
21
+ Requires-Dist: pytest; extra == "dev"
22
+ Dynamic: license-file
23
+
24
+ # bandcheck
25
+
26
+ **Find audio files whose real frequency range is lower than their sample rate claims.**
27
+
28
+ A file saved as "48 kHz WAV" may hold audio that was recorded at 16 kHz and upsampled, or that went through an MP3 or AAC encoder before being converted back. Everything above the source's limit is empty. A header never shows this. bandcheck measures where the sound really stops and tells you which files hold much less than they claim.
29
+
30
+ > **Status: alpha (0.1.0).** Tested on generated audio, real speech recordings (LibriSpeech dev-clean), and 44.1 kHz studio music (MUSDB18-HQ). Run `scripts/real_data_check.py` (see `docs/REAL_DATA_CHECK.md`) on your own data.
31
+
32
+ ## Install
33
+
34
+ ```bash
35
+ pip install --pre bandcheck # after it is published; until then: pip install -e ".[dev]"
36
+ ```
37
+ Needs Python 3.9+. Depends only on numpy, scipy, soundfile.
38
+
39
+ ## Use
40
+
41
+ ```bash
42
+ bandcheck check my_dataset/ # every audio file in the folder
43
+ bandcheck check clip.wav
44
+ bandcheck check my_dataset/ --need-hz 8000 --out verdicts.csv
45
+ ```
46
+ Options: `--need-hz N` (fail files whose sound stops below N Hz, e.g. 8000 for a 16 kHz speech model), `--show-pass`, `--jobs 4`, `--out file.csv`.
47
+ The report ends with a table by sample rate (files, how many are flagged, median cut-off), so you see at once that, for example, one rate holds most of the problem files.
48
+
49
+ ## What the verdicts mean
50
+
51
+ | verdict | meaning | exit code |
52
+ |---|---|---|
53
+ | PASS | no sharp cut-off, or the sound reaches at least 90% of the way to the Nyquist frequency (or 19.5 kHz) | 0 |
54
+ | WARN | sound stops at 60-90% of the way to Nyquist. Typical of an MP3/AAC lowpass or a source one step lower in rate | 0 |
55
+ | FAIL | sound stops below 60% of the way, or below the `--need-hz` you asked for. Typical of upsampled audio | 1 |
56
+ | REVIEW | too short, silent, or too few loud parts to measure | 0 |
57
+ | ERROR | file could not be read | 0 |
58
+
59
+ For WARN and FAIL it lists the likely causes, for example `upsampled from 16000 Hz or a lossy encoder's lowpass (MP3/AAC)`. One cut-off frequency cannot tell an upsampled file from a lossy encode, so every candidate is listed and you decide.
60
+
61
+ ## How it works (short)
62
+ 1. Reads the file at its own sample rate (no resampling) and averages the channels.
63
+ 2. Averages the power spectrum over the loud parts (frames within 30 dB of the loudest).
64
+ 3. Looks for the sharpest drop in that average: the level just below a frequency compared with the level just above it. A drop of 25 dB or more, with nothing coming back above it, counts as a cut-off. The cut-off is reported where the level has fallen 10 dB.
65
+ 4. Compares the cut-off with the Nyquist frequency of the file.
66
+
67
+ ## What has been measured
68
+
69
+ ### 1. Real Speech Audio (LibriSpeech dev-clean, 200 files)
70
+ - **Original recordings:**
71
+ - 185 PASS, 11 WARN, 1 FAIL, 3 REVIEW, 0 ERROR.
72
+ - Flagged files exhibited genuine hardware or codec lowpass cut-offs (e.g. 4.8 kHz cut-off on `7850-286674-0017.flac`, 7.0–7.1 kHz on audiobook recordings).
73
+ - **Planted upsampling test (185 files evaluated):**
74
+ - From 8 kHz: 185 / 185 flagged as FAIL (median detected cut-off ratio: 1.03).
75
+ - From 11.025 kHz: 185 / 185 flagged as WARN (median detected cut-off ratio: 1.05).
76
+ - Detected cut-off sat within 3–5% of the source Nyquist frequency (consistent with resampling anti-aliasing filter transition).
77
+
78
+ ![Spectrogram Verification](real_check/spectrogram_verification.png)
79
+
80
+ ### 2. Real 44.1 kHz Studio Music (MUSDB18-HQ, 200 files)
81
+ - **Original recordings:**
82
+ - 197 PASS (98.5%), 2 WARN, 0 FAIL, 1 REVIEW, 0 ERROR.
83
+ - Flagged originals: 2 files (`other.wav` at 19.1 kHz, `drums.wav` at 19.3 kHz, both at 87% of 22.05 kHz Nyquist).
84
+ - **Planted upsampling test (197 files evaluated):**
85
+ - From 8 kHz: 191 / 197 flagged as FAIL (median ratio 1.02).
86
+ - From 11.025 kHz: 185 / 197 flagged as FAIL (median ratio 1.05).
87
+ - From 16 kHz: 162 / 197 flagged as FAIL (median ratio 1.10).
88
+ - **Planted MP3 encodes:**
89
+ - 64 kbps: 171 / 197 flagged, median cut-off 11.1 kHz.
90
+ - 128 kbps: 157 / 197 flagged, median cut-off 16.5 kHz.
91
+
92
+ ### 3. Synthetic Audio & Bug Injection
93
+ - Full-band audio at 16, 22.05, 44.1 and 48 kHz: no cut-off found, PASS.
94
+ - Audio lowpassed at 16, 19 and 20 kHz (44.1 and 48 kHz files) and 6-7 kHz (16 kHz files): cut-off found within 2% of where it was put.
95
+ - Audio upsampled from 8, 11.025, 16, 22.05 and 24 kHz: flagged every time; the cut-off sits about 4-11% above the source's Nyquist frequency, because resampler filters roll off a little past it.
96
+ - MP3 copies made with ffmpeg (mono): 64 kbps gave a cut-off near 16.6 kHz (WARN); 128, 192 and 256 kbps gave about 20.1 kHz (PASS).
97
+ - 79 tests pass. 25 deliberately planted bugs in the code were each caught by the tests (5 needed a stronger test first).
98
+
99
+ ## Limits (read these)
100
+ - **Defaults and thresholds:** The 25 dB drop, the 90% and 60% limits and the 19.5 kHz rule are judgement defaults set on generated audio; on 200 LibriSpeech dev-clean files, 12 were flagged (11 WARN, 1 FAIL), and caught 370 of 370 planted copies (from 8 and 11.025 kHz sources).
101
+ - **Cannot tell upsampled from lossy-encoded** from one number, and cannot tell either from a recording that really was made with a limited microphone or filter. It reports where the sound stops, not why.
102
+ - **Gradual roll-offs are not detected.** A gentle filter with no sharp edge, or dull-sounding music, passes.
103
+ - **Cut-offs below 2.5 kHz are not looked for.** Speech and music change a lot there.
104
+ - A quiet recording or one with long silences is judged on its louder parts only.
105
+ - The channels are averaged, so a file with one full-band channel and one band-limited channel passes.
106
+ - It tells you the bandwidth, not whether the audio is good.
107
+
108
+ ## Licence
109
+ MIT.
@@ -0,0 +1,86 @@
1
+ # bandcheck
2
+
3
+ **Find audio files whose real frequency range is lower than their sample rate claims.**
4
+
5
+ A file saved as "48 kHz WAV" may hold audio that was recorded at 16 kHz and upsampled, or that went through an MP3 or AAC encoder before being converted back. Everything above the source's limit is empty. A header never shows this. bandcheck measures where the sound really stops and tells you which files hold much less than they claim.
6
+
7
+ > **Status: alpha (0.1.0).** Tested on generated audio, real speech recordings (LibriSpeech dev-clean), and 44.1 kHz studio music (MUSDB18-HQ). Run `scripts/real_data_check.py` (see `docs/REAL_DATA_CHECK.md`) on your own data.
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ pip install --pre bandcheck # after it is published; until then: pip install -e ".[dev]"
13
+ ```
14
+ Needs Python 3.9+. Depends only on numpy, scipy, soundfile.
15
+
16
+ ## Use
17
+
18
+ ```bash
19
+ bandcheck check my_dataset/ # every audio file in the folder
20
+ bandcheck check clip.wav
21
+ bandcheck check my_dataset/ --need-hz 8000 --out verdicts.csv
22
+ ```
23
+ Options: `--need-hz N` (fail files whose sound stops below N Hz, e.g. 8000 for a 16 kHz speech model), `--show-pass`, `--jobs 4`, `--out file.csv`.
24
+ The report ends with a table by sample rate (files, how many are flagged, median cut-off), so you see at once that, for example, one rate holds most of the problem files.
25
+
26
+ ## What the verdicts mean
27
+
28
+ | verdict | meaning | exit code |
29
+ |---|---|---|
30
+ | PASS | no sharp cut-off, or the sound reaches at least 90% of the way to the Nyquist frequency (or 19.5 kHz) | 0 |
31
+ | WARN | sound stops at 60-90% of the way to Nyquist. Typical of an MP3/AAC lowpass or a source one step lower in rate | 0 |
32
+ | FAIL | sound stops below 60% of the way, or below the `--need-hz` you asked for. Typical of upsampled audio | 1 |
33
+ | REVIEW | too short, silent, or too few loud parts to measure | 0 |
34
+ | ERROR | file could not be read | 0 |
35
+
36
+ For WARN and FAIL it lists the likely causes, for example `upsampled from 16000 Hz or a lossy encoder's lowpass (MP3/AAC)`. One cut-off frequency cannot tell an upsampled file from a lossy encode, so every candidate is listed and you decide.
37
+
38
+ ## How it works (short)
39
+ 1. Reads the file at its own sample rate (no resampling) and averages the channels.
40
+ 2. Averages the power spectrum over the loud parts (frames within 30 dB of the loudest).
41
+ 3. Looks for the sharpest drop in that average: the level just below a frequency compared with the level just above it. A drop of 25 dB or more, with nothing coming back above it, counts as a cut-off. The cut-off is reported where the level has fallen 10 dB.
42
+ 4. Compares the cut-off with the Nyquist frequency of the file.
43
+
44
+ ## What has been measured
45
+
46
+ ### 1. Real Speech Audio (LibriSpeech dev-clean, 200 files)
47
+ - **Original recordings:**
48
+ - 185 PASS, 11 WARN, 1 FAIL, 3 REVIEW, 0 ERROR.
49
+ - Flagged files exhibited genuine hardware or codec lowpass cut-offs (e.g. 4.8 kHz cut-off on `7850-286674-0017.flac`, 7.0–7.1 kHz on audiobook recordings).
50
+ - **Planted upsampling test (185 files evaluated):**
51
+ - From 8 kHz: 185 / 185 flagged as FAIL (median detected cut-off ratio: 1.03).
52
+ - From 11.025 kHz: 185 / 185 flagged as WARN (median detected cut-off ratio: 1.05).
53
+ - Detected cut-off sat within 3–5% of the source Nyquist frequency (consistent with resampling anti-aliasing filter transition).
54
+
55
+ ![Spectrogram Verification](real_check/spectrogram_verification.png)
56
+
57
+ ### 2. Real 44.1 kHz Studio Music (MUSDB18-HQ, 200 files)
58
+ - **Original recordings:**
59
+ - 197 PASS (98.5%), 2 WARN, 0 FAIL, 1 REVIEW, 0 ERROR.
60
+ - Flagged originals: 2 files (`other.wav` at 19.1 kHz, `drums.wav` at 19.3 kHz, both at 87% of 22.05 kHz Nyquist).
61
+ - **Planted upsampling test (197 files evaluated):**
62
+ - From 8 kHz: 191 / 197 flagged as FAIL (median ratio 1.02).
63
+ - From 11.025 kHz: 185 / 197 flagged as FAIL (median ratio 1.05).
64
+ - From 16 kHz: 162 / 197 flagged as FAIL (median ratio 1.10).
65
+ - **Planted MP3 encodes:**
66
+ - 64 kbps: 171 / 197 flagged, median cut-off 11.1 kHz.
67
+ - 128 kbps: 157 / 197 flagged, median cut-off 16.5 kHz.
68
+
69
+ ### 3. Synthetic Audio & Bug Injection
70
+ - Full-band audio at 16, 22.05, 44.1 and 48 kHz: no cut-off found, PASS.
71
+ - Audio lowpassed at 16, 19 and 20 kHz (44.1 and 48 kHz files) and 6-7 kHz (16 kHz files): cut-off found within 2% of where it was put.
72
+ - Audio upsampled from 8, 11.025, 16, 22.05 and 24 kHz: flagged every time; the cut-off sits about 4-11% above the source's Nyquist frequency, because resampler filters roll off a little past it.
73
+ - MP3 copies made with ffmpeg (mono): 64 kbps gave a cut-off near 16.6 kHz (WARN); 128, 192 and 256 kbps gave about 20.1 kHz (PASS).
74
+ - 79 tests pass. 25 deliberately planted bugs in the code were each caught by the tests (5 needed a stronger test first).
75
+
76
+ ## Limits (read these)
77
+ - **Defaults and thresholds:** The 25 dB drop, the 90% and 60% limits and the 19.5 kHz rule are judgement defaults set on generated audio; on 200 LibriSpeech dev-clean files, 12 were flagged (11 WARN, 1 FAIL), and caught 370 of 370 planted copies (from 8 and 11.025 kHz sources).
78
+ - **Cannot tell upsampled from lossy-encoded** from one number, and cannot tell either from a recording that really was made with a limited microphone or filter. It reports where the sound stops, not why.
79
+ - **Gradual roll-offs are not detected.** A gentle filter with no sharp edge, or dull-sounding music, passes.
80
+ - **Cut-offs below 2.5 kHz are not looked for.** Speech and music change a lot there.
81
+ - A quiet recording or one with long silences is judged on its louder parts only.
82
+ - The channels are averaged, so a file with one full-band channel and one band-limited channel passes.
83
+ - It tells you the bandwidth, not whether the audio is good.
84
+
85
+ ## Licence
86
+ MIT.
@@ -0,0 +1,40 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "bandcheck"
7
+ dynamic = ["version"]
8
+ description = "Find audio files whose real frequency range is lower than their sample rate claims (upsampled or lossy-encoded)."
9
+ readme = "README.md"
10
+ license = { text = "MIT" }
11
+ requires-python = ">=3.9"
12
+ authors = [
13
+ { name = "Boobalan Arjunan", email = "boobalan_arjunan@composermind.com" },
14
+ ]
15
+ maintainers = [
16
+ { name = "Boobalan Arjunan", email = "boobalan_arjunan@composermind.com" },
17
+ ]
18
+ dependencies = ["numpy>=1.23", "scipy>=1.9", "soundfile>=0.12"]
19
+ classifiers = ["Development Status :: 3 - Alpha", "License :: OSI Approved :: MIT License", "Topic :: Multimedia :: Sound/Audio :: Analysis"]
20
+
21
+ [project.urls]
22
+ Homepage = "https://github.com/BoobalanArjunan/Bandcheck"
23
+ Issues = "https://github.com/BoobalanArjunan/Bandcheck/issues"
24
+ Source = "https://github.com/BoobalanArjunan/Bandcheck"
25
+
26
+ [project.optional-dependencies]
27
+ dev = ["pytest"]
28
+
29
+ [project.scripts]
30
+ bandcheck = "bandcheck.cli:main"
31
+
32
+ [tool.setuptools.dynamic]
33
+ version = { attr = "bandcheck._version.__version__" }
34
+
35
+ [tool.setuptools.packages.find]
36
+ where = ["src"]
37
+
38
+ [tool.pytest.ini_options]
39
+ testpaths = ["tests"]
40
+ pythonpath = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,5 @@
1
+ """bandcheck: does an audio file really hold the frequency range its sample rate promises?"""
2
+ from ._version import __version__
3
+ from .check import Verdict, check_file, judge
4
+
5
+ __all__ = ["__version__", "Verdict", "check_file", "judge"]
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,89 @@
1
+ """Compare the real bandwidth of a file with what its sample rate promises, and give a verdict."""
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+ from typing import List, Optional
7
+
8
+ import numpy as np
9
+
10
+ from .spectrum import Spectrum, find_cliff, load_mono, mean_spectrum
11
+
12
+ FULL_FRACTION = 0.90 # cut-off at or above this share of the Nyquist frequency: full band
13
+ FULL_HZ = 19500.0 # ... or at or above this frequency, whatever the sample rate
14
+ FAKE_FRACTION = 0.60 # cut-off below this share of Nyquist: the file holds much less than it claims
15
+ NATIVE_RATES = (8000, 11025, 12000, 16000, 22050, 24000, 32000, 44100)
16
+ RATE_LOW, RATE_HIGH = 0.90, 1.12 # the measured cut-off sits at 0.9 to 1.12 times the source's Nyquist (resampler filters)
17
+
18
+ OK, LIMITED, FAKE, UNSURE, ERROR = "OK", "LIMITED", "FAKE", "UNSURE", "ERROR"
19
+
20
+
21
+ @dataclass
22
+ class Verdict:
23
+ file: str
24
+ sample_rate: Optional[int]
25
+ status: str
26
+ cutoff_hz: Optional[float]
27
+ reason: str
28
+ likely_source: str = ""
29
+ seconds: float = 0.0
30
+
31
+ @property
32
+ def level(self) -> str:
33
+ return {OK: "PASS", LIMITED: "WARN", UNSURE: "REVIEW", FAKE: "FAIL", ERROR: "ERROR"}[self.status]
34
+
35
+ @property
36
+ def fraction(self) -> Optional[float]:
37
+ return None if (self.cutoff_hz is None or not self.sample_rate) else self.cutoff_hz / (self.sample_rate / 2)
38
+
39
+
40
+ def source_guess(cutoff_hz: float, sample_rate: int) -> str:
41
+ """One cut-off cannot tell an upsampled file from a lossy encode, so every candidate is listed."""
42
+ rates = [r for r in NATIVE_RATES if r < sample_rate and RATE_LOW <= cutoff_hz / (r / 2) <= RATE_HIGH]
43
+ parts = (["upsampled from " + " or ".join(f"{r} Hz" for r in rates)] if rates else []) + ["a lossy encoder's lowpass (MP3/AAC)"]
44
+ return " or ".join(parts)
45
+
46
+
47
+ def judge(sp: Optional[Spectrum], file: str = "", sample_rate: Optional[int] = None, need_hz: Optional[float] = None) -> Verdict:
48
+ if sp is None:
49
+ return Verdict(file, sample_rate, UNSURE, None, "too short, silent or too few loud parts to measure a bandwidth")
50
+ sr, nyq, secs = sp.sample_rate, sp.sample_rate / 2, sp.active_seconds
51
+ c = find_cliff(sp)
52
+ if c is None:
53
+ return Verdict(file, sr, OK, None, "no sharp cut-off found", "", secs)
54
+ frac = c.cutoff_hz / nyq
55
+ kHz = c.cutoff_hz / 1000
56
+ if need_hz is not None and c.cutoff_hz < need_hz:
57
+ return Verdict(file, sr, FAKE, c.cutoff_hz, f"sound stops at {kHz:.1f} kHz, but {need_hz / 1000:g} kHz is required", source_guess(c.cutoff_hz, sr), secs)
58
+ if frac >= FULL_FRACTION or c.cutoff_hz >= FULL_HZ:
59
+ return Verdict(file, sr, OK, c.cutoff_hz, f"sound reaches {kHz:.1f} kHz ({100 * frac:.0f}% of the way to {nyq / 1000:g} kHz)", "", secs)
60
+ if frac < FAKE_FRACTION:
61
+ return Verdict(file, sr, FAKE, c.cutoff_hz, f"sound stops at {kHz:.1f} kHz, only {100 * frac:.0f}% of the way to {nyq / 1000:g} kHz", source_guess(c.cutoff_hz, sr), secs)
62
+ return Verdict(file, sr, LIMITED, c.cutoff_hz, f"sound stops at {kHz:.1f} kHz ({100 * frac:.0f}% of the way to {nyq / 1000:g} kHz)", source_guess(c.cutoff_hz, sr), secs)
63
+
64
+
65
+ def check_file(path, need_hz: Optional[float] = None) -> Verdict:
66
+ p = Path(path)
67
+ try:
68
+ x, sr = load_mono(p)
69
+ except Exception as e: # unreadable audio
70
+ return Verdict(p.name, None, ERROR, None, f"could not read audio: {e}")
71
+ return judge(mean_spectrum(x, sr), p.name, sr, need_hz)
72
+
73
+
74
+ def summary_by_rate(vs: List[Verdict]) -> List[dict]:
75
+ """One row per sample rate: files, how many are flagged (WARN or FAIL), median cut-off among those with a cut-off."""
76
+ rows = {}
77
+ for v in vs:
78
+ if v.sample_rate is None:
79
+ continue
80
+ r = rows.setdefault(v.sample_rate, {"rate": v.sample_rate, "files": 0, "flagged": 0, "cutoffs": []})
81
+ r["files"] += 1
82
+ r["flagged"] += v.level in ("WARN", "FAIL")
83
+ if v.cutoff_hz:
84
+ r["cutoffs"].append(v.cutoff_hz)
85
+ out = []
86
+ for r in sorted(rows.values(), key=lambda r: r["rate"]):
87
+ out.append({"rate": r["rate"], "files": r["files"], "flagged": r["flagged"],
88
+ "median_cutoff_hz": float(np.median(r["cutoffs"])) if r["cutoffs"] else None})
89
+ return out
@@ -0,0 +1,54 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import sys
5
+ from concurrent.futures import ProcessPoolExecutor
6
+ from functools import partial
7
+ from pathlib import Path
8
+
9
+ from . import __version__
10
+ from .check import check_file
11
+ from .report import text, write_csv
12
+
13
+ AUDIO_EXT = {".wav", ".flac", ".mp3", ".ogg", ".m4a", ".aiff", ".aif"}
14
+
15
+
16
+ def find_audio(source: Path):
17
+ if source.is_file():
18
+ return [source]
19
+ return sorted(p for p in source.rglob("*") if p.suffix.lower() in AUDIO_EXT and not p.name.startswith("._") and "__MACOSX" not in p.parts)
20
+
21
+
22
+ def main(argv=None) -> int:
23
+ ap = argparse.ArgumentParser(prog="bandcheck", description="Find audio files whose real frequency range is lower than their sample rate claims.")
24
+ ap.add_argument("--version", action="version", version=f"bandcheck {__version__}")
25
+ sub = ap.add_subparsers(dest="cmd", required=True)
26
+ c = sub.add_parser("check", help="check one file or every audio file in a folder")
27
+ c.add_argument("source", type=Path)
28
+ c.add_argument("--need-hz", type=float, help="fail files whose sound stops below this frequency (for example 8000 for 16 kHz speech models)")
29
+ c.add_argument("--out", type=Path, help="write every verdict to this CSV")
30
+ c.add_argument("--show-pass", action="store_true", help="also list files that passed")
31
+ c.add_argument("--jobs", type=int, default=1, help="files analysed in parallel (default 1)")
32
+ a = ap.parse_args(argv)
33
+
34
+ if not a.source.exists():
35
+ print(f"error: {a.source} not found", file=sys.stderr)
36
+ return 2
37
+ files = find_audio(a.source)
38
+ if not files:
39
+ print("error: no audio files found", file=sys.stderr)
40
+ return 2
41
+ job = partial(check_file, need_hz=a.need_hz)
42
+ if a.jobs > 1:
43
+ with ProcessPoolExecutor(a.jobs) as ex:
44
+ vs = list(ex.map(job, files, chunksize=1))
45
+ else:
46
+ vs = [job(f) for f in files]
47
+ print(text(vs, a.show_pass))
48
+ if a.out:
49
+ write_csv(a.out, vs)
50
+ return 1 if any(v.level == "FAIL" for v in vs) else 0
51
+
52
+
53
+ if __name__ == "__main__":
54
+ sys.exit(main())
@@ -0,0 +1,39 @@
1
+ from __future__ import annotations
2
+
3
+ import csv
4
+ from collections import Counter
5
+ from typing import List
6
+
7
+ from .check import Verdict, summary_by_rate
8
+
9
+
10
+ def summary_line(vs: List[Verdict]) -> str:
11
+ c = Counter(v.level for v in vs)
12
+ return (f"{len(vs)} files: {c['PASS']} pass, {c['WARN']} warn (band-limited), {c['REVIEW']} need a look, "
13
+ f"{c['FAIL']} fail (much less bandwidth than claimed), {c['ERROR']} unreadable")
14
+
15
+
16
+ def text(vs: List[Verdict], show_pass: bool = False) -> str:
17
+ L = []
18
+ for v in vs:
19
+ if v.level == "PASS" and not show_pass:
20
+ continue
21
+ src = f" likely: {v.likely_source}" if v.likely_source else ""
22
+ L.append(f"{v.level:6} {v.file} {v.sample_rate or '-'} Hz {v.reason}{src}")
23
+ rows = summary_by_rate(vs)
24
+ if rows:
25
+ L += ["", "By sample rate:"]
26
+ for r in rows:
27
+ mc = f", median cut-off {r['median_cutoff_hz'] / 1000:.1f} kHz" if r["median_cutoff_hz"] else ""
28
+ L.append(f" {r['rate']} Hz: {r['files']} files, {r['flagged']} flagged{mc}")
29
+ L += ["", summary_line(vs)]
30
+ return "\n".join(L)
31
+
32
+
33
+ def write_csv(path, vs: List[Verdict]) -> None:
34
+ with open(path, "w", newline="", encoding="utf-8") as f:
35
+ w = csv.writer(f)
36
+ w.writerow(["file", "sample_rate", "cutoff_hz", "cutoff_fraction", "verdict", "reason", "likely_source", "seconds_analysed"])
37
+ for v in vs:
38
+ w.writerow([v.file, v.sample_rate or "", "" if v.cutoff_hz is None else round(v.cutoff_hz, 1),
39
+ "" if v.fraction is None else round(v.fraction, 3), v.level, v.reason, v.likely_source, round(v.seconds, 1)])
@@ -0,0 +1,108 @@
1
+ """Audio -> long-term average spectrum, and the search for a sharp cut-off in it.
2
+
3
+ A file that was upsampled, or lowpassed by a lossy encoder, has real sound up to some frequency and almost nothing
4
+ above it, with a steep drop in between (a "cliff"). Natural sound fades gradually. So BandCheck looks for the steepest
5
+ drop in the average spectrum and reports where it is. Light on purpose: numpy, scipy, soundfile only.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass
10
+ from typing import Optional
11
+
12
+ import numpy as np
13
+ import soundfile as sf
14
+ from scipy.signal import medfilt, stft
15
+
16
+ MAX_SECONDS = 120.0 # long files: only the middle 2 minutes are used
17
+ MIN_SECONDS = 2.0
18
+ ACTIVE_FLOOR_DB = -30.0 # frames more than 30 dB below the loudest frame are ignored
19
+ MIN_ACTIVE_FRAMES = 4
20
+ SEARCH_FROM_HZ = 2500.0 # a cliff below this is not looked for (speech and music change a lot down there)
21
+ EDGE_MARGIN_HZ = 500.0 # a cliff must leave at least this much spectrum above it to compare
22
+ SIDE_GAP_HZ, SIDE_SPAN_HZ = 200.0, 1000.0 # compare the level just below the cliff with the level just above it
23
+ GRID_HZ = 50.0
24
+ CLIFF_DB = 25.0 # the drop that counts as a cliff
25
+ KNEE_DB = 10.0 # the cut-off is reported where the level has fallen this far below the band's own level
26
+ SMOOTH_HZ = 100.0
27
+
28
+
29
+ @dataclass
30
+ class Spectrum:
31
+ freqs: np.ndarray
32
+ level_db: np.ndarray # average power per bin, dB, relative to its own maximum
33
+ sample_rate: int
34
+ seconds: float # length of the audio analysed
35
+ active_seconds: float # time in frames that were loud enough to use
36
+
37
+
38
+ @dataclass
39
+ class Cliff:
40
+ cutoff_hz: float
41
+ drop_db: float # level just below the cut-off minus level just above it
42
+ floor_db: float # median level of everything above the cut-off, relative to just below it
43
+
44
+
45
+ def load_mono(path, max_seconds: float = MAX_SECONDS):
46
+ """Reads the file at its OWN sample rate (no resampling) -> (mono float64, sample rate)."""
47
+ x, sr = sf.read(str(path), dtype="float64", always_2d=True)
48
+ x = x.mean(axis=1)
49
+ n = int(max_seconds * sr)
50
+ if len(x) > n:
51
+ s = (len(x) - n) // 2
52
+ x = x[s:s + n]
53
+ return x, int(sr)
54
+
55
+
56
+ def mean_spectrum(x: np.ndarray, sr: int) -> Optional[Spectrum]:
57
+ """Average power spectrum over the loud frames. None if the audio is too short, silent or has too few loud frames."""
58
+ if len(x) < MIN_SECONDS * sr or not np.any(np.abs(x) > 1e-9):
59
+ return None
60
+ n = int(2 ** round(np.log2(max(sr / 10.0, 256)))) # about 10 Hz per bin
61
+ n = min(max(n, 512), 8192)
62
+ _, _, Z = stft(x, fs=sr, nperseg=n, noverlap=n // 2, boundary=None, padded=False)
63
+ P = np.abs(Z) ** 2
64
+ energy = P.sum(axis=0)
65
+ if energy.max() <= 0:
66
+ return None
67
+ active = energy > energy.max() * 10 ** (ACTIVE_FLOOR_DB / 10)
68
+ if active.sum() < MIN_ACTIVE_FRAMES:
69
+ return None
70
+ S = P[:, active].mean(axis=1)
71
+ L = 10 * np.log10(S + 1e-30)
72
+ L -= L.max()
73
+ freqs = np.linspace(0, sr / 2, len(L))
74
+ hop = n // 2
75
+ return Spectrum(freqs, L, sr, len(x) / sr, float(active.sum() * hop / sr))
76
+
77
+
78
+ def find_cliff(sp: Spectrum) -> Optional[Cliff]:
79
+ """The sharpest drop (at least CLIFF_DB) in the smoothed average spectrum, or None if the spectrum fades gradually."""
80
+ f, nyq = sp.freqs, sp.sample_rate / 2
81
+ df = f[1] - f[0]
82
+ k = max(3, int(SMOOTH_HZ / df) | 1)
83
+ Ls = medfilt(sp.level_db, k)
84
+ best_g, best_d = None, 0.0
85
+ for g in np.arange(SEARCH_FROM_HZ, nyq - EDGE_MARGIN_HZ, GRID_HZ):
86
+ lo = (f >= g - SIDE_SPAN_HZ) & (f < g - SIDE_GAP_HZ)
87
+ hi = (f > g + SIDE_GAP_HZ) & (f <= min(g + SIDE_SPAN_HZ, nyq))
88
+ if lo.sum() < 3 or hi.sum() < 3:
89
+ continue
90
+ d = float(np.median(Ls[lo]) - np.median(Ls[hi]))
91
+ if d > best_d:
92
+ best_g, best_d = g, d
93
+ if best_g is None or best_d < CLIFF_DB:
94
+ return None
95
+ lo = (f >= best_g - SIDE_SPAN_HZ) & (f < best_g - SIDE_GAP_HZ)
96
+ hi = (f > best_g + SIDE_GAP_HZ) & (f <= min(best_g + SIDE_SPAN_HZ, nyq))
97
+ below, above = float(np.median(Ls[lo])), float(np.median(Ls[hi]))
98
+ knee = below - min(KNEE_DB, (below - above) / 2) # where the level has fallen 10 dB: the edge of the usable band
99
+ win = (f >= best_g - SIDE_SPAN_HZ) & (f <= best_g + SIDE_SPAN_HZ)
100
+ idx = np.nonzero(win & (Ls <= knee))[0]
101
+ cutoff = float(f[idx[0]]) if len(idx) else float(best_g)
102
+ rest = f > cutoff + SIDE_GAP_HZ
103
+ if rest.sum() < 3:
104
+ return None
105
+ floor = float(np.median(Ls[rest]) - below)
106
+ if -floor < CLIFF_DB - 5: # content comes back above the drop: a notch or a gap, not a cut-off
107
+ return None
108
+ return Cliff(cutoff, best_d, floor)
@@ -0,0 +1,109 @@
1
+ Metadata-Version: 2.4
2
+ Name: bandcheck
3
+ Version: 0.1.0
4
+ Summary: Find audio files whose real frequency range is lower than their sample rate claims (upsampled or lossy-encoded).
5
+ Author-email: Boobalan Arjunan <boobalan_arjunan@composermind.com>
6
+ Maintainer-email: Boobalan Arjunan <boobalan_arjunan@composermind.com>
7
+ License: MIT
8
+ Project-URL: Homepage, https://github.com/BoobalanArjunan/Bandcheck
9
+ Project-URL: Issues, https://github.com/BoobalanArjunan/Bandcheck/issues
10
+ Project-URL: Source, https://github.com/BoobalanArjunan/Bandcheck
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Topic :: Multimedia :: Sound/Audio :: Analysis
14
+ Requires-Python: >=3.9
15
+ Description-Content-Type: text/markdown
16
+ License-File: LICENSE
17
+ Requires-Dist: numpy>=1.23
18
+ Requires-Dist: scipy>=1.9
19
+ Requires-Dist: soundfile>=0.12
20
+ Provides-Extra: dev
21
+ Requires-Dist: pytest; extra == "dev"
22
+ Dynamic: license-file
23
+
24
+ # bandcheck
25
+
26
+ **Find audio files whose real frequency range is lower than their sample rate claims.**
27
+
28
+ A file saved as "48 kHz WAV" may hold audio that was recorded at 16 kHz and upsampled, or that went through an MP3 or AAC encoder before being converted back. Everything above the source's limit is empty. A header never shows this. bandcheck measures where the sound really stops and tells you which files hold much less than they claim.
29
+
30
+ > **Status: alpha (0.1.0).** Tested on generated audio, real speech recordings (LibriSpeech dev-clean), and 44.1 kHz studio music (MUSDB18-HQ). Run `scripts/real_data_check.py` (see `docs/REAL_DATA_CHECK.md`) on your own data.
31
+
32
+ ## Install
33
+
34
+ ```bash
35
+ pip install --pre bandcheck # after it is published; until then: pip install -e ".[dev]"
36
+ ```
37
+ Needs Python 3.9+. Depends only on numpy, scipy, soundfile.
38
+
39
+ ## Use
40
+
41
+ ```bash
42
+ bandcheck check my_dataset/ # every audio file in the folder
43
+ bandcheck check clip.wav
44
+ bandcheck check my_dataset/ --need-hz 8000 --out verdicts.csv
45
+ ```
46
+ Options: `--need-hz N` (fail files whose sound stops below N Hz, e.g. 8000 for a 16 kHz speech model), `--show-pass`, `--jobs 4`, `--out file.csv`.
47
+ The report ends with a table by sample rate (files, how many are flagged, median cut-off), so you see at once that, for example, one rate holds most of the problem files.
48
+
49
+ ## What the verdicts mean
50
+
51
+ | verdict | meaning | exit code |
52
+ |---|---|---|
53
+ | PASS | no sharp cut-off, or the sound reaches at least 90% of the way to the Nyquist frequency (or 19.5 kHz) | 0 |
54
+ | WARN | sound stops at 60-90% of the way to Nyquist. Typical of an MP3/AAC lowpass or a source one step lower in rate | 0 |
55
+ | FAIL | sound stops below 60% of the way, or below the `--need-hz` you asked for. Typical of upsampled audio | 1 |
56
+ | REVIEW | too short, silent, or too few loud parts to measure | 0 |
57
+ | ERROR | file could not be read | 0 |
58
+
59
+ For WARN and FAIL it lists the likely causes, for example `upsampled from 16000 Hz or a lossy encoder's lowpass (MP3/AAC)`. One cut-off frequency cannot tell an upsampled file from a lossy encode, so every candidate is listed and you decide.
60
+
61
+ ## How it works (short)
62
+ 1. Reads the file at its own sample rate (no resampling) and averages the channels.
63
+ 2. Averages the power spectrum over the loud parts (frames within 30 dB of the loudest).
64
+ 3. Looks for the sharpest drop in that average: the level just below a frequency compared with the level just above it. A drop of 25 dB or more, with nothing coming back above it, counts as a cut-off. The cut-off is reported where the level has fallen 10 dB.
65
+ 4. Compares the cut-off with the Nyquist frequency of the file.
66
+
67
+ ## What has been measured
68
+
69
+ ### 1. Real Speech Audio (LibriSpeech dev-clean, 200 files)
70
+ - **Original recordings:**
71
+ - 185 PASS, 11 WARN, 1 FAIL, 3 REVIEW, 0 ERROR.
72
+ - Flagged files exhibited genuine hardware or codec lowpass cut-offs (e.g. 4.8 kHz cut-off on `7850-286674-0017.flac`, 7.0–7.1 kHz on audiobook recordings).
73
+ - **Planted upsampling test (185 files evaluated):**
74
+ - From 8 kHz: 185 / 185 flagged as FAIL (median detected cut-off ratio: 1.03).
75
+ - From 11.025 kHz: 185 / 185 flagged as WARN (median detected cut-off ratio: 1.05).
76
+ - Detected cut-off sat within 3–5% of the source Nyquist frequency (consistent with resampling anti-aliasing filter transition).
77
+
78
+ ![Spectrogram Verification](real_check/spectrogram_verification.png)
79
+
80
+ ### 2. Real 44.1 kHz Studio Music (MUSDB18-HQ, 200 files)
81
+ - **Original recordings:**
82
+ - 197 PASS (98.5%), 2 WARN, 0 FAIL, 1 REVIEW, 0 ERROR.
83
+ - Flagged originals: 2 files (`other.wav` at 19.1 kHz, `drums.wav` at 19.3 kHz, both at 87% of 22.05 kHz Nyquist).
84
+ - **Planted upsampling test (197 files evaluated):**
85
+ - From 8 kHz: 191 / 197 flagged as FAIL (median ratio 1.02).
86
+ - From 11.025 kHz: 185 / 197 flagged as FAIL (median ratio 1.05).
87
+ - From 16 kHz: 162 / 197 flagged as FAIL (median ratio 1.10).
88
+ - **Planted MP3 encodes:**
89
+ - 64 kbps: 171 / 197 flagged, median cut-off 11.1 kHz.
90
+ - 128 kbps: 157 / 197 flagged, median cut-off 16.5 kHz.
91
+
92
+ ### 3. Synthetic Audio & Bug Injection
93
+ - Full-band audio at 16, 22.05, 44.1 and 48 kHz: no cut-off found, PASS.
94
+ - Audio lowpassed at 16, 19 and 20 kHz (44.1 and 48 kHz files) and 6-7 kHz (16 kHz files): cut-off found within 2% of where it was put.
95
+ - Audio upsampled from 8, 11.025, 16, 22.05 and 24 kHz: flagged every time; the cut-off sits about 4-11% above the source's Nyquist frequency, because resampler filters roll off a little past it.
96
+ - MP3 copies made with ffmpeg (mono): 64 kbps gave a cut-off near 16.6 kHz (WARN); 128, 192 and 256 kbps gave about 20.1 kHz (PASS).
97
+ - 79 tests pass. 25 deliberately planted bugs in the code were each caught by the tests (5 needed a stronger test first).
98
+
99
+ ## Limits (read these)
100
+ - **Defaults and thresholds:** The 25 dB drop, the 90% and 60% limits and the 19.5 kHz rule are judgement defaults set on generated audio; on 200 LibriSpeech dev-clean files, 12 were flagged (11 WARN, 1 FAIL), and caught 370 of 370 planted copies (from 8 and 11.025 kHz sources).
101
+ - **Cannot tell upsampled from lossy-encoded** from one number, and cannot tell either from a recording that really was made with a limited microphone or filter. It reports where the sound stops, not why.
102
+ - **Gradual roll-offs are not detected.** A gentle filter with no sharp edge, or dull-sounding music, passes.
103
+ - **Cut-offs below 2.5 kHz are not looked for.** Speech and music change a lot there.
104
+ - A quiet recording or one with long silences is judged on its louder parts only.
105
+ - The channels are averaged, so a file with one full-band channel and one band-limited channel passes.
106
+ - It tells you the bandwidth, not whether the audio is good.
107
+
108
+ ## Licence
109
+ MIT.
@@ -0,0 +1,16 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/bandcheck/__init__.py
5
+ src/bandcheck/_version.py
6
+ src/bandcheck/check.py
7
+ src/bandcheck/cli.py
8
+ src/bandcheck/report.py
9
+ src/bandcheck/spectrum.py
10
+ src/bandcheck.egg-info/PKG-INFO
11
+ src/bandcheck.egg-info/SOURCES.txt
12
+ src/bandcheck.egg-info/dependency_links.txt
13
+ src/bandcheck.egg-info/entry_points.txt
14
+ src/bandcheck.egg-info/requires.txt
15
+ src/bandcheck.egg-info/top_level.txt
16
+ tests/test_bandcheck.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ bandcheck = bandcheck.cli:main
@@ -0,0 +1,6 @@
1
+ numpy>=1.23
2
+ scipy>=1.9
3
+ soundfile>=0.12
4
+
5
+ [dev]
6
+ pytest
@@ -0,0 +1 @@
1
+ bandcheck
@@ -0,0 +1,359 @@
1
+ import csv
2
+
3
+ import numpy as np
4
+ import pytest
5
+ import soundfile as sf
6
+ from scipy.signal import firwin, fftconvolve
7
+
8
+ import bandcheck.check as chk
9
+ import synth
10
+ from bandcheck.check import (FAKE, LIMITED, OK, UNSURE, check_file, judge, source_guess, summary_by_rate)
11
+ from bandcheck.cli import find_audio, main
12
+ from bandcheck.report import summary_line, text, write_csv
13
+ from bandcheck.spectrum import Cliff, Spectrum, find_cliff, load_mono, mean_spectrum
14
+ from conftest import cut, full, up, write
15
+
16
+
17
+ def spec_of(x, sr):
18
+ return mean_spectrum(synth.to_pcm16(x).astype(float) / 32767, sr)
19
+
20
+
21
+ def fake_spec(sr=44100, secs=10.0):
22
+ f = np.linspace(0, sr / 2, 2049)
23
+ return Spectrum(f, np.zeros_like(f), sr, secs, secs)
24
+
25
+
26
+ # ---------------------------------------------------------------- full-band audio passes
27
+ @pytest.mark.parametrize("sr", [16000, 22050, 44100, 48000])
28
+ def test_full_band_audio_passes(tmp_path, sr):
29
+ v = check_file(write(tmp_path / "a.wav", full(sr), sr))
30
+ assert v.status == OK and v.level == "PASS" and v.cutoff_hz is None
31
+ assert v.sample_rate == sr and v.likely_source == ""
32
+
33
+
34
+ @pytest.mark.parametrize("tilt", [1.0, 1.5, 2.0, 2.5])
35
+ def test_steeply_falling_natural_spectra_have_no_cliff(tilt):
36
+ """Dull sound that fades gradually is not a cut-off."""
37
+ assert find_cliff(spec_of(synth.make_full(44100, 12, 1, tilt=tilt), 44100)) is None
38
+
39
+
40
+ def test_quiet_recordings_are_not_flagged(tmp_path):
41
+ for level in (0.001, 0.01):
42
+ assert check_file(write(tmp_path / "q.wav", synth.make_full(44100, 12, 2, level=level), 44100)).status == OK
43
+
44
+
45
+ def test_a_gap_in_the_spectrum_is_not_a_cutoff():
46
+ """A band-stop (sound on both sides) is a notch, not the end of the band."""
47
+ x = full(44100)
48
+ band = firwin(2001, [10000, 11500], pass_zero=False, fs=44100)
49
+ y = x - fftconvolve(x, band, mode="same")
50
+ assert find_cliff(spec_of(y, 44100)) is None
51
+
52
+
53
+ # ---------------------------------------------------------------- lowpass (lossy-encoder-like) detection
54
+ @pytest.mark.parametrize("sr,fc", [(44100, 16000), (44100, 19000), (44100, 20000), (48000, 16000), (48000, 20000), (16000, 6000), (16000, 7000)])
55
+ def test_cutoff_is_found_where_it_was_put(sr, fc):
56
+ c = find_cliff(spec_of(cut(sr, fc), sr))
57
+ assert c is not None
58
+ assert abs(c.cutoff_hz - fc) / fc < 0.02
59
+ assert c.drop_db > 30 and c.floor_db < -25
60
+
61
+
62
+ def test_cutoff_found_for_other_seeds():
63
+ for seed in (1, 2, 3):
64
+ c = find_cliff(spec_of(cut(44100, 17000, seed), 44100))
65
+ assert c is not None and abs(c.cutoff_hz - 17000) < 400
66
+
67
+
68
+ @pytest.mark.parametrize("sr,fc,status", [
69
+ (44100, 16000, LIMITED), (48000, 16000, LIMITED), (48000, 19000, LIMITED),
70
+ (44100, 20000, OK), (48000, 20000, OK), (16000, 7500, OK), (48000, 10000, FAKE), (44100, 12000, FAKE)])
71
+ def test_verdict_for_lowpassed_audio(tmp_path, sr, fc, status):
72
+ v = check_file(write(tmp_path / "a.wav", cut(sr, fc), sr))
73
+ assert v.status == status, v.reason
74
+
75
+
76
+ # ---------------------------------------------------------------- upsampled audio
77
+ @pytest.mark.parametrize("sr,src,status", [(16000, 8000, FAKE), (16000, 11025, LIMITED), (44100, 16000, FAKE), (44100, 22050, FAKE),
78
+ (48000, 16000, FAKE), (48000, 24000, FAKE), (48000, 8000, FAKE)])
79
+ def test_upsampled_audio_is_flagged_and_names_its_source(tmp_path, sr, src, status):
80
+ """Upsampling to a rate close to the source (11.025 to 16 kHz keeps 72% of the band) is a warning; far from it, a failure."""
81
+ v = check_file(write(tmp_path / "a.wav", up(sr, src), sr))
82
+ assert v.status == status, v.reason
83
+ assert 0.9 * src / 2 <= v.cutoff_hz <= 1.12 * src / 2
84
+ assert f"upsampled from {src} Hz" in v.likely_source or f"{src} Hz" in v.likely_source
85
+
86
+
87
+ def test_the_reason_says_how_much_is_missing(tmp_path):
88
+ v = check_file(write(tmp_path / "a.wav", up(48000, 16000), 48000))
89
+ assert "kHz" in v.reason and "% of the way to 24 kHz" in v.reason
90
+
91
+
92
+ def test_source_guess_lists_every_candidate_and_the_lossy_option():
93
+ g = source_guess(5781, 16000)
94
+ assert "11025 Hz" in g and "12000 Hz" in g and "lossy" in g
95
+ assert source_guess(8636, 48000).startswith("upsampled from 16000 Hz")
96
+ assert "upsampled" not in source_guess(3000, 48000) and "lossy" in source_guess(3000, 48000)
97
+ assert "from 48000" not in source_guess(24000, 48000) # never "upsampled from" the file's own rate or higher
98
+ assert "from 44100" not in source_guess(20000, 44100)
99
+
100
+
101
+ # ---------------------------------------------------------------- need-hz
102
+ def test_need_hz_fails_files_that_stop_too_low(tmp_path):
103
+ p = write(tmp_path / "a.wav", cut(44100, 16000), 44100)
104
+ assert check_file(p, need_hz=12000).status == LIMITED
105
+ v = check_file(p, need_hz=18000)
106
+ assert v.status == FAKE and "18 kHz is required" in v.reason
107
+
108
+
109
+ def test_need_hz_does_nothing_without_a_cutoff(tmp_path):
110
+ assert check_file(write(tmp_path / "a.wav", full(44100), 44100), need_hz=20000).status == OK
111
+
112
+
113
+ # ---------------------------------------------------------------- verdict boundaries (cut-off position forced)
114
+ def force(monkeypatch, cutoff):
115
+ monkeypatch.setattr(chk, "find_cliff", lambda sp: Cliff(cutoff, 40.0, -40.0))
116
+
117
+
118
+ @pytest.mark.parametrize("sr,cutoff,status", [
119
+ (32000, 0.9 * 16000, OK), (32000, 0.9 * 16000 - 1, LIMITED), (44100, 19500, OK), (48000, 19499, LIMITED),
120
+ (48000, 0.6 * 24000, LIMITED), (48000, 0.6 * 24000 - 1, FAKE), (16000, 0.9 * 8000, OK), (16000, 0.9 * 8000 - 1, LIMITED),
121
+ (16000, 0.6 * 8000 - 1, FAKE)])
122
+ def test_thresholds(monkeypatch, sr, cutoff, status):
123
+ force(monkeypatch, cutoff)
124
+ assert judge(fake_spec(sr), "a.wav", sr).status == status
125
+
126
+
127
+ def test_need_hz_boundary(monkeypatch):
128
+ force(monkeypatch, 15000.0)
129
+ assert judge(fake_spec(), "a", 44100, need_hz=15000.0).status == LIMITED
130
+ assert judge(fake_spec(), "a", 44100, need_hz=15000.1).status == FAKE
131
+
132
+
133
+ def test_levels_and_fraction():
134
+ v = chk.Verdict("a", 48000, FAKE, 12000.0, "r")
135
+ assert v.level == "FAIL" and v.fraction == 0.5
136
+ assert chk.Verdict("a", None, chk.ERROR, None, "r").fraction is None
137
+ assert {chk.Verdict("a", 1, s, None, "").level for s in (OK, LIMITED, FAKE, UNSURE, chk.ERROR)} == {"PASS", "WARN", "FAIL", "REVIEW", "ERROR"}
138
+
139
+
140
+ # ---------------------------------------------------------------- edge cases
141
+ def test_silent_and_short_files_are_review(tmp_path):
142
+ assert check_file(write(tmp_path / "s.wav", np.zeros(44100 * 10), 44100)).status == UNSURE
143
+ assert check_file(write(tmp_path / "t.wav", full(44100)[:44100], 44100)).status == UNSURE
144
+ assert judge(None, "a", 44100).status == UNSURE
145
+
146
+
147
+ def test_one_click_in_silence_has_too_few_loud_frames(tmp_path):
148
+ x = np.zeros(44100 * 10)
149
+ x[44100] = 0.5
150
+ assert check_file(write(tmp_path / "c.wav", x, 44100)).status == UNSURE
151
+
152
+
153
+ def test_unreadable_file_is_error(tmp_path):
154
+ p = tmp_path / "bad.wav"
155
+ p.write_bytes(b"this is not audio")
156
+ v = check_file(p)
157
+ assert v.status == chk.ERROR and "could not read" in v.reason and v.sample_rate is None
158
+
159
+
160
+ def test_low_sample_rate_without_room_to_search():
161
+ assert judge(spec_of(synth.make_full(8000, 12, 1), 8000), "a", 8000).status == OK
162
+
163
+
164
+ def test_cutoff_below_the_search_start_is_not_reported(tmp_path):
165
+ """A cut-off far below 2.5 kHz is outside the search (speech and music vary a lot there), so it is not flagged."""
166
+ assert find_cliff(spec_of(cut(16000, 1000), 16000)) is None
167
+
168
+
169
+ def test_stereo_equals_mono(tmp_path):
170
+ x = cut(44100, 16000)
171
+ st = np.stack([x, x], axis=1)
172
+ p = tmp_path / "s.wav"
173
+ sf.write(str(p), synth.to_pcm16(st), 44100, subtype="PCM_16")
174
+ a, b = check_file(p), check_file(write(tmp_path / "m.wav", x, 44100))
175
+ assert a.status == b.status and abs(a.cutoff_hz - b.cutoff_hz) < 1.0
176
+
177
+
178
+ def test_one_dead_channel_still_measured(tmp_path):
179
+ x = cut(44100, 16000)
180
+ p = tmp_path / "d.wav"
181
+ sf.write(str(p), synth.to_pcm16(np.stack([x, 0 * x], axis=1)), 44100, subtype="PCM_16")
182
+ assert check_file(p).status == LIMITED
183
+
184
+
185
+ def test_flac_and_24_bit(tmp_path):
186
+ x = cut(48000, 16000)
187
+ sf.write(str(tmp_path / "a.flac"), x, 48000, subtype="PCM_24")
188
+ assert check_file(tmp_path / "a.flac").status == LIMITED
189
+
190
+
191
+ def test_gain_does_not_change_the_result():
192
+ for g in (0.05, 1.0, 3.0):
193
+ c = find_cliff(mean_spectrum(g * cut(44100, 16000), 44100))
194
+ assert c is not None and abs(c.cutoff_hz - 16000) < 400
195
+
196
+
197
+ def test_load_mono_keeps_the_files_own_rate_and_uses_the_middle(tmp_path):
198
+ p = write(tmp_path / "a.wav", full(22050), 22050)
199
+ x, sr = load_mono(p)
200
+ assert sr == 22050 and len(x) == 12 * 22050
201
+ x2, _ = load_mono(p, max_seconds=4)
202
+ assert len(x2) == 4 * 22050
203
+
204
+
205
+ def test_active_seconds_counts_only_loud_parts():
206
+ x = np.concatenate([full(44100)[:44100 * 4], np.zeros(44100 * 8)])
207
+ sp = mean_spectrum(x, 44100)
208
+ assert sp is not None and sp.seconds == pytest.approx(12.0, abs=0.01) and 3.0 < sp.active_seconds < 5.5
209
+
210
+
211
+ def test_quiet_sections_do_not_hide_the_cutoff():
212
+ quiet = 0.001 * full(44100)
213
+ loud = cut(44100, 16000)
214
+ x = np.concatenate([quiet[:44100 * 6], loud[:44100 * 6]])
215
+ c = find_cliff(mean_spectrum(x, 44100))
216
+ assert c is not None and abs(c.cutoff_hz - 16000) < 400
217
+
218
+
219
+ def test_two_cutoffs_the_sharpest_drop_wins():
220
+ x = cut(48000, 20000)
221
+ y = cut(48000, 10000)
222
+ c = find_cliff(spec_of(0.5 * x + 0.5 * y, 48000))
223
+ assert c is not None and (abs(c.cutoff_hz - 10000) < 500 or abs(c.cutoff_hz - 20000) < 500)
224
+
225
+
226
+ # ---------------------------------------------------------------- summary and reports
227
+ def test_summary_by_rate():
228
+ vs = [chk.Verdict("a", 48000, FAKE, 8000.0, ""), chk.Verdict("b", 48000, OK, None, ""), chk.Verdict("c", 48000, LIMITED, 16000.0, ""),
229
+ chk.Verdict("d", 16000, OK, None, ""), chk.Verdict("e", None, chk.ERROR, None, "")]
230
+ rows = summary_by_rate(vs)
231
+ assert rows == [{"rate": 16000, "files": 1, "flagged": 0, "median_cutoff_hz": None},
232
+ {"rate": 48000, "files": 3, "flagged": 2, "median_cutoff_hz": 12000.0}]
233
+
234
+
235
+ def test_text_report_hides_passes_by_default():
236
+ vs = [chk.Verdict("good.wav", 48000, OK, None, "no sharp cut-off found"), chk.Verdict("bad.wav", 48000, FAKE, 8000.0, "stops low", "upsampled from 16000 Hz")]
237
+ t = text(vs)
238
+ assert "bad.wav" in t and "good.wav" not in t and "likely: upsampled from 16000 Hz" in t
239
+ assert "good.wav" in text(vs, show_pass=True)
240
+ assert "48000 Hz: 2 files, 1 flagged" in t
241
+ assert "2 files: 1 pass, 0 warn" in summary_line(vs)
242
+
243
+
244
+ def test_csv_columns_and_values(tmp_path):
245
+ out = tmp_path / "v.csv"
246
+ write_csv(out, [chk.Verdict("a.wav", 48000, FAKE, 8000.0, "r", "x", 9.96)])
247
+ row = list(csv.DictReader(open(out, encoding="utf-8")))[0]
248
+ assert list(row) == ["file", "sample_rate", "cutoff_hz", "cutoff_fraction", "verdict", "reason", "likely_source", "seconds_analysed"]
249
+ assert (row["verdict"], row["cutoff_hz"], row["cutoff_fraction"], row["seconds_analysed"]) == ("FAIL", "8000.0", "0.333", "10.0")
250
+
251
+
252
+ # ---------------------------------------------------------------- command line
253
+ def make_folder(tmp_path):
254
+ write(tmp_path / "good.wav", full(44100), 44100)
255
+ write(tmp_path / "limited.wav", cut(44100, 16000), 44100)
256
+ (tmp_path / "sub").mkdir()
257
+ write(tmp_path / "sub" / "fake.wav", up(48000, 16000), 48000)
258
+ return tmp_path
259
+
260
+
261
+ def test_cli_folder_exit_code_and_output(tmp_path, capsys):
262
+ rc = main(["check", str(make_folder(tmp_path))])
263
+ out = capsys.readouterr().out
264
+ assert rc == 1
265
+ assert "FAIL fake.wav" in out and "WARN limited.wav" in out and "good.wav" not in out
266
+ assert "3 files: 1 pass, 1 warn" in out and "By sample rate:" in out
267
+
268
+
269
+ def test_cli_exit_zero_when_nothing_fails(tmp_path, capsys):
270
+ write(tmp_path / "good.wav", full(44100), 44100)
271
+ write(tmp_path / "limited.wav", cut(44100, 16000), 44100)
272
+ assert main(["check", str(tmp_path)]) == 0
273
+
274
+
275
+ def test_cli_single_file_show_pass_and_out(tmp_path, capsys):
276
+ p = write(tmp_path / "good.wav", full(16000), 16000)
277
+ o = tmp_path / "v.csv"
278
+ assert main(["check", str(p), "--show-pass", "--out", str(o)]) == 0
279
+ assert "good.wav" in capsys.readouterr().out
280
+ assert list(csv.DictReader(open(o, encoding="utf-8")))[0]["verdict"] == "PASS"
281
+
282
+
283
+ def test_cli_need_hz(tmp_path, capsys):
284
+ write(tmp_path / "limited.wav", cut(44100, 16000), 44100)
285
+ assert main(["check", str(tmp_path)]) == 0
286
+ assert main(["check", str(tmp_path), "--need-hz", "18000"]) == 1
287
+
288
+
289
+ def test_cli_parallel_jobs_same_result(tmp_path, capsys):
290
+ d = make_folder(tmp_path)
291
+ main(["check", str(d)])
292
+ one = capsys.readouterr().out
293
+ main(["check", str(d), "--jobs", "2"])
294
+ assert capsys.readouterr().out == one
295
+
296
+
297
+ def test_cli_skips_macos_junk_and_non_audio(tmp_path):
298
+ write(tmp_path / "a.wav", full(16000), 16000)
299
+ (tmp_path / "._a.wav").write_bytes(b"junk")
300
+ (tmp_path / "notes.txt").write_text("x")
301
+ (tmp_path / "__MACOSX").mkdir()
302
+ (tmp_path / "__MACOSX" / "b.wav").write_bytes(b"junk")
303
+ assert [p.name for p in find_audio(tmp_path)] == ["a.wav"]
304
+
305
+
306
+ def test_cli_usage_errors(tmp_path, capsys):
307
+ assert main(["check", str(tmp_path / "missing")]) == 2
308
+ assert main(["check", str(tmp_path)]) == 2
309
+ assert "error" in capsys.readouterr().err
310
+
311
+
312
+ def test_cli_unreadable_file_listed_but_not_failing(tmp_path, capsys):
313
+ (tmp_path / "bad.wav").write_bytes(b"not audio")
314
+ assert main(["check", str(tmp_path)]) == 0
315
+ assert "ERROR" in capsys.readouterr().out
316
+
317
+
318
+ def test_version(capsys):
319
+ with pytest.raises(SystemExit):
320
+ main(["--version"])
321
+ assert "bandcheck 0.1.0" in capsys.readouterr().out
322
+
323
+
324
+ # ---------------------------------------------------------------- tests added after the planted-bug check
325
+ def test_a_20_db_step_is_not_a_cutoff():
326
+ """A shelf that drops 20 dB is below the 25 dB needed to call something a cut-off."""
327
+ x = 0.1 * full(44100) + cut(44100, 16000)
328
+ assert find_cliff(spec_of(x, 44100)) is None
329
+
330
+
331
+ def test_a_30_db_step_is_a_cutoff():
332
+ x = 0.03 * full(44100) + cut(44100, 16000)
333
+ c = find_cliff(spec_of(x, 44100))
334
+ assert c is not None and abs(c.cutoff_hz - 16000) < 500
335
+
336
+
337
+ def test_quiet_full_band_passages_do_not_hide_a_cutoff():
338
+ """Parts more than 30 dB under the loudest frame are ignored, so a quiet hiss does not fill in the empty band."""
339
+ x = np.concatenate([cut(44100, 16000)[:44100 * 3], 0.03 * full(44100)[:44100 * 9]]) # 3 s loud, 9 s quiet
340
+ c = find_cliff(mean_spectrum(synth.to_pcm16(x).astype(float) / 32767, 44100))
341
+ assert c is not None and abs(c.cutoff_hz - 16000) < 500
342
+
343
+
344
+ def test_stereo_channels_are_averaged_so_a_full_band_channel_counts(tmp_path):
345
+ p = tmp_path / "s.wav"
346
+ sf.write(str(p), synth.to_pcm16(np.stack([cut(44100, 16000), full(44100)], axis=1)), 44100, subtype="PCM_16")
347
+ assert check_file(p).status == OK
348
+
349
+
350
+ def test_a_long_file_is_judged_by_its_middle(tmp_path):
351
+ x = np.concatenate([cut(44100, 16000)[:44100 * 4], full(44100)[:44100 * 4], cut(44100, 16000)[:44100 * 4]])
352
+ p = write(tmp_path / "long.wav", x, 44100)
353
+ xs, sr = load_mono(p, max_seconds=4)
354
+ assert find_cliff(mean_spectrum(xs, sr)) is None # the full-band middle, not the cut first 4 s
355
+
356
+
357
+ def test_spectrum_level_is_relative_to_its_loudest_bin():
358
+ sp = spec_of(full(44100), 44100)
359
+ assert sp.level_db.max() == pytest.approx(0.0, abs=1e-9) and sp.level_db.min() < -20