Commit 7ae3c6b23 for llama.cpp

commit 7ae3c6b231e38057e825ff257bb734e456633d9a
Author: Xuan-Son Nguyen <son@huggingface.co>
Date:   Sun Nov 15 19:44:44 2026 +0100

    mtmd: add zonos3-mini TTS model (#32020)

    * zonos3 init

    * add audio gen pipeline

    * fix string_format

    * add default voice as gguf metadata

    * better ref

    * support voice clone

    * add delivery params

    * optimize speed

    * nits

    * nits 2

    * add text normalization

    * nits

diff --git a/conversion/__init__.py b/conversion/__init__.py
index fb6b8464c..e915753a8 100644
--- a/conversion/__init__.py
+++ b/conversion/__init__.py
@@ -282,6 +282,7 @@ TEXT_MODEL_MAP: dict[str, str] = {
     "Qwen3NextForCausalLM": "qwen",
     "Qwen3OmniMoeForConditionalGeneration": "qwen3vl",
     "PocketTTSModel": "pockettts",
+    "Zonos3Model": "zonos3",
     "Qwen3TTSForConditionalGeneration": "qwen3tts",
     "Qwen3VLForConditionalGeneration": "qwen3vl",
     "Qwen3VLMoeForConditionalGeneration": "qwen3vl",
@@ -356,6 +357,7 @@ MMPROJ_MODEL_MAP: dict[str, str] = {
     "EmbeddingGemma2Model": "gemma",
     "Exaone4_5_ForConditionalGeneration": "exaone",
     "FishOmniModel": "drama3",
+    "Zonos3Model": "zonos3",
     "Gemma3ForConditionalGeneration": "gemma",
     "Gemma3nForConditionalGeneration": "gemma",
     "Gemma4ForConditionalGeneration": "gemma",
diff --git a/conversion/zonos3.py b/conversion/zonos3.py
new file mode 100644
index 000000000..bf1c7a625
--- /dev/null
+++ b/conversion/zonos3.py
@@ -0,0 +1,575 @@
+from __future__ import annotations
+
+import json
+from pathlib import Path
+from typing import Any, Callable, Iterable, TYPE_CHECKING
+
+import numpy as np
+import torch
+
+if TYPE_CHECKING:
+    from torch import Tensor
+
+from .base import ModelBase, TextModel, MmprojModel, LazyTorchTensor, gguf, logger
+
+LM_DIR = Path("lm") / "zonos3_mini"
+
+# codec decoder: quantizers and semantic head from the generator, the rest from the head checkpoint
+CODEC_GENERATOR = Path("autoencoder") / "generator.pt"
+CODEC_VARIANT   = "keep2_plain"
+CODEC_HEAD      = Path("autoencoder") / "heads" / CODEC_VARIANT / "step_0050000.pt"
+CODEC_STAGES    = [0, 2, 4, 6, 8]  # transformer stages of the decoder
+
+DEFAULT_VOICE = "Savannah_en-American_F_1"
+
+# speaker encoder (ownmect_s2), MECT_B2 blocks: (cnn depth, out channels, (stride f, stride t), expansion)
+SPK_CKPT   = Path("speaker_encoders") / "ownmect_s2" / "ckpt_0037.pth"
+SPK_STAGES = [
+    [(3, 32, (1, 1), 4)] * 2,
+    [(3, 64, (2, 1), 2)] + [(3, 64, (1, 2), 2)] * 3,
+    [(3, 128, (2, 1), 2)] + [(3, 128, (1, 2), 2)] * 4,
+    [(3, 256, (2, 1), 1)] + [(2, 256, (1, 2), 1)] * 2,
+]
+VOICE_MAX_FRAMES = 900  # reference_max_frames of the reference
+
+# GDN geometry, hard-coded in the reference (zonos3.reference.lm)
+GDN_HEADS, GDN_HEAD_DIM, GDN_KERNEL = 4, 128, 4
+
+# the text table keeps the tokenizer ids shifted by this offset, ids 2 and 3 are BOS and EOS
+TEXT_OFFSET = 4
+TEXT_BOS, TEXT_EOS = 2, 3
+
+
+def _round_up(value: float, multiple: int) -> int:
+    return multiple * ((int(value) + multiple - 1) // multiple)
+
+
+def _load_params(dir_model: Path) -> dict[str, Any] | None:
+    path = dir_model / LM_DIR / "params.json"
+    if not path.is_file():
+        return None
+    with open(path, encoding="utf-8") as f:
+        params = json.load(f).get("model", {})
+    return params if params.get("model_type") == "zonos2" else None
+
+
+def _is_zonos3_checkpoint(dir_model: Path) -> bool:
+    return _load_params(dir_model) is not None
+
+
+@ModelBase.register_hparams_loader(_is_zonos3_checkpoint)
+def _load_zonos3_hparams(dir_model: Path) -> dict[str, Any]:
+    logger.info("gguf: detected ZONOS3 checkpoint")
+    params = _load_params(dir_model)
+    assert params is not None
+    for key, want in (("attention_pattern", "gdn3_global1"), ("residual_type", "full_attnres"),
+                      ("moe_router_type", "linear"), ("moe_router_use_eda", False), ("use_xsa", False),
+                      ("moe_end_from_layer", 0), ("speaker_enabled", True)):
+        if params.get(key) != want:
+            raise NotImplementedError(f"ZONOS3: unsupported {key} = {params.get(key)!r}, expected {want!r}")
+    text_config = dict(params, hidden_size=params["dim"])
+    return {"architectures": ["Zonos3Model"], "text_config": text_config, "audio_config": {"num_hidden_layers": 0}}
+
+
+def _index_lm_tensors(model: ModelBase) -> dict[str, Callable[[], Tensor]]:
+    path = model.dir_model / LM_DIR / "model.pth"
+    logger.info(f"gguf: indexing model part '{LM_DIR / 'model.pth'}'")
+    state = torch.load(str(path), map_location="cpu", mmap=True, weights_only=True)
+    tensors: dict[str, Callable[[], Tensor]] = {}
+    for name, data in state.items():
+        gen = (lambda data=data: LazyTorchTensor.from_eager(data)) if model.lazy else (lambda data=data: data)
+        titem = model.filter_tensors((name, gen))
+        model._source_tensor_names[name] = titem[0] if titem else None
+        if titem:
+            tensors[titem[0]] = titem[1]
+    return tensors
+
+
+@ModelBase.register("Zonos3Model")
+class Zonos3TextModel(TextModel):
+    model_arch = gguf.MODEL_ARCH.ZONOS3
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        hp = self.hparams
+        self.n_embd       = hp["dim"]
+        self.n_codebooks  = hp["n_codebooks"]
+        self.audio_vocab  = hp["codebook_size"] + 2  # codes, EOA, pad
+        self.audio_pad_id = hp["audio_pad_id"]
+        self.text_rows    = hp["text_vocab"] + 1     # + the padding row, used by the generated rows
+        self.cond_counts  = [n + 1 for n in hp["condition_bucket_counts"]]  # + the unknown bucket
+        self.gdn_period   = int(hp["attention_pattern"][len("gdn"):].split("_")[0]) + 1
+        self.n_ff         = _round_up(hp["ffn_dim_multiplier"] * hp["dim"], hp["multiple_of"])
+        self.n_ff_exp     = _round_up(hp["moe_ffn_dim_multiplier"] * hp["dim"], hp["moe_multiple_of"])
+        self.n_latent     = int(hp["dim"] * hp["moe_alpha"])
+        self._embd: dict[str, Tensor] = {}
+        self._router: dict[int, dict[str, Tensor]] = {}
+
+    def index_tensors(self, remote_hf_model_id: str | None = None) -> dict[str, Callable[[], Tensor]]:
+        return _index_lm_tensors(self)
+
+    @classmethod
+    def filter_tensors(cls, item: tuple[str, Callable[[], Tensor]]) -> tuple[str, Callable[[], Tensor]] | None:
+        name, _ = item
+        # audio heads and speaker projection go to the mmproj
+        if name.startswith(("multi_output.", "speaker_projection.")):
+            return None
+        return item
+
+    def set_vocab(self):
+        with open(self.dir_model / LM_DIR / "tokenizer" / "superbpe8192.json", encoding="utf-8") as f:
+            tok = json.load(f)
+        model = tok["model"]
+        if model["type"] != "BPE" or model.get("byte_fallback") or tok.get("normalizer") is not None:
+            raise NotImplementedError("ZONOS3: unexpected tokenizer type")
+
+        vocab: dict[str, int] = model["vocab"]
+        n_bpe = len(vocab)
+        if n_bpe + TEXT_OFFSET != self.hparams["text_vocab"]:
+            raise ValueError(f"ZONOS3: tokenizer has {n_bpe} tokens, text_vocab is {self.hparams['text_vocab']}")
+        added = {t["id"] for t in tok.get("added_tokens", [])}
+
+        tokens: list[str] = ["<|unused_0|>", "<|unused_1|>", "<|bos|>", "<|eos|>"]
+        toktypes: list[int] = [gguf.TokenType.CONTROL] * TEXT_OFFSET
+        inv = {i: t for t, i in vocab.items()}
+        for i in range(n_bpe):
+            tokens.append(inv[i])
+            toktypes.append(gguf.TokenType.CONTROL if i in added else gguf.TokenType.NORMAL)
+
+        # padding row of the text table (the text column of every generated row)
+        tokens.append("<|text_pad|>")
+        toktypes.append(gguf.TokenType.CONTROL)
+
+        # condition rows, the last bucket of each table is "unknown"
+        for cond, n in zip(self.hparams["condition_names"], self.cond_counts):
+            tokens += [f"<|{cond}_{i}|>" for i in range(n - 1)] + [f"<|{cond}_unk|>"]
+            toktypes += [gguf.TokenType.CONTROL] * n
+
+        merges = [" ".join(m) if isinstance(m, list) else m for m in model["merges"]]
+
+        self.gguf_writer.add_tokenizer_model("gpt2")
+        self.gguf_writer.add_tokenizer_pre("zonos3")
+        self.gguf_writer.add_token_list(tokens)
+        self.gguf_writer.add_token_types(toktypes)
+        self.gguf_writer.add_token_merges(merges)
+        self.gguf_writer.add_bos_token_id(TEXT_BOS)
+        self.gguf_writer.add_eos_token_id(TEXT_EOS)
+        self.gguf_writer.add_pad_token_id(TEXT_OFFSET + 1)  # <|pad|> of the tokenizer
+        # the reference wraps the text as [BOS] + ids + [EOS]
+        self.gguf_writer.add_add_bos_token(True)
+        self.gguf_writer.add_add_eos_token(True)
+        # the reference normalizes the text with NeMo, the template covers the English numbers
+        template_path = Path(__file__).parent.parent / "models" / "templates" / "Zyphra-ZONOS3-Mini-tts.jinja"
+        with open(template_path, "r", encoding="utf-8") as f:
+            self.gguf_writer.add_chat_template([{"name": "tts", "template": f.read()}])
+
+    def set_gguf_parameters(self):
+        hp = self.hparams
+        n_layer = self.block_count
+        self.gguf_writer.add_file_type(self.ftype)
+        self.gguf_writer.add_block_count(n_layer)
+        self.gguf_writer.add_context_length(hp["max_seqlen"])
+        self.gguf_writer.add_embedding_length(self.n_embd)
+        self.gguf_writer.add_feed_forward_length(self.n_ff)
+        self.gguf_writer.add_head_count(hp["n_heads"])
+        self.gguf_writer.add_head_count_kv(hp["n_kv_heads"])
+        self.gguf_writer.add_key_length(hp["head_dim"])
+        self.gguf_writer.add_value_length(hp["head_dim"])
+        self.gguf_writer.add_rope_dimension_count(hp["head_dim"])
+        self.gguf_writer.add_rope_freq_base(hp["rope_theta"])
+        self.gguf_writer.add_layer_norm_rms_eps(hp["norm_eps"])
+        self.gguf_writer.add_full_attention_interval(self.gdn_period)
+
+        # gated delta net
+        self.gguf_writer.add_ssm_conv_kernel(GDN_KERNEL)
+        self.gguf_writer.add_ssm_inner_size(GDN_HEADS * GDN_HEAD_DIM)
+        self.gguf_writer.add_ssm_state_size(GDN_HEAD_DIM)
+        self.gguf_writer.add_ssm_time_step_rank(GDN_HEADS)
+        self.gguf_writer.add_ssm_group_count(GDN_HEADS)
+
+        # latent MoE, the gating (softmax over the selected logits) is hard-coded in libllama
+        self.gguf_writer.add_leading_dense_block_count(hp["moe_start_from_layer"])
+        self.gguf_writer.add_expert_count(hp["moe_n_experts"])
+        self.gguf_writer.add_expert_used_count(hp["moe_router_topk"])
+        self.gguf_writer.add_expert_feed_forward_length(self.n_ff_exp)
+        self.gguf_writer.add_moe_latent_size(self.n_latent)
+
+    def tensor_force_quant(self, name, new_name, bid, n_dims):
+        # router logits pick the experts, keep them exact
+        if new_name.endswith(("ffn_gate_inp.weight", "exp_probs_b.bias")):
+            return gguf.GGMLQuantizationType.F32
+        return super().tensor_force_quant(name, new_name, bid, n_dims)
+
+    def _token_embd(self) -> Tensor:
+        n_cb = self.n_codebooks
+        pad = sum(self._embd[f"multi_embedder.embedders.{cb}.weight"][self.audio_pad_id].float() for cb in range(n_cb))
+        text = self._embd[f"multi_embedder.embedders.{n_cb}.weight"].float()
+        if text.shape[0] != self.text_rows:
+            raise ValueError(f"ZONOS3: text table has {text.shape[0]} rows, expected {self.text_rows}")
+        conds = [self._embd[f"condition_embeddings.{i}.weight"].float() for i in range(len(self.cond_counts))]
+        for t, n in zip(conds, self.cond_counts):
+            assert t.shape[0] == n
+        # a text row is [pad x n_codebooks, text_id], condition rows replace the summed row
+        return torch.cat([text + pad] + conds, dim=0)
+
+    def modify_tensors(self, data_torch: Tensor, name: str, bid: int | None) -> Iterable[tuple[str, Tensor]]:
+        T = gguf.MODEL_TENSOR
+        hp = self.hparams
+        n_layer = self.block_count
+
+        if name.startswith(("multi_embedder.", "condition_embeddings.")):
+            self._embd[name] = data_torch
+            if len(self._embd) == self.n_codebooks + 1 + len(self.cond_counts):
+                yield (self.format_tensor_name(T.TOKEN_EMBD), self._token_embd())
+                self._embd.clear()
+            return
+
+        if name == "block_attnres_queries":
+            # one query per routing point: 2*il before the mixer, 2*il+1 before the FFN, the last one before out_norm
+            assert data_torch.shape[0] == 2 * n_layer + 1
+            for il in range(n_layer):
+                yield (self.format_tensor_name(T.ATTN_RES_SCORE, il), data_torch[2 * il])
+                yield (self.format_tensor_name(T.FFN_RES_SCORE, il), data_torch[2 * il + 1])
+            yield (self.format_tensor_name(T.OUTPUT_RES_SCORE), data_torch[2 * n_layer])
+            return
+
+        if name == "out_norm.weight":
+            yield (self.format_tensor_name(T.OUTPUT_NORM), data_torch)
+            return
+
+        assert bid is not None, name
+        key = name.split(".", 2)[2]  # strip "layers.{bid}."
+
+        if key == "attention_norm.weight":
+            yield (self.format_tensor_name(T.ATTN_NORM, bid), data_torch)
+            return
+        if key == "ffn_norm.weight":
+            yield (self.format_tensor_name(T.FFN_NORM, bid), data_torch)
+            return
+
+        # gated delta net
+        if key == "attention.in_proj.weight":
+            # split the fused [qkv | z | a | b] weight into the qwen35 GDN tensors
+            # attn_gate holds z, per-head output gate in the attention layers
+            inner = GDN_HEADS * GDN_HEAD_DIM
+            qkv, z, a, b = data_torch.split([3 * inner, inner, GDN_HEADS, GDN_HEADS], dim=0)
+            yield (self.format_tensor_name(T.ATTN_QKV, bid), qkv)
+            yield (self.format_tensor_name(T.ATTN_GATE, bid), z)
+            yield (self.format_tensor_name(T.SSM_ALPHA, bid), a)
+            yield (self.format_tensor_name(T.SSM_BETA, bid), b)
+            return
+        if key == "attention.conv.weight":
+            yield (self.format_tensor_name(T.SSM_CONV1D, bid), data_torch.squeeze(1))
+            return
+        if key == "attention.A_log":
+            yield (self.format_tensor_name(T.SSM_A, bid, suffix=""), -torch.exp(data_torch.float()))
+            return
+        if key == "attention.dt_bias":
+            yield (self.format_tensor_name(T.SSM_DT, bid, suffix=".bias"), data_torch)
+            return
+        if key == "attention.out_weight":
+            yield (self.format_tensor_name(T.SSM_NORM, bid), data_torch)
+            return
+        if key == "attention.wo.weight" and (bid + 1) % self.gdn_period != 0:
+            yield (self.format_tensor_name(T.SSM_OUT, bid), data_torch)
+            return
+
+        # global attention
+        if key == "attention.wq.weight":
+            yield (self.format_tensor_name(T.ATTN_Q, bid), data_torch)
+            return
+        if key == "attention.wkv.weight":
+            yield (self.format_tensor_name(T.ATTN_K, bid), data_torch[0])
+            yield (self.format_tensor_name(T.ATTN_V, bid), data_torch[1])
+            return
+        if key == "attention.wo.weight":
+            yield (self.format_tensor_name(T.ATTN_OUT, bid), data_torch)
+            return
+        if key == "attention.gater.weight":
+            # one sigmoid gate per head, same tensor name as the GDN z gate
+            yield (self.format_tensor_name(T.ATTN_GATE, bid), data_torch)
+            return
+        if key == "attention.temp":
+            # Q = rms_norm(q) * |temp|, the per-head scale is stored as the Q norm weight
+            temp = data_torch.float().abs().reshape(hp["n_heads"], 1).expand(hp["n_heads"], hp["head_dim"])
+            yield (self.format_tensor_name(T.ATTN_Q_NORM, bid), temp.contiguous())
+            return
+
+        # dense FFN (layer 0): out = w_out(w_in[0](x) * silu(w_in[1](x)))
+        if key == "feed_forward.w_in.weight":
+            yield (self.format_tensor_name(T.FFN_UP, bid), data_torch[0])
+            yield (self.format_tensor_name(T.FFN_GATE, bid), data_torch[1])
+            return
+        if key == "feed_forward.w_out.weight":
+            yield (self.format_tensor_name(T.FFN_DOWN, bid), data_torch)
+            return
+
+        # latent MoE
+        if key in ("feed_forward.router.router_linear.weight", "feed_forward.router.rmsnorm_eda.weight"):
+            # logits = router_linear(rms_norm(x) * w), the norm weight is folded into the router columns
+            r = self._router.setdefault(bid, {})
+            r[key] = data_torch
+            if len(r) == 2:
+                w = r["feed_forward.router.router_linear.weight"].float() * r["feed_forward.router.rmsnorm_eda.weight"].float()[None, :]
+                yield (self.format_tensor_name(T.FFN_GATE_INP, bid), w)
+                del self._router[bid]
+            return
+        if key == "feed_forward.router.balancing_biases":
+            # the reference selects on logits - bias
+            yield (self.format_tensor_name(T.FFN_EXP_PROBS_B, bid, suffix=".bias"), -data_torch.float())
+            return
+        if key == "feed_forward.latent_down.weight":
+            yield (self.format_tensor_name(T.MOE_LATENT_DOWN, bid), data_torch)
+            return
+        if key == "feed_forward.latent_up.weight":
+            yield (self.format_tensor_name(T.MOE_LATENT_UP, bid), data_torch)
+            return
+        if key == "feed_forward.experts.gate_up_proj":
+            gate, up = data_torch.chunk(2, dim=1)
+            yield (self.format_tensor_name(T.FFN_GATE_EXP, bid), gate)
+            yield (self.format_tensor_name(T.FFN_UP_EXP, bid), up)
+            return
+        if key == "feed_forward.experts.down_proj":
+            yield (self.format_tensor_name(T.FFN_DOWN_EXP, bid), data_torch)
+            return
+
+        raise ValueError(f"ZONOS3: unexpected tensor {name}")
+
+
+@ModelBase.register("Zonos3Model")
+class Zonos3MmprojModel(MmprojModel):
+    has_vision_encoder = False
+    has_audio_encoder = True
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        hp = self.global_config["text_config"]
+        self.n_codebooks = hp["n_codebooks"]
+        self.text_vocab  = hp["text_vocab"]
+        self._embd: dict[str, Tensor] = {}
+
+    def index_tensors(self, remote_hf_model_id: str | None = None) -> dict[str, Callable[[], Tensor]]:
+        return _index_lm_tensors(self)
+
+    @classmethod
+    def filter_tensors(cls, item: tuple[str, Callable[[], Tensor]]) -> tuple[str, Callable[[], Tensor]] | None:
+        name, _ = item
+        if name.startswith(("multi_output.", "multi_embedder.", "speaker_projection.")):
+            return item
+        return None
+
+    def set_gguf_parameters(self):
+        self.gguf_writer.add_file_type(self.ftype)
+
+        # speaker encoder for voice cloning, the front-end hparams are hard-coded in tools/mtmd
+        self.gguf_writer.add_clip_has_audio_encoder(True)
+        self.gguf_writer.add_clip_audio_projector_type(gguf.VisionProjectorType.ZONOS3_SPKENC)
+        self.gguf_writer.add_audio_projection_dim(self.n_embd_text)
+        self.gguf_writer.add_audio_num_mel_bins(80)
+        self.gguf_writer.add_audio_block_count(0)
+        self.gguf_writer.add_audio_embedding_length(192)
+        self.gguf_writer.add_audio_head_count(1)
+        self.gguf_writer.add_audio_feed_forward_length(192)
+        self.gguf_writer.add_audio_attention_layernorm_eps(1e-5)
+
+        self.gguf_writer.add_clip_has_gen_audio_encoder(True)
+        self.gguf_writer.add_clip_gen_audio_projector_type(gguf.VisionProjectorType.ZONOS3_GEN)
+        self.gguf_writer.add_gen_audio_projection_dim(self.n_embd_text)
+        self.gguf_writer.add_gen_audio_embedding_length(self.n_embd_text)
+        self.gguf_writer.add_gen_audio_feed_forward_length(0)
+        self.gguf_writer.add_gen_audio_block_count(0)
+        self.gguf_writer.add_gen_audio_head_count(0)
+        self.gguf_writer.add_gen_audio_attention_layernorm_eps(self.global_config["text_config"]["norm_eps"])
+        self.gguf_writer.add_gen_audio_model_variant(CODEC_VARIANT)
+        if any(self.dir_model.glob(f"gguf/voices/*/{DEFAULT_VOICE}.codes.npy")):
+            self.gguf_writer.add_gen_audio_default_voice(DEFAULT_VOICE)
+        # softcap and the codec hparams are hard-coded in tools/mtmd
+
+    def tensor_force_quant(self, name, new_name, bid, n_dims):
+        # read on the host, or used by get_rows / ggml_ssm_conv / the conv ops of the speaker encoder
+        if new_name.startswith(("voice.", "a.spk.")) or new_name.endswith((".table", ".dw.weight", ".mixer.weight", ".resample.weight")):
+            return gguf.GGMLQuantizationType.F32
+        return super().tensor_force_quant(name, new_name, bid, n_dims)
+
+    @staticmethod
+    def _wn(sd: dict[str, Tensor], prefix: str) -> Tensor:
+        # torch weight norm, over every dim but 0
+        g, v = sd[prefix + ".weight_g"].float(), sd[prefix + ".weight_v"].float()
+        return (g * v / v.flatten(1).norm(dim=1).view(-1, *[1] * (v.dim() - 1))).squeeze(-1)
+
+    def _speaker_encoder(self) -> Iterable[tuple[str, Tensor]]:
+        # same layout as the reference ggml runtime (tools/convert_gguf.py speaker-encoder)
+        sd = torch.load(self.dir_model / SPK_CKPT, map_location="cpu", weights_only=True)["modules"]["spk_model"]
+        sd = {k[len("model."):]: v.double() for k, v in sd.items() if not k.endswith("num_batches_tracked")}
+        p = "a.spk"
+
+        def bn(dst: str, src: str) -> Iterable[tuple[str, Tensor]]:
+            scale = sd[src + ".weight"] / torch.sqrt(sd[src + ".running_var"] + 1e-5)
+            yield (f"{p}.{dst}.scale", scale)
+            yield (f"{p}.{dst}.shift", sd[src + ".bias"] - sd[src + ".running_mean"] * scale)
+
+        def put(dst: str, t: Tensor) -> tuple[str, Tensor]:
+            return (f"{p}.{dst}", t)
+
+        fe = "feature_extractor."
+        yield put("fe.head.conv.weight", sd[fe + "head.0.weight"])
+        yield put("fe.head.conv.bias",   sd[fe + "head.0.bias"])
+        yield from bn("fe.head.bn", fe + "head.1")
+        c_in, f_in = 32, 80
+        for si, stage in enumerate(SPK_STAGES):
+            for k, (depth, out_ch, (s_f, s_t), expansion) in enumerate(stage):
+                src, dst = f"{fe}mect_block{si + 1}.{k}.", f"fe.b{si + 1}.{k}."
+                for r in range(depth):
+                    cs, cd = f"{src}cnn_block.{r}.", f"{dst}cnn.{r}."
+                    # grouped 3x3 convs, one depthwise kernel per input slot of a group
+                    for conv in ("conv1", "conv2"):
+                        w = sd[cs + conv + ".weight"]
+                        for j in range(w.shape[1]):
+                            yield put(f"{cd}{conv}.w{j}", w[:, j:j + 1])
+                    for i in (1, 2):
+                        yield put(f"{cd}pw{i}.weight", sd[f"{cs}conv_pw{i}.weight"])
+                        yield put(f"{cd}pw{i}.bias",   sd[f"{cs}conv_pw{i}.bias"])
+                        yield from bn(f"{cd}bn{i}", f"{cs}bn{i}")
+                    if cs + "shortcut.0.weight" in sd:
+                        yield put(cd + "sc.weight", sd[cs + "shortcut.0.weight"])
+                        yield from bn(cd + "sc.bn", cs + "shortcut.1")
+                if expansion > 1:
+                    yield put(dst + "reduce.weight", sd[src + "reduce_conv.0.weight"])
+                    yield put(dst + "reduce.bias",   sd[src + "reduce_conv.0.bias"])
+                    yield from bn(dst + "reduce.bn", src + "reduce_conv.1")
+                yield put(dst + "rproj.weight",    sd[src + "reduce_project.0.weight"].squeeze(-1))
+                yield put(dst + "rproj.bias",      sd[src + "reduce_project.0.bias"])
+                yield put(dst + "rproj.ln.weight", sd[src + "reduce_project.1.weight"])
+                yield put(dst + "rproj.ln.bias",   sd[src + "reduce_project.1.bias"])
+                yield put(dst + "pos.dw.weight",   sd[src + "pos_conv.pos_conv.0.weight"].squeeze(1))
+                yield put(dst + "pos.dw.bias",     sd[src + "pos_conv.pos_conv.0.bias"])
+                yield from bn(dst + "pos.bn", src + "pos_conv.pos_conv.1")
+                yield put(dst + "pos.pw.weight",   sd[src + "pos_conv.pos_conv.3.weight"].squeeze(-1))
+                yield put(dst + "pos.pw.bias",     sd[src + "pos_conv.pos_conv.3.bias"])
+                tr = src + "transformer."
+                for a, b in (("q", "q_proj"), ("k", "k_proj"), ("v", "v_proj"), ("o", "out_proj")):
+                    yield put(f"{dst}attn.{a}.weight", sd[f"{tr}attention.{b}.weight"])
+                    yield put(f"{dst}attn.{a}.bias",   sd[f"{tr}attention.{b}.bias"])
+                yield put(dst + "ln1.weight", sd[tr + "layer_norm.weight"])
+                yield put(dst + "ln1.bias",   sd[tr + "layer_norm.bias"])
+                moe = tr + "feed_forward.intermediate_dense.token_moe."
+                yield put(dst + "moe.gate.weight",    sd[moe + "gate.weight"])
+                yield put(dst + "moe.experts.weight", sd[moe + "experts.weight"])
+                yield put(dst + "ffn_out.weight", sd[tr + "feed_forward.output_dense.weight"])
+                yield put(dst + "ffn_out.bias",   sd[tr + "feed_forward.output_dense.bias"])
+                yield put(dst + "ln2.weight", sd[tr + "final_layer_norm.weight"])
+                yield put(dst + "ln2.bias",   sd[tr + "final_layer_norm.bias"])
+                yield put(dst + "eproj.weight", sd[src + "expansion_project.0.weight"].squeeze(-1))
+                yield put(dst + "eproj.bias",   sd[src + "expansion_project.0.bias"])
+                c_in, f_in = c_in * s_f, f_in // s_f
+        # the stage outputs are mixed by softmax weights
+        yield put("fe.dense_w", torch.softmax(sd[fe + "dense3.inputs_weights"], dim=1)[0, :, :, 0])
+        yield put("pool.l1.weight", sd["encoding_layers.linear1.weight"].squeeze(-1))
+        yield put("pool.l1.bias",   sd["encoding_layers.linear1.bias"])
+        yield put("pool.l2.weight", sd["encoding_layers.linear2.weight"].squeeze(-1))
+        yield put("pool.l2.bias",   sd["encoding_layers.linear2.bias"])
+        yield from bn("bn", "bn")
+        yield put("linear.weight", sd["linear.weight"])
+
+    def generate_extra_tensors(self) -> Iterable[tuple[str, Tensor]]:
+        yield from self._speaker_encoder()
+        logger.info(f"gguf: loading codec decoder from '{CODEC_GENERATOR}' and '{CODEC_HEAD}'")
+        gen  = torch.load(self.dir_model / CODEC_GENERATOR, map_location="cpu", mmap=True, weights_only=True)["generator"]
+        head = torch.load(self.dir_model / CODEC_HEAD,      map_location="cpu", mmap=True, weights_only=True)["trainable"]
+        p = "a.gen.wav"
+
+        def convnext(dst: str, src: dict[str, Tensor], prefix: str) -> Iterable[tuple[str, Tensor]]:
+            yield (f"{dst}.dw.weight",   src[f"{prefix}.dwconv.weight"].squeeze(1))
+            yield (f"{dst}.dw.bias",     src[f"{prefix}.dwconv.bias"])
+            yield (f"{dst}.norm.weight", src[f"{prefix}.norm.weight"])
+            yield (f"{dst}.norm.bias",   src[f"{prefix}.norm.bias"])
+            yield (f"{dst}.pw1.weight",  src[f"{prefix}.pwconv1.weight"])
+            yield (f"{dst}.pw1.bias",    src[f"{prefix}.pwconv1.bias"])
+            yield (f"{dst}.pw2.weight",  src[f"{prefix}.pwconv2.weight"])
+            yield (f"{dst}.pw2.bias",    src[f"{prefix}.pwconv2.bias"])
+
+        # RVQ codebooks folded through their out_proj, the biases are added once
+        def rvq(prefix: str) -> tuple[Tensor, Tensor]:
+            return gen[prefix + ".codebook.weight"].float() @ self._wn(gen, prefix + ".out_proj").T, gen[prefix + ".out_proj.bias"].float()
+
+        t, b = rvq("quantizer.semantic_quantizer.quantizers.0")
+        yield (f"{p}.sem.table", t)
+        yield (f"{p}.sem.bias",  b)
+        res = [rvq(f"quantizer.quantizer.quantizers.{i}") for i in range(self.n_codebooks - 1)]
+        yield (f"{p}.res.table", torch.cat([t for t, _ in res], dim=0))
+        # one bias per residual codebook, a frame decoded before all its codes are known leaves the missing ones out
+        yield (f"{p}.res.bias",  torch.stack([b for _, b in res]))
+
+        n_sem = sum(1 for k in gen if k.startswith("semantic_head.") and k.endswith(".dwconv.weight"))
+        for i in range(n_sem):
+            yield from convnext(f"{p}.sem.{i}", gen, f"semantic_head.{i}")
+        yield (f"{p}.sem.out.weight", self._wn(gen, f"semantic_head.{n_sem}"))
+        yield (f"{p}.sem.out.bias",   gen[f"semantic_head.{n_sem}.bias"])
+
+        names = {"norm1.weight": "norm1.weight", "norm1.bias": "norm1.bias", "qkv.weight": "qkv.weight",
+                 "attention_out.weight": "wo.weight", "attention_scale": "attn_scale", "norm2.weight": "norm2.weight",
+                 "norm2.bias": "norm2.bias", "ffn.0.weight": "ffn1.weight", "ffn.3.weight": "ffn2.weight", "ffn_scale": "ffn_scale"}
+        for si, s in enumerate(CODEC_STAGES):
+            yield (f"{p}.stage.{si}.in.weight", head[f"stages.{s}.input_proj.weight"])
+            if f"stages.{s}.output_proj.weight" in head:
+                yield (f"{p}.stage.{si}.out.weight", head[f"stages.{s}.output_proj.weight"])
+            n_layer = sum(1 for k in head if k.startswith(f"stages.{s}.layers.") and k.endswith(".qkv.weight"))
+            for il in range(n_layer):
+                for src, dst in names.items():
+                    yield (f"{p}.stage.{si}.blk.{il}.{dst}", head[f"stages.{s}.layers.{il}.{src}"])
+
+        n_head = sum(1 for k in head if k.startswith("head.blocks.") and k.endswith(".dwconv.weight"))
+        for i in range(n_head):
+            yield from convnext(f"{p}.head.{i}", head, f"head.blocks.{i}")
+        yield (f"{p}.head.norm.weight", head["head.norm.weight"])
+        yield (f"{p}.head.norm.bias",   head["head.norm.bias"])
+        # both iSTFT branches in one projection
+        yield (f"{p}.head.out.weight", torch.cat([head["head.outs.0.weight"], head["head.outs.1.weight"]], dim=0))
+        yield (f"{p}.head.out.bias",   torch.cat([head["head.outs.0.bias"],   head["head.outs.1.bias"]],   dim=0))
+        # read on the host
+        yield (f"{p}.mixer.weight",   head["head.mixer.weight"].squeeze(0))
+        yield (f"{p}.resample.weight", gen["to_external.kernel"].squeeze(1))
+
+    # "reference" speaker mode, the default of the reference: the voice is a prefix of delayed code rows
+    def _voices(self, tables: list[Tensor]) -> Iterable[tuple[str, Tensor]]:
+        n_cb, pad = self.n_codebooks, self.global_config["text_config"]["audio_pad_id"]
+        for path in sorted((self.dir_model / "gguf" / "voices").glob("*/*.codes.npy")):
+            codes = torch.from_numpy(np.load(path, allow_pickle=False).astype(np.int64))
+            if codes.ndim != 2 or codes.shape[1] != n_cb or codes.min() < 0 or codes.max() >= pad - 1:
+                logger.warning(f"voice {path.name}: skipped, expected [frames, {n_cb}] codes")
+                continue
+            codes = codes[:VOICE_MAX_FRAMES]
+            n_frames = codes.shape[0]
+            # codebook cb of frame t is in row t + cb, the other cells are pad
+            rows = torch.full((n_frames + n_cb - 1, n_cb), pad, dtype=torch.int64)
+            for cb in range(n_cb):
+                rows[cb:cb + n_frames, cb] = codes[:, cb]
+            embd = sum(tables[cb][rows[:, cb]] for cb in range(n_cb))
+            yield (f"voice.{path.name[:-len('.codes.npy')]}", embd)
+
+    def modify_tensors(self, data_torch: Tensor, name: str, bid: int | None) -> Iterable[tuple[str, Tensor]]:
+        T = gguf.MODEL_TENSOR
+
+        if name.startswith(("a.gen.wav.", "a.spk.")):
+            yield (name, data_torch)
+            return
+
+        # the speaker encoder ends with the speaker projection of the backbone
+        if name.startswith("speaker_projection."):
+            yield ("a.spk.proj." + name.split(".")[-1], data_torch)
+            return
+
+        if name == "multi_output.weight":
+            yield (self.format_tensor_name(T.A_GEN_CODE_HEAD), data_torch)
+            return
+
+        assert name.startswith("multi_embedder.")
+        self._embd[name] = data_torch
+        if len(self._embd) < self.n_codebooks + 1:
+            return
+        # every row built by the mmproj has text id = text_vocab, so that row is folded into codebook 0
+        tables = [self._embd[f"multi_embedder.embedders.{cb}.weight"].float() for cb in range(self.n_codebooks)]
+        tables[0] = tables[0] + self._embd[f"multi_embedder.embedders.{self.n_codebooks}.weight"][self.text_vocab].float()
+        yield (self.format_tensor_name(T.A_GEN_CODE_OUT_EMBD), torch.cat(tables, dim=0))
+        yield from self._voices([LazyTorchTensor.to_eager(t) for t in tables])
+        self._embd.clear()
diff --git a/docs/multimodal/tts.md b/docs/multimodal/tts.md
index 3ffc6d45e..1dca6c461 100644
--- a/docs/multimodal/tts.md
+++ b/docs/multimodal/tts.md
@@ -11,6 +11,7 @@ Supported models:
 - [LFM3-Audio](#lfm3-audio)
 - [Chatterbox](#chatterbox)
 - [Drama 3](#drama-3)
+- [ZONOS3](#zonos3)

 The sections below list the params supported by each model, with `llama-tts` examples. In `llama-server`, the same options are set with `--tts-lang` and `--tts-voice`, or per request (see the [`/tts` endpoint](../../tools/server/README.md#post-tts-generate-speech-audio-from-text)).

@@ -180,3 +181,37 @@ The routed experts of the checkpoint are FP8, they are dequantized at conversion
 python convert_hf_to_gguf.py path/to/drama-3 --outtype q8_0 --outfile drama3.gguf
 python convert_hf_to_gguf.py path/to/drama-3 --mmproj --outtype bf16 --outfile mmproj-drama3.gguf
 ```
+
+## ZONOS3
+
+[Zyphra ZONOS3 Mini](https://huggingface.co/Zyphra/ZONOS3-Mini): a hybrid Gated DeltaNet / attention MoE backbone (800M, 100M active) predicts the 10 codebooks of each frame with an inter-codebook delay, and the ZAC codec decodes them to 44.1 kHz audio.
+
+Available params:
+- `--tts-voice` picks one of the predefined voices of the release (`Savannah_en-American_F_1` by default); `llama-server` lists them in `GET /props`. A voice is the prompt prefix of its reference codes, the default speaker mode of the reference
+- The language is the one of the text; `--tts-lang` (`lang` in `llama-server`) only selects the text normalization
+- Numbers are spelled out by the `tts` chat template of the GGUF: cardinals, decimals, money, percents, clock times, common ordinal forms (`1st`, `1er`, `1º`), English and German years. A 4-digit number is read as a year only after a word like `in`, `since`, a month name, or `im Jahr`, `seit` in German (`in 1990` -> "in nineteen ninety", `1234 people` -> "one thousand two hundred thirty-four people"). Languages: `en`, `fr`, `de`, `es`, `it`, `pt`, `ja` and `zh` (or `cmn`), the region is ignored (`fr_fr`, `pt_br`); English when the language is empty, other languages are left as written. Set the language to the one of the text: the separators differ, e.g. `1.234` is a thousand in German
+- Note: the reference uses NeMo text normalization, which also covers units, dates, abbreviations and Korean; supply normalized text for them
+- `--tts-speaker-file` clones the voice of a clip (the first 60 s are used) with the speaker encoder of the release, the "embedding" speaker mode of the reference
+- `llama-server` also takes `speed` (relative to 15 UTF-8 bytes per second) and `loudness` (LUFS), see the `/tts` endpoint; they pick the speaking-rate and loudness condition buckets of the checkpoint, the defaults leave them unknown
+- Note: `loudness` has a strong effect with a cloned voice; a predefined voice mostly keeps the loudness of its reference clip
+- Note: `--top-k` and `--top-p` are not used, frames are sampled with the settings of the reference: temperature 0.75 (`--temp` changes it), top-k 80, top-p 0.69, repetition penalty 2.4 over the last 96 frames of codebooks 0 to 5
+- Each frame is 2940 samples (about 67 ms), generation stops at the end-of-audio code
+
+Example usage:
+
+```sh
+llama-tts -m zonos3-mini.gguf \
+    -mm mmproj-zonos3-mini.gguf \
+    -p "The old lighthouse keeper climbed the spiral stairs every evening." \
+    --tts-voice Imogen_en-British_F_1 \
+    --output out.wav
+```
+
+**Note for GGUF conversion:**
+
+Convert the root of the release repository, not the `lm/` subdirectory. The mmproj holds the codebook heads, the predefined voices, the speaker encoder and the `keep2_plain` codec decoder, it is best kept in F32:
+
+```sh
+python convert_hf_to_gguf.py path/to/ZONOS3-Mini --outtype q8_0 --outfile zonos3-mini.gguf
+python convert_hf_to_gguf.py path/to/ZONOS3-Mini --mmproj --outtype f32 --outfile mmproj-zonos3-mini.gguf
+```
diff --git a/gguf-py/gguf/constants.py b/gguf-py/gguf/constants.py
index ed3ade939..c9b27a11f 100644
--- a/gguf-py/gguf/constants.py
+++ b/gguf-py/gguf/constants.py
@@ -545,6 +545,7 @@ class Keys:
         PROJECTOR_TYPE      = "clip.gen.audio.projector_type" # for mixed modality models
         # name of the weight variant, for settings that are not in the checkpoint
         MODEL_VARIANT       = "clip.gen.audio.model_variant"
+        DEFAULT_VOICE       = "clip.gen.audio.default_voice"
         EMBEDDING_LENGTH    = "clip.gen.audio.embedding_length"
         FEED_FORWARD_LENGTH = "clip.gen.audio.feed_forward_length"
         BLOCK_COUNT         = "clip.gen.audio.block_count"
@@ -765,6 +766,7 @@ class MODEL_ARCH(IntEnum):
     NANBEIGE         = auto()
     QWEN3TTS         = auto()
     POCKETTTS        = auto()
+    ZONOS3           = auto()
     K2HORIZON        = auto()
     KOLIBRI1         = auto()
     LIMITE           = auto()
@@ -1703,6 +1705,7 @@ MODEL_ARCH_NAMES: dict[MODEL_ARCH, str] = {
     MODEL_ARCH.NANBEIGE:         "nanbeige",
     MODEL_ARCH.QWEN3TTS:         "qwen3tts",
     MODEL_ARCH.POCKETTTS:        "pockettts",
+    MODEL_ARCH.ZONOS3:           "zonos3",
     MODEL_ARCH.K2HORIZON:        "k2-horizon",
     MODEL_ARCH.KOLIBRI1:         "kolibri1",
     MODEL_ARCH.LIMITE:           "limite",
@@ -6722,6 +6725,39 @@ MODEL_TENSORS: dict[MODEL_ARCH, list[MODEL_TENSOR]] = {
         MODEL_TENSOR.FFN_DOWN,
         MODEL_TENSOR.FFN_UP,
     ],
+    MODEL_ARCH.ZONOS3: [
+        MODEL_TENSOR.TOKEN_EMBD,
+        MODEL_TENSOR.OUTPUT_NORM,
+        MODEL_TENSOR.OUTPUT_RES_SCORE,
+        MODEL_TENSOR.ATTN_NORM,
+        MODEL_TENSOR.ATTN_RES_SCORE,
+        MODEL_TENSOR.ATTN_Q,
+        MODEL_TENSOR.ATTN_Q_NORM,
+        MODEL_TENSOR.ATTN_K,
+        MODEL_TENSOR.ATTN_V,
+        MODEL_TENSOR.ATTN_OUT,
+        MODEL_TENSOR.ATTN_GATE,
+        MODEL_TENSOR.ATTN_QKV,
+        MODEL_TENSOR.SSM_A,
+        MODEL_TENSOR.SSM_CONV1D,
+        MODEL_TENSOR.SSM_DT,
+        MODEL_TENSOR.SSM_NORM,
+        MODEL_TENSOR.SSM_BETA,
+        MODEL_TENSOR.SSM_ALPHA,
+        MODEL_TENSOR.SSM_OUT,
+        MODEL_TENSOR.FFN_NORM,
+        MODEL_TENSOR.FFN_RES_SCORE,
+        MODEL_TENSOR.FFN_GATE,
+        MODEL_TENSOR.FFN_DOWN,
+        MODEL_TENSOR.FFN_UP,
+        MODEL_TENSOR.FFN_GATE_INP,
+        MODEL_TENSOR.FFN_EXP_PROBS_B,
+        MODEL_TENSOR.MOE_LATENT_DOWN,
+        MODEL_TENSOR.MOE_LATENT_UP,
+        MODEL_TENSOR.FFN_GATE_EXP,
+        MODEL_TENSOR.FFN_DOWN_EXP,
+        MODEL_TENSOR.FFN_UP_EXP,
+    ],
     MODEL_ARCH.K2HORIZON: [
         MODEL_TENSOR.TOKEN_EMBD,
         MODEL_TENSOR.OUTPUT_NORM,
@@ -7156,6 +7192,8 @@ class VisionProjectorType:
     CHATTERBOX = "chatterbox" # audio generation: s3gen flow matching + HiFT vocoder
     SOPRANO = "soprano" # audio generation: Vocos decoder
     DRAMA3_GEN = "drama3_gen" # audio generation: codebook tower + Fish DAC decoder
+    ZONOS3_GEN = "zonos3_gen" # audio generation: 10 codebook heads + ZAC decoder
+    ZONOS3_SPKENC = "zonos3_spkenc" # audio: MECT-B2 speaker encoder
     HUNYUANVL      = "hunyuanvl"
     PARAKEET       = "parakeet"  # audio
     MINIMAXM3      = "minimax_m3"
diff --git a/gguf-py/gguf/gguf_writer.py b/gguf-py/gguf/gguf_writer.py
index 6ca757080..edbe8cd09 100644
--- a/gguf-py/gguf/gguf_writer.py
+++ b/gguf-py/gguf/gguf_writer.py
@@ -1812,6 +1812,9 @@ class GGUFWriter:
     def add_gen_audio_model_variant(self, value: str) -> None:
         self.add_string(Keys.ClipGenAudio.MODEL_VARIANT, value)

+    def add_gen_audio_default_voice(self, value: str) -> None:
+        self.add_string(Keys.ClipGenAudio.DEFAULT_VOICE, value)
+
     def add_xielu_alpha_p(self, values: Sequence[float]):
         self.add_array(Keys.xIELU.ALPHA_P, values)

diff --git a/models/templates/Zyphra-ZONOS3-Mini-tts.jinja b/models/templates/Zyphra-ZONOS3-Mini-tts.jinja
new file mode 100644
index 000000000..ec1255862
--- /dev/null
+++ b/models/templates/Zyphra-ZONOS3-Mini-tts.jinja
@@ -0,0 +1,627 @@
+{#- ZONOS3 "tts" template: the text to speak (first message), normalized; the helper adds BOS and EOS itself -#}
+{#- numbers, decimals, money, percents, ordinals, years and clock times are spelled out in en, fr, de, es, it, pt, ja and zh -#}
+{#- other languages pass through; patterns: tts_norm_scan() / tts_norm_sub(), see docs/tts-norm-patterns.md -#}
+{#- the ja / zh conventions follow the NeMo grammars of the reference (e.g. zh: 两千, 二百, 三百一十六, 二零零二年) -#}
+{#- optional identifies this template, so that llama.cpp can detect it and apply quick fixes to GGUFs already converted -#}
+{%- set template_name = "zonos3-mini" -%}
+
+{#- English -#}
+{%- set ones = ["zero", "one", "two", "three", "four", "five", "six", "seven", "eight", "nine", "ten", "eleven", "twelve",
+                "thirteen", "fourteen", "fifteen", "sixteen", "seventeen", "eighteen", "nineteen"] -%}
+{%- set tens = ["", "", "twenty", "thirty", "forty", "fifty", "sixty", "seventy", "eighty", "ninety"] -%}
+{%- set scales = ["", " thousand", " million", " billion", " trillion"] -%}
+{%- set irregular = {"one": "first", "two": "second", "three": "third", "five": "fifth", "eight": "eighth", "nine": "ninth", "twelve": "twelfth"} -%}
+
+{%- macro under100(n) -%}
+{{- ones[n] if n < 20 else tens[(n / 10)|int] ~ ("-" ~ ones[n % 10] if n % 10 else "") -}}
+{%- endmacro -%}
+
+{%- macro under1000(n) -%}
+{{- (ones[(n / 100)|int] ~ " hundred" ~ (" " ~ under100(n % 100) if n % 100 else "")) if n >= 100 else under100(n) -}}
+{%- endmacro -%}
+
+{#- all the *_card macros take a digit string of up to 15 digits -#}
+{%- macro en_card(s) -%}
+{%- set p = ["", "00", "0"][(s|length) % 3] ~ s -%}
+{%- set ns = namespace(out="", ng=((p|length) / 3)|int) -%}
+{%- for g in range(ns.ng) -%}
+{%- set v = p[g * 3:g * 3 + 3]|int -%}
+{%- if v > 0 -%}
+{%- set ns.out = ns.out ~ (" " if ns.out else "") ~ under1000(v) ~ scales[ns.ng - 1 - g] -%}
+{%- endif -%}
+{%- endfor -%}
+{{- ns.out if ns.out else "zero" -}}
+{%- endmacro -%}
+
+{#- the last word of a cardinal becomes ordinal: twenty-one -> twenty-first -#}
+{%- macro en_ord(w) -%}
+{%- set last = (w.split(" ")|last).split("-")|last -%}
+{%- set head = w[0:(w|length) - (last|length)] -%}
+{{- head ~ (irregular[last] if last in irregular else (last[0:(last|length) - 1] ~ "ieth" if last.endswith("y") else last ~ "th")) -}}
+{%- endmacro -%}
+
+{#- French: traditional spelling, quatre-vingts / deux cents take the plural s only at the end -#}
+{%- set fr_u = ["zéro", "un", "deux", "trois", "quatre", "cinq", "six", "sept", "huit", "neuf", "dix", "onze", "douze", "treize",
+                "quatorze", "quinze", "seize"] -%}
+{%- set fr_t = ["", "", "vingt", "trente", "quarante", "cinquante", "soixante", "", "quatre-vingt", ""] -%}
+{%- set fr_big = [[], [], ["million", "millions"], ["milliard", "milliards"], ["billion", "billions"]] -%}
+
+{%- macro fr99(n, final) -%}
+{%- if n < 17 -%}
+{{- fr_u[n] -}}
+{%- elif n < 20 -%}
+{{- "dix-" ~ fr_u[n - 10] -}}
+{%- else -%}
+{%- set t = (n / 10)|int -%}
+{%- set u = n % 10 -%}
+{%- if t == 7 or t == 9 -%}
+{{- ("soixante" if t == 7 else "quatre-vingt") ~ (" et onze" if t == 7 and u == 1 else "-" ~ fr99(n - (60 if t == 7 else 80), final)) -}}
+{%- else -%}
+{{- fr_t[t] ~ ("s" if t == 8 and u == 0 and final else "") ~ (" et un" if u == 1 and t != 8 else ("-" ~ fr_u[u] if u else "")) -}}
+{%- endif -%}
+{%- endif -%}
+{%- endmacro -%}
+
+{%- macro fr999(n, final) -%}
+{%- set h = (n / 100)|int -%}
+{%- set r = n % 100 -%}
+{%- set hs = "" if h == 0 else ("cent" if h == 1 else fr_u[h] ~ " cent" ~ ("s" if r == 0 and final else "")) -%}
+{{- hs ~ (" " if hs and r else "") ~ (fr99(r, final) if r else "") -}}
+{%- endmacro -%}
+
+{%- macro fr_card(s) -%}
+{%- set p = ["", "00", "0"][(s|length) % 3] ~ s -%}
+{%- set ns = namespace(out="", ng=((p|length) / 3)|int) -%}
+{%- for g in range(ns.ng) -%}
+{%- set v = p[g * 3:g * 3 + 3]|int -%}
+{%- set k = ns.ng - 1 - g -%}
+{%- if v > 0 -%}
+{%- if k == 0 -%}
+{%- set w = fr999(v, true) -%}
+{%- elif k == 1 -%}
+{%- set w = ("" if v == 1 else fr999(v, false) ~ " ") ~ "mille" -%}
+{%- else -%}
+{%- set w = fr999(v, true) ~ " " ~ fr_big[k][0 if v == 1 else 1] -%}
+{%- endif -%}
+{%- set ns.out = ns.out ~ (" " if ns.out else "") ~ w -%}
+{%- endif -%}
+{%- endfor -%}
+{{- ns.out if ns.out else "zéro" -}}
+{%- endmacro -%}
+
+{%- macro fr_ord(w) -%}
+{%- set last = (w.split(" ")|last).split("-")|last -%}
+{%- set head = w[0:(w|length) - (last|length)] -%}
+{%- set base = last[0:(last|length) - 1] if last in ["vingts", "cents", "millions", "milliards", "billions"] else last -%}
+{%- set map = {"cinq": "cinquième", "neuf": "neuvième"} -%}
+{{- head ~ (map[base] if base in map else (base[0:(base|length) - 1] ~ "ième" if base.endswith("e") else base ~ "ième")) -}}
+{%- endmacro -%}
+
+{#- German: below a million one word, eins / ein / eine -#}
+{%- set de_u = ["null", "eins", "zwei", "drei", "vier", "fünf", "sechs", "sieben", "acht", "neun", "zehn", "elf", "zwölf",
+                "dreizehn", "vierzehn", "fünfzehn", "sechzehn", "siebzehn", "achtzehn", "neunzehn"] -%}
+{%- set de_t = ["", "", "zwanzig", "dreißig", "vierzig", "fünfzig", "sechzig", "siebzig", "achtzig", "neunzig"] -%}
+{%- set de_big = [[], [], ["Million", "Millionen"], ["Milliarde", "Milliarden"], ["Billion", "Billionen"]] -%}
+
+{%- macro de99(n) -%}
+{{- de_u[n] if n < 20 else ((("ein" if n % 10 == 1 else de_u[n % 10]) ~ "und") if n % 10 else "") ~ de_t[(n / 10)|int] -}}
+{%- endmacro -%}
+
+{%- macro de999(n) -%}
+{%- set h = (n / 100)|int -%}
+{%- set r = n % 100 -%}
+{{- (((("ein" if h == 1 else de_u[h]) ~ "hundert")) if h else "") ~ (de99(r) if r else "") -}}
+{%- endmacro -%}
+
+{#- attributive form: eins -> ein -#}
+{%- macro de_attr(w) -%}
+{{- w[0:(w|length) - 1] if w.endswith("eins") else w -}}
+{%- endmacro -%}
+
+{%- macro de_card(s) -%}
+{%- set p = ["", "00", "0"][(s|length) % 3] ~ s -%}
+{%- set ns = namespace(big="", small="", ng=((p|length) / 3)|int) -%}
+{%- for g in range(ns.ng) -%}
+{%- set v = p[g * 3:g * 3 + 3]|int -%}
+{%- set k = ns.ng - 1 - g -%}
+{%- if v > 0 -%}
+{%- if k == 0 -%}
+{%- set ns.small = ns.small ~ de999(v) -%}
+{%- elif k == 1 -%}
+{%- set ns.small = ns.small ~ de_attr(de999(v)) ~ "tausend" -%}
+{%- else -%}
+{%- set ns.big = ns.big ~ (" " if ns.big else "") ~ ("eine" if v == 1 else de_attr(de999(v))) ~ " " ~ de_big[k][0 if v == 1 else 1] -%}
+{%- endif -%}
+{%- endif -%}
+{%- endfor -%}
+{%- set out = ns.big ~ (" " if ns.big and ns.small else "") ~ ns.small -%}
+{{- out if out else "null" -}}
+{%- endmacro -%}
+
+{#- join the non-empty parts with a space -#}
+{%- macro join4(a, b, c, d) -%}
+{%- set ns = namespace(out="") -%}
+{%- for x in [a, b, c, d] -%}
+{%- if x -%}
+{%- set ns.out = ns.out ~ (" " if ns.out else "") ~ x -%}
+{%- endif -%}
+{%- endfor -%}
+{{- ns.out -}}
+{%- endmacro -%}
+
+{#- Spanish: long scale (mil millones, billón), un / veintiún before a noun or mil -#}
+{%- set es_u = ["cero", "uno", "dos", "tres", "cuatro", "cinco", "seis", "siete", "ocho", "nueve", "diez", "once", "doce",
+                "trece", "catorce", "quince", "dieciséis", "diecisiete", "dieciocho", "diecinueve", "veinte", "veintiuno",
+                "veintidós", "veintitrés", "veinticuatro", "veinticinco", "veintiséis", "veintisiete", "veintiocho", "veintinueve"] -%}
+{%- set es_t = ["", "", "", "treinta", "cuarenta", "cincuenta", "sesenta", "setenta", "ochenta", "noventa"] -%}
+{%- set es_h = ["", "ciento", "doscientos", "trescientos", "cuatrocientos", "quinientos", "seiscientos", "setecientos",
+                "ochocientos", "novecientos"] -%}
+
+{%- macro es99(n) -%}
+{{- es_u[n] if n < 30 else es_t[(n / 10)|int] ~ (" y " ~ es_u[n % 10] if n % 10 else "") -}}
+{%- endmacro -%}
+
+{%- macro es999(n) -%}
+{%- if n == 100 -%}
+{{- "cien" -}}
+{%- else -%}
+{%- set h = (n / 100)|int -%}
+{%- set r = n % 100 -%}
+{{- es_h[h] ~ (" " if h and r else "") ~ (es99(r) if r else "") -}}
+{%- endif -%}
+{%- endmacro -%}
+
+{%- macro es_apoc(w) -%}
+{{- (w[0:(w|length) - 9] ~ "veintiún") if w.endswith("veintiuno") else ((w[0:(w|length) - 3] ~ "un") if w.endswith("uno") else w) -}}
+{%- endmacro -%}
+
+{%- macro es6(n) -%}
+{%- set a = (n / 1000)|int -%}
+{%- set b = n % 1000 -%}
+{%- set hi = "" if a == 0 else ("mil" if a == 1 else es_apoc(es999(a)) ~ " mil") -%}
+{{- hi ~ (" " if hi and b else "") ~ (es999(b) if b else "") -}}
+{%- endmacro -%}
+
+{%- macro es_card(s) -%}
+{%- set p = ("000000000000000000" ~ s)[-18:] -%}
+{%- set top = p[0:6]|int -%}
+{%- set mid = p[6:12]|int -%}
+{%- set low = p[12:18]|int -%}
+{%- set a = "" if top == 0 else ("un billón" if top == 1 else es_apoc(es6(top)) ~ " billones") -%}
+{%- set b = "" if mid == 0 else ("un millón" if mid == 1 else es_apoc(es6(mid)) ~ " millones") -%}
+{%- set out = join4(a, b, es6(low) if low else "", "") -%}
+{{- out if out else "cero" -}}
+{%- endmacro -%}
+
+{#- Italian: below a million one word, ventitré, centottanta -#}
+{%- set it_u = ["zero", "uno", "due", "tre", "quattro", "cinque", "sei", "sette", "otto", "nove", "dieci", "undici", "dodici",
+                "tredici", "quattordici", "quindici", "sedici", "diciassette", "diciotto", "diciannove"] -%}
+{%- set it_t = ["", "", "venti", "trenta", "quaranta", "cinquanta", "sessanta", "settanta", "ottanta", "novanta"] -%}
+
+{%- macro it99(n) -%}
+{%- if n < 20 -%}
+{{- it_u[n] -}}
+{%- else -%}
+{%- set t = it_t[(n / 10)|int] -%}
+{%- set u = n % 10 -%}
+{{- (t[0:(t|length) - 1] if u == 1 or u == 8 else t) ~ (it_u[u] if u else "") -}}
+{%- endif -%}
+{%- endmacro -%}
+
+{%- macro it999(n) -%}
+{%- set h = (n / 100)|int -%}
+{%- set r = n % 100 -%}
+{%- set hs = "" if h == 0 else ("cento" if h == 1 else it_u[h] ~ "cento") -%}
+{{- (hs[0:(hs|length) - 1] if (r / 10)|int == 8 else hs) ~ (it99(r) if r else "") -}}
+{%- endmacro -%}
+
+{#- without the final accent, see it_acc -#}
+{%- macro it6(n) -%}
+{%- set a = (n / 1000)|int -%}
+{%- set b = n % 1000 -%}
+{{- ("" if a == 0 else ("mille" if a == 1 else it999(a) ~ "mila")) ~ (it999(b) if b else "") -}}
+{%- endmacro -%}
+
+{%- macro it_acc(w) -%}
+{{- (w[0:(w|length) - 3] ~ "tré") if w.endswith("tre") and w != "tre" else w -}}
+{%- endmacro -%}
+
+{%- macro it_card(s) -%}
+{%- set p = ("000000000000000000" ~ s)[-18:] -%}
+{%- set bil = p[0:6]|int -%}
+{%- set mld = p[6:9]|int -%}
+{%- set mil = p[9:12]|int -%}
+{%- set low = p[12:18]|int -%}
+{%- set a = "" if bil == 0 else ("un bilione" if bil == 1 else it_acc(it6(bil)) ~ " bilioni") -%}
+{%- set b = "" if mld == 0 else ("un miliardo" if mld == 1 else it_acc(it999(mld)) ~ " miliardi") -%}
+{%- set c = "" if mil == 0 else ("un milione" if mil == 1 else it_acc(it999(mil)) ~ " milioni") -%}
+{%- set out = join4(a, b, c, it_acc(it6(low)) if low else "") -%}
+{{- out if out else "zero" -}}
+{%- endmacro -%}
+
+{#- Portuguese (Brazil): short scale, "e" before the last group when it is below 100 or a round hundred -#}
+{%- set pt_u = ["zero", "um", "dois", "três", "quatro", "cinco", "seis", "sete", "oito", "nove", "dez", "onze", "doze",
+                "treze", "catorze", "quinze", "dezesseis", "dezessete", "dezoito", "dezenove"] -%}
+{%- set pt_t = ["", "", "vinte", "trinta", "quarenta", "cinquenta", "sessenta", "setenta", "oitenta", "noventa"] -%}
+{%- set pt_h = ["", "cento", "duzentos", "trezentos", "quatrocentos", "quinhentos", "seiscentos", "setecentos", "oitocentos",
+                "novecentos"] -%}
+{%- set pt_big = [[], [], ["milhão", "milhões"], ["bilhão", "bilhões"], ["trilhão", "trilhões"]] -%}
+
+{%- macro pt99(n) -%}
+{{- pt_u[n] if n < 20 else pt_t[(n / 10)|int] ~ (" e " ~ pt_u[n % 10] if n % 10 else "") -}}
+{%- endmacro -%}
+
+{%- macro pt999(n) -%}
+{%- if n == 100 -%}
+{{- "cem" -}}
+{%- else -%}
+{%- set h = (n / 100)|int -%}
+{%- set r = n % 100 -%}
+{{- pt_h[h] ~ (" e " if h and r else "") ~ (pt99(r) if r else "") -}}
+{%- endif -%}
+{%- endmacro -%}
+
+{%- macro pt_card(s) -%}
+{%- set p = ["", "00", "0"][(s|length) % 3] ~ s -%}
+{%- set ns = namespace(out="", ng=((p|length) / 3)|int, last=-1) -%}
+{%- for g in range(ns.ng) -%}
+{%- if p[g * 3:g * 3 + 3]|int > 0 -%}
+{%- set ns.last = g -%}
+{%- endif -%}
+{%- endfor -%}
+{%- for g in range(ns.ng) -%}
+{%- set v = p[g * 3:g * 3 + 3]|int -%}
+{%- set k = ns.ng - 1 - g -%}
+{%- if v > 0 -%}
+{%- if k == 0 -%}
+{%- set w = pt999(v) -%}
+{%- elif k == 1 -%}
+{%- set w = "mil" if v == 1 else pt999(v) ~ " mil" -%}
+{%- else -%}
+{%- set w = ("um" if v == 1 else pt999(v)) ~ " " ~ pt_big[k][0 if v == 1 else 1] -%}
+{%- endif -%}
+{%- set sep = " e " if g == ns.last and (v < 100 or v % 100 == 0) else " " -%}
+{%- set ns.out = ns.out ~ (sep if ns.out else "") ~ w -%}
+{%- endif -%}
+{%- endfor -%}
+{{- ns.out if ns.out else "zero" -}}
+{%- endmacro -%}
+
+{#- Japanese: 万 / 億 / 兆 groups, no 一 before 十 / 百 / 千 (but 一千万), zero digits are skipped -#}
+{%- set cjk_d = ["零", "一", "二", "三", "四", "五", "六", "七", "八", "九"] -%}
+
+{%- macro ja4(n, big) -%}
+{%- set th = (n / 1000)|int -%}
+{%- set h = ((n / 100)|int) % 10 -%}
+{%- set t = ((n / 10)|int) % 10 -%}
+{%- set u = n % 10 -%}
+{{- ((("一" if big else "") if th == 1 else cjk_d[th]) ~ "千" if th else "")
+  ~ (("" if h == 1 else cjk_d[h]) ~ "百" if h else "")
+  ~ (("" if t == 1 else cjk_d[t]) ~ "十" if t else "")
+  ~ (cjk_d[u] if u else "") -}}
+{%- endmacro -%}
+
+{%- macro ja_card(s) -%}
+{%- set p = ("0000000000000000" ~ s)[-16:] -%}
+{%- set ns = namespace(out="") -%}
+{%- for g in range(4) -%}
+{%- set v = p[g * 4:g * 4 + 4]|int -%}
+{%- if v > 0 -%}
+{%- set ns.out = ns.out ~ ja4(v, g < 3) ~ ["兆", "億", "万", ""][g] -%}
+{%- endif -%}
+{%- endfor -%}
+{{- ns.out if ns.out else "零" -}}
+{%- endmacro -%}
+
+{#- Chinese: 万 / 亿 groups, 两 before 千 / 万 / 亿, 二 before 百, 十 at the head but 一十 inside, one 零 per gap -#}
+{%- macro zh4(n, head) -%}
+{%- set ds = [(n / 1000)|int, ((n / 100)|int) % 10, ((n / 10)|int) % 10, n % 10] -%}
+{%- set ns = namespace(out="", zero=false) -%}
+{%- for i in range(4) -%}
+{%- set d = ds[i] -%}
+{%- if d == 0 -%}
+{%- if ns.out -%}
+{%- set ns.zero = true -%}
+{%- endif -%}
+{%- else -%}
+{%- set w = "两" if d == 2 and i == 0 else cjk_d[d] -%}
+{%- if i == 2 and d == 1 and head and not ns.out -%}
+{%- set w = "" -%}
+{%- endif -%}
+{%- set ns.out = ns.out ~ ("零" if ns.zero else "") ~ w ~ ["千", "百", "十", ""][i] -%}
+{%- set ns.zero = false -%}
+{%- endif -%}
+{%- endfor -%}
+{{- ns.out -}}
+{%- endmacro -%}
+
+{%- macro zh_card(s) -%}
+{%- set p = ("0000000000000000" ~ s)[-16:] -%}
+{%- set ns = namespace(out="", gap=false) -%}
+{%- for g in range(4) -%}
+{%- set v = p[g * 4:g * 4 + 4]|int -%}
+{%- if v == 0 -%}
+{%- if ns.out -%}
+{%- set ns.gap = true -%}
+{%- endif -%}
+{%- else -%}
+{%- set w = "两" if v == 2 and g < 3 else zh4(v, not ns.out) -%}
+{%- set ns.out = ns.out ~ ("零" if ns.out and (ns.gap or v < 1000) else "") ~ w ~ ["万亿", "亿", "万", ""][g] -%}
+{%- set ns.gap = false -%}
+{%- endif -%}
+{%- endfor -%}
+{{- ns.out if ns.out else "零" -}}
+{%- endmacro -%}
+
+{#- per-language words -#}
+{%- set digit_words = {"en": ones[0:10], "fr": fr_u[0:10], "de": de_u[0:10], "es": es_u[0:10], "it": it_u[0:10], "pt": pt_u[0:10],
+                       "ja": cjk_d, "zh": cjk_d} -%}
+{%- set W = {
+    "en": {"point": " point ",   "minus": "minus ",   "and": " and ", "percent": " percent",   "sep": " "},
+    "fr": {"point": " virgule ", "minus": "moins ",   "and": " et ",  "percent": " pour cent", "sep": " "},
+    "de": {"point": " Komma ",   "minus": "minus ",   "and": " und ", "percent": " Prozent",   "sep": " "},
+    "es": {"point": " coma ",    "minus": "menos ",   "and": " con ", "percent": " por ciento", "sep": " "},
+    "it": {"point": " virgola ", "minus": "meno ",    "and": " e ",   "percent": " per cento", "sep": " "},
+    "pt": {"point": " vírgula ", "minus": "menos ",   "and": " e ",   "percent": " por cento", "sep": " "},
+    "ja": {"point": "点",        "minus": "マイナス", "and": "",      "percent": "パーセント",  "sep": ""},
+    "zh": {"point": "点",        "minus": "负",       "and": "",      "percent": "",           "sep": ""},
+} -%}
+{#- currency -> [singular, plural, cent singular, cent plural]; ja / zh: the unit after the number -#}
+{%- set M = {
+    "en": {"$": ["dollar", "dollars", "cent", "cents"], "€": ["euro", "euros", "cent", "cents"],
+           "£": ["pound", "pounds", "penny", "pence"], "¥": ["yen", "yen", "sen", "sen"]},
+    "fr": {"$": ["dollar", "dollars", "cent", "cents"], "€": ["euro", "euros", "centime", "centimes"]},
+    "de": {"$": ["Dollar", "Dollar", "Cent", "Cent"], "€": ["Euro", "Euro", "Cent", "Cent"], "£": ["Pfund", "Pfund", "Penny", "Pence"]},
+    "es": {"$": ["dólar", "dólares", "centavo", "centavos"], "€": ["euro", "euros", "céntimo", "céntimos"]},
+    "it": {"$": ["dollaro", "dollari", "centesimo", "centesimi"], "€": ["euro", "euro", "centesimo", "centesimi"]},
+    "pt": {"$": ["dólar", "dólares", "centavo", "centavos"], "€": ["euro", "euros", "cêntimo", "cêntimos"],
+           "R$": ["real", "reais", "centavo", "centavos"]},
+    "ja": {"$": "ドル", "€": "ユーロ", "£": "ポンド", "¥": "円"},
+    "zh": {"$": "美元", "€": "欧元", "£": "英镑", "¥": "元"},
+} -%}
+{%- set es_ord = ["", "primero", "segundo", "tercero", "cuarto", "quinto", "sexto", "séptimo", "octavo", "noveno", "décimo"] -%}
+{%- set it_ord = ["", "primo", "secondo", "terzo", "quarto", "quinto", "sesto", "settimo", "ottavo", "nono", "decimo"] -%}
+{%- set pt_ord = ["", "primeiro", "segundo", "terceiro", "quarto", "quinto", "sexto", "sétimo", "oitavo", "nono", "décimo"] -%}
+
+{%- macro card(L, s) -%}
+{%- if L == "fr" -%}{{- fr_card(s) -}}
+{%- elif L == "de" -%}{{- de_card(s) -}}
+{%- elif L == "es" -%}{{- es_card(s) -}}
+{%- elif L == "it" -%}{{- it_card(s) -}}
+{%- elif L == "pt" -%}{{- pt_card(s) -}}
+{%- elif L == "ja" -%}{{- ja_card(s) -}}
+{%- elif L == "zh" -%}{{- zh_card(s) -}}
+{%- else -%}{{- en_card(s) -}}
+{%- endif -%}
+{%- endmacro -%}
+
+{#- attributive form before a noun: ein Euro, un euro, veintiún euros -#}
+{%- macro attr(L, w) -%}
+{%- if L == "de" -%}{{- de_attr(w) -}}
+{%- elif L == "es" -%}{{- es_apoc(w) -}}
+{%- elif L == "it" and w == "uno" -%}{{- "un" -}}
+{%- else -%}{{- w -}}
+{%- endif -%}
+{%- endmacro -%}
+
+{%- macro spell(L, s) -%}
+{%- set ns = namespace(out="") -%}
+{%- for i in range(s|length) -%}
+{%- set ns.out = ns.out ~ (W[L]["sep"] if i > 0 else "") ~ digit_words[L][s[i:i + 1]|int] -%}
+{%- endfor -%}
+{{- ns.out -}}
+{%- endmacro -%}
+
+{#- decimals: fr / es / it / pt read a short fraction as a number (virgule quatorze), the others digit by digit -#}
+{%- macro fraction(L, f) -%}
+{%- if L in ["fr", "es", "it", "pt"] and f|length <= 3 -%}
+{%- set z = tts_norm_sub(f, "^(0*).*", "%1") -%}
+{%- set rest = f[z|length:] -%}
+{{- spell(L, z) ~ (" " if z and rest else "") ~ (card(L, rest) if rest else "") -}}
+{%- else -%}
+{{- spell(L, f) -}}
+{%- endif -%}
+{%- endmacro -%}
+
+{#- en: 1990 -> nineteen ninety, 1905 -> nineteen oh five, 2005 -> two thousand five; de: 1990 -> neunzehnhundertneunzig -#}
+{%- macro year(L, s) -%}
+{%- set hi = s[0:2]|int -%}
+{%- set lo = s[2:4]|int -%}
+{%- if L == "de" -%}
+{{- (de99(hi) ~ "hundert" ~ (de99(lo) if lo else "")) if hi < 20 else de_card(s) -}}
+{%- elif (hi == 20 and lo < 10) or (hi % 10 == 0 and lo == 0) -%}
+{{- en_card(s) -}}
+{%- elif lo == 0 -%}
+{{- under100(hi) ~ " hundred" -}}
+{%- else -%}
+{{- under100(hi) ~ (" oh " ~ ones[lo] if lo < 10 else " " ~ under100(lo)) -}}
+{%- endif -%}
+{%- endmacro -%}
+
+{#- ip: digits with optional thousands separators, frac: decimal digits or empty -#}
+{%- macro number(L, ip, frac, year_ok) -%}
+{%- set d = tts_norm_sub(ip, "%D", "") -%}
+{%- if d|length > 15 or (d|length > 1 and d[0:1] == "0" and not frac) -%}
+{{- spell(L, d) ~ (W[L]["point"] ~ spell(L, frac) if frac else "") -}}
+{%- elif frac -%}
+{{- card(L, d) ~ W[L]["point"] ~ fraction(L, frac) -}}
+{%- elif year_ok and d == ip and d|length == 4 and d|int >= 1100 and d|int <= 2099 and L in ["en", "de"] -%}
+{{- year(L, d) -}}
+{%- else -%}
+{{- card(L, d) -}}
+{%- endif -%}
+{%- endmacro -%}
+
+{#- fr / es / it / pt: million and above are nouns, a round amount takes "de" before the currency (deux millions d'euros) -#}
+{%- macro unit_sep(L, ip, unit) -%}
+{%- set d = tts_norm_sub(ip, "%D", "") -%}
+{%- if L in ["fr", "es", "it", "pt"] and d|length > 6 and d[(d|length) - 6:] == "000000" -%}
+{{- " d'" if L == "fr" and unit[0:1] in "aeiou" else " " ~ ("di" if L == "it" else "de") ~ " " -}}
+{%- else -%}
+{{- " " -}}
+{%- endif -%}
+{%- endmacro -%}
+
+{%- macro money(L, cur, ip, cents, scale) -%}
+{%- if cur not in M[L] -%}
+{{- cur ~ W[L]["sep"] ~ number(L, ip, cents, false) -}}
+{%- elif L in ["ja", "zh"] -%}
+{{- number(L, ip, cents, false) ~ M[L][cur] -}}
+{%- else -%}
+{%- set u = M[L][cur] -%}
+{%- if scale -%}
+{{- number(L, ip, cents, false) ~ " " ~ scale ~ " " ~ u[1] -}}
+{%- elif cents|length == 2 -%}
+{%- set c = attr(L, card(L, cents)) ~ " " ~ u[2 if cents|int == 1 else 3] -%}
+{%- if tts_norm_sub(ip, "%D", "")|int == 0 -%}
+{{- c -}}
+{%- else -%}
+{{- attr(L, number(L, ip, "", false)) ~ unit_sep(L, ip, u[1]) ~ u[0 if ip == "1" else 1] ~ (W[L]["and"] ~ c if cents|int > 0 else "") -}}
+{%- endif -%}
+{%- elif cents -%}
+{{- number(L, ip, cents, false) ~ " " ~ u[1] -}}
+{%- else -%}
+{{- attr(L, number(L, ip, "", false)) ~ unit_sep(L, ip, u[1]) ~ u[0 if ip == "1" else 1] -}}
+{%- endif -%}
+{%- endif -%}
+{%- endmacro -%}
+
+{%- macro clock(L, h, m) -%}
+{%- set hn = h|int -%}
+{%- set mm = m|int if m else 0 -%}
+{%- if L == "fr" -%}
+{%- set w = fr_card(h) -%}
+{{- w ~ ("e" if w.endswith("un") else "") ~ (" heure" if hn <= 1 else " heures") ~ (" " ~ fr_card(m) if mm else "") -}}
+{%- elif L == "de" -%}
+{{- de_attr(de_card(h)) ~ " Uhr" ~ (" " ~ de_card(m) if mm else "") -}}
+{%- elif L == "es" -%}
+{%- set w = es_card(h) -%}
+{{- (w[0:(w|length) - 1] ~ "a" if w.endswith("uno") else w) ~ (" y " ~ es_card(m) if mm else " en punto") -}}
+{%- elif L == "it" -%}
+{{- ("una" if hn == 1 else it_card(h)) ~ (" e " ~ it_card(m) if mm else "") -}}
+{%- elif L == "pt" -%}
+{%- set w = pt_card(h) -%}
+{%- set w = w ~ "a" if w.endswith("um") else (w[0:(w|length) - 4] ~ "duas" if w.endswith("dois") else w) -%}
+{{- w ~ (" e " ~ pt_card(m) if mm else (" hora" if hn == 1 else " horas")) -}}
+{%- elif L == "ja" -%}
+{{- ja_card(h) ~ "時" ~ (ja_card(m) ~ "分" if mm else "") -}}
+{%- elif L == "zh" -%}
+{{- ("两" if hn == 2 else zh_card(h)) ~ "点" ~ (zh_card(m) ~ "分" if mm else "") -}}
+{%- else -%}
+{{- en_card(h) ~ (" o'clock" if mm == 0 else (" oh " ~ ones[mm] if mm < 10 else " " ~ under100(mm))) -}}
+{%- endif -%}
+{%- endmacro -%}
+
+{%- macro ordinal(L, d, suf) -%}
+{%- set n = d|int -%}
+{%- if L == "fr" -%}
+{%- set plural = "s" if suf.endswith("s") else "" -%}
+{%- if n == 1 -%}
+{{- ("première" if suf.startswith("r") or suf.startswith("è") else "premier") ~ plural -}}
+{%- elif n == 2 and suf.startswith("nd") -%}
+{{- ("seconde" if suf.startswith("nde") else "second") ~ plural -}}
+{%- else -%}
+{{- fr_ord(fr_card(d)) ~ plural -}}
+{%- endif -%}
+{%- elif L in ["es", "it", "pt"] -%}
+{%- set fem = suf == "ª" -%}
+{%- if n >= 1 and n <= 10 -%}
+{%- set w = {"es": es_ord, "it": it_ord, "pt": pt_ord}[L][n] -%}
+{{- w[0:(w|length) - 1] ~ "a" if fem else w -}}
+{%- elif L == "it" and n < 1000000 -%}
+{#- undicesimo, ventitreesimo, ventiseiesimo -#}
+{%- set w = it6(n) -%}
+{%- set w = w ~ "esimo" if w.endswith("tre") or w.endswith("sei") else w[0:(w|length) - 1] ~ "esimo" -%}
+{{- w[0:(w|length) - 1] ~ "a" if fem else w -}}
+{%- else -%}
+{{- card(L, d) -}}
+{%- endif -%}
+{%- else -%}
+{{- en_ord(en_card(d)) -}}
+{%- endif -%}
+{%- endmacro -%}
+
+{%- set l = ((language | lower) if language is defined and language else "").replace("-", "_") -%}
+{%- set L = "en" if l == "" else ("zh" if l.startswith("zh") or l.startswith("cmn") else l.split("_")[0]) -%}
+{%- set text = messages[0]["content"] -%}
+{%- if L not in M -%}
+{{- text -}}
+{%- else -%}
+{%- set t = text -%}
+{%- if L not in ["ja", "zh"] -%}
+{#- a space between a number and the letters it touches (5km, mp3), except for ordinal suffixes and the French 15h30 -#}
+{%- set suffixes = {"en": "st|nd|rd|th|s", "fr": "ers?|res?|ères?|nde?s?|èmes?|es?|h", "es": "º|ª", "it": "º|ª", "pt": "º|ª"} -%}
+{%- if L in suffixes -%}
+{%- set t = tts_norm_sub(t, "(%d)(?!(?:" ~ suffixes[L] ~ ")(?!%a))(%a)", "%1 %2") -%}
+{%- else -%}
+{%- set t = tts_norm_sub(t, "(%d)(%a)", "%1 %2") -%}
+{%- endif -%}
+{%- set t = tts_norm_sub(t, "(?<!%d)(%a)(%d)", "%1 %2") -%}
+{%- endif -%}
+{#- integer part with thousands separators (groups of exactly three digits), decimal part not followed by another group -#}
+{%- set tsep = {"fr": "[%s.]", "de": "%.", "es": "%.", "it": "%.", "pt": "%."}.get(L, ",") -%}
+{%- set int = "(%d{1,3}(?:" ~ tsep ~ "%d{3})+(?!%d)|%d+)" -%}
+{%- set frac = ("(?:%.(%d+)(?![,.]%d))?" if L in ["en", "ja", "zh"] else "(?:[,.](%d+)(?![,.]%d))?") -%}
+{#- a minus sign at the start of the text, after a space or after an opening parenthesis; ja / zh: after anything but a digit -#}
+{%- set neg = "(?:(?<!%d)(-))?" if L in ["ja", "zh"] else "(?:(?<![^%s(])(-))?" -%}
+{%- set cur = "(R%$|[$€£¥])" -%}
+{#- the matcher never backtracks into a choice, so the longest hour forms come first -#}
+{%- set hour = "(?<!%d)(2[0-3]|[01]%d|%d)" -%}
+{%- set time = hour ~ "(?:h([0-5]%d)?|:([0-5]%d))(?![%d%a])" if L == "fr"
+          else hour ~ ":([0-5]%d)(?!%d)" ~ ("(?: Uhr%b)?" if L == "de" else "") -%}
+{%- set ords = {
+    "en": [["ordinal", "(?<![%d,.])(%d{1,15})(st|nd|rd|th)(?!%a)"]],
+    "fr": [["ordinal", "(?<![%d,.])(%d{1,15})(ers?|res?|ères?|nde?s?|èmes?|es?)(?!%a)"]],
+    "es": [["ordinal", "(?<![%d,.])(%d{1,15})%.?([ºª])"]],
+    "it": [["ordinal", "(?<![%d,.])(%d{1,15})%.?([ºª])"]],
+    "pt": [["ordinal", "(?<![%d,.])(%d{1,15})%.?([ºª])"]],
+}.get(L, []) -%}
+{#- en / de: a 4-digit number is read as a year only after one of these words (in 1990, im Jahr 1990), 1234 people stays a count -#}
+{%- set year_cues = {
+    "en": "(?i)%b(in|since|by|from|until|till|to|through|between|of|around|circa|before|after|during|year|early|late|mid"
+          ~ "|january|february|march|april|may|june|july|august|september|october|november|december)",
+    "de": "(?i)%b(im|seit|bis|von|ab|anno|um|vor|nach|zwischen|jahr|jahre|jahres|jahren|anfang|mitte|ende"
+          ~ "|januar|februar|märz|april|mai|juni|juli|august|september|oktober|november|dezember)",
+} -%}
+{%- set rules = [
+    ["money",   cur ~ "%s?" ~ int ~ frac ~ "(?: (thousand|million|billion|trillion)%b)?"],
+    ["money2",  int ~ frac ~ "%s?" ~ cur],
+    ["percent", neg ~ int ~ frac ~ "%s?[%%%]"],
+    ["date",    "(?<![%d,./])(%d{1,2})[./](%d{1,2})[./](%d{4}|%d{2})(?!%d|[,./]%d)"],
+    ["time",    time],
+] + ords
+  + ([["year", "(?<![%d,.])(%d{2,4})(?=年)"]] if L == "zh" else [])
+  + ([["year4", year_cues[L] ~ "(%s+)(%d{4})(?!%a|%d|[,.]%d|%s?[$€£¥%%])"]] if L in year_cues else [])
+  + ([["plural", "(?<![%d,.])(%d{1,15})s(?!%a)"]] if L == "en" else [])
+  + [["number", neg ~ int ~ frac]] -%}
+{%- for s in tts_norm_scan(t, rules) -%}
+{%- set g = s.groups -%}
+{%- if s.rule == "money" -%}
+{{- money(L, g[0], g[1], g[2], g[3]) -}}
+{%- elif s.rule == "money2" -%}
+{{- money(L, g[2], g[0], g[1], "") -}}
+{%- elif s.rule == "percent" -%}
+{%- set w = number(L, g[1], g[2], false) -%}
+{{- (W[L]["minus"] if g[0] else "") ~ ("百分之" ~ w if L == "zh" else w ~ W[L]["percent"]) -}}
+{%- elif s.rule == "date" -%}
+{#- read as three numbers, the order of day and month is the one of the text -#}
+{{- number(L, g[0], "", false) ~ W[L]["sep"] ~ number(L, g[1], "", false) ~ W[L]["sep"] ~ number(L, g[2], "", true) -}}
+{%- elif s.rule == "time" -%}
+{{- clock(L, g[0], g[1] or (g[2] if g|length > 2 else "")) -}}
+{%- elif s.rule == "ordinal" -%}
+{{- ordinal(L, g[0], g[1]) -}}
+{%- elif s.rule == "year" -%}
+{{- spell(L, g[0]) -}}
+{%- elif s.rule == "year4" -%}
+{{- g[0] ~ g[1] ~ number(L, g[2], "", true) -}}
+{%- elif s.rule == "plural" -%}
+{#- decades and plurals: 1990s -> nineteen nineties -#}
+{%- set w = number(L, g[0], "", true) -%}
+{{- (w[0:(w|length) - 1] ~ "ies") if w.endswith("y") else w ~ "s" -}}
+{%- elif s.rule == "number" -%}
+{{- (W[L]["minus"] if g[0] else "") ~ number(L, g[1], g[2], false) -}}
+{%- else -%}
+{{- s.text -}}
+{%- endif -%}
+{%- endfor -%}
+{%- endif -%}
diff --git a/src/llama-arch.cpp b/src/llama-arch.cpp
index 22419390f..1869c1203 100644
--- a/src/llama-arch.cpp
+++ b/src/llama-arch.cpp
@@ -175,6 +175,7 @@ static const std::map<llm_arch, const char *> LLM_ARCH_NAMES = {
     { LLM_ARCH_NANBEIGE,         "nanbeige"         },
     { LLM_ARCH_QWEN3TTS,         "qwen3tts"         },
     { LLM_ARCH_POCKETTTS,        "pockettts"        },
+    { LLM_ARCH_ZONOS3,           "zonos3"           },
     { LLM_ARCH_K2_HORIZON,       "k2-horizon"       },
     { LLM_ARCH_BEAM,             "beam"             },
     { LLM_ARCH_KOLIBRI1,         "kolibri1"         },
@@ -1416,6 +1417,7 @@ bool llm_arch_is_hybrid(const llm_arch & arch) {
         case LLM_ARCH_DEEPSEEK4:
         case LLM_ARCH_DEEPSEEK41:
         case LLM_ARCH_MINIMAX_01:
+        case LLM_ARCH_ZONOS3:
         case LLM_ARCH_INKLING2:
             return true;
         default:
diff --git a/src/llama-arch.h b/src/llama-arch.h
index 74bd6ce67..d015b5d5a 100644
--- a/src/llama-arch.h
+++ b/src/llama-arch.h
@@ -176,6 +176,7 @@ enum llm_arch {
     LLM_ARCH_NANBEIGE,
     LLM_ARCH_QWEN3TTS,
     LLM_ARCH_POCKETTTS,
+    LLM_ARCH_ZONOS3,
     LLM_ARCH_MINIMAX_01,
     LLM_ARCH_HRM_TEXT,
     LLM_ARCH_K2_HORIZON,
diff --git a/src/llama-context.cpp b/src/llama-context.cpp
index 93edabc02..b66092b17 100644
--- a/src/llama-context.cpp
+++ b/src/llama-context.cpp
@@ -2908,6 +2908,7 @@ uint32_t llama_context::graph_max_nodes() const {
         model.arch == LLM_ARCH_BAILINGMOE3 ||
         model.arch == LLM_ARCH_QWEN35 ||
         model.arch == LLM_ARCH_QWEN35MOE ||
+        model.arch == LLM_ARCH_ZONOS3 ||
         model.arch == LLM_ARCH_CLEF ||
         model.arch == LLM_ARCH_CLOVER_MOE ||
         model.arch == LLM_ARCH_QWEN4EXP ||
diff --git a/src/llama-model.cpp b/src/llama-model.cpp
index 194bbb92b..9801b4b0c 100644
--- a/src/llama-model.cpp
+++ b/src/llama-model.cpp
@@ -126,6 +126,8 @@ static llama_model * llama_model_mapping(llm_arch arch, const llama_model_params
             return new llama_model_qwen3tts(params);
         case LLM_ARCH_POCKETTTS:
             return new llama_model_pockettts(params);
+        case LLM_ARCH_ZONOS3:
+            return new llama_model_zonos3(params);
         case LLM_ARCH_PHI2:
             return new llama_model_phi2(params);
         case LLM_ARCH_PHI3:
@@ -687,8 +689,9 @@ struct ggml_backend_meta_split_state llama_meta_device_get_split_state(const str
             return {{conv_state_size, 3}};
         }
         // TODO: deduplicate condition [TAG_SPLIT_QGATE_QWEN]
+        // zonos3 has the qwen35 GDN layout, its attention layers have no fused qkv
         if (ud->model->arch == LLM_ARCH_QWEN3NEXT || ud->model->arch == LLM_ARCH_QWEN35 || ud->model->arch == LLM_ARCH_QWEN35MOE || ud->model->arch == LLM_ARCH_CLEF || ud->model->arch == LLM_ARCH_CLOVER_MOE ||
-                ud->model->arch == LLM_ARCH_QWEN4EXP) {
+                ud->model->arch == LLM_ARCH_QWEN4EXP || ud->model->arch == LLM_ARCH_ZONOS3) {

             // fused full attention layers with Q gate tensors that need n_embd doubled:
             if (!hparams.is_recr(il) && (std::regex_match(tensor_name, pattern_qkv_weight) || std::regex_match(tensor_name, pattern_qkv_bias))) {
@@ -731,7 +734,7 @@ struct ggml_backend_meta_split_state llama_meta_device_get_split_state(const str
                     GGML_ASSERT(tensor->ne[axis] == 2*key_dim + value_dim);
                     return {{key_dim, 2 + head_ratio}};
                 }
-                if (std::regex_match(tensor_name, pattern_attn_gate_weight) || std::regex_match(tensor_name, pattern_ssm_out_weight)) {
+                if ((hparams.is_recr(il) && std::regex_match(tensor_name, pattern_attn_gate_weight)) || std::regex_match(tensor_name, pattern_ssm_out_weight)) {
                     return {{key_dim, head_ratio}};
                 }
                 if (std::regex_match(tensor_name, pattern_ssm_dt) || std::regex_match(tensor_name, pattern_ssm_a) ||
@@ -2700,6 +2703,7 @@ void llama_model::print_info() const {
                 arch == LLM_ARCH_QWEN35 ||
                 arch == LLM_ARCH_QWEN35MOE ||
                 arch == LLM_ARCH_CLOVER_MOE ||
+                arch == LLM_ARCH_ZONOS3 ||
                 arch == LLM_ARCH_NEMOTRON_H ||
                 arch == LLM_ARCH_NEMOTRON_H_MOE) {
             LLAMA_LOG_INFO("%s: ssm_d_conv            = %u\n",     __func__, hparams.ssm_d_conv);
@@ -3331,7 +3335,8 @@ llama_memory_i * llama_model::create_memory(const llama_memory_params & params,
                         filter_recr = [&](uint32_t il) {
                             return hparams.is_recr(il) && hparams.n_ff(il) == 0;
                         };
-                    } else if (arch == LLM_ARCH_QWEN3NEXT || arch == LLM_ARCH_QWEN35 || arch == LLM_ARCH_QWEN35MOE || arch == LLM_ARCH_CLOVER_MOE || arch == LLM_ARCH_QWEN4EXP || arch == LLM_ARCH_MINIMAX_01) {
+                    } else if (arch == LLM_ARCH_QWEN3NEXT || arch == LLM_ARCH_QWEN35 || arch == LLM_ARCH_QWEN35MOE || arch == LLM_ARCH_CLOVER_MOE || arch == LLM_ARCH_QWEN4EXP || arch == LLM_ARCH_MINIMAX_01 ||
+                               arch == LLM_ARCH_ZONOS3) {
                         filter_attn = [&](uint32_t il) {
                             return il < hparams.n_layer() && !hparams.is_recr(il);
                         };
@@ -3745,6 +3750,7 @@ llama_rope_type llama_model_rope_type(const llama_model * model) {
         case LLM_ARCH_DOTS3NOTE:
         case LLM_ARCH_NANBEIGE:
         case LLM_ARCH_POCKETTTS:
+        case LLM_ARCH_ZONOS3:
         case LLM_ARCH_LIMITE:
             return LLAMA_ROPE_TYPE_NORM;
         case LLM_ARCH_BAILINGMOE3:
diff --git a/src/llama-vocab.cpp b/src/llama-vocab.cpp
index f2c1854c9..6f23a29d8 100644
--- a/src/llama-vocab.cpp
+++ b/src/llama-vocab.cpp
@@ -584,6 +584,12 @@ struct llm_tokenizer_bpe : llm_tokenizer {
                 regex_exprs = { "\\p{N}", "\\s+|[\\p{L}\\p{N}_]+|[^\\p{L}\\p{N}_\\s]+" };
                 byte_encode = false;
                 break;
+            case LLAMA_VOCAB_PRE_TYPE_ZONOS3:
+                // SuperBPE: no split on whitespace, tokens can span several words
+                regex_exprs = {
+                    "\\p{N}{1,3}| ?[^\\s\\p{L}\\p{N}]{2,}[\\r\\n/]*| +(?!\\S)",
+                };
+                break;
             case LLAMA_VOCAB_PRE_TYPE_WHITESPACE:
                 // whitespace pre-tokenizer (jinaai/jina-embeddings-v2-base-zh)
                 regex_exprs = {
@@ -2774,6 +2780,10 @@ void llama_vocab::impl::load(llama_model_loader & ml, const LLM_KV & kv) {
             } else if (
                 tokenizer_pre == "soprano") {
                 pre_type = LLAMA_VOCAB_PRE_TYPE_SOPRANO;
+            } else if (
+                tokenizer_pre == "zonos3") {
+                pre_type = LLAMA_VOCAB_PRE_TYPE_ZONOS3;
+                clean_spaces = false;
             } else {
                 throw std::runtime_error(format("unknown pre-tokenizer type: '%s'", tokenizer_pre.c_str()));
             }
diff --git a/src/llama-vocab.h b/src/llama-vocab.h
index 1ae5b65f6..9345275f5 100644
--- a/src/llama-vocab.h
+++ b/src/llama-vocab.h
@@ -75,6 +75,7 @@ enum llama_vocab_pre_type {
     LLAMA_VOCAB_PRE_TYPE_NEMOTRON_3_5_EMBD = 64,
     LLAMA_VOCAB_PRE_TYPE_SOPRANO           = 65,
     LLAMA_VOCAB_PRE_TYPE_TEKKEN_NUM3       = 66,
+    LLAMA_VOCAB_PRE_TYPE_ZONOS3            = 67,
 };

 // HF Metaspace pre-tokenizer prepend_scheme
diff --git a/src/models/models.h b/src/models/models.h
index 913c52ade..932887c91 100644
--- a/src/models/models.h
+++ b/src/models/models.h
@@ -769,6 +769,28 @@ struct llama_model_pockettts : public llama_model_base {
     std::unique_ptr<llm_graph_context> build_arch_graph(const llm_graph_params & params) const override;
 };

+struct llama_model_zonos3 : public llama_model_base {
+    llama_model_zonos3(const struct llama_model_params & params) : llama_model_base(params) {}
+    void load_arch_hparams(llama_model_loader & ml) override;
+    void load_arch_tensors(llama_model_loader & ml) override;
+
+    struct graph : public llm_build_delta_net_base {
+        graph(const llm_graph_params & params);
+    private:
+        // Full AttnRes bank: embedding + every sublayer output so far, [n_embd, n_src, n_tokens]
+        ggml_tensor * bank = nullptr;
+
+        void          attn_res_push(ggml_tensor * cur);
+        ggml_tensor * attn_res_route(ggml_tensor * query, int il);
+
+        ggml_tensor * build_layer_attn(llm_graph_input_attn_kv * inp, ggml_tensor * cur, ggml_tensor * inp_pos, int il);
+        ggml_tensor * build_layer_gdn(llm_graph_input_rs * inp, ggml_tensor * cur, int il);
+        ggml_tensor * build_layer_ffn(ggml_tensor * cur, int il);
+    };
+
+    std::unique_ptr<llm_graph_context> build_arch_graph(const llm_graph_params & params) const override;
+};
+
 struct llama_model_codeshell : public llama_model_base {
     llama_model_codeshell(const struct llama_model_params & params) : llama_model_base(params) {}
     void load_arch_hparams(llama_model_loader & ml) override;
diff --git a/src/models/zonos3.cpp b/src/models/zonos3.cpp
new file mode 100644
index 000000000..c5f62fe29
--- /dev/null
+++ b/src/models/zonos3.cpp
@@ -0,0 +1,351 @@
+#include "models.h"
+#include "llama-memory-recurrent.h"
+
+// ZONOS3 TTS backbone, the 10 audio heads are in the mmproj
+
+void llama_model_zonos3::load_arch_hparams(llama_model_loader & ml) {
+    ml.get_key(LLM_KV_ATTENTION_LAYERNORM_RMS_EPS, hparams.f_norm_rms_eps);
+
+    ml.get_key(LLM_KV_SSM_CONV_KERNEL,    hparams.ssm_d_conv);
+    ml.get_key(LLM_KV_SSM_INNER_SIZE,     hparams.ssm_d_inner);
+    ml.get_key(LLM_KV_SSM_STATE_SIZE,     hparams.ssm_d_state);
+    ml.get_key(LLM_KV_SSM_TIME_STEP_RANK, hparams.ssm_dt_rank);
+    ml.get_key(LLM_KV_SSM_GROUP_COUNT,    hparams.ssm_n_group);
+
+    if (!ml.get_key_or_arr(LLM_KV_ATTENTION_RECURRENT_LAYERS, hparams.is_recr_impl, hparams.n_layer_all, false)) {
+        uint32_t full_attn_interval = 0;
+        ml.get_key(LLM_KV_FULL_ATTENTION_INTERVAL, full_attn_interval);
+        if (full_attn_interval == 0) {
+            throw std::runtime_error("zonos3: full_attention_interval must be > 0");
+        }
+        for (uint32_t i = 0; i < hparams.n_layer_all; ++i) {
+            hparams.is_recr_impl[i] = (i + 1) % full_attn_interval != 0;
+        }
+    }
+
+    ml.get_key(LLM_KV_LEADING_DENSE_BLOCK_COUNT, hparams.n_layer_dense_lead);
+    ml.get_key_or_arr(LLM_KV_EXPERT_FEED_FORWARD_LENGTH, hparams.n_ff_exp_arr, hparams.n_layer_all);
+    ml.get_key(LLM_KV_MOE_LATENT_SIZE, hparams.moe_latent_size);
+
+    // the router picks the top-k of logits + bias, then takes a softmax over the picked logits
+    hparams.expert_gating_func  = LLAMA_EXPERT_GATING_FUNC_TYPE_SOFTMAX_WEIGHT;
+    hparams.expert_weights_norm = false;
+
+    switch (hparams.n_layer()) {
+        case 11: type = LLM_TYPE_0_8B; break;
+        default: type = LLM_TYPE_UNKNOWN;
+    }
+}
+
+void llama_model_zonos3::load_arch_tensors(llama_model_loader &) {
+    LLAMA_LOAD_LOCALS;
+
+    tok_embd = create_tensor(tn(LLM_TENSOR_TOKEN_EMBD, "weight"), {n_embd, n_vocab}, 0);
+
+    output_norm      = create_tensor(tn(LLM_TENSOR_OUTPUT_NORM,      "weight"), {n_embd}, 0);
+    output_res_score = create_tensor(tn(LLM_TENSOR_OUTPUT_RES_SCORE, "weight"), {n_embd}, 0);
+    // no output head, the logits are unused; reuse the embedding table so a sampler can still run
+    output = create_tensor(tn(LLM_TENSOR_TOKEN_EMBD, "weight"), {n_embd, n_vocab}, TENSOR_DUPLICATED);
+
+    const int64_t d_inner    = hparams.ssm_d_inner;
+    const int64_t head_dim   = hparams.ssm_d_state;
+    const int64_t n_gdn_head = hparams.ssm_dt_rank;
+    const int64_t n_latent   = hparams.moe_latent_size;
+    const int64_t n_ff_exp   = hparams.n_ff_exp();
+
+    for (int i = 0; i < n_layer; ++i) {
+        auto & layer = layers[i];
+
+        layer.attn_norm      = create_tensor(tn(LLM_TENSOR_ATTN_NORM,      "weight", i), {n_embd}, 0);
+        layer.ffn_norm       = create_tensor(tn(LLM_TENSOR_FFN_NORM,       "weight", i), {n_embd}, 0);
+        layer.attn_res_score = create_tensor(tn(LLM_TENSOR_ATTN_RES_SCORE, "weight", i), {n_embd}, 0);
+        layer.ffn_res_score  = create_tensor(tn(LLM_TENSOR_FFN_RES_SCORE,  "weight", i), {n_embd}, 0);
+
+        if (hparams.is_recr(i)) {
+            // split from the fused GDN in_proj at conversion, same layout as qwen35
+            layer.wqkv       = create_tensor(tn(LLM_TENSOR_ATTN_QKV,   "weight", i), {n_embd, 3 * d_inner}, 0);
+            layer.wqkv_gate  = create_tensor(tn(LLM_TENSOR_ATTN_GATE,  "weight", i), {n_embd, d_inner}, 0); // z gate
+            layer.ssm_conv1d = create_tensor(tn(LLM_TENSOR_SSM_CONV1D, "weight", i), {hparams.ssm_d_conv, 3 * d_inner}, 0);
+            layer.ssm_dt     = create_tensor(tn(LLM_TENSOR_SSM_DT,     "bias",   i), {n_gdn_head}, 0);
+            layer.ssm_a      = create_tensor(tn(LLM_TENSOR_SSM_A_NOSCAN,         i), {n_gdn_head}, 0);
+            layer.ssm_alpha  = create_tensor(tn(LLM_TENSOR_SSM_ALPHA,  "weight", i), {n_embd, n_gdn_head}, 0);
+            layer.ssm_beta   = create_tensor(tn(LLM_TENSOR_SSM_BETA,   "weight", i), {n_embd, n_gdn_head}, 0);
+            layer.ssm_norm   = create_tensor(tn(LLM_TENSOR_SSM_NORM,   "weight", i), {head_dim}, 0);
+            layer.ssm_out    = create_tensor(tn(LLM_TENSOR_SSM_OUT,    "weight", i), {d_inner, n_embd}, 0);
+        } else {
+            create_tensor_qkv(layer, i, n_embd, n_embd_head_k * n_head, n_embd_k_gqa, n_embd_v_gqa, 0);
+            layer.wo          = create_tensor(tn(LLM_TENSOR_ATTN_OUT,    "weight", i), {n_embd_head_k * n_head, n_embd}, 0);
+            // per-head scale of the normalized Q, the QK norm itself has no weight
+            layer.attn_q_norm = create_tensor(tn(LLM_TENSOR_ATTN_Q_NORM, "weight", i), {n_embd_head_k, n_head}, 0);
+            // per-head output gate, same name as the GDN z gate
+            layer.wqkv_gate   = create_tensor(tn(LLM_TENSOR_ATTN_GATE,   "weight", i), {n_embd, n_head}, 0);
+        }
+
+        if (i < (int) hparams.n_layer_dense_lead) {
+            layer.ffn_gate = create_tensor(tn(LLM_TENSOR_FFN_GATE, "weight", i), {n_embd, n_ff}, 0);
+            layer.ffn_up   = create_tensor(tn(LLM_TENSOR_FFN_UP,   "weight", i), {n_embd, n_ff}, 0);
+            layer.ffn_down = create_tensor(tn(LLM_TENSOR_FFN_DOWN, "weight", i), {n_ff, n_embd}, 0);
+        } else {
+            layer.ffn_gate_inp    = create_tensor(tn(LLM_TENSOR_FFN_GATE_INP,    "weight", i), {n_embd, n_expert}, 0);
+            layer.ffn_exp_probs_b = create_tensor(tn(LLM_TENSOR_FFN_EXP_PROBS_B, "bias",   i), {n_expert}, 0);
+            layer.ffn_latent_down = create_tensor(tn(LLM_TENSOR_FFN_LATENT_DOWN, "weight", i), {n_embd, n_latent}, 0);
+            layer.ffn_latent_up   = create_tensor(tn(LLM_TENSOR_FFN_LATENT_UP,   "weight", i), {n_latent, n_embd}, 0);
+            layer.ffn_gate_exps   = create_tensor(tn(LLM_TENSOR_FFN_GATE_EXPS,   "weight", i), {n_latent, n_ff_exp, n_expert}, 0);
+            layer.ffn_up_exps     = create_tensor(tn(LLM_TENSOR_FFN_UP_EXPS,     "weight", i), {n_latent, n_ff_exp, n_expert}, 0);
+            layer.ffn_down_exps   = create_tensor(tn(LLM_TENSOR_FFN_DOWN_EXPS,   "weight", i), {n_ff_exp, n_latent, n_expert}, 0);
+        }
+    }
+}
+
+std::unique_ptr<llm_graph_context> llama_model_zonos3::build_arch_graph(const llm_graph_params & params) const {
+    return std::make_unique<graph>(params);
+}
+
+llama_model_zonos3::graph::graph(const llm_graph_params & params) :
+    llm_build_delta_net_base(params) {
+    const float eps = hparams.f_norm_rms_eps;
+
+    ggml_tensor * cur;
+
+    ggml_tensor * inpL = build_inp_embd(model.tok_embd);
+
+    auto * inp = build_inp_mem_hybrid();
+
+    ggml_tensor * inp_pos     = build_inp_pos();
+    ggml_tensor * inp_out_ids = build_inp_out_ids();
+
+    // the summed row embedding goes through a weightless norm
+    inpL = ggml_rms_norm(ctx0, inpL, eps);
+    cb(inpL, "emb_norm", -1);
+    attn_res_push(inpL);
+
+    for (int il = 0; il < n_layer; ++il) {
+        const auto & layer = model.layers[il];
+
+        cur = attn_res_route(layer.attn_res_score, il);
+        cur = build_norm(cur, layer.attn_norm, nullptr, LLM_NORM_RMS, il);
+        cb(cur, "attn_norm", il);
+
+        if (hparams.is_recr(il)) {
+            cur = build_layer_gdn(inp->get_recr(), cur, il);
+        } else {
+            cur = build_layer_attn(inp->get_attn(), cur, inp_pos, il);
+        }
+        cb(cur, "attn_out", il);
+        attn_res_push(cur);
+
+        cur = attn_res_route(layer.ffn_res_score, il);
+        cur = build_norm(cur, layer.ffn_norm, nullptr, LLM_NORM_RMS, il);
+        cb(cur, "ffn_norm", il);
+
+        cur = build_layer_ffn(cur, il);
+        cur = build_cvec(cur, il);
+        cb(cur, "l_out", il);
+        attn_res_push(cur);
+    }
+
+    cur = attn_res_route(model.output_res_score, -1);
+    if (inp_out_ids) {
+        cur = ggml_get_rows(ctx0, cur, inp_out_ids);
+    }
+
+    cur = build_norm(cur, model.output_norm, nullptr, LLM_NORM_RMS, -1);
+    cb(cur, "result_norm", -1);
+    res->t_embd = cur;
+
+    cur = build_lora_mm(model.output, cur, model.output_s);
+    cb(cur, "result_output", -1);
+    res->t_logits = cur;
+}
+
+void llama_model_zonos3::graph::attn_res_push(ggml_tensor * cur) {
+    ggml_tensor * src = ggml_reshape_3d(ctx0, cur, n_embd, 1, n_tokens);
+    bank = bank ? ggml_concat(ctx0, bank, src, 1) : src;
+}
+
+// softmax over the bank sources, scored by query . rms_norm(source); the sum uses the raw sources
+ggml_tensor * llama_model_zonos3::graph::attn_res_route(ggml_tensor * query, int il) {
+    const int64_t n_src = bank->ne[1];
+
+    ggml_tensor * scores = ggml_rms_norm(ctx0, bank, hparams.f_norm_rms_eps);
+    scores = ggml_mul(ctx0, scores, query);
+    scores = ggml_sum_rows(ctx0, scores);                  // [1, n_src, n_tokens]
+    scores = ggml_reshape_2d(ctx0, scores, n_src, n_tokens);
+
+    ggml_tensor * probs = ggml_soft_max(ctx0, scores);
+    cb(probs, "attn_res_probs", il);
+
+    return ggml_dsv4_hc_pre(ctx0, bank, probs);
+}
+
+ggml_tensor * llama_model_zonos3::graph::build_layer_attn(
+        llm_graph_input_attn_kv * inp,
+        ggml_tensor *             cur,
+        ggml_tensor *             inp_pos,
+        int                       il) {
+    const auto & layer = model.layers[il];
+
+    const int64_t n_embd_head = hparams.n_embd_head_v();
+    GGML_ASSERT(n_embd_head == hparams.n_embd_head_k());
+
+    // the reference QK norm has no weight and a fixed eps
+    const float qk_norm_eps = 1e-6f;
+
+    auto [Qcur, Kcur, Vcur] = build_qkv(layer, cur, n_embd_head, n_head, n_head_kv, il);
+
+    ggml_tensor * gate = build_lora_mm(layer.wqkv_gate, cur);
+    gate = ggml_sigmoid(ctx0, ggml_reshape_3d(ctx0, gate, 1, n_head, n_tokens));
+    cb(gate, "attn_gate", il);
+
+    Qcur = ggml_rms_norm(ctx0, Qcur, qk_norm_eps);
+    Qcur = ggml_mul(ctx0, Qcur, layer.attn_q_norm);
+    Kcur = ggml_rms_norm(ctx0, Kcur, qk_norm_eps);
+    cb(Qcur, "Qcur_normed", il);
+    cb(Kcur, "Kcur_normed", il);
+
+    Qcur = build_rope(Qcur, inp_pos, il);
+    Kcur = build_rope(Kcur, inp_pos, il);
+    cb(Qcur, "Qcur", il);
+    cb(Kcur, "Kcur", il);
+    cb(Vcur, "Vcur", il);
+
+    cur = build_attn(inp,
+            nullptr, nullptr, nullptr,
+            Qcur, Kcur, Vcur, nullptr, nullptr, nullptr, 1.0f/sqrtf(float(n_embd_head)), il);
+    cb(cur, "attn_pregate", il);
+
+    cur = ggml_reshape_3d(ctx0, cur, n_embd_head, n_head, n_tokens);
+    cur = ggml_mul(ctx0, cur, gate);
+    cur = ggml_reshape_2d(ctx0, cur, n_embd_head * n_head, n_tokens);
+    cb(cur, "attn_gated", il);
+
+    return build_lora_mm(layer.wo, cur, layer.wo_s);
+}
+
+// copied from qwen35 build_layer_attn_linear, with the reference L2 norm eps
+ggml_tensor * llama_model_zonos3::graph::build_layer_gdn(
+        llm_graph_input_rs * inp,
+        ggml_tensor *        cur,
+        int                  il) {
+    const auto & layer    = model.layers[il];
+    const auto * mctx_cur = inp->mctx;
+
+    const int64_t d_inner      = hparams.ssm_d_inner;
+    const int64_t n_seqs       = ubatch.n_seqs;
+    const int64_t head_k_dim   = hparams.ssm_d_state;
+    const int64_t num_k_heads  = hparams.ssm_n_group;
+    const int64_t num_v_heads  = hparams.ssm_dt_rank;
+    const int64_t head_v_dim   = d_inner / num_v_heads;
+    const int64_t n_seq_tokens = ubatch.n_seq_tokens;
+
+    GGML_ASSERT(n_seqs != 0);
+    GGML_ASSERT(ubatch.equal_seqs());
+    GGML_ASSERT(ubatch.n_tokens == n_seq_tokens * n_seqs);
+    GGML_ASSERT(num_k_heads == num_v_heads);
+
+    ggml_tensor * qkv_mixed = build_lora_mm(layer.wqkv, cur, layer.wqkv_s);
+    qkv_mixed = ggml_reshape_3d(ctx0, qkv_mixed, qkv_mixed->ne[0], n_seq_tokens, n_seqs);
+    cb(qkv_mixed, "linear_attn_qkv_mixed", il);
+
+    ggml_tensor * z = build_lora_mm(layer.wqkv_gate, cur, layer.wqkv_gate_s);
+    cb(z, "z", il);
+
+    ggml_tensor * beta = build_lora_mm(layer.ssm_beta, cur, layer.ssm_beta_s);
+    beta = ggml_reshape_4d(ctx0, beta, 1, num_v_heads, n_seq_tokens, n_seqs);
+    beta = ggml_sigmoid(ctx0, beta);
+    cb(beta, "beta_sigmoid", il);
+
+    ggml_tensor * alpha = build_lora_mm(layer.ssm_alpha, cur, layer.ssm_alpha_s);
+    alpha = ggml_reshape_3d(ctx0, alpha, num_v_heads, n_seq_tokens, n_seqs);
+
+    ggml_tensor * alpha_softplus = ggml_softplus(ctx0, ggml_add(ctx0, alpha, layer.ssm_dt));
+    ggml_tensor * gate = ggml_mul(ctx0, alpha_softplus, layer.ssm_a); // -A_log.exp() * softplus
+    gate = ggml_reshape_4d(ctx0, gate, 1, num_v_heads, n_seq_tokens, n_seqs);
+    cb(gate, "gate", il);
+
+    ggml_tensor * conv_states_all = mctx_cur->get_r_l(il);
+    ggml_tensor * ssm_states_all  = mctx_cur->get_s_l(il);
+
+    ggml_tensor * conv_kernel      = layer.ssm_conv1d;
+    const int64_t conv_kernel_size = conv_kernel->ne[0];
+    const int64_t conv_channels    = d_inner + 2 * num_k_heads * head_k_dim;
+
+    ggml_tensor * conv_input = build_conv_state(inp, conv_states_all, qkv_mixed, conv_kernel_size, conv_channels, il);
+
+    ggml_tensor * state = build_rs(inp, ssm_states_all, hparams.n_embd_s(), n_seqs);
+    state = ggml_reshape_4d(ctx0, state, head_v_dim, head_v_dim, num_v_heads, n_seqs);
+
+    ggml_tensor * conv_out = ggml_silu(ctx0, ggml_ssm_conv(ctx0, conv_input, conv_kernel));
+    cb(conv_out, "conv_output_silu", il);
+
+    const int64_t qkv_dim = head_k_dim * num_k_heads * 2 + head_v_dim * num_v_heads;
+    const int64_t nb1_qkv = ggml_row_size(conv_out->type, qkv_dim);
+
+    ggml_tensor * q_conv = ggml_view_4d(ctx0, conv_out, head_k_dim, num_k_heads, n_seq_tokens, n_seqs,
+            ggml_row_size(conv_out->type, head_k_dim), nb1_qkv, nb1_qkv * n_seq_tokens, 0);
+    ggml_tensor * k_conv = ggml_view_4d(ctx0, conv_out, head_k_dim, num_k_heads, n_seq_tokens, n_seqs,
+            ggml_row_size(conv_out->type, head_k_dim), nb1_qkv, nb1_qkv * n_seq_tokens,
+            ggml_row_size(conv_out->type, head_k_dim * num_k_heads));
+    ggml_tensor * v_conv = ggml_view_4d(ctx0, conv_out, head_v_dim, num_v_heads, n_seq_tokens, n_seqs,
+            ggml_row_size(conv_out->type, head_v_dim), nb1_qkv, nb1_qkv * n_seq_tokens,
+            ggml_row_size(conv_out->type, 2 * head_k_dim * num_k_heads));
+
+    // the reference L2 norm is x * rsqrt(sum(x^2) + 1e-6)
+    q_conv = build_gdn_l2_norm(ctx0, q_conv, 1e-6f);
+    k_conv = build_gdn_l2_norm(ctx0, k_conv, 1e-6f);
+    cb(q_conv, "q_conv_predelta", il);
+    cb(k_conv, "k_conv_predelta", il);
+    cb(v_conv, "v_conv_predelta", il);
+
+    ggml_tensor * output = build_recurrent_attn(inp, ssm_states_all, q_conv, k_conv, v_conv, gate, beta, state, il);
+
+    // gated norm: rms_norm(output) * silu(z), per head
+    ggml_tensor * z_4d = ggml_reshape_4d(ctx0, z, head_v_dim, num_v_heads, n_seq_tokens, n_seqs);
+    output = build_norm(output, layer.ssm_norm, nullptr, LLM_NORM_RMS, il);
+    output = ggml_mul(ctx0, output, ggml_silu(ctx0, z_4d));
+
+    output = ggml_reshape_2d(ctx0, output, head_v_dim * num_v_heads, n_seq_tokens * n_seqs);
+    cb(output, "linear_attn_gated", il);
+
+    return build_lora_mm(layer.ssm_out, output, layer.ssm_out_s);
+}
+
+ggml_tensor * llama_model_zonos3::graph::build_layer_ffn(ggml_tensor * cur, int il) {
+    const auto & layer = model.layers[il];
+
+    if (layer.ffn_gate_inp == nullptr) {
+        cur = build_ffn(cur,
+                layer.ffn_up,   nullptr, layer.ffn_up_s,
+                layer.ffn_gate, nullptr, layer.ffn_gate_s,
+                layer.ffn_down, nullptr, layer.ffn_down_s,
+                nullptr,
+                LLM_FFN_SILU, LLM_FFN_PAR, il);
+        cb(cur, "ffn_out", il);
+        return cur;
+    }
+
+    // the router reads the full-width input, the experts the latent one
+    ggml_tensor * logits = build_lora_mm(layer.ffn_gate_inp, ggml_rms_norm(ctx0, cur, hparams.f_norm_rms_eps));
+    cb(logits, "ffn_moe_logits", il);
+
+    ggml_tensor * latent = build_lora_mm(layer.ffn_latent_down, cur);
+    cb(latent, "ffn_latent_in", il);
+
+    cur = build_moe_ffn(latent,
+            nullptr,
+            layer.ffn_up_exps,
+            layer.ffn_gate_exps,
+            layer.ffn_down_exps,
+            layer.ffn_exp_probs_b,
+            n_expert, n_expert_used,
+            LLM_FFN_SILU, hparams.expert_weights_norm,
+            hparams.expert_weights_scale,
+            (llama_expert_gating_func_type) hparams.expert_gating_func,
+            il,
+            logits);
+    cb(cur, "ffn_moe_out", il);
+
+    cur = build_lora_mm(layer.ffn_latent_up, cur);
+    cb(cur, "ffn_out", il);
+    return cur;
+}
diff --git a/tests/fusion/MTL.csv b/tests/fusion/MTL.csv
index 89cafb247..efb8718e7 100644
--- a/tests/fusion/MTL.csv
+++ b/tests/fusion/MTL.csv
@@ -348,3 +348,8 @@ xing4_0             ,0   ,any     ,MUL+ADD                     ,      2
 xing4_0             ,0   ,any     ,RMS_NORM+MUL                ,      9
 xing4_0             ,0   ,any     ,UNARY+ADD+ARGSORT+GET_ROWS  ,      1
 xverse              ,0   ,any     ,RMS_NORM+MUL                ,      5
+zonos3              ,0   ,any     ,GATED_DELTA_NET+CPY         ,      1
+zonos3              ,0   ,any     ,MUL+ADD                     ,      2
+zonos3              ,0   ,any     ,RMS_NORM+MUL                ,     12
+zonos3              ,0   ,any     ,RMS_NORM+SCALE              ,      2
+zonos3              ,0   ,any     ,SSM_CONV+UNARY              ,      1
diff --git a/tests/test-jinja.cpp b/tests/test-jinja.cpp
index d52f1af9c..3f86d1535 100644
--- a/tests/test-jinja.cpp
+++ b/tests/test-jinja.cpp
@@ -3,6 +3,9 @@
 #include <random>
 #include <cstdlib>
 #include <future>
+#include <array>
+#include <fstream>
+#include <iterator>

 #include "chat-tts-norm.h"
 #include "json.h"
@@ -42,6 +45,7 @@ static void test_caps(testing & t);
 static void test_string_parts(testing & t);
 static void test_fuzzing(testing & t);
 static void test_tts_norm_patterns(testing & t);
+static void test_tts_template_zonos3(testing & t);

 static void test_shared_render(testing & t);

@@ -81,6 +85,7 @@ int main(int argc, char *argv[]) {
     t.test("array methods", test_array_methods);
     t.test("object methods", test_object_methods);
     t.test("tts norm patterns", test_tts_norm_patterns);
+    t.test("tts template zonos3", test_tts_template_zonos3);
     if (!g_python_mode) {
         t.test("shared render", test_shared_render);
         t.test("hasher", test_hasher);
@@ -2979,6 +2984,72 @@ static void test_tts_norm_patterns(testing & t) {
     }
 }

+// the ZONOS3 "tts" template spells out numbers in en, fr, de, es, it, pt, ja and zh, other languages pass through
+static void test_tts_template_zonos3(testing & t) {
+    std::string path = __FILE__;
+    const size_t pos = path.find_last_of("/\\");
+    path = (pos == std::string::npos ? std::string(".") : path.substr(0, pos)) + "/../models/templates/Zyphra-ZONOS3-Mini-tts.jinja";
+    std::ifstream fs(path, std::ios_base::binary);
+    if (!t.assert_true("template file is readable: " + path, fs.is_open())) {
+        return;
+    }
+    const std::string tmpl((std::istreambuf_iterator<char>(fs)), std::istreambuf_iterator<char>());
+
+    const std::vector<std::array<std::string, 3>> cases = {
+        { "en", "I have 3 cats and 21 dogs.",                "I have three cats and twenty-one dogs." },
+        { "en", "1,234,567 people, 1,2345 and 12345",        "one million two hundred thirty-four thousand five hundred sixty-seven people, one,two thousand three hundred forty-five and twelve thousand three hundred forty-five" },
+        { "en", "$1, $0.99, $5.32 and $2.5 billion",         "one dollar, ninety-nine cents, five dollars and thirty-two cents and two point five billion dollars" },
+        { "en", "\xe2\x82\xac" "20 and \xc2\xa3" "3.50",     "twenty euros and three pounds and fifty pence" },
+        { "en", "3.5% then 10%",                             "three point five percent then ten percent" },
+        { "en", "1st, 22nd, 103rd, 12th",                    "first, twenty-second, one hundred third, twelfth" },
+        { "en", "in 1990, since 1905, by 2005, in 2000, from 1900, the 1990s, 1234 people",
+                "in nineteen ninety, since nineteen oh five, by two thousand five, in two thousand, from nineteen hundred, the nineteen nineties, one thousand two hundred thirty-four people" },
+        { "en", "at 3:30, 10:05 or 7:00, not 25:00",         "at three thirty, ten oh five or seven o'clock, not twenty-five:zero zero" },
+        { "en", "pi is 3.14, it hit -5 today, range 10-20",  "pi is three point one four, it hit minus five today, range ten-twenty" },
+        { "en", "agent 007 ran 5km with an mp3",             "agent zero zero seven ran five km with an mp three" },
+        { "en", "caf\xc3\xa9 42.",                           "caf\xc3\xa9 forty-two." },
+        { "",   "no language 42",                            "no language forty-two" },
+        { "en", "no digits here",                            "no digits here" },
+        { "ko", "3\xea\xb0\x9c",                                 "3\xea\xb0\x9c" },
+        // other languages
+        { "fr_fr", "J'ai 21 chats, 71 chiens, 80 oiseaux et 91 souris.",
+                   "J'ai vingt et un chats, soixante et onze chiens, quatre-vingts oiseaux et quatre-vingt-onze souris." },
+        { "fr", "200 ans, 2 345 678 habitants et 80 000 euros.",
+                "deux cents ans, deux millions trois cent quarante-cinq mille six cent soixante-dix-huit habitants et quatre-vingt mille euros." },
+        { "fr", "Ça coûte 5,32 €, hausse de 12,05 %.",
+                "Ça coûte cinq euros et trente-deux centimes, hausse de douze virgule zéro cinq pour cent." },
+        { "fr", "Le 1er mai, la 1re fois, le 21e siècle, à 15h30, à 1h ou à 21:05, -5 degrés.",
+                "Le premier mai, la première fois, le vingt et unième siècle, à quinze heures trente, à une heure ou à vingt et une heures cinq, moins cinq degrés." },
+        { "de", "21 Katzen, 101 Hunde, 1.234 Bücher, 1.000.000 Einwohner, im Jahr 1990.",
+                "einundzwanzig Katzen, einhunderteins Hunde, eintausendzweihundertvierunddreißig Bücher, eine Million Einwohner, im Jahr neunzehnhundertneunzig." },
+        { "fr", "2 000 000 €, 3 000 000 $ et 2 500 000 €.",
+                "deux millions d'euros, trois millions de dollars et deux millions cinq cent mille euros." },
+        { "es", "2.000.000 € y 1.000.000 $.", "dos millones de euros y un millón de dólares." },
+        { "de", "Im Jahr 1990 waren 1500 Leute da.", "Im Jahr neunzehnhundertneunzig waren eintausendfünfhundert Leute da." },
+        { "de", "5,32 € oder 1 €, plus 3,5 % um 15:30 Uhr, am 3.10.2024.",
+                "fünf Euro und zweiunddreißig Cent oder ein Euro, plus drei Komma fünf Prozent um fünfzehn Uhr dreißig, am drei zehn zweitausendvierundzwanzig." },
+        { "es", "100, 101, 21.000 personas, 2.500.000 habitantes, 21 €, a las 1:00, el 3.º y la 2ª vez.",
+                "cien, ciento uno, veintiún mil personas, dos millones quinientos mil habitantes, veintiún euros, a las una en punto, el tercero y la segunda vez." },
+        { "it", "21, 23, 28, 180, 3.003, 1.000.000 di euro, 5,32 €, all'1:00, l'11º e il 23º.",
+                "ventuno, ventitré, ventotto, centottanta, tremilatré, un milione di euro, cinque euro e trentadue centesimi, all'una, l'undicesimo e il ventitreesimo." },
+        { "pt_br", "101, 1.200, 1.234, R$ 5,32, às 2:00, o 1º e a 2ª vez.",
+                   "cento e um, mil e duzentos, mil duzentos e trinta e quatro, cinco reais e trinta e dois centavos, às duas horas, o primeiro e a segunda vez." },
+        { "ja", "3個、1000円、10000人、123456789人、1.5キロ、30%、$5、午後3:30、-5度、2024年。",
+                "三個、千円、一万人、一億二千三百四十五万六千七百八十九人、一点五キロ、三十パーセント、五ドル、午後三時三十分、マイナス五度、二千二十四年。" },
+        { "cmn", "10005人,115人,2000人,20000人,316,2:30,2002年,-5度,3.5%,$23。",
+                 "一万零五人,一百一十五人,两千人,两万人,三百一十六,两点三十分,二零零二年,负五度,百分之三点五,二十三美元。" },
+    };
+    for (const auto & c : cases) {
+        // same variables as common_chat_get_tts_prompt()
+        const json vars = {
+            {"messages", json::array({{{"role", "user"}, {"content", c[1]}}})},
+            {"language", c[0]},
+            {"voice",    ""},
+        };
+        test_template(t, c[0] + ": " + c[1], tmpl, vars, c[2], true);
+    }
+}
+
 static void test_template_error(testing & t, const std::string & name, const std::string & tmpl, const std::string & expect, bool tts_norm) {
     t.test(name, [&tmpl, &expect, tts_norm](testing & t) {
         std::string error;
diff --git a/tests/test-llama-archs.cpp b/tests/test-llama-archs.cpp
index 32e77e883..147d5b536 100644
--- a/tests/test-llama-archs.cpp
+++ b/tests/test-llama-archs.cpp
@@ -785,6 +785,11 @@ static gguf_context_ptr get_gguf_ctx(const llm_arch arch, const bool moe, const
         ms.add_kv(LLM_KV_EXPERT_FEED_FORWARD_LENGTH, std::vector<uint32_t>{n_ff, n_ff / 2, n_ff, n_ff});
     }

+    if (arch == LLM_ARCH_ZONOS3) {
+        ms.add_kv(LLM_KV_MOE_LATENT_SIZE,            n_embd / 2);
+        ms.add_kv(LLM_KV_EXPERT_FEED_FORWARD_LENGTH, n_ff);
+    }
+
     ms.add_kv(LLM_KV_POSNET_EMBEDDING_LENGTH,   n_embd);
     ms.add_kv(LLM_KV_POSNET_BLOCK_COUNT,        n_layer);
     ms.add_kv(LLM_KV_CONVNEXT_EMBEDDING_LENGTH, n_embd);
@@ -793,7 +798,7 @@ static gguf_context_ptr get_gguf_ctx(const llm_arch arch, const bool moe, const
     ms.add_kv(LLM_KV_XIELU_ALPHA_P,             1.0f);
     ms.add_kv(LLM_KV_XIELU_BETA,                1.0f);
     ms.add_kv(LLM_KV_XIELU_EPS,                 1.0e-7f);
-    ms.add_kv(LLM_KV_SSM_INNER_SIZE,            arch == LLM_ARCH_QWEN3NEXT || arch == LLM_ARCH_QWEN35 || arch == LLM_ARCH_QWEN35MOE || arch == LLM_ARCH_QWEN4EXP || arch == LLM_ARCH_CLOVER_MOE ? 256 : 2*n_embd);
+    ms.add_kv(LLM_KV_SSM_INNER_SIZE,            arch == LLM_ARCH_QWEN3NEXT || arch == LLM_ARCH_QWEN35 || arch == LLM_ARCH_QWEN35MOE || arch == LLM_ARCH_QWEN4EXP || arch == LLM_ARCH_CLOVER_MOE || arch == LLM_ARCH_ZONOS3 ? 256 : 2*n_embd);
     ms.add_kv(LLM_KV_SSM_CONV_KERNEL,           uint32_t(4));
     ms.add_kv(LLM_KV_SSM_STATE_SIZE,            uint32_t(128));
     ms.add_kv(LLM_KV_SSM_TIME_STEP_RANK,        n_head);
@@ -1077,6 +1082,7 @@ static bool moe_mandatory(const llm_arch arch) {
         case LLM_ARCH_MUSE_SPARK:
         case LLM_ARCH_KOLIBRI1:
         case LLM_ARCH_METIS16:
+        case LLM_ARCH_ZONOS3:
         case LLM_ARCH_INKLING2:
             return true;
         default:
diff --git a/tests/test-server-component.cpp b/tests/test-server-component.cpp
index 580203be8..2ef8b04f5 100644
--- a/tests/test-server-component.cpp
+++ b/tests/test-server-component.cpp
@@ -825,9 +825,9 @@ static void test_speech_convert(testing & t) {
     t.assert_true ("audio streams",     out.at("stream").get<bool>());
     t.assert_true ("rejects sse",       rejected("stream_format", "sse"));

-    // speed
-    t.assert_true ("speed 1.0",         !rejected("speed", 1.0));
-    t.assert_true ("rejects speed 1.5", rejected("speed", 1.5));
+    // speed is forwarded, the model decides whether it is supported
+    out = convert_speech_to_tts(with("speed", 1.5));
+    t.assert_equal("speed forwarded",   1.5, out.at("speed").get<double>());

     // instructions are ignored
     t.assert_true ("instructions",      !rejected("instructions", "whisper"));
diff --git a/tools/mtmd/CMakeLists.txt b/tools/mtmd/CMakeLists.txt
index fd54e2e72..32a17ef61 100644
--- a/tools/mtmd/CMakeLists.txt
+++ b/tools/mtmd/CMakeLists.txt
@@ -83,6 +83,8 @@ add_library(mtmd
             models/qwen3tts-spkenc.cpp
             models/qwen3tts-gen.cpp
             models/drama3-gen.cpp
+            models/zonos3-gen.cpp
+            models/zonos3-spkenc.cpp
             models/pockettts-seanet.cpp
             models/pockettts-spkenc.cpp
             models/pockettts-gen.cpp
diff --git a/tools/mtmd/README-dev.md b/tools/mtmd/README-dev.md
index 8feb7a647..b6ed5a577 100644
--- a/tools/mtmd/README-dev.md
+++ b/tools/mtmd/README-dev.md
@@ -103,6 +103,7 @@ Due to wide variety of audio generation pipelines, the `mtmd_gen_audio` system i
         - In most cases, you can hard-code model configs in the model graph class, or in `mtmd_hparams`
         - If some values need to be exposed to the `mtmd_helper` layer, hard-code them in `mtmd_helper` and distinguish by pipeline and `mtmd_gen_audio_info::model_variant` if necessary
         - Do NOT add new GGUF metadata or new fields to `mtmd_gen_audio_info` unless you can prove that you absolutely need them
+    - If the model requires special preprocessing of the input text prompt (prompt format, text normalization), put it in `tokenizer.chat_template.tts` as a jinja program. It receives the text as `messages[0].content`, plus `language` and `voice`. See `conversion/lfm3.py` (prompt format) and `models/templates/Zyphra-ZONOS3-Mini-tts.jinja` (text normalization) as examples. For text normalization, use `tts_norm_scan()` / `tts_norm_sub()` (see `docs/tts-norm-patterns.md`) and add test cases to `tests/test-jinja.cpp`, which also checks them against the python reference with `-py`
 4. Make sure most of the changes happen inside `mtmd-helper-gen.cpp`. A good PR looks like this:
     - 10-20% changes is to add new backbone (text) model and conversion
     - 60% changes inside `mtmd-helper-gen.cpp`
diff --git a/tools/mtmd/models/models.h b/tools/mtmd/models/models.h
index 58a83378a..05494c71f 100644
--- a/tools/mtmd/models/models.h
+++ b/tools/mtmd/models/models.h
@@ -341,6 +341,27 @@ struct mtmd_graph_mimo_audio : mtmd_graph {
     ggml_cgraph * build() override;
 };

+// zonos3 speaker encoder: MECT-B2 + ASTP, then the speaker projection of the backbone
+struct mtmd_graph_zonos3_spkenc : mtmd_graph {
+    mtmd_graph_zonos3_spkenc(mtmd_subcontext * ctx, const mtmd_image_f32 & img) : mtmd_graph(ctx, img) {}
+    ggml_cgraph * build() override;
+
+    ggml_tensor * W(const std::string & name) const;
+    bool          has(const std::string & name) const;
+    ggml_tensor * chan(ggml_tensor * v) const;
+    ggml_tensor * bn(ggml_tensor * x, const std::string & p) const;
+    ggml_tensor * conv(ggml_tensor * x, const std::string & p, int s_t, int s_f, bool bias, int pad) const;
+    ggml_tensor * grouped3x3(ggml_tensor * x, const std::string & p, int s_t, int s_f) const;
+    ggml_tensor * res_block(ggml_tensor * x, const std::string & q, int s_f, int s_t) const;
+    ggml_tensor * linear(ggml_tensor * x, const std::string & p) const;
+    ggml_tensor * layer_norm(ggml_tensor * x, const std::string & p) const;
+    ggml_tensor * flatten(ggml_tensor * x) const;
+    ggml_tensor * unflatten(ggml_tensor * x, int64_t F, int64_t C) const;
+    ggml_tensor * attention(ggml_tensor * h, const std::string & p) const;
+    ggml_tensor * moe_ffn(ggml_tensor * h, const std::string & p) const;
+    ggml_tensor * pos_conv(ggml_tensor * h, const std::string & p) const;
+};
+
 struct mtmd_graph_qwen3tts_spkenc : mtmd_graph {
     mtmd_graph_qwen3tts_spkenc(mtmd_subcontext * ctx, const mtmd_image_f32 & img) : mtmd_graph(ctx, img) {}
     ggml_cgraph * build() override;
@@ -536,6 +557,47 @@ struct mtmd_graph_drama3_gen : mtmd_graph {
                               ggml_tensor * inp_pos, ggml_tensor * k_slot, ggml_tensor * v_slot, int pos);
 };

+//
+// zonos3 generation:
+// GEN_CODE = 10 codebook heads, or the backbone row of a sampled frame
+// GEN_WAV  = ZAC codec decoder up to the iSTFT spectra
+//
+struct mtmd_graph_zonos3_gen : mtmd_graph {
+    mtmd_graph_zonos3_gen(mtmd_subcontext * ctx, const mtmd_image_f32 & img, mtmd_subctx_gen_process gen_process, bool all_known, int n_frames)
+        : mtmd_graph(ctx, img), gen_process(gen_process), all_known(all_known), n_frames(n_frames) {}
+    ggml_cgraph * build() override;
+
+    static constexpr int   n_codebooks = 10;
+    static constexpr int   n_cb_vocab  = 4096;
+    static constexpr float softcap     = 15.0f;
+
+    // ZAC decoder, fixed by the codec config
+    static constexpr int   n_stages              = 5;
+    static constexpr int   stage_ctx[n_stages]   = { 150, 300, 480, 480, 480 }; // attention window
+    static constexpr int   head_dim              = 64;
+    static constexpr float ln_eps                = 1e-5f;
+    static constexpr float convnext_eps          = 1e-6f;
+    static constexpr int   hop                   = 184;  // at 44160 Hz
+    static constexpr int   n_fft[2]              = { 736, 2208 };
+    static constexpr int   rs_orig               = 736;  // 44160 -> 44100 Hz
+    static constexpr int   rs_new                = 735;
+    static constexpr int   samples_per_frame     = 2940;
+
+    mtmd_subctx_gen_process gen_process;
+    bool all_known; // GEN_CODE: only the backbone row
+    int  n_frames;  // GEN_WAV
+
+    std::map<std::string, ggml_tensor *> state_in;
+    std::vector<std::pair<std::string, ggml_tensor *>> state_out;
+
+    ggml_cgraph * build_gen_code();
+    ggml_cgraph * build_gen_wav();
+    ggml_tensor * convnext(ggml_tensor * x, const mtmd_zonos3_wav::convnext & b, const std::string & state_name);
+    ggml_tensor * moss_layer(ggml_tensor * x, const mtmd_zonos3_wav::layer & l, int s, int il, ggml_tensor * base);
+};
+
+std::vector<c2w_state_slot> list_zonos3_gen_state_slots(const mtmd_hparams & hparams, const mtmd_model & model);
+
 struct mtmd_graph_soprano : mtmd_graph {
     mtmd_graph_soprano(mtmd_subcontext * ctx, const mtmd_image_f32 & img, int n_frames) : mtmd_graph(ctx, img), n_frames(n_frames) {}
     ggml_cgraph * build() override;
diff --git a/tools/mtmd/models/zonos3-gen.cpp b/tools/mtmd/models/zonos3-gen.cpp
new file mode 100644
index 000000000..d306f9858
--- /dev/null
+++ b/tools/mtmd/models/zonos3-gen.cpp
@@ -0,0 +1,207 @@
+#include "models.h"
+
+// GEN_CODE: hidden state -> logits of the 10 codebooks (out_feats), or codes -> backbone row (graph output)
+ggml_cgraph * mtmd_graph_zonos3_gen::build_gen_code() {
+    ggml_tensor * embd_w = model.gen_code_out_embd_w; // [n_embd, n_vocab * n_codebooks]
+    const int64_t n_vocab = embd_w->ne[1] / n_codebooks;
+
+    if (!all_known) {
+        ggml_tensor * h = ggml_new_tensor_1d(ctx0, GGML_TYPE_F32, n_mmproj_embd);
+        ggml_set_name(h, "inp_raw");
+        ggml_set_input(h);
+
+        ggml_tensor * logits = ggml_mul_mat(ctx0, model.gen_code_head_w, h);
+        logits = ggml_scale(ctx0, ggml_tanh(ctx0, ggml_scale(ctx0, logits, 1.0f / softcap)), softcap);
+        logits = ggml_reshape_2d(ctx0, logits, n_vocab, n_codebooks);
+        ggml_set_name(logits, "out_feats");
+        ggml_set_output(logits);
+        ggml_build_forward_expand(gf, logits);
+    }
+
+    ggml_tensor * inp_codes = ggml_new_tensor_1d(ctx0, GGML_TYPE_I32, n_codebooks);
+    ggml_set_name(inp_codes, "inp_codes");
+    ggml_set_input(inp_codes);
+
+    ggml_tensor * embd = ggml_get_rows(ctx0, embd_w, inp_codes); // [n_embd, n_codebooks]
+    embd = ggml_sum_rows(ctx0, ggml_cont(ctx0, ggml_transpose(ctx0, embd)));
+    embd = ggml_reshape_2d(ctx0, embd, n_mmproj_embd, 1);
+    cb(embd, "gen_embd", -1);
+    ggml_build_forward_expand(gf, embd);
+
+    return gf;
+}
+
+std::vector<c2w_state_slot> list_zonos3_gen_state_slots(const mtmd_hparams & hparams, const mtmd_model & model) {
+    GGML_UNUSED(hparams);
+    const auto & z = model.zonos3;
+    std::vector<c2w_state_slot> slots;
+    slots.push_back({"pos", 1, 1});
+    for (size_t i = 0; i < z.sem.size(); i++) {
+        slots.push_back({"sem_conv_" + std::to_string(i), z.sem[i].dw_w->ne[0] - 1, z.sem[i].dw_w->ne[1]});
+    }
+    for (size_t s = 0; s < z.stages.size(); s++) {
+        const int64_t d = z.stages[s].layers.empty() ? 0 : z.stages[s].layers[0].wo_w->ne[1];
+        for (size_t il = 0; il < z.stages[s].layers.size(); il++) {
+            const std::string sfx = std::to_string(s) + "_" + std::to_string(il);
+            slots.push_back({"tfm_k_" + sfx, d, mtmd_graph_zonos3_gen::stage_ctx[s] - 1});
+            slots.push_back({"tfm_v_" + sfx, d, mtmd_graph_zonos3_gen::stage_ctx[s] - 1});
+        }
+    }
+    for (size_t i = 0; i < z.head.size(); i++) {
+        slots.push_back({"head_conv_" + std::to_string(i), z.head[i].dw_w->ne[0] - 1, z.head[i].dw_w->ne[1]});
+    }
+    return slots;
+}
+
+static ggml_tensor * zonos3_layer_norm(ggml_context * ctx, ggml_tensor * x, ggml_tensor * w, ggml_tensor * b, float eps) {
+    return ggml_add(ctx, ggml_mul(ctx, ggml_norm(ctx, x, eps), w), b);
+}
+
+// causal ConvNeXt block, the conv history is kept in the state
+ggml_tensor * mtmd_graph_zonos3_gen::convnext(ggml_tensor * x, const mtmd_zonos3_wav::convnext & b, const std::string & state_name) {
+    const int64_t C = x->ne[0];
+    const int64_t T = x->ne[1];
+    const int64_t K = b.dw_w->ne[0];
+
+    ggml_tensor * hist = state_in.at(state_name); // [K - 1, C]
+    ggml_tensor * xt   = ggml_concat(ctx0, hist, ggml_cont(ctx0, ggml_transpose(ctx0, x)), 0); // [K - 1 + T, C]
+    state_out.push_back({state_name, ggml_cont(ctx0, ggml_view_2d(ctx0, xt, K - 1, C, xt->nb[1], (size_t) T * xt->nb[0]))});
+
+    ggml_tensor * h = ggml_ssm_conv(ctx0, ggml_reshape_3d(ctx0, xt, K - 1 + T, C, 1), b.dw_w); // [C, T, 1]
+    h = ggml_add(ctx0, ggml_reshape_2d(ctx0, h, C, T), b.dw_b);
+    h = zonos3_layer_norm(ctx0, h, b.norm_w, b.norm_b, convnext_eps);
+    h = ggml_gelu_erf(ctx0, ggml_add(ctx0, ggml_mul_mat(ctx0, b.pw1_w, h), b.pw1_b));
+    h = ggml_add(ctx0, ggml_mul_mat(ctx0, b.pw2_w, h), b.pw2_b);
+    return ggml_add(ctx0, x, h);
+}
+
+// windowed causal MOSS layer, adapted from qwen3tts code2wav tfm_layer_forward()
+ggml_tensor * mtmd_graph_zonos3_gen::moss_layer(ggml_tensor * x, const mtmd_zonos3_wav::layer & l, int s, int il, ggml_tensor * base) {
+    const int64_t d      = x->ne[0];
+    const int64_t N      = x->ne[1];
+    const int64_t n_head = d / head_dim;
+    const int64_t W      = stage_ctx[s];
+    const int64_t prefix = W - 1;
+    const int64_t n_kv   = prefix + N;
+    const std::string sfx = std::to_string(s) + "_" + std::to_string(il);
+
+    ggml_tensor * h   = zonos3_layer_norm(ctx0, x, l.norm1_w, l.norm1_b, ln_eps);
+    ggml_tensor * qkv = ggml_mul_mat(ctx0, l.qkv_w, h); // [3d, N]
+    auto section = [&](int i) {
+        return ggml_cont(ctx0, ggml_view_3d(ctx0, qkv, head_dim, n_head, N, ggml_row_size(qkv->type, head_dim), qkv->nb[1],
+                                            ggml_row_size(qkv->type, i * d)));
+    };
+
+    ggml_tensor * pos = ggml_cast(ctx0, ggml_add(ctx0, ggml_arange(ctx0, 0.0f, (float) N, 1.0f), base), GGML_TYPE_I32);
+    ggml_tensor * q = ggml_rope_ext(ctx0, section(0), pos, nullptr, head_dim, GGML_ROPE_TYPE_NORMAL, 0, 10000.0f, 1.0f, 0.0f, 1.0f, 0.0f, 0.0f);
+    ggml_tensor * k = ggml_rope_ext(ctx0, section(1), pos, nullptr, head_dim, GGML_ROPE_TYPE_NORMAL, 0, 10000.0f, 1.0f, 0.0f, 1.0f, 0.0f, 0.0f);
+    ggml_tensor * v = section(2);
+
+    ggml_tensor * k_full = ggml_concat(ctx0, state_in.at("tfm_k_" + sfx), ggml_reshape_2d(ctx0, k, d, N), 1); // [d, n_kv]
+    ggml_tensor * v_full = ggml_concat(ctx0, state_in.at("tfm_v_" + sfx), ggml_reshape_2d(ctx0, v, d, N), 1);
+    state_out.push_back({"tfm_k_" + sfx, ggml_cont(ctx0, ggml_view_2d(ctx0, k_full, d, prefix, k_full->nb[1], (size_t) N * k_full->nb[1]))});
+    state_out.push_back({"tfm_v_" + sfx, ggml_cont(ctx0, ggml_view_2d(ctx0, v_full, d, prefix, v_full->nb[1], (size_t) N * v_full->nb[1]))});
+
+    // key j is visible to query i iff 0 <= (prefix + i) - j < W and j >= prefix - base (cold start)
+    ggml_tensor * pos_k = ggml_reshape_2d(ctx0, ggml_arange(ctx0, 0.0f, (float) n_kv, 1.0f), n_kv, 1);
+    ggml_tensor * pos_q = ggml_reshape_2d(ctx0, ggml_arange(ctx0, (float) prefix, (float) (prefix + N), 1.0f), 1, N);
+    ggml_tensor * diff  = ggml_sub(ctx0, ggml_repeat_4d(ctx0, pos_q, n_kv, N, 1, 1), pos_k); // [n_kv, N]
+    ggml_tensor * keep  = ggml_mul(ctx0, ggml_step(ctx0, ggml_scale_bias(ctx0, diff, 1.0f, 0.5f)),
+                                         ggml_step(ctx0, ggml_scale_bias(ctx0, diff, -1.0f, (float) W - 0.5f)));
+    keep = ggml_mul(ctx0, keep, ggml_step(ctx0, ggml_scale_bias(ctx0, ggml_add(ctx0, pos_k, base), 1.0f, 0.5f - (float) prefix)));
+    ggml_tensor * mask = ggml_reshape_4d(ctx0, ggml_log(ctx0, keep), n_kv, N, 1, 1); // 0 = keep, -inf = masked
+    if (flash_attn_type == MTMD_FLASH_ATTN_TYPE_ENABLED) {
+        mask = ggml_cast(ctx0, mask, GGML_TYPE_F16);
+    }
+
+    ggml_tensor * attn = build_attn(l.wo_w, nullptr,
+                                    ggml_reshape_4d(ctx0, q, head_dim, n_head, N, 1),
+                                    ggml_reshape_4d(ctx0, k_full, head_dim, n_head, n_kv, 1),
+                                    ggml_reshape_4d(ctx0, v_full, head_dim, n_head, n_kv, 1),
+                                    mask, 1.0f / sqrtf((float) head_dim), il);
+    x = ggml_add(ctx0, x, ggml_mul(ctx0, attn, l.attn_scale));
+
+    h = zonos3_layer_norm(ctx0, x, l.norm2_w, l.norm2_b, ln_eps);
+    h = ggml_mul_mat(ctx0, l.ffn2_w, ggml_gelu_erf(ctx0, ggml_mul_mat(ctx0, l.ffn1_w, h)));
+    return ggml_add(ctx0, x, ggml_mul(ctx0, h, l.ffn_scale));
+}
+
+ggml_cgraph * mtmd_graph_zonos3_gen::build_gen_wav() {
+    const auto & z = model.zonos3;
+    const int64_t T = n_frames;
+
+    for (const auto & slot : list_zonos3_gen_state_slots(hparams, model)) {
+        ggml_tensor * t = ggml_new_tensor_2d(ctx0, GGML_TYPE_F32, slot.ne0, slot.ne1);
+        ggml_set_name(t, ("state_in_" + slot.name).c_str());
+        ggml_set_input(t);
+        state_in[slot.name] = t;
+    }
+
+    // codebook-major, residual codes are rows of the merged table
+    ggml_tensor * inp_codes = ggml_new_tensor_1d(ctx0, GGML_TYPE_I32, T * n_codebooks);
+    ggml_set_name(inp_codes, "inp_codes");
+    ggml_set_input(inp_codes);
+    auto codes_of = [&](int c) {
+        return ggml_view_1d(ctx0, inp_codes, T, (size_t) c * T * ggml_element_size(inp_codes));
+    };
+
+    // 1 for a known residual code, 0 for a missing one (early decode of a frame), codebook-major
+    ggml_tensor * inp_keep = ggml_new_tensor_1d(ctx0, GGML_TYPE_F32, T * (n_codebooks - 1));
+    ggml_set_name(inp_keep, "inp_keep");
+    ggml_set_input(inp_keep);
+
+    ggml_tensor * x = ggml_add(ctx0, ggml_get_rows(ctx0, z.sem_table, codes_of(0)), z.sem_bias); // [semantic_dim, T]
+    for (size_t i = 0; i < z.sem.size(); i++) {
+        x = convnext(x, z.sem[i], "sem_conv_" + std::to_string(i));
+    }
+    x = ggml_add(ctx0, ggml_mul_mat(ctx0, z.sem_out_w, x), z.sem_out_b); // [latent, T]
+    for (int c = 1; c < n_codebooks; c++) {
+        ggml_tensor * bias = ggml_view_1d(ctx0, z.res_bias, z.res_bias->ne[0], (size_t) (c - 1) * z.res_bias->nb[1]);
+        ggml_tensor * keep = ggml_view_2d(ctx0, inp_keep, 1, T, ggml_element_size(inp_keep), (size_t) (c - 1) * T * ggml_element_size(inp_keep));
+        x = ggml_add(ctx0, x, ggml_mul(ctx0, ggml_add(ctx0, ggml_get_rows(ctx0, z.res_table, codes_of(c)), bias), keep));
+    }
+    cb(x, "zac_latent", -1);
+
+    ggml_tensor * frames = ggml_reshape_1d(ctx0, state_in.at("pos"), 1);
+    int cpf = 1; // positions per frame
+    for (int s = 0; s < n_stages; s++) {
+        const auto & st = z.stages[s];
+        x = ggml_mul_mat(ctx0, st.in_w, x);
+        ggml_tensor * base = ggml_scale(ctx0, frames, (float) cpf);
+        for (size_t il = 0; il < st.layers.size(); il++) {
+            x = moss_layer(x, st.layers[il], s, (int) il, base);
+        }
+        if (st.out_w) {
+            x = ggml_mul_mat(ctx0, st.out_w, x);
+        }
+        if (s + 1 < n_stages) {
+            // patch up x2: out[ch, 2t + j] = in[2ch + j, t]
+            const int64_t C = x->ne[0], N = x->ne[1];
+            x = ggml_cont(ctx0, ggml_permute(ctx0, ggml_reshape_3d(ctx0, x, 2, C / 2, N), 1, 0, 2, 3));
+            x = ggml_reshape_2d(ctx0, x, C / 2, 2 * N);
+            cpf *= 2;
+        }
+        cb(x, "zac_stage_out", s);
+    }
+    state_out.push_back({"pos", ggml_scale_bias(ctx0, state_in.at("pos"), 1.0f, (float) T)});
+
+    for (size_t i = 0; i < z.head.size(); i++) {
+        x = convnext(x, z.head[i], "head_conv_" + std::to_string(i));
+    }
+    x = zonos3_layer_norm(ctx0, x, z.head_norm_w, z.head_norm_b, convnext_eps);
+    ggml_tensor * spec = ggml_add(ctx0, ggml_mul_mat(ctx0, z.head_out_w, x), z.head_out_b); // [n_fft0 + 2 + n_fft1 + 2, 16T]
+    ggml_set_name(spec, "out_feats");
+    ggml_set_output(spec);
+    ggml_build_forward_expand(gf, spec);
+
+    for (auto & slot : state_out) {
+        ggml_set_name(slot.second, ("state_out_" + slot.first).c_str());
+        ggml_set_output(slot.second);
+        ggml_build_forward_expand(gf, slot.second);
+    }
+    return gf;
+}
+
+ggml_cgraph * mtmd_graph_zonos3_gen::build() {
+    return gen_process == MTMD_SUBCTX_GEN_PROCESS_GEN_WAV ? build_gen_wav() : build_gen_code();
+}
diff --git a/tools/mtmd/models/zonos3-spkenc.cpp b/tools/mtmd/models/zonos3-spkenc.cpp
new file mode 100644
index 000000000..94a388cd7
--- /dev/null
+++ b/tools/mtmd/models/zonos3-spkenc.cpp
@@ -0,0 +1,221 @@
+#include "models.h"
+
+// zonos3 speaker encoder: MECT-B2 + ASTP pooling (ownmect_s2), then the speaker projection of the backbone
+// ported from the reference ggml runtime (spk-encoder.cpp)
+
+namespace {
+
+constexpr int kHidden   = 64;
+constexpr int kFeat     = 2560;
+constexpr int kHeads    = 4;
+constexpr int kExperts  = 4;
+constexpr int kPosK     = 113;
+
+// per block: cnn depth, stride f, stride t
+struct mect_block { int cnn, s_f, s_t; };
+const std::vector<std::vector<mect_block>> & mect_b2() {
+    static const std::vector<std::vector<mect_block>> cfg = {
+        { {3, 1, 1}, {3, 1, 1} },
+        { {3, 2, 1}, {3, 1, 2}, {3, 1, 2}, {3, 1, 2} },
+        { {3, 2, 1}, {3, 1, 2}, {3, 1, 2}, {3, 1, 2}, {3, 1, 2} },
+        { {3, 2, 1}, {2, 1, 2}, {2, 1, 2} },
+    };
+    return cfg;
+}
+
+} // namespace
+
+ggml_tensor * mtmd_graph_zonos3_spkenc::W(const std::string & name) const {
+    return model.zonos3_spk.at("a.spk." + name);
+}
+
+bool mtmd_graph_zonos3_spkenc::has(const std::string & name) const {
+    return model.zonos3_spk.count("a.spk." + name) > 0;
+}
+
+// [C] as a per-channel broadcast over a [T, F, C] activation
+ggml_tensor * mtmd_graph_zonos3_spkenc::chan(ggml_tensor * v) const {
+    return ggml_reshape_4d(ctx0, v, 1, 1, v->ne[0], 1);
+}
+
+ggml_tensor * mtmd_graph_zonos3_spkenc::bn(ggml_tensor * x, const std::string & p) const {
+    return ggml_add(ctx0, ggml_mul(ctx0, x, chan(W(p + ".scale"))), chan(W(p + ".shift")));
+}
+
+ggml_tensor * mtmd_graph_zonos3_spkenc::conv(ggml_tensor * x, const std::string & p, int s_t, int s_f, bool bias, int pad) const {
+    ggml_tensor * y = ggml_conv_2d_direct(ctx0, W(p + ".weight"), x, s_t, s_f, pad, pad, 1, 1);
+    return bias ? ggml_add(ctx0, y, chan(W(p + ".bias"))) : y;
+}
+
+// grouped 3x3 conv as one depthwise conv per input slot of a group
+ggml_tensor * mtmd_graph_zonos3_spkenc::grouped3x3(ggml_tensor * x, const std::string & p, int s_t, int s_f) const {
+    int n_slot = 0;
+    while (has(p + ".w" + std::to_string(n_slot))) {
+        n_slot++;
+    }
+    const int64_t T  = x->ne[0];
+    const int64_t F  = x->ne[1];
+    const int64_t IC = x->ne[2];
+    const int64_t OC = W(p + ".w0")->ne[3];
+    const int64_t G  = IC / n_slot;
+    const int64_t B  = OC / G;
+    ggml_tensor * x4 = ggml_reshape_4d(ctx0, x, T, F, n_slot, G);
+    ggml_tensor * y  = nullptr;
+    for (int j = 0; j < n_slot; j++) {
+        ggml_tensor * xj = x;
+        if (n_slot > 1 || B > 1) {
+            ggml_tensor * slot = ggml_view_4d(ctx0, x4, T, F, 1, G, x4->nb[1], x4->nb[2], x4->nb[3], (size_t) j * x4->nb[2]);
+            xj = ggml_reshape_3d(ctx0, ggml_repeat_4d(ctx0, slot, T, F, B, G), T, F, OC);
+        }
+        ggml_tensor * t = ggml_conv_2d_dw_direct(ctx0, W(p + ".w" + std::to_string(j)), xj, s_t, s_f, 1, 1, 1, 1);
+        y = y ? ggml_add(ctx0, y, t) : t;
+    }
+    return y;
+}
+
+ggml_tensor * mtmd_graph_zonos3_spkenc::res_block(ggml_tensor * x, const std::string & q, int s_f, int s_t) const {
+    ggml_tensor * t = grouped3x3(x, q + "conv1", s_t, s_f);
+    t = ggml_relu(ctx0, bn(conv(t, q + "pw1", 1, 1, true, 0), q + "bn1"));
+    t = grouped3x3(t, q + "conv2", 1, 1);
+    t = ggml_relu(ctx0, bn(conv(t, q + "pw2", 1, 1, true, 0), q + "bn2"));
+    ggml_tensor * sc = x;
+    if (has(q + "sc.weight")) {
+        sc = bn(conv(x, q + "sc", s_t, s_f, false, 0), q + "sc.bn");
+    }
+    return ggml_add(ctx0, t, sc);
+}
+
+ggml_tensor * mtmd_graph_zonos3_spkenc::linear(ggml_tensor * x, const std::string & p) const {
+    return ggml_add(ctx0, ggml_mul_mat(ctx0, W(p + ".weight"), x), W(p + ".bias"));
+}
+
+ggml_tensor * mtmd_graph_zonos3_spkenc::layer_norm(ggml_tensor * x, const std::string & p) const {
+    return ggml_add(ctx0, ggml_mul(ctx0, ggml_norm(ctx0, x, 1e-6f), W(p + ".weight")), W(p + ".bias"));
+}
+
+// [T, F, C] -> [F*C, T], k = f*C + c
+ggml_tensor * mtmd_graph_zonos3_spkenc::flatten(ggml_tensor * x) const {
+    const int64_t T = x->ne[0], F = x->ne[1], C = x->ne[2];
+    return ggml_reshape_2d(ctx0, ggml_cont(ctx0, ggml_permute(ctx0, x, 2, 1, 0, 3)), F * C, T);
+}
+
+ggml_tensor * mtmd_graph_zonos3_spkenc::unflatten(ggml_tensor * x, int64_t F, int64_t C) const {
+    return ggml_cont(ctx0, ggml_permute(ctx0, ggml_reshape_3d(ctx0, x, C, F, x->ne[1]), 2, 1, 0, 3));
+}
+
+ggml_tensor * mtmd_graph_zonos3_spkenc::attention(ggml_tensor * h, const std::string & p) const {
+    const int64_t T  = h->ne[1];
+    const int     hd = kHidden / kHeads;
+    ggml_tensor * q = ggml_scale(ctx0, linear(h, p + "attn.q"), 1.0f / sqrtf((float) hd));
+    ggml_tensor * k = linear(h, p + "attn.k");
+    ggml_tensor * v = linear(h, p + "attn.v");
+    auto heads = [&](ggml_tensor * z) { // [64, T] -> [hd, T, n_head]
+        return ggml_cont(ctx0, ggml_permute(ctx0, ggml_reshape_3d(ctx0, z, hd, kHeads, T), 0, 2, 1, 3));
+    };
+    ggml_tensor * qp = heads(q);
+    ggml_tensor * kp = heads(k);
+    ggml_tensor * vt = ggml_cont(ctx0, ggml_permute(ctx0, heads(v), 1, 0, 2, 3)); // [T, hd, n_head]
+    // queries in blocks, so long clips do not need a T x T score matrix per head
+    ggml_tensor * o = nullptr;
+    for (int64_t a = 0; a < T; a += 512) {
+        const int64_t nq = std::min<int64_t>(512, T - a);
+        ggml_tensor * qb    = ggml_view_3d(ctx0, qp, hd, nq, kHeads, qp->nb[1], qp->nb[2], (size_t) a * qp->nb[1]);
+        ggml_tensor * score = ggml_soft_max(ctx0, ggml_mul_mat(ctx0, kp, qb)); // [T, nq, n_head]
+        ggml_tensor * ob    = ggml_mul_mat(ctx0, vt, score);                   // [hd, nq, n_head]
+        ob = ggml_reshape_2d(ctx0, ggml_cont(ctx0, ggml_permute(ctx0, ob, 0, 2, 1, 3)), kHidden, nq);
+        o = o ? ggml_concat(ctx0, o, ob, 1) : ob;
+    }
+    return linear(o, p + "attn.o");
+}
+
+// soft MoE: all experts run, weighted by the router
+ggml_tensor * mtmd_graph_zonos3_spkenc::moe_ffn(ggml_tensor * h, const std::string & p) const {
+    ggml_tensor * r = ggml_soft_max(ctx0, ggml_mul_mat(ctx0, W(p + "moe.gate.weight"), h)); // [n_expert, T]
+    ggml_tensor * e = ggml_gelu(ctx0, ggml_mul_mat(ctx0, W(p + "moe.experts.weight"), h));  // [64 * n_expert, T]
+    ggml_tensor * mix = nullptr;
+    for (int i = 0; i < kExperts; i++) {
+        ggml_tensor * ei = ggml_view_2d(ctx0, e, kHidden, e->ne[1], e->nb[1], (size_t) i * kHidden * ggml_element_size(e));
+        ggml_tensor * ri = ggml_view_2d(ctx0, r, 1, r->ne[1], r->nb[1], (size_t) i * ggml_element_size(r));
+        ggml_tensor * t  = ggml_mul(ctx0, ei, ri);
+        mix = mix ? ggml_add(ctx0, mix, t) : t;
+    }
+    return linear(ggml_gelu(ctx0, mix), p + "ffn_out");
+}
+
+ggml_tensor * mtmd_graph_zonos3_spkenc::pos_conv(ggml_tensor * h, const std::string & p) const {
+    const int64_t T = h->ne[1];
+    ggml_tensor * ht = ggml_cont(ctx0, ggml_transpose(ctx0, h));               // [T, 64]
+    ht = ggml_pad_ext(ctx0, ht, kPosK / 2, kPosK / 2, 0, 0, 0, 0, 0, 0);        // [T + K - 1, 64]
+    ggml_tensor * acc = ggml_ssm_conv(ctx0, ggml_reshape_3d(ctx0, ht, ht->ne[0], kHidden, 1), W(p + "pos.dw.weight")); // [64, T, 1]
+    acc = ggml_add(ctx0, ggml_reshape_2d(ctx0, acc, kHidden, T), W(p + "pos.dw.bias"));
+    acc = ggml_add(ctx0, ggml_mul(ctx0, acc, W(p + "pos.bn.scale")), W(p + "pos.bn.shift"));
+    acc = ggml_gelu_erf(ctx0, acc);
+    return linear(acc, p + "pos.pw");
+}
+
+ggml_cgraph * mtmd_graph_zonos3_spkenc::build() {
+    const int64_t T = img.nx();
+    const int64_t n_mel = img.ny();
+
+    ggml_tensor * inp = ggml_new_tensor_4d(ctx0, GGML_TYPE_F32, T, n_mel, 1, 1);
+    ggml_set_name(inp, "inp_raw");
+    ggml_set_input(inp);
+
+    ggml_tensor * x  = bn(conv(inp, "fe.head.conv", 1, 1, true, 1), "fe.head.bn"); // [T, F, C]
+    ggml_tensor * xf = flatten(x);
+    std::vector<ggml_tensor *> feats;
+    const auto & stages = mect_b2();
+    for (size_t s = 0; s < stages.size(); s++) {
+        for (size_t k = 0; k < stages[s].size(); k++) {
+            const auto & b = stages[s][k];
+            const std::string p = "fe.b" + std::to_string(s + 1) + "." + std::to_string(k) + ".";
+            ggml_tensor * y = x;
+            for (int r = 0; r < b.cnn; r++) {
+                y = res_block(y, p + "cnn." + std::to_string(r) + ".", r == 0 ? b.s_f : 1, r == 0 ? b.s_t : 1);
+            }
+            if (has(p + "reduce.weight")) {
+                y = bn(conv(y, p + "reduce", 1, 1, true, 0), p + "reduce.bn");
+            }
+            const int64_t F2 = y->ne[1], C2 = y->ne[2];
+            ggml_tensor * x2 = flatten(y); // [2560, T']
+            ggml_tensor * h  = layer_norm(linear(x2, p + "rproj"), p + "rproj.ln");
+            h = ggml_add(ctx0, h, pos_conv(h, p));
+            h = layer_norm(ggml_add(ctx0, h, attention(h, p)), p + "ln1");
+            h = layer_norm(ggml_add(ctx0, h, moe_ffn(h, p)), p + "ln2");
+            ggml_tensor * e = ggml_add(ctx0, linear(h, p + "eproj"), x2);
+            if (b.s_t > 1) {
+                // nearest upsampling back to the input frames
+                const int64_t Tp = e->ne[1];
+                e = ggml_repeat_4d(ctx0, ggml_reshape_3d(ctx0, e, kFeat, 1, Tp), kFeat, b.s_t, Tp, 1);
+                e = ggml_view_2d(ctx0, ggml_reshape_2d(ctx0, e, kFeat, (int64_t) b.s_t * Tp), kFeat, T, e->nb[1], 0);
+            }
+            xf = ggml_add(ctx0, e, xf);
+            x  = unflatten(xf, F2, C2);
+        }
+        feats.push_back(xf);
+    }
+
+    ggml_tensor * dense = W("fe.dense_w"); // [2560, n_stage]
+    ggml_tensor * fused = nullptr;
+    for (size_t s = 0; s < feats.size(); s++) {
+        ggml_tensor * t = ggml_mul(ctx0, feats[s], ggml_view_2d(ctx0, dense, kFeat, 1, dense->nb[1], s * dense->nb[1]));
+        fused = fused ? ggml_add(ctx0, fused, t) : t;
+    }
+
+    // ASTP: attention-weighted mean and std over time
+    ggml_tensor * att   = linear(ggml_tanh(ctx0, linear(fused, "pool.l1")), "pool.l2");   // [2560, T]
+    ggml_tensor * alpha = ggml_soft_max(ctx0, ggml_cont(ctx0, ggml_transpose(ctx0, att))); // [T, 2560]
+    ggml_tensor * xt    = ggml_cont(ctx0, ggml_transpose(ctx0, fused));                    // [T, 2560]
+    ggml_tensor * mean  = ggml_sum_rows(ctx0, ggml_mul(ctx0, alpha, xt));                  // [1, 2560]
+    ggml_tensor * sq    = ggml_sum_rows(ctx0, ggml_mul(ctx0, alpha, ggml_mul(ctx0, xt, xt)));
+    ggml_tensor * var   = ggml_sub(ctx0, sq, ggml_mul(ctx0, mean, mean));
+    ggml_tensor * stdv  = ggml_sqrt(ctx0, ggml_clamp(ctx0, var, 1e-7f, 3.0e38f));
+    ggml_tensor * pooled = ggml_concat(ctx0, ggml_reshape_2d(ctx0, mean, kFeat, 1), ggml_reshape_2d(ctx0, stdv, kFeat, 1), 0);
+    pooled = ggml_add(ctx0, ggml_mul(ctx0, pooled, W("bn.scale")), W("bn.shift"));
+    ggml_tensor * spk = ggml_mul_mat(ctx0, W("linear.weight"), pooled); // [192, 1], not normalized
+    cb(spk, "spk_embd", -1);
+
+    ggml_tensor * out = linear(spk, "proj"); // speaker row of the prompt
+    ggml_build_forward_expand(gf, out);
+    return gf;
+}
diff --git a/tools/mtmd/mtmd-audio.cpp b/tools/mtmd/mtmd-audio.cpp
index 33c26c31f..c6d751b41 100644
--- a/tools/mtmd/mtmd-audio.cpp
+++ b/tools/mtmd/mtmd-audio.cpp
@@ -1176,45 +1176,102 @@ static float mtmd_audio_lufs(const float * samples, size_t n_samples, int sample
 // mtmd_audio_preprocessor_chatterbox_ref
 //

-void mtmd_audio_preprocessor_chatterbox_ref::initialize() {
-    // kaldi fbank: povey window, kaldi mel banks over the 512-point spectrum, the nyquist bin left out
-    {
-        const int frame_len = 400;
-        const int n_fft     = 512;
-        const int n_bins    = n_fft / 2 + 1;
-        const int n_mel     = hparams.n_mel_bins;
-        const double sr     = (double) hparams.audio_sample_rate;
-
-        fbank_cache.fill_sin_cos_table(n_fft);
-        fbank_cache.hann_window.resize(frame_len);
-        for (int i = 0; i < frame_len; i++) {
-            fbank_cache.hann_window[(size_t) i] = (float) pow(0.5 - 0.5 * cos(2.0 * M_PI * i / (frame_len - 1)), 0.85);
-        }
-
-        auto mel = [](double f) { return 1127.0 * log(1.0 + f / 700.0); };
-        const double mel_lo = mel(20.0);
-        const double mel_hi = mel(sr / 2.0);
-        const double delta  = (mel_hi - mel_lo) / (n_mel + 1);
-        const double bin_hz = sr / n_fft;
-
-        auto & filters = fbank_cache.filters;
-        filters.n_mel = n_mel;
-        filters.n_fft = n_fft;
-        filters.data.assign((size_t) n_mel * n_bins, 0.0f);
-        for (int m = 0; m < n_mel; m++) {
-            const double left   = mel_lo + m * delta;
-            const double center = left + delta;
-            const double right  = center + delta;
-            for (int i = 0; i < n_bins - 1; i++) {
-                const double f = mel(bin_hz * i);
-                if (f > left && f < right) {
-                    const double w = f <= center ? (f - left) / (center - left)
-                                                 : (right - f) / (right - center);
-                    filters.data[(size_t) m * n_bins + i] = (float) w;
-                }
+// torchaudio kaldi.fbank at 16 kHz, the nyquist bin is left out of the mel banks
+static constexpr int kaldi_frame_len = 400;
+static constexpr int kaldi_hop       = 160;
+static constexpr int kaldi_n_fft     = 512;
+
+static void mtmd_audio_kaldi_fbank_init(mtmd_audio_cache & cache, int n_mel, double sr, bool povey) {
+    const int n_bins = kaldi_n_fft / 2 + 1;
+
+    cache.fill_sin_cos_table(kaldi_n_fft);
+    cache.hann_window.resize(kaldi_frame_len);
+    for (int i = 0; i < kaldi_frame_len; i++) {
+        const double c = cos(2.0 * M_PI * i / (kaldi_frame_len - 1));
+        cache.hann_window[(size_t) i] = (float) (povey ? pow(0.5 - 0.5 * c, 0.85) : 0.54 - 0.46 * c);
+    }
+
+    auto mel = [](double f) { return 1127.0 * log(1.0 + f / 700.0); };
+    const double mel_lo = mel(20.0);
+    const double mel_hi = mel(sr / 2.0);
+    const double delta  = (mel_hi - mel_lo) / (n_mel + 1);
+    const double bin_hz = sr / kaldi_n_fft;
+
+    auto & filters = cache.filters;
+    filters.n_mel = n_mel;
+    filters.n_fft = kaldi_n_fft;
+    filters.data.assign((size_t) n_mel * n_bins, 0.0f);
+    for (int m = 0; m < n_mel; m++) {
+        const double left   = mel_lo + m * delta;
+        const double center = left + delta;
+        const double right  = center + delta;
+        for (int i = 0; i < n_bins - 1; i++) {
+            const double f = mel(bin_hz * i);
+            if (f > left && f < right) {
+                const double w = f <= center ? (f - left) / (center - left)
+                                             : (right - f) / (right - center);
+                filters.data[(size_t) m * n_bins + i] = (float) w;
             }
         }
     }
+}
+
+// snip edges, dc removal, preemphasis 0.97, log mel energies mean subtracted over time; out is mel major
+static bool mtmd_audio_kaldi_fbank(const mtmd_audio_cache & cache, const float * pcm, size_t n_samples, int n_mel, float scale,
+                                   mtmd_audio_mel & out) {
+    if ((int) n_samples < kaldi_frame_len) {
+        return false;
+    }
+    const int n_frames = 1 + ((int) n_samples - kaldi_frame_len) / kaldi_hop;
+
+    out.n_len     = n_frames;
+    out.n_len_org = n_frames;
+    out.n_mel     = n_mel;
+    out.data.assign((size_t) n_mel * n_frames, 0.0f);
+
+    const std::vector<float> & win = cache.hann_window;
+    std::vector<float>  fft_in((size_t) kaldi_n_fft * 2), fft_out((size_t) kaldi_n_fft * 8);
+    std::vector<double> e;
+    std::vector<float>  x(kaldi_frame_len);
+    for (int fr = 0; fr < n_frames; fr++) {
+        for (int i = 0; i < kaldi_frame_len; i++) {
+            x[(size_t) i] = pcm[(size_t) fr * kaldi_hop + i] * scale;
+        }
+
+        double mean = 0.0;
+        for (int i = 0; i < kaldi_frame_len; i++) {
+            mean += x[i];
+        }
+        mean /= kaldi_frame_len;
+
+        std::fill(fft_in.begin(), fft_in.end(), 0.0f);
+        fft_in[0] = (float) ((x[0] - mean) * (1.0 - 0.97) * win[0]);
+        for (int i = 1; i < kaldi_frame_len; i++) {
+            fft_in[(size_t) i] = (float) (((x[i] - mean) - 0.97 * (x[i - 1] - mean)) * win[(size_t) i]);
+        }
+        mtmd_audio_mel_energies(cache, fft_in, fft_out, false, e);
+        for (int m = 0; m < n_mel; m++) {
+            out.data[(size_t) m * n_frames + fr] = (float) log(std::max(e[(size_t) m], (double) FLT_EPSILON));
+        }
+    }
+
+    for (int m = 0; m < n_mel; m++) {
+        float * row = out.data.data() + (size_t) m * n_frames;
+        double mean = 0.0;
+        for (int fr = 0; fr < n_frames; fr++) {
+            mean += row[fr];
+        }
+        mean /= n_frames;
+        for (int fr = 0; fr < n_frames; fr++) {
+            row[fr] -= (float) mean;
+        }
+    }
+    return true;
+}
+
+void mtmd_audio_preprocessor_chatterbox_ref::initialize() {
+    // kaldi fbank of CAMPPlus: povey window
+    mtmd_audio_kaldi_fbank_init(fbank_cache, hparams.n_mel_bins, (double) hparams.audio_sample_rate, true);

     // s3 tokenizer: librosa slaney mel, 128 bands
     s3tok_cache.fill_sin_cos_table(400);
@@ -1253,59 +1310,12 @@ bool mtmd_audio_preprocessor_chatterbox_ref::process(const float *
         }
     }

-    // entry 0: torchaudio kaldi.fbank of CAMPPlus (snip edges, dc removal, preemphasis 0.97), mean subtracted over time
+    // entry 0: torchaudio kaldi.fbank of CAMPPlus
     {
-        const int frame_len = 400;
-        const int hop       = 160;
-        const int n_fft     = 512;
-        const int n_mel     = hparams.n_mel_bins;
-
-        if ((int) pcm.size() < frame_len) {
-            return false;
-        }
-        const int n_frames = 1 + ((int) pcm.size() - frame_len) / hop;
-
         mtmd_audio_mel out;
-        out.n_len     = n_frames;
-        out.n_len_org = n_frames;
-        out.n_mel     = n_mel;
-        out.data.assign((size_t) n_mel * n_frames, 0.0f);
-
-        const std::vector<float> & win = fbank_cache.hann_window;
-        std::vector<float>  fft_in((size_t) n_fft * 2), fft_out((size_t) n_fft * 8);
-        std::vector<double> e;
-        for (int fr = 0; fr < n_frames; fr++) {
-            const float * x = pcm.data() + (size_t) fr * hop;
-
-            double mean = 0.0;
-            for (int i = 0; i < frame_len; i++) {
-                mean += x[i];
-            }
-            mean /= frame_len;
-
-            std::fill(fft_in.begin(), fft_in.end(), 0.0f);
-            fft_in[0] = (float) ((x[0] - mean) * (1.0 - 0.97) * win[0]);
-            for (int i = 1; i < frame_len; i++) {
-                fft_in[(size_t) i] = (float) (((x[i] - mean) - 0.97 * (x[i - 1] - mean)) * win[(size_t) i]);
-            }
-            mtmd_audio_mel_energies(fbank_cache, fft_in, fft_out, false, e);
-            for (int m = 0; m < n_mel; m++) {
-                out.data[(size_t) m * n_frames + fr] = (float) log(std::max(e[(size_t) m], (double) FLT_EPSILON));
-            }
-        }
-
-        for (int m = 0; m < n_mel; m++) {
-            float * row = out.data.data() + (size_t) m * n_frames;
-            double mean = 0.0;
-            for (int fr = 0; fr < n_frames; fr++) {
-                mean += row[fr];
-            }
-            mean /= n_frames;
-            for (int fr = 0; fr < n_frames; fr++) {
-                row[fr] -= (float) mean;
-            }
+        if (!mtmd_audio_kaldi_fbank(fbank_cache, pcm.data(), pcm.size(), hparams.n_mel_bins, 1.0f, out)) {
+            return false;
         }
-
         output.push_back(std::move(out));
     }

@@ -1883,6 +1893,197 @@ bool mtmd_audio_preprocessor_gemma4ua::process(const float *                 sam
     return true;
 }

+//
+// mtmd_audio_preprocessor_zonos3_spk
+//
+
+void mtmd_audio_preprocessor_zonos3_spk::initialize() {
+    mtmd_audio_kaldi_fbank_init(cache, hparams.n_mel_bins, (double) hparams.audio_sample_rate, false);
+}
+
+bool mtmd_audio_preprocessor_zonos3_spk::process(const float * samples, size_t n_samples, std::vector<mtmd_audio_mel> & output,
+                                                 bool /*shape_only*/) const {
+    // the reference uses the first 60 s, on the int16 sample scale
+    const size_t n = std::min(n_samples, (size_t) hparams.audio_sample_rate * 60);
+    mtmd_audio_mel out;
+    if (!mtmd_audio_kaldi_fbank(cache, samples, n, hparams.n_mel_bins, 32768.0f, out)) {
+        return false;
+    }
+    output.clear();
+    output.push_back(std::move(out));
+    return true;
+}
+
+//
+// mtmd_audio_zonos3_synth implementation
+//
+
+mtmd_audio_zonos3_synth::mtmd_audio_zonos3_synth(const std::vector<float> & mixer, const std::vector<float> & resample,
+                                                 int n_fft0, int n_fft1, int hop, int rs_orig, int rs_new)
+    : mixer(mixer), fir_kernel((int) (mixer.size() / 2)), hop(hop), rs_kernel(resample), rs_orig(rs_orig), rs_new(rs_new) {
+    GGML_ASSERT(mixer.size() % 2 == 0 && fir_kernel % 2 == 1);
+    GGML_ASSERT(resample.size() % (size_t) rs_new == 0);
+    const int rs_len = (int) (resample.size() / rs_new);
+    GGML_ASSERT(rs_len >= rs_orig && (rs_len - rs_orig) % 2 == 0);
+    rs_width = (rs_len - rs_orig) / 2;
+
+    const int n_fft[2] = { n_fft0, n_fft1 };
+    const int h = (fir_kernel - 1) / 2;
+    for (int i = 0; i < 2; i++) {
+        branch & b = br[i];
+        b.n_fft = n_fft[i];
+        b.cache.fill_sin_cos_table(b.n_fft);
+        b.cache.fill_hann_window(b.n_fft, true);
+        b.ifft_in.assign((size_t) b.n_fft * 2 * 4, 0.0f);
+        b.ifft_out.assign((size_t) b.n_fft * 2 * 4, 0.0f);
+        b.mix_in.assign((size_t) h, 0.0f);
+        b.mix_avail = h;
+    }
+}
+
+// the first n_fft / 2 samples are the centering pad
+void mtmd_audio_zonos3_synth::ola_finalize(branch & b, int64_t pf) {
+    const int64_t pad = b.n_fft / 2;
+    const int64_t n   = std::min<int64_t>(pf - b.ola_base, (int64_t) b.ola.size());
+    if (n <= 0) {
+        return;
+    }
+    for (int64_t j = 0; j < n; j++) {
+        if (b.ola_base + j < pad) {
+            continue;
+        }
+        b.mix_in.push_back(b.env[(size_t) j] > 1e-11f ? b.ola[(size_t) j] / b.env[(size_t) j] : 0.0f);
+        b.mix_avail++;
+    }
+    b.ola.erase(b.ola.begin(), b.ola.begin() + n);
+    b.env.erase(b.env.begin(), b.env.begin() + n);
+    b.ola_base += n;
+}
+
+void mtmd_audio_zonos3_synth::mix_emit(std::vector<float> & out) {
+    const int64_t avail = std::min(br[0].mix_avail, br[1].mix_avail);
+    const int64_t m1    = avail - fir_kernel + 1;
+    if (m1 <= mix_next) {
+        return;
+    }
+    const size_t n  = (size_t) (m1 - mix_next);
+    std::vector<float> y(n, 0.0f);
+    for (int c = 0; c < 2; c++) {
+        const float * w = mixer.data() + (size_t) c * fir_kernel;
+        const float * s = br[c].mix_in.data() + (mix_next - mix_base);
+        for (int k = 0; k < fir_kernel; k++) {
+            for (size_t i = 0; i < n; i++) {
+                y[i] += w[k] * s[i + k];
+            }
+        }
+    }
+    mix_next = m1;
+    for (int c = 0; c < 2; c++) {
+        br[c].mix_in.erase(br[c].mix_in.begin(), br[c].mix_in.begin() + (mix_next - mix_base));
+    }
+    mix_base = mix_next;
+
+    rs_buf.insert(rs_buf.end(), y.begin(), y.end());
+    rs_in += (int64_t) n;
+    resample(false, out);
+}
+
+// on flush, the input is zero-extended and the output is ceil(new * rs_in / orig) samples
+void mtmd_audio_zonos3_synth::resample(bool flush, std::vector<float> & out) {
+    const int     KL    = 2 * rs_width + rs_orig;
+    const int64_t total = flush ? ((int64_t) rs_new * rs_in + rs_orig - 1) / rs_orig : -1;
+    std::vector<float> xw(KL);
+    for (;; rs_block++) {
+        const int64_t x0 = rs_block * rs_orig - rs_width;
+        if (flush ? rs_block * rs_new >= total : x0 + KL > rs_in) {
+            break;
+        }
+        for (int k = 0; k < KL; k++) {
+            const int64_t j = x0 + k;
+            xw[k] = (j < 0 || j >= rs_in) ? 0.0f : rs_buf[(size_t) (j - rs_base)];
+        }
+        const int64_t n_emit = flush ? std::min<int64_t>(rs_new, total - rs_block * rs_new) : rs_new;
+        for (int64_t i = 0; i < n_emit; i++) {
+            const float * ki = rs_kernel.data() + (size_t) i * KL;
+            float acc = 0.0f;
+            for (int k = 0; k < KL; k++) {
+                acc += ki[k] * xw[k];
+            }
+            out.push_back(acc);
+        }
+    }
+    // drop the samples no later block reads
+    const int64_t keep_from = std::min(rs_in, std::max<int64_t>(0, rs_block * rs_orig - rs_width));
+    if (keep_from > rs_base) {
+        rs_buf.erase(rs_buf.begin(), rs_buf.begin() + (keep_from - rs_base));
+        rs_base = keep_from;
+    }
+}
+
+void mtmd_audio_zonos3_synth::push(const float * spec, int64_t n_cols, std::vector<float> & out) {
+    const int64_t n_rows = (br[0].n_fft + 2) + (br[1].n_fft + 2);
+    const float max_log_mag = logf(100.0f);
+    for (int i = 0; i < 2; i++) {
+        branch & b = br[i];
+        const int n    = b.n_fft;
+        const int bins = n / 2 + 1;
+        const int64_t row0 = i == 0 ? 0 : br[0].n_fft + 2;
+        for (int64_t c = 0; c < n_cols; c++) {
+            const float * logmag = spec + c * n_rows + row0;
+            const float * phase  = logmag + bins;
+            // irfft ignores the imaginary parts of DC and Nyquist
+            for (int k = 0; k < bins; k++) {
+                const float a = expf(std::min(logmag[k], max_log_mag));
+                b.ifft_in[2 * k + 0] = a * cosf(phase[k]);
+                b.ifft_in[2 * k + 1] = (k == 0 || k == n / 2) ? 0.0f : a * sinf(phase[k]);
+            }
+            for (int k = 1; k < n - bins + 1; k++) {
+                b.ifft_in[2 * (n - k) + 0] =  b.ifft_in[2 * k + 0];
+                b.ifft_in[2 * (n - k) + 1] = -b.ifft_in[2 * k + 1];
+            }
+            ifft(b.cache, b.ifft_in.data(), n, b.ifft_out.data());
+
+            const int64_t t = n_frames + c;
+            const size_t need = (size_t) (t * hop + n - b.ola_base);
+            if (b.ola.size() < need) {
+                b.ola.resize(need, 0.0f);
+                b.env.resize(need, 0.0f);
+            }
+            float * ola = b.ola.data() + (t * hop - b.ola_base);
+            float * env = b.env.data() + (t * hop - b.ola_base);
+            const float * win = b.cache.hann_window.data();
+            for (int j = 0; j < n; j++) {
+                ola[j] += win[j] * b.ifft_out[2 * j];
+                env[j] += win[j] * win[j];
+            }
+        }
+    }
+    n_frames += n_cols;
+    for (auto & b : br) {
+        ola_finalize(b, n_frames * hop);
+    }
+    mix_emit(out);
+}
+
+void mtmd_audio_zonos3_synth::flush(std::vector<float> & out) {
+    if (done || n_frames == 0) {
+        return;
+    }
+    done = true;
+    // the mixer has h zeros of right padding
+    const int h = (fir_kernel - 1) / 2;
+    const int64_t L = n_frames * hop;
+    for (auto & b : br) {
+        ola_finalize(b, b.ola_base + (int64_t) b.ola.size());
+        const int64_t keep = h + L - mix_base;
+        b.mix_in.resize((size_t) keep, 0.0f);
+        b.mix_in.resize((size_t) (keep + h), 0.0f);
+        b.mix_avail = mix_base + keep + h;
+    }
+    mix_emit(out);
+    resample(true, out);
+}
+
 //
 // mtmd_audio_streaming_istft implementation
 //
diff --git a/tools/mtmd/mtmd-audio.h b/tools/mtmd/mtmd-audio.h
index 7251f82db..937451c8a 100644
--- a/tools/mtmd/mtmd-audio.h
+++ b/tools/mtmd/mtmd-audio.h
@@ -243,6 +243,16 @@ struct mtmd_audio_preprocessor_chatterbox_ref : mtmd_audio_preprocessor {
     mtmd_audio_cache ve_cache;     // entry 4
 };

+// zonos3 speaker encoder front-end: kaldi fbank with a hamming window [80 x n_frames] (mel major)
+struct mtmd_audio_preprocessor_zonos3_spk : mtmd_audio_preprocessor {
+    mtmd_audio_preprocessor_zonos3_spk(const mtmd_model * model) : mtmd_audio_preprocessor(model) {}
+    void initialize() override;
+    bool process(const float * samples, size_t n_samples, std::vector<mtmd_audio_mel> & output, bool shape_only) const override;
+
+  private:
+    mtmd_audio_cache cache;
+};
+
 struct mtmd_audio_preprocessor_parakeet : mtmd_audio_preprocessor {
     mtmd_audio_preprocessor_parakeet(const mtmd_model * model) : mtmd_audio_preprocessor(model) { }
     void initialize() override;
@@ -261,6 +271,51 @@ struct mtmd_audio_preprocessor_nemotron_transcribe : mtmd_audio_preprocessor_par
     bool process(const float * samples, size_t n_samples, std::vector<mtmd_audio_mel> & output, bool shape_only) const override;
 };

+//
+// zonos3 iSTFT head, streaming: 2 iSTFT branches, FIR mixer, 44160 -> 44100 Hz resampler
+// ported from the reference ggml runtime (zac.cpp)
+//
+struct mtmd_audio_zonos3_synth {
+    // mixer [2, fir_kernel], resample [rs_new, rs_orig + 2 * width]
+    mtmd_audio_zonos3_synth(const std::vector<float> & mixer, const std::vector<float> & resample,
+                            int n_fft0, int n_fft1, int hop, int rs_orig, int rs_new);
+
+    // spec: n_cols frames of [log magnitude, phase] of each branch
+    void push(const float * spec, int64_t n_cols, std::vector<float> & out);
+    void flush(std::vector<float> & out);
+
+  private:
+    struct branch {
+        int n_fft;
+        mtmd_audio_cache   cache;
+        std::vector<float> ola, env; // from ola_base on
+        int64_t ola_base = 0;
+        std::vector<float> mix_in;   // from mix_base on
+        int64_t mix_avail = 0;
+        std::vector<float> ifft_in, ifft_out;
+    };
+
+    void ola_finalize(branch & b, int64_t pf);
+    void mix_emit(std::vector<float> & out);
+    void resample(bool flush, std::vector<float> & out);
+
+    branch br[2];
+    std::vector<float> mixer;
+    int     fir_kernel;
+    int     hop;
+    int64_t n_frames = 0;
+    int64_t mix_base = 0;
+    int64_t mix_next = 0;
+    bool    done     = false;
+
+    std::vector<float> rs_kernel;
+    int     rs_orig, rs_new, rs_width;
+    std::vector<float> rs_buf; // from rs_base on
+    int64_t rs_base  = 0;
+    int64_t rs_in    = 0;
+    int64_t rs_block = 0;
+};
+
 //
 // streaming ISTFT - converts spectrogram frames back to audio one frame at a time
 //
diff --git a/tools/mtmd/mtmd-graph.cpp b/tools/mtmd/mtmd-graph.cpp
index 01c4c0ac6..ac656032d 100644
--- a/tools/mtmd/mtmd-graph.cpp
+++ b/tools/mtmd/mtmd-graph.cpp
@@ -960,6 +960,10 @@ std::unique_ptr<mtmd_graph> mtmd_get_graph_builder(mtmd_subcontext * ctx, const
             {
                 builder = std::make_unique<mtmd_graph_qwen3tts_spkenc>(ctx, img);
             } break;
+        case PROJECTOR_TYPE_ZONOS3_SPKENC:
+            {
+                builder = std::make_unique<mtmd_graph_zonos3_spkenc>(ctx, img);
+            } break;
         case PROJECTOR_TYPE_POCKETTTS_SPKENC:
             {
                 builder = std::make_unique<mtmd_graph_pockettts_spkenc>(ctx, img);
@@ -1015,6 +1019,16 @@ std::unique_ptr<mtmd_graph> mtmd_get_graph_builder(mtmd_subcontext * ctx, const
                 }
                 builder = std::make_unique<mtmd_graph_lfm3a_gen>(ctx, img, gen_process, all_known, n_frames);
             } break;
+        case PROJECTOR_TYPE_ZONOS3_GEN:
+            {
+                const auto gen_process = params ? params->gen_process : MTMD_SUBCTX_GEN_PROCESS_GEN_CODE;
+                const int64_t n_cb = mtmd_graph_zonos3_gen::n_codebooks;
+                // GEN_CODE with codes: only the backbone row of that frame is needed
+                const bool all_known = params && params->codes && gen_process == MTMD_SUBCTX_GEN_PROCESS_GEN_CODE;
+                const int  n_frames  = params && params->codes && gen_process == MTMD_SUBCTX_GEN_PROCESS_GEN_WAV
+                                     ? (int) (params->codes->size() / n_cb) : 1;
+                builder = std::make_unique<mtmd_graph_zonos3_gen>(ctx, img, gen_process, all_known, n_frames);
+            } break;
         case PROJECTOR_TYPE_DRAMA3_GEN:
             {
                 const auto gen_process = params ? params->gen_process : MTMD_SUBCTX_GEN_PROCESS_GEN_CODE;
diff --git a/tools/mtmd/mtmd-helper-gen.cpp b/tools/mtmd/mtmd-helper-gen.cpp
index 73e7e1b27..8a5757c1e 100644
--- a/tools/mtmd/mtmd-helper-gen.cpp
+++ b/tools/mtmd/mtmd-helper-gen.cpp
@@ -107,6 +107,8 @@ public:
     virtual int32_t flush() = 0;
     // continuing a sequence (inp->n_past > 0) with the model's own text/audio switching
     virtual bool supports_continue() const { return false; }
+    // mtmd_helper_gen_audio_inp::speed and loudness other than the defaults
+    virtual bool supports_delivery() const { return false; }
     virtual bool next_is_text() const { return false; }

     std::vector<llama_logit_bias> logit_bias; // see mtmd_helper_gen_audio_get_logit_bias()
@@ -2418,6 +2420,535 @@ private:
     bool   wav_header_sent = false;
 };

+// ZONOS3: one backbone row per frame of 10 codebooks, codebook j lags j frames
+class zonos3_gen_audio_pipeline : public mtmd_gen_audio_pipeline {
+public:
+    using mtmd_gen_audio_pipeline::mtmd_gen_audio_pipeline;
+
+    bool supports_delivery() const override { return true; }
+
+    void reset() override {
+        seq_id = 0;
+        pos = 0;
+        segments.clear();
+        seg_idx = 0;
+        seg_pos = 0;
+        rows.clear();
+        step = 0;
+        eos_row = -1;
+        countdown = 0;
+        done = false;
+        n_decoded = 0;
+        n_codec_samples = 0;
+        flushed = false;
+        gen_state.reset();
+        pcm_sent = 0;
+        wav_header_sent = false;
+        audio_pcm.clear();
+        h_state_buf.clear();
+        out_buf.clear();
+    }
+
+    int32_t set_input(const mtmd_helper_gen_audio_inp * inp) override {
+        reset();
+        seq_id   = inp->seq_id;
+        out_type = inp->out_type;
+        stream   = inp->stream;
+
+        // settings of the reference, only the temperature can be changed
+        const mtmd_gen_inp def = mtmd_gen_inp_default(mctx);
+        temp    = inp->temp >= 0.0f ? inp->temp : def.temp;
+        top_k   = def.top_k;
+        top_p   = def.top_p;
+        rep_pen = def.penalty_repeat;
+        rep_win = def.penalty_last_n;
+        rng.seed(inp->seed == UINT32_MAX ? std::random_device{}() : inp->seed);
+
+        if (!init_gen_state(inp->seed)) {
+            return 1;
+        }
+
+        // prompt: [voice rows] [condition rows] BOS text EOS [silence rows]
+        // the speaker CFG of the reference is omitted: it adds too much code and maintenance cost
+        if (inp->speaker_ref) {
+            segment seg;
+            if (!encode_speaker(inp->speaker_ref, seg.embd)) {
+                return 1;
+            }
+            segments.push_back(std::move(seg));
+        }
+
+        // condition rows: the bucket of each value over the checkpoint edges, or unknown
+        auto bucket = [](const std::vector<float> & edges, float v) {
+            return std::to_string(std::upper_bound(edges.begin(), edges.end(), v) - edges.begin());
+        };
+        const std::string loudness = inp->loudness != 0.0f ? bucket(loudness_edges, inp->loudness) : "unk";
+        const std::string rate     = inp->speed != 1.0f ? bucket(rate_edges, inp->speed * typical_rate) : "unk";
+        segment text;
+        for (const std::string & name : { "<|loudness_" + loudness + "|>", std::string("<|fmax_unk|>"), "<|speaking_rate_" + rate + "|>" }) {
+            const llama_token t = find_special_token(vocab, name);
+            if (t == LLAMA_TOKEN_NULL) {
+                LOG_ERR("mtmd_helper_gen_audio: missing ZONOS3 token %s in vocab\n", name.c_str());
+                return 1;
+            }
+            text.tokens.push_back(t);
+        }
+        const std::string prompt(inp->prompt, inp->prompt_len);
+        std::vector<llama_token> ids(prompt.size() + 16);
+        const int32_t n_ids = llama_tokenize(vocab, prompt.c_str(), (int32_t) prompt.size(), ids.data(), (int32_t) ids.size(), true, false);
+        if (n_ids <= 2) {
+            LOG_ERR("mtmd_helper_gen_audio: empty prompt or tokenization failed\n");
+            return 1;
+        }
+        text.tokens.insert(text.tokens.end(), ids.begin(), ids.begin() + n_ids);
+        segments.push_back(std::move(text));
+
+        // codec silence, delayed
+        segment silence;
+        for (int k = 0; k < n_prefill; k++) {
+            std::vector<int32_t> row(n_cb, audio_pad);
+            for (int cb = 0; cb <= k && cb < n_cb; cb++) {
+                row[cb] = silence_frame[cb];
+            }
+            const float * embd = embed_row(row);
+            if (!embd) {
+                return 1;
+            }
+            silence.embd.insert(silence.embd.end(), embd, embd + n_embd);
+            rows.insert(rows.end(), row.begin(), row.end());
+        }
+        segments.push_back(std::move(silence));
+
+        size_t n_prompt = 0;
+        for (const auto & seg : segments) {
+            n_prompt += seg.n_rows(n_embd);
+        }
+        if (n_prompt >= llama_n_ctx_seq(lctx)) {
+            LOG_ERR("mtmd_helper_gen_audio: prompt does not fit in the context\n");
+            return 1;
+        }
+        if (!llama_memory_seq_rm(llama_get_memory(lctx), seq_id, 0, -1)) {
+            LOG_ERR("mtmd_helper_gen_audio: cannot reset the sequence\n");
+            return 1;
+        }
+        return 0;
+    }
+
+    int32_t step_prompt(int32_t n_batch) override {
+        GGML_ASSERT(n_batch > 0);
+        if (seg_idx >= segments.size()) {
+            return 0;
+        }
+        const segment & seg = segments[seg_idx];
+        const int n_rows = (int) seg.n_rows(n_embd);
+        const int n      = std::min(n_batch, n_rows - seg_pos);
+        const bool last  = seg_idx + 1 == segments.size() && seg_pos + n == n_rows;
+
+        int ret;
+        if (!seg.tokens.empty()) {
+            llama_batch batch = llama_batch_init(n, 0, 1);
+            for (int i = 0; i < n; i++) {
+                batch.token   [i]    = seg.tokens[(size_t) (seg_pos + i)];
+                batch.pos     [i]    = pos + i;
+                batch.n_seq_id[i]    = 1;
+                batch.seq_id  [i][0] = seq_id;
+                batch.logits  [i]    = last && i == n - 1;
+            }
+            batch.n_tokens = n;
+            ret = llama_decode(lctx, batch);
+            llama_batch_free(batch);
+        } else {
+            decode_embd_batch batch(seg.embd.data() + (size_t) seg_pos * n_embd, n, 1, n_embd);
+            batch.set_position_normal(pos, seq_id);
+            batch.logits[n - 1] = last;
+            ret = llama_process(lctx, LLAMA_PROCESS_TYPE_DECODE, batch.render(lctx, 0, n));
+        }
+        if (ret != 0) {
+            LOG_ERR("mtmd_helper_gen_audio: prompt decode failed\n");
+            return -1;
+        }
+        pos     += n;
+        seg_pos += n;
+        if (seg_pos == n_rows) {
+            seg_idx++;
+            seg_pos = 0;
+        }
+
+        int32_t n_left = 0;
+        for (size_t i = seg_idx; i < segments.size(); i++) {
+            n_left += (int32_t) segments[i].n_rows(n_embd) - (i == seg_idx ? seg_pos : 0);
+        }
+        if (n_left == 0) {
+            const float * h = llama_get_embeddings_ith(lctx, -1);
+            h_state_buf.assign(h, h + n_embd);
+        }
+        return n_left;
+    }
+
+    int32_t step_gen(llama_token sampled, const float * h_state_in, const float ** h_state_out, bool * out_stop) override {
+        GGML_UNUSED(sampled);
+        GGML_UNUSED(h_state_in);
+        *h_state_out = nullptr;
+        *out_stop    = false;
+        if (done) {
+            *out_stop = true;
+            return 0;
+        }
+
+        mtmd_gen_inp inp = mtmd_gen_inp_default(mctx);
+        inp.type = MTMD_GEN_PROCESS_TYPE_GEN_CODE;
+        inp.embd = h_state_buf.data();
+        mtmd_gen_out out{};
+        if (mtmd_gen_audio_process(mctx, gen_state.get(), &inp, &out) != 0 || out.n_feats != (size_t) n_cb * n_vocab) {
+            LOG_ERR("mtmd_helper_gen_audio: codebook heads failed\n");
+            return 1;
+        }
+
+        std::vector<int32_t> frame(n_cb);
+        for (int cb = 0; cb < n_cb; cb++) {
+            frame[cb] = sample(out.feats + (size_t) cb * n_vocab, cb);
+        }
+        // codebooks whose delayed stream has not started yet are pad
+        const int r = n_prefill + step;
+        for (int cb = r + 1; cb < n_cb; cb++) {
+            frame[cb] = audio_pad;
+        }
+        rows.insert(rows.end(), frame.begin(), frame.end());
+        step++;
+
+        // after EOA, n_cb + 1 more rows flush the delay
+        int max_eoa = -1;
+        for (int cb = 0; cb < n_cb; cb++) {
+            if (frame[cb] == audio_eoa) {
+                max_eoa = cb;
+            }
+        }
+        if (eos_row < 0 && max_eoa >= 0) {
+            eos_row   = std::max(0, r - max_eoa);
+            countdown = n_cb + 1;
+        }
+        if ((countdown > 0 && --countdown == 0) || step >= max_frames || (uint32_t) pos >= llama_n_ctx_seq(lctx)) {
+            done = true;
+            *out_stop = true;
+            return 0;
+        }
+
+        const float * next = embed_row(frame);
+        if (!next) {
+            return 1;
+        }
+        decode_embd_batch batch(next, 1, 1, n_embd);
+        batch.set_position_normal(pos, seq_id);
+        batch.logits[0] = 1;
+        if (llama_process(lctx, LLAMA_PROCESS_TYPE_DECODE, batch.render(lctx, 0, 1)) != 0) {
+            LOG_ERR("mtmd_helper_gen_audio: decode failed\n");
+            return 1;
+        }
+        pos++;
+        const float * h = llama_get_embeddings_ith(lctx, -1);
+        h_state_buf.assign(h, h + n_embd);
+        *h_state_out = h_state_buf.data();
+
+        if (stream) {
+            return decode_stream() ? 0 : 1;
+        }
+        return 0;
+    }
+
+    int32_t get_output(int32_t * out_sample_rate, const char ** out_data, size_t * out_data_len, int64_t * out_n_samples) override {
+        *out_sample_rate = info.sample_rate;
+
+        if (!stream) {
+            if (flush() != 0) {
+                return 1;
+            }
+            if (out_n_samples) {
+                *out_n_samples = (int64_t) audio_pcm.size();
+            }
+            if (out_type == MTMD_HELPER_GEN_AUDIO_OUTTYPE_PCM) {
+                *out_data     = (const char *) audio_pcm.data();
+                *out_data_len = audio_pcm.size() * sizeof(float);
+                return 0;
+            }
+            out_buf.clear();
+            if (!write_wav16(out_buf, audio_pcm, info.sample_rate)) {
+                LOG_ERR("mtmd_helper_gen_audio: output too large for WAV\n");
+                return 1;
+            }
+            *out_data     = out_buf.data();
+            *out_data_len = out_buf.size();
+            return 0;
+        }
+
+        // streaming: only the audio since the previous call
+        const size_t n_new = audio_pcm.size() - pcm_sent;
+        if (out_n_samples) {
+            *out_n_samples = (int64_t) n_new;
+        }
+        if (out_type == MTMD_HELPER_GEN_AUDIO_OUTTYPE_PCM) {
+            *out_data     = (const char *) (audio_pcm.data() + pcm_sent);
+            *out_data_len = n_new * sizeof(float);
+            pcm_sent = audio_pcm.size();
+            return 0;
+        }
+        out_buf.clear();
+        if (!wav_header_sent) {
+            write_wav16_header(out_buf, UINT32_MAX, info.sample_rate);
+            wav_header_sent = true;
+        }
+        append_wav16_pcm(out_buf, audio_pcm.data() + pcm_sent, n_new);
+        pcm_sent = audio_pcm.size();
+        *out_data     = out_buf.data();
+        *out_data_len = out_buf.size();
+        return 0;
+    }
+
+    int32_t flush() override {
+        return decode_wav(true) ? 0 : 1;
+    }
+
+private:
+    static constexpr int n_cb      = 10;
+    static constexpr int n_vocab   = 4098; // codes, EOA, pad
+    static constexpr int audio_eoa = 4096;
+    static constexpr int audio_pad = 4097;
+
+    // condition_edges of params.json; speed 1 is this many UTF-8 bytes per second, the middle of the rate buckets
+    const std::vector<float> loudness_edges = { -29.778f, -26.947f, -25.047f, -23.3f, -21.743f, -20.386f, -19.186f, -17.851f, -15.967f };
+    const std::vector<float> rate_edges = {
+        6.546f, 8.182f, 9.273f, 10.31f, 11.054f, 11.667f, 12.193f, 12.656f, 13.083f, 13.476f, 13.846f, 14.196f, 14.53f, 14.887f, 15.193f,
+        15.517f, 15.865f, 16.208f, 16.579f, 16.972f, 17.411f, 17.898f, 18.434f, 19.069f, 19.818f, 20.816f, 22.286f, 24.722f, 29.25f,
+    };
+    static constexpr float typical_rate = 15.0f;
+
+    // digital silence of the ZAC codec (registries/silence_frame_*.json)
+    static constexpr int32_t silence_frame[n_cb] = { 1918, 746, 3544, 234, 1267, 2914, 3944, 2849, 326, 1337 };
+    static constexpr int n_prefill = 3;
+
+    // -3 dB of headroom
+    static constexpr int   samples_per_frame = 2940;
+    static constexpr float out_gain          = 0.70794578f;
+
+    static constexpr int rep_codebooks = 6;
+    static constexpr int max_frames    = 2000;
+    static constexpr size_t stream_frames = 8;
+
+    struct segment {
+        std::vector<llama_token> tokens; // either tokens or embd rows
+        std::vector<float>       embd;
+        size_t n_rows(int n) const { return tokens.empty() ? embd.size() / (size_t) n : tokens.size(); }
+    };
+
+    const float * embed_row(const std::vector<int32_t> & codes) {
+        mtmd_gen_inp inp = mtmd_gen_inp_default(mctx);
+        inp.type    = MTMD_GEN_PROCESS_TYPE_GEN_CODE;
+        inp.codes   = const_cast<int32_t *>(codes.data());
+        inp.n_codes = codes.size();
+        mtmd_gen_out out{};
+        if (mtmd_gen_audio_process(mctx, gen_state.get(), &inp, &out) != 0) {
+            LOG_ERR("mtmd_helper_gen_audio: codebook embedding failed\n");
+            return nullptr;
+        }
+        return out.embd;
+    }
+
+    // same as the reference sampler (servers/ggml/src/zonos3-sampler.cpp)
+    int32_t sample(const float * in, int cb) {
+        std::vector<float> l(in, in + n_vocab);
+        l[audio_pad] = -INFINITY;
+
+        if (rep_pen != 1.0f && cb < rep_codebooks) {
+            const size_t n_rows = rows.size() / n_cb - n_prefill;
+            const size_t n_win  = std::min(n_rows, (size_t) std::max(rep_win, 0));
+            std::vector<bool> seen(audio_eoa, false);
+            for (size_t f = n_prefill + n_rows - n_win; f < n_prefill + n_rows; f++) {
+                const int32_t c = rows[f * n_cb + (size_t) cb];
+                if (c >= 0 && c < audio_eoa && !seen[(size_t) c]) {
+                    seen[(size_t) c] = true;
+                    l[c] = l[c] > 0.0f ? l[c] / rep_pen : l[c] * rep_pen;
+                }
+            }
+        }
+
+        if (temp <= 0.0f) {
+            return (int32_t) (std::max_element(l.begin(), l.end()) - l.begin());
+        }
+        for (auto & v : l) {
+            v /= temp;
+        }
+
+        float kth = -INFINITY;
+        if (top_k > 0 && top_k < n_vocab) {
+            std::vector<float> tmp(l);
+            std::nth_element(tmp.begin(), tmp.begin() + (top_k - 1), tmp.end(), std::greater<float>());
+            kth = tmp[(size_t) top_k - 1];
+        }
+        std::vector<int32_t> idx;
+        for (int32_t i = 0; i < n_vocab; i++) {
+            if (l[i] >= kth && l[i] > -INFINITY) {
+                idx.push_back(i);
+            }
+        }
+        float lmax = -INFINITY;
+        for (int32_t i : idx) {
+            lmax = std::max(lmax, l[i]);
+        }
+        std::vector<double> p(idx.size());
+        double sum = 0.0;
+        for (size_t j = 0; j < idx.size(); j++) {
+            p[j] = std::exp((double) (l[idx[j]] - lmax));
+            sum += p[j];
+        }
+        std::vector<size_t> ord(idx.size());
+        for (size_t j = 0; j < ord.size(); j++) {
+            ord[j] = j;
+        }
+        std::sort(ord.begin(), ord.end(), [&](size_t a, size_t b) { return p[a] > p[b]; });
+        if (top_p > 0.0f && top_p < 1.0f) {
+            double before = 0.0;
+            for (size_t j : ord) {
+                const double pj = p[j];
+                if (before > top_p * sum) {
+                    p[j] = 0.0;
+                }
+                before += pj;
+            }
+        }
+        double kept = 0.0;
+        for (double v : p) {
+            kept += v;
+        }
+        double u = std::uniform_real_distribution<double>(0.0, kept)(rng);
+        for (size_t j = 0; j < p.size(); j++) {
+            u -= p[j];
+            if (u <= 0.0 && p[j] > 0.0) {
+                return idx[j];
+            }
+        }
+        return idx[ord[0]];
+    }
+
+    // frames with all codebooks known
+    size_t n_ready() const {
+        const size_t n_rows = rows.size() / n_cb;
+        size_t n = n_rows >= (size_t) n_cb ? n_rows - (n_cb - 1) : 0;
+        if (eos_row >= 0) {
+            n = std::min(n, (size_t) eos_row);
+        }
+        return n;
+    }
+
+    // streaming, as the reference does for the first audio: the silence frames are decoded with silence codes only,
+    // then the first generated frame from its first code alone; later chunks of complete frames grow 2, 4, 8
+    bool decode_stream() {
+        if (n_decoded == 0) {
+            const size_t n_rows = rows.size() / n_cb;
+            if (n_rows <= (size_t) n_prefill || rows[(size_t) n_prefill * n_cb] >= audio_eoa) {
+                return true;
+            }
+            std::vector<int32_t> codes;
+            for (int k = 0; k < n_prefill; k++) {
+                codes.insert(codes.end(), silence_frame, silence_frame + n_cb);
+            }
+            for (int cb = 0; cb < n_cb; cb++) {
+                const size_t r = (size_t) n_prefill + cb;
+                const int32_t c = r < n_rows ? rows[r * n_cb + cb] : -1;
+                codes.push_back(c >= audio_eoa ? -1 : c);
+            }
+            if (!run_codec(codes.data(), codes.size())) {
+                return false;
+            }
+            n_decoded = n_prefill + 1;
+            return true;
+        }
+        const size_t n_chunk = std::min(stream_frames, n_decoded - n_prefill + 1);
+        if (n_ready() >= n_decoded + n_chunk) {
+            return decode_wav(false);
+        }
+        return true;
+    }
+
+    // the silence frames are decoded too, as left context
+    bool decode_wav(bool final) {
+        const size_t n_frames = n_ready();
+        std::vector<int32_t> codes;
+        while (n_decoded < n_frames) {
+            const size_t n = std::min(n_frames - n_decoded, (size_t) 64);
+            codes.resize(n * n_cb);
+            for (size_t f = 0; f < n; f++) {
+                for (int cb = 0; cb < n_cb; cb++) {
+                    // EOA and pad have no codec row
+                    const int32_t c = rows[(n_decoded + f + cb) * n_cb + cb];
+                    codes[f * n_cb + cb] = std::clamp(c, 0, audio_eoa - 1);
+                }
+            }
+            if (!run_codec(codes.data(), codes.size())) {
+                return false;
+            }
+            n_decoded += n;
+        }
+        if (final && done && n_decoded > 0 && !flushed) {
+            flushed = true;
+            return run_codec(nullptr, 0);
+        }
+        return true;
+    }
+
+    bool run_codec(const int32_t * codes, size_t n_codes) {
+        mtmd_gen_inp inp = mtmd_gen_inp_default(mctx);
+        inp.type    = MTMD_GEN_PROCESS_TYPE_GEN_WAV;
+        inp.codes   = const_cast<int32_t *>(codes);
+        inp.n_codes = n_codes;
+        mtmd_gen_out out{};
+        if (mtmd_gen_audio_process(mctx, gen_state.get(), &inp, &out) != 0) {
+            LOG_ERR("mtmd_helper_gen_audio: codec decode failed\n");
+            return false;
+        }
+        // drop the audio of the silence frames, then the output gain and soft limiter of the reference
+        const float knee = std::pow(10.0f, -1.0f / 20.0f);
+        for (size_t i = 0; i < out.n_samples; i++, n_codec_samples++) {
+            if (n_codec_samples < (size_t) n_prefill * samples_per_frame) {
+                continue;
+            }
+            const float x = out.audio[i] * out_gain;
+            const float a = std::fabs(x);
+            audio_pcm.push_back(a <= knee ? x : std::copysign(knee + (1.0f - knee) * std::tanh((a - knee) / (1.0f - knee)), x));
+        }
+        return true;
+    }
+
+    std::vector<segment> segments;
+    size_t seg_idx = 0;
+    int    seg_pos = 0;
+
+    std::vector<int32_t> rows; // delayed rows: silence rows, then the generated ones
+    int  step      = 0;
+    int  eos_row   = -1;
+    int  countdown = 0;
+    bool done      = false;
+    size_t n_decoded = 0;
+    size_t n_codec_samples = 0;
+    bool   flushed   = false;
+
+    float   temp    = 0.75f;
+    int32_t top_k   = 80;
+    float   top_p   = 0.69f;
+    float   rep_pen = 2.4f;
+    int32_t rep_win = 96;
+    std::mt19937 rng;
+
+    llama_seq_id seq_id = 0;
+    llama_pos    pos    = 0;
+    mtmd_helper_gen_audio_outtype out_type = MTMD_HELPER_GEN_AUDIO_OUTTYPE_WAV;
+    bool stream = false;
+
+    std::vector<float> h_state_buf;
+    std::vector<float> audio_pcm;
+    std::vector<char>  out_buf;
+    size_t pcm_sent        = 0;
+    bool   wav_header_sent = false;
+};
+
 static std::unique_ptr<mtmd_gen_audio_pipeline> make_pipeline(llama_context * lctx, mtmd_context * mctx) {
     switch (mtmd_gen_audio_get_info(mctx).type) {
         case MTMD_GEN_AUDIO_TYPE_QWEN3TTS:
@@ -2432,6 +2963,8 @@ static std::unique_ptr<mtmd_gen_audio_pipeline> make_pipeline(llama_context * lc
             return std::unique_ptr<mtmd_gen_audio_pipeline>(new chatterbox_gen_audio_pipeline(lctx, mctx));
         case MTMD_GEN_AUDIO_TYPE_DRAMA3:
             return std::unique_ptr<mtmd_gen_audio_pipeline>(new drama3_gen_audio_pipeline(lctx, mctx));
+        case MTMD_GEN_AUDIO_TYPE_ZONOS3:
+            return std::unique_ptr<mtmd_gen_audio_pipeline>(new zonos3_gen_audio_pipeline(lctx, mctx));
         default:
             return nullptr;
     }
@@ -2463,6 +2996,8 @@ struct mtmd_helper_gen_audio_inp mtmd_helper_gen_audio_inp_default(void) {
     inp.top_p    = 1.0f;
     inp.temp     = -1.0f; // model default
     inp.seed     = UINT32_MAX; // random
+    inp.speed    = 1.0f;
+    inp.loudness = 0.0f;
     inp.out_type = MTMD_HELPER_GEN_AUDIO_OUTTYPE_WAV;
     return inp;
 }
@@ -2476,6 +3011,14 @@ int32_t mtmd_helper_gen_audio_set_input(mtmd_helper_gen_audio * ctx, const mtmd_
         LOG_ERR("mtmd_helper_gen_audio: the pipeline cannot continue a sequence\n");
         return 1;
     }
+    if (!std::isfinite(inp->speed) || inp->speed <= 0.0f || !std::isfinite(inp->loudness) || inp->loudness > 0.0f) {
+        LOG_ERR("mtmd_helper_gen_audio: speed must be > 0 and loudness < 0 LUFS (0 = model default)\n");
+        return 2;
+    }
+    if ((inp->speed != 1.0f || inp->loudness != 0.0f) && !ctx->pipeline->supports_delivery()) {
+        LOG_ERR("mtmd_helper_gen_audio: the model does not support speed or loudness\n");
+        return 2;
+    }
     ctx->pipeline->logit_bias.clear();
     return ctx->pipeline->set_input(inp);
 }
diff --git a/tools/mtmd/mtmd-helper.h b/tools/mtmd/mtmd-helper.h
index 1bf3ee2ba..5ca414906 100644
--- a/tools/mtmd/mtmd-helper.h
+++ b/tools/mtmd/mtmd-helper.h
@@ -291,6 +291,10 @@ struct mtmd_helper_gen_audio_inp {
     float    temp; // audio frame temperature if the model supports it, < 0 = model default, 0 = greedy
     uint32_t seed; // UINT32_MAX for random (default: random)

+    // delivery (only for models that support it); other models reject non-default values
+    float speed;    // speaking speed relative to a typical pace, > 0 (default: 1.0 = the model's own pace)
+    float loudness; // target loudness in LUFS, < 0 (default: 0.0 = the model's own loudness)
+
     enum mtmd_helper_gen_audio_outtype out_type;
 };

@@ -304,6 +308,7 @@ MTMD_API void mtmd_helper_gen_audio_free(mtmd_helper_gen_audio * ctx);

 MTMD_API void mtmd_helper_gen_audio_reset(mtmd_helper_gen_audio * ctx);

+// returns 0 on success, 2 if the input is invalid for the model (e.g. unsupported speed or loudness), 1 on other errors
 MTMD_API int32_t mtmd_helper_gen_audio_set_input(
                         mtmd_helper_gen_audio * ctx,
                         const struct mtmd_helper_gen_audio_inp * inp);
diff --git a/tools/mtmd/mtmd-impl.h b/tools/mtmd/mtmd-impl.h
index e2157f767..0d287dcdd 100644
--- a/tools/mtmd/mtmd-impl.h
+++ b/tools/mtmd/mtmd-impl.h
@@ -106,6 +106,7 @@
 #define KEY_GEN_AUDIO_PROJ_TYPE    "clip.gen.audio.projector_type" // for models with mixed modalities
 // name of the weight variant, for settings that are not in the checkpoint
 #define KEY_GEN_AUDIO_VARIANT      "clip.gen.audio.model_variant"
+#define KEY_GEN_AUDIO_DEFAULT_VOICE "clip.gen.audio.default_voice"
 // sampling defaults of the generated audio, like general.sampling.* for text
 #define KEY_GEN_AUDIO_TEMP           "clip.gen.audio.sampling.temp"
 #define KEY_GEN_AUDIO_TOP_K          "clip.gen.audio.sampling.top_k"
@@ -572,6 +573,8 @@ enum projector_type {
     PROJECTOR_TYPE_SOPRANO,
     PROJECTOR_TYPE_LFM3A_GEN,
     PROJECTOR_TYPE_DRAMA3_GEN,
+    PROJECTOR_TYPE_ZONOS3_GEN,
+    PROJECTOR_TYPE_ZONOS3_SPKENC,
     PROJECTOR_TYPE_MUSE_GLIMMER,
     PROJECTOR_TYPE_SENSENOVA_U1,
     PROJECTOR_TYPE_COHERE2V,
@@ -660,6 +663,8 @@ static std::map<projector_type, std::string> PROJECTOR_TYPE_NAMES = {
     { PROJECTOR_TYPE_SOPRANO,           "soprano"},
     { PROJECTOR_TYPE_LFM3A_GEN,         "lfm3a_gen"},
     { PROJECTOR_TYPE_DRAMA3_GEN,        "drama3_gen"},
+    { PROJECTOR_TYPE_ZONOS3_GEN,        "zonos3_gen"},
+    { PROJECTOR_TYPE_ZONOS3_SPKENC,     "zonos3_spkenc"},
     { PROJECTOR_TYPE_MUSE_GLIMMER,      "muse-glimmer"},
     { PROJECTOR_TYPE_SENSENOVA_U1,      "sensenova_u1"},
     { PROJECTOR_TYPE_COHERE2V,          "cohere2v"},
diff --git a/tools/mtmd/mtmd-model.cpp b/tools/mtmd/mtmd-model.cpp
index c4e5bbd54..c4c52a4d5 100644
--- a/tools/mtmd/mtmd-model.cpp
+++ b/tools/mtmd/mtmd-model.cpp
@@ -214,6 +214,7 @@ struct mtmd_model_loader {
                 hparams.image_size = 0;
                 hparams.patch_size = 1;
                 get_string(KEY_GEN_AUDIO_VARIANT, hparams.gen_model_variant, false);
+                get_string(KEY_GEN_AUDIO_DEFAULT_VOICE, hparams.gen_default_voice, false);

             } else {
                 GGML_ASSERT(false && "unknown modality");
@@ -967,6 +968,15 @@ struct mtmd_model_loader {
                                 "%s: mimo_audio: %s must be > 0\n", __func__, KEY_A_LOCAL_GROUP_SIZE));
                         }
                     } break;
+                case PROJECTOR_TYPE_ZONOS3_SPKENC:
+                    {
+                        // kaldi fbank front-end
+                        hparams.audio_sample_rate = 16000;
+                        hparams.audio_n_fft       = 512;
+                        hparams.audio_window_len  = 400;
+                        hparams.audio_hop_len     = 160;
+                        hparams.warmup_audio_size = 1000;
+                    } break;
                 case PROJECTOR_TYPE_QWEN3TTS_SPKENC:
                     {
                         // ECAPA-TDNN speaker encoder, mel front-end uses the Slaney default (fmin=0, fmax=sr/2)
@@ -1032,6 +1042,10 @@ struct mtmd_model_loader {
                         // matches the reference decoder's sliding_window (speech_tokenizer/config.json)
                         hparams.wav_tfm_swa = 72;
                     } break;
+                case PROJECTOR_TYPE_ZONOS3_GEN:
+                    {
+                        hparams.audio_sample_rate = 44100;
+                    } break;
                 case PROJECTOR_TYPE_DRAMA3_GEN:
                     {
                         // Fish DAC decoder, codec.pth has no config
@@ -1479,6 +1493,7 @@ struct mtmd_model_loader {
         const bool has_standard_layers = (
             model.proj_type != PROJECTOR_TYPE_GEMMA3NV &&
             model.proj_type != PROJECTOR_TYPE_QWEN3TTS_SPKENC &&
+            model.proj_type != PROJECTOR_TYPE_ZONOS3_SPKENC &&
             model.proj_type != PROJECTOR_TYPE_POCKETTTS_GEN &&
             model.proj_type != PROJECTOR_TYPE_SOPRANO &&
             model.proj_type != PROJECTOR_TYPE_MUSE_SPARK_AUDIO &&
@@ -2574,6 +2589,118 @@ struct mtmd_model_loader {
                     // code2wav: RVQ codes -> raw PCM, lives in the same ctx as code_predictor
                     load_code2wav(true);
                 } break;
+            case PROJECTOR_TYPE_ZONOS3_GEN:
+                {
+                    model.gen_code_head_w     = get_tensor(mtmd_format(TN_A_GEN_CODE_HEAD,     "weight"));
+                    model.gen_code_out_embd_w = get_tensor(mtmd_format(TN_A_GEN_CODE_OUT_EMBD, "weight"));
+                    if (model.gen_code_out_embd_w->ne[1] % mtmd_graph_zonos3_gen::n_codebooks != 0 ||
+                            model.gen_code_head_w->ne[1] != model.gen_code_out_embd_w->ne[1]) {
+                        throw std::runtime_error(mtmd_format("%s: zonos3: unexpected codebook table shape\n", __func__));
+                    }
+
+                    // voice.<name>: the voice rows of the prompt
+                    for (int64_t i = 0; i < gguf_get_n_tensors(ctx_gguf.get()); ++i) {
+                        const char * name = gguf_get_tensor_name(ctx_gguf.get(), i);
+                        if (strncmp(name, "voice.", 6) != 0) {
+                            continue;
+                        }
+                        const ggml_tensor * t = ggml_get_tensor(ctx_meta.get(), name);
+                        if (t->type != GGML_TYPE_F32 || ggml_n_dims(t) > 2 || t->ne[0] != model.gen_code_out_embd_w->ne[0]) {
+                            throw std::runtime_error(mtmd_format("%s: invalid voice tensor %s\n", __func__, name));
+                        }
+                        auto & embd = model.gen_voices[name + 6].rows;
+                        embd.resize(ggml_nelements(t));
+                        fin.seekg(tensor_offset.at(name), std::ios::beg);
+                        if (!fin.read(reinterpret_cast<char *>(embd.data()), ggml_nbytes(t))) {
+                            throw std::runtime_error(mtmd_format("%s: failed to read %s\n", __func__, name));
+                        }
+                    }
+                    LOG_INF("%s: %zu predefined voices\n", __func__, model.gen_voices.size());
+
+                    // ZAC codec decoder
+                    auto & z = model.zonos3;
+                    const std::string p = "a.gen.wav";
+                    auto has = [&](const std::string & name) {
+                        return gguf_find_tensor(ctx_gguf.get(), name.c_str()) >= 0;
+                    };
+                    auto load_convnext = [&](const std::string & q) {
+                        mtmd_zonos3_wav::convnext b;
+                        b.dw_w   = get_tensor(q + ".dw.weight");
+                        b.dw_b   = get_tensor(q + ".dw.bias");
+                        b.norm_w = get_tensor(q + ".norm.weight");
+                        b.norm_b = get_tensor(q + ".norm.bias");
+                        b.pw1_w  = get_tensor(q + ".pw1.weight");
+                        b.pw1_b  = get_tensor(q + ".pw1.bias");
+                        b.pw2_w  = get_tensor(q + ".pw2.weight");
+                        b.pw2_b  = get_tensor(q + ".pw2.bias");
+                        return b;
+                    };
+                    z.sem_table = get_tensor(p + ".sem.table");
+                    z.sem_bias  = get_tensor(p + ".sem.bias");
+                    for (int i = 0; has(mtmd_format("%s.sem.%d.dw.weight", p.c_str(), i)); i++) {
+                        z.sem.push_back(load_convnext(p + ".sem." + std::to_string(i)));
+                    }
+                    z.sem_out_w = get_tensor(p + ".sem.out.weight");
+                    z.sem_out_b = get_tensor(p + ".sem.out.bias");
+                    z.res_table = get_tensor(p + ".res.table");
+                    z.res_bias  = get_tensor(p + ".res.bias");
+                    for (int s = 0; has(mtmd_format("%s.stage.%d.in.weight", p.c_str(), s)); s++) {
+                        const std::string q = p + ".stage." + std::to_string(s);
+                        mtmd_zonos3_wav::stage st;
+                        st.in_w  = get_tensor(q + ".in.weight");
+                        st.out_w = get_tensor(q + ".out.weight", false);
+                        for (int il = 0; has(mtmd_format("%s.blk.%d.qkv.weight", q.c_str(), il)); il++) {
+                            const std::string b = q + ".blk." + std::to_string(il);
+                            mtmd_zonos3_wav::layer l;
+                            l.norm1_w    = get_tensor(b + ".norm1.weight");
+                            l.norm1_b    = get_tensor(b + ".norm1.bias");
+                            l.qkv_w      = get_tensor(b + ".qkv.weight");
+                            l.wo_w       = get_tensor(b + ".wo.weight");
+                            l.attn_scale = get_tensor(b + ".attn_scale");
+                            l.norm2_w    = get_tensor(b + ".norm2.weight");
+                            l.norm2_b    = get_tensor(b + ".norm2.bias");
+                            l.ffn1_w     = get_tensor(b + ".ffn1.weight");
+                            l.ffn2_w     = get_tensor(b + ".ffn2.weight");
+                            l.ffn_scale  = get_tensor(b + ".ffn_scale");
+                            st.layers.push_back(l);
+                        }
+                        z.stages.push_back(std::move(st));
+                    }
+                    for (int i = 0; has(mtmd_format("%s.head.%d.dw.weight", p.c_str(), i)); i++) {
+                        z.head.push_back(load_convnext(p + ".head." + std::to_string(i)));
+                    }
+                    z.head_norm_w = get_tensor(p + ".head.norm.weight");
+                    z.head_norm_b = get_tensor(p + ".head.norm.bias");
+                    z.head_out_w  = get_tensor(p + ".head.out.weight");
+                    z.head_out_b  = get_tensor(p + ".head.out.bias");
+                    if (z.stages.size() != mtmd_graph_zonos3_gen::n_stages || z.sem.empty() || z.head.empty() ||
+                            z.head_out_w->ne[1] != mtmd_graph_zonos3_gen::n_fft[0] + mtmd_graph_zonos3_gen::n_fft[1] + 4) {
+                        throw std::runtime_error(mtmd_format("%s: zonos3: unexpected codec decoder layout\n", __func__));
+                    }
+
+                    // read on the host
+                    auto read_host = [&](const std::string & name, std::vector<float> & dst) -> const ggml_tensor * {
+                        const ggml_tensor * t = ggml_get_tensor(ctx_meta.get(), name.c_str());
+                        if (!t || t->type != GGML_TYPE_F32 || ggml_n_dims(t) != 2) {
+                            throw std::runtime_error(mtmd_format("%s: invalid or missing tensor %s\n", __func__, name.c_str()));
+                        }
+                        dst.resize(ggml_nelements(t));
+                        fin.seekg(tensor_offset.at(name), std::ios::beg);
+                        if (!fin.read(reinterpret_cast<char *>(dst.data()), ggml_nbytes(t))) {
+                            throw std::runtime_error(mtmd_format("%s: failed to read %s\n", __func__, name.c_str()));
+                        }
+                        return t;
+                    };
+                    const ggml_tensor * mixer = read_host(p + ".mixer.weight", z.mixer);
+                    const ggml_tensor * rs    = read_host(p + ".resample.weight", z.resample);
+                    if (mixer->ne[1] != 2 || rs->ne[1] != mtmd_graph_zonos3_gen::rs_new ||
+                            rs->ne[0] < mtmd_graph_zonos3_gen::rs_orig || (rs->ne[0] - mtmd_graph_zonos3_gen::rs_orig) % 2 != 0) {
+                        throw std::runtime_error(mtmd_format("%s: zonos3: unexpected mixer or resampler shape\n", __func__));
+                    }
+                    z.fir_kernel    = mixer->ne[0];
+                    z.rs_new        = rs->ne[1];
+                    z.rs_kernel_len = rs->ne[0];
+                } break;
             case PROJECTOR_TYPE_DRAMA3_GEN:
                 {
                     // generic a.gen.code.blk.* tensors are loaded by the loop above
@@ -2847,6 +2974,19 @@ struct mtmd_model_loader {
                         }
                     }
                 } break;
+            case PROJECTOR_TYPE_ZONOS3_SPKENC:
+                {
+                    // the graph reads the tensors by name
+                    for (int64_t i = 0; i < gguf_get_n_tensors(ctx_gguf.get()); ++i) {
+                        const char * name = gguf_get_tensor_name(ctx_gguf.get(), i);
+                        if (strncmp(name, "a.spk.", 6) == 0) {
+                            model.zonos3_spk[name] = get_tensor(name);
+                        }
+                    }
+                    if (!model.zonos3_spk.count("a.spk.proj.weight") || !model.zonos3_spk.count("a.spk.linear.weight")) {
+                        throw std::runtime_error(mtmd_format("%s: zonos3: missing speaker encoder tensors\n", __func__));
+                    }
+                } break;
             case PROJECTOR_TYPE_CHATTERBOX_SPKENC:
                 {
                     auto & c = model.cbx;
@@ -3756,6 +3896,10 @@ mtmd_models mtmd_model_load(const char * fname, const mtmd_model_params & params
         model->hparams.custom_image_max_tokens = params.image_max_tokens;
         loader.load_hparams(*model, modality);
         loader.load_tensors(*model, buft, params.no_alloc);
+        const std::string & def = model->hparams.gen_default_voice;
+        if (!def.empty() && model->gen_voices.find(def) == model->gen_voices.end()) {
+            throw std::runtime_error(mtmd_format("%s: default voice \"%s\" is not a predefined voice\n", __func__, def.c_str()));
+        }
         return model;
     };

@@ -4163,6 +4307,7 @@ int mtmd_model::n_output_tokens(const mtmd_image_f32 * img) const {
                 n_patches = ((img->nx() + ws - 1) / ws) * (ws / ds);
             } break;
         case PROJECTOR_TYPE_QWEN3TTS_SPKENC:
+        case PROJECTOR_TYPE_ZONOS3_SPKENC:
             {
                 // pooling gives one speaker embedding, whatever the clip length is
                 n_patches = 1;
@@ -4175,6 +4320,7 @@ int mtmd_model::n_output_tokens(const mtmd_image_f32 * img) const {
         case PROJECTOR_TYPE_QWEN3TTS_GEN:
         case PROJECTOR_TYPE_LFM3A_GEN:
         case PROJECTOR_TYPE_DRAMA3_GEN:
+        case PROJECTOR_TYPE_ZONOS3_GEN:
             {
                 // one hidden-state vector fed back to the talker per call
                 n_patches = 1;
@@ -4356,9 +4502,12 @@ int mtmd_model::n_mmproj_embd() const {
             return mm_2_w->ne[1];
         case PROJECTOR_TYPE_QWEN3TTS_SPKENC:
             return mm_fc_w->ne[2];
+        case PROJECTOR_TYPE_ZONOS3_SPKENC:
+            return zonos3_spk.at("a.spk.proj.weight")->ne[1];
         case PROJECTOR_TYPE_QWEN3TTS_GEN:
         case PROJECTOR_TYPE_LFM3A_GEN:
         case PROJECTOR_TYPE_DRAMA3_GEN:
+        case PROJECTOR_TYPE_ZONOS3_GEN:
             return gen_code_out_embd_w->ne[0];
         case PROJECTOR_TYPE_POCKETTTS_SPKENC:
             return spk_proj_w->ne[1];
diff --git a/tools/mtmd/mtmd-model.h b/tools/mtmd/mtmd-model.h
index b27b1c0d8..d04314d93 100644
--- a/tools/mtmd/mtmd-model.h
+++ b/tools/mtmd/mtmd-model.h
@@ -200,6 +200,7 @@ struct mtmd_hparams {

     // name of the weight variant, some pipelines tune themselves on it
     std::string gen_model_variant;
+    std::string gen_default_voice;

     // sampling defaults of the generated audio: greedy, no top-k, no repetition penalty
     float   gen_temp           = 0.0f;
@@ -869,6 +870,60 @@ struct mtmd_drama3_layer {
     ggml_tensor * out_norm_w = nullptr;
 };

+// zonos3 ZAC codec decoder (a.gen.wav.*)
+struct mtmd_zonos3_wav {
+    struct convnext {
+        ggml_tensor * dw_w   = nullptr; // [K, C]
+        ggml_tensor * dw_b   = nullptr;
+        ggml_tensor * norm_w = nullptr;
+        ggml_tensor * norm_b = nullptr;
+        ggml_tensor * pw1_w  = nullptr;
+        ggml_tensor * pw1_b  = nullptr;
+        ggml_tensor * pw2_w  = nullptr;
+        ggml_tensor * pw2_b  = nullptr;
+    };
+    struct layer {
+        ggml_tensor * norm1_w    = nullptr;
+        ggml_tensor * norm1_b    = nullptr;
+        ggml_tensor * qkv_w      = nullptr;
+        ggml_tensor * wo_w       = nullptr;
+        ggml_tensor * attn_scale = nullptr;
+        ggml_tensor * norm2_w    = nullptr;
+        ggml_tensor * norm2_b    = nullptr;
+        ggml_tensor * ffn1_w     = nullptr;
+        ggml_tensor * ffn2_w     = nullptr;
+        ggml_tensor * ffn_scale  = nullptr;
+    };
+    struct stage {
+        ggml_tensor * in_w  = nullptr;
+        ggml_tensor * out_w = nullptr; // nullptr in the last stage
+        std::vector<layer> layers;
+    };
+
+    ggml_tensor * sem_table = nullptr; // folded through its out_proj
+    ggml_tensor * sem_bias  = nullptr;
+    std::vector<convnext> sem;
+    ggml_tensor * sem_out_w = nullptr;
+    ggml_tensor * sem_out_b = nullptr;
+    ggml_tensor * res_table = nullptr; // merged
+    ggml_tensor * res_bias  = nullptr; // [latent, n_codebooks - 1]
+
+    std::vector<stage> stages;
+
+    std::vector<convnext> head;
+    ggml_tensor * head_norm_w = nullptr;
+    ggml_tensor * head_norm_b = nullptr;
+    ggml_tensor * head_out_w  = nullptr; // both iSTFT branches
+    ggml_tensor * head_out_b  = nullptr;
+
+    // read on the host
+    std::vector<float> mixer;    // [2, fir_kernel]
+    std::vector<float> resample; // [rs_new, rs_kernel_len]
+    int64_t fir_kernel    = 0;
+    int64_t rs_new        = 0;
+    int64_t rs_kernel_len = 0;
+};
+
 struct mtmd_vocos {
     ggml_tensor * input_w        = nullptr;
     ggml_tensor * input_b        = nullptr;
@@ -1148,6 +1203,7 @@ struct mtmd_model {
     // qwen3tts code2wav: RVQ codes -> raw PCM
     mtmd_code2wav c2w;
     std::vector<mtmd_drama3_layer> drama3_layers;
+    mtmd_zonos3_wav zonos3;

     // pocket-tts: SEANet stack, shared by the encoder (speaker path) and the decoder (gen path)
     mtmd_seanet seanet;
@@ -1172,6 +1228,7 @@ struct mtmd_model {

     // chatterbox tensors read on the host, see cbx_read_tensor()
     std::map<std::string, ggml_tensor *> cbx_tensors;
+    std::map<std::string, ggml_tensor *> zonos3_spk; // a.spk.*
     // predefined voices, no rows if the voice is named in the prompt (lfm3a)
     std::map<std::string, mtmd_gen_voice> gen_voices;

diff --git a/tools/mtmd/mtmd-subcontext.cpp b/tools/mtmd/mtmd-subcontext.cpp
index 798d875bb..ed8800fce 100644
--- a/tools/mtmd/mtmd-subcontext.cpp
+++ b/tools/mtmd/mtmd-subcontext.cpp
@@ -392,6 +392,7 @@ static std::vector<c2w_state_slot> list_gen_state_slots(const mtmd_hparams & hpa
         case PROJECTOR_TYPE_DRAMA3_GEN:    return list_drama3_state_slots(hparams, model);
         case PROJECTOR_TYPE_POCKETTTS_GEN: return list_pockettts_state_slots(hparams, model);
         case PROJECTOR_TYPE_LFM3A_GEN:     return list_lfm3a_gen_state_slots(hparams, model);
+        case PROJECTOR_TYPE_ZONOS3_GEN:    return list_zonos3_gen_state_slots(hparams, model);
         default:                           return {};
     }
 }
@@ -1676,6 +1677,7 @@ bool mtmd_subcontext::encode(mtmd_subctx_encode_params * params) {
         case PROJECTOR_TYPE_GEMMA4UA:
         case PROJECTOR_TYPE_MUSE_SPARK_AUDIO:
         case PROJECTOR_TYPE_QWEN3TTS_SPKENC:
+        case PROJECTOR_TYPE_ZONOS3_SPKENC:
             {
                 // do nothing
             } break;
@@ -1689,6 +1691,54 @@ bool mtmd_subcontext::encode(mtmd_subctx_encode_params * params) {
                 segfix[(size_t) S - 1] = 100.0f / (float) last;
                 set_input_f32("inp_segfix", segfix);
             } break;
+        case PROJECTOR_TYPE_ZONOS3_GEN:
+            if (params->gen_process == MTMD_SUBCTX_GEN_PROCESS_GEN_WAV) {
+                // frame-major codes -> codebook-major rows; a residual code of -1 is missing and left out
+                const int64_t n_cb     = mtmd_graph_zonos3_gen::n_codebooks;
+                const int64_t n_codes  = mtmd_graph_zonos3_gen::n_cb_vocab;
+                GGML_ASSERT(params->codes != nullptr && params->codes->size() % n_cb == 0);
+                const int64_t n_frames = (int64_t) params->codes->size() / n_cb;
+                std::vector<int32_t> rows(params->codes->size());
+                std::vector<float>   keep((size_t) n_frames * (n_cb - 1), 1.0f);
+                for (int64_t t = 0; t < n_frames; t++) {
+                    for (int64_t c = 0; c < n_cb; c++) {
+                        int32_t code = (*params->codes)[t * n_cb + c];
+                        if (code == -1 && c > 0) {
+                            keep[(c - 1) * n_frames + t] = 0.0f;
+                            code = 0;
+                        }
+                        if (code < 0 || code >= n_codes) {
+                            LOG_ERR("%s: code out of range (%d, codebook size %lld)\n", __func__, code, (long long) n_codes);
+                            return false;
+                        }
+                        rows[c * n_frames + t] = code + (int32_t) (c == 0 ? 0 : (c - 1) * n_codes);
+                    }
+                }
+                set_input_i32("inp_codes", rows);
+                set_input_f32("inp_keep", keep);
+                set_gen_state_in();
+            } else {
+                const int64_t n_cb    = mtmd_graph_zonos3_gen::n_codebooks;
+                const int64_t n_vocab = model.gen_code_out_embd_w->ne[1] / n_cb;
+                // no codes: logits only, the row of codes 0 is ignored
+                std::vector<int32_t> codes(n_cb, 0);
+                if (params->codes) {
+                    if (params->codes->size() != (size_t) n_cb) {
+                        LOG_ERR("%s: expected %lld codes, got %zu\n", __func__, (long long) n_cb, params->codes->size());
+                        return false;
+                    }
+                    codes = *params->codes;
+                }
+                for (int64_t i = 0; i < n_cb; i++) {
+                    if (codes[i] < 0 || codes[i] >= n_vocab) {
+                        LOG_ERR("%s: code out of range (%d, vocab %lld)\n", __func__, codes[i], (long long) n_vocab);
+                        return false;
+                    }
+                    codes[i] += (int32_t) (i * n_vocab);
+                }
+                set_input_i32("inp_codes", codes);
+            }
+            break;
         case PROJECTOR_TYPE_LFM3A_GEN:
             {
                 const int64_t n_cb    = model.gen_code_embd_w->ne[2];
diff --git a/tools/mtmd/mtmd.cpp b/tools/mtmd/mtmd.cpp
index 63a8e3954..0042aeb42 100644
--- a/tools/mtmd/mtmd.cpp
+++ b/tools/mtmd/mtmd.cpp
@@ -1286,6 +1286,10 @@ struct mtmd_context {
                 {
                     audio_preproc = std::make_unique<mtmd_audio_preprocessor_qwen3tts_spk>(models.a.get());
                 } break;
+            case PROJECTOR_TYPE_ZONOS3_SPKENC:
+                {
+                    audio_preproc = std::make_unique<mtmd_audio_preprocessor_zonos3_spk>(models.a.get());
+                } break;
             case PROJECTOR_TYPE_POCKETTTS_SPKENC:
                 {
                     audio_preproc = std::make_unique<mtmd_audio_preprocessor_pockettts>(models.a.get());
@@ -2462,6 +2466,10 @@ mtmd_gen_audio_info mtmd_gen_audio_get_info(const mtmd_context * ctx) {
             info.type = MTMD_GEN_AUDIO_TYPE_DRAMA3;
             info.sample_rate = 44100;
             break;
+        case PROJECTOR_TYPE_ZONOS3_GEN:
+            info.type = MTMD_GEN_AUDIO_TYPE_ZONOS3;
+            info.sample_rate = 44100;
+            break;
         default:
             info.type = MTMD_GEN_AUDIO_TYPE_NONE;
             break;
@@ -2822,8 +2830,23 @@ const char * mtmd_gen_audio_voice_name(const mtmd_context * ctx, int32_t idx) {
     if (!ctx->ctx_gen_a || idx < 0) {
         return nullptr;
     }
-    const auto & voices = ctx->ctx_gen_a->model.gen_voices;
-    return idx < (int32_t) voices.size() ? std::next(voices.begin(), idx)->first.c_str() : nullptr;
+    const auto & model = ctx->ctx_gen_a->model;
+    const auto & voices = model.gen_voices;
+    if (idx >= (int32_t) voices.size()) {
+        return nullptr;
+    }
+    // the default voice goes first, the others keep their order
+    const auto def = voices.find(model.hparams.gen_default_voice);
+    if (def != voices.end() && idx == 0) {
+        return def->first.c_str();
+    }
+    int32_t i = def != voices.end() ? 1 : 0;
+    for (auto it = voices.begin(); it != voices.end(); ++it) {
+        if (it != def && i++ == idx) {
+            return it->first.c_str();
+        }
+    }
+    return nullptr;
 }

 mtmd_bitmap * mtmd_gen_audio_get_voice(const mtmd_context * ctx, const char * name) {
@@ -2871,6 +2894,14 @@ mtmd_gen_inp mtmd_gen_inp_default(const mtmd_context * ctx) {
             inp.top_p = 0.9f;
             inp.temp  = 1.0f;
             break;
+        case PROJECTOR_TYPE_ZONOS3_GEN:
+            // sampled in the helper, defaults of registries/generation.json
+            inp.top_k          = 80;
+            inp.top_p          = 0.69f;
+            inp.temp           = 0.75f;
+            inp.penalty_repeat = 2.4f;
+            inp.penalty_last_n = 96;
+            break;
         case PROJECTOR_TYPE_LFM3A_GEN:
             {
                 // MaskGIT sampling runs in the helper, with the settings of the checkpoint
@@ -2902,6 +2933,9 @@ struct mtmd_gen_state {
     // lfm3a: the codec graph yields STFT frames, the ISTFT runs on the host
     std::unique_ptr<mtmd_audio_streaming_istft> istft;

+    // zonos3: the iSTFT head runs on the host
+    std::unique_ptr<mtmd_audio_zonos3_synth> zonos3_synth;
+
     ~mtmd_gen_state() {
         delete gen_state;
     }
@@ -2941,6 +2975,60 @@ void mtmd_gen_state_reset(mtmd_gen_state * state) {
         const auto * hparams = &state->sctx->model.hparams;
         state->istft = std::make_unique<mtmd_audio_streaming_istft>(hparams->audio_n_fft, hparams->audio_hop_len);
     }
+    state->zonos3_synth.reset();
+}
+
+// zonos3 GEN_WAV: n_codes == 0 flushes and resets the state
+static int32_t mtmd_gen_wav_zonos3(mtmd_context * ctx, mtmd_gen_state * state, const mtmd_gen_inp * inp, mtmd_gen_out * out) {
+    mtmd_subcontext * sctx = ctx->ctx_gen_a.get();
+    const auto & z = sctx->model.zonos3;
+    using g = mtmd_graph_zonos3_gen;
+
+    if (!state->zonos3_synth) {
+        state->zonos3_synth = std::make_unique<mtmd_audio_zonos3_synth>(z.mixer, z.resample, g::n_fft[0], g::n_fft[1], g::hop, g::rs_orig, g::rs_new);
+    }
+    auto & audio = state->out_audio;
+    audio.clear();
+
+    if (!inp->codes || inp->n_codes == 0) {
+        state->zonos3_synth->flush(audio);
+        mtmd_gen_state_reset(state);
+    } else {
+        if (inp->n_codes % g::n_codebooks != 0) {
+            LOG_ERR("%s: expected whole frames of %d codes\n", __func__, g::n_codebooks);
+            return 1;
+        }
+        std::vector<int32_t> in_codes(inp->codes, inp->codes + inp->n_codes);
+        std::vector<float>   spec;
+
+        // no hidden-state input, the batch entry is an unused placeholder
+        mtmd_image_f32 dummy;
+        dummy.set_size({1, 1}, false, true);
+        dummy.cpy_buf(std::vector<float>(1, 0.0f));
+
+        mtmd_image_f32_batch batch;
+        batch.is_audio = true;
+        batch.entries.push_back(std::move(dummy));
+
+        mtmd_subctx_encode_params params;
+        params.imgs        = &batch;
+        params.n_threads   = ctx->n_threads;
+        params.gen_process = MTMD_SUBCTX_GEN_PROCESS_GEN_WAV;
+        params.codes       = &in_codes;
+        params.out_feats   = &spec;
+        params.gen_state   = state->gen_state;
+        if (!sctx->encode(&params)) {
+            LOG_ERR("%s: encode failed (codec)\n", __func__);
+            return 1;
+        }
+        const int64_t n_rows = (g::n_fft[0] + 2) + (g::n_fft[1] + 2);
+        GGML_ASSERT(spec.size() % (size_t) n_rows == 0);
+        state->zonos3_synth->push(spec.data(), (int64_t) (spec.size() / n_rows), audio);
+    }
+
+    out->audio     = audio.data();
+    out->n_samples = audio.size();
+    return 0;
 }

 // lfm3a GEN_WAV: ISTFT of the codec output, n_codes == 0 flushes and resets the state
@@ -3042,6 +3130,9 @@ static int32_t mtmd_gen_audio_process_impl(mtmd_context * ctx, mtmd_gen_state *
     if (inp->type == MTMD_GEN_PROCESS_TYPE_GEN_WAV && sctx->model.proj_type == PROJECTOR_TYPE_LFM3A_GEN) {
         return mtmd_gen_wav_lfm3a(ctx, state, inp, out);
     }
+    if (inp->type == MTMD_GEN_PROCESS_TYPE_GEN_WAV && sctx->model.proj_type == PROJECTOR_TYPE_ZONOS3_GEN) {
+        return mtmd_gen_wav_zonos3(ctx, state, inp, out);
+    }

     if (inp->type == MTMD_GEN_PROCESS_TYPE_GEN_CODE) {
         const size_t n_embd = (size_t) sctx->model.n_mmproj_embd();
diff --git a/tools/mtmd/mtmd.h b/tools/mtmd/mtmd.h
index e14f345a6..b0d673dbe 100644
--- a/tools/mtmd/mtmd.h
+++ b/tools/mtmd/mtmd.h
@@ -472,6 +472,7 @@ enum mtmd_gen_audio_type {
     MTMD_GEN_AUDIO_TYPE_LFM3A,
     MTMD_GEN_AUDIO_TYPE_CHATTERBOX,
     MTMD_GEN_AUDIO_TYPE_DRAMA3,
+    MTMD_GEN_AUDIO_TYPE_ZONOS3,
 };

 struct mtmd_gen_audio_info {
@@ -485,7 +486,7 @@ MTMD_API struct mtmd_gen_audio_info mtmd_gen_audio_get_info(const mtmd_context *

 // read a named tensor of the gen audio model as F32, returns the element count (0 if not found), out may be null to query the size
 MTMD_API size_t mtmd_gen_audio_read_tensor(mtmd_context * ctx, const char * name, float * out, size_t n_max);
-// name of the idx-th predefined voice, nullptr when idx is out of range
+// name of the idx-th predefined voice, nullptr when idx is out of range; idx 0 is the default voice
 MTMD_API const char * mtmd_gen_audio_voice_name(const mtmd_context * ctx, int32_t idx);

 // predefined voice with this name as a voice bitmap, to pass as speaker reference
@@ -516,6 +517,7 @@ struct mtmd_gen_inp {
     // for MTMD_GEN_PROCESS_TYPE_GEN_WAV
     // pass either codes (discrete) or feats (continuous), depending on the pipeline
     // lfm3a GEN_CODE also takes codes: the codes revealed so far, -1 for a masked codebook
+    // zonos3 GEN_WAV: -1 for a residual code not known yet, the frame is decoded without it
     int32_t * codes;
     size_t    n_codes;
     const float * feats;
diff --git a/tools/server/README.md b/tools/server/README.md
index 753530a86..13d41d884 100644
--- a/tools/server/README.md
+++ b/tools/server/README.md
@@ -874,6 +874,10 @@ Returns raw audio bytes (`audio/wav` by default) rather than JSON. For the list

 `seed`: RNG seed for both the backbone sampler and the codec/vocoder (`-1` = random, the default). Set to a fixed value for reproducible output.

+`speed`: Speaking speed relative to a typical pace, `> 0` (default `1.0`, the model's own pace). Only for models that support it, others reject a value other than `1.0` with a 400 error.
+
+`loudness`: Target loudness in LUFS, `< 0` (default `0.0`, the model's own loudness). Only for models that support it, others reject a value other than `0.0` with a 400 error.
+
 `n_predict`: Max number of audio frames to generate. Defaults to `512`; generation normally stops earlier once the model emits an end-of-speech token.

 `response_format`: `wav` (default), `pcm` (raw `float32` little-endian mono samples, no header; the response `Content-Type` carries the sample rate, e.g. `audio/pcm;rate=24000;encoding=float;bits=32`) or `pcm16` (same, as signed 16-bit samples, e.g. `audio/pcm;rate=24000;encoding=signed-int;bits=16`).
@@ -1668,7 +1672,7 @@ Generates speech from text, for models that can generate audio. The request is c

 `stream_format`: `audio` streams the response as it becomes available, see `stream` in `/tts` for the WAV header it writes. `sse` is rejected. The response is not streamed when omitted.

-`speed`: Only `1.0` is accepted.
+`speed`: Speaking speed, see `speed` in `/tts`. Only models that support it accept a value other than `1.0`. `loudness` of `/tts` can be used as well.

 `instructions`: Ignored.

diff --git a/tools/server/server-chat.cpp b/tools/server/server-chat.cpp
index 4e4622baf..af1e42dbd 100644
--- a/tools/server/server-chat.cpp
+++ b/tools/server/server-chat.cpp
@@ -799,10 +799,6 @@ json convert_speech_to_tts(const json & inp_body) {
         throw std::invalid_argument("Only 'audio' stream_format is supported for speech");
     }

-    if (json_value(inp_body, "speed", 1.0) != 1.0) {
-        throw std::invalid_argument("'speed' is not supported for speech");
-    }
-
     if (!json_value(inp_body, "instructions", std::string()).empty()) {
         SRV_WRN("%s", "'instructions' is not supported for speech and is ignored\n");
     }
diff --git a/tools/server/server-context.cpp b/tools/server/server-context.cpp
index 7c2c35c35..28e01c69b 100644
--- a/tools/server/server-context.cpp
+++ b/tools/server/server-context.cpp
@@ -1951,7 +1951,12 @@ private:
             // mtmd_helper always re-eval the whole prompt
             slot.prompt_clear();
             task.tts_inp.data.seq_id = slot.id;
-            if (slot.tts.ctx.set_input(task.tts_inp.get()) != 0) {
+            const int32_t ret = slot.tts.ctx.set_input(task.tts_inp.get());
+            if (ret == 2) {
+                send_error(task, "invalid TTS input: the model may not support \"speed\" or \"loudness\", see the server log", ERROR_TYPE_INVALID_REQUEST);
+                return false;
+            }
+            if (ret != 0) {
                 send_error(task, "failed to process TTS prompt", ERROR_TYPE_SERVER);
                 return false;
             }
@@ -5650,7 +5655,7 @@ static json parse_tts_body(const server_http_req & req) {
             body[k] = std::stoll(body[k].get<std::string>());
         }
     }
-    for (const char * k : { "top_p", "temperature", "repeat_penalty", "speed" }) {
+    for (const char * k : { "top_p", "temperature", "repeat_penalty", "speed", "loudness" }) {
         if (body.contains(k) && body[k].is_string()) {
             body[k] = std::stof(body[k].get<std::string>());
         }
diff --git a/tools/server/server-schema.cpp b/tools/server/server-schema.cpp
index 84e872f21..e72617d6d 100644
--- a/tools/server/server-schema.cpp
+++ b/tools/server/server-schema.cpp
@@ -555,6 +555,12 @@ std::vector<std::unique_ptr<field>> make_tts_schema(server_task & task) {
     add((new field_num("seed", task.tts_inp.data.seed))
         ->set_desc("RNG seed for the backbone sampler and the codec/vocoder (-1 = random)"));

+    add((new field_num("speed", task.tts_inp.data.speed))
+        ->set_desc("Speaking speed relative to a typical pace, for models that support it (1.0 = model default)"));
+
+    add((new field_num("loudness", task.tts_inp.data.loudness))
+        ->set_desc("Target loudness in LUFS, for models that support it (0.0 = model default)"));
+
     add((new field_num("n_predict", task.params.n_predict))
         ->set_hard_limits(-1, INT32_MAX)
         ->add_alias("max_tokens")