Servidor de síntese de voz (TTS) próprio para substituir a ElevenLabs em dois usos: o agente de voz que atende ligações (LiveKit/SIP) e as mensagens de áudio do WhatsApp. Roda o Qwen3-TTS, um modelo aberto com licença comercial, e entrega áudio em tempo real por WebSocket ou em lote por URL assinada.
Toda a stack fala com uma interface única de motor (TTSEngine). Trocar de modelo é escrever uma implementação nova, sem mexer em API, roteamento ou normalização.
O serviço está implementado e testado. A troca da ElevenLabs depende de medições em GPU que ainda não foram feitas. Os números que decidem se o projeto segue (qualidade no teste cego e latência sob carga) são o próximo passo, com roteiro pronto em docs/gpu-nuvem.md.
| Fase | Entregue | Pendente |
|---|---|---|
| 0 — Validação | Benchmarks de síntese, degradação para telefone, página de teste cego ABX/MOS, bench de latência, bench de pronúncia por transcrição automática | Rodar o teste cego e registrar o resultado (PR 5) |
| 1 — Motor e streaming | Qwen3-TTS com streaming e cancelamento, normalização pt-BR, banco, WebSocket com várias falas por conexão, cache de áudio, imagem CUDA | Gate: TTFA p95 ≤ 350 ms e cancelamento < 50 ms medidos em GPU |
| 2 — LiveKit | Plugin TTS com streaming e fallback, roteamento por cliente, carga SIP simulada | Rampa de 1% a 10% do tráfego real |
| 3 — Lote, MCP e compat | Lote em OGG/Opus, servidor MCP, API compatível com a ElevenLabs, dicionário de pronúncia, vozes por cliente | Migrar as integrações de WhatsApp |
| 4 — Vozes próprias | Clonagem com consentimento e referência cifrada | Ajuste fino por voz, vozes dos locutores, novo teste cego |
| 5 — Substituição | — | Painéis de SLO, segunda região, rampa a 100% |
Detalhe por PR em docs/roadmap.md.
| Recurso | Como funciona |
|---|---|
Streaming (WS /v1/tts/stream) |
O texto chega em pedaços, como sai de um LLM. Cada frase completa vai para o motor na hora; em GPU, o faster-qwen3-tts envia o primeiro pedaço de áudio antes de terminar a frase. PCM 16-bit 24 kHz, cancelamento imediato (barge-in), inclusive depois do end, e flushed alinhado ao fim do áudio de cada trecho. Depois do done, a mesma conexão aceita a próxima fala, sem nova autenticação. Até 10.000 caracteres por fala; texto sem fim de frase vai ao motor em pedaços de até 600. |
Lote (POST /v1/tts) |
OGG/Opus, MP3 ou WAV, devolvido no corpo ou gravado em S3 com URL assinada e expiração. Até 10.000 caracteres; textos longos são divididos por frase e unidos com 60 ms de pausa (o compat e o MCP fazem igual). |
| Normalização pt-BR | Determinística e testada por tabela: moeda, datas, horas, intervalos, CPF/CNPJ, CEP, telefone, URL e e-mail, unidades, ordinais, porcentagem, decimais, número no feminino ("duas parcelas", "vinte e uma horas"), siglas soletradas ou lidas como palavra (com as que também são palavras, como "IR" e "TI", trocadas só em maiúscula), anglicismos e marcas. O dicionário por cliente troca só palavras inteiras. No streaming, só normaliza até um limite seguro para não ler "R$ 1.2" antes de virar "R$ 1.234,50". |
| Dicionário de pronúncia | Global ou por cliente, literal ou regex, aplicado nos quatro caminhos. No streaming vale mesmo com o termo partido entre dois pedaços do LLM. |
| Vozes | Preset (speakers do Qwen3 CustomVoice) e clonadas a partir de referência (Qwen3 Base). Voz sem dono é do catálogo e vale para todos; voz com dono (sempre, na clonagem) só aparece e só sintetiza para aquele cliente. |
| Clonagem | O upload passa por ffmpeg (corte de silêncio, loudnorm, WAV 16 kHz mono) e exige 5 s de fala limpa. Cada referência registra consentimento em voice_consent (ADR-0007) e é gravada cifrada; o motor decifra para um temporário só durante a geração. |
| Roteamento e fallback | Por cliente: só k-voice, só ElevenLabs ou percentual. Com a fila do motor cheia, cai para a ElevenLabs em vez de atrasar a fala, usando a voz equivalente cadastrada (na voz ou no mapa da regra do cliente). Sem voz equivalente, responde capacity e o plugin do LiveKit faz o fallback. |
| Compatibilidade ElevenLabs | POST /v1/text-to-speech/{voice_id} com xi-api-key: integração existente troca só a URL base e a chave (ADR-0008). |
| MCP | synthesize, list_voices e get_voice em /mcp (Streamable HTTP), só lote (ADR-0006). |
| Plugin LiveKit | kvoice.livekit.TTS com streaming e cancelamento; create_tts monta o FallbackAdapter com a ElevenLabs. |
| Cache de áudio | Frase curta repetida ("Um momento, por favor.") sai da memória a partir da segunda vez, sem passar pela GPU. Por frase, com a voz inteira, o estilo e a velocidade na chave; limpo quando a voz muda. |
| Medição | Uma linha por fala em usage_event (caracteres, áudio, TTFA, motor, fallback) e métricas Prometheus em /metrics, incluindo acertos do cache. |
| Painel demo | /demo: criar e editar vozes (instrução, voz equivalente na ElevenLabs, cliente dono), clonar com referência gravada pelo microfone, testar a síntese e administrar clientes, tokens e dicionário. Funciona no celular. |
┌──────────── LiveKit ──────────────┐
Telefone ── SIP ──▶ │ Agent (STT ─▶ LLM ─▶ TTS plugin) │
└────────────────┬──────────────────┘
│ WebSocket
▼
HTTP (lote) ───────▶ ┌──────────── k-voice (FastAPI) ─────────────┐
Agentes via MCP ───▶ │ auth · roteador · normalização · medição │──▶ Postgres
Compat ElevenLabs ─▶ │ /v1/tts · /v1/text-to-speech · /mcp · /demo │──▶ S3 / MinIO
└─────────────────────┬──────────────────────┘
▼
FasterQwen3Engine (GPU, streaming na frase)
Qwen3TTSEngine (CPU, frase a frase)
ElevenLabsEngine (fallback)
O motor é escolhido por QWEN3_DEVICE: cuda usa o faster-qwen3-tts com CUDA graphs; cpu usa o módulo qwen_tts e sintetiza frase a frase. Contratos e formatos em docs/arquitetura.md.
Requer Python 3.12, uv, Docker e ffmpeg.
docker compose up -d # Postgres + MinIO
cp .env.example .env
uv sync
uv run alembic upgrade head
uv run python scripts/criar_admin.py # imprime o primeiro token de adminGere a chave do áudio de referência e coloque em REFERENCE_AUDIO_KEY no .env; sem ela o upload de clonagem é recusado:
uv run python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"Suba o servidor e abra http://localhost:8003/demo com o token de admin:
uv run uvicorn kvoice.api.app:app --port 8003Sem QWEN3_ENABLED=true o servidor usa o StubEngine, que gera um tom proporcional ao texto, útil para testar a API sem modelo. Com o modelo em CPU, a síntese é lenta: num Ryzen 7 7735HS, 36 s para 3,6 s de áudio (0.6B, float32, medição em docs/modelos.md).
Requer GPU NVIDIA e driver com CUDA 13 (o torch fixado traz as bibliotecas cu13).
docker compose -f docker-compose.dev.yml up --buildSobe Postgres, MinIO, a migração e o servidor na porta 8000 com a GPU reservada. Defina no .env QWEN3_ENABLED=true e QWEN3_DEVICE=cuda. Para medir latência e concorrência numa GPU alugada, siga docs/gpu-nuvem.md.
| Variável | Padrão | Uso |
|---|---|---|
DATABASE_URL |
Postgres local | Banco (asyncpg) |
S3_* |
MinIO local | Áudio de lote |
REFERENCE_AUDIO_KEY |
vazio | Chave Fernet do áudio de referência; só por ambiente |
ELEVENLABS_API_KEY |
vazio | Habilita o fallback |
QWEN3_ENABLED |
false |
Carrega o Qwen3-TTS; senão usa o stub. Se o carregamento falhar, o servidor não sobe |
QWEN3_DEVICE |
cpu |
cuda ativa o streaming em GPU |
QWEN3_DTYPE |
auto |
float32 em CPU, bfloat16 em CUDA |
QWEN3_CHUNK_SIZE |
8 |
Pedaço do streaming em GPU (~chunk_size/12 s de áudio) |
QWEN3_MODEL_ID / QWEN3_BASE_MODEL_ID |
0.6B CustomVoice / Base | Modelos preset e de clonagem |
QWEN3_SPEAKER |
vivian |
Speaker padrão das vozes preset |
MCP_ALLOWED_HOSTS |
[] |
Hosts aceitos em /mcp, em JSON; vazio desliga a checagem de Host |
AUDIO_CACHE_MB / AUDIO_CACHE_MAX_CHARS / AUDIO_CACHE_TTL_S |
64 / 200 / 86400 |
Cache em memória do áudio de frases curtas repetidas, por processo; 0 MB desliga |
| Métrica | Meta | Medido |
|---|---|---|
| TTFA p50 / p95 em GPU | ≤ 200 / ≤ 350 ms | pendente (docs/gpu-nuvem.md) |
| Cancelamento | < 50 ms | pendente em GPU; em teste, não espera a geração em curso |
| RTF sob carga | < 0,5 | pendente |
| Pronúncia (WER por transcrição) | — | 1,7% nas 67 frases, Qwen3 0.6B em CPU (resultados) |
| Consultas ao banco antes do primeiro áudio | — | 29–30 ms p50, 36–45 ms p95, banco local (resultados) |
Afirmações de latência e qualidade só valem com medição no repositório (bench/), com hardware, versão e data. Números de fabricante ficam em docs/modelos.md, marcados como tal.
| Modelo | Licença dos pesos | pt-BR | Situação |
|---|---|---|---|
| Qwen3-TTS 0.6B / 1.7B | Apache-2.0 | sim | principal; instrução de estilo só no 1.7B |
| Chatterbox Multilingual + finetune pt-BR | MIT | sim | comparação |
| Kokoro-82M | Apache-2.0 | sim | piso de latência, sem clonagem |
| Voxtral, Fish S2, XTTS-v2, F5-TTS | não comercial | — | descartados |
Pesos com licença não comercial ficam fora. Versões, compatibilidade e medições em docs/modelos.md.
- 329 testes (pytest), integração com Postgres real via testcontainers, mypy strict e ruff no CI.
- Toda mudança na normalização ou no motor roda a tabela de regressão (
tests/normalize/casos.yamlebench/frases.yaml). - Decisões em ADRs; segredos só por ambiente; tokens de cliente guardados como hash; token do WebSocket ocultado nos logs.
Python 3.12 · FastAPI · PyTorch + CUDA · Qwen3-TTS · faster-qwen3-tts · PostgreSQL · SQLAlchemy 2 + Alembic · S3 / MinIO · LiveKit Agents · MCP · Docker · uv
| docs/escopo.md | Requisitos e critério de sucesso |
| docs/arquitetura.md | Componentes, contratos de API e formatos |
| docs/roadmap.md | Fases, PRs e gates |
| docs/modelos.md | Modelos, licenças, versões e medições |
| docs/operacao.md | SLOs, métricas, capacidade e plantão |
| docs/gpu-nuvem.md | Roteiro de validação em GPU alugada |
| docs/bench-mac.md | Bench de pronúncia 0.6B contra 1.7B num Mac com Apple Silicon |
| docs/teste-cego.md | Metodologia do gate de qualidade |
| docs/resultados/ | Medições registradas: pronúncia e consultas antes do primeiro áudio |
| docs/adr/ | Decisões de arquitetura |