Parte 1 de 8
LeXi: objetivo y arquitectura de un comunicador AAC
Por qué construir un comunicador AAC como PWA offline-first sobre Cloudflare Pages, D1 y R2, y el stack que elegimos.
LeXi es un comunicador AAC (Comunicación Aumentativa y Alternativa): una PWA de tarjetas con imágenes que, al pulsarlas, reproducen su sonido en voz sintetizada o con audio grabado por la familia. Está pensado para personas con dificultades de habla y para terapeutas y familias. En esta serie cuento cómo lo construí desde cero, decisión a decisión, incluyendo los problemas con los que me encontré.
Qué quería conseguir
Los requisitos que guiaron todo el diseño:
- Offline-first: un niño en el aula o en el coche no siempre tiene red; la app tiene que funcionar sin conexión desde el primer segundo.
- Instalable: que quede como una app en el móvil o la tablet, no una pestaña del navegador.
- Audio propio por tarjeta: el reconocimiento de la voz de la familia es clave en AAC; no basta con voz sintética.
- Sin registro ni login: las familias no van a crear cuentas. Cada dispositivo es anónimo.
- Gratis de mantener: nada de servidores encendidos 24/7 ni costes por uso.
El stack elegido y por qué
| Capa | Tecnología | Por qué |
|---|---|---|
| Frontend | Astro (SSG) + islas Svelte 5 | HTML estático rápido + componentes interactivos solo donde hacen falta. Svelte tiene el runtime más pequeño, ideal para tiles y teclado táctil. |
| Estado global | nanostores + @nanostores/svelte-runes |
Compartir estado entre islas Svelte aisladas dentro de Astro. Con las runas de Svelte 5 se usa useStore(). |
| Hosting | Cloudflare Pages | Necesitábamos Functions serverless, D1 y R2. GitHub Pages no sirve para esto. |
| Datos | Cloudflare D1 (SQLite serverless) | La nube es el backup; IndexedDB es la fuente de verdad del día a día. |
| Audio | Cloudflare R2 (S3-compatible) | Almacenar grabaciones; lectura vía proxy del mismo origen (sin CORS). |
| Datos offline | IndexedDB (vía idb) |
Base local de la app; cola de sincronización. |
| Voz TTS | Web Speech API | SpeechSynthesis nativa del navegador, coste cero. |
| Micrófono | MediaRecorder + getUserMedia | 100 % en cliente, blob WebM/AAC que se sube a R2. |
| PWA | @vite-pwa/astro (workbox) | Service Worker + manifest + precache. |
Decisiones de arquitectura clave
Astro SSG + Pages Functions, no Astro SSR. Evaluamos @astrojs/cloudflare con output: 'server', pero lo descartamos: el modo SSG con output: 'static' y un directorio functions/ para la API es más simple de depurar y suficiente.
R2 privado con proxy de lectura. El bucket no es público. Se lee el audio a través de una Function del mismo origen (/api/audio/*), lo que además evita configurar CORS.
Subida vía proxy directo, no presigned URL. El cliente manda el blob a POST /api/upload y la Function lo valida (tamaño, cuotas) y hace BUCKET.put(). No hacen falta secrets de acceso S3 ni firmas; una sola petición.
Sin auth: user_id = UUID del dispositivo. Dos dispositivos son dos usuarios independientes. La seguridad se basa en un UUID que se genera en el cliente.
Sync last-writer-wins por updated_at. Los borrados son tombstones (deleted_at) y los conflictos los resuelve la fecha de modificación más reciente.
Lección: KV se descartó para el MVP. D1 cubre todo lo que necesitábamos (config, códigos, uso) y añadir KV era una pieza más que mantener sin beneficio claro.
El esquema de datos en D1
Cinco tablas, con IDs UUID generados en el cliente (crítico para offline-first) y timestamps en epoch ms:
users— perfil anónimo por dispositivo (UUID, locale, voz, tema).categories— tableros;user_id NULL= catálogo global.cards— tarjetas (etiqueta, imagen/audio en R2, texto TTS, orden).recordings— grabaciones de audio subidas a R2.events— estadísticas de uso (tap, hablar, crear, editar).
Diseñado desde el día 1 para i18n: users.locale y users.voice_uri, con camino futuro a card_translations. Español (es) es el único idioma implantado ahora.
En la siguiente parte, el esqueleto Astro + Svelte con el sistema de temas.