Panagiotis Gkilis

VALL-E-X Fork • Original Conditioning & Data Pipeline • Solo on Consumer GPUs

BedVibe-TTS: the architecture, the conditioning, and the pipeline that feeds it.

BedVibe-TTS is a VALL-E-X-family AR + NAR neural-codec language model (over EnCodec Q=8 tokens) — forked from the open VALL-E-X architecture and trained from scratch (weights from random initialisation, not fine-tuned). The base transformer is not my invention; the original engineering here is the conditioning design — a 6-dim emotion vector, a 13-dim voice-trait vector, and a 2-dim speaker-blend vector, none of which exist in stock VALL-E-X — plus a custom memory-mapped .bvbean dataset format and a Rust metadata pipeline that make a 108,076-row corpus trainable on consumer hardware.

AR + NAR codec LM EnCodec Q=8 (12 kbps) Emotion[6] / Trait[13] / Blend[2] conditioning ECAPA-TDNN speaker embeddings SentencePiece (13 languages) Custom .bvbean mmap format Rust metadata generators PyTorch
108,076rows in metadata_B_train.jsonl
13 / 17languages / speakers (per metadata)
~730Mdesigned AR+NAR size; AR stage trained
37 GBsource WAV corpus (A1/A2/B)
Status — research-stage. This deep-dive documents the architecture, the conditioning design, and the data pipeline as proof of engineering. It is not a validated production speech system: training convergence across GPU generations — including a flash-attention / SDPA failure on Blackwell hardware — is reported honestly in the BedVibe-TTS engineering report (DOI), and full AR+NAR inference is not claimed here. All figures below are measured from the actual corpus files and that report. Speaker names are anonymised; all voices are contracted, rights-cleared performers.

End-to-end pipeline

Raw studio audio becomes a single memory-mapped training file through six deterministic stages. Each stage writes a typed, validated artifact consumed by the next.

Stage 0
Studio WAVs
109,369 files · 48 kHz · 13 languages
Stage 1
EnCodec tokenize
tokenize_encodec.py → codes_qt [Q=8, T] + attn
Stage 2
Metadata (Rust)
metadata_a/b generators → JSONL + vectors
Stage 3
SPM + blend
SentencePiece text_ids · speaker blending
Stage 4
.bvbean packer
packer_magicbeans.py → BDBEAN1 mmap
Stage 5
AR + NAR train
train.py / train_nar.py → checkpoints

The conditioning metadata — one real record

Every training record carries five conditioning channels. Below is a real (anonymised) row from metadata_B_train.jsonl, with the three vector channels broken out and labelled with the project's own names. Click an emotion to see how the one-hot moves.

emotion_vector

6-dim one-hot— record emotion: Happy

trait_vector

13-dim continuous— 9 dimensions defined from the Dataset B trait corpus (breathiness and low-high are per-gender); remaining slots not yet assigned. Training rows currently hold the whole axis at zero.

blend_vector

2-dim speaker interpolation
speaker_a
Speaker_07
1.00
speaker_b
None
0.00

Single-speaker rows use [1.0, 0.0]. Blended rows (e.g. [0.67, 0.33], [0.50, 0.50]) are generated by the Rust blender into 768k / 588k-row variants.

