Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
8ea4865
docs: 0.6B voice clone plan
wavekat-eason Apr 16, 2026
400adae
chore: gitignore .claude/
wavekat-eason Apr 16, 2026
47a2274
docs: lock voice clone design decisions
wavekat-eason Apr 16, 2026
0d64253
feat: ONNX export for 0.6B Base voice clone encoders
wavekat-eason Apr 16, 2026
d642e09
chore: consolidate Makefile help targets
wavekat-eason Apr 16, 2026
288cb1f
fix: tokenizer encoder uses JIT trace (fixed size)
wavekat-eason Apr 16, 2026
5154201
fix: patch causal mask for tokenizer encoder + step logging
wavekat-eason Apr 16, 2026
a775e89
fix: patch causal mask in Mimi module for tokenizer encoder
wavekat-eason Apr 17, 2026
086ca3f
feat: end-to-end ONNX voice clone generation script
wavekat-eason Apr 17, 2026
b5aeade
chore: add reference WAV fixture for voice clone tests
wavekat-eason Apr 17, 2026
7e5d8de
chore: add clone-fixture target to regenerate ref WAV
wavekat-eason Apr 17, 2026
d1d7b26
chore: regenerate ref fixture with wavekat-tts
wavekat-eason Apr 17, 2026
caa9c47
chore: regenerate fixture with warmer voice instruction
wavekat-eason Apr 17, 2026
10245e4
chore: use WaveKat tagline as clone ref text
wavekat-eason Apr 17, 2026
f989214
chore: longer ref fixture via FP32 1.7B VoiceDesign
wavekat-eason Apr 17, 2026
4cd9d06
fix: clone-fixture target uses --precision fp32
wavekat-eason Apr 17, 2026
ac7bd96
chore: use distinct text for clone-generate target
wavekat-eason Apr 17, 2026
32a87e6
feat: Qwen3TtsClone Rust backend for 0.6B voice clone
wavekat-eason Apr 17, 2026
5da2c7f
ci: add clone ONNX export workflow + workflows README
wavekat-eason Apr 17, 2026
4a871d3
ci: unify ONNX export workflow with variant selector
wavekat-eason Apr 17, 2026
c00fd61
docs: add HuggingFace README for 0.6B voice clone
wavekat-eason Apr 17, 2026
5972b58
fix: download encoder .data files and resolve HF symlinks
wavekat-eason Apr 17, 2026
3236dd0
docs: add voice clone to README
wavekat-eason Apr 17, 2026
2994957
docs: improve README examples and add completion output
wavekat-eason Apr 17, 2026
e20f1a1
docs: improve API docs and README FP32 examples
wavekat-eason Apr 17, 2026
f846251
fix: convert CoreError to TtsError via From impl
wavekat-eason Apr 17, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .github/workflows/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# GitHub Actions Workflows

| Workflow | Trigger | Description |
|----------|---------|-------------|
| [CI](ci.yml) | Push to `main`, PRs, manual | Runs `cargo fmt`, `clippy`, tests, and doc build |
| [Release-plz](release-plz.yml) | Push to `main`, manual | Automates crate releases to crates.io and opens release PRs |
| [Update benchmark table](update-bench.yml) | Push to `main` (when `bench/results/**.csv` changes), manual | Regenerates the benchmark table in README.md |
| [Export ONNX](export-onnx.yml) | Manual | Exports Qwen3-TTS to ONNX (FP32 + INT4), validates, and uploads to HuggingFace. Supports `voicedesign` (1.7B) and `clone` (0.6B Base) variants via input selector. |
71 changes: 55 additions & 16 deletions .github/workflows/export-onnx.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,20 @@ name: Export ONNX & Publish to HuggingFace
on:
workflow_dispatch:
inputs:
variant:
description: "Model variant to export"
default: "voicedesign"
type: choice
options:
- voicedesign
- clone
model_id:
description: "HuggingFace source model"
default: "Qwen/Qwen3-TTS-12Hz-1.7B-VoiceDesign"
description: "HuggingFace source model (leave default for selected variant)"
default: ""
type: string
hf_repo:
description: "Target HuggingFace repo (org/name)"
default: "wavekat/Qwen3-TTS-1.7B-VoiceDesign-ONNX"
description: "Target HuggingFace repo (leave default for selected variant)"
default: ""
type: string
runner:
description: "GitHub Actions runner label"
Expand All @@ -20,7 +27,7 @@ on:
default: false
type: boolean
revision:
description: "HuggingFace revision/branch to push to (e.g. v1, 2026-04-06). Defaults to main."
description: "HuggingFace revision/branch to push to. Defaults to main."
default: ""
type: string

