talkies: Manda a Tomar Por Culo la API de Voz de OpenAI

Me llegó la factura de la API de Whisper de OpenAI después de un mes enchufándole mis propias herramientas, y me quedé mirando el número como si me hubiera insultado personalmente a mi madre. No porque fuera una cantidad demencial, no lo era, sino porque estaba pagando alquiler por minuto por un modelo que lleva años siendo público y autoalojable, por el privilegio de subir mi propio audio al servidor de otro para que ejecutase una inferencia que yo podía ejecutar en una GPU que ya tengo. Luego necesité TTS encima, para el mismo pipeline, lo que significaba un segundo proveedor, una segunda clave de API, una segunda factura, un segundo documento de Términos y Condiciones que no lee nadie, y un segundo sitio donde mis datos, muestras de voz, transcripciones, lo que fuera que le diera de comer, se quedan en el disco de un desconocido. Ya tenía mal cuerpo desde que construí qwenspeak, metiendo texto por Kokoro a través de SSH porque tampoco quería una factura de una API de TTS. Aquel invento funcionaba, pero tenía forma de SSH, un solo propósito, y cero interés en hablar el formato de cable de OpenAI. Cuando aigate necesitó un backend de voz con el que su router compatible con OpenAI pudiera hablar sin que yo escribiera un adaptador a medida, nada del cajón desastre me servía. Así que construí talkies: un container, ASR de entrada, TTS de salida, cableado para hablar exactamente la forma HTTP que todo el ecosistema del SDK de OpenAI ya entiende, corriendo sobre hardware que es mío.

Autoalojar Voz Es un Pantano de Dependencias

Autoalojar modelos de voz suena sencillo hasta que de verdad intentas montar un servicio con las piezas que existen ahí fuera. Esto es con lo que te topas:

  • Cada proyecto tiene su propio formato de cable. faster-whisper quiere un script. NeMo quiere que te pelees con su sistema de configuración una tarde entera antes de dignarse a cargar un checkpoint. Kokoro es un paquete de PyPI alrededor del cual tienes que construirte tú la capa HTTP. Ninguno se pone de acuerdo en una forma de petición y respuesta, así que si ya tienes herramientas montadas sobre /v1/audio/transcriptions y /v1/audio/speech de OpenAI, te toca escribir una capa de traducción para cada uno de ellos, y luego mantenerla para siempre.
  • Solo ASR o solo TTS. Elige un proyecto de ASR autoalojado y tendrás exactamente eso, transcripción, nada más. ¿Quieres TTS también? Container nuevo, puerto nuevo, configuración nueva, modo de fallo nuevo cuando uno se cae y el otro no.
  • La memoria de la GPU no negocia. Carga dos modelos pesados en la misma tarjeta sin estrategia de desalojo y te llevas un crash por OOM a mitad de petición, o aprovisionas una tarjeta del doble de lo que necesitas para que los dos modelos puedan quedarse residentes para siempre sin hacer nada el 95% del tiempo.
  • La clonación de voz o falta, o está mal pegada. Muchos stacks de TTS autoalojado o no clonan en absoluto, o lo hacen con algún script a medida que ejecutas una vez, sin conexión, que escupe un checkpoint que luego tienes que volver a cablear a mano en la ruta de servicio.
  • CPU frente a GPU es una ocurrencia tardía. La mayoría de proyectos dan por hecho que tienes una GPU de brazos cruzados, o lo ejecutan todo en CPU y se comen cinco minutos por transcripción. Nadie entrega ambas y te dice honestamente qué modelos tienen sentido en cuál de las dos.

Nada de esto es exótico. Es el impuesto estándar de la IA autoalojada: cada componente habla su propio dialecto, y pegarlos entre sí hasta formar algo que una librería cliente existente pueda usar de verdad es el trabajo real del que no escribe nadie.

Una Sola Forma de Cable, Todos los Backends

talkies es un servicio de FastAPI (psyb0t/docker-talkies) que habla el formato de voz de OpenAI en las dos direcciones, POST /v1/audio/transcriptions para ASR, POST /v1/audio/speech para TTS, y despacha cada petición a uno de un puñado de motores de backend según un slug de model que pasas en la petición, exactamente como elegirías un nombre de modelo contra la API real de OpenAI. Apunta el SDK oficial de openai ahí, cambia el base_url, listo:

from openai import OpenAI
c = OpenAI(base_url="https://ciprian.51k.eu00/v1", api_key="x")
c.audio.transcriptions.create(model="whisper-large-v3-turbo", file=open("a.mp3", "rb"))
c.audio.speech.create(model="qwen3-tts-0.6b", voice="alloy", input="hello").stream_to_file("out.mp3")

Detrás de esa forma de cable idéntica, el registro de modelos (models.json, o models-cpu.json para la imagen de CPU) asocia cada slug a una cadena executor, y talkies/config.py valida esa cadena contra una lista blanca fija, VALID_EXECUTORS, de exactamente trece valores: whisper, parakeet, parakeet_cpp, canary_multitask, canary_salm, kokoro, kokoro_nvidia, qwen3_tts, sherpa, sherpa_offline_ctc, vosk, chatterbox, wav2vec2_phoneme. Cualquier cosa fuera de ese conjunto tumba el container al arrancar en vez de entregar un servicio medio roto. La fábrica de talkies/models/__init__.py lee el registro e instancia la clase de backend que corresponde a cada slug, y cada una implementa un protocolo de duck typing (get_model(), unload(), loaded(), last_used_secs_ago(), más transcribe() para ASR o synthesize() y voices() para TTS), así que a los handlers de rutas de server.py les da exactamente igual qué motor está haciendo el trabajo.

El models.json que se entrega registra catorce slugs de ASR (dos tamaños de Whisper vía faster-whisper, Parakeet-TDT, Nemotron-3.5-ASR vía un runtime de C++ ggml, tres variantes de Canary, cuatro variantes de streaming Sherpa-ONNX Zipformer, Vosk small English, y dos reconocedores de fonemas) y ocho slugs de TTS repartidos en cuatro familias de motores (Kokoro en dos sabores de runtime, cinco combinaciones de checkpoint y modo de Qwen3-TTS, y Chatterbox Turbo). Digan lo que digan las insignias o las listas con viñetas del README sobre la cuenta, y se contradicen entre sí en tres sitios distintos, que es justo el tipo de deriva que te sale cuando un proyecto crece rápido, el fichero de registro es el contrato de verdad, y lo conté directamente en vez de fiarme de la prosa. Veintidós slugs en total en la imagen CUDA; en la imagen de CPU, models-cpu.json tira los modelos que son inútiles sin VRAM y se queda con once slugs de ASR (los dos tamaños de Whisper, Canary-180M-Flash, Nemotron-3.5-ASR que va bien en CPU porque es un port de C++ ggml y no un modelo de PyTorch, las cuatro variantes Sherpa-ONNX Zipformer, Vosk, y los dos reconocedores de fonemas) más dos variantes de Kokoro para TTS, trece en total.

Arranque Rápido

docker run -d --name talkies 
  -v $HOME/talkies-data:/data 
  -p 8000:8000 
  psyb0t/talkies:latest
curl -s https://ciprian.51k.eu00/v1/audio/transcriptions 
  -F "file=@samples/hello.wav" 
  -F "model=whisper-large-v3-turbo" | jq

El primer arranque descarga cada modelo activado en /data/models/<slug>/ como directorio plano, sin la indirección de la caché de HuggingFace, solo snapshot_download(local_dir=...) directo a una ruta indexada por el slug. Monta /data con bind mount o te vuelves a bajar gigas en cada reinicio. Si no quieres el registro entero ocupándote disco, TALKIES_ENABLED_MODELS pone slugs en lista blanca, lo ajustas a una lista separada por comas y el bucle de precarga, más /v1/models, solo sirven lo que hay en ella. Referencia un slug desconocido y el container falla rápido al arrancar, con el catálogo completo impreso, para que arregles la errata en vez de depurar un 404 una hora después:

docker run -d --gpus all 
  -e TALKIES_ENABLED_MODELS=whisper-large-v3-turbo,qwen3-tts-1.7b-custom 
  -v $HOME/talkies-data:/data 
  -p 8000:8000 psyb0t/talkies:latest-cuda

ASR: Catorce Slugs, Una Sola Respuesta con Forma de Whisper

Cada backend de ASR, independientemente de qué esté masticando el audio por debajo, devuelve la misma respuesta con forma de Whisper, text, y para verbose_json, los arrays completos de segments y words. Cambia model=whisper-large-v3 por model=canary-1b-flash y nada aguas abajo de la respuesta HTTP tiene que saberlo ni le tiene que importar.

