J’ai reçu la facture de l’API Whisper d’OpenAI après un mois à y avoir branché mon propre outillage, et je suis resté à fixer le nombre comme s’il avait personnellement insulté ma mère. Pas parce que c’était une somme démente, ça ne l’était pas, mais parce que je payais un loyer à la minute pour un modèle public et auto-hébergeable depuis des années, pour le privilège d’uploader mon propre audio sur le serveur de quelqu’un d’autre afin qu’il fasse tourner une inférence que je pouvais faire tourner moi-même sur un GPU que je possède déjà. Ensuite il m’a fallu du TTS par-dessus, pour le même pipeline, ce qui voulait dire un deuxième fournisseur, une deuxième clé d’API, une deuxième facture, un deuxième document de Conditions Générales que personne ne lit, et un deuxième endroit où mes données, échantillons de voix, transcriptions, tout ce que je lui donnais à manger, traînent sur le disque d’un inconnu. J’avais déjà un mauvais goût dans la bouche depuis que j’avais construit qwenspeak, à pousser du texte à travers Kokoro par SSH parce que je n’avais pas envie d’une facture d’API de TTS non plus. Ce truc marchait, mais il avait une forme de SSH, un seul usage, et zéro intérêt à parler le format de fil d’OpenAI. Quand aigate a eu besoin d’un backend vocal auquel son routeur compatible OpenAI puisse parler sans que j’écrive un adaptateur sur mesure, rien dans mon tiroir à bordel ne collait. Alors j’ai construit talkies: un container, de l’ASR en entrée, du TTS en sortie, câblé pour parler exactement la forme HTTP que tout l’écosystème du SDK OpenAI comprend déjà, tournant sur du matériel qui m’appartient.
L’Auto-Hébergement Vocal Est un Marécage de Dépendances
Auto-héberger des modèles vocaux a l’air simple jusqu’au moment où tu essaies vraiment d’assembler un service à partir des morceaux qui existent dans la nature. Voilà ce que tu te prends:
- Chaque projet a son propre format de fil. faster-whisper veut un script. NeMo veut que tu te battes avec son système de config tout un après-midi avant de daigner charger un checkpoint. Kokoro est un paquet PyPI autour duquel tu dois construire toi-même la couche HTTP. Aucun d’eux ne s’accorde sur une forme de requête et de réponse, donc si tu as déjà de l’outillage construit sur
/v1/audio/transcriptionset/v1/audio/speechd’OpenAI, tu as le plaisir d’écrire une couche de traduction pour chacun d’entre eux, puis de la maintenir jusqu’à la fin des temps. - ASR seul ou TTS seul. Choisis un projet d’ASR auto-hébergé et tu auras exactement ça, de la transcription, rien d’autre. Tu veux du TTS aussi? Nouveau container, nouveau port, nouvelle config, nouveau mode de panne quand l’un tombe et pas l’autre.
- La mémoire du GPU ne négocie pas. Charge deux modèles lourds sur la même carte sans stratégie d’éviction et tu te prends un crash OOM en plein milieu d’une requête, ou tu provisionnes une carte deux fois plus grosse que nécessaire pour que les deux modèles restent résidents à ne rien faire 95% du temps.
- Le clonage de voix est soit absent, soit mal greffé. Beaucoup de stacks TTS auto-hébergées soit ne clonent pas du tout, soit le font via un script sur mesure que tu lances une fois, hors ligne, et qui recrache un checkpoint que tu dois ensuite rebrancher à la main dans le chemin de service.
- CPU contre GPU est une arrière-pensée. La plupart des projets supposent que tu as un GPU qui dort, ou bien ils font tout tourner sur CPU et bouffent cinq minutes par transcription. Personne ne livre les deux en te disant honnêtement quels modèles ont du sens sur lequel des deux.
Rien de tout ça n’est exotique. C’est la taxe standard de l’IA auto-hébergée: chaque composant parle son propre dialecte, et les coller ensemble en quelque chose qu’une bibliothèque cliente existante puisse réellement utiliser, c’est le vrai boulot dont personne ne parle.
Une Seule Forme de Fil, Tous les Backends
talkies est un service FastAPI (psyb0t/docker-talkies) qui parle le format vocal d’OpenAI dans les deux sens, POST /v1/audio/transcriptions pour l’ASR, POST /v1/audio/speech pour le TTS, et répartit chaque requête vers l’un d’une poignée de moteurs de backend selon un slug de model que tu passes dans la requête, exactement comme tu choisirais un nom de modèle sur la vraie API d’OpenAI. Pointe le SDK officiel openai dessus, change le base_url, terminé:
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")Derrière cette forme de fil identique, le registre de modèles (models.json, ou models-cpu.json pour l’image CPU) associe chaque slug à une chaîne executor, et talkies/config.py valide cette chaîne contre une liste blanche fixe, VALID_EXECUTORS, de exactement treize valeurs: whisper, parakeet, parakeet_cpp, canary_multitask, canary_salm, kokoro, kokoro_nvidia, qwen3_tts, sherpa, sherpa_offline_ctc, vosk, chatterbox, wav2vec2_phoneme. Tout ce qui sort de cet ensemble fait planter le container au démarrage au lieu de livrer un service à moitié cassé. La fabrique dans talkies/models/__init__.py lit le registre et instancie la classe de backend correspondante par slug, chacune implémentant un protocole en duck typing (get_model(), unload(), loaded(), last_used_secs_ago(), plus transcribe() pour l’ASR ou synthesize() et voices() pour le TTS), si bien que les handlers de routes dans server.py se fichent complètement de quel moteur fait réellement le travail.
Le models.json livré enregistre quatorze slugs d’ASR (deux tailles de Whisper via faster-whisper, Parakeet-TDT, Nemotron-3.5-ASR via un runtime C++ ggml, trois variantes de Canary, quatre variantes de streaming Sherpa-ONNX Zipformer, Vosk small English, et deux reconnaisseurs de phonèmes) et huit slugs de TTS répartis sur quatre familles de moteurs (Kokoro en deux saveurs de runtime, cinq combinaisons de checkpoint et de mode chez Qwen3-TTS, et Chatterbox Turbo). Quoi que racontent les badges ou les listes à puces du README sur le compte, et ils se contredisent entre eux à trois endroits différents, ce qui est exactement le genre de dérive qu’on récolte quand un projet grossit vite, le fichier de registre est le vrai contrat, et je l’ai compté directement plutôt que de faire confiance à de la prose. Vingt-deux slugs au total sur l’image CUDA; sur l’image CPU, models-cpu.json jette les modèles inutiles sans VRAM et garde onze slugs d’ASR (les deux tailles de Whisper, Canary-180M-Flash, Nemotron-3.5-ASR qui tourne très bien sur CPU parce que c’est un portage C++ ggml et pas un modèle PyTorch, les quatre variantes Sherpa-ONNX Zipformer, Vosk, et les deux reconnaisseurs de phonèmes) plus deux variantes Kokoro de TTS, treize au total.
Démarrage Rapide
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" | jqLe premier démarrage télécharge chaque modèle activé dans /data/models/<slug>/ sous forme de répertoire plat, pas d’indirection par le cache HuggingFace, juste snapshot_download(local_dir=...) droit vers un chemin indexé par le slug. Monte /data en bind mount, sinon tu retélécharges des gigaoctets à chaque redémarrage. Si tu ne veux pas tout le registre posé sur ton disque, TALKIES_ENABLED_MODELS met des slugs en liste blanche, tu le règles sur une liste séparée par des virgules et la boucle de préchargement, plus /v1/models, ne servent que ce qui s’y trouve. Réfère un slug inconnu et le container échoue vite au démarrage, avec le catalogue complet imprimé, pour que tu corriges la faute de frappe au lieu de déboguer un 404 une heure plus tard:
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-cudaASR: Quatorze Slugs, Une Seule Réponse en Forme de Whisper
Chaque backend d’ASR, peu importe ce qui mâche réellement l’audio en dessous, renvoie la même réponse en forme de Whisper, text, et pour verbose_json, les tableaux complets segments et words. Échange model=whisper-large-v3 contre model=canary-1b-flash et rien en aval de la réponse HTTP n’a à le savoir ni à s’en soucier.
faster-whisper (2 slugs)
whisper-large-v3 et whisper-large-v3-turbo tournent via le runtime CTranslate2 de faster-whisper, tous deux capables de CPU et de GPU, tous deux dans l’image CPU.
NeMo (4 slugs)
parakeet-tdt-0.6b-v3 (décodeur TDT), canary-180m-flash et canary-1b-flash (têtes de transformer multitâches, la variante 1B fait de la traduction parole-texte EN/DE/FR/ES dans les deux sens), et canary-qwen-2.5b, qui remplace le décodeur de Canary par un LLM Qwen2, l’astuce du “modèle de langage augmenté par la parole” de NVIDIA. Canary-Qwen n’a pas de tête d’alignement, c’est donc le seul backend qui revient avec des tableaux segments et words vides en verbose_json, tu récupères quand même la transcription complète, tu n’as juste pas les horodatages par mot pour ce modèle-là.
parakeet.cpp (1 slug)
nemotron-3.5-asr-0.6b est le cas à part, une quantification GGUF du Nemotron-3.5-ASR-Streaming-0.6B de NVIDIA servie via mudler/parakeet.cpp, un runtime C++17 en ggml, chargé par ctypes depuis talkies/models/parakeet_cpp.py, sans le moindre NeMo Python sur le chemin chaud. C’est le seul modèle d’ASR de la classe autorégressive-streaming qui tourne correctement sur du CPU nu, raison pour laquelle il est livré dans les deux images alors que ses frères (Parakeet-TDT, Canary-1B, Canary-Qwen) sont réservés à CUDA. Vingt-trois locales épinglables plus la détection auto, directement depuis le tableau languages de l’entrée de registre, horodatages par mot synthétisés en segments par regroupement sur les silences (_SEGMENT_GAP_THRESHOLD_S = 0.5 dans parakeet_cpp.py).
Sherpa-ONNX plus Vosk (5 slugs, et ceux-là streament vraiment)
Quatre variantes anglaises de Sherpa-ONNX Zipformer, sherpa-zipformer-en-left-64, -left-128, et les quantifications int8 des deux, plus vosk-small-en-us-0.15. Ils sont dans les deux registres, CPU et CUDA, et l’image CUDA installe un wheel Sherpa CUDA amont vérifié par hash, pour qu’elle utilise réellement le provider d’exécution CUDA natif au lieu de retomber discrètement sur CPU à l’intérieur d’une image GPU.
Ce sont les modèles transducteurs, ce qui veut dire qu’ils sont faits pour la chose que le reste du lot ne fait pas: le streaming en direct. Il y a un WebSocket sur /v1/audio/transcriptions/stream qui sort des partiels pendant que tu parles, et les mêmes slugs servent aussi un POST /v1/audio/transcriptions ordinaire, la route fichier se contente de faire passer de l’audio normalisé dans un flux natif de courte durée, sous le capot. Les entrées de registre portent des download_patterns, donc choisir une variante Sherpa ne tire que ses artefacts correspondants de tokens, encodeur, décodeur et joiner, plutôt que le repo entier.
Ils sont aussi arrivés cassés de trois façons précises, ce qui mérite d’être écrit, parce que chacune d’elles était silencieuse:
- Aucun horodatage de mot.
OnlineRecognizer.get_result()renvoieresult.text.strip(), une simplestr. L’adaptateur lisaittokensettimestampssur une chaîne, donc chaque modèle Sherpa renvoyait"words": []quoi que demande l’appelant. Il lit maintenant leOnlineRecognizerResultcomplet viaget_result_all(). - Des mots qui n’étaient pas des mots. Les tokens de transducteur sont des morceaux BPE,
"QUICK"arrive en("QUI", "CK"), et chaque token était émis comme un mot à lui tout seul. Ils sont maintenant regroupés sur le marqueur d’espace initial qui signale un début de mot. Les vocabulaires au niveau du caractère et au niveau du mot n’ont pas ce marqueur, donc ceux-là sont détectés et laissés à un token par mot plutôt que d’écraser tout un énoncé en un seul mot. - La transcription de fichier dupliquait tout. La route batch ouvre son flux avec
interim_results=False. Vosk l’honorait; Sherpa l’ignorait.get_resultest cumulatif à l’intérieur d’un énoncé, donc chaque partiel répétait tout le préfixe et l’adaptateur batch concaténait chaque révision, un clip de neuf mots revenait en"THE QUICK THE QUICK BROWN FOX … THE QUICK BROWN FOX JUMPS OVER THE LAZY DO". Le streaming en direct n’a jamais été touché; là, les partiels cumulatifs sont tout l’intérêt.
Sherpa rapporte aussi désormais une confidence par mot, dérivée des log-probabilités acoustiques par token du modèle et moyennée sur les tokens de chaque mot, même nom de champ et même plage 0–1 que Vosk émettait déjà, si bien que les deux backends rendent la même forme de mot.
Les longs fichiers sont d’abord découpés, tout ce qui dépasse TALKIES_VAD_CHUNK_THRESHOLD (30 secondes par défaut) passe par Silero VAD, est découpé en régions de parole plafonnées à TALKIES_VAD_MAX_SPEECH (28 secondes par défaut), transcrit morceau par morceau, et recousu en une seule timeline continue avec des horodatages corrigés du décalage. Chaque backend passe par le même découpeur, la fenêtre interne long-format de Whisper est entièrement contournée pour que le comportement de découpage soit identique d’un moteur à l’autre, au lieu que Whisper fasse son truc pendant que NeMo en fait un autre.
Il y a aussi de la diarisation stéréo sans modèle d’embedding de locuteur greffé à côté: donne un fichier à 2 canaux avec diarization=true, le canal gauche devient le locuteur L, le droit devient R, chacun transcrit indépendamment puis fusionné chronologiquement. Une entrée mono avec diarization=true se fait rejeter par un 400 (NotStereoError, vérifié via le nombre de canaux d’ffprobe dans talkies/audio.py), ce n’est pas de la séparation magique de locuteurs, c’est une astuce de montage à deux micros, et il le dit franchement plutôt que de prétendre autre chose.
Deux d’entre eux ne te donnent pas de mots du tout
La v0.17.0 a ajouté une paire de slugs d’ASR qui transcrivent en phones API au lieu de texte, sur l’image CPU comme sur l’image CUDA. wav2vec2-xlsr-53-espeak est le wav2vec2-xlsr-53-espeak-cv-ft de facebook derrière un exécuteur wav2vec2_phoneme, découpé sur l’activité vocale dès qu’un fichier dépasse TALKIES_VAD_CHUNK_THRESHOLD, et il n’a demandé aucune nouvelle dépendance dans l’image. zipa-ipa, c’est anyspeech/zipa-small-crctc-500k à travers un nouvel exécuteur sherpa_offline_ctc: 71 MB en int8, et il avale le fichier entier d’une traite au lieu de le streamer.
Note que le nouvel exécuteur en est vraiment un autre, pas le sherpa de streaming avec un drapeau retourné. Même forme de sherpa_config, reconnaisseur hors ligne en dessous.
Il n’y a ni modèle de langage ni lexique derrière l’un ou l’autre, et c’est tout l’intérêt. Tu obtiens un flux de phones séparés par des espaces avec des horodatages par phone, via verbose_json, srt, vtt et timestamp_granularities, et rien n’essaie de deviner quel vrai mot tu voulais dire. C’est ce qu’il te faut pour noter la prononciation, faire de l’alignement forcé, travailler les accents, ou n’importe quelle langue sur laquelle les modèles au niveau du mot n’ont jamais été entraînés. Ce n’est catégoriquement pas ce qu’il te faut si tu veux juste une transcription.
TTS: Kokoro Deux Fois, Qwen3 de Cinq Façons, Chatterbox Une Fois
Quatre familles de moteurs TTS, huit slugs. kokoro-82m fait tourner le modèle Kokoro à poids ouverts de 82M de paramètres en processus, via le paquet PyPI kokoro, assez rapide sur CPU pour être réellement utile, sans sidecar. kokoro-82m-nvidia, ce sont les mêmes poids servis à la place par ONNXRuntime sur l’export ONNX de NVIDIA pensé pour TensorRT, provider d’exécution CUDA sur l’image GPU, provider CPU sur l’image CPU, G2P via espeak-ng et phonemizer au lieu de misaki. Même catalogue de voix, même format de fil, échange direct entre les deux.
Les voix sont scannées en direct sur le disque, talkies/models/kokoro.py filtre le pack de voix jusqu’à quatorze préfixes de noms couvrant six langues (anglais américain et britannique, espagnol, français, hindi, italien, portugais) qui tournent sur le G2P léger espeak-ng livré dans l’image de base, en sautant les voix japonaises et mandarines qui réclament les extras misaki plus lourds que personne n’a demandés. Kokoro n’a aucun alias de voix OpenAI, tu obtiens les noms natifs du style af_heart / bm_george / ef_dora, découvrables via GET /v1/audio/voices.
Les cinq autres slugs de TTS sont tous du Qwen3-TTS, réservés à CUDA parce que le wrapper faster-qwen3-tts sous-jacent capture des graphes CUDA au chargement et n’a absolument aucun chemin de code CPU:
qwen3-tts-0.6b/qwen3-tts-1.7b, mode de base, clonage de voix. Dépose un.wavde référence de 10-30 secondes dans/data/custom-voices/<name>.wav, synthétise avecvoice=<name>. Les chemins imbriqués survivent (clients/acme/jane.wavdevient la voixclients/acme/jane). Ajoute un<name>.txtvoisin avec la transcription et la qualité du clonage grimpe nettement (mode apprentissage en contexte); saute-le et le backend retombe sur une synthèse x-vector seule, avec un avertissement dans les logs, plutôt que d’échouer carrément.qwen3-tts-0.6b-custom/qwen3-tts-1.7b-custom, neuf locuteurs préréglés fixes, aucun audio de référence nécessaire. La variante 1.7B honore un champinstructionscomme indice d’émotion (“Speak angrily.”); le checkpoint 0.6B ne le supporte pas du tout,faster-qwen3-ttsannule le champ en interne, et talkies logue un avertissement plutôt que d’avaler ton instruction en silence.qwen3-tts-1.7b-design, aucun catalogue de voix du tout. Tu décris une voix en langage naturel viainstructions(“a warm, friendly young female voice with a cheerful tone”) et le modèle en invente une. Uninstructionsvide vaut 400, et relancer deux fois la même description ne te rendra pas la même voix, l’échantillonnage est stochastique, c’est le deal.
Les contrôles d’échantillonnage par requête (temperature, top_k, top_p, repetition_penalty, max_new_tokens, do_sample) voyagent à côté, en champs extra-OpenAI via extra_body sur les SDK officiels, rien de tout ça n’a nécessité un endpoint sur mesure, c’est toujours POST /v1/audio/speech. La sortie est encodée par ffmpeg dans celui de mp3 / opus / aac / flac / wav / pcm que tu as demandé, c’est la liste exhaustive tirée directement de la table de formats de talkies/tts.py, pas une supposition. pcm contre un modèle Qwen3-TTS streame des octets bruts en morceaux au lieu de tamponner tout l’énoncé, et c’est le seul format de réponse qui streame réellement, tout le reste, Kokoro compris, synthétise le clip entier avant de le renvoyer.
Chatterbox Turbo (1 slug, et il accepte les indications de mise en scène)
Le troisième moteur TTS, réservé à CUDA: chatterbox-turbo, anglais mono en 24 kHz, par le même POST /v1/audio/speech que tout le reste. Deux choses le distinguent de Kokoro et Qwen3.
Des balises paralinguistiques, écrites inline dans le texte. Pas un paramètre, pas un préréglage de voix, mais des tokens entre crochets posés dans la chaîne que tu synthétises:
"[sigh] fine, I'll do it. [whispering] but I'm not happy about it. [laugh]"Ce sont de vrais tokens dans le tokenizer du checkpoint, et il y en a exactement 19. Tout le reste entre crochets n’est pas une erreur, ça se fait juste prononcer comme du texte littéral, ce qui est le mode d’échec que tu veux plutôt qu’un 400 au milieu d’un paragraphe.
Clonage de voix sans transcription. Pointe-le sur un .wav de référence de plus de cinq secondes et il clone à partir de ça seul, sans texte correspondant, contrairement au chemin de clonage de Qwen3. Les voix viennent de /data/custom-voices plus un locuteur intégré livré dans le checkpoint.
Il s’emboîte dans la même mécanique que les deux autres: chatterbox.py implémente le protocole commun TTSBackend, donc le chargement paresseux, les horodatages du balayeur d’inactivité et l’éviction des frères se comportent exactement comme pour Kokoro et Qwen3. Rien de traité en cas particulier.
Le huitième slug, et la quatrième famille, c’est chatterbox-turbo: le modèle expressif anglais uniquement de ResembleAI, réservé à CUDA, mono en 24 kHz. C’est celui vers lequel tu te tournes quand tu veux de l’émotion et des sons non verbaux, et ceux-là se posent inline dans le texte en balises entre crochets plutôt qu’en paramètres séparés. La voix est soit celle intégrée, cuite dans le checkpoint, soit un clip de référence que tu déposes, et contrairement à Qwen3-TTS il ne veut aucune transcription de référence, juste un clip de plus de cinq secondes, sinon il rejette la requête avec un 400. Il tamponne tout l’énoncé, pas de streaming PCM, et speed est ignoré.
Deux réserves honnêtes. Par défaut la sortie porte un filigrane neuronal, le PerTh de ResembleAI, pas quelque chose d’ajouté ici. Depuis la v0.16.0 c’est un interrupteur: TALKIES_CHATTERBOX_WATERMARK vaut true par défaut, et le régler sur false met un passthrough à la place, donc l’audio sort propre (vide ou non défini le laisse actif, pour qu’une valeur vide égarée ne puisse pas le retirer en douce). Et seul Chatterbox met un filigrane, Kokoro et Qwen3-TTS n’incorporent rien. Et chatterbox-tts plus s3tokenizer s’installent avec hash épinglé et --no-deps depuis leur propre fichier de dépendances, précisément pour garder leurs pins insatisfiables et leur outillage de dev hors de l’image d’exécution.
Concurrence par Modèle, Parce Qu’un Seul Modèle Est Résident à la Fois
talkies garde un modèle résident et évince les autres à l’admission. C’est le design, et ça veut dire que “combien de requêtes je peux lancer d’un coup” est une question sur un seul modèle, pas sur combien de modèles tiennent en mémoire. Le coût, ce sont les poids, chargés une fois, plus un buffer d’activation par requête en vol, raison pour laquelle le plafond par défaut est un conservateur 2.
Un seul contrôleur d’admission couvre désormais chaque surface d’inférence: transcription HTTP, transcription MCP, ASR en WebSocket, TTS tamponné et TTS en streaming. TALKIES_MODEL_MAX_CONCURRENCY pose la limite de repli, TALKIES_MODEL_CONCURRENCY prend des surcharges par slug, et une entrée de registre peut déclarer son propre max_concurrency, l’entrée fournie pour Nemotron met 2. Les valeurs malformées, dupliquées, désactivées, inconnues et hors plage échouent au démarrage plutôt qu’à la première requête qui trébuche dessus.
Tu peux voir les chiffres plutôt que deviner: GET /v1/models rapporte max_concurrency, et GET /api/ps rapporte à la fois active_requests et max_concurrency. Sors de la capacité et tu récupères un 429. Essaie de changer de modèle pendant qu’une inférence tourne vraiment et tu récupères un 409, pas une éviction surprise au milieu de la transcription de quelqu’un d’autre.
Gestion des Ressources, Dépôt de Fichiers et MCP
Tous les backends partagent le même pool de VRAM et de RAM, un modèle résident à la fois. Quand une requête arrive pour un slug qui n’est pas chargé, tout ce qui est chargé se fait évincer d’abord, éviction entre frères, quelle que soit la modalité, donc charger Kokoro vire un Whisper résident et inversement. Il y a aussi un balayeur d’inactivité à une cadence de TALKIES_SWEEPER_INTERVAL (60s par défaut) qui décharge tout ce qui traîne inactif au-delà de TALKIES_MODEL_TTL (600s par défaut, soit 10 minutes; mets-le à 0 pour désactiver le déchargement automatique). La surface d’introspection façon Ollama, GET /api/ps, DELETE /api/ps/{model_id}, POST /unload, te laisse vérifier ce qui est résident et l’évincer à la main, et le tout reflète la forme de gestion de ressources de speaches d’assez près pour que le même code de driver façon LiteLLM marche contre les deux.
Le dépôt de fichiers côté serveur (/v1/files) existe pour que tu ne réuploades pas les mêmes octets d’audio à chaque réessai pendant que tu règles un response_format. Tu fais un PUT du fichier une fois, tu le référes ensuite par chemin relatif via le champ de formulaire file_path sur /v1/audio/transcriptions au lieu du champ multipart file. file_path accepte aussi une simple URL http(s)://, le premier appel la télécharge et la met en cache sous un chemin indexé en sha256, chaque appel suivant avec la même URL est un cache hit, et des requêtes concurrentes sur la même URL ne double-téléchargent pas. La traversée de chemin (.., antislashs, octets nuls, doubles slashs) se fait rejeter par un 400, et les symlinks qui pointent hors de la racine de dépôt sont refusés après résolution du chemin.
Il y a aussi un serveur MCP complet monté sur /v1/mcp en Streamable HTTP, tournant dans le même processus FastAPI et partageant exactement le même pool de backends et le même middleware d’auth que les routes HTTP, un modèle que charge un appel d’outil MCP est la même instance que voit l’API HTTP. Six outils au total, droit sorti de mcp_server.py: list_models, transcribe, list_files, put_file, get_file, delete_file. Branche-le dans Claude Code en une ligne:
claude mcp add --transport http talkies https://ciprian.51k.eu00/v1/mcpCe qui veut dire qu’un agent peut transcrire un enregistrement ou balader de l’audio dans le répertoire de dépôt sous forme d’appels d’outils, sans que tu écrives la moindre colle, même serveur, mêmes modèles, mêmes règles d’éviction, juste un transport différent. Le TTS est délibérément absent de la surface MCP: list_models jette tout ce qui n’implémente pas transcribe(), donc la synthèse reste sur POST /v1/audio/speech, là où vivent déjà la mécanique de streaming et de formats.
Auth, et Ne Pas Faire Confiance au Réseau par Défaut
Pose TALKIES_AUTH_TOKEN et chaque route sauf /healthz et les préflights CORS exige Authorization: Bearer <token>, sinon elle rend un 401 avec WWW-Authenticate: Bearer attaché. C’est implémenté en middleware ASGI, pas en dépendance FastAPI, précisément pour que ça couvre aussi la sous-application MCP montée, et la comparaison du token passe par hmac.compare_digest plutôt qu’une simple égalité de chaînes, donc il n’y a aucun canal auxiliaire temporel sur lequel s’appuyer. Laisse la variable vide et le serveur est grand ouvert, ce qui est le défaut délibéré pour un LAN auto-hébergé, colle un reverse proxy devant si ce n’est pas ton modèle de menace.
Tout le reste est verrouillé de façon ennuyeuse, comme une image de production devrait l’être: images de base épinglées par digest sha256, chaque dépendance Python verrouillée par hash via uv.lock et des installs en --require-hashes pour la grosse stack ML, une barrière exclude-newer dans pyproject.toml qui refuse de verrouiller des versions de paquets plus récentes que le jour où le lockfile a été généré (ça tue les conneries de supply chain du jour même avant qu’elles atterrissent), tourne en non-root, et HF_HUB_OFFLINE=1 en régime de croisière, une fois les poids en cache le container n’a plus aucune raison de toucher au réseau, sauf pour servir tes requêtes. Les téléchargements d’URL via file_path ont un garde-fou SSRF optionnel (TALKIES_BLOCK_PRIVATE_DOWNLOADS) qui refuse les noms d’hôtes résolvant vers des plages privées, de loopback ou link-local, désactivé par défaut parce que la plupart des déploiements auto-hébergés sont des boîtes de LAN qui vont chercher chez d’autres boîtes de LAN, mais présent si tu exposes ça à quelque chose de moins fiable.
Ce Que Ça a Remplacé
talkies, c’est le truc dont j’aurais voulu qu’il existe avant de construire qwenspeak, avant qu’aigate ait besoin d’un backend vocal, avant que j’en aie marre de deux factures de fournisseurs séparées pour deux moitiés de la même fonctionnalité. Un container, un format de fil, quatorze slugs d’ASR, huit slugs de TTS, du clonage de voix, un endpoint MCP, et rien de tout ça ne téléphone à la maison une fois les modèles en cache sur le disque. Si tu parles déjà le SDK d’OpenAI contre une vraie clé d’API, le pointer là-dessus à la place est un changement de base-url, pas une réécriture.
Le repo est sur github.com/psyb0t/docker-talkies, l’image sur Docker Hub. Sous licence WTFPL, alors fais-en ce que tu veux, putain.
L’Installer Dans Ton Agent
De l’outillage vocal, c’est plus facile à confier à un agent qu’à lui expliquer. Tout ce qui est sous .agents/ est catalogué dans un seul marketplace, donc ça fait deux commandes:
claude plugin marketplace add psyb0t/agents
claude plugin install talkies@psyb0tCodex utilise le même marketplace avec un verbe différent, codex plugin add talkies@psyb0t, parce qu’il n’existe pas de codex plugin install. Il trouve aussi le skill tout seul dans un checkout du repo, puisqu’il scanne .agents/skills/ nativement sans que rien ne soit installé. Il est aussi listé sur le MCP Registry officiel maintenant, donc un client qui résout ses serveurs depuis là peut le trouver sans qu’on lui donne une URL.