Add causal person fusion and MuVAP stage-3 baselines

This commit is contained in:
Xin Wang
2026-09-10 06:54:22 +08:00
parent 0534c9d3d0
commit d426b4740a
225 changed files with 27716 additions and 119 deletions
+233
View File
@@ -0,0 +1,233 @@
# 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
```