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]>
186 lines
6.2 KiB
Rust
186 lines
6.2 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(())
|
||
}
|