smad-audio-classifier / predict_crnn.py
duclvQ's picture
Add inference package
bddc68f verified
Raw
History Blame Contribute Delete
8.08 kB
"""Classify any audio file into speech_noise / speech_music / singing_music / none.
Command line:
python predict_crnn.py song.mp3
Library:
from predict_crnn import SmadClassifier
clf = SmadClassifier()
print(clf.predict("song.mp3")["overall"])
Audio of any length, sample rate or channel count is accepted: it is converted
to 16 kHz mono, cut into 4-second windows, and each window classified. That
window length is not a free choice -- it is what the model was trained on.
"""
import argparse
import json
from pathlib import Path
import numpy as np
import torch
from crnn_model import TinyAudioCRNN
CLASS_NAMES = ("speech_noise", "speech_music", "singing_music", "none")
DESCRIPTIONS = {
"speech_noise": "spoken voice over non-music noise",
"speech_music": "spoken voice over a music bed",
"singing_music": "sung voice over music",
"none": "no human voice: instrumental music, noise, or silence",
}
# Must match mel_features.py exactly -- these numbers define the input the
# model was trained on, and changing any of them silently degrades accuracy
# rather than raising an error. Duplicated here so this file works standalone,
# and checked against the training module below when it is importable.
SAMPLE_RATE = 16000
SEGMENT_SECONDS = 4.0
N_MELS = 80
N_FFT = 400 # 25 ms
HOP_LENGTH = 160 # 10 ms
DEFAULT_CHECKPOINT = Path(__file__).with_name("models") / "crnn_scratch_v1.pt"
def _check_constants_match_training():
"""Fail loudly if the training module and this file ever disagree."""
try:
import mel_features
except Exception: # noqa: BLE001 - standalone use, nothing to compare against
return
mismatches = [
name for name, here, there in [
("N_MELS", N_MELS, mel_features.N_MELS),
("N_FFT", N_FFT, mel_features.N_FFT),
("HOP_LENGTH", HOP_LENGTH, mel_features.HOP_LENGTH),
] if here != there
]
if mismatches:
raise RuntimeError(
f"feature settings drifted from mel_features.py: {mismatches}. "
"Inference must use the same settings as training.")
def load_audio(path, sample_rate=SAMPLE_RATE):
"""Any audio file -> mono float32 at `sample_rate`."""
import librosa
audio, _ = librosa.load(str(path), sr=sample_rate, mono=True)
return audio.astype(np.float32)
def waveform_to_mel(waveform, sample_rate=SAMPLE_RATE):
"""(N,) float32 -> (T, N_MELS) log-mel in dB."""
import librosa
mel = librosa.feature.melspectrogram(
y=waveform, sr=sample_rate, n_fft=N_FFT,
hop_length=HOP_LENGTH, n_mels=N_MELS, power=2.0)
return librosa.power_to_db(mel).T.astype(np.float32)
class SmadClassifier:
"""The trained CRNN, ready to classify audio.
The checkpoint carries its own input normalisation and confidence
temperature, so nothing here needs configuring -- feeding the model a
differently-scaled input than it trained on is not possible by accident.
"""
def __init__(self, checkpoint=None, device=None):
_check_constants_match_training()
path = Path(checkpoint) if checkpoint else DEFAULT_CHECKPOINT
if not path.exists():
raise FileNotFoundError(f"checkpoint not found: {path}")
self.device = device or ("cuda" if torch.cuda.is_available() else "cpu")
ckpt = torch.load(path, map_location=self.device, weights_only=False)
self.model = TinyAudioCRNN()
self.model.load_state_dict(ckpt["state_dict"]) # includes feat_mean/feat_std
self.model.to(self.device).eval()
# Divide logits by this before softmax. Fitted on validation; it is a
# monotonic rescale, so it never changes which class wins -- it only
# stops the model overstating how sure it is.
self.temperature = float(ckpt.get("temperature", 1.0))
self.trained_val_acc = ckpt.get("val_acc")
@torch.no_grad()
def predict_windows(self, audio, sample_rate=SAMPLE_RATE, hop_seconds=None, batch_size=64):
"""One prediction per window. Returns a list of dicts.
Windows are run through the model in chunks of `batch_size` rather
than all at once -- a merged/long input can have thousands of 4s
windows, and stacking them into a single GPU batch is what causes an
out-of-memory error on a shared GPU, not the model itself.
"""
window = int(SEGMENT_SECONDS * sample_rate)
hop = int((hop_seconds or SEGMENT_SECONDS) * sample_rate)
if audio.shape[0] < window: # pad a too-short clip
audio = np.pad(audio, (0, window - audio.shape[0]))
starts = list(range(0, audio.shape[0] - window + 1, hop))
mels = [waveform_to_mel(audio[s:s + window], sample_rate) for s in starts]
probs_chunks = []
for i in range(0, len(mels), batch_size):
batch = torch.from_numpy(np.stack(mels[i:i + batch_size])).to(self.device)
probs_chunks.append(
torch.softmax(self.model(batch) / self.temperature, dim=-1).cpu().numpy())
probs = np.concatenate(probs_chunks, axis=0)
out = []
for start, p in zip(starts, probs):
best = int(p.argmax())
out.append({
"start_s": round(start / sample_rate, 2),
"end_s": round((start + window) / sample_rate, 2),
"label": CLASS_NAMES[best],
"confidence": round(float(p[best]), 4),
"probs": {name: round(float(v), 4) for name, v in zip(CLASS_NAMES, p)},
})
return out
def predict(self, path, hop_seconds=None):
"""Classify a file. Returns per-window results plus an overall verdict.
`overall` is the label covering the most time, not an average of
probabilities -- a 3-minute song with a spoken intro is a song, and
averaging would let a handful of confident intro windows outvote the
rest.
"""
audio = load_audio(path)
windows = self.predict_windows(audio, hop_seconds=hop_seconds)
share = {name: 0 for name in CLASS_NAMES}
for w in windows:
share[w["label"]] += 1
overall = max(share, key=share.get)
return {
"file": str(path),
"duration_s": round(len(audio) / SAMPLE_RATE, 2),
"overall": overall,
"overall_share": round(share[overall] / max(len(windows), 1), 4),
"share": {k: round(v / max(len(windows), 1), 4) for k, v in share.items()},
"windows": windows,
}
def main():
parser = argparse.ArgumentParser(
description="Classify audio into speech_noise / speech_music / singing_music / none")
parser.add_argument("audio", nargs="+", help="audio file(s); any format ffmpeg reads")
parser.add_argument("--checkpoint", default=None)
parser.add_argument("--hop-seconds", type=float, default=None,
help="window step; defaults to 4.0 (no overlap)")
parser.add_argument("--json", action="store_true", help="print JSON instead of a table")
parser.add_argument("--quiet", action="store_true", help="only the overall label")
args = parser.parse_args()
clf = SmadClassifier(args.checkpoint)
for path in args.audio:
result = clf.predict(path, hop_seconds=args.hop_seconds)
if args.json:
print(json.dumps(result, indent=2))
continue
if args.quiet:
print(f"{result['overall']}\t{path}")
continue
print(f"\n{path} ({result['duration_s']:.1f}s)")
for w in result["windows"]:
print(f" {w['start_s']:7.1f} - {w['end_s']:6.1f}s "
f"{w['label']:<14s} {w['confidence'] * 100:5.1f}%")
print(f" {'':>7s} {'':>6s} {'-' * 26}")
print(f" overall: {result['overall']} "
f"({result['overall_share'] * 100:.0f}% of the audio) "
f"-- {DESCRIPTIONS[result['overall']]}")
if __name__ == "__main__":
main()