Skip to content

feat(models): Kokoro-82M TTS with iSTFTNet vocoder for /v1/audio/speech - #374

Merged
inureyes merged 6 commits into
mainfrom
feat/366-kokoro-tts
Jun 19, 2026
Merged

inureyes merged 6 commits into
mainfrom
feat/366-kokoro-tts

Conversation

@inureyes

Copy link
Copy Markdown
Member

Summary

Ports the Kokoro-82M text-to-speech model (a StyleTTS2 phoneme-to-mel acoustic model with a built-in iSTFTNet vocoder) to MLX/Rust and wires it to POST /v1/audio/speech, plus a self-contained English grapheme-to-phoneme front-end since Kokoro consumes IPA phonemes rather than raw text. The audio scaffolding from #364 (route, WAV writer, binary response) and #365 (the dedicated-thread AudioWorker and AudioModelProvider seam) is reused unchanged; this PR adds the synthesize direction.

A real synthesis through the full pipeline produces playable audio: for the phonemes of "hello" the model emits a 24 kHz mono waveform of 15900 samples (0.663 s), finite and bounded (rms 0.012, peak 0.083), written through the existing WAV writer with a valid RIFF header.

What changed

g2p front-end (src/models/g2p/): an American-English phonemizer. normalize tokenizes text and speaks integers; lexicon is a compiled-in word-to-IPA table; rules is a deterministic letter-to-sound fallback for out-of-vocabulary words; mod orchestrates text_to_phonemes. Output uses only symbols in Kokoro's vocab (audited by a test). No external binary or download.

Kokoro model (src/models/kokoro/): the StyleTTS2 + iSTFTNet forward pass split into focused modules: bert (PLBert / weight-shared ALBERT), predictor (duration + F0/N prosody with AdaLayerNorm and AdaIN), text_encoder (acoustic), decoder (iSTFTNet generator with NSF source and Snake MRF blocks), stft (STFT/iSTFT + harmonic source), lstm (hand-rolled bi-LSTM, since MLX has no recurrent op), blocks (shared AdaIN residual blocks), ops/weights (MLX helpers, weight-norm reconstruction, channels-last conv). mod is the KokoroModel::load / synthesize entry plus the is_kokoro_checkpoint detector.

Provider (src/server/kokoro_tts.rs): KokoroTtsProvider mirrors the Whisper STT provider, loading and running all MLX on one AudioWorker thread and forwarding requests over its channel.

Registry and wiring: ModelType::Kokoro added to the enum, ALL_MODEL_TYPES, metadata, the static-descriptor and tensor-parallel fallback tables, and the mlxcel arch family ordering; detection in get_model_type keys off the istftnet config block or the kokoro-v1_0.safetensors filename (Kokoro has no model_type) before the model_type dispatch; startup loads the provider for a Kokoro checkpoint. docs/supported-models.md gains a Text-to-speech section.

Design decisions and scope

g2p approach: a bundled lexicon plus letter-to-sound rules, scoped to American English (the default voice af_heart and the other a* voices). This is the lowest-risk self-contained choice: no mandatory external binary (the repo has no espeak/phonemizer dependency) and it yields vocab-valid IPA for the default voice. Limit: non-English voices still load and synthesize, but their phonemes come from the English front-end, so pronunciation quality is reduced; per-language g2p (the analogue of upstream misaki[xx]) is future work.

iSTFT: the inverse follows the PyTorch reference (magnitude * exp(j*phase), periodic Hann, no phase unwrap). It is computed with an explicit real inverse-DFT (Hermitian bin doubling, 1/N normalization) rather than MLX complex64 array construction, which a round-trip test pinned as the correct unit-scale path. The NSF harmonic source is deterministic (zero initial phase, no additive noise) so synthesis is reproducible.

