Skip to content
Merged
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
16 changes: 13 additions & 3 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,12 @@ VOICEFORGE_DATA_DIR=data
# Where downloaded model checkpoints (Hugging Face cache, etc.) live.
VOICEFORGE_MODELS_DIR=models

# "cpu", "cuda", or "auto" (auto = use CUDA if available, else CPU).
# "cpu", "cuda", "mps", or "auto" (auto = CUDA, then Apple MPS, then CPU).
VOICEFORGE_DEVICE=auto

# Comma-separated engine ids enabled for clone/synth in this deployment.
# Empty = all registered engines. Studio still lists all nine; unconfigured
# ones show "not in this deployment". Modal: see MODAL_ENABLED_ENGINES in modal_app.py.
# Empty = established engines only; experimental `voxcpm2` remains disabled.
# Studio still lists every registered engine and marks deployment state.
# VOICEFORGE_ENABLED_ENGINES=openvoice-v2,f5-tts,xtts-v2,qwen3-tts,chatterbox

# Preload models at container startup (faster first synth; adds to cold-start time).
Expand Down Expand Up @@ -49,6 +49,16 @@ VOICEFORGE_PREPROCESS_SAMPLES=true
# VOICEFORGE_COSYVOICE_PYTHON=/opt/cosyvoice-venv/bin/python
# VOICEFORGE_INDEXTTS_PYTHON=/opt/indextts-venv/bin/python
# VOICEFORGE_CHATTERBOX_PYTHON=/opt/chatterbox-venv/bin/python
# VOICEFORGE_CHATTERBOX_MODEL_DIR=/app/models/chatterbox
# VOICEFORGE_QWEN3_TTS_MODEL_DIR=/absolute/path/to/models/qwen3-tts

# Experimental providers — explicit local worker + local snapshot only:
# VOICEFORGE_VOXCPM2_PYTHON=/path/to/voxcpm2-venv/bin/python
# VOICEFORGE_VOXCPM2_MODEL_DIR=/absolute/path/to/models/voxcpm2
# VOICEFORGE_REFERENCE_ASR_PROVIDER=qwen3-asr
# VOICEFORGE_QWEN3_ASR_PYTHON=/path/to/qwen3-asr-venv/bin/python
# VOICEFORGE_QWEN3_ASR_MODEL_DIR=/absolute/path/to/models/qwen3-asr-0.6b
# VOICEFORGE_QWEN3_ALIGNER_MODEL_DIR=/absolute/path/to/models/qwen3-forced-aligner-0.6b

# M7 polish — structured logs and optional synth watermark
# VOICEFORGE_LOG_FORMAT=text
Expand Down
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ start-gpu:
$(COMPOSE) --profile gpu up --build

stop:
$(COMPOSE) --profile cpu --profile gpu --profile fish down
$(COMPOSE) --profile cpu --profile gpu down

logs:
$(COMPOSE) --profile cpu logs -f --tail=200
Expand Down
21 changes: 14 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ Details: [RESPONSIBLE_USE.md](RESPONSIBLE_USE.md) ·
### Higher-quality / heavier zero-shot

- `f5-tts`, `xtts-v2` (CPML non-commercial), `chatterbox`, `qwen3-tts`
- `voxcpm2` (experimental, local-worker opt-in; Hindi + English)

### Advanced / externally managed

Expand All @@ -139,14 +140,15 @@ Full matrix (CPU/GPU, Docker defaults, licences, verification labels):
| Engine | Clone type | CPU | GPU | Default CPU image | Licence note | Verification |
|--------|------------|-----|-----|-------------------|--------------|--------------|
| `openvoice-v2` | Zero-shot | Yes | Yes | Yes | MIT VC + verify YourTTS base | Verified (CPU e2e) |
| `f5-tts` | Zero-shot | Slow | Recommended | Yes | Apache-2.0 / CC (upstream) | Verified (CPU e2e) |
| `f5-tts` | Zero-shot | Slow | Recommended | Yes | **CC-BY-NC pretrained weights** | Verified (CPU e2e) |
| `xtts-v2` | Zero-shot | Slow | Recommended | Yes | **CPML non-commercial** | Verified (CPU e2e) |
| `chatterbox` | Zero-shot | Worker | Optional | Yes (worker venv) | MIT | Smoke-documented |
| `qwen3-tts` | Zero-shot | Limited | Recommended | Yes | Apache-2.0 | Smoke-documented |
| `chatterbox` | Zero-shot | Worker/MPS | Optional | Worker only; local weights required | MIT | Multilingual V3 wiring; inference unverified |
| `qwen3-tts` | Zero-shot | Limited/MPS experimental | Recommended | Yes; local snapshot required | Apache-2.0 | Smoke-documented |
| `voxcpm2` | Zero-shot | Experimental MPS | Recommended | No | Apache-2.0 | Disabled-by-default scaffold |
| `rvc` | Trained | No | Required | No (GPU image) | MIT architecture | GPU verification needed |
| `fish-speech` | Sidecar | Depends | Recommended | External | Check upstream | External sidecar |
| `fish-speech` | Sidecar | Depends | Recommended | External | Fish Audio Research License | Digest-pinned sidecar required |
| `cosyvoice-3` | Zero-shot | Limited | Recommended | Extra install | Apache-2.0 | GPU verification needed |
| `indextts-2` | Zero-shot | Limited | Recommended | Extra install | Check upstream | GPU verification needed |
| `indextts-2` | Zero-shot | Limited | Recommended | Extra install | Custom Bilibili model license | GPU verification needed |

