You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
147 lines
5.1 KiB
147 lines
5.1 KiB
#!/usr/bin/env python3
|
|
"""Audio quality checker — detects energy spikes, DC offset, and clipping.
|
|
|
|
Usage:
|
|
python3 audio_check.py <audio_file> [--top 20]
|
|
|
|
Decodes audio to raw PCM via ffmpeg, then checks for:
|
|
1. Energy spikes: abrupt jumps from near-silence to loud audio within one hop,
|
|
typically caused by TTS splice artifacts or corrupted segments.
|
|
2. DC offset: non-zero mean amplitude indicating a constant bias in the waveform,
|
|
which can cause clicks at splice points and reduce headroom.
|
|
3. Clipping: samples hitting the maximum amplitude ceiling (digital distortion).
|
|
"""
|
|
|
|
import argparse
|
|
import math
|
|
import struct
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
|
|
def decode_to_pcm(path: str, sr: int = 16000) -> bytes:
|
|
"""Decode audio to mono 16-bit PCM via ffmpeg."""
|
|
r = subprocess.run(
|
|
[
|
|
"ffmpeg", "-v", "quiet", "-y",
|
|
"-i", path,
|
|
"-ac", "1", "-ar", str(sr), "-f", "s16le", "-acodec", "pcm_s16le",
|
|
"pipe:1",
|
|
],
|
|
capture_output=True,
|
|
)
|
|
if r.returncode != 0:
|
|
print(f"ffmpeg decode failed: {r.stderr.decode()}", file=sys.stderr)
|
|
sys.exit(1)
|
|
return r.stdout
|
|
|
|
|
|
def analyze(pcm: bytes, sr: int, top_n: int):
|
|
n_samples = len(pcm) // 2
|
|
if n_samples == 0:
|
|
print("ERROR: empty audio")
|
|
return
|
|
|
|
samples = struct.unpack(f"<{n_samples}h", pcm)
|
|
floats = [s / 32768.0 for s in samples]
|
|
|
|
duration = n_samples / sr
|
|
peak = max(abs(f) for f in floats)
|
|
rms = (sum(f * f for f in floats) / n_samples) ** 0.5
|
|
|
|
print(f"Duration: {duration:.1f}s ({n_samples} samples @ {sr}Hz)")
|
|
print(f"Peak: {peak:.4f} ({20 * math.log10(max(peak, 1e-10)):.1f} dB)")
|
|
print(f"RMS: {rms:.4f} ({20 * math.log10(max(rms, 1e-10)):.1f} dB)")
|
|
print()
|
|
|
|
issues = []
|
|
|
|
# --- 1. Energy spikes (abrupt silence -> loud in one hop) ---
|
|
win = int(sr * 0.05) # 50ms window
|
|
hop = win // 2 # 25ms hop
|
|
spike_silence = 10 ** (-48 / 20)
|
|
spike_loud = 0.04
|
|
energy_spikes = []
|
|
prev_rms = 0
|
|
for w in range(0, n_samples - win, hop):
|
|
chunk = floats[w : w + win]
|
|
w_rms = (sum(x * x for x in chunk) / win) ** 0.5
|
|
if prev_rms < spike_silence and w_rms > spike_loud:
|
|
ratio = w_rms / max(prev_rms, 1e-10)
|
|
energy_spikes.append((w / sr, prev_rms, w_rms, ratio))
|
|
prev_rms = w_rms
|
|
|
|
if energy_spikes:
|
|
severe = [s for s in energy_spikes if s[3] > 100]
|
|
if severe:
|
|
issues.append(f"{len(severe)} energy spike(s)")
|
|
print(f" Energy spikes (abrupt onset): {len(severe)} detected")
|
|
severe.sort(key=lambda x: -x[3])
|
|
for t, pre, post, ratio in severe[:top_n]:
|
|
pre_db = 20 * math.log10(max(pre, 1e-10))
|
|
post_db = 20 * math.log10(max(post, 1e-10))
|
|
mm = int(t) // 60
|
|
ss = t - mm * 60
|
|
print(f" {mm}:{ss:05.2f} {pre_db:.0f}dB -> {post_db:.0f}dB (x{ratio:.0f})")
|
|
if len(severe) > top_n:
|
|
print(f" ... and {len(severe) - top_n} more")
|
|
else:
|
|
print(" Energy spikes: none severe")
|
|
else:
|
|
print(" Energy spikes: none detected")
|
|
|
|
# --- 2. DC offset ---
|
|
dc_offset = sum(floats) / n_samples
|
|
dc_pct = abs(dc_offset) * 100
|
|
if dc_pct > 1.0:
|
|
issues.append(f"DC offset {dc_pct:.2f}%")
|
|
print(f" DC offset: {dc_offset:+.6f} ({dc_pct:.2f}%) -- exceeds 1% threshold")
|
|
else:
|
|
print(f" DC offset: {dc_offset:+.6f} ({dc_pct:.2f}%) -- OK")
|
|
|
|
# --- 3. Clipping ---
|
|
clip_threshold = 32767 / 32768.0 # ~0.99997
|
|
clipped = sum(1 for f in floats if abs(f) >= clip_threshold)
|
|
clip_pct = clipped / n_samples * 100
|
|
if clipped > 0:
|
|
# Only flag as issue if clipping is significant (>0.01% of samples)
|
|
if clip_pct > 0.01:
|
|
issues.append(f"clipping {clipped} samples ({clip_pct:.3f}%)")
|
|
print(f" Clipping: {clipped} samples ({clip_pct:.3f}%) -- significant")
|
|
else:
|
|
print(f" Clipping: {clipped} samples ({clip_pct:.4f}%) -- negligible")
|
|
else:
|
|
print(f" Clipping: none")
|
|
|
|
# --- Summary ---
|
|
print()
|
|
if issues:
|
|
print(f"=== {len(issues)} issue(s) found: {'; '.join(issues)} ===")
|
|
else:
|
|
print("=== PASS ===")
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description="Check audio for quality issues (energy spikes, DC offset, clipping)")
|
|
parser.add_argument("audio", help="Audio file path")
|
|
parser.add_argument("--top", "-n", type=int, default=20,
|
|
help="Show top N worst spikes (default 20)")
|
|
parser.add_argument("--sr", type=int, default=16000,
|
|
help="Sample rate for analysis (default 16000)")
|
|
args = parser.parse_args()
|
|
|
|
path = args.audio
|
|
if not Path(path).exists():
|
|
print(f"File not found: {path}", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
print(f"Checking: {Path(path).name}")
|
|
print(f"{'─' * 50}")
|
|
|
|
pcm = decode_to_pcm(path, args.sr)
|
|
analyze(pcm, args.sr, args.top)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|
|
|