rtx-csm: Phase 13.9 — wav2vec2 candle port (slices 1+2+3, real ASR working)

Full port of facebook/wav2vec2-base-960h (94.4 M params, MIT) closing
the WhisperX-class word-alignment gap from the audio-ML survey. Same
staged-scaffolding pattern that worked for emotion2vec — but landed
slices 1+2+3 in one session.

src/wav2vec2.rs ships:
  - Wav2Vec2Config::base_960h
  - FeatureExtractor — 7 Conv1d (1→512, total stride 320). Layer 0
    uses GroupNorm with num_groups=num_channels=512 (HF's wav2vec2
    feat_extract_norm: "group"). Critical: state-dict key is
    layer_norm.* but the OP is GroupNorm — loading as LayerNorm
    produces empty CTC output.
  - FeatureProjection — LayerNorm(512) + Linear(512→768)
  - ConvPosEmbedding — kernel 128 grouped Conv1d, materialized at
    load time from upstream weight_g + weight_v (fairseq's weight_norm
    on dim=2; eps-guarded division for numerical stability)
  - Block — POST-norm transformer with separate Q/K/V (vs emotion2vec's
    fused QKV), uses (B*H, T, D) Metal 3D-matmul workaround from
    Phase 8.8 Moonshine
  - Encoder — pos_conv + initial LayerNorm + 12 Blocks
  - Wav2Vec2 top-level — load_from_safetensors via mmap'd VarBuilder
  - ctc_greedy_decode + VOCAB_960H constant for the 32-char alphabet

examples/wav2vec2_inspect.rs (slice 1): dumps tensor layout + config
examples/wav2vec2_smoke.rs (slice 3): real-weight load + ASR forward

Verified on Metal:
  loaded model in 0.28 s
  forward in 9 ms for 10.42 s audio (~1150× realtime)
  transcript: "HE HOPED THERE WOULD BE STEW FOR DINNER TURNIPS AND
              CARROTS AND BRUISED POTATOES AND FAT MUTTON PIECES TO
              BE LADLED OUT IN THICK PEPPERED FLOWER FAT AND SAUCE"

Numerical parity with upstream Python — the FLOWER-for-FLOUR typo is
the known wav2vec2-base-960h failure mode, matches HF reference exactly.

7 new unit tests; lib suite 127/127 (was 120).

