CoolFace
Apppublic

XiaoyuMa94/Music_Genre_Classification

sourceHugging Faceupdated 3mo agoView on Hugging Face
1likes
app.py399 linesDownload Raw Back to root
1"""2Music Genre Classifier — Streamlit demo app3Deploy on Hugging Face Spaces (free CPU tier is enough).4 5Pure PyTorch + timm — NO fastai needed at inference, so there is no6pickle/version coupling with the training environment. The model file is7produced by the "plain export" cell in README.md Step 1a (it saves the8weights + head spec + genre vocab, and numerically verifies the round-trip9inside Kaggle before saving).10 11Required file:   genre_model_plain.pt12Optional files:  confusion_matrix.png        (methodology page)13                 per_class_f1.csv            (methodology page)14                 embeddings.npz              (find-similar page: keys 'emb', 'names')15                 samples/<Genre>/<clip>.mp3  (one-click demo clips)16 17Spectrogram settings below MUST match how training spectrograms were made.18"""19 20import glob21import io22import os23from pathlib import Path24 25import numpy as np26import streamlit as st27import torch28import torch.nn as nn29import torch.nn.functional as F30 31# ---------------------------------------------------------------- settings32SR = 2205033N_MELS = 12834N_FFT = 204835HOP = 51236CLIP_SECONDS = 30          # length used per training spectrogram37MAX_DECODE_SECONDS = 300   # decode up to this much, then keep the center38IMG_SIZE = 224             # training used Resize(224, method=Squish)39IMAGENET_MEAN = torch.tensor([0.485, 0.456, 0.406])[:, None, None]40IMAGENET_STD = torch.tensor([0.229, 0.224, 0.225])[:, None, None]41 42_pts = sorted(glob.glob("genre_model_plain*.pt"))43MODEL_PATH = _pts[0] if _pts else "genre_model_plain.pt"44SAMPLES_DIR = Path("samples")45 46st.set_page_config(page_title="Music Genre Classifier", page_icon="🎵", layout="wide")47 48 49# ---------------------------------------------------------------- model50class ConcatPool(nn.Module):51    """fastai's AdaptiveConcatPool2d: [max-pool, avg-pool] concatenated."""52    def forward(self, x):53        return torch.cat([F.adaptive_max_pool2d(x, 1),54                          F.adaptive_avg_pool2d(x, 1)], dim=1)55 56 57class FeatureBody(nn.Module):58    """Run the timm backbone the way fastai's TimmBody does:59    forward_features() only — skip timm's own head norm/pool."""60    def __init__(self, m):61        super().__init__()62        self.m = m63 64    def forward(self, x):65        return self.m.forward_features(x)66 67 68def build_head(spec):69    """Rebuild the classifier head from the exported layer spec."""70    layers = []71    for s in spec:72        kind = s[0]73        if kind == "concat_pool":74            layers.append(ConcatPool())75        elif kind == "flatten":76            layers.append(nn.Flatten(1))77        elif kind == "bn":78            layers.append(nn.BatchNorm1d(s[1]))79        elif kind == "dropout":80            layers.append(nn.Dropout(s[1]))81        elif kind == "linear":82            layers.append(nn.Linear(s[1], s[2], bias=s[3]))83        elif kind == "relu":84            layers.append(nn.ReLU(inplace=True))85        else:86            raise ValueError(f"unknown head layer: {kind}")87    return nn.Sequential(*layers)88 89 90@st.cache_resource(show_spinner="Loading model…")91def load_model():92    import timm93    d = torch.load(MODEL_PATH, map_location="cpu", weights_only=False)94    body = timm.create_model(d["arch"], pretrained=False, num_classes=0)95    body.load_state_dict(d["body_sd"])96    head = build_head(d["head_spec"])97    head.load_state_dict(d["head_sd"])98    model = nn.Sequential(FeatureBody(body), head).eval()99    return model, list(d["vocab"])100 101 102@st.cache_resource103def load_embeddings():104    if not os.path.exists("embeddings.npz"):105        return None106    d = np.load("embeddings.npz", allow_pickle=True)107    emb = d["emb"].astype(np.float32)108    emb /= np.linalg.norm(emb, axis=1, keepdims=True) + 1e-8109    return emb, list(d["names"])110 111 112# ---------------------------------------------------------------- audio -> image113def _decode_with_av(audio_bytes: bytes):114    """Fallback decoder (m4a/aac and friends) using PyAV's bundled FFmpeg."""115    import av116    container = av.open(io.BytesIO(audio_bytes))117    resampler = av.AudioResampler(format="s16", layout="mono", rate=SR)118    chunks, total, limit = [], 0, SR * MAX_DECODE_SECONDS119    for frame in container.decode(audio=0):120        out = resampler.resample(frame)121        for f in (out if isinstance(out, list) else [out]):122            arr = f.to_ndarray().astype(np.float32).ravel() / 32768.0123            chunks.append(arr)124            total += arr.size125        if total >= limit:126            break127    container.close()128    if not chunks:129        return None130    return np.concatenate(chunks)[:limit]131 132 133def _center_crop(y):134    """Keep the middle CLIP_SECONDS — training clips are mid-song excerpts,135    so classifying a song's intro would be a train/serve mismatch."""136    n = SR * CLIP_SECONDS137    if y is not None and len(y) > n:138        start = (len(y) - n) // 2139        y = y[start:start + n]140    return y141 142 143def load_audio(audio_bytes: bytes):144    """Decode to mono float32 @ SR. librosa/soundfile first, PyAV fallback."""145    import librosa146    try:147        y, _ = librosa.load(io.BytesIO(audio_bytes), sr=SR, mono=True,148                            duration=MAX_DECODE_SECONDS)149        return _center_crop(y)150    except Exception:151        try:152            return _center_crop(_decode_with_av(audio_bytes))153        except Exception:154            return None155 156 157def audio_to_melspec(audio_bytes: bytes):158    """Decode audio -> mel-spectrogram (dB), same recipe as training."""159    import librosa160    y = load_audio(audio_bytes)161    if y is None or len(y) < SR:  # decode failure, or under 1 s162        return None163    mel = librosa.feature.melspectrogram(y=y, sr=SR, n_mels=N_MELS,164                                         n_fft=N_FFT, hop_length=HOP)165    return librosa.power_to_db(mel, ref=np.max)166 167 168def melspec_to_image(mel_db):169    """dB mel-spectrogram -> PIL image, EXACTLY as the training notebook:170    per-clip min-max to uint8 grayscale, flipped so low freqs are at the171    bottom. RGB conversion mirrors what the training image loader did."""172    from PIL import Image173    x = ((mel_db - mel_db.min()) / (mel_db.max() - mel_db.min() + 1e-9)174         * 255).astype(np.uint8)175    return Image.fromarray(np.flipud(x)).convert("RGB")176 177 178def preprocess(img):179    """PIL RGB image -> normalized 1x3x224x224 tensor (Squish resize +180    ImageNet stats, matching the training DataBlock)."""181    img = img.resize((IMG_SIZE, IMG_SIZE))182    x = torch.from_numpy(np.array(img)).permute(2, 0, 1).float() / 255.0183    return ((x - IMAGENET_MEAN) / IMAGENET_STD).unsqueeze(0)184 185 186# ---------------------------------------------------------------- inference187def predict(model, vocab, xb):188    with torch.no_grad():189        probs = torch.softmax(model(xb), dim=1)[0].cpu().numpy()190    probs = dict(zip(vocab, probs.tolist()))191    pred = max(probs, key=probs.get)192    return pred, probs193 194 195def find_cam_layer(body):196    body = getattr(body, "m", body)     # unwrap FeatureBody197    if hasattr(body, "stages"):    # ConvNeXt and many timm models198        return body.stages[-1]199    if hasattr(body, "layer4"):    # ResNet family200        return body.layer4201    return None202 203 204def gradcam(model, xb):205    """Grad-CAM heatmap in [0,1] over the input, or None."""206    try:207        target = find_cam_layer(model[0])208        if target is None:209            return None210        acts, grads = [], []211        h1 = target.register_forward_hook(lambda m, i, o: acts.append(o))212        h2 = target.register_full_backward_hook(213            lambda m, gi, go: grads.append(go[0]))214        try:215            out = model(xb)216            out[0, out.argmax(dim=1)].backward()217        finally:218            h1.remove(); h2.remove()219        a, g = acts[0][0], grads[0][0]220        w = g.mean(dim=(1, 2), keepdim=True)221        cam = torch.relu((w * a).sum(0)).detach().cpu().numpy()222        return (cam - cam.min()) / (cam.max() - cam.min() + 1e-8)223    except Exception:224        return None225 226 227def overlay_cam(img, cam):228    import matplotlib.cm as cm229    from PIL import Image230    heat = Image.fromarray(231        (cm.jet(cam)[:, :, :3] * 255).astype(np.uint8)).resize(img.size)232    return Image.blend(img.convert("RGB"), heat, alpha=0.35)233 234 235def embed_clip(model, xb):236    """Concat-pooled backbone features (matches the README embeddings cell)."""237    with torch.no_grad():238        f = model[0](xb)239        v = ConcatPool()(f).flatten().cpu().numpy().astype(np.float32)240    return v / (np.linalg.norm(v) + 1e-8)241 242 243# ---------------------------------------------------------------- ui helpers244def list_samples():245    out = {}246    if SAMPLES_DIR.exists():247        for gdir in sorted(SAMPLES_DIR.iterdir()):248            if gdir.is_dir():249                clips = sorted(gdir.glob("*.mp3")) + sorted(gdir.glob("*.wav"))250                if clips:251                    out[gdir.name] = clips252    return out253 254 255def get_audio_bytes():256    st.markdown("Upload a clip (mp3/wav, a few seconds is enough)")257    258    uploaded = st.file_uploader(259        "Choose audio file",260        type=["mp3", "wav", "ogg", "flac", "m4a"],261        accept_multiple_files=False,262        label_visibility="collapsed"263    )264    265    if uploaded is not None:266        bytes_data = bytes(uploaded.getvalue())267        if bytes_data:268            return bytes_data, uploaded.name269 270    samples = list_samples()271    if samples:272        st.caption("…or try a bundled sample:")273        c1, c2 = st.columns(2)274        genre = c1.selectbox("Genre", list(samples))275        clip = c2.selectbox("Clip", samples[genre], format_func=lambda p: p.name)276        if st.button("Use this sample"):277            return clip.read_bytes(), f"{genre}/{clip.name}"278    return None, None279 280 281# ---------------------------------------------------------------- pages282def page_classify(model, vocab):283    st.header("🎵 Classify a clip")284    audio, label = get_audio_bytes()285    if audio is None:286        st.info("Upload a clip or pick a sample to get started.")287        return288 289    st.audio(audio)290    with st.spinner("Analyzing…"):291        mel_db = audio_to_melspec(audio)292        if mel_db is None:293            st.error("Couldn't decode this file (or it's under ~3 seconds). "294                     "Try mp3, wav, m4a, ogg, or flac.")295            return296        img = melspec_to_image(mel_db)297        xb = preprocess(img)298        pred, probs = predict(model, vocab, xb)299        cam = gradcam(model, xb)300 301    left, right = st.columns([1.2, 1])302    with left:303        st.subheader("What the model saw")304        st.image(img, caption="Mel-spectrogram", use_container_width=True)305        if cam is not None:306            st.image(overlay_cam(img, cam),307                     caption="Grad-CAM — regions that drove the prediction",308                     use_container_width=True)309    with right:310        st.subheader("Prediction")311        st.metric("Genre", pred, f"{probs[pred]*100:.1f}% confidence")312        top3 = sorted(probs.items(), key=lambda kv: -kv[1])[:3]313        st.write(" · ".join(f"**{g}** {p*100:.1f}%" for g, p in top3))314        st.subheader("All genres")315        st.bar_chart(probs, horizontal=True)316 317 318def page_similar(model):319    st.header("🔎 Find similar tracks")320    data = load_embeddings()321    if data is None:322        st.info("Add `embeddings.npz` (see README export snippet) to enable "323                "similar-track search.")324        return325    ref_emb, names = data326 327    audio, _ = get_audio_bytes()328    if audio is None:329        return330    st.audio(audio)331    with st.spinner("Embedding…"):332        mel_db = audio_to_melspec(audio)333        if mel_db is None:334            st.error("Clip too short.")335            return336        v = embed_clip(model, preprocess(melspec_to_image(mel_db)))337    sims = ref_emb @ v338    order = np.argsort(-sims)[:10]339    st.subheader("Closest tracks in the reference set")340    for i in order:341        st.write(f"**{names[i]}** — similarity {sims[i]:.3f}")342 343 344def page_methodology():345    st.header("📊 How it works")346    st.markdown(347        f"""348**Pipeline.** Audio → mel-spectrogram ({N_MELS} mels, {N_FFT} FFT,349hop {HOP}, {SR} Hz) → rendered as a grayscale image → **ConvNeXt-Tiny**350(transfer learning from ImageNet-22k, fine-tuned) → genre probabilities.351 352**Dataset.** [FMA — Free Music Archive](https://github.com/mdeff/fma),35316 top-level genres, official train/validation/test split.354 355**Why spectrograms?** They turn audio into images, letting us reuse356powerful pretrained vision models — genre cues (rhythm, timbre,357instrumentation) show up as visual texture.358        """)359    c1, c2 = st.columns(2)360    with c1:361        if os.path.exists("confusion_matrix.png"):362            st.image("confusion_matrix.png", caption="Confusion matrix")363        else:364            st.info("Add `confusion_matrix.png` to show the confusion matrix.")365    with c2:366        if os.path.exists("per_class_f1.csv"):367            import pandas as pd368            st.dataframe(pd.read_csv("per_class_f1.csv"),369                         use_container_width=True)370        else:371            st.info("Add `per_class_f1.csv` to show per-genre F1 scores.")372 373 374# ---------------------------------------------------------------- main375def main():376    st.sidebar.title("Music Genre Classifier")377    page = st.sidebar.radio("Page", ["Classify", "Find similar", "How it works"])378    st.sidebar.markdown("---")379    st.sidebar.caption("ConvNeXt on mel-spectrograms · FMA dataset · "380                       "PyTorch + Streamlit")381 382    if not os.path.exists(MODEL_PATH):383        st.error(f"Model file `{MODEL_PATH}` not found. Run the plain-export "384                 "cell from README.md Step 1a in your Kaggle notebook and "385                 "put `genre_model_plain.pt` next to app.py.")386        return387    model, vocab = load_model()388 389    if page == "Classify":390        page_classify(model, vocab)391    elif page == "Find similar":392        page_similar(model)393    else:394        page_methodology()395 396 397if __name__ == "__main__":398    main()399