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 +10 -0
- soloslot/__main__.py +4 -0
- soloslot/audio.py +69 -0
- soloslot/cli.py +150 -0
- soloslot/config.py +208 -0
- soloslot/process.py +140 -0
- soloslot/separate.py +118 -0
- soloslot-0.1.0.dist-info/METADATA +254 -0
- soloslot-0.1.0.dist-info/RECORD +12 -0
- soloslot-0.1.0.dist-info/WHEEL +4 -0
- soloslot-0.1.0.dist-info/entry_points.txt +2 -0
- soloslot-0.1.0.dist-info/licenses/LICENSE +21 -0
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
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,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.
|