talkies: Zi-i API-ului de Voce de la OpenAI Să Se Ducă Dracului

Mi-a venit factura de la API-ul Whisper de la OpenAI, după o lună în care îmi legasem uneltele proprii la el, și am stat pur și simplu holbându-mă la număr de parcă mi-ar fi înjurat personal mama. Nu pentru că era o sumă nebunească, nu era, ci pentru că plăteam chirie la minut pentru un model care e public și self-hostabil de ani de zile, pentru privilegiul de a-mi urca propriul audio pe serverul altcuiva, ca ăia să ruleze o inferență pe care o puteam rula singur pe o placă video pe care deja o am. Pe urmă mi-a mai trebuit și TTS peste asta, pentru același pipeline, ceea ce însemna un al doilea furnizor, o a doua cheie de API, o a doua factură, un al doilea document de Termeni și Condiții pe care nu îl citește nimeni, și un al doilea loc în care datele mele, mostre de voce, transcrieri, orice îi dădeam de mâncare, stau pe discul unui străin. Aveam deja un gust prost în gură de când construisem qwenspeak mai devreme, băgând text prin Kokoro peste SSH, pentru că nici de o factură la un API de TTS nu aveam chef. Chestia aia mergea, dar era în formă de SSH, cu un singur scop, și zero interes să vorbească formatul de pe fir al OpenAI. Când aigate a avut nevoie de un backend de voce cu care routerul lui compatibil cu OpenAI să poată vorbi fără să scriu un adaptor pe comandă, nimic din sertarul meu cu vechituri nu se potrivea. Așa că am construit talkies: un container, ASR la intrare, TTS la ieșire, cablat să vorbească exact forma de HTTP pe care tot ecosistemul de SDK-uri OpenAI o înțelege deja, rulând pe hardware care e al meu.

Self-Hostingul de Voce E o Mlaștină de Dependențe

Să îți autogăzduiești modele de voce sună simplu până încerci chiar să asamblezi un serviciu din piesele care există prin lume. Uite în ce dai:

  • Fiecare proiect are propriul format pe fir. faster-whisper vrea un script. NeMo vrea să te lupți cu sistemul lui de configurare o după-amiază întreagă înainte să binevoiască să încarce un checkpoint. Kokoro e un pachet PyPI în jurul căruia trebuie să îți construiești singur stratul de HTTP. Niciunul nu cade de acord asupra unei forme de cerere și răspuns, deci dacă deja ai unelte construite pe /v1/audio/transcriptions și /v1/audio/speech de la OpenAI, apuci să scrii un strat de traducere pentru fiecare în parte, și pe urmă să îl întreții pe veci.
  • Doar ASR sau doar TTS. Alege un proiect de ASR self-hosted și capeți exact atât, transcriere, nimic altceva. Vrei și TTS? Container nou, port nou, configurare nouă, mod nou de a se strica atunci când unul cade și celălalt nu.
  • Memoria de pe placa video nu negociază. Încarcă două modele grele pe aceeași placă fără o strategie de evacuare și capeți un crash de OOM în mijlocul unei cereri, sau îți iei o placă de două ori mai mare decât îți trebuie, ca să stea ambele modele rezidente pe veci, nefăcând nimic 95% din timp.
  • Clonarea de voce ori lipsește, ori e lipită prost pe deasupra. Multe stackuri de TTS self-hosted ori nu fac deloc clonare, ori o fac printr-un script făcut la comandă pe care îl rulezi o dată, offline, și care scuipă un checkpoint pe care apoi trebuie să îl legi înapoi de mână în calea de servire.
  • CPU față de GPU e un gând de pe urmă. Majoritatea proiectelor presupun că ai o placă video care stă degeaba, sau rulează totul pe CPU și mănâncă cinci minute pe transcriere. Nimeni nu livrează amândouă și nu îți zice cinstit ce modele au sens pe care dintre ele.

Nimic din asta nu e exotic. E taxa standard de AI self-hosted: fiecare componentă își vorbește propriul dialect, iar lipitul lor împreună în ceva pe care o bibliotecă de client existentă chiar să îl poată folosi e munca adevărată despre care nu scrie nimeni.

O Singură Formă pe Fir, Toate Backendurile

