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 +5 -0
- bandcheck/_version.py +1 -0
- bandcheck/check.py +89 -0
- bandcheck/cli.py +54 -0
- bandcheck/report.py +39 -0
- bandcheck/spectrum.py +108 -0
- bandcheck-0.1.0.dist-info/METADATA +109 -0
- bandcheck-0.1.0.dist-info/RECORD +12 -0
- bandcheck-0.1.0.dist-info/WHEEL +5 -0
- bandcheck-0.1.0.dist-info/entry_points.txt +2 -0
- bandcheck-0.1.0.dist-info/licenses/LICENSE +29 -0
- bandcheck-0.1.0.dist-info/top_level.txt +1 -0
bandcheck/__init__.py
ADDED
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
|
+

|
|
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,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
|