10 KiB
Chatterbox Voice Studio Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Add LAN-open voice-file management and the approved two-column Chatterbox Studio without changing the public speech API shape.
Architecture: Extract safe local voice-file operations into voice_store.py, then expose a small JSON/multipart API from FastAPI. Serve a dependency-free HTML/CSS/JS studio from the same FastAPI service; the browser holds only selected relative names and sends them to the existing OpenAI-compatible speech endpoint.
Tech Stack: Python 3.12, FastAPI, Pydantic, python-multipart, standard-library wave, vanilla HTML/CSS/JavaScript, pytest.
Global Constraints
- The UI is open to everyone on the local network; add no authentication.
- Store and serve reference audio only from
/home/vince/ai/apps/chatterbox-tts/voices. - Upload WAV only, with a maximum exact size of
20 * 1024 * 1024bytes. - Never accept absolute paths, traversal, symlink escapes, or non-regular files.
- Sanitize filenames for storage and render filenames via browser
textContent, neverinnerHTML. - Require an explicit UI confirmation before DELETE requests.
- Keep
/v1/audio/speechOpenAI-compatible and WAV-only; a selected voice is its stored relative filename. - Keep the existing 2,000-character input cap, one-request GPU lock, and 300-second unload.
- Do not change Kokoro, Caddy, local DNS, service binding, or systemd configuration.
Task 1: Implement a safe, test-covered voice store and API
Files:
- Create:
voice_store.py - Modify:
app.py - Modify:
requirements.txt - Modify:
tests/test_app.py
Interfaces:
-
Produces:
VoiceStore.list() -> list[VoiceMetadata],VoiceStore.save(filename: str, payload: bytes) -> VoiceMetadata,VoiceStore.delete(name: str) -> None, andVoiceStore.resolve(name: str) -> Path. -
Produces:
GET /v1/voices,POST /v1/voices, andDELETE /v1/voices/{name:path}so encoded separators reach the validator and return 400. -
Step 1: Write failing voice-management tests
def test_list_voices_returns_safe_wav_metadata(client, voices_dir):
write_wav(voices_dir / "warm voice.wav")
payload = client.get("/v1/voices").json()
assert payload["data"][0]["name"] == "warm voice.wav"
assert payload["data"][0]["sample_rate"] == 24000
def test_upload_rejects_non_wav_and_oversized_files(client):
assert client.post("/v1/voices", files={"file": ("x.mp3", b"x")}).status_code == 400
assert client.post("/v1/voices", files={"file": ("x.wav", b"0" * (20 * 1024 * 1024 + 1))}).status_code == 413
def test_upload_and_delete_never_escape_voice_directory(client):
assert client.post("/v1/voices", files={"file": ("../../bad.wav", valid_wav_bytes())}).status_code == 400
assert client.post("/v1/voices", files={"file": ("..%2Fbad.wav", valid_wav_bytes())}).status_code == 400
assert client.delete("/v1/voices/../reference.wav").status_code == 400
assert client.delete("/v1/voices/%2E%2E%2Freference.wav").status_code == 400
- Step 2: Run the new tests and verify RED
Run: PYTHONPATH=. .venv/bin/pytest -q tests/test_app.py -k 'voice or upload or delete'
Expected: FAIL because the voice-store module and endpoints do not exist.
- Step 3: Implement the safe store and endpoints
Reject an empty upload name and any name containing /, \\, percent-encoded path separators/traversal, or a path component that differs from its basename; never strip a path-like name into a valid filename. For accepted plain filenames, normalize new storage names to safe ASCII a-z, 0-9, -, _, . characters and preserve only the .wav suffix. Listing returns the stored basename unchanged, escaping only at the browser rendering boundary. In the endpoint, read the upload then reject payloads over the exact byte cap before calling save(). Validate nonempty WAV headers/frames with wave.open(BytesIO(payload)). Write first to a unique file inside VOICES_DIR, then atomically rename it to the final path after rechecking containment and collision. list() must skip symlinks/non-WAV files and return sorted metadata. delete() must URL-decode once, then resolve containment and refuse encoded traversal, missing, and non-regular files.
@app.post("/v1/voices", status_code=201)
async def upload_voice(file: UploadFile) -> dict[str, VoiceMetadata]:
payload = await file.read()
return {"data": voice_store.save(file.filename or "", payload)}
@app.delete("/v1/voices/{name:path}", status_code=204)
def delete_voice(name: str) -> Response:
voice_store.delete(name)
return Response(status_code=204)
Add python-multipart to requirements.txt for multipart parsing.
- Step 4: Run focused and full API tests
Run: PYTHONPATH=. .venv/bin/pytest -q tests/test_app.py
Expected: PASS; no test loads CUDA or downloads a model.
- Step 5: Commit the voice API
git add voice_store.py app.py requirements.txt tests/test_app.py
git commit -m "feat: add safe voice management API"
Task 2: Use the managed voice store for speech selection
Files:
- Modify:
app.py - Modify:
tests/test_app.py
Interfaces:
-
Consumes:
VoiceStore.resolve(name) -> Pathfrom Task 1. -
Produces: the existing
POST /v1/audio/speechaccepting only a stored relative voice name. -
Step 1: Write failing speech-selection tests
def test_speech_uses_selected_stored_voice(client, voices_dir, monkeypatch):
write_wav(voices_dir / "selected.wav")
seen = {}
def generate(text, voice, speed):
seen["voice"] = voice
return b"wav"
monkeypatch.setattr(app.model_manager, "generate", generate)
response = client.post("/v1/audio/speech", json={"input": "Hello", "voice": "selected.wav"})
assert response.status_code == 200
assert seen["voice"] == voices_dir / "selected.wav"
def test_speech_rejects_deleted_or_escaped_voice(client):
assert client.post("/v1/audio/speech", json={"input": "Hello", "voice": "deleted.wav"}).status_code == 400
assert client.post("/v1/audio/speech", json={"input": "Hello", "voice": "../x.wav"}).status_code == 400
- Step 2: Run the selection tests and verify RED
Run: PYTHONPATH=. .venv/bin/pytest -q tests/test_app.py -k 'selected_stored or deleted_or_escaped'
Expected: FAIL until speech resolution delegates to VoiceStore.
- Step 3: Refactor speech resolution to the store
Replace the standalone resolve_voice() call with voice_store.resolve(request.voice). Preserve error status/detail behavior, response content type audio/wav, 2,000-character Pydantic cap, and ModelManager locking/unload behavior. Do not introduce model loading to voice-management endpoints.
- Step 4: Run all API tests
Run: PYTHONPATH=. .venv/bin/pytest -q tests/test_app.py
Expected: PASS with speech selection and all previous safety cases covered.
- Step 5: Commit the speech integration
git add app.py tests/test_app.py
git commit -m "refactor: resolve speech voices through voice store"
Task 3: Build the approved Studio UI and wire its flow
Files:
- Create:
static/index.html - Create:
static/studio.css - Create:
static/studio.js - Modify:
app.py - Modify:
tests/test_app.py
Interfaces:
-
Consumes: all Task 1 voice endpoints and
POST /v1/audio/speechfrom Task 2. -
Produces:
GET /serving the Studio and a responsive browser flow for upload, select, confirmed delete, generate, play, and download. -
Step 1: Write failing static-route tests
def test_root_serves_studio(client):
response = client.get("/")
assert response.status_code == 200
assert "Chatterbox Studio" in response.text
assert 'src="/static/studio.js"' in response.text
- Step 2: Run the route test and verify RED
Run: PYTHONPATH=. .venv/bin/pytest -q tests/test_app.py::test_root_serves_studio
Expected: FAIL with 404 before static assets and the root route exist.
- Step 3: Implement the studio
Mount /static with FastAPI StaticFiles and return static/index.html for /. Implement the accepted true-white, left-library/right-generator layout with a mobile stacked media query. In studio.js, use fetch for /v1/voices; use FormData for WAV upload; create all list text using element.textContent; keep selected state as a relative filename; show an explicit in-page modal/panel with Cancel and Delete before calling DELETE; submit JSON to /v1/audio/speech; render the returned Blob using URL.createObjectURL in an <audio controls> element and set a download anchor with download="chatterbox.wav". Render errors in an aria-live status region.
- Step 4: Run tests and perform browser QA
Run: PYTHONPATH=. .venv/bin/pytest -q tests/test_app.py
Expected: PASS.
Run the service locally and use the Browser plugin; if unavailable, use Playwright Chromium and record that fallback. Verify desktop and mobile: upload a valid WAV, select it, cancel then confirm deletion, generate a short result, play it, and download WAV. Capture a screenshot for comparison with docs/design/chatterbox-studio-concept.png.
- Step 5: Commit the studio
git add app.py static/index.html static/studio.css static/studio.js tests/test_app.py
git commit -m "feat: add Chatterbox voice studio"
Task 4: Deploy and verify the updated live service
Files:
- Modify: none expected
Interfaces:
-
Consumes: existing
chatterbox-tts.service, Caddy route, and local DNS record. -
Produces: the live studio and voice APIs on
https://chatterbox.dimensionlab.net. -
Step 1: Restart only the Chatterbox user service
Run: systemctl --user restart chatterbox-tts.service && systemctl --user is-active chatterbox-tts.service
Expected: active.
- Step 2: Verify live API and root page
Run:
curl -fsS https://chatterbox.dimensionlab.net/v1/voices
curl -fsS https://chatterbox.dimensionlab.net/ | rg 'Chatterbox Studio'
curl -fsS https://chatterbox.dimensionlab.net/health
Expected: voices JSON, studio HTML, and {"status":"ok"}.
- Step 3: Push the reviewed commits
Run: git push origin main
Expected: the clean, verified implementation is published to vince/chatterbox-tts.