talkies e un serviciu FastAPI (psyb0t/docker-talkies) care vorbește formatul de voce de pe firul OpenAI în ambele direcții, POST /v1/audio/transcriptions pentru ASR, POST /v1/audio/speech pentru TTS, și trimite fiecare cerere spre unul dintr-un pumn de motoare de backend, în funcție de un slug de model pe care îl dai în cerere, exact cum ai alege un nume de model pe API-ul OpenAI adevărat. Îndrepți SDK-ul oficial openai spre el, schimbi base_url, gata:

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

În spatele acelei forme identice de pe fir, registrul de modele (models.json, sau models-cpu.json pentru imaginea de CPU) mapează fiecare slug la un string de executor, iar talkies/config.py validează stringul ăla față de o listă albă fixă, VALID_EXECUTORS, cu exact treisprezece valori: whisper, parakeet, parakeet_cpp, canary_multitask, canary_salm, kokoro, kokoro_nvidia, qwen3_tts, sherpa, sherpa_offline_ctc, vosk, chatterbox, wav2vec2_phoneme. Orice în afara setului ăluia pică containerul la pornire, în loc să livreze un serviciu pe jumătate stricat. Fabrica din talkies/models/__init__.py citește registrul și instanțiază clasa de backend care se potrivește, pentru fiecare slug, iar fiecare implementează un protocol de tip duck typing (get_model(), unload(), loaded(), last_used_secs_ago(), plus transcribe() pentru ASR sau synthesize() și voices() pentru TTS), deci handlerele de rute din server.py nu au habar ce motor face de fapt treaba.

models.json-ul livrat înregistrează paisprezece sluguri de ASR (două mărimi de Whisper prin faster-whisper, Parakeet-TDT, Nemotron-3.5-ASR printr-un runtime C++ ggml, trei variante de Canary, patru variante de streaming Sherpa-ONNX Zipformer, Vosk small English și două recunoscătoare de foneme) și opt sluguri de TTS din patru familii de motoare (Kokoro în două arome de runtime, cinci combinații de checkpoint și mod la Qwen3-TTS, și Chatterbox Turbo). Orice ar zice badge-urile sau listele cu bulină din README despre număr, și se contrazic între ele în trei locuri diferite, ceea ce e fix genul de derivă pe care o capeți când un proiect crește repede, fișierul de registru e contractul adevărat, iar eu l-am numărat direct, în loc să mă încred în proză. Douăzeci și două de sluguri în total pe imaginea CUDA; pe imaginea de CPU, models-cpu.json aruncă modelele care sunt inutile fără VRAM și păstrează unsprezece sluguri de ASR (ambele mărimi de Whisper, Canary-180M-Flash, Nemotron-3.5-ASR care merge bine pe CPU pentru că e un port C++ ggml, nu un model PyTorch, cele patru variante Sherpa-ONNX Zipformer, Vosk și ambele recunoscătoare de foneme), plus două variante Kokoro de TTS, treisprezece în total.

Pornire Rapidă

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

Prima pornire descarcă fiecare model activat în /data/models/<slug>/, ca director plat, fără indirecția de cache a HuggingFace, doar snapshot_download(local_dir=...) direct spre o cale indexată după slug. Montează /data cu bind mount, altfel redescarci gigaocteți la fiecare repornire. Dacă nu vrei tot registrul stând pe disc, TALKIES_ENABLED_MODELS pune sluguri pe listă albă, îl setezi cu o listă separată prin virgule, iar bucla de preîncărcare, plus /v1/models, servesc doar ce e în ea. Referă un slug necunoscut și containerul pică repede la pornire, cu tot catalogul tipărit, ca să îți repari greșeala de tipar, în loc să depanezi un 404 o oră mai târziu:

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: Paisprezece Sluguri, Un Singur Răspuns în Formă de Whisper

Fiecare backend de ASR, indiferent ce macină de fapt audio-ul pe dedesubt, întoarce același răspuns în formă de Whisper, text, iar pentru verbose_json, array-uri complete de segments și words. Schimbi model=whisper-large-v3 cu model=canary-1b-flash și nimic din aval de răspunsul HTTP nu trebuie să știe sau să îi pese.

faster-whisper (2 sluguri)

whisper-large-v3 și whisper-large-v3-turbo rulează prin runtime-ul CTranslate2 al lui faster-whisper, amândouă merg și pe CPU, și pe GPU, amândouă sunt în imaginea de CPU.

NeMo (4 sluguri)

