# Neural Expression Generator: Architecture Design & Analysis

## Should You Build This? (Executive Summary)

**Short answer: No. Not yet. The rule-based approach (V3 Plan v2) is the right call for your current situation.**

This document provides two things:
1. A rigorous analysis of why the rule-based Base+Delta approach should come first
2. A complete neural generator architecture for when you have enough data to justify it (Phase 2)

The rule-based generator and the neural generator are not competitors --- they are stages. The rule-based system produces the training data that eventually trains the neural system. Skipping to the neural system without the rule-based foundation is like writing a compiler before you have a language specification.

---

## 1. The Honest Comparison: Rule-Based vs. Neural Generator

### 1.1 What Each Approach Actually Does

| Aspect | Rule-Based (V3 Plan v2) | Neural Generator |
|--------|------------------------|------------------|
| **Input** | emotion_tag + intensity + audio features + LAM blendshapes | text + audio + emotion_tag + intensity |
| **Output** | (T, 52) blendshape sequence | (T, 52) blendshape sequence |
| **How it works** | VAD lookup -> parametric blendshape delta -> apply to base -> add special patterns -> add noise | Learned mapping from inputs to blendshape sequence |
| **Data requirement** | None (uses research docs + MEAD as base) | 50,000+ paired clips minimum for decent results |
| **Training time** | 0 (no training) | 2-5 GPU-days on A100 |
| **Generation speed** | ~10,000 clips/hour on CPU | ~2,000 clips/hour on GPU |
| **Output diversity** | Style modes + Beta noise + speaker variation (good but patterned) | Learned distribution (better if well-trained) |
| **Failure mode** | Predictable: "robotic but correct" | Unpredictable: "sometimes great, sometimes garbage" |
| **Controllability** | Full --- every coefficient is tuneable | Partial --- only through conditioning inputs |
| **Extensibility** | Add a VAD coordinate and it works | Retrain the model |

### 1.2 What the Neural Generator Adds (When You Eventually Build It)

The neural generator's value proposition is NOT that it can do things the rule-based system cannot. It is that it can learn **correlations and subtleties that are too complex to specify by hand**:

1. **Audio-expression synchronization**: A rule system can modulate expression intensity by RMS energy. A neural model can learn that a rising pitch at the end of a sentence with "gratitude" conditioning produces a specific eyebrow-smile timing pattern that no hand-tuned rule would capture.

2. **Naturalness in transitions**: The rule system uses smoothstep crossfades in VAD space. A neural model, trained on enough real data, learns that the transition from laughter to apology involves a specific sequence: jaw closing precedes smile decay by ~100ms, browInnerUp onset overlaps with cheek relaxation, and the overall transition duration correlates with speech rate.

3. **Speaker-conditioned variation**: The rule system uses random Beta samples. A neural model learns that high-pitched voices correlate with wider eye openings during surprise, that male voices produce less cheekSquint during mild smiles, etc.

4. **Temporal dynamics beyond envelopes**: Real expressions have micro-dynamics --- subtle 2-3Hz oscillations in brow position during sustained sadness, asymmetric onset/offset curves that vary with emotion intensity, coupling between breathing rhythm (from audio) and facial muscle tension.

**But none of these matter if the base patterns are wrong.** A neural model that learns from bad synthetic data will produce outputs that are more diverse but equally wrong.

### 1.3 The Correct Sequence

```
Phase 1 (Now - Month 2):
  Rule-based generator -> Synthetic dataset -> Train V3 model
  Validate: Is the V3 model better than V2? Do the 16 emotions feel distinct?

Phase 2 (Month 3-5, IF Phase 1 succeeds):
  Collect real expression data (motion capture / MediaPipe on actors)
  Train neural generator on real data
  Use neural generator to produce BETTER synthetic dataset
  Retrain V3 model on improved dataset

Phase 3 (Month 6+, IF Phase 2 succeeds):
  Neural generator replaces rule-based system entirely
  Can produce arbitrary emotions, not just 16 predefined
  Can be conditioned on speaker identity for personalized expressions
```

### 1.4 When to Pull the Trigger on Phase 2

Build the neural generator when ALL of the following are true:

- [x] V3 model trained and deployed (Phase 1 complete)
- [ ] Quality assessment reveals systematic weaknesses in rule-based data (e.g., "gratitude always looks the same", "transitions feel mechanical")
- [ ] You have access to at least 20,000 clips of real facial expression data with paired audio (from MoCap, MediaPipe extraction from video, or purchased datasets)
- [ ] You have GPU budget for 2-5 days of A100 training
- [ ] The product requires emotion quality that the rule-based system demonstrably cannot achieve

---

## 2. Neural Generator Architecture (For Phase 2)

The remainder of this document specifies the architecture in full, ready to implement when the time comes.

### 2.1 Architecture Selection

| Architecture | Pros | Cons | Verdict for THIS task |
|-------------|------|------|----------------------|
| **Autoregressive Transformer** | Strong temporal modeling, proven for sequence generation (speech, music), good at learning long-range dependencies | Slow generation (sequential), can drift/degenerate over long sequences, requires large datasets | **Good candidate** --- temporal coherence is critical for expression |
| **Conditional Diffusion Model** | State-of-the-art quality for generative tasks, handles multimodality well, natural diversity | Slow generation (iterative denoising), complex training, harder to control output precisely | Overpowered for this task --- we want controlled, deterministic-ish output, not creative diversity |
| **Conditional VAE** | Fast generation, learned latent space, controllable via latent manipulation, works with smaller datasets | Mode collapse risk, blurry/averaged outputs, latent space may not be semantically meaningful | **Good candidate** --- latent space aligns with VAD conceptually |
| **GAN (Conditional)** | Sharp, realistic outputs, fast generation | Training instability, mode collapse, hard to evaluate quality, adversarial training is fragile | Bad fit --- 52-dim continuous output is not the kind of output GANs excel at (they shine for images) |
| **Flow-based (Normalizing Flow)** | Exact likelihood, invertible, stable training | Limited expressiveness, large model size, complex architecture | Moderate fit --- theoretically clean but practically over-engineered for this |
| **Non-autoregressive Transformer + Noise schedule** | Fast generation, parallel, can model long sequences | Needs careful design to avoid temporal artifacts | Worth considering as a variant |

