Commit Graph
8 Commits
Author SHA1 Message Date
osobhandClaude Opus 4.7 a5cedfb46a rtx-csm: emotional_speech_guide — CREMA-D vs RAVDESS firdhokk verdict
8-gen bench (4 emotions × 2 corpora) at seed=42 against firdhokk
Whisper-LV3:

  target    RAVDESS              CREMA-D
  happy     happy (0.999) ✓      happy (0.999) ✓
  angry     neutral (0.92)       sad (0.99)
  fearful   happy (0.998)        fearful (0.984) ✓
  sad       angry (0.99)         fearful (0.99)

CREMA-D 2/4 vs RAVDESS 1/4. Larger / more naturalistic corpus
produces more class-pure fearful direction. Neither corpus solves
angry or sad — recipe shifts into 'vague expressivity' rather than
class-specific corners.

Practical: prefer CREMA-D when available; A/B both per emotion if
class precision matters.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-30 00:01:02 -07:00
Omar Sobh 6a03aeba61 fix(cuda+csm): P0 rtx-backend-cuda compile fix + P2 rtx-csm clippy cleanup (#9)
Co-authored-by: Omar Sobh <[email protected]>
Co-committed-by: Omar Sobh <[email protected]>
2026-04-30 05:24:58 +00:00
osobhandClaude Opus 4.7 d42aba0f1b rtx-csm: emotion2vec input normalization + 9-class direct mapping
Three real bugs found while running a YouTube → train → eval pipeline
end-to-end on real corpora:

1. emotion2vec was producing near-constant logits regardless of input.
   Per config.yaml `normalize: true` — data2vec2/emotion2vec expects
   per-utterance zero-mean unit-variance normalization on the raw
   waveform before the local_encoder. Added inside the EmotionDetector
   trait impl so all callers get it.

   Verified empirically: 4 different audio inputs (Carlini talk,
   audience question, McConaughey speech) now produce different argmax
   classes. Before fix: all 4 produced identical logits.

2. The 9→5 emotion fold was collapsing every real-world clip to
   [excited]. happy / surprised / other all mapped to Excited covered
   ~95% of natural speech. Replaced with a direct 9-class identity
   mapping; EmotionLabel gained Disgusted, Fearful, Happy, Surprised,
   Unk variants. Now: 132 [surprised] + 12 [excited] across the
   Carlini corpus instead of 144 [excited].

3. lora_train_emotional --peak-lr / --epochs flags. The canned 3-stage
   recipe over-fits on small (~100 clip) corpora at extended rank 8;
   users need to tune. (The recipe stays as defaults; flags are pure
   overrides.)

Plus diagnostic: examples/emotion2vec_probe — feed real audio files
into emotion2vec and dump per-class logits. Used to find bug #1.

Lib suite still 131/131 (the test that locked the 9→5 fold updated
to lock the new identity mapping).

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-28 12:23:53 -07:00
osobhandClaude Opus 4.7 f50233a9cc rtx-csm: Phase 13.8 — emotion2vec port, slice 4 (EmotionDetector + integration)
Port complete. The Phase 13.3 prosody-rule placeholder is now
retire-able by setting one CLI flag — the real candle-ported
emotion2vec_plus_base classifier slots in behind the same
EmotionDetector trait the placeholder used.

impl EmotionDetector for Emotion2Vec — builds (1, 1, T) tensor on
stored device, runs forward, argmaxes the 9 logits, maps to the
5-bucket label via Classifier::tag_for_class. Empty input
short-circuits to Neutral.

Emotion2Vec struct gained a `device` field so the trait impl can
build tensors without an out-of-band handle. new() / load_from_pickle()
threaded through; existing tests + smoke binary updated.

audio_to_manifest --use-emotion2vec — pairs with --auto-emotion-tag
to swap ProsodyDetector for Emotion2Vec, boxed as
Box<dyn EmotionDetector> so the call site is unchanged.

converse_server --use-emotion2vec — same pattern; built once at boot
and stored in Shared as Box<dyn EmotionDetector + Send + Sync>.
~150 ms/turn forward cost vs <1 ms for prosody, but actually runs
SOTA SER. Removed redundant reactive_emotion: bool field — the
Option<Box<dyn>> already encodes the same state.

Verified end-to-end on Metal:
  - audio_to_manifest --use-emotion2vec on 2-speaker concat → both
    tagged [excited] (prosody had said [neutral] on same input)
  - converse_server --quantized-gguf … --lora … --reactive-emotion
    --use-emotion2vec boots, 1 bench turn 0 errors, /metrics shows
    reactive_emotion_total{label="excited"} 1 — same tag
    audio_to_manifest produced. Cross-consumer consistency.

Phase 13.8 complete (slices 1+2+3+4 shipped). The emotional-voice
stack now has a real, trained, candle-ported SER classifier with
no Python sidecar, no ort, no whisper.cpp.

Lib suite 120/120.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-28 03:17:11 -07:00
osobhandClaude Opus 4.7 9c1894ec73 rtx-csm: Phase 13.8 — emotion2vec port, slice 2d (real weights load + forward)
Slice 2 complete + slice 3 collapsed in. The full candle port loads
real upstream weights and runs forward in 160 ms.

RelativePositionalEncoder — 5 grouped Conv1d (kernel 19, groups 16,
same-padding via pad 9) with GELU between. Output is added back to
input as a positional bias. Pickle keys
relative_positional_encoder.{1..=5}.0.weight/bias (1-based indexing,
no .0.*).

Emotion2Vec top-level — wires LocalEncoder → ProjectFeatures →
RelPosEnc → ContextEncoder → MainEncoder → mean-pool → Classifier.
The proj.* classifier head lives at the state-dict root (not under
d2v_model.), so the constructor uses vb directly there.

Emotion2Vec::load_from_pickle uses VarBuilder::from_pth_with_state
to descend into the fairseq-style nested checkpoint via the "model"
key. One-shot loader; all 185 upstream tensor keys must map onto
candle params of matching shape — and they do.

examples/emotion2vec_smoke.rs — full pipeline integration test:
downloads (or reuses cached) emotion2vec_plus_base from HF, loads it
into candle, runs forward on 2 s of synthetic audio, prints all 9
raw logits + argmax + the 9→5 bucket fold.

Verified on Metal:
  loaded model in 0.19 s
  forward in 160 ms
  9 logits all finite (50-290 range, expected for raw classifier)
  argmax: class 7 (surprised) → 5-bucket [excited]

Mechanical correctness end-to-end. Semantic accuracy on real
emotional speech lands in slice 4 (EmotionDetector trait impl +
swap into audio_to_manifest + converse_server reactive-emotion path).

2 new unit tests:
  - relative_positional_encoder_preserves_shape
  - emotion2vec_random_init_end_to_end_shape

Lib suite 120/120 (was 118, +2).

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-28 03:09:06 -07:00
osobhandClaude Opus 4.7 491f51565a rtx-csm: Phase 13.8 — emotion2vec port, slice 2c (Classifier + Encoders)
Three composition wrappers over the Block from slice 2b.

ContextEncoder — 4 Blocks + final LayerNorm
(context_encoder.blocks.0..3 + context_encoder.norm). Acts as a prenet
between ProjectFeatures and the main encoder.

MainEncoder — 8 Blocks, no final LayerNorm. Confirmed via inspector:
all 96 d2v_model.blocks.* tensors live inside numbered blocks; there's
no d2v_model.norm. Pre-norm pattern's per-block norm2 keeps residuals
conditioned without a global tail norm.

Classifier — single Linear 768→9 (proj.weight/proj.bias at the top of
the state dict, NOT under d2v_model.). Includes tag_for_class(idx)
that folds the 9 fine-grained model classes (angry/disgusted/fearful/
happy/neutral/other/sad/surprised/<unk>) into the 5-bucket label set
the Phase 13.3 EmotionDetector trait already uses:
  - 0 angry                   → Angry
  - 1 disgusted, 2 fearful,
    6 sad                     → Sad (low valence)
  - 3 happy, 5 other,
    7 surprised               → Excited (high arousal)
  - 4 neutral, 8 <unk>        → Neutral

4 new tests:
  - context_encoder_chains_4_blocks_with_final_norm
  - main_encoder_chains_8_blocks_no_final_norm
  - classifier_emits_9_logits
  - classifier_class_to_emotion_label_mapping (locks the 9→5 fold)

Lib suite 118/118 (was 114, +4 new).

Slice 2 remaining: relative_positional_encoder (5 Conv1d, the conv
positional bias) + top-level Emotion2Vec + .pt pickle loader. Then
slice 3 = forward pass + numerical parity check vs upstream Python.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-28 02:45:30 -07:00
osobhandClaude Opus 4.7 90f1b4667c rtx-csm: Phase 13.8 — emotion2vec port, slice 2b (ProjectFeatures + Block)
Two more candle modules toward the full port.

ProjectFeatures — LayerNorm(512) → Linear(512→768). Sits between
LocalEncoder and the transformer. Pickle layout matches upstream:
project_features.1.* is the LayerNorm, project_features.2.* is the
Linear. Both have learnable affine params; the .1 LN is NOT just an
eps constant.

Block — pre-norm fused-QKV transformer block, the workhorse for both
ContextEncoder (4 instances) and MainEncoder (8 instances). Pickle
keys per block: norm1, attn.qkv (fused 768→2304), attn.proj,
norm2, mlp.fc1, mlp.fc2. GELU MLP activation. No positional encoding
inside the block — the conv-based positional bias lives at the encoder
boundary.

Attention uses the (B*H, T, D) 3D collapse-before-matmul Metal
workaround we shipped for Phase 8.8 Moonshine — candle's 4D batched
matmul still has the shape-mismatch bug.

2 new unit tests:
  - project_features_shape_check: (1, 50, 512) → (1, 50, 768)
  - block_residual_shape_check: random (2, 8, 768) → same shape AND
    all values finite (catches softmax NaN / attention overflow)

Lib suite 114/114 (was 112, +2 new).

Remaining within slice 2: relative_positional_encoder (5 Conv1d),
ContextEncoder (4 Blocks), MainEncoder (8 Blocks + LN), Classifier
(Linear 768→9), top-level Emotion2Vec + pickle .pt loader.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-28 02:34:29 -07:00
osobhandClaude Opus 4.7 ce00e48acb rtx-csm: Phase 13.8 — emotion2vec port, slice 2a (LocalEncoder)
First module of the candle port. src/emotion2vec.rs ships:

Emotion2VecConfig::plus_base() — embed_dim=768, depth=8, prenet_depth=4,
num_classes=9, conv_layers spec yielding stride-product 320 (16 kHz →
50 Hz feature frames).

LocalConvBlock — one Conv1d → LayerNorm → GELU block. Weight key layout
matches the upstream pickle exactly: .0.weight for the conv (no bias),
.2.1.weight/bias for the LayerNorm (upstream wraps it as
Sequential(Conv1d, Dropout, Sequential(TransposeLast, LayerNorm,
TransposeLast), GELU); we collapse dropout / transposes since they're
inactive at inference / handled inline via .transpose(1,2)).

LocalEncoder — 7-block stack, channels 1→512, time shrinks by stride
product. Output shape (B, 512, T/320) — for 16 kHz input that's a
50 Hz feature frame rate.

2 unit tests pass:
  - config_stride_product_matches_320 (catches future spec drift)
  - local_encoder_random_init_shape_check — builds via VarMap+Kaiming,
    runs forward on 1 s of zeros, asserts (1, 512, ~50) output

Lib suite 112/112 (was 110, +2 new tests).

Remaining within slice 2: project_features, relative_positional_encoder,
Block (fused-QKV), ContextEncoder (4 prenet blocks), MainEncoder
(8 main blocks), Classifier, and top-level Emotion2Vec with .pt loader.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-28 02:30:05 -07:00