talkies: Zeg Tegen de Speech API van OpenAI Dat Hij De Pot Op Kan

Ik kreeg de rekening van de Whisper-API van OpenAI na een maand waarin ik er mijn eigen gereedschap aan had gehangen, en zat naar het getal te staren alsof het persoonlijk mijn moeder had beledigd. Niet omdat het een krankzinnig bedrag was, dat was het niet, maar omdat ik huur per minuut betaalde voor een model dat al jaren openbaar en zelf te hosten is, voor het voorrecht mijn eigen audio naar andermans server te uploaden zodat die een inferentie kon draaien die ik zelf kon draaien op een GPU die ik al heb. Daarna had ik er ook nog TTS bij nodig voor dezelfde pipeline, wat een tweede leverancier betekende, een tweede API-sleutel, een tweede rekening, een tweede document met voorwaarden dat niemand leest, en een tweede plek waar mijn data, stemfragmenten, transcripties, wat ik er ook in stopte, op de schijf van een vreemde blijft staan. Ik had sowieso al een vieze smaak in mijn mond sinds ik qwenspeak had gebouwd, tekst door Kokoro duwen over SSH omdat ik ook geen zin had in een rekening van een TTS-API. Dat ding werkte, maar het had de vorm van SSH, één doel, en nul interesse om het draadformaat van OpenAI te spreken. Toen aigate een spraakbackend nodig had waar zijn OpenAI-compatibele router mee kon praten zonder dat ik een adapter op maat schreef, paste er niets uit mijn rommella. Dus bouwde ik talkies: één container, ASR erin, TTS eruit, bedraad om precies de HTTP-vorm te spreken die het hele OpenAI-SDK-ecosysteem al begrijpt, draaiend op hardware die van mij is.

Spraak Zelf Hosten Is een Moeras van Dependencies

Spraakmodellen zelf hosten klinkt simpel tot je echt probeert een dienst in elkaar te zetten uit de onderdelen die in het wild bestaan. Dit is waar je tegenaan loopt:

  • Elk project heeft zijn eigen draadformaat. faster-whisper wil een script. NeMo wil dat je een middag lang met zijn configuratiesysteem vecht voordat het zich verwaardigt een checkpoint te laden. Kokoro is een PyPI-pakket waar je zelf een HTTP-laag omheen moet bouwen. Geen van alle is het eens over een vorm van verzoek en antwoord, dus als je al gereedschap hebt gebouwd op /v1/audio/transcriptions en /v1/audio/speech van OpenAI, mag je voor elk van hen een vertaallaag schrijven, en die daarna eeuwig onderhouden.
  • Alleen ASR of alleen TTS. Kies een zelfgehost ASR-project en je krijgt precies dat, transcriptie, verder niets. Wil je ook TTS? Nieuwe container, nieuwe poort, nieuwe configuratie, nieuwe faalmodus als de een omvalt en de ander niet.
  • GPU-geheugen onderhandelt niet. Laad twee zware modellen op dezelfde kaart zonder uitzettingsstrategie en je krijgt midden in een verzoek een OOM-crash, of je schaft een kaart aan die twee keer zo groot is als je nodig hebt zodat beide modellen eeuwig resident kunnen blijven en 95% van de tijd niets doen.
  • Stemklonen ontbreekt of is er slecht op geplakt. Veel zelfgehoste TTS-stacks klonen helemaal niet, of doen het via een script op maat dat je één keer offline draait en dat een checkpoint uitspuugt dat je daarna met de hand terug in het serveerpad moet bedraden.
  • CPU tegenover GPU is een bijgedachte. De meeste projecten gaan ervan uit dat je een GPU hebt die duimen zit te draaien, of ze draaien alles op de CPU en vreten vijf minuten per transcriptie. Niemand levert allebei en vertelt je eerlijk welke modellen op welk van beide zinnig zijn.

