rtx-csm: Phase 10.3 — SilentCipher embed pipeline end-to-end

End-to-end encode pipeline working: 3 ckpts load from HF, STFT runs,
encoder + carrier-decoder forward, iSTFT reconstructs. Watermarked
audio out preserves length + carries an embedded message.

New components in src/silentcipher.rs (~150 LOC added):

  SilentCipherWatermarker     bundle of cfg + 3 networks + STFT + device
  ::from_ckpts(...)           load enc_c.ckpt + dec_c.ckpt + dec_m_0.ckpt
                              pickle files via candle_core::pickle::read_all
  ::build_message(codes, T)   one-hot + tile across time axis to match
                              n_frames; matches Python letters_encoding
                              shape semantics
  ::embed(samples_16k, codes) full encode pipeline:
                              1. RMS-normalize to VCTK baseline
                              2. STFT -> magnitude + phase
                              3. enc_c forward -> 32-channel carrier
                              4. enc_c.transform_message -> projected msg
                              5. cat(carrier_enc, mag.repeat(32),
                                     msg_enc.repeat(32)) -> 96 channels
                              6. dec_c forward + utterance-level
                                 normalization + ensure_negative_message
                                 + ReLU clamp
                              7. iSTFT -> watermarked audio
                              8. de-normalize energy
  ::encode_bits(payload)      pack a u32 into message_len-1 2-bit codes

Smoke test (`examples/silentcipher_smoke`) verified end-to-end:

  Build watermarker:       29 ms (loads 3 .ckpt files)
  Synthetic sine embed:   187 ms /  1.00 s audio
  Real speech embed:     1042 ms / 10.42 s audio  =  0.10x realtime

The 0.10x realtime figure is comparable to AudioSeal in Phase 6f.wm
(73 ms per ~6.8 s sentence = ~0.011x realtime, but AudioSeal had
warm-cache benefit). On a fresh cold model, SilentCipher comes in
~10x faster than realtime — order-of-magnitude OK.

SNR vs original: 24.6 dB on the speech sample, target 47 dB per the
released hparams. The watermark is currently more audible than
intended. Likely cause: utterance-level normalization scale factor
needs refinement, OR the ensure_negative_message + ReLU path is
clipping more than the Python path. Will be diagnosed in Phase 10.4
when detection round-trip lands — the real test of correctness is
"can dec_m recover the embedded codes?", not absolute SNR.

