Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,8 @@ yarn.lock
.python-version
.venv/
venv/
workers/supervisor/target/
workers/chatterbox/.cache/
*.egg-info/
tools/stemlab/
tools/win-x64/
Expand Down
50 changes: 50 additions & 0 deletions workers/PROTOCOL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# Sidecar wire protocol (normative, v1)

Transport: JSON-lines on stdin/stdout, one request per line, one response per
line, strictly ordered (no interleaving). Every response carries
`protocolVersion`; any other value refuses to serve.

## Requests (C# host / supervisor → worker)

```jsonc
{ "id": "req-1", "op": "health" }
{ "id": "req-2", "op": "load", "plan": {
"model": "<repo id or local path>",
"providers": ["CUDA", "CPU"], // ordered, planner-approved
"requirePreferred": false, // honor hard-pin: no silent fallback
"voicePromptPath": "<reference voice wav, optional>" } }
{ "id": "req-3", "op": "infer", "inputs": {
"input_ids": { "dtype": "int64", "shape": [1, 3], "data": "<base64>" } } }
```

`id` may be null on responses to unparseable lines (nothing to echo).

## Responses (worker → host)

```jsonc
{ "id": "req-1", "status": "alive", "modelLoaded": false,
"activeProvider": "none", "protocolVersion": 1 }
{ "id": "req-2", "status": "loaded", "activeProvider": "cuda",
"model": "<resolved>", "protocolVersion": 1 }
{ "id": "req-3", "status": "ok",
"outputs": { "audio": { "dtype": "int16", "shape": [95616], "data": "<base64>" } },
"sampleRate": 24000, "protocolVersion": 1 }
{ "id": null, "status": "error", "reason": "invalid-json",
"detail": "<parse error>", "protocolVersion": 1 }
```

## Status and reason vocabulary (closed sets — do not invent more)

- `status`: `alive` | `loaded` | `ok` | `error`
- `reason`: `invalid-json` | `unknown-op` | `bad-plan` | `bad-inputs` |
`dependency-missing` | `model-not-found` | `load-failed` |
`no-model-loaded` | `infer-failed` | `load-not-implemented`

## Rules

1. A malformed line never kills the loop: answer `invalid-json`, keep serving.
2. Readiness keys only on real state: `loaded` requires a committed session /
model; `ok` requires it to have produced output. Placeholders answer errors.
3. The worker executes the load plan; it never selects models or providers.
4. `activeProvider` reports what actually settled — never "cuda" when the
answer was CPU.
49 changes: 49 additions & 0 deletions workers/chatterbox/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# Chatterbox worker

Real `.pt` TTS worker behind the sidecar protocol (`workers/PROTOCOL.md`).
Upstream weights: `ResembleAI/chatterbox` (MIT — license evidence in ADR-0006
for the Chatterbox family; the `.pt` artifact gets manifest-grade treatment
at `src/` promotion, not here).

## Layout

- `worker.py` — the worker. Protocol loop + envelopes always; torch and
`chatterbox-tts` import lazily inside the load path only.
- `tests/test_protocol.py` — conformance suite. Stdlib only, no weights, no
GPU. Drives the real `serve()` loop over piped stdio.
- `fixtures/` — shared request/response vectors (grows with parity work).

## Environment (pinned, per the supervision design)

```powershell
cd workers/chatterbox
uv python install 3.12
uv venv --python 3.12
uv sync --extra model # installs torch (CUDA) + chatterbox-tts, writes uv.lock — commit it
```

Rules: never the system Python (3.14 on this host cannot run torch), no
`pip install` outside the lockfile, version stamp checked at startup by the
supervisor. The worker refuses to serve on mismatch rather than degrading.

## Running

```powershell
# Protocol conformance (no model needed):
python -m pytest tests/ -q

# Manual smoke (needs the model extra):
echo '{"id":"h","op":"health"}' | uv run worker.py
```

## Status

- [x] Protocol loop: health / load / infer / errors, typed envelopes
- [x] Lazy model stack (dependency-missing, never crash on import)
- [x] 8 conformance tests green without torch
- [x] Model env: `uv.lock` committed (torch 2.11+cu128, chatterbox-tts 0.1.7,
Python 3.12); `uv sync --locked --extra model` reproduces it
- [x] First synthesis through the protocol (CUDA, 3.36 s, peak 29377, RMS 4275)
- [x] Voice-clone plumbing (`voicePromptPath` plan → `audio_prompt_path`;
unreadable prompt fails load loudly, never silent default; same text
default vs cloned differs in pacing and samples)
38 changes: 38 additions & 0 deletions workers/chatterbox/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
[project]
name = "trackdub-chatterbox-worker"
version = "0.1.0"
description = "Trackdub inference worker: Chatterbox TTS behind the sidecar JSON-lines protocol."
requires-python = ">=3.11,<3.14"
# torch + chatterbox-tts are intentionally lazy imports inside worker.py, so
# `pytest` (protocol conformance) runs with nothing but the stdlib. Install
# the model stack with: uv sync --extra model (writes uv.lock — commit it).
dependencies = []

[project.optional-dependencies]
model = [
"torch==2.11.*",
"torchaudio==2.11.*",
"chatterbox-tts==0.1.7",
]