**Selected: Conditional VAE with Transformer backbone**

Rationale:
- The VAE's latent space maps naturally to the VAD emotion space --- we can initialize it with known VAD coordinates and let training refine the mapping
- The Transformer backbone handles temporal modeling (expression sequences are 30-300 frames at 30fps = 1-10 seconds)
- The VAE's reconstruction loss naturally prevents the mode collapse that plagues GANs
- Generation is fast (single forward pass), which matters because we need to generate 15,000-50,000 clips
- Works with moderate dataset sizes (20,000-50,000 clips vs. 200,000+ for diffusion)

### 2.2 Model Architecture: ExpressionVAE-T

```
=============================================================================
ExpressionVAE-T (Expression Variational Autoencoder with Transformer)
=============================================================================

Inputs:
  audio_features: (B, T, 141)     -- mel + pitch + energy + HuBERT features
  text_tokens:    (B, L)           -- tokenized text (for semantic understanding)
  emotion_tag:    (B, 16)          -- 16-class one-hot * intensity
  lam_blendshapes:(B, T, 52)      -- LAM lip sync output (provides speech timing)

Output:
  blendshapes:    (B, T, 52)      -- full 52-channel blendshape sequence

Latent:
  z_style:        (B, D_z)        -- style/speaker latent (D_z = 32)
  z_emotion:      (B, 3)          -- learned VAD-like space (initialized from VAD)

=============================================================================

Architecture:

                    text_tokens
                        |
                    [Text Encoder]
                    Pretrained HuBERT/KoBERT (frozen)
                    or learned embedding + 2-layer Transformer
                        |
                    text_context: (B, L, 256)
                        |
                    [Cross-Attention Pool] --> text_summary: (B, 256)
                        |
    audio_features      |       emotion_tag
        |               |           |
    [Audio Encoder]     |     [Emotion Embed]
    Conv1D stack        |     Linear(16, 64)
    k=5, ch=[141,256,256]|         |
        |               |           |
    audio_enc: (B,T,256)|           |
        |               |           |
    [========== Fusion Module ==========]
    |  Concat: audio_enc + text_summary.expand(T) + emotion_embed.expand(T)  |
    |  -> (B, T, 256+256+64 = 576)                                            |
    |  Linear(576, 512)                                                        |
    |  -> fused: (B, T, 512)                                                   |
    [=======================================]
        |
        v
    +-----------+                    +-----------+
    |  ENCODER  |                    |  DECODER  |
    +-----------+                    +-----------+
    |                                |
    | CausalTransformer              | CausalTransformer
    | 4 layers, 8 heads              | 6 layers, 8 heads
    | d_model=512, d_ff=1024         | d_model=512, d_ff=1024
    | + FiLM conditioning            | + FiLM conditioning
    |   (from emotion_embed)         |   (from emotion_embed + z)
    |                                |
    | -> enc_out: (B, T, 512)        | Input: fused + z_style.expand(T)
    |                                |        + z_emotion.expand(T)
    | [Global Pool over T]           |
    | -> (B, 512)                    | -> dec_out: (B, T, 512)
    |                                |
    | [z_style head]                 | [Output Head]
    | Linear(512, 64)                | Linear(512, 256)
    | -> mu_s, logvar_s: (B, 32)     | ReLU
    | -> z_style ~ N(mu_s, var_s)    | Linear(256, 52)
    |                                | Sigmoid
    | [z_emotion head]               | -> blendshapes_raw: (B, T, 52)
    | Linear(512, 6)                 |
    | -> mu_e, logvar_e: (B, 3)      | [Channel Merge]
    | -> z_emotion ~ N(mu_e, var_e)  | LIPSYNC_ONLY: lam_blendshapes
    |                                | EXPRESSION_ONLY: blendshapes_raw
    +-----------+                    | SHARED: speech-aware blend
                                     | -> blendshapes: (B, T, 52)
                                     +-----------+

Total parameters: ~12M (small enough for fast iteration)
```

### 2.3 Component Details

#### 2.3.1 Audio Encoder

```python
class AudioEncoder(nn.Module):
    """Process 141-dim audio features into temporal representations.

    Input: (B, T, 141) -- same features as V2 model
      - dims 0-39:   40 mel filterbank energies
      - dims 40-79:  40 mel deltas
      - dims 80-119: 40 mel delta-deltas
      - dim 120:     pitch (F0, normalized)
      - dim 121:     pitch confidence
      - dim 122:     RMS energy
      - dims 123-140: 18 additional prosodic features
    Output: (B, T, 256)
    """
    def __init__(self):
        self.conv_stack = nn.Sequential(
            # Causal Conv1D stack -- same causality as V2 for consistency
            CausalConv1d(141, 256, kernel_size=5),
            nn.GELU(),
            CausalConv1d(256, 256, kernel_size=5),
            nn.GELU(),
            CausalConv1d(256, 256, kernel_size=3),
            nn.LayerNorm(256),
        )
```

The audio encoder is deliberately simple because the audio features are already well-engineered (mel + prosodic features). The heavy lifting happens in the Transformer.

