Este artículo está basado en una investigación que hice junto con varios agentes de investigación, como parte de un proceso semiautomático que estoy armando: yo decido qué se prueba y reviso, ellos escriben los scripts, corren las pruebas y me entregan un reporte técnico. Los clips de audio son de textos de dominio público: "Nuestra América" de José Martí (1891) y la traducción de Helen Zimmern de Beyond Good and Evil (Project Gutenberg).

Introducción

Kokoro es un modelo de texto a voz de 82 millones de parámetros con licencia Apache 2.0. Le das un texto y una voz, y te regresa el audio. El archivo ONNX pesa 311 MB y corre en CPU, sin GPU y sin ningún servicio en la nube. Su página en Hugging Face (el model card) lista 54 voces en 9 idiomas; el archivo de voces que usé trae 50. Tres son de español: ef_dora, em_alex y em_santa.

Kokoro trabaja con fonemas, los sonidos escritos en el alfabeto fonético (IPA): "casa" se escribe kˈasa y "hojas", ˈoxas. Para convertir el texto a fonemas usa espeak-ng, un sintetizador de voz viejo de código abierto que aquí solo sirve como diccionario de pronunciación.

En el model card, los autores califican cada voz según la calidad y la cantidad de audio con que se entrenó. La mejor es af_heart, de inglés, con A:

af_heart
Primeras frases del §1 de Beyond Good and Evil, traducción de Helen Zimmern. 28 segundos.

Las voces de español no tienen calificación, y el mismo model card avisa que fuera del inglés el soporte puede ser delgado.

Yo quería ver si sirve para hacer audiolibros en español: qué tan bien suena, qué tan rápido genera en CPU y si se pueden sacar subtítulos sincronizados. Para correrlo usé kokoro-tts, un CLI que lee TXT, EPUB y PDF, y por debajo kokoro-onnx, la librería de Python que corre el modelo.

Resumen

  • En español sí sirve, pero no tal como viene: sin cambios suena a España, con acento inglés en la r y en la jota.
  • En CPU genera como tres veces más rápido que el tiempo real.
  • A velocidad normal se oye apurado. A 0.75 se escucha bien.
  • Los números hay que escribirlos con letra.

Los Libros

Lo probé con dos libros de mi biblioteca: la Parte 1 de Beyond Good and Evil de Nietzsche, en la traducción al inglés de Judith Norman (Cambridge), y el Capítulo I de El Capital de Marx en español, de una edición en PDF de Feedbooks. Las dos traducciones tienen derechos, así que no publico ese audio ni el texto. Las cifras de abajo salen de esos libros; todo lo que se escucha en este post es de textos de dominio público.

Los dos venían en PDF y ninguno se podía mandar tal cual. El de Nietzsche tiene texto digital, pero sucio: palabras pegadas donde termina una cursiva ("truthfulnessthatallphilosophers"), ligaduras (fi), notas al pie, encabezados, diéresis separadas de su letra ("Tu¨ bingen") y los números de página y de sección escritos con caracteres especiales de la fuente, que al copiar el texto salen como símbolos raros en lugar de dígitos. Lo limpiamos filtrando por tamaño de letra (el cuerpo va en 11 puntos, las notas en 6 a 8) y reconstruyendo los espacios por la distancia entre letras. Quedaron 7,579 palabras en 23 secciones.

El de El Capital estaba más limpio: las notas al pie vienen todas juntas al final del capítulo y el tamaño de letra separa el cuerpo de todo lo demás. Quedaron 14,910 palabras. Esa edición no trae la sección del fetichismo de la mercancía; el capítulo termina en la forma dinero.

El Experimento

Con kokoro-onnx, generar audio se ve así. Le das el texto, la voz, la velocidad y el idioma, y te regresa las muestras de audio y cuántas van por segundo (24,000):

from kokoro_onnx import Kokoro

kokoro = Kokoro("kokoro-v1.0.onnx", "voices-v1.0.bin")
samples, sample_rate = kokoro.create(texto, voice="em_alex", speed=1.0, lang="es")

Para cada libro el proceso fue el mismo: partir el texto limpio en trozos de unos 700 caracteres, generar un MP3 por trozo y unirlos con ffmpeg. Trabajar por trozos permite reanudar si algo truena a la mitad, y el tamaño salió de una recomendación del model card que, como explico más abajo, calculamos mal. Armamos también una página con el audio, los subtítulos que avanzan y una imagen por sección, pero no la publico por los derechos del texto.