Phase 10.4 will:
  - Implement detect() to recover the embedded codes via dec_m_0
  - Add Watermarker trait impl for SilentCipherWatermarker
  - examples/silentcipher_apply CLI mirroring audioseal_apply

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
osobh
2026-04-27 12:40:02 -07:00
co-authored by Claude Opus 4.7
parent e7b34abd16
commit f6bcf0735a
3 changed files with 331 additions and 0 deletions
+4
View File
@@ -269,3 +269,7 @@ path = "examples/moonshine_profile.rs"
[[example]] [[example]]
name = "silentcipher_inspect" name = "silentcipher_inspect"
path = "examples/silentcipher_inspect.rs" path = "examples/silentcipher_inspect.rs"
[[example]]
name = "silentcipher_smoke"
path = "examples/silentcipher_smoke.rs"
@@ -0,0 +1,150 @@
//! Phase 10.3 smoke test for SilentCipher embed. Downloads the
//! 16 kHz checkpoint from `sony/silentcipher`, loads all 3 networks,
//! runs the encode pipeline on a synthetic sine + a real speech clip,
//! verifies the output preserves shape + has signal.
//!
//! Stops short of detection (Phase 10.4 will add detect + ship the
//! Watermarker trait impl).
//!
//! Usage:
//! ```bash
//! cargo run -p rtx-csm --release --features metal --example silentcipher_smoke
//! ```
use anyhow::{Context, Result};
use candle_core::Device;
use hf_hub::api::sync::Api;
use rtx_csm::silentcipher::{SilentCipherConfig, SilentCipherWatermarker};
use std::time::Instant;
const REPO: &str = "sony/silentcipher";
const CKPT_DIR: &str = "16_khz/97561_iteration";
fn main() -> Result<()> {
tracing_subscriber::fmt()
.with_max_level(tracing::Level::WARN)
.init();
let device = if candle_core::utils::metal_is_available() {
Device::new_metal(0)?
} else {
Device::Cpu
};
eprintln!("device: {device:?}");
// Download the 3 ckpts + hparams.
let api = Api::new()?;
let repo = api.model(REPO.to_string());
let enc_c = repo
.get(&format!("{CKPT_DIR}/enc_c.ckpt"))
.context("download enc_c.ckpt")?;
let dec_c = repo
.get(&format!("{CKPT_DIR}/dec_c.ckpt"))
.context("download dec_c.ckpt")?;
let dec_m_0 = repo
.get(&format!("{CKPT_DIR}/dec_m_0.ckpt"))
.context("download dec_m_0.ckpt")?;
eprintln!("downloaded: {}", enc_c.parent().unwrap().display());
// Construct.
let cfg = SilentCipherConfig::sixteen_khz();
let load_t = Instant::now();
let wm = SilentCipherWatermarker::from_ckpts(cfg, &enc_c, &dec_c, &dec_m_0, &device)?;
eprintln!("watermarker built in {} ms", load_t.elapsed().as_millis());
// Synthetic 1 s 440 Hz sine at 16 kHz.
let n = 16_000;
let samples: Vec<f32> = (0..n)
.map(|i| {
(2.0 * std::f32::consts::PI * 440.0 * i as f32 / 16_000.0).sin() * 0.3
})
.collect();
eprintln!();
eprintln!("=== synthetic sine ===");
eprintln!("input samples: {}", samples.len());
// Encode a deliberate payload (5 bytes -> 15 codes via encode_bits).
let payload = 0xCAFEBABEu32;
let codes = wm.encode_bits(payload);
eprintln!(
"payload 0x{payload:08X} -> {} codes (each in 0..{}): {:?}",
codes.len(),
wm.cfg.message_dim,
&codes
);
let t = Instant::now();
let watermarked = wm
.embed(&samples, &codes)
.context("embed sine")?;
let embed_ms = t.elapsed().as_millis();
eprintln!(
"embed: {embed_ms} ms ({} samples in / {} samples out)",
samples.len(),
watermarked.len()
);
// Sanity: shape preserved + samples differ from original (watermark
// present) but not catastrophically.
assert_eq!(
samples.len(),
watermarked.len(),
"embed must preserve length"
);
let diff_rms: f32 = samples
.iter()
.zip(watermarked.iter())
.map(|(a, b)| (a - b).powi(2))
.sum::<f32>()
.sqrt()
/ (samples.len() as f32).sqrt();
let orig_rms: f32 =
(samples.iter().map(|&s| s * s).sum::<f32>() / samples.len() as f32).sqrt();
let ratio = diff_rms / orig_rms;
eprintln!(
"RMS diff vs original: {diff_rms:.5} (orig RMS {orig_rms:.4}, ratio {ratio:.4})"
);
if ratio < 1e-5 {
println!("WARN: watermark RMS is near-zero — model may not be loading correctly");
} else if ratio > 0.5 {
println!("WARN: watermark RMS is huge — corruption likely");
} else {
println!("PASS: shape preserved + watermark added (ratio {ratio:.4})");
}
// Try a real speech clip if /tmp/asr_test.flac exists.
let asr_path = std::path::Path::new("/tmp/asr_test.flac");
if asr_path.exists() {
eprintln!();
eprintln!("=== real speech (/tmp/asr_test.flac) ===");
let speech = rtx_csm::audio_io::load_mono_at_rate(asr_path, 16_000)?;
eprintln!("input samples: {} ({:.2}s)", speech.len(), speech.len() as f32 / 16_000.0);
let t = Instant::now();
let watermarked = wm.embed(&speech, &codes).context("embed speech")?;
let speech_ms = t.elapsed().as_millis();
eprintln!(
"embed: {speech_ms} ms ({:.2}× realtime)",
speech_ms as f32 / (speech.len() as f32 / 16_000.0 * 1000.0)
);
let diff_rms: f32 = speech
.iter()
.zip(watermarked.iter())
.map(|(a, b)| (a - b).powi(2))
.sum::<f32>()
.sqrt()
/ (speech.len() as f32).sqrt();
let orig_rms: f32 = (speech.iter().map(|&s| s * s).sum::<f32>()
/ speech.len() as f32)
.sqrt();
let snr_db = 20.0 * (orig_rms / diff_rms.max(1e-12)).log10();
println!("speech embed: SNR vs original = {snr_db:.1} dB (target ~47 dB per hparams)");
// Save the output WAV for ear test.
rtx_csm::audio_io::write_wav_mono(
std::path::Path::new("/tmp/silentcipher_smoke.wav"),
&watermarked,
16_000,
)?;
eprintln!("wrote /tmp/silentcipher_smoke.wav for ear test");
}
Ok(())
}
+177
View File
@@ -510,6 +510,183 @@ pub fn vb_from_ckpt(
Ok(VarBuilder::from_tensors(map, DType::F32, device).pp("module")) Ok(VarBuilder::from_tensors(map, DType::F32, device).pp("module"))
} }
// ---------------------------------------------------------------------
// Phase 10.3 — end-to-end embed pipeline
// ---------------------------------------------------------------------
/// VCTK average power baseline used by the original to pre-condition
/// audio energy before watermarking. Source: `server.py:60` constant.
const AVERAGE_ENERGY_VCTK: f32 = 0.002837200844477648;
/// SilentCipher watermarker holding all three networks + an STFT
/// helper. Loads from the three released `.ckpt` files (`enc_c`,
/// `dec_c`, `dec_m_0`).
pub struct SilentCipherWatermarker {
pub cfg: SilentCipherConfig,
pub enc_c: Encoder,
pub dec_c: CarrierDecoder,
pub dec_m: MsgDecoder,
pub stft: Stft,
pub device: Device,
}
impl SilentCipherWatermarker {
/// Construct from three checkpoint paths. Each is a PyTorch
/// `.ckpt` (pickle) file with `module.` prefix.
pub fn from_ckpts(
cfg: SilentCipherConfig,
enc_c_path: &std::path::Path,
dec_c_path: &std::path::Path,
dec_m_0_path: &std::path::Path,
device: &Device,
) -> Result<Self> {
let enc_c = Encoder::new(&cfg, vb_from_ckpt(enc_c_path, device)?)?;
let dec_c = CarrierDecoder::new(&cfg, vb_from_ckpt(dec_c_path, device)?)?;
let dec_m = MsgDecoder::new(&cfg, vb_from_ckpt(dec_m_0_path, device)?)?;
let stft = Stft::new(cfg.n_fft, cfg.hop_length);
Ok(Self {
cfg,
enc_c,
dec_c,
dec_m,
stft,
device: device.clone(),
})
}
/// Build the message one-hot tensor matching the encoded patch
/// length. Mirrors `letters_encoding` in the Python source:
///
/// 1. Pad the message bytes' 2-bit groups with a `0` terminator
/// (so each chunk's first index is "0 = end").
/// 2. Add `+1` to every value so 0 becomes the dedicated
/// terminator and the carriers occupy `1..message_dim`.
/// 3. One-hot encode: `(message_len, message_dim)`.
/// 4. Tile across `n_frames` along the time axis.
///
/// **Note**: the released 16 kHz checkpoint has `message_dim=4` and
/// `message_len=16`, meaning each patch carries 15 four-way values
/// = 30 bits ≈ 3.75 bytes per patch. We size the input message
/// (after 2-bit chunking + terminator) to exactly `message_len`
/// values and let the caller pre-encode. The simplest valid call
/// is to pass an already-padded `Vec<u32>` of length
/// `message_len - 1` with each value in `0..message_dim`.
fn build_message(&self, codes: &[u32], n_frames: usize) -> Result<Tensor> {
let n_codes = self.cfg.message_len; // includes terminator slot
// Build (message_dim, message_len) one-hot, then tile to n_frames.
let mut pad: Vec<u32> = codes.iter().map(|&c| c + 1).collect();
if pad.len() < n_codes {
// Append the 0 terminator + zero-pad to message_len.
pad.push(0);
pad.resize(n_codes, 0);
} else {
pad.truncate(n_codes);
}
let mut one_hot =
vec![0.0f32; self.cfg.message_dim * n_codes];
for (t, &c) in pad.iter().enumerate() {
let cc = (c as usize).min(self.cfg.message_dim - 1);
one_hot[cc * n_codes + t] = 1.0;
}
// Tile along the time axis to fill n_frames.
let mut tiled = vec![0.0f32; self.cfg.message_dim * n_frames];
for c in 0..self.cfg.message_dim {
for t in 0..n_frames {
let src_t = t % n_codes;
tiled[c * n_frames + t] = one_hot[c * n_codes + src_t];
}
}
// Shape: (1, 1, message_dim, n_frames)
Tensor::from_vec(
tiled,
(1, 1, self.cfg.message_dim, n_frames),
&self.device,
)
}
/// Embed a 16-bit message into the audio waveform. Input/output
/// are 16 kHz mono `f32` PCM. Shape is preserved.
///
/// `codes` is the pre-encoded list of 2-bit values to embed. For
/// the 16 kHz model, length should be `message_len - 1 = 15`,
/// each value in `0..message_dim = 4`. Callers can use
/// [`Self::encode_bits`] to convert a `u32` payload into codes.
pub fn embed(&self, samples_16k: &[f32], codes: &[u32]) -> Result<Vec<f32>> {
if samples_16k.is_empty() {
return Ok(Vec::new());
}
// Normalize energy to the VCTK baseline so the magnitude
// distribution matches what the model was trained on.
let original_power = samples_16k.iter().map(|&s| s * s).sum::<f32>()
/ samples_16k.len() as f32;
if original_power < 1e-12 {
return Ok(samples_16k.to_vec());
}
let pre_scale = (AVERAGE_ENERGY_VCTK / original_power).sqrt();
let normalized: Vec<f32> =
samples_16k.iter().map(|&s| s * pre_scale).collect();
// STFT.
let (mag, phase, n_frames) = self.stft.forward(&normalized);
let n_freq = self.cfg.n_fft / 2 + 1;
// Tensor: (1, 1, n_freq, n_frames)
let mag_t = Tensor::from_vec(mag.clone(), (1, 1, n_freq, n_frames), &self.device)?;
// Build the message tensor.
let msg_t = self.build_message(codes, n_frames)?;
// Encoder forward.
let carrier_enc = self.enc_c.forward(&mag_t)?; // (1, 32, n_freq, T)
let msg_enc = self.enc_c.transform_message(&msg_t)?; // (1, 1, n_freq, T)
// merged = cat(carrier_enc, mag.repeat(32), msg_enc.repeat(32))
let mag_rep = mag_t.broadcast_as((1, 32, n_freq, n_frames))?.contiguous()?;
let msg_rep = msg_enc.broadcast_as((1, 32, n_freq, n_frames))?.contiguous()?;
let merged = Tensor::cat(&[&carrier_enc, &mag_rep, &msg_rep], 1)?;
// (1, 96, n_freq, T)
// Carrier decoder produces (positive) message_info.
let mut message_info = self.dec_c.forward(&merged, self.cfg.message_sdr)?;
// utterance_level_normalization: multiply by carrier RMS.
let carrier_rms = mag_t.sqr()?.mean_keepdim(2)?.mean_keepdim(3)?.sqrt()?;
message_info = message_info.broadcast_mul(&carrier_rms)?;
// ensure_negative_message + ReLU clamp.
let neg = message_info.neg()?;
let summed = (mag_t.clone() + neg)?;
let zeros = summed.zeros_like()?;
let carrier_reconst = summed.maximum(&zeros)?;
// Pull the watermarked magnitude back to host for iSTFT.
let new_mag: Vec<f32> = carrier_reconst
.squeeze(0)?
.squeeze(0)?
.flatten_all()?
.to_vec1::<f32>()?;
let recon = self.stft.inverse(&new_mag, &phase, n_frames);
// De-normalize energy.
let post_scale = (original_power / AVERAGE_ENERGY_VCTK).sqrt();
let mut out: Vec<f32> = recon.iter().map(|&s| s * post_scale).collect();
out.truncate(samples_16k.len());
Ok(out)
}
/// Convenience: pack a `u32` into `message_len - 1` 2-bit codes.
/// Output values are in `0..message_dim` (i.e., `0..4` for the
/// 16 kHz model). Up to 30 bits of payload fit (15 codes × 2 bits).
pub fn encode_bits(&self, payload: u32) -> Vec<u32> {
let n = self.cfg.message_len - 1;
let mut codes = Vec::with_capacity(n);
for i in 0..n {
let shift = (n - 1 - i) * 2;
codes.push((payload >> shift) & 0x3);
}
codes
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;