faster-whisper (2 slugs)

whisper-large-v3 y whisper-large-v3-turbo corren por el runtime CTranslate2 de faster-whisper, los dos capaces de CPU y GPU, los dos en la imagen de CPU.

NeMo (4 slugs)

parakeet-tdt-0.6b-v3 (decodificador TDT), canary-180m-flash y canary-1b-flash (cabezas de transformer multitarea, la variante de 1B hace traducción voz-texto EN/DE/FR/ES en los dos sentidos), y canary-qwen-2.5b, que cambia el decodificador de Canary por un LLM Qwen2, el truco del “modelo de lenguaje aumentado con voz” de NVIDIA. Canary-Qwen no tiene cabeza de alineamiento, así que es el único backend que vuelve con arrays segments y words vacíos en verbose_json, sigues teniendo la transcripción completa, simplemente no tienes marcas de tiempo por palabra para ese modelo.

parakeet.cpp (1 slug)

nemotron-3.5-asr-0.6b es la rara del grupo, una cuantización GGUF del Nemotron-3.5-ASR-Streaming-0.6B de NVIDIA servida a través de mudler/parakeet.cpp, un runtime de C++17 con ggml, cargado por ctypes desde talkies/models/parakeet_cpp.py, sin nada de NeMo de Python en la ruta caliente. Es el único modelo de ASR de la clase autorregresiva-streaming que corre decentemente en CPU pelada, razón por la cual viene en las dos imágenes mientras sus hermanos (Parakeet-TDT, Canary-1B, Canary-Qwen) son solo CUDA. Veintitrés locales fijables más detección auto, directamente del array languages de la entrada de registro, marcas de tiempo por palabra sintetizadas en segmentos agrupando por los huecos de silencio (_SEGMENT_GAP_THRESHOLD_S = 0.5 en parakeet_cpp.py).

Sherpa-ONNX más Vosk (5 slugs, y estos sí hacen streaming)

Cuatro variantes inglesas de Sherpa-ONNX Zipformer, sherpa-zipformer-en-left-64, -left-128, y las cuantizaciones int8 de ambas, más vosk-small-en-us-0.15. Están en los dos registros, CPU y CUDA, y la imagen CUDA instala un wheel de Sherpa CUDA de upstream verificado por hash, para que use de verdad el proveedor de ejecución CUDA nativo en lugar de caer calladamente a CPU dentro de una imagen de GPU.

Estos son los modelos transductores, lo que significa que están hechos para lo que el resto del lote no: el streaming en directo. Hay un WebSocket en /v1/audio/transcriptions/stream que va soltando parciales mientras hablas, y los mismos slugs sirven también un POST /v1/audio/transcriptions normal, la ruta de fichero simplemente mete audio normalizado por un stream nativo de corta vida, por debajo. Las entradas de registro llevan download_patterns, así que elegir una variante de Sherpa tira solo de sus artefactos correspondientes de tokens, encoder, decoder y joiner, en vez del repo entero.

También llegaron rotos de tres maneras concretas, y merece la pena dejarlo escrito porque cada una de ellas era silenciosa:

  • Cero marcas de tiempo de palabras. OnlineRecognizer.get_result() devuelve result.text.strip(), un str pelado. El adaptador leía tokens y timestamps de una cadena, así que todos los modelos de Sherpa devolvían "words": [] pidiera lo que pidiera quien llamaba. Ahora lee el OnlineRecognizerResult completo vía get_result_all().
  • Palabras que no eran palabras. Los tokens de transductor son trozos BPE, "QUICK" llega como ("QUI", "CK"), y cada token se emitía como palabra propia. Ahora se reagrupan por la marca de espacio inicial que señala el comienzo de una palabra. Los vocabularios a nivel de carácter y a nivel de palabra no tienen esa marca, así que esos se detectan y se dejan a un token por palabra en vez de colapsar un enunciado entero en una sola palabra.
  • La transcripción de ficheros lo duplicaba todo. La ruta por lotes abre su stream con interim_results=False. Vosk lo respetaba; Sherpa lo ignoraba. get_result es acumulativo dentro de un enunciado, así que cada parcial repetía el prefijo entero y el adaptador por lotes concatenaba cada revisión, un clip de nueve palabras volvía como "THE QUICK THE QUICK BROWN FOX … THE QUICK BROWN FOX JUMPS OVER THE LAZY DO". El streaming en directo nunca se vio afectado; ahí los parciales acumulativos son toda la gracia.