B_metadata — a B_BLEND row (speaker-interpolated, anonymised)
{
  "id": 437, "text": "Δεν ήξερα", "language": "Greek", "emotion": "Angry",
  "dataset": "B_BLEND",
  "emotion_vector": [6, 0, 0, 0, 0, 0],
  "trait_vector":  [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
  "speaker_a": "Speaker_01", "speaker_b": "Speaker_02",
  "speaker_a_weight": 0.67,  "speaker_b_weight": 0.33,
  "blend_vector": [0.67, 0.33],
  "tokens_a": [
    "tokens/.../Speaker_01/Greek/Angry/0437.pt",    "tokens/.../Speaker_01/Greek/Happy/0437.pt",
    "tokens/.../Speaker_01/Greek/Neutral/0437.pt",  "tokens/.../Speaker_01/Greek/Scared/0437.pt",
    "tokens/.../Speaker_01/Greek/Shouting/0437.pt", "tokens/.../Speaker_01/Greek/Whisper/0437.pt"
  ],
  "tokens_b": [
    "tokens/.../Speaker_02/Greek/Angry/0437.pt",    "tokens/.../Speaker_02/Greek/Happy/0437.pt",
    "tokens/.../Speaker_02/Greek/Neutral/0437.pt",  "tokens/.../Speaker_02/Greek/Scared/0437.pt",
    "tokens/.../Speaker_02/Greek/Shouting/0437.pt", "tokens/.../Speaker_02/Greek/Whisper/0437.pt"
  ],
  "text_ids": [207, 149, 207, 182, 207, 190, 33, 207, 175, ...]
}

A B_BLEND row interpolates two speakers (here 0.67 / 0.33) and pairs both speakers' EnCodec token sets across all six emotional states for the same utterance — the raw material the model needs to learn the interpolation rather than memorise one voice. The Rust blender emits hundreds of thousands of these from the base corpus.

The real code

A browsable slice of the actual pipeline source. Pick a file.

A metadata tokenizer/tokenize_encodec.py

          

The metadata generators (B metadata training/main.rs, 543 lines) and the speaker-blender (B metadata blending/src/main.rs, 468 lines) are compiled Rust; the model itself (valle_training/VALL-E-X/models/vallex.py) wires the emotion/trait/blend projections into the AR and NAR heads as additive bias. Full source is in the repository.

The .bvbean binary format

One memory-mapped file packs the entire corpus — audio tokens, attention weights, text IDs, the three conditioning vectors, a speaker index, and the speaker-embedding lookup table — behind a JSON header of byte offsets, so the loader reads any sample with O(1) random access and no per-file open. Magic bytes: BDBEAN1\0.

MAGICformat sentinel8 bytes
header_reservedsize of JSON header regionu32
JSON headerN, Q, speakers[], languages[], all offsets & sizes≤ 64 KB
audio_offsetsper-sample start into audio_datau64 · N+1
audio_dataEnCodec codes, flat [T·Q]u16
attn_dataper-frame attention weightsf16
text_dataSentencePiece token IDsu16
emotion6-dim one-hot per samplef16 · N×6
trait13-dim continuous per samplef16 · N×13
blend2-dim speaker interpolationf16 · N×2
language_id0–12u8 · N
speaker_idxrow → speaker table indexu16 · N
speaker_embECAPA-TDNN lookup tablef16 · S×D

uint16-safe by design (all IDs validated < 65,536 at pack time); conditioning vectors stored as float16 and upcast to float32 on read — roughly half the storage of a naive float32/uint32 layout.

Training resilience — the loop that refuses to die

A weeks-long run on a single consumer GPU will hit NaNs, out-of-memory batches, and poisoned samples. The training loop (utils/train_epoch.py) is built to survive all of them without crashing — and, before it trusts a single step, to prove the model is actually using the text rather than hallucinating audio. Every guard below is in the real source; the skip-counters are returned in each epoch's summary.

Diagnostic
TEXT-USED test
Batch 0: ablate the text and compare logits. If they don't move, training halts — the model must read text, never babble audio blind.
Guard
Non-finite loss skip
isfinite(loss) drops NaN/Inf batches and zeroes grads — one poisoned sample can't sink a week of training.
Guard
Non-finite grad skip
Scans every parameter's gradient; on NaN/Inf it skips the optimizer step and logs the offending param + sample IDs for triage.
Recovery
OOM skip + cache flush
Catches OutOfMemoryError, empties CUDA cache, drops the batch, and continues — one long utterance won't kill the run.
Strategy
Segmented fallback
train_epoch_seg.py slices long sequences into overlapping VRAM-safe windows — run as an A/B against the flat loop to confirm the loss curve held.

BF16 autocast (GradScaler disabled by design), env-selectable SDPA kernel (flash / mem-efficient / math — the same switch that surfaced the Blackwell failure documented in the report), gradient accumulation, and a deterministic per-epoch sampler (seed = base + epoch) for reproducible shuffles.

valle_training/VALL-E-X/utils/train_epoch.py — resilience guards (excerpt)
# per-epoch skip counters (returned in each epoch's summary)
skip_oom = skip_nonfinite_loss = skip_nonfinite_grad = 0
scaler = torch.cuda.amp.GradScaler(enabled=False)    # BF16, not FP16

# --- TEXT-USED test (batch 0): prove the model actually reads the text ---
out_norm = model(x=text,     x_lens=text_lens, y=audio, ...)
text_abl = torch.zeros_like(text)                    # ablate the text
out_abl  = model(x=text_abl, x_lens=torch.ones_like(text_lens), y=audio, ...)
diff = (out_norm["logits"] - out_abl["logits"]).abs().max().item()
if diff <= 0:
    raise RuntimeError("[FATAL] TEXT-USED test FAILED: logits did not "
                       "change when text was ablated. STOP TRAINING.")

try:
    with torch.autocast("cuda", dtype=torch.bfloat16):
        loss = model(...)["loss"]

    if not torch.isfinite(loss):                     # NON-FINITE LOSS GUARD
        print(f"[NONFINITE-LOSS-SKIP] batch_idx={i} loss={loss}")
        skip_nonfinite_loss += 1
        optimizer.zero_grad(set_to_none=True); continue

    (loss / grad_accum_steps).backward()
    if accum >= grad_accum_steps:                    # NON-FINITE GRAD GUARD
        bad_grad = False
        for name, p in model.named_parameters():
            if p.grad is not None and not torch.isfinite(p.grad).all():
                bad_grad = True; break
        if bad_grad:
            print(f"[NONFINITE-GRAD-SKIP] :: bad={name} :: skipping step")
            skip_nonfinite_grad += 1
            optimizer.zero_grad(set_to_none=True); continue
        optimizer.step(); optimizer.zero_grad(set_to_none=True)

except torch.cuda.OutOfMemoryError as e:             # OOM RECOVERY
    print(f"[OOM-SKIP] batch_idx={i} :: {e}")
    skip_oom += 1
    torch.cuda.empty_cache()
    optimizer.zero_grad(set_to_none=True); continue
valle_training/VALL-E-X/utils/train_epoch_seg.py — VRAM-safe segmenting (excerpt)
def _make_segments(T, max_t, overlap=0, min_tail=0):
    """Slice [0, T) into VRAM-safe windows of <= max_t frames so a long
    utterance still fits in memory; overlap keeps continuity; a too-short
    tail is shifted left to stay full-length. max_t <= 0 = no segmenting."""
    if max_t <= 0 or T <= 0:
        return [(0, T)]
    step = max(1, max_t - overlap)
    segs, s = [], 0
    while s < T:
        e = min(T, s + max_t)
        segs.append((s, e))
        if e >= T: break
        s += step
    if min_tail > 0 and segs[-1][1] - segs[-1][0] < min_tail:
        segs[-1] = (max(0, T - max_t), T)            # shift the last window left
    return segs