Skip to content

server : add POST /tts endpoint for TTS models - #26603

Merged
ngxson merged 19 commits into
masterfrom
xsn/server_tts_2
Oct 20, 2026
Merged

ngxson merged 19 commits into
masterfrom
xsn/server_tts_2

Conversation

@ngxson

@ngxson ngxson commented Aug 4, 2026 •

Copy link
Copy Markdown
Collaborator

Overview

Follow-up #26254

Example:

curl -X POST http://127.0.0.1:9931/tts \
    -H "Content-Type: application/json" \
    -d '{"input": "Hello, this is a test."}' \
    -o output.wav

With a speaker reference uploaded as a file (multipart/form-data), instead of base64-encoding it into the JSON body:

curl -X POST http://127.0.0.1:9931/tts \
    -F "input=Hello, this is a test." \
    -F "speaker_ref=@/path/to/speaker-reference.wav;type=audio/wav" \
    -o output.wav

  • Add support for streaming output (mtmd helper)
  • Add /tts endpoint to server
  • Add docs
  • Throw error if model cannot be used for chat (mtmd_helper_model_can_chat)
  • Update qwen3-tts docs to specify about ref voice requirements
  • Update router mode to reflect if model is TTS-capable

Next PRs:

  • Add --tts-speaker-dir that allows specify voice name via API, without including them as base64
  • Support OAI-compat TTS endpoint

Requirements

  • I have read and agree with the contributing guidelines
  • AI usage disclosure: most code written by AI, based on my plan

@github-actions github-actions Bot added server mtmd Related to multimodal functionality (video/image/audio) labels Aug 4, 2026
@github-actions github-actions Bot added the documentation Improvements or additions to documentation label Aug 4, 2026
@mudler

mudler commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

@ngxson sorry for the AI automated report, but as I was trying to port qwen3 tts to LocalAI I've met a blocker (which is reported by the bot)

@ngxson

ngxson commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator Author

@mudler fwiw, the project's policy forbidden using automated bots to reply to maintainers. Please remove all bot's replies as they are not very readble and quite spam, otherwise, we will need to ban your bot, as mentioned in project's policy

dltsyxs pushed a commit to dltsyxs/llama.cpp that referenced this pull request Aug 11, 2026
… slots bypass the shared batch (pre_decode() returns early for them), so they never run the per-request KV hygiene the normal prompt path does. Second request on the same slot failed with 'inconsistent sequence positions' / 'TTS prompt processing failed' (reproducible with -np 1 or -c 4096). Fix per LocalAI report on PR ggml-org#26603: slot.prompt_clear() before set_input. Verified: 6/6 consecutive requests pass on -c 4096 single slot.
dltsyxs pushed a commit to dltsyxs/llama.cpp that referenced this pull request Aug 11, 2026
…the cherry-picked upstream PR ggml-org#26603 were written by DeepSeek-V4-Flash-0731 + QwenPaw (AI-assisted). Add a notice for transparency.
@Lberondroth

This comment was marked as low quality.

truecharts-admin added a commit to trueforge-org/truecharts that referenced this pull request Aug 20, 2026
#51645)

> ℹ️ **Note**
> 
> This PR body was truncated due to platform limits.

This PR contains the following updates:

