Ich bekam die Rechnung für OpenAIs Whisper-API, nach einem Monat, in dem ich mein eigenes Werkzeug daran gehängt hatte, und saß da und starrte auf die Zahl, als hätte sie persönlich meine Mutter beleidigt. Nicht weil es ein wahnsinniger Betrag gewesen wäre, war es nicht, sondern weil ich Miete pro Minute zahlte für ein Modell, das seit Jahren öffentlich und selbst hostbar ist, für das Privileg, mein eigenes Audio auf den Server von jemand anderem zu laden, damit der eine Inferenz fährt, die ich selbst auf einer GPU fahren könnte, die mir längst gehört. Dann brauchte ich obendrauf noch TTS für dieselbe Pipeline, was einen zweiten Anbieter bedeutete, einen zweiten API-Key, eine zweite Rechnung, ein zweites AGB-Dokument, das keiner liest, und einen zweiten Ort, an dem meine Daten, Sprachproben, Transkripte, was auch immer ich reinfütterte, auf der Platte eines Fremden liegen. Ich hatte ohnehin schon einen schlechten Geschmack im Mund, seit ich qwenspeak gebaut hatte, Text per SSH durch Kokoro geschoben, weil ich auch auf eine TTS-API-Rechnung keine Lust hatte. Das Ding funktionierte, aber es hatte SSH-Form, einen einzigen Zweck, und null Interesse daran, OpenAIs Leitungsformat zu sprechen. Als aigate ein Sprach-Backend brauchte, mit dem sein OpenAI-kompatibler Router reden konnte, ohne dass ich einen Adapter von Hand schreibe, passte nichts aus meiner Kramschublade. Also habe ich talkies gebaut: ein Container, ASR rein, TTS raus, verdrahtet, um genau die HTTP-Form zu sprechen, die das gesamte OpenAI-SDK-Ökosystem längst versteht, laufend auf Hardware, die mir gehört.
Sprache Selbst Zu Hosten Ist ein Abhängigkeitssumpf
Sprachmodelle selbst zu hosten klingt simpel, bis du tatsächlich versuchst, aus den Teilen, die es da draußen gibt, einen Dienst zusammenzubauen. Das erwartet dich:
- Jedes Projekt hat sein eigenes Leitungsformat. faster-whisper will ein Skript. NeMo will, dass du dich einen Nachmittag lang mit seinem Konfigurationssystem prügelst, bevor es geruht, einen Checkpoint zu laden. Kokoro ist ein PyPI-Paket, um das du dir die HTTP-Schicht selbst bauen musst. Keines davon einigt sich auf eine Anfrage- und Antwortform, wenn du also bereits Werkzeug gegen OpenAIs
/v1/audio/transcriptionsund/v1/audio/speechgebaut hast, darfst du für jedes einzelne eine Übersetzungsschicht schreiben und sie dann auf ewig pflegen. - Nur ASR oder nur TTS. Such dir ein selbst gehostetes ASR-Projekt aus, und du bekommst genau das, Transkription, sonst nichts. Du willst auch TTS? Neuer Container, neuer Port, neue Konfiguration, neuer Fehlermodus, wenn eines umfällt und das andere nicht.
- GPU-Speicher verhandelt nicht. Lade zwei schwere Modelle auf dieselbe Karte ohne Verdrängungsstrategie, und du kassierst mitten in einer Anfrage einen OOM-Absturz, oder du beschaffst eine Karte doppelt so groß wie nötig, damit beide Modelle ewig resident herumliegen und 95% der Zeit nichts tun.
- Voice Cloning fehlt entweder oder ist schlecht drangeklebt. Viele selbst gehostete TTS-Stacks klonen entweder gar nicht, oder sie tun es über irgendein maßgeschneidertes Skript, das du einmal offline laufen lässt und das einen Checkpoint ausspuckt, den du danach von Hand wieder in den Serving-Pfad verdrahten musst.
- CPU gegen GPU ist ein nachträglicher Gedanke. Die meisten Projekte setzen voraus, dass bei dir eine GPU untätig herumsteht, oder sie fahren alles auf der CPU und fressen fünf Minuten pro Transkription. Niemand liefert beides und sagt dir ehrlich, welche Modelle auf welchem von beiden Sinn ergeben.
Nichts davon ist exotisch. Es ist die übliche Steuer auf selbst gehostete KI: jede Komponente spricht ihren eigenen Dialekt, und sie so zusammenzukleben, dass eine vorhandene Client-Bibliothek sie wirklich benutzen kann, ist die eigentliche Arbeit, über die niemand schreibt.
Eine Leitungsform, Alle Backends
talkies ist ein FastAPI-Dienst (psyb0t/docker-talkies), der OpenAIs Sprach-Leitungsformat in beide Richtungen spricht, POST /v1/audio/transcriptions für ASR, POST /v1/audio/speech für TTS, und jede Anfrage anhand eines model-Slugs, den du in der Anfrage mitgibst, an eine aus einer Handvoll Backend-Engines verteilt, genau so, wie du gegen die echte OpenAI-API einen Modellnamen wählen würdest. Richte das offizielle openai-SDK darauf, ändere die base_url, fertig:
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")Hinter dieser identischen Leitungsform bildet die Modell-Registry (models.json, oder models-cpu.json für das CPU-Image) jeden Slug auf einen executor-String ab, und talkies/config.py prüft diesen String gegen eine feste Erlaubnisliste, VALID_EXECUTORS, mit genau dreizehn Werten: whisper, parakeet, parakeet_cpp, canary_multitask, canary_salm, kokoro, kokoro_nvidia, qwen3_tts, sherpa, sherpa_offline_ctc, vosk, chatterbox, wav2vec2_phoneme. Alles außerhalb dieser Menge lässt den Container beim Start scheitern, statt einen halb kaputten Dienst auszuliefern. Die Fabrik in talkies/models/__init__.py liest die Registry und instanziiert pro Slug die passende Backend-Klasse, und jede implementiert ein Duck-Typing-Protokoll (get_model(), unload(), loaded(), last_used_secs_ago(), dazu transcribe() für ASR oder synthesize() und voices() für TTS), sodass es die Route-Handler in server.py nie interessiert, welche Engine die Arbeit tatsächlich macht.
Die ausgelieferte models.json registriert vierzehn ASR-Slugs (zwei Whisper-Größen über faster-whisper, Parakeet-TDT, Nemotron-3.5-ASR über eine C++-ggml-Laufzeit, drei Canary-Varianten, vier Sherpa-ONNX-Zipformer-Streaming-Varianten, Vosk small English, und zwei Phonem-Erkenner) und acht TTS-Slugs über vier Engine-Familien (Kokoro in zwei Laufzeit-Geschmacksrichtungen, fünf Checkpoint- und Modus-Kombinationen bei Qwen3-TTS, und Chatterbox Turbo). Was auch immer die Badges oder Stichpunktlisten der README über die Anzahl sagen, und sie widersprechen sich an drei verschiedenen Stellen, was genau die Art Drift ist, die man bekommt, wenn ein Projekt schnell wächst, die Registry-Datei ist der tatsächliche Vertrag, und ich habe sie direkt gezählt, statt der Prosa zu trauen. Zweiundzwanzig Slugs insgesamt im CUDA-Image; im CPU-Image wirft models-cpu.json die Modelle weg, die ohne VRAM nutzlos sind, und behält elf ASR-Slugs (beide Whisper-Größen, Canary-180M-Flash, Nemotron-3.5-ASR, das auf der CPU gut läuft, weil es eine C++-ggml-Portierung ist und kein PyTorch-Modell, die vier Sherpa-ONNX-Zipformer-Varianten, Vosk, und beide Phonem-Erkenner) plus zwei Kokoro-TTS-Varianten, dreizehn insgesamt.
Schnellstart
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" | jqDer erste Start lädt jedes aktivierte Modell nach /data/models/<slug>/ als flaches Verzeichnis, keine HuggingFace-Cache-Umwege, nur snapshot_download(local_dir=...) direkt auf einen Pfad, der nach dem Slug benannt ist. Mounte /data per Bind-Mount, sonst lädst du bei jedem Neustart Gigabytes neu. Willst du nicht die ganze Registry auf der Platte liegen haben, setzt TALKIES_ENABLED_MODELS Slugs auf eine Erlaubnisliste, du gibst ihm eine kommaseparierte Liste, und die Vorlade-Schleife plus /v1/models bedienen nur noch, was darin steht. Referenzier einen unbekannten Slug, und der Container scheitert beim Start sofort, mit dem vollständigen Katalog ausgedruckt, damit du den Tippfehler korrigierst, statt eine Stunde später einen 404 zu 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-cudaASR: Vierzehn Slugs, Eine Antwort in Whisper-Form
Jedes ASR-Backend liefert, egal was darunter das Audio zerkaut, dieselbe Antwort in Whisper-Form, text, und für verbose_json vollständige segments– und words-Arrays. Tausch model=whisper-large-v3 gegen model=canary-1b-flash, und nichts hinter der HTTP-Antwort muss es wissen oder sich darum scheren.
faster-whisper (2 Slugs)
whisper-large-v3 und whisper-large-v3-turbo laufen über die CTranslate2-Laufzeit von faster-whisper, beide CPU- und GPU-fähig, beide im CPU-Image.
NeMo (4 Slugs)
parakeet-tdt-0.6b-v3 (TDT-Decoder), canary-180m-flash und canary-1b-flash (Multitask-Transformer-Köpfe, die 1B-Variante macht Sprache-zu-Text-Übersetzung EN/DE/FR/ES in beide Richtungen), und canary-qwen-2.5b, das Canarys Decoder gegen ein Qwen2-LLM tauscht, NVIDIAs Trick mit dem “sprachaugmentierten Sprachmodell”. Canary-Qwen hat keinen Alignment-Kopf, es ist also das einzige Backend, das in verbose_json mit leeren segments– und words-Arrays zurückkommt, du bekommst trotzdem das vollständige Transkript, nur eben keine Zeitstempel pro Wort für dieses eine Modell.
parakeet.cpp (1 Slug)
nemotron-3.5-asr-0.6b ist der Ausreißer, eine GGUF-Quantisierung von NVIDIAs Nemotron-3.5-ASR-Streaming-0.6B, serviert über mudler/parakeet.cpp, eine C++17-ggml-Laufzeit, per ctypes aus talkies/models/parakeet_cpp.py geladen, ohne irgendein Python-NeMo auf dem heißen Pfad. Es ist das einzige ASR-Modell der autoregressiven Streaming-Klasse, das auf blanker CPU anständig läuft, weshalb es in beiden Images mitkommt, während seine Geschwister (Parakeet-TDT, Canary-1B, Canary-Qwen) nur CUDA sind. Dreiundzwanzig festlegbare Locales plus auto-Erkennung, direkt aus dem languages-Array des Registry-Eintrags, Zeitstempel pro Wort, die über Gruppierung an Stillepausen zu Segmenten zusammengesetzt werden (_SEGMENT_GAP_THRESHOLD_S = 0.5 in parakeet_cpp.py).
Sherpa-ONNX plus Vosk (5 Slugs, und die streamen wirklich)
Vier englische Sherpa-ONNX-Zipformer-Varianten, sherpa-zipformer-en-left-64, -left-128, und die int8-Quantisierungen von beiden, dazu vosk-small-en-us-0.15. Sie stehen in beiden Registries, CPU und CUDA, und das CUDA-Image installiert ein hash-geprüftes Upstream-Sherpa-CUDA-Wheel, damit es tatsächlich den nativen CUDA-Execution-Provider benutzt, statt in einem GPU-Image still auf die CPU zurückzufallen.
Das sind die Transducer-Modelle, was heißt, sie sind für genau das gebaut, wofür der Rest der Riege nicht gebaut ist: Live-Streaming. Es gibt ein WebSocket auf /v1/audio/transcriptions/stream, das Teilergebnisse ausgibt, während du sprichst, und dieselben Slugs bedienen auch ein gewöhnliches POST /v1/audio/transcriptions, die Datei-Route schiebt normalisiertes Audio einfach unter der Haube durch einen kurzlebigen nativen Stream. Registry-Einträge tragen download_patterns, eine Sherpa-Variante zu wählen zieht also nur ihre passenden Tokens-, Encoder-, Decoder- und Joiner-Artefakte statt des ganzen Repos.
Sie kamen außerdem auf drei bestimmte Arten kaputt an, was aufzuschreiben sich lohnt, weil jede einzelne davon still war:
- Überhaupt keine Wort-Zeitstempel.
OnlineRecognizer.get_result()liefertresult.text.strip(), einen schlichtenstr. Der Adapter lastokensundtimestampsvon einem String, jedes Sherpa-Modell gab also"words": []zurück, egal was der Aufrufer verlangte. Er liest jetzt das vollständigeOnlineRecognizerResultüberget_result_all(). - Wörter, die keine Wörter waren. Transducer-Tokens sind BPE-Stücke,
"QUICK"kommt als("QUI", "CK")an, und jedes Token wurde als eigenes Wort ausgegeben. Sie werden jetzt wieder am führenden Leerzeichen-Marker gruppiert, der einen Wortanfang kennzeichnet. Vokabulare auf Zeichen- und auf Wortebene haben diesen Marker nicht, die werden also erkannt und bei einem Token pro Wort belassen, statt eine ganze Äußerung in ein einziges Wort zu quetschen. - Die Dateitranskription verdoppelte alles. Die Batch-Route öffnet ihren Stream mit
interim_results=False. Vosk hielt sich daran; Sherpa ignorierte es.get_resultist innerhalb einer Äußerung kumulativ, jedes Teilergebnis wiederholte also den ganzen Präfix, und der Batch-Adapter hängte jede Revision aneinander, ein Neun-Wort-Clip kam als"THE QUICK THE QUICK BROWN FOX … THE QUICK BROWN FOX JUMPS OVER THE LAZY DO"zurück. Live-Streaming war nie betroffen; dort sind kumulative Teilergebnisse der ganze Sinn.
Sherpa meldet jetzt außerdem confidence pro Wort, abgeleitet aus den akustischen Log-Wahrscheinlichkeiten pro Token des Modells und über die Tokens jedes Wortes gemittelt, derselbe Feldname und derselbe Bereich 0–1, den Vosk schon ausgab, sodass beide Backends dieselbe Wortform zurückreichen.
Lange Dateien werden zuerst zerlegt, alles über TALKIES_VAD_CHUNK_THRESHOLD (Standard 30 Sekunden) geht durch Silero VAD, wird in Sprachregionen zerteilt, die bei TALKIES_VAD_MAX_SPEECH (Standard 28 Sekunden) gedeckelt sind, stückweise transkribiert und zu einer durchgehenden Zeitachse mit offsetkorrigierten Zeitstempeln zusammengenäht. Jedes Backend läuft durch denselben Zerteiler, Whispers eigenes internes Langform-Fenster wird komplett umgangen, damit das Zerteilverhalten über alle Engines hinweg identisch ist, statt dass Whisper sein Ding macht und NeMo ein anderes.
Es gibt auch Stereo-Diarisierung ohne ein separat drangeschraubtes Sprecher-Embedding-Modell: gib eine 2-Kanal-Datei mit diarization=true, der linke Kanal wird Sprecher L, der rechte wird R, jeder unabhängig transkribiert und chronologisch zusammengeführt. Mono-Eingabe mit diarization=true wird mit einem 400 abgelehnt (NotStereoError, geprüft über die Kanalzahl von ffprobe in talkies/audio.py), das ist keine magische Sprechertrennung, das ist ein Zwei-Mikrofon-Trick, und es sagt das offen, statt etwas anderes vorzugeben.
Zwei davon geben dir überhaupt keine Wörter
v0.17.0 hat ein Paar ASR-Slugs dazugebracht, die in IPA-Laute statt in Text transkribieren, sowohl im CPU- als auch im CUDA-Image. wav2vec2-xlsr-53-espeak ist facebooks wav2vec2-xlsr-53-espeak-cv-ft hinter einem wav2vec2_phoneme-Executor, an der Sprachaktivität zerteilt, sobald eine Datei über TALKIES_VAD_CHUNK_THRESHOLD hinausgeht, und es brauchte keine neue Abhängigkeit im Image. zipa-ipa ist anyspeech/zipa-small-crctc-500k über einen neuen sherpa_offline_ctc-Executor: 71 MB bei int8, und es frisst die ganze Datei in einem Durchgang, statt sie zu streamen.
Beachte, dass der neue Executor wirklich ein anderer ist, nicht das Streaming-sherpa mit umgelegtem Flag. Dieselbe sherpa_config-Form, darunter ein Offline-Erkenner.
Hinter keinem von beiden steckt ein Sprachmodell oder ein Lexikon, und das ist der ganze Sinn. Du bekommst einen leerzeichengetrennten Strom von Lauten mit Zeitstempeln pro Laut über verbose_json, srt, vtt und timestamp_granularities, und nichts versucht zu erraten, welches echte Wort du gemeint hast. Genau das willst du für Aussprachebewertung, Forced Alignment, Akzentarbeit, oder jede Sprache, auf die die Modelle auf Wortebene nie trainiert wurden. Es ist ausdrücklich nicht das, was du willst, wenn du einfach nur ein Transkript brauchst.
TTS: Kokoro Zweimal, Qwen3 Auf Fünf Arten, Chatterbox Einmal
Vier TTS-Engine-Familien, acht Slugs. kokoro-82m fährt das offene Kokoro-Modell mit 82M Parametern in-process über das PyPI-Paket kokoro, auf der CPU schnell genug, um wirklich brauchbar zu sein, ohne Sidecar. kokoro-82m-nvidia sind dieselben Gewichte, stattdessen über ONNXRuntime gegen NVIDIAs TensorRT-freundlichen ONNX-Export serviert, CUDA-Execution-Provider im GPU-Image, CPU-Provider im CPU-Image, G2P über espeak-ng und phonemizer statt misaki. Gleicher Stimmenkatalog, gleiches Leitungsformat, direkter Tausch zwischen beiden.
Stimmen werden live von der Platte gescannt, talkies/models/kokoro.py filtert das Stimmenpaket auf vierzehn Namenspräfixe über sechs Sprachen (amerikanisches und britisches Englisch, Spanisch, Französisch, Hindi, Italienisch, Portugiesisch), die auf dem leichtgewichtigen espeak-ng-G2P laufen, das im Basis-Image mitkommt, und überspringt die japanischen und Mandarin-Stimmen, die die schwereren misaki-Extras brauchen, nach denen keiner gefragt hat. Kokoro hat keine OpenAI-Stimmen-Aliase, du bekommst die nativen Namen im Stil af_heart / bm_george / ef_dora, auffindbar über GET /v1/audio/voices.
Die anderen fünf TTS-Slugs sind alle Qwen3-TTS, nur CUDA, weil der darunterliegende faster-qwen3-tts-Wrapper beim Laden CUDA-Graphen aufzeichnet und überhaupt keinen CPU-Codepfad hat:
qwen3-tts-0.6b/qwen3-tts-1.7b, Basismodus, Voice Cloning. Leg eine 10-30 Sekunden lange Referenz-.wavin/data/custom-voices/<name>.wav, synthetisiere mitvoice=<name>. Verschachtelte Pfade überleben (clients/acme/jane.wavwird zur Stimmeclients/acme/jane). Leg ein<name>.txtmit dem Transkript daneben, und die Klonqualität springt merklich nach oben (In-Context-Learning-Modus); lass es weg, und das Backend fällt mit einer geloggten Warnung auf reine x-Vector-Synthese zurück, statt komplett zu scheitern.qwen3-tts-0.6b-custom/qwen3-tts-1.7b-custom, neun feste Preset-Sprecher, keine Referenzaufnahme nötig. Die 1.7B-Variante beachtet eininstructions-Feld als Emotionshinweis (“Speak angrily.”); der 0.6B-Checkpoint unterstützt es überhaupt nicht,faster-qwen3-ttsannulliert das Feld intern, und talkies loggt eine Warnung, statt deine Anweisung still zu schlucken.qwen3-tts-1.7b-design, gar kein Stimmenkatalog. Du beschreibst eine Stimme in natürlicher Sprache überinstructions(“a warm, friendly young female voice with a cheerful tone”), und das Modell erfindet eine. Ein leeresinstructionsist ein 400, und dieselbe Beschreibung zweimal laufen zu lassen gibt dir nicht dieselbe Stimme zurück, das Sampling ist stochastisch, so ist der Deal.
Sampling-Regler pro Anfrage (temperature, top_k, top_p, repetition_penalty, max_new_tokens, do_sample) reisen als OpenAI-Extrafelder über extra_body auf den offiziellen SDKs mit, nichts davon brauchte einen eigenen Endpoint, es ist immer noch POST /v1/audio/speech. Die Ausgabe wird per ffmpeg in das kodiert, was du von mp3 / opus / aac / flac / wav / pcm angefragt hast, das ist die vollständige Liste direkt aus der Formattabelle in talkies/tts.py, keine Vermutung. pcm gegen ein Qwen3-TTS-Modell streamt rohe Bytes in Stücken, statt die ganze Äußerung zu puffern, und ist das einzige Antwortformat, das wirklich streamt, alles andere, Kokoro eingeschlossen, synthetisiert den kompletten Clip, bevor es ihn zurückgibt.
Chatterbox Turbo (1 Slug, und es nimmt Regieanweisungen)
Die dritte TTS-Engine, nur CUDA: chatterbox-turbo, englisch mono mit 24 kHz, über dasselbe POST /v1/audio/speech wie alles andere. Zwei Dinge unterscheiden es von Kokoro und Qwen3.
Paralinguistische Tags, inline in den Text geschrieben. Kein Parameter, kein Stimmen-Preset, sondern Tokens in eckigen Klammern, die im String stehen, den du synthetisierst:
"[sigh] fine, I'll do it. [whispering] but I'm not happy about it. [laugh]"Es sind echte Tokens im Tokenizer des Checkpoints, und es gibt genau 19 davon. Alles andere in eckigen Klammern ist kein Fehler, es wird einfach als wörtlicher Text gesprochen, und das ist der Fehlermodus, den du willst, statt eines 400 mitten in einem Absatz.
Voice Cloning ohne Transkript. Richte es auf eine Referenz-.wav, die länger als fünf Sekunden ist, und es klont allein daraus, ohne passenden Text, anders als der Klonpfad von Qwen3. Die Stimmen kommen aus /data/custom-voices plus einem eingebauten Sprecher, der im Checkpoint mitgeliefert wird.
Es fügt sich in dieselbe Maschinerie wie die anderen beiden: chatterbox.py implementiert das gemeinsame TTSBackend-Protokoll, also verhalten sich Lazy Loading, die Zeitstempel des Leerlauf-Kehrers und die Geschwister-Verdrängung genau wie bei Kokoro und Qwen3. Nichts als Sonderfall behandelt.
Der achte Slug, und die vierte Familie, ist chatterbox-turbo: ResembleAIs ausdrucksstarkes, rein englisches Modell, nur CUDA, mono mit 24 kHz. Es ist das, zu dem du greifst, wenn du Emotion und nonverbale Laute willst, und die gehen inline in den Text als Tags in eckigen Klammern statt als separate Parameter. Die Stimme ist entweder die eingebaute, in den Checkpoint gebackene, oder ein Referenzclip, den du hineinlegst, und anders als Qwen3-TTS will es überhaupt kein Referenztranskript, nur einen Clip von mehr als fünf Sekunden, sonst weist es die Anfrage mit einem 400 ab. Es puffert die ganze Äußerung, kein PCM-Streaming, und speed wird ignoriert.
Zwei ehrliche Einschränkungen. Standardmäßig trägt die Ausgabe ein neuronales Wasserzeichen, ResembleAIs PerTh, nichts, was hier dazugekommen wäre. Seit v0.16.0 ist es ein Schalter: TALKIES_CHATTERBOX_WATERMARK steht standardmäßig auf true, und es auf false zu setzen schiebt stattdessen einen Passthrough ein, sodass das Audio sauber herauskommt (leer oder ungesetzt lässt es an, damit ein versehentlich leerer Wert es nicht still entfernen kann). Und nur Chatterbox setzt überhaupt ein Wasserzeichen, Kokoro und Qwen3-TTS betten nichts ein. Und chatterbox-tts plus s3tokenizer installieren sich hash-gepinnt und mit --no-deps aus ihrer eigenen Requirements-Datei, gezielt, um ihre unerfüllbaren Pins und ihr Dev-Werkzeug aus dem Laufzeit-Image herauszuhalten.
Nebenläufigkeit pro Modell, Weil Immer Nur Ein Modell Resident Ist
talkies hält ein Modell resident und verdrängt die anderen bei der Zulassung. Das ist das Design, und es heißt, dass “wie viele Anfragen kann ich gleichzeitig abfeuern” eine Frage über ein einzelnes Modell ist, nicht darüber, wie viele Modelle in den Speicher passen. Die Kosten sind die Gewichte, einmal geladen, plus ein Aktivierungspuffer pro laufender Anfrage, weshalb die Obergrenze standardmäßig bei konservativen 2 liegt.
Ein Zulassungscontroller deckt jetzt jede Inferenz-Oberfläche ab: HTTP-Transkription, MCP-Transkription, WebSocket-ASR, gepuffertes TTS und Streaming-TTS. TALKIES_MODEL_MAX_CONCURRENCY setzt das Rückfall-Limit, TALKIES_MODEL_CONCURRENCY nimmt Überschreibungen pro Slug, und ein Registry-Eintrag kann sein eigenes max_concurrency deklarieren, der mitgelieferte Nemotron-Eintrag setzt 2. Fehlerhafte, doppelte, deaktivierte, unbekannte und außerhalb des Bereichs liegende Werte scheitern beim Start statt bei der ersten Anfrage, die darüber stolpert.
Du kannst die Zahlen sehen, statt zu raten: GET /v1/models meldet max_concurrency, und GET /api/ps meldet sowohl active_requests als auch max_concurrency. Geht die Kapazität aus, bekommst du einen 429. Versuch das Modell zu wechseln, während wirklich Inferenz läuft, und du bekommst einen 409, keine Überraschungsverdrängung mitten im Transkript von jemand anderem.
Ressourcenverwaltung, Dateiablage und MCP
Alle Backends teilen sich denselben VRAM- und RAM-Pool, ein Modell resident zur Zeit. Kommt eine Anfrage für einen Slug herein, der gerade nicht geladen ist, wird zuerst alles verdrängt, was geladen ist, Geschwister-Verdrängung, unabhängig von der Modalität, Kokoro zu laden wirft also ein residentes Whisper raus und umgekehrt. Es gibt außerdem einen Leerlauf-Kehrer im Takt von TALKIES_SWEEPER_INTERVAL (Standard 60s), der alles entlädt, was länger als TALKIES_MODEL_TTL untätig war (Standard 600s, also 10 Minuten; setz es auf 0, um das automatische Entladen abzuschalten). Die Introspektions-Oberfläche im Ollama-Stil, GET /api/ps, DELETE /api/ps/{model_id}, POST /unload, lässt dich prüfen, was resident ist, und es von Hand verdrängen, und das Ganze spiegelt die Ressourcenverwaltungsform von speaches nah genug, dass derselbe Treibercode im LiteLLM-Stil gegen beide funktioniert.
Die serverseitige Dateiablage (/v1/files) gibt es, damit du nicht bei jedem Versuch dieselben Audio-Bytes neu hochlädst, während du an einem response_format herumschraubst. Du machst einmal ein PUT auf die Datei und referenzierst sie danach per relativem Pfad über das Formularfeld file_path auf /v1/audio/transcriptions statt über das Multipart-Feld file. file_path nimmt auch eine schlichte http(s)://-URL, der erste Zugriff lädt sie herunter und cacht sie unter einem sha256-benannten Pfad, jeder weitere Aufruf mit derselben URL ist ein Cache-Treffer, und gleichzeitige Anfragen auf dieselbe URL holen sie nicht doppelt. Pfad-Traversal (.., Backslashes, Nullbytes, doppelte Slashes) wird mit 400 abgelehnt, und Symlinks, die aus der Ablagewurzel hinauszeigen, werden nach der Pfadauflösung verweigert.
Es gibt außerdem einen vollständigen MCP-Server, gemountet auf /v1/mcp über Streamable HTTP, der im selben FastAPI-Prozess läuft und sich exakt denselben Backend-Pool und dieselbe Auth-Middleware mit den HTTP-Routen teilt, ein Modell, das ein MCP-Tool-Aufruf lädt, ist dieselbe Instanz, die die HTTP-API sieht. Sechs Tools insgesamt, direkt aus mcp_server.py: list_models, transcribe, list_files, put_file, get_file, delete_file. Häng es in einer Zeile in Claude Code:
claude mcp add --transport http talkies https://ciprian.51k.eu00/v1/mcpWas heißt, dass ein Agent eine Aufnahme transkribieren oder Audio im Ablageverzeichnis herumschieben kann, als Tool-Aufrufe, ohne dass du irgendeinen Kleber schreibst, gleicher Server, gleiche Modelle, gleiche Verdrängungsregeln, nur ein anderer Transport. TTS ist bewusst nicht auf der MCP-Oberfläche: list_models wirft alles raus, was kein transcribe() implementiert, die Synthese bleibt also auf POST /v1/audio/speech, wo die Streaming- und Formatmaschinerie ohnehin schon wohnt.
Auth, und dem Netz Standardmäßig Nicht Trauen
Setz TALKIES_AUTH_TOKEN, und jede Route außer /healthz und CORS-Preflights verlangt Authorization: Bearer <token>, oder liefert einen 401 mit angehängtem WWW-Authenticate: Bearer. Es ist als ASGI-Middleware implementiert, nicht als FastAPI-Dependency, gezielt damit es auch die gemountete MCP-Unteranwendung abdeckt, und der Token-Vergleich läuft über hmac.compare_digest statt über schlichte String-Gleichheit, es gibt also keinen Timing-Seitenkanal, an dem man sich langhangeln könnte. Lass die Variable ungesetzt, und der Server steht sperrangelweit offen, was der bewusste Standard für ein selbst gehostetes LAN ist, setz einen Reverse Proxy davor, wenn das nicht dein Bedrohungsmodell ist.
Alles andere ist langweilig dichtgemacht, wie ein Produktions-Image es sein sollte: Basis-Images per sha256-Digest gepinnt, jede Python-Abhängigkeit per uv.lock hash-gesperrt und mit --require-hashes installiert für den schweren ML-Stack, eine exclude-newer-Schranke in pyproject.toml, die sich weigert, Paketversionen zu sperren, die neuer sind als der Tag, an dem die Lockdatei erzeugt wurde (das killt Supply-Chain-Unsinn vom selben Tag, bevor er landen kann), läuft als non-root, und HF_HUB_OFFLINE=1 im Dauerbetrieb, sind die Gewichte einmal gecacht, hat der Container keinerlei Grund mehr, je wieder ans Netz zu gehen, außer um deine Anfragen zu bedienen. URL-Downloads über file_path bekommen einen optionalen SSRF-Schutz (TALKIES_BLOCK_PRIVATE_DOWNLOADS), der Hostnamen ablehnt, die auf private, Loopback- oder Link-Local-Bereiche auflösen, standardmäßig aus, weil die meisten selbst gehosteten Deployments LAN-Kisten sind, die von anderen LAN-Kisten holen, aber da, falls du das an etwas weniger Vertrauenswürdiges hängst.
Was Es Ersetzt Hat
talkies ist das Ding, von dem ich mir gewünscht hätte, dass es existiert, bevor ich qwenspeak gebaut habe, bevor aigate ein Sprach-Backend brauchte, bevor ich zwei getrennte Anbieterrechnungen für zwei Hälften derselben Funktion satthatte. Ein Container, ein Leitungsformat, vierzehn ASR-Slugs, acht TTS-Slugs, Voice Cloning, ein MCP-Endpoint, und nichts davon telefoniert nach Hause, sobald die Modelle auf der Platte gecacht sind. Wenn du ohnehin schon OpenAIs SDK gegen einen echten API-Key sprichst, ist es eine Base-URL-Änderung, es stattdessen hierher zu richten, keine Neuschreibung.
Das Repo liegt auf github.com/psyb0t/docker-talkies, das Image auf Docker Hub. Unter WTFPL lizenziert, also mach damit, was immer du verdammt nochmal willst.
Rein Damit In Deinen Agenten
Sprachwerkzeug einem Agenten in die Hand zu drücken ist leichter, als es ihm zu erklären. Alles unter .agents/ ist in einem einzigen Marketplace katalogisiert, also sind es zwei Befehle:
claude plugin marketplace add psyb0t/agents
claude plugin install talkies@psyb0tCodex nutzt denselben Marketplace mit einem anderen Verb, codex plugin add talkies@psyb0t, weil es kein codex plugin install gibt. Er findet den Skill außerdem von allein in einem Checkout des Repos, da er .agents/skills/ nativ scannt, ganz ohne Installation. Es ist jetzt auch im offiziellen MCP Registry gelistet, ein Client, der seine Server von dort auflöst, kann es also finden, ohne dass man ihm eine URL gibt.