diff --git a/.gitignore b/.gitignore
index 8ae4ae3b23..033ba3233b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -30,6 +30,8 @@ examples/python/hf_cache
examples/csharp/ModelChat/models
!test/models/gemma4/*
+!/test/models/lfm2-audio/
+!test/models/lfm2-audio/*
!test/models/hf-internal-testing/*
!test/models/lfm2-vl/*
!test/models/marian-batch/*
diff --git a/README.md b/README.md
index a4b7251a1f..478b565734 100644
--- a/README.md
+++ b/README.md
@@ -16,7 +16,7 @@ See documentation at the [ONNX Runtime website](https://onnxruntime.ai/docs/gena
| Support matrix | Supported now | Under development | On the roadmap|
| -------------- | ------------- | ----------------- | -------------- |
-| Model architectures | AMD OLMo
ChatGLM
DeepSeek
ERNIE 4.5
Fara
Gemma
gpt-oss
Granite
Granite MoE Hybrid
HunYuan Dense V1
InternLM2
LFM2 (language + vision)
Llama
Mistral
Nemotron
Phi (language + vision)
Qwen (language + vision)
SmolLM3
Whisper | Stable diffusion | Multi-modal models |
+| Model architectures | AMD OLMo
ChatGLM
DeepSeek
ERNIE 4.5
Fara
Gemma
gpt-oss
Granite
Granite MoE Hybrid
HunYuan Dense V1
InternLM2
LFM2 (language + vision + speech input)
Llama
Mistral
Nemotron
Phi (language + vision)
Qwen (language + vision)
SmolLM3
Whisper | Stable diffusion | Multi-modal models |
| API | Python
C#
C/C++
Java ^ | Objective-C ||
| O/S | Linux
Windows
Mac
Android || iOS |||
| Architecture | x86
x64
arm64 ||||
diff --git a/examples/python/common.py b/examples/python/common.py
index bf23a6914e..a5f408a0a6 100644
--- a/examples/python/common.py
+++ b/examples/python/common.py
@@ -322,6 +322,11 @@ def get_user_content(model_type: str, num_images: int, num_audios: int, prompt:
# per projected vision feature, and <|image_end|>, based on the resized image's patch grid.
image_tags = "".join(["" for _ in range(num_images)])
content = image_tags + prompt
+ elif model_type == "lfm2_audio":
+ # LFM2-Audio: the C++ audio processor replaces each <|audio|> with one placeholder per
+ # encoder frame (one per 80 ms of audio) and splices the encoder output in at those positions.
+ audio_tags = "".join(["<|audio|>" for _ in range(num_audios)])
+ content = audio_tags + prompt
elif model_type == "mistral3":
# Pixtral / Ministral-3 VLM: the C++ image processor expands each
# [IMG] into the full token sequence based on image resolution.
diff --git a/examples/python/lfm2-audio-tutorial.md b/examples/python/lfm2-audio-tutorial.md
new file mode 100644
index 0000000000..9bec5a9347
--- /dev/null
+++ b/examples/python/lfm2-audio-tutorial.md
@@ -0,0 +1,561 @@
+# Build your LFM2-Audio / LFM2.5-Audio ONNX models for ONNX Runtime GenAI
+
+LFM2-Audio pairs a FastConformer speech encoder (based on
+[nvidia/canary-180m-flash](https://huggingface.co/nvidia/canary-180m-flash)) with an LFM2 decoder
+(the hybrid conv/attention model described in
+[the LFM2 support PR](https://github.com/microsoft/onnxruntime-genai/pull/1979)). ONNX Runtime GenAI
+runs the speech-to-text half of it — ASR and spoken-prompt chat — as three ONNX models:
+
+| Model | Inputs | Outputs |
+| --- | --- | --- |
+| speech encoder (`audio_encoder.onnx`) | `mel_spectrogram`, `mel_lengths` | `audio_embeddings`, `audio_lengths` |
+| `embeddings.onnx` | `input_ids`, `audio_features` | `inputs_embeds` |
+| `model.onnx` (decoder) | `inputs_embeds`, `attention_mask`, KV + conv cache | `logits` |
+
+Only the decoder is produced by the model builder in this repository; the speech encoder is
+published as ONNX by LiquidAI, and the embedding model is a small graph you build once.
+
+The model can also *speak*. Two more graphs, both published by LiquidAI, give it a voice here: a
+depthformer that turns a decoder hidden state into a frame of audio codes, and the audio embedding that
+feeds each frame back to the decoder. [Speech output](#6-speech-output) adds them; without them the
+model answers in text only.
+
+## Steps
+
+1. [Build the decoder](#1-build-the-decoder)
+2. [Get the speech encoder and build the embedding model](#2-get-the-speech-encoder-and-build-the-embedding-model)
+3. [Write `genai_config.json`](#3-write-genai_configjson)
+4. [Choose the precisions](#4-choose-the-precisions)
+5. [Run the model](#5-run-the-model)
+6. [Speech output](#6-speech-output)
+7. [Known limitations](#7-known-limitations)
+
+## 1. Build the decoder
+
+```bash
+# Download the PyTorch model
+$ huggingface-cli download LiquidAI/LFM2.5-Audio-1.5B --local-dir ./lfm2.5-audio/pytorch
+
+# Build the decoder as INT4 with FP32 inputs/outputs for CPU
+$ python3 -m onnxruntime_genai.models.builder \
+ -i ./lfm2.5-audio/pytorch \
+ -o ./lfm2.5-audio/cpu \
+ -p int4 \
+ -e cpu \
+ --extra_options exclude_embeds=true
+```
+
+`-m LiquidAI/LFM2.5-Audio-1.5B` instead of `-i` works too, and downloads the checkpoint itself.
+
+The three checkpoints LiquidAI publishes as PyTorch — `LFM2-Audio-1.5B`, `LFM2.5-Audio-1.5B` and
+`LFM2.5-Audio-1.5B-JP` — build the same way. Their `"lfm"`, `"encoder"` and `"preprocessor"` settings
+are identical, so the front-end defaults below fit all three; only the weights differ. The GGUF
+repositories are for llama.cpp and are not inputs to this builder.
+
+The checkpoint has no `model_type` and no transformers model class: its `config.json` nests the LFM2
+decoder config under `"lfm"`, next to the speech encoder, depthformer and mel front-end settings, and
+the checkpoint stores the decoder under the `lfm.` prefix. The builder recognizes
+`Lfm2AudioForConditionalGeneration`, reads that nested config and loads only the decoder, whose
+logits are tied to the token embeddings.
+
+`exclude_embeds=true` is what makes this a speech pipeline stage: the decoder then takes
+`inputs_embeds` instead of `input_ids`, so the embedding model can splice the encoder output into the
+token embeddings before the decoder runs. The builder writes `"type": "lfm2_audio"` into
+`genai_config.json` for this case, and `"type": "lfm2_audio_text"` when the flag is omitted — that
+second form is a plain text-only LFM2 model that happens to come from an audio checkpoint, and it
+runs through the normal `AppendTokens` path with no speech or embedding model.
+
+## 2. Get the speech encoder and build the embedding model
+
+The speech encoder is `onnx/audio_encoder.onnx` in
+[LiquidAI/LFM2.5-Audio-1.5B-ONNX](https://huggingface.co/LiquidAI/LFM2.5-Audio-1.5B-ONNX)
+(`audio_encoder_fp16.onnx` and `audio_encoder_q4.onnx` are there too). Download the graph together
+with every `*.onnx_data*` file next to it. That repository is the export of `LFM2.5-Audio-1.5B`, so
+its encoder belongs to that checkpoint only; `LFM2-Audio-1.5B` and `LFM2.5-Audio-1.5B-JP` have their
+own encoder weights and need their own export, from `conformer.*` and `audio_adapter.*` in the
+checkpoint (`liquid_audio.model.conformer.ConformerEncoder` plus its adapter MLP, traced for one
+clip). It has the signature ONNX Runtime GenAI expects, for a
+single clip at a time:
+
+| Name | Shape | Type |
+| --- | --- | --- |
+| `mel_spectrogram` (input) | `[1, num_frames, 128]` | float |
+| `mel_lengths` (input) | `[1]` | int64 |
+| `audio_embeddings` (output) | `[1, ceil(num_frames / 8), hidden_size]` | float |
+| `audio_lengths` (output) | `[1]` | int64 |
+
+The encoder subsamples the mel frames by 8 (three stride-2 convolutions) and its adapter projects the
+result to the decoder's hidden size, so one output frame covers 80 ms of audio. `mel_lengths` gives
+it the clip's real length; the runtime always passes the clip's own frames, so nothing is padded.
+The `audio_lengths` output is not read — the runtime derives the same counts itself, before the
+encoder runs, so that it can size the prompt — but the graph produces it.
+
+The published export only handles one clip per run: its subsampling mask is traced for a batch of
+one, and a wider `mel_spectrogram` fails inside the graph. The runtime therefore runs it once per
+clip. An encoder exported to take a real batch would work here too, one clip at a time.
+
+The embedding model looks up `input_ids` in the decoder's embedding table and scatters
+`audio_features` into the positions holding the audio placeholder token. Build it from
+`lfm.embed_tokens.weight` in the checkpoint:
+
+```python
+import json
+from pathlib import Path
+
+import numpy as np
+import onnx
+from onnx import TensorProto, helper, numpy_helper
+from safetensors.torch import load_file
+
+pytorch_dir = Path("./lfm2.5-audio/pytorch")
+output_dir = Path("./lfm2.5-audio/cpu")
+
+config = json.loads((pytorch_dir / "config.json").read_text())["lfm"]
+hidden_size, vocab_size = config["hidden_size"], config["vocab_size"]
+# Pick any id the text never uses; it must match model.audio_token_id in genai_config.json.
+audio_token_id = 133 # <|reserved_123|>
+
+embed_weight = load_file(pytorch_dir / "model.safetensors")["lfm.embed_tokens.weight"]
+table = numpy_helper.from_array(embed_weight.float().numpy(), name="embed_tokens.weight")
+
+graph = helper.make_graph(
+ [
+ helper.make_node("Gather", ["embed_tokens.weight", "input_ids"], ["text_embeds"], axis=0),
+ helper.make_node("Shape", ["text_embeds"], ["embeds_shape"]),
+ helper.make_node("Reshape", ["text_embeds", "flat_rows"], ["flat_embeds"]),
+ helper.make_node("Reshape", ["input_ids", "flat"], ["flat_ids"]),
+ helper.make_node("Equal", ["flat_ids", "audio_token_id"], ["is_audio"]),
+ helper.make_node("NonZero", ["is_audio"], ["audio_positions_t"]),
+ helper.make_node("Transpose", ["audio_positions_t"], ["audio_positions"], perm=[1, 0]),
+ helper.make_node("ScatterND", ["flat_embeds", "audio_positions", "audio_features"], ["merged"]),
+ helper.make_node("Reshape", ["merged", "embeds_shape"], ["inputs_embeds"]),
+ ],
+ "embedding",
+ [
+ helper.make_tensor_value_info("input_ids", TensorProto.INT64, ["batch_size", "sequence_length"]),
+ helper.make_tensor_value_info("audio_features", TensorProto.FLOAT, ["num_audio_tokens", hidden_size]),
+ ],
+ [helper.make_tensor_value_info("inputs_embeds", TensorProto.FLOAT, ["batch_size", "sequence_length", hidden_size])],
+ initializer=[
+ table,
+ numpy_helper.from_array(np.array([-1, hidden_size], np.int64), name="flat_rows"),
+ numpy_helper.from_array(np.array([-1], np.int64), name="flat"),
+ numpy_helper.from_array(np.array(audio_token_id, np.int64), name="audio_token_id"),
+ ],
+)
+model = helper.make_model(graph, opset_imports=[helper.make_opsetid("", 17)], ir_version=8)
+onnx.checker.check_model(model)
+onnx.save(model, output_dir / "embeddings.onnx", save_as_external_data=True, location="embeddings.onnx.data")
+```
+
+## 3. Write `genai_config.json`
+
+Add `embedding` and `speech` sections and an `audio_token_id` to the `genai_config.json` the builder
+produced. Leave the `decoder` and `search` sections it wrote alone:
+
+```json
+{
+ "model": {
+ "audio_token_id": 133,
+ "bos_token_id": 1,
+ "context_length": 32768,
+ "decoder": { "...": "written by the model builder" },
+ "embedding": {
+ "filename": "embeddings.onnx",
+ "inputs": {
+ "input_ids": "input_ids",
+ "audio_features": "audio_features"
+ },
+ "outputs": {
+ "inputs_embeds": "inputs_embeds"
+ },
+ "session_options": {
+ "log_id": "onnxruntime-genai",
+ "provider_options": []
+ }
+ },
+ "eos_token_id": [7, 128, 130],
+ "pad_token_id": 0,
+ "speech": {
+ "filename": "audio_encoder.onnx",
+ "inputs": {
+ "audio_embeds": "mel_spectrogram",
+ "audio_lengths": "mel_lengths",
+ "audio_sizes": "audio_sizes"
+ },
+ "outputs": {
+ "audio_features": "audio_embeddings"
+ },
+ "session_options": {
+ "log_id": "onnxruntime-genai",
+ "provider_options": []
+ }
+ },
+ "type": "lfm2_audio",
+ "vocab_size": 65536
+ },
+ "search": { "...": "written by the model builder" }
+}
+```
+
+Keep the `eos_token_id` list the builder wrote. Besides `<|im_end|>` (7) it holds `<|audio_start|>`
+(128) and `<|text_end|>` (130), where the model turns from text to speech; without them generation
+runs on into positions meant for the audio head and returns fluent nonsense. See
+[the modes](#the-models-modes).
+
+`filename` is whatever the encoder file is called on disk — keep the name it was published under,
+for the reason in [Choose the precisions](#4-choose-the-precisions).
+
+`audio_sizes` is not an encoder input; the name only tells the runtime what to call the per-clip
+token counts it computes, so leave it as is unless it collides with a real input of your graph.
+`audio_token_id` must be the id the embedding model scatters over, and must not be an id the
+tokenizer can emit for ordinary text — the reserved range of the LFM2 tokenizer is the natural home
+for it.
+
+The audio front end needs no configuration file: it defaults to the settings every published
+LFM2-Audio checkpoint shares (`"preprocessor"` in `config.json`), which are NeMo's
+`AudioToMelSpectrogramPreprocessor`:
+
+| Setting | Value | `genai_config.json` override |
+| --- | --- | --- |
+| Sample rate | 16000 | `model.sample_rate` |
+| Mel bins | 128 | `model.num_mels` |
+| FFT size | 512 | `model.fft_size` |
+| Window | 400 samples (25 ms), symmetric Hann | `model.win_length` |
+| Hop | 160 samples (10 ms) | `model.hop_length` |
+| Pre-emphasis | 0.97 | `model.preemph` |
+| Log guard | 2⁻²⁴ | `model.log_eps` |
+| Normalization epsilon | 1e-5 | `model.norm_eps` |
+| Encoder subsampling | 8 | `model.subsampling_factor` |
+
+Set the overrides only for a fine-tune that changed them; the defaults already match all the
+published models.
+
+## 4. Choose the precisions
+
+`-p int4` quantizes the decoder, and the decoder alone. The embedding model and the speech encoder
+are assembled by hand, so they stay at whatever precision you built or downloaded them at — and
+together they are bigger than the quantized decoder. Measured on LFM2.5-Audio-1.5B, transcribing
+three clips (16 kHz, 44.1 kHz stereo, and an mp3):
+
+| Decoder | Embedding table | Encoder | Size | Transcripts |
+| --- | --- | --- | --- | --- |
+| fp32 | fp32 | fp32 | 5736 MB | baseline |
+| int4 | fp32 | fp32 | 1787 MB | correct; one comma differs from fp32 |
+| int4 | fp16 | fp32 | 1518 MB | identical to the row above |
+| int4 | fp16 | fp16 | 1278 MB | identical to the row above |
+| **int4** | **fp16** | **q4** | **1178 MB** | **identical to the row above** |
+
+Quantizing the other two components is free here: every int4 row produced the same tokens as every
+other, so the last row is the one to build. The only difference anywhere is the int4 decoder against
+fp32 — `And so, my fellow Americans` became `And so my fellow Americans` on one clip, with the words
+otherwise unchanged.
+
+For the embedding table, `astype(np.float16)` on the weight in the snippet above is the whole change;
+keep the graph's output fp32 by casting after the `Gather`, so the decoder still receives what it
+declares. A 4-bit table would be smaller still — the builder's own text-only export gets one, through
+`GatherBlockQuantized` over the quantized `lm_head` weights — but `exclude_embeds` is what puts the
+table outside the decoder in the first place, so the pipeline cannot share that copy.
+
+For the encoder, take `audio_encoder_q4.onnx` instead of `audio_encoder.onnx`. **Keep the published
+file names.** Each variant's graph refers to its own `*.onnx_data` by name, so renaming the pair to
+`speech.onnx` breaks the lookup with `External data path validation failed`. Leave both files as they
+are and point `model.speech.filename` at the name you downloaded.
+
+## 5. Run the model
+
+[`model-mm.py`](model-mm.py) drives any multi-modal model in this repository:
+
+```bash
+$ python3 model-mm.py -m ./lfm2.5-audio/cpu -e cpu --audio_paths ./question.wav
+```
+
+The prompt must hold exactly one `<|audio|>` marker per clip, in clip order; the processor rejects
+any other count rather than guessing, and it accepts one prompt at a time (a single-entry list is
+fine, batching is not). The C++ audio processor replaces each marker with one `audio_token_id` per
+encoder frame, so the number of placeholders always matches the number of features the encoder
+produced. Text on either side of a marker is tokenized on its own, which is what
+`liquid_audio.ChatState` does.
+
+LFM2-Audio has no audio token of its own and no chat-template entry for audio: `<|audio|>` is this
+runtime's marker, the same role `` plays for LFM2-VL.
+
+```
+<|startoftext|><|im_start|>system
+Perform ASR.<|im_end|>
+<|im_start|>user
+<|audio|><|im_end|>
+<|im_start|>assistant
+```
+
+### The model's modes
+
+The system prompt picks the task, and the reference implementation pairs each with a generation
+routine of its own:
+
+| Mode | System prompt | Reference routine | Output | Text-only build | With [speech output](#6-speech-output) |
+| --- | --- | --- | --- | --- | --- |
+| Chat | none | `generate_sequential` | text | works | works |
+| ASR | `Perform ASR.` | `generate_sequential` | text | works | works |
+| TTS | `Perform TTS. Use the UK male voice.` (also US male, US female, UK female) | `generate_sequential` | speech | stops immediately | works |
+| Interleaved | `Respond with interleaved text and audio.` | `generate_interleaved` | text and speech, alternating | do not use | works, with `audio_interleaved=True` |
+
+**ASR** and **chat** need nothing more than the three models above. A spoken or typed question with
+no system prompt gets a text answer, and both match the reference step for step: on the reference's
+own `question.wav` and three typed questions the text logits agree to within 6e-4 and every token is
+the same.
+
+`LFM2.5-Audio-1.5B-JP` takes its own prompts, `Perform ASR in japanese.` and
+`Perform TTS in japanese.`; the rest of this section applies to it unchanged.
+
+Two tokens mark the turn passing from text to speech: `<|audio_start|>` when the rest of the answer is
+spoken, and `<|text_end|>` when the text half of an interleaved answer is done. In a text-only build
+the builder puts both in `eos_token_id`, because the positions after either are audio codes meant for
+the depthformer, and read off the text head they decode to fluent, plausible nonsense.
+
+**TTS** in a text-only build produces nothing: the whole text stream the model emits is
+`<|audio_start|>` followed by `<|im_end|>`, everything in between being audio. Generation stops on
+that first token, and the stop token is not added to the sequence, so there is nothing after the
+prompt to decode: expect an empty string.
+
+**Interleaved** cannot be used in a text-only build. The reference writes six text tokens, then
+`interleaved_n_audio` audio frames (12, or 9 for the JP checkpoint) whose embeddings go back into the
+context, then six more text tokens, and so on. Without audio frames to feed, the text head is read at
+positions the model means for speech: the first six tokens are right and the rest is mostly the
+non-breaking-space token. For a text answer, leave the system prompt out and use chat mode.
+
+Sampling: the reference generates text greedily in every mode, so `do_sample=False` is right here.
+The temperatures and `top_k` values quoted for the model apply to the audio codes only; see
+[Speech output](#6-speech-output) for where they go.
+
+## 6. Speech output
+
+Speech needs the decoder's hidden states, two more graphs and a few lines of `genai_config.json`.
+
+**Build the decoder with its hidden states.** The depthformer reads the hidden state the logits are
+projected from, so add `include_hidden_states=true`:
+
+```bash
+$ python3 -m onnxruntime_genai.models.builder \
+ -i ./lfm2.5-audio/pytorch -o ./lfm2.5-audio/cpu -p int4 -e cpu \
+ --extra_options exclude_embeds=true include_hidden_states=true
+```
+
+**Get the two graphs** from
+[LiquidAI/LFM2.5-Audio-1.5B-ONNX](https://huggingface.co/LiquidAI/LFM2.5-Audio-1.5B-ONNX):
+`onnx/vocoder_depthformer.onnx` and `onnx/audio_embedding.onnx` (with their `*.onnx_data` files for
+the `fp16` and `q4` variants), and `onnx/audio_detokenizer.onnx` for turning the codes into sound
+afterwards. Like the encoder they belong to `LFM2.5-Audio-1.5B`; the other checkpoints need their own
+export, which LiquidAI's [onnx-export](https://github.com/Liquid4All/onnx-export) produces.
+
+**Fix the depthformer's rotary embedding.** As published, the graph's attention nodes carry rotary
+tables but do not apply them (`do_rotary` is unset), and the tables are computed for `theta` 10000
+where the model uses 1,000,000. The first codebook of each frame sits at position zero and is
+unaffected; the other seven come out with logits up to 2.5 away from the PyTorch model's. The speech
+is intelligible either way, since the first codebook carries most of what is said, but only the
+corrected graph gives the model's own codes: with both fixes every codebook agrees to 1e-5.
+
+```python
+import numpy as np
+import onnx
+from onnx import helper, numpy_helper
+
+model = onnx.load("vocoder_depthformer.onnx")
+for node in model.graph.node:
+ if node.op_type == "GroupQueryAttention" and not any(a.name == "do_rotary" for a in node.attribute):
+ node.attribute.append(helper.make_attribute("do_rotary", 1))
+for table in model.graph.initializer:
+ if table.name.endswith((".gqa_cos", ".gqa_sin")):
+ positions, half = numpy_helper.to_array(table).shape
+ angles = np.outer(np.arange(positions), 1.0 / 1_000_000 ** (np.arange(0, 2 * half, 2) / (2 * half)))
+ values = np.cos(angles) if table.name.endswith("cos") else np.sin(angles)
+ table.CopyFrom(numpy_helper.from_array(values.astype(np.float32), table.name))
+onnx.save(model, "vocoder_depthformer.onnx")
+```
+
+The same defect is in all four published depthformers (`fp16`, `q4` and `q8` too), and the same fix
+applies to each. Which variants are worth taking, measured against the PyTorch model:
+
+| Graph | fp32 | fp16 | q8 | q4 |
+| --- | --- | --- | --- | --- |
+| `vocoder_depthformer` (fixed), largest logit difference | 9e-6 | 7e-3 | not measured | 2.0, and 37 of 48 codes agree |
+| `audio_detokenizer`, waveform SNR on real speech | 112 dB | 51 dB | 18 dB | 3 dB |
+| `audio_embedding`, against the checkpoint | exact | 6e-8 | exact | exact |
+
+Take `fp32` or `fp16`. The `q4` depthformer loses more to quantization than the rotary fix gains, and
+the `q4` detokenizer's waveform is barely correlated with the model's, though the speech is still
+understood: all four detokenizers read back word for word through ASR. The `q4` and `q8` audio
+embeddings are the `fp32` table under another name, so they save nothing. LiquidAI's own `decoder*.onnx`
+graphs, which this pipeline does not use, do apply their rotary embedding.
+
+**Add `audio_output` to `genai_config.json`, and take the two switch tokens out of `eos_token_id`.**
+They end a text-only answer; here they are where speech begins, and the model is refused if they are
+still stop tokens:
+
+```json
+{
+ "model": {
+ "eos_token_id": 7,
+ "audio_output": {
+ "depthformer": { "filename": "vocoder_depthformer.onnx" },
+ "embedding": { "filename": "audio_embedding.onnx" }
+ }
+ }
+}
+```
+
+`audio_output` also takes `num_codebooks` (8), `codebook_size` (2049, the last entry being the
+end-of-audio code), `audio_start_token_id` (128), `text_end_token_id` (130), `interleaved_n_text` (6)
+and `interleaved_n_audio` (12). The defaults are the published checkpoints'; set
+`interleaved_n_audio` to 9 for `LFM2.5-Audio-1.5B-JP`.
+
+**Generate.** Three search options steer the speech, next to the usual ones:
+
+| Option | Default | Meaning |
+| --- | --- | --- |
+| `audio_interleaved` | `false` | Alternate text and speech by count, as `generate_interleaved` does. Leave it off for TTS, ASR and chat, which switch on `<|audio_start|>` alone. |
+| `audio_temperature` | `1.0` | Temperature of the audio codes. `0` takes the likeliest code. The model card uses 0.8 for TTS and 1.0 for interleaved. |
+| `audio_top_k` | `4` | Audio codes kept when sampling. `1` takes the likeliest code. The model card uses 64 for TTS and 4 for interleaved. |
+
+The text is still decoded greedily, and `random_seed` seeds the audio codes as well.
+
+```python
+import numpy as np
+import onnxruntime_genai as og
+
+model = og.Model("./lfm2.5-audio/cpu")
+processor = model.create_multimodal_processor()
+tokenizer = og.Tokenizer(model)
+
+prompt = (
+ "<|startoftext|><|im_start|>system\nRespond with interleaved text and audio.<|im_end|>\n"
+ "<|im_start|>user\n<|audio|><|im_end|>\n<|im_start|>assistant\n"
+)
+inputs = processor(prompt, audios=og.Audios.open("question.wav"))
+prompt_length = inputs["input_ids"].as_numpy().shape[1]
+
+params = og.GeneratorParams(model)
+params.set_search_options(
+ do_sample=False, max_length=prompt_length + 512, audio_interleaved=True, audio_temperature=1.0, audio_top_k=4
+)
+generator = og.Generator(model, params)
+generator.set_inputs(inputs)
+while not generator.is_done():
+ generator.generate_next_token()
+
+answer = generator.get_sequence(0)[prompt_length:]
+audio_token_id, audio_start, text_end = 133, 128, 130
+text = tokenizer.decode(np.array([t for t in answer if t not in (audio_token_id, audio_start, text_end)], np.int32))
+codes = generator.get_output("audio_codes") # [num_frames, 8] int64, 80 ms of speech per frame
+```
+
+Each audio frame takes one place in the sequence, held by `audio_token_id`, so `max_length` counts
+frames as well as text tokens; twelve and a half frames make a second of speech. The frames themselves
+come from `get_output("audio_codes")`, at any point during generation or after it, with the
+end-of-audio frames left out.
+
+**Turn the codes into sound** with `audio_detokenizer.onnx`, which gives the log-magnitude and phase
+of a short-time Fourier transform, six columns per frame, and an inverse transform with the
+reference's "same" padding. This graph needs no correction: it matches the PyTorch detokenizer to
+1e-5.
+
+```python
+import onnxruntime as ort
+import soundfile as sf
+
+n_fft, hop = 1280, 320
+detokenizer = ort.InferenceSession("audio_detokenizer.onnx")
+# Only the first codebook carries the end-of-audio code on purpose; the detokenizer takes 0..2047.
+features = detokenizer.run(None, {"audio_codes": np.minimum(codes, 2047).T[None]})[0][0]
+spectrum = (np.exp(features[:, : n_fft // 2 + 1]) * np.exp(1j * features[:, n_fft // 2 + 1 :])).T
+
+window = 0.5 - 0.5 * np.cos(2 * np.pi * np.arange(n_fft) / n_fft) # torch.hann_window: periodic
+frames = np.fft.irfft(spectrum, n_fft, axis=0) * window[:, None]
+wave = np.zeros((frames.shape[1] - 1) * hop + n_fft)
+envelope = np.zeros_like(wave)
+for t in range(frames.shape[1]):
+ wave[t * hop : t * hop + n_fft] += frames[:, t]
+ envelope[t * hop : t * hop + n_fft] += window**2
+pad = (n_fft - hop) // 2
+sf.write("answer.wav", (wave[pad:-pad] / envelope[pad:-pad]).astype(np.float32), 24000)
+```
+
+**How it compares with the reference.** With the audio codes taken greedily on both sides
+(`audio_top_k=1`), so that the two can be compared at all, this runtime reproduces liquid-audio
+exactly: every text token and every audio frame of an interleaved answer to the reference's
+`question.wav` (200 items, 149 of them frames), of two interleaved answers to typed questions, and of a
+TTS sentence, on Linux x64 and on macOS arm64 alike. With the model card's sampling the speech reads back through this runtime's own ASR as
+the text the model wrote: *Red, blue, and yellow are the three primary colors. Would you like to hear
+how they’re used in art?* comes back word for word, and *The quick brown fox jumps over the lazy dog.*
+comes back exactly in TTS.
+
+## 7. Known limitations
+
+**The waveform is made outside the runtime.** Generation gives audio codes; the detokenizer and the
+inverse transform that turn them into sound are the few lines of Python in
+[Speech output](#6-speech-output), not part of the generation loop.
+
+**Speech output is for one sequence.** A batch size of 1 and no beam search, as for speech input.
+After an answer that ended in speech, a new turn appended to the same generator starts in text.
+
+**One prompt at a time, decoded greedily.** Several clips in one prompt work, but batched prompts
+and beam search (`num_beams` above 1) are refused; the reference decodes its text greedily too. The
+clips do not share an encoder run: the published encoder export is traced for a single clip, so each
+one is encoded on its own frames and the results are concatenated in prompt order.
+
+**In ASR mode, two clips in one turn give one transcript.** Asked to transcribe a turn holding two
+clips, the model writes out the last one and leaves the first, whichever order they come in. That is
+the model and not this runtime: the reference implementation, given the same two clips through
+`ChatState.add_audio`, produces the same tokens exactly, in both orders. Transcribe one clip per
+request.
+
+**LoRA adapters are not supported.** The builder refuses `adapter_path` for these checkpoints: an
+adapter trained on them names the decoder `lfm.*`, which does not match the decoder the builder
+loads. Merge the adapter into the checkpoint first.
+
+**More than two channels has to be downmixed first.** Mono and stereo are handled: a stereo clip is
+mixed down and reaches the front end at its true length. Wider audio is not — the decoder
+deinterleaves it as though it were stereo, so a six channel clip arrives three times too long and
+garbled, and transcribes to nonsense. Nothing in the decoder's interface reports how many channels a
+file had, so this cannot be caught and refused here. Mix down to mono or stereo before passing the
+file in.
+
+**Audio is not chunked.** The whole clip goes through the encoder in one run, and one hour of audio
+is 450k mel frames, so memory grows with the clip length. The reference implementation has the same
+shape; for long-form transcription, split the audio yourself.
+
+**The mel front end differs from the reference by about 1e-4.** It computes the same NeMo pipeline in
+float32 where the reference uses float64 intermediates in places, and the FFT is a different
+implementation. The difference is far below the audio's own quantization noise.
+
+**Resampling is the reference's.** A clip at any other rate is decoded at its own rate and brought to
+16 kHz with `torchaudio`'s resampler (Hann-windowed sinc), upwards as well as downwards, which is what
+`ChatState.add_audio` does. The audio decoder's own resampler is a different filter and changes the
+log-mel frames by up to about 2, enough to flip a token: on the reference's 24 kHz `asr_jp.wav` it
+turned one homophone, and resampled this way the transcript matches token for token.
+
+**CPU is the verified path; a GPU needs every graph on it.** All three modes were checked against
+the reference on CPU, x64 and arm64, and match token for token and frame for frame. On **CUDA** they
+match too, but only once *every* graph runs there: build the model for CUDA, or leave the embedding
+and speech entries without their own `session_options` so they inherit the decoder's provider. A
+mixed configuration — the decoder on the GPU and the embedding or encoder left on CPU, which is what
+a CPU export's `genai_config.json` describes — is refused with an error naming the entry to remove:
+the CPU graph would be handed buffers that live on the decoder's device and write host bytes into
+them ([onnxruntime-genai#2604](https://github.com/microsoft/onnxruntime-genai/issues/2604)). The encoder's convolutions need cuDNN
+on the library path. Expect the audio codes to drift a little against a CPU run: on one of the test
+clips two of 1192 codes differed, both in the last two codebooks, with every text token identical.
+
+**WebGPU runs the text-in modes.** Interleaved and TTS match the reference once the `audio_output`
+graphs keep their own CPU `session_options`: ONNX Runtime's WebGPU `GroupQueryAttention` fails
+whenever the present KV cache has to grow past the buffer it was given, which is every step of the
+depthformer's loop ([onnxruntime#32716](https://github.com/microsoft/onnxruntime/issues/32716)).
+Speech *input* does not work there yet, for a reason of this runtime's own rather than the
+encoder's: the features buffer lands on the wrong device
+([onnxruntime-genai#2604](https://github.com/microsoft/onnxruntime-genai/issues/2604)), so a prompt
+with audio is refused while the encoder or embedding keeps its own CPU `session_options`. Note also
+that the shipped `onnxruntime-ep-webgpu` 0.3.0 cannot load the encoder at the default optimisation
+level — it rejects the fused `Conv` activation that a current `onnxruntime` emits — but does load
+and run it at `ORT_ENABLE_BASIC`.
+
+**Dither is off, as in the reference's eval mode.** NeMo adds 1e-5 of white noise to the samples
+during training only; the runtime never does.
diff --git a/src/config.cpp b/src/config.cpp
index 6c0e726538..f2023b5bc1 100644
--- a/src/config.cpp
+++ b/src/config.cpp
@@ -1473,6 +1473,8 @@ struct SpeechInputs_Element : JSON::Element {
v_.attention_mask = JSON::Get(value);
} else if (name == "audio_sizes") {
v_.audio_sizes = JSON::Get(value);
+ } else if (name == "audio_lengths") {
+ v_.audio_lengths = JSON::Get(value);
} else if (name == "audio_projection_mode") {
v_.audio_projection_mode = JSON::Get(value);
} else {
@@ -1542,6 +1544,68 @@ struct Speech_Element : JSON::Element {
SpeechOutputs_Element outputs_{v_.outputs};
};
+struct AudioOutputGraph_Element : JSON::Element {
+ explicit AudioOutputGraph_Element(Config::Model::AudioOutput::Graph& v) : v_{v} {}
+
+ void OnValue(std::string_view name, JSON::Value value) override {
+ if (name == "filename") {
+ v_.filename = JSON::Get(value);
+ } else {
+ throw JSON::unknown_value_error{};
+ }
+ }
+
+ Element& OnObject(std::string_view name) override {
+ if (name == "session_options") {
+ v_.session_options = Config::SessionOptions{};
+ session_options_ = std::make_unique(*v_.session_options);
+ return *session_options_;
+ }
+ throw JSON::unknown_value_error{};
+ }
+
+ private:
+ Config::Model::AudioOutput::Graph& v_;
+ std::unique_ptr session_options_;
+};
+
+struct AudioOutput_Element : JSON::Element {
+ explicit AudioOutput_Element(Config::Model::AudioOutput& v) : v_{v} {}
+
+ void OnValue(std::string_view name, JSON::Value value) override {
+ if (name == "num_codebooks") {
+ v_.num_codebooks = SafeDoubleToInt(JSON::Get(value), name);
+ } else if (name == "codebook_size") {
+ v_.codebook_size = SafeDoubleToInt(JSON::Get(value), name);
+ } else if (name == "audio_start_token_id") {
+ v_.audio_start_token_id = SafeDoubleToInt(JSON::Get(value), name);
+ } else if (name == "text_end_token_id") {
+ v_.text_end_token_id = SafeDoubleToInt(JSON::Get(value), name);
+ } else if (name == "interleaved_n_text") {
+ v_.interleaved_n_text = SafeDoubleToInt(JSON::Get(value), name);
+ } else if (name == "interleaved_n_audio") {
+ v_.interleaved_n_audio = SafeDoubleToInt(JSON::Get(value), name);
+ } else {
+ throw JSON::unknown_value_error{};
+ }
+ }
+
+ Element& OnObject(std::string_view name) override {
+ if (name == "depthformer") {
+ return depthformer_;
+ }
+ if (name == "embedding") {
+ return embedding_;
+ }
+ throw JSON::unknown_value_error{};
+ }
+
+ private:
+ Config::Model::AudioOutput& v_;
+ AudioOutputGraph_Element depthformer_{v_.depthformer};
+ AudioOutputGraph_Element embedding_{v_.embedding};
+};
+
struct JoinerInputs_Element : JSON::Element {
explicit JoinerInputs_Element(Config::Model::Joiner::Inputs& v) : v_{v} {}
@@ -2160,6 +2224,9 @@ struct Model_Element : JSON::Element {
if (name == "speech") {
return speech_;
}
+ if (name == "audio_output") {
+ return audio_output_;
+ }
if (name == "joiner") {
return joiner_;
}
@@ -2196,6 +2263,7 @@ struct Model_Element : JSON::Element {
Vision_Element vision_{v_.vision};
Embedding_Element embedding_{v_.embedding};
Speech_Element speech_{v_.speech};
+ AudioOutput_Element audio_output_{v_.audio_output};
Joiner_Element joiner_{v_.joiner};
VAD_Element vad_{v_.vad};
Moonshine_Element moonshine_{v_.moonshine};
@@ -2310,6 +2378,12 @@ struct Search_Element : JSON::Element {
v_.early_stopping = JSON::Get(value);
} else if (name == "blank_penalty") {
v_.blank_penalty = static_cast(JSON::Get(value));
+ } else if (name == "audio_interleaved") {
+ v_.audio_interleaved = JSON::Get(value);
+ } else if (name == "audio_temperature") {
+ v_.audio_temperature = static_cast(JSON::Get(value));
+ } else if (name == "audio_top_k") {
+ v_.audio_top_k = SafeDoubleToInt(JSON::Get(value), name);
} else {
throw JSON::unknown_value_error{};
}
diff --git a/src/config.h b/src/config.h
index 64022a32e3..13208cf02e 100644
--- a/src/config.h
+++ b/src/config.h
@@ -48,6 +48,7 @@ struct Config {
// Speech encoder names
static constexpr std::string_view AudioAttentionMaskName = "audio_attention_mask";
static constexpr std::string_view AudioSizesName = "audio_sizes";
+ static constexpr std::string_view AudioLengthsName = "audio_lengths";
static constexpr std::string_view AudioProjectionModeName = "audio_projection_mode";
static constexpr std::string_view AudioFeaturesName = "audio_features";
static constexpr std::string_view NumAudioTokens = "num_audio_tokens";
@@ -317,6 +318,7 @@ struct Config {
std::string audio_embeds{Defaults::AudioEmbedsName};
std::string attention_mask{Defaults::AudioAttentionMaskName};
std::string audio_sizes{Defaults::AudioSizesName};
+ std::string audio_lengths{Defaults::AudioLengthsName}; // per-clip valid frame count of audio_embeds (LFM2-Audio)
std::string audio_projection_mode{Defaults::AudioProjectionModeName};
} inputs;
@@ -325,6 +327,24 @@ struct Config {
} outputs;
} speech;
+ // Speech output of LFM2-Audio: the depthformer turns one decoder hidden state into a frame of
+ // audio codes, one per codebook, and the audio embedding turns that frame back into the decoder's
+ // next input. Empty filenames leave the model answering in text only.
+ struct AudioOutput {
+ struct Graph {
+ std::string filename;
+ std::optional session_options;
+ };
+ Graph depthformer;
+ Graph embedding;
+ int num_codebooks{8};
+ int codebook_size{2049}; // entries per codebook; the last one is the end-of-audio code
+ int audio_start_token_id{128}; // <|audio_start|>: the rest of a sequential answer is speech
+ int text_end_token_id{130}; // <|text_end|>: the text of an interleaved answer is over
+ int interleaved_n_text{6}; // text tokens per turn of an interleaved answer
+ int interleaved_n_audio{12}; // audio frames per turn of an interleaved answer
+ } audio_output;
+
struct Joiner {
std::string filename;
std::optional session_options;
@@ -734,6 +754,9 @@ struct Config {
int random_seed{-1}; // -1 = Seed with random device, otherwise use value to seed RNG
std::optional chunk_size; // Chunk size for prefill chunking during context processing. If present, chunking is enabled with the chunk size > 0.
float blank_penalty{}; // Penalty applied to blank token logits in CTC/RNNT decoding. Default 0 means no penalty.
+ bool audio_interleaved{}; // LFM2-Audio: alternate text tokens and audio frames by count (interleaved mode) rather than switching on <|audio_start|>.
+ float audio_temperature{1.0f}; // LFM2-Audio: temperature the audio codes are sampled with. 0 takes the most likely code.
+ int audio_top_k{4}; // LFM2-Audio: number of most likely audio codes kept when sampling. 1 takes the most likely code.
} search;
struct Speculative {
diff --git a/src/generator/generators.cpp b/src/generator/generators.cpp
index 0b63554382..3608babb57 100644
--- a/src/generator/generators.cpp
+++ b/src/generator/generators.cpp
@@ -971,7 +971,7 @@ void Generator::RewindToLength(size_t new_length) {
if (ModelType::IsTransducer(model_type) || ModelType::IsStreamingEncDecASR(model_type))
throw std::runtime_error("RewindTo is not supported for streaming ASR models (" + model_type + ").");
if (model_type == "whisper" || model_type == "phi3v" || model_type == "decoder-pipeline" ||
- ModelType::IsLFM2(model_type) || model_type == "lfm2_vl")
+ ModelType::IsLFM2(model_type) || model_type == "lfm2_vl" || model_type == "lfm2_audio")
throw std::runtime_error("RewindTo is currently not supported for " + model_type + ".");
const size_t current_length = search_->GetSequenceLength();
if (new_length > current_length)
diff --git a/src/generator/generators.h b/src/generator/generators.h
index 4a865a374f..40a1ee4734 100644
--- a/src/generator/generators.h
+++ b/src/generator/generators.h
@@ -70,6 +70,12 @@ DeviceSpan WrapTensor(DeviceInterface& device, OrtValue& value) {
DeviceSpan ByteWrapTensor(DeviceInterface& device, OrtValue& value);
+// The interface that matches where a tensor's memory actually is. A model that runs on a device does
+// not necessarily keep its tensors there: WebGPU without graph capture leaves them host-side, and ORT
+// places some outputs on the CPU whatever the EP. Wrapping such a tensor with the device interface
+// hands a host pointer to the EP's copy, which crashes inside it.
+DeviceInterface& DeviceForTensor(const OrtValue& value, DeviceInterface& device);
+
// OgaSequences are a vector of int32 vectors
using TokenSequences = std::vector>;
diff --git a/src/models/lfm2_audio_output.cpp b/src/models/lfm2_audio_output.cpp
new file mode 100644
index 0000000000..f3d728450c
--- /dev/null
+++ b/src/models/lfm2_audio_output.cpp
@@ -0,0 +1,283 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+#include "generator/generators.h"
+#include "models/lfm2_audio_output.h"
+#include "models/multi_modal.h"
+#include "models/utils.h"
+
+#include
+#include
+#include
+#include
+
+namespace Generators {
+
+namespace {
+
+// Names of LiquidAI's depthformer and audio embedding exports (github.com/Liquid4All/onnx-export).
+constexpr const char* kDepthformerInputs[] = {"hidden_states", "depth_slices_in", "step_idx", "prev_token",
+ "past_keys", "past_values", "seqlens_k", "total_seq_len"};
+constexpr const char* kDepthformerOutputs[] = {"logits", "depth_slices", "new_keys", "new_values"};
+constexpr const char* kEmbeddingInputs[] = {"audio_codes"};
+constexpr const char* kEmbeddingOutputs[] = {"audio_embeds"};
+
+std::vector InputShape(OrtSession& session, const char* name, const char* graph) {
+ const auto names = session.GetInputNames();
+ for (size_t i = 0; i < names.size(); ++i) {
+ if (names[i] == name) {
+ return session.GetInputTypeInfo(i)->GetTensorTypeAndShapeInfo().GetShape();
+ }
+ }
+ throw std::runtime_error(std::string("Lfm2AudioOutput: the ") + graph + " model has no input named \"" + name + "\".");
+}
+
+template
+std::unique_ptr MakeTensor(std::span values, std::vector shape) {
+ auto tensor = OrtValue::CreateTensor(Ort::Allocator::GetWithDefaultOptions(), shape);
+ std::copy(values.begin(), values.end(), tensor->template GetTensorMutableData());
+ return tensor;
+}
+
+template
+std::unique_ptr MakeScalar(T value) {
+ return MakeTensor(std::span{&value, 1}, {});
+}
+
+} // namespace
+
+Lfm2AudioOutput::Lfm2AudioOutput(const MultiModalLanguageModel& model, const GeneratorParams& params)
+ : model_{model},
+ config_{model.config_->model.audio_output},
+ interleaved_{params.search.audio_interleaved},
+ temperature_{params.search.audio_temperature},
+ top_k_{params.search.audio_top_k},
+ placeholder_token_id_{model.config_->model.audio_token_id},
+ rng_{CreateRandomGenerator(params.search.random_seed)},
+ modality_left_{config_.interleaved_n_text} {
+ if (params.BatchBeamSize() != 1) {
+ throw std::runtime_error("Lfm2AudioOutput: speech output needs a batch size of 1 and no beam search.");
+ }
+
+ const auto hidden_shape = InputShape(*model_.depthformer_session_, "hidden_states", "depthformer");
+ const auto slices_shape = InputShape(*model_.depthformer_session_, "depth_slices_in", "depthformer");
+ const auto keys_shape = InputShape(*model_.depthformer_session_, "past_keys", "depthformer");
+ if (hidden_shape.size() != 2 || slices_shape.size() != 3 || keys_shape.size() != 5 ||
+ config_.num_codebooks <= 0 || slices_shape[1] != config_.num_codebooks) {
+ throw std::runtime_error("Lfm2AudioOutput: the depthformer model does not have the expected inputs for " +
+ std::to_string(config_.num_codebooks) + " codebooks.");
+ }
+ hidden_size_ = hidden_shape[1];
+ depth_size_ = slices_shape[2];
+ num_layers_ = keys_shape[0];
+ num_kv_heads_ = keys_shape[2];
+ head_size_ = keys_shape[4];
+
+ // RunDepthformer checks it against the logits the depthformer produces.
+ if (config_.codebook_size <= 0) {
+ throw std::runtime_error("Lfm2AudioOutput: model.audio_output.codebook_size " +
+ std::to_string(config_.codebook_size) + " must be positive.");
+ }
+
+ // The search has to pick the placeholder whatever its sampling settings are.
+ const int vocab_size = model_.config_->model.vocab_size;
+ if (placeholder_token_id_ <= 0 || placeholder_token_id_ >= vocab_size) {
+ throw std::runtime_error("Lfm2AudioOutput: model.audio_token_id " + std::to_string(placeholder_token_id_) +
+ " must be set to a token inside the vocabulary of " + std::to_string(vocab_size) +
+ " tokens.");
+ }
+ placeholder_logits_ = model_.p_device_->Allocate(static_cast(vocab_size));
+ auto logits = placeholder_logits_.CpuSpan();
+ std::fill(logits.begin(), logits.end(), std::numeric_limits::lowest());
+ logits[static_cast(placeholder_token_id_)] = 0.0f;
+ placeholder_logits_.CopyCpuToDevice();
+}
+
+void Lfm2AudioOutput::BeginStep(DeviceSpan& next_tokens, bool is_prompt) {
+ if (is_prompt || next_tokens.size() != 1) {
+ // A new turn starts in text, whatever the last one ended in.
+ modality_ = Modality::Text;
+ previous_was_text_ = false;
+ text_done_ = false;
+ modality_left_ = config_.interleaved_n_text;
+ pending_embedding_.clear();
+ } else if (previous_was_text_) {
+ const int32_t token = next_tokens.CopyDeviceToCpu()[0];
+ if (interleaved_) {
+ text_done_ = text_done_ || token == config_.text_end_token_id;
+ if (modality_left_ == 0 || text_done_) {
+ modality_ = Modality::Audio;
+ modality_left_ = config_.interleaved_n_audio;
+ }
+ } else if (token == config_.audio_start_token_id) {
+ modality_ = Modality::Audio;
+ }
+ }
+
+ --modality_left_;
+ previous_was_text_ = modality_ == Modality::Text;
+}
+
+void Lfm2AudioOutput::WritePendingFrame(OrtValue& inputs_embeds) {
+ const auto info = inputs_embeds.GetTensorTypeAndShapeInfo();
+ if (info->GetElementCount() != pending_embedding_.size()) {
+ throw std::runtime_error("Lfm2AudioOutput: expected the decoder to take one embedding of " +
+ std::to_string(pending_embedding_.size()) + " values after an audio frame, got " +
+ std::to_string(info->GetElementCount()) + ".");
+ }
+
+ auto bytes = ByteWrapTensor(DeviceForTensor(inputs_embeds, *model_.p_device_inputs_), inputs_embeds);
+ auto cpu = bytes.CpuSpan();
+ if (info->GetElementType() == ONNX_TENSOR_ELEMENT_DATA_TYPE_FLOAT) {
+ std::memcpy(cpu.data(), pending_embedding_.data(), pending_embedding_.size() * sizeof(float));
+ } else if (info->GetElementType() == ONNX_TENSOR_ELEMENT_DATA_TYPE_FLOAT16) {
+ auto* half = reinterpret_cast(cpu.data());
+ std::transform(pending_embedding_.begin(), pending_embedding_.end(), half, FastFloat32ToFloat16);
+ } else {
+ throw std::runtime_error("Lfm2AudioOutput: the decoder's inputs_embeds must be float or float16.");
+ }
+ bytes.CopyCpuToDevice();
+ pending_embedding_.clear();
+}
+
+DeviceSpan Lfm2AudioOutput::SampleFrame(OrtValue& hidden_states) {
+ const auto info = hidden_states.GetTensorTypeAndShapeInfo();
+ const auto shape = info->GetShape();
+ if (shape.empty() || shape.back() != hidden_size_) {
+ throw std::runtime_error("Lfm2AudioOutput: the decoder's hidden states do not match the depthformer's input of " +
+ std::to_string(hidden_size_) + " values.");
+ }
+
+ // The last position of [batch, sequence, hidden_size].
+ const size_t count = static_cast(hidden_size_);
+ const size_t first = info->GetElementCount() - count;
+ auto bytes = ByteWrapTensor(DeviceForTensor(hidden_states, *model_.p_device_inputs_), hidden_states).CopyDeviceToCpu();
+ std::vector hidden(count);
+ if (info->GetElementType() == ONNX_TENSOR_ELEMENT_DATA_TYPE_FLOAT) {
+ std::memcpy(hidden.data(), bytes.data() + first * sizeof(float), count * sizeof(float));
+ } else if (info->GetElementType() == ONNX_TENSOR_ELEMENT_DATA_TYPE_FLOAT16) {
+ const auto* half = reinterpret_cast(bytes.data()) + first;
+ std::transform(half, half + count, hidden.begin(), FastFloat16ToFloat32);
+ } else {
+ throw std::runtime_error("Lfm2AudioOutput: the decoder's hidden states must be float or float16.");
+ }
+
+ std::vector frame = RunDepthformer(hidden);
+
+ // As the reference orders it: the count first, then the end-of-audio code, which wins.
+ if (interleaved_ && modality_left_ == 0 && !text_done_) {
+ modality_ = Modality::Text;
+ modality_left_ = config_.interleaved_n_text;
+ }
+ const int64_t end_of_audio = config_.codebook_size - 1;
+ if (frame[0] == end_of_audio) {
+ std::fill(frame.begin(), frame.end(), end_of_audio);
+ modality_ = Modality::Text;
+ } else {
+ audio_codes_.insert(audio_codes_.end(), frame.begin(), frame.end());
+ }
+
+ EmbedFrame(frame);
+ return placeholder_logits_;
+}
+
+std::vector Lfm2AudioOutput::RunDepthformer(std::span hidden) {
+ const auto hidden_tensor = MakeTensor(hidden, {1, hidden_size_});
+ const std::vector cache_shape{num_layers_, 1, num_kv_heads_, 0, head_size_};
+ auto keys = OrtValue::CreateTensor(Ort::Allocator::GetWithDefaultOptions(), cache_shape);
+ auto values = OrtValue::CreateTensor(Ort::Allocator::GetWithDefaultOptions(), cache_shape);
+ const std::vector no_slices(static_cast(config_.num_codebooks * depth_size_), 0.0f);
+ auto slices = MakeTensor(no_slices, {1, config_.num_codebooks, depth_size_});
+
+ // One run per codebook: each code is sampled given the ones before it in the frame. The projection
+ // of the hidden state is computed on the first run and handed back in on the rest.
+ std::vector frame;
+ int64_t previous_code = 0;
+ for (int32_t step = 0; step < config_.num_codebooks; ++step) {
+ const auto step_index = MakeScalar(step);
+ const auto previous = MakeTensor(std::span{&previous_code, 1}, {1});
+ const auto past_length = MakeTensor(std::span{&step, 1}, {1});
+ const auto total_length = MakeScalar(step + 1);
+ const OrtValue* inputs[] = {hidden_tensor.get(), slices.get(), step_index.get(), previous.get(),
+ keys.get(), values.get(), past_length.get(), total_length.get()};
+ auto outputs = model_.depthformer_session_->Run(nullptr, kDepthformerInputs, inputs, std::size(inputs),
+ kDepthformerOutputs, std::size(kDepthformerOutputs));
+
+ // The code is sampled from the first codebook_size logits, so there have to be at least that many.
+ const auto logits_info = outputs[0]->GetTensorTypeAndShapeInfo();
+ if (logits_info->GetElementType() != ONNX_TENSOR_ELEMENT_DATA_TYPE_FLOAT) {
+ throw std::runtime_error(std::string("Lfm2AudioOutput: the depthformer's logits must be float, got ") +
+ TypeToString(logits_info->GetElementType()) + ".");
+ }
+ const auto logits_shape = logits_info->GetShape();
+ if (logits_shape.empty() || logits_shape.back() < config_.codebook_size) {
+ throw std::runtime_error("Lfm2AudioOutput: model.audio_output.codebook_size is " +
+ std::to_string(config_.codebook_size) + ", but the depthformer produces " +
+ std::to_string(logits_shape.empty() ? 0 : logits_shape.back()) + " logits.");
+ }
+ const float* logits = outputs[0]->GetTensorData();
+ previous_code = SampleCode({logits, static_cast(config_.codebook_size)});
+ frame.push_back(previous_code);
+ if (step == 0) {
+ slices = std::move(outputs[1]);
+ }
+ keys = std::move(outputs[2]);
+ values = std::move(outputs[3]);
+ }
+ return frame;
+}
+
+int64_t Lfm2AudioOutput::SampleCode(std::span logits) {
+ if (temperature_ <= 0.0f || top_k_ == 1) {
+ return std::max_element(logits.begin(), logits.end()) - logits.begin();
+ }
+
+ std::vector order(logits.size());
+ std::iota(order.begin(), order.end(), int64_t{0});
+ const size_t kept = top_k_ > 0 ? std::min(static_cast(top_k_), order.size()) : order.size();
+ std::partial_sort(order.begin(), order.begin() + kept, order.end(),
+ [&](int64_t a, int64_t b) { return logits[a] > logits[b]; });
+
+ std::vector weights(kept);
+ const float highest = logits[order[0]];
+ for (size_t i = 0; i < kept; ++i) {
+ weights[i] = std::exp(static_cast(logits[order[i]] - highest) / temperature_);
+ }
+ std::discrete_distribution distribution(weights.begin(), weights.end());
+ return order[distribution(rng_)];
+}
+
+void Lfm2AudioOutput::EmbedFrame(std::span frame) {
+ // Every codebook has its own rows of the one embedding table.
+ std::vector rows(frame.size());
+ for (size_t codebook = 0; codebook < frame.size(); ++codebook) {
+ rows[codebook] = frame[codebook] + static_cast(codebook) * config_.codebook_size;
+ }
+ const auto codes = MakeTensor(rows, {1, static_cast(rows.size())});
+ const OrtValue* inputs[] = {codes.get()};
+ auto outputs = model_.audio_embedding_session_->Run(nullptr, kEmbeddingInputs, inputs, std::size(inputs),
+ kEmbeddingOutputs, std::size(kEmbeddingOutputs));
+
+ const auto info = outputs[0]->GetTensorTypeAndShapeInfo();
+ const auto shape = info->GetShape(); // [1, num_codebooks, hidden_size]
+ if (info->GetElementType() != ONNX_TENSOR_ELEMENT_DATA_TYPE_FLOAT || shape.empty() ||
+ info->GetElementCount() != frame.size() * static_cast(shape.back())) {
+ throw std::runtime_error("Lfm2AudioOutput: the audio embedding model must produce one float embedding per codebook.");
+ }
+ const size_t width = static_cast(shape.back());
+ const float* embeddings = outputs[0]->GetTensorData();
+ pending_embedding_.assign(width, 0.0f);
+ for (size_t codebook = 0; codebook < frame.size(); ++codebook) {
+ for (size_t i = 0; i < width; ++i) {
+ pending_embedding_[i] += embeddings[codebook * width + i];
+ }
+ }
+}
+
+OrtValue* Lfm2AudioOutput::GetAudioCodes() {
+ const int64_t num_frames = static_cast(audio_codes_.size()) / config_.num_codebooks;
+ audio_codes_tensor_ = MakeTensor(audio_codes_, {num_frames, config_.num_codebooks});
+ return audio_codes_tensor_.get();
+}
+
+} // namespace Generators
diff --git a/src/models/lfm2_audio_output.h b/src/models/lfm2_audio_output.h
new file mode 100644
index 0000000000..f13b8538cd
--- /dev/null
+++ b/src/models/lfm2_audio_output.h
@@ -0,0 +1,83 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+#pragma once
+
+#include
+#include
+#include
+#include
+#include
+
+#include "models/model.h"
+
+namespace Generators {
+
+struct MultiModalLanguageModel;
+
+// Speech output of LFM2-Audio, as liquid-audio's generate_sequential and generate_interleaved
+// produce it.
+//
+// The decoder emits one hidden state per step. While the answer is text the search samples a token
+// from the logits as usual; while it is speech the depthformer turns that hidden state into one
+// audio frame of num_codebooks codes instead, and the sum of the codes' embeddings is the decoder's
+// next input. The generator's sequence records each frame as one model.audio_token_id placeholder,
+// so lengths and stopping work unchanged, and the codes themselves are read with
+// generator.get_output("audio_codes").
+//
+// The turn passes to speech on <|audio_start|> and back on the end-of-audio code. With
+// search.audio_interleaved it also alternates by count, interleaved_n_text tokens against
+// interleaved_n_audio frames, until <|text_end|> leaves the rest of the turn to speech.
+struct Lfm2AudioOutput {
+ Lfm2AudioOutput(const MultiModalLanguageModel& model, const GeneratorParams& params);
+
+ // Called at the top of every pipeline run with the tokens the search just chose.
+ void BeginStep(DeviceSpan& next_tokens, bool is_prompt);
+
+ // True when the decoder's next input is the last audio frame rather than a token.
+ bool HasPendingFrame() const { return !pending_embedding_.empty(); }
+ // Overwrites the decoder's [1, 1, hidden_size] input with the last frame's embedding.
+ void WritePendingFrame(OrtValue& inputs_embeds);
+
+ bool InAudio() const { return modality_ == Modality::Audio; }
+ // Samples the next frame from the decoder's last hidden state and returns logits that make the
+ // search pick the placeholder token.
+ DeviceSpan SampleFrame(OrtValue& hidden_states);
+
+ // Every frame so far, [num_frames, num_codebooks] int64, end-of-audio frames left out.
+ OrtValue* GetAudioCodes();
+
+ private:
+ enum struct Modality { Text,
+ Audio };
+
+ std::vector RunDepthformer(std::span hidden);
+ int64_t SampleCode(std::span logits);
+ void EmbedFrame(std::span frame);
+
+ const MultiModalLanguageModel& model_;
+ const Config::Model::AudioOutput& config_;
+ const bool interleaved_;
+ const float temperature_;
+ const int top_k_;
+ const int32_t placeholder_token_id_;
+ std::mt19937 rng_;
+
+ // Depthformer geometry, read off the graph.
+ int64_t hidden_size_{};
+ int64_t depth_size_{};
+ int64_t num_layers_{};
+ int64_t num_kv_heads_{};
+ int64_t head_size_{};
+
+ Modality modality_{Modality::Text};
+ bool previous_was_text_{};
+ bool text_done_{};
+ int modality_left_{};
+
+ std::vector pending_embedding_; // the last frame, as the decoder's next input
+ std::vector audio_codes_; // num_frames * num_codebooks
+ std::unique_ptr audio_codes_tensor_;
+ DeviceSpan placeholder_logits_;
+};
+
+} // namespace Generators
diff --git a/src/models/model.cpp b/src/models/model.cpp
index 4c6f465719..321d8ca0ce 100644
--- a/src/models/model.cpp
+++ b/src/models/model.cpp
@@ -949,6 +949,8 @@ std::shared_ptr CreateModel(OrtEnv& ort_env, std::unique_ptr conf
return std::make_shared(std::move(config), ort_env);
if (ModelType::IsTDT(config->model.type))
return std::make_shared(std::move(config), ort_env);
+ if (config->model.type == "lfm2_audio")
+ return std::make_shared(std::move(config), ort_env, /*vision=*/false, /*speech=*/true);
if (ModelType::IsALM(config->model.type))
return std::make_shared(std::move(config), ort_env);
if (ModelType::IsVLM(config->model.type))
diff --git a/src/models/model_type.h b/src/models/model_type.h
index 4690ba4f67..d5e9ea07be 100644
--- a/src/models/model_type.h
+++ b/src/models/model_type.h
@@ -17,7 +17,7 @@ namespace Generators {
struct ModelType {
inline static bool IsLLM(const std::string& model_type) {
// Large-language model (LLM)
- static constexpr std::array LLM = {"chatglm", "decoder", "ernie4_5", "gemma", "gemma2", "gemma3_text", "gemma3_vl_text", "gemma4_text", "gpt2", "gptoss", "granite", "granitemoehybrid", "hunyuandensev1", "internlm2", "lfm2", "lfm2_moe", "lfm2_vl_text", "llama", "mistral", "mistral3_text", "nemotron", "olmo", "phi", "phimoe", "phi3", "phi3small", "qwen2", "qwen2_5_vl_text", "qwen3", "qwen3_vl_text", "qwen3_5_moe_text", "qwen3_5_text", "smollm3"};
+ static constexpr std::array LLM = {"chatglm", "decoder", "ernie4_5", "gemma", "gemma2", "gemma3_text", "gemma3_vl_text", "gemma4_text", "gpt2", "gptoss", "granite", "granitemoehybrid", "hunyuandensev1", "internlm2", "lfm2", "lfm2_audio_text", "lfm2_moe", "lfm2_vl_text", "llama", "mistral", "mistral3_text", "nemotron", "olmo", "phi", "phimoe", "phi3", "phi3small", "qwen2", "qwen2_5_vl_text", "qwen3", "qwen3_vl_text", "qwen3_5_moe_text", "qwen3_5_text", "smollm3"};
return std::find(LLM.begin(), LLM.end(), model_type) != LLM.end();
}
@@ -28,8 +28,9 @@ struct ModelType {
}
inline static bool IsALM(const std::string& model_type) {
- // Audio-language model (ALM)
- static constexpr std::array ALM = {"whisper"};
+ // Audio-language model (ALM): audio in, text out. "lfm2_audio" runs the multi-modal pipeline
+ // (speech encoder -> embedding -> LFM2 decoder), "whisper" its own encoder-decoder model.
+ static constexpr std::array ALM = {"lfm2_audio", "whisper"};
return std::find(ALM.begin(), ALM.end(), model_type) != ALM.end();
}
@@ -86,9 +87,10 @@ struct ModelType {
inline static bool IsLFM2(const std::string& model_type) {
// Liquid Foundation Model 2: hybrid attention/conv architecture with conv state cache.
// The MoE variant (LFM2-8B-A1B, LFM2.5-8B-A1B, LFM2-24B-A2B) has the same runtime shape, and
- // "lfm2_vl_text" is an LFM2-VL checkpoint exported as a plain text decoder; both have the same
- // conv/attention layer mix and need the same conv state cache.
- return model_type == "lfm2" || model_type == "lfm2_moe" || model_type == "lfm2_vl_text";
+ // "lfm2_vl_text" / "lfm2_audio_text" are LFM2-VL / LFM2-Audio checkpoints exported as plain text
+ // decoders; all have the same conv/attention layer mix and need the same conv state cache.
+ return model_type == "lfm2" || model_type == "lfm2_moe" || model_type == "lfm2_vl_text" ||
+ model_type == "lfm2_audio_text";
}
};
diff --git a/src/models/multi_modal.cpp b/src/models/multi_modal.cpp
index 911f284b6f..c1982fb235 100644
--- a/src/models/multi_modal.cpp
+++ b/src/models/multi_modal.cpp
@@ -95,8 +95,51 @@ int64_t GetImageFeatureBatchSize(const std::vector& extra_inputs) {
} // namespace
+void CheckLfm2AudioSessionDevices(const Config& config, DeviceType decoder_device, DeviceType inputs_device,
+ bool with_audio) {
+ // Whether a buffer allocated on the device is device memory. OpenVINO allocates from the CPU and
+ // QNN from shared memory, both of which a CPU session can use.
+ const auto is_device_memory = [](DeviceType device) {
+ switch (device) {
+ case DeviceType::CUDA:
+ case DeviceType::DML:
+ case DeviceType::WEBGPU:
+ case DeviceType::NvTensorRtRtx:
+ case DeviceType::RyzenAI:
+ case DeviceType::AMDGPU:
+ return true;
+ default:
+ return false;
+ }
+ };
+ const auto check = [](const std::optional& options, const char* graph,
+ const char* buffers, DeviceType device) {
+ // Without session_options of its own a graph follows the decoder; with them, no provider but CPU
+ // leaves it on CPU.
+ if (!options.has_value() || !std::all_of(options->providers.begin(), options->providers.end(),
+ [](const std::string& provider) { return provider == "CPU"; })) {
+ return;
+ }
+ throw std::runtime_error(std::string("lfm2_audio: model.") + graph + ".session_options run the " + graph +
+ " model on CPU, but " + buffers + " in " + to_string(device) +
+ " memory, which it cannot use. Remove model." + graph +
+ ".session_options so that it runs on the decoder's device.");
+ };
+
+ if (is_device_memory(inputs_device)) {
+ check(config.model.embedding.session_options, "embedding", "the decoder takes its inputs", inputs_device);
+ }
+ if (with_audio && is_device_memory(decoder_device)) {
+ check(config.model.speech.session_options, "speech", "the audio features are passed", decoder_device);
+ check(config.model.embedding.session_options, "embedding", "the audio features are passed", decoder_device);
+ }
+}
+
MultiModalLanguageModel::MultiModalLanguageModel(std::unique_ptr config, OrtEnv& ort_env, bool vision, bool speech)
: Model(std::move(config)) {
+ if (config_->model.type == "lfm2_audio") {
+ CheckLfm2AudioSessionDevices(*config_, p_device_->GetType(), p_device_inputs_->GetType(), /*with_audio=*/false);
+ }
// The non-decoder models don't support graph capture because of control flow nodes, so disable graph capture for them
if (vision) {
vision_session_options_ = OrtSessionOptions::Create();
@@ -116,6 +159,28 @@ MultiModalLanguageModel::MultiModalLanguageModel(std::unique_ptr config,
embedding_session_ = CreateSession(ort_env, config_->model.embedding.filename, embedding_session_options_.get());
decoder_session_ = CreateSession(ort_env, config_->model.decoder.filename, session_options_.get());
+ const auto& audio_output = config_->model.audio_output;
+ if (!audio_output.depthformer.filename.empty() || !audio_output.embedding.filename.empty()) {
+ if (audio_output.depthformer.filename.empty() || audio_output.embedding.filename.empty()) {
+ throw std::runtime_error("model.audio_output needs both depthformer.filename and embedding.filename.");
+ }
+ // With speech output these tokens hand the turn to the depthformer; as stop tokens they would end
+ // it there instead, which is what a text-only export needs and this one must not have.
+ for (const int token : {audio_output.audio_start_token_id, audio_output.text_end_token_id}) {
+ if (std::find(config_->model.eos_token_id.begin(), config_->model.eos_token_id.end(), token) !=
+ config_->model.eos_token_id.end()) {
+ throw std::runtime_error("model.eos_token_id holds " + std::to_string(token) +
+ ", which model.audio_output uses to start speech. Remove it from eos_token_id.");
+ }
+ }
+ depthformer_session_options_ = OrtSessionOptions::Create();
+ CreateSessionOptionsFromConfig(audio_output.depthformer.session_options.has_value() ? audio_output.depthformer.session_options.value() : config_->model.decoder.session_options, *depthformer_session_options_, true, /*disable_graph_capture=*/true);
+ depthformer_session_ = CreateSession(ort_env, audio_output.depthformer.filename, depthformer_session_options_.get());
+ audio_embedding_session_options_ = OrtSessionOptions::Create();
+ CreateSessionOptionsFromConfig(audio_output.embedding.session_options.has_value() ? audio_output.embedding.session_options.value() : config_->model.decoder.session_options, *audio_embedding_session_options_, true, /*disable_graph_capture=*/true);
+ audio_embedding_session_ = CreateSession(ort_env, audio_output.embedding.filename, audio_embedding_session_options_.get());
+ }
+
session_info_.Add(*decoder_session_);
session_info_.Add(*embedding_session_);
if (speech) {
@@ -603,6 +668,154 @@ DeviceSpan SpeechState::Run(int current_length, DeviceSpan& next
return {};
}
+void Lfm2AudioSpeechState::SetExtraInputs(const std::vector& extra_inputs, const int64_t num_audio_tokens) {
+ // The feature buffer is one sequence wide, so beams would fail as a shape mismatch inside the
+ // encoder. The reference decodes greedily.
+ if (params_->search.num_beams > 1) {
+ throw std::runtime_error("Lfm2AudioSpeechState: beam search is not supported for lfm2_audio; got num_beams " +
+ std::to_string(params_->search.num_beams) + ". Set num_beams to 1.");
+ }
+
+ SpeechState::SetExtraInputs(extra_inputs, num_audio_tokens);
+
+ // audio_sizes holds the decoder tokens each clip contributes; its sum is num_audio_tokens.
+ tokens_per_clip_.clear();
+ for (const auto& input : extra_inputs) {
+ if (input.name == model_.config_->model.speech.inputs.audio_sizes) {
+ const auto info = input.tensor->ort_tensor_->GetTensorTypeAndShapeInfo();
+ const int64_t* sizes = input.tensor->ort_tensor_->GetTensorData();
+ tokens_per_clip_.assign(sizes, sizes + info->GetElementCount());
+ break;
+ }
+ }
+}
+
+Lfm2AudioSpeechState::SpeechBindings Lfm2AudioSpeechState::ResolveBindings() const {
+ SpeechBindings bindings;
+ bindings.mel_index = FindInput(model_.config_->model.speech.inputs.audio_embeds);
+ bindings.lengths_index = FindInput(model_.config_->model.speech.inputs.audio_lengths);
+ bindings.features_index = FindOutput(model_.config_->model.speech.outputs.audio_features);
+
+ const auto mel_info = inputs_[bindings.mel_index]->GetTensorTypeAndShapeInfo();
+ const auto mel_shape = mel_info->GetShape(); // [num_clips, longest_clip, num_mels]
+ if (mel_shape.size() != 3) {
+ throw std::runtime_error("Lfm2AudioSpeechState: expected a 3D [num_clips, num_frames, num_mels] mel tensor, got rank " +
+ std::to_string(mel_shape.size()) + ".");
+ }
+ bindings.num_clips = mel_shape[0];
+ bindings.longest_clip = mel_shape[1];
+ bindings.num_mels = mel_shape[2];
+ bindings.mel_type = mel_info->GetElementType();
+
+ if (bindings.num_clips != static_cast(tokens_per_clip_.size())) {
+ throw std::runtime_error("Lfm2AudioSpeechState: the mel tensor holds " + std::to_string(bindings.num_clips) +
+ " clips but audio_sizes has " + std::to_string(tokens_per_clip_.size()) + " entries.");
+ }
+ const auto lengths_info = inputs_[bindings.lengths_index]->GetTensorTypeAndShapeInfo();
+ if (static_cast(lengths_info->GetElementCount()) != bindings.num_clips) {
+ throw std::runtime_error("Lfm2AudioSpeechState: the mel tensor holds " + std::to_string(bindings.num_clips) +
+ " clips but " + model_.config_->model.speech.inputs.audio_lengths + " has " +
+ std::to_string(lengths_info->GetElementCount()) + " entries.");
+ }
+
+ const auto features_info = outputs_[bindings.features_index]->GetTensorTypeAndShapeInfo();
+ const auto features_shape = features_info->GetShape(); // [1, num_audio_tokens, hidden_size]
+ bindings.features_type = features_info->GetElementType();
+ bindings.hidden_size = features_shape.back();
+ return bindings;
+}
+
+std::unique_ptr Lfm2AudioSpeechState::RunClip(const SpeechBindings& bindings, int64_t index,
+ int64_t num_frames) {
+ const size_t mel_element_size = Ort::SizeOf(bindings.mel_type);
+ const size_t clip_stride = static_cast(bindings.longest_clip * bindings.num_mels) * mel_element_size;
+ const auto* mel_data = static_cast(inputs_[bindings.mel_index]->GetTensorRawData());
+
+ auto clip_mel = OrtValue::CreateTensor(Ort::Allocator::GetWithDefaultOptions(),
+ std::vector{1, num_frames, bindings.num_mels}, bindings.mel_type);
+ std::memcpy(clip_mel->GetTensorMutableRawData(), mel_data + static_cast(index) * clip_stride,
+ static_cast(num_frames * bindings.num_mels) * mel_element_size);
+
+ auto clip_length = OrtValue::CreateTensor(Ort::Allocator::GetWithDefaultOptions(), std::vector{1});
+ clip_length->GetTensorMutableData()[0] = num_frames;
+
+ auto clip_features = OrtValue::CreateTensor(
+ model_.p_device_->GetAllocator(),
+ std::vector{1, tokens_per_clip_[static_cast(index)], bindings.hidden_size},
+ bindings.features_type);
+
+ OrtValue* mel_batch = inputs_[bindings.mel_index];
+ OrtValue* lengths = inputs_[bindings.lengths_index];
+ OrtValue* features = outputs_[bindings.features_index];
+ inputs_[bindings.mel_index] = clip_mel.get();
+ inputs_[bindings.lengths_index] = clip_length.get();
+ outputs_[bindings.features_index] = clip_features.get();
+ State::Run(*model_.speech_session_);
+ inputs_[bindings.mel_index] = mel_batch;
+ inputs_[bindings.lengths_index] = lengths;
+ outputs_[bindings.features_index] = features;
+
+ return clip_features;
+}
+
+DeviceSpan Lfm2AudioSpeechState::Run(int current_length, DeviceSpan& next_tokens, DeviceSpan next_indices) {
+ CheckLfm2AudioSessionDevices(*model_.config_, model_.p_device_->GetType(), model_.p_device_inputs_->GetType(),
+ /*with_audio=*/true);
+ if (model_.config_->model.speech.run_options.has_value()) {
+ State::SetRunOptions(model_.config_->model.speech.run_options.value());
+ }
+ // A single clip fills the whole mel tensor and the whole feature buffer; run it as it stands.
+ if (tokens_per_clip_.size() <= 1) {
+ State::Run(*model_.speech_session_);
+ return {};
+ }
+
+ const SpeechBindings bindings = ResolveBindings();
+ const int64_t* frames_per_clip = inputs_[bindings.lengths_index]->GetTensorData();
+ auto features_bytes = ByteWrapTensor(*model_.p_device_, *outputs_[bindings.features_index]);
+ const size_t feature_row_bytes = static_cast(bindings.hidden_size) * Ort::SizeOf(bindings.features_type);
+ size_t destination = 0;
+
+ // The published encoder export is traced for one clip (its subsampling mask cannot broadcast over
+ // a batch), so run it once per clip on that clip's own frames — which also keeps the padding out
+ // of the encoder entirely — and concatenate the results in clip order.
+ for (int64_t clip = 0; clip < bindings.num_clips; ++clip) {
+ const int64_t num_frames = frames_per_clip[clip];
+ if (num_frames <= 0 || num_frames > bindings.longest_clip) {
+ throw std::runtime_error("Lfm2AudioSpeechState: clip " + std::to_string(clip) + " reports " +
+ std::to_string(num_frames) + " mel frames, outside the 1.." +
+ std::to_string(bindings.longest_clip) + " the mel tensor holds.");
+ }
+
+ auto clip_features = RunClip(bindings, clip, num_frames);
+ const size_t clip_bytes = static_cast(tokens_per_clip_[static_cast(clip)]) * feature_row_bytes;
+ features_bytes.subspan(destination, clip_bytes).CopyFrom(ByteWrapTensor(*model_.p_device_, *clip_features));
+ destination += clip_bytes;
+ }
+ return {};
+}
+
+size_t Lfm2AudioSpeechState::FindInput(const std::string& name) const {
+ for (size_t i = 0; i < input_names_.size(); ++i) {
+ if (name == input_names_[i]) return i;
+ }
+ throw std::runtime_error("Lfm2AudioSpeechState: speech input \"" + name + "\" is not bound.");
+}
+
+size_t Lfm2AudioSpeechState::FindOutput(const std::string& name) const {
+ for (size_t i = 0; i < output_names_.size(); ++i) {
+ if (name == output_names_[i]) return i;
+ }
+ throw std::runtime_error("Lfm2AudioSpeechState: speech output \"" + name + "\" is not bound.");
+}
+
+std::unique_ptr CreateSpeechState(const MultiModalLanguageModel& model, const GeneratorParams& params) {
+ if (model.config_->model.type == "lfm2_audio") {
+ return std::make_unique(model, params);
+ }
+ return std::make_unique(model, params);
+}
+
EmbeddingState::EmbeddingState(const MultiModalLanguageModel& model, const GeneratorParams& params)
: State{params, model},
model_{model} {
@@ -818,10 +1031,13 @@ MultiModalPipelineState::MultiModalPipelineState(const MultiModalLanguageModel&
vision_state_ = CreateVisionState(model_, params);
}
if (model_.speech_session_) {
- speech_state_ = std::make_unique(model_, params);
+ speech_state_ = CreateSpeechState(model_, params);
}
embedding_state_ = std::make_unique(model, params);
decoder_state_ = std::make_unique(model_, sequence_lengths, params);
+ if (model_.depthformer_session_) {
+ audio_output_ = std::make_unique(model_, params);
+ }
if (vision_state_ != nullptr && model_.config_->model.vision.adapter_filename.has_value() && num_image_tokens_ > 0) {
const auto lora_adapter = (model_.config_->config_path / fs::path(*model_.config_->model.vision.adapter_filename)).string();
@@ -877,6 +1093,9 @@ DeviceSpan MultiModalPipelineState::Run(int current_length, DeviceSpan |embeddings_model| -> inputs_embeds
// - inputs_embeds -> |decoder_model| -> logits
+ if (audio_output_) {
+ audio_output_->BeginStep(next_tokens, is_prompt_);
+ }
embedding_state_->UpdateInputsOutputs(next_tokens, is_prompt_);
// Prefill chunking (search.chunk_size): during the prompt stage the decoder can process the
@@ -930,15 +1149,36 @@ DeviceSpan MultiModalPipelineState::Run(int current_length, DeviceSpaninputs_embeds_.ReuseEmbeddingsBuffer(decoder_state_->inputs_embeds_);
if (embedding_state_->per_layer_inputs_ && decoder_state_->per_layer_inputs_) {
embedding_state_->per_layer_inputs_->ReuseEmbeddingsBuffer(*decoder_state_->per_layer_inputs_);
}
- embedding_state_->Run(current_length, next_tokens, next_indices);
- return decoder_state_->Run(current_length, next_tokens, next_indices);
+ if (audio_output_ && audio_output_->HasPendingFrame()) {
+ // The token is only the placeholder of the last audio frame: the decoder takes the frame itself,
+ // and the embedding model would look for audio features to put in the placeholder's place.
+ audio_output_->WritePendingFrame(*decoder_state_->inputs_embeds_.Get());
+ } else {
+ embedding_state_->Run(current_length, next_tokens, next_indices);
+ }
+ auto logits = decoder_state_->Run(current_length, next_tokens, next_indices);
+ return audio_output_ ? SampleAudioOrText(logits) : logits;
+}
+
+DeviceSpan MultiModalPipelineState::SampleAudioOrText(DeviceSpan logits) {
+ if (!audio_output_->InAudio()) {
+ return logits;
+ }
+ const std::string& configured = model_.config_->model.decoder.outputs.hidden_states;
+ const std::string name = configured.empty() ? std::string(Config::Defaults::HiddenStatesName) : configured;
+ OrtValue* hidden_states = decoder_state_->GetOutput(name.c_str());
+ if (!hidden_states) {
+ throw std::runtime_error("Speech output needs the decoder's \"" + name +
+ "\" output. Build the decoder with --extra_options include_hidden_states=true.");
+ }
+ return audio_output_->SampleFrame(*hidden_states);
}
OrtValue* MultiModalPipelineState::GetInput(const char* name) {
@@ -978,6 +1218,10 @@ OrtValue* MultiModalPipelineState::GetInput(const char* name) {
};
OrtValue* MultiModalPipelineState::GetOutput(const char* name) {
+ if (audio_output_ && std::strcmp(name, "audio_codes") == 0) {
+ return audio_output_->GetAudioCodes();
+ }
+
if (vision_state_) {
// Check if output name is in vision state's outputs
for (size_t i = 0; i < vision_state_->output_names_.size(); i++) {
diff --git a/src/models/multi_modal.h b/src/models/multi_modal.h
index 7726f292b8..35d0554b0c 100644
--- a/src/models/multi_modal.h
+++ b/src/models/multi_modal.h
@@ -19,9 +19,17 @@
#include "models/io/position_inputs.h"
#include "model_type.h"
#include "models/io/recurrent_state.h"
+#include "models/lfm2_audio_output.h"
namespace Generators {
+// lfm2_audio exchanges tensors with its embedding and speech sessions in buffers allocated on the
+// decoder's devices. Throws if either has session_options of its own that leave it on CPU while a
+// buffer it would be handed is device memory, which the session would treat as host memory and
+// corrupt: the decoder's inputs for the embedding session, and with_audio, the audio features for both.
+void CheckLfm2AudioSessionDevices(const Config& config, DeviceType decoder_device, DeviceType inputs_device,
+ bool with_audio);
+
struct MultiModalLanguageModel : Model {
MultiModalLanguageModel(std::unique_ptr config, OrtEnv& ort_env, bool vision, bool speech);
MultiModalLanguageModel(const MultiModalLanguageModel&) = delete;
@@ -37,6 +45,12 @@ struct MultiModalLanguageModel : Model {
std::unique_ptr vision_session_options_;
std::unique_ptr speech_session_options_;
std::unique_ptr embedding_session_options_;
+
+ // LFM2-Audio speech output, present when model.audio_output names the two graphs.
+ std::unique_ptr depthformer_session_; // hidden_states -> one audio code per run, a frame in num_codebooks runs
+ std::unique_ptr audio_embedding_session_; // audio_codes -> audio_embeds, summed into the decoder's next input
+ std::unique_ptr depthformer_session_options_;
+ std::unique_ptr audio_embedding_session_options_;
};
// Base VisionState: runs vision.onnx with a single State::Run() call.
@@ -133,10 +147,10 @@ struct SpeechState : State {
SpeechState(const SpeechState&) = delete;
SpeechState& operator=(const SpeechState&) = delete;
- void SetExtraInputs(const std::vector& extra_inputs, const int64_t num_audio_tokens);
+ virtual void SetExtraInputs(const std::vector& extra_inputs, const int64_t num_audio_tokens);
DeviceSpan Run(int current_length, DeviceSpan& next_tokens, DeviceSpan next_indices = {}) override;
- private:
+ protected:
friend struct MultiModalPipelineState;
const MultiModalLanguageModel& model_;
@@ -145,6 +159,49 @@ struct SpeechState : State {
std::unique_ptr audio_features_;
};
+// Lfm2AudioSpeechState: per-clip encoder loop for LFM2-Audio.
+//
+// The processor stacks the clips of one prompt into a zero-padded [N, T_max, num_mels] mel tensor
+// with their real frame counts alongside. The published encoder export is traced for a single clip
+// (its subsampling mask cannot broadcast over a batch), so with several clips this subclass slices
+// each clip's own frames out of that tensor, runs the encoder on [1, T_i, num_mels], and writes the
+// results one after another into the contiguous [1, total_tokens, hidden] feature buffer the
+// embedding model expects, in clip order.
+struct Lfm2AudioSpeechState : SpeechState {
+ using SpeechState::SpeechState; // inherit constructor
+
+ void SetExtraInputs(const std::vector& extra_inputs, const int64_t num_audio_tokens) override;
+ DeviceSpan Run(int current_length, DeviceSpan& next_tokens, DeviceSpan next_indices = {}) override;
+
+ private:
+ // Where the processor's staged mel tensor and the encoder's feature buffer are bound.
+ struct SpeechBindings {
+ size_t mel_index{}; // the [num_clips, longest, num_mels] staging tensor
+ size_t lengths_index{}; // its per-clip frame counts
+ size_t features_index{}; // the [1, total_tokens, hidden] buffer the embedding model reads
+ int64_t num_clips{};
+ int64_t longest_clip{};
+ int64_t num_mels{};
+ int64_t hidden_size{};
+ ONNXTensorElementDataType mel_type{};
+ ONNXTensorElementDataType features_type{};
+ };
+
+ // Reads the bound shapes and checks them against the per-clip token counts.
+ SpeechBindings ResolveBindings() const;
+
+ // Runs the encoder on clip `index`'s own frames and returns its features.
+ std::unique_ptr RunClip(const SpeechBindings& bindings, int64_t index, int64_t num_frames);
+
+ size_t FindInput(const std::string& name) const;
+ size_t FindOutput(const std::string& name) const;
+
+ std::vector tokens_per_clip_;
+};
+
+// Factory: pick the right SpeechState subclass based on model type.
+std::unique_ptr CreateSpeechState(const MultiModalLanguageModel& model, const GeneratorParams& params);
+
struct EmbeddingState : State {
EmbeddingState(const MultiModalLanguageModel& model, const GeneratorParams& params);
EmbeddingState(const EmbeddingState&) = delete;
@@ -221,6 +278,8 @@ struct MultiModalPipelineState : State {
private:
void UpdateInputsOutputs(const DeviceSpan& next_tokens, DeviceSpan next_indices,
int current_length);
+ // The decoder's logits while the answer is text, or the next audio frame's placeholder while it is speech.
+ DeviceSpan SampleAudioOrText(DeviceSpan logits);
const MultiModalLanguageModel& model_;
int64_t num_image_tokens_{};
@@ -230,6 +289,7 @@ struct MultiModalPipelineState : State {
std::unique_ptr speech_state_;
std::unique_ptr embedding_state_;
std::unique_ptr decoder_state_;
+ std::unique_ptr audio_output_; // LFM2-Audio speech output, when the model has it
std::shared_ptr adapters_;
bool is_prompt_{true};
diff --git a/src/models/preprocessing/genai_tokenizer.cpp b/src/models/preprocessing/genai_tokenizer.cpp
index 53309dc134..783c04bf57 100644
--- a/src/models/preprocessing/genai_tokenizer.cpp
+++ b/src/models/preprocessing/genai_tokenizer.cpp
@@ -114,6 +114,12 @@ std::vector Tokenizer::Encode(const char* text) const {
}
std::string Tokenizer::Decode(std::span tokens) const {
+ // The detokenizer dies with SIGFPE on an empty sequence, which a generation that stops on its
+ // first token produces.
+ if (tokens.empty()) {
+ return {};
+ }
+
OrtxPtr ortx_string_array;
CheckResult(OrtxDetokenize1D(tokenizer_, reinterpret_cast(tokens.data()), tokens.size(), ortx_string_array.Address()));
diff --git a/src/models/preprocessing/lfm2_audio_processor.cpp b/src/models/preprocessing/lfm2_audio_processor.cpp
new file mode 100644
index 0000000000..11343f1f8c
--- /dev/null
+++ b/src/models/preprocessing/lfm2_audio_processor.cpp
@@ -0,0 +1,409 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+#include "generator/generators.h"
+#include "models/model.h"
+#include "models/preprocessing/genai_tokenizer.h"
+#include "models/preprocessing/lfm2_audio_processor.h"
+#include "nemo_mel_spectrogram.h"
+
+#include
+#include
+#include
+#include
+
+namespace Generators {
+
+namespace {
+
+// The list overload of MultiModalProcessor::Process fills payload.prompts and leaves payload.prompt
+// empty. Batching is not supported, so a single-entry list is the prompt and anything longer is an error
+// rather than silently dropped text.
+std::string ResolvePrompt(const Payload& payload) {
+ if (payload.prompts.empty()) {
+ return payload.prompt;
+ }
+ if (payload.prompts.size() != 1) {
+ throw std::runtime_error("Lfm2AudioProcessor: batched prompts are not supported; got " +
+ std::to_string(payload.prompts.size()) + " prompts. Pass a single prompt string.");
+ }
+ return payload.prompts[0] ? std::string(payload.prompts[0]) : std::string{};
+}
+
+// The encoder graph's input names come from genai_config.json; a wrong name would otherwise be
+// dropped silently by ExtraInputs and only surface as ORT's "Missing Input" at run time.
+ONNXTensorElementDataType SpeechInputType(const SessionInfo& session_info, const std::string& name,
+ const char* config_field) {
+ if (!session_info.HasInput(name)) {
+ throw std::runtime_error("Lfm2AudioProcessor: the speech model has no input named \"" + name +
+ "\". Point model.speech.inputs." + config_field +
+ " in genai_config.json at the name the speech model uses.");
+ }
+ return session_info.GetInputDataType(name);
+}
+
+std::unique_ptr MakeInt64Tensor(const std::vector& values, std::vector shape,
+ Ort::Allocator& allocator) {
+ auto tensor = OrtValue::CreateTensor(allocator, shape);
+ std::copy(values.begin(), values.end(), tensor->GetTensorMutableData());
+ return tensor;
+}
+
+std::unique_ptr MakeInputIds(const std::vector& input_ids, Ort::Allocator& allocator) {
+ auto tensor = OrtValue::CreateTensor(allocator, std::vector{1, static_cast(input_ids.size())});
+ std::copy(input_ids.begin(), input_ids.end(), tensor->GetTensorMutableData());
+ return tensor;
+}
+
+// torch.hann_window(win_length, periodic=False), the window NeMo's FilterbankFeatures registers.
+std::vector SymmetricHannWindow(int win_length) {
+ constexpr double kPi = 3.14159265358979323846;
+ std::vector window(static_cast(win_length));
+ for (int n = 0; n < win_length; ++n) {
+ window[static_cast(n)] = static_cast(0.5 * (1.0 - std::cos(2.0 * kPi * n / (win_length - 1))));
+ }
+ return window;
+}
+
+} // namespace
+
+int64_t Lfm2AudioNumMelFrames(int64_t num_samples, const Lfm2AudioMelConfig& config) {
+ // torch.stft(center=True): fft_size / 2 zeros on both sides, one frame per hop_length.
+ const int64_t padded = num_samples + 2 * (config.fft_size / 2);
+ if (padded < config.fft_size) {
+ return 0;
+ }
+ return (padded - config.fft_size) / config.hop_length + 1;
+}
+
+int64_t Lfm2AudioNumValidMelFrames(int64_t num_samples, const Lfm2AudioMelConfig& config) {
+ // FilterbankFeatures.get_seq_len: (num_samples + 2 * (fft_size // 2) - fft_size) // hop_length.
+ const int64_t padded = num_samples + 2 * (config.fft_size / 2);
+ if (padded < config.fft_size) {
+ return 0;
+ }
+ return (padded - config.fft_size) / config.hop_length;
+}
+
+int64_t Lfm2AudioNumTokens(int64_t num_mel_frames, int64_t subsampling_factor) {
+ if (subsampling_factor <= 0) {
+ throw std::runtime_error("Lfm2AudioProcessor: subsampling_factor must be positive.");
+ }
+ return (num_mel_frames + subsampling_factor - 1) / subsampling_factor;
+}
+
+std::vector ResampleLfm2Audio(const float* pcm, int64_t num_samples, int64_t source_rate, int64_t target_rate) {
+ if (source_rate <= 0 || target_rate <= 0) {
+ throw std::runtime_error("Lfm2AudioProcessor: cannot resample from " + std::to_string(source_rate) + " Hz to " +
+ std::to_string(target_rate) + " Hz.");
+ }
+ if (source_rate == target_rate) {
+ return {pcm, pcm + num_samples};
+ }
+
+ constexpr double kPi = 3.14159265358979323846;
+ constexpr double kLowpassFilterWidth = 6.0;
+ constexpr double kRolloff = 0.99;
+ const int64_t divisor = std::gcd(source_rate, target_rate);
+ const int64_t orig = source_rate / divisor;
+ const int64_t target = target_rate / divisor;
+ const double base_freq = static_cast(std::min(orig, target)) * kRolloff;
+ const int64_t width = static_cast(std::ceil(kLowpassFilterWidth * static_cast(orig) / base_freq));
+ const int64_t kernel_size = 2 * width + orig;
+
+ // One kernel per output phase, built in double and stored in float as torchaudio does.
+ std::vector kernels(static_cast(target * kernel_size));
+ for (int64_t phase = 0; phase < target; ++phase) {
+ for (int64_t k = 0; k < kernel_size; ++k) {
+ double t = (-static_cast(phase) / static_cast(target) +
+ static_cast(k - width) / static_cast(orig)) *
+ base_freq;
+ t = std::clamp(t, -kLowpassFilterWidth, kLowpassFilterWidth);
+ const double window = std::pow(std::cos(t * kPi / kLowpassFilterWidth / 2.0), 2.0);
+ t *= kPi;
+ const double sinc = t == 0.0 ? 1.0 : std::sin(t) / t;
+ kernels[static_cast(phase * kernel_size + k)] =
+ static_cast(sinc * window * base_freq / static_cast(orig));
+ }
+ }
+
+ std::vector padded(static_cast(width + num_samples + width + orig), 0.0f);
+ std::copy(pcm, pcm + num_samples, padded.begin() + width);
+
+ // ceil(target * num_samples / orig) samples; the strided convolution makes a few more, which
+ // torchaudio trims.
+ const int64_t num_outputs = (target * num_samples + orig - 1) / orig;
+ std::vector resampled(static_cast(num_outputs));
+ for (int64_t i = 0; i < num_outputs; ++i) {
+ const float* frame = padded.data() + (i / target) * orig;
+ const float* kernel = kernels.data() + (i % target) * kernel_size;
+ float value = 0.0f;
+ for (int64_t k = 0; k < kernel_size; ++k) {
+ value += frame[k] * kernel[k];
+ }
+ resampled[static_cast(i)] = value;
+ }
+ return resampled;
+}
+
+std::vector ComputeLfm2AudioMel(const float* pcm, int64_t num_samples, const Lfm2AudioMelConfig& config,
+ int64_t& num_frames) {
+ if (config.num_mels <= 0 || config.fft_size <= 0 || config.hop_length <= 0 || config.win_length < 2 ||
+ config.win_length > config.fft_size) {
+ throw std::runtime_error("Lfm2AudioProcessor: invalid mel configuration (num_mels " + std::to_string(config.num_mels) +
+ ", fft_size " + std::to_string(config.fft_size) + ", win_length " +
+ std::to_string(config.win_length) + ", hop_length " + std::to_string(config.hop_length) + ").");
+ }
+
+ num_frames = Lfm2AudioNumMelFrames(num_samples, config);
+ const int64_t valid_frames = Lfm2AudioNumValidMelFrames(num_samples, config);
+ // The per-feature normalization divides by valid_frames - 1; NeMo raises on a single frame and
+ // produces NaNs on none, so refuse both up front with the clip length that would be needed.
+ if (valid_frames < 2) {
+ throw std::runtime_error("Lfm2AudioProcessor: audio clip is too short (" + std::to_string(num_samples) +
+ " samples at " + std::to_string(config.sample_rate) + " Hz); at least " +
+ std::to_string(2 * config.hop_length) + " samples are needed.");
+ }
+
+ const auto mel_filters = nemo_mel::CreateMelFilterbank(config.num_mels, config.fft_size, config.sample_rate);
+ const auto window = SymmetricHannWindow(config.win_length);
+
+ std::vector preemphasized(static_cast(num_samples));
+ nemo_mel::ApplyPreemphasis(pcm, static_cast(num_samples), config.preemph, 0.0f, preemphasized.data());
+
+ // Constant (zero) padding on both sides: NeMo's stft uses pad_mode="constant".
+ const int64_t pad = config.fft_size / 2;
+ std::vector padded(static_cast(pad + num_samples + pad), 0.0f);
+ std::copy(preemphasized.begin(), preemphasized.end(), padded.begin() + pad);
+
+ // The win_length window sits centered inside the fft_size frame; the power spectrum does not
+ // depend on where the windowed samples land within the FFT buffer.
+ const int64_t win_offset = (config.fft_size - config.win_length) / 2;
+ const int num_bins = config.fft_size / 2 + 1;
+ const auto num_mels = static_cast(config.num_mels);
+
+ std::vector mel(static_cast(num_frames) * num_mels);
+ std::vector magnitudes;
+ for (int64_t t = 0; t < num_frames; ++t) {
+ const float* frame = padded.data() + t * config.hop_length + win_offset;
+ nemo_mel::ComputeSTFTFrame(frame, window.data(), config.win_length, config.fft_size, magnitudes);
+ float* row = mel.data() + static_cast(t) * num_mels;
+ for (size_t m = 0; m < num_mels; ++m) {
+ float value = 0.0f;
+ for (int k = 0; k < num_bins; ++k) {
+ value += mel_filters[m][static_cast(k)] * magnitudes[static_cast(k)];
+ }
+ row[m] = std::log(value + config.log_eps);
+ }
+ }
+
+ // normalize_batch(normalize_type="per_feature"): mean and unbiased standard deviation over the
+ // valid frames only, then the frames past the valid length are set to pad_value (0).
+ for (size_t m = 0; m < num_mels; ++m) {
+ double sum = 0.0;
+ for (int64_t t = 0; t < valid_frames; ++t) {
+ sum += mel[static_cast(t) * num_mels + m];
+ }
+ const float mean = static_cast(sum / static_cast(valid_frames));
+ double variance = 0.0;
+ for (int64_t t = 0; t < valid_frames; ++t) {
+ const double delta = mel[static_cast(t) * num_mels + m] - mean;
+ variance += delta * delta;
+ }
+ const float std = static_cast(std::sqrt(variance / static_cast(valid_frames - 1))) + config.norm_eps;
+ for (int64_t t = 0; t < valid_frames; ++t) {
+ float& value = mel[static_cast(t) * num_mels + m];
+ value = (value - mean) / std;
+ }
+ }
+ std::fill(mel.begin() + static_cast(valid_frames) * num_mels, mel.end(), 0.0f);
+ return mel;
+}
+
+std::vector SplitLfm2AudioPrompt(const std::string& prompt, size_t num_clips) {
+ const std::string marker{kLfm2AudioMarker};
+ std::vector segments;
+ size_t position = 0;
+ for (size_t match = prompt.find(marker, position); match != std::string::npos; match = prompt.find(marker, position)) {
+ segments.push_back(prompt.substr(position, match - position));
+ position = match + marker.size();
+ }
+ segments.push_back(prompt.substr(position));
+
+ // Same rule as the LFM2-VL processor applies to : one marker per clip, in clip order.
+ // Silently inventing or dropping placeholders would misalign the encoder features with the
+ // decoder positions.
+ const size_t num_markers = segments.size() - 1;
+ if (num_markers != num_clips) {
+ throw std::runtime_error("Lfm2AudioProcessor: the prompt contains " + std::to_string(num_markers) + " " + marker +
+ " markers but " + std::to_string(num_clips) + " audio clips were provided. Put exactly one " +
+ marker + " in the prompt per clip.");
+ }
+ return segments;
+}
+
+Lfm2AudioProcessor::Lfm2AudioProcessor(Config& config, const SessionInfo& session_info)
+ : mel_type_{SpeechInputType(session_info, config.model.speech.inputs.audio_embeds, "audio_embeds")},
+ subsampling_factor_{config.model.subsampling_factor > 0 ? config.model.subsampling_factor : 8},
+ audio_token_id_{config.model.audio_token_id} {
+ if (mel_type_ != ONNX_TENSOR_ELEMENT_DATA_TYPE_FLOAT && mel_type_ != ONNX_TENSOR_ELEMENT_DATA_TYPE_FLOAT16) {
+ throw std::runtime_error("Lfm2AudioProcessor: speech input \"" + config.model.speech.inputs.audio_embeds +
+ "\" must be float or float16, got " + TypeToString(mel_type_) + ".");
+ }
+ // The clip lengths are always emitted as int64, so fail at load rather than at the first clip if
+ // the graph was exported with another integer type.
+ const auto lengths_type = SpeechInputType(session_info, config.model.speech.inputs.audio_lengths, "audio_lengths");
+ if (lengths_type != ONNX_TENSOR_ELEMENT_DATA_TYPE_INT64) {
+ throw std::runtime_error("Lfm2AudioProcessor: speech input \"" + config.model.speech.inputs.audio_lengths +
+ "\" must be int64, got " + TypeToString(lengths_type) + ".");
+ }
+ if (audio_token_id_ <= 0) {
+ throw std::runtime_error(
+ "Lfm2AudioProcessor: model.audio_token_id must be set in genai_config.json to the id "
+ "the embedding model replaces with the audio features.");
+ }
+ if (audio_token_id_ >= config.model.vocab_size) {
+ throw std::runtime_error("Lfm2AudioProcessor: model.audio_token_id " + std::to_string(audio_token_id_) +
+ " is outside the vocabulary of " + std::to_string(config.model.vocab_size) + " tokens.");
+ }
+
+ // The mel front end defaults to the settings of the published checkpoints; a genai_config.json can
+ // override any of them with the same top-level fields the Parakeet and Nemotron models use.
+ const auto& m = config.model;
+ if (m.num_mels > 0) mel_config_.num_mels = m.num_mels;
+ if (m.fft_size > 0) mel_config_.fft_size = m.fft_size;
+ if (m.win_length > 0) mel_config_.win_length = m.win_length;
+ if (m.hop_length > 0) mel_config_.hop_length = m.hop_length;
+ if (m.sample_rate > 0) mel_config_.sample_rate = m.sample_rate;
+ if (m.preemph > 0.0f) mel_config_.preemph = m.preemph;
+ if (m.log_eps > 0.0f) mel_config_.log_eps = m.log_eps;
+ if (m.norm_eps > 0.0f) mel_config_.norm_eps = m.norm_eps;
+
+ config.AddMapping(std::string(Config::Defaults::InputIdsName), config.model.embedding.inputs.input_ids);
+ config.AddMapping(std::string(Config::Defaults::AudioEmbedsName), config.model.speech.inputs.audio_embeds);
+ config.AddMapping(std::string(Config::Defaults::AudioLengthsName), config.model.speech.inputs.audio_lengths);
+ config.AddMapping(std::string(Config::Defaults::AudioSizesName), config.model.speech.inputs.audio_sizes);
+}
+
+Lfm2AudioProcessor::ClipMel Lfm2AudioProcessor::ComputeClipMel(const Audios& audios, size_t index) const {
+ // Decoded at the file's own rate and resampled here, the way the reference does it: the decoder's
+ // resampler is a different filter, enough to change a token, and it cannot resample upwards.
+ ort_extensions::OrtxObjectPtr decoded;
+ CheckResult(OrtxDecodeAudio(audios.audios_.get(), index, /*target_sample_rate=*/0, /*stereo_to_mono=*/1,
+ decoded.ToBeAssigned()));
+
+ ort_extensions::OrtxObjectPtr pcm_tensor;
+ CheckResult(OrtxTensorResultGetAt(decoded.get(), 0, pcm_tensor.ToBeAssigned()));
+ const float* pcm{};
+ const int64_t* pcm_shape{};
+ size_t pcm_dims{};
+ CheckResult(OrtxGetTensorData(pcm_tensor.get(), reinterpret_cast(&pcm), &pcm_shape, &pcm_dims));
+
+ int64_t num_samples = 0;
+ if (pcm_dims == 1) {
+ num_samples = pcm_shape[0];
+ } else if (pcm_dims == 2 && pcm_shape[0] == 1) {
+ num_samples = pcm_shape[1];
+ } else {
+ throw std::runtime_error("Lfm2AudioProcessor: expected mono PCM from the audio decoder for clip " +
+ std::to_string(index) + ", got a rank " + std::to_string(pcm_dims) + " tensor.");
+ }
+
+ ort_extensions::OrtxObjectPtr rate_tensor;
+ CheckResult(OrtxTensorResultGetAt(decoded.get(), 1, rate_tensor.ToBeAssigned()));
+ const int64_t* source_rate{};
+ CheckResult(OrtxGetTensorData(rate_tensor.get(), reinterpret_cast