parakeet-tdt-0.6b-v3 (decodor TDT), canary-180m-flash și canary-1b-flash (capete de transformer multitask, varianta de 1B face traducere vorbire-text EN/DE/FR/ES în ambele sensuri), și canary-qwen-2.5b, care schimbă decodorul lui Canary cu un LLM Qwen2, trucul NVIDIA cu “modelul de limbaj augmentat cu vorbire”. Canary-Qwen nu are cap de aliniere, deci e singurul backend care se întoarce cu array-uri goale de segments și words în verbose_json, tot capeți transcrierea completă, doar că nu capeți timestampuri per cuvânt pentru modelul ăla.

parakeet.cpp (1 slug)

nemotron-3.5-asr-0.6b e excepția din lot, o cuantizare GGUF a lui Nemotron-3.5-ASR-Streaming-0.6B de la NVIDIA, servită prin mudler/parakeet.cpp, un runtime C++17 cu ggml, încărcat prin ctypes din talkies/models/parakeet_cpp.py, fără niciun NeMo de Python pe calea fierbinte. E singurul model de ASR din clasa autoregresiv-streaming care merge decent pe CPU simplu, motiv pentru care e livrat în ambele imagini, în timp ce frații lui (Parakeet-TDT, Canary-1B, Canary-Qwen) sunt doar CUDA. Douăzeci și trei de locale care se pot fixa, plus detecție auto, direct din array-ul languages al intrării din registru, timestampuri per cuvânt sintetizate în segmente prin grupare pe pauzele de liniște (_SEGMENT_GAP_THRESHOLD_S = 0.5 în parakeet_cpp.py).

Sherpa-ONNX plus Vosk (5 sluguri, și astea chiar streamează)

Patru variante englezești de Sherpa-ONNX Zipformer, sherpa-zipformer-en-left-64, -left-128 și cuantizările int8 ale amândurora, plus vosk-small-en-us-0.15. Sunt în ambele registre, și CPU, și CUDA, iar imaginea CUDA instalează un wheel Sherpa CUDA din amonte, cu hash verificat, ca să folosească chiar providerul nativ de execuție CUDA, în loc să cadă pe tăcute înapoi pe CPU înăuntrul unei imagini de GPU.

Astea sunt modelele transducer, ceea ce înseamnă că sunt făcute pentru chestia pentru care restul lotului nu e: streaming live. Există un WebSocket la /v1/audio/transcriptions/stream care scoate rezultate parțiale pe măsură ce vorbești, iar aceleași sluguri servesc și un POST /v1/audio/transcriptions obișnuit, ruta de fișier doar bagă audio normalizat printr-un stream nativ de scurtă durată, pe sub capotă. Intrările din registru poartă download_patterns, deci alegerea unei variante Sherpa trage doar artefactele ei potrivite de tokenuri, encoder, decoder și joiner, în loc de tot repo-ul.

Și au ajuns stricate în trei feluri anume, ceea ce merită scris, pentru că fiecare dintre ele era tăcut:

  • Zero timestampuri de cuvinte. OnlineRecognizer.get_result() întoarce result.text.strip(), un str simplu. Adaptorul citea tokens și timestamps de pe un string, deci fiecare model Sherpa întorcea "words": [] indiferent ce cerea apelantul. Acum citește OnlineRecognizerResult complet, prin get_result_all().
  • Cuvinte care nu erau cuvinte. Tokenurile de transducer sunt bucăți BPE, "QUICK" sosește ca ("QUI", "CK"), iar fiecare token era scos drept cuvânt de sine stătător. Acum sunt grupate înapoi după marcajul de spațiu din față, care indică începutul unui cuvânt. Vocabularele la nivel de caracter și la nivel de cuvânt nu au marcajul ăla, deci alea sunt detectate și lăsate cu un token pe cuvânt, în loc să prăbușească o rostire întreagă într-un singur cuvânt.
  • Transcrierea de fișiere dubla tot. Ruta de lot își deschide streamul cu interim_results=False. Vosk îl respecta; Sherpa îl ignora. get_result e cumulativ în interiorul unei rostiri, deci fiecare parțial repeta tot prefixul, iar adaptorul de lot concatena fiecare revizie, un clip de nouă cuvinte se întorcea ca "THE QUICK THE QUICK BROWN FOX … THE QUICK BROWN FOX JUMPS OVER THE LAZY DO". Streamingul live nu a fost niciodată afectat; acolo parțialele cumulative sunt tot rostul.

