nakas commited on
Commit
093ff4b
·
verified ·
1 Parent(s): bad77c5

deploy from local repo (runtime files only)

Browse files
README.md CHANGED
@@ -1,13 +1,61 @@
1
  ---
2
- title: Stemflipper
3
- emoji: 😻
4
- colorFrom: gray
5
- colorTo: yellow
6
  sdk: gradio
7
  sdk_version: 6.19.0
8
- python_version: '3.13'
9
  app_file: app.py
 
10
  pinned: false
 
 
11
  ---
12
 
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: StemFlipper
3
+ emoji: 🎛️
4
+ colorFrom: purple
5
+ colorTo: blue
6
  sdk: gradio
7
  sdk_version: 6.19.0
 
8
  app_file: app.py
9
+ python_version: "3.10.13"
10
  pinned: false
11
+ license: mit
12
+ short_description: Song → stems → MIDI → editable instruments → DAW bundle
13
  ---
14
 
15
+ # 🎛️ StemFlipper
16
+
17
+ Upload a song → AI source-separation into stems → each stem becomes **transcribed MIDI +
18
+ a playable sliced-sample instrument (SFZ)** → download a **DAW project bundle**:
19
+
20
+ ```
21
+ song/
22
+ stems/*.wav separated stems (htdemucs)
23
+ midi/song.mid multitrack SMF Format 1 (tempo map) + per-stem .mid
24
+ instruments/*/*.sfz sliced-sample instruments (load in sfizz / Sforzando / DecentSampler)
25
+ project.RPP Reaper project with the stems arranged at the right tempo
26
+ manifest.json tempo, key, stem→file map
27
+ README.txt how to import into any DAW
28
+ ```
29
+
30
+ ## Run locally
31
+
32
+ ```bash
33
+ python3.10 -m venv .venv && .venv/bin/pip install -r requirements.txt
34
+ .venv/bin/python -m stemflipper song.mp3 -o out/ # CLI
35
+ .venv/bin/python app.py # Gradio UI at :7860
36
+ .venv/bin/pytest -m "not slow" # tests
37
+ ```
38
+
39
+ ## Hardware notes
40
+
41
+ - **This Space runs on free CPU hardware** — separation of a 3–4 min song takes several
42
+ minutes. The queue + progress bar handle it; just wait.
43
+ - **ZeroGPU upgrade path** (needs a PRO account): Space Settings → Hardware → ZeroGPU.
44
+ The code is already compatible — `app.py` wraps only the separation stage in
45
+ `@spaces.GPU(duration=180)`; everything else stays on CPU.
46
+
47
+ ## Honest limitations (MVP)
48
+
49
+ - Transcription is an *editable starting point*, not a perfect score. Drums use an onset
50
+ heuristic that misses overlapping hits.
51
+ - Sampler slices inherit any bleed/reverb baked into the separated stems.
52
+ - Separation weights (htdemucs) are trained on MUSDB18 (non-commercial training data) —
53
+ this app is a **research/educational demo**, not a commercial service. See `PLAN.md`
54
+ ("Licensing") for the retrain-before-monetization gate.
55
+
56
+ ## Repo map
57
+
58
+ `stemflipper/` pipeline library (CLI: `python -m stemflipper`) · `app.py` Gradio adapter ·
59
+ `tests/` pytest suite with a deterministic synthetic test song · `PLAN.md` + `research/`
60
+ the research/architecture brief · `HANDOFF.md` build state, task queue, invariants ·
61
+ `dataset/`, `web/` upcoming (see HANDOFF.md).
app.py ADDED
@@ -0,0 +1,105 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """StemFlipper Gradio app — thin adapter over stemflipper.pipeline.
2
+
3
+ The same file runs locally, on a free CPU Space, and on ZeroGPU: only the
4
+ separation stage is GPU-relevant, so it alone is wrapped with @spaces.GPU
5
+ (a no-op everywhere else).
6
+ """
7
+
8
+ import tempfile
9
+ from pathlib import Path
10
+
11
+ import gradio as gr
12
+
13
+ from stemflipper import separate
14
+ from stemflipper.audio_io import duration_of
15
+ from stemflipper.pipeline import run_pipeline
16
+
17
+ MAX_AUDIO_MINUTES = 8
18
+ PREVIEW_STEMS = ("vocals", "drums", "bass", "other")
19
+
20
+ try:
21
+ import spaces
22
+
23
+ _separate_fn = spaces.GPU(duration=180)(separate.separate_stems)
24
+ except Exception:
25
+ _separate_fn = separate.separate_stems
26
+
27
+ _HEADER = """\
28
+ # 🎛️ StemFlipper
29
+
30
+ Upload a song → AI separates it into stems → each stem becomes **MIDI + a playable
31
+ sliced-sample instrument (SFZ)** → download a **DAW project bundle** (stems, MIDI,
32
+ instruments, Reaper project, manifest).
33
+
34
+ *Research/educational demo. Separation runs on CPU on this Space — a 3–4 min song takes
35
+ several minutes; the progress bar keeps moving. Transcription is an editable starting
36
+ point, not a perfect score.*
37
+ """
38
+
39
+
40
+ def flip(audio_path, model, progress=gr.Progress()):
41
+ if not audio_path:
42
+ raise gr.Error("Upload an audio file first.")
43
+ if duration_of(audio_path) > MAX_AUDIO_MINUTES * 60:
44
+ raise gr.Error(f"Please keep songs under {MAX_AUDIO_MINUTES} minutes for this demo.")
45
+
46
+ workdir = Path(tempfile.mkdtemp(prefix="stemflipper_"))
47
+ result = run_pipeline(
48
+ audio_path,
49
+ workdir,
50
+ model=model,
51
+ progress=lambda frac, desc: progress(frac, desc=desc),
52
+ separate_fn=_separate_fn,
53
+ )
54
+ manifest = result["manifest"]
55
+
56
+ lines = [
57
+ f"**tempo** {manifest['tempo']} BPM · **key** {manifest['key']} · "
58
+ f"**duration** {manifest['duration']:.0f}s · model `{manifest['separation_model']}`",
59
+ "",
60
+ "| stem | notes | instrument |",
61
+ "|---|---|---|",
62
+ ]
63
+ for name, meta in manifest["stems"].items():
64
+ notes = "silent" if meta["silent"] else str(meta["n_notes"])
65
+ sfz = "SFZ ✓" if meta["instrument_sfz"] else "—"
66
+ lines.append(f"| {name} | {notes} | {sfz} |")
67
+ summary = "\n".join(lines)
68
+
69
+ bundle = result["bundle_dir"]
70
+ previews = [
71
+ str(bundle / "stems" / f"{name}.wav")
72
+ if (bundle / "stems" / f"{name}.wav").exists()
73
+ else None
74
+ for name in PREVIEW_STEMS
75
+ ]
76
+ return str(result["zip_path"]), summary, *previews
77
+
78
+
79
+ with gr.Blocks(title="StemFlipper") as demo:
80
+ gr.Markdown(_HEADER)
81
+ with gr.Row():
82
+ audio_in = gr.Audio(type="filepath", label="Song (wav/mp3/flac/m4a, ≤8 min)")
83
+ with gr.Column():
84
+ model_in = gr.Dropdown(
85
+ choices=list(separate.MODELS),
86
+ value=separate.DEFAULT_MODEL,
87
+ label="Separation model",
88
+ info="htdemucs = 4 stems (default). htdemucs_6s adds guitar+piano (piano is weak).",
89
+ )
90
+ go_btn = gr.Button("Flip it 🎚️", variant="primary")
91
+ zip_out = gr.File(label="DAW project bundle (.zip)")
92
+ summary_out = gr.Markdown()
93
+ with gr.Row():
94
+ preview_outs = [
95
+ gr.Audio(label=name, interactive=False) for name in PREVIEW_STEMS
96
+ ]
97
+ go_btn.click(
98
+ flip,
99
+ inputs=[audio_in, model_in],
100
+ outputs=[zip_out, summary_out, *preview_outs],
101
+ api_name="flip",
102
+ )
103
+
104
+ if __name__ == "__main__":
105
+ demo.queue(default_concurrency_limit=1).launch(max_file_size="30mb")
packages.txt ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ ffmpeg
2
+ libsndfile1
requirements.txt ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # StemFlipper MVP dependency set (staged — frontier deps land with their milestones; see HANDOFF.md)
2
+ audio-separator[cpu]>=0.44,<0.45
3
+ basic-pitch>=0.4,<0.5
4
+ tflite-runtime; sys_platform == "linux" # basic-pitch inference runtime on the Space (CoreML covers macOS)
5
+ librosa>=0.10,<0.12
6
+ numba>=0.61
7
+ pretty_midi>=0.2.10
8
+ mido>=1.3
9
+ soundfile>=0.12
10
+ gradio>=4
11
+ spaces
stemflipper/__init__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ """StemFlipper — song -> stems -> MIDI -> editable instruments -> DAW bundle."""
2
+
3
+ __version__ = "0.1.0"
stemflipper/__main__.py ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """CLI: python -m stemflipper song.mp3 -o out/"""
2
+
3
+ import argparse
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ from .pipeline import run_pipeline
8
+ from .separate import DEFAULT_MODEL, MODELS
9
+
10
+
11
+ def main(argv=None) -> int:
12
+ parser = argparse.ArgumentParser(
13
+ prog="stemflipper",
14
+ description="song -> stems -> MIDI -> editable instruments -> DAW bundle",
15
+ )
16
+ parser.add_argument("input", help="audio file (wav/mp3/flac/m4a)")
17
+ parser.add_argument("-o", "--output", default="out", help="output directory")
18
+ parser.add_argument(
19
+ "--model",
20
+ default=DEFAULT_MODEL,
21
+ help=f"separation model: {', '.join(MODELS)} (or any audio-separator filename)",
22
+ )
23
+ parser.add_argument("--model-dir", default=None, help="model weight cache dir")
24
+ parser.add_argument("--no-zip", action="store_true", help="skip bundle zip")
25
+ args = parser.parse_args(argv)
26
+
27
+ if not Path(args.input).exists():
28
+ print(f"error: no such file: {args.input}", file=sys.stderr)
29
+ return 2
30
+
31
+ def progress(frac, desc):
32
+ print(f"[{frac * 100:5.1f}%] {desc}", flush=True)
33
+
34
+ result = run_pipeline(
35
+ args.input,
36
+ args.output,
37
+ model=args.model,
38
+ model_dir=args.model_dir,
39
+ progress=progress,
40
+ make_zip=not args.no_zip,
41
+ )
42
+ manifest = result["manifest"]
43
+ print(f"\nbundle: {result['bundle_dir']}")
44
+ if result["zip_path"]:
45
+ print(f"zip: {result['zip_path']}")
46
+ print(f"tempo: {manifest['tempo']} BPM key: {manifest['key']}")
47
+ for name, meta in manifest["stems"].items():
48
+ note = "silent" if meta["silent"] else f"{meta['n_notes']} notes"
49
+ sfz = " +sfz" if meta["instrument_sfz"] else ""
50
+ print(f" {name:8s} {note}{sfz}")
51
+ return 0
52
+
53
+
54
+ if __name__ == "__main__":
55
+ sys.exit(main())
stemflipper/analyze.py ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Song-level analysis: tempo, beat grid, key."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+
7
+ import numpy as np
8
+
9
+ # Krumhansl-Schmuckler key profiles (Krumhansl & Kessler 1982).
10
+ _MAJOR_PROFILE = np.array(
11
+ [6.35, 2.23, 3.48, 2.33, 4.38, 4.09, 2.52, 5.19, 2.39, 3.66, 2.29, 2.88]
12
+ )
13
+ _MINOR_PROFILE = np.array(
14
+ [6.33, 2.68, 3.52, 5.38, 2.60, 3.53, 2.54, 4.75, 3.98, 2.69, 3.34, 3.17]
15
+ )
16
+ _PITCH_NAMES = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"]
17
+
18
+
19
+ @dataclass
20
+ class Analysis:
21
+ tempo: float
22
+ beat_times: list[float]
23
+ key: str
24
+ duration: float
25
+ time_signature: str = "4/4"
26
+ sr: int = 44100
27
+
28
+
29
+ def estimate_tempo(y: np.ndarray, sr: int) -> tuple[float, list[float]]:
30
+ import librosa
31
+
32
+ tempo, beat_frames = librosa.beat.beat_track(y=y, sr=sr)
33
+ bpm = float(np.atleast_1d(tempo)[0])
34
+ # octave-error sanity clamp: prefer 70-180 BPM (see PLAN.md quantization pitfalls)
35
+ while bpm and bpm < 70:
36
+ bpm *= 2
37
+ while bpm > 180:
38
+ bpm /= 2
39
+ beat_times = librosa.frames_to_time(beat_frames, sr=sr).tolist()
40
+ return bpm, beat_times
41
+
42
+
43
+ def estimate_key(y: np.ndarray, sr: int) -> str:
44
+ import librosa
45
+
46
+ chroma = librosa.feature.chroma_cqt(y=y, sr=sr).mean(axis=1)
47
+ if chroma.max() <= 0:
48
+ return "unknown"
49
+ best_score, best_key = -np.inf, "unknown"
50
+ for tonic in range(12):
51
+ rotated = np.roll(chroma, -tonic)
52
+ for profile, mode in ((_MAJOR_PROFILE, "major"), (_MINOR_PROFILE, "minor")):
53
+ score = np.corrcoef(rotated, profile)[0, 1]
54
+ if score > best_score:
55
+ best_score, best_key = score, f"{_PITCH_NAMES[tonic]} {mode}"
56
+ return best_key
57
+
58
+
59
+ def analyze_audio(y: np.ndarray, sr: int) -> Analysis:
60
+ tempo, beat_times = estimate_tempo(y, sr)
61
+ return Analysis(
62
+ tempo=round(tempo, 2),
63
+ beat_times=beat_times,
64
+ key=estimate_key(y, sr),
65
+ duration=len(y) / sr,
66
+ sr=sr,
67
+ )
stemflipper/audio_io.py ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Audio loading/saving helpers. librosa handles mp3/m4a via soundfile/audioread+ffmpeg."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import numpy as np
8
+ import soundfile as sf
9
+
10
+
11
+ def load_audio(path: str | Path, sr: int | None = None, mono: bool = True):
12
+ """Return (audio, sr). Resamples only if sr is given."""
13
+ import librosa
14
+
15
+ y, out_sr = librosa.load(str(path), sr=sr, mono=mono)
16
+ return y, int(out_sr)
17
+
18
+
19
+ def save_audio(path: str | Path, audio: np.ndarray, sr: int) -> Path:
20
+ path = Path(path)
21
+ path.parent.mkdir(parents=True, exist_ok=True)
22
+ sf.write(str(path), audio, sr)
23
+ return path
24
+
25
+
26
+ def duration_of(path: str | Path) -> float:
27
+ try:
28
+ info = sf.info(str(path))
29
+ return info.frames / info.samplerate
30
+ except Exception: # formats libsndfile can't probe (some mp3/m4a) — decode instead
31
+ import librosa
32
+
33
+ return float(librosa.get_duration(path=str(path)))
34
+
35
+
36
+ def is_silent(audio: np.ndarray, threshold: float = 1e-4) -> bool:
37
+ """True when a stem carries no usable signal (e.g. vocals stem of an instrumental)."""
38
+ return float(np.sqrt(np.mean(audio**2))) < threshold
stemflipper/export.py ADDED
@@ -0,0 +1,166 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Bundle export: SMF Format-1 MIDI, manifest.json, README.txt, Reaper .RPP, zip.
2
+
3
+ MVP scope per plan: the .RPP carries tempo + stem audio tracks only (plain-text
4
+ template); MIDI files sit alongside for drag-drop. Inline MIDI embedding in RPP is a
5
+ later task (see HANDOFF.md).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import shutil
12
+ from datetime import datetime, timezone
13
+ from pathlib import Path
14
+
15
+ # GM programs (0-indexed) per canonical stem name
16
+ _STEM_PROGRAMS = {
17
+ "vocals": 53, # Voice Oohs
18
+ "bass": 33, # Electric Bass (finger)
19
+ "guitar": 24, # Nylon Guitar
20
+ "piano": 0, # Acoustic Grand
21
+ "other": 48, # String Ensemble
22
+ }
23
+
24
+ _RPP_TRACK = """\
25
+ <TRACK
26
+ NAME "{name}"
27
+ <ITEM
28
+ POSITION 0
29
+ LENGTH {length}
30
+ NAME "{name}"
31
+ <SOURCE WAVE
32
+ FILE "{file}"
33
+ >
34
+ >
35
+ >
36
+ """
37
+
38
+ _README = """\
39
+ StemFlipper export — {source}
40
+ =================================
41
+
42
+ tempo: {tempo} BPM key: {key} time signature: {timesig}
43
+
44
+ What's in this folder
45
+ ---------------------
46
+ stems/ separated audio stems (drag into any DAW)
47
+ midi/ transcribed MIDI — song.mid is the full multitrack (Format 1,
48
+ tempo map included); per-stem .mid files for single-track import
49
+ instruments/ sliced-sample instruments (.sfz) built from each stem — load in a
50
+ free SFZ player (sfizz, Sforzando) or convert with DecentSampler
51
+ project.RPP Reaper project: opens the stems arranged on tracks at the right
52
+ tempo. Import midi/*.mid onto tracks, then drop the matching .sfz
53
+ on an instrument track to make parts editable.
54
+ manifest.json machine-readable metadata (tempo, key, stem->file mapping)
55
+
56
+ Honest limitations (MVP)
57
+ ------------------------
58
+ - Transcription quality varies by stem; drums use an onset heuristic that misses
59
+ overlapping hits. Treat MIDI as an editable starting point, not a perfect score.
60
+ - Sampler slices inherit any bleed/reverb baked into the separated stems.
61
+ """
62
+
63
+
64
+ def write_midi(
65
+ tracks: dict[str, dict], tempo: float, midi_dir: str | Path
66
+ ) -> dict[str, Path]:
67
+ """tracks: {stem_name: {"notes": [...], "is_drum": bool}} -> song.mid + per-stem mids."""
68
+ import pretty_midi
69
+
70
+ midi_dir = Path(midi_dir)
71
+ midi_dir.mkdir(parents=True, exist_ok=True)
72
+ written: dict[str, Path] = {}
73
+
74
+ def make_pm(subset: dict[str, dict]) -> "pretty_midi.PrettyMIDI":
75
+ pm = pretty_midi.PrettyMIDI(initial_tempo=tempo)
76
+ for name, data in subset.items():
77
+ inst = pretty_midi.Instrument(
78
+ program=_STEM_PROGRAMS.get(name, 0),
79
+ is_drum=data["is_drum"],
80
+ name=name,
81
+ )
82
+ for n in data["notes"]:
83
+ inst.notes.append(
84
+ pretty_midi.Note(
85
+ velocity=n.get("velocity", 100),
86
+ pitch=n["pitch"],
87
+ start=n["start"],
88
+ end=max(n["end"], n["start"] + 0.02),
89
+ )
90
+ )
91
+ pm.instruments.append(inst)
92
+ return pm
93
+
94
+ with_notes = {k: v for k, v in tracks.items() if v["notes"]}
95
+ if with_notes:
96
+ song_path = midi_dir / "song.mid"
97
+ make_pm(with_notes).write(str(song_path))
98
+ written["song"] = song_path
99
+ for name, data in with_notes.items():
100
+ p = midi_dir / f"{name}.mid"
101
+ make_pm({name: data}).write(str(p))
102
+ written[name] = p
103
+ return written
104
+
105
+
106
+ def write_rpp(
107
+ bundle_dir: str | Path,
108
+ tempo: float,
109
+ stems: dict[str, str],
110
+ duration: float,
111
+ ) -> Path:
112
+ """stems: {name: bundle-relative wav path}."""
113
+ tracks = "".join(
114
+ _RPP_TRACK.format(name=name, length=round(duration, 3), file=rel)
115
+ for name, rel in stems.items()
116
+ )
117
+ content = (
118
+ f'<REAPER_PROJECT 0.1 "7.0" 0\n'
119
+ f" TEMPO {tempo} 4 4\n"
120
+ f"{tracks}"
121
+ f">\n"
122
+ )
123
+ path = Path(bundle_dir) / "project.RPP"
124
+ path.write_text(content)
125
+ return path
126
+
127
+
128
+ def write_manifest(bundle_dir: str | Path, meta: dict) -> Path:
129
+ path = Path(bundle_dir) / "manifest.json"
130
+ path.write_text(json.dumps(meta, indent=2))
131
+ return path
132
+
133
+
134
+ def write_readme(bundle_dir: str | Path, source: str, analysis) -> Path:
135
+ path = Path(bundle_dir) / "README.txt"
136
+ path.write_text(
137
+ _README.format(
138
+ source=source,
139
+ tempo=analysis.tempo,
140
+ key=analysis.key,
141
+ timesig=analysis.time_signature,
142
+ )
143
+ )
144
+ return path
145
+
146
+
147
+ def make_manifest_meta(source: str, analysis, model: str, stems_meta: dict) -> dict:
148
+ return {
149
+ "app": "stemflipper",
150
+ "version": "0.1.0",
151
+ "created_utc": datetime.now(timezone.utc).isoformat(timespec="seconds"),
152
+ "source_file": source,
153
+ "separation_model": model,
154
+ "tempo": analysis.tempo,
155
+ "key": analysis.key,
156
+ "time_signature": analysis.time_signature,
157
+ "duration": round(analysis.duration, 3),
158
+ "sample_rate": analysis.sr,
159
+ "stems": stems_meta,
160
+ }
161
+
162
+
163
+ def zip_bundle(bundle_dir: str | Path) -> Path:
164
+ bundle_dir = Path(bundle_dir)
165
+ zip_path = shutil.make_archive(str(bundle_dir), "zip", root_dir=bundle_dir.parent, base_dir=bundle_dir.name)
166
+ return Path(zip_path)
stemflipper/pipeline.py ADDED
@@ -0,0 +1,109 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Pipeline orchestrator: the one entry point the CLI, tests, and Gradio app share."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import shutil
7
+ from pathlib import Path
8
+
9
+ from . import analyze, audio_io, export, sampler, separate, transcribe
10
+
11
+
12
+ def run_pipeline(
13
+ input_path: str | Path,
14
+ output_dir: str | Path,
15
+ model: str = separate.DEFAULT_MODEL,
16
+ model_dir: str | Path | None = None,
17
+ progress=None,
18
+ make_zip: bool = True,
19
+ separate_fn=None,
20
+ ) -> dict:
21
+ """Full flow: analyze -> separate -> transcribe -> sampler -> bundle.
22
+
23
+ separate_fn lets app.py swap in a GPU-decorated separation call (ZeroGPU);
24
+ defaults to separate.separate_stems.
25
+ Returns {"bundle_dir", "zip_path", "manifest"}.
26
+ """
27
+
28
+ def report(frac: float, desc: str) -> None:
29
+ if progress is not None:
30
+ progress(frac, desc)
31
+
32
+ input_path = Path(input_path)
33
+ slug = re.sub(r"[^\w-]+", "_", input_path.stem).strip("_") or "song"
34
+ bundle_dir = Path(output_dir) / slug
35
+ if bundle_dir.exists():
36
+ shutil.rmtree(bundle_dir)
37
+ stems_dir = bundle_dir / "stems"
38
+ stems_dir.mkdir(parents=True)
39
+
40
+ report(0.02, "Loading audio")
41
+ y, sr = audio_io.load_audio(input_path, mono=True)
42
+
43
+ report(0.05, "Analyzing tempo & key")
44
+ analysis = analyze.analyze_audio(y, sr)
45
+
46
+ report(0.12, f"Separating stems ({model}) — the slow part")
47
+ do_separate = separate_fn or separate.separate_stems
48
+ raw_stems = do_separate(input_path, stems_dir, model=model, model_dir=model_dir)
49
+
50
+ # normalize to stems/<name>.wav
51
+ stem_paths: dict[str, Path] = {}
52
+ for name, path in raw_stems.items():
53
+ target = stems_dir / f"{name}.wav"
54
+ if path != target:
55
+ path.rename(target)
56
+ stem_paths[name] = target
57
+
58
+ tracks: dict[str, dict] = {}
59
+ stems_meta: dict[str, dict] = {}
60
+ ordered = [s for s in separate.KNOWN_STEMS if s in stem_paths]
61
+ for i, name in enumerate(ordered):
62
+ report(0.55 + 0.25 * i / len(ordered), f"Transcribing {name}")
63
+ stem_audio, stem_sr = audio_io.load_audio(stem_paths[name], mono=True)
64
+ silent = audio_io.is_silent(stem_audio)
65
+ result = (
66
+ {"notes": [], "is_drum": name == "drums"}
67
+ if silent
68
+ else transcribe.transcribe_stem(name, stem_paths[name])
69
+ )
70
+ tracks[name] = result
71
+ stems_meta[name] = {
72
+ "audio": f"stems/{name}.wav",
73
+ "silent": silent,
74
+ "n_notes": len(result["notes"]),
75
+ "midi": f"midi/{name}.mid" if result["notes"] else None,
76
+ "instrument_sfz": None,
77
+ }
78
+
79
+ for i, name in enumerate(ordered):
80
+ report(0.8 + 0.08 * i / len(ordered), f"Building {name} instrument")
81
+ built = sampler.build_sampler(
82
+ name,
83
+ stem_paths[name],
84
+ tracks[name]["notes"],
85
+ bundle_dir / "instruments" / name,
86
+ is_drum=tracks[name]["is_drum"],
87
+ )
88
+ if built:
89
+ stems_meta[name]["instrument_sfz"] = f"instruments/{name}/{name}.sfz"
90
+
91
+ report(0.9, "Writing MIDI, manifest, Reaper project")
92
+ export.write_midi(tracks, analysis.tempo, bundle_dir / "midi")
93
+ manifest = export.make_manifest_meta(input_path.name, analysis, model, stems_meta)
94
+ export.write_manifest(bundle_dir, manifest)
95
+ export.write_readme(bundle_dir, input_path.name, analysis)
96
+ export.write_rpp(
97
+ bundle_dir,
98
+ analysis.tempo,
99
+ {n: m["audio"] for n, m in stems_meta.items()},
100
+ analysis.duration,
101
+ )
102
+
103
+ zip_path = None
104
+ if make_zip:
105
+ report(0.96, "Zipping bundle")
106
+ zip_path = export.zip_bundle(bundle_dir)
107
+
108
+ report(1.0, "Done")
109
+ return {"bundle_dir": bundle_dir, "zip_path": zip_path, "manifest": manifest}
stemflipper/sampler.py ADDED
@@ -0,0 +1,105 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Sliced-sample instrument builder: transcribed MIDI boundaries -> wav slices + SFZ.
2
+
3
+ SFZ is plain text — generated with string templates on purpose (pysfz is abandoned;
4
+ see PLAN.md §4). One representative slice per distinct pitch, regions cover the full
5
+ key range via midpoints, single velocity layer. Loadable in sfizz / DecentSampler /
6
+ Sforzando.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from pathlib import Path
12
+
13
+ import numpy as np
14
+ import soundfile as sf
15
+
16
+ _MIN_SLICE_S = 0.03
17
+ _RELEASE_PAD_S = 0.05
18
+
19
+ _SFZ_HEADER = """\
20
+ // StemFlipper sliced instrument: {stem}
21
+ // Generated from transcribed note boundaries; load in sfizz, Sforzando or DecentSampler.
22
+ <control>
23
+ default_path=samples/
24
+
25
+ <global>
26
+ ampeg_release=0.4
27
+ {global_extra}
28
+ """
29
+
30
+ _SFZ_REGION = (
31
+ "<region> sample={sample} lokey={lokey} hikey={hikey} "
32
+ "pitch_keycenter={center}{extra}\n"
33
+ )
34
+
35
+
36
+ def build_sampler(
37
+ stem_name: str,
38
+ audio_path: str | Path,
39
+ notes: list[dict],
40
+ out_dir: str | Path,
41
+ is_drum: bool = False,
42
+ ) -> dict | None:
43
+ """Write instruments/<stem>/{<stem>.sfz, samples/*.wav}; return metadata or None."""
44
+ if not notes:
45
+ return None
46
+ y, sr = sf.read(str(audio_path))
47
+ if y.ndim > 1:
48
+ y = y.mean(axis=1)
49
+
50
+ picked = _pick_representatives(notes, is_drum)
51
+ out_dir = Path(out_dir)
52
+ samples_dir = out_dir / "samples"
53
+ samples_dir.mkdir(parents=True, exist_ok=True)
54
+
55
+ regions = []
56
+ for pitch, note in sorted(picked.items()):
57
+ start = int(note["start"] * sr)
58
+ end = int((note["end"] + _RELEASE_PAD_S) * sr)
59
+ seg = y[start : min(end, len(y))]
60
+ if len(seg) < int(_MIN_SLICE_S * sr):
61
+ continue
62
+ sample_name = f"{stem_name}_{pitch:03d}.wav"
63
+ sf.write(str(samples_dir / sample_name), seg, sr)
64
+ regions.append((pitch, sample_name))
65
+ if not regions:
66
+ return None
67
+
68
+ sfz_path = out_dir / f"{stem_name}.sfz"
69
+ sfz_path.write_text(_render_sfz(stem_name, regions, is_drum))
70
+ return {
71
+ "sfz": sfz_path,
72
+ "samples_dir": samples_dir,
73
+ "n_regions": len(regions),
74
+ }
75
+
76
+
77
+ def _pick_representatives(notes: list[dict], is_drum: bool) -> dict[int, dict]:
78
+ """One note per distinct pitch: loudest for drums, longest for pitched."""
79
+ best: dict[int, dict] = {}
80
+ for n in notes:
81
+ cur = best.get(n["pitch"])
82
+ if cur is None:
83
+ best[n["pitch"]] = n
84
+ elif is_drum and n["velocity"] > cur["velocity"]:
85
+ best[n["pitch"]] = n
86
+ elif not is_drum and (n["end"] - n["start"]) > (cur["end"] - cur["start"]):
87
+ best[n["pitch"]] = n
88
+ return best
89
+
90
+
91
+ def _render_sfz(stem_name: str, regions: list[tuple[int, str]], is_drum: bool) -> str:
92
+ global_extra = "loop_mode=one_shot" if is_drum else ""
93
+ out = _SFZ_HEADER.format(stem=stem_name, global_extra=global_extra)
94
+ pitches = [p for p, _ in regions]
95
+ for i, (pitch, sample) in enumerate(regions):
96
+ if is_drum:
97
+ lokey = hikey = pitch
98
+ else:
99
+ # midpoints to neighbors; outermost regions cover the full keyboard
100
+ lokey = 0 if i == 0 else (pitches[i - 1] + pitch) // 2 + 1
101
+ hikey = 127 if i == len(regions) - 1 else (pitch + pitches[i + 1]) // 2
102
+ out += _SFZ_REGION.format(
103
+ sample=sample, lokey=lokey, hikey=hikey, center=pitch, extra=""
104
+ )
105
+ return out
stemflipper/separate.py ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Source separation via audio-separator (wraps Demucs v4 / MDX / BS-RoFormer).
2
+
3
+ This module is the ONLY GPU-relevant stage. app.py wraps `separate_stems` in
4
+ `@spaces.GPU` when running on ZeroGPU; everywhere else it runs as-is (CPU or CUDA,
5
+ auto-detected by audio-separator).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import re
12
+ from pathlib import Path
13
+
14
+ # canonical stem names, in bundle order
15
+ KNOWN_STEMS = ("vocals", "drums", "bass", "guitar", "piano", "other")
16
+
17
+ # model name -> audio-separator model_filename
18
+ MODELS = {
19
+ "htdemucs": "htdemucs.yaml", # 4 stems, default (speed / CPU-viable)
20
+ "htdemucs_6s": "htdemucs_6s.yaml", # 6 stems; piano documented-weak, gate it
21
+ "htdemucs_ft": "htdemucs_ft.yaml", # bag-of-4, ~4x slower, +0.2 dB
22
+ }
23
+ DEFAULT_MODEL = "htdemucs"
24
+
25
+
26
+ def default_model_dir() -> Path:
27
+ env = os.environ.get("STEMFLIPPER_MODEL_DIR")
28
+ if env:
29
+ return Path(env)
30
+ return Path.home() / ".cache" / "stemflipper" / "models"
31
+
32
+
33
+ def separate_stems(
34
+ input_path: str | Path,
35
+ output_dir: str | Path,
36
+ model: str = DEFAULT_MODEL,
37
+ model_dir: str | Path | None = None,
38
+ ) -> dict[str, Path]:
39
+ """Run separation; return {canonical_stem_name: wav_path}."""
40
+ from audio_separator.separator import Separator
41
+
42
+ output_dir = Path(output_dir)
43
+ output_dir.mkdir(parents=True, exist_ok=True)
44
+ model_filename = MODELS.get(model, model) # allow raw model filenames too
45
+
46
+ separator = Separator(
47
+ output_dir=str(output_dir),
48
+ model_file_dir=str(model_dir or default_model_dir()),
49
+ output_format="WAV",
50
+ )
51
+ separator.load_model(model_filename=model_filename)
52
+ output_files = separator.separate(str(input_path))
53
+
54
+ stems: dict[str, Path] = {}
55
+ for f in output_files:
56
+ path = Path(f)
57
+ if not path.is_absolute():
58
+ path = output_dir / path
59
+ name = _stem_name_from_filename(path.name)
60
+ if name:
61
+ stems[name] = path
62
+ if not stems:
63
+ raise RuntimeError(f"separation produced no recognizable stems: {output_files}")
64
+ return stems
65
+
66
+
67
+ def _stem_name_from_filename(filename: str) -> str | None:
68
+ """audio-separator names outputs like 'song_(Vocals)_htdemucs.wav'."""
69
+ match = re.search(r"\(([^)]+)\)", filename)
70
+ candidate = (match.group(1) if match else filename).strip().lower()
71
+ for stem in KNOWN_STEMS:
72
+ if stem in candidate:
73
+ return stem
74
+ return None
stemflipper/transcribe.py ADDED
@@ -0,0 +1,97 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Per-stem transcription to note events.
2
+
3
+ Notes are plain dicts {pitch, start, end, velocity} — JSON-friendly, no cross-module types.
4
+ Pitched stems: basic-pitch (Apache-2.0, TFLite/CoreML — CPU-fast).
5
+ Drums: librosa onset detection + spectral-band heuristic -> GM map (known-weak on
6
+ overlapping hits; the honest MVP baseline per PLAN.md; ADT upgrade is out for licensing).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from pathlib import Path
12
+
13
+ import numpy as np
14
+
15
+ GM_KICK, GM_SNARE, GM_HAT = 36, 38, 42
16
+
17
+ # fmin/fmax clamps per stem type; bass clamp kills the #1 failure mode (octave errors)
18
+ _PITCH_RANGES = {
19
+ "bass": (30.0, 350.0),
20
+ "vocals": (60.0, 1500.0),
21
+ }
22
+
23
+
24
+ def transcribe_pitched(audio_path: str | Path, stem_name: str = "other") -> list[dict]:
25
+ from basic_pitch.inference import predict
26
+
27
+ fmin, fmax = _PITCH_RANGES.get(stem_name, (None, None))
28
+ kwargs = {}
29
+ if fmin is not None:
30
+ kwargs = {"minimum_frequency": fmin, "maximum_frequency": fmax}
31
+ _, midi_data, _ = predict(str(audio_path), **kwargs)
32
+
33
+ notes = []
34
+ for inst in midi_data.instruments:
35
+ for n in inst.notes:
36
+ notes.append(
37
+ {
38
+ "pitch": int(n.pitch),
39
+ "start": round(float(n.start), 4),
40
+ "end": round(float(n.end), 4),
41
+ "velocity": int(n.velocity),
42
+ }
43
+ )
44
+ notes.sort(key=lambda n: (n["start"], n["pitch"]))
45
+ return notes
46
+
47
+
48
+ def transcribe_drums(audio_path: str | Path) -> list[dict]:
49
+ import librosa
50
+
51
+ y, sr = librosa.load(str(audio_path), sr=None, mono=True)
52
+ if not len(y):
53
+ return []
54
+ onset_times = librosa.onset.onset_detect(
55
+ y=y, sr=sr, units="time", backtrack=False, delta=0.05
56
+ )
57
+ window = int(0.05 * sr)
58
+ freqs = np.fft.rfftfreq(window, 1 / sr)
59
+ peak = float(np.abs(y).max()) or 1.0
60
+
61
+ notes = []
62
+ for t in onset_times:
63
+ i = int(t * sr)
64
+ seg = y[i : i + window]
65
+ if len(seg) < window // 2 or np.abs(seg).max() < 1e-4:
66
+ continue
67
+ mag = np.abs(np.fft.rfft(seg, n=window))
68
+ total = mag.sum() or 1.0
69
+ low_ratio = float(mag[freqs < 150].sum() / total)
70
+ high_ratio = float(mag[freqs > 5000].sum() / total)
71
+ # a coincident hat adds ~0.4 high-band ratio, so only near-pure HF is a hat
72
+ if low_ratio > 0.25:
73
+ pitch = GM_KICK
74
+ elif high_ratio > 0.7:
75
+ pitch = GM_HAT
76
+ else:
77
+ pitch = GM_SNARE
78
+ velocity = int(np.clip(np.abs(seg).max() / peak * 127, 20, 127))
79
+ notes.append(
80
+ {
81
+ "pitch": pitch,
82
+ "start": round(float(t), 4),
83
+ "end": round(float(t) + 0.1, 4),
84
+ "velocity": velocity,
85
+ }
86
+ )
87
+ return notes
88
+
89
+
90
+ def transcribe_stem(stem_name: str, audio_path: str | Path) -> dict:
91
+ """Return {"notes": [...], "is_drum": bool}. Never raises on empty/unpitched audio."""
92
+ is_drum = stem_name == "drums"
93
+ try:
94
+ notes = transcribe_drums(audio_path) if is_drum else transcribe_pitched(audio_path, stem_name)
95
+ except Exception:
96
+ notes = []
97
+ return {"notes": notes, "is_drum": is_drum}