Sherpa también informa ahora de confidence por palabra, derivada de las log-probabilidades acústicas por token del modelo y promediada sobre los tokens de cada palabra, mismo nombre de campo y mismo rango 0–1 que Vosk ya emitía, así que los dos backends devuelven la misma forma de palabra.

Los ficheros largos se trocean primero, todo lo que pase de TALKIES_VAD_CHUNK_THRESHOLD (30 segundos por defecto) pasa por Silero VAD, se parte en regiones de voz topadas a TALKIES_VAD_MAX_SPEECH (28 segundos por defecto), se transcribe trozo a trozo, y se vuelve a coser en una sola línea temporal continua con marcas de tiempo corregidas por desplazamiento. Todos los backends pasan por el mismo troceador, la ventana interna de formato largo de Whisper se esquiva por completo para que el comportamiento de troceado sea idéntico entre motores, en vez de que Whisper haga lo suyo mientras NeMo hace otra cosa.

También hay diarización estéreo sin un modelo aparte de embedding de hablante pegado al lado: le das un fichero de 2 canales con diarization=true, el canal izquierdo pasa a ser el hablante L, el derecho pasa a ser R, cada uno transcrito de forma independiente y mezclados cronológicamente. Una entrada mono con diarization=true se rechaza con un 400 (NotStereoError, comprobado con el número de canales de ffprobe en talkies/audio.py), no es separación mágica de hablantes, es un truco de montaje con dos micros, y lo dice a las claras en vez de fingir otra cosa.

Dos de ellos no te dan palabras en absoluto

La v0.17.0 añadió un par de slugs de ASR que transcriben a fonos del AFI en vez de a texto, tanto en la imagen de CPU como en la CUDA. wav2vec2-xlsr-53-espeak es el wav2vec2-xlsr-53-espeak-cv-ft de facebook detrás de un ejecutor wav2vec2_phoneme, troceado por actividad de voz en cuanto un fichero pasa de TALKIES_VAD_CHUNK_THRESHOLD, y no necesitó ninguna dependencia nueva en la imagen. zipa-ipa es anyspeech/zipa-small-crctc-500k a través de un ejecutor nuevo, sherpa_offline_ctc: 71 MB en int8, y se come el fichero entero de una pasada en vez de streamearlo.

Ojo, el ejecutor nuevo es genuinamente otro, no el sherpa de streaming con una bandera girada. Misma forma de sherpa_config, reconocedor offline por debajo.

No hay ni modelo de lenguaje ni léxico detrás de ninguno de los dos, y esa es toda la gracia. Obtienes un flujo de fonos separados por espacios con marcas de tiempo por fono a través de verbose_json, srt, vtt y timestamp_granularities, y nada intenta adivinar qué palabra real querías decir. Eso es lo que quieres para puntuar pronunciación, alineamiento forzado, trabajo de acentos, o cualquier idioma con el que los modelos a nivel de palabra nunca se entrenaron. Categóricamente no es lo que quieres si lo único que necesitas es una transcripción.

TTS: Kokoro Dos Veces, Qwen3 de Cinco Formas, Chatterbox Una Vez

Cuatro familias de motores TTS, ocho slugs. kokoro-82m ejecuta el modelo Kokoro de pesos abiertos y 82M de parámetros en proceso, vía el paquete de PyPI kokoro, lo bastante rápido en CPU como para ser genuinamente útil, sin sidecar. kokoro-82m-nvidia son los mismos pesos servidos en cambio por ONNXRuntime contra el export ONNX de NVIDIA pensado para TensorRT, proveedor de ejecución CUDA en la imagen de GPU, proveedor de CPU en la imagen de CPU, G2P vía espeak-ng y phonemizer en vez de misaki. Mismo catálogo de voces, mismo formato de cable, intercambio directo entre los dos.

Las voces se escanean en vivo desde disco, talkies/models/kokoro.py filtra el pack de voces hasta catorce prefijos de nombre repartidos en seis idiomas (inglés americano y británico, español, francés, hindi, italiano, portugués) que corren sobre el G2P ligero espeak-ng que viene en la imagen base, saltándose las voces japonesas y mandarinas que necesitan los extras más pesados de misaki que no pidió nadie. Kokoro no tiene alias de voces de OpenAI, te llevas los nombres nativos al estilo af_heart / bm_george / ef_dora, descubribles vía GET /v1/audio/voices.