Sherpa raportează acum și confidence per cuvânt, derivată din log-probabilitățile acustice per token ale modelului și mediată peste tokenurile fiecărui cuvânt, același nume de câmp și același interval 0–1 pe care Vosk îl scotea deja, deci ambele backenduri întorc aceeași formă de cuvânt.

Fișierele lungi sunt feliate întâi, orice trece peste TALKIES_VAD_CHUNK_THRESHOLD (implicit 30 de secunde) trece prin Silero VAD, e împărțit în regiuni de vorbire plafonate la TALKIES_VAD_MAX_SPEECH (implicit 28 de secunde), transcris bucată cu bucată și cusut înapoi într-o singură linie de timp continuă, cu timestampuri corectate cu offset. Fiecare backend trece prin același tăietor, fereastra internă de formă lungă a lui Whisper e ocolită complet, ca purtarea la tăiere să fie identică între motoare, în loc ca Whisper să facă ce vrea el, iar NeMo altceva.

Mai există și diarizare stereo fără vreun model separat de embedding de vorbitor lipit pe deasupra: dai un fișier pe 2 canale cu diarization=true, canalul stâng devine vorbitorul L, cel drept devine R, fiecare transcris independent și îmbinate cronologic. Intrarea mono cu diarization=true e respinsă cu un 400 (NotStereoError, verificat prin numărul de canale de la ffprobe, în talkies/audio.py), nu e separare magică de vorbitori, e un truc de montaj cu două microfoane, iar asta o zice pe șleau, în loc să se prefacă altceva.

Două dintre ele nu îți dau deloc cuvinte

v0.17.0 a adăugat o pereche de sluguri de ASR care transcriu în foneme IPA în loc de text, și pe imaginea de CPU, și pe cea CUDA. wav2vec2-xlsr-53-espeak e wav2vec2-xlsr-53-espeak-cv-ft de la facebook, în spatele unui executor wav2vec2_phoneme, tăiat pe activitate vocală odată ce un fișier trece de TALKIES_VAD_CHUNK_THRESHOLD, și nu a avut nevoie de nicio dependență nouă în imagine. zipa-ipa e anyspeech/zipa-small-crctc-500k printr-un executor nou, sherpa_offline_ctc: 71 MB la int8, și mănâncă tot fișierul dintr-o trecere, în loc să îl streameze.

Ține minte că executorul nou chiar e altul, nu sherpa-ul de streaming cu un flag întors. Aceeași formă de sherpa_config, recunoscător offline pe dedesubt.

Nu există niciun model de limbaj și niciun lexicon în spatele vreunuia dintre ele, și ăsta e tot rostul. Capeți un flux de foneme separate prin spații, cu timestampuri per fonem, prin verbose_json, srt, vtt și timestamp_granularities, iar nimic nu încearcă să ghicească ce cuvânt adevărat ai vrut. Aia e ce vrei pentru punctarea pronunției, aliniere forțată, lucrul pe accente, sau orice limbă pe care modelele la nivel de cuvânt nu au fost antrenate niciodată. Categoric nu e ce vrei dacă îți trebuie doar o transcriere.

TTS: Kokoro de Două Ori, Qwen3 în Cinci Feluri, Chatterbox o Dată

Patru familii de motoare TTS, opt sluguri. kokoro-82m rulează modelul Kokoro cu greutăți deschise, de 82M de parametri, în proces, prin pachetul PyPI kokoro, destul de rapid pe CPU cât să fie chiar folositor, fără sidecar. kokoro-82m-nvidia e aceleași ponderi, servite în schimb prin ONNXRuntime pe exportul ONNX prietenos cu TensorRT al NVIDIA, provider de execuție CUDA pe imaginea de GPU, provider de CPU pe imaginea de CPU, G2P prin espeak-ng și phonemizer în loc de misaki. Același catalog de voci, același format pe fir, schimb direct între cele două.

Vocile sunt scanate viu de pe disc, talkies/models/kokoro.py filtrează pachetul de voci până la paisprezece prefixe de nume din șase limbi (engleză americană și britanică, spaniolă, franceză, hindi, italiană, portugheză) care merg pe G2P-ul ușor espeak-ng livrat în imaginea de bază, sărind peste vocile japoneze și mandarine, care au nevoie de extrașii mai grei misaki, pe care nu i-a cerut nimeni. Kokoro nu are aliasuri de voci OpenAI, capeți numele native în stil af_heart / bm_george / ef_dora, descoperibile prin GET /v1/audio/voices.

