OmniVoice ONNX β€” bidirectional backbone

An ONNX export of k2-fsa/OmniVoice whose llm_decoder can attend in both directions, which the model requires and the existing export does not do.

Everything except llm_decoder and higgs_decoder is copied unchanged from onnx-community/OmniVoice-Onnx. The Higgs vocoder is that repo's graph converted to fp32, because the fp16 original overflows to NaN on arm64 β€” see below. The embeddings encoder, heads decoder and tokenizer are untouched.

Why this exists

OmniVoice is a masked diffusion language model. It fills an 8-codebook audio grid by repeatedly committing the slots it is most confident about, so a frame at position 5 must be able to attend to a frame committed at position 50. That is the entire point of ordering commits by confidence.

onnx-community/OmniVoice-Onnx was built with onnxruntime-genai's ModelBuilder, which emits autoregressive decoders: 28 fused com.microsoft.GroupQueryAttention nodes, whose documented contract is causal or local attention only. It takes no arbitrary mask and no attention bias, so the causality cannot be patched out β€” it lives inside the operator.

The effect is measurable in about ten lines. Run a twelve-token sequence, change only the last token, run it again:

published export:  earlier positions max|diff| = 0.000000e+00   -> causal
this export:       earlier positions max|diff| = 7.312805e+00   -> bidirectional

Bit-identical. Later commitments can never inform earlier ones, the unmasking loop collapses onto a handful of repeated codes, and the output is a buzz rather than speech β€” 42 distinct codes across 1024 slots, in a faithful reimplementation of the repo's own inference.py.

This is not caught by that repo's eval.py, which validates the embeddings encoder and the heads decoder against PyTorch and describes the LLM as "a black-box genai model". The full chain was never run.

What changed

The Qwen3 backbone was re-exported with torch.onnx.export (opset 20) taking the attention mask as a real 4-D input, which is what transformers passes through verbatim instead of building a causal mask from. Then quantised back to 4 bits.

published this repo
attention fused GroupQueryAttention, causal decomposed, 4-D mask input
attention_mask [batch, total_seq] int64 [batch, 1, seq, seq] bool
KV cache inputs 56 none
llm_decoder size 296 MB 284 MB

There are no cache inputs because unmasking runs a full-sequence forward every step and never reuses a cache β€” the upstream handoff notes say the same.

Files

Pick one backbone from int4/ or fp32/ and take all of components/ with it.

path what
int4/llm_decoder.onnx + .data 4-bit bidirectional backbone, 284 MB β€” use this
fp32/llm_decoder.onnx the same graph unquantised, 1763 MB
components/audio_embeddings_encoder.onnx + .data unchanged, from onnx-community
components/audio_heads_decoder.onnx unchanged
components/higgs_decoder.onnx the vocoder, converted to fp32, 86 MB β€” changed, see below
components/acoustic_encoder, semantic_encoder, quantizer_encoder unchanged β€” voice cloning only
tokenizer.json, tokenizer_config.json unchanged

So int4 is about 800 MB all in and fp32 about 2.3 GB. Note that 328 MB of components/ is the three cloning encoders; skip them if you only need text-to-speech.

The layout matters. This was first published flat β€” every graph at the repo root under a unique name, and the full-precision backbone called llm_decoder_bidir.onnx. Tooling decides "one model in several parts" by finding the same graph basename in more than one directory, so a flat repo reads as eight alternative models instead, and picking any one of them gives you an install that cannot run. Keeping llm_decoder.onnx in both int4/ and fp32/ is what makes the two precisions legible as a choice.

The vocoder had to change too

components/higgs_decoder.onnx as published by onnx-community is float16 from its weights to its output β€” 136 initialisers, 601 activations, and no Cast node anywhere in the graph. That is fine on x86, where ONNX Runtime's CPU provider has no fp16 kernels and quietly wraps the whole graph in fp32 casts. It is fatal on arm64, where the CPU has real ARMv8.2 half-precision arithmetic and float16 stops at 65504: the decoder overflows, and every sample comes back NaN.

Measured on an 8-core arm64-v8a phone, from a grid that was otherwise healthy β€” 8 codebooks Γ— 50 frames, 9 to 42 distinct codes per codebook:

vocoder frames=50 n=48000 peak=0 mean=0 nonZero=0 nan=48000 head=NaN, NaN, NaN, NaN

