File size: 5,420 Bytes
093ff4b
 
 
 
 
 
 
251b2e1
93eb19c
093ff4b
 
 
 
 
 
 
 
 
 
 
 
93eb19c
 
 
 
 
093ff4b
 
 
 
 
 
 
 
 
 
 
3eaf896
 
 
093ff4b
 
 
 
 
 
 
3eaf896
 
 
 
 
 
 
 
 
 
 
093ff4b
 
 
 
 
 
 
 
 
 
 
 
 
93eb19c
093ff4b
 
3eaf896
093ff4b
 
 
 
 
3eaf896
 
093ff4b
 
 
93eb19c
3eaf896
 
 
 
 
 
 
 
93eb19c
 
 
 
3eaf896
093ff4b
 
 
 
 
 
 
 
251b2e1
 
 
 
 
 
 
093ff4b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
251b2e1
 
 
093ff4b
 
 
251b2e1
093ff4b
 
 
 
 
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
"""StemFlipper Gradio app β€” thin adapter over stemflipper.pipeline.

The same file runs locally, on a free CPU Space, and on ZeroGPU: only the
separation stage is GPU-relevant, so it alone is wrapped with @spaces.GPU
(a no-op everywhere else).
"""

import json
import os
import tempfile
from pathlib import Path

import gradio as gr

from stemflipper import separate
from stemflipper.audio_io import duration_of
from stemflipper.pipeline import run_pipeline

MAX_AUDIO_MINUTES = 8
PREVIEW_STEMS = ("vocals", "drums", "bass", "other")

# PANNs CNN14 (~340 MB) is off by default on the Space so the first request isn't stalled
# by a cold-weights download; set STEMFLIPPER_PANNS=1 (and ideally warm the cache at build)
# to enable the instrument classifier. The router degrades to spectral cues when off.
USE_PANNS = os.environ.get("STEMFLIPPER_PANNS", "0") == "1"

try:
    import spaces

    _separate_fn = spaces.GPU(duration=180)(separate.separate_stems)
except Exception:
    _separate_fn = separate.separate_stems

_HEADER = """\
# πŸŽ›οΈ StemFlipper

Upload a song β†’ AI separates it into stems β†’ each stem becomes **MIDI + a playable
sliced-sample instrument (SFZ)**, plus best-effort **synth presets (Vital)** for
mono synth lines and **EQ/reverb match** per stem β†’ download a **DAW project bundle**
(stems, MIDI, instruments, effects, Reaper project, manifest).

*Research/educational demo. Separation runs on CPU on this Space β€” a 3–4 min song takes
several minutes; the progress bar keeps moving. Transcription is an editable starting
point, not a perfect score.*
"""


def _rt60_of(bundle, effects_rel):
    """Read the reverb RT60 from a stem's effects json (0.0/absent if dry). Best-effort."""
    import json

    try:
        fx = json.loads((Path(bundle) / effects_rel).read_text())
        return fx.get("rt60_s") or 0.0
    except Exception:
        return 0.0


def flip(audio_path, model, progress=gr.Progress()):
    if not audio_path:
        raise gr.Error("Upload an audio file first.")
    if duration_of(audio_path) > MAX_AUDIO_MINUTES * 60:
        raise gr.Error(f"Please keep songs under {MAX_AUDIO_MINUTES} minutes for this demo.")

    workdir = Path(tempfile.mkdtemp(prefix="stemflipper_"))
    result = run_pipeline(
        audio_path,
        workdir,
        model=model,
        progress=lambda frac, desc: progress(frac, desc=desc),
        separate_fn=_separate_fn,
        use_panns=USE_PANNS,
    )
    manifest = result["manifest"]
    bundle = result["bundle_dir"]

    lines = [
        f"**tempo** {manifest['tempo']} BPM Β· **key** {manifest['key']} Β· "
        f"**duration** {manifest['duration']:.0f}s Β· model `{manifest['separation_model']}`",
        "",
        "| stem | instrument | notes | strategy | SFZ | Vital | FX |",
        "|---|---|---|---|---|---|---|",
    ]
    for name, meta in manifest["stems"].items():
        notes = "silent" if meta["silent"] else str(meta["n_notes"])
        sfz = "βœ“" if meta["instrument_sfz"] else "β€”"
        vital = "βœ“" if meta.get("instrument_vital") else "β€”"
        # FX cell: reverb RT60 (if any) from the effects json reference, EQ always present
        fx = "β€”"
        if meta.get("effects"):
            fx = "EQ"
            rt60 = _rt60_of(bundle, meta["effects"])
            if rt60:
                fx += f" Β· rev {rt60:.1f}s"
        inst = meta.get("instrument", "β€”")
        strat = meta.get("strategy", "β€”")
        if meta.get("low_confidence"):
            strat += " ⚠️"
        lines.append(f"| {name} | {inst} | {notes} | {strat} | {sfz} | {vital} | {fx} |")
    summary = "\n".join(lines)

    previews = [
        str(bundle / "stems" / f"{name}.wav")
        if (bundle / "stems" / f"{name}.wav").exists()
        else None
        for name in PREVIEW_STEMS
    ]

    # Per-stem detected notes for the client piano-roll (appended LAST so the preview
    # output indices above stay stable for existing API callers).
    notes_path = bundle / "notes.json"
    notes = json.loads(notes_path.read_text()) if notes_path.exists() else {"stems": {}}

    return str(result["zip_path"]), summary, *previews, notes


with gr.Blocks(title="StemFlipper") as demo:
    gr.Markdown(_HEADER)
    with gr.Row():
        audio_in = gr.Audio(type="filepath", label="Song (wav/mp3/flac/m4a, ≀8 min)")
        with gr.Column():
            model_in = gr.Dropdown(
                choices=list(separate.MODELS),
                value=separate.DEFAULT_MODEL,
                label="Separation model",
                info="htdemucs = 4 stems (default). htdemucs_6s adds guitar+piano (piano is weak).",
            )
            go_btn = gr.Button("Flip it 🎚️", variant="primary")
    zip_out = gr.File(label="DAW project bundle (.zip)")
    summary_out = gr.Markdown()
    with gr.Row():
        preview_outs = [
            gr.Audio(label=name, interactive=False) for name in PREVIEW_STEMS
        ]
    # Per-stem detected notes β†’ the static web frontend draws piano-rolls from this.
    # Hidden in the Gradio UI itself (visible=False) but present in the API output.
    notes_out = gr.JSON(visible=False)
    go_btn.click(
        flip,
        inputs=[audio_in, model_in],
        outputs=[zip_out, summary_out, *preview_outs, notes_out],
        api_name="flip",
    )

if __name__ == "__main__":
    demo.queue(default_concurrency_limit=1).launch(max_file_size="30mb")