# torch/torchaudio MUST come from the CUDA index (PyPI wheels are CPU-only,
# which silently works but never touches the GPU). The pin above plus this
# index is what makes `uv lock`/`uv sync` reproduce the exact CUDA stack.
[[tool.uv.index]]
name = "pytorch-cu128"
url = "https://download.pytorch.org/whl/cu128"
explicit = true

[tool.uv.sources]
torch = [{ index = "pytorch-cu128" }]
torchaudio = [{ index = "pytorch-cu128" }]

# chatterbox-tts 0.1.7 pins torch==2.6.0 exactly, but torch 2.6 has no CUDA
# build new enough for Blackwell (sm_120 needs CUDA 12.8+). Override to the
# 2.11 CUDA stack and prove compatibility by running the model, not by
# trusting the pin. Revisit if chatterbox-tts relaxes its torch requirement.
[tool.uv]
override-dependencies = ["torch==2.11.*", "torchaudio==2.11.*"]

[tool.pytest.ini_options]
testpaths = ["tests"]
119 changes: 119 additions & 0 deletions workers/chatterbox/tests/test_protocol.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
"""Protocol conformance tests for the Chatterbox worker.

These run with stdlib only — no torch, no weights, no GPU. Every test drives
the real `serve()` loop over piped stdio, so what passes here is the exact
byte behavior the Rust supervisor will speak to.
"""

import io
import json
import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parent.parent))

from worker import PROTOCOL_VERSION, serve


def run_worker(lines):
stdin = io.StringIO("".join(line + "\n" for line in lines))
stdout = io.StringIO()
old = sys.stdout
sys.stdout = stdout
try:
serve(stdin=stdin)
finally:
sys.stdout = old
return [json.loads(line) for line in stdout.getvalue().splitlines() if line.strip()]


def test_health_before_load_reports_unloaded():
(resp,) = run_worker([json.dumps({"id": "h1", "op": "health"})])
assert resp["status"] == "alive"
assert resp["modelLoaded"] is False
assert resp["protocolVersion"] == PROTOCOL_VERSION


def test_malformed_line_yields_invalid_json_and_loop_survives():
resps = run_worker([
"{not json",
json.dumps({"id": "h2", "op": "health"}),
])
assert resps[0]["status"] == "error"
assert resps[0]["reason"] == "invalid-json"
assert "id" not in resps[0]
assert resps[1]["status"] == "alive"


def test_non_object_line_yields_invalid_json():
(resp,) = run_worker(['[1,2,3]'])
assert resp["reason"] == "invalid-json"


def test_unknown_op_names_itself():
(resp,) = run_worker([json.dumps({"id": "u1", "op": "dance"})])
assert resp["status"] == "error"
assert resp["reason"] == "unknown-op"


def test_infer_before_load_is_no_model_loaded():
(resp,) = run_worker([json.dumps({"id": "i1", "op": "infer", "inputs": {"text": "hi"}})])
assert resp["status"] == "error"
assert resp["reason"] == "no-model-loaded"


def test_load_without_model_stack_is_dependency_missing_not_crash():
from unittest.mock import patch

with patch(
"worker._import_model_stack",
side_effect=ImportError("model stack missing: install with `uv sync --extra model`"),
):
(resp,) = run_worker([json.dumps({"id": "l1", "op": "load",
"plan": {"model": "somewhere", "providers": ["CPU"]}})])
assert resp["status"] == "error"
assert resp["reason"] == "dependency-missing"
assert "uv sync --extra model" in resp["detail"]
Comment thread
Copilot marked this conversation as resolved.


def test_load_without_plan_is_bad_plan():
(resp,) = run_worker([json.dumps({"id": "l2", "op": "load"})])
assert resp["status"] == "error"
assert resp["reason"] == "bad-plan"


def test_every_response_carries_protocol_version():
resps = run_worker([
"{bad",
json.dumps({"id": "x", "op": "nope"}),
json.dumps({"id": "y", "op": "health"}),
])
assert resps and all(r["protocolVersion"] == PROTOCOL_VERSION for r in resps)


def test_unreadable_voice_prompt_fails_load_loudly():
(resp,) = run_worker([json.dumps({"id": "v1", "op": "load",
"plan": {"model": "somewhere",
"voicePromptPath": "/no/such/voice.wav"}})])
assert resp["status"] == "error"
assert resp["reason"] == "bad-plan"
assert "voicePromptPath" in resp["detail"]


def test_readable_voice_prompt_passes_plan_validation():
import tempfile, os
fd, prompt = tempfile.mkstemp(suffix=".wav")
os.close(fd)
try:
(resp,) = run_worker([json.dumps({"id": "v2", "op": "load",
"plan": {"model": "somewhere",
"voicePromptPath": prompt}})])
finally:
os.unlink(prompt)
# Whatever the model stack does next (loaded / load-failed /
# dependency-missing), the prompt itself must not be the complaint.
assert resp["status"] in ("loaded", "error")
if resp["status"] == "error":
assert resp["reason"] != "bad-plan" or "voicePromptPath" not in resp.get("detail", "")
else:
assert resp["voicePrompt"] == prompt
Loading
Loading