chuja 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.
- chuja/__init__.py +33 -0
- chuja/banner.py +77 -0
- chuja/cli.py +286 -0
- chuja/errors.py +26 -0
- chuja/export.py +112 -0
- chuja/pipeline.py +78 -0
- chuja/separator.py +206 -0
- chuja/server.py +245 -0
- chuja/sources.py +105 -0
- chuja/util.py +62 -0
- chuja/web/console.html +814 -0
- chuja-0.1.0.dist-info/METADATA +202 -0
- chuja-0.1.0.dist-info/RECORD +16 -0
- chuja-0.1.0.dist-info/WHEEL +4 -0
- chuja-0.1.0.dist-info/entry_points.txt +2 -0
- chuja-0.1.0.dist-info/licenses/LICENSE +21 -0
chuja/__init__.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""chuja — separate a song into stems from a local file or (opt-in) a URL.
|
|
2
|
+
|
|
3
|
+
>>> import chuja
|
|
4
|
+
>>> result = chuja.separate("song.mp3", out_dir="stems", fmt="mp3")
|
|
5
|
+
>>> result.stems
|
|
6
|
+
{'drums': PosixPath('stems/song/drums.mp3'), ...}
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from .errors import (
|
|
10
|
+
ExportError,
|
|
11
|
+
FetchError,
|
|
12
|
+
MissingDependencyError,
|
|
13
|
+
SeparationError,
|
|
14
|
+
SourceError,
|
|
15
|
+
ChujaError,
|
|
16
|
+
)
|
|
17
|
+
from .pipeline import Result, separate
|
|
18
|
+
from .separator import MODELS
|
|
19
|
+
|
|
20
|
+
__version__ = "0.1.0"
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"separate",
|
|
24
|
+
"Result",
|
|
25
|
+
"MODELS",
|
|
26
|
+
"__version__",
|
|
27
|
+
"ChujaError",
|
|
28
|
+
"SourceError",
|
|
29
|
+
"FetchError",
|
|
30
|
+
"MissingDependencyError",
|
|
31
|
+
"SeparationError",
|
|
32
|
+
"ExportError",
|
|
33
|
+
]
|
chuja/banner.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""ASCII wordmark + a small startup animation for the CLI.
|
|
2
|
+
|
|
3
|
+
All animation auto-disables when stdout is not a TTY (piped/redirected), so
|
|
4
|
+
scripts and captured output stay clean."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import sys
|
|
9
|
+
import time
|
|
10
|
+
|
|
11
|
+
from rich.console import Console
|
|
12
|
+
from rich.text import Text
|
|
13
|
+
|
|
14
|
+
# Block wordmark. Kept as plain rows so we can color/animate per line.
|
|
15
|
+
_LOGO = [
|
|
16
|
+
" ██████ ██ ██ ██ ██ ███ █████ ",
|
|
17
|
+
"██ ██ ██ ██ ██ ██ ██ ██",
|
|
18
|
+
"██ ███████ ██ ██ ██ ███████",
|
|
19
|
+
"██ ██ ██ ██ ██ ██ ██ ██ ██",
|
|
20
|
+
" ██████ ██ ██ █████ █████ ██ ██",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
# warm amber gradient top→bottom
|
|
24
|
+
_SHADES = ["#ffb38f", "#ff8c5a", "#ff6b35", "#ef5a28", "#d44a1c"]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _is_tty() -> bool:
|
|
28
|
+
try:
|
|
29
|
+
return sys.stdout.isatty()
|
|
30
|
+
except Exception:
|
|
31
|
+
return False
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def show(console: Console | None = None, *, animate: bool | None = None, tagline: str = "stem console") -> None:
|
|
35
|
+
"""Render the wordmark. Animates a staggered reveal + a tiny VU flourish
|
|
36
|
+
when attached to a terminal."""
|
|
37
|
+
console = console or Console()
|
|
38
|
+
if animate is None:
|
|
39
|
+
animate = _is_tty()
|
|
40
|
+
|
|
41
|
+
console.print()
|
|
42
|
+
for i, row in enumerate(_LOGO):
|
|
43
|
+
line = Text(" " + row, style=f"bold {_SHADES[i]}")
|
|
44
|
+
console.print(line)
|
|
45
|
+
if animate:
|
|
46
|
+
time.sleep(0.05)
|
|
47
|
+
|
|
48
|
+
sub = Text(" ", style="")
|
|
49
|
+
sub.append("│ ", style="#474c54")
|
|
50
|
+
sub.append(tagline.upper(), style="bold #d7dbe0")
|
|
51
|
+
sub.append(" · ", style="#474c54")
|
|
52
|
+
sub.append("local neural stem separation", style="#767c85")
|
|
53
|
+
console.print(sub)
|
|
54
|
+
|
|
55
|
+
if animate:
|
|
56
|
+
_vu(console)
|
|
57
|
+
console.print()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _vu(console: Console, frames: int = 14) -> None:
|
|
61
|
+
"""A brief equalizer flourish under the wordmark."""
|
|
62
|
+
import random # local: only used in the animated TTY path
|
|
63
|
+
|
|
64
|
+
bars = 28
|
|
65
|
+
heights = [0] * bars
|
|
66
|
+
blocks = " ▁▂▃▄▅▆▇█"
|
|
67
|
+
for _ in range(frames):
|
|
68
|
+
line = Text(" ")
|
|
69
|
+
for b in range(bars):
|
|
70
|
+
target = random.randint(0, 8)
|
|
71
|
+
heights[b] = (heights[b] + target) // 2
|
|
72
|
+
shade = _SHADES[min(len(_SHADES) - 1, heights[b] // 2)]
|
|
73
|
+
line.append(blocks[heights[b]], style=shade)
|
|
74
|
+
console.print(line, end="\r")
|
|
75
|
+
time.sleep(0.045)
|
|
76
|
+
# clear the flourish line
|
|
77
|
+
console.print(Text(" " * (bars + 4)), end="\r")
|
chuja/cli.py
ADDED
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
"""Command-line interface for chuja."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
import tempfile
|
|
7
|
+
import webbrowser
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Optional
|
|
10
|
+
|
|
11
|
+
import click
|
|
12
|
+
import typer
|
|
13
|
+
from rich.console import Console
|
|
14
|
+
from rich.panel import Panel
|
|
15
|
+
from rich.table import Table
|
|
16
|
+
from rich.text import Text
|
|
17
|
+
from typer.core import TyperGroup
|
|
18
|
+
|
|
19
|
+
from . import __version__, banner
|
|
20
|
+
from .errors import ChujaError
|
|
21
|
+
from .pipeline import separate as run_separate
|
|
22
|
+
from .separator import DEFAULT_MODEL, MODELS
|
|
23
|
+
|
|
24
|
+
console = Console()
|
|
25
|
+
err_console = Console(stderr=True)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class DefaultGroup(TyperGroup):
|
|
29
|
+
"""Let `chuja song.mp3` work as a shortcut for `chuja separate song.mp3`."""
|
|
30
|
+
|
|
31
|
+
def resolve_command(self, ctx, args):
|
|
32
|
+
try:
|
|
33
|
+
return super().resolve_command(ctx, args)
|
|
34
|
+
except click.UsageError:
|
|
35
|
+
if not args:
|
|
36
|
+
raise
|
|
37
|
+
return super().resolve_command(ctx, ["separate", *args])
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
app = typer.Typer(
|
|
41
|
+
cls=DefaultGroup,
|
|
42
|
+
add_completion=False,
|
|
43
|
+
help="Separate a song into stems (vocals/drums/bass/other). "
|
|
44
|
+
"Run `chuja` with no arguments for interactive mode, or `chuja serve` for the visual console.",
|
|
45
|
+
rich_markup_mode="rich",
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _version_callback(value: bool):
|
|
50
|
+
if value:
|
|
51
|
+
console.print(f"chuja {__version__}")
|
|
52
|
+
raise typer.Exit()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# ----------------------------------------------------------------------------
|
|
56
|
+
# separate
|
|
57
|
+
# ----------------------------------------------------------------------------
|
|
58
|
+
@app.command()
|
|
59
|
+
def separate(
|
|
60
|
+
source: str = typer.Argument(..., help="Audio file path, or a URL (needs the 'url' extra)."),
|
|
61
|
+
out: Path = typer.Option("stems", "--out", "-o", help="Directory to write the stem folder into."),
|
|
62
|
+
model: str = typer.Option(DEFAULT_MODEL, "--model", "-m", help="Separation model. See `chuja models`."),
|
|
63
|
+
fmt: str = typer.Option("wav", "--format", "-f", help="Output format: wav, mp3, or flac."),
|
|
64
|
+
two_stems: Optional[str] = typer.Option(None, "--two-stems", help="One stem + accompaniment, e.g. vocals."),
|
|
65
|
+
mp3_bitrate: int = typer.Option(320, "--mp3-bitrate", help="kbps for mp3 export."),
|
|
66
|
+
zip_output: bool = typer.Option(False, "--zip", help="Also bundle the stems into a portable .zip."),
|
|
67
|
+
device: Optional[str] = typer.Option(None, "--device", help="Force a torch device: cpu, cuda, or mps."),
|
|
68
|
+
open_folder: bool = typer.Option(False, "--open", "-O", help="Reveal the output folder in your file manager when done."),
|
|
69
|
+
quiet: bool = typer.Option(False, "--quiet", "-q", help="Suppress the banner and progress chatter."),
|
|
70
|
+
):
|
|
71
|
+
"""Separate SOURCE into stems."""
|
|
72
|
+
if not quiet:
|
|
73
|
+
banner.show(console)
|
|
74
|
+
|
|
75
|
+
def on_event(message: str):
|
|
76
|
+
if not quiet:
|
|
77
|
+
console.print(f" [dim]›[/] {message}")
|
|
78
|
+
|
|
79
|
+
try:
|
|
80
|
+
result = run_separate(
|
|
81
|
+
source, out_dir=out, model=model, two_stems=two_stems, fmt=fmt,
|
|
82
|
+
mp3_bitrate=mp3_bitrate, zip_output=zip_output, device=device, on_event=on_event,
|
|
83
|
+
)
|
|
84
|
+
except ChujaError as exc:
|
|
85
|
+
err_console.print(f"\n[bold red]✗ Error:[/] {exc}")
|
|
86
|
+
raise typer.Exit(code=1)
|
|
87
|
+
except KeyboardInterrupt: # pragma: no cover
|
|
88
|
+
err_console.print("\n[yellow]Cancelled.[/]")
|
|
89
|
+
raise typer.Exit(code=130)
|
|
90
|
+
|
|
91
|
+
_print_result(result, fmt)
|
|
92
|
+
if open_folder:
|
|
93
|
+
_reveal(result.out_dir)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _reveal_command() -> str:
|
|
97
|
+
"""The platform-native 'open this folder' command, for the copy-paste hint."""
|
|
98
|
+
if sys.platform == "darwin":
|
|
99
|
+
return "open"
|
|
100
|
+
if sys.platform.startswith("win"):
|
|
101
|
+
return "explorer"
|
|
102
|
+
return "xdg-open"
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _reveal(path: Path) -> bool:
|
|
106
|
+
"""Open a folder in the OS file manager. Best-effort; never raises."""
|
|
107
|
+
import subprocess
|
|
108
|
+
|
|
109
|
+
target = str(Path(path).resolve())
|
|
110
|
+
try:
|
|
111
|
+
if sys.platform.startswith("win"):
|
|
112
|
+
import os
|
|
113
|
+
os.startfile(target) # type: ignore[attr-defined]
|
|
114
|
+
else:
|
|
115
|
+
subprocess.run([_reveal_command(), target], check=False,
|
|
116
|
+
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
117
|
+
return True
|
|
118
|
+
except Exception:
|
|
119
|
+
return False
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _print_result(result, fmt):
|
|
123
|
+
out_dir = Path(result.out_dir).resolve()
|
|
124
|
+
body = Text()
|
|
125
|
+
body.append(f"{result.track}\n\n", style="bold #ff6b35")
|
|
126
|
+
# The folder, front and center — absolute so it's unambiguous and clickable.
|
|
127
|
+
body.append("folder ", style="#767c85")
|
|
128
|
+
body.append(f"{out_dir}\n", style="bold #d7dbe0")
|
|
129
|
+
for name, path in result.stems.items():
|
|
130
|
+
body.append(f" {name:<13}", style="#29d3c2")
|
|
131
|
+
body.append(f"{Path(path).name}\n", style="dim")
|
|
132
|
+
if result.archive:
|
|
133
|
+
body.append(" ", style="")
|
|
134
|
+
body.append(f"{'zip':<13}", style="#b56bff")
|
|
135
|
+
body.append(f"{Path(result.archive).name}\n", style="dim")
|
|
136
|
+
body.append("\nreveal ", style="#767c85")
|
|
137
|
+
body.append(f'{_reveal_command()} "{out_dir}"', style="#29d3c2")
|
|
138
|
+
console.print()
|
|
139
|
+
console.print(Panel(body, title=f"[bold green]✓ {len(result.stems)} stems · {fmt}[/]",
|
|
140
|
+
subtitle="[dim]rerun with --open to jump straight to the folder[/]",
|
|
141
|
+
border_style="#34383e", expand=False))
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
# ----------------------------------------------------------------------------
|
|
145
|
+
# serve
|
|
146
|
+
# ----------------------------------------------------------------------------
|
|
147
|
+
@app.command()
|
|
148
|
+
def serve(
|
|
149
|
+
out: Path = typer.Option(
|
|
150
|
+
Path(tempfile.gettempdir()) / "chuja-stems", "--out", "-o",
|
|
151
|
+
help="Where the console writes stems.",
|
|
152
|
+
),
|
|
153
|
+
port: int = typer.Option(7777, "--port", "-p", help="Port to listen on."),
|
|
154
|
+
host: str = typer.Option("127.0.0.1", "--host", help="Interface to bind (localhost by default)."),
|
|
155
|
+
model: str = typer.Option(DEFAULT_MODEL, "--model", "-m", help="Default model for the console."),
|
|
156
|
+
no_open: bool = typer.Option(False, "--no-open", help="Don't auto-open the browser."),
|
|
157
|
+
):
|
|
158
|
+
"""Launch the visual mixing console in your browser."""
|
|
159
|
+
_serve(out=out, port=port, host=host, model=model, no_open=no_open)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _serve(
|
|
163
|
+
*,
|
|
164
|
+
out: Optional[Path] = None,
|
|
165
|
+
port: int = 7777,
|
|
166
|
+
host: str = "127.0.0.1",
|
|
167
|
+
model: str = DEFAULT_MODEL,
|
|
168
|
+
no_open: bool = False,
|
|
169
|
+
):
|
|
170
|
+
"""Plain implementation shared by the `serve` command and interactive mode.
|
|
171
|
+
|
|
172
|
+
Kept separate because Typer command functions can't be called directly —
|
|
173
|
+
their parameter defaults are OptionInfo descriptors, not values."""
|
|
174
|
+
from . import server # local import: avoids loading the server unless used
|
|
175
|
+
|
|
176
|
+
if out is None:
|
|
177
|
+
out = Path(tempfile.gettempdir()) / "chuja-stems"
|
|
178
|
+
|
|
179
|
+
banner.show(console, tagline="visual console")
|
|
180
|
+
try:
|
|
181
|
+
httpd, url = server.serve(out_dir=out, host=host, port=port, model=model)
|
|
182
|
+
except OSError as exc:
|
|
183
|
+
err_console.print(f"[bold red]✗[/] Could not bind {host}:{port} — {exc}")
|
|
184
|
+
err_console.print(" Try another port: [bold]chuja serve --port 7788[/]")
|
|
185
|
+
raise typer.Exit(code=1)
|
|
186
|
+
|
|
187
|
+
panel = Text()
|
|
188
|
+
panel.append(" console live at ", style="#767c85")
|
|
189
|
+
panel.append(url, style="bold #ff6b35 underline")
|
|
190
|
+
panel.append("\n output folder ", style="#767c85")
|
|
191
|
+
panel.append(str(Path(out)), style="dim")
|
|
192
|
+
panel.append("\n\n drop a file or paste a URL in the browser.", style="#d7dbe0")
|
|
193
|
+
panel.append("\n press ", style="#767c85")
|
|
194
|
+
panel.append("Ctrl+C", style="bold #29d3c2")
|
|
195
|
+
panel.append(" here to stop.", style="#767c85")
|
|
196
|
+
console.print(Panel(panel, border_style="#34383e", title="[bold]▶ chuja serve[/]", expand=False))
|
|
197
|
+
|
|
198
|
+
if not no_open:
|
|
199
|
+
try:
|
|
200
|
+
webbrowser.open(url)
|
|
201
|
+
except Exception:
|
|
202
|
+
pass
|
|
203
|
+
try:
|
|
204
|
+
httpd.serve_forever()
|
|
205
|
+
except KeyboardInterrupt:
|
|
206
|
+
console.print("\n[dim]console stopped.[/]")
|
|
207
|
+
finally:
|
|
208
|
+
httpd.server_close()
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
# ----------------------------------------------------------------------------
|
|
212
|
+
# models
|
|
213
|
+
# ----------------------------------------------------------------------------
|
|
214
|
+
@app.command()
|
|
215
|
+
def models():
|
|
216
|
+
"""List available separation models."""
|
|
217
|
+
table = Table(title="Demucs models", border_style="#34383e", title_style="bold #ff6b35")
|
|
218
|
+
table.add_column("Model", style="#29d3c2", no_wrap=True)
|
|
219
|
+
table.add_column("Description", style="#d7dbe0")
|
|
220
|
+
for name, desc in MODELS.items():
|
|
221
|
+
label = f"{name} (default)" if name == DEFAULT_MODEL else name
|
|
222
|
+
table.add_row(label, desc)
|
|
223
|
+
console.print(table)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
# ----------------------------------------------------------------------------
|
|
227
|
+
# interactive (bare `chuja`)
|
|
228
|
+
# ----------------------------------------------------------------------------
|
|
229
|
+
def interactive():
|
|
230
|
+
"""A small guided flow when the user runs `chuja` with no arguments."""
|
|
231
|
+
from rich.prompt import Prompt
|
|
232
|
+
|
|
233
|
+
if not sys.stdin.isatty():
|
|
234
|
+
# Non-interactive context (piped/CI): show help instead of hanging.
|
|
235
|
+
command = typer.main.get_command(app)
|
|
236
|
+
with click.Context(command) as ctx:
|
|
237
|
+
console.print(ctx.get_help())
|
|
238
|
+
return
|
|
239
|
+
|
|
240
|
+
banner.show(console)
|
|
241
|
+
console.print(" [bold]What would you like to do?[/]\n")
|
|
242
|
+
console.print(" [#ff6b35]1[/] Separate a local file")
|
|
243
|
+
console.print(" [#ff6b35]2[/] Separate from a URL")
|
|
244
|
+
console.print(" [#ff6b35]3[/] Launch the visual console [dim](web UI)[/]")
|
|
245
|
+
console.print(" [#ff6b35]q[/] Quit\n")
|
|
246
|
+
choice = Prompt.ask(" [bold]›[/]", choices=["1", "2", "3", "q"], default="3", show_choices=False)
|
|
247
|
+
|
|
248
|
+
if choice == "q":
|
|
249
|
+
return
|
|
250
|
+
if choice == "3":
|
|
251
|
+
return _serve()
|
|
252
|
+
|
|
253
|
+
source = Prompt.ask(" [#29d3c2]file path[/]" if choice == "1" else " [#29d3c2]url[/]")
|
|
254
|
+
model = Prompt.ask(" model", choices=list(MODELS), default=DEFAULT_MODEL, show_choices=False)
|
|
255
|
+
fmt = Prompt.ask(" format", choices=["wav", "mp3", "flac"], default="mp3")
|
|
256
|
+
split = Prompt.ask(" split", choices=["full", "karaoke"], default="full")
|
|
257
|
+
two = "vocals" if split == "karaoke" else None
|
|
258
|
+
bundle = Prompt.ask(" zip the stems?", choices=["y", "n"], default="y") == "y"
|
|
259
|
+
|
|
260
|
+
def on_event(message: str):
|
|
261
|
+
console.print(f" [dim]›[/] {message}")
|
|
262
|
+
|
|
263
|
+
try:
|
|
264
|
+
result = run_separate(source, out_dir="stems", model=model, two_stems=two,
|
|
265
|
+
fmt=fmt, zip_output=bundle, on_event=on_event)
|
|
266
|
+
except ChujaError as exc:
|
|
267
|
+
err_console.print(f"\n[bold red]✗ Error:[/] {exc}")
|
|
268
|
+
raise typer.Exit(code=1)
|
|
269
|
+
_print_result(result, fmt)
|
|
270
|
+
if Prompt.ask(" reveal the folder?", choices=["y", "n"], default="y") == "y":
|
|
271
|
+
_reveal(result.out_dir)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
@app.callback(invoke_without_command=True)
|
|
275
|
+
def main(
|
|
276
|
+
ctx: typer.Context,
|
|
277
|
+
version: bool = typer.Option(False, "--version", callback=_version_callback, is_eager=True, help="Show version and exit."),
|
|
278
|
+
):
|
|
279
|
+
"""chuja — stem separation for the command line."""
|
|
280
|
+
if ctx.invoked_subcommand is None:
|
|
281
|
+
interactive()
|
|
282
|
+
raise typer.Exit()
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
if __name__ == "__main__": # pragma: no cover
|
|
286
|
+
app()
|
chuja/errors.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Exception types raised across chuja. Kept dependency-free so callers can
|
|
2
|
+
catch them without importing the heavy ML stack."""
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class ChujaError(Exception):
|
|
6
|
+
"""Base class for all expected, user-facing failures."""
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class SourceError(ChujaError):
|
|
10
|
+
"""The input could not be resolved to a usable audio file."""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class FetchError(SourceError):
|
|
14
|
+
"""A URL could not be downloaded (network, unsupported site, blocked, etc.)."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class MissingDependencyError(ChujaError):
|
|
18
|
+
"""An optional dependency required for this operation is not installed."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class SeparationError(ChujaError):
|
|
22
|
+
"""The separation engine (Demucs) failed to produce stems."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ExportError(ChujaError):
|
|
26
|
+
"""A separated stem could not be written to disk in the requested format."""
|
chuja/export.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Write separated stem tensors to disk and optionally bundle them.
|
|
2
|
+
|
|
3
|
+
Export is intentionally backend-independent: WAV/FLAC go through soundfile
|
|
4
|
+
(libsndfile) and MP3 through ffmpeg. We deliberately do NOT use torchaudio's
|
|
5
|
+
save path, which depends on whichever audio backend (sox/soundfile/torchcodec)
|
|
6
|
+
happens to be installed and fails confusingly across environments.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import subprocess
|
|
12
|
+
import tempfile
|
|
13
|
+
import zipfile
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Dict
|
|
16
|
+
|
|
17
|
+
from .errors import ExportError
|
|
18
|
+
from .util import require_ffmpeg
|
|
19
|
+
|
|
20
|
+
FORMATS = {"wav", "mp3", "flac"}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def write_stems(
|
|
24
|
+
stems: Dict,
|
|
25
|
+
samplerate: int,
|
|
26
|
+
out_dir: Path,
|
|
27
|
+
*,
|
|
28
|
+
fmt: str = "wav",
|
|
29
|
+
mp3_bitrate: int = 320,
|
|
30
|
+
) -> Dict[str, Path]:
|
|
31
|
+
"""Write each stem tensor to ``out_dir/<name>.<fmt>``.
|
|
32
|
+
|
|
33
|
+
Returns a mapping of stem name -> written path."""
|
|
34
|
+
if fmt not in FORMATS:
|
|
35
|
+
raise ExportError(f"Unsupported format '{fmt}'. Choose from: {', '.join(sorted(FORMATS))}.")
|
|
36
|
+
|
|
37
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
38
|
+
written: Dict[str, Path] = {}
|
|
39
|
+
for name, tensor in stems.items():
|
|
40
|
+
dest = out_dir / f"{name}.{fmt}"
|
|
41
|
+
try:
|
|
42
|
+
_write_one(tensor, samplerate, dest, fmt=fmt, mp3_bitrate=mp3_bitrate)
|
|
43
|
+
except ExportError:
|
|
44
|
+
raise
|
|
45
|
+
except Exception as exc:
|
|
46
|
+
raise ExportError(f"Failed to write stem '{name}' to {dest}: {exc}") from exc
|
|
47
|
+
written[name] = dest
|
|
48
|
+
return written
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _to_frames(tensor):
|
|
52
|
+
"""Convert a Demucs stem tensor [channels, time] to a soundfile-shaped
|
|
53
|
+
float32 array [time, channels], rescaling if it would clip."""
|
|
54
|
+
import numpy as np
|
|
55
|
+
|
|
56
|
+
data = tensor.detach().to("cpu").numpy() # [channels, time]
|
|
57
|
+
if data.ndim == 1:
|
|
58
|
+
data = data[None, :]
|
|
59
|
+
peak = float(np.abs(data).max()) if data.size else 0.0
|
|
60
|
+
if peak > 1.0: # match Demucs' default 'rescale' clip behavior
|
|
61
|
+
data = data / peak
|
|
62
|
+
return np.ascontiguousarray(data.T, dtype="float32") # [time, channels]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _write_one(tensor, samplerate: int, dest: Path, *, fmt: str, mp3_bitrate: int) -> None:
|
|
66
|
+
try:
|
|
67
|
+
import soundfile as sf
|
|
68
|
+
except ImportError as exc: # pragma: no cover - import guard
|
|
69
|
+
raise ExportError(
|
|
70
|
+
"soundfile is required for audio export. Install chuja's deps: pip install chuja"
|
|
71
|
+
) from exc
|
|
72
|
+
|
|
73
|
+
frames = _to_frames(tensor)
|
|
74
|
+
|
|
75
|
+
if fmt == "wav":
|
|
76
|
+
sf.write(str(dest), frames, samplerate, subtype="PCM_16")
|
|
77
|
+
elif fmt == "flac":
|
|
78
|
+
sf.write(str(dest), frames, samplerate, format="FLAC")
|
|
79
|
+
elif fmt == "mp3":
|
|
80
|
+
_write_mp3(frames, samplerate, dest, mp3_bitrate, sf)
|
|
81
|
+
else: # pragma: no cover - guarded by caller
|
|
82
|
+
raise ExportError(f"Unsupported format '{fmt}'.")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _write_mp3(frames, samplerate: int, dest: Path, bitrate: int, sf) -> None:
|
|
86
|
+
"""Encode to MP3 via ffmpeg (libsndfile can't write MP3)."""
|
|
87
|
+
ffmpeg = require_ffmpeg()
|
|
88
|
+
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp:
|
|
89
|
+
tmp_path = Path(tmp.name)
|
|
90
|
+
try:
|
|
91
|
+
sf.write(str(tmp_path), frames, samplerate, subtype="PCM_16")
|
|
92
|
+
proc = subprocess.run(
|
|
93
|
+
[ffmpeg, "-y", "-loglevel", "error", "-i", str(tmp_path),
|
|
94
|
+
"-b:a", f"{bitrate}k", str(dest)],
|
|
95
|
+
capture_output=True, text=True,
|
|
96
|
+
)
|
|
97
|
+
if proc.returncode != 0:
|
|
98
|
+
raise ExportError(f"ffmpeg failed to encode MP3: {proc.stderr.strip()}")
|
|
99
|
+
finally:
|
|
100
|
+
tmp_path.unlink(missing_ok=True)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def bundle(stem_paths: Dict[str, Path], archive_path: Path) -> Path:
|
|
104
|
+
"""Zip the written stems into a single portable archive."""
|
|
105
|
+
archive_path.parent.mkdir(parents=True, exist_ok=True)
|
|
106
|
+
try:
|
|
107
|
+
with zipfile.ZipFile(archive_path, "w", zipfile.ZIP_DEFLATED) as zf:
|
|
108
|
+
for path in stem_paths.values():
|
|
109
|
+
zf.write(path, arcname=path.name)
|
|
110
|
+
except OSError as exc:
|
|
111
|
+
raise ExportError(f"Failed to build archive {archive_path}: {exc}") from exc
|
|
112
|
+
return archive_path
|
chuja/pipeline.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""End-to-end orchestration: source -> stems -> files. The public entrypoint."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import tempfile
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Callable, Dict, Optional
|
|
9
|
+
|
|
10
|
+
from . import export, separator, sources
|
|
11
|
+
from .separator import DEFAULT_MODEL
|
|
12
|
+
from .util import safe_name, unique_dir
|
|
13
|
+
|
|
14
|
+
EventHook = Optional[Callable[[str], None]]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class Result:
|
|
19
|
+
track: str # cleaned track name
|
|
20
|
+
out_dir: Path # folder containing the stems
|
|
21
|
+
stems: Dict[str, Path] = field(default_factory=dict)
|
|
22
|
+
archive: Optional[Path] = None # zip path, if bundling was requested
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def stem_names(self):
|
|
26
|
+
return list(self.stems)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def separate(
|
|
30
|
+
source: str,
|
|
31
|
+
*,
|
|
32
|
+
out_dir: str | Path = "stems",
|
|
33
|
+
model: str = DEFAULT_MODEL,
|
|
34
|
+
two_stems: Optional[str] = None,
|
|
35
|
+
fmt: str = "wav",
|
|
36
|
+
mp3_bitrate: int = 320,
|
|
37
|
+
zip_output: bool = False,
|
|
38
|
+
device: Optional[str] = None,
|
|
39
|
+
on_event: EventHook = None,
|
|
40
|
+
on_progress: Optional[Callable[[float], None]] = None,
|
|
41
|
+
) -> Result:
|
|
42
|
+
"""Separate ``source`` (a local audio file or, with the ``url`` extra, a URL)
|
|
43
|
+
into stems written under ``out_dir/<track name>/``.
|
|
44
|
+
|
|
45
|
+
This is the single function the CLI and library users call.
|
|
46
|
+
"""
|
|
47
|
+
out_root = Path(out_dir).expanduser()
|
|
48
|
+
|
|
49
|
+
# One temp dir for any URL download; cleaned up on exit no matter what.
|
|
50
|
+
with tempfile.TemporaryDirectory(prefix="chuja-") as tmp:
|
|
51
|
+
resolved = sources.resolve(source, workdir=Path(tmp), on_event=on_event)
|
|
52
|
+
|
|
53
|
+
samplerate, stems = separator.separate(
|
|
54
|
+
resolved.path,
|
|
55
|
+
model=model,
|
|
56
|
+
device=device,
|
|
57
|
+
two_stems=two_stems,
|
|
58
|
+
on_event=on_event,
|
|
59
|
+
on_progress=on_progress,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
track_dir = unique_dir(out_root, safe_name(resolved.title))
|
|
63
|
+
if on_event:
|
|
64
|
+
on_event(f"Writing {len(stems)} stems to {track_dir}")
|
|
65
|
+
|
|
66
|
+
written = export.write_stems(
|
|
67
|
+
stems, samplerate, track_dir, fmt=fmt, mp3_bitrate=mp3_bitrate
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
archive = None
|
|
71
|
+
if zip_output:
|
|
72
|
+
# NB: not track_dir.with_suffix(".zip") — a track title like
|
|
73
|
+
# "feat. K.Flay" would have ".Flay" mistaken for an extension.
|
|
74
|
+
archive = export.bundle(written, track_dir.parent / f"{track_dir.name}.zip")
|
|
75
|
+
if on_event:
|
|
76
|
+
on_event(f"Bundled archive at {archive}")
|
|
77
|
+
|
|
78
|
+
return Result(track=resolved.title, out_dir=track_dir, stems=written, archive=archive)
|