Skip to content

Commit 6cf56a5

Browse files
Kairos-aclaude
andcommitted
feat: add Higgs Audio v2 — 3B Llama-backed TTS with voice cloning
Port of Boson AI's Higgs Audio v2 to MLX. Llama-3.2-3B backbone with a dual-FFN decoder layer (text + audio paths share self-attention; LN + MLP are per-path, routed by audio_out_mask) and delay-pattern audio emission (codebook i lags by i frames). Framework interface ------------------- Exposes `Model` and `ModelConfig` in the shape expected by `mlx_audio.tts.utils.load()` and `python -m mlx_audio.tts.generate`: python -m mlx_audio.tts.generate \ --model mlx-community/higgs-audio-v2-3B-mlx-q8 \ --text "Hello" --ref_audio voice.wav --ref_text "..." `Model` subclasses the underlying `HiggsAudioModel` so safetensors keys land unchanged. Tokenizer + codec attach via `post_load_hook`. The generate() signature matches the standard TTS convention (text, voice, ref_audio, ref_text, ...) and yields a mlx_audio.tts.models.base GenerationResult. `HiggsAudioServer` is kept as an additional Python entrypoint for Higgs-specific kwargs (max_new_frames, ras_win_len, fade_in_ms, etc.). Load-bearing details -------------------- The first generated audio frame must be a synthetic all-stream_bos frame — sampling from the model's audio_logits at the <|audio_out_bos|> text position collapses to stream-EOS on half the codebooks (those positions were never trained for direct audio prediction). See Model.generate and HiggsAudioModel._generate_raw_frames for the full AUDIO_INIT + K-frame ramp-in + EOS ramp-out state machine. Quantization ------------ MLX native 4/6/8-bit on the Llama backbone. `Model.model_quant_predicate` protects `audio_codebook_embeddings` and `audio_decoder_proj.audio_lm_head` at bf16 — quantizing them introduces voice-character drift (pitch register shifts at q6, trajectory instability at q4). RTF on M5 Max: bf16 0.60× / q8 0.36× / q6 0.33×. Bundled assets -------------- Three drop-in reference voices in examples/voice_prompts/ (en_woman, en_man, en_man_deep), generated via smart-voice mode so they're license-clean. Example: examples/higgs_audio_clone_demo.py. Tests ----- 16 unit tests in mlx_audio/tts/tests/test_higgs_audio.py — delay-pattern round-trip, audio-embedding lookup, sampling equivalence at T=0, tiny config model forward, selective-quant predicate verification, and the framework-interface contract (Model subclass, ModelConfig.from_dict, sample_rate, model_quant_predicate, generate-before-load guard). References ---------- Original: https://github.com/boson-ai/higgs-audio HF (bf16): https://huggingface.co/bosonai/higgs-audio-v2-generation-3B-base HF (q8): https://huggingface.co/mlx-community/higgs-audio-v2-3B-mlx-q8 HF (q6): https://huggingface.co/mlx-community/higgs-audio-v2-3B-mlx-q6 HF codec: https://huggingface.co/mlx-community/higgs-audio-v2-tokenizer Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 0de1561 commit 6cf56a5

17 files changed