Niets hiervan is exotisch. Het is de standaardbelasting op zelfgehoste AI: elk onderdeel spreekt zijn eigen dialect, en ze aan elkaar lijmen tot iets wat een bestaande clientbibliotheek echt kan gebruiken is het echte werk waar niemand over schrijft.

Eén Draadvorm, Alle Backends

talkies is een FastAPI-dienst (psyb0t/docker-talkies) die het spraakdraadformaat van OpenAI in beide richtingen spreekt, POST /v1/audio/transcriptions voor ASR, POST /v1/audio/speech voor TTS, en elk verzoek doorstuurt naar een van een handvol backend-engines op basis van een model-slug die je in het verzoek meegeeft, precies zoals je tegen de echte OpenAI-API een modelnaam zou kiezen. Wijs de officiële openai-SDK erop, verander de base_url, klaar:

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")

Achter die identieke draadvorm koppelt de modelregistry (models.json, of models-cpu.json voor de CPU-image) elke slug aan een executor-string, en talkies/config.py valideert die string tegen een vaste toelatingslijst, VALID_EXECUTORS, van precies dertien waarden: whisper, parakeet, parakeet_cpp, canary_multitask, canary_salm, kokoro, kokoro_nvidia, qwen3_tts, sherpa, sherpa_offline_ctc, vosk, chatterbox, wav2vec2_phoneme. Alles buiten die verzameling laat de container bij het opstarten falen in plaats van een half kapotte dienst uit te leveren. De fabriek in talkies/models/__init__.py leest de registry en instantieert per slug de bijpassende backendklasse, en elk daarvan implementeert een duck-typed protocol (get_model(), unload(), loaded(), last_used_secs_ago(), plus transcribe() voor ASR of synthesize() en voices() voor TTS), zodat het de routehandlers in server.py nooit uitmaakt welke engine het werk werkelijk doet.

De meegeleverde models.json registreert veertien ASR-slugs (twee Whisper-formaten via faster-whisper, Parakeet-TDT, Nemotron-3.5-ASR via een C++ ggml-runtime, drie Canary-varianten, vier Sherpa-ONNX Zipformer streamingvarianten, Vosk small English, en twee foneemherkenners) en acht TTS-slugs verdeeld over vier enginefamilies (Kokoro in twee runtimesmaken, vijf combinaties van checkpoint en modus bij Qwen3-TTS, en Chatterbox Turbo). Wat de badges of opsommingen in de README ook zeggen over het aantal, en ze spreken elkaar op drie verschillende plekken tegen, wat precies het soort afdrijving is dat je krijgt als een project snel groeit, het registrybestand is het echte contract, en ik heb het rechtstreeks geteld in plaats van op proza te vertrouwen. Tweeëntwintig slugs in totaal op de CUDA-image; op de CPU-image gooit models-cpu.json de modellen weg die zonder VRAM nutteloos zijn en houdt elf ASR-slugs over (beide Whisper-formaten, Canary-180M-Flash, Nemotron-3.5-ASR dat prima op de CPU draait omdat het een C++ ggml-port is en geen PyTorch-model, de vier Sherpa-ONNX Zipformer-varianten, Vosk, en beide foneemherkenners) plus twee Kokoro-TTS-varianten, dertien in totaal.

Snelle Start

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

De eerste start downloadt elk ingeschakeld model naar /data/models/<slug>/ als platte map, geen omweg via de HuggingFace-cache, gewoon snapshot_download(local_dir=...) rechtstreeks naar een pad dat op de slug is genoemd. Mount /data met een bind mount, anders download je bij elke herstart gigabytes opnieuw. Wil je niet de hele registry op je schijf hebben staan, dan zet TALKIES_ENABLED_MODELS slugs op een toelatingslijst, je geeft er een kommagescheiden lijst aan en de voorlaadlus, plus /v1/models, bedienen alleen nog wat daarin staat. Verwijs naar een onbekende slug en de container faalt meteen bij het opstarten, met de volledige catalogus afgedrukt, zodat je de typefout herstelt in plaats van een uur later een 404 te debuggen:

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: Veertien Slugs, Eén Antwoord in Whisper-vorm