Celelalte cinci sluguri de TTS sunt toate Qwen3-TTS, doar CUDA, pentru că wrapperul faster-qwen3-tts de dedesubt capturează grafuri CUDA la încărcare și nu are absolut nicio cale de cod pentru CPU:

  • qwen3-tts-0.6b / qwen3-tts-1.7b, mod de bază, clonare de voce. Pui un .wav de referință de 10-30 de secunde în /data/custom-voices/<name>.wav, sintetizezi cu voice=<name>. Căile imbricate supraviețuiesc (clients/acme/jane.wav devine vocea clients/acme/jane). Adaugi un <name>.txt alăturat cu transcrierea și calitatea clonării sare vizibil (modul de învățare în context); îl sari și backendul cade înapoi pe sinteză doar cu x-vector, cu un avertisment logat, în loc să pice de tot.
  • qwen3-tts-0.6b-custom / qwen3-tts-1.7b-custom, nouă vorbitori presetați ficși, fără audio de referință. Varianta de 1.7B respectă un câmp instructions drept indiciu de emoție (“Speak angrily.”); checkpointul de 0.6B nu îl suportă deloc, faster-qwen3-tts anulează câmpul pe dinăuntru, iar talkies loghează un avertisment, în loc să îți mănânce instrucțiunea pe tăcute.
  • qwen3-tts-1.7b-design, fără niciun catalog de voci. Descrii o voce în limbaj natural prin instructions (“a warm, friendly young female voice with a cheerful tone”) și modelul inventează una. instructions gol înseamnă 400, iar dacă rulezi aceeași descriere de două ori nu capeți aceeași voce înapoi, eșantionarea e stocastică, asta e înțelegerea.

Controalele de eșantionare per cerere (temperature, top_k, top_p, repetition_penalty, max_new_tokens, do_sample) călătoresc alături, ca extra-câmpuri OpenAI, prin extra_body pe SDK-urile oficiale, nimic din asta nu a avut nevoie de un endpoint pe comandă, tot POST /v1/audio/speech e. Ieșirea e codată prin ffmpeg în oricare dintre mp3 / opus / aac / flac / wav / pcm ai cerut, aia e lista exhaustivă direct din tabelul de formate din talkies/tts.py, nu o ghiceală. pcm pe un model Qwen3-TTS streamează octeți bruți în bucăți, în loc să tamponeze toată rostirea, și e singurul format de răspuns care chiar streamează, tot restul, Kokoro inclusiv, sintetizează clipul întreg înainte să îl întoarcă.

Chatterbox Turbo (1 slug, și acceptă indicații de regie)

Al treilea motor de TTS, doar CUDA: chatterbox-turbo, engleză mono la 24 kHz, prin același POST /v1/audio/speech ca tot restul. Două lucruri îl fac diferit de Kokoro și Qwen3.

Etichete paralingvistice, scrise inline în text. Nu un parametru, nu o presetare de voce, ci tokenuri în paranteze drepte care stau chiar în stringul pe care îl sintetizezi:

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

Sunt tokenuri adevărate în tokenizerul checkpointului, și sunt exact 19 la număr. Orice altceva în paranteze drepte nu e o eroare, doar se rostește ca text literal, ceea ce e modul de eșuare pe care îl vrei, în loc de un 400 la jumătatea unui paragraf.

Clonare de voce fără transcriere. Îl îndrepți spre un .wav de referință mai lung de cinci secunde și clonează doar din el, fără text potrivit, spre deosebire de calea de clonare a lui Qwen3. Vocile vin din /data/custom-voices, plus un vorbitor încorporat livrat înăuntrul checkpointului.

Se îmbucă în aceeași mecanică precum celelalte două: chatterbox.py implementează protocolul comun TTSBackend, deci încărcarea leneșă, timestampurile pentru măturătorul de inactivitate și evacuarea fraților se poartă exact ca la Kokoro și Qwen3. Niciun caz special.

Al optulea slug, și a patra familie, e chatterbox-turbo: modelul expresiv doar pentru engleză al lui ResembleAI, doar CUDA, mono la 24 kHz. E ăla spre care te întorci când vrei emoție și sunete non-verbale, iar alea se pun inline în text, ca etichete în paranteze drepte, nu ca parametri separați. Vocea e ori cea încorporată, coaptă în checkpoint, ori un clip de referință pe care îl bagi tu, iar spre deosebire de Qwen3-TTS nu vrea nicio transcriere de referință, doar un clip mai lung de cinci secunde, altfel respinge cererea cu un 400. Tamponează toată rostirea, fără streaming PCM, iar speed e ignorat.