Lines changed: 2047 additions & 0 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@ for result in model.generate("Hello from MLX-Audio!", voice="af_heart"):
105105
| **Voxtral TTS** | Mistral's 4B multilingual TTS (20 voices, 9 languages) | EN, FR, ES, DE, IT, PT, NL, AR, HI | [mlx-community/Voxtral-4B-TTS-2603-mlx-bf16](https://huggingface.co/mlx-community/Voxtral-4B-TTS-2603-mlx-bf16) |
106106
| **LongCat-AudioDiT** | SOTA diffusion TTS in waveform latent space with voice cloning | ZH, EN | [mlx-community/LongCat-AudioDiT-1B-bf16](https://huggingface.co/mlx-community/LongCat-AudioDiT-1B-bf16) |
107107
| **MeloTTS** | Lightweight VITS2-based TTS with streaming | EN (more coming) | [mlx-community/MeloTTS-English-MLX](https://huggingface.co/mlx-community/MeloTTS-English-MLX) |
108+
| **Higgs Audio v2** | 3B Llama-backed TTS with real-time voice cloning | EN, ZH, KO, DE, ES | [bf16 (upstream)](https://huggingface.co/bosonai/higgs-audio-v2-generation-3B-base), [q8](https://huggingface.co/mlx-community/higgs-audio-v2-3B-mlx-q8), [q6](https://huggingface.co/mlx-community/higgs-audio-v2-3B-mlx-q6) |
108109

109110
### Speech-to-Text (STT)
110111

docs/models/tts/higgs_audio.md

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
# Higgs Audio v2
2+
3+
Higgs Audio v2 is a Llama-3.2-3B-backed TTS with multi-codebook acoustic tokens and delay-pattern streaming. The MLX port targets the 3B open-weights release from Boson AI and reuses the in-tree HiggsAudio acoustic tokenizer (originally added for OmniVoice).
4+
5+
## Highlights
6+
7+
- Real-time voice cloning on Apple Silicon (RTF ≈ 0.6× bf16 / 0.36× q8 / 0.33× q6 on M5 Max)
8+
- Reference-audio voice cloning via ChatML prompt format
9+
- Full `AUDIO_INIT` + delay-pattern ramp-in/out state machine
10+
- Repetition-avoidance sampling (RAS) for stable long-form output
11+
- MLX native 4/6/8-bit quantization with optional per-layer protection
12+
13+
## Basic usage
14+
15+
### Top-level CLI
16+
17+
```bash
18+
python -m mlx_audio.tts.generate \
19+
--model mlx-community/higgs-audio-v2-3B-mlx-q8 \
20+
--text "Hello from Higgs Audio on MLX." \
21+
--ref_audio path/to/reference.wav \
22+
--ref_text "Transcript of the reference clip."
23+
```
24+
25+
The `Model` class conforms to the standard mlx-audio interface, so the
26+
existing `mlx_audio.tts.generate` CLI and `mlx_audio.server` both work
27+
unchanged against Higgs.
28+
29+
### Python API (standard)
30+
31+
```python
32+
from mlx_audio.tts.utils import load
33+
import soundfile as sf
34+
35+
model = load("mlx-community/higgs-audio-v2-3B-mlx-q8")
36+
37+
for result in model.generate(
38+
text="Hello from Higgs Audio on MLX.",
39+
ref_audio="path/to/reference.wav", # optional; strongly recommended
40+
ref_text="Transcript of the reference clip.",
41+
temperature=0.7,
42+
top_p=0.95,
43+
max_new_frames=1200,
44+
fade_in_ms=30.0,
45+
):
46+
sf.write("output.wav", result.audio, result.sample_rate)
47+
```
48+
49+
Without `ref_audio`, generation runs in "smart voice" mode (random voice
50+
per sample). This works but is less reliable than voice cloning — the
51+
sampling occasionally collapses to `stream_eos` early and produces silent
52+
output. If that happens, rerun (each call draws fresh noise) or pass
53+
`ref_audio`. For production use, a reference voice is strongly recommended.
54+
55+
### Python API (Higgs-specific kwargs)
56+
57+
For direct access to the full Higgs parameter surface (RAS windowing,
58+
sampling warmup, pre-loaded codec override, etc.), use `HiggsAudioServer`:
59+
60+
```python
61+
from mlx_audio.tts.models.higgs_audio import HiggsAudioServer
62+
import soundfile as sf
63+
64+
server = HiggsAudioServer.from_pretrained(
65+
model_path="bosonai/higgs-audio-v2-generation-3B-base", # bf16 base
66+
codec_path="mlx-community/higgs-audio-v2-tokenizer", # acoustic tokenizer
67+
)
68+
69+
result = server.generate(
70+
target_text="Hello from Higgs Audio on MLX.",
71+
temperature=0.7,
72+
top_p=0.95,
73+
max_new_frames=1200,
74+
fade_in_ms=30.0,
75+
)
76+
sf.write("output.wav", result.pcm, result.sampling_rate)
77+
```
78+
79+
### Recommended parameters
80+
81+
- `temperature=0.7`, `top_p=0.95` — proven stable across prompt lengths during the M5 benchmark
82+
- `max_new_frames=1200` — generous cap; generation stops naturally at the EOS ramp
83+
- `fade_in_ms=30.0`, `fade_out_ms=15.0` — suppresses the first-frame transient that the 5ms default occasionally lets through
84+
85+
## Voice cloning
86+
87+
Pass `ref_audio` (path or pre-loaded mx.array at 24 kHz mono) together with
88+
`ref_text` (the transcript of that clip). Reference audio is encoded through
89+
the in-tree `HiggsAudioTokenizer` and stitched into the assistant turn of a
90+
ChatML prompt — the transcript is required for stable alignment between the
91+
cloned voice and the target text.
92+
93+
```python
94+
for result in model.generate(
95+
text="Hello, this is a cloned voice.",
96+
ref_audio="reference.wav",
97+
ref_text="Transcript of the reference clip.",
98+
temperature=0.7,
99+
top_p=0.95,
100+
max_new_frames=1200,
101+
fade_in_ms=30.0,
102+
):
103+
sf.write("output.wav", result.audio, result.sample_rate)
104+
```
105+
106+
Best results come from 5–15 seconds of clean reference speech.
107+
108+
### Bundled sample voices
109+
110+
Three drop-in reference voices ship in `examples/voice_prompts/`, generated via Higgs smart-voice mode so they're license-clean:
111+
112+
- `en_woman.wav` — English, feminine register
113+
- `en_man.wav` — English, masculine register
114+
- `en_man_deep.wav` — English, masculine register, lower pitch
115+
116+
Each `.wav` is paired with a matching `.txt` transcript. See `examples/voice_prompts/README.md` for the usage snippet.
117+
118+
## Streaming
119+
120+
For chunked streaming output (e.g. Pipecat pipelines), use
121+
`HiggsAudioServer.generate_stream`:
122+
123+
```python
124+
for pcm_chunk in server.generate_stream(
125+
target_text="Generating in chunks for live playback.",
126+
reference_audio_path="reference.wav",
127+
reference_text="...",
128+
chunk_ms=640.0,
129+
):
130+
# emit or resample pcm_chunk (float32 at 24 kHz)
131+
...
132+
```
133+
134+
Current shape: full generate, then chunk the resulting PCM. Per-chunk quality matches non-streaming exactly. Mid-generation streaming (emit-as-you-go) is not yet supported because the neural-vocoder codec produces subtly different PCM at the same sample position when called with different accumulated lengths — boundary discontinuities become audible. Proper overlap-add streaming is follow-up work.
135+
136+
## Quantization
137+
138+
MLX native 4/6/8-bit quantization works on the Llama backbone. The audio head and audio codebook embeddings benefit from staying at bf16 — quantizing them introduces voice-character drift (pitch register shifts at q6, trajectory instability at q4).
139+
140+
Already-quantized checkpoints load transparently via `load(...)` — config.json carries a `quantization` block that the framework applies before weight load. To quantize in place on a fresh bf16 load, use `model.model_quant_predicate`:
141+
142+
```python
143+
import mlx.core as mx
144+
import mlx.nn as nn
145+
from mlx_audio.tts.utils import load
146+
147+
model = load("bosonai/higgs-audio-v2-generation-3B-base")
148+
nn.quantize(model, group_size=64, bits=8, class_predicate=model.model_quant_predicate)
149+
mx.eval(model.parameters())
150+
```
151+
152+
Benchmark on M5 Max (warm), long-prompt RTF:
153+
154+
| variant | RTF | weights size | notes |
155+
|---------|-------|--------------|---------------------------------------------|
156+
| bf16 | 0.60× | 6.8 GB | `bosonai/higgs-audio-v2-generation-3B-base` (authoritative) |
157+
| q8 | 0.36× | 6.18 GB | `mlx-community/higgs-audio-v2-3B-mlx-q8` |
158+
| q6 | 0.33× | 4.75 GB | `mlx-community/higgs-audio-v2-3B-mlx-q6` |
159+
| q4 | 0.26× | 3.32 GB | deferred — seed-sensitive, follow-up PR |
160+
161+
bf16 is served directly from the authoritative `bosonai/*` upload — no need for a redundant mlx-community re-host. q8 and q6 are MLX-specific selectively-quantized variants.
162+
163+
## Sampling controls
164+
165+
- `temperature=0.7`, `top_p=0.95` are the Higgs defaults.
166+
- `ras_win_len=7`, `ras_max_repeat=2` enables repetition-avoidance sampling (catches near-tie mispicks that compound into loops). Set `ras_win_len=None` to disable.
167+
- `sampling_warmup_frames=N` uses greedy sampling for the first N frames, then switches to temperature. Exposed for experimentation; not helpful at default settings.
168+
- `fade_in_ms=5.0`, `fade_out_ms=5.0` applies a short linear fade to the decoded PCM boundaries. Below onset perception threshold on bf16/q8; masks rounding-click transients on quantized variants.
169+
170+
## Implementation notes
171+
172+
The generation state machine is the non-obvious piece of this port. See source at `mlx_audio/tts/models/higgs_audio/higgs_audio.py:HiggsAudioModel._generate_raw_frames`. The first audio frame is **synthetic all `audio_stream_bos_id`** (AUDIO_INIT) — not sampled from audio_logits at the `<|audio_out_bos|>` text position, because those logits were never trained for direct audio prediction. Without this, the model emits the stream-EOS token on half the codebooks at step 1 and output collapses to a stuck pitch.
173+
174+
Codebook `i` is emitted with `i`-frame delay, so the first K frames are a progressive ramp-in (cb₀ sampled at frame 1, cb₁ at frame 2, etc.; the rest forced to BOS). On any codebook emitting EOS, a K-frame ramp-out begins — trailing codebooks forced to EOS before termination. After `revert_delay_pattern`, the first and last aligned columns are dropped (BOS-seed and EOS-seal — they decode to arbitrary codec token 1023 and produce audible clicks otherwise).
175+
176+
## References
177+
178+
- Original repo: <https://github.com/boson-ai/higgs-audio>
179+
- Paper / blog: <https://boson.ai/blog/higgs-audio-v2>
180+
- HF model (reference): <https://huggingface.co/bosonai/higgs-audio-v2-generation-3B-base>
181+
- HF model (MLX q8): <https://huggingface.co/mlx-community/higgs-audio-v2-3B-mlx-q8>
182+
- HF model (MLX q6): <https://huggingface.co/mlx-community/higgs-audio-v2-3B-mlx-q6>
183+
- HF codec: <https://huggingface.co/mlx-community/higgs-audio-v2-tokenizer>

examples/higgs_audio_clone_demo.py

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
#!/usr/bin/env python3
2+
"""Higgs Audio v2 voice cloning demo.
3+
4+
Uses the Higgs-specific HiggsAudioServer API for full parameter surface.
5+
For a drop-in example against the standard mlx_audio.tts.generate CLI,
6+
see docs/models/tts/higgs_audio.md.
7+
8+
Quick start with the bundled `en_woman` sample voice:
9+
python examples/higgs_audio_clone_demo.py \\
10+
--text "Text to synthesize in the cloned voice."
11+
12+
Supply your own reference:
13+
python examples/higgs_audio_clone_demo.py \\
14+
--ref_audio reference.wav \\
15+
--ref_text "Reference transcript text." \\
16+
--text "Text to synthesize in the cloned voice."
17+
18+
Reference audio is encoded through the in-tree HiggsAudioTokenizer and
19+
stitched into the assistant turn of a ChatML prompt. ref_text is the
20+
transcript of the reference clip — required for stable alignment
21+
between the cloned voice and the target text.
22+
23+
Best results come from 5-15 seconds of clean reference speech.
24+
Three sample voices live in examples/voice_prompts/ (en_woman,
25+
en_man, en_man_deep) for drop-in use.
26+
"""
27+
28+
import argparse
29+
import sys
30+
import time
31+
from pathlib import Path
32+
33+
import mlx.core as mx
34+
import mlx.nn as nn
35+
import numpy as np
36+
import soundfile as sf
37+
38+
from mlx_audio.tts.models.higgs_audio import HiggsAudioServer
39+
40+
41+
def _quantize_predicate(name: str, module: nn.Module) -> bool:
42+
"""Keep audio head + audio codebook embeddings at bf16 — they're most
43+
sensitive to quantization noise. Everything else (Llama backbone + text
44+
head) gets compressed."""
45+
if not isinstance(module, (nn.Linear, nn.Embedding)):
46+
return False
47+
protected = ("audio_codebook_embeddings", "audio_decoder_proj.audio_lm_head")
48+
return not any(b in name for b in protected)
49+
50+
51+
def _default_voice_prompt() -> tuple[str, str]:
52+
"""Return the bundled `en_woman` voice prompt (wav path + transcript)."""
53+
here = Path(__file__).resolve().parent / "voice_prompts"
54+
return str(here / "en_woman.wav"), (here / "en_woman.txt").read_text().strip()
55+
56+
57+
def main() -> int:
58+
p = argparse.ArgumentParser(description="Higgs Audio v2 voice cloning demo")
59+
default_ref_audio, default_ref_text = _default_voice_prompt()
60+
p.add_argument("--ref_audio", default=default_ref_audio,
61+
help="Reference audio WAV (defaults to bundled en_woman sample)")
62+
p.add_argument("--ref_text", default=default_ref_text,
63+
help="Transcript of the reference audio")
64+
p.add_argument("--text", required=True, help="Target text to synthesize")
65+
p.add_argument("--output", default="higgs_clone_output.wav", help="Output WAV path")
66+
p.add_argument(
67+
"--model",
68+
default="mlx-community/higgs-audio-v2-3B-mlx-bf16",
69+
help="Higgs Audio v2 MLX model repo or path",
70+
)
71+
p.add_argument(
72+
"--codec",
73+
default="mlx-community/higgs-audio-v2-tokenizer",
74+
help="Higgs Audio v2 tokenizer repo or path",
75+
)
76+
p.add_argument(
77+
"--quantize_bits",
78+
type=int,
79+
default=None,
80+
choices=[4, 6, 8],
81+
help="Optionally quantize the loaded model in-place (4/6/8-bit)",
82+
)
83+
p.add_argument("--temperature", type=float, default=0.7)
84+
p.add_argument("--top_p", type=float, default=0.95)
85+
p.add_argument("--max_new_frames", type=int, default=1200)
86+
p.add_argument("--ras_win_len", type=int, default=7)
87+
p.add_argument("--ras_max_repeat", type=int, default=2)
88+
p.add_argument("--fade_in_ms", type=float, default=30.0,
89+
help="Leading fade (ms) — 30ms suppresses the first-frame transient cleanly")
90+
p.add_argument("--fade_out_ms", type=float, default=15.0)
91+
args = p.parse_args()
92+
93+
print(f"[load] HiggsAudioServer from {args.model}")
94+
t0 = time.monotonic()
95+
server = HiggsAudioServer.from_pretrained(
96+
model_path=args.model,
97+
codec_path=args.codec,
98+
)
99+
if args.quantize_bits is not None:
100+
print(f"[quantize] group_size=64 bits={args.quantize_bits} (audio head protected)")
101+
nn.quantize(
102+
server.model,
103+
group_size=64,
104+
bits=args.quantize_bits,
105+
class_predicate=_quantize_predicate,
106+
)
107+
mx.eval(server.model.parameters())
108+
print(f" loaded in {time.monotonic() - t0:.2f}s")
109+
110+
print(f"[generate] target: {args.text!r}")
111+
t_gen = time.monotonic()
112+
result = server.generate(
113+
target_text=args.text,
114+
reference_audio_path=args.ref_audio,
115+
reference_text=args.ref_text,
116+
max_new_frames=args.max_new_frames,
117+
temperature=args.temperature,
118+
top_p=args.top_p,
119+
ras_win_len=args.ras_win_len,
120+
ras_max_repeat=args.ras_max_repeat,
121+
fade_in_ms=args.fade_in_ms,
122+
fade_out_ms=args.fade_out_ms,
123+
)
124+
wall = time.monotonic() - t_gen
125+
audio_sec = len(result.pcm) / result.sampling_rate
126+
rtf = wall / audio_sec if audio_sec > 0 else float("inf")
127+
128+
sf.write(args.output, result.pcm, result.sampling_rate)
129+
print(
130+
f"[done] {audio_sec:.2f}s audio in {wall:.2f}s wall "
131+
f"(RTF {rtf:.2f}×, {result.num_frames_raw} frames, "
132+
f"stop={result.stop_reason}) → {args.output}"
133+
)
134+
return 0
135+
136+
137+
if __name__ == "__main__":
138+
sys.exit(main())

examples/voice_prompts/README.md

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
# Higgs Audio v2 — Sample Voice Prompts
2+
3+
Drop-in reference voices for `HiggsAudioServer.generate(..., reference_audio_path=...)`. Each `.wav` is paired with a `.txt` containing the transcript of that clip (required for stable alignment between the cloned voice and the target text).
4+
5+
| File | Character |
6+
| --- | --- |
7+
| `en_woman.wav` | English, feminine register |
8+
| `en_man.wav` | English, masculine register |
9+
| `en_man_deep.wav` | English, masculine register, lower pitch |
10+
11+
All three were generated via Higgs Audio v2 smart-voice mode (no human recordings), so they're license-clean and can be freely redistributed.
12+
13+
## Usage
14+
15+
```python
16+
from mlx_audio.tts.models.higgs_audio import HiggsAudioServer
17+
from pathlib import Path
18+
19+
voice_dir = Path("examples/voice_prompts")
20+
ref_wav = voice_dir / "en_woman.wav"
21+
ref_txt = (voice_dir / "en_woman.txt").read_text().strip()
22+
23+
server = HiggsAudioServer.from_pretrained(
24+
model_path="mlx-community/higgs-audio-v2-3B-mlx-q8",
25+
codec_path="mlx-community/higgs-audio-v2-tokenizer",
26+
)
27+
28+
result = server.generate(
29+
target_text="Anything you want cloned in the chosen voice.",
30+
reference_audio_path=str(ref_wav),
31+
reference_text=ref_txt,
32+
temperature=0.7,
33+
top_p=0.95,
34+
max_new_frames=1200,
35+
fade_in_ms=30.0,
36+
)
37+
```
38+
39+
For the recommended parameter set, see [`docs/models/tts/higgs_audio.md`](../../docs/models/tts/higgs_audio.md).

examples/voice_prompts/en_man.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
The radio quietly played a familiar song. Outside, rain tapped against the window in a steady rhythm. Coffee cooled slowly in a ceramic mug. Somewhere down the hall, a door clicked shut.

examples/voice_prompts/en_man.wav

624 KB
Binary file not shown.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
The radio quietly played a familiar song. Outside, rain tapped against the window in a steady rhythm. Coffee cooled slowly in a ceramic mug. Somewhere down the hall, a door clicked shut.
666 KB
Binary file not shown.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
The radio quietly played a familiar song. Outside, rain tapped against the window in a steady rhythm. Coffee cooled slowly in a ceramic mug. Somewhere down the hall, a door clicked shut.
833 KB
Binary file not shown.

0 commit comments

Comments
 (0)