Full encoder forward path: conv stem -> 6 transformer layers -> final
LayerNorm. Loads HF safetensors, runs end-to-end on Metal.
Components added to src/moonshine.rs:
RotaryCache partial RoPE (32 of 36 head_dim, theta=10000)
EncoderAttention MHA (8 heads, no bias), partial RoPE on q/k
EncoderMlp 288 -> 1152 -> 288 with bias, GELU(erf) activation
EncoderLayer Pre-LN attn + Pre-LN MLP (LayerNorm weight-only)
Encoder stem + 6 layers + final LayerNorm
load_encoder() VarBuilder convenience for the standalone smoke
Smoke test (`examples/moonshine_smoke`) verified end-to-end:
input (1, 1, 160000) -> output (1, 415, 288)
forward: 132 ms (10 s of audio at 0.013x realtime)
max abs: 6.67 (signal preserved, not zeros)
Implementation notes captured in the diff:
- candle Metal 4D batched matmul had shape-mismatch issues for our
(B, H, T, D) pattern. Switched to (B*H, T, D) 3D form which is
unambiguous and avoids the kernel bug.
- LayerNorm is weight-only (no bias tensors in safetensors); we
construct LayerNorm with a zeros bias to satisfy candle's API.
- rotary_dim = floor(head_dim * 0.9 / 2) * 2 = 32 (must be even).
The remaining 4 head_dim channels pass through unchanged via
`narrow + cat` on dim 3.
Numerical parity vs HF Python reference is NOT yet verified — that's
the next bounded chunk (Phase 8.7). Shape + signal correctness are
verified by the smoke test.
Next: decoder transformer block (self-attn + cross-attn + SwiGLU).
~3-4 h of focused work.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
128 lines
4.5 KiB
Rust
128 lines
4.5 KiB
Rust
//! Phase 8.5 smoke test for the Moonshine conv stem.
|
|
//!
|
|
//! Verifies: weight loading from HF safetensors works, conv shapes
|
|
//! produce expected output dimensions, no panics on a realistic input.
|
|
//! Stops short of the encoder transformer block (Phase 8.6).
|
|
//!
|
|
//! Usage:
|
|
//! ```bash
|
|
//! cargo run -p rtx-csm --release --features metal --example moonshine_smoke
|
|
//! ```
|
|
|
|
use anyhow::{Context, Result};
|
|
use candle_core::{Device, Tensor};
|
|
use hf_hub::api::sync::Api;
|
|
use rtx_csm::moonshine;
|
|
|
|
fn main() -> Result<()> {
|
|
let device = if candle_core::utils::metal_is_available() {
|
|
Device::new_metal(0)?
|
|
} else {
|
|
Device::Cpu
|
|
};
|
|
eprintln!("device: {device:?}");
|
|
|
|
// Download weights (cached after first run).
|
|
let api = Api::new()?;
|
|
let weights = api
|
|
.model("UsefulSensors/moonshine-tiny".to_string())
|
|
.get("model.safetensors")?;
|
|
eprintln!("weights: {}", weights.display());
|
|
|
|
// Build the full encoder (conv stem + 6 transformer layers + final LN).
|
|
let cfg = moonshine::MoonshineConfig::tiny();
|
|
let encoder = moonshine::load_encoder(&weights, &device, &cfg)
|
|
.context("load encoder")?;
|
|
eprintln!("encoder loaded (conv stem + 6 layers + final LN)");
|
|
|
|
// Also keep the standalone conv stem for the conv-only check below.
|
|
let stem = moonshine::load_conv_stem(&weights, &device)
|
|
.context("load conv stem")?;
|
|
|
|
// Synthetic 10 s of 16 kHz audio (silence + a sine pulse).
|
|
let sr = 16_000usize;
|
|
let n = sr * 10;
|
|
let mut samples = vec![0.0f32; n];
|
|
let freq = 440.0;
|
|
for (i, s) in samples.iter_mut().enumerate() {
|
|
let t = i as f32 / sr as f32;
|
|
// 1-3 s active sine, rest silence
|
|
if (1.0..3.0).contains(&t) {
|
|
*s = (2.0 * std::f32::consts::PI * freq * t).sin() * 0.3;
|
|
}
|
|
}
|
|
let pcm = Tensor::from_vec(samples, (1, 1, n), &device).context("pcm tensor")?;
|
|
eprintln!("input shape: {:?}", pcm.shape());
|
|
|
|
// Forward.
|
|
let out = stem.forward(&pcm).context("conv stem forward")?;
|
|
eprintln!("output shape: {:?}", out.shape());
|
|
|
|
// Expected: (B=1, T_seq, 288). T_seq ≈ n / 384.
|
|
// Conv arithmetic (output_len = (input_len - kernel) / stride + 1):
|
|
// conv1: (160000 - 127) / 64 + 1 = 2498
|
|
// conv2: (2498 - 7) / 3 + 1 = 831
|
|
// conv3: (831 - 3) / 2 + 1 = 415
|
|
let dims = out.dims();
|
|
let expected_t_seq = ((((n - 127) / 64 + 1) - 7) / 3 + 1 - 3) / 2 + 1;
|
|
eprintln!(
|
|
"expected (B=1, T_seq={expected_t_seq}, hidden=288); got {:?}",
|
|
dims
|
|
);
|
|
if dims == [1, expected_t_seq, 288] {
|
|
println!("PASS: conv stem forward matches expected shape");
|
|
} else {
|
|
println!(
|
|
"MISMATCH: expected [1, {expected_t_seq}, 288], got {:?}",
|
|
dims
|
|
);
|
|
std::process::exit(2);
|
|
}
|
|
|
|
// Check the output isn't all zeros (sanity).
|
|
let max = out.abs()?.max_keepdim(0)?.max_keepdim(1)?.max_keepdim(2)?;
|
|
let max_val: f32 = max.flatten_all()?.to_vec1::<f32>()?[0];
|
|
eprintln!("output max abs: {max_val:.4}");
|
|
if max_val < 1e-6 {
|
|
println!("WARN: output is all near-zero — conv weights may not be loading");
|
|
} else {
|
|
println!("output has signal — weight loading verified");
|
|
}
|
|
|
|
// Now run through the FULL encoder (stem + 6 transformer layers + LN).
|
|
eprintln!();
|
|
eprintln!("=== full encoder forward ===");
|
|
let full_t = std::time::Instant::now();
|
|
let enc_out = encoder.forward(&pcm).context("encoder forward")?;
|
|
let full_ms = full_t.elapsed().as_millis();
|
|
eprintln!("forward: {full_ms} ms");
|
|
eprintln!("encoder output shape: {:?}", enc_out.shape());
|
|
let enc_dims = enc_out.dims();
|
|
if enc_dims == [1, expected_t_seq, 288] {
|
|
println!("PASS: encoder output preserves (B, T_seq, 288) shape");
|
|
} else {
|
|
println!(
|
|
"MISMATCH: expected [1, {expected_t_seq}, 288], got {:?}",
|
|
enc_dims
|
|
);
|
|
std::process::exit(2);
|
|
}
|
|
let enc_max = enc_out
|
|
.abs()?
|
|
.max_keepdim(0)?
|
|
.max_keepdim(1)?
|
|
.max_keepdim(2)?;
|
|
let enc_max_val: f32 = enc_max.flatten_all()?.to_vec1::<f32>()?[0];
|
|
let enc_mean = enc_out.mean_all()?;
|
|
let enc_mean_val: f32 = enc_mean.to_vec0::<f32>()?;
|
|
eprintln!("encoder output max abs: {enc_max_val:.4}");
|
|
eprintln!("encoder output mean : {enc_mean_val:.4}");
|
|
if enc_max_val < 1e-6 {
|
|
println!("WARN: encoder output is all near-zero");
|
|
} else {
|
|
println!("encoder output has signal — full transformer pipeline verified");
|
|
}
|
|
|
|
Ok(())
|
|
}
|