Hasta esta etapa, el traductor era un comando: python -m perico.generate.translate "texto". Funcionaba, se había medido de arriba abajo… y no se podía usar desde ningún otro sitio.
El problema no era la falta de una API. Era dónde vivía el pipeline.
El pipeline estaba atrapado en main()
Todo el trabajo ocurría dentro de una función:
main()
leer los argumentos de la terminal
cargar el modelo de embeddings (~5 s)
abrir una conexión a Supabase
convertir el texto en vector y buscar 12 frases
montar el prompt
llamar a Claude
imprimir el resultadoLo útil —recuperar, montar el prompt, generar— estaba mezclado con lo propio de la terminal: leer argumentos e imprimir. Una API necesita lo del medio pero no lo de los extremos, y además no puede cargar el modelo en cada petición. Cualquier otro uso habría obligado a copiar el flujo, y dos copias de un flujo empiezan a divergir el día que alguien toca una.
La prueba de fuego la puso el dueño del proyecto, sin saber que era una prueba de fuego:
yo quiero hacerlo reutilizable, de manera que si dentro de una semana quiero montar una api rest que devuelva una frase de Perico de nuestra base de datos lo pueda hacer
El dueño del proyecto, al preguntarle cómo debía reutilizar la API el pipelineEse endpoint no tiene nada que ver con traducir: no usa el modelo, ni el prompt, ni Claude. Solo necesita la base de datos. Si para escribirlo hubiera que abrir translate.py, el diseño estaría mal.
Piezas que no saben unas de otras
config.py las constantes que estaban repetidas en tres ficheros
db.py la tabla phrases, y nada más
embeddings.py carga el modelo una vez
generate/
prompt.py el prompt, con funciones puras
translator.py recupera y genera, completo o en streaming
translate.py la CLI, que ahora es un envoltorio fino
api/
app.py la API HTTPCada pieza contesta a tres preguntas: qué hace, cómo se usa y de qué depende. db recibe una conexión y devuelve filas; no sabe que existen los modelos. Embedder convierte texto en vector; no sabe que existe la base de datos. prompt son funciones puras de texto a texto; no toca la red.
Y con eso, el endpoint de la semana que viene cabe en dos trozos:
# db.py
def random_phrase(conn):
with conn.cursor() as cur:
cur.execute("select text, title, url from phrases order by random() limit 1")
return cur.fetchone()
# api/app.py
@app.get("/api/phrases/random")
def random(request: Request):
with request.app.state.translator.connection() as conn:
return db.random_phrase(conn)La pieza que lo une todo es el Translator, y lo interesante es cómo recibe lo que necesita:
class Translator:
def __init__(self, *, embedder, connection, client, core, retrieve_n=RETRIEVE_N):
...No construye nada por dentro: se lo dan. La CLI le pasa una conexión nueva, la API una del pool, y los tests le pasan dobles —un embedder que devuelve siempre el mismo vector, un cliente que finge ser Claude— y así se puede probar entero sin red, sin modelo y sin gastar un céntimo. A esto se le llama inyección de dependencias, y sirve para lo que se ve aquí: que el código se pueda usar en sitios que su autor no tenía previstos, empezando por los tests.
Un refactor que no podía cambiar ni un byte
Aquí estaba el riesgo de verdad. Todas las mediciones del proyecto —el eje de recuperación, el juez a ciegas, el solapamiento, el barrido de la etapa 15— se tomaron contra un prompt concreto. Si al mover el código cambiaba un salto de línea, todas esas cifras pasarían a describir un programa que ya no existe. Y nadie se enteraría: la app seguiría funcionando igual de bien.
Así que antes de mover una sola línea se congeló la petición exacta que el código de entonces enviaba a Claude, para cuatro casos: el pipeline completo, sin recuperación, sin núcleo y con caracteres raros. Después del refactor, un test exige que el código nuevo construya exactamente lo mismo:
request = build_request(given["text"], retrieved, core)
assert json.dumps(request, ensure_ascii=False, sort_keys=True) == \
json.dumps(case["request"], ensure_ascii=False, sort_keys=True)El orden es lo que le da valor. Una instantánea sacada después del refactor solo demuestra que el código nuevo coincide consigo mismo. La que se sacó aquí sale del commit anterior, que queda anotado dentro del propio fichero, y se commiteó sola, antes de tocar nada.
Y el propio texto de STYLE se movió con un script que recortaba el fichero original, no escribiéndolo otra vez a mano. Un acento distinto habría bastado.
La comprobación en vivo lo confirmó por otro lado. La misma traducción, antes y después: las mismas doce frases con las mismas puntuaciones, y los mismos 565 tokens de entrada y 1.725 de sistema cacheado. Que coincida el número de tokens es la prueba barata de que el prompt es idéntico.
El SDK que ya no era el de memoria
Para el streaming, lo que se habría escrito de memoria es stream.text_stream. En el SDK instalado —anthropic 1.4— ese atributo no existe. Se vio inspeccionando el paquete antes de usarlo:
MessageStream: close, current_message_snapshot, get_final_message, get_final_text, …
TextEvent fields: ['type', 'text', 'snapshot']La interfaz real es iterar los eventos y quedarse con los de tipo text. Es la misma lección del principio del proyecto con otra cara: lo que uno recuerda de una librería es una hipótesis, y la versión instalada es la que decide.
La API: lo caro, una sola vez
def build_translator():
pool = ConnectionPool(os.environ["SUPABASE_DB_URL"], min_size=1, max_size=4, ...)
translator = Translator(embedder=Embedder(), connection=pool.connection,
client=anthropic.Anthropic(), core=load_core())
return translator, poolEsto se ejecuta en el lifespan de FastAPI, que es el código que corre al arrancar el servidor y al pararlo. En la CLI, cargar el modelo cuesta unos 5 segundos cada vez, cosa que en un comando suelto se tolera. En una API sería inaceptable, así que se carga una vez y se reutiliza. El resultado se midió: con el modelo ya cargado y el pool abierto, la recuperación tarda 0,27 s.
Los manejadores son def normales y no async def, a propósito. El modelo de embeddings, el driver de Postgres y el cliente de Anthropic son bloqueantes, y FastAPI ejecuta los manejadores síncronos en un pool de hilos, donde bloquear no molesta a nadie. Meterlos en async def sin más habría congelado el servidor entero durante cada petición.
Streaming: el 200 sale con el primer byte
Una traducción tarda unos 5 segundos. En vez de tener al usuario mirando un spinner, la respuesta llega en server-sent events, un formato de texto muy simple:
event: retrieved
data: [{"text": "Estás y no estás.", "score": 0.454, "url": "…", …}]
event: delta
data: {"text": "¡Ostras"}
event: done
data: {"usage": {"input": 493, "cached": 1725, "output": 419}}Primero las frases recuperadas, luego el texto a trozos según Claude lo escribe, y al final lo que costó.
Lo que no es obvio es cómo se informan los errores. El código de estado HTTP se envía con el primer byte de la respuesta. Si falla la base de datos, aún no se ha enviado nada y se puede contestar un 503 como es debido. Pero si Claude falla a mitad de frase, el cliente ya recibió un 200 hace rato y ya no se puede cambiar. Lo único que queda es avisar dentro del stream:
try:
for event in translator.stream(body.text, retrieved):
...
except Exception:
log.exception("generation failed")
yield sse("error", {"code": "generation_failed"})Los errores viajan como un código, no como una frase. Cómo decírselo a una persona, y en qué idioma, lo decide la interfaz.
La herramienta que decía que no había streaming
La primera prueba de verdad se hizo con el TestClient de FastAPI, que ejecuta la app completa dentro del mismo proceso, sin abrir ningún puerto. Todo funcionó: 12 frases, 165 trozos de texto, el done… y todos los eventos llegaron a la vez, a los 9,2 segundos.
Eso significaría que el streaming no funcionaba: la app habría estado acumulando la respuesta y soltándola entera al final.
Pero había dos sospechosos, la app y el cliente de pruebas. Para separarlos se midió un nivel por debajo, en el protocolo ASGI, que es la interfaz por la que la app entrega los bytes al servidor, y se anotó cuándo salía cada trozo:
97 body chunks
0.27s event: retrieved
0.93s event: delta
1.28s event: delta
…
5.18s event: doneLa app hacía streaming perfectamente. Era el TestClient el que acumulaba la respuesta antes de devolverla. Si la prueba se hubiera quedado en el primer resultado, se habría «arreglado» algo que funcionaba.
Es el mismo patrón de la etapa 8, cuando Whisper metía bucles en las transcripciones sin avisar. Un instrumento de medida con un comportamiento propio que no conoces produce resultados perfectamente creíbles y falsos. El remedio también es el mismo: cuando un resultado sorprende, se mide un nivel más abajo antes de tocar nada.
El cliente: fetch, no EventSource
El navegador trae EventSource para consumir server-sent events sin escribir nada… pero solo sabe hacer GET, y el texto a traducir va en el cuerpo de un POST. Así que el stream se lee a mano, con fetch y un ReadableStream:
const reader = response.body.getReader();
const decoder = new TextDecoder();
const parser = new SseParser();
while (true) {
const { value, done } = await reader.read();
if (done) break;
for (const raw of parser.push(decoder.decode(value, { stream: true }))) { … }
}Hay dos detalles que un stream real destapa y una prueba ingenua no. Los trozos pueden cortar un evento por cualquier sitio, incluso a medio carácter: una «ñ» ocupa dos bytes, y puede llegar un byte en un trozo y el otro en el siguiente. El stream: true del decodificador guarda el medio carácter hasta que llega el resto. Y el parser se prueba cortando el stream en cada una de las posiciones posibles, comprobando que siempre salen los mismos eventos.
La primera interfaz se hizo con la identidad del curso, y al dueño del proyecto no le gustó nada. Diseñó la suya en v0, el generador de interfaces de Vercel, y la exportó: un proyecto Next.js con Tailwind y shadcn. Lo que parecía obligar a cambiar de framework no lo hacía. La página solo usaba clases de Tailwind e iconos de lucide-react; el componente de shadcn venía incluido pero no se usaba. Así que se trajo lo que usaba (Tailwind y lucide, añadidos a la app de Vite) y se portó la página tal cual, conservando el streaming y la API propios.
La adaptación fue mínima, y casi toda fue de texto, porque parte del texto genérico habría sido falso aquí. «Private by default» y «Your text stays yours» prometen que el texto no sale del ordenador, y sale: viaja a la API de Anthropic. En su lugar, la píldora de arriba es solo un enlace a este curso, «Cómo está hecho»; si la API no responde, lo dice. Antes pasó por dos intentos. «3.134 frases suyas» sugería que cada palabra de la respuesta es suya, cuando el traductor también adorna e inventa. «Hecho con 36 horas de Perico transcritas» era verdad, pero el dueño lo vio claro: contar cómo se hizo es trabajo del curso, no de la app. «Try an example» escribe una frase al azar letra a letra. Y el teclado sigue la convención de cualquier caja de mensaje: Enter traduce y Shift+Enter hace salto de línea, con dos salvedades que se olvidan a menudo. En pantallas táctiles no se aplica, porque allí la tecla de retorno es como se hacen los saltos de línea y no hay Shift que sujetar; y se ignoran los eventos de composición, para que escribir una tilde o usar un teclado de japonés o chino no envíe el formulario a medio carácter. Y las frases recuperadas, que la primera versión ponía a la vista como parte de la respuesta, pasaron a los detalles técnicos, cerrados por defecto: son para el que quiera curiosear, no para todo el mundo.
Lo que el límite de longitud se llevó por delante
Semanas después, usando la app, el dueño del proyecto escribió «Mi perro se ha comido un calcetín» y recibió esto:
¡Ostras! Pues como el ciclista que se come las etapas, ¿eh? Mala digestión seguro.
La app, comentando en vez de contarNo hay perro y no hay calcetín. La app no estaba traduciendo el texto: lo estaba comentando, como si le hubieran hablado y tuviera que responder.
La causa era el arreglo de la longitud de esta misma etapa. «Mi perro se ha comido un calcetín» son seis palabras, así que el límite del doble daba doce, y en doce palabras no caben el perro, el calcetín y la gracia. El modelo elegía la gracia.
Y se pudo medir sin gastar un céntimo, porque todas las ejecuciones anteriores estaban guardadas con su entrada y su salida. La medida es sencilla: qué proporción de las palabras con contenido del texto original sobrevive en la respuesta.
| Versión del prompt | Fidelidad | Peor caso |
|---|---|---|
| Antes del límite de longitud | 85 % | 67 % |
| Con el límite del doble | 55 % | 0 % |
| Con adornos libres | 52 % | 0 % |
| Con adornos orientados a sus frases | 45 % | 0 % |
Mira la columna otra vez. Las tres cifras que bajan corresponden a las tres mejoras de esta etapa y de la anterior, cada una medida y celebrada: la longitud bajó de ×16,9 a ×1,9, los adornos orientados dejaron la recuperación en su mejor marca. Y las tres, a la vez, iban vaciando la respuesta de lo que el usuario había escrito.
Ninguna prueba lo vio porque todas medían el estilo: si suena a Perico, si reutiliza sus frases, cuánto se alarga. Ninguna preguntaba lo más elemental, que es si la respuesta sigue contando lo que contaba el texto.
Desde entonces, la prueba de longitud mide también fidelidad. No porque la fidelidad importe más que el estilo, sino porque era el eje sin vigilar, y el trabajo se escapó por ahí.
El arreglo tuvo dos partes. La primera, decirle qué tarea es: «tienes que contarlo tú, con tu forma de hablar; no es una conversación, no le respondes ni lo comentas». La segunda la decidió el dueño del proyecto cuando vio los primeros resultados, y es una regla de prioridades:
que se salte algo la longitud no es problema, pero que no reproduzca el sujeto de la oración sí
El dueño del proyecto, fijando qué cede ante quéAsí que la longitud dejó de ser la primera regla y pasó a ceder explícitamente ante el contenido, y el presupuesto ganó un suelo: por debajo de cierto número de palabras no hay forma de contar lo que pasa y además sonar a él.
El suelo se midió con tres valores, sobre los mismos veinte textos:
| Suelo | Fidelidad | Peor caso | Una frase se alarga |
|---|---|---|---|
| 18 palabras | 71 % | 33 % | ×2,5 |
| 30 palabras | 65 % | 25 % | ×3,4 |
| 45 palabras | 68 % | 25 % | ×5,8 |
Subir el suelo no compra fidelidad. Las tres cifras están dentro del ruido, y en cambio las frases cortas se hinchan: una línea de seis palabras acaba devolviendo casi seis veces su tamaño. Así que el suelo se queda en 18.
Lo que sí movió la fidelidad fue el prompt: decirle que su tarea es contar y no responder, y poner la longitud por debajo del contenido. De 45-56 % a 71 %. Sigue por debajo del 85 % de la versión larga del principio, y eso es un intercambio aceptado a sabiendas: aquella versión era fiel porque tenía sitio de sobra, y era insoportable de leer.
Probar sin arrancar servidores
Hay una regla del proyecto que obliga a hacer esto bien: ningún agente arranca servidores, se le pide al dueño. Un servidor arrancado desde una sesión sobrevive al comando que lo lanzó, se queda con el puerto y nadie lo ve.
Así que casi todo se probó sin puertos:
| Qué | Cómo |
|---|---|
| El prompt no cambia | Instantánea congelada antes del refactor, comparación byte a byte |
El Translator | Dobles de embedder, base de datos y Claude |
| El contrato de la API | TestClient con un traductor falso: orden de eventos, 422, 503, error a mitad |
| La app con todo real | TestClient con el arranque real: modelo, pool y una llamada a Claude, sin abrir puerto |
| Que de verdad hace streaming | Marcas de tiempo en el protocolo ASGI |
| El parser del cliente | Vitest, con el stream cortado en todas las posiciones |
Lo único que falta es lo que solo puede hacer una persona: arrancar la API y la interfaz y mirar la pantalla.
La primera prueba de verdad
Con la app montada, el dueño del proyecto la probó por primera vez. Escribió una frase:
Llevo dos horas esperando al fontanero y no aparece.
Y recibió tres párrafos: el fontanero comparado con dos puertos de primera, la fuga que se queda en nada, el bidón de agua, Dios que le pille confesado… Su veredicto:
qué es esa cantidad absurda de palabrería? una frase de input y su output tienen que estar más o menos balanceados, entiendo que perico es más verboso y eso está bien hasta cierto punto, pero esto que estamos haciendo no es correcto, es muy cansino.
El dueño del proyecto, después de la primera prueba en la interfazEl prompt pedía justo lo contrario, con estas palabras: «La longitud, parecida al original. Puedes irte por las ramas un poco, pero no escribas tres párrafos donde había una frase». Escribió exactamente tres párrafos.
Lo que nadie había medido
Se midió antes de tocar nada: veinte textos de tres tamaños, y cuántas palabras devolvía la app por cada palabra de entrada.
| Entrada | Salida, en veces la entrada (mediana) | Máximo |
|---|---|---|
| Una frase | ×16,9 | ×19,9 |
| Dos o tres frases | ×6,8 | ×9,1 |
| Un párrafo | ×3,4 | ×3,7 |
Lo revelador está en la columna de las frases cortas. La salida medía casi siempre lo mismo, entre 120 y 200 palabras, se le diera lo que se le diera. El modelo no escalaba su respuesta a la entrada: escribía «un texto de Perico» de tamaño fijo.
Las causas estaban a la vista una vez que se buscaron:
- Casi todo el prompt empujaba a alargar. De las siete líneas que describen cómo habla, cinco piden más texto: irse por las ramas, empezar una idea y saltar a otra, interrumpirse, corregirse… Y la regla de la longitud iba al final, era blanda y dejaba una puerta abierta.
- 62 frases de ejemplo en cada llamada, con una media de 25 palabras cada una.
- Y la de fondo: ninguna prueba medía la longitud. Todas las mediciones del proyecto preguntaban si sonaba a Perico. El juez a ciegas de la etapa 14 premiaba justo «más muletillas, más digresiones». Semanas optimizando para que se enrollara, sin que ninguna cifra lo avisara.
Cada métrica empuja en su dirección. Si todas miden lo mismo, en este caso lo mucho que suena a él, lo que ninguna mide acaba cediendo, y nadie se entera hasta que una persona usa la cosa de verdad. La primera prueba real encontró en un minuto lo que semanas de mediciones no habían visto.
Tres capas para una regla
El límite lo decidió el dueño del proyecto: como mucho, el doble de palabras que la entrada. Se aplicó en tres capas, de la más blanda a la más dura:
- El prompt. La longitud pasa a ser la primera regla y lleva la cifra. La línea de las digresiones pide ahora «una digresión corta». Y desaparece el «puedes irte por las ramas un poco».
- La cuenta, hecha. Pedir «el doble de palabras» obliga al modelo a contar, y a los modelos se les da mal contar palabras. Así que el mensaje le dice el número: «Texto (9 palabras; responde con 18 como mucho)». Va después del punto de corte de la caché, así que la parte cacheada no cambia.
- Un tope en el código, como garantía. Cada petición lleva un
max_tokensproporcional a la entrada, con margen para que solo salte si se ha incumplido la regla. Si salta, el texto se corta en el último punto, en el servidor y en el navegador, para que nunca acabe a media palabra. Es otra vez la regla del proyecto: el prompt es un ruego y el tope es la garantía.
| Entrada | Antes | Después |
|---|---|---|
| Una frase | ×16,9 | ×1,9 |
| Dos o tres frases | ×6,8 | ×1,9 |
| Un párrafo | ×3,4 | ×1,5 |
El fontanero, después: «Dos horas de espera, macho, esto ya son etapas de montaña. Se me ha pasado hasta la paella.»
Adornar, sí, pero con lo suyo
Con la longitud arreglada apareció un detalle: en la versión corta del fontanero, Perico «se había pasado hasta la paella», que no estaba en el texto original. El prompt decía «no inventes hechos». El dueño del proyecto lo vio de otra manera:
a mi lo de no inventar me parece que igual es un error, inventar cositas en tono perico está bien, no pretende ser esto nada fiable de si el señor dijo esto o aquello
El dueño del proyectoTenía razón, y la regla vieja mezclaba dos cosas. Adornar (una comparación, una exageración, una ocurrencia) es la gracia de la app. Cambiar lo que cuentas (que el perro pase a ser un gato, o que el fontanero sí llegue) ya no es tu mensaje dicho por Perico: es otro mensaje. Así que la regla pasó a permitir lo primero y a prohibir lo segundo.
Y se midió, porque tocar el prompt invalida lo medido. La prueba de solapamiento de la etapa 14 dice cuánto de lo que escribe el modelo sale de las frases recuperadas, por encima de lo que saldría por casualidad:
| Regla | Uso de la recuperación | Gana en | p |
|---|---|---|---|
| Solo el arreglo de longitud | +19,6 ±9,0 | 18 de 20 | < 0,001 |
| «Adórnalo con alguna ocurrencia que no esté en el original» (dos pasadas) | +7,4 y +5,1 | 11 de 18 y 13 de 18 | 0,48 y 0,10 |
Con la invitación a inventar, la recuperación dejó de notarse. Una moneda gana 11 de 18 con facilidad. Invitado a añadir «alguna ocurrencia que no esté en el original», el modelo escribía chistes suyos en lugar de tirar de lo que Perico dijo de verdad: justo lo contrario de aquello para lo que se construyó el corpus.
La salida no fue quitar el permiso, sino orientarlo:
«Adornarlo es cosa tuya: comparaciones, exageraciones, ocurrencias. Y lo mejor que tienes para eso son las frases tuyas que te pongo delante: úsalas o retuércelas antes que inventar de cero.»
| Regla | Uso de la recuperación | Gana en | p |
|---|---|---|---|
| Adornos orientados a sus frases, pasada 1 | +23,6 ±11,3 | 19 de 22 | 0,001 |
| Adornos orientados a sus frases, pasada 2 | +24,5 ±11,7 | 19 de 21 | < 0,001 |
El mejor resultado de todo el proyecto, con la longitud intacta (×2,2, ×1,7 y ×1,4). La libertad de adornar se quedó, y se convirtió en el sitio donde entran sus frases.
Nada se rompió de forma visible. La app seguía funcionando, y las respuestas seguían sonando a Perico y teniendo gracia. Pero una sola línea del prompt había dejado la recuperación, la pieza que costó semanas construir, sin efecto medible. Solo lo destapó volver a pasar la medición de la etapa 14 después de cada cambio del prompt, y con dos pasadas, porque una sola, con un modelo que no responde igual dos veces, no basta para distinguir un cambio del ruido.
Lo que se eligió, y qué fijó cada cosa
| Se eligió | Y con eso quedó fijado |
|---|---|
| Local primero | El modelo de embeddings de 384 dimensiones sigue siendo local. Desplegar es otra etapa |
| Solo traducir, con las fuentes | Que la app enseñe el RAG por dentro. Las frases destacadas, para más adelante |
| Núcleo compartido | Que la CLI, la API y las evaluaciones usen el mismo código: lo que se mide es lo que se sirve |
| Instantánea antes del refactor | Que las mediciones de las etapas 12 a 15 sigan describiendo esta app |
| Streaming con SSE sobre POST | Texto que aparece mientras se escribe, y un parser propio en el cliente |
| Errores como códigos | Que la API no decida el idioma ni el tono de un mensaje |
| 500 caracteres | Cuánto puede escribir el usuario y cuánto cuesta una petición |
Vite, React y TypeScript en web/ | Una pantalla sin un framework de servidor que se pise con FastAPI |
| El diseño del dueño, portado con Tailwind | La interfaz, sin traer Next.js: solo lo que la página usaba de verdad |
| Como mucho el doble de palabras | Que una frase no vuelva convertida en tres párrafos. Decisión del dueño |
| Tope de tokens y corte en el último punto | La garantía bajo la regla del prompt |
| Adornar sí, y con sus frases primero | Que la libertad de inventar no apague la recuperación |
Lo que se lleva uno de esta etapa
- El problema de reutilizar casi nunca es la API que falta: es dónde vive la lógica. Mientras estaba en
main(), cualquier uso nuevo habría sido una copia. - Antes de refactorizar algo que se ha medido, congela lo que produce. La foto se hace antes, se commitea sola, y el código nuevo tiene que reproducirla byte a byte.
- Lo que recuerdas de una librería es una hipótesis. Pregúntale a la versión instalada.
- En streaming, el código de estado sale con el primer byte. Los errores de después viajan dentro del stream.
- Cuando un resultado sorprende, mide un nivel más abajo antes de arreglar nada. El streaming funcionaba; era el cliente de pruebas el que lo ocultaba.
- Lo que ninguna métrica mide, se estropea. Semanas midiendo si sonaba a Perico, y ninguna prueba miraba si se hacía pesado. Lo encontró la primera persona que usó la app.
- Cada cambio del prompt vuelve a abrir todo lo medido. Una línea bienintencionada dejó sin efecto la recuperación; otra, orientada, la dejó mejor que nunca.
Epílogo: que se le oiga
El proyecto se llama la voz de Perico y durante dieciséis etapas no se oyó nada. Añadir sonido no estaba en ningún plan; surgió de una pregunta del dueño mientras se decidía dónde alojar la app, y resultó cerrar el círculo temático mejor que ninguna función pensada a propósito.
Lo primero fue lo gratis: el navegador trae su propio sintetizador, speechSynthesis, con soporte en el 95% de los navegadores y sin nada que instalar ni contratar. Tres trampas conocidas, todas invisibles hasta que muerden:
getVoices()devuelve una lista vacía en la primera llamada y se rellena después, avisando convoiceschanged—un evento que Safari no tuvo hasta la versión 16—. Así que hay que preguntar, y además esperar, y además rendirse a tiempo en vez de colgarse.- Chrome corta las lecturas largas a los quince segundos. El arreglo portable es leer por trozos, partiendo por finales de frase para que las pausas sigan donde estaban.
- El navegador ignora
speak()si no desciende de un clic real. Se cumple por construcción al ser un botón, pero prohíbe cualquier idea de leer algo al cargar la página.
Se montó, se probó, y el veredicto fue de una sola línea:
lo de la voz del sistema es malisimo, hay que poner algún modelo que podamos controlar, en español, que suene relativamente bien
El dueño del proyecto, tras escucharloLa restricción que acababa de imponerse el proyecto
El problema es que esa petición llegaba justo después de haber sacado 1,4 GB de modelo del servidor para que la app cupiera en un alojamiento gratuito. Meter ahora un modelo de voz lo desharía entero.
Eso dejaba tres caminos: una API de pago por carácter, un modelo corriendo en el navegador del visitante, o un modelo pequeño en una máquina propia. Y el segundo parecía el ganador antes de mirarlo: Kokoro, 82 millones de parámetros, corre en el navegador, coste cero para el servidor.
Kokoro tiene voces en español sobre el papel. En la práctica, la librería que lo lleva al navegador tiene el inglés escrito a fuego: convierte texto a fonemas con language === "a" ? "en-us" : "en", sus voces españolas están comentadas en el fichero de voces, y la dependencia fonética solo incluye los datos del inglés. Además, las tres voces españolas del modelo están calificadas C/D por sus propios autores y entrenadas en español latinoamericano. Lleva así desde mayo de 2025.
No se descubre en la ficha del modelo, que anuncia soporte multilingüe. Se descubre abriendo el paquete publicado.
Una clave que la documentación no menciona
La opción que ganó fue Google Chirp 3 HD: treinta voces nativas de español de España, un millón de caracteres al mes gratis de forma indefinida —consumiríamos el 12%—, y cero bytes añadidos al despliegue, porque es una llamada HTTP.
Quedaba una duda con consecuencias prácticas: cómo se autentica. Si exige una cuenta de servicio, la credencial es un JSON que hay que meter con calzador en las variables de entorno del alojamiento; si acepta una clave de API, es una línea y ya está. Tres páginas de la documentación no lo dicen: la guía de autenticación solo explica OAuth y cuentas de servicio, la referencia de la API menciona un scope de OAuth, y la página de claves no enumera qué servicios las aceptan.
Se resolvió preguntándole al propio servicio, con una clave inventada:
"message": "API key not valid. Please pass a valid API key.",
"reason": "API_KEY_INVALID",
"metadata": { "service": "texttospeech.googleapis.com" }Si el servicio no aceptara claves, no se quejaría de que ésta no valga. Un minuto de curiosidad contra media hora de leer documentación que no contesta.
Treinta voces y ningún criterio medible
Con el proveedor elegido quedaba escoger cuál de las treinta. Y aquí el proyecto, que lleva quince etapas midiendo todo lo medible, se topó con algo que no lo es: no existe ninguna comparativa seria de calidad de voz en español —las tablas públicas son de inglés—, y lo que se pedía era además difuso: voz con carácter, tipo locutor, pero inventada.
Así que se hizo lo único razonable: generarlas todas y escucharlas. Un pasaje de habla real suya, las treinta voces leyéndolo, y una página privada donde compararlas con el teclado —flechas para saltar de una a otra, una tecla para marcar las que valen, y un filtro para la segunda vuelta, porque el proceso real no es elegir una entre treinta sino pasar de treinta a cinco y de cinco a una—. Costó nueve mil caracteres del millón mensual.
Ganó es-ES-Chirp3-HD-Sadaltager.
El sintetizador del navegador no se borró al perder. Sigue ahí como respaldo para cuando nuestra API no responda o haya agotado su techo. Suena mal —por eso perdió— pero una voz mala es mejor que un botón que no hace nada, y ya estaba escrito y probado.