Elk ASR-backend geeft, ongeacht wat er onderwater op de audio zit te kauwen, hetzelfde antwoord in Whisper-vorm terug, text, en voor verbose_json volledige segments– en words-arrays. Ruil model=whisper-large-v3 in voor model=canary-1b-flash en niets stroomafwaarts van het HTTP-antwoord hoeft het te weten of er iets om te geven.

faster-whisper (2 slugs)

whisper-large-v3 en whisper-large-v3-turbo draaien via de CTranslate2-runtime van faster-whisper, allebei geschikt voor CPU en GPU, allebei in de CPU-image.

NeMo (4 slugs)

parakeet-tdt-0.6b-v3 (TDT-decoder), canary-180m-flash en canary-1b-flash (multitask-transformerkoppen, de 1B-variant doet spraak-naar-tekstvertaling EN/DE/FR/ES in beide richtingen), en canary-qwen-2.5b, dat de decoder van Canary vervangt door een Qwen2-LLM, de truc van NVIDIA met het “spraakverrijkte taalmodel”. Canary-Qwen heeft geen alignment-kop, dus het is het enige backend dat in verbose_json met lege segments– en words-arrays terugkomt, je krijgt nog steeds de volledige transcriptie, alleen geen tijdstempels per woord voor dat ene model.

parakeet.cpp (1 slug)

nemotron-3.5-asr-0.6b is het buitenbeentje, een GGUF-kwantisatie van NVIDIA’s Nemotron-3.5-ASR-Streaming-0.6B, geserveerd via mudler/parakeet.cpp, een C++17-ggml-runtime, geladen via ctypes vanuit talkies/models/parakeet_cpp.py, zonder enige Python-NeMo op het hete pad. Het is het enige ASR-model uit de autoregressieve streamingklasse dat fatsoenlijk op kale CPU draait, en daarom zit het in beide images terwijl zijn broertjes (Parakeet-TDT, Canary-1B, Canary-Qwen) alleen CUDA zijn. Drieëntwintig vast te zetten locales plus auto-detectie, rechtstreeks uit het languages-array van de registry-invoer, tijdstempels per woord die tot segmenten worden samengevoegd door te groeperen op stiltegaten (_SEGMENT_GAP_THRESHOLD_S = 0.5 in parakeet_cpp.py).

Sherpa-ONNX plus Vosk (5 slugs, en deze streamen echt)

Vier Engelse Sherpa-ONNX Zipformer-varianten, sherpa-zipformer-en-left-64, -left-128, en de int8-kwantisaties van allebei, plus vosk-small-en-us-0.15. Ze zitten in beide registries, CPU en CUDA, en de CUDA-image installeert een hash-geverifieerde upstream Sherpa CUDA-wheel, zodat hij echt de native CUDA-executionprovider gebruikt in plaats van binnen een GPU-image stilletjes terug te vallen op de CPU.

Dit zijn de transducermodellen, wat betekent dat ze gebouwd zijn voor precies het ding waar de rest van de rij niet voor gebouwd is: live streamen. Er is een WebSocket op /v1/audio/transcriptions/stream die tussentijdse resultaten uitspuugt terwijl je praat, en dezelfde slugs bedienen ook een gewone POST /v1/audio/transcriptions, de bestandsroute duwt genormaliseerde audio onder de motorkap gewoon door een kortlevende native stream. Registry-invoeren dragen download_patterns, dus een Sherpa-variant kiezen trekt alleen de bijbehorende tokens-, encoder-, decoder- en joiner-artefacten binnen in plaats van de hele repo.

