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 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)