Popiai-skill仓库
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

#!/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()