Los otros cinco slugs de TTS son todos Qwen3-TTS, solo CUDA, porque el wrapper faster-qwen3-tts de debajo captura grafos CUDA al cargar y no tiene absolutamente ninguna ruta de código para CPU:

  • qwen3-tts-0.6b / qwen3-tts-1.7b, modo base, clonación de voz. Suelta un .wav de referencia de 10-30 segundos en /data/custom-voices/<name>.wav, sintetiza con voice=<name>. Las rutas anidadas sobreviven (clients/acme/jane.wav pasa a ser la voz clients/acme/jane). Añade un <name>.txt hermano con la transcripción y la calidad de la clonación sube notablemente (modo de aprendizaje en contexto); sáltatelo y el backend cae a síntesis solo con x-vector, con un aviso en el log, en lugar de fallar del todo.
  • qwen3-tts-0.6b-custom / qwen3-tts-1.7b-custom, nueve hablantes preajustados fijos, sin necesidad de audio de referencia. La variante de 1.7B respeta un campo instructions como pista de emoción (“Speak angrily.”); el checkpoint de 0.6B no lo soporta en absoluto, faster-qwen3-tts anula el campo por dentro, y talkies registra un aviso en vez de comerse tu instrucción en silencio.
  • qwen3-tts-1.7b-design, sin catálogo de voces en absoluto. Describes una voz en lenguaje natural vía instructions (“a warm, friendly young female voice with a cheerful tone”) y el modelo se inventa una. Un instructions vacío es un 400, y volver a lanzar la misma descripción dos veces no te va a devolver la misma voz, el muestreo es estocástico, así es el trato.

Los controles de muestreo por petición (temperature, top_k, top_p, repetition_penalty, max_new_tokens, do_sample) viajan al lado, como campos extra de OpenAI, vía extra_body en los SDK oficiales, nada de esto necesitó un endpoint a medida, sigue siendo POST /v1/audio/speech. La salida se codifica con ffmpeg a cualquiera de mp3 / opus / aac / flac / wav / pcm que hayas pedido, esa es la lista exhaustiva sacada directamente de la tabla de formatos de talkies/tts.py, no una suposición. pcm contra un modelo Qwen3-TTS streamea bytes crudos por trozos en vez de bufferear el enunciado entero, y es el único formato de respuesta que de verdad hace streaming, todo lo demás, Kokoro incluido, sintetiza el clip completo antes de devolverlo.

Chatterbox Turbo (1 slug, y acepta indicaciones escénicas)

El tercer motor de TTS, solo CUDA: chatterbox-turbo, inglés mono a 24 kHz, por el mismo POST /v1/audio/speech que todo lo demás. Dos cosas lo hacen distinto de Kokoro y Qwen3.

Etiquetas paralingüísticas, escritas inline en el texto. No un parámetro, no un preajuste de voz, sino tokens entre corchetes puestos dentro de la cadena que estás sintetizando:

"[sigh] fine, I'll do it. [whispering] but I'm not happy about it. [laugh]"

Son tokens de verdad en el tokenizador del checkpoint, y hay exactamente 19. Cualquier otra cosa entre corchetes no es un error, simplemente se pronuncia como texto literal, que es el modo de fallo que quieres en vez de un 400 a mitad de un párrafo.

Clonación de voz sin transcripción. Apúntalo a un .wav de referencia de más de cinco segundos y clona solo a partir de eso, sin texto que lo acompañe, al contrario que la ruta de clonación de Qwen3. Las voces salen de /data/custom-voices más un hablante incorporado que viene dentro del checkpoint.

Encaja en la misma maquinaria que los otros dos: chatterbox.py implementa el protocolo común TTSBackend, así que la carga perezosa, las marcas de tiempo del barredor de inactividad y el desalojo entre hermanos se comportan exactamente igual que con Kokoro y Qwen3. Nada tratado como caso especial.