Două avertismente cinstite. Implicit, ieșirea poartă un watermark neural, PerTh de la ResembleAI, nu ceva adăugat aici. De la v0.16.0 e un comutator: TALKIES_CHATTERBOX_WATERMARK e implicit true, iar setarea lui pe false bagă în loc un passthrough, deci audio-ul iese curat (gol sau nesetat îl ține pornit, ca o valoare goală rătăcită să nu îl poată scoate pe tăcute). Și numai Chatterbox pune vreun watermark, Kokoro și Qwen3-TTS nu înglobează nimic. Iar chatterbox-tts plus s3tokenizer se instalează cu hash fixat și cu --no-deps, din propriul lor fișier de cerințe, special ca să țină pinurile lor nesatisfăcibile și uneltele de dezvoltare în afara imaginii de rulare.

Concurență per Model, Pentru Că Un Singur Model E Rezident o Dată

talkies ține un singur model rezident și le evacuează pe celelalte la admitere. Ăsta e designul, și înseamnă că “câte cereri pot trage deodată” e o întrebare despre un singur model, nu despre câte modele încap în memorie. Costul sunt ponderile, încărcate o dată, plus un buffer de activare per cerere în zbor, motiv pentru care plafonul implicit e un conservator 2.

Un singur controlor de admitere acoperă acum fiecare suprafață de inferență: transcriere HTTP, transcriere MCP, ASR pe WebSocket, TTS tamponat și TTS în streaming. TALKIES_MODEL_MAX_CONCURRENCY pune limita de rezervă, TALKIES_MODEL_CONCURRENCY ia suprascrieri per slug, iar o intrare din registru își poate declara propriul max_concurrency, intrarea livrată pentru Nemotron pune 2. Valorile malformate, duplicate, dezactivate, necunoscute și în afara intervalului pică la pornire, nu la prima cerere care se împiedică de ele.

Poți vedea numerele, în loc să ghicești: GET /v1/models raportează max_concurrency, iar GET /api/ps raportează și active_requests, și max_concurrency. Rămâi fără capacitate și capeți un 429. Încerci să schimbi modelul în timp ce inferența chiar rulează și capeți un 409, nu o evacuare surpriză la jumătatea transcrierii altcuiva.

Administrarea Resurselor, Pregătirea Fișierelor și MCP

Toate backendurile împart același bazin de VRAM și RAM, un singur model rezident o dată. Când vine o cerere pentru un slug care nu e încărcat momentan, tot ce e încărcat e evacuat întâi, evacuare între frați, indiferent de modalitate, deci încărcarea lui Kokoro dă afară un Whisper rezident, și invers. Mai există și un măturător de inactivitate pe o cadență de TALKIES_SWEEPER_INTERVAL (implicit 60s), care descarcă orice a stat degeaba peste TALKIES_MODEL_TTL (implicit 600s, adică 10 minute; pune-l pe 0 ca să dezactivezi descărcarea automată). Suprafața de introspecție în stil Ollama, GET /api/ps, DELETE /api/ps/{model_id}, POST /unload, îți lasă să verifici ce e rezident și să îl evacuezi manual, iar toată treaba oglindește forma de administrare a resurselor de la speaches suficient de aproape cât același cod de driver în stil LiteLLM să meargă pe amândouă.

Pregătirea fișierelor pe server (/v1/files) există ca să nu reîncarci aceiași octeți de audio la fiecare reîncercare, cât timp îți reglezi un response_format. Faci PUT cu un fișier o dată, apoi îl referi prin cale relativă, cu câmpul de formular file_path pe /v1/audio/transcriptions, în loc de câmpul multipart file. file_path acceptă și un URL simplu http(s)://, prima lovitură îl descarcă și îl pune în cache sub o cale indexată cu sha256, fiecare apel următor cu același URL e o lovitură în cache, iar cererile concurente pentru același URL nu îl aduc de două ori. Traversarea de căi (.., backslashuri, octeți nuli, slashuri duble) e respinsă cu 400, iar symlinkurile care arată în afara rădăcinii de pregătire sunt refuzate după rezolvarea căii.