Expand All @@ -29,6 +36,11 @@ jobs:
runs-on: ${{ inputs.runner }}
timeout-minutes: 120

env:
MODEL_ID: ${{ inputs.model_id != '' && inputs.model_id || (inputs.variant == 'clone' && 'Qwen/Qwen3-TTS-12Hz-0.6B-Base' || 'Qwen/Qwen3-TTS-12Hz-1.7B-VoiceDesign') }}
HF_REPO: ${{ inputs.hf_repo != '' && inputs.hf_repo || (inputs.variant == 'clone' && 'wavekat/Qwen3-TTS-0.6B-Base-ONNX' || 'wavekat/Qwen3-TTS-1.7B-VoiceDesign-ONNX') }}
OUTPUT_DIR: ${{ inputs.variant == 'clone' && './output/qwen3-tts-0.6b-base' || './output/qwen3-tts-1.7b-voicedesign' }}

steps:
- name: Free disk space
run: |
Expand Down Expand Up @@ -61,12 +73,23 @@ jobs:
working-directory: tools/qwen3-tts-onnx
run: make venv

- name: Export FP32 ONNX models
- name: Export FP32 ONNX models (VoiceDesign)
if: inputs.variant == 'voicedesign'
working-directory: tools/qwen3-tts-onnx
env:
HF_TOKEN: ${{ secrets.HF_TOKEN }}
run: |
make export MODEL_ID="${{ inputs.model_id }}"
make export MODEL_ID="${{ env.MODEL_ID }}"
echo "=== Disk after export ==="
df -h /

- name: Export FP32 ONNX models (Clone — 6 components)
if: inputs.variant == 'clone'
working-directory: tools/qwen3-tts-onnx
env:
HF_TOKEN: ${{ secrets.HF_TOKEN }}
run: |
make clone-export CLONE_MODEL_ID="${{ env.MODEL_ID }}"
echo "=== Disk after export ==="
df -h /

Expand All @@ -75,25 +98,40 @@ jobs:
working-directory: tools/qwen3-tts-onnx
env:
HF_TOKEN: ${{ secrets.HF_TOKEN }}
run: make validate MODEL_ID="${{ inputs.model_id }}"
run: make validate MODEL_ID="${{ env.MODEL_ID }}" OUTPUT_DIR="${{ env.OUTPUT_DIR }}"

- name: Free PyTorch model cache
run: |
rm -rf ~/.cache/huggingface/hub
echo "=== Disk after cache cleanup ==="
df -h /

- name: Quantize to INT4
- name: Quantize to INT4 (VoiceDesign)
if: inputs.variant == 'voicedesign'
working-directory: tools/qwen3-tts-onnx
run: |
make quantize
echo "=== Disk after quantize ==="
df -h /

- name: Package for HuggingFace
- name: Quantize to INT4 (Clone — encoders stay FP32)
if: inputs.variant == 'clone'
working-directory: tools/qwen3-tts-onnx
run: |
make clone-base-preset CLONE_MODEL_ID="${{ env.MODEL_ID }}"
echo "=== Disk after quantize ==="
df -h /

- name: Package for HuggingFace (VoiceDesign)
if: inputs.variant == 'voicedesign'
working-directory: tools/qwen3-tts-onnx
run: make hf

- name: Package for HuggingFace (Clone)
if: inputs.variant == 'clone'
working-directory: tools/qwen3-tts-onnx
run: make clone-hf

- name: Upload to HuggingFace
working-directory: tools/qwen3-tts-onnx
env:
Expand All @@ -102,25 +140,26 @@ jobs:
.venv/bin/pip install -q huggingface_hub
REVISION="${{ inputs.revision }}"
.venv/bin/huggingface-cli upload \
"${{ inputs.hf_repo }}" \
./output/qwen3-tts-1.7b-voicedesign/ \
"${{ env.HF_REPO }}" \
${{ env.OUTPUT_DIR }}/ \
--repo-type model \
--commit-message "export: ${{ inputs.model_id }} (run #${{ github.run_number }})" \
--commit-message "export: ${{ env.MODEL_ID }} (run #${{ github.run_number }})" \
${REVISION:+--revision "$REVISION"}

- name: Job summary
if: always()
run: |
REVISION="${{ inputs.revision }}"
REVISION_DISPLAY="${REVISION:-main}"
HF_URL="https://huggingface.co/${{ inputs.hf_repo }}/tree/${REVISION_DISPLAY}"
HF_URL="https://huggingface.co/${{ env.HF_REPO }}/tree/${REVISION_DISPLAY}"
cat >> "$GITHUB_STEP_SUMMARY" <<EOF
## ONNX Export Summary