#### 2.3.2 Text Encoder

```python
class TextEncoder(nn.Module):
    """Extract semantic context from text.

    This is the key addition over the rule-based system.
    The text tells the model WHAT is being said, which should
    influence HOW the face moves.

    Example: "Thank you so much" + gratitude -> warm browInnerUp + sustained smile
             "Sorry about that" + apology -> gaze aversion + compressed lips
    """
    def __init__(self, vocab_size=32000, d_model=256):
        # Option A: Pretrained (recommended if Korean text)
        # self.backbone = AutoModel.from_pretrained('skt/ko-gpt-trinity')
        # self.proj = nn.Linear(backbone.config.hidden_size, 256)

        # Option B: Learned from scratch (simpler, sufficient for emotion)
        self.embed = nn.Embedding(vocab_size, d_model)
        self.pos_enc = SinusoidalPositionalEncoding(d_model)
        self.transformer = nn.TransformerEncoder(
            nn.TransformerEncoderLayer(d_model, nhead=4, dim_feedforward=512),
            num_layers=2
        )
        self.pool = nn.MultiheadAttention(d_model, num_heads=4)  # query pool

    def forward(self, tokens):
        # tokens: (B, L)
        x = self.embed(tokens) + self.pos_enc(tokens)
        x = self.transformer(x)  # (B, L, 256)

        # Cross-attention pool to fixed-size summary
        query = self.learned_query.expand(tokens.size(0), 1, -1)  # (B, 1, 256)
        summary, _ = self.pool(query, x, x)  # (B, 1, 256)
        return summary.squeeze(1)  # (B, 256)
```

**Why text matters**: Without text, "gratitude intensity 2" always produces the same expression regardless of what is being said. With text, the model can learn that "I really appreciate your help" produces a different expression trajectory than "thanks" --- the former has a longer sustained smile, the latter is a brief pulse.

#### 2.3.3 Emotion Embedding with VAD Initialization

```python
class EmotionEmbedding(nn.Module):
    """Embed 16-class emotion tag into conditioning vector.

    Critically: initialize embeddings from known VAD coordinates,
    so the emotion space starts with meaningful structure.
    """
    def __init__(self, n_emotions=16, d_embed=64):
        self.embed = nn.Linear(n_emotions, d_embed, bias=False)

        # Initialize: emotions with same parent start close together
        # This mirrors the V3 Plan v2 FiLM initialization strategy
        with torch.no_grad():
            parent_centroids = {
                'JOY':      torch.randn(d_embed) * 0.1,
                'SADNESS':  torch.randn(d_embed) * 0.1,
                'ANGER':    torch.randn(d_embed) * 0.1,
                'SURPRISE': torch.randn(d_embed) * 0.1,
                'NEUTRAL':  torch.randn(d_embed) * 0.1,
            }
            PARENT_MAP = {
                0: 'JOY', 1: 'SADNESS', 2: 'ANGER', 3: 'SURPRISE', 4: 'NEUTRAL',
                5: 'JOY', 6: 'JOY', 7: 'JOY', 8: 'JOY',
                9: 'SADNESS', 10: 'SADNESS', 11: 'SADNESS', 12: 'SADNESS',
                13: 'ANGER', 14: 'SURPRISE', 15: 'SURPRISE',
            }
            for idx, parent in PARENT_MAP.items():
                # Inject known VAD coordinates into first 3 dims
                vad = EMOTION_VAD_EXTREMES[idx]  # from research doc
                base = parent_centroids[parent] + torch.randn(d_embed) * 0.02
                base[0] = vad[0]  # V
                base[1] = vad[1]  # A
                base[2] = vad[2]  # D
                self.embed.weight.data[:, idx] = base
```

#### 2.3.4 The VAE Latent Space

Two latent variables, serving different purposes:

**z_style (32-dim)**: Captures speaker-specific and utterance-specific variation --- how expressive is this particular speaker, do they smile more with the left side, how fast are their brow movements, etc. This is the "how" of expression.

**z_emotion (3-dim)**: Captures the affective state in a learned continuous space that is initialized from but not constrained to VAD coordinates. This allows the model to learn corrections to the hand-specified VAD mappings. This is the "what" of expression.

```python
class LatentHeads(nn.Module):
    def __init__(self, d_enc=512, d_style=32, d_emotion=3):
        # Style latent
        self.style_mu = nn.Linear(d_enc, d_style)
        self.style_logvar = nn.Linear(d_enc, d_style)

        # Emotion latent -- initialized to output known VAD coordinates
        self.emotion_mu = nn.Linear(d_enc, d_emotion)
        self.emotion_logvar = nn.Linear(d_enc, d_emotion)

        # Initialize emotion_mu bias to neutral VAD
        with torch.no_grad():
            self.emotion_mu.bias.data = torch.tensor([0.50, 0.30, 0.50])
            self.emotion_logvar.bias.data = torch.full((d_emotion,), -2.0)
            # logvar = -2.0 -> var = 0.135 -> std = 0.37
            # This is tight enough to be meaningful but loose enough to learn

    def forward(self, enc_pooled):
        mu_s = self.style_mu(enc_pooled)
        logvar_s = self.style_logvar(enc_pooled)
        z_style = self.reparameterize(mu_s, logvar_s)

        mu_e = self.emotion_mu(enc_pooled)
        logvar_e = self.emotion_logvar(enc_pooled)
        z_emotion = self.reparameterize(mu_e, logvar_e)

        return z_style, z_emotion, mu_s, logvar_s, mu_e, logvar_e

    def reparameterize(self, mu, logvar):
        if self.training:
            std = torch.exp(0.5 * logvar)
            eps = torch.randn_like(std)
            return mu + eps * std
        return mu
```