Nothing before it was wrong. Embeddings came back at peak 0.42, backbone hidden states at 179, heads logits at 137, not one non-finite value among them, and the bidirectional probe above passed at 8.21. Only the last graph failed β€” and it failed silently, because NaN has no peak: a naive silence check reports zero and the result is written out as a valid, empty WAV.

The same file is correct on x86. Run as shipped there it produces clean audio (peak=0.3901 rms=0.0556), and the fp32 conversion in this repo agrees with it to max|diff| = 0.000122, which is fp16 rounding. The graph was never wrong; only its declared precision was, and only on the platform most likely to run it.

The conversion is mechanical β€” every fp16 initialiser, attribute tensor and value-info upcast to fp32 β€” and costs 43 MB of download for a vocoder that works everywhere rather than on half of the devices that will try.

It replaces the file in components/ rather than arriving as an fp32/ copy, for the layout reason above: a basename appearing in more than one directory is exactly what tells tooling those directories are alternatives. A second higgs_decoder.onnx under fp32/ would stop fp32/ and int4/ being comparable, and the backbone choice would break. The fp16 original remains in this repo's git history.

Results

Dynamic range across 20 ms frames β€” speech is intermittent, noise is flat. Judged against the PyTorch reference and confirmed by ear.

configuration dyn range verdict
inference.py on the published export 2.4 dB buzz
correct algorithm, published export 23.2 dB traces of speech
correct algorithm, this export, no prompt framing 25.9 dB speech with an invented first word
correct algorithm, this export, fp32 58.8 dB clean
correct algorithm, this export, 4-bit 54.6 dB clean
PyTorch reference (model.generate()) 54.7 dB clean

Quantisation cost nothing measurable.

Two things the graph alone will not fix

The prompt needs framing. inference.py sends bare tokenizer.encode(text). models/omnivoice.py does not β€” and without <|text_start|> the model has no marker for where the text begins, so it invents a lead-in and then runs out of grid. This is worth about 33 dB:

<|lang_start|>None<|lang_end|><|instruct_start|>None<|instruct_end|><|text_start|>…<|text_end|>

None is upstream's own literal for "nothing specified". <|denoise|> is prepended only when reference-audio tokens are supplied.

The unmasking loop in inference.py is not the model's. It commits all eight codebooks of a frame at once on a linear schedule. The real one, in omnivoice/models/omnivoice.py, commits individual (codebook, frame) slots chosen by topk over a flattened score grid, on a shifted timestep curve (t_shift=0.1, so the first step commits ~4 slots rather than 32), with a subtractive layer penalty (layer_penalty_factor=5.0) and classifier-free guidance (guidance_scale=2.0) against an unconditional branch that sees the grid with no text.

Guidance is not optional here: with this export, turning it off collapses the output to 0.9 dB. It only makes sense once the unconditional branch can see the whole grid, which is exactly what the causal export prevented.

Usage

import numpy as np, onnxruntime as ort

llm = ort.InferenceSession("int4/llm_decoder.onnx", providers=["CPUExecutionProvider"])

# Full square mask: every position sees every other. This is the input the
# published export does not have.
mask = np.ones((1, 1, S, S), dtype=bool)
hidden = llm.run(["hidden_states"], {
    "inputs_embeds": embeds.astype(np.float32),   # [1, S, 1024]
    "attention_mask": mask,
})[0]

Ask for outputs by name. audio_heads_decoder returns logits shaped [batch, 8, seq, 1025]; the vocoder takes codes shaped [8, 1, frames] and returns waveform_24k at 24 kHz, 40 ms per frame.

Licence and credit

Apache-2.0, inherited from the upstream model. All weights are k2-fsa/OmniVoice (a Qwen3-0.6B finetune); the non-backbone graphs come from onnx-community/OmniVoice-Onnx. This repo contributes a corrected export and the measurements above.

Paper: OmniVoice: Towards Omnilingual Zero-Shot Text-to-Speech with Diffusion Language Models

Downloads last month

-

Downloads are not tracked for this model. How to track
Inference Providers NEW
This model isn't deployed by any Inference Provider. πŸ™‹ Ask for provider support

Model tree for Emerald7664/OmniVoice-Onnx-bidirectional

Finetuned
Qwen/Qwen3-0.6B
Finetuned
k2-fsa/OmniVoice
Quantized
(32)
this model

Paper for Emerald7664/OmniVoice-Onnx-bidirectional