uncompose-engine 0.1.0__tar.gz

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.
@@ -0,0 +1,4 @@
1
+ /target
2
+ engine/.venv/
3
+ __pycache__/
4
+ node_modules/
@@ -0,0 +1,7 @@
1
+ Metadata-Version: 2.4
2
+ Name: uncompose-engine
3
+ Version: 0.1.0
4
+ Summary: Separation engine shim: JSONL Engine Contract on one side, audio-separator on the other
5
+ License-Expression: MIT
6
+ Requires-Python: >=3.10
7
+ Requires-Dist: audio-separator[gpu]>=0.44.5
@@ -0,0 +1,21 @@
1
+ [project]
2
+ name = "uncompose-engine"
3
+ version = "0.1.0"
4
+ description = "Separation engine shim: JSONL Engine Contract on one side, audio-separator on the other"
5
+ # Floor matches audio-separator; the runtime Engine Environment is pinned to
6
+ # 3.10 specifically because diffq (via demucs) ships binary wheels only up to
7
+ # cp310 — any newer interpreter forces a C build, which a clean user machine
8
+ # cannot do (ADR-0004).
9
+ requires-python = ">=3.10"
10
+ license = "MIT"
11
+ dependencies = ["audio-separator[gpu]>=0.44.5"]
12
+
13
+ [dependency-groups]
14
+ dev = ["pytest>=8"]
15
+
16
+ [build-system]
17
+ requires = ["hatchling"]
18
+ build-backend = "hatchling.build"
19
+
20
+ [tool.hatch.build.targets.wheel]
21
+ packages = ["src/uncompose_engine"]
@@ -0,0 +1,5 @@
1
+ """uncompose-engine: the Python side of the Engine Contract (ADR-0001).
2
+
3
+ Spawned by the core, never imported by it. Request JSON on stdin, JSONL
4
+ events on stdout, all human logging on stderr.
5
+ """
@@ -0,0 +1,117 @@
1
+ """Engine Contract entry point: `python -m uncompose_engine`.
2
+
3
+ Reads one JSON request from stdin, runs the model via audio-separator, and
4
+ emits JSONL events on stdout. Everything else (library logging, tqdm bars,
5
+ stray prints) is forced onto stderr so the event stream stays parseable.
6
+ """
7
+
8
+ import importlib.metadata
9
+ import json
10
+ import logging
11
+ import os
12
+ import sys
13
+ import time
14
+
15
+ import torch
16
+ from audio_separator.separator import Separator
17
+
18
+ # Models known to this engine: audio-separator filename plus the mapping
19
+ # from audio-separator's stem names to Uncompose's preset-level stem names
20
+ # (keys.wav, not piano.wav — the model is piano-trained, the name is stable).
21
+ MODELS = {
22
+ "mel_band_roformer_kim": {
23
+ "filename": "vocals_mel_band_roformer.ckpt",
24
+ # audio-separator calls this model's non-vocal stem "Other".
25
+ "output_names": {
26
+ "Vocals": "vocals",
27
+ "Other": "instrumental",
28
+ },
29
+ },
30
+ "htdemucs_6s": {
31
+ "filename": "htdemucs_6s.yaml",
32
+ "output_names": {
33
+ "Vocals": "vocals",
34
+ "Drums": "drums",
35
+ "Bass": "bass",
36
+ "Guitar": "guitar",
37
+ "Piano": "keys",
38
+ "Other": "other",
39
+ },
40
+ },
41
+ }
42
+
43
+
44
+ def main() -> int:
45
+ # Keep a private handle to the real stdout for events, then point fd 1
46
+ # at stderr so nothing the ML stack prints can corrupt the JSONL stream.
47
+ events = os.fdopen(os.dup(1), "w", buffering=1)
48
+ os.dup2(2, 1)
49
+ sys.stdout = sys.stderr
50
+
51
+ def emit(event: dict) -> None:
52
+ events.write(json.dumps(event) + "\n")
53
+
54
+ try:
55
+ request = json.load(sys.stdin)
56
+ return run(request, emit)
57
+ except Exception as exc: # terminal: report on the contract, exit nonzero
58
+ logging.exception("engine failed")
59
+ emit({"event": "error", "message": str(exc)})
60
+ return 1
61
+
62
+
63
+ def run(request: dict, emit) -> int:
64
+ model = MODELS.get(request["model_id"])
65
+ if model is None:
66
+ raise ValueError(f"unknown model id: {request['model_id']}")
67
+
68
+ logging.basicConfig(stream=sys.stderr, level=logging.INFO)
69
+
70
+ emit({"event": "stage", "stage": "model_load", "message": request["model_id"]})
71
+ t0 = time.monotonic()
72
+ separator = Separator(
73
+ output_dir=request["output_dir"],
74
+ model_file_dir=request["model_dir"],
75
+ output_format="wav",
76
+ )
77
+ separator.load_model(model_filename=model["filename"])
78
+ model_load_secs = time.monotonic() - t0
79
+
80
+ emit({"event": "stage", "stage": "separate"})
81
+ t1 = time.monotonic()
82
+ separator.separate(request["audio_path"], custom_output_names=model["output_names"])
83
+ separate_secs = time.monotonic() - t1
84
+
85
+ # audio-separator can swallow decode failures and return with nothing
86
+ # written; missing stems are a failed job, not a quiet success. Each stem
87
+ # is staged as `<stem>.wav.partial`; the core promotes it to its final
88
+ # name when it sees the stem event, so a crash leaves partials behind.
89
+ missing = []
90
+ for stem_name in model["output_names"].values():
91
+ final = os.path.join(request["output_dir"], f"{stem_name}.wav")
92
+ if os.path.exists(final):
93
+ partial = final + ".partial"
94
+ os.replace(final, partial)
95
+ emit({"event": "stem", "name": stem_name, "path": partial})
96
+ else:
97
+ missing.append(stem_name)
98
+ if missing:
99
+ raise RuntimeError(f"separation produced no output for: {', '.join(missing)}")
100
+
101
+ emit(
102
+ {
103
+ "event": "done",
104
+ "model_id": request["model_id"],
105
+ "engine_version": importlib.metadata.version("uncompose-engine"),
106
+ "device": "cuda" if torch.cuda.is_available() else "cpu",
107
+ "timings": {
108
+ "model_load_secs": round(model_load_secs, 2),
109
+ "separate_secs": round(separate_secs, 2),
110
+ },
111
+ }
112
+ )
113
+ return 0
114
+
115
+
116
+ if __name__ == "__main__":
117
+ sys.exit(main())
@@ -0,0 +1,8 @@
1
+ """Shim contract tests run with audio-separator and torch faked at their
2
+ Python interface (Testing Decisions in #22): the fakes dir must shadow any
3
+ real install before the module under test is imported."""
4
+
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ sys.path.insert(0, str(Path(__file__).parent / "fakes"))
@@ -0,0 +1,50 @@
1
+ """Fake audio-separator, substituted at its Python interface.
2
+
3
+ The shim under test imports `Separator` from here (the fakes dir shadows any
4
+ real install on sys.path). Behavior is steered by env vars so it works the
5
+ same in-process and across the subprocess tests:
6
+
7
+ - FAKE_SEPARATOR_CALL_LOG: path to write a JSON log of calls for assertions
8
+ - FAKE_SEPARATOR_SKIP_STEMS: comma-separated output names to *not* write,
9
+ simulating audio-separator swallowing a decode failure
10
+ """
11
+
12
+ import json
13
+ import os
14
+
15
+
16
+ class Separator:
17
+ def __init__(self, **kwargs):
18
+ self.kwargs = kwargs
19
+ self.calls = []
20
+ self.loaded = None
21
+
22
+ def load_model(self, model_filename):
23
+ self.loaded = model_filename
24
+ self.calls.append({"method": "load_model", "model_filename": model_filename})
25
+
26
+ def separate(self, audio_path, custom_output_names=None):
27
+ # Anything the real library prints or bars through stdout must not
28
+ # corrupt the JSONL stream; the purity test relies on this print.
29
+ print("fake audio-separator chatter on stdout")
30
+ self.calls.append(
31
+ {
32
+ "method": "separate",
33
+ "audio_path": audio_path,
34
+ "custom_output_names": custom_output_names,
35
+ }
36
+ )
37
+ skip = set(filter(None, os.environ.get("FAKE_SEPARATOR_SKIP_STEMS", "").split(",")))
38
+ for name in (custom_output_names or {}).values():
39
+ if name in skip:
40
+ continue
41
+ path = os.path.join(self.kwargs["output_dir"], f"{name}.wav")
42
+ with open(path, "wb") as f:
43
+ f.write(b"RIFF")
44
+ self._write_call_log()
45
+
46
+ def _write_call_log(self):
47
+ log_path = os.environ.get("FAKE_SEPARATOR_CALL_LOG")
48
+ if log_path:
49
+ with open(log_path, "w") as f:
50
+ json.dump({"init": self.kwargs, "calls": self.calls}, f)
@@ -0,0 +1,5 @@
1
+ """Fake torch: just enough surface for the shim's device report."""
2
+
3
+ from types import SimpleNamespace
4
+
5
+ cuda = SimpleNamespace(is_available=lambda: False)
@@ -0,0 +1,164 @@
1
+ """The shim's side of the Engine Contract: given a request, it drives
2
+ audio-separator correctly and emits valid JSONL. No model inference here or
3
+ in CI; audio-separator is faked at its Python interface (see fakes/)."""
4
+
5
+ import json
6
+ import os
7
+ import subprocess
8
+ import sys
9
+ from pathlib import Path
10
+
11
+ import pytest
12
+
13
+ FAKES = Path(__file__).parent / "fakes"
14
+
15
+
16
+ def make_request(tmp_path, model_id="htdemucs_6s"):
17
+ audio = tmp_path / "song.wav"
18
+ audio.write_bytes(b"not really audio")
19
+ output_dir = tmp_path / "out"
20
+ output_dir.mkdir()
21
+ return {
22
+ "audio_path": str(audio),
23
+ "model_id": model_id,
24
+ "output_dir": str(output_dir),
25
+ "model_dir": str(tmp_path / "models"),
26
+ "device": "cpu",
27
+ }
28
+
29
+
30
+ # --- in-process: run() drives the library correctly ---
31
+
32
+
33
+ def run_shim(request, monkeypatch, tmp_path):
34
+ from uncompose_engine.__main__ import run
35
+
36
+ call_log = tmp_path / "calls.json"
37
+ monkeypatch.setenv("FAKE_SEPARATOR_CALL_LOG", str(call_log))
38
+ events = []
39
+ code = run(request, events.append)
40
+ calls = json.loads(call_log.read_text()) if call_log.exists() else None
41
+ return code, events, calls
42
+
43
+
44
+ def test_success_emits_stages_stems_and_done(monkeypatch, tmp_path):
45
+ request = make_request(tmp_path)
46
+ code, events, calls = run_shim(request, monkeypatch, tmp_path)
47
+
48
+ assert code == 0
49
+ kinds = [e["event"] for e in events]
50
+ assert kinds[:2] == ["stage", "stage"]
51
+ assert events[0]["stage"] == "model_load"
52
+ assert events[1]["stage"] == "separate"
53
+
54
+ stems = [e for e in events if e["event"] == "stem"]
55
+ # Preset-level stem names: keys.wav, never piano.wav.
56
+ assert sorted(e["name"] for e in stems) == sorted(
57
+ ["vocals", "drums", "bass", "guitar", "keys", "other"]
58
+ )
59
+ for e in stems:
60
+ # Stems are staged as `<stem>.wav.partial`; the core promotes them.
61
+ assert e["path"].endswith(".wav.partial")
62
+ assert os.path.exists(e["path"])
63
+
64
+ done = events[-1]
65
+ assert done["event"] == "done"
66
+ assert done["model_id"] == "htdemucs_6s"
67
+ assert done["device"] == "cpu" # fake torch reports no CUDA
68
+ assert done["engine_version"]
69
+ assert set(done["timings"]) == {"model_load_secs", "separate_secs"}
70
+
71
+
72
+ def test_success_calls_audio_separator_correctly(monkeypatch, tmp_path):
73
+ request = make_request(tmp_path)
74
+ _, _, calls = run_shim(request, monkeypatch, tmp_path)
75
+
76
+ assert calls["init"]["output_dir"] == request["output_dir"]
77
+ assert calls["init"]["model_file_dir"] == request["model_dir"]
78
+ assert calls["init"]["output_format"] == "wav"
79
+ load, separate = calls["calls"]
80
+ assert load == {"method": "load_model", "model_filename": "htdemucs_6s.yaml"}
81
+ assert separate["audio_path"] == request["audio_path"]
82
+ assert separate["custom_output_names"]["Piano"] == "keys"
83
+
84
+
85
+ def test_roformer_splits_vocals_and_instrumental(monkeypatch, tmp_path):
86
+ request = make_request(tmp_path, model_id="mel_band_roformer_kim")
87
+ code, events, calls = run_shim(request, monkeypatch, tmp_path)
88
+
89
+ assert code == 0
90
+ load, separate = calls["calls"]
91
+ assert load == {
92
+ "method": "load_model",
93
+ "model_filename": "vocals_mel_band_roformer.ckpt",
94
+ }
95
+ # audio-separator names this model's non-vocal stem "Other"; the contract
96
+ # name stays `instrumental`.
97
+ assert separate["custom_output_names"] == {
98
+ "Vocals": "vocals",
99
+ "Other": "instrumental",
100
+ }
101
+ stems = [e for e in events if e["event"] == "stem"]
102
+ assert sorted(e["name"] for e in stems) == ["instrumental", "vocals"]
103
+
104
+
105
+ def test_unknown_model_id_raises(monkeypatch, tmp_path):
106
+ request = make_request(tmp_path, model_id="not-a-model")
107
+ with pytest.raises(ValueError, match="unknown model id"):
108
+ run_shim(request, monkeypatch, tmp_path)
109
+
110
+
111
+ def test_missing_stem_output_is_a_failure_not_a_quiet_success(monkeypatch, tmp_path):
112
+ monkeypatch.setenv("FAKE_SEPARATOR_SKIP_STEMS", "keys")
113
+ request = make_request(tmp_path)
114
+ with pytest.raises(RuntimeError, match="keys"):
115
+ run_shim(request, monkeypatch, tmp_path)
116
+
117
+
118
+ # --- subprocess: the executable's stream stays parseable end to end ---
119
+
120
+
121
+ def run_engine_process(stdin_text):
122
+ env = dict(os.environ)
123
+ env["PYTHONPATH"] = os.pathsep.join(
124
+ [str(FAKES)] + [p for p in [env.get("PYTHONPATH")] if p]
125
+ )
126
+ return subprocess.run(
127
+ [sys.executable, "-m", "uncompose_engine"],
128
+ input=stdin_text,
129
+ capture_output=True,
130
+ text=True,
131
+ timeout=60,
132
+ env=env,
133
+ )
134
+
135
+
136
+ def parse_events(stdout):
137
+ return [json.loads(line) for line in stdout.splitlines() if line.strip()]
138
+
139
+
140
+ def test_stdout_is_pure_jsonl_despite_library_chatter(tmp_path):
141
+ result = run_engine_process(json.dumps(make_request(tmp_path)))
142
+ assert result.returncode == 0, result.stderr
143
+ # The fake Separator prints to stdout; the shim must have routed that to
144
+ # stderr, leaving stdout parseable line by line.
145
+ events = parse_events(result.stdout)
146
+ assert all("event" in e for e in events)
147
+ assert events[-1]["event"] == "done"
148
+ assert "chatter" not in result.stdout
149
+ assert "chatter" in result.stderr
150
+
151
+
152
+ def test_unknown_model_emits_error_event_and_exits_nonzero(tmp_path):
153
+ result = run_engine_process(json.dumps(make_request(tmp_path, model_id="nope")))
154
+ assert result.returncode == 1
155
+ events = parse_events(result.stdout)
156
+ assert events[-1]["event"] == "error"
157
+ assert "unknown model id" in events[-1]["message"]
158
+
159
+
160
+ def test_garbage_stdin_emits_error_event_and_exits_nonzero(tmp_path):
161
+ result = run_engine_process("this is not json")
162
+ assert result.returncode == 1
163
+ events = parse_events(result.stdout)
164
+ assert events[-1]["event"] == "error"