Files
rustytorch/crates/models/rtx-csm/src/audioseal.rs
T
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

842 lines
29 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! AudioSeal watermark — SEANet generator + detector.
//!
//! Architectural port of Meta's AudioSeal (Roman et al., ICML 2024,
//! [arXiv:2401.17264]) against candle 0.9.
//!
//! ## Module layout (matches `facebook/audioseal` reference exactly)
//!
//! Generator state_dict keys are organized as a `nn.Sequential` indexed by
//! integer position. With `n_filters=32, ratios=[8,5,4,2], n_residual_layers=1,
//! lstm=2, dimension=128` the encoder runs:
//!
//! ```text
//! encoder.model.0 Conv1d(1, 32, k=7) (SConv1d wrapper)
//! encoder.model.1 ResidualBlock(32, dilation=1) (n_residual=1, j=0)
//! encoder.model.2 ELU(1.0)
//! encoder.model.3 Conv1d(32, 64, k=4, stride=2)
//! encoder.model.4 ResidualBlock(64, 1)
//! encoder.model.5 ELU
//! encoder.model.6 Conv1d(64, 128, k=8, stride=4)
//! encoder.model.7 ResidualBlock(128, 1)
//! encoder.model.8 ELU
//! encoder.model.9 Conv1d(128, 256, k=10, stride=5)
//! encoder.model.10 ResidualBlock(256, 1)
//! encoder.model.11 ELU
//! encoder.model.12 Conv1d(256, 512, k=16, stride=8)
//! encoder.model.13 LSTM(512, 512, num_layers=2) (skip-connected)
//! encoder.model.14 ELU
//! encoder.model.15 Conv1d(512, 128, k=7) (dimension projection)
//! ```
//!
//! Decoder mirrors this: index 0 is the 128→512 init conv, index 1 is the
//! LSTM, indices 3,6,9,12 are ConvTranspose1d upsamples (with ratios
//! [8,5,4,2] in order), residuals at 4,7,10,13, final 32→1 conv at 15.
//!
//! The 16-bit message embedding lives at `msg_processor.msg_processor.weight`
//! shape `(32, 128)` and is broadcast-added to the encoder bottleneck
//! activations BEFORE the decoder runs.
//!
//! Detector reuses the same encoder + a mirrored upsample stack (the
//! reference instantiates a SEANetDecoder with output_channels=18 instead of
//! 1 and no `final_activation`); we expose this directly via `Detector`.
//!
//! ## Reference uses `weight_norm` parameterization
//!
//! The PyTorch checkpoint stores each Conv1d/ConvTranspose1d weight split
//! into `weight_g` (per-output-channel scale) + `weight_v` (unnormalized
//! direction). At forward time: `weight = weight_g * weight_v / ‖weight_v‖`.
//! Our offline converter merges these splits at conversion time so this
//! module's VarBuilder reads a single `weight` per layer.
//!
//! See `audioseal_convert.rs` example for the conversion path.
//!
//! [arXiv:2401.17264]: https://arxiv.org/abs/2401.17264
use crate::error::{CsmError, Result};
use crate::watermark::Watermarker;
use candle_core::{D, DType, Device, IndexOp, Module, Tensor};
use candle_nn::{
Activation, Conv1d, Conv1dConfig, ConvTranspose1d, ConvTranspose1dConfig, Embedding, LSTM,
LSTMConfig, RNN, VarBuilder, conv_transpose1d, conv1d, embedding, lstm, ops,
};
use std::path::Path;
pub const SAMPLE_RATE: u32 = 16_000;
/// AudioSeal embeds a 16-bit message per audio frame.
pub const MESSAGE_BITS: usize = 16;
/// Encoder downsample ratios (decoder iterates these in order; encoder reversed).
pub const RATIOS: [usize; 4] = [8, 5, 4, 2];
/// Total downsampling factor: prod(RATIOS) = 320.
pub const HOP_LENGTH: usize = 320;
/// Bottleneck channel dimension (the final 1×1 projection target).
pub const DIMENSION: usize = 128;
/// Initial filter count.
pub const N_FILTERS: usize = 32;
/// Channels at the LSTM bottleneck = N_FILTERS * 2^|ratios| = 512.
pub const LSTM_HIDDEN: usize = N_FILTERS * (1 << RATIOS.len());
// -- Conv1d/ConvTranspose1d wrappers with SEANet symmetric padding ----------
/// Apply `Conv1d` with SEANet's symmetric extra padding (non-causal mode).
/// Mirrors `audiocraft.modules.conv._get_extra_padding_for_conv1d` exactly:
///
/// ```text
/// padding_total = (kernel - 1) * dilation - (stride - 1)
/// n_frames = (length - kernel + padding_total) / stride + 1
/// ideal_length = (ceil(n_frames) - 1) * stride + (kernel - padding_total)
/// extra_padding = ideal_length - length
/// pad_right = padding_total // 2
/// pad_left = padding_total - pad_right
/// padded = pad_with_zeros(xs, pad_left, pad_right + extra_padding)
/// ```
fn padded_conv1d(
xs: &Tensor,
conv: &Conv1d,
kernel: usize,
stride: usize,
dilation: usize,
) -> candle_core::Result<Tensor> {
let length = xs.dim(D::Minus1)?;
// Reference: (k - 1) * dilation - (stride - 1). For stride=1 this is (k-1)*d.
let padding_total = ((kernel - 1) * dilation).saturating_sub(stride - 1);
let n_frames_num = length as i64 + padding_total as i64 - kernel as i64;
let n_frames = (n_frames_num as f64 / stride as f64) + 1.0;
let n_frames_ceil = n_frames.ceil() as i64;
let ideal_length =
((n_frames_ceil - 1) * stride as i64 + kernel as i64 - padding_total as i64) as usize;
let extra = ideal_length.saturating_sub(length);
let pad_right = padding_total / 2;
let pad_left = padding_total - pad_right;
let xs = xs.pad_with_zeros(D::Minus1, pad_left, pad_right + extra)?;
xs.apply(conv)
}
/// Apply `ConvTranspose1d` then trim the SEANet asymmetric padding from the
/// output. Trim split: `trim_right = (k - stride) // 2`,
/// `trim_left = (k - stride) - trim_right` (non-causal, `trim_right_ratio=1.0`).
fn trimmed_conv_transpose1d(
xs: &Tensor,
conv: &ConvTranspose1d,
kernel: usize,
stride: usize,
) -> candle_core::Result<Tensor> {
let xs = xs.apply(conv)?;
let trim_total = kernel.saturating_sub(stride);
let trim_right = trim_total / 2;
let trim_left = trim_total - trim_right;
let len = xs.dim(D::Minus1)?;
let new_len = len.saturating_sub(trim_left + trim_right);
if new_len == 0 {
return Ok(xs);
}
xs.narrow(D::Minus1, trim_left, new_len)
}
// -- Residual block (matches `block.{1,3}.conv.conv.weight` layout) ---------
#[derive(Debug, Clone)]
pub struct SeanetResidualBlock {
conv1: Conv1d,
conv2: Conv1d,
activation: Activation,
k1: usize,
d1: usize,
k2: usize,
d2: usize,
}
impl SeanetResidualBlock {
/// `vb` is rooted at the residual block (e.g. `encoder.model.1`).
pub fn new(dim: usize, dilation: usize, vb: VarBuilder) -> candle_core::Result<Self> {
let hidden = dim / 2; // compress=2
// Reference path: `block.1.conv.conv.weight` (the inner SConv1d→NormConv1d→Conv1d).
let conv1 = conv1d(
dim,
hidden,
3,
Conv1dConfig {
dilation,
..Default::default()
},
vb.pp("block.1.conv.conv"),
)?;
let conv2 = conv1d(
hidden,
dim,
1,
Conv1dConfig::default(),
vb.pp("block.3.conv.conv"),
)?;
Ok(Self {
conv1,
conv2,
activation: Activation::Elu(1.0),
k1: 3,
d1: dilation,
k2: 1,
d2: 1,
})
}
}
impl Module for SeanetResidualBlock {
fn forward(&self, xs: &Tensor) -> candle_core::Result<Tensor> {
let h = xs.apply(&self.activation)?;
let h = padded_conv1d(&h, &self.conv1, self.k1, 1, self.d1)?;
let h = h.apply(&self.activation)?;
let h = padded_conv1d(&h, &self.conv2, self.k2, 1, self.d2)?;
h + xs
}
}
// -- LSTM bottleneck (skip-connected, matches `model.13.lstm` layout) -------
#[derive(Debug, Clone)]
pub struct LstmBottleneck {
layers: Vec<LSTM>,
hidden: usize,
}
impl LstmBottleneck {
/// `vb` is rooted at the LSTM module (e.g. `encoder.model.13.lstm`). We
/// then read `weight_ih_l0`, `weight_hh_l0`, `bias_ih_l0`, `bias_hh_l0`,
/// `weight_ih_l1`, … directly via candle's lstm() helper.
pub fn new(dim: usize, num_layers: usize, vb: VarBuilder) -> candle_core::Result<Self> {
let mut layers = Vec::with_capacity(num_layers);
for layer_idx in 0..num_layers {
let cfg = LSTMConfig {
layer_idx,
..Default::default()
};
layers.push(lstm(dim, dim, cfg, vb.clone())?);
}
Ok(Self {
layers,
hidden: dim,
})
}
/// Input/output shape: `(B, C, T)`. SEANet uses skip = `lstm(x) + x`.
pub fn forward(&self, xs: &Tensor) -> candle_core::Result<Tensor> {
let (b, c, t) = xs.dims3()?;
debug_assert_eq!(c, self.hidden);
let mut h = xs.transpose(1, 2)?.contiguous()?;
for layer in &self.layers {
let init = layer.zero_state(b)?;
let states = layer.seq_init(&h, &init)?;
h = layer.states_to_tensor(&states)?;
}
let lstm_out = h.transpose(1, 2)?.contiguous()?;
debug_assert_eq!(lstm_out.dims(), &[b, c, t]);
lstm_out + xs
}
}
// -- Encoder ----------------------------------------------------------------
/// Encoder stage = 1 residual block + ELU + downsample conv.
/// Module-list indices the stage occupies are `[res_idx, _, ds_idx]` since
/// the ELU at `res_idx + 1` carries no params.
#[derive(Debug, Clone)]
struct EncoderStage {
residual: SeanetResidualBlock,
downsample: Conv1d,
ratio: usize,
}
#[derive(Debug, Clone)]
pub struct SeanetEncoder {
init_conv: Conv1d,
stages: Vec<EncoderStage>,
lstm: LstmBottleneck,
final_conv: Conv1d,
activation: Activation,
}
impl SeanetEncoder {
pub fn new(vb: VarBuilder) -> candle_core::Result<Self> {
let m = vb.pp("model");
let init_conv = conv1d(
1,
N_FILTERS,
7,
Conv1dConfig::default(),
m.pp("0.conv.conv"),
)?;
let mut stages = Vec::with_capacity(RATIOS.len());
let mut mult = 1usize;
let mut idx = 1usize; // start at module index 1 (after init_conv at 0)
for &ratio in RATIOS.iter().rev() {
let residual = SeanetResidualBlock::new(
mult * N_FILTERS,
/* dilation */ 1,
m.pp(idx.to_string()),
)?;
// ELU at idx+1, downsample at idx+2.
let downsample = conv1d(
mult * N_FILTERS,
mult * N_FILTERS * 2,
ratio * 2,
Conv1dConfig {
stride: ratio,
..Default::default()
},
m.pp((idx + 2).to_string()).pp("conv.conv"),
)?;
stages.push(EncoderStage {
residual,
downsample,
ratio,
});
mult *= 2;
idx += 3;
}
// After the final downsample (at idx-1=12 for our config), idx is now 13.
// model.13 = LSTM, model.14 = ELU, model.15 = final conv.
let lstm = LstmBottleneck::new(LSTM_HIDDEN, 2, m.pp(idx.to_string()).pp("lstm"))?;
let final_conv = conv1d(
LSTM_HIDDEN,
DIMENSION,
7,
Conv1dConfig::default(),
m.pp((idx + 2).to_string()).pp("conv.conv"),
)?;
Ok(Self {
init_conv,
stages,
lstm,
final_conv,
activation: Activation::Elu(1.0),
})
}
}
impl Module for SeanetEncoder {
fn forward(&self, xs: &Tensor) -> candle_core::Result<Tensor> {
let mut h = padded_conv1d(xs, &self.init_conv, 7, 1, 1)?;
for stage in &self.stages {
h = stage.residual.forward(&h)?;
h = h.apply(&self.activation)?;
h = padded_conv1d(&h, &stage.downsample, stage.ratio * 2, stage.ratio, 1)?;
}
h = self.lstm.forward(&h)?;
h = h.apply(&self.activation)?;
padded_conv1d(&h, &self.final_conv, 7, 1, 1)
}
}
// -- Decoder ----------------------------------------------------------------
#[derive(Debug, Clone)]
struct DecoderStage {
upsample: ConvTranspose1d,
residual: SeanetResidualBlock,
ratio: usize,
}
/// Decoder produces `output_channels` (1 for generator, 2+nbits for detector).
#[derive(Debug, Clone)]
pub struct SeanetDecoder {
init_conv: Conv1d,
lstm: LstmBottleneck,
stages: Vec<DecoderStage>,
final_conv: Conv1d,
activation: Activation,
}
impl SeanetDecoder {
pub fn new(output_channels: usize, vb: VarBuilder) -> candle_core::Result<Self> {
let m = vb.pp("model");
// model.0 = init conv (DIMENSION → LSTM_HIDDEN), model.1 = LSTM,
// model.2 = ELU, model.3 = upsample stage 0, model.4 = residual stage 0, ...
let init_conv = conv1d(
DIMENSION,
LSTM_HIDDEN,
7,
Conv1dConfig::default(),
m.pp("0.conv.conv"),
)?;
let lstm = LstmBottleneck::new(LSTM_HIDDEN, 2, m.pp("1.lstm"))?;
let mut stages = Vec::with_capacity(RATIOS.len());
let mut mult = 1usize << RATIOS.len(); // 16
let mut idx = 3usize;
for &ratio in RATIOS.iter() {
let upsample = conv_transpose1d(
mult * N_FILTERS,
mult * N_FILTERS / 2,
ratio * 2,
ConvTranspose1dConfig {
stride: ratio,
..Default::default()
},
m.pp(idx.to_string()).pp("convtr.convtr"),
)?;
let residual = SeanetResidualBlock::new(
mult * N_FILTERS / 2,
/* dilation */ 1,
m.pp((idx + 1).to_string()),
)?;
stages.push(DecoderStage {
upsample,
residual,
ratio,
});
mult /= 2;
idx += 3; // upsample, residual, then ELU on next iter
}
// After 4 stages, idx is now 15. model.14 = ELU, model.15 = final conv.
let final_conv = conv1d(
N_FILTERS,
output_channels,
7,
Conv1dConfig::default(),
m.pp(idx.to_string()).pp("conv.conv"),
)?;
Ok(Self {
init_conv,
lstm,
stages,
final_conv,
activation: Activation::Elu(1.0),
})
}
}
impl Module for SeanetDecoder {
fn forward(&self, xs: &Tensor) -> candle_core::Result<Tensor> {
let mut h = padded_conv1d(xs, &self.init_conv, 7, 1, 1)?;
h = self.lstm.forward(&h)?;
for stage in &self.stages {
h = h.apply(&self.activation)?;
h = trimmed_conv_transpose1d(&h, &stage.upsample, stage.ratio * 2, stage.ratio)?;
h = stage.residual.forward(&h)?;
}
h = h.apply(&self.activation)?;
padded_conv1d(&h, &self.final_conv, 7, 1, 1)
}
}
// -- 16-bit message embedding ----------------------------------------------
#[derive(Debug, Clone)]
pub struct MsgProcessor {
table: Embedding,
nbits: usize,
hidden: usize,
alpha: f32,
}
impl MsgProcessor {
/// `vb` is rooted at the model root (NOT inside `msg_processor`); we
/// extend by `msg_processor.msg_processor` to match the reference key
/// `msg_processor.msg_processor.weight` of shape `(2*nbits, hidden)`.
pub fn new(nbits: usize, hidden: usize, vb: VarBuilder) -> candle_core::Result<Self> {
let table = embedding(2 * nbits, hidden, vb.pp("msg_processor.msg_processor"))?;
Ok(Self {
table,
nbits,
hidden,
alpha: 1.0,
})
}
/// Take encoder activations `xs: (B, C, T)` and a `u32` message of
/// `nbits` bits; return `(B, C, T)` with the broadcast-added watermark.
pub fn forward(
&self,
xs: &Tensor,
message: u32,
device: &Device,
dtype: DType,
) -> candle_core::Result<Tensor> {
let (b, c, t) = xs.dims3()?;
debug_assert_eq!(c, self.hidden);
let mut indices = Vec::with_capacity(self.nbits);
for k in 0..self.nbits {
let bit = (message >> k) & 1;
indices.push(2 * k as u32 + bit);
}
let idx = Tensor::from_vec(indices, (self.nbits,), device)?;
let looked = self.table.forward(&idx)?;
let summed = looked.sum(0)?.to_dtype(dtype)?;
let wm = summed
.reshape((1, self.hidden, 1))?
.broadcast_as((b, c, t))?;
let scaled = (wm * self.alpha as f64)?;
xs + scaled
}
}
// -- Generator (encoder + msg + decoder) -----------------------------------
#[derive(Debug, Clone)]
pub struct Generator {
pub encoder: SeanetEncoder,
pub msg_processor: MsgProcessor,
pub decoder: SeanetDecoder,
}
impl Generator {
pub fn new(vb: VarBuilder) -> candle_core::Result<Self> {
let encoder = SeanetEncoder::new(vb.pp("encoder"))?;
let msg_processor = MsgProcessor::new(MESSAGE_BITS, DIMENSION, vb.clone())?;
let decoder = SeanetDecoder::new(1, vb.pp("decoder"))?;
Ok(Self {
encoder,
msg_processor,
decoder,
})
}
/// Forward `(B, 1, T) → (B, 1, T)`. Returns the **watermark residual**;
/// `embed` adds it to the input audio with `alpha`. Trim/pad to input
/// length so callers can sum directly without shape headaches.
pub fn forward(&self, xs: &Tensor, message: u32) -> candle_core::Result<Tensor> {
let device = xs.device().clone();
let dtype = xs.dtype();
let want = xs.dim(D::Minus1)?;
let h = self.encoder.forward(xs)?;
let h = self.msg_processor.forward(&h, message, &device, dtype)?;
let h = self.decoder.forward(&h)?;
let got = h.dim(D::Minus1)?;
if got == want {
Ok(h)
} else if got > want {
h.narrow(D::Minus1, 0, want)
} else {
h.pad_with_zeros(D::Minus1, 0, want - got)
}
}
}
// -- Detector --------------------------------------------------------------
/// Detector: `(B, 1, T) → (B, 2 + nbits, T)`.
///
/// Architecture (verbatim from `facebook/audioseal` detector_base.pth):
/// - `detector.0.model.*` — SeanetEncoder (full, ending with 128-channel
/// bottleneck at frame-rate ≈ T/320)
/// - `detector.0.reverse_convolution` — single ConvTranspose1d(128, 32,
/// kernel=320, stride=320, bias=True). Lifts frame-rate features back
/// to sample-rate (320× upsample) without weight_norm, no overlap.
/// - `detector.1` — Conv1d(32, 2+nbits, kernel=1, bias=True). Pointwise
/// head producing per-sample presence + message-bit logits.
#[derive(Debug, Clone)]
pub struct Detector {
encoder: SeanetEncoder,
reverse_convolution: ConvTranspose1d,
head: Conv1d,
nbits: usize,
}
impl Detector {
pub fn new(vb: VarBuilder) -> candle_core::Result<Self> {
let inner = vb.pp("detector.0");
let encoder = SeanetEncoder::new(inner.clone())?;
let reverse_convolution = conv_transpose1d(
DIMENSION,
N_FILTERS,
HOP_LENGTH,
ConvTranspose1dConfig {
stride: HOP_LENGTH,
..Default::default()
},
inner.pp("reverse_convolution"),
)?;
let head = conv1d(
N_FILTERS,
2 + MESSAGE_BITS,
1,
Conv1dConfig::default(),
vb.pp("detector.1"),
)?;
Ok(Self {
encoder,
reverse_convolution,
head,
nbits: MESSAGE_BITS,
})
}
/// `(B, 1, T) → (B, 2+nbits, T)` per-sample logits.
pub fn forward(&self, xs: &Tensor) -> candle_core::Result<Tensor> {
let h = self.encoder.forward(xs)?;
// Single-shot 320× upsample. ConvTranspose1d with k=stride=320 → no overlap.
let h = h.apply(&self.reverse_convolution)?;
// Re-narrow to original T (the upsample may produce slightly more samples
// than the input, depending on encoder rounding).
let want = xs.dim(D::Minus1)?;
let got = h.dim(D::Minus1)?;
let h = if got == want {
h
} else if got > want {
h.narrow(D::Minus1, 0, want)?
} else {
h.pad_with_zeros(D::Minus1, 0, want - got)?
};
h.apply(&self.head)
}
/// Decode per-sample presence + message bits from `(B, 2+nbits, T)` logits.
pub fn decode(&self, logits: &Tensor) -> candle_core::Result<(Tensor, u16, f32)> {
let presence_logits = logits.i((.., ..2, ..))?;
let message_logits = logits.i((.., 2.., ..))?;
let presence_probs = ops::softmax(&presence_logits, 1)?;
let presence = presence_probs.i((.., 1, ..))?;
let bit_probs = ops::sigmoid(&message_logits)?.mean(D::Minus1)?;
let bits: Vec<f32> = bit_probs.i(0)?.to_dtype(DType::F32)?.to_vec1()?;
let mut decoded: u16 = 0;
for (k, p) in bits.iter().enumerate().take(self.nbits) {
if *p > 0.5 {
decoded |= 1 << k;
}
}
let mean_presence = presence
.mean_all()?
.to_dtype(DType::F32)?
.to_scalar::<f32>()?;
Ok((presence, decoded, mean_presence))
}
}
// -- Public watermarker (Watermarker trait surface) ------------------------
#[derive(Debug, Clone)]
pub struct DetectionResult {
pub presence_per_sample: Vec<f32>,
pub message: Option<u16>,
pub mean_presence: f32,
}
pub struct AudioSealWatermarker {
pub device: Device,
pub message: u16,
pub generator: Option<Generator>,
pub detector: Option<Detector>,
pub alpha: f32,
}
impl AudioSealWatermarker {
pub fn new(device: Device, message: u16) -> Self {
Self {
device,
message,
generator: None,
detector: None,
alpha: 1.0,
}
}
/// Construct from in-memory VarBuilders rooted at the generator and
/// detector subtrees (after `weight_norm` merge by the converter).
pub fn from_var_builders(
generator_vb: VarBuilder,
detector_vb: VarBuilder,
device: Device,
message: u16,
) -> Result<Self> {
let generator = Generator::new(generator_vb)
.map_err(|e| CsmError::Config(format!("AudioSeal generator load: {e}")))?;
let detector = Detector::new(detector_vb)
.map_err(|e| CsmError::Config(format!("AudioSeal detector load: {e}")))?;
Ok(Self {
device,
message,
generator: Some(generator),
detector: Some(detector),
alpha: 1.0,
})
}
pub fn load<P: AsRef<Path>>(_weights_dir: P, device: Device, message: u16) -> Result<Self> {
Ok(Self::new(device, message))
}
pub fn detect(&self, samples: &[f32]) -> Result<DetectionResult> {
let detector = self.detector.as_ref().ok_or_else(|| {
CsmError::Config(
"AudioSeal::detect: detector weights not loaded — see audioseal_convert example"
.into(),
)
})?;
let xs = Tensor::from_slice(samples, (1, 1, samples.len()), &self.device)
.map_err(|e| CsmError::Config(format!("detect: input tensor: {e}")))?;
let logits = detector
.forward(&xs)
.map_err(|e| CsmError::Config(format!("detect: forward: {e}")))?;
let (presence, message, mean_presence) = detector
.decode(&logits)
.map_err(|e| CsmError::Config(format!("detect: decode: {e}")))?;
let presence_per_sample = presence
.i(0)
.and_then(|t| t.to_dtype(DType::F32))
.and_then(|t| t.to_vec1::<f32>())
.map_err(|e| CsmError::Config(format!("detect: presence to_vec: {e}")))?;
Ok(DetectionResult {
presence_per_sample,
message: Some(message),
mean_presence,
})
}
}
impl AudioSealWatermarker {
/// Internal implementation used by both `embed` (default message) and
/// `embed_with_message` (per-call override).
fn embed_inner(&self, audio: &[f32], message: u16) -> Result<Vec<f32>> {
let generator = self.generator.as_ref().ok_or_else(|| {
CsmError::Config(
"AudioSeal::embed: generator weights not loaded — see audioseal_convert example"
.into(),
)
})?;
let xs = Tensor::from_slice(audio, (1, 1, audio.len()), &self.device)
.map_err(|e| CsmError::Config(format!("embed: input tensor: {e}")))?;
let residual = generator
.forward(&xs, message as u32)
.map_err(|e| CsmError::Config(format!("embed: forward: {e}")))?;
let scaled = (residual * self.alpha as f64)
.map_err(|e| CsmError::Config(format!("embed: scale: {e}")))?;
let out = (xs + scaled).map_err(|e| CsmError::Config(format!("embed: sum: {e}")))?;
let len = out
.dim(D::Minus1)
.map_err(|e| CsmError::Config(e.to_string()))?;
let flat = out
.reshape((len,))
.and_then(|t| t.to_dtype(DType::F32))
.and_then(|t| t.to_vec1::<f32>())
.map_err(|e| CsmError::Config(format!("embed: to_vec: {e}")))?;
Ok(flat)
}
}
impl Watermarker for AudioSealWatermarker {
fn embed(&self, audio: &[f32]) -> Result<Vec<f32>> {
self.embed_inner(audio, self.message)
}
fn embed_with_message(&self, audio: &[f32], message: u16) -> Result<Vec<f32>> {
self.embed_inner(audio, message)
}
}
#[cfg(test)]
mod tests {
use super::*;
use candle_nn::{VarBuilder, VarMap};
fn random_vb(device: &Device) -> (VarMap, VarBuilder<'static>) {
let vm = VarMap::new();
let vb = VarBuilder::from_varmap(&vm, DType::F32, device);
(vm, vb)
}
#[test]
fn scaffold_constructs() {
let w = AudioSealWatermarker::new(Device::Cpu, 0xABCD);
assert_eq!(w.message, 0xABCD);
}
#[test]
fn embed_returns_typed_error_without_weights() {
let w = AudioSealWatermarker::new(Device::Cpu, 0);
assert!(w.embed(&[0.0, 0.1, 0.2]).is_err());
}
#[test]
fn detect_returns_typed_error_without_weights() {
let w = AudioSealWatermarker::new(Device::Cpu, 0);
assert!(w.detect(&[0.0, 0.1, 0.2]).is_err());
}
#[test]
fn residual_block_preserves_shape() {
let device = Device::Cpu;
let (_vm, vb) = random_vb(&device);
let block = SeanetResidualBlock::new(64, 1, vb).unwrap();
let xs = Tensor::randn(0f32, 1f32, (2, 64, 100), &device).unwrap();
let out = block.forward(&xs).unwrap();
assert_eq!(out.dims(), &[2, 64, 100]);
}
#[test]
fn lstm_bottleneck_preserves_shape() {
let device = Device::Cpu;
let (_vm, vb) = random_vb(&device);
let lstm = LstmBottleneck::new(LSTM_HIDDEN, 2, vb).unwrap();
let xs = Tensor::randn(0f32, 1f32, (2, LSTM_HIDDEN, 50), &device).unwrap();
let out = lstm.forward(&xs).unwrap();
assert_eq!(out.dims(), &[2, LSTM_HIDDEN, 50]);
}
#[test]
fn msg_processor_preserves_shape() {
let device = Device::Cpu;
let (_vm, vb) = random_vb(&device);
let msg = MsgProcessor::new(16, 128, vb).unwrap();
let xs = Tensor::randn(0f32, 1f32, (1, 128, 25), &device).unwrap();
let out = msg.forward(&xs, 0xABCDu32, &device, DType::F32).unwrap();
assert_eq!(out.dims(), &[1, 128, 25]);
}
#[test]
fn encoder_downsamples_by_320() {
let device = Device::Cpu;
let (_vm, vb) = random_vb(&device);
let enc = SeanetEncoder::new(vb).unwrap();
let xs = Tensor::randn(0f32, 1f32, (1, 1, 16000), &device).unwrap();
let out = enc.forward(&xs).unwrap();
let frames = out.dim(D::Minus1).unwrap();
assert_eq!(out.dim(0).unwrap(), 1);
assert_eq!(out.dim(1).unwrap(), DIMENSION);
assert!(
(frames as i64 - 50).abs() <= 2,
"encoder frames expected ~50, got {frames}",
);
}
#[test]
fn generator_round_trips_shape() {
let device = Device::Cpu;
let (_vm, vb) = random_vb(&device);
let g = Generator::new(vb).unwrap();
let t = 16000;
let xs = Tensor::randn(0f32, 1f32, (1, 1, t), &device).unwrap();
let out = g.forward(&xs, 0xBEEFu32).unwrap();
assert_eq!(out.dim(0).unwrap(), 1);
assert_eq!(out.dim(1).unwrap(), 1);
let got = out.dim(D::Minus1).unwrap();
let drift = (got as i64 - t as i64).abs() as usize;
assert!(
drift * 100 < t,
"generator length drift too large: {drift} samples"
);
}
#[test]
fn detector_emits_18_channel_logits() {
let device = Device::Cpu;
let (_vm, vb) = random_vb(&device);
let det = Detector::new(vb).unwrap();
let t = 16000;
let xs = Tensor::randn(0f32, 1f32, (1, 1, t), &device).unwrap();
let logits = det.forward(&xs).unwrap();
assert_eq!(logits.dim(0).unwrap(), 1);
assert_eq!(logits.dim(1).unwrap(), 2 + MESSAGE_BITS);
assert_eq!(logits.dim(D::Minus1).unwrap(), t);
}
#[test]
fn detector_decode_yields_u16_message() {
let device = Device::Cpu;
let (_vm, vb) = random_vb(&device);
let det = Detector::new(vb).unwrap();
let xs = Tensor::randn(0f32, 1f32, (1, 1, 16000), &device).unwrap();
let logits = det.forward(&xs).unwrap();
let (presence, _message, mean_presence) = det.decode(&logits).unwrap();
assert_eq!(presence.dim(D::Minus1).unwrap(), 16000);
assert!(mean_presence.is_finite());
}
}