response_format: WAV is implemented (via the #364 writer). mp3 is not added; no audio codec is currently a dependency, so it stays a documented follow-up and the route returns 400 for non-wav as before.

Validation

cargo check --lib --tests: clean. cargo fmt --check: clean. cargo clippy --lib --tests -- -D warnings: clean (also rewrote a pre-existing mean_prompt_len division that the gate flagged).

Unit tests (narrow selectors): 17 g2p tests pass (tokenizer, number expansion, lexicon lookup, symbol audit, rules, OOV, punctuation). 7 Kokoro tests pass including the STFT round-trip, alignment-matrix construction, voice-pack loading, detection, a real-checkpoint end-to-end synthesis (24 kHz, non-trivial duration, finite bounded samples), and a speed-scaling check (speed 1.5 yields fewer samples than 0.7). The end-to-end tests load prince-canuma/Kokoro-82M when present and skip otherwise.

Acceptance criteria

All six issue checkboxes are satisfied: end-to-end synthesis through /v1/audio/speech returns playable 24 kHz audio; voice, speed, and response_format (wav) are honored; detection resolves a Kokoro checkpoint without a model_type; mlxcel arch lists the TTS architecture; unit tests are added and fmt/clippy/check are clean; docs/supported-models.md is updated.

Known gaps and risks

mp3 output is deferred. g2p is English-only. The bundled lexicon is curated for high-frequency words; rarer words use the rule-based fallback, which is approximate. The deterministic NSF makes the waveform slightly quieter than a randomized run but perceptually equivalent; exact perceptual parity with the upstream MLX/PyTorch implementations was not bit-compared (a NumPy reference oracle was used during development to validate the forward-pass structure and the unit-scale iSTFT).

Closes #366

inureyes added 3 commits June 19, 2026 19:16
Kokoro and other phoneme-input TTS models consume IPA phonemes, not raw text, so this adds a self-contained American-English g2p front-end (the host-side analogue of an STT mel front-end).

Pipeline: normalize text (lower-case, speak integers, keep common punctuation), look each word up in a bundled lexicon, and fall back to deterministic letter-to-sound rules for out-of-vocabulary words. Output uses only the IPA symbols in Kokoro's vocab, so no symbol is silently dropped at tokenization. No external binary or download is required.

Modules: normalize (tokenizer + number expansion), lexicon (compiled-in word to IPA table with a symbol-audit test), rules (digraph and single-letter letter-to-sound with stress placement), and the orchestrator text_to_phonemes. Language scope is American English; per-language packs are future work.
Port the Kokoro-82M text-to-speech model to MLX/Rust. Kokoro is a StyleTTS2 phoneme-to-mel acoustic model with a built-in iSTFTNet vocoder, so it produces a 24 kHz mono waveform directly with no separate neural codec.

Forward pass: phoneme ids to a PLBert text encoder (a custom ALBERT with one weight-shared layer reused 12 times, gelu_new activation), a duration predictor (DurationEncoder bi-LSTMs + AdaLayerNorm, a duration head, and an alignment matrix that expands per-token features to per-frame), F0 and N prosody (a shared bi-LSTM plus AdainResBlk1d stacks), an acoustic text encoder, and the iSTFTNet decoder (encode/decode AdainResBlk1d blocks, transposed-conv upsampling, an NSF harmonic source, Snake-activated MRF residual blocks, conv_post, and an inverse STFT with magnitude = exp and phase = sin).

Implementation notes: weight-norm conv kernels are reconstructed from weight_g/weight_v and transposed from PyTorch layout to MLX channels-last; grouped (depthwise) transposed convs use a distinct weight transpose. MLX has no recurrent primitive, so bidirectional LSTMs are scanned by hand. The inverse STFT uses an explicit real inverse-DFT with Hermitian bin doubling and the standard 1/N normalization, matching the PyTorch reference (mag * exp(j*phase), periodic Hann, no phase unwrap), which avoids depending on MLX complex64 byte layout. The NSF source is deterministic (zero initial phase, no additive noise) so output is reproducible.

Registry: add ModelType::Kokoro with metadata ("Kokoro (StyleTTS2 + iSTFTNet)", "Text-to-speech"), register it in ALL_MODEL_TYPES and the static descriptor and tensor-parallel fallback tables (served via the audio endpoints, never text generation), and add the Text-to-speech family to the `mlxcel arch` ordering.

Tests: STFT round-trip at unit scale, alignment-matrix construction, voice-pack loading and indexing, detection by the istftnet block, and a checkpoint-gated end-to-end synthesis that checks 24 kHz output, non-trivial duration, finite bounded samples, and that speed scales duration. The end-to-end tests load the real prince-canuma/Kokoro-82M checkpoint when present (KOKORO_MODEL_DIR or models/kokoro-82m) and skip otherwise.

Also rewrite mean_prompt_len with checked_div to satisfy the workspace clippy gate.
Connect the Kokoro model to the existing audio seam so `POST /v1/audio/speech` synthesizes real audio.

KokoroTtsProvider mirrors the Whisper STT provider: it owns an AudioWorker that loads the checkpoint and runs every synthesis on one dedicated, stream-initialized thread (MLX work is thread-affine), and the provider itself holds no MLX handles. The engine runs the g2p front-end (text to phonemes) then the acoustic model and built-in vocoder, returning f32 PCM that the route encodes to WAV via the existing #364 writer. The voice request field selects a voices/ pack (validated, default af_heart), speed scales duration, and response_format honors wav.

Detection: Kokoro's config.json has no top-level model_type, so get_model_type checks is_kokoro_checkpoint (the istftnet config block or the kokoro-v1_0.safetensors filename) before the model_type dispatch that would otherwise error. Startup loads KokoroTtsProvider when the checkpoint resolves to ModelType::Kokoro. No existing model's detection is affected.

Docs: add a Text-to-speech section to supported-models.md describing the flow, detection, request fields, the g2p approach and its English-only scope, and the single-model serving constraint.

Closes #366
@inureyes inureyes added type:enhancement New features, capabilities, or significant additions priority:medium Medium priority area:models Model architectures, weights, loading, metadata area:inference Generation, sampling, decoding (incl. speculative, DRY) status:review Under review labels Jun 19, 2026
The decoder handed the NSF harmonic source the conv-downsampled F0 curve (f0_a, length T) instead of the raw predictor F0 (f0_pred, length 2T). The generator upsamples its F0 input by 300, so the downsampled curve produced a harmonic source at T*300 samples, half the intended 2T*300, which after the align_time trim left the synthesized waveform at roughly half its correct duration (and pitched off the smoothed, strided curve rather than the raw pitch).

Both the upstream MLX reference (istftnet.py Decoder.__call__ passes the raw F0_pred to generator, computing the downsampled F0 only for the encode/decode concat) and the NumPy oracle pass the full-resolution F0_pred. The conv-downsampled f0_a stays correct for the feature concatenation; only the generator's source curve changes to f0_pred.

Verified against the oracle: for the test phonemes the fixed path emits exactly 31800 samples (1.325 s, = 2T*300 with 2T=106), matching ref_audio.npy and doubling the prior 15900. cargo check --lib --tests, clippy --lib --tests -D warnings, fmt, and test --lib models::kokoro (7/7, real-checkpoint e2e included) are clean.
@inureyes

Copy link
Copy Markdown
Member Author

Implementation Review Summary

Intent

Port Kokoro-82M (StyleTTS2 acoustic + built-in iSTFTNet vocoder) plus a from-scratch English g2p front-end, wired to POST /v1/audio/speech, with detection that works without a top-level model_type.

Findings Addressed

  • Generator fed the wrong F0 curve, producing half-duration audio (HIGH). The iSTFTNet generator received the conv-downsampled F0 (f0_a, length T) instead of the raw predictor F0 (f0_pred, length 2T). Since the generator upsamples its F0 input by 300, the harmonic source came out at T300 (half of the intended 2T300), and the align_time trim left the waveform at roughly half its correct duration, with pitch driven off the smoothed/strided curve rather than the raw pitch. Fixed in 2a5123d to pass the raw f0_pred, matching both the upstream MLX reference (istftnet.py Decoder.call) and the NumPy oracle. Verified: the real-checkpoint e2e test for the test phonemes now emits exactly 31800 samples (1.325 s = 2T*300), matching ref_audio.npy and doubling the prior 15900. The conv-downsampled f0_a stays correct for the encode/decode feature concat.

Remaining Items

  • None blocking. The known gaps the PR already documents (mp3 output deferred; English-only g2p with rule-based fallback for rare words; deterministic NSF making output slightly quieter than a randomized run) are acceptable, honestly stated, and in scope as follow-ups.

Verification

  • All stated requirements implemented (e2e synthesis via /v1/audio/speech; voice, speed, response_format=wav honored; detection without model_type via istftnet block / weight filename; mlxcel arch and list show the TTS architecture; docs updated)
  • No placeholder/mock code remaining (real forward pass; numeric path validated against a NumPy oracle and an iSTFT round-trip test)
  • Integrated into project code flow (provider loaded in startup.rs for ModelType::Kokoro, stored in AppState via with_audio_model, reached through the route which gates on supports(Tts); MLX runs strictly on the AudioWorker thread, spawn_blocking only forwards over the channel)
  • Project conventions followed (Apache license headers, attribution-free git output, no em dashes, thiserror/anyhow-style error handling, Rust import/format conventions)
  • Existing modules reused (AudioWorker / AudioModelProvider seam and WAV writer from prior PRs; weight-map and stream helpers reused; no duplication)
  • No unintended structural changes (registry wiring is additive across the enum, ALL_MODEL_TYPES, metadata, tensor-parallel fallback, arch ordering; the mtp_policy.rs change is a benign checked_div refactor flagged by lint, semantics-preserving)
  • Tests pass (cargo check --lib --tests, clippy --lib --tests -D warnings, fmt, and test --lib models::kokoro 7/7 including the real-checkpoint e2e and STFT round-trip; g2p 17/17 per the orchestrator)

Robustness (spot-checked, all sound)

  • speed is sanitized in KokoroModel::synthesize (finite and > 0, else 1.0) and per-token frame counts are clamped to [1, 100] with NaN mapped to 1, so a zero/negative/huge/NaN speed cannot panic, hang, or allocate unboundedly.
  • voice name handling is whitelisted to [A-Za-z0-9_-] at both resolve_voice and VoicePack::load, must also match an existing pack, and falls back to af_heart; path traversal is fully guarded (safe_name_rejects_traversal covers ../secret, a/b, a.b, empty).
  • empty / whitespace / no-phoneme input returns a structured error; phoneme ids are truncated to MAX_TOKENS (510); g2p output is audited to stay within Kokoro's vocab.

Two hardening fixes on the attacker-reachable /v1/audio/speech path.

DoS: the TTS route inherits the 25 MiB audio body limit (sized for STT file uploads), so request.input can be ~25 MB of free text. KokoroEngine::synthesize ran the full g2p front-end (normalize plus per-word lexicon/rules) over the entire string before the model truncates to 510 tokens, so the 510-token cap protected only the MLX stage. Because synthesis runs on the single AudioWorker thread, one oversized request monopolized it and blocked all audio requests. Cap the text to MAX_INPUT_CHARS (4096) on a char boundary before any g2p work; truncate rather than reject so well-behaved long inputs still synthesize. The cap is unit-tested independently of the model (short passthrough, exact-cap, overlong, and multibyte boundary cases).

Availability: the worker loop called engine.synthesize/transcribe with no panic boundary. The kokoro forward path carries many expect/unwrap calls reachable from request input; a panic there unwound out of the loop, closed the channel, and made every later audio request fail with WORKER_GONE for the process lifetime. Wrap each engine call in catch_unwind (AssertUnwindSafe over the single-thread-owned engine), still synchronize the stream on both paths, and convert a caught panic into a per-request Inference error so the thread keeps serving. A regression test proves the worker survives a panicking request and serves the next one. Note: this backstop only takes effect when the binary unwinds; release builds set panic = "abort" in Cargo.toml, so a production build still aborts on panic. Removing that global profile setting is out of scope here and is called out in the PR review for the maintainers to decide.
@inureyes

inureyes commented Jun 19, 2026 •

Copy link
Copy Markdown
Member Author

Security & performance review (PR #374, Kokoro TTS)

Reviewed the new code on the trust boundary POST /v1/audio/speech -> g2p -> Kokoro model, plus safetensors/voice loading. The model-side guards from the prior numeric-fidelity review hold up. Two HIGH findings on the attacker-reachable path are fixed in 9204867; one residual item needs a maintainer decision.

Fixed

HIGH, DoS: unbounded g2p work before the 510-token cap. The TTS route inherits the 25 MiB audio body limit (AUDIO_MAX_UPLOAD_BYTES, sized for STT audio uploads), so request.input can be ~25 MB of free text. KokoroEngine::synthesize ran the full g2p front-end (normalize over the whole string, then a per-word lexicon/rules lookup) before KokoroModel::synthesize truncates to MAX_TOKENS=510. So the 510-token cap protected only the MLX/BERT stage, not g2p. With all synthesis on the single AudioWorker thread, one oversized request monopolizes it and stalls every audio request; repeated requests sustain an outage. Fix: cap input to MAX_INPUT_CHARS=4096 on a char boundary before any g2p work (truncate, not reject; 4096 is already ~8x the 510-token ceiling). Unit-tested independently of the model.

HIGH, availability: a synthesis panic kills the audio worker. The worker loop called engine.synthesize/transcribe with no panic boundary. The kokoro forward pass carries ~20 expect/unwrap calls reachable from request input (MLX FFI helpers, shape math). A panic there unwound out of the loop, dropped the command channel, and made every later /v1/audio/speech and /v1/audio/transcriptions request fail with WORKER_GONE for the rest of the process (the provider never respawns the thread). The route-layer spawn_blocking JoinError branch does not catch this (the panic is on the worker thread, not the pool thread). Fix: wrap each engine call in catch_unwind, still synchronize the stream on both paths, and convert a caught panic into a per-request Inference error so the worker keeps serving. Regression test proves the worker survives a panicking request and serves the next one. I could not construct a phoneme string that provably panics under the current shape math, so triggerability is plausible-but-unproven; the backstop is cheap and the expect surface is large enough to warrant it.

Needs a maintainer decision (not changed here)

panic = "abort" defeats the panic backstop in release builds. [profile.release] in Cargo.toml sets panic = "abort", and the release workflow / README build with cargo build --release. In a production binary, any attacker-reachable panic in the synthesis path aborts the entire inference server (not just audio), and catch_unwind cannot intercept an abort. The backstop above still works in dev/test and any future unwind build, and documents intent, but it does not close the production hole. Options: (a) set panic = "unwind" for the release profile (reverses a deliberate size/codegen choice, affects the whole binary), or (b) de-panic the reachable kokoro forward-path expect/unwrap sites (return Result instead). Both are broader than this TTS PR's scope, hence flagged rather than applied. Recommendation: track as a follow-up; option (b) is the more robust long-term fix.

Verified as already-safe (no change needed)

  • Voice names whitelisted [A-Za-z0-9_-] and path-traversal-guarded at both resolve_voice and VoicePack::load; no user value reaches the filesystem except the whitelisted voice name; the voice name cannot escape voices/.
  • speed sanitized (finite, > 0); per-token frame counts clamped to [1,100]; total_frames bounded by 510*100; input ids truncate to 510 before the O(L^2) BERT attention and the alignment-matrix allocation; PLBert position-embedding indices stay in-bounds at the padded length of 512.
  • No request input text logged at info level. Safetensors/voice loading is operator-controlled and returns Result (not panics) at the load_safetensors boundary.
  • g2p lexicon is a HashMap (O(1) lookup), not a linear scan; expand_integer is O(n) with a digit-by-digit fallback past u64. No O(n^2) in normalize/lexicon/rules.

Verification

Toolchain 1.93.1, narrow selectors: cargo check --lib --tests clean; cargo clippy --lib --tests -- -D warnings clean; models::g2p 17/17, server::kokoro_tts 4/4, server::audio_worker 4/4, models::kokoro 7/7 (the real-checkpoint e2e synthesis ran and passed).

Add narrow, deterministic tests that were absent:

- g2p/mod.rs: possessive clitic strip-and-retry path ("cat's" -> kætz), whitespace-only input
- kokoro/mod.rs: weight-filename detection branch (no istftnet block, file present)
- kokoro/voices.rs: resolve_voice fallback to DEFAULT_VOICE (empty dir, unknown name, unsafe name, None); resolve_voice picks known voice from tmpdir

Apply cargo fmt to audio_worker.rs (two run_guarded call sites reformatted by the formatter; no logic change).

Update docs/audio-api.md: remove the stale "TTS always returns 501" statement in the Current Status paragraph, add kokoro_tts.rs to the source map table, and add a Kokoro TTS setup section covering checkpoint detection, voices, language scope, input length cap, and current limitations.
@inureyes

Copy link
Copy Markdown
Member Author

PR Finalization Complete

Tests added

Three new test groups covering previously untested code paths (all deterministic, no checkpoint required):

  • g2p/mod.rs: possessive clitic strip-and-retry (cat's -> kætz) and whitespace-only input (tab/newline)
  • kokoro/mod.rs: weight-filename detection branch (file present, no istftnet block in config)
  • kokoro/voices.rs: resolve_voice fallback behavior (empty voices dir, None, unknown name, unsafe traversal name) and known-voice resolution via a tmpdir with a stub safetensors file

Running total: 19 g2p tests, 7 kokoro tests (non-checkpoint), 3 voice tests, 4 kokoro_tts tests, 4 audio_worker tests.

Documentation updated

docs/audio-api.md:

  • Removed the stale "TTS always returns 501" statement from the Current Status paragraph
  • Added src/server/kokoro_tts.rs to the implementation source map table
  • Added a Kokoro TTS setup section covering checkpoint detection, voice selection, language scope, input length cap, and current limitations

docs/supported-models.md: already accurate from prior commits; no changes needed.

Lint / format

cargo fmt reformatted two run_guarded call sites in audio_worker.rs (style only, no logic change). Clippy passes clean with -D warnings.

Verification

cargo check --lib --tests, cargo clippy --lib --tests -- -D warnings, cargo fmt --check: all green. All 33 targeted tests pass.

@inureyes inureyes added status:done Completed and removed status:review Under review labels Jun 19, 2026
@inureyes
inureyes merged commit 16102e9 into main Jun 19, 2026
5 checks passed
@inureyes
inureyes deleted the feat/366-kokoro-tts branch June 19, 2026 11:07
inureyes added a commit that referenced this pull request Jun 20, 2026
Audio I/O on the OpenAI /v1/audio/* surface (Whisper speech-to-text #371,
Kokoro-82M text-to-speech #374, endpoint plumbing #368), reasoning_content on
non-streaming chat (#359), pre-M5 decode +8-12% via a hardware-gated op cap
(#360), Turbo4Asym decode rerouted through dequant-SDPA (#369), batched
sampling fixes (#339, #340, #344, #356), and the Turbo KV advisor/docs aligned
to the measured sweep with ADR 0002 (#343, #376).

README: consolidate the v0.3.0 and v0.3.1 highlights under a single v0.3 entry
and add v0.3.2.
@inureyes inureyes added this to the 0.3 milestone Jun 21, 2026
inureyes added a commit that referenced this pull request Jun 21, 2026
…ind with explicit core-thread abort (#383)

* fix(server): make audio synthesis panic-safe in release via unwind

The release profile set `panic = "abort"`, which defeats the `catch_unwind` request-isolation backstop PR #374 added to the audio worker (`run_guarded` in src/server/audio_worker.rs). Under abort a synthesis panic does not unwind, so the boundary never runs and the whole server process aborts instead of the offending request degrading to a per-request error. The same setting silenced the pipeline stage-isolation `catch_unwind` in production.

Flip `[profile.release]` to `panic = "unwind"` so those deliberate containment boundaries work in the shipped binary as they already do under `cargo test` (the test harness always unwinds). The unwinding-table size and runtime cost are negligible for an MLX-linked inference server whose decode hot path does not panic.

Unwind alone would let a panic on a core generation thread silently kill that thread and leave the server alive but unable to generate, so re-impose fail-fast explicitly where it is wanted: add `run_core_thread_or_abort`, a narrow `catch_unwind` + `process::abort()` wrapper that logs without request content, and wrap the two core generation worker threads (batched and legacy sequential) in src/server/model_worker.rs. The batch scheduler, the DiffusionGemma batch-1 loop, and the disaggregated serving-role loop all run inside those two thread bodies, so the two wrappers cover every core generation path.

The audio worker `run_guarded` and the pipeline stage boundaries deliberately contain panics and are not wrapped. There is no global abort panic hook on purpose: a hook runs before unwinding and would defeat those backstops. The default panic hook (prints panic + backtrace) is kept.

An MLX C++ FFI exception still becomes `std::terminate` rather than a Rust panic and terminates the process; that residual vector is unchanged here and tracked as issue #382.

Adds a happy-path unit test for `run_core_thread_or_abort` (the abort path terminates the test runner and is verified manually), documents that the existing `worker_survives_engine_panic_and_keeps_serving` test now represents release behavior, and records the decision and rejected options in docs/adr/0003.

* fix(server): wrap remote pipeline stage thread for fail-fast parity

Release `panic = "unwind"` (issue #375) made a panic on the remote pipeline stage service thread (`RemoteStageService::run` in src/distributed/pipeline/remote_service.rs) unwind and silently kill the thread while `serve_remote_pipeline_stage` stays parked on `ctrl_c`, leaving a zombie stage process a supervisor will not restart. Under the previous `panic = "abort"` such a panic aborted the stage cleanly, so the unwind flip was a fail-fast regression for a core inference thread that this PR had not covered.

Relocate `run_core_thread_or_abort` from src/server/model_worker.rs to a shared crate-level module (src/worker_failfast.rs) reachable from both the server workers and the distributed pipeline, generalizing it to forward the body's return value so a recoverable `Err` propagates unchanged and only a panic aborts. Wrap the remote stage service thread with it; the `JoinHandle<Result<()>>` type and the `shutdown()` join handling are unchanged on the non-panic path. The local batched and legacy `--no-batch` workers keep their existing wrapping, no global panic hook is added, and the audio worker `run_guarded` containment is untouched.

Correct the ADR 0003, the Cargo.toml profile comment, and architecture.md, which claimed the pipeline stage "stays contained" with its own `catch_unwind`. There is no production `catch_unwind` under src/distributed/ (the only one is `#[cfg(test)]`). The audio worker is now described as the only deliberately contained boundary, all core inference threads (batched, legacy, and remote stage) fail-fast explicitly through `run_core_thread_or_abort`, and pipeline faults are handled at the coordinator by `Result` propagation plus stage timeout and health probing. The MLX FFI `std::terminate` residual (#382) and the always-unwind `cargo test` notes are kept.

* test(worker): add Err-forwarding test for run_core_thread_or_abort

The existing happy-path test exercises the void return case. This adds
a test with a Result-returning body that returns Err, confirming the
wrapper forwards a recoverable error unchanged and does not abort.
@inureyes inureyes self-assigned this Aug 31, 2026
be-free-snowboy pushed a commit to freestylejs/snowbrain-mlxcel that referenced this pull request Oct 27, 2026
…ch (lablup#374)

* feat(models): add English grapheme-to-phoneme front-end for TTS

Kokoro and other phoneme-input TTS models consume IPA phonemes, not raw text, so this adds a self-contained American-English g2p front-end (the host-side analogue of an STT mel front-end).

Pipeline: normalize text (lower-case, speak integers, keep common punctuation), look each word up in a bundled lexicon, and fall back to deterministic letter-to-sound rules for out-of-vocabulary words. Output uses only the IPA symbols in Kokoro's vocab, so no symbol is silently dropped at tokenization. No external binary or download is required.

Modules: normalize (tokenizer + number expansion), lexicon (compiled-in word to IPA table with a symbol-audit test), rules (digraph and single-letter letter-to-sound with stress placement), and the orchestrator text_to_phonemes. Language scope is American English; per-language packs are future work.

* feat(models): port Kokoro-82M StyleTTS2 + iSTFTNet TTS to MLX

Port the Kokoro-82M text-to-speech model to MLX/Rust. Kokoro is a StyleTTS2 phoneme-to-mel acoustic model with a built-in iSTFTNet vocoder, so it produces a 24 kHz mono waveform directly with no separate neural codec.

Forward pass: phoneme ids to a PLBert text encoder (a custom ALBERT with one weight-shared layer reused 12 times, gelu_new activation), a duration predictor (DurationEncoder bi-LSTMs + AdaLayerNorm, a duration head, and an alignment matrix that expands per-token features to per-frame), F0 and N prosody (a shared bi-LSTM plus AdainResBlk1d stacks), an acoustic text encoder, and the iSTFTNet decoder (encode/decode AdainResBlk1d blocks, transposed-conv upsampling, an NSF harmonic source, Snake-activated MRF residual blocks, conv_post, and an inverse STFT with magnitude = exp and phase = sin).

Implementation notes: weight-norm conv kernels are reconstructed from weight_g/weight_v and transposed from PyTorch layout to MLX channels-last; grouped (depthwise) transposed convs use a distinct weight transpose. MLX has no recurrent primitive, so bidirectional LSTMs are scanned by hand. The inverse STFT uses an explicit real inverse-DFT with Hermitian bin doubling and the standard 1/N normalization, matching the PyTorch reference (mag * exp(j*phase), periodic Hann, no phase unwrap), which avoids depending on MLX complex64 byte layout. The NSF source is deterministic (zero initial phase, no additive noise) so output is reproducible.

Registry: add ModelType::Kokoro with metadata ("Kokoro (StyleTTS2 + iSTFTNet)", "Text-to-speech"), register it in ALL_MODEL_TYPES and the static descriptor and tensor-parallel fallback tables (served via the audio endpoints, never text generation), and add the Text-to-speech family to the `mlxcel arch` ordering.

Tests: STFT round-trip at unit scale, alignment-matrix construction, voice-pack loading and indexing, detection by the istftnet block, and a checkpoint-gated end-to-end synthesis that checks 24 kHz output, non-trivial duration, finite bounded samples, and that speed scales duration. The end-to-end tests load the real prince-canuma/Kokoro-82M checkpoint when present (KOKORO_MODEL_DIR or models/kokoro-82m) and skip otherwise.

Also rewrite mean_prompt_len with checked_div to satisfy the workspace clippy gate.

* feat(server): wire Kokoro TTS provider to /v1/audio/speech

Connect the Kokoro model to the existing audio seam so `POST /v1/audio/speech` synthesizes real audio.

KokoroTtsProvider mirrors the Whisper STT provider: it owns an AudioWorker that loads the checkpoint and runs every synthesis on one dedicated, stream-initialized thread (MLX work is thread-affine), and the provider itself holds no MLX handles. The engine runs the g2p front-end (text to phonemes) then the acoustic model and built-in vocoder, returning f32 PCM that the route encodes to WAV via the existing lablup#364 writer. The voice request field selects a voices/ pack (validated, default af_heart), speed scales duration, and response_format honors wav.

Detection: Kokoro's config.json has no top-level model_type, so get_model_type checks is_kokoro_checkpoint (the istftnet config block or the kokoro-v1_0.safetensors filename) before the model_type dispatch that would otherwise error. Startup loads KokoroTtsProvider when the checkpoint resolves to ModelType::Kokoro. No existing model's detection is affected.

Docs: add a Text-to-speech section to supported-models.md describing the flow, detection, request fields, the g2p approach and its English-only scope, and the single-model serving constraint.

Closes lablup#366

* fix(kokoro): feed raw 2T predictor F0 to the iSTFTNet generator

The decoder handed the NSF harmonic source the conv-downsampled F0 curve (f0_a, length T) instead of the raw predictor F0 (f0_pred, length 2T). The generator upsamples its F0 input by 300, so the downsampled curve produced a harmonic source at T*300 samples, half the intended 2T*300, which after the align_time trim left the synthesized waveform at roughly half its correct duration (and pitched off the smoothed, strided curve rather than the raw pitch).

Both the upstream MLX reference (istftnet.py Decoder.__call__ passes the raw F0_pred to generator, computing the downsampled F0 only for the encode/decode concat) and the NumPy oracle pass the full-resolution F0_pred. The conv-downsampled f0_a stays correct for the feature concatenation; only the generator's source curve changes to f0_pred.

Verified against the oracle: for the test phonemes the fixed path emits exactly 31800 samples (1.325 s, = 2T*300 with 2T=106), matching ref_audio.npy and doubling the prior 15900. cargo check --lib --tests, clippy --lib --tests -D warnings, fmt, and test --lib models::kokoro (7/7, real-checkpoint e2e included) are clean.

* fix(kokoro): bound TTS input and survive synthesis panics

Two hardening fixes on the attacker-reachable /v1/audio/speech path.

DoS: the TTS route inherits the 25 MiB audio body limit (sized for STT file uploads), so request.input can be ~25 MB of free text. KokoroEngine::synthesize ran the full g2p front-end (normalize plus per-word lexicon/rules) over the entire string before the model truncates to 510 tokens, so the 510-token cap protected only the MLX stage. Because synthesis runs on the single AudioWorker thread, one oversized request monopolized it and blocked all audio requests. Cap the text to MAX_INPUT_CHARS (4096) on a char boundary before any g2p work; truncate rather than reject so well-behaved long inputs still synthesize. The cap is unit-tested independently of the model (short passthrough, exact-cap, overlong, and multibyte boundary cases).

Availability: the worker loop called engine.synthesize/transcribe with no panic boundary. The kokoro forward path carries many expect/unwrap calls reachable from request input; a panic there unwound out of the loop, closed the channel, and made every later audio request fail with WORKER_GONE for the process lifetime. Wrap each engine call in catch_unwind (AssertUnwindSafe over the single-thread-owned engine), still synchronize the stream on both paths, and convert a caught panic into a per-request Inference error so the thread keeps serving. A regression test proves the worker survives a panicking request and serves the next one. Note: this backstop only takes effect when the binary unwinds; release builds set panic = "abort" in Cargo.toml, so a production build still aborts on panic. Removing that global profile setting is out of scope here and is called out in the PR review for the maintainers to decide.

* test(kokoro): add missing unit tests and fix fmt; update audio-api.md

Add narrow, deterministic tests that were absent:

- g2p/mod.rs: possessive clitic strip-and-retry path ("cat's" -> kætz), whitespace-only input
- kokoro/mod.rs: weight-filename detection branch (no istftnet block, file present)
- kokoro/voices.rs: resolve_voice fallback to DEFAULT_VOICE (empty dir, unknown name, unsafe name, None); resolve_voice picks known voice from tmpdir

Apply cargo fmt to audio_worker.rs (two run_guarded call sites reformatted by the formatter; no logic change).

Update docs/audio-api.md: remove the stale "TTS always returns 501" statement in the Current Status paragraph, add kokoro_tts.rs to the source map table, and add a Kokoro TTS setup section covering checkpoint detection, voices, language scope, input length cap, and current limitations.
be-free-snowboy pushed a commit to freestylejs/snowbrain-mlxcel that referenced this pull request Oct 27, 2026
Audio I/O on the OpenAI /v1/audio/* surface (Whisper speech-to-text lablup#371,
Kokoro-82M text-to-speech lablup#374, endpoint plumbing lablup#368), reasoning_content on
non-streaming chat (lablup#359), pre-M5 decode +8-12% via a hardware-gated op cap
(lablup#360), Turbo4Asym decode rerouted through dequant-SDPA (lablup#369), batched
sampling fixes (lablup#339, lablup#340, lablup#344, lablup#356), and the Turbo KV advisor/docs aligned
to the measured sweep with ADR 0002 (lablup#343, lablup#376).

README: consolidate the v0.3.0 and v0.3.1 highlights under a single v0.3 entry
and add v0.3.2.
be-free-snowboy pushed a commit to freestylejs/snowbrain-mlxcel that referenced this pull request Oct 27, 2026
…ind with explicit core-thread abort (lablup#383)

* fix(server): make audio synthesis panic-safe in release via unwind

The release profile set `panic = "abort"`, which defeats the `catch_unwind` request-isolation backstop PR lablup#374 added to the audio worker (`run_guarded` in src/server/audio_worker.rs). Under abort a synthesis panic does not unwind, so the boundary never runs and the whole server process aborts instead of the offending request degrading to a per-request error. The same setting silenced the pipeline stage-isolation `catch_unwind` in production.

Flip `[profile.release]` to `panic = "unwind"` so those deliberate containment boundaries work in the shipped binary as they already do under `cargo test` (the test harness always unwinds). The unwinding-table size and runtime cost are negligible for an MLX-linked inference server whose decode hot path does not panic.

Unwind alone would let a panic on a core generation thread silently kill that thread and leave the server alive but unable to generate, so re-impose fail-fast explicitly where it is wanted: add `run_core_thread_or_abort`, a narrow `catch_unwind` + `process::abort()` wrapper that logs without request content, and wrap the two core generation worker threads (batched and legacy sequential) in src/server/model_worker.rs. The batch scheduler, the DiffusionGemma batch-1 loop, and the disaggregated serving-role loop all run inside those two thread bodies, so the two wrappers cover every core generation path.

The audio worker `run_guarded` and the pipeline stage boundaries deliberately contain panics and are not wrapped. There is no global abort panic hook on purpose: a hook runs before unwinding and would defeat those backstops. The default panic hook (prints panic + backtrace) is kept.

An MLX C++ FFI exception still becomes `std::terminate` rather than a Rust panic and terminates the process; that residual vector is unchanged here and tracked as issue lablup#382.

Adds a happy-path unit test for `run_core_thread_or_abort` (the abort path terminates the test runner and is verified manually), documents that the existing `worker_survives_engine_panic_and_keeps_serving` test now represents release behavior, and records the decision and rejected options in docs/adr/0003.

* fix(server): wrap remote pipeline stage thread for fail-fast parity

Release `panic = "unwind"` (issue lablup#375) made a panic on the remote pipeline stage service thread (`RemoteStageService::run` in src/distributed/pipeline/remote_service.rs) unwind and silently kill the thread while `serve_remote_pipeline_stage` stays parked on `ctrl_c`, leaving a zombie stage process a supervisor will not restart. Under the previous `panic = "abort"` such a panic aborted the stage cleanly, so the unwind flip was a fail-fast regression for a core inference thread that this PR had not covered.

Relocate `run_core_thread_or_abort` from src/server/model_worker.rs to a shared crate-level module (src/worker_failfast.rs) reachable from both the server workers and the distributed pipeline, generalizing it to forward the body's return value so a recoverable `Err` propagates unchanged and only a panic aborts. Wrap the remote stage service thread with it; the `JoinHandle<Result<()>>` type and the `shutdown()` join handling are unchanged on the non-panic path. The local batched and legacy `--no-batch` workers keep their existing wrapping, no global panic hook is added, and the audio worker `run_guarded` containment is untouched.

Correct the ADR 0003, the Cargo.toml profile comment, and architecture.md, which claimed the pipeline stage "stays contained" with its own `catch_unwind`. There is no production `catch_unwind` under src/distributed/ (the only one is `#[cfg(test)]`). The audio worker is now described as the only deliberately contained boundary, all core inference threads (batched, legacy, and remote stage) fail-fast explicitly through `run_core_thread_or_abort`, and pipeline faults are handled at the coordinator by `Result` propagation plus stage timeout and health probing. The MLX FFI `std::terminate` residual (lablup#382) and the always-unwind `cargo test` notes are kept.

* test(worker): add Err-forwarding test for run_core_thread_or_abort

The existing happy-path test exercises the void return case. This adds
a test with a Result-returning body that returns Err, confirming the
wrapper forwards a recoverable error unchanged and does not abort.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:inference Generation, sampling, decoding (incl. speculative, DRY) area:models Model architectures, weights, loading, metadata priority:medium Medium priority status:done Completed type:enhancement New features, capabilities, or significant additions

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(models): TTS model + vocoder for text-to-speech (/v1/audio/speech)

1 participant