#### 2.3.5 Channel Merge Module

```python
class ChannelMerge(nn.Module):
    """Merge neural generator output with LAM lip sync output.

    Exactly mirrors the 3-way channel classification from V3 Plan v2.
    """
    LIPSYNC_ONLY = [14, 15, 16, 18, 19, 20, 21, 22, 29, 30, 37, 38, 39, 40]
    EXPRESSION_ONLY = [0,1,2,3,4,5,6,7,8,9,10,11,12,13,41,42,43,44,45,47,48,49,50,51]
    SHARED = [17, 23,24, 25,26, 27,28, 31,32, 33,34, 35,36, 46]

    def forward(self, generated, lam_output):
        """
        generated:  (B, T, 52) -- neural generator raw output
        lam_output: (B, T, 52) -- LAM lip sync blendshapes
        returns:    (B, T, 52) -- merged output
        """
        output = torch.zeros_like(generated)

        # LIPSYNC_ONLY: LAM owns these completely
        output[:, :, self.LIPSYNC_ONLY] = lam_output[:, :, self.LIPSYNC_ONLY]

        # EXPRESSION_ONLY: Generator owns these completely
        output[:, :, self.EXPRESSION_ONLY] = generated[:, :, self.EXPRESSION_ONLY]

        # SHARED: Speech-aware blend
        speech_activity = (
            lam_output[:, :, 17] * 1.2 +   # jawOpen
            lam_output[:, :, 18] * 1.5 +   # mouthClose
            lam_output[:, :, 19] * 1.0 +   # mouthFunnel
            lam_output[:, :, 20] * 1.0      # mouthPucker
        ).clamp(0, 1)
        emotion_influence = 1.0 - speech_activity * 0.7

        for ch in self.SHARED:
            if ch == 17:  # jawOpen: multiplicative
                arousal_scale = generated[:, :, ch] / (lam_output[:, :, ch] + 1e-6)
                arousal_scale = arousal_scale.clamp(0.7, 1.5)
                output[:, :, ch] = lam_output[:, :, ch] * (
                    1.0 + (arousal_scale - 1.0) * emotion_influence
                )
            else:
                delta = generated[:, :, ch] - lam_output[:, :, ch]
                output[:, :, ch] = lam_output[:, :, ch] + delta * emotion_influence.unsqueeze(-1)

        return output.clamp(0, 1)
```

### 2.4 Loss Function

```python
class ExpressionVAELoss(nn.Module):
    """
    L_total = L_recon + beta * L_kl + L_aux

    where:
      L_recon = channel-weighted L1 + velocity L1 + acceleration L1
      L_kl    = KL(z_style) + lambda_emotion * KL(z_emotion)
      L_aux   = emotion classification + VAD regression
    """
    def __init__(self):
        # Channel weights mirror V3 Plan v2
        self.channel_w = torch.ones(52)
        self.channel_w[LIPSYNC_ONLY] = 0.0      # Don't penalize -- these come from LAM
        self.channel_w[EXPRESSION_ONLY] = 1.5    # Main learning target
        self.channel_w[SHARED] = 1.0             # Moderate -- partially from LAM

        # Loss weights
        self.w_recon = 100.0
        self.w_velocity = 10.0
        self.w_accel = 2.0
        self.w_peak = 30.0
        self.beta_style = 0.01       # Low beta for style -- encourage diversity
        self.beta_emotion = 1.0      # Higher beta for emotion -- encourage structure
        self.w_emotion_cls = 0.5
        self.w_vad_reg = 2.0

    def forward(self, pred, target, z_params, emotion_label, vad_target):
        mu_s, logvar_s, mu_e, logvar_e = z_params

        # --- Reconstruction ---
        # Weighted L1
        diff = (pred - target).abs()  # (B, T, 52)
        L_recon = (diff * self.channel_w).mean()

        # Velocity matching (temporal smoothness)
        pred_vel = pred[:, 1:] - pred[:, :-1]
        tgt_vel = target[:, 1:] - target[:, :-1]
        L_vel = (pred_vel - tgt_vel).abs().mean()

        # Acceleration matching
        pred_acc = pred_vel[:, 1:] - pred_vel[:, :-1]
        tgt_acc = tgt_vel[:, 1:] - tgt_vel[:, :-1]
        L_acc = (pred_acc - tgt_acc).abs().mean()

        # Peak frame emphasis -- frames with high expression activation matter more
        peak_weight = target[:, :, EXPRESSION_ONLY].sum(dim=-1)  # (B, T)
        peak_weight = (peak_weight / peak_weight.max(dim=1, keepdim=True).values.clamp(min=0.1))
        L_peak = (diff[:, :, EXPRESSION_ONLY] * peak_weight.unsqueeze(-1)).mean()

        L_reconstruction = (self.w_recon * L_recon +
                           self.w_velocity * L_vel +
                           self.w_accel * L_acc +
                           self.w_peak * L_peak)

        # --- KL Divergence ---
        L_kl_style = -0.5 * (1 + logvar_s - mu_s.pow(2) - logvar_s.exp()).sum(dim=-1).mean()
        L_kl_emotion = -0.5 * (1 + logvar_e - mu_e.pow(2) - logvar_e.exp()).sum(dim=-1).mean()

        L_kl = self.beta_style * L_kl_style + self.beta_emotion * L_kl_emotion

        # --- Auxiliary losses ---
        # VAD regression: z_emotion should predict known VAD coordinates
        L_vad = F.mse_loss(mu_e, vad_target)  # vad_target: (B, 3) from emotion lookup

        L_total = L_reconstruction + L_kl + self.w_vad_reg * L_vad

        return L_total, {
            'recon': L_recon.item(),
            'vel': L_vel.item(),
            'peak': L_peak.item(),
            'kl_style': L_kl_style.item(),
            'kl_emotion': L_kl_emotion.item(),
            'vad_reg': L_vad.item(),
        }
```