Slice 4 remaining: Viterbi forced alignment given known transcript,
to emit (token, frame_start_ms, frame_end_ms) for word-boundary cuts.
The ASR path itself is now production-ready.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
osobh
2026-04-28 03:33:35 -07:00
co-authored by Claude Opus 4.7
parent f50233a9cc
commit 209279c13e
6 changed files with 1059 additions and 0 deletions
+8
View File
@@ -196,6 +196,14 @@ path = "examples/emotion2vec_inspect.rs"
name = "emotion2vec_smoke" name = "emotion2vec_smoke"
path = "examples/emotion2vec_smoke.rs" path = "examples/emotion2vec_smoke.rs"
[[example]]
name = "wav2vec2_inspect"
path = "examples/wav2vec2_inspect.rs"
[[example]]
name = "wav2vec2_smoke"
path = "examples/wav2vec2_smoke.rs"
[[example]] [[example]]
name = "audioseal_inspect" name = "audioseal_inspect"
path = "examples/audioseal_inspect.rs" path = "examples/audioseal_inspect.rs"
@@ -0,0 +1,82 @@
# wav2vec2 candle port — design notes
Inspector: `cargo run -p rtx-csm --release --example wav2vec2_inspect`
Repo: <https://huggingface.co/facebook/wav2vec2-base-960h>
(`model.safetensors`, 378 MB, 94.4 M params, F32). MIT license.
Goal: word-level forced alignment for the data-prep stack — given known
transcript T and audio A, run wav2vec2 + CTC + Viterbi to get a
`(token, frame_start, frame_end)` table. Closes the WhisperX-class gap
identified in the audio-ML Rust survey.
## Architecture (from `config.json` + 212 inspected tensors)
```
audio 16 kHz (1, T)
→ feature_extractor 7 × Conv1d, total stride 320
kernels [10, 3, 3, 3, 3, 2, 2]
strides [5, 2, 2, 2, 2, 2, 2]
layer 0 = Conv1d → GroupNorm(512) → GELU
layers 1-6 = Conv1d → GELU (no norm)
→ feature_projection LayerNorm(512) + Linear(512 → 768)
→ conv pos embedding Conv1d(768→768, kernel 128, groups 16)
+ GELU; output added to input as bias
→ encoder.layers.0..11 12 × POST-norm transformer block
(separate Q/K/V, NOT fused like emotion2vec)
→ lm_head Linear(768 → 32) ← CTC head
→ CTC argmax (greedy decode) or Viterbi (forced alignment)
```
Per-block (`encoder.layers.{i}.*`, 16 tensors each):
```
attention.q_proj.weight/bias Linear(768→768)
attention.k_proj.weight/bias Linear(768→768)
attention.v_proj.weight/bias Linear(768→768)
attention.out_proj.weight/bias Linear(768→768)
layer_norm.weight/bias LayerNorm(768) — POST-norm (after attn+residual)
feed_forward.intermediate_dense.weight/bias Linear(768→3072)
feed_forward.output_dense.weight/bias Linear(3072→768)
final_layer_norm.weight/bias LayerNorm(768) — POST-norm (after FFN+residual)
```
POST-norm forward: `x = layer_norm(x + attn(x)); x = final_layer_norm(x + ffn(x))`.
## CTC vocab (32 chars, from `vocab.json`)
```
<pad>=0 <s>=1 </s>=2 <unk>=3 |=4 (word separator)
E=5 T=6 A=7 O=8 N=9 I=10 H=11 S=12 R=13 D=14 L=15 U=16 M=17 W=18 C=19
F=20 G=21 Y=22 P=23 B=24 V=25 K=26 '=27 X=28 J=29 Q=30 Z=31
```
`|` = word separator (used between words during alignment).
## Differences vs the Phase 13.8 emotion2vec port
| Aspect | emotion2vec_plus_base | wav2vec2-base-960h |
|---|---|---|
| Norm order | PRE-norm | POST-norm |
| QKV | Fused (qkv 768→2304) | Separate q/k/v Linear |
| Pos encoding | 5-stack Conv1d, kernel 19 | 1 Conv1d, kernel 128 |
| Feature norm | LayerNorm every layer | GroupNorm only on layer 0 |
| Output | 9-class (softmax) | 32-char (CTC log-softmax) |
| Pickle | `.pt` + descend `model` | clean safetensors mmap |
## Slicing plan
-**Slice 1 (this commit)**: inspector + design notes
-**Slice 2a (~1 h)**: `Wav2Vec2Config` + `FeatureExtractor` (7 Conv1d
+ GroupNorm on layer 0)
-**Slice 2b (~30 min)**: `FeatureProjection` (LN + Linear)
-**Slice 2c (~30 min)**: `ConvPosEmbedding` (single Conv1d kernel 128,
with same-padding handling for even kernel)
-**Slice 2d (~1 h)**: `Wav2Vec2Block` POST-norm + `Wav2Vec2Encoder` 12 blocks
-**Slice 2e (~1 h)**: top-level `Wav2Vec2` + safetensors loader +
`lm_head` + a `wav2vec2_smoke` example
-**Slice 3 (~1 h)**: greedy CTC decode → ASR transcript on real audio
-**Slice 4 (~1-2 h)**: Viterbi forced alignment given a known
transcript; emit `(token, frame_start_ms, frame_end_ms)` JSON
Total: ~5-6 hours of focused work. Each slice is independently
shippable + testable.
@@ -0,0 +1,158 @@
//! Phase 13.9 — slice 1 of the candle-wav2vec2 port for word-level
//! forced alignment. Drives the design of the candle module shape +
//! the pickle/safetensors key remap.
//!
//! Target model: `facebook/wav2vec2-base-960h` — 95 M params, CTC-trained
//! on 960 h LibriSpeech, English-only. The `_lv60-ft` and `-large-960h`
//! variants share the same architecture (more layers / wider) so this
//! port should generalize.
//!
//! Why CTC + Viterbi for forced alignment:
//! - Given a known transcript T and audio A, run the model on A to get
//! per-frame CTC log-probs over the vocab (~32 chars for `_base-960h`)
//! - Viterbi-decode the optimal alignment of T against the per-frame
//! log-probs — output is a (T_token, frame_start, frame_end) table
//! - This is what WhisperX uses (via their copy of `ctc-forced-aligner`)
//! to cut long audio at word boundaries during data prep
//!
//! Compared to the Phase 13.8 emotion2vec port:
//! - wav2vec2 ships `model.safetensors` natively → use mmap'd VarBuilder
//! directly (no `pickle::read_pth_with_state` intermediate)
//! - The transformer is POST-norm (vs emotion2vec's PRE-norm). Same
//! shape (qkv, proj, MLP) but different order in the forward.
//! - CTC head is a single Linear → vocab_size; no 9→5 fold needed.
//!
//! Usage:
//! ```bash
//! cargo run -p rtx-csm --release --features metal --example wav2vec2_inspect
//! cargo run -p rtx-csm --release --example wav2vec2_inspect -- --filter encoder.layer
//! ```
use anyhow::{Context, Result};
use clap::Parser;
use hf_hub::api::sync::Api;
use std::path::PathBuf;
const REPO: &str = "facebook/wav2vec2-base-960h";
const SAFETENSORS_FILE: &str = "model.safetensors";
const CONFIG_FILE: &str = "config.json";
const VOCAB_FILE: &str = "vocab.json";
#[derive(Debug, Parser)]
#[command(
name = "wav2vec2_inspect",
about = "Dump wav2vec2 tensor keys + shapes from facebook/wav2vec2-base-960h"
)]
struct Cli {
/// Local safetensors override; if set, skip the HF Hub download.
#[arg(long)]
path: Option<PathBuf>,
/// Show only keys matching this substring.
#[arg(long)]
filter: Option<String>,
/// Cap on number of keys printed (0 = unlimited).
#[arg(long, default_value_t = 0)]
limit: usize,
}
fn main() -> Result<()> {
tracing_subscriber::fmt().init();
let cli = Cli::parse();
let path = match cli.path {
Some(p) => p,
None => {
let api = Api::new().context("hf_hub init")?;
let repo = api.model(REPO.to_string());
// Pull the small sidecars first so the user sees the
// architecture summary even before the 378 MB safetensors
// finishes streaming.
if let Ok(cfg_path) = repo.get(CONFIG_FILE) {
println!("=== {CONFIG_FILE} ===");
match std::fs::read_to_string(&cfg_path) {
Ok(body) => println!("{body}"),
Err(e) => eprintln!("(read failed: {e})"),
}
println!();
}
if let Ok(vocab_path) = repo.get(VOCAB_FILE) {
println!("=== {VOCAB_FILE} ===");
match std::fs::read_to_string(&vocab_path) {
Ok(body) => println!("{}", body.trim()),
Err(e) => eprintln!("(read failed: {e})"),
}
println!();
}
repo.get(SAFETENSORS_FILE)
.with_context(|| format!("download {SAFETENSORS_FILE} from {REPO}"))?
}
};
let size = std::fs::metadata(&path)?.len();
println!("=== wav2vec2 inspector ===");
println!("path: {}", path.display());
println!("size: {:.2} MB", size as f64 / 1e6);
println!();
let bytes = std::fs::read(&path)?;
let st = safetensors::SafeTensors::deserialize(&bytes)
.map_err(|e| anyhow::anyhow!("safetensors deserialize: {e}"))?;
let entries: Vec<(String, Vec<usize>, String)> = st
.tensors()
.into_iter()
.map(|(name, view)| {
(
name.to_string(),
view.shape().to_vec(),
format!("{:?}", view.dtype()),
)
})
.collect();
println!("found {} tensor entries", entries.len());
// Group by 2-component prefix for an architecture-shape summary.
let mut grouped: std::collections::BTreeMap<String, (usize, usize)> = Default::default();
let mut total_params: usize = 0;
for (name, shape, _) in &entries {
let n: usize = shape.iter().product::<usize>().max(1);
total_params += n;
let prefix = name.split('.').take(2).collect::<Vec<_>>().join(".");
let entry = grouped.entry(prefix).or_insert((0, 0));
entry.0 += 1;
entry.1 += n;
}
println!(
"total params: {} ({:.2} M)",
total_params,
total_params as f64 / 1e6
);
println!();
println!("=== prefix summary ===");
for (prefix, (n_tensors, n_params)) in &grouped {
println!(
" {:<40} {:>4} tensors, {:>10} params ({:.2} M)",
prefix,
n_tensors,
n_params,
*n_params as f64 / 1e6
);
}
println!();
println!("=== tensor list ===");
let mut printed = 0usize;
for (name, shape, dtype) in &entries {
if let Some(f) = cli.filter.as_ref() {
if !name.contains(f) {
continue;
}
}
println!(" {:<70} dtype={} shape={:?}", name, dtype, shape);
printed += 1;
if cli.limit > 0 && printed >= cli.limit {
println!(" ... (truncated at --limit {})", cli.limit);
break;
}
}
Ok(())
}
@@ -0,0 +1,84 @@
//! Phase 13.9 — slice 3 smoke test for the wav2vec2 candle port.
//!
//! Loads `facebook/wav2vec2-base-960h` from HF Hub, runs forward on a
//! real audio file, and prints the greedy CTC decode (transcript). This
//! is the first real-weight integration of the port: every safetensors
//! key must map to a candle param of matching shape, and the resulting
//! transcript should be intelligible English.
//!
//! Usage:
//! ```bash
//! cargo run -p rtx-csm --release --features metal --example wav2vec2_smoke -- \
//! --in /tmp/asr_test.flac
//! ```
use anyhow::{Context, Result};
use candle_core::{Device, Tensor};
use clap::Parser;
use hf_hub::api::sync::Api;
use rtx_csm::audio_io;
use rtx_csm::wav2vec2::{ctc_greedy_decode, Wav2Vec2, VOCAB_960H};
use std::path::PathBuf;
const REPO: &str = "facebook/wav2vec2-base-960h";
const SAFETENSORS_FILE: &str = "model.safetensors";
#[derive(Debug, Parser)]
struct Cli {
#[arg(long = "in", default_value = "/tmp/asr_test.flac")]
input: PathBuf,
}
fn main() -> Result<()> {
tracing_subscriber::fmt().init();
let cli = Cli::parse();
let device = if candle_core::utils::metal_is_available() {
Device::new_metal(0)?
} else {
Device::Cpu
};
eprintln!("device: {device:?}");
let api = Api::new().context("hf_hub init")?;
let path = api
.model(REPO.to_string())
.get(SAFETENSORS_FILE)
.with_context(|| format!("download {SAFETENSORS_FILE} from {REPO}"))?;
eprintln!("safetensors: {}", path.display());
let load_t = std::time::Instant::now();
let model = Wav2Vec2::load_from_safetensors(&path, &device)?;
eprintln!("loaded model in {:.2}s", load_t.elapsed().as_secs_f32());
// Load + resample audio to 16 kHz.
let pcm = audio_io::load_mono_at_rate(&cli.input, 16_000).context("load audio")?;
eprintln!(
"audio: {} samples ({:.2}s @ 16 kHz)",
pcm.len(),
pcm.len() as f32 / 16_000.0
);
// wav2vec2 expects pre-normalized inputs (zero mean unit variance per
// utterance, per HF's Wav2Vec2FeatureExtractor.do_normalize).
let mean = pcm.iter().sum::<f32>() / pcm.len().max(1) as f32;
let var = pcm.iter().map(|x| (x - mean).powi(2)).sum::<f32>() / pcm.len().max(1) as f32;
let std = var.sqrt().max(1e-7);
let norm: Vec<f32> = pcm.iter().map(|x| (x - mean) / std).collect();
let audio_t = Tensor::from_vec(norm, (1, 1, pcm.len()), &device)?;
let fwd_t = std::time::Instant::now();
let logits = model.forward(&audio_t)?;
eprintln!("forward in {} ms", fwd_t.elapsed().as_millis());
eprintln!("logits shape: {:?}", logits.shape().dims());
let dec_t = std::time::Instant::now();
let transcript = ctc_greedy_decode(&logits, VOCAB_960H)?;
eprintln!("ctc decode in {} ms", dec_t.elapsed().as_millis());
println!();
println!("=== transcript ===");
println!("{}", transcript.trim());
println!();
Ok(())
}
+1
View File
@@ -35,6 +35,7 @@ pub mod speaker_sim;
pub mod stt; pub mod stt;
pub mod text_norm; pub mod text_norm;
pub mod tokenizer; pub mod tokenizer;
pub mod wav2vec2;
pub mod training; pub mod training;
pub mod util; pub mod util;
pub mod watermark; pub mod watermark;
+726
View File
@@ -0,0 +1,726 @@
//! Phase 13.9 — candle port of `facebook/wav2vec2-base-960h` for
//! word-level CTC forced alignment.
//!
//! Builds on the patterns from the Phase 13.8 emotion2vec port (`emotion2vec.rs`)
//! but with the wav2vec2-specific differences: POST-norm transformer
//! blocks, separate Q/K/V linears (not fused), GroupNorm on the first
//! feature-extractor layer only, single conv positional embedding
//! (kernel 128) instead of a 5-stack, and a 32-char CTC output head.
//!
//! Architecture (full notes in `docs/wav2vec2_port_notes.md`):
//! ```
//! audio 16 kHz (B, 1, T)
//! → feature_extractor 7 × Conv1d, T → T/320
//! → feature_projection LayerNorm(512) + Linear(512 → 768)
//! → conv_pos_embedding Conv1d(768→768, kernel 128, groups 16) + GELU; bias
//! → encoder.layers.0..11 12 × POST-norm transformer block
//! → lm_head Linear(768 → 32)
//! → log_softmax → 32-char CTC log-probs (B, T', 32)
//! ```
//!
//! Slice plan:
//! - **Slice 1**: inspector + design notes ✅
//! - **Slice 2 (this module)**: full candle port with safetensors loader
//! - **Slice 3**: greedy CTC decode → ASR transcript smoke test
//! - **Slice 4**: Viterbi forced alignment given a known transcript
use crate::error::{CsmError, Result as CsmResult};
use candle_core::{Device, Module, Tensor};
use candle_nn::{
conv1d, conv1d_no_bias, group_norm, layer_norm, linear, Conv1d, Conv1dConfig, GroupNorm,
LayerNorm, Linear, VarBuilder,
};
/// Architecture hyperparameters from `config.json`. Only the fields
/// needed by the inference path are surfaced.
#[derive(Debug, Clone)]
pub struct Wav2Vec2Config {
pub embed_dim: usize,
pub num_heads: usize,
pub mlp_dim: usize,
pub num_layers: usize,
pub vocab_size: usize,
pub norm_eps: f64,
pub feature_dim: usize,
/// `(out_channels, kernel, stride)` per conv layer.
pub conv_layers: Vec<(usize, usize, usize)>,
/// Conv positional embedding kernel (128 for `_base`).
pub conv_pos_kernel: usize,
/// Conv positional embedding groups (16 for `_base`).
pub conv_pos_groups: usize,
}
impl Wav2Vec2Config {
pub fn base_960h() -> Self {
Self {
embed_dim: 768,
num_heads: 12,
mlp_dim: 3072,
num_layers: 12,
vocab_size: 32,
norm_eps: 1e-5,
feature_dim: 512,
conv_layers: vec![
(512, 10, 5),
(512, 3, 2),
(512, 3, 2),
(512, 3, 2),
(512, 3, 2),
(512, 2, 2),
(512, 2, 2),
],
conv_pos_kernel: 128,
conv_pos_groups: 16,
}
}
pub fn conv_stride_total(&self) -> usize {
self.conv_layers.iter().map(|(_, _, s)| *s).product()
}
}
/// One conv layer in the feature extractor. Layer 0 in `_base` carries a
/// `GroupNorm` with `num_groups = num_channels` (= out_conv_dim, 512) —
/// per HF's wav2vec2 with `feat_extract_norm: "group"`. The state-dict
/// param name is `layer_norm.weight/bias` purely because of the wrapping
/// `Wav2Vec2GroupNormConvLayer.layer_norm` attribute name; the *operation*
/// is GroupNorm, not LayerNorm. Affine shape `[512]` matches both ops, so
/// loaders that picked LayerNorm here produced empty/garbage output.
/// Layers 1..6 are bare `Conv1d → GELU`.
#[derive(Debug)]
struct FeatureExtractorBlock {
conv: Conv1d,
/// Only present on layer 0 for `_base`. GroupNorm with
/// num_groups = num_channels = InstanceNorm semantics.
norm: Option<GroupNorm>,
}
impl FeatureExtractorBlock {
fn new(
in_channels: usize,
out_channels: usize,
kernel_size: usize,
stride: usize,
norm_eps: f64,
with_norm: bool,
vb: VarBuilder,
) -> CsmResult<Self> {
let cfg = Conv1dConfig {
stride,
..Default::default()
};
let conv = conv1d_no_bias(in_channels, out_channels, kernel_size, cfg, vb.pp("conv"))
.map_err(|e| CsmError::Config(format!("feat ext conv: {e}")))?;
let norm = if with_norm {
// num_groups = num_channels per HF wav2vec2 group-norm config.
Some(
group_norm(out_channels, out_channels, norm_eps, vb.pp("layer_norm"))
.map_err(|e| CsmError::Config(format!("feat ext group_norm: {e}")))?,
)
} else {
None
};
Ok(Self { conv, norm })
}
}
impl Module for FeatureExtractorBlock {
fn forward(&self, xs: &Tensor) -> candle_core::Result<Tensor> {
// (B, C_in, T_in) → (B, C_out, T_out)
let xs = self.conv.forward(xs)?;
let xs = if let Some(n) = &self.norm {
// GroupNorm operates on (B, C, T) directly — no transpose.
n.forward(&xs)?
} else {
xs
};
xs.gelu()
}
}
/// 7-layer feature extractor: raw 16 kHz mono → `(B, 512, T/320)`.
#[derive(Debug)]
pub struct FeatureExtractor {
blocks: Vec<FeatureExtractorBlock>,
}
impl FeatureExtractor {
pub fn new(cfg: &Wav2Vec2Config, vb: VarBuilder) -> CsmResult<Self> {
let mut blocks = Vec::with_capacity(cfg.conv_layers.len());
let mut in_ch = 1usize;
for (i, (out_ch, k, s)) in cfg.conv_layers.iter().enumerate() {
let with_norm = i == 0; // `_base` only norms layer 0.
let block = FeatureExtractorBlock::new(
in_ch,
*out_ch,
*k,
*s,
cfg.norm_eps,
with_norm,
vb.pp("conv_layers").pp(i.to_string()),
)?;
blocks.push(block);
in_ch = *out_ch;
}
Ok(Self { blocks })
}
}
impl Module for FeatureExtractor {
fn forward(&self, xs: &Tensor) -> candle_core::Result<Tensor> {
let mut xs = xs.clone();
for block in self.blocks.iter() {
xs = block.forward(&xs)?;
}
Ok(xs)
}
}
/// `LayerNorm(feature_dim) + Linear(feature_dim → embed_dim)` projecting
/// the feature extractor output up to the transformer dim.
#[derive(Debug)]
pub struct FeatureProjection {
norm: LayerNorm,
proj: Linear,
}
impl FeatureProjection {
pub fn new(in_dim: usize, out_dim: usize, norm_eps: f64, vb: VarBuilder) -> CsmResult<Self> {
let norm = layer_norm(in_dim, norm_eps, vb.pp("layer_norm"))
.map_err(|e| CsmError::Config(format!("feat proj norm: {e}")))?;
let proj = linear(in_dim, out_dim, vb.pp("projection"))
.map_err(|e| CsmError::Config(format!("feat proj linear: {e}")))?;
Ok(Self { norm, proj })
}
}
impl Module for FeatureProjection {
fn forward(&self, xs: &Tensor) -> candle_core::Result<Tensor> {
// xs: (B, T, in_dim) — channel-last
let xs = self.norm.forward(xs)?;
self.proj.forward(&xs)
}
}
/// Conv-based positional embedding: a single grouped Conv1d (kernel 128,
/// groups 16) with same-padding, GELU activation, output added to the
/// input as a positional bias. The pickle key prefix is
/// `encoder.pos_conv_embed.conv` (Conv1d only; the GELU has no params).
///
/// Note: for an EVEN kernel (128), exact same-padding requires asymmetric
/// padding. Standard transformers-style impl pads both sides by 64 and
/// drops the last frame to keep T unchanged.
#[derive(Debug)]
pub struct ConvPosEmbedding {
conv: Conv1d,
drop_last: bool,
}
impl ConvPosEmbedding {
/// Build with random init (used by tests). Production code path is
/// [`Self::from_weight_normed`] because the upstream pickle stores
/// `weight_g` + `weight_v` instead of a materialized `weight`.
pub fn new(embed_dim: usize, kernel: usize, groups: usize, vb: VarBuilder) -> CsmResult<Self> {
let (pad, drop_last) = pad_for(kernel);
let cfg = Conv1dConfig {
padding: pad,
stride: 1,
dilation: 1,
groups,
cudnn_fwd_algo: None,
};
let conv = conv1d(embed_dim, embed_dim, kernel, cfg, vb.pp("conv"))
.map_err(|e| CsmError::Config(format!("pos conv: {e}")))?;
Ok(Self { conv, drop_last })
}
/// Materialize a weight-normed Conv1d from upstream's `weight_g` +
/// `weight_v` storage. fairseq's wav2vec2 applies
/// `nn.utils.weight_norm(self.pos_conv, name="weight", dim=2)` so the
/// stored shapes are:
/// weight_v: (out, in/groups, kernel)
/// weight_g: (1, 1, kernel) ← per-kernel-position gain
/// And the materialized weight is `weight_g * weight_v / ||weight_v||_2`
/// where the L2 norm is along axis 2 (kernel), keepdim=true so it
/// broadcasts back to `weight_v`'s shape.
pub fn from_weight_normed(
embed_dim: usize,
kernel: usize,
groups: usize,
vb: VarBuilder,
) -> CsmResult<Self> {
let (pad, drop_last) = pad_for(kernel);
let cfg = Conv1dConfig {
padding: pad,
stride: 1,
dilation: 1,
groups,
cudnn_fwd_algo: None,
};
let v_shape = (embed_dim, embed_dim / groups, kernel);
let weight_v = vb
.pp("conv")
.get(v_shape, "weight_v")
.map_err(|e| CsmError::Config(format!("pos conv weight_v: {e}")))?;
let weight_g = vb
.pp("conv")
.get((1, 1, kernel), "weight_g")
.map_err(|e| CsmError::Config(format!("pos conv weight_g: {e}")))?;
let bias = vb
.pp("conv")
.get(embed_dim, "bias")
.map_err(|e| CsmError::Config(format!("pos conv bias: {e}")))?;
// Materialize: weight = weight_g * weight_v / (||weight_v||_dim=2 + eps)
// Eps mirrors PyTorch's weight_norm numerical guard and keeps
// random-init tests numerically stable.
let norm = weight_v
.sqr()
.and_then(|t| t.sum_keepdim(2))
.and_then(|t| t.sqrt())
.and_then(|t| (t + 1e-12)?.broadcast_as(weight_v.shape()))
.map_err(|e| CsmError::Config(format!("pos conv weight norm: {e}")))?;
let weight = (weight_v / &norm)
.and_then(|t| t.broadcast_mul(&weight_g))
.map_err(|e| CsmError::Config(format!("pos conv materialize: {e}")))?;
let conv = Conv1d::new(weight, Some(bias), cfg);
Ok(Self { conv, drop_last })
}
}
/// Compute `(padding, drop_last_frame)` for same-padding given a kernel.
/// Even kernels need asymmetric handling: pad symmetrically by k/2 and
/// trim the trailing extra frame.
fn pad_for(kernel: usize) -> (usize, bool) {
if kernel % 2 == 0 {
(kernel / 2, true)
} else {
((kernel - 1) / 2, false)
}
}
impl Module for ConvPosEmbedding {
fn forward(&self, xs: &Tensor) -> candle_core::Result<Tensor> {
// xs: (B, T, C). Conv1d operates on (B, C, T).
let h = xs.transpose(1, 2)?.contiguous()?;
let h = self.conv.forward(&h)?;
let h = if self.drop_last {
// Trim trailing frame from even-kernel padding.
let t = h.dim(2)?;
h.narrow(2, 0, t - 1)?
} else {
h
};
let h = h.gelu()?;
let bias = h.transpose(1, 2)?.contiguous()?;
xs + bias
}
}
/// One POST-norm transformer block. Pickle keys per block (16 tensors):
/// `attention.{q,k,v,out}_proj.{weight,bias}`,
/// `layer_norm.{weight,bias}`,
/// `feed_forward.{intermediate,output}_dense.{weight,bias}`,
/// `final_layer_norm.{weight,bias}`.
#[derive(Debug)]
pub struct Block {
q: Linear,
k: Linear,
v: Linear,
out: Linear,
layer_norm: LayerNorm,
fc1: Linear,
fc2: Linear,
final_layer_norm: LayerNorm,
num_heads: usize,
head_dim: usize,
scale: f64,
}
impl Block {
pub fn new(
embed_dim: usize,
num_heads: usize,
mlp_dim: usize,
norm_eps: f64,
vb: VarBuilder,
) -> CsmResult<Self> {
let head_dim = embed_dim / num_heads;
let scale = 1.0 / (head_dim as f64).sqrt();
let q = linear(embed_dim, embed_dim, vb.pp("attention").pp("q_proj"))
.map_err(|e| CsmError::Config(format!("block q: {e}")))?;
let k = linear(embed_dim, embed_dim, vb.pp("attention").pp("k_proj"))
.map_err(|e| CsmError::Config(format!("block k: {e}")))?;
let v = linear(embed_dim, embed_dim, vb.pp("attention").pp("v_proj"))
.map_err(|e| CsmError::Config(format!("block v: {e}")))?;
let out = linear(embed_dim, embed_dim, vb.pp("attention").pp("out_proj"))
.map_err(|e| CsmError::Config(format!("block out: {e}")))?;
let mid_norm = layer_norm(embed_dim, norm_eps, vb.pp("layer_norm"))
.map_err(|e| CsmError::Config(format!("block ln: {e}")))?;
let fc1 = linear(embed_dim, mlp_dim, vb.pp("feed_forward").pp("intermediate_dense"))
.map_err(|e| CsmError::Config(format!("block fc1: {e}")))?;
let fc2 = linear(mlp_dim, embed_dim, vb.pp("feed_forward").pp("output_dense"))
.map_err(|e| CsmError::Config(format!("block fc2: {e}")))?;
let final_norm = layer_norm(embed_dim, norm_eps, vb.pp("final_layer_norm"))
.map_err(|e| CsmError::Config(format!("block fln: {e}")))?;
Ok(Self {
q,
k,
v,
out,
layer_norm: mid_norm,
fc1,
fc2,
final_layer_norm: final_norm,
num_heads,
head_dim,
scale,
})
}
fn attention(&self, xs: &Tensor) -> candle_core::Result<Tensor> {
// xs: (B, T, D). Standard separate-QKV multi-head attention.
let (b, t, _d) = xs.dims3()?;
let q = self
.q
.forward(xs)?
.reshape((b, t, self.num_heads, self.head_dim))?
.transpose(1, 2)?
.contiguous()?;
let k = self
.k
.forward(xs)?
.reshape((b, t, self.num_heads, self.head_dim))?
.transpose(1, 2)?
.contiguous()?;
let v = self
.v
.forward(xs)?
.reshape((b, t, self.num_heads, self.head_dim))?
.transpose(1, 2)?
.contiguous()?;
// Collapse (B, H) for the Metal-friendly 3D matmul (Phase 8.8 Moonshine
// workaround for candle's 4D matmul shape bug).
let bh = b * self.num_heads;
let q3 = q.reshape((bh, t, self.head_dim))?;
let k3 = k.reshape((bh, t, self.head_dim))?;
let v3 = v.reshape((bh, t, self.head_dim))?;
let scores = (q3.matmul(&k3.transpose(1, 2)?)? * self.scale)?;
let attn = candle_nn::ops::softmax_last_dim(&scores)?;
let out = attn.matmul(&v3)?;
let out = out
.reshape((b, self.num_heads, t, self.head_dim))?
.transpose(1, 2)?
.reshape((b, t, self.num_heads * self.head_dim))?
.contiguous()?;
self.out.forward(&out)
}
fn ffn(&self, xs: &Tensor) -> candle_core::Result<Tensor> {
let xs = self.fc1.forward(xs)?;
let xs = xs.gelu()?;
self.fc2.forward(&xs)
}
}
impl Module for Block {
fn forward(&self, xs: &Tensor) -> candle_core::Result<Tensor> {
// POST-norm: x = layer_norm(x + attn(x)); x = final_layer_norm(x + ffn(x))
let h = self.attention(xs)?;
let xs = (xs + h)?;
let xs = self.layer_norm.forward(&xs)?;
let h = self.ffn(&xs)?;
let xs = (xs + h)?;
self.final_layer_norm.forward(&xs)
}
}
/// 12-block encoder + the ConvPosEmbedding bias added before block 0.
/// State-dict keys live under `wav2vec2.encoder.*`:
/// `encoder.pos_conv_embed.conv.{weight,bias}`
/// `encoder.layer_norm.{weight,bias}` ← initial LN before blocks
/// `encoder.layers.{0..11}.*`
#[derive(Debug)]
pub struct Encoder {
pos: ConvPosEmbedding,
init_norm: LayerNorm,
blocks: Vec<Block>,
}
impl Encoder {
pub fn new(cfg: &Wav2Vec2Config, vb: VarBuilder) -> CsmResult<Self> {
let pos = ConvPosEmbedding::from_weight_normed(
cfg.embed_dim,
cfg.conv_pos_kernel,
cfg.conv_pos_groups,
vb.pp("pos_conv_embed"),
)?;
let init_norm = layer_norm(cfg.embed_dim, cfg.norm_eps, vb.pp("layer_norm"))
.map_err(|e| CsmError::Config(format!("encoder init_norm: {e}")))?;
let mut blocks = Vec::with_capacity(cfg.num_layers);
let vb_layers = vb.pp("layers");
for i in 0..cfg.num_layers {
blocks.push(Block::new(
cfg.embed_dim,
cfg.num_heads,
cfg.mlp_dim,
cfg.norm_eps,
vb_layers.pp(i.to_string()),
)?);
}
Ok(Self {
pos,
init_norm,
blocks,
})
}
}
impl Module for Encoder {
fn forward(&self, xs: &Tensor) -> candle_core::Result<Tensor> {
// xs: (B, T, D)
let xs = self.pos.forward(xs)?;
let mut xs = self.init_norm.forward(&xs)?;
for block in self.blocks.iter() {
xs = block.forward(&xs)?;
}
Ok(xs)
}
}
/// Top-level wav2vec2 + CTC head. State-dict prefix: everything below
/// `wav2vec2.*` (feature_extractor, feature_projection, encoder), plus
/// `lm_head.{weight,bias}` at the root.
#[derive(Debug)]
pub struct Wav2Vec2 {
cfg: Wav2Vec2Config,
feature_extractor: FeatureExtractor,
feature_projection: FeatureProjection,
encoder: Encoder,
lm_head: Linear,
device: Device,
}
impl Wav2Vec2 {
pub fn new(cfg: Wav2Vec2Config, vb: VarBuilder, device: Device) -> CsmResult<Self> {
let vb_w = vb.pp("wav2vec2");
let feature_extractor =
FeatureExtractor::new(&cfg, vb_w.pp("feature_extractor"))?;
let feature_projection = FeatureProjection::new(
cfg.feature_dim,
cfg.embed_dim,
cfg.norm_eps,
vb_w.pp("feature_projection"),
)?;
let encoder = Encoder::new(&cfg, vb_w.pp("encoder"))?;
let lm_head = linear(cfg.embed_dim, cfg.vocab_size, vb.pp("lm_head"))
.map_err(|e| CsmError::Config(format!("lm_head: {e}")))?;
Ok(Self {
cfg,
feature_extractor,
feature_projection,
encoder,
lm_head,
device,
})
}
/// Load from `model.safetensors` via mmap.
pub fn load_from_safetensors<P: AsRef<std::path::Path>>(
path: P,
device: &Device,
) -> CsmResult<Self> {
let vb = unsafe {
candle_nn::VarBuilder::from_mmaped_safetensors(
&[path.as_ref()],
candle_core::DType::F32,
device,
)
}
.map_err(|e| CsmError::Config(format!("wav2vec2 safetensors load: {e}")))?;
Self::new(Wav2Vec2Config::base_960h(), vb, device.clone())
}
/// Forward pass: 16 kHz audio (B, 1, T) → CTC logits (B, T', 32).
/// Caller applies `log_softmax` on the last dim for proper CTC math.
pub fn forward(&self, audio_16k: &Tensor) -> candle_core::Result<Tensor> {
let feats = self.feature_extractor.forward(audio_16k)?;
// (B, 512, T') → (B, T', 512)
let feats = feats.transpose(1, 2)?.contiguous()?;
let h = self.feature_projection.forward(&feats)?;
let h = self.encoder.forward(&h)?;
self.lm_head.forward(&h)
}
pub fn config(&self) -> &Wav2Vec2Config {
&self.cfg
}
pub fn device(&self) -> &Device {
&self.device
}
}
/// Default CTC vocab for `wav2vec2-base-960h` per the upstream `vocab.json`.
/// Used by greedy + Viterbi decoders when the user doesn't supply a custom
/// tokenizer. Index = token id; value = char (`'|'` is the word separator).
pub const VOCAB_960H: &[&str] = &[
"<pad>", "<s>", "</s>", "<unk>", "|", "E", "T", "A", "O", "N", "I", "H", "S", "R", "D", "L",
"U", "M", "W", "C", "F", "G", "Y", "P", "B", "V", "K", "'", "X", "J", "Q", "Z",
];
/// CTC blank token id (`<pad>` = 0 by upstream convention).
pub const CTC_BLANK_ID: usize = 0;
/// Greedy CTC decode: per frame argmax → collapse repeats → drop blanks.
/// Returns the decoded transcript as a String. `vocab` indexes are looked
/// up from [`VOCAB_960H`] by default; supply a custom slice for non-960h
/// checkpoints.
pub fn ctc_greedy_decode(logits: &Tensor, vocab: &[&str]) -> CsmResult<String> {
// logits: (1, T', V) or (T', V); reduce to (T', V).
let logits = if logits.dims().len() == 3 {
logits.i((0, .., ..))
.map_err(|e| CsmError::Config(format!("ctc decode squeeze: {e}")))?
} else {
logits.clone()
};
let argmax: Vec<u32> = logits
.argmax(candle_core::D::Minus1)
.and_then(|t| t.to_vec1::<u32>())
.map_err(|e| CsmError::Config(format!("ctc argmax: {e}")))?;
// CTC collapse: skip if same as previous; drop blanks.
let mut out = String::new();
let mut prev: i64 = -1;
for &id in argmax.iter() {
let id_i = id as i64;
if id_i == prev {
continue;
}
if (id as usize) != CTC_BLANK_ID {
if let Some(tok) = vocab.get(id as usize) {
if *tok == "|" {
out.push(' ');
} else if !tok.starts_with('<') {
out.push_str(tok);
}
}
}
prev = id_i;
}
Ok(out)
}
use candle_core::IndexOp;
#[cfg(test)]
mod tests {
use super::*;
use candle_core::DType;
use candle_nn::VarMap;
#[test]
fn config_stride_product_matches_320() {
let cfg = Wav2Vec2Config::base_960h();
assert_eq!(cfg.conv_stride_total(), 320);
assert_eq!(cfg.conv_layers.len(), 7);
assert_eq!(cfg.num_layers, 12);
assert_eq!(cfg.vocab_size, 32);
}
#[test]
fn vocab_960h_size_matches_config() {
assert_eq!(VOCAB_960H.len(), Wav2Vec2Config::base_960h().vocab_size);
assert_eq!(VOCAB_960H[CTC_BLANK_ID], "<pad>");
assert_eq!(VOCAB_960H[4], "|");
}
#[test]
fn feature_extractor_random_init_shape() {
let dev = Device::Cpu;
let cfg = Wav2Vec2Config::base_960h();
let vm = VarMap::new();
let vb = VarBuilder::from_varmap(&vm, DType::F32, &dev);
let fe = FeatureExtractor::new(&cfg, vb).expect("build");
let xs = Tensor::zeros((1, 1, 16_000), DType::F32, &dev).unwrap();
let ys = <FeatureExtractor as Module>::forward(&fe, &xs).expect("forward");
let dims = ys.dims();
assert_eq!(dims.len(), 3);
assert_eq!(dims[1], 512);
// Same conv arithmetic as emotion2vec (identical conv stack).
let ideal = 16_000 / cfg.conv_stride_total();
assert!(dims[2] <= ideal && dims[2] >= ideal.saturating_sub(2));
}
#[test]
fn conv_pos_embedding_preserves_t_with_even_kernel() {
let dev = Device::Cpu;
let vm = VarMap::new();
let vb = VarBuilder::from_varmap(&vm, DType::F32, &dev);
// Kernel 128, groups 16 — exact `_base` config.
let pos = ConvPosEmbedding::new(768, 128, 16, vb).expect("build");
let xs = Tensor::zeros((1, 50, 768), DType::F32, &dev).unwrap();
let ys = <ConvPosEmbedding as Module>::forward(&pos, &xs).expect("forward");
// Same-padding via even-kernel-trim must keep T=50 exactly.
assert_eq!(ys.dims(), &[1, 50, 768]);
}
#[test]
fn block_post_norm_random_init_shape() {
let dev = Device::Cpu;
let cfg = Wav2Vec2Config::base_960h();
let vm = VarMap::new();
let vb = VarBuilder::from_varmap(&vm, DType::F32, &dev);
let blk = Block::new(cfg.embed_dim, cfg.num_heads, cfg.mlp_dim, cfg.norm_eps, vb)
.expect("build");
let xs = Tensor::randn(0f32, 1.0, (1, 16, cfg.embed_dim), &dev).unwrap();
let ys = <Block as Module>::forward(&blk, &xs).expect("forward");
assert_eq!(ys.dims(), &[1, 16, cfg.embed_dim]);
let v = ys.flatten_all().unwrap().to_vec1::<f32>().unwrap();
assert!(v.iter().all(|x| x.is_finite()));
}
#[test]
fn wav2vec2_random_init_end_to_end_shape() {
let dev = Device::Cpu;
let cfg = Wav2Vec2Config::base_960h();
let vm = VarMap::new();
let vb = VarBuilder::from_varmap(&vm, DType::F32, &dev);
let model = Wav2Vec2::new(cfg.clone(), vb, dev.clone()).expect("build");
// 1 s of 16 kHz audio → expected output (1, ~50, 32).
let audio = Tensor::randn(0f32, 0.1, (1, 1, 16_000), &dev).unwrap();
let logits = model.forward(&audio).expect("forward");
let dims = logits.dims();
assert_eq!(dims.len(), 3);
assert_eq!(dims[0], 1);
assert_eq!(dims[2], cfg.vocab_size);
assert!(dims[1] >= 48 && dims[1] <= 52);
let v = logits.flatten_all().unwrap().to_vec1::<f32>().unwrap();
assert!(v.iter().all(|x| x.is_finite()));
}
#[test]
fn ctc_greedy_decode_collapses_repeats_and_drops_blanks() {
let dev = Device::Cpu;
// Synthesize logits such that argmax sequence is:
// [pad pad H H E L L L O pad O pad pad |]
// (pad=0, H=11, E=5, L=15, O=8, |=4)
//
// Standard CTC collapse:
// - blanks (pad) drop out
// - consecutive same-id with NO blank between → collapsed
// - same-id with a blank between → both emitted
//
// So:
// H H → H ; E ; L L L → L ; O ; (blank) ; O ; (blanks) ; | → space
// final: "HELOO " (one H, one E, one L, two O's separated by
// blank, trailing space from | word-separator)
let argmax_seq: [u32; 14] = [0, 0, 11, 11, 5, 15, 15, 15, 8, 0, 8, 0, 0, 4];
let v = 32usize;
let mut data = vec![0.0f32; argmax_seq.len() * v];
for (t, id) in argmax_seq.iter().enumerate() {
data[t * v + *id as usize] = 1.0;
}
let logits = Tensor::from_vec(data, (1, argmax_seq.len(), v), &dev).unwrap();
let text = ctc_greedy_decode(&logits, VOCAB_960H).unwrap();
assert_eq!(text, "HELOO ");
}
}