bandcheck 0.1.0__py3-none-any.whl

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.
bandcheck/__init__.py ADDED
@@ -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"]
bandcheck/_version.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
bandcheck/check.py ADDED
@@ -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
bandcheck/cli.py ADDED
@@ -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())
bandcheck/report.py ADDED
@@ -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)])
bandcheck/spectrum.py ADDED
@@ -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,12 @@
1
+ bandcheck/__init__.py,sha256=VOzjgjydwEG5WvQj_a0LUH7Ru2VZ8vUJqDBrEvlLziY,235
2
+ bandcheck/_version.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
3
+ bandcheck/check.py,sha256=6cgyBoeE1R-jn1xlXwRCx_oCvolVE2xVm6tn3kTj7E4,4262
4
+ bandcheck/cli.py,sha256=egU9CjQW8wRNrJeEyipzrt42vx1qwNgPKAlKTdzMBBg,2091
5
+ bandcheck/report.py,sha256=E2TYKsGROTgrJAQn3wYl8Tqowii_GjbyX3m72aFmYJA,1645
6
+ bandcheck/spectrum.py,sha256=fze-Ni99NGkz5YD7qhdDJ9kOfi_w7jBDE_tnXkVbYhY,4773
7
+ bandcheck-0.1.0.dist-info/licenses/LICENSE,sha256=brmceFF8Uw_1KeQBOshibuDU12xBGtsi2Y4mzSoRlgE,1462
8
+ bandcheck-0.1.0.dist-info/METADATA,sha256=4mQKkQEO3FdnjAhgEMN5WxxKXoz7rrG5OOG6NcvuXwA,6668
9
+ bandcheck-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
10
+ bandcheck-0.1.0.dist-info/entry_points.txt,sha256=dnsEcTkCFj4zsTBo1PpuMUTFvmgFK2bC5zYCjgZxdZ0,49
11
+ bandcheck-0.1.0.dist-info/top_level.txt,sha256=MC6AD5MwIwIEryp4SIsEkdTWXLtqcNvX9w3AiipKpA0,10
12
+ bandcheck-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ bandcheck = bandcheck.cli:main
@@ -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 @@
1
+ bandcheck