---

## 3. Training Strategy

### 3.1 Data Requirements

| Data Source | Clips | Has Real Blendshapes? | Role |
|------------|-------|----------------------|------|
| **MEAD** | 4,488 | Yes (MediaPipe extracted) | Foundation: 5 base emotions with real expressions |
| **MEAD re-labeled** | Same 4,488 | Yes (originals) + synthetic delta | Sub-emotion variants via VAD delta |
| **ElevenLabs TTS (existing)** | 5,001 | No -> needs V2 inference + delta | Audio diversity for expression-speech coupling |
| **ElevenLabs TTS (new)** | 8,000-12,000 | No -> needs V2 inference + delta | Sub-emotion audio diversity |
| **RAVDESS / CREMA-D** | ~7,000 | Extractable via MediaPipe | More real emotion data (English) |
| **Actor-recorded (ideal)** | 5,000+ | Yes (MoCap or MediaPipe) | High-quality ground truth |

**Minimum viable training set**: ~20,000 clips with paired (audio, blendshape, emotion_tag)

**Current available**: ~9,500 clips (MEAD + ElevenLabs existing), expandable to ~22,000 with new TTS

**Assessment**: You are at the lower bound. The neural generator would work but be undertrained. This is the primary reason to start with the rule-based approach --- it costs nothing to generate 50,000 synthetic clips, and those can bootstrap neural generator training later.

### 3.2 Three-Phase Training

```
Phase A: Pre-train on MEAD real data (5 base emotions only)
  Purpose: Learn audio-expression coupling from real data
  Data: 4,488 MEAD clips (real blendshapes from MediaPipe)
  Labels: 5 base emotions only
  Duration: ~50 epochs, 4-6 hours on single GPU
  Expected outcome: Model learns basic audio-to-expression mapping

Phase B: Fine-tune on expanded dataset (16 emotions)
  Purpose: Learn sub-emotion differentiation
  Data: 4,488 MEAD + rule-based synthetic (from V3 Plan v2 generator)
  Labels: 16 emotions with intensity
  Strategy:
    - Freeze audio encoder (learned in Phase A)
    - Train emotion embedding + decoder with lower LR
    - Mix ratio: 40% MEAD real, 60% synthetic
  Duration: ~80 epochs, 8-12 hours
  Expected outcome: Model can generate distinct expressions for 16 emotions

Phase C: (When available) Fine-tune on real sub-emotion data
  Purpose: Replace synthetic patterns with real ones
  Data: Actor-recorded / MoCap data for sub-emotions
  Strategy: Curriculum --- start with high synthetic ratio, gradually shift to real
  Duration: ~100 epochs, 12-24 hours
  Expected outcome: Natural, diverse expressions that surpass rule-based quality
```

### 3.3 The Chicken-and-Egg Solution

The question: "How does the generator learn to produce emotions it has never seen?"

The answer is a three-stage bootstrap:

```
Stage 1: Anchor with known emotions
  - Train on MEAD: model learns joy, sadness, anger, surprise, neutral
  - The z_emotion latent space organizes around 5 known VAD points

Stage 2: Interpolate in latent space
  - The VAD regression loss forces z_emotion to be geometrically meaningful
  - "Gratitude" has VAD (0.85, 0.45, 0.38) -- between joy (0.87, 0.72, 0.72)
    and neutral (0.50, 0.30, 0.50) in VAD space
  - The model can interpolate: lower arousal than joy, lower dominance
  - This produces a REASONABLE first approximation

Stage 3: Refine with rule-based targets
  - Feed the model rule-based synthetic data for sub-emotions
  - The model learns the SPECIFIC patterns (browInnerUp for gratitude,
    jaw oscillation for laughter) that interpolation alone misses
  - Because it already has the right general structure from Stage 2,
    it converges quickly

Stage 4: (Eventually) Replace with real data
  - When real sub-emotion recordings become available,
    the model's Stage 2+3 initialization means it needs fewer real samples
    to reach good quality
```

**This is why VAD is an intermediate representation inside the generator.** The z_emotion latent is not constrained to equal VAD exactly (that would be too rigid), but the VAD regression loss ensures it has the right geometric structure. The model learns to deviate from VAD where the real data shows VAD is wrong, while maintaining the overall layout.

### 3.4 Key Training Hyperparameters

```python
config = {
    # Model
    'd_model': 512,
    'd_ff': 1024,
    'n_enc_layers': 4,
    'n_dec_layers': 6,
    'n_heads': 8,
    'd_style': 32,
    'd_emotion': 3,
    'dropout': 0.1,

    # Training
    'batch_size': 32,
    'max_seq_len': 300,          # 10 seconds at 30fps
    'lr_encoder': 1e-4,
    'lr_decoder': 3e-4,
    'lr_latent': 1e-4,
    'weight_decay': 1e-5,
    'scheduler': 'cosine_warmup',
    'warmup_steps': 2000,

    # Beta annealing for KL
    'beta_start': 0.0,           # Start with pure reconstruction
    'beta_end_style': 0.01,
    'beta_end_emotion': 1.0,
    'beta_anneal_epochs': 20,    # Linear anneal over 20 epochs

    # Data
    'train_split': 0.9,
    'val_split': 0.1,
    'mead_weight': 2.0,          # Oversample MEAD real data
}
```

---

## 4. VAD's Role in the Generator