| | |
|---|---|
| **Source model** | \`${{ inputs.model_id }}\` |
| **HuggingFace repo** | [${{ inputs.hf_repo }}](${HF_URL}) |
| **Variant** | \`${{ inputs.variant }}\` |
| **Source model** | \`${{ env.MODEL_ID }}\` |
| **HuggingFace repo** | [${{ env.HF_REPO }}](${HF_URL}) |
| **Revision** | \`${REVISION_DISPLAY}\` |
| **Run** | [#${{ github.run_number }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) |
| **Validation** | ${{ inputs.skip_validation && 'skipped' || 'passed' }} |
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,5 @@ Cargo.lock
dataset/
models/
*.wav
!tools/qwen3-tts-onnx/fixtures/*.wav
.claude/
84 changes: 60 additions & 24 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ Same pattern as

| Backend | Feature flag | Status | License |
|---------|-------------|--------|---------|
| [Qwen3-TTS](https://huggingface.co/Qwen/Qwen3-TTS) | `qwen3-tts` | ✅ Available | Apache 2.0 |
| [Qwen3-TTS](https://huggingface.co/Qwen/Qwen3-TTS) (VoiceDesign 1.7B) | `qwen3-tts` | ✅ Available | Apache 2.0 |
| [Qwen3-TTS](https://huggingface.co/Qwen/Qwen3-TTS) (Voice Clone 0.6B) | `qwen3-tts` | ✅ Available | Apache 2.0 |
| [CosyVoice](https://github.com/FunAudioLLM/CosyVoice) | `cosyvoice` | 🚧 Planned | Apache 2.0 |

## Model weights
Expand All @@ -29,39 +30,67 @@ ONNX-converted weights are published under the [`wavekat`](https://huggingface.c

| Backend | Repository | Precision |
|---------|------------|-----------|
| Qwen3-TTS | [wavekat/Qwen3-TTS-1.7B-VoiceDesign-ONNX](https://huggingface.co/wavekat/Qwen3-TTS-1.7B-VoiceDesign-ONNX) | FP32, INT4 |
| Qwen3-TTS VoiceDesign | [wavekat/Qwen3-TTS-1.7B-VoiceDesign-ONNX](https://huggingface.co/wavekat/Qwen3-TTS-1.7B-VoiceDesign-ONNX) | FP32, INT4 |
| Qwen3-TTS Voice Clone | [wavekat/Qwen3-TTS-0.6B-Base-ONNX](https://huggingface.co/wavekat/Qwen3-TTS-0.6B-Base-ONNX) | FP32, INT4 |

## Quick start

```sh
cargo add wavekat-tts --features qwen3-tts
```

### VoiceDesign (prompt-based styling)

```rust
use wavekat_tts::{TtsBackend, SynthesizeRequest};
use wavekat_tts::backends::qwen3_tts::{Qwen3Tts, ModelConfig, ModelPrecision, ExecutionProvider};

// Auto-downloads INT4 model files on first run, runs on CPU (default):
let tts = Qwen3Tts::new()?;

// Or FP32 on CPU:
// let tts = Qwen3Tts::from_config(ModelConfig::default().with_precision(ModelPrecision::Fp32))?;

// Or INT4 from a local directory on CUDA:
// let tts = Qwen3Tts::from_config(
// ModelConfig::default()
// .with_dir("models/qwen3-tts-1.7b")
// .with_execution_provider(ExecutionProvider::Cuda),
// )?;
use wavekat_tts::backends::qwen3_tts::Qwen3Tts;
// use wavekat_tts::backends::qwen3_tts::{ModelConfig, ModelPrecision, ExecutionProvider};

fn main() {
let tts = Qwen3Tts::new().unwrap(); // auto-downloads INT4 model on first run

// For FP32 or GPU inference, use from_config:
// let config = ModelConfig::default()
// .with_precision(ModelPrecision::Fp32) // FP32 instead of INT4
// .with_execution_provider(ExecutionProvider::Cuda); // GPU via CUDA
// let tts = Qwen3Tts::from_config(config).unwrap();

let request = SynthesizeRequest::new("Hello, world")
.with_instruction("Speak naturally and clearly.");
let audio = tts.synthesize(&request).unwrap();
audio.write_wav("output.wav").unwrap();
println!("Wrote output.wav ({:.2}s)", audio.duration_secs());
}
```

let request = SynthesizeRequest::new("Hello, world")
.with_instruction("Speak naturally and clearly.");
let audio = tts.synthesize(&request)?;
### Voice Clone (reference-audio cloning)

// Save to WAV (wavekat-core includes WAV I/O via the `wav` feature):
audio.write_wav("output.wav")?;
> **Requires a reference WAV file** (`ref.wav`) — a short mono clip of the voice
> you want to clone, plus a transcript of what is spoken in the clip.

println!("{}s at {} Hz", audio.duration_secs(), audio.sample_rate());
```rust
use wavekat_tts::AudioFrame;
use wavekat_tts::backends::qwen3_tts::{Qwen3TtsClone, CloneRequest};
// use wavekat_tts::backends::qwen3_tts::{ModelConfig, ModelPrecision};

