@pedrobef/vozz
v0.2.7
Published
TTS ultrarrealista em português do Brasil, 100% offline no navegador. Vozes neurais leves (~86 MB), sem API key, sem servidor, sem custo.
Downloads
1,786
Maintainers
Readme
Voz neural em português do Brasil, direto no navegador.
Sem API key. Sem servidor. Sem custo por caractere.
npm install @pedrobef/vozzimport { Piper } from "@pedrobef/vozz/piper";
const tts = await Piper.carregar();
const audio = await tts.falar("Olá! Tudo bem com você?");
audio.tocar();Três linhas. O texto nunca sai do dispositivo do usuário.
Índice
- Por que existe
- Os três motores
- Começando
- Receitas por framework
- API
- Problemas comuns ← comece por aqui se algo falhou
- Quanto pesa
Por que existe
Já havia bibliotecas de TTS neural em JavaScript, mas nenhuma falava
português. O motivo é técnico: modelos de voz não recebem letras, recebem
fonemas IPA. O fonemizador disponível em JS (phonemizer) só traz inglês
compilado:
phonemize("Olá", "pt-br")
// Erro: Invalid language identifier. Should be one of: en, en-us, en-gb...Sem fonemizador de português, passar texto brasileiro por um motor inglês produz sotaque grotesco:
"Eu sou a Dora"
fonemizador inglês → ˌiːjˈuː sˈuː ɐ dˈoːɹə ✗ "iiu su a dôra"
vozz → ˈeʊ sˈoʊ a dˈoɾæ ✓O núcleo do vozz é um conversor grafema→fonema (G2P) de português
brasileiro escrito em JavaScript puro — sem WASM, sem binário nativo, sem
rede. Ele implementa silabificação, acentuação tônica, nasalização,
palatalização de /d/ e /t/, vocalização do /l/ em coda, vibrante múltipla,
redução de vogais átonas e sândi entre palavras.
Fidelidade medida: 94,97% contra o espeak-ng pt-br (PER de 5,03% em
319 palavras e frases). O teste roda em npm test.
Os três motores
| | piper ⭐ | index (Kokoro) | sintetizador |
| --- | --- | --- | --- |
| Qualidade | voz natural pt-BR | natural, multilíngue | robótica |
| Download | 18,7 MB | ~86 MB | nada |
| Velocidade | ~2× tempo real | ~1× | ~70× |
| Navegador | ✅ | ✅ | ✅ |
| Workers / edge | importa apenas¹ | ❌ | ✅ executa |
| Node / SSR | ✅ | ✅ | ✅ |
¹ O Piper precisa de ~11 MB de WASM + 18,7 MB de modelo, o que estoura o limite de memória do edge. Por design, a síntese neural roda no dispositivo do usuário — onde é gratuita e escala sozinha.
Escolha rápida: quer voz boa em pt-BR? piper. Precisa rodar dentro de um
Worker ou não pode baixar nada? sintetizador. Só precisa dos fonemas
(lipsync, legendas, visemas)? g2p.
Começando
Voz neural (recomendado)
import { Piper } from "@pedrobef/vozz/piper";
const tts = await Piper.carregar({
aoProgredir: (p) => {
if (p.status === "baixando") {
console.log(`${Math.round(p.progresso * 100)}% — ${p.arquivo}`);
}
},
});
const audio = await tts.falar("Bom dia! Hoje são 14h30.", { velocidade: 1.1 });
audio.tocar();O modelo baixa uma vez e fica na Cache API do navegador. Visitas seguintes carregam instantaneamente.
Streaming — tocar sem esperar o texto inteiro
for await (const { texto, audio } of tts.falarEmFluxo(textoLongo)) {
console.log("tocando:", texto);
await audio.tocar();
}Ler a resposta de um LLM em tempo real
import { DivisorDeTexto } from "@pedrobef/vozz/piper";
const divisor = new DivisorDeTexto();
(async () => {
for await (const { audio } of tts.falarEmFluxo(divisor)) await audio.tocar();
})();
for await (const token of respostaDoLLM) divisor.empurrar(token);
divisor.fechar();O divisor acumula tokens e libera frases completas — evita prosódia picotada no meio da sentença.
Sem download nenhum (roda até no edge)
import { Sintetizador } from "@pedrobef/vozz/sintetizador";
const tts = new Sintetizador(); // sem await: não há I/O
const audio = tts.falar("Olá!", { voz: "clara" });
audio.tocar();Vozes: clara (feminina), bruno (masculina), grave (narração).
Só os fonemas
import { fonemizar } from "@pedrobef/vozz/g2p";
fonemizar("Olá, tudo bem?"); // "olˈa, tˈudʊ bˈeɪŋ?"
fonemizar("Custa R$ 25,50 às 9h"); // números e moeda já expandidosZero dependências, ~36 kB. Útil para lipsync, visemas e legendas fonéticas.
Salvar e exportar
const audio = await tts.falar("Olá!");
audio.duracao; // segundos
audio.paraWav(); // ArrayBuffer (WAV PCM 16-bit)
audio.paraBlob(); // Blob audio/wav
audio.paraURL(); // object URL para <audio src>
await audio.salvar("ola.wav"); // baixa no navegador, grava em disco no NodeNormalização automática
O texto é preparado antes de virar fala:
| Entrada | Falado como |
| --- | --- |
| R$ 1.234,56 | mil duzentos e trinta e quatro reais e cinquenta e seis centavos |
| 14h30 | quatorze horas e trinta minutos |
| 07/09/2025 | sete de setembro de dois mil e vinte e cinco |
| 23°C | vinte e três graus celsius |
| 15% | quinze por cento |
| 1º | primeiro |
| Dr. Silva | doutor Silva |
| IBGE | i-bê-gê-é |
Desligar: tts.falar(texto, { normalizar: false }).
Pronúncia customizada
await tts.falar("Rodamos em Kubernetes.", {
lexico: { kubernetes: "kubeɾnˈetʃis" },
});Para descobrir o IPA de qualquer texto: Piper.fonemizar("...").
Receitas por framework
Next.js (Vercel)
O Piper só existe no cliente:
"use client";
import { useEffect, useRef, useState } from "react";
export default function BotaoFalar() {
const tts = useRef<any>(null);
const [pronto, setPronto] = useState(false);
useEffect(() => {
(async () => {
const { Piper } = await import("@pedrobef/vozz/piper");
tts.current = await Piper.carregar();
setPronto(true);
})();
}, []);
return (
<button disabled={!pronto} onClick={async () => (await tts.current.falar("Olá!")).tocar()}>
{pronto ? "Falar" : "Carregando..."}
</button>
);
}Vite / React / Astro / SvelteKit
import { Piper } from "@pedrobef/vozz/piper";
const tts = await Piper.carregar();
(await tts.falar("Olá!")).tocar();Sem optimizeDeps, sem config, sem instalar onnxruntime-web.
Cloudflare Pages
Site estático comum — a síntese roda no navegador, então não há backend. Faça o deploy normalmente; nenhuma configuração especial é necessária.
Cloudflare Workers / Pages Functions
No edge funcionam o G2P e o sintetizador (JS puro, sem dependências):
import { Sintetizador } from "@pedrobef/vozz/sintetizador";
export default {
fetch(request) {
const texto = new URL(request.url).searchParams.get("t") ?? "Olá!";
const audio = new Sintetizador().falar(texto);
return new Response(audio.paraWav(), {
headers: { "content-type": "audio/wav" },
});
},
};Node (gerar arquivos em lote)
npm i @pedrobef/vozz onnxruntime-nodeimport { Piper } from "@pedrobef/vozz/piper";
const tts = await Piper.carregar();
await (await tts.falar("Olá!")).salvar("saida.wav");Deno / Bun
import { fonemizar } from "npm:@pedrobef/vozz/g2p";Mais exemplos em exemplos/README.md.
API
| Símbolo | Descrição |
| --- | --- |
| Piper.carregar(opcoes?) | Baixa o modelo e inicializa. Devolve Piper. |
| Piper.fonemizar(texto) | Texto → IPA, sem sintetizar. |
| Piper.usarRuntime(ort) | Injeta o runtime ONNX manualmente. |
| Piper.limparCache() | Remove o modelo do cache do navegador. |
| tts.falar(texto, opcoes?) | Sintetiza tudo. Devolve Audio. |
| tts.falarEmFluxo(entrada, o?) | Gera frase a frase (async generator). |
| new Sintetizador(opcoes?) | Motor por formantes, síncrono. |
| fonemizar(texto, opcoes?) | G2P puro (@pedrobef/vozz/g2p). |
| DivisorDeTexto | Divisor incremental para saída de LLM. |
| Audio | tocar, parar, salvar, paraWav/Blob/URL, duracao. |
Opções de carregar: dispositivo (auto/wasm/webgpu), cdn,
urlModelo, urlConfig, urlRuntime, cache, aoProgredir, threads.
Opções de falar: velocidade (0.5–2), ruido (expressividade),
ruidoW, lexico, normalizar, maxFonemas (tamanho do bloco; padrão 360).
Para textos longos prefira falarEmFluxo: ele devolve cada trecho assim que
fica pronto, em vez de esperar o texto inteiro.
Tipos TypeScript inclusos.
Problemas comuns
Corrigido na 0.2.2. Atualize:
npm i @pedrobef/vozz@latestO runtime agora é importado do CDN por URL absoluta, que o navegador resolve
sozinho — nenhum bundler precisa resolvê-lo. Não é preciso instalar
onnxruntime-web nem mexer em optimizeDeps.
Se você usa uma CSP restritiva, veja "CSP bloqueando o CDN" abaixo.
[vozz] Falha ao criar a sessão de inferência: Can't create a session.
ERROR_CODE: 9, ERROR_MESSAGE: Could not find an implementation for
ConvInteger(10) node with name '/enc_p/encoder/attn_layers.0/conv_q/Conv_quant'Corrigido na 0.2.5. Se você usa o modelo padrão, basta atualizar:
npm i @pedrobef/vozz@latestSe aponta para um modelo próprio (urlModelo / cdn), continue lendo.
Por que acontece. O ONNX Runtime implementa o operador ConvInteger
somente para pesos uint8 (inteiro sem sinal). Um modelo quantizado com
QuantType.QInt8 (com sinal) carrega o grafo normalmente, mas nenhum kernel
casa com os tipos — e a sessão falha em todos os backends, WASM
inclusive. Não é problema de WebGPU nem de navegador.
Como corrigir. Re-quantize a partir do modelo em fp32 usando QUInt8:
from onnxruntime.quantization import quantize_dynamic, QuantType
quantize_dynamic(
"pt_BR-faber-medium.onnx", # modelo fp32 original
"pt_BR-faber-medium-uint8.onnx", # saída
weight_type=QuantType.QUInt8, # ← a correção; NÃO use QInt8
)O arquivo de saída tem o mesmo tamanho (18,7 MB) e a mesma qualidade de
áudio. Os avisos Inference failed or unsupported type to quantize durante
a conversão são normais e podem ser ignorados.
O fp32 original das vozes Piper está em rhasspy/piper-voices.
Como saber se o seu modelo tem o problema:
import onnx
from onnx import TensorProto
m = onnx.load("seu-modelo.onnx")
tipos = {i.name: i.data_type for i in m.graph.initializer}
for n in m.graph.node:
if n.op_type == "ConvInteger":
for e in n.input:
if e in tipos:
print(e, "INT8 (quebra)" if tipos[e] == TensorProto.INT8 else "UINT8 (ok)")
breakDepois de re-quantizar, aponte o pacote para o novo arquivo:
await Piper.carregar({ urlModelo: "/modelo/pt_BR-faber-medium-uint8.onnx" });Bug da versão 0.2.5, corrigido na 0.2.6:
npm i @pedrobef/vozz@latestEra um ReferenceError meu: ao remover um bloco de código, a declaração de
uma variável saiu junto, mas os usos ficaram. O erro aparecia dentro de
Piper.carregar(), logo após o download do modelo.
O CI agora roda eslint com a regra no-undef antes de publicar, e há um
teste que percorre carregar() inteiro com um runtime falso — os dois
detectam esse tipo de falha.
Se a mensagem cita ConvInteger, veja o item acima — é quantização, não
backend.
Caso contrário, force o WASM, que implementa todos os operadores:
await Piper.carregar({ dispositivo: "wasm" });WebGPU só acelera modelos em fp32/fp16; com modelos quantizados ele repassa os nós ao WASM automaticamente.
Importe o Piper apenas em código de cliente (dentro de useEffect,
onMount, ou await import() sob if (typeof window !== "undefined")).
No Next.js, se ainda ocorrer, marque como externo:
// next.config.js
module.exports = {
webpack: (config, { isServer }) => {
if (isServer) config.externals = [...(config.externals ?? []), "onnxruntime-web"];
return config;
},
};Se a sua Content-Security-Policy restringe origens, libere o jsDelivr:
script-src 'self' https://cdn.jsdelivr.net;
connect-src 'self' https://cdn.jsdelivr.net;
worker-src 'self' blob:;Ou hospede tudo no seu domínio:
await Piper.carregar({
cdn: "/modelo", // seu .onnx e .json
urlRuntime: "/vendor/ort.min.mjs", // build ESM do onnxruntime-web
});Ele fica na Cache API do navegador. Se estiver rebaixando sempre:
- Modo anônimo descarta o cache ao fechar a aba — é esperado.
- Sem HTTPS: a Cache API exige contexto seguro (
https://oulocalhost). - Cota cheia: o pacote ignora falhas de cache em silêncio e continua funcionando; libere espaço no navegador.
Para forçar o redownload: await Piper.limparCache().
Melhorado na 0.2.7. Atualize:
npm i @pedrobef/vozz@latestO custo do modelo é linear: uma sentença muito longa virava uma única inferência que segurava a thread principal por vários segundos — a página não repintava e o áudio pronto não tocava. Medido aqui: 8,0 s de bloqueio numa frase de 479 fonemas.
Agora a síntese é fatiada em blocos (~360 fonemas), cortando em vírgulas e pausas naturais, e a thread é liberada entre eles. Mesmo texto: maior bloqueio caiu para 5,9 s e o primeiro áudio saiu 1,4× mais rápido, com o tempo total inalterado.
Para textos realmente longos, use streaming. Assim o usuário ouve a primeira frase enquanto o resto é gerado:
for await (const { audio } of tts.falarEmFluxo(textoGrande)) {
await audio.tocar();
}Ajustes finos:
// blocos menores = interface mais fluida, mais chamadas
await tts.falar(texto, { maxFonemas: 200 });
// mais threads (exige COOP/COEP; veja "A síntese está lenta")
await Piper.carregar({ threads: 2 });Se ainda travar, o gargalo provavelmente é a thread principal do navegador. A solução definitiva é rodar a síntese num Web Worker — o pacote funciona dentro de um sem alteração.
Ative WASM multi-thread com estes cabeçalhos (SharedArrayBuffer):
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corpNo Cloudflare Pages, crie um arquivo _headers na raiz do deploy:
/*
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corpOutras opções: use falarEmFluxo para tocar a primeira frase enquanto o
resto gera; ou Sintetizador, que é ~70× tempo real (voz robótica).
Atenção: COEP quebra recursos de terceiros sem CORS. Se algo parar de carregar, teste sem esses cabeçalhos primeiro.
Em Node não há import por URL. Instale o pacote nativo:
npm i onnxruntime-nodeImportar /piper no Worker é seguro (0.2.1+), mas a síntese neural não
roda no edge — falta memória para o WASM. Use o sintetizador:
import { Sintetizador } from "@pedrobef/vozz/sintetizador";Padrão recomendado: o Worker devolve os fonemas (/g2p, rápido e leve) e o
navegador faz a síntese neural.
Nomes próprios e estrangeirismos podem escapar do léxico. Corrija com IPA:
await tts.falar("Trabalho na Zendesk.", {
lexico: { zendesk: "zẽndˈɛski" },
});Use Piper.fonemizar("palavra") para ver o que o G2P está gerando.
Navegadores móveis exigem que a reprodução venha de um gesto do usuário.
Chame tocar() dentro do handler de clique — não em useEffect nem em
setTimeout:
botao.onclick = async () => (await tts.falar("Olá!")).tocar();Quanto pesa
| Item | Tamanho | Quando |
| --- | --- | --- |
| código do vozz | ~52 kB | sempre |
| /g2p sozinho | ~36 kB | se usar só os fonemas |
| runtime ONNX (JS) | ~435 kB | só com o Piper |
| runtime ONNX (WASM) | ~11 MB | só com o Piper |
| modelo de voz | 18,7 MB | só com o Piper, uma vez |
Tudo fica em cache. Se o peso for proibitivo, vozz/sintetizador gera voz
sem baixar nada (~72 kB, roda no edge) — mais robótica, mas inteligível.
Desenvolvimento
npm test # 39 testes, incluindo a fidelidade do G2P
npm run qualidade # relatório acústico do sintetizador
npm run eval # PER contra o espeak-ng
npm run pages # demo local em http://localhost:8080Créditos e licença
Código em Apache-2.0. Uso comercial liberado.
- Modelo de voz: Piper (VITS), voz
pt_BR-faber-medium, quantizada em int8 — MIT. - Motor alternativo: Kokoro-82M
de
hexgrad— Apache-2.0. - Inferência: onnxruntime-web — MIT.
Os pesos não vão dentro do pacote: são baixados sob demanda.
