← Todos los artículos

ABReader por dentro: el lector que no encontré, construido a mi medida

Voces neuronales que se ejecutan en el navegador, sincronización entre dispositivos sin servidor y once formatos detrás de un mismo contrato. Las decisiones técnicas de ABReader y por qué las tomé.

En Android e iPhone hay lectores de libros electrónicos con lectura en voz alta muy buena. En el PC, o en algo que funcione igual en todos mis dispositivos, no encontré ninguno. Los lectores de escritorio que probé eran antiguos, leían uno o dos formatos, o me pedían subir mis libros a su nube.

Así que construí ABReader para mí, a mi medida. Este artículo cuenta cómo está hecho por dentro, ordenado por las necesidades que lo originaron. Cada una trajo su propio reto técnico. Si lo que buscas es qué hace y cómo se usa, eso está en el artículo para usuarios.

Una restricción lo condiciona todo: no hay servidor. Los libros, el progreso, los subrayados y las estadísticas viven en el dispositivo del usuario. No es una limitación que acepté, es lo que quería: mis libros son míos.

«Quiero escucharlo sin conexión y con una voz que no suene a robot»

Este fue el motivo original. El navegador ya trae una API de voz (speechSynthesis), pero depende de las voces que tenga instaladas el sistema: en unos equipos suenan bien, en otros fatal, y muchas solo funcionan con conexión.

La alternativa era ejecutar un modelo neuronal de texto a voz en el propio navegador, sin API ni servidor. ABReader tiene dos motores locales: Piper, con modelos pequeños que funcionan en cualquier equipo, y Supertonic, más natural, que encadena varios modelos ONNX y necesita GPU para ir cómodo. Ambos corren con onnxruntime-web.

Que la lectura no se congele

Sintetizar audio es caro. Si se hace en el hilo principal, la interfaz se bloquea mientras se genera cada frase. Por eso cada motor vive en su propio Web Worker, y el reproductor solo conoce esta interfaz:

export interface LocalTtsClient {
  readonly engine: LocalTtsEngineId
  synthesize(text: string, options: LocalTtsOptions): Promise<Blob>
  terminate(): void
}

Una única factoría decide qué worker hay detrás de cada motor. Las opciones (voz, velocidad, calidad) viajan en cada llamada, así que cambiar de voz no obliga a recrear el worker, y añadir un motor nuevo no toca la reproducción.

WebGPU cuando hay GPU, WASM cuando no

Supertonic va mucho mejor en GPU, pero WebGPU no está disponible en todos los navegadores ni funciona en todos los equipos. El worker lo intenta primero y, si falla por cualquier motivo, cae a WASM sin que el usuario tenga que hacer nada:

if (typeof navigator !== 'undefined' && 'gpu' in navigator) {
  try {
    const ort = await loadOrtBuild('webgpu')
    const sessions = await createModelSessions(ort, 'webgpu', modelBytes)
    return new SupertonicSession(ort, 'webgpu', config, indexer, sessions)
  } catch (error) {
    console.warn('[Supertonic] WebGPU is not usable, falling back to WASM:', error)
  }
}
const ort = await loadOrtBuild('wasm')

Cada build de onnxruntime-web se importa de forma dinámica, así que el navegador solo descarga la que va a usar.

Hay un límite que no pude saltar: WASM solo usa varios hilos si la página está aislada (cross-origin isolated), y eso requiere cabeceras HTTP que GitHub Pages no permite configurar. En la versión web, WASM corre en un solo hilo. Lo descubrí cuando generar el audio de un libro entero mientras escuchaba otro capítulo dejaba la descarga parada durante minutos: las dos tareas competían por el mismo hilo. Ahora la descarga reutiliza un único worker para todo el libro y pausa la lectura en vivo mientras dura.

Modelos de cientos de megas, sin corromperse

