em produção5 min read

FalaPai

App de comunicação assistiva com síntese de voz natural via ElevenLabs API. PWA instalável com persistência local.

React 19ViteTailwind CSSElevenLabs APIPWATypeScript

A História

Meu pai precisou fazer uma cirurgia que comprometeu as cordas vocais e a capacidade de falar. Ele conseguia pensar, entender, mas apresentava rouquidão extrema pra falar.

Como desenvolvedor, a primeira coisa que eu penso nessas horas é: eu consigo resolver ou amenizar esse problema através de código?

Os apps de comunicação assistiva que encontramos eram caros, feios, ou com vozes robóticas que não pareciam humanas. Decidi construir algo melhor.

Fala-Pai é um aplicativo que permite digitar o que você quer dizer e ouvir em uma voz natural e expressiva.

O Desafio Técnico

O requisito principal era qualidade de voz. A Web Speech API nativa produz vozes que soam artificiais, bom para assistentes, mas não para substituir a voz de uma pessoa.

Testei várias APIs:

  • Web Speech API: Gratuita, mas vozes pobres
  • Google Cloud TTS: Boa qualidade, mas caro para uso contínuo
  • Amazon Polly: Razoável, API complexa
  • ElevenLabs: Vozes indistinguíveis de humanos, API simples

ElevenLabs ganhou pela qualidade. A API oferece vozes com entonação natural, pausas corretas, e emoção.

Arquitetura

1┌─────────────────────────────────────────────────┐
2│ PWA │
3│ ┌───────────────────────────────────────────┐ │
4│ │ React App │ │
5│ │ ┌─────────────┐ ┌──────────────────┐ │ │
6│ │ │ Text Input │ │ Quick Phrases │ │ │
7│ │ └─────────────┘ └──────────────────┘ │ │
8│ │ ┌─────────────────────────────────────┐ │ │
9│ │ │ Audio Player │ │ │
10│ │ │ (with offline cache) │ │ │
11│ │ └─────────────────────────────────────┘ │ │
12│ └───────────────────────────────────────────┘ │
13│ │ │
14│ ┌───────────────────┴────────────────────┐ │
15│ │ Service Worker │ │
16│ │ (caching + offline support) │ │
17│ └────────────────────────────────────────┘ │
18└──────────────────────┬──────────────────────────┘
19
20
21┌──────────────────────────────────────────────────┐
22│ ElevenLabs API │
23│ (Text-to-Speech Synthesis) │
24└──────────────────────────────────────────────────┘

Implementação do TTS

O core do app é a integração com ElevenLabs:

1// services/tts.service.ts
2const ELEVENLABS_API = "https://api.elevenlabs.io/v1";
3
4interface TTSOptions {
5 text: string;
6 voiceId?: string;
7 stability?: number;
8 similarityBoost?: number;
9}
10
11export async function synthesizeSpeech(options: TTSOptions): Promise<Blob> {
12 const {
13 text,
14 voiceId = "pNInz6obpgDQGcFmaJgB", // Voz "Adam" (masculina, brasileira)
15 stability = 0.5,
16 similarityBoost = 0.75,
17 } = options;
18
19 const response = await fetch(`${ELEVENLABS_API}/text-to-speech/${voiceId}`, {
20 method: "POST",
21 headers: {
22 "Content-Type": "application/json",
23 "xi-api-key": import.meta.env.VITE_ELEVENLABS_API_KEY,
24 },
25 body: JSON.stringify({
26 text,
27 model_id: "eleven_multilingual_v2",
28 voice_settings: {
29 stability,
30 similarity_boost: similarityBoost,
31 },
32 }),
33 });
34
35 if (!response.ok) {
36 throw new Error(`TTS failed: ${response.status}`);
37 }
38
39 return response.blob();
40}

Frases Rápidas

Para comunicação urgente, o app tem frases pré-configuradas que podem ser acionadas com um toque:

1// components/QuickPhrases.tsx
2const DEFAULT_PHRASES = [
3 { id: '1', text: 'Sim', emoji: '👍' },
4 { id: '2', text: 'Não', emoji: '👎' },
5 { id: '3', text: 'Estou com fome', emoji: '🍽️' },
6 { id: '4', text: 'Estou com sede', emoji: '💧' },
7 { id: '5', text: 'Preciso ir ao banheiro', emoji: '🚽' },
8 { id: '6', text: 'Estou com dor', emoji: '😣' },
9 { id: '7', text: 'Me ajuda', emoji: '🆘' },
10 { id: '8', text: 'Te amo', emoji: '❤️' },
11];
12
13export function QuickPhrases({ onSelect }: { onSelect: (text: string) => void }) {
14 const [phrases, setPhrases] = useState(DEFAULT_PHRASES);
15
16 return (
17 <div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
18 {phrases.map((phrase) => (
19 <button
20 key={phrase.id}
21 onClick={() => onSelect(phrase.text)}
22 className="p-4 rounded-xl bg-card hover:bg-accent transition-colors text-left"
23 >
24 <span className="text-2xl mb-2 block">{phrase.emoji}</span>
25 <span className="text-sm font-medium">{phrase.text}</span>
26 </button>
27 ))}
28 </div>
29 );
30}

PWA e Offline

O app precisava funcionar mesmo sem internet; hospitais e clínicas nem sempre têm WiFi estável.

1// vite.config.ts
2import { VitePWA } from "vite-plugin-pwa";
3
4export default defineConfig({
5 plugins: [
6 react(),
7 VitePWA({
8 registerType: "autoUpdate",
9 includeAssets: ["favicon.ico", "robots.txt", "apple-touch-icon.png"],
10 manifest: {
11 name: "Fala-Pai",
12 short_name: "FalaPai",
13 description: "Comunicação assistiva com voz natural",
14 theme_color: "#10b981",
15 icons: [
16 {
17 src: "pwa-192x192.png",
18 sizes: "192x192",
19 type: "image/png",
20 },
21 {
22 src: "pwa-512x512.png",
23 sizes: "512x512",
24 type: "image/png",
25 },
26 ],
27 },
28 workbox: {
29 globPatterns: ["**/*.{js,css,html,ico,png,svg,woff2}"],
30 runtimeCaching: [
31 {
32 urlPattern: /^https:\/\/api\.elevenlabs\.io\/.*/i,
33 handler: "CacheFirst",
34 options: {
35 cacheName: "tts-audio-cache",
36 expiration: {
37 maxEntries: 50,
38 maxAgeSeconds: 60 * 60 * 24 * 7, // 1 semana
39 },
40 },
41 },
42 ],
43 },
44 }),
45 ],
46});

Cache de Áudio

Frases já sintetizadas são cacheadas para uso offline:

1// hooks/useAudioCache.ts
2const CACHE_NAME = "tts-audio-v1";
3
4export function useAudioCache() {
5 const getCached = async (text: string): Promise<Blob | null> => {
6 const cache = await caches.open(CACHE_NAME);
7 const key = hashText(text);
8 const response = await cache.match(key);
9
10 return response ? response.blob() : null;
11 };
12
13 const setCached = async (text: string, audioBlob: Blob): Promise<void> => {
14 const cache = await caches.open(CACHE_NAME);
15 const key = hashText(text);
16
17 await cache.put(
18 key,
19 new Response(audioBlob, {
20 headers: { "Content-Type": "audio/mpeg" },
21 }),
22 );
23 };
24
25 return { getCached, setCached };
26}

UX Decisions

Botões Grandes

Pessoas com mobilidade reduzida ou tremores precisam de alvos de toque grandes:

1// components/SpeakButton.tsx
2export function SpeakButton({ onClick, isLoading }: SpeakButtonProps) {
3 return (
4 <button
5 onClick={onClick}
6 disabled={isLoading}
7 className="
8 w-full h-20
9 rounded-2xl
10 bg-emerald-500 hover:bg-emerald-600
11 text-white text-xl font-bold
12 flex items-center justify-center gap-3
13 disabled:opacity-50
14 touch-manipulation
15 "
16 >
17 {isLoading ? (
18 <Loader2 className="h-8 w-8 animate-spin" />
19 ) : (
20 <>
21 <Volume2 className="h-8 w-8" />
22 Falar
23 </>
24 )}
25 </button>
26 );
27}

Dark Mode por Padrão

Telas brilhantes são desconfortáveis em ambientes de baixa luz (quartos de hospital):

1// hooks/useTheme.ts
2export function useTheme() {
3 const [theme, setTheme] = useState<"light" | "dark">(() => {
4 // Dark mode como padrão para acessibilidade
5 return (localStorage.getItem("theme") as "light" | "dark") || "dark";
6 });
7
8 useEffect(() => {
9 document.documentElement.classList.toggle("dark", theme === "dark");
10 localStorage.setItem("theme", theme);
11 }, [theme]);
12
13 return {
14 theme,
15 setTheme,
16 toggle: () => setTheme((t) => (t === "dark" ? "light" : "dark")),
17 };
18}

Métricas

Desde o deploy:

  • 50+ frases sintetizadas
  • Tempo médio de síntese: 1.2s
  • Taxa de cache hit: 67%
  • Instalações PWA: 8 (incluindo família)

O que aprendi

  1. Acessibilidade não é feature, é requisito: mudou como penso sobre UX
  2. APIs pagas podem valer o custo: qualidade da ElevenLabs faz diferença real
  3. PWA é subestimado: app install sem App Store é poderoso
  4. Projetos pessoais têm outra energia: construí em 2 semanas o que levaria meses em trabalho corporativo