fn main() {
let ref_audio = AudioFrame::from_wav("ref.wav").unwrap(); // 24 kHz mono WAV
let tts = Qwen3TtsClone::new().unwrap(); // auto-downloads 0.6B Base INT4 model

// For FP32 precision:
// let config = ModelConfig::default().with_precision(ModelPrecision::Fp32);
// let tts = Qwen3TtsClone::from_config(config).unwrap();

let req = CloneRequest::new(
"Text to say in the cloned voice",
ref_audio.samples(),
24000,
"Transcript of the reference clip.",
).with_language("en");
let audio = tts.synthesize_clone(&req).unwrap();
audio.write_wav("clone_output.wav").unwrap();
println!("Wrote clone_output.wav ({:.2}s)", audio.duration_secs());
}
```

Model files are cached by the HF Hub client at `$HF_HOME/hub/` (default `~/.cache/huggingface/hub/`).
Expand Down Expand Up @@ -92,10 +121,17 @@ Two trait families:
Generate a WAV file from text (model files are auto-downloaded on first run):

```sh
# VoiceDesign (1.7B)
cargo run --example synthesize --features qwen3-tts -- "Hello, world\!"
cargo run --example synthesize --features qwen3-tts -- --instruction "Speak in a warm, friendly tone." "Give every small business the voice of a big one."
cargo run --example synthesize --features qwen3-tts -- --precision fp32 "Hello"
cargo run --example synthesize --features qwen3-tts -- --model-dir /path/to/model --output hello.wav "Hello"
# cargo run --example synthesize --features qwen3-tts -- --precision fp32 "Hello, world\!"

# Voice Clone (0.6B)
cargo run --example synthesize_clone --features qwen3-tts -- \
--ref-audio ref.wav --ref-text "Transcript of the reference clip." \
"Text to synthesize in the cloned voice."
# cargo run --example synthesize_clone --features qwen3-tts -- --precision fp32 \
# --ref-audio ref.wav --ref-text "Transcript." "Text to synthesize."
```

## Performance
Expand Down
9 changes: 7 additions & 2 deletions crates/wavekat-tts/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ categories = ["multimedia::audio"]
default = []

# Local inference backends (all ONNX-based)
qwen3-tts = ["dep:ort", "dep:ndarray", "dep:tokenizers", "dep:npyz", "dep:rand", "dep:hf-hub"]
qwen3-tts = ["dep:ort", "dep:ndarray", "dep:tokenizers", "dep:npyz", "dep:rand", "dep:hf-hub", "dep:realfft"]
cosyvoice = ["dep:ort", "dep:ndarray"]

# Execution providers — composable with any ONNX backend feature
Expand All @@ -22,7 +22,7 @@ cuda = ["ort?/cuda"]
tensorrt = ["ort?/tensorrt"]

[dependencies]
wavekat-core = { version = "0.0.5", features = ["wav"] }
wavekat-core = { version = "0.0.7", features = ["wav"] }
thiserror = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
Expand All @@ -34,11 +34,16 @@ tokenizers = { version = "0.21", optional = true, default-features = false, feat
npyz = { version = "0.8", optional = true }
rand = { version = "0.9", optional = true }
hf-hub = { version = "0.5", optional = true, default-features = false, features = ["ureq"] }
realfft = { version = "3", optional = true }

[[example]]
name = "synthesize"
required-features = ["qwen3-tts"]

[[example]]
name = "synthesize_clone"
required-features = ["qwen3-tts"]

[[example]]
name = "bench_rtf"
required-features = ["qwen3-tts"]
1 change: 1 addition & 0 deletions crates/wavekat-tts/examples/synthesize.rs
Original file line number Diff line number Diff line change
Expand Up @@ -259,4 +259,5 @@ fn synthesize_one(
audio.write_wav(output).expect("failed to write WAV");

eprintln!("Wrote {}", output.display());
eprintln!("Done.");
}
Loading