Los modelos de Supertonic ocupan unos 256 MB. No caben cómodamente en IndexedDB, así que van al Origin Private File System (OPFS), el sistema de archivos privado del navegador. Dos problemas:

  1. No cargarlos enteros en memoria. Cada archivo se descarga en streaming: se lee la respuesta por trozos y se escribe a disco a medida que llega.
  2. Una descarga interrumpida no puede parecer completa. Si el usuario cierra la pestaña a mitad, quedan archivos a medias. La regla es que el único indicador de «modelo completo» es un archivo marcador que se escribe al final. Si falta, el modelo no existe, haya lo que haya en la carpeta:
for (const file of SUPERTONIC_FILES) {
  // ...descarga en streaming de cada archivo a OPFS
}
await writeBlob(dir, SUPERTONIC_COMPLETE_MARKER, new Blob(['ok']))

Cualquier error borra la carpeta entera, y si el problema es la cuota de almacenamiento, el usuario lo ve explicado en vez de un fallo genérico.

Mientras el modelo no está listo, la lectura usa la voz del navegador y vuelve sola a la voz local cuando termina la descarga. Esa regla vive en un composable y no en un componente, así que se cumple aunque el panel de ajustes esté cerrado.

«Quiero mis libros en todos mis dispositivos, pero sin subirlos a ningún servidor»

Sin backend, sincronizar parece imposible. La solución fue usar el Google Drive del propio usuario como almacenamiento compartido: los datos van a su cuenta, no a la mía, y ABReader solo pide acceso a los archivos que crea.

No tener servidor no elimina los problemas de backend, solo los mueve al cliente: serializar la biblioteca, fusionar los cambios de dos dispositivos, propagar borrados y mover archivos grandes.

Libros borrados que resucitaban

El bug que más me enseñó: borraba un libro en un PC, sincronizaba en otro, y el libro volvía a aparecer. El borrado era físico y no dejaba rastro, así que al fusionar, el otro dispositivo veía un libro que «faltaba» en el remoto y lo volvía a subir.

Antes de tocar código fijé una regla: que algo falte en el remoto no significa que se haya borrado. Sin esa regla, cualquier fallo de red o sincronización a medias podría borrar libros, y perder datos es peor que tener un duplicado.

La solución son marcas de borrado explícitas, una por libro, que viajan con la sincronización. Cada marca guarda cuándo se borró y, si alguien restaura una copia de seguridad, cuándo se restauró. La fusión de dos marcas toma la fecha más reciente de cada campo por separado:

export const isBookDeleted = (marker?: DeletedBookMarker | null): boolean =>
  !!marker && !isNewer(marker.restoredAt, marker.deletedAt)

export const mergeMarkers = (a: DeletedBookMarker, b: DeletedBookMarker): DeletedBookMarker => {
  const restoredAt = later(a.restoredAt, b.restoredAt)
  // ...
  return { id: a.id, title, deletedAt: later(a.deletedAt, b.deletedAt)!, ...(restoredAt ? { restoredAt } : {}) }
}

Hacerlo así es lo que permite que funcione con cualquier número de dispositivos. La fusión es conmutativa e idempotente: da igual en qué orden se encuentren las marcas o cuántas veces se apliquen, el resultado es el mismo. Son funciones puras, así que las pruebo sin montar nada, con tests que simulan dos dispositivos. Para comprobar que esos tests sirven, rompo el código a propósito y verifico que fallan.

De un JSON gigante a un archivo por libro

La primera versión de la sincronización guardaba todo en un único JSON en Drive, con los libros dentro en base64. Funcionaba con bibliotecas pequeñas. Con cientos de megas, cada sincronización descargaba y subía el archivo entero, y construirlo en memoria podía agotarla en equipos modestos.

Primero lo mitigué: la exportación pasó a generarse en streaming y la subida a Drive a ser reanudable. Pero el problema de fondo era el formato. Ahora Drive guarda un library.json solo con los datos y una carpeta con cada libro tal cual. Una sincronización mueve solo lo que ha cambiado, y los libros que faltan en un dispositivo se descargan uno a uno. El JSON antiguo se importa una vez y nunca se modifica, para que nadie pierda datos con la migración.