Ze kwamen ook op drie specifieke manieren kapot binnen, en dat is het opschrijven waard omdat elk ervan stil was:

  • Helemaal geen woordtijdstempels. OnlineRecognizer.get_result() geeft result.text.strip() terug, een kale str. De adapter las tokens en timestamps van een string, dus elk Sherpa-model gaf "words": [] terug, wat de aanroeper ook vroeg. Hij leest nu het volledige OnlineRecognizerResult via get_result_all().
  • Woorden die geen woorden waren. Transducertokens zijn BPE-stukjes, "QUICK" komt binnen als ("QUI", "CK"), en elk token werd als eigen woord uitgegeven. Ze worden nu weer gegroepeerd op de voorafgaande spatiemarkering die een woordbegin aangeeft. Vocabulaires op teken- en op woordniveau hebben die markering niet, dus die worden herkend en op één token per woord gelaten in plaats van een hele uiting tot één woord in te klappen.
  • Bestandstranscriptie verdubbelde alles. De batchroute opent zijn stream met interim_results=False. Vosk hield zich eraan; Sherpa negeerde het. get_result is cumulatief binnen een uiting, dus elk tussenresultaat herhaalde het hele voorvoegsel en de batchadapter plakte elke herziening aan elkaar, een clip van negen woorden kwam terug als "THE QUICK THE QUICK BROWN FOX … THE QUICK BROWN FOX JUMPS OVER THE LAZY DO". Live streamen is nooit geraakt; daar zijn cumulatieve tussenresultaten juist het hele punt.

Sherpa rapporteert nu ook confidence per woord, afgeleid uit de akoestische log-waarschijnlijkheden per token van het model en gemiddeld over de tokens van elk woord, dezelfde veldnaam en hetzelfde bereik 0–1 dat Vosk al uitgaf, zodat beide backends dezelfde woordvorm teruggeven.

Lange bestanden worden eerst in plakken gesneden, alles boven TALKIES_VAD_CHUNK_THRESHOLD (standaard 30 seconden) gaat door Silero VAD, wordt opgedeeld in spraakregio’s met een plafond van TALKIES_VAD_MAX_SPEECH (standaard 28 seconden), per stuk getranscribeerd, en weer aan elkaar genaaid tot één doorlopende tijdlijn met voor de offset gecorrigeerde tijdstempels. Elk backend gaat door dezelfde snijder, het eigen interne langevormvenster van Whisper wordt volledig omzeild zodat het snijgedrag identiek is tussen engines, in plaats van dat Whisper zijn eigen ding doet terwijl NeMo iets anders doet.

Er is ook stereodiarisatie zonder een apart sprekerembeddingmodel dat ernaast is geschroefd: geef een bestand met 2 kanalen mee met diarization=true, het linkerkanaal wordt spreker L, het rechter wordt R, elk apart getranscribeerd en chronologisch samengevoegd. Mono-invoer met diarization=true wordt geweigerd met een 400 (NotStereoError, gecontroleerd via het kanaalaantal van ffprobe in talkies/audio.py), het is geen magische sprekerscheiding, het is een truc met twee microfoons, en dat zegt het ronduit in plaats van iets anders voor te wenden.

Twee ervan geven je helemaal geen woorden

v0.17.0 voegde een paar ASR-slugs toe die naar IPA-klanken transcriberen in plaats van naar tekst, op zowel de CPU- als de CUDA-image. wav2vec2-xlsr-53-espeak is facebooks wav2vec2-xlsr-53-espeak-cv-ft achter een wav2vec2_phoneme-executor, gesneden op spraakactiviteit zodra een bestand voorbij TALKIES_VAD_CHUNK_THRESHOLD gaat, en het had geen enkele nieuwe dependency in de image nodig. zipa-ipa is anyspeech/zipa-small-crctc-500k via een nieuwe sherpa_offline_ctc-executor: 71 MB op int8, en het vreet het hele bestand in één keer op in plaats van het te streamen.

Let op, die nieuwe executor is echt een andere, niet de streaming-sherpa met een vlag omgezet. Dezelfde sherpa_config-vorm, offline herkenner eronder.

