File size: 8,082 Bytes
bddc68f | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 | """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()
|