Todo corrió en mi Ryzen 7 3700X, dentro de WSL, sin tocar la GPU. Para medir la velocidad uso el RTF (real-time factor): el tiempo que tarda en generar el audio dividido entre lo que dura el audio. Si un minuto de audio tarda 18 segundos en generarse, el RTF es 0.30. Menos de 1 quiere decir que genera más rápido de lo que tardas en escucharlo; 0.30 es como tres veces más rápido.

Para los subtítulos hay que saber en qué segundo del audio se dice cada palabra. Para eso usamos Whisper, el modelo de reconocimiento de voz de OpenAI, con faster-whisper, que corre en CPU. Le das un audio y te regresa el texto que oyó, con el segundo en que empieza y termina cada palabra.

Whisper se equivoca a veces, pero aquí no importa tanto, porque el texto correcto ya lo tenemos: es el mismo que le mandamos a Kokoro. Un script pone en fila las palabras del libro y las que oyó Whisper, y cuando una coincide, la palabra del libro se queda con el tiempo que dio Whisper. Si una palabra no coincide, porque Whisper la oyó distinto o se la saltó, su tiempo se calcula entre el de la palabra anterior y el de la siguiente que sí coincidieron.

Resultados

En la tabla, audio es lo que dura el libro narrado y CPU lo que tardó en generarse:

libroidiomavoztrozosaudioCPURTF
Nietzsche, Parte 1inglésaf_heart7342.9 min12 min0.28 a 0.38
Marx, Capítulo Iespañolem_alex14480.3 min23.6 min0.29
Marx, Capítulo I a 0.75×españolem_alex144110.4 min33.6 min0.30

El log de Nietzsche se cortó en el trozo 68 por un bug de kokoro-onnx que explico en Otros Detalles, así que su tiempo de CPU es de los primeros 67 trozos. Cargar el modelo tarda unos 2.5 segundos. En los clips cortos de este post, de 6 a 11 segundos, el RTF quedó entre 0.34 y 0.51.

En los subtítulos, el 95.4% de las palabras de Nietzsche coincidieron con lo que oyó Whisper (en el peor trozo, el 83.3%), y el 95.7% en El Capital (en el peor, el 79.2%). Con el modelo base de Whisper, en CPU, alinear los 110 minutos de El Capital tardó 6.5 minutos.

Whisper sirve para sacar los tiempos, pero no para calificar qué tan bien habla Kokoro. Más abajo, en la sección del español, hay dos clips con el mismo texto, uno con pronunciación de España y otro latinoamericana; se oyen distintos, y Whisper los transcribió exactamente igual. Y los trozos de El Capital donde menos coincidió son los de las ecuaciones de la forma del valor ("20 varas de lienzo = 1 levita"). Ahí Kokoro leyó el "=" como "igual", "1/2" como "uno barra dos" y el "2°" como "grados", palabras que no están escritas así en el texto, y Whisper escribió "baras" y "lienso", que es como suenan con seseo. El porcentaje baja por los símbolos y la ortografía; yo esas partes las revisaría con el texto antes de generar el audio.

En MP3 mono a unos 60 kbps, una hora de audio pesa entre 26 y 29 MB.

Lo que Hay que Arreglar en Español

Con el código de arriba, kokoro-onnx 0.3.9 truena antes de generar nada:

AssertionError: Language must be either en-us, en-gb, fr-fr, it, ja, cmn. Got es

El archivo de voces sí trae las de español, pero kokoro-onnx tiene una lista de idiomas permitidos y "es" no está. Se puede agregar desde tu código, sin modificar la librería instalada:

import kokoro_onnx

kokoro_onnx.SUPPORTED_LANGUAGES.append("es")

Con eso ya genera audio en español, pero con dos problemas: la pronunciación es la de España, y la r fuerte y la jota suenan como en inglés.

Castellano o Latinoamericano

espeak-ng tiene varias variantes de español. La que usa kokoro-onnx con "es" es la de España, así que la z y la c de "alcanza" o "dicen" salen con θ, el sonido de la th de "thin" en inglés. La variante latinoamericana se llama es-419 y pronuncia esas letras como s. En estos dos clips el texto y la voz son los mismos, y lo único que cambia es la variante:

Castellano, es
Latinoamericano, es-419
Martí, “Nuestra América”. Voz em_alex, con los fonemas pasados directo al modelo en los dos.
          alcanza    difícil    brazo    dicen
es        alkˈanθa   ðifˈiθil   βɾˈaθo   ðˈiθen
es-419    alkˈansa   ðifˈisil   βɾˈaso   ðˈisen