### 4.1 VAD as Structural Prior, Not Hard Constraint

The generator uses VAD in three ways:

1. **Initialization**: Emotion embeddings are initialized with known VAD coordinates (dims 0-2 of the 64-dim embedding). This gives the model a head start.

2. **Latent structure**: The z_emotion latent (3-dim) is regularized toward VAD coordinates via the VAD regression loss. This means the latent space has a meaningful geometry from the start: emotions that are close in VAD space start close in latent space.

3. **No runtime VAD**: At generation time, the model receives emotion_tag (16-class) and intensity, NOT raw VAD values. VAD is a training signal, not an input. This prevents the brittleness of hard-coded VAD thresholds.

### 4.2 What If VAD Is Wrong?

The beauty of using VAD as a soft prior (not hard constraint) is that the model can learn corrections:

- The research doc places gratitude at V=0.85, A=0.45, D=0.38
- Real gratitude data might show that Korean speakers express gratitude with slightly higher arousal (more eyebrow activity) than the Western-derived VAD literature suggests
- The model can learn this deviation because z_emotion is allowed to differ from the VAD target (the regression loss is MSE, not equality)
- Over training, the mu_e for gratitude might drift from (0.85, 0.45, 0.38) to (0.85, 0.52, 0.35), reflecting the actual data distribution

This is the advantage of the neural approach over the rule-based system: the rule-based system is locked to the VAD coordinates you specify. The neural system uses them as a starting point and adapts.

---

## 5. Quality Control

### 5.1 Offline Validation Metrics

```python
class ExpressionQualityMetrics:
    """Evaluate generated expression sequences."""

    def lip_sync_preservation(self, generated, lam_reference):
        """LIPSYNC_ONLY channels should be identical to LAM."""
        diff = (generated[:, LIPSYNC_ONLY] - lam_reference[:, LIPSYNC_ONLY]).abs()
        return 1.0 - diff.mean()  # Should be > 0.99

    def expression_distinctiveness(self, samples_by_emotion):
        """Different emotions should produce different expressions.
        Compute pairwise cosine distance between emotion centroids.
        """
        centroids = {}
        for emotion, samples in samples_by_emotion.items():
            # Average over time, then average over samples
            centroids[emotion] = np.mean([s.mean(axis=0)[EXPRESSION_ONLY]
                                          for s in samples], axis=0)

        distances = []
        for e1, c1 in centroids.items():
            for e2, c2 in centroids.items():
                if e1 >= e2:
                    continue
                dist = 1.0 - np.dot(c1, c2) / (np.linalg.norm(c1) * np.linalg.norm(c2) + 1e-8)
                distances.append((e1, e2, dist))

        return distances  # Should be > 0.3 for different families, > 0.1 within family

    def temporal_smoothness(self, generated):
        """Expressions should not jitter.
        Compute acceleration (2nd derivative) magnitude.
        """
        vel = np.diff(generated, axis=0)
        acc = np.diff(vel, axis=0)
        jerk = np.abs(acc).mean()
        return jerk  # Should be < 0.015 for natural motion

    def blendshape_validity(self, generated):
        """Check for physiologically impossible combinations."""
        violations = 0

        # Can't smile and frown simultaneously at high intensity
        coactive = np.minimum(generated[:, 23], generated[:, 25])  # SmileL vs FrownL
        if coactive.max() > 0.3:
            violations += 1

        # Can't have browDown and browInnerUp both high
        coactive = np.minimum(generated[:, 41], generated[:, 43])  # browDownL vs browInnerUp
        if coactive.max() > 0.5:
            violations += 1

        # eyeBlink and eyeWide are antagonistic
        coactive = np.minimum(generated[:, 0], generated[:, 12])
        if coactive.max() > 0.4:
            violations += 1

        return violations  # Should be 0

    def intensity_monotonicity(self, samples_intensity_1, samples_intensity_2, samples_intensity_3):
        """Higher intensity should produce larger expression magnitudes."""
        mag1 = np.mean([s[:, EXPRESSION_ONLY].sum(axis=1).mean() for s in samples_intensity_1])
        mag2 = np.mean([s[:, EXPRESSION_ONLY].sum(axis=1).mean() for s in samples_intensity_2])
        mag3 = np.mean([s[:, EXPRESSION_ONLY].sum(axis=1).mean() for s in samples_intensity_3])
        return mag1 < mag2 < mag3  # Should be True

    def parent_family_coherence(self, sub_emotion_samples, parent_emotion_samples):
        """Sub-emotions should be recognizably related to their parent.
        e.g., gratitude should look more like joy than like anger.
        """
        sub_centroid = np.mean([s.mean(axis=0) for s in sub_emotion_samples], axis=0)
        parent_centroid = np.mean([s.mean(axis=0) for s in parent_emotion_samples], axis=0)

        dist_to_parent = np.linalg.norm(sub_centroid[EXPRESSION_ONLY] -
                                         parent_centroid[EXPRESSION_ONLY])

        # Compare to distance to other parents
        # dist_to_parent should be smallest
        return dist_to_parent
```

### 5.2 Visual Spot-Check Protocol

Automated metrics catch gross errors. Subtle quality issues require human evaluation:

```
Spot-check procedure (run after every training phase):

1. Generate 10 samples each for all 16 emotions at intensity 2
   -> Render as video using AnimaSync avatar
   -> Check: Does the emotion "read"? Would a person identify it?

2. Generate 5 pairs of similar emotions:
   - gratitude vs. joy (should differ in browInnerUp)
   - crying vs. sadness (should differ in arousal/jaw activity)
   - refusal vs. anger (should differ in arousal intensity)
   - fluster vs. surprise (should differ in valence)
   - shy vs. sulk (should differ in parent family)
   -> Check: Are they distinguishable?

3. Generate 5 transition clips (emotion A -> B):
   - laughter -> apology
   - neutral -> excitement
   - anger -> agreement
   -> Check: Is the transition smooth? Does it pass through
             reasonable intermediate states?

4. Generate same text + same emotion but different "z_style" samples:
   -> Check: Do they look different but recognizably the same emotion?
   -> This tests the diversity of the style latent

5. Compare neural vs. rule-based output for identical inputs:
   -> Check: Is the neural version better? If not, Phase 2 is not ready.
```

### 5.3 Failure Modes

| Failure Mode | Symptom | Detection | Root Cause | Fix |
|-------------|---------|-----------|------------|-----|
| **Mode collapse** | All samples for an emotion look identical | Low variance across samples | KL weight too high, or insufficient training data for that emotion | Lower beta, augment data |
| **Lip sync corruption** | Mouth movements don't match speech | lip_sync_preservation < 0.95 | Channel merge not working, or generator learning to override LAM channels | Check LIPSYNC_ONLY masking, increase lipsync channel weight |
| **Temporal jitter** | Twitching, unnatural rapid movements | temporal_smoothness > 0.02 | Insufficient velocity/acceleration loss, or noisy training targets | Increase L_vel weight, smooth training targets |
| **Emotion confusion** | Gratitude looks like joy | expression_distinctiveness < 0.1 between these emotions | Insufficient separation in training data, or z_emotion not structured | Increase L_vad_reg, ensure training data has clear differences |
| **Intensity plateau** | Intensity 1 and 3 look the same | intensity_monotonicity fails | Intensity not effectively modulating the conditioning | Check how intensity scales the emotion_tag vector |
| **Dead channels** | Some blendshapes always near zero | Per-channel activation histogram | Imbalanced training data, or loss doesn't penalize these channels | Per-channel loss weighting, data augmentation |
| **Physiological violations** | Contradictory muscle activations | blendshape_validity > 0 | Model hasn't learned antagonistic constraints | Add antagonistic penalty to loss, or post-process |

---

## 6. Practical Considerations

### 6.1 Compute Requirements

```
Training:
  Phase A (MEAD pre-train):     ~4,500 clips * 50 epochs = 225K iterations
                                 at batch_size=32: ~7,000 steps
                                 at ~0.5s/step on RTX 3090: ~1 hour

  Phase B (expanded fine-tune):  ~20,000 clips * 80 epochs = 1.6M iterations
                                 at batch_size=32: ~50,000 steps
                                 at ~0.5s/step: ~7 hours

  Phase C (real data fine-tune): depends on data volume
                                 estimate ~12-24 hours

  Total: ~1-2 GPU-days on RTX 3090, or ~0.5-1 GPU-day on A100

Generation:
  Forward pass: ~15ms per clip (single GPU, T=100 frames)
  Throughput: ~240,000 clips/hour (batched, GPU)
             ~3,000 clips/hour (single, CPU via ONNX)

  For 50,000 clip dataset: ~12 minutes on GPU, ~17 hours on CPU
```

### 6.2 Model Size

```
Component                    Parameters
------------------------------------------
Audio Encoder (Conv1D x3)    ~400K
Text Encoder (Transformer)   ~1.5M (or 0 if pretrained and frozen)
Emotion Embedding             ~1K
Encoder Transformer (4L)     ~4.2M
Decoder Transformer (6L)     ~6.3M
Latent Heads                  ~35K
Output MLP                    ~15K
Channel Merge                 0 (hard-coded logic)
------------------------------------------
Total                        ~12.5M parameters

ONNX export (FP16):          ~25MB
ONNX export (INT8):          ~13MB

For comparison:
  V2 production model:       ~5.5MB (INT8 ONNX)
  Stable Diffusion:          ~4GB
  GPT-2 Small:               ~500MB
```

The generator model is small enough to run on consumer hardware but large enough to learn meaningful patterns.

### 6.3 Generation Pipeline (How It Integrates)

```
generate_expression_dataset.py:

  for each (audio_file, text, emotion, intensity) in manifest:
      # 1. Extract audio features (reuse existing pipeline)
      features = extract_features(audio_file)  # (T, 141)

      # 2. Extract LAM lip sync (reuse existing pipeline)
      lam_output = lam_model.infer(features)   # (T, 52)

      # 3. Tokenize text
      tokens = tokenizer.encode(text)           # (L,)

      # 4. Prepare emotion conditioning
      emotion_vec = one_hot(16, emotion_idx) * (intensity / 3.0)

      # 5. Generate expression (neural model)
      blendshapes = generator.generate(
          features, tokens, emotion_vec, lam_output,
          n_samples=1,           # or more for diversity
          temperature=0.8,       # controls z_style sampling spread
      )

      # 6. Quality check
      metrics = quality_checker.evaluate(blendshapes, lam_output, emotion)
      if metrics.lip_sync_preservation < 0.95:
          log_warning(f"Lip sync degraded for {audio_file}")
          continue

      # 7. Save as V3 training target
      np.save(f'teacher_outputs/{clip_id}.npy', blendshapes)
```

---

## 7. Why Not a Diffusion Model?

This deserves explicit discussion because diffusion models are the current hype in generative AI.

**The case for diffusion:**
- State-of-the-art sample quality in images, audio, and motion synthesis
- Natural handling of multimodal distributions
- Stable training compared to GANs
- Good papers exist for facial animation specifically (DiffTalk, FaceDiffuser)

**The case against diffusion FOR THIS SPECIFIC TASK:**