Mai există și un server MCP complet, montat la /v1/mcp peste Streamable HTTP, rulând în același proces FastAPI și împărțind exact același bazin de backenduri și același middleware de autentificare ca rutele HTTP, un model pe care îl încarcă un apel de unealtă MCP e aceeași instanță pe care o vede API-ul HTTP. Șase unelte în total, direct din mcp_server.py: list_models, transcribe, list_files, put_file, get_file, delete_file. Îl legi la Claude Code într-o singură linie:

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

Ceea ce înseamnă că un agent poate transcrie o înregistrare sau muta audio prin directorul de pregătire ca apeluri de unealtă, fără să scrii tu vreun lipici, același server, aceleași modele, aceleași reguli de evacuare, doar alt transport. TTS-ul e lăsat intenționat în afara suprafeței MCP: list_models aruncă orice nu implementează transcribe(), deci sinteza rămâne pe POST /v1/audio/speech, unde stă deja mecanica de streaming și de formate.

Autentificare, și Neîncrederea în Rețea Implicit

Setezi TALKIES_AUTH_TOKEN și fiecare rută în afară de /healthz și de preflighturile CORS cere Authorization: Bearer <token>, altfel capeți un 401 cu WWW-Authenticate: Bearer atașat. E implementat ca middleware ASGI, nu ca dependență FastAPI, special ca să acopere și sub-aplicația MCP montată, iar comparația de token trece prin hmac.compare_digest, nu printr-o egalitate simplă de stringuri, deci nu există niciun canal lateral de cronometrare pe care să te sprijini. Lași variabila nesetată și serverul e larg deschis, ceea ce e defaultul deliberat pentru LAN-ul self-hosted, pune un reverse proxy în față dacă nu ăsta e modelul tău de amenințare.

Tot restul e blocat plictisitor de bine, cum ar trebui să fie o imagine de producție: imaginile de bază fixate pe digest sha256, fiecare dependență Python blocată pe hash prin uv.lock și instalări cu --require-hashes pentru stackul ML greu, o poartă exclude-newer în pyproject.toml care refuză să blocheze versiuni de pachete mai noi decât ziua în care a fost generat lockfileul (omoară prostiile de supply chain din aceeași zi înainte să apuce să aterizeze), rulează ca non-root, și HF_HUB_OFFLINE=1 în regim de croazieră, odată ce ponderile sunt în cache, containerul nu are absolut niciun motiv să mai atingă vreodată rețeaua, în afară de a-ți servi cererile. Descărcările de URL-uri prin file_path capătă o gardă SSRF opțională (TALKIES_BLOCK_PRIVATE_DOWNLOADS) care refuză hostnameurile care se rezolvă în intervale private, de loopback sau link-local, oprită implicit, pentru că majoritatea deployurilor self-hosted sunt cutii de LAN care aduc de la alte cutii de LAN, dar e acolo dacă expui asta spre ceva mai puțin de încredere.

Ce A Înlocuit

talkies e chestia despre care îmi doream să existe înainte să construiesc qwenspeak, înainte ca aigate să aibă nevoie de un backend de voce, înainte să mă satur de două facturi separate de la doi furnizori, pentru două jumătăți ale aceleiași funcții. Un container, un format pe fir, paisprezece sluguri de ASR, opt sluguri de TTS, clonare de voce, un endpoint MCP, și nimic din toate astea nu sună acasă odată ce modelele sunt în cache pe disc. Dacă deja vorbești SDK-ul OpenAI pe o cheie de API adevărată, să îl îndrepți spre asta în schimb e o schimbare de base-url, nu o rescriere.

Repo-ul e la github.com/psyb0t/docker-talkies, imaginea e pe Docker Hub. Licențiat WTFPL, deci fă ce mama dracului vrei cu el.

Cum Îl Instalezi în Agentul Tău

Uneltele de voce sunt mai ușor de dat pe mâna unui agent decât de explicat unuia. Tot ce e sub .agents/ e catalogat într-un singur marketplace, deci sunt două comenzi:

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

Codex folosește același marketplace cu alt verb, codex plugin add talkies@psyb0t, pentru că nu există codex plugin install. Găsește singur și skillul într-un checkout al repo-ului, pentru că scanează .agents/skills/ nativ, fără să fie instalat absolut nimic. E listat acum și pe MCP Registry-ul oficial, deci un client care rezolvă servere de acolo îl poate găsi fără să i se dea un URL.