| Package | Update | Change |
|---|---|---|
|
[docker.io/localai/localai](https://redirect.github.com/mudler/LocalAI)
| minor | `df29190` → `d78cd11` |

---

> [!WARNING]
> Some dependencies could not be looked up. Check the [Dependency
Dashboard](../issues/18710) for more information.

Add the preset `:preserveSemverRanges` to your config if you don't want
to pin your dependencies.

---

### Release Notes

<details>
<summary>mudler/LocalAI (docker.io/localai/localai)</summary>

###
[`v4.9.0`](https://redirect.github.com/mudler/LocalAI/releases/tag/v4.9.0)

[Compare
Source](https://redirect.github.com/mudler/LocalAI/compare/v4.8.2...v4.9.0)

### 🎉 LocalAI 4.9.0 Release! 🚀

<h1 align="center">
  <br>
<img height="300"
src="https://raw.githubusercontent.com/mudler/LocalAI/refs/heads/master/core/http/static/logo.png">
  <br>
  <br>
</h1>

LocalAI 4.9.0 is out!

Thirteen days and 146 pull requests, spent on the parts of LocalAI you
touch every day rather than on new engines. Authentication is now
deny-by-default, chat gained end-to-end context compression, models and
backends each have one canonical page instead of three, and `vllm-cpp`
grew a video modality serving MiniMax-H3 with a real audio track.

**Highlights:**

- 🔐 **Authentication is deny-by-default** - every HTTP route requires
credentials unless it appears in an explicit public registry. This
closes a class of bypass in which unprefixed aliases such as
`/moderations`, `/models`, `/backends` and `/mcp/chat/completions` fell
outside the old protected-prefix list. Reported by [Naor
Yaacov](https://www.linkedin.com/in/naor-yaacov/).
- 🗜️ **Chat context compression** - opt-in per model, older complete
turns are compressed through a LocalAI model before inference,
preserving system prompts, the newest messages and whole tool-call
units. Ratio and duration come back as response metadata and metrics.
- 🖥️ **One page per resource** - `/app/models` now owns Explore and
Installed, `/app/backends` owns Catalog and Installed, and the nested
Host view is gone. Old `/app/manage` bookmarks still work.
- 🎬 **MiniMax-H3 video generation** - `vllm-cpp` opens a second engine
handle for the H3 checkpoint set and renders video and audio jointly, so
the MP4 arrives with a real AAC track. Ask for speech in the prompt and
the model lip-syncs it.
- 🗣️ **Qwen3-TTS on llama.cpp** - text-to-speech on the full accelerator
matrix already shipped for text generation (CUDA, ROCm, SYCL, Vulkan,
Metal, L4T), using upstream's own GGUF conversion.
- 🧭 **KNN as a first-class router** - similarity-weighted voting over a
curated, persisted corpus of labelled prompts. No classifier model, and
a prompt unlike anything labelled is treated as undecidable rather than
guessed.
- 📊 **Global admission control and live backend traces** - process-wide
HTTP admission bounds, in-flight backend operations are represented
while they run, and the UI links straight to their logs.
- 🕵️ **Reversible PII pseudonyms** - masked values become request-scoped
deterministic pseudonyms (`EMAIL_001`) and are restored if the backend
echoes them, across JSON and SSE tokens split over writes.
- 📦 **Parallel Hugging Face downloads** - snapshot materialization runs
up to N whole-file transfers at once, so a repository split into many
shards stops spending its wall clock in per-file latency.
- 🖧 **Cold model loads are durable jobs** - the per-model advisory lock
no longer spans a multi-GB transfer, which had made a 35.7 GB load look
permanently broken from the operator's seat while staging progressed
normally underneath.
- 🎮 **vllm-cpp covers the cards you own** - CUDA builds went from one or
two architectures to eight on amd64 and five on arm64, picking up A100,
L4, 4090, H100/H200, B200, Jetson Orin and Jetson Thor.

Plus a single shared WebRTC UDP port for Realtime, Metal actually
enabled in the macOS Stable Diffusion and Parakeet builds, backend crash
diagnostics at the default log level, and Portuguese (Brazil) and
Indonesian UI translations.

<p align="center">
<img width="2880" height="1800" alt="ui-models-explore"
src="https://github.com/user-attachments/assets/7dd68a4a-e707-43e6-af1a-dcc3922469eb"
/>
<br><em>Models and backends each get one canonical page, with Explore
and Installed as views rather than separate destinations.</em>
</p>

***

#### 📊 This release in numbers

|                      |                                    |
| -------------------- | ---------------------------------- |
| Pull requests merged | **146**                            |
| Commits              | 148                                |
| Files changed        | 420 (**+27,788** / -4,670)         |
| Development window   | 13 days (2026-08-07 to 2026-08-20) |
| Human contributors   | 10, of whom **3 first-time**       |
| Gallery entries      | 1,622 to **1,707** (+85)           |

Where the work landed:

| Area       | Change                            |
| ---------- | --------------------------------- |
| `core/`    | +17,193 / -4,187 across 279 files |
| `gallery/` | +3,808 / -62                      |
| `backend/` | +3,298 / -177 across 60 files     |
| `pkg/`     | +1,180 / -113 across 31 files     |
| `swagger/` | +1,095 / -5                       |
| `docs/`    | +952 / -98 across 23 files        |

***

#### 📌 TL;DR

| Area | Summary |
| ------------------------------ |
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
|
| 🔐 **Auth by default** | Every method and path requires credentials
unless listed in an explicit public registry. API instructions, Swagger
GETs, the LocalAI well-known document, and the health, login, OAuth,
SPA, asset, branding and node registration-token bootstrap flows stay
public. **Migration:** with database auth or legacy API keys configured,
`/version` and generated audio, image, video and 3D URLs now require
credentials. Embedded deployments can add narrow prefixes through
`ApplicationConfig.PathWithoutAuth`, and the legacy GET exemption flags
remain as explicit compatibility overrides. |
| 🗜️ **Context compression** | Opt-in per-model `compression` config.
Older complete turns are compressed through a configured LocalAI model
before inference, after PII filtering and Assistant/MCP prompt injection
(including later MCP iterations). Leading system/developer prompts, the
newest messages and complete tool-call/result units are preserved; tool
schemas and completion headroom are accounted for with a conservative
offline token bound. Metadata rides non-streaming responses and
streaming usage trailers, with event, ratio and duration metrics
exported. Disabled by default; cloud-proxy passthrough is rejected
(translate mode works). |
| 🖥️ **Unified lifecycle UI** | `/app/models` owns **Explore** and
**Installed** with URL-backed search, state and selection;
`/app/backends` owns **Catalog** and **Installed** while keeping
variants, development builds and target-node scope. Explore offers
capability-aware Open and Manage installation; destructive model
controls stay in Installed. Operate Overview shows host capacity from
its shared summary poller, and the nested Host destination is removed.
`/app/manage` redirects while preserving legacy query state. No API
change. |
| 📥 **Import form rebuild** | The import page moves to `page--medium`
with a work column and the format reference beside it rather than behind
a closed chevron. The source field is the hero and carries its own
Import button, which removes the `aria-hidden` submit that existed only
because the real action sat outside the `<form>`. Simple and Advanced
modes are gone (about 80% the same surface); the real distinction, a
source or YAML, is now two tabs. Also fixes two class bugs: a primary
button with no `className` at all falling through to browser chrome, and
`class="btn btn-primary fas fa-save fa-upload"` setting Font Awesome as
the button's own font while two icons fought over one `::before`. |
| 🎬 **MiniMax-H3 video** | `vllm-cpp` over vllm.cpp ABI v12. A second
engine handle loads the H3 checkpoint set (the DiT is
`parameters.model`, the text encoder and two VAEs are named in
`options:`), and `GenerateVideo` renders video and audio jointly into an
MP4 with a real AAC track. The DiT partition is declared, not detected:
community quantizations strip the release metadata and the FL2VA and
Ref2VA DiTs are byte-structurally identical, so
`checkPartitionConditioning` refuses a reference-conditioned FL2VA
request before the engine runs (it would otherwise render for hours and
return a coloured lattice). ffmpeg comes from the host: libvllm composes
the mux argv and spawns nothing. New gallery entry
`minimax-h3-fl2va-q4`. |
| 🗣️ **Qwen3-TTS on llama-cpp** | TTS through the `llama-cpp` backend on
CUDA, ROCm, SYCL, Vulkan, Metal and L4T, using upstream's GGUF
conversion. Implemented as a slot-based `SERVER_TASK_TYPE_TTS` task,
which is the concurrency-safe integration given that `server_context`
owns the `llama_context` and runs the slot scheduler on its own thread.
Carries the still-draft upstream server hunks as
`patches/0002-add-server-task-type-tts.patch` (delete on merge of
[ggml-org/llama.cpp#26603](https://redirect.github.com/ggml-org/llama.cpp/issues/26603)).
Gallery: `qwen3-tts-llamacpp` and `qwen3-tts-llamacpp-q4`. The existing
`qwen3-tts-cpp` backend is untouched and remains a separate path. |
| 🧭 **KNN routing** | `classifier: knn` routes by similarity-weighted
voting over labelled example prompts, so no classifier model is needed
and label knowledge lives in a corpus you seed and curate. Entries below
`knn.similarity_threshold` cannot vote; when none clears it the router
takes the fallback, and `nearest_similarity` is recorded on decisions
and fallbacks alike. One JSONL file per router under `<data
path>/router-corpus` is the source of truth, with the in-memory index
rebuilt at classifier build time and entries re-embedded when the
embedding model changed. Corpus input is API-only by design: `POST
/api/router/{name}/corpus`, `GET .../corpus/stats` (label counts only,
texts are never returned), `DELETE .../corpus`, admin-gated and exposed
as MCP tools. |
| 📊 **Admission and traces** | Process-wide HTTP admission control,
bounding what was previously only per backend. Backend operations are
represented while in flight, and running backend traces surface in the
UI with immediate log links. |
| 🕵️ **PII pseudonyms** | Opt-in `pii.reverse_in_response`. Masked
request values become unique deterministic pseudonyms within the request
(`EMAIL_001`, `EMAIL_002`) and are restored if the backend returns them,
including SSE tokens split across response writes. Substitution maps are
request-local and never persisted. Irreversible `[REDACTED:...]` remains
the default. |
| 📦 **Parallel HF downloads** | `DownloadFilesWithConcurrency` runs up
to N whole-file transfers through an `errgroup` with `SetLimit`. Single
files are never split, so `.partial` resume and per-file SHA
verification are untouched, and the two non-artifact callers keep
sequential ordering and fail-fast behaviour through a limit-of-1
wrapper. `completedBytes` became an `atomic.Int64` (the race detector
reported three races otherwise) and the caller's status callback stays
serialized. |
| 📞 **Realtime WebRTC port** | `--web-rtc-udp-port` /
`LOCALAI_WEBRTC_UDP_PORT` reuses one Pion ICE UDP mux across Realtime
calls, with bind failures surfaced through signaling and
container/firewall setup documented. The follow-up fix keeps
`LOCALAI_WEBRTC_ICE_INTERFACES` effective when a fixed port is set,
which had been silently ignored: a wildcard mux made pion enumerate
every interface itself, handing browsers unroutable `172.x` candidates
that dropped once ICE consent checks failed. |
| 🖧 **Durable cold loads** | The per-model advisory lock is a dedup
decision measured in milliseconds, not a transfer's lifetime. Cold loads
now run as durable jobs instead of holding it across backend install,
multi-GB staging and checkpoint load, and `WithLockCtx` now defends
against `statement_timeout` as well as `lock_timeout` (both abort the
same blocking `pg_advisory_lock`, only the latter was overridden). |
| 🎮 **vllm-cpp CUDA coverage** | amd64 goes from `120a;121a` to
`80;86;89;90a;100a;103a;120a;121a`, arm64 from `121a` to
`87;90a;100a;110;121a`, split by where the silicon exists. An unlisted
card did not run slower, it died at the first request with `no kernel
image is available for execution on the device`, long after install
reported success. The CUDA 13 guard now covers both branches, and
Triton-AOT stays on. |
| 🌍 **Two new languages** | Portuguese (Brazil), a complete 14-namespace
translation at full key parity with `en/`, and Indonesian for the admin,
media and navigation surfaces. |
| 🧠 **Models** | 85 new gallery entries: Qwen3.8 (9B, 27B, Ridge and
small variants), Gemma 4 agentic and Scotoma 2, DeepSeek V4 Pro 0813,
Ling 3.0 Flash, Nemotron 3.5 Lightning 30B, Tess 4 27B, Ornith 1.0 and
1.5 9B, LFM2.5 230M and VL 1.6B, HunyuanOCR and OvisOCR2, Higgs Audio v3
TTS, MiniMax-H3 Ref2VA, plus vllm.cpp text-generation entries and a
first Carbon genomics family. |

***

#### 🚀 New Features & Major Enhancements

##### 🔐 Authentication now denies by default

The previous classifier gated selected API-style paths by prefix.
Anything whose path was not on that list was public, which meant
unprefixed aliases (`/mcp/chat/completions`, `/moderations`, `/models`,
`/backends`, `/import-model`) could bypass global authentication, and
any newly registered route inherited the same weakness by default.

The middleware is now method-aware and denies by default: a route is
public only if its method and path appear in an explicit public
registry. What stays public is the set required to bootstrap and to be
discoverable: API instructions, Swagger GET routes, the LocalAI
well-known document, and the health, login, OAuth, SPA, asset, branding
and node registration-token flows. Whole-router coverage is asserted in
tests, so a new route cannot become public by omission.

**Migration impact.** When database authentication or legacy API keys
are configured, `/version` and generated audio, image, video and 3D URLs
now require credentials. Embedded deployments can still add narrow
prefixes via `ApplicationConfig.PathWithoutAuth`, and the legacy GET
exemption flags remain available as explicit compatibility overrides.

Thanks to [Naor Yaacov](https://www.linkedin.com/in/naor-yaacov/) for
reporting this class of authentication bypass.

> 🔗 PRs:
[#&#8203;11602](https://redirect.github.com/mudler/LocalAI/issues/11602)

##### 🗜️ End-to-end context compression

A long conversation eventually stops fitting. Compression is opt-in per
model, and when enabled it compresses older complete turns through a
configured LocalAI model before inference rather than truncating them
away.

What it will not touch: leading system and developer safety prompts, the
newest messages, and complete tool-call/result units, which are kept
whole so a compressed history never leaves a call without its result. It
runs after PII filtering and after Assistant/MCP prompt injection,
including on later MCP iterations, so what gets compressed is the prompt
that would actually have been sent. Tool schemas and completion headroom
are accounted for with a conservative offline token bound.

Compression metadata is exposed in non-streaming responses and in
streaming usage trailers, and compression events, ratios and durations
are exported as metrics. Cloud-proxy passthrough configurations reject
compression because LocalAI cannot safely rewrite an opaque provider
payload; translate mode is supported. A late failure in an
already-started stream is returned as an in-band SSE error followed by
`[DONE]`.

> 🔗 PRs:
[#&#8203;11556](https://redirect.github.com/mudler/LocalAI/issues/11556)

##### 🖥️ One canonical page per resource

Models had a gallery and a separate Host management surface. Backends
had a nested Host view for installed binaries. Between them it was not
obvious where a resource lived, and the common lifecycle actions sat one
level deeper than they needed to.

Each resource now has one page. `/app/models` owns **Explore** and
**Installed**, `/app/backends` owns **Catalog** and **Installed**, both
with URL-backed search, state and selection, and backends keep their
variants, development builds and target-node scope. Explore presents
capability-aware **Open** and **Manage installation** actions while
destructive model controls stay in Installed. Operate Overview reads
host capacity from its shared summary poller, and the nested Host
destination is removed. Narrow list/detail views restore focus to the
originating row when you come back from a detail view.

Nothing in the API changed, and existing `/app/manage` bookmarks keep
working through a replace redirect that preserves legacy model and
backend query state.

<p align="center">
<img width="2880" height="1800" alt="ui-backends-catalog"
src="https://github.com/user-attachments/assets/c18e9272-70bd-4875-bda0-319bea616637"
/>
<br><em>Catalog and Installed as views of one page, with target-node
scope intact.</em>
</p>

> 🔗 PRs:
[#&#8203;11548](https://redirect.github.com/mudler/LocalAI/issues/11548)

##### 📥 The import form, rebuilt

The import page had taken the new palette but kept its old layout: a
760px column with the primary action detached from the form it submits.
Two of its problems were outright bugs.

`ImportModel.jsx:808` carried no `className` at all, so the page's
single most important control fell through to the user-agent button,
with system chrome, system font, the wrong radius and no design-system
focus ring. Next to it, `class="btn btn-primary fas fa-save fa-upload"`
set Font Awesome as the button's own font family, which its label text
inherited, while `fa-save` and `fa-upload` fought over one `::before`.

The layout moves to `page--medium` with a work column and the format
reference beside it, since that reference answers the only question a
first-time admin has and used to sit behind a chevron that was closed by
default. Below 1024px it becomes a disclosure instead of disappearing.
The source field is the hero, monospace because it holds something you
paste, and it carries its own Import button, which removes the
`aria-hidden` submit that existed only to compensate for the real action
sitting outside the `<form>`. Simple and Advanced modes are gone: they
were about 80% the same surface, and the overlap cost a mode switch, a
localStorage key and a three-button Keep/Discard/Cancel dialog whose
only job was protecting state the switch would have hidden. What
genuinely differs is the kind of input, which is now two tabs: a source,
or YAML. The size and VRAM estimate reports under the field that
produced it instead of as a banner above the page header.

A follow-up swept the same class of bug across the rest of the UI: eight
header controls on seven pages had two or three elements' classes
collapsed into one string.

<p align="center">
<img width="2880" height="1800" alt="ui-import-model"
src="https://github.com/user-attachments/assets/d8abd9d6-81fa-4ef7-8062-4bcf3836a27a"
/>
<br><em>One form, a collapsible options panel, and the format reference
where you can read it.</em>
</p>

> 🔗 PRs:
[#&#8203;11461](https://redirect.github.com/mudler/LocalAI/issues/11461),
[#&#8203;11462](https://redirect.github.com/mudler/LocalAI/issues/11462),
[#&#8203;11488](https://redirect.github.com/mudler/LocalAI/issues/11488)

##### 🎬 MiniMax-H3: video and audio, jointly

vllm.cpp's stable C ABI grew a video slice at v12, and `vllm-cpp` now
serves two things. Text generation is unchanged. When a model config
declares the H3 checkpoint set, `Load` opens a video engine instead and
`GenerateVideo` renders a clip through LocalAI's existing `/video`
endpoint, with **video and audio generated jointly**, so the MP4 comes
back with a real AAC track rather than silent. Ask for speech in the
prompt and the model lip-syncs it.

Three things shape the integration:

**The video engine is a second handle, not a mode of the first.** H3 is
not a model directory. The DiT, the text encoder and two VAEs are
separate artifacts, and vllm.cpp's two loaders refuse each other's
checkpoints. `parameters.model` is the DiT; the rest of the set is named
in `options:`.

**The DiT partition is declared, not detected.** The FL2VA DiT serves
`t2va` and `fl2va`; `ref2va` is a different checkpoint. Community GGUF
and NVFP4 quantizations strip the release metadata and the two DiTs are
byte-structurally identical, so the engine refuses to generate until it
is told which it has. Handing reference conditioning to an FL2VA DiT
renders for hours and returns a coloured lattice over the frame, so
`checkPartitionConditioning` rejects that combination before the engine
is ever called.

**ffmpeg comes from the host.** libvllm writes the frames and the WAV
and composes the mux argv, then spawns nothing, which is a deliberate
upstream process boundary. The backend substitutes `argv[0]` and execs
it.

Gallery entries `minimax-h3-fl2va-q4` (Q4\_K\_M FL2VA set) and
`minimax-h3-ref2va-q4` ship with it.

> 🔗 PRs:
[#&#8203;11424](https://redirect.github.com/mudler/LocalAI/issues/11424),
[#&#8203;11439](https://redirect.github.com/mudler/LocalAI/issues/11439)

##### 🗣️ Qwen3-TTS through the llama.cpp backend

Qwen3-TTS now runs on the `llama-cpp` backend, which means
text-to-speech on the same accelerator matrix already shipped for text
generation (CUDA, ROCm, SYCL, Vulkan, Metal, L4T) using upstream's own
GGUF conversion.

`grpc-server.cpp` is an adapter over llama.cpp's shared
`server_context`, which owns the `llama_context` and runs the slot
scheduler on its own thread, so a gRPC handler driving the gen-audio
loop itself would race that scheduler. Making TTS a slot-based
`SERVER_TASK_TYPE_TTS` task is the concurrency-safe integration,
following the `0001-add-server-task-type-score.patch` precedent already
in the tree. llama.cpp merged Qwen3-TTS in
[ggml-org/llama.cpp#26254](https://redirect.github.com/ggml-org/llama.cpp/issues/26254);
the server plumbing in
[#&#8203;26603](https://redirect.github.com/mudler/LocalAI/issues/26603)
is still a draft, so it is carried as
`patches/0002-add-server-task-type-tts.patch` and should be deleted once
that merges. `disable-tts-task.sh` keeps turboquant and bonsai
compiling, since they copy `grpc-server.cpp` into forks without our
patches.

Both paths were verified end to end on CPU returning valid 24 kHz mono
16-bit WAV containing real speech, measured rather than eyeballed.
Gallery entries: `qwen3-tts-llamacpp` and `qwen3-tts-llamacpp-q4`.

The existing `qwen3-tts-cpp` backend over qwentts.cpp is untouched. This
is a second, independent path, not a replacement.

> 🔗 PRs:
[#&#8203;11392](https://redirect.github.com/mudler/LocalAI/issues/11392)

##### 🧭 KNN as a first-class router

`classifier: knn` promotes KNN search from a cache for the classifier to
a primary request router. Unlike `score` or `colbert` it needs no
classifier model: label knowledge lives in a corpus of labelled example
prompts that you seed and curate through the admin API, so routing
decisions are deterministic, auditable, and grounded in graded
experience rather than a model's opinion.

There is an explicit epistemic gate. Corpus entries below
`knn.similarity_threshold` cannot vote, and when none clears it the
classifier activates no labels and the router takes the fallback: a
prompt unlike all labelled experience is treated as undecidable, not
guessed. Decisions record `nearest_similarity`, on fallback rows too, so
you can see how far the nearest labelled experience actually was, and
the Routing tab explains out-of-corpus fallbacks and shows per-label
corpus counts.

Persistence is one JSONL file per router under `<data
path>/router-corpus` holding text, labels, vector and embedder
fingerprint. That file is the source of truth; the local-store index is
rebuilt from it at classifier build time and stays a pure in-memory
index, and entries recorded under a different embedding model re-embed
on load. This also corrects the docs' claim that local-store collections
persist: the embedding cache never survived restarts and still does not,
while the corpus does.

Corpus input is API-only by design, since entries may contain example
user content: `POST /api/router/{name}/corpus` seeds (labels validated
against declared policies, embedded server-side, indexed immediately),
`GET .../corpus/stats` inspects and returns label counts only (entry
texts are never returned by any surface), and `DELETE .../corpus` wipes.
All admin-gated like the sibling router endpoints and exposed as MCP
tools.

> 🔗 PRs:
[#&#8203;10652](https://redirect.github.com/mudler/LocalAI/issues/10652)

##### 📊 Global admission control and running backend traces

Admission control existed per backend, which left nothing bounding the
process as a whole, and the traces list could grow without limit. HTTP
admission is now bounded process-wide.

Alongside it, backend operations are represented while they are still in
flight rather than only once they finish, and running backend traces
surface in the UI with immediate links to their logs, so an operation
that is taking too long is something you can look at instead of
something you wait out.

<p align="center">
<img width="2880" height="1800" alt="ui-activity-inflight"
src="https://github.com/user-attachments/assets/fe0acd52-3688-4c20-ae1d-cef9f8b2825e"
/>
<br><em>Operations are represented while they are still running, not
only once they finish.</em>
</p>

> 🔗 PRs:
[#&#8203;11560](https://redirect.github.com/mudler/LocalAI/issues/11560)

##### 🕵️ PII pseudonyms that survive the round trip

The PII middleware masked values irreversibly, which is right for logs
and wrong for a conversation: a model that is handed `[REDACTED:EMAIL]`
twice cannot tell whether it saw one address or two, and anything it
says about them comes back unusable.

Opt-in `pii.reverse_in_response` turns masked request values into unique
deterministic pseudonyms within the request (`EMAIL_001`, `EMAIL_002`)
and restores them if the backend returns them. Restoration handles
normal JSON and SSE tokens split across response writes. Substitution
maps stay request-local and are never persisted. Irreversible
`[REDACTED:...]` remains the default.

> 🔗 PRs:
[#&#8203;11272](https://redirect.github.com/mudler/LocalAI/issues/11272)

##### 📦 Bounded parallel Hugging Face downloads

Snapshot materialization fetched every file through the sequential
executor, so a repository split into many shards spent most of its wall
clock in per-file request latency rather than moving bytes.

`DownloadFilesWithConcurrency` now runs up to N whole-file transfers at
once through an `errgroup` with `SetLimit`. Only whole files run in
parallel: a single file is never split, so the `.partial` resume
machinery and the per-file SHA check are untouched. The two non-artifact
callers (`core/gallery/models.go` and
`core/config/model_config_loader.go`) keep exactly their previous
behaviour through a wrapper passing a limit of 1, so tasks still run in
slice order and the first failure still returns before any later task
starts.

Two consequences of the parallel path are worth knowing:
`completedBytes` is now an `atomic.Int64`, which is not a precaution
(with a plain `int64` the race detector reports three races), and the
caller's status callback is serialized to preserve the guarantee the
sequential path gave it implicitly. `AfterDownload` is deliberately not
serialized, because it does the verify-and-promote work that the
parallelism exists to overlap.

> 🔗 PRs:
[#&#8203;11162](https://redirect.github.com/mudler/LocalAI/issues/11162)

##### 📞 Realtime WebRTC on one UDP port

`--web-rtc-udp-port` / `LOCALAI_WEBRTC_UDP_PORT` reuses a single Pion
ICE UDP mux across Realtime WebRTC calls, so a container or firewall
needs one rule rather than a range. UDP bind failures are surfaced
through signaling instead of failing opaquely, and the container and
firewall setup is documented.

A follow-up closed the gap it opened. `LOCALAI_WEBRTC_ICE_INTERFACES`
was silently ignored whenever a fixed UDP port was set, which is exactly
the combination an operator reaches for: pinning a port to write a
firewall rule and restricting interfaces to keep unreachable `docker0`
and `veth` addresses out of the candidate list usually go together. A
wildcard mux made pion derive candidate addresses by enumerating
interfaces itself with a nil filter, so on a host with docker bridges
the browser received `172.18.0.1`, `172.17.0.1`, `10.10.10.1` and
friends, connected on a good pair, then dropped when ICE consent checks
failed on the others.

> 🔗 PRs:
[#&#8203;11436](https://redirect.github.com/mudler/LocalAI/issues/11436),
[#&#8203;11466](https://redirect.github.com/mudler/LocalAI/issues/11466)

##### 🖧 Cold model loads run as durable jobs

On a two-replica frontend, loading a 35.7 GB GGUF onto a newly added
Jetson Thor worker made the model permanently unloadable from the
operator's seat, while staging was in fact progressing normally
underneath. Replica A held the per-model advisory lock through roughly
twenty minutes of transfer; replica B blocked on `pg_advisory_lock` for
the same model and was killed at 60s by the role's `statement_timeout`,
and every UI retry reproduced it.

Two defects sat behind that one symptom. The lock's lifetime was the
transfer's lifetime, with `Route` wrapping backend install, multi-GB
staging and checkpoint load in `advisorylock.WithLockCtx`, which turns a
millisecond dedup decision into a cluster-wide outage for that model.
And `WithLockCtx` overrode `lock_timeout` but not `statement_timeout`,
though both abort the same blocking call.

Cold loads now run as durable jobs, so the lock is held only for the
decision it exists to make.

> 🔗 PRs:
[#&#8203;11514](https://redirect.github.com/mudler/LocalAI/issues/11514)

##### 🎮 vllm-cpp builds for the cards people own

The `vllm-cpp` CUDA images were built for Blackwell only: `120a;121a` on
amd64 and `121a` alone on arm64, out of the ten architectures vllm.cpp's
own release archive builds.

What makes it worth calling out is the failure mode. An unlisted card is
not slower, it dies at the first request with `no kernel image is
available for execution on the device`, long after `local-ai backends
install vllm-cpp` reported success. This was found on a Jetson Thor node
that had the backend installed and could serve nothing.

|       | before      | after                              |
| ----- | ----------- | ---------------------------------- |
| amd64 | `120a;121a` | `80;86;89;90a;100a;103a;120a;121a` |
| arm64 | `121a`      | `87;90a;100a;110;121a`             |

The split follows where the silicon exists: Jetson (`87` Orin, `110`
Thor) is arm64-only, desktop `120a` is amd64-only, and `90a`/`100a` are
on both because of the SBSA parts. That adds A100, A10/3090, L4/4090/RTX
6000 Ada, H100/H200, B200, B300, Jetson Orin and Jetson Thor. The CUDA
13 guard now covers both branches rather than amd64 alone, and
Triton-AOT stays on.

> 🔗 PRs:
[#&#8203;11512](https://redirect.github.com/mudler/LocalAI/issues/11512)

##### 🌍 Portuguese (Brazil) and Indonesian

A complete `pt-BR` translation of the WebUI: 14 namespaces at full key
parity with `en/` including `modelEditor.json`, with every i18next
interpolation variable and `_one`/`_other` plural key preserved.
Indonesian covers the admin, media and navigation strings. Both keep
brand, model and technical identifiers untranslated, matching existing
locale conventions.

> 🔗 PRs:
[#&#8203;11427](https://redirect.github.com/mudler/LocalAI/issues/11427),
[#&#8203;11493](https://redirect.github.com/mudler/LocalAI/issues/11493)

##### 🧰 Smaller features worth knowing about

- **Metal is actually on in two macOS backends.** `stablediffusion-ggml`
gated its Metal flags on an `OS=Darwin` variable the runner never
defines, so the Darwin workflow's `BUILD_TYPE=metal` produced a build
without `GGML_METAL_EMBED_LIBRARY=ON`, shipping a runtime source path
that failed to expose `kernel_mul_mv_ext_bf16_f32_r1_5`. `parakeet-cpp`
never forwarded `BUILD_TYPE=metal` to `PARAKEET_GGML_METAL` at all: on
an M1 Air the same five-minute sample went from 82.57s to 50.18s with
byte-identical output. A dry-run build-contract test now guards the
Stable Diffusion flags.
- **Backend crashes say why.** An unexpected runtime exit logged its
stderr only at debug level, so at default log level operators saw an
exit code and nothing else. The final non-empty stderr line now rides
the unexpected-exit warning, and a failed gRPC readiness preserves the
process exit code plus the last stderr diagnostic bounded to 4 KiB.
- **MCP servers stay visible when they fail.** Model-level MCP servers
disappeared from Chat whenever connection setup or tool discovery
failed, hiding container DNS, routing and VPN reachability problems.
They now stay listed as disabled error rows with a per-server error,
discovery retries while safely closing partially created sessions, and
the model editor documents the expected `mcp.remote` and `mcp.stdio`
formats.
- **Checksum mismatches retry.** A post-download SHA mismatch is now
treated as a transient transfer failure: LocalAI removes the mismatched
partial and lets the bounded planner retry a stale or corrupted CDN
response, while still refusing unverified bytes. Direct
`URI.DownloadFile` callers still see the mismatch immediately.
- **Audio transform rejects the wrong contract.** The audio-transform
WebSocket accepted `realtime_audio` models and opened the frame-based
`AudioTransformStream` RPC, which failed after the handshake with
`NotImplementedError` for any-to-any models like `liquid-audio`. The use
case is now validated before the backend loads, and any-to-any callers
are pointed at the OpenAI Realtime API.
- **Invalid preload JSON names itself.** `PRELOAD_MODELS` /
`--preload-models` now identifies which input was invalid and rejects
non-array top-level values (including booleans and `null`) with the
expected shape in the error.

> 🔗 PRs:
[#&#8203;11531](https://redirect.github.com/mudler/LocalAI/issues/11531),
[#&#8203;11492](https://redirect.github.com/mudler/LocalAI/issues/11492),
[#&#8203;11532](https://redirect.github.com/mudler/LocalAI/issues/11532),
[#&#8203;11447](https://redirect.github.com/mudler/LocalAI/issues/11447),
[#&#8203;11495](https://redirect.github.com/mudler/LocalAI/issues/11495),
[#&#8203;11536](https://redirect.github.com/mudler/LocalAI/issues/11536),
[#&#8203;11565](https://redirect.github.com/mudler/LocalAI/issues/11565),
[#&#8203;11434](https://redirect.github.com/mudler/LocalAI/issues/11434)

***

#### 🐛 Bug Fixes (recap)

- `fix(auth)`: protect HTTP routes by default -
[#&#8203;11602](https://redirect.github.com/mudler/LocalAI/issues/11602)
- `fix(distributed)`: run cold model loads as durable jobs instead of
holding the advisory lock -
[#&#8203;11514](https://redirect.github.com/mudler/LocalAI/issues/11514)
- `fix(vllm-cpp)`: build every CUDA architecture the platform can host -
[#&#8203;11512](https://redirect.github.com/mudler/LocalAI/issues/11512)
- `fix(realtime)`: keep the ICE interface allow-list working with a
fixed UDP port -
[#&#8203;11466](https://redirect.github.com/mudler/LocalAI/issues/11466)
- `fix(stablediffusion)`: embed Metal library -
[#&#8203;11531](https://redirect.github.com/mudler/LocalAI/issues/11531)
- `fix(parakeet-cpp)`: enable Metal in macOS builds -
[#&#8203;11492](https://redirect.github.com/mudler/LocalAI/issues/11492)
- `fix(model)`: report backend crash diagnostics -
[#&#8203;11532](https://redirect.github.com/mudler/LocalAI/issues/11532)
- `fix(model)`: surface backend startup exits -
[#&#8203;11447](https://redirect.github.com/mudler/LocalAI/issues/11447)
- `fix(downloader)`: retry checksum mismatches -
[#&#8203;11536](https://redirect.github.com/mudler/LocalAI/issues/11536)
- `fix(audio)`: reject incompatible transform streams -
[#&#8203;11565](https://redirect.github.com/mudler/LocalAI/issues/11565)
- `fix(gallery)`: parse harmony output of gpt-oss-\* models correctly -
[#&#8203;11518](https://redirect.github.com/mudler/LocalAI/issues/11518)
- `fix(gallery)`: identify invalid preload JSON -
[#&#8203;11434](https://redirect.github.com/mudler/LocalAI/issues/11434)
- `fix(gallery)`: repair DeepSeek V4 fallback -
[#&#8203;11480](https://redirect.github.com/mudler/LocalAI/issues/11480)
- `fix(gallery)`: correct Higgs Audio v3 checksum -
[#&#8203;11459](https://redirect.github.com/mudler/LocalAI/issues/11459)
- `fix(fish-speech)`: preserve ROCm PyTorch -
[#&#8203;11568](https://redirect.github.com/mudler/LocalAI/issues/11568)
- `fix(vllm)`: align Intel basekit runtime to oneAPI 2025.3.2 -
[#&#8203;11437](https://redirect.github.com/mudler/LocalAI/issues/11437)
- `fix(kokoros)`: add the missing `upscale_image` stub to the Backend
trait impl -
[#&#8203;11414](https://redirect.github.com/mudler/LocalAI/issues/11414)
- `fix`: show MCP connection errors in the UI -
[#&#8203;11495](https://redirect.github.com/mudler/LocalAI/issues/11495)
- `fix(ui)`: unmerge the class strings that left buttons in browser
chrome -
[#&#8203;11462](https://redirect.github.com/mudler/LocalAI/issues/11462)
- `fix(ui)`: keep agent import action visible -
[#&#8203;11488](https://redirect.github.com/mudler/LocalAI/issues/11488)
- `fix`: wrap long TTS request text instead of widening the page -
[#&#8203;11576](https://redirect.github.com/mudler/LocalAI/issues/11576)

***

#### 🧠 Models

85 new gallery entries this cycle, taking the index from 1,622 to 1,707.

Text generation: Qwen3.8 in 9B, 27B, Ridge and small variants, Gemma 4
agentic and Gemma 4 Scotoma 2, DeepSeek V4 Pro 0813, Ling 3.0 Flash,
Nemotron 3.5 Lightning 30B, Tess 4 27B, Ornith 1.0 and 1.5 9B, Muse
Glimmer 30B, Grug 12B, BigBang v1, Genesis Hermes V7, TwIL-LM3, BTL-4
Compact, XYZ Aquila mini, North Mini Code, AREX Turbo, Fara1.5 4B,
MiniCPM5 1B Q8, Shieldstral 1.0 3B and UI-Mate 9B.

Vision and OCR: HunyuanOCR, OvisOCR2, LFM2.5 VL 1.6B, and LFM2.5 230M
alongside it.

Audio and video: Higgs Audio v3 TTS, the Qwen3-TTS llama.cpp entries,
and the MiniMax-H3 FL2VA and Ref2VA video sets.

Also a first Carbon genomics family, and text-generation entries for the
`vllm-cpp` backend.

> 🔗 PRs:
[#&#8203;11622](https://redirect.github.com/mudler/LocalAI/issues/11622),
[#&#8203;11603](https://redirect.github.com/mudler/LocalAI/issues/11603),
[#&#8203;11599](https://redirect.github.com/mudler/LocalAI/issues/11599),
[#&#8203;11598](https://redirect.github.com/mudler/LocalAI/issues/11598),
[#&#8203;11594](https://redirect.github.com/mudler/LocalAI/issues/11594),
[#&#8203;11584](https://redirect.github.com/mudler/LocalAI/issues/11584),
[#&#8203;11573](https://redirect.github.com/mudler/LocalAI/issues/11573),
[#&#8203;11571](https://redirect.github.com/mudler/LocalAI/issues/11571),
[#&#8203;11561](https://redirect.github.com/mudler/LocalAI/issues/11561),
[#&#8203;11559](https://redirect.github.com/mudler/LocalAI/issues/11559),
[#&#8203;11557](https://redirect.github.com/mudler/LocalAI/issues/11557),
[#&#8203;11552](https://redirect.github.com/mudler/LocalAI/issues/11552),
[#&#8203;11551](https://redirect.github.com/mudler/LocalAI/issues/11551),
[#&#8203;11549](https://redirect.github.com/mudler/LocalAI/issues/11549),
[#&#8203;11547](https://redirect.github.com/mudler/LocalAI/issues/11547),
[#&#8203;11540](https://redirect.github.com/mudler/LocalAI/issues/11540),
[#&#8203;11533](https://redirect.github.com/mudler/LocalAI/issues/11533),
[#&#8203;11526](https://redirect.github.com/mudler/LocalAI/issues/11526),
[#&#8203;11519](https://redirect.github.com/mudler/LocalAI/issues/11519),
[#&#8203;11511](https://redirect.github.com/mudler/LocalAI/issues/11511),
[#&#8203;11490](https://redirect.github.com/mudler/LocalAI/issues/11490),
[#&#8203;11479](https://redirect.github.com/mudler/LocalAI/issues/11479),
[#&#8203;11478](https://redirect.github.com/mudler/LocalAI/issues/11478),
[#&#8203;11477](https://redirect.github.com/mudler/LocalAI/issues/11477),
[#&#8203;11458](https://redirect.github.com/mudler/LocalAI/issues/11458),
[#&#8203;11456](https://redirect.github.com/mudler/LocalAI/issues/11456),
[#&#8203;11455](https://redirect.github.com/mudler/LocalAI/issues/11455),
[#&#8203;11449](https://redirect.github.com/mudler/LocalAI/issues/11449),
[#&#8203;11446](https://redirect.github.com/mudler/LocalAI/issues/11446),
[#&#8203;11443](https://redirect.github.com/mudler/LocalAI/issues/11443),
[#&#8203;11441](https://redirect.github.com/mudler/LocalAI/issues/11441),
[#&#8203;11439](https://redirect.github.com/mudler/LocalAI/issues/11439),
[#&#8203;11438](https://redirect.github.com/mudler/LocalAI/issues/11438),
[#&#8203;11435](https://redirect.github.com/mudler/LocalAI/issues/11435)

***

#### 👒 Dependencies

Submodule and pin bumps this cycle:

| Project | Bumps |
|
------------------------------------------------------------------------------------------------------------------
| ------ |
| CrispStrobe/CrispASR | 10 |
| ikawrakow/ik\_llama.cpp | 9 |
| vllm-metal (darwin) | 7 |
| mudler/vllm.cpp | 6 |
| 0xShug0/audio.cpp | 5 |
| ggml-org/llama.cpp | 4 |
| ggml-org/whisper.cpp | 3 |
| leejet/stable-diffusion.cpp | 2 |
| antirez/ds4, mudler/parakeet.cpp, mudler/depth-anything.cpp,
NVIDIA/NeMo-Speech.cpp, vllm-project/vllm cu130 wheel | 1 each |

Plus `golang.org/x/net` to v0.55.0, vllm 0.26.0 and transformers
>=5.15.0 in the Python backends, sentence-transformers 5.7.0, packaging
26.3, dompurify 3.4.13, the Kokoros source pin, inference defaults
refreshed from unsloth, and seven gallery checksum refreshes.

***

#### 📖 Documentation

New pages for context compression and `vllm-cpp`, a substantially
expanded middleware page covering the PII pseudonym and compression
surfaces, and a rewritten authentication page documenting the public and
protected route surfaces after the deny-by-default change.

Video generation gained the MiniMax-H3 setup, text-to-audio the
Qwen3-TTS llama.cpp path, distributed mode the durable cold-load
behaviour, MCP the configuration formats and container networking
implications, and Realtime the shared UDP port and firewall guidance.
Embeddings, model gallery, backends and API discovery all picked up
corrections, and the broken stars counter came out of the site.

> 🔗 PRs:
[#&#8203;11448](https://redirect.github.com/mudler/LocalAI/issues/11448),
[#&#8203;11581](https://redirect.github.com/mudler/LocalAI/issues/11581),
[#&#8203;11582](https://redirect.github.com/mudler/LocalAI/issues/11582),
[#&#8203;11415](https://redirect.github.com/mudler/LocalAI/issues/11415)

***

#### 🙌 New Contributors

- [@&#8203;tom-mi](https://redirect.github.com/tom-mi) made their first
contribution in
[#&#8203;11518](https://redirect.github.com/mudler/LocalAI/issues/11518)
- [@&#8203;kassane](https://redirect.github.com/kassane) made their
first contribution in
[#&#8203;11427](https://redirect.github.com/mudler/LocalAI/issues/11427)
- [@&#8203;fieryWaters](https://redirect.github.com/fieryWaters) made
their first contribution in
[#&#8203;11492](https://redirect.github.com/mudler/LocalAI/issues/11492)

Thanks also to [@&#8203;richiejp](https://redirect.github.com/richiejp),
[@&#8203;jimmykarily](https://redirect.github.com/jimmykarily),
[@&#8203;Dennisadira](https://redirect.github.com/Dennisadira),
[@&#8203;dedyf5](https://redirect.github.com/dedyf5),
[@&#8203;ALameLlama](https://redirect.github.com/ALameLlama) and
[@&#8203;walcz-de](https://redirect.github.com/walcz-de), and to [Naor
Yaacov](https://www.linkedin.com/in/naor-yaacov/) for the authentication
bypass report.

<!-- Release notes generated using configuration in .github/release.yml
at master -->

#### What's Changed

##### Breaking Changes 🛠

- fix(auth): protect HTTP routes by default by
[@&#8203;localai-bot](https://redirect.github.com/localai-bot) in
[#&#8203;11602](https://redirect.github.com/mudler/LocalAI/pull/11602)

##### Bug fixes :bug:

- fix(kokoros): add missing `upscale_image` stub to Backend trait impl
by [@&#8203;mudler](https://redirect.github.com/mudler) with
[@&#8203;Copilot](https://redirect.github.com/Copilot) in
[#&#8203;11414](https://redirect.github.com/mudler/LocalAI/pull/11414)
- fix(gallery): identify invalid preload JSON by
[@&#8203;localai-org-maint-bot](https://redirect.github.com/localai-org-maint-bot)
in
[#&#8203;11434](https://redirect.github.com/mudler/LocalAI/pull/11434)
- fix(vllm): align Intel basekit runtime by
[@&#8203;localai-org-maint-bot](https://redirect.github.com/localai-org-maint-bot)
in
[#&#8203;11437](https://redirect.github.com/mudler/LocalAI/pull/11437)
- fix(model): surface backend startup exits by
[@&#8203;localai-org-maint-bot](https://redirect.github.com/localai-org-maint-bot)
in
[#&#8203;11447](https://redirect.github.com/mudler/LocalAI/pull/11447)
- fix(ui): unmerge the class strings that left buttons in browser chrome
by [@&#8203;localai-bot](https://redirect.github.com/localai-bot) in
[#&#8203;11462](https://redirect.github.com/mudler/LocalAI/pull/11462)
- fix(realtime): keep the ICE interface allow-list working with a fixed
UDP port by
[@&#8203;jimmykarily](https://redirect.github.com/jimmykarily) in
[#&#8203;11466](https://redirect.github.com/mudler/LocalAI/pull/11466)
- fix(parakeet-cpp): enable Metal in macOS builds by
[@&#8203;fieryWaters](https://redirect.github.com/fieryWaters) in
[#&#8203;11492](https://redirect.github.com/mudler/LocalAI/pull/11492)
- fix: Show MCP connection errors in the UI by
[@&#8203;richiejp](https://redirect.github.com/richiejp) in
[#&#8203;11495](https://redirect.github.com/mudler/LocalAI/pull/11495)
- fix(vllm-cpp): build every CUDA architecture the platform can host by
[@&#8203;localai-bot](https://redirect.github.com/localai-bot) in
[#&#8203;11512](https://redirect.github.com/mudler/LocalAI/pull/11512)
- fix (gallery): Parse harmony output of gpt-oss-\* models correctly
([#&#8203;8037](https://redirect.github.com/mudler/LocalAI/issues/8037))
by [@&#8203;tom-mi](https://redirect.github.com/tom-mi) in
[#&#8203;11518](https://redirect.github.com/mudler/LocalAI/pull/11518)
- fix(ui): keep agent import action visible by
[@&#8203;localai-org-maint-bot](https://redirect.github.com/localai-org-maint-bot)
in
[#&#8203;11488](https://redirect.github.com/mudler/LocalAI/pull/11488)
- fix(stablediffusion): embed Metal library by
[@&#8203;localai-org-maint-bot](https://redirect.github.com/localai-org-maint-bot)
in
[#&#8203;11531](https://redirect.github.com/mudler/LocalAI/pull/11531)
- fix(model): report backend crash diagnostics by
[@&#8203;localai-org-maint-bot](https://redirect.github.com/localai-org-maint-bot)
in
[#&#8203;11532](https://redirect.github.com/mudler/LocalAI/pull/11532)
- fix(distributed): run cold model loads as durable jobs instead of
holding the advisory lock by
[@&#8203;localai-bot](https://redirect.github.com/localai-bot) in
[#&#8203;11514](https://redirect.github.com/mudler/LocalAI/pull/11514)
- fix(downloader): retry checksum mismatches by
[@&#8203;localai-org-maint-bot](https://redirect.github.com/localai-org-maint-bot)
in
[#&#8203;11536](https://redirect.github.com/mudler/LocalAI/pull/11536)
- fix(fish-speech): preserve ROCm PyTorch by
[@&#8203;localai-org-maint-bot](https://redirect.github.com/localai-org-maint-bot)
in
[#&#8203;11568](https://redirect.github.com/mudler/LocalAI/pull/11568)
- fix(audio): reject incompatible transform streams by
[@&#8203;localai-org-maint-bot](https://redirect.github.com/localai-org-maint-bot)
in
[#&#8203;11565](https://redirect.github.com/mudler/LocalAI/pull/11565)
- fix: tts text wrap by
[@&#8203;ALameLlama](https://redirect.github.com/ALameLlama) in
[#&#8203;11576](https://redirect.github.com/mudler/LocalAI/pull/11576)

##### Exciting New Features 🎉

- feat(modelartifacts): support bounded parallel Hugging Face file
downloads by
[@&#8203;Dennisadira](https://redirect.github.com/Dennisadira) in
[#&#8203;11162](https://redirect.github.com/mudler/LocalAI/pull/11162)
- feat(i18n): add pt-BR translation by
[@&#8203;kassane](https://redirect.github.com/kassane) in
[#&#8203;11427](https://redirect.github.com/mudler/LocalAI/pull/11427)
- feat(vllm-cpp): serve MiniMax-H3 video+audio generation by
[@&#8203;localai-bot](https://redirect.github.com/localai-bot) in
[#&#8203;11424](https://redirect.github.com/mudler/LocalAI/pull/11424)
- feat(pii): restore request-scoped pseudonyms by
[@&#8203;localai-org-maint-bot](https://redirect.github.com/localai-org-maint-bot)
in
[#&#8203;11272](https://redirect.github.com/mudler/LocalAI/pull/11272)
- feat(llama-cpp): serve Qwen3-TTS through the llama.cpp backend by
[@&#8203;localai-bot](https://redirect.github.com/localai-bot) in
[#&#8203;11392](https://redirect.github.com/mudler/LocalAI/pull/11392)
- feat(realtime): add shared WebRTC UDP port by
[@&#8203;localai-org-maint-bot](https://redirect.github.com/localai-org-maint-bot)
in
[#&#8203;11436](https://redirect.github.com/mudler/LocalAI/pull/11436)
- feat(ui): rebuild the import form on the restyled design language by
[@&#8203;localai-bot](https://redirect.github.com/localai-bot) in
[#&#8203;11461](https://redirect.github.com/mudler/LocalAI/pull/11461)
- i18n(id): translate admin, media, and nav UI strings to Indonesian by
[@&#8203;dedyf5](https://redirect.github.com/dedyf5) in
[#&#8203;11493](https://redirect.github.com/mudler/LocalAI/pull/11493)
- feat(ui): unify model and backend lifecycle by
[@&#8203;localai-bot](https://redirect.github.com/localai-bot) in
[#&#8203;11548](https://redirect.github.com/mudler/LocalAI/pull/11548)
- feat: bound global admission and expose running backend traces by
[@&#8203;richiejp](https://redirect.github.com/richiejp) in
[#&#8203;11560](https://redirect.github.com/mudler/LocalAI/pull/11560)
- feat(router): make KNN a first-class classifier with a persisted,
curated corpus by
[@&#8203;richiejp](https://redirect.github.com/richiejp) in
[#&#8203;10652](https://redirect.github.com/mudler/LocalAI/pull/10652)
- feat(chat): add end-to-end context compression by
[@&#8203;localai-org-maint-bot](https://redirect.github.com/localai-org-maint-bot)
in
[#&#8203;11556](https://redirect.github.com/mudler/LocalAI/pull/11556)

##### 🧠 Models

- feat(gallery): add Grug 12B variants by
[@&#8203;localai-org-maint-bot](https://redirect.github.com/localai-org-maint-bot)
in
[#&#8203;11438](https://redirect.github.com/mudler/LocalAI/pull/11438)
- 

</details>

---

### Configuration

📅 **Schedule**: (UTC)

- Branch creation
  - At any time (no schedule defined)
- Automerge
  - At any time (no schedule defined)

🚦 **Automerge**: Enabled.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the
rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update
again.

---

- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check
this box

---

This PR has been generated by [Renovate
Bot](https://redirect.github.com/renovatebot/renovate).

<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4xMzAuMSIsInVwZGF0ZWRJblZlciI6IjQzLjEzMC4xIiwidGFyZ2V0QnJhbmNoIjoibWFzdGVyIiwibGFiZWxzIjpbImFwcC9sb2NhbC1haSIsImF1dG9tZXJnZSIsInJlbm92YXRlL2NvbnRhaW5lciIsInR5cGUvbWlub3IiXX0=-->
@K234-1

K234-1 commented Sep 28, 2026

Copy link
Copy Markdown

We've been running this PR's /tts server for a local voice assistant (Qwen3-TTS-1.7B-Base + x-vector cloning, Vulkan backend) and hit an issue with code2wav batching that others may run into, plus a fix that works well for us.

Symptom: decoding in batches smaller than wav_tfm_swa (72 frames) audibly "detunes" the voice mid-sentence. We had reduced the batch size to lower time-to-first-audio in streaming.

Cause: code2wav zero-pads every batch to 72 frames (clip.cpp input setter, and inp_codes in clip_graph_qwen3tts_gen::build()), and the padding leaks into the carried state: the KV prefix for the next batch and the causal-conv left-contexts are taken from padded positions (with 12-frame batches, 60 of the 71 prefix frames come from code-0 padding). The trimmed audio of the current batch is fine — it's the state handed to the next batch that's corrupted.

Fix: the state contract is already length-agnostic (prefix W−1, conv tails, tfm_pos += N all derive from tensor shapes), so it's enough to build the GEN_WAV sub-graph for the actual frame count instead of a fixed window — the same pattern clip_graph_pockettts_gen already uses. The banded attention mask keeps the effective window at wav_tfm_swa regardless of batch size, so short batches decode equivalently to full windows. The code-0 rear padding in the input setter and the tail-trim in the output path can then be dropped.

Results (12 sentences in 12 requests, ECAPA-TDNN speaker similarity vs. the cloning reference, identical sampling):

config mean / min similarity
unpatched, constant 12-frame batches 0.817 / 0.777 (audible drift)
unpatched, 72-frame batches 0.862 / 0.815
patched, constant 12-frame batches 0.854 / 0.816
patched, batch ramp 12→24→48→72 0.847 / 0.800

With a short first batch (we ramp 12 → 24 → 48 → 72 in mtmd-helper-gen.cpp), streaming time-to-first-audio drops from ~1.8 s to ~0.4 s with no measurable quality loss.

patch (core fix, against 435d411)
diff --git a/tools/mtmd/clip.cpp b/tools/mtmd/clip.cpp
index 5969bf7..f8e0abc 100644
--- a/tools/mtmd/clip.cpp
+++ b/tools/mtmd/clip.cpp
@@ -1087,7 +1087,15 @@ static std::unique_ptr<clip_graph> clip_get_graph_builder(clip_ctx * ctx, const
                 const auto  gen_process = params ? params->gen_process : CLIP_GEN_PROCESS_GEN_CODE;
                 const int   top_k = params ? params->top_k : 50;
                 const float top_p = params ? params->top_p : 1.0f;
-                builder = std::make_unique<clip_graph_qwen3tts_gen>(ctx, img, gen_process, top_k, top_p);
+                // GEN_WAV: build code2wav for the actual number of frames in this call,
+                // rear-padding to a fixed window would leak padding into the carried state
+                const int64_t n_codes = ctx->model.gen_code_head_w->ne[2] + 1;
+                int n_frames = ctx->model.hparams.wav_tfm_swa;
+                if (params && params->gen_process == CLIP_GEN_PROCESS_GEN_WAV && params->codes) {
+                    GGML_ASSERT(params->codes->size() % (size_t) n_codes == 0);
+                    n_frames = (int) (params->codes->size() / n_codes);
+                }
+                builder = std::make_unique<clip_graph_qwen3tts_gen>(ctx, img, gen_process, top_k, top_p, n_frames);
             } break;
         case PROJECTOR_TYPE_YOUTUVL:
             {
@@ -5090,11 +5098,11 @@ bool clip_encode(struct clip_ctx * ctx, struct clip_encode_params * params) {
                 if (params->gen_process == CLIP_GEN_PROCESS_GEN_WAV) {
                     GGML_ASSERT(params->codes != nullptr);
 
-                    // frame-major input to group-major, rear-padded with code 0 up to one window
+                    // frame-major input to group-major; the graph is built for this
+                    // exact frame count (see the builder dispatch), so no padding
                     const int64_t n_codes  = model.gen_code_head_w->ne[2] + 1;
-                    const int64_t n_frames_w = hparams.wav_tfm_swa;
-                    const int64_t n_frames   = (int64_t) params->codes->size() / n_codes;
-                    GGML_ASSERT(n_frames > 0 && n_frames <= n_frames_w);
+                    const int64_t n_frames = (int64_t) params->codes->size() / n_codes;
+                    GGML_ASSERT(n_frames > 0);
 
                     // codes are used as ggml_get_rows indices, so check them against the codebook vocab
                     const int64_t vocab_first = model.c2w.quant_first_cb_w->ne[1];
@@ -5111,10 +5119,10 @@ bool clip_encode(struct clip_ctx * ctx, struct clip_encode_params * params) {
                         }
                     }
 
-                    std::vector<int32_t> codes(n_frames_w * n_codes, 0);
+                    std::vector<int32_t> codes(n_frames * n_codes);
                     for (int64_t f = 0; f < n_frames; f++) {
                         for (int64_t g = 0; g < n_codes; g++) {
-                            codes[g * n_frames_w + f] = (*params->codes)[f * n_codes + g];
+                            codes[g * n_frames + f] = (*params->codes)[f * n_codes + g];
                         }
                     }
                     set_input_i32("inp_codes", codes);
@@ -5628,15 +5636,8 @@ bool clip_encode(struct clip_ctx * ctx, struct clip_encode_params * params) {
         auto & out_audio = *params->out_audio;
         out_audio.resize(ggml_nelements(audio));
         ggml_backend_tensor_get(audio, out_audio.data(), 0, ggml_nbytes(audio));
-
-        // drop the tail audio that comes from the code-0 rear padding
-        const int64_t n_codes    = params->codes ? model.gen_code_head_w->ne[2] + 1 : 0;
-        const int64_t n_frames_w = hparams.wav_tfm_swa;
-        const int64_t n_frames   = params->codes ? (int64_t) params->codes->size() / n_codes : n_frames_w;
-        if (n_frames < n_frames_w) {
-            const size_t hop = out_audio.size() / n_frames_w;
-            out_audio.resize((size_t) n_frames * hop);
-        }
+        // no tail to drop: the code2wav graph is built for the actual frame
+        // count, so every emitted sample comes from a real input frame
     }
     if (params->state_out != nullptr) {
         auto & state_out = *params->state_out;
diff --git a/tools/mtmd/models/models.h b/tools/mtmd/models/models.h
index 3631d84..80181c9 100644
--- a/tools/mtmd/models/models.h
+++ b/tools/mtmd/models/models.h
@@ -233,8 +233,8 @@ struct clip_graph_qwen3tts_spkenc : clip_graph {
 };
 
 struct clip_graph_qwen3tts_gen : clip_graph {
-    clip_graph_qwen3tts_gen(clip_ctx * ctx, const clip_image_f32 & img, clip_gen_process_type gen_process, int top_k, float top_p)
-        : clip_graph(ctx, img), gen_process(gen_process), top_k(top_k), top_p(top_p) {}
+    clip_graph_qwen3tts_gen(clip_ctx * ctx, const clip_image_f32 & img, clip_gen_process_type gen_process, int top_k, float top_p, int n_frames)
+        : clip_graph(ctx, img), gen_process(gen_process), top_k(top_k), top_p(top_p), n_frames(n_frames) {}
     ggml_cgraph * build() override;
 
     // which sub-graph build() constructs, fixed at graph-build time
@@ -244,6 +244,11 @@ struct clip_graph_qwen3tts_gen : clip_graph {
     int   top_k;
     float top_p;
 
+    // GEN_WAV: number of frames this call actually decodes (the caller's batch size,
+    // hparams.wav_tfm_swa when unknown). the code2wav state contract is length-agnostic,
+    // so the sub-graph is built for the real frame count instead of a zero-padded window
+    int n_frames;
+
     //
     // code_gen: backbone hidden state + sampled code0 -> 16 RVQ codes
     // MTP-style code predictor, one token per codebook
diff --git a/tools/mtmd/models/qwen3tts-gen.cpp b/tools/mtmd/models/qwen3tts-gen.cpp
index 84c77f4..9befab6 100644
--- a/tools/mtmd/models/qwen3tts-gen.cpp
+++ b/tools/mtmd/models/qwen3tts-gen.cpp
@@ -732,7 +732,10 @@ ggml_cgraph * clip_graph_qwen3tts_gen::build() {
     cb(out_embd, "gen_audio_out", -1);
 
     // ---- CLIP_GEN_PROCESS_GEN_WAV: 16 RVQ codes -> raw PCM ----
-    const int n_frames = hparams.wav_tfm_swa; // frames per batch, == the attention window
+    // built for the caller's actual frame count: the attention stays banded to
+    // wav_tfm_swa via the mask, and every state slot is batch-size independent,
+    // so short batches (low-latency streaming) decode bit-equivalently to full windows
+    GGML_ASSERT(n_frames > 0);
 
     ggml_tensor * inp_codes = ggml_new_tensor_2d(ctx0, GGML_TYPE_I32, n_frames, n_codes);
     ggml_set_name(inp_codes, "inp_codes");

@ngxson
ngxson marked this pull request as ready for review October 20, 2026 17:35
@ngxson
ngxson requested a review from ggerganov as a code owner October 20, 2026 17:35
@ngxson
ngxson requested review from a team as code owners October 20, 2026 17:35
@ngxson

ngxson commented Oct 20, 2026

Copy link
Copy Markdown
Collaborator Author

@ServeurpersoCom let's also try to merge this today or tmr, I'm working on a refactoring of mtmd audio_gen API right after this

@ServeurpersoCom
ServeurpersoCom self-requested a review October 20, 2026 18:20
Form fields arrive as strings, so -F seed=42 and -F stream=true were
rejected by the schema. seed joins the integer fields, parsed as a long
long so a full uint32 seed fits, and stream is read as a boolean when
it comes as a string.
@ServeurpersoCom

Copy link
Copy Markdown
Contributor

Tested it in a container with an RTX PRO 6000 and the Qwen3-TTS 1.7B Base Q8_0 GGUF. It works really well: JSON, multipart and base64 speaker refs all give clean speech (checked by transcribing the outputs with Gemma 4), streaming starts in about 0.65 s and stays correct across the code2wav windows, pcm is real float32, a client that disconnects frees its slot, and router mode reports the audio modality, autoloads and proxies /tts including streaming.

I pushed a small fix: seed and stream were missing from the multipart conversion list, so -F stream=true was rejected and you couldn't stream while uploading the speaker ref as a file.

One real issue left: a fixed seed isn't reproducible on the server. clip_ctx holds a single RNG that only reseeds when the seed changes, and the server shares that context across all requests, so two requests with seed 42 give different audio, and concurrent requests draw from the same RNG. llama-tts is only reproducible because each run is a fresh process. Letting the helper own its RNG and pass a derived seed per clip call would fix both, could fit in your audio_gen refactor.

Small things for later: the streaming get_output is duplicated in both pipelines, mtmd_helper_gen_audio_flush has no null check like reset, and K234-1's patch above would bring the first audio down from 0.65 s by decoding shorter code2wav batches.

@ServeurpersoCom ServeurpersoCom left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM once CI is green. The seed reproducibility can go into the audio_gen refactor.

@ngxson
ngxson merged commit 5c0b49a into master Oct 20, 2026
14 checks passed
auRose94 pushed a commit to auRose94/llama.cpp that referenced this pull request Oct 28, 2026
* mtmd: add audio out stream api

* add /tts endpoint

* add docs

* wire up mtmd_helper_model_can_chat

* upload speaker_ref via form-data

* expose seed to api

* fix header for pcm

* wip

* fix penalty_last_n

* fix server_output_limits

* fix pocket-tts stream

* wip

* add server_schema for tts

* add server routing

* server: coerce seed and stream from multipart form fields

Form fields arrive as strings, so -F seed=42 and -F stream=true were
rejected by the schema. seed joins the integer fields, parsed as a long
long so a full uint32 seed fits, and stream is read as a boolean when
it comes as a string.

---------

Co-authored-by: Pascal <admin@serveurperso.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation mtmd Related to multimodal functionality (video/image/audio) server

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants