(Planning) Support audio output in mtmd #21956
Description
This feature has been discussed multiple times but until now that I have the time to summarize some design choices.
Currently, there are some problems when bringing audio generation support into llama.cpp / mtmd:
- Model file: Audio generation usually requires a dedicated "audio detokenizer" model. For example, sesame CSM and LFM (first version) uses Mimi, the LFM2 uses a normal text generation model as audio tokenizer, and nvidia's chatterbox uses S3Gen (token2wav)
- Data passing: Some models like the qwen-omni family or sesame CSM requires passing the embedding vectors (float values) between LLM and audio generation model, instead of passing raw token IDs (int values)
- Generation state: We need a way to mark the start and end of speech data. For example, a model may generates:
<text>hello<audio><523><5><84>...<text>world<audio>..., that means the token IDs between<audio>and<text>markers should be piped into mtmd to generate audio wav output.
Design choices
1. Model file
Probably the best way we can do for now is:
- If the audio detokenizer model is a normal causal-style decoder transformer, we can make it a normal text model (this is the case for LFM-Audio). mtmd will need to be extended to support managing this auxiliary model
- If the audio detokenizer model is fundamentally different. For example, being a convolution based model, we can fully implement it in libmtmd
Alternately to the solution (1), we can also implement a mechanism such that mmproj and a text model can co-exist inside a GGUF. But I think it's better to be a future improvement.
2. Data passing
While many models simply passing the token ID between the main LLM and the audio generation model, I think it's more flexible to always pass the data as embeddings.
The implication is that text model will hold 2 set of embeddings: one for normal text model, and another to be used by audio detokenizer
The pseudo-code looks like this:
llama_token sampled_token = llama_sampling_sample(...); // llama_token is an integer
if (mtmd_token_is_audio(sampled_token)) {
float * embeddings = llama_get_audio_embeddings(ctx, sampled_token);
mtmd_add_audio_embeddings(mctx, embeddings);
}3. Generation state
Making an API for managing generation state (i.e. knowing that the model is currently generating text or something else) can be quite hard to get right. Therefore, we will try to make the public API as "clean" as possible in the first iteration.
A proposal can be:
mtmd_gen_stateobject, which is an opaque pointermtmd_gen_state_track(state, generated_token)add a new generated token to tracking statemtmd_gen_state_get(state)get the current state, return an enum: TEXT, AUDIO, ...
The underlay logic can vary from model to model
Activity
akarale-nv commentedon Apr 30, 2026
Thanks @ngxson for kicking off the planning here. We're working on bringing Chatterbox TTS (Resemble AI) into llama.cpp and have been prototyping against this exact design space. We wanted to share a few concrete extensions for discussion.
TL;DR — Addition to MTMD API surface
The public additions to
libmtmdare grouped into 4 clusters.Capability
Per-request generation state — owns the audio accumulator and the TEXT↔AUDIO state. One per concurrent inference; never shared.
Paired llama.cpp additions for the embedding-input case — looking up the model's per-token audio-embedding row (Mimi-aware LMs ship a learned audio-embedding table; the lookup belongs in
llama.cppsince that's where the model lives):Decode helpers — drive
decode_aanddecode_postprocend-to-end. They take a per-requestgen_state(carries the accumulator) and the sharedmtmd_context(carries the loaded decoders). The reference audio for speaker conditioning is passed directly as amtmd_audio_buffer *— no chunk-container indirection, no name routing.ref_wavis optional. Non-NULL on the first decode of a session populates the decoder's"ref_wav"slot; passNULLthereafter (or always, for arches without that slot). Mismatches —ref_wavto a decoder without the slot, orNULLto a decoder that requires it with nothing previously populated — returnfalsewith a verbose log.Per-arch encoder→decoder wiring lives inside the helper. Some detokenizers consume outputs from the loaded encoder beyond what the LM produces. S3Gen, for instance, needs
ref_speech_tokens— the reference audio's speech tokens, generated byclip_a's S3TokenizerV2 — pushed into itsref_speech_tokensinput. These aren't on the LM's autoregressive path, so they don't flow through the gen_state accumulator.mtmd_helper_decode_audiocarries a small arch-dispatched table (switch (decoder_get_type(dctx))) that pulls each required output out ofclip_a(viaclip_get_output(name)) and pushes it into the decoder (viadecoder_set_input(name, …)). Arches without such requirements (Mimi-style detokenizers, plain vocoders) fall through the default branch and consume only the accumulator +ref_wav.Typed audio container — encoder ref audio, decoder output, postproc in-place buffer, and save-to-file all share one type (see §6).
That is the entire public surface. Together these form the minimum needed to run a Chatterbox-style pipeline (LM → accumulator → S3Gen → Perth → wav) entirely through
mtmd_helper_*, with no caller-side knowledge of which detokenizer or postprocessor is loaded. The internaldecoder_*layer that the helpers call into is in §4.User's hot path
End-to-end usage of the public surface:
Chatterbox pipeline → mtmd mapping
How each stage of the Chatterbox pipeline lands in llama.cpp / mtmd. The first column is the model component, the second is what we propose to implement it as, the third is what's new vs. existing.
File layout — two GGUFs, mirroring how llama.cpp already ships LM + MMProj for vision/audio models:
chatterbox.gguf— the LM file. Holds T3's weights, hparams, vocab, and the BPE tokenizer (with the audio-cue special tokens). Loaded viallama_model_load_from_file, drives the LM through the existingllama_context.chatterbox-mmproj.gguf— the multimodal projection / detokenizer file. Loaded viamtmd_init_from_fileand routed by GGUF key namespace:clip_a(audio encoder ctx). GGUF keys underclip.audio.*.decode_a(detokenizer slot). GGUF keys underdecoder.detokenizer.*.decode_postproc(postproc slot). GGUF keys underdecoder.postproc.*.Splitting this way matches the existing convention (LMs and projection models ship separately, can be quantized independently) and lets users swap the watermarker or the vocoder by replacing only the mmproj file.
convert_hf_to_gguf.pyextensionsllama_tokenizeclip_acontextllama_contextdecoder_ctx(decode_a)ref_wavdecoder_ctx(decode_postproc)mtmd_audio_buffermtmd_bitmap's audio roleBold rows are the additions this proposal motivates. Everything else either exists already or is a small, localized add (converter entries, DSP utility) that's not part of the mtmd-side design discussion.
Implementation Details
1. MMProj metadata for decoder routing.
Suggest pinning down concrete GGUF keys so loaders can peek and route without arch sniffing. Presence of
decoder.<ns>.archis the source of truth — no parallelhas_*bool flags to keep in sync:Loader API:
The
postprocnamespace gives watermarkers / loudness-restorers a clean home without overloading the detokenizer slot. The arch's slot names, dtypes,modality, andinput_kindare not stored in GGUF — they live in the arch's C++ source, sincebuild_graphis the real authority on what a given arch consumes and produces. See §3 for the staging model.2.
mtmd_contextadditions — and what doesn't live there.The per-request audio token/embedding accumulator and the TEXT↔AUDIO state machine do not live on
mtmd_context— they live on the caller-ownedmtmd_gen_state. A server with N concurrent inferences allocates Nmtmd_gen_states sharing onemtmd_context. Without this split, two concurrent requests would step on each other's accumulators or state transitions.Stateful detokenizers (S3Gen's flow-matching session state) get their own per-request scratch held inside
mtmd_gen_staterather thandecoder_ctx— same reason. Thedecoder_ctxitself owns weights + scheduler, both of which are reentrant-safe under the assumption that runtime tensors come from the per-request scratch passed in viadecoder_set_input.Postproc (
decode_postproc) is stateless by construction —mtmd_helper_decode_postprocis in-place over a caller-supplied audio buffer with no accumulator — so it doesn't need a per-request slot.3. A generic
decoder_ctx, parallel toclip_ctx. (internal to libmtmd; not on the public API)Chatterbox needs two non-transformer post-LM modules: S3Gen (token→waveform) and a Perth watermarker (waveform→watermarked waveform). Implementing each "directly in libmtmd" works for one, but two starts to look like the same lifecycle/scheduler/backend boilerplate twice. We'd suggest a sibling context type — used only by
mtmd_helper_*and the per-arch model implementations, never by callers:with a polymorphic
decoder_modelbase (load_hparams/load_weights/build_graph, status-returning where applicable) and per-arch subclasses (s3gen_model : decoder_model,perth_model : decoder_model).decoder_model— overview.The shape mirrors
clip_modelbut is reversed in intent: aclip_modelproduces embeddings, adecoder_modelconsumes them and produces audio. It owns everything arch-specific sodecoder_ctxcan stay arch-blind.The arch's
build_graphis the single source of truth for slot names.decoder_set_input(name, data, shape, type)just stages the bytes under that name on the ctx;build_graphlater picks them up by name when constructing input tensors, and tags output tensors by name so they can be read back.ggml_tensor *is per-decode, never long-lived on the ctx.decoder_set_inputdoesn't allocate aggml_tensor *— those exist only insidebuild_graph→compute, bound to the per-decodeggml_contextand backend buffers allocated by the scheduler. The ctx carries host-side byte buffers that get memcpy'd to/from the device tensors around each compute.Init order is just
load_hparams → load_weights → backend + scheduler setup.Concrete subclasses supply their own hparams + weights structs and override the three virtuals. Nothing about S3Gen leaks into the base, and nothing about the base assumes an arch:
build_graphreads staged inputs by name and tags outputs:perth_modelfollows the same pattern with its own (much smaller) hparams + weights and the same three overrides; itsbuild_graphreadsaudio_inand tagsaudio_out.The slot names are part of each arch's public contract — documented in the arch source.
mtmd_helper_*functions (the only callers expected to populate the staging maps) know what to set. End users and the LM loop never touch these names directly; they go through the helper API in the TL;DR above. For S3Gen:speech_tokens[T]ref_speech_tokens[T_ref]ref_wav[N_samples_ref]noise[80, T]audio[N_samples]Adding Vocos or SoundStorm later is a new subclass whose
build_graphreads its own input names and tags its own outputs.decoder_ctx,mtmd_helper_*, and the public API are untouched.4. Internal
decoder_*API surface. (internal to libmtmd; consumed only bymtmd_helper_*impls and unit tests)There's no introspection API — slot names live in the arch source; tools that want to know "what does this arch expect" read it from there. After at least one decode has run,
ctx->staged_inputsandctx->output_buffersgive a runtime answer to "what slots have been used."5.
clip_ctxextension for multi-tensor encoder outputs. (internal to libmtmd; existing single-embeddingclip_*_encodecallers see no change)Today's
clip_*_encode()returns a single embedding. That breaks for Chatterbox's reference voice encoder, which produces multiple heterogeneous outputs (speech tokens consumed by both T3's cond prefix and S3Gen asref_speech_tokens; speaker embedding consumed by T3) — different dtypes, shapes, and consumers. We propose mirroring the decoder-side staging model: the clip arch'sbuild_graphtags each output viaggml_set_name+ggml_set_output; afterclip_*_encoderuns, the named outputs are read back into aclip_ctx::output_buffersmap (samestaged_ioshape as §3). Callers fetch by name:Single-output encoders keep working through
clip_*_encodeunchanged — they tag exactly one output, andclip_get_output(ctx, "embedding", …)is equivalent to today's behavior. Multi-output encoders just tag more outputs inbuild_graph.6.
mtmd_audio_buffer— typed audio container, replacing the audio role ofmtmd_bitmap.mtmd_bitmap's audio path is a fiction:nx × 1shape, no sample rate,datareinterpreted as float. We propose a purpose-builtmtmd_audio_buffer(PCM samples + sample rate + id) used everywhere audio shows up — encoder ref input, decoder output, postproc, save-to-file. Public API in TL;DR.mtmd_input_chunks's audio variant switches tomtmd_audio_buffer *; the image variant keepsmtmd_bitmap *.mtmd_bitmap_init_from_audio/_is_audio/ theis_audiofield deprecate for one release, then go.The constructor's
samples == NULLbranch zero-allocatesn_samplesfloats so the helper can fill the buffer in place — same pattern asmtmd_bitmap_inittoday.Encoder-side reuse (follow-up, not in this PR). The same type can replace
mtmd_bitmapon the encoder's audio path.clip_audio_encodewould takeconst mtmd_audio_buffer *directly instead of readingnxasn_samplesand castingdatatofloat *; the encoder gains a realsample_ratefield for free. Vision callers stay onmtmd_bitmap, audio callers move, and theis_audiodiscriminator goes away. Migration is mechanical (get_data + cast → _data,get_nx → _n_samples) and worth doing in a follow-up PR after the TTS work lands.ServeurpersoCom commentedon May 18, 2026
Hey @ngxson, dropping a note since this is the area I want to contribute to next. I work on audio in GGML and have been studying vocoder math and optimizing the inference path through acestep, omnivoice and qwentts. Two GGML primitives consistently dominate the hot path of every BigVGAN and SEANet-style decoder (Mimi included, since SEANet is its backbone): the Snake activation y = x + sin(a*x)^2 * inv_b fused from its 5 op decomposition, and transposed 1D conv reformulated as matmul + GGML_OP_COL2IM_1D, the latter being the bigger of the two when left unoptimized.
Status on llama.cpp: Snake CUDA merged via #22667, Snake Vulkan #22855 reviewed and waiting on a second approval, Snake Metal done on my fork with the PR coming next, COL2IM_1D done and validated CPU + CUDA + Vulkan + Metal on my fork with one PR per backend incoming. End to end on a real BigVGAN-like decoder, Snake fusion alone is +42.7% CUDA and +24.8% Vulkan wall clock with the optimized COL2IM_1D already in place.
The three projects below have been picked up by a broad enough range of users to validate hardware compatibility and surface edge cases on older Ampere / Turing GPUs:
https://github.com/ServeurpersoCom/acestep.cpp (music, ACE-Step)
https://github.com/ServeurpersoCom/omnivoice.cpp (TTS/voice clone)
https://github.com/ServeurpersoCom/qwentts.cpp (TTS/voice clone)
I would probably start with Qwen Omni and work my way down the layers toward a POC on llama-ui. Plenty of work ahead :)
ngxson commentedon May 21, 2026
@akarale-nv Firstly, I want to remind that the project's policy doesn't allow fully using AI to write messages or comments. Your message's content is densely packed and it took me quite a lot of time to read.
I kindly request to only have human-to-human communication (no AI-written messages) in this thread to avoid further mental strain.
For your proposal, I have some questions:
mtmd_gguf_peek_decoderat the beginning and then never mention about it again, is it still relevant?mtmd_gen_state_push_audio_embeddings/tokensdo under the hood. Can you explain futher?Review about your proposal:
I feel likeactually we don't need that, see my code belowmtmd_gen_stateis suddenly too complicated, we are introducing 7 calls at once here, things like_getand_transitionedcan be collapsed into one call that returns astruct mtmd_gen_state_datamtmd_gen_statea C++ only interface for now, ideally in a dedicated filemtmd-gen-context.cppand we will expose C interface later.contextand thestatewill now be an enum.I propose a new pseudo-code as below:
Let me know if the version above looks good to you.
I would like to finalize the discussion about the interface above first, then move on to other points later.
ngxson commentedon May 21, 2026
@akarale-nv on second thought, a C++ only API is a bit messy, I will see how we can have a better API
I'm working on a demo with LFM audio model, will share when it's ready. we can then extend from such API to support chatterbox
akarale-nv commentedon May 22, 2026
Hi @ngxson,
Thank you for your feedback.
I would like to note that although, the generated text may be AI-assisted, the core proposal and its design is thoroughly human-made and human-reviewed. However, I will keep this in mind going forward.
Coming to your query about the mtmd_gen_state design, the current design is split into 3 parts:
Now, if you look at the "State Tracking" section, it is close to what you had originally proposed as well as the C++ interface you have explained above. However, as you mentioned we can still optimize and remove the "mtmd_gen_state_transitioned()" API and instead just rely of the "mtmd_gen_state_get()" to achieve the same results.
It would be helpful if you can share an early prototype or a design for this so that we can try it out on our end as well.
ServeurpersoCom commentedon May 27, 2026
I created a first hacky POC...
llama-ui-TTS.mp4
ngxson commentedon May 27, 2026
Hey @akarale-nv , I drafted a non-working PoC on how the API should look like: master...ngxson:llama.cpp:xsn/mtmd_gen_lfm
My implementation still missing the depthformer component to make it work, but even without that, I think I can now safely define a list of goals that I want to achieve in the first iteration:
llama-server, so I'd recommend thinking twice before adding a new API call. (Hint: in many cases, libmtmd can call directly functions in libllama, so we should make sure user code has the least extra calls to libllama)mtmd_gen_vocodercan manage multiple sub-contexts if it needs to, that includes both llama-context and mtmd-context (one-to-many relationship)mtmd_gen_vocodercan be used to create multiplemtmd_gen_context--> HOWEVER, for now, let's just make it simple by allow one vocoder per contextmtmd_gen_vocoder, I already did an example ofmtmd_gen_vocoder_lfm2ain my codeFor the next step, I invite you to review my PoC above, and let me know if you think there are anything missing (in terms of API design). Thanks!
@ServeurpersoCom appreciated if you can take a look too. Since you already had the PoC, I think it's only matter of agreeing on a good architecture now, before we can upstream your implementation
ServeurpersoCom commentedon May 27, 2026
I'll examine your PoC carefully and we'll converge toward a clean implementation!
Danmoreng commentedon May 27, 2026
@ServeurpersoCom Nice to see work on this issue. I have a - fully vibecoded - working gguf based qwen3tts implementation, not sure if useful, but maybe doesn’t hurt to have a look? https://github.com/Danmoreng/qwen3-tts.cpp
ServeurpersoCom commentedon May 28, 2026
I'm going to look at how you controlled the AI and the architecture he created out of curiosity. Which model did you use? I use Opus 4.7 from its website with MCP agentic loops that connect to my rootless Podman server with a shared GPU. I made one too, but it's faster (1.7x in BF16) than the Python version of an already accelerated fork (FasterQwenTTS) on CUDA!
Danmoreng commentedon May 28, 2026
Faster that the FasterQwenTTS sounds great. Didn't manage to get it quite that fast. I used Codex CLI for this. The architecture is most likely sub-optimal, since I let the AI do basically everything.
ServeurpersoCom commentedon May 28, 2026
This is a bit off-topic; it would almost require a dedicated thread for AI coding: You have to read every single change, create highly targeted LLM contexts to ensure you get only the benefits and not the slop, and completely control the architecture. If even one slop creeps into the project, it contaminates the entire session, You should always clear the context before continuing. it's like training an animal! LLM can even falsify benchmarks! Everything must be programmatic and in git, otherwise it won't hesitate to lie. And you always have to be explicit, especially about the stupidest things. LLM is a gifted child.
akarale-nv commentedon Jun 10, 2026
Hi @ngxson, thanks for the prototype. I now have a clearer picture of the LFM2 audio needs. Overall, our design seems to be aligning on some parts. Here are some of the parts where I think we need a bit more re-design.
Orchestration loop:
For LFM, the loop looks to be
Backbone->Vocoder->Backbone->…and so on.While for Chatterbox, it looks like the following:
backbone generates audio tokens till EOS -> accumulate tokens -> Vocoder (S3Gen) -> Watermarker -> Audio.How would you suggest we handle this difference in the orchestration loops?
Struct mtmd_gen_vocoder :
I like the idea of having a single interface that can hold multiple types of contexts. I think this aligns with the way we were originally thinking about this problem. However, there are some caveats we need to handle to be able to generalize this:
mtmd_gen_vocoder_s3gencontext will have to implement its own ggml_ctx, ggml_cgraph and build the graph. Will this be allowed in the new design?mmproj.has_decoder=true. This should still be feasible givenmtmd_gen_vocoder_init()API. But are we okay updating the mmproj metadata list to include these?mtmd_gen_vocoder_init() API
The current implementation assumes the existence of a llama model for the vocoder, which would not be the case for Chatterbox S3Gen. How about we move all the loading logic to
mtmd_gen_vocoder_*c'tor and have a pass-through implementation in this API?mtmd_gen_vocoder::decode() API
The following is what we need for chatterbox:
Combining the above requirements with LFM's requirements, would it be possible to design a decode interface where we do not restrict the number and dtype of the I/Os? In my mind something roughly like the following should help satisfy a larger set of usecases:
These can be used inside the mtmd_gen_decode() under a per-arch switch case similar to how it is handled in clip_image_batch_chunk().
akarale-nv commentedon Jun 11, 2026
@ngxson here is my POC implementation for Chatterbox TTS that implements the Backbone + S3Gen Vocoder for your reference as well.
Looking forward to your thoughts
ngxson commentedon Jun 21, 2026
Hi @akarale-nv sorry for the delay. Thanks for the demo, I will have a look.
To response to your questions:
mtmd_gen_vocoder_s3genwill haveggml_ctx--> maybe we can "fuse" the vocoder with the existing mmproj (if any)? this way we can reuse the same graph/ctx infrastructure already exist insideclip.cppv., audio prefixed bya., go maybe audio gen can be prefixed bygen.a.llama_modelis the main backbone text model (usually causal, becauseclip.cpponly supports non-causal mode). I'm not sure if s3gen is causal or non-causal?7 remaining items
akarale-nv commentedon Jul 21, 2026
@ServeurpersoCom did you get a chance to review this proposal?
0xShug0 commentedon Jul 24, 2026
Sorry for piggybacking on the discussion. Please check out my project audio.cpp, which aims to bridge the gap between llama.cpp and the messy world of audio models (many are non-AR). It currently supports 35 models. Consider it as llama.cpp for audio models.
I’m also considering using llama.cpp as backbone for AR TTS models instead of my hand-tuned autoregressive model template in the future. This PR may be relevent.
Repo: https://github.com/0xShug0/audio.cpp
ngxson commentedon Jul 28, 2026
sorry for the delay, @akarale-nv I'm implementing Qwen3-TTS in #26254 , it is still WIP but it's quite similar to Sesame-CSM (#12648) with dual autoregressive model (one backbone, one emits codes) and a code2wav to convert audio codes to waveform
I will have a look later on chatterbox to see how it fits, but I'm quite confident that most TTS models already converged into the same overall form now.
I haven't yet look into @ServeurpersoCom 's impl, but will take a look when I finish implementing all the non-standard components.
Split video follow-up to issue #450, re-confirm #439 audio no-go
edenreich commentedon Aug 31, 2026
Chiming in as a downstream consumer: I maintain the Inference Gateway, which proxies OpenAI-style
POST /v1/audio/speechto allamacppprovider. Our CLI just grew a gateway TTS engine on top of that, but right now there is nothing to point it at, since Qwen3-TTS (#26254) only exists in thellama-ttsbinary.Two questions:
/v1/audio/speechin llama-server be acceptable before the genericmtmd_genAPI lands, or is server wiring strictly blocked on it? mtmd: support Qwen3-TTS (note: breaking change to llama-tts binary) #26254 took a similar shortcut for the CLI, so maybe the same is fine for the server as a first step.reference_audio, base64 wav) for zero-shot cloning be considered? That's what we forward today, and it would be a shame if the endpoint gets frozen OpenAI-strict without a way to pass a speaker reference.I can test builds or run a server PoC against a real client if that helps.
edenreich commentedon Sep 1, 2026
Temporary one-shot implementation using llama-tts in the backend is done on the inference-gateway. Looking forward to see this also in llama.cpp so I can simply forward the traffic to an llama.cpp server (I think that's the natural place for this feature to land). You can use it in the meantime in the inference-gateway, but it won't handle a lot of traffic, I gave it default concurrency of 2 and I guess it depends on the hardware - for basic usage it's fine.
Here is a usage example: https://github.com/inference-gateway/inference-gateway/tree/main/examples/rest-endpoints#text-to-speech
github-actions commentedon Oct 16, 2026
This issue was closed because it has been inactive for 14 days since being marked as stale.
tdakhran commentedon Oct 27, 2026
Implemented in #31269 for LFM3 models