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.ts2const ELEVENLABS_API = "https://api.elevenlabs.io/v1";34interface TTSOptions {5 text: string;6 voiceId?: string;7 stability?: number;8 similarityBoost?: number;9}1011export 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;1819 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 });3435 if (!response.ok) {36 throw new Error(`TTS failed: ${response.status}`);37 }3839 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.tsx2const 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];1213export function QuickPhrases({ onSelect }: { onSelect: (text: string) => void }) {14 const [phrases, setPhrases] = useState(DEFAULT_PHRASES);1516 return (17 <div className="grid grid-cols-2 sm:grid-cols-4 gap-3">18 {phrases.map((phrase) => (19 <button20 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.ts2import { VitePWA } from "vite-plugin-pwa";34export 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 semana39 },40 },41 },42 ],43 },44 }),45 ],46});Cache de Áudio
Frases já sintetizadas são cacheadas para uso offline:
1// hooks/useAudioCache.ts2const CACHE_NAME = "tts-audio-v1";34export 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);910 return response ? response.blob() : null;11 };1213 const setCached = async (text: string, audioBlob: Blob): Promise<void> => {14 const cache = await caches.open(CACHE_NAME);15 const key = hashText(text);1617 await cache.put(18 key,19 new Response(audioBlob, {20 headers: { "Content-Type": "audio/mpeg" },21 }),22 );23 };2425 return { getCached, setCached };26}UX Decisions
Botões Grandes
Pessoas com mobilidade reduzida ou tremores precisam de alvos de toque grandes:
1// components/SpeakButton.tsx2export function SpeakButton({ onClick, isLoading }: SpeakButtonProps) {3 return (4 <button5 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-bold12 flex items-center justify-center gap-313 disabled:opacity-5014 touch-manipulation15 "16 >17 {isLoading ? (18 <Loader2 className="h-8 w-8 animate-spin" />19 ) : (20 <>21 <Volume2 className="h-8 w-8" />22 Falar23 </>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.ts2export function useTheme() {3 const [theme, setTheme] = useState<"light" | "dark">(() => {4 // Dark mode como padrão para acessibilidade5 return (localStorage.getItem("theme") as "light" | "dark") || "dark";6 });78 useEffect(() => {9 document.documentElement.classList.toggle("dark", theme === "dark");10 localStorage.setItem("theme", theme);11 }, [theme]);1213 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
- Acessibilidade não é feature, é requisito: mudou como penso sobre UX
- APIs pagas podem valer o custo: qualidade da ElevenLabs faz diferença real
- PWA é subestimado: app install sem App Store é poderoso
- Projetos pessoais têm outra energia: construí em 2 semanas o que levaria meses em trabalho corporativo