«Quiero abrir cualquier libro que tenga»

Tengo libros en ePub, PDF, MOBI, cómics en CBZ y CBR, y documentos sueltos en DOCX o Markdown. ABReader lee once formatos, y cada uno tiene su librería: pdf.js, epub.js, parsers de MOBI, FB2, DjVu...

Si el lector tuviera que saber de todos, cada formato nuevo sería un if más en veinte sitios. En su lugar, cada formato implementa el mismo contrato (patrón Strategy):

export interface FormatHandler {
  format: string
  extensions: string[]
  extractMetadata(file: File | ArrayBuffer): Promise<BookMetadata>
  extractCover(file: File | ArrayBuffer): Promise<Blob | null>
  createRenderer(container: HTMLElement, data: ArrayBuffer, options?: RenderOptions): Promise<FormatRenderer>
  capabilities: FormatCapabilities
  getToc(fileData: ArrayBuffer): Promise<TocItem[]>
  search(query: string, fileData: ArrayBuffer): Promise<FormatSearchResult[]>
  // ...
}

Cada formato declara qué sabe hacer en capabilities: si tiene índice, si admite zoom, si se puede leer en continuo. La interfaz muestra u oculta controles según esos valores, sin preguntar nunca qué formato tiene delante.

El registro de formatos los carga bajo demanda. Cada formato se registra con una función que hace un import() dinámico, así que la librería de PDF solo se descarga la primera vez que abres un PDF:

registerLazyHandler('pdf', {
  extensions: ['pdf'],
  loader: async () => {
    const module = await import('~/formats/pdf-handler')
    return module.createPdfFormatHandler()
  },
})

Quien solo lee ePub nunca descarga pdf.js ni el parser de DjVu. Añadir un formato es crear su handler, registrarlo y escribir su test; no hay que tocar nada más.

Cómo lo mantengo yo solo

ABReader tiene unos 900 commits desde febrero de 2026. Mantener algo así sin romperlo cada semana requiere disciplina, sobre todo porque nadie más lo revisa.

  • Unos 2.500 tests unitarios con Vitest y 23 specs end-to-end con Playwright que recorren la biblioteca, los lectores, la búsqueda, la voz y el móvil.
  • Toda corrección empieza con un test que reproduce el fallo. Si no puedo reproducirlo en un test, todavía no lo entiendo.
  • El CI ejecuta lint, typecheck, tests y el build de producción en cada push. El build se añadió después de que un cambio en el worker de voz pasara todos los checks y rompiera la versión publicada: Vite empaquetaba los workers en un formato que no admite dividir el código, y onnxruntime-web lo necesita. Arreglé el bug y también el hueco del proceso que lo dejó pasar.

Lo desarrollo con agentes de IA, con método. Yo defino el problema y tomo las decisiones de diseño, como la regla de que una ausencia no es un borrado. Antes de cualquier cambio no trivial hay un documento de diseño y un plan, y en el repositorio hay más de cien. La IA implementa siguiendo reglas que fijé yo: primero el test que falla, y nada se da por terminado sin pasar la suite completa. Después reviso cada cambio.

La IA multiplica la velocidad de implementación, pero la calidad sigue dependiendo de lo bien que se defina el problema y de lo rigurosa que sea la verificación. Esa parte es ingeniería, y sigue siendo mía.

Qué haría distinto

Diseñaría el modelo de sincronización desde el primer día: fechas de modificación y marcas de borrado por registro, y un archivo por libro. Lo añadí cuando aparecieron los bugs, y migrar los datos de usuarios que ya existen siempre cuesta más que acertar al principio. El vocabulario, que llegó más tarde, ya nació con ese modelo, y es el que generalizaría.

Lo siguiente es una versión para Android con Tauri, el mismo framework de la versión de escritorio. Antes quiero una prueba de viabilidad, porque en el móvil cambian el inicio de sesión con Google, el almacenamiento y, sobre todo, el rendimiento de las voces neuronales.

Puedes probar ABReader en abdell1999.github.io/ebook-reader-public.