El model card dice que las voces de español se hicieron con espeak-ng es, la variante de España, así que probablemente se entrenaron con fonemas con θ. No lo pude confirmar, porque no publican los datos de entrenamiento. Aun así, la voz pronuncia bien los fonemas de es-419: el sonido s ya lo conoce, porque en España también se usa, en "casa" o "así". Para El Capital usé es-419.

Acento Inglés

El segundo problema viene de kokoro-onnx. Después de convertir el texto a fonemas con espeak-ng, hace cuatro reemplazos de símbolos. Están pensados para el inglés, pero los aplica a cualquier idioma:

phonemes = (
    phonemes.replace("ʲ", "j")
    .replace("r", "ɹ")
    .replace("x", "k")
    .replace("ɬ", "l")
)

En español, espeak-ng escribe con r la r fuerte, la de "perro", "tierra" o "recuento", y con x la jota, la de "hojas" o "gente". La r suave, la de "pero" o "cara", la escribe con otro símbolo, ɾ. Los reemplazos convierten la r fuerte en ɹ, la r del inglés, y la jota en k: "hojas" queda como "okas". La r suave no la tocan. El modelo sí conoce r, x y ɾ, así que en español estos reemplazos solo estropean la pronunciación.

Los dos clips usan es-419, para que solo cambie esto. El primero pasa por los reemplazos y el segundo no. En el primero, "hojas" suena "okas" y la r de "ser" y "restallando" suena a inglés:

Con los reemplazos (mal)
Sin los reemplazos (bien)
Martí, “Nuestra América”. Los dos con fonemas de es-419; el primero pasa por los reemplazos de kokoro-onnx.
                 ser    hojas   restallando
con reemplazos   sˈeɹ   ˈokas   ɹˌestajjˈando
sin reemplazos   sˈer   ˈoxas   rˌestajjˈando

Los dos problemas se arreglan igual. En lugar de dejar que kokoro-onnx convierta el texto a fonemas, lo convertimos nosotros con phonemizer, la misma librería que kokoro-onnx usa por dentro para llamar a espeak-ng, pero pidiéndole es-419. El resultado se le pasa a create() en el parámetro phonemes. Cuando recibe los fonemas ya hechos, kokoro-onnx no los vuelve a generar y tampoco les aplica los reemplazos. Así ya no hace falta agregar "es" a la lista de idiomas. El código queda así:

import kokoro_onnx
import phonemizer
import soundfile as sf

# un lote de 510 fonemas exactos truena (ver abajo)
kokoro_onnx.MAX_PHONEME_LENGTH = 509

kokoro = kokoro_onnx.Kokoro("kokoro-v1.0.onnx", "voices-v1.0.bin")

# Kokoro configura espeak-ng al crearse, así que esto va después
fonemas = phonemizer.phonemize(texto, "es-419", preserve_punctuation=True, with_stress=True)
samples, sample_rate = kokoro.create(texto, voice="em_alex", speed=0.75, phonemes=fonemas)
sf.write("salida.mp3", samples, sample_rate)

La diferencia completa, en una frase con z, rr y jota. El primer clip es kokoro-onnx tal como viene, solo con "es" agregado a la lista; el segundo tiene el arreglo:

Como viene (mal)
Con el arreglo (bien)
Martí, “Nuestra América”. El primero usa lang="es"; el segundo, fonemas de es-419 hechos aparte.
             celosos    tierra   mejor    encajar
como viene   θelˈosos   tjˈeɹa   mekˈoɾ   ˌenkakˈaɾ
arreglado    selˈosos   tjˈera   mexˈoɾ   ˌenkaxˈaɾ

Whisper, que usamos de control, transcribió "el pueblo de Okas" en el clip con los reemplazos, y "cacar" en lugar de "encajar" en el que viene sin arreglar.

Números

Con los números hay dos problemas distintos, uno con kokoro-onnx tal como viene y otro con el arreglo. Antes de convertir a fonemas, kokoro-onnx normaliza el texto: reescribe números, abreviaturas y símbolos para que espeak-ng los lea bien. Las reglas son del inglés, así que parte los años en dos pares ("1891" como "18 91", igual que "eighteen ninety-one") y cambia el punto decimal por la palabra point:

texto        México, 30 de enero de 1891. El capítulo dura 110.4 minutos.
normalizado  México, 30 de enero de 18 91. El capítulo dura 110 point 4 minutos.

Con el arreglo nos saltamos esa normalización. espeak-ng sí lee bien el año en español, "mil ochocientos noventa y uno", pero el decimal lo lee como dos números separados por una pausa, "ciento diez… cuatro", sin decir "punto". Lo más simple es escribir los números con letra antes de generar el audio. La frase de estos clips la inventé para oír los números:

