Spaces:
Running on Zero
Running on Zero
deploy from local repo (runtime files only)
Browse files- README.md +54 -6
- app.py +105 -0
- packages.txt +2 -0
- requirements.txt +11 -0
- stemflipper/__init__.py +3 -0
- stemflipper/__main__.py +55 -0
- stemflipper/analyze.py +67 -0
- stemflipper/audio_io.py +38 -0
- stemflipper/export.py +166 -0
- stemflipper/pipeline.py +109 -0
- stemflipper/sampler.py +105 -0
- stemflipper/separate.py +74 -0
- stemflipper/transcribe.py +97 -0
README.md
CHANGED
|
@@ -1,13 +1,61 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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}
|