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.
- uncompose_engine-0.1.0/.gitignore +4 -0
- uncompose_engine-0.1.0/PKG-INFO +7 -0
- uncompose_engine-0.1.0/pyproject.toml +21 -0
- uncompose_engine-0.1.0/src/uncompose_engine/__init__.py +5 -0
- uncompose_engine-0.1.0/src/uncompose_engine/__main__.py +117 -0
- uncompose_engine-0.1.0/tests/conftest.py +8 -0
- uncompose_engine-0.1.0/tests/fakes/audio_separator/__init__.py +0 -0
- uncompose_engine-0.1.0/tests/fakes/audio_separator/separator.py +50 -0
- uncompose_engine-0.1.0/tests/fakes/torch.py +5 -0
- uncompose_engine-0.1.0/tests/test_contract.py +164 -0
- uncompose_engine-0.1.0/uv.lock +2337 -0
|
@@ -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,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"))
|
|
File without changes
|
|
@@ -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,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"
|