1. **We want controllable, predictable output.** The generator is producing training data. If the training data is randomly sampled from a distribution, we lose the ability to systematically cover the emotion space. We need to be able to say "generate gratitude intensity 2, style A" and get a consistent result. Diffusion models produce diverse outputs by design --- that is their strength, but it is a weakness here.

2. **Generation speed matters.** Diffusion models require 20-100 denoising steps per sample. For 50,000 clips, this is 1M-5M forward passes instead of 50K. On a single GPU, this is the difference between 12 minutes and 4+ hours.

3. **Small dataset.** Diffusion models typically need much more data than VAEs to learn a good denoising process. With 20,000 clips, a VAE will produce coherent output; a diffusion model may produce noisy or averaged output.

4. **52-dim continuous output.** Diffusion models shine when the output space is high-dimensional and structured (images: 256x256x3, audio: 16000 samples/sec). 52-dim blendshapes are low-dimensional and well-understood. The added complexity of diffusion is not needed.

5. **Debugging difficulty.** When the generator produces a bad clip, we need to understand why. VAE: inspect the z_style and z_emotion values, check which reconstruction loss terms are high. Diffusion: inspect... the noise schedule? The denoising trajectory? Much harder to debug.

**Verdict**: Diffusion would be the right choice if we were generating photorealistic face videos. For structured 52-dim blendshape sequences, a conditional VAE with Transformer backbone is the sweet spot between quality and controllability.

---

## 8. Summary: What to Build and When

### Now (Phase 1): Rule-based generator

This is already specified in V3_IMPLEMENTATION_PLAN_v2.md. Build it.

- Base + VAD delta approach
- Style modes for variation
- Beta distribution noise
- Special patterns for laughter, crying, fluster
- Speech-aware channel merging
- Generate 15,000-20,000 clips
- Train V3 model on this data

### Later (Phase 2): Neural generator (this document)

Build when Phase 1 is deployed and you have quality feedback.

- ExpressionVAE-T architecture: ~12M parameters
- Pre-train on MEAD, fine-tune on rule-based synthetic data
- VAD as structural prior in latent space
- Replaces rule-based generator for higher quality dataset
- Retrain V3 model on improved dataset

### Maybe (Phase 3): End-to-end model

If the neural generator proves valuable, eventually merge it with the V3 production model:

- The generator IS the production model
- Direct text+audio+emotion -> blendshapes
- No separate "generator" and "student" --- single model
- Requires substantial real data and validation

---

## Appendix A: Comparison with Existing Literature

| Paper | Architecture | Task | Data Size | Key Insight for Us |
|-------|-------------|------|-----------|-------------------|
| **FaceFormer** (2022) | Autoregressive Transformer | Audio -> 3D face mesh | ~30 hours audio | Self-supervised pre-training on audio helps |
| **EMOTE** (2023) | Transformer + VQ-VAE | Audio+emotion -> expression | MEAD + RAVDESS | Emotion conditioning via cross-attention |
| **DiffTalk** (2023) | Diffusion | Audio -> talking head | LRS2 | Diffusion produces diverse but slow output |
| **EmoTalk** (2023) | Transformer + emotion disentangling | Audio -> blendshapes | Custom MoCap | Separate content/emotion latent spaces |
| **CodeTalker** (2023) | VQ-VAE + GPT | Audio -> face mesh | VOCASET/BIWI | Discrete codes reduce mode collapse |
| **FaceDiffuser** (2024) | Diffusion + Transformer | Audio -> face mesh | VOCASET | Better diversity than autoregressive |

Our approach (ExpressionVAE-T) is closest to EmoTalk's philosophy: separate content (lip sync / audio coupling) from emotion (expression) in the latent space. The key difference is that we explicitly use LAM's output as the content foundation and only need the neural model to learn the expression layer.

## Appendix B: Data Augmentation Strategies

When training data is limited (which it is), augmentation is critical:

```python
class ExpressionAugmentation:
    def temporal_stretch(self, blendshapes, factor_range=(0.8, 1.2)):
        """Resample sequence at different speed."""
        factor = random.uniform(*factor_range)
        return scipy.signal.resample(blendshapes, int(len(blendshapes) * factor))

    def intensity_scale(self, blendshapes, scale_range=(0.7, 1.3)):
        """Scale expression channels by a global factor."""
        scale = random.uniform(*scale_range)
        result = blendshapes.copy()
        result[:, EXPRESSION_ONLY] *= scale
        return np.clip(result, 0, 1)

    def asymmetry_noise(self, blendshapes, max_diff=0.05):
        """Add slight L/R asymmetry to bilateral blendshapes."""
        BILATERAL = [(0,1), (10,11), (12,13), (23,24), (25,26), ...]
        for l, r in BILATERAL:
            offset = random.uniform(-max_diff, max_diff)
            blendshapes[:, l] += offset
            blendshapes[:, r] -= offset
        return np.clip(blendshapes, 0, 1)

    def temporal_jitter(self, blendshapes, max_shift=3):
        """Shift channels by 1-3 frames to simulate natural asynchrony."""
        for ch in EXPRESSION_ONLY:
            shift = random.randint(-max_shift, max_shift)
            blendshapes[:, ch] = np.roll(blendshapes[:, ch], shift)
        return blendshapes

    def emotion_bleed(self, blendshapes, neighbor_bs, alpha_range=(0.05, 0.15)):
        """Blend with a nearby emotion's sample for realistic ambiguity."""
        alpha = random.uniform(*alpha_range)
        result = blendshapes.copy()
        result[:, EXPRESSION_ONLY] = (
            (1-alpha) * blendshapes[:, EXPRESSION_ONLY] +
            alpha * neighbor_bs[:, EXPRESSION_ONLY]
        )
        return result
```