El octavo slug, y la cuarta familia, es chatterbox-turbo: el modelo expresivo solo en inglés de ResembleAI, solo CUDA, mono a 24 kHz. Es al que recurres cuando quieres emoción y sonidos no verbales, y esos van inline en el texto como etiquetas entre corchetes en vez de como parámetros aparte. La voz es o la incorporada, horneada en el checkpoint, o un clip de referencia que sueltes tú, y a diferencia de Qwen3-TTS no quiere ninguna transcripción de referencia, solo un clip de más de cinco segundos o rechaza la petición con un 400. Bufferea el enunciado entero, sin streaming de PCM, y speed se ignora.

Dos advertencias honestas. Por defecto la salida lleva una marca de agua neuronal, el PerTh de ResembleAI, no algo añadido aquí. Desde la v0.16.0 es un interruptor: TALKIES_CHATTERBOX_WATERMARK vale true por defecto, y ponerlo a false mete un passthrough en su lugar, así que el audio sale limpio (vacío o sin definir lo deja encendido, para que un valor vacío despistado no pueda quitarlo a escondidas). Y solo Chatterbox marca nada en absoluto, Kokoro y Qwen3-TTS no incrustan nada. Y chatterbox-tts más s3tokenizer se instalan con hash fijado y --no-deps desde su propio fichero de requisitos, específicamente para mantener sus pins insatisfacibles y sus herramientas de desarrollo fuera de la imagen de ejecución.

Concurrencia por Modelo, Porque Solo Un Modelo Está Residente a la Vez

talkies mantiene un modelo residente y desaloja a los demás en la admisión. Ese es el diseño, y significa que “cuántas peticiones puedo lanzar de golpe” es una pregunta sobre un único modelo, no sobre cuántos modelos caben en memoria. El coste son los pesos, cargados una vez, más un buffer de activación por petición en vuelo, razón por la cual el tope por defecto es un conservador 2.

Un solo controlador de admisión cubre ahora cada superficie de inferencia: transcripción HTTP, transcripción MCP, ASR por WebSocket, TTS bufereado y TTS en streaming. TALKIES_MODEL_MAX_CONCURRENCY pone el límite de reserva, TALKIES_MODEL_CONCURRENCY acepta sobrescrituras por slug, y una entrada de registro puede declarar su propio max_concurrency, la entrada que viene para Nemotron pone 2. Los valores malformados, duplicados, desactivados, desconocidos y fuera de rango fallan al arrancar en vez de en la primera petición que tropieza con ellos.

Puedes ver los números en vez de adivinar: GET /v1/models informa de max_concurrency, y GET /api/ps informa tanto de active_requests como de max_concurrency. Quédate sin capacidad y te llevas un 429. Intenta cambiar de modelo mientras la inferencia está corriendo de verdad y te llevas un 409, no un desalojo sorpresa a mitad de la transcripción de otro.

Gestión de Recursos, Preparación de Ficheros y MCP

Todos los backends comparten el mismo pool de VRAM y RAM, un modelo residente a la vez. Cuando entra una petición para un slug que no está cargado, lo que sea que esté cargado se desaloja primero, desalojo entre hermanos, sin importar la modalidad, así que cargar Kokoro echa a un Whisper residente y viceversa. También hay un barredor de inactividad con una cadencia de TALKIES_SWEEPER_INTERVAL (60s por defecto) que descarga cualquier cosa que lleve ociosa más de TALKIES_MODEL_TTL (600s por defecto, o sea 10 minutos; ponlo a 0 para desactivar la descarga automática). La superficie de introspección al estilo Ollama, GET /api/ps, DELETE /api/ps/{model_id}, POST /unload, te deja comprobar qué está residente y desalojarlo a mano, y todo el asunto refleja la forma de gestión de recursos de speaches lo bastante de cerca como para que el mismo código de driver al estilo LiteLLM funcione contra los dos.

La preparación de ficheros en el servidor (/v1/files) existe para que no vuelvas a subir los mismos bytes de audio en cada reintento mientras ajustas un response_format. Haces PUT del fichero una vez, y luego lo referencias por ruta relativa con el campo de formulario file_path en /v1/audio/transcriptions en vez del campo multipart file. file_path acepta también una URL http(s):// pelada, la primera vez la descarga y la cachea bajo una ruta indexada por sha256, cada llamada posterior con la misma URL es un acierto de caché, y peticiones concurrentes sobre la misma URL no la descargan dos veces. El recorrido de rutas (.., barras invertidas, bytes nulos, barras dobles) se rechaza con 400, y los symlinks que apuntan fuera de la raíz de preparación se rechazan tras resolver la ruta.