Como viene
Con el arreglo
Arreglo y decimal con letra
En el tercero, “110.4” va escrito “ciento diez punto cuatro”.

Los libros no tenían decimales. Los números sí complicaron los subtítulos: Whisper a veces escribe dígitos donde el texto tiene palabras, o al revés, así que el script de subtítulos convierte los dígitos a palabras antes de comparar.

¿Por Qué Se Oye Apurado?

A 1.0× el capítulo de El Capital me sonó apurado, y el audio que se quedó es a 0.75× (el parámetro speed de create()): pasó de 80.3 a 110.4 minutos.

1.0×
0.75×
Primera oración de “Nuestra América”, con em_alex y fonemas de es-419.

Revisando para este post encontramos una causa probable. kokoro-onnx no le manda al modelo cada trozo completo: lo corta en lotes de hasta 510 fonemas, en la puntuación, porque es lo máximo que acepta el modelo en una llamada. El model card dice que las voces funcionan mejor con 100 a 200 fonemas por lote (ellos les dicen tokens), y que con más de 400 se apuran.

Los trozos de 700 caracteres se escogieron pensando que eran unos 150 fonemas, pero cada carácter da como 1.1 fonemas. Un trozo de 700 caracteres son unos 700 fonemas, que kokoro-onnx parte en dos lotes grandes. En El Capital, 137 de los 282 lotes pasaron de 400.

Lo probamos con los dos primeros párrafos de Martí a 1.0×, cambiando solo el tamaño máximo del lote. Con lotes de hasta 509 fonemas, como en los libros, el audio dura 99.7 segundos; con lotes de hasta 200, 109.1 segundos, un 9% más lento (de 21.8 a 19.9 fonemas por segundo). Es un solo texto con una sola voz, y no lo comparé contra el 0.75×.

Lotes de hasta 509
Lotes de hasta 200
Los dos a 1.0×, recortados a los primeros 45 segundos.

Un Fragmento Más Largo

Así queda con todo lo de arriba: los dos primeros párrafos de "Nuestra América", con em_alex, fonemas de es-419 y a 0.75×.

em_alex, 0.75×
José Martí, “Nuestra América” (1891): la fecha y los dos primeros párrafos. 2 minutos 20 segundos.

Otros Detalles

El CLI no corre en Python 3.13 o más nuevo; lo instalé con uv tool install kokoro-tts y Python 3.12. Aunque solo guardes a archivo, importa sounddevice, así que en Linux necesita PortAudio (libportaudio2). El modelo y las voces se bajan aparte: kokoro-v1.0.onnx (311 MB) y voices-v1.0.bin (25 MB). soundfile escribe MP3 directo, sin ffmpeg.

kokoro-onnx 0.3.9 tiene un bug cuando un lote llega a exactamente 510 fonemas:

IndexError: index 510 is out of bounds for axis 0 with size 510

Cada voz de Kokoro es una tabla con 510 renglones, uno para cada largo posible del lote, y kokoro-onnx escoge el renglón según cuántos fonemas trae. Con 510 fonemas pide el renglón 510, que no existe, porque van del 0 al 509. Pasó en el trozo 68 de Nietzsche y mató la corrida. Se arregla bajando el límite a 509, como en el código de arriba.

La "O" mayúscula del PDF de El Capital salía como cero en algunos estilos, en cinco lugares, incluidos dos títulos ("FORMA TOTAL 0 DESARROLLADA"). Kokoro habría dicho "cero". Lo encontramos después de la primera conversión y la relanzamos con el texto corregido.

Al preparar los clips encontramos un bug en nuestro script que parte el libro en trozos: corta en cada punto, también en los decimales, y "110.4" quedaba como "110. 4". Los libros no tenían decimales, así que no los afectó.

Conclusión

Para oír un libro en español en mi máquina, Kokoro ya alcanza. Hay que pasarle los fonemas de es-419 y escribir los números con letra; con eso, a 0.75×, el capítulo de Marx se escucha bien, y generarlo tardó menos de la tercera parte de lo que dura.

Lo de los lotes de 200 fonemas falta probarlo con un libro completo, a ver si con eso ya no hace falta bajar la velocidad. Tampoco lo he comparado contra un TTS en la nube con el mismo texto.

Las versiones son kokoro-onnx 0.3.9, kokoro-tts 2.3.2, kokoro-v1.0.onnx y voices-v1.0.bin, del 28 de septiembre de 2026.