Er zit achter geen van beide een taalmodel of een lexicon, en dat is het hele punt. Je krijgt een door spaties gescheiden stroom klanken met tijdstempels per klank via verbose_json, srt, vtt en timestamp_granularities, en niets probeert te raden welk echt woord je bedoelde. Dat is wat je wilt voor uitspraakbeoordeling, geforceerde uitlijning, accentwerk, of welke taal dan ook waar de modellen op woordniveau nooit op zijn getraind. Het is uitdrukkelijk niet wat je wilt als je gewoon een transcriptie nodig hebt.

TTS: Kokoro Twee Keer, Qwen3 op Vijf Manieren, Chatterbox Eén Keer

Vier TTS-enginefamilies, acht slugs. kokoro-82m draait het Kokoro-model met open gewichten en 82M parameters in-process via het PyPI-pakket kokoro, op de CPU snel genoeg om echt bruikbaar te zijn, zonder sidecar. kokoro-82m-nvidia zijn dezelfde gewichten, maar dan geserveerd via ONNXRuntime tegen NVIDIA’s TensorRT-vriendelijke ONNX-export, CUDA-executionprovider op de GPU-image, CPU-provider op de CPU-image, G2P via espeak-ng en phonemizer in plaats van misaki. Dezelfde stemcatalogus, hetzelfde draadformaat, rechtstreeks uitwisselbaar.

Stemmen worden live van schijf gescand, talkies/models/kokoro.py filtert het stemmenpakket terug tot veertien naamprefixen verdeeld over zes talen (Amerikaans en Brits Engels, Spaans, Frans, Hindi, Italiaans, Portugees) die draaien op de lichte espeak-ng-G2P die in de basisimage zit, en slaat de Japanse en Mandarijnse stemmen over die de zwaardere misaki-extra’s nodig hebben waar niemand om heeft gevraagd. Kokoro heeft geen OpenAI-stemaliassen, je krijgt de eigen namen in de stijl af_heart / bm_george / ef_dora, te ontdekken via GET /v1/audio/voices.

De andere vijf TTS-slugs zijn allemaal Qwen3-TTS, alleen CUDA, omdat de onderliggende faster-qwen3-tts-wrapper bij het laden CUDA-grafen vastlegt en helemaal geen CPU-codepad heeft:

  • qwen3-tts-0.6b / qwen3-tts-1.7b, basismodus, stemklonen. Zet een referentie-.wav van 10-30 seconden in /data/custom-voices/<name>.wav, synthetiseer met voice=<name>. Geneste paden blijven werken (clients/acme/jane.wav wordt de stem clients/acme/jane). Zet er een <name>.txt met de transcriptie naast en de kloonkwaliteit gaat merkbaar omhoog (in-context-leermodus); sla het over en het backend valt terug op synthese met alleen x-vector, met een gelogde waarschuwing, in plaats van helemaal te falen.
  • qwen3-tts-0.6b-custom / qwen3-tts-1.7b-custom, negen vaste vooringestelde sprekers, geen referentieaudio nodig. De 1.7B-variant respecteert een instructions-veld als emotieaanwijzing (“Speak angrily.”); het 0.6B-checkpoint ondersteunt het helemaal niet, faster-qwen3-tts maakt het veld intern leeg, en talkies logt een waarschuwing in plaats van je instructie stilletjes op te eten.
  • qwen3-tts-1.7b-design, helemaal geen stemcatalogus. Je beschrijft een stem in natuurlijke taal via instructions (“a warm, friendly young female voice with a cheerful tone”) en het model verzint er een. Een lege instructions is een 400, en dezelfde beschrijving twee keer draaien geeft je niet dezelfde stem terug, het samplen is stochastisch, zo is de deal.

