soloslot 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
soloslot/__init__.py ADDED
@@ -0,0 +1,10 @@
1
+ """Mute instruments in a solo section of a song or its stems to build play-along tracks."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ try:
6
+ __version__ = version("soloslot")
7
+ except PackageNotFoundError: # pragma: no cover - running from a source tree without install
8
+ __version__ = "0.0.0"
9
+
10
+ __all__ = ["__version__"]
soloslot/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from soloslot.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
soloslot/audio.py ADDED
@@ -0,0 +1,69 @@
1
+ """Pure audio and stem-name helpers (no file access)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from pathlib import Path
7
+
8
+ import numpy as np
9
+ from numpy.typing import NDArray
10
+
11
+ from soloslot.config import Solo
12
+
13
+ Audio = NDArray[np.floating]
14
+
15
+ # Moises names stems "<Title>-<Stem>-<Key>-<tempo>bpm-<hz>hz.wav"
16
+ _MOISES_RE = re.compile(r"-(?P<label>[^-]+)-[^-]+-\d+\s*bpm-\d+\s*hz$", re.IGNORECASE)
17
+
18
+
19
+ def stem_label(path: Path) -> str:
20
+ """Lower-case stem name (e.g. 'winds') taken from a file name."""
21
+ m = _MOISES_RE.search(path.stem)
22
+ return (m.group("label") if m else path.stem).strip().lower()
23
+
24
+
25
+ def matches(label: str, names: tuple[str, ...] | list[str]) -> bool:
26
+ """True if any name is a substring of the label (case-insensitive)."""
27
+ return any(n.lower() in label for n in names)
28
+
29
+
30
+ def apply_gaps(audio: Audio, sr: int, solos: tuple[Solo, ...], fade_ms: float) -> Audio:
31
+ """Silence the solo regions, with short fades at the edges to avoid clicks.
32
+
33
+ `audio` has shape (frames, channels).
34
+ """
35
+ out = audio.copy()
36
+ n = len(out)
37
+ fade = max(1, int(sr * fade_ms / 1000))
38
+ for solo in solos:
39
+ a = min(max(round(solo.start * sr), 0), n)
40
+ b = min(max(round(solo.end * sr), 0), n)
41
+ if b <= a:
42
+ continue
43
+ out[a:b] = 0
44
+ fa = min(fade, a) # fade-out ends at the gap
45
+ if fa:
46
+ out[a - fa : a] *= np.linspace(1.0, 0.0, fa, endpoint=False)[:, None]
47
+ fb = min(fade, n - b) # fade-in starts after the gap
48
+ if fb:
49
+ out[b : b + fb] *= np.linspace(0.0, 1.0, fb, endpoint=False)[:, None]
50
+ return out
51
+
52
+
53
+ def mix_stems(stems: list[Audio]) -> tuple[Audio, float]:
54
+ """Sum stems of possibly different length/channel count.
55
+
56
+ Returns the mix and the gain in dB applied to avoid clipping (0.0 if none).
57
+ """
58
+ length = max(len(s) for s in stems)
59
+ channels = max(s.shape[1] for s in stems)
60
+ mix = np.zeros((length, channels), dtype=np.float64)
61
+ for s in stems:
62
+ if s.shape[1] == 1 and channels > 1:
63
+ s = np.repeat(s, channels, axis=1)
64
+ mix[: len(s), : s.shape[1]] += s
65
+ peak = float(np.max(np.abs(mix))) if mix.size else 0.0
66
+ if peak > 0.99:
67
+ gain = 0.99 / peak
68
+ return (mix * gain).astype(np.float32), float(20 * np.log10(gain))
69
+ return mix.astype(np.float32), 0.0
soloslot/cli.py ADDED
@@ -0,0 +1,150 @@
1
+ """Command line interface."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import click
8
+
9
+ from soloslot import __version__
10
+ from soloslot.config import OUTPUT_FORMATS, ConfigError, load_config, song_from_audio
11
+ from soloslot.process import process_song
12
+ from soloslot.separate import DEFAULT_MODEL
13
+
14
+ DEFAULT_CONFIG = "solo.toml"
15
+
16
+
17
+ def _split(values: tuple[str, ...]) -> tuple[str, ...]:
18
+ """'piano,bass' 'drums' -> ('piano', 'bass', 'drums')"""
19
+ return tuple(v.strip() for value in values for v in value.split(",") if v.strip())
20
+
21
+
22
+ def _solo(value: str) -> dict[str, str]:
23
+ start, sep, end = value.partition("-")
24
+ if not sep or not start.strip() or not end.strip():
25
+ raise click.BadParameter(f"expected START-END, e.g. 2:15-3:40, not {value!r}")
26
+ return {"start": start.strip(), "end": end.strip()}
27
+
28
+
29
+ @click.command(context_settings={"help_option_names": ["-h", "--help"]})
30
+ @click.argument("inputs", nargs=-1, type=click.Path(exists=True, dir_okay=False, path_type=Path))
31
+ @click.option(
32
+ "--song", "only", metavar="NAME", help="Process only this song (name from the config)."
33
+ )
34
+ @click.option(
35
+ "--solo",
36
+ "solos",
37
+ multiple=True,
38
+ metavar="START-END",
39
+ help="Solo region of an audio file, e.g. 2:15-3:40 or 135-220. Repeat for several.",
40
+ )
41
+ @click.option(
42
+ "--hear",
43
+ multiple=True,
44
+ metavar="STEMS",
45
+ help="Stems that stay audible in the solo, e.g. piano,bass,drums. All others are muted.",
46
+ )
47
+ @click.option(
48
+ "--mute",
49
+ multiple=True,
50
+ metavar="STEMS",
51
+ help="Stems that are muted in the solo, e.g. guitar. All others stay audible.",
52
+ )
53
+ @click.option(
54
+ "--model",
55
+ default=None,
56
+ metavar="NAME",
57
+ help=f"Demucs model used to split audio files (default: {DEFAULT_MODEL}).",
58
+ )
59
+ @click.option(
60
+ "--format", "fmt", type=click.Choice(OUTPUT_FORMATS), default=None, help="Output format."
61
+ )
62
+ @click.option(
63
+ "-o",
64
+ "--output",
65
+ type=click.Path(file_okay=False, path_type=Path),
66
+ default=None,
67
+ help="Output folder for an audio file (default: out/<song>/ next to it).",
68
+ )
69
+ @click.option(
70
+ "--backing-only",
71
+ is_flag=True,
72
+ help="Write only the backing track, not the individual stems.",
73
+ )
74
+ @click.option("--dry-run", is_flag=True, help="Show what would happen, write nothing.")
75
+ @click.version_option(__version__, prog_name="soloslot")
76
+ def main(
77
+ inputs: tuple[Path, ...],
78
+ only: str | None,
79
+ solos: tuple[str, ...],
80
+ hear: tuple[str, ...],
81
+ mute: tuple[str, ...],
82
+ model: str | None,
83
+ fmt: str | None,
84
+ output: Path | None,
85
+ backing_only: bool,
86
+ dry_run: bool,
87
+ ) -> None:
88
+ """Mute instruments in a solo section and build a play-along backing track.
89
+
90
+ INPUTS are songs (MP3, WAV, FLAC, ...) or TOML configs.
91
+
92
+ \b
93
+ A song is split into stems with Demucs (needs the 'separate' extra):
94
+ soloslot song.mp3 --solo 2:15-3:40 --hear piano,bass,drums
95
+ soloslot song.flac --solo 1:05-2:40 --mute guitar
96
+
97
+ A config describes stems you already have, or songs to split (`input = "song.mp3"`).
98
+ A file holds either one song or several [[song]] tables. Without arguments,
99
+ ./solo.toml is used, so you can run the tool inside a stems folder. See
100
+ songs.example.toml and solo.example.toml.
101
+ """
102
+ configs = [p for p in inputs if p.suffix.lower() == ".toml"]
103
+ audio = [p for p in inputs if p.suffix.lower() != ".toml"]
104
+ song_options = solos or hear or mute or model or fmt or output or backing_only
105
+ if song_options and not audio:
106
+ raise click.UsageError(
107
+ "--solo, --hear, --mute, --model, --format, --output and --backing-only apply to "
108
+ "audio files; put these settings in the config instead"
109
+ )
110
+ if not inputs:
111
+ default = Path(DEFAULT_CONFIG)
112
+ if not default.is_file():
113
+ raise click.ClickException(
114
+ f"no input given and no ./{DEFAULT_CONFIG} in {Path.cwd()}. "
115
+ "Pass a song or a config file (see solo.example.toml)."
116
+ )
117
+ configs = [default]
118
+ if audio and not solos:
119
+ raise click.UsageError("give the solo region of the song with --solo START-END")
120
+ if hear and mute:
121
+ raise click.UsageError("use either --hear or --mute, not both")
122
+ if output and len(audio) > 1:
123
+ raise click.UsageError("--output works with one audio file at a time")
124
+
125
+ songs = []
126
+ try:
127
+ for config in configs:
128
+ songs.extend(load_config(config))
129
+ regions = [_solo(s) for s in solos]
130
+ options = {k: v for k, v in (("model", model), ("format", fmt)) if v}
131
+ if backing_only:
132
+ options["stems"] = False
133
+ songs.extend(
134
+ song_from_audio(
135
+ path, regions, hear=_split(hear), mute=_split(mute), output=output, **options
136
+ )
137
+ for path in audio
138
+ )
139
+ except ConfigError as e:
140
+ raise click.ClickException(str(e)) from e
141
+ if only:
142
+ songs = [s for s in songs if s.name == only]
143
+ if not songs:
144
+ raise click.ClickException(f"song '{only}' not found")
145
+
146
+ ok = [process_song(s, dry_run=dry_run, log=click.echo) for s in songs]
147
+ failed = len(ok) - sum(ok)
148
+ click.echo(f"\nDone: {sum(ok)} song(s) ok, {failed} failed.")
149
+ if failed:
150
+ raise SystemExit(1)
soloslot/config.py ADDED
@@ -0,0 +1,208 @@
1
+ """Reading and validating the songs config (TOML)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import tomllib
6
+ from dataclasses import dataclass, field
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from soloslot.separate import DEFAULT_MODEL
11
+
12
+ OUTPUT_FORMATS = ("wav", "flac")
13
+
14
+
15
+ class ConfigError(ValueError):
16
+ """The config file or one of its songs is invalid."""
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class Solo:
21
+ """A solo region in seconds."""
22
+
23
+ start: float
24
+ end: float
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class Song:
29
+ name: str
30
+ folder: Path
31
+ output: Path
32
+ solos: tuple[Solo, ...]
33
+ hear: tuple[str, ...] = ()
34
+ mute: tuple[str, ...] = ()
35
+ skip: tuple[str, ...] = ()
36
+ fade_ms: float = 30.0
37
+ mixdown: bool = True
38
+ write_stems: bool = True # False: only the backing track is written
39
+ format: str = "wav"
40
+ # A full song to split into stems first; `folder` then holds the separated stems.
41
+ input: Path | None = None
42
+ model: str = DEFAULT_MODEL
43
+ notes: list[str] = field(default_factory=list, compare=False)
44
+
45
+
46
+ def parse_time(value: Any) -> float:
47
+ """Seconds from a number, '135.5', '2:15' or '1:02:15.5'."""
48
+ if isinstance(value, bool):
49
+ raise ConfigError(f"invalid time: {value!r}")
50
+ if isinstance(value, int | float):
51
+ return float(value)
52
+ seconds = 0.0
53
+ try:
54
+ for part in str(value).strip().split(":"):
55
+ seconds = seconds * 60 + float(part)
56
+ except ValueError:
57
+ raise ConfigError(f"invalid time: {value!r}") from None
58
+ return seconds
59
+
60
+
61
+ def _solos(raw: dict[str, Any], name: str) -> tuple[Solo, ...]:
62
+ entries = raw.get("solos") or []
63
+ if not entries:
64
+ raise ConfigError(f"{name}: 'solos' is missing or empty")
65
+ sec_per_bar = None
66
+ solos: list[Solo] = []
67
+ for entry in entries:
68
+ if "start" in entry or "end" in entry:
69
+ if "start" not in entry or "end" not in entry:
70
+ raise ConfigError(f"{name}: 'start' and 'end' belong together")
71
+ start, end = parse_time(entry["start"]), parse_time(entry["end"])
72
+ elif "start_bar" in entry or "end_bar" in entry:
73
+ if "start_bar" not in entry or "end_bar" not in entry:
74
+ raise ConfigError(f"{name}: 'start_bar' and 'end_bar' belong together")
75
+ if "bpm" not in raw:
76
+ raise ConfigError(f"{name}: bars need 'bpm' on the song")
77
+ if sec_per_bar is None:
78
+ beats = float(raw.get("beats_per_bar", 4))
79
+ sec_per_bar = beats * 60.0 / float(raw["bpm"])
80
+ offset = parse_time(raw.get("offset", 0))
81
+ start = offset + (float(entry["start_bar"]) - 1) * sec_per_bar
82
+ end = offset + (float(entry["end_bar"]) - 1) * sec_per_bar
83
+ else:
84
+ raise ConfigError(f"{name}: a solo needs start/end or start_bar/end_bar")
85
+ if end <= start:
86
+ raise ConfigError(f"{name}: solo end {end:.2f}s is not after start {start:.2f}s")
87
+ solos.append(Solo(start, end))
88
+ return tuple(solos)
89
+
90
+
91
+ def _names(merged: dict[str, Any], key: str, name: str) -> tuple[str, ...]:
92
+ value = merged.get(key, ())
93
+ if isinstance(value, str):
94
+ value = [value]
95
+ if not isinstance(value, list | tuple) or not all(isinstance(v, str) for v in value):
96
+ raise ConfigError(f"{name}: '{key}' must be a list of stem names")
97
+ return tuple(value)
98
+
99
+
100
+ def _song(raw: dict[str, Any], defaults: dict[str, Any], base: Path, *, single: bool) -> Song:
101
+ merged = {**defaults, **raw}
102
+ if "input" in raw and "folder" in raw:
103
+ raise ConfigError(
104
+ f"{raw.get('name', raw['input'])}: use either 'input' (a song) or 'folder' (stems)"
105
+ )
106
+ source = base / str(raw["input"]) if "input" in raw else None
107
+ if single:
108
+ # One song per config file: the stems live next to the config by default.
109
+ folder = base / str(raw.get("folder", "."))
110
+ default_name = source.stem if source else folder.resolve().name
111
+ name = str(raw.get("name", default_name))
112
+ output = base / str(merged.get("output", "out"))
113
+ else:
114
+ if "name" not in raw and source is None:
115
+ raise ConfigError("every [[song]] needs 'name' and 'folder' (or 'input')")
116
+ if "folder" not in raw and source is None:
117
+ raise ConfigError(f"{raw['name']}: [[song]] needs 'folder' or 'input'")
118
+ name = str(raw.get("name", source.stem if source else ""))
119
+ folder = base / str(raw.get("folder", "."))
120
+ output = base / str(merged.get("output", "out")) / name
121
+ fmt = str(merged.get("format", "wav")).lower().lstrip(".")
122
+ if fmt not in OUTPUT_FORMATS:
123
+ raise ConfigError(f"{name}: format must be one of {', '.join(OUTPUT_FORMATS)}, not {fmt!r}")
124
+ hear, mute = _names(merged, "hear", name), _names(merged, "mute", name)
125
+ if "hear" in raw and "mute" in raw:
126
+ raise ConfigError(f"{name}: use either 'hear' (keep) or 'mute' (remove), not both")
127
+ # A song-level choice replaces the other one from [defaults].
128
+ if "hear" in raw:
129
+ mute = ()
130
+ elif "mute" in raw:
131
+ hear = ()
132
+ elif hear and mute:
133
+ raise ConfigError(f"{name}: [defaults] has both 'hear' and 'mute'")
134
+ mixdown, write_stems = bool(merged.get("mixdown", True)), bool(merged.get("stems", True))
135
+ if not mixdown and not write_stems:
136
+ raise ConfigError(f"{name}: 'mixdown' and 'stems' are both off, nothing would be written")
137
+ model = str(merged.get("model", DEFAULT_MODEL))
138
+ if source is not None:
139
+ # Separated stems are cached in the output folder and reused on the next run.
140
+ folder = output / "separated" / model
141
+ return Song(
142
+ name=name,
143
+ folder=folder,
144
+ output=output,
145
+ solos=_solos(raw, name),
146
+ hear=hear,
147
+ mute=mute,
148
+ skip=_names(merged, "skip", name),
149
+ fade_ms=float(merged.get("fade_ms", 30)),
150
+ mixdown=mixdown,
151
+ write_stems=write_stems,
152
+ format=fmt,
153
+ input=source,
154
+ model=model,
155
+ )
156
+
157
+
158
+ def song_from_audio(
159
+ path: Path,
160
+ solos: list[dict[str, Any]],
161
+ *,
162
+ hear: tuple[str, ...] = (),
163
+ mute: tuple[str, ...] = (),
164
+ output: Path | None = None,
165
+ **options: Any,
166
+ ) -> Song:
167
+ """A song to separate, given on the command line instead of in a config file.
168
+
169
+ The output defaults to `out/<song>/` next to the audio file.
170
+ """
171
+ path = path.resolve()
172
+ raw: dict[str, Any] = {"input": path.name, "solos": solos, **options}
173
+ if hear:
174
+ raw["hear"] = list(hear)
175
+ if mute:
176
+ raw["mute"] = list(mute)
177
+ raw["output"] = str(output.resolve() if output else path.parent / "out" / path.stem)
178
+ return _song(raw, {}, path.parent, single=True)
179
+
180
+
181
+ def load_config(path: Path) -> list[Song]:
182
+ """Load the songs of a config file. Relative paths resolve against the config's directory.
183
+
184
+ Two layouts are accepted:
185
+ - several songs: `[[song]]` tables, each with `name` and `folder`;
186
+ - one song: `solos` (and optional `hear`, `bpm`, ...) at the top level. `folder` defaults
187
+ to the config's directory, `name` to that directory's name, and output goes to `out/`.
188
+
189
+ Instead of `folder`, a song can name a full recording as `input`; it is split into stems
190
+ first (see `separate.py`).
191
+ """
192
+ try:
193
+ with path.open("rb") as fh:
194
+ cfg = tomllib.load(fh)
195
+ except tomllib.TOMLDecodeError as e:
196
+ raise ConfigError(f"{path}: {e}") from e
197
+ base = path.resolve().parent
198
+ defaults = cfg.get("defaults", {})
199
+ raw_songs = cfg.get("song", [])
200
+
201
+ if raw_songs and "solos" in cfg:
202
+ raise ConfigError(f"{path}: use either [[song]] tables or a top-level 'solos', not both")
203
+ if raw_songs:
204
+ return [_song(raw, defaults, base, single=False) for raw in raw_songs]
205
+ if "solos" in cfg:
206
+ top = {k: v for k, v in cfg.items() if k != "defaults"}
207
+ return [_song(top, defaults, base, single=True)]
208
+ raise ConfigError(f"{path}: no [[song]] entries and no top-level 'solos'")
soloslot/process.py ADDED
@@ -0,0 +1,140 @@
1
+ """Process one song: read stems, clear the solo, write stems and backing track."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+ from pathlib import Path
7
+
8
+ import soundfile as sf
9
+
10
+ from soloslot.audio import apply_gaps, matches, mix_stems, stem_label
11
+ from soloslot.config import Song
12
+ from soloslot.separate import MODEL_STEMS, SeparationError, is_cached, separate
13
+
14
+ AUDIO_EXTENSIONS = (".wav", ".flac", ".mp3") # in order of preference
15
+ _LOSSLESS_SUBTYPES = {"PCM_16", "PCM_24", "PCM_32", "FLOAT", "DOUBLE"}
16
+
17
+
18
+ def find_audio_files(folder: Path) -> list[Path]:
19
+ """Audio files in `folder`, one per base name (WAV preferred over FLAC over MP3)."""
20
+ chosen: dict[str, Path] = {}
21
+ for ext in reversed(AUDIO_EXTENSIONS):
22
+ for f in folder.iterdir():
23
+ if f.is_file() and f.suffix.lower() == ext:
24
+ chosen[f.stem] = f
25
+ return sorted(chosen.values())
26
+
27
+
28
+ def output_subtype(input_subtype: str, fmt: str = "wav") -> str:
29
+ """Keep the input's bit depth; lossy or unsupported inputs become 24 bit PCM.
30
+
31
+ FLAC cannot store float or 32 bit PCM, so those become 24 bit there.
32
+ """
33
+ allowed = _LOSSLESS_SUBTYPES if fmt == "wav" else {"PCM_16", "PCM_24"}
34
+ return input_subtype if input_subtype in allowed else "PCM_24"
35
+
36
+
37
+ def _check_names(song: Song, labels: list[str], log: Callable[[str], None]) -> None:
38
+ # A typo in 'hear' would silently mute a stem (and one in 'mute' keep it), so report it.
39
+ key, names = ("mute", song.mute) if song.mute else ("hear", song.hear)
40
+ for n in names:
41
+ if not any(n.lower() in label for label in labels):
42
+ log(f" WARNING: '{key}' entry '{n}' matches no stem of this song")
43
+
44
+
45
+ def _silenced(song: Song, label: str) -> bool:
46
+ return matches(label, song.mute) if song.mute else not matches(label, song.hear)
47
+
48
+
49
+ def _separate(song: Song, *, dry_run: bool, log: Callable[[str], None]) -> bool | None:
50
+ """Split `song.input` into `song.folder`. Returns True/False to stop early, None to go on."""
51
+ assert song.input is not None
52
+ if not song.input.is_file():
53
+ log(f" ERROR: input file not found: {song.input}")
54
+ return False
55
+ if dry_run and not is_cached(song.input, song.folder, song.model):
56
+ # Nothing to read yet: show what the separation would produce.
57
+ labels = list(MODEL_STEMS.get(song.model, ()))
58
+ log(f" would separate {song.input.name} with Demucs '{song.model}' into {song.folder}")
59
+ for s in song.solos:
60
+ log(f" Solo: {s.start:7.2f}s - {s.end:7.2f}s ({s.end - s.start:.1f}s)")
61
+ if labels:
62
+ _check_names(song, labels, log)
63
+ for label in labels:
64
+ log(f" {'silent in solo' if _silenced(song, label) else 'stays audible'}: {label}")
65
+ return True
66
+ try:
67
+ separate(song.input, song.folder, song.model, log=log)
68
+ except SeparationError as e:
69
+ log(f" ERROR: {e}")
70
+ return False
71
+ return None
72
+
73
+
74
+ def process_song(song: Song, *, dry_run: bool = False, log: Callable[[str], None] = print) -> bool:
75
+ """Process one song. Returns False (after logging why) if it could not be done."""
76
+ log(f"\n=== {song.name} ===")
77
+ if song.input is not None:
78
+ done = _separate(song, dry_run=dry_run, log=log)
79
+ if done is not None:
80
+ return done
81
+ if not song.folder.is_dir():
82
+ log(f" ERROR: folder not found: {song.folder}")
83
+ return False
84
+ if song.output.resolve() == song.folder.resolve():
85
+ # Would overwrite the original stems and feed the results back in on the next run.
86
+ log(" ERROR: output folder is the stems folder; choose a different 'output'")
87
+ return False
88
+ for solo in song.solos:
89
+ log(f" Solo: {solo.start:7.2f}s - {solo.end:7.2f}s ({solo.end - solo.start:.1f}s)")
90
+
91
+ stems = []
92
+ for f in find_audio_files(song.folder):
93
+ label = stem_label(f)
94
+ if song.skip and matches(label, song.skip):
95
+ log(f" skipped: {f.name}")
96
+ continue
97
+ stems.append((f, label))
98
+ if not stems:
99
+ log(f" ERROR: no audio files found ({', '.join(AUDIO_EXTENSIONS)})")
100
+ return False
101
+
102
+ _check_names(song, [label for _, label in stems], log)
103
+
104
+ out_dir = song.output
105
+ sample_rate = None
106
+ results = []
107
+ for f, label in stems:
108
+ info = sf.info(f)
109
+ audio, sr = sf.read(f, dtype="float32", always_2d=True)
110
+ if sample_rate is None:
111
+ sample_rate = sr
112
+ elif sr != sample_rate:
113
+ log(f" ERROR: {f.name} has {sr} Hz, expected {sample_rate} Hz")
114
+ return False
115
+ audible = not _silenced(song, label)
116
+ result = audio if audible else apply_gaps(audio, sr, song.solos, song.fade_ms)
117
+ log(f" {'stays audible' if audible else 'silent in solo'}: {label}")
118
+ results.append(result)
119
+ if song.write_stems and not dry_run:
120
+ out_dir.mkdir(parents=True, exist_ok=True)
121
+ sf.write(
122
+ out_dir / f"{f.stem}.{song.format}",
123
+ result,
124
+ sr,
125
+ subtype=output_subtype(info.subtype, song.format),
126
+ )
127
+
128
+ if song.mixdown and sample_rate is not None:
129
+ mix, gain_db = mix_stems(results)
130
+ if gain_db:
131
+ log(f" Mix would clip, lowered by {-gain_db:.1f} dB")
132
+ if not dry_run:
133
+ out_dir.mkdir(parents=True, exist_ok=True)
134
+ path = out_dir / f"{song.name} - Backing Track.{song.format}"
135
+ sf.write(path, mix, sample_rate, subtype="PCM_24")
136
+ log(f" written: {path}")
137
+
138
+ if not dry_run:
139
+ log(f" output: {out_dir.resolve()}")
140
+ return True
soloslot/separate.py ADDED
@@ -0,0 +1,118 @@
1
+ """Split a full song into stems with Demucs (optional `separate` extra).
2
+
3
+ Demucs and PyTorch are imported only when a song actually needs separating, so the rest of
4
+ the tool works without them.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ from collections.abc import Callable
11
+ from pathlib import Path
12
+
13
+ import numpy as np
14
+ import soundfile as sf
15
+ from numpy.typing import NDArray
16
+
17
+ DEFAULT_MODEL = "htdemucs_6s"
18
+
19
+ # Stems each known Demucs model produces. Used for --dry-run and for checking `hear`/`mute`
20
+ # before the (slow) separation; other model names are passed to Demucs as they are.
21
+ MODEL_STEMS: dict[str, tuple[str, ...]] = {
22
+ "htdemucs_6s": ("drums", "bass", "other", "vocals", "guitar", "piano"),
23
+ "htdemucs": ("drums", "bass", "other", "vocals"),
24
+ "htdemucs_ft": ("drums", "bass", "other", "vocals"),
25
+ "hdemucs_mmi": ("drums", "bass", "other", "vocals"),
26
+ "mdx": ("drums", "bass", "other", "vocals"),
27
+ "mdx_extra": ("drums", "bass", "other", "vocals"),
28
+ "mdx_q": ("drums", "bass", "other", "vocals"),
29
+ "mdx_extra_q": ("drums", "bass", "other", "vocals"),
30
+ }
31
+
32
+ INSTALL_HINT = (
33
+ "stem separation needs the 'separate' extra: "
34
+ "uv tool install 'soloslot[separate] @ git+https://github.com/swallat/soloslot.git'"
35
+ )
36
+
37
+ _MARKER = "source.json"
38
+ _EXT = "flac" # lossless and about half the size of WAV
39
+
40
+ # (frames, channels) float32 audio per stem name, and the sample rate
41
+ Stems = dict[str, NDArray[np.float32]]
42
+ Separator = Callable[[Path], tuple[Stems, int]]
43
+
44
+
45
+ class SeparationError(RuntimeError):
46
+ """Separation could not run (Demucs missing, model not available, unreadable input)."""
47
+
48
+
49
+ def load_separator(model: str) -> Separator:
50
+ """Load a Demucs model and return a function that splits one file into stems."""
51
+ try:
52
+ from demucs.api import Separator as DemucsSeparator
53
+ except ImportError as e:
54
+ raise SeparationError(INSTALL_HINT) from e
55
+ try:
56
+ demucs = DemucsSeparator(model=model, progress=True)
57
+ except Exception as e: # unknown model name, download failed, ...
58
+ raise SeparationError(f"could not load Demucs model '{model}': {e}") from e
59
+
60
+ def separate(path: Path) -> tuple[Stems, int]:
61
+ try:
62
+ _, stems = demucs.separate_audio_file(path)
63
+ except Exception as e:
64
+ raise SeparationError(f"could not separate {path.name}: {e}") from e
65
+ return {name: t.T.cpu().numpy().astype(np.float32) for name, t in stems.items()}, int(
66
+ demucs.samplerate
67
+ )
68
+
69
+ return separate
70
+
71
+
72
+ def _fingerprint(path: Path, model: str) -> dict[str, object]:
73
+ st = path.stat()
74
+ return {"input": path.name, "size": st.st_size, "mtime_ns": st.st_mtime_ns, "model": model}
75
+
76
+
77
+ def is_cached(input_path: Path, folder: Path, model: str) -> bool:
78
+ """True if `folder` holds a finished separation of exactly this input file and model."""
79
+ marker = folder / _MARKER
80
+ try:
81
+ return json.loads(marker.read_text(encoding="utf-8")) == _fingerprint(input_path, model)
82
+ except (OSError, ValueError):
83
+ return False
84
+
85
+
86
+ def separate(
87
+ input_path: Path,
88
+ folder: Path,
89
+ model: str = DEFAULT_MODEL,
90
+ *,
91
+ log: Callable[[str], None] = print,
92
+ load: Callable[[str], Separator] | None = None,
93
+ ) -> None:
94
+ """Split `input_path` into `<folder>/<stem>.flac`, unless that was already done.
95
+
96
+ The result is reused as long as the input file and model stay the same, so trying
97
+ another solo region does not separate the song again.
98
+ """
99
+ if is_cached(input_path, folder, model):
100
+ log(f" using separated stems from {folder}")
101
+ return
102
+ log(f" separating {input_path.name} with Demucs '{model}' (this takes a while) ...")
103
+ stems, sr = (load or load_separator)(model)(input_path)
104
+
105
+ # Separated stems can peak slightly above full scale. Lower them all by the same amount
106
+ # so they still add up to the original instead of clipping in the PCM file.
107
+ peak = max((float(np.max(np.abs(a))) for a in stems.values() if a.size), default=0.0)
108
+ gain = 0.99 / peak if peak > 0.99 else 1.0
109
+ if gain < 1.0:
110
+ log(f" separated stems lowered by {-20 * np.log10(gain):.1f} dB to avoid clipping")
111
+
112
+ folder.mkdir(parents=True, exist_ok=True)
113
+ (folder / _MARKER).unlink(missing_ok=True)
114
+ for old in folder.glob(f"*.{_EXT}"):
115
+ old.unlink()
116
+ for name, audio in stems.items():
117
+ sf.write(folder / f"{name}.{_EXT}", audio * gain, sr, subtype="PCM_24")
118
+ (folder / _MARKER).write_text(json.dumps(_fingerprint(input_path, model)), encoding="utf-8")
@@ -0,0 +1,254 @@
1
+ Metadata-Version: 2.5
2
+ Name: soloslot
3
+ Version: 0.1.0
4
+ Summary: Mute instruments in the solo of a song to build play-along backing tracks
5
+ Project-URL: Homepage, https://github.com/swallat/soloslot
6
+ Project-URL: Source, https://github.com/swallat/soloslot
7
+ Project-URL: Issues, https://github.com/swallat/soloslot/issues
8
+ Author: Sebastian Wallat
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: audio,backing-track,demucs,play-along,practice,stem-separation,stems
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: End Users/Desktop
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Topic :: Multimedia :: Sound/Audio
16
+ Requires-Python: >=3.12
17
+ Requires-Dist: click>=8.1
18
+ Requires-Dist: numpy>=1.26
19
+ Requires-Dist: soundfile>=0.12
20
+ Provides-Extra: separate
21
+ Requires-Dist: demucs>=4.1; extra == 'separate'
22
+ Description-Content-Type: text/markdown
23
+
24
+ <p align="center">
25
+ <img src="docs/logo.svg" alt="soloslot logo" width="420">
26
+ </p>
27
+
28
+ <h1 align="center">soloslot</h1>
29
+
30
+ <p align="center"><strong>Make room for your solo.</strong><br>
31
+ Turn any song into a play-along backing track in one command.</p>
32
+
33
+ <p align="center">
34
+ <a href="https://github.com/swallat/soloslot/actions/workflows/ci.yml"><img src="https://github.com/swallat/soloslot/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
35
+ <a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="License: MIT"></a>
36
+ </p>
37
+
38
+ You know the song. You want to play the guitar part, the piano solo, the sax line. But the
39
+ record has the original player on it, right where you want to jump in.
40
+
41
+ soloslot takes the song, finds the instruments you name, and takes them **out of the solo
42
+ section only**. The band keeps playing, the song stays whole before and after, and the gap is
43
+ yours.
44
+
45
+ ```bash
46
+ soloslot "Blue Bossa.mp3" --solo 1:05-2:40 --mute guitar
47
+ ```
48
+
49
+ That is the whole workflow: one song in, one backing track out.
50
+
51
+ ## Why soloslot
52
+
53
+ - **Practice with the real recording.** No more generic backing tracks or MIDI band-in-a-box.
54
+ You play with the actual band, the actual groove, the actual song.
55
+ - **Only the solo is muted.** Intro, theme and outro stay intact, so you play the song the way
56
+ it goes, from the top, and step into the gap at the right moment.
57
+ - **Start from a plain MP3.** Drop in an MP3, WAV or FLAC. soloslot splits it into stems with
58
+ [Demucs](https://github.com/adefossez/demucs), a state-of-the-art open-source separator. No
59
+ stems? No problem.
60
+ - **Or bring your own stems.** Already have stems from Moises, Demucs or your DAW? soloslot works
61
+ on those directly and needs no separation, no PyTorch and no GPU.
62
+ - **Say what goes, or what stays.** `--mute guitar` removes one instrument. `--hear
63
+ piano,bass,drums` keeps only the rhythm section. Pick whichever is shorter to type.
64
+ - **Several solos, one run.** Repeat `--solo` for every section you want to play over.
65
+ - **Click-free edits.** Short fades at the edges of each gap, so there are no pops or abrupt
66
+ cuts where the instrument leaves and returns.
67
+ - **Fast to try again.** The separation is cached. Change the solo region, mute another
68
+ instrument, re-run: the song is not split twice.
69
+ - **Safe for your files.** Your originals are never modified. Everything goes to a separate
70
+ output folder, and soloslot refuses to write over your input.
71
+ - **Private and offline.** Everything runs on your computer. Your recordings are never uploaded.
72
+ Only the Demucs model is downloaded, once.
73
+ - **Lossless output.** WAV or FLAC, with the bit depth of your source preserved (MP3 input is
74
+ written as 24 bit).
75
+ - **Plain-text config for repeat use.** Describe a song, or a whole repertoire, in a small TOML
76
+ file. Give solos as timestamps or as bar numbers.
77
+
78
+ ## What you get
79
+
80
+ For every song, an output folder with:
81
+
82
+ | File | What it is |
83
+ | --- | --- |
84
+ | `<song> - Backing Track.wav` | The finished play-along track: the whole song with your chosen instruments missing in the solo. Load it into your player and go. |
85
+ | `drums.wav`, `bass.wav`, `guitar.wav`, ... | The individual stems, each with the same mute applied. Handy for DAWs, for loops, or for mixing your own balance. Skip them with `--backing-only`. |
86
+ | `separated/<model>/` | The untouched stems from separation, kept so the next run is instant. |
87
+
88
+ ## Install
89
+
90
+ You need [uv](https://docs.astral.sh/uv/). It fetches a suitable Python (3.12 or newer) for you.
91
+
92
+ ```bash
93
+ # with stem separation (pulls in PyTorch, a large download)
94
+ uv tool install "soloslot[separate] @ git+https://github.com/swallat/soloslot.git"
95
+
96
+ # without it, if you only work on stems you already have
97
+ uv tool install git+https://github.com/swallat/soloslot.git
98
+
99
+ soloslot --help
100
+ ```
101
+
102
+ Update with `uv tool upgrade soloslot`.
103
+
104
+ The first separation downloads the Demucs model (about 50 to 100 MB) and caches it. Separation
105
+ runs on an NVIDIA GPU if PyTorch finds one, otherwise on the CPU, where a song takes a few
106
+ minutes.
107
+
108
+ ## Quick start
109
+
110
+ ```bash
111
+ # The guitar drops out between 1:05 and 2:40
112
+ soloslot song.mp3 --solo 1:05-2:40 --mute guitar
113
+
114
+ # Only piano, bass and drums stay in the solo
115
+ soloslot song.flac --solo 2:15-3:40 --hear piano,bass,drums
116
+
117
+ # Two solos, two instruments gone
118
+ soloslot song.mp3 --solo 1:05-2:40 --solo 4:10-5:02 --mute guitar,piano
119
+
120
+ # Look first, write nothing
121
+ soloslot song.mp3 --solo 2:15-3:40 --mute guitar --dry-run
122
+ ```
123
+
124
+ | Option | Meaning |
125
+ | --- | --- |
126
+ | `--solo START-END` | A solo region. Repeat it for several. Times like `135.5`, `2:15`, `1:02:15.5`. |
127
+ | `--mute STEMS` | Silence these stems in the solo; everything else stays. |
128
+ | `--hear STEMS` | The opposite: only these stems stay in the solo. Use one of the two. |
129
+ | `--model` | Demucs model. Default `htdemucs_6s` splits into **drums, bass, guitar, piano, vocals, other**. `htdemucs` and `htdemucs_ft` give drums, bass, vocals, other, and are a bit cleaner at that. |
130
+ | `--backing-only` | Write only the backing track. |
131
+ | `--format flac` | Write FLAC instead of WAV. |
132
+ | `-o, --output DIR` | Output folder. Default: `out/<song>/` next to the song. |
133
+ | `--dry-run` | Show what would happen and write nothing. |
134
+
135
+ ## Working with stems you already have
136
+
137
+ Put each song's stems (WAV, FLAC or MP3) in their own folder and describe the solo in a
138
+ `solo.toml` next to them (see `solo.example.toml`):
139
+
140
+ ```toml
141
+ hear = ["piano", "bass", "drums"] # stems that stay audible in the solo
142
+ skip = ["visualizer"] # ignore the full mix
143
+ solos = [ { start = "2:15", end = "3:40" } ]
144
+ ```
145
+
146
+ ```bash
147
+ cd stems/Interplay
148
+ soloslot # uses ./solo.toml
149
+ soloslot --dry-run # show what would happen, write nothing
150
+ ```
151
+
152
+ The song name defaults to the folder name and the result goes to `./out/`. You can also pass
153
+ several configs at once, for example `soloslot stems/*/solo.toml`.
154
+
155
+ ### A whole repertoire in one file
156
+
157
+ Copy `songs.example.toml` to `songs.toml` and list your songs as `[[song]]` tables:
158
+
159
+ ```toml
160
+ [defaults]
161
+ hear = ["piano", "bass", "drums"] # shared by all songs
162
+
163
+ [[song]]
164
+ name = "Interplay"
165
+ folder = "stems/Interplay"
166
+ bpm = 113
167
+ solos = [ { start_bar = 36, end_bar = 97 } ]
168
+
169
+ [[song]]
170
+ name = "Blue Bossa"
171
+ input = "Blue Bossa.mp3" # a full song: split it first
172
+ mute = ["guitar"]
173
+ solos = [ { start = "1:05", end = "2:40" } ]
174
+ ```
175
+
176
+ ```bash
177
+ soloslot songs.toml # all songs
178
+ soloslot songs.toml --song Interplay # one song
179
+ ```
180
+
181
+ Output goes to `out/<song>/`, next to the config file. Defaults live in `[defaults]` and any
182
+ song can override them.
183
+
184
+ ## Configuration reference
185
+
186
+ - **`input`**: a full song to split into stems first, instead of `folder`. The song name
187
+ defaults to the file name.
188
+ - **`model`**: Demucs model for `input`, default `htdemucs_6s`.
189
+ - **`hear`**: stems that stay audible in the solo; every other stem is silenced there. Matching
190
+ is a case-insensitive substring of the stem name (`"piano"` matches `Grand Piano`). Stems a
191
+ song does not have are ignored. A `hear` entry that matches nothing is reported as a warning,
192
+ so a typo cannot silently mute the wrong thing.
193
+ - **`mute`**: the opposite of `hear`. Use either one per song.
194
+ - **`skip`**: files or stems to ignore, such as a full mix (`["visualizer"]`).
195
+ - **`solos`**: one or more solo regions, see below.
196
+ - **`format`**: `"wav"` (default) or `"flac"`. FLAC is lossless and smaller; float or 32 bit
197
+ input becomes 24 bit there.
198
+ - **`stems`**: `stems = false` writes only the backing track.
199
+ - **`fade_ms`**, **`mixdown`**, **`output`**: see `songs.example.toml`.
200
+ - Relative paths are resolved against the directory of the config file.
201
+
202
+ ### Solo regions: timestamps or bars
203
+
204
+ ```toml
205
+ # Timestamps: no tempo needed
206
+ solos = [ { start = "2:15", end = "3:40" } ]
207
+
208
+ # Several solos, mixing seconds and mm:ss
209
+ solos = [ { start = 135.5, end = "3:40" }, { start = "4:10.5", end = "5:02" } ]
210
+
211
+ # Bars: needs bpm on the song
212
+ bpm = 113
213
+ solos = [ { start_bar = 36, end_bar = 97 } ]
214
+ ```
215
+
216
+ - **Timestamps** are seconds from the start of the file: `135.5`, `"2:15"` or `"1:02:15.5"`.
217
+ Read them off your player or DAW.
218
+ - **Bars** count from 1, bar 1 starts at the beginning of the file, and `end_bar` is the bar
219
+ where the solo is over again. Use `beats_per_bar` (default 4) for other meters and `offset`
220
+ if the file has a lead-in before bar 1.
221
+ - Bars assume a constant tempo. For live recordings with a drifting tempo, use timestamps.
222
+
223
+ Stem names are read from Moises file names (`<title>-<Stem>-<key>-<tempo>bpm-<hz>hz.wav`);
224
+ other names are matched on the whole file name.
225
+
226
+ ## Your audio
227
+
228
+ soloslot only processes files you provide, on your own computer. It includes no recordings and
229
+ downloads none; only the Demucs model is fetched. Make sure you have the right to use your
230
+ recordings. The output keeps the copyright status of the original. That is why `*.wav`,
231
+ `*.mp3` and `*.flac` are git-ignored and the tests use synthetic audio only.
232
+
233
+ ## Develop
234
+
235
+ [mise](https://mise.jdx.dev) provides `uv`; `uv` manages Python and packages.
236
+
237
+ ```bash
238
+ mise install # uv
239
+ mise run install # uv sync
240
+ mise run check # ruff + pytest
241
+ mise tasks # all tasks
242
+ ```
243
+
244
+ See [CONTRIBUTING.md](CONTRIBUTING.md) to contribute, [CHANGELOG.md](CHANGELOG.md) for changes
245
+ and [SECURITY.md](SECURITY.md) to report a vulnerability.
246
+
247
+ ## License
248
+
249
+ [MIT](LICENSE) © 2026 Sebastian Wallat.
250
+
251
+ soloslot installs these libraries, which keep their own licenses: click (BSD-3-Clause), NumPy
252
+ (BSD-3-Clause and other permissive licenses) and python-soundfile (BSD-3-Clause), whose binary
253
+ wheels include libsndfile (LGPL-2.1-or-later). The `separate` extra adds Demucs (MIT) and
254
+ PyTorch (BSD-3-Clause).
@@ -0,0 +1,12 @@
1
+ soloslot/__init__.py,sha256=Lr3YjsthOixHR7NbxDJyDeosrMlExGsYw0zmXEW9DOE,345
2
+ soloslot/__main__.py,sha256=Egt1P0y1KycRehrUz3pBg4QpYJUXTflCVkdeUELA9CU,69
3
+ soloslot/audio.py,sha256=tuvud7Qv-yqQfnsd8nBji7GoPLrC_dV0bUZ5U021olc,2347
4
+ soloslot/cli.py,sha256=VaCI2nemHKwOIiSBwvOPr24WGAg-QzMZ_69n93dC-2I,5281
5
+ soloslot/config.py,sha256=dHsdHhvt4jWNQIQFSOMOGGO8k2RiEoGRH1dj7KEoEyo,7998
6
+ soloslot/process.py,sha256=xLHdG1l9LOumHClH0kzV2ywWsHeraRr8S8jaG3eU_-Q,5666
7
+ soloslot/separate.py,sha256=svjV92J6VzyKan5NV9NIf0uZAdfiNcEyvu6s1U9-cRc,4549
8
+ soloslot-0.1.0.dist-info/METADATA,sha256=nyobUnaYHqNXtsFiME8vmqY1I3jal7GAvj4o_BXVasc,10581
9
+ soloslot-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
10
+ soloslot-0.1.0.dist-info/entry_points.txt,sha256=WBvT6yEo2xyjIT1mTqneh51sWzC8dCnwpKbLSkq4qfs,47
11
+ soloslot-0.1.0.dist-info/licenses/LICENSE,sha256=sRhl1P6VL5jxrbHYAjBUnStsajxXaMiVzrHuTnzwtkM,1073
12
+ soloslot-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ soloslot = soloslot.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Sebastian Wallat
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.