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()