Sampling-instellingen per verzoek (temperature, top_k, top_p, repetition_penalty, max_new_tokens, do_sample) liften mee als OpenAI-extravelden via extra_body op de officiële SDK’s, niets hiervan had een eigen endpoint nodig, het is nog steeds POST /v1/audio/speech. De uitvoer wordt via ffmpeg gecodeerd naar welke van mp3 / opus / aac / flac / wav / pcm je ook hebt gevraagd, dat is de volledige lijst rechtstreeks uit de formattabel van talkies/tts.py, geen gok. pcm tegen een Qwen3-TTS-model streamt rauwe bytes in brokken in plaats van de hele uiting te bufferen, en het is het enige antwoordformaat dat werkelijk streamt, al het andere, Kokoro inbegrepen, synthetiseert de complete clip voordat het hem teruggeeft.

Chatterbox Turbo (1 slug, en hij neemt regieaanwijzingen aan)

De derde TTS-engine, alleen CUDA: chatterbox-turbo, Engels mono op 24 kHz, via dezelfde POST /v1/audio/speech als al het andere. Twee dingen maken hem anders dan Kokoro en Qwen3.

Paralinguïstische tags, inline in de tekst geschreven. Geen parameter, geen steminstelling, maar tokens tussen blokhaken die gewoon in de string staan die je synthetiseert:

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

Het zijn echte tokens in de tokenizer van het checkpoint, en er zijn er precies 19. Al het andere tussen blokhaken is geen fout, het wordt gewoon als letterlijke tekst uitgesproken, en dat is de faalmodus die je wilt in plaats van een 400 halverwege een alinea.

Stemklonen zonder transcriptie. Wijs hem naar een referentie-.wav van meer dan vijf seconden en hij kloont alleen daaruit, zonder bijpassende tekst, anders dan het kloonpad van Qwen3. De stemmen komen uit /data/custom-voices plus één ingebouwde spreker die in het checkpoint meekomt.

Hij past in dezelfde machinerie als de andere twee: chatterbox.py implementeert het gedeelde TTSBackend-protocol, dus lui laden, de tijdstempels van de inactiviteitsveger en het uitzetten van broertjes gedragen zich precies zoals bij Kokoro en Qwen3. Niets als speciaal geval behandeld.

De achtste slug, en de vierde familie, is chatterbox-turbo: het expressieve, alleen Engelse model van ResembleAI, alleen CUDA, mono op 24 kHz. Dat is degene waar je naar grijpt als je emotie en non-verbale klanken wilt, en die gaan inline in de tekst als tags tussen blokhaken in plaats van als aparte parameters. De stem is ofwel de ingebouwde, in het checkpoint gebakken, ofwel een referentieclip die jij erin zet, en anders dan Qwen3-TTS wil hij helemaal geen referentietranscriptie, alleen een clip van meer dan vijf seconden, anders weigert hij het verzoek met een 400. Hij buffert de hele uiting, geen PCM-streaming, en speed wordt genegeerd.

Twee eerlijke kanttekeningen. Standaard draagt de uitvoer een neuraal watermerk, de PerTh van ResembleAI, niet iets wat hier is toegevoegd. Sinds v0.16.0 is het een schakelaar: TALKIES_CHATTERBOX_WATERMARK staat standaard op true, en hem op false zetten schuift er een passthrough voor in de plaats, zodat de audio schoon naar buiten komt (leeg of niet gezet houdt hem aan, zodat een verdwaalde lege waarde hem niet stiekem kan weghalen). En alleen Chatterbox zet überhaupt een watermerk, Kokoro en Qwen3-TTS bedden niets in. En chatterbox-tts plus s3tokenizer installeren zich met vastgezette hash en --no-deps vanuit hun eigen requirementsbestand, specifiek om hun onvervulbare pins en hun ontwikkelgereedschap buiten de runtime-image te houden.

Gelijktijdigheid per Model, Omdat Er Maar Eén Model Tegelijk Resident Is

