MuVAP
MuVAP: Multimodal Multiparty Voice Activity Projection for Turn-taking Prediction in the Wild — Interspeech 2026.
Demo: https://interspeech2026anonymous-eng.github.io/demo/
MuVAP is built from modules that share one codebase and are released one at a time. Each is self-contained: it trains, evaluates, and publishes weights on its own, without the modules that have not landed yet.
| Module | Status | Entry point |
|---|---|---|
| VAP — voice activity projection from audio | released | train_vap.py |
| ASD — audio-visual active speaker detection | released | train_asd.py |
| MuVAP — multiparty fusion of the two | to follow | — |
Install
python -m pip install -e . # add ".[mimi]" for the Mimi frontend
Install the PyTorch build matching your CUDA version first.
The frozen audio frontends download their pretrained weights the first time a model is built. Fetch them ahead of time — worth doing before a batch job, on a machine whose compute nodes have no outbound network:
python tools/fetch_frontends.py # or: fetch_frontends.py cpc
VAP
Voice Activity Projection: a causal model that predicts who will hold the floor next, from audio alone. Every configuration shares one training program and differs only in the config file, so a result difference between them comes from the setup rather than from a different pipeline.
| Configuration | Config | Input | Codebook |
|---|---|---|---|
| Role-based VAP | config/yaml/vap_role.yaml |
1 mixed channel | 136 role-relative classes |
| Speaker-based VAP | config/yaml/vap_speaker.yaml |
2 channels, one per speaker | 256 classes, one channel per row |
| Speaker-based, mono | config/yaml/vap_speaker_mono_mimi.yaml |
1 mixed channel + VAD | 256 classes, one channel per row |
The mono setup is the original VAP arrangement: one channel carries the audio
and the causal voice activity behind each frame supplies the speaker identity a
second channel would have carried. config/yaml/ also holds the frontend and
frame-rate variants of each - *_mimi_12hz, *_cpc_50hz.
python train_vap.py --config config/yaml/vap_role.yaml --name role
Positional encoding
vap.temporal.pos_encoding selects alibi, causal rope, sinusoidal, or
none. The stack stays causal regardless — only how position reaches attention
changes.
See docs/vap.md for what each setup means, what it logs, how to
prepare Fisher, and the exact causality and alignment contracts.
ASD
Audio-visual active speaker detection: given a face track and the audio around it, decide frame by frame whether that face is speaking. The reported number is the official AVA-ActiveSpeaker mAP.
| Configuration | Config | Frontend |
|---|---|---|
| ASD | config/yaml/asd.yaml |
CPC |
| ASD + Mimi | config/yaml/asd_mimi.yaml |
Mimi |
The frontends are the same two the VAP module uses, and both emit features at the 25 Hz rate the visual frontend runs at, so they substitute for each other here too.
python train_asd.py --config config/yaml/asd.yaml --name asd
Faces reach the model as 112x112 grayscale, the geometry the packs are written
in - see preprocessing for where that is fixed and why.
On top of it, asd.augment applies one geometric transform per chunk, drawn
uniformly from none, horizontal flip, random crop rescaled back to 112, and
rotation; crop_scale is the side of that crop as a fraction of the frame.
Audio is augmented separately: with probability audio_mix_prob a donor clip
is mixed in at an SNR drawn from audio_snr_db while the label stays unchanged,
so the model has to reject speech it cannot see on the face.
augment:
audio_mix_prob: 0.5 # chance of mixing in off-screen speech
audio_snr_db: [-5.0, 5.0] # how loud that donor is against the real audio
visual: true # geometric transforms on the face track
crop_scale: [0.7, 1.0] # random-crop side, as a fraction of 112
rotate_degrees: 15.0 # maximum rotation
substitute_audio_prob: 0.0 # ablation: replace the audio, relabel silent
Every source corpus is packed into one memory-mapped format first. A pack is a decoded mirror of the corpus, not a model-ready tensor set: grayscale face crops and one label per frame at the native video rate, plus 16 kHz audio at its original gain. Sampling onto the 25 Hz model timeline and loudness normalization happen in the dataloader, so one pack can serve models with different conventions.
python -m preprocess.asd.prepare ava --split train \
--loader /data/AVA/csv/train_loader.csv \
--visual-root /data/AVA/clips_videos --audio-root /data/AVA/clips_audios
python -m preprocess.asd.validate /data/AVA/packed/train
python -m preprocess.asd.prepare --help lists the accepted source schemas.
See docs/preprocessing.md and
docs/data_alignment.md for the storage and
alignment contracts.
Audio convention. The model expects audio normalized to -25 dBFS RMS,
capped at 20 dB of gain and a 0.99 peak, with the level measured over the whole
recording rather than the evaluated slice. This is not the raw-waveform
convention other ASD codebases use, and a checkpoint evaluated on unnormalized
audio will not reproduce the mAP it was trained to. Packs store audio
unnormalized and the loader applies this; the constants live in
data/media.py.
Running either module
--set KEY=VALUE overrides any config value (dotted path, YAML-parsed) and can
be repeated; it fails on a key the config does not already define, so a typo
cannot silently do nothing. --wandb enables tracking, --name chooses the
run directory.
checkpoint_monitor picks what best.ckpt selects on — val/loss for VAP,
val/mAP_official for ASD. Clear it where there is no held-out split to select
on, and the final epoch is the only artifact the run keeps:
python train_vap.py --config config/yaml/vap_role.yaml --set vap.checkpoint_monitor=
--test then falls back to last instead of best. See
Weight releases for what each checkpoint is.
Audio frontends
vap.audio_encoder selects the frozen frontend. Both emit features at the rate
the projection window labels, so they are drop-in alternatives at a shared rate.
cpc (default) |
mimi |
|
|---|---|---|
| Weights | CPC, LibriLight 60k | kyutai/mimi, from Moshi |
| Frozen params | 1.8M | 37.8M |
| Feature dim | 256 | 512 |
| Frame rates | 100 / 50 / 25 / 12.5 | 25 / 12.5 |
CPC halves a 100 Hz convolutional stack down to the requested rate; Mimi taps either its 25 Hz encoder output or its 12.5 Hz latent, so 50 Hz is a CPC-only option.
Both are causal, which is what makes the model usable as a streaming predictor.
Weight releases
Trained weights live on the Hub at Haotian-Qi/MuVAP.
Two ASD releases, asd-cpc and asd-mimi, scoring 90.50 and 92.04
mAP_official on AVA-ActiveSpeaker. The VAP releases span three codebooks, two
frontends and three frame rates - docs/vap.md
lists them with their scores.
Fetch one, then evaluate it:
huggingface-cli download Haotian-Qi/MuVAP --include 'vap-role-mimi/*' --local-dir weights
python train_vap.py --config config/yaml/vap_role.yaml \
--test --weights weights/vap-role-mimi \
--set fisher_path=/path/to/fisher
That reproduces the score in the table exactly. All of them at once:
huggingface-cli download Haotian-Qi/MuVAP --local-dir weights
A run produces two checkpoints. last.ckpt is the final epoch — the artifact
the run committed to. best.ckpt is the epoch a validation metric picked, and
exists only when checkpoint_monitor is set; where there is no held-out split
to select on, clear it and the final epoch is the only artifact.
Publish either one with:
python tools/export_checkpoint.py <run>/last.ckpt <release dir> --module vap
A release carries the trained weights alone: no optimizer state, and no copy of
the frozen audio frontend, which is fetched from its own pretrained source when
the model is built. It ships config.yaml (the architecture) and
provenance.json, which records the checkpoint it came from and what that
checkpoint scored. Point --weights at a release directory to evaluate it:
python train_vap.py --config config/yaml/vap_role.yaml --test --weights <release>
The architecture comes from the release's own config.yaml; dataset roots and
output directories come from the --config you pass, so a release runs anywhere
without reproducing the paths it was trained with. A release that does not fit
the config it is given fails immediately, naming the offending weights.
Or load one directly:
import yaml
from models.release import load_weights, resolve
from models.vap import build_vap
weights, config = resolve("path/to/release")
cfg = yaml.safe_load(open(config))["vap"]
model = load_weights(build_vap(cfg), weights).eval()
logits = model(waveform) # [batch, frames, classes] at the release's rate