234 lines
9.0 KiB
Markdown
234 lines
9.0 KiB
Markdown
# 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
|
||
|
|
|
||
|
|
```bash
|
||
|
|
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:
|
||
|
|
|
||
|
|
```bash
|
||
|
|
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`.
|
||
|
|
|
||
|
|
```bash
|
||
|
|
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`](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.
|
||
|
|
|
||
|
|
```bash
|
||
|
|
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](docs/preprocessing.md) 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.
|
||
|
|
|
||
|
|
```yaml
|
||
|
|
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.
|
||
|
|
|
||
|
|
```bash
|
||
|
|
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`](docs/preprocessing.md) and
|
||
|
|
[`docs/data_alignment.md`](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:
|
||
|
|
|
||
|
|
```bash
|
||
|
|
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](#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](https://huggingface.co/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](docs/vap.md#released-models)
|
||
|
|
lists them with their scores.
|
||
|
|
|
||
|
|
Fetch one, then evaluate it:
|
||
|
|
|
||
|
|
```bash
|
||
|
|
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:
|
||
|
|
|
||
|
|
```bash
|
||
|
|
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:
|
||
|
|
|
||
|
|
```bash
|
||
|
|
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:
|
||
|
|
|
||
|
|
```bash
|
||
|
|
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:
|
||
|
|
|
||
|
|
```python
|
||
|
|
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
|
||
|
|
```
|