talkies houdt één model resident en zet de andere eruit bij toelating. Dat is het ontwerp, en het betekent dat “hoeveel verzoeken kan ik tegelijk afvuren” een vraag is over één enkel model, niet over hoeveel modellen er in het geheugen passen. De kosten zijn de gewichten, één keer geladen, plus één activatiebuffer per lopend verzoek, en daarom staat het plafond standaard op een conservatieve 2.

Eén toelatingscontroller dekt nu elk inferentieoppervlak: HTTP-transcriptie, MCP-transcriptie, WebSocket-ASR, gebufferde TTS en streaming-TTS. TALKIES_MODEL_MAX_CONCURRENCY zet de terugvallimiet, TALKIES_MODEL_CONCURRENCY neemt overschrijvingen per slug, en een registry-invoer kan zijn eigen max_concurrency opgeven, de meegeleverde Nemotron-invoer zet 2. Misvormde, dubbele, uitgeschakelde, onbekende en buiten bereik liggende waarden falen bij het opstarten in plaats van bij het eerste verzoek dat erover struikelt.

Je kunt de getallen zien in plaats van gokken: GET /v1/models rapporteert max_concurrency, en GET /api/ps rapporteert zowel active_requests als max_concurrency. Raak je door je capaciteit heen, dan krijg je een 429. Probeer van model te wisselen terwijl er echt inferentie draait en je krijgt een 409, geen verrassingsuitzetting halverwege iemand anders zijn transcriptie.

Resourcebeheer, Bestanden Klaarzetten en MCP

Alle backends delen dezelfde pool van VRAM en RAM, één model resident tegelijk. Komt er een verzoek binnen voor een slug die op dat moment niet geladen is, dan wordt eerst alles wat wel geladen is eruit gezet, uitzetting tussen broertjes, ongeacht de modaliteit, dus Kokoro laden schopt een residente Whisper eruit en andersom. Er is ook een inactiviteitsveger op een cadans van TALKIES_SWEEPER_INTERVAL (standaard 60s) die alles uitlaadt dat langer dan TALKIES_MODEL_TTL inactief is geweest (standaard 600s, oftewel 10 minuten; zet hem op 0 om automatisch uitladen uit te schakelen). Het introspectieoppervlak in Ollama-stijl, GET /api/ps, DELETE /api/ps/{model_id}, POST /unload, laat je controleren wat er resident is en het met de hand eruit zetten, en het geheel spiegelt de vorm van resourcebeheer van speaches dicht genoeg dat dezelfde driver-code in LiteLLM-stijl tegen allebei werkt.

Het klaarzetten van bestanden aan de serverkant (/v1/files) bestaat zodat je niet bij elke poging dezelfde audiobytes opnieuw uploadt terwijl je aan een response_format zit te draaien. Je doet één keer een PUT op het bestand en verwijst er daarna via een relatief pad naar met het formulierveld file_path op /v1/audio/transcriptions in plaats van het multipart-veld file. file_path accepteert ook een kale http(s)://-URL, de eerste keer wordt hij gedownload en gecachet onder een pad met sha256 als sleutel, elke volgende aanroep met dezelfde URL is een cachetreffer, en gelijktijdige verzoeken om dezelfde URL halen hem niet dubbel op. Padtraversal (.., backslashes, nulbytes, dubbele slashes) wordt geweigerd met 400, en symlinks die buiten de klaarzetwortel wijzen worden na padresolutie geweigerd.

Er is ook een volledige MCP-server gemount op /v1/mcp over Streamable HTTP, draaiend in hetzelfde FastAPI-proces en met exact dezelfde backendpool en dezelfde auth-middleware als de HTTP-routes, een model dat een MCP-toolaanroep laadt is dezelfde instantie die de HTTP-API ziet. Zes tools in totaal, rechtstreeks uit mcp_server.py: list_models, transcribe, list_files, put_file, get_file, delete_file. Hang het in één regel aan Claude Code:

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

