Packages

Elixir wrapper for Chatterbox TTS - state-of-the-art text-to-speech models from Resemble AI with zero-shot voice cloning capabilities.

Current section

Files

Jump to
chatterbex priv python chatterbex_bridge.py
Raw

priv/python/chatterbex_bridge.py

#!/usr/bin/env python3
"""
Chatterbex Bridge - Python bridge for Elixir Chatterbex library.
This script communicates with the Elixir GenServer via stdin/stdout,
loading and running Chatterbox TTS models.
"""
import sys
import json
import base64
import io
import os
# Force eager attention to avoid SDPA compatibility issues with output_attentions
os.environ["ATTN_IMPLEMENTATION"] = "eager"
# Attempt to import torch and torchaudio early to catch missing deps
try:
import torch
import torchaudio as ta
except ImportError as e:
print(json.dumps({"status": "error", "error": f"Missing dependency: {e}"}))
sys.exit(1)
# Monkey patch torch.load to handle CUDA->CPU/MPS device mapping
# This is needed because Chatterbox models are saved with CUDA references
_original_torch_load = torch.load
def _patched_torch_load(f, map_location=None, **kwargs):
"""Patched torch.load that maps CUDA tensors to CPU for compatibility."""
if map_location is None:
map_location = "cpu"
return _original_torch_load(f, map_location=map_location, **kwargs)
torch.load = _patched_torch_load
def _detect_device(requested_device: str) -> str:
"""
Detect and validate the compute device.
Returns the best available device, with fallback handling for MPS.
"""
if requested_device == "cuda":
if torch.cuda.is_available():
return "cuda"
return "cpu"
elif requested_device == "mps":
if torch.backends.mps.is_available():
return "mps"
return "cpu"
else:
return "cpu"
class ChatterboxBridge:
"""Bridge between Elixir and Chatterbox TTS models."""
def __init__(self):
self.model = None
self.model_type = None
self.device = None
def init_model(self, model_type: str, device: str = "cuda") -> dict:
"""Initialize the specified Chatterbox model."""
try:
# Validate and detect actual device
actual_device = _detect_device(device)
self.device = actual_device
self.model_type = model_type
# For MPS, load to CPU first then move components
# This avoids "Placeholder storage has not been allocated on MPS device" errors
load_device = "cpu" if actual_device == "mps" else actual_device
if model_type == "turbo":
from chatterbox.tts_turbo import ChatterboxTurboTTS
self.model = ChatterboxTurboTTS.from_pretrained(device=load_device)
elif model_type == "english":
from chatterbox.tts import ChatterboxTTS
self.model = ChatterboxTTS.from_pretrained(device=load_device)
elif model_type == "multilingual":
from chatterbox.mtl_tts import ChatterboxMultilingualTTS
self.model = ChatterboxMultilingualTTS.from_pretrained(device=load_device)
# Fix SDPA attention compatibility issue with output_attentions
self._fix_attention_implementation()
else:
return {"status": "error", "error": f"Unknown model type: {model_type}"}
# Move model components to MPS if requested
if actual_device == "mps" and load_device == "cpu":
self._move_to_mps()
return {"status": "ok", "device": actual_device}
except Exception as e:
return {"status": "error", "error": str(e)}
def _move_to_mps(self) -> None:
"""
Move model components to MPS device.
Chatterbox models have multiple subcomponents that need to be moved individually.
If MPS fails for any component, fall back to CPU for stability.
"""
try:
# Common components across model types
for component_name in ["t3", "s3gen", "ve", "model"]:
if hasattr(self.model, component_name):
component = getattr(self.model, component_name)
if component is not None and hasattr(component, "to"):
setattr(self.model, component_name, component.to("mps"))
# Update model's device reference if it has one
if hasattr(self.model, "device"):
self.model.device = "mps"
except Exception:
# MPS failed, fall back to CPU
self.device = "cpu"
if hasattr(self.model, "device"):
self.model.device = "cpu"
def _fix_attention_implementation(self) -> None:
"""
Fix SDPA attention compatibility issues.
Some transformer components use SDPA which doesn't support output_attentions.
This patches them to use eager attention instead.
"""
try:
# Check common component names that might have transformer layers
for component_name in ["t3", "model", "encoder", "decoder"]:
if hasattr(self.model, component_name):
component = getattr(self.model, component_name)
if component is not None:
# Recursively find and fix attention implementation
self._set_eager_attention(component)
except Exception:
pass # Best effort - if it fails, generation will show the original error
def _set_eager_attention(self, module) -> None:
"""Recursively set attention implementation to eager on transformer modules."""
if hasattr(module, "config") and hasattr(module.config, "_attn_implementation"):
module.config._attn_implementation = "eager"
if hasattr(module, "_attn_implementation"):
module._attn_implementation = "eager"
# Recurse into submodules
if hasattr(module, "modules"):
for submodule in module.modules():
if submodule is not module:
if hasattr(submodule, "config") and hasattr(submodule.config, "_attn_implementation"):
submodule.config._attn_implementation = "eager"
if hasattr(submodule, "_attn_implementation"):
submodule._attn_implementation = "eager"
def generate(self, text: str, **kwargs) -> dict:
"""Generate speech from text."""
if self.model is None:
return {"status": "error", "error": "Model not initialized"}
try:
# Build generation arguments
gen_kwargs = {}
if "audio_prompt" in kwargs and kwargs["audio_prompt"]:
gen_kwargs["audio_prompt_path"] = kwargs["audio_prompt"]
if self.model_type == "multilingual" and "language" in kwargs:
gen_kwargs["language_id"] = kwargs["language"]
if self.model_type == "english":
if "exaggeration" in kwargs and kwargs["exaggeration"] is not None:
gen_kwargs["exaggeration"] = kwargs["exaggeration"]
if "cfg_weight" in kwargs and kwargs["cfg_weight"] is not None:
gen_kwargs["cfg_weight"] = kwargs["cfg_weight"]
# Generate audio
wav = self.model.generate(text, **gen_kwargs)
# Convert to WAV bytes
audio_bytes = self._wav_to_bytes(wav, self.model.sr)
# Encode as base64 for JSON transport
audio_base64 = base64.b64encode(audio_bytes).decode("utf-8")
return {"status": "ok", "audio": audio_base64}
except Exception as e:
return {"status": "error", "error": str(e)}
def _wav_to_bytes(self, wav: torch.Tensor, sample_rate: int) -> bytes:
"""Convert a PyTorch tensor to WAV bytes."""
buffer = io.BytesIO()
# Ensure correct shape (channels, samples)
if wav.dim() == 1:
wav = wav.unsqueeze(0)
ta.save(buffer, wav.cpu(), sample_rate, format="wav")
buffer.seek(0)
return buffer.read()
def main():
"""Main loop - read JSON commands from stdin, write responses to stdout."""
bridge = ChatterboxBridge()
for line in sys.stdin:
line = line.strip()
if not line:
continue
try:
request = json.loads(line)
except json.JSONDecodeError as e:
response = {"status": "error", "error": f"Invalid JSON: {e}"}
print(json.dumps(response), flush=True)
continue
request_type = request.get("type")
if request_type == "init":
model_type = request.get("model", "turbo")
device = request.get("device", "cuda")
response = bridge.init_model(model_type, device)
elif request_type == "generate":
text = request.get("text", "")
kwargs = {
k: v for k, v in request.items()
if k not in ("type", "text")
}
response = bridge.generate(text, **kwargs)
elif request_type == "ping":
response = {"status": "ok", "message": "pong"}
else:
response = {"status": "error", "error": f"Unknown request type: {request_type}"}
print(json.dumps(response), flush=True)
if __name__ == "__main__":
main()