GPU Docker paths were written carefully but **not** run against real NVIDIA
hardware during development — please verify on your host.
Expand All @@ -172,7 +174,8 @@ After the first build, `make start-cpu` (or compose `up` without `--build`) is
enough. Helpers: `make stop`, `make logs`, `make smoke-openvoice`.

Compose caps the CPU service at **4 CPUs / 6 GB RAM** by default. Data → `./data`;
weights → `models-cache` Docker volume.
weights → `models-cache` Docker volume. The host port is published on
`127.0.0.1` only.

**First clone:** follow **[docs/FIRST_CLONE.md](docs/FIRST_CLONE.md)**
(sample length, quiet room, expected download time, deletion).
Expand Down Expand Up @@ -338,6 +341,8 @@ Issue/PR templates live under `.github/`.
| [FIRST_CLONE.md](docs/FIRST_CLONE.md) | First successful clone |
| [ENGINES.md](docs/ENGINES.md) | Support matrix |
| [ENGINE_LICENSING.md](docs/ENGINE_LICENSING.md) | Licence matrix |
| [EXPERIMENTAL_STUDIO.md](docs/EXPERIMENTAL_STUDIO.md) | Mac, Docker CPU, Colab/Kaggle profiles and roadmap |
| [BENCHMARKING.md](docs/BENCHMARKING.md) | English/Hindi/Hinglish benchmark harness |
| [RESPONSIBLE_USE.md](RESPONSIBLE_USE.md) / [CONSENT.md](docs/CONSENT.md) | Safety & consent |
| [WATERMARKING.md](docs/WATERMARKING.md) | Fingerprint limitations |
| [SELF_HOSTING.md](docs/SELF_HOSTING.md) | Deploy securely |
Expand All @@ -359,7 +364,9 @@ docker compose -f docker/docker-compose.yml config
```

Unit tests use a `FakeEngine` (no multi-GB downloads). Optional ML extras are
in `requirements-*.txt` / `pyproject.toml` optional deps.
in `requirements-*.txt` / `pyproject.toml` optional deps. `GET /v1/providers`
returns model revisions, license gates, runtime support, and integration state
without importing an ML SDK or contacting a model hub.

## Repo layout

Expand Down
20 changes: 20 additions & 0 deletions app/api/providers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
from __future__ import annotations

from fastapi import APIRouter, Depends

from app.providers.models import ProviderStatus
from app.providers.registry import provider_statuses
from app.security import auth_dependency

router = APIRouter(
prefix="/v1/providers",
tags=["providers"],
dependencies=[Depends(auth_dependency)],
)


@router.get("", response_model=list[ProviderStatus])
async def get_providers() -> list[ProviderStatus]:
"""List engine and audio-intelligence manifests without loading models."""

return provider_statuses()
1 change: 1 addition & 0 deletions app/api/synth.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ async def synthesize(
sample_rate=body.sample_rate,
speed=body.speed,
language=body.language or voice.language,
style=body.style,
)

async with get_job_limiter():
Expand Down
1 change: 1 addition & 0 deletions app/audio_intelligence/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Local audio-intelligence provider contracts and adapters."""
41 changes: 41 additions & 0 deletions app/audio_intelligence/base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
from __future__ import annotations

from pathlib import Path
from typing import Protocol, runtime_checkable

from pydantic import BaseModel, Field


class AlignmentSegment(BaseModel):
text: str
start_seconds: float
end_seconds: float


class TranscriptionResult(BaseModel):
text: str
language: str | None = None
timestamps: list[AlignmentSegment] = Field(default_factory=list)


@runtime_checkable
class AudioIntelligenceProvider(Protocol):
id: str

def is_ready(self) -> bool: ...

def transcribe(
self,
audio_path: Path,
*,
language: str | None = None,
timestamps: bool = False,
) -> TranscriptionResult: ...

def align(
self,
audio_path: Path,
*,
text: str,
language: str,
) -> list[AlignmentSegment]: ...
132 changes: 132 additions & 0 deletions app/audio_intelligence/qwen3_asr.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
"""Qwen3-ASR reference transcription through an isolated, local-only worker."""

from __future__ import annotations

import json
import subprocess
import tempfile
from pathlib import Path

from app.audio_intelligence.base import AlignmentSegment, TranscriptionResult
from app.config import get_settings
from app.engines.base import EngineError
from app.engines.subprocess_env import sanitized_subprocess_env, worker_exec_command
from app.runtime_device import resolve_torch_device

_WORKER_SCRIPT = Path(__file__).resolve().parents[2] / "scripts" / "qwen3_asr_worker.py"


class Qwen3AsrProvider:
id = "qwen3-asr"

def is_ready(self) -> bool:
settings = get_settings()
return (
settings.qwen3_asr_python is not None
and settings.qwen3_asr_python.is_file()
and settings.qwen3_asr_model_dir is not None
and settings.qwen3_asr_model_dir.is_dir()
and _WORKER_SCRIPT.is_file()
)

def _invoke(self, args: list[str]) -> dict:
settings = get_settings()
python = settings.qwen3_asr_python
if python is None or not python.is_file() or not self.is_ready():
raise EngineError(
"Qwen3-ASR is not configured; set VOICEFORGE_QWEN3_ASR_PYTHON "
"and VOICEFORGE_QWEN3_ASR_MODEL_DIR to an isolated worker and "
"an explicitly downloaded local snapshot"
)

settings.data_dir.mkdir(parents=True, exist_ok=True)
with tempfile.NamedTemporaryFile(
prefix="qwen3-asr-",
suffix=".json",
dir=settings.data_dir,
delete=False,
) as tmp:
output_path = Path(tmp.name)
try:
command = worker_exec_command(
python,
_WORKER_SCRIPT,
[*args, "--output-json", str(output_path)],
)
proc = subprocess.run( # noqa: S603 - argv contains validated local paths only
command,
check=False,
capture_output=True,
text=True,
timeout=600,
env=sanitized_subprocess_env(),
)
if proc.returncode != 0:
detail = (proc.stdout or proc.stderr or "worker failed").strip()
raise EngineError(f"Qwen3-ASR worker failed: {detail[-1000:]}")
return json.loads(output_path.read_text(encoding="utf-8"))
except subprocess.TimeoutExpired as exc:
raise EngineError("Qwen3-ASR worker timed out after 600 seconds") from exc
finally:
output_path.unlink(missing_ok=True)

def transcribe(
self,
audio_path: Path,
*,
language: str | None = None,
timestamps: bool = False,
) -> TranscriptionResult:
settings = get_settings()
if not audio_path.is_file():
raise EngineError(f"Reference audio not found: {audio_path}")
assert settings.qwen3_asr_model_dir is not None
args = [
"transcribe",
"--audio",
str(audio_path),
"--model-dir",
str(settings.qwen3_asr_model_dir),
"--device",
resolve_torch_device(settings.device),
]
if language:
args.extend(["--language", language])
if timestamps:
aligner_dir = settings.qwen3_aligner_model_dir
if aligner_dir is None or not aligner_dir.is_dir():
raise EngineError(
"Qwen3-ASR timestamps require VOICEFORGE_QWEN3_ALIGNER_MODEL_DIR"
)
args.extend(["--timestamps", "--aligner-dir", str(aligner_dir)])
return TranscriptionResult.model_validate(self._invoke(args))

def align(
self,
audio_path: Path,
*,
text: str,
language: str,
) -> list[AlignmentSegment]:
settings = get_settings()
aligner_dir = settings.qwen3_aligner_model_dir
if aligner_dir is None or not aligner_dir.is_dir():
raise EngineError(
"Qwen3 forced alignment requires VOICEFORGE_QWEN3_ALIGNER_MODEL_DIR"
)
payload = self._invoke(
[
"align",
"--audio",
str(audio_path),
"--text",
text,
"--language",
language,
"--aligner-dir",
str(aligner_dir),
"--device",
resolve_torch_device(settings.device),
]
)
return [AlignmentSegment.model_validate(item) for item in payload["timestamps"]]
Loading
Loading