Wat betekent dat een agent een opname kan transcriberen of audio door de klaarzetmap kan schuiven als toolaanroepen, zonder dat jij ook maar enige lijm schrijft, dezelfde server, dezelfde modellen, dezelfde uitzetregels, alleen een ander transport. TTS staat met opzet niet op het MCP-oppervlak: list_models gooit alles weg dat geen transcribe() implementeert, dus de synthese blijft op POST /v1/audio/speech, waar de streaming- en formaatmachinerie al woont.

Auth, en het Netwerk Standaard Niet Vertrouwen

Zet TALKIES_AUTH_TOKEN en elke route behalve /healthz en CORS-preflights eist Authorization: Bearer <token>, of geeft een 401 met WWW-Authenticate: Bearer eraan vast. Het is geïmplementeerd als ASGI-middleware, niet als FastAPI-dependency, juist zodat het ook de gemounte MCP-subapplicatie dekt, en de tokenvergelijking loopt via hmac.compare_digest in plaats van een kale stringvergelijking, dus er is geen timingzijkanaal om op te leunen. Laat de variabele ongezet en de server staat wagenwijd open, wat de bewuste standaard is voor een zelfgehost LAN, zet er een reverse proxy voor als dat niet jouw dreigingsmodel is.

Al het andere zit saai dichtgetimmerd zoals een productie-image hoort: basisimages vastgezet op sha256-digest, elke Python-dependency op hash vergrendeld via uv.lock en installaties met --require-hashes voor de zware ML-stack, een exclude-newer-poort in pyproject.toml die weigert pakketversies te vergrendelen die nieuwer zijn dan de dag waarop het lockbestand is gemaakt (dat doodt supply-chain-onzin van dezelfde dag voordat die kan landen), draait als non-root, en HF_HUB_OFFLINE=1 in kruissnelheid, zodra de gewichten gecachet zijn heeft de container geen enkele reden meer om ooit nog het netwerk aan te raken, behalve om jouw verzoeken te bedienen. URL-downloads via file_path krijgen een optionele SSRF-bewaker (TALKIES_BLOCK_PRIVATE_DOWNLOADS) die hostnamen weigert die naar privé-, loopback- of link-localbereiken oplossen, standaard uit omdat de meeste zelfgehoste opstellingen LAN-kasten zijn die van andere LAN-kasten halen, maar hij is er als je dit aan iets minder vertrouwds blootstelt.

Wat Het Verving

talkies is het ding waarvan ik wilde dat het bestond voordat ik qwenspeak bouwde, voordat aigate een spraakbackend nodig had, voordat ik genoeg kreeg van twee aparte leveranciersrekeningen voor twee helften van dezelfde functie. Eén container, één draadformaat, veertien ASR-slugs, acht TTS-slugs, stemklonen, een MCP-endpoint, en niets daarvan belt naar huis zodra de modellen op schijf gecachet staan. Spreek je al de SDK van OpenAI tegen een echte API-sleutel, dan is hem hierheen wijzen een wijziging van de base-url, geen herschrijving.

De repo staat op github.com/psyb0t/docker-talkies, de image op Docker Hub. Onder WTFPL-licentie, dus doe ermee wat je verdomme wilt.

Zo Zet Je Het In Je Agent

Spraakgereedschap is makkelijker aan een agent te geven dan aan een agent uit te leggen. Alles onder .agents/ staat in één marketplace gecatalogiseerd, dus het zijn twee commando’s:

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

Codex gebruikt dezelfde marketplace met een ander werkwoord, codex plugin add talkies@psyb0t, omdat codex plugin install niet bestaat. Hij vindt de skill ook uit zichzelf in een checkout van de repo, aangezien hij .agents/skills/ native scant zonder dat er iets geïnstalleerd is. Het staat nu ook in het officiële MCP Registry, dus een client die zijn servers daarvandaan oplost kan het vinden zonder dat je hem een URL geeft.