También hay un servidor MCP completo montado en /v1/mcp sobre Streamable HTTP, corriendo en el mismo proceso de FastAPI y compartiendo exactamente el mismo pool de backends y el mismo middleware de auth que las rutas HTTP, un modelo que carga una llamada a herramienta MCP es la misma instancia que ve la API HTTP. Seis herramientas en total, sacadas directamente de mcp_server.py: list_models, transcribe, list_files, put_file, get_file, delete_file. Conéctalo a Claude Code en una línea:

claude mcp add --transport http talkies https://ciprian.51k.eu00/v1/mcp

Lo que significa que un agente puede transcribir una grabación o mover audio por el directorio de preparación como llamadas a herramientas, sin que tú escribas pegamento alguno, mismo servidor, mismos modelos, mismas reglas de desalojo, solo un transporte distinto. El TTS queda deliberadamente fuera de la superficie MCP: list_models descarta todo lo que no implemente transcribe(), así que la síntesis se queda en POST /v1/audio/speech, donde ya vive la maquinaria de streaming y de formatos.

Auth, y No Fiarse de la Red por Defecto

Pon TALKIES_AUTH_TOKEN y cada ruta salvo /healthz y los preflight de CORS exige Authorization: Bearer <token>, o suelta un 401 con WWW-Authenticate: Bearer adjunto. Está implementado como middleware ASGI, no como dependencia de FastAPI, específicamente para que cubra también la subaplicación MCP montada, y la comparación del token pasa por hmac.compare_digest en vez de una igualdad de cadenas pelada, así que no hay ningún canal lateral de temporización en el que apoyarse. Deja la variable sin poner y el servidor queda abierto de par en par, que es el valor por defecto deliberado para una LAN autoalojada, ponle un proxy inverso delante si ese no es tu modelo de amenaza.

Todo lo demás está aburridamente cerrado como debería estar una imagen de producción: imágenes base fijadas por digest sha256, cada dependencia de Python bloqueada por hash vía uv.lock e instalaciones con --require-hashes para el stack pesado de ML, una barrera exclude-newer en pyproject.toml que se niega a bloquear versiones de paquetes más nuevas que el día en que se generó el lockfile (mata las tonterías de supply chain del mismo día antes de que puedan aterrizar), corre como no-root, y HF_HUB_OFFLINE=1 en régimen de crucero, una vez los pesos están cacheados el container no tiene motivo alguno para volver a tocar la red, salvo para servir tus peticiones. Las descargas de URL vía file_path tienen una guarda SSRF opcional (TALKIES_BLOCK_PRIVATE_DOWNLOADS) que rechaza nombres de host que resuelven a rangos privados, de loopback o link-local, apagada por defecto porque la mayoría de despliegues autoalojados son cajas de LAN tirando de otras cajas de LAN, pero ahí está si expones esto a algo menos fiable.

Qué Sustituyó

talkies es lo que deseaba que existiera antes de construir qwenspeak, antes de que aigate necesitara un backend de voz, antes de hartarme de dos facturas de proveedores separadas por dos mitades de la misma funcionalidad. Un container, un formato de cable, catorce slugs de ASR, ocho slugs de TTS, clonación de voz, un endpoint MCP, y nada de eso llama a casa una vez los modelos están cacheados en disco. Si ya hablas el SDK de OpenAI contra una clave de API real, apuntarlo a esto en su lugar es un cambio de base-url, no una reescritura.

El repo está en github.com/psyb0t/docker-talkies, la imagen en Docker Hub. Con licencia WTFPL, así que haz lo que te salga de los cojones con ello.

Cómo Instalarlo En Tu Agente

Las herramientas de voz son más fáciles de ponerle en la mano a un agente que de explicárselas. Todo lo que hay bajo .agents/ está catalogado en un solo marketplace, así que son dos comandos:

claude plugin marketplace add psyb0t/agents
claude plugin install talkies@psyb0t

Codex usa el mismo marketplace con un verbo distinto, codex plugin add talkies@psyb0t, porque no existe codex plugin install. También encuentra el skill por su cuenta en un checkout del repo, ya que escanea .agents/skills/ de forma nativa sin nada instalado en absoluto. Además ahora está listado en el MCP Registry oficial, así que un cliente que resuelva servidores desde ahí puede encontrarlo sin que le den una URL.