End-to-end SilentCipher: bit-perfect round-trip on real LibriSpeech
audio. Sesame's actual production watermarker now works in pure
candle 0.9 + Metal.
New components in src/silentcipher.rs:
detect(samples_16k) -> DetectResult
1. RMS-normalize to VCTK baseline (matches embed pre-conditioning)
2. STFT -> magnitude
3. dec_m_0(magnitude) -> (B, message_dim, 1, T) logits
4. argmax along message_dim -> (T,) per-frame predictions
5. Truncate to multiple of message_len
6. Reshape to (n_patches, message_len), per-column mode
7. Find terminator (value 0), rotate so payload follows it
8. Subtract +1 offset -> original codes
encode_bits / decode_bits (Phase 10.4 fix)
Switched from base-4 (2 bits per code) to base-`(message_dim - 1)`.
The 16 kHz model has message_dim=4 = 3 carrier values (1,2,3) +
terminator (0), NOT 4 carrier values. Original base-4 packing
occasionally produced value 3, which Python's
`np.identity(4)[index+1]` would have crashed on. Real capacity:
15 codes x log2(3) ~= 23.78 bits per patch.
SilentCipherWatermark (impl Watermarker)
Wraps a SilentCipherWatermarker with a fixed default_payload so
it satisfies the existing Watermarker trait. Maps confidence ->
DetectionResult.mean_presence and the lower-16-bits of the
decoded payload -> DetectionResult.message (None below confidence
0.7 to suppress false positives).
examples/silentcipher_apply
Mirrors audioseal_apply: --in / --out / --payload / --detect-only.
Loads from sony/silentcipher HF repo, embeds, optionally
resamples back to source rate, optionally re-detects to verify.
Verified end-to-end (LibriSpeech /tmp/asr_test.flac, 10.42 s @ 16 kHz):
Build: 29 ms (3 .ckpt files from HF cache)
Embed: 1213 ms = 0.116x realtime
Detect: 1838 ms = 0.18x realtime
payload: 0x00BC614E (in)
recovered: 0x00BC614E (out)
codes match: 15 / 15
confidence: 1.0000
Clean (un-watermarked) audio: confidence 0.475, codes mostly 0 -
strong signal-vs-noise discrimination at the 0.7 threshold.
This closes the most surprising gap from the Sesame stack analysis:
rtx-csm now has the *literal* Sesame watermarker (not Meta's
AudioSeal) working in pure candle. AudioSeal stays available for
callers that prefer it.
Phase 10.5 (next): wire as a third option in converse_server alongside
AudioSeal, and a 24/16 kHz ResampledWatermarker for the CSM path.
Plus an A/B bench (SilentCipher vs AudioSeal).
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
178 lines
6.1 KiB
Rust
178 lines
6.1 KiB
Rust
//! Phase 10.4 — apply SilentCipher to an arbitrary WAV. Mirror of
|
||
//! `audioseal_apply`. Loads the released 16 kHz checkpoint from
|
||
//! `sony/silentcipher`, embeds a payload, optionally re-detects to
|
||
//! verify round-trip.
|
||
//!
|
||
//! Usage (embed + verify):
|
||
//! ```bash
|
||
//! cargo run -p rtx-csm --release --features metal --example silentcipher_apply -- \
|
||
//! --in /tmp/asr_test.flac --out /tmp/asr_test_silent.wav --payload 12345678
|
||
//! ```
|
||
//!
|
||
//! Usage (detect only):
|
||
//! ```bash
|
||
//! cargo run -p rtx-csm --release --features metal --example silentcipher_apply -- \
|
||
//! --in /tmp/asr_test_silent.wav --detect-only
|
||
//! ```
|
||
|
||
use anyhow::{Context, Result};
|
||
use candle_core::Device;
|
||
use clap::Parser;
|
||
use hf_hub::api::sync::Api;
|
||
use rtx_csm::{
|
||
audio_io,
|
||
silentcipher::{SilentCipherConfig, SilentCipherWatermarker},
|
||
};
|
||
use std::path::PathBuf;
|
||
use std::time::Instant;
|
||
|
||
const REPO: &str = "sony/silentcipher";
|
||
const CKPT_DIR: &str = "16_khz/97561_iteration";
|
||
const SR: u32 = 16_000;
|
||
|
||
#[derive(Debug, Parser)]
|
||
struct Cli {
|
||
/// Input WAV (any rate, mono OK; resampled to 16 kHz).
|
||
#[arg(long = "in")]
|
||
input: PathBuf,
|
||
/// Output WAV. Optional in `--detect-only` mode.
|
||
#[arg(long)]
|
||
out: Option<PathBuf>,
|
||
/// 30-bit payload to embed. Default 0 (no payload). 16 kHz model
|
||
/// can carry up to ~23.78 bits (15 base-3 codes).
|
||
#[arg(long, default_value_t = 0)]
|
||
payload: u32,
|
||
/// Skip embedding; just run detect on the input. Useful for
|
||
/// verifying that a previously-watermarked file still carries the
|
||
/// signature.
|
||
#[arg(long)]
|
||
detect_only: bool,
|
||
/// Source sample rate of the input WAV. Default = read from file.
|
||
#[arg(long)]
|
||
source_rate: Option<u32>,
|
||
#[arg(long)]
|
||
cpu: bool,
|
||
}
|
||
|
||
fn main() -> Result<()> {
|
||
tracing_subscriber::fmt().with_max_level(tracing::Level::WARN).init();
|
||
let cli = Cli::parse();
|
||
|
||
let device = if cli.cpu {
|
||
Device::Cpu
|
||
} else if candle_core::utils::metal_is_available() {
|
||
Device::new_metal(0)?
|
||
} else {
|
||
Device::Cpu
|
||
};
|
||
eprintln!("device: {device:?}");
|
||
|
||
// Load model.
|
||
let api = Api::new()?;
|
||
let repo = api.model(REPO.to_string());
|
||
let enc_c = repo.get(&format!("{CKPT_DIR}/enc_c.ckpt"))?;
|
||
let dec_c = repo.get(&format!("{CKPT_DIR}/dec_c.ckpt"))?;
|
||
let dec_m_0 = repo.get(&format!("{CKPT_DIR}/dec_m_0.ckpt"))?;
|
||
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!("model loaded in {} ms", load_t.elapsed().as_millis());
|
||
|
||
// Load + resample input to 16 kHz.
|
||
let src_rate = cli.source_rate.unwrap_or_else(|| {
|
||
// Read native rate via hound where possible — but we just use
|
||
// load_mono_at_rate which resamples to whatever target we
|
||
// pass. For round-trip output at the original rate, we read
|
||
// the source rate manually.
|
||
match hound::WavReader::open(&cli.input) {
|
||
Ok(r) => r.spec().sample_rate,
|
||
Err(_) => SR, // assume 16 kHz if we can't read header
|
||
}
|
||
});
|
||
let src = audio_io::load_mono_at_rate(&cli.input, src_rate)?;
|
||
let src_16k = audio_io::resample(&src, src_rate, SR)?;
|
||
eprintln!(
|
||
"loaded {} ({} samples @ {src_rate} Hz, {} samples @ {SR} Hz)",
|
||
cli.input.display(),
|
||
src.len(),
|
||
src_16k.len()
|
||
);
|
||
|
||
if cli.detect_only {
|
||
// Detect on the input directly.
|
||
let t = Instant::now();
|
||
let r = wm
|
||
.detect(&src_16k)
|
||
.context("silentcipher detect")?;
|
||
let ms = t.elapsed().as_millis();
|
||
let recovered = wm.decode_bits(&r.codes);
|
||
println!();
|
||
println!("=== detect-only ===");
|
||
println!("input: {}", cli.input.display());
|
||
println!("audio: {} samples ({:.2}s) at 16 kHz", src_16k.len(), src_16k.len() as f32 / SR as f32);
|
||
println!("detect: {ms} ms");
|
||
println!("confidence: {:.4}", r.confidence);
|
||
println!("payload: 0x{recovered:08X} ({recovered})");
|
||
println!("codes: {:?}", r.codes);
|
||
return Ok(());
|
||
}
|
||
|
||
// Embed.
|
||
let codes = wm.encode_bits(cli.payload);
|
||
eprintln!(
|
||
"payload 0x{:08X} -> {} codes (each in 0..{}): {:?}",
|
||
cli.payload,
|
||
codes.len(),
|
||
wm.cfg.message_dim,
|
||
codes
|
||
);
|
||
let embed_t = Instant::now();
|
||
let watermarked_16k = wm
|
||
.embed(&src_16k, &codes)
|
||
.context("silentcipher embed")?;
|
||
let embed_ms = embed_t.elapsed().as_millis();
|
||
eprintln!(
|
||
"embed: {embed_ms} ms ({:.3}× realtime)",
|
||
embed_ms as f32 / (src_16k.len() as f32 / SR as f32 * 1000.0)
|
||
);
|
||
|
||
// Resample back to source rate and write output.
|
||
let out_path = cli
|
||
.out
|
||
.as_ref()
|
||
.context("--out is required unless --detect-only is set")?;
|
||
let watermarked = audio_io::resample(&watermarked_16k, SR, src_rate)?;
|
||
let mut watermarked = watermarked;
|
||
watermarked.truncate(src.len());
|
||
audio_io::write_wav_mono(out_path.as_path(), &watermarked, src_rate)?;
|
||
eprintln!("wrote {} ({} samples @ {} Hz)", out_path.display(), watermarked.len(), src_rate);
|
||
|
||
// Round-trip detect on the resampled output (the realistic test).
|
||
let probe_16k = audio_io::resample(&watermarked, src_rate, SR)?;
|
||
let detect_t = Instant::now();
|
||
let r = wm
|
||
.detect(&probe_16k)
|
||
.context("silentcipher detect")?;
|
||
let detect_ms = detect_t.elapsed().as_millis();
|
||
let recovered = wm.decode_bits(&r.codes);
|
||
let matching = r
|
||
.codes
|
||
.iter()
|
||
.zip(codes.iter())
|
||
.filter(|(a, b)| a == b)
|
||
.count();
|
||
println!();
|
||
println!("=== round-trip ===");
|
||
println!("payload: 0x{:08X} (expected)", cli.payload);
|
||
println!("recovered: 0x{recovered:08X}");
|
||
println!("codes match: {matching} / {}", codes.len());
|
||
println!("confidence: {:.4}", r.confidence);
|
||
println!("detect: {detect_ms} ms");
|
||
if matching == codes.len() && recovered == cli.payload {
|
||
println!("PASS");
|
||
} else {
|
||
println!("WEAK — bit accuracy {:.0}%", 100.0 * matching as f32 / codes.len() as f32);
|
||
}
|
||
Ok(())
|
||
}
|