Foundation for porting Sesame's actual production watermarker (NOT
AudioSeal — the gap analysis identified this as the literal Sesame
parity item). Same iterative-shipping pattern as Phase 8.4 for
Moonshine.
`docs/silentcipher_port_notes.md`:
- Full architecture from SesameAILabs/silentcipher/src/.../model.py
(verified against 95 LOC of source)
- Three small networks of gated 2D convs on STFT:
enc_c 3 layers 1 -> 32 channels
dec_c 4 layers 96 -> 1 channels
dec_m 10 layers 1 -> 128 -> message_dim, plus Linear
- Each Layer = Conv2d * sigmoid(Conv2d) + BatchNorm2d
- Pipeline (encode + decode) walked through step by step
- 10 ordered porting tasks with hour estimates totaling ~1-2 days
- Risks flagged: STFT helper needed, BatchNorm running stats loading,
phase passthrough, message-length differences vs AudioSeal
`examples/silentcipher_inspect`:
- Downloads sony/silentcipher 16 kHz checkpoint from HuggingFace
- Dumps hparams.yaml + tensor shapes per .ckpt file
- Verified output:
N_FFT 2048 HOP 1024 SR 16000
message_dim 4 message_len 16 message_band 512
enc_c 0.17 MB 40 k params
dec_c 2.01 MB 500 k params
dec_m_0 9.54 MB 2.38 M params
Total ~2.92 M params
That's ~10x smaller than AudioSeal's gen+det combined. Port
estimated 1-2 days.
`.ckpt` files are pickle (PyTorch state_dict) — direct loadable via
candle_core::pickle::read_all, same path as audioseal_convert.rs.
No safetensors conversion needed.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
10 KiB
SilentCipher candle port notes (Phase 10)
Working notes for the candle port of SilentCipher — Sesame's actual
production watermarker (not AudioSeal). See
docs/sesame_gap_analysis.md for the gap analysis that motivated this.
Why this exists
rtx-csm currently watermarks via AudioSeal (Phase 4). AudioSeal is
Meta's system; SilentCipher is what Sesame's GitHub fork
(SesameAILabs/silentcipher) actually uses. Functionally similar
(invisible audio watermark + bit-decode), but different architecture
and weights. Goal: literal Sesame watermarker parity.
SilentCipher is substantially simpler than AudioSeal:
- AudioSeal: SEANet 1D conv stack on raw audio + LSTM bottleneck + message embedding + decoder, ~14-15 M params each (gen + det)
- SilentCipher: 3 small networks of gated 2D convs on STFT, totaling maybe 5-10 M params
Architecture (verified from SesameAILabs/silentcipher/src/silentcipher/model.py)
Layer (gated conv block, used everywhere)
Layer(in, out, k, s, p):
conv: Conv2d(in, out, k, s, p, bias=True)
gate: Conv2d(in, out, k, s, p, bias=True)
bn: BatchNorm2d(out)
forward(x): bn(conv(x) * sigmoid(gate(x)))
Encoder (enc_c)
3 stacked Layers (default enc_n_layers=3, kernel 3×3 stride 1 pad 1):
Layer(1, 32) ── input: (B, 1, n_fft//2+1, T)
Layer(32, 32)
Layer(32, 32) ── output: (B, 32, n_fft//2+1, T)
Plus Linear(message_dim, message_band_size) for transform_message
(broadcasts the bit message across freq bins).
CarrierDecoder (dec_c)
4 stacked Layers (default dec_c_n_layers=4, kernel 3×3 stride 1 pad 1
except last is 1×1):
Layer(96, 96) ── input: (B, 96, n_fft//2+1, T) [from concatenated carrier+msg+msg_band]
Layer(96, 96)
Layer(96, 96)
Layer(96, 1, k=1) ── output: (B, 1, n_fft//2+1, T) [watermark spectrogram]
MsgDecoder (dec_m)
10 stacked Layers (dec_m_num_repeat=8 plus first/last, channel_dim=128):
Dropout(0)
Layer(1, 128) ── input: (B, 1, message_band_size, T)
[Dropout(0) + Layer(128, 128)] × 8
Dropout(0)
Layer(128, message_dim)
Linear(message_band_size, 1) ── output: (B, message_dim, 1, T) reshaped
The model has multiple dec_m instances — one per message channel
(n_messages from config, typically 1 for our use). Released
checkpoints provide dec_m_0.ckpt, dec_m_1.ckpt, etc.
Hyperparameters (from config.yaml in checkpoint)
Exact values aren't in the repo — they're in hparams.yaml shipped
with the released weights at
hf.co/sony/silentcipher/{16_khz/97561_iteration, 44_1_khz/73999_iteration}/hparams.yaml.
Values referenced in server.py source:
enc_n_layers = 3dec_c_n_layers = 4dec_m_num_repeat = 8(so total layers ≈ 10)message_dim— fixed in config (typically 256 for byte-encoding 5 characters of 8 bits = 40 bits per patch)message_band_size— typically a fraction ofn_fft//2+1(e.g. 256 out of 513 forn_fft=1024)n_messages— number of independent watermark channels (typically 1)message_len— number of bytes per "message patch" (5 from the Python demo:[123, 234, 111, 222, 11])
STFT params:
- 16 kHz model:
N_FFT=1024,HOP_LENGTH=??(from hparams) - 44.1 kHz model:
N_FFT=??(likely 2048 or 4096 to match SR/HOP ratio)
Pipeline (encode)
- Load audio, resample to model SR (16 kHz or 44.1 kHz)
- Compute STFT: complex → magnitude + phase
- Encode message bytes as one-hot, replicated across patches
enc_c.transform_message(msg_one_hot)→ padded msg with shape matching mag (B, 1, n_fft//2+1, T)enc_c(magnitude)→ carrier features (B, 32, n_fft//2+1, T)enc_c.transform_messageis a separate broadcast pass producing another tensor (B, 1, n_fft//2+1, T) of message info- Concatenate (carrier 32, msg 1, msg_band ≤32) along channels → 96
channels (matches
dec_c_conv_dim = 32*3) dec_c(...)→ watermark spectrogram (B, 1, n_fft//2+1, T) scaled by SDRmag_watermarked = magnitude + watermark(orrelu/absper config flag)- iSTFT(mag_watermarked, phase) → encoded audio
Pipeline (decode)
- STFT
dec_m_i(magnitude)for each message channel → logits per byte position, shape (B, message_dim, 1, T)- Argmax along
message_dim, group T into patches ofmessage_len× bytes - Per-patch majority vote → recovered byte sequence
- Confidence = mean softmax probability of argmax tokens
Weight checkpoints
Hosted at https://huggingface.co/sony/silentcipher. Two folders:
44_1_khz/73999_iteration/
enc_c.ckpt ~ encoder
dec_c.ckpt ~ carrier decoder
dec_m_0.ckpt ~ message decoder (one per channel)
hparams.yaml ~ config
16_khz/97561_iteration/
enc_c.ckpt
dec_c.ckpt
dec_m_0.ckpt
hparams.yaml
.ckpt files are PyTorch state_dict pickle (same format as the
AudioSeal .pth). Use candle_core::pickle::read_all (already in
audioseal_convert.rs) to load directly without converting.
For our use, the 16 kHz model is the natural fit: CSM TTS is 24 kHz, we resample to 16 kHz for watermarking (same 24↔16 dance as AudioSeal). The 44.1 kHz model is for high-fidelity music watermarking, irrelevant to our use case.
Porting tasks (~1-2 days, MUCH simpler than AudioSeal port was)
In order of dependency:
-
examples/silentcipher_inspect.rs(~30 min, this commit) Same pattern asmoonshine_inspect: downloadsony/silentcipher, dump tensor shapes for the 16 kHz checkpoint. Verifies the layout matches what we read from the source. -
src/silentcipher.rsskeleton +Layertype (~1 h)MoonshineConfig-styleSilentCipherConfigfrom hparams.yamlLayer { conv, gate, bn }impl with the gated activation forward- Verify with a tiny smoke test (random input, shape preserved)
-
STFT helper (~2-3 h)
- candle has FFT primitives; we need windowed STFT with Hann window
- Reference:
src/silentcipher/stft.py(40 LOC, simple). Forward pads to whole-window multiple, doestorch.stft, returns magnitude + phase. - Pure-Rust path: implement framing +
rustfftper frame, or use candle's built-in FFT if available. - Test: round-trip a sine wave, verify error < 1 e-4.
-
Encoder + CarrierDecoder + MsgDecoder forward (~2-3 h)
- Each is straightforward Sequential of Layers
enc_c.transform_messageneeds the Linear + zero-pad ton_fft//2+1- Test: random input, shape sanity through full encode pipeline
-
Weight loading via
candle_core::pickle::read_all(~1 h)- Direct load from
enc_c.ckptetc. — no safetensors conversion needed (same approach asaudioseal_convert.rs) - Map PyTorch
.weight/.bias/.running_mean/.running_varfor BatchNorm to candle'sBatchNorm2dconstructor
- Direct load from
-
SilentCipherWatermarkerend-to-end (~2-3 h)- Wraps everything: load config + weights, construct STFT, hold
all three networks, expose
embed(samples) -> Vec<f32>anddetect(samples) -> DetectionResult - Implements the existing
Watermarkertrait so it drops intoGenerator::set_watermarker
- Wraps everything: load config + weights, construct STFT, hold
all three networks, expose
-
examples/silentcipher_applyCLI (~30 min)- Mirror
audioseal_applyexactly: input WAV → embed → output WAV, plus--detect-onlyfor verifying
- Mirror
-
examples/silentcipher_demo(~30 min)- End-to-end: real LibriSpeech audio → embed
[123,234,111,222,11]→ detect → assert message matches
- End-to-end: real LibriSpeech audio → embed
-
Bench vs AudioSeal (~30 min)
- Single-WAV benchmark: embed time, detect time, SDR, bit
accuracy. Capture in
docs/perf_history.md.
- Single-WAV benchmark: embed time, detect time, SDR, bit
accuracy. Capture in
-
--watermark-silentcipherflag in converse_server (~1 h)- Mutex with
--watermark-generator/--watermark-detector(AudioSeal). Stripped down: SilentCipher takes a single checkpoint folder.
- Mutex with
Risks / unknowns
-
STFT in candle. We may need to add a
rustfftdep or implement STFT manually. Performance-wise both should be ~ms-scale, fine for our use case. -
BatchNorm running stats.
candle_nn::BatchNormexists but we need to verify it loadsrunning_mean/running_varfrom pickle correctly. Worst case we manually compute via stored stats (eval mode means(x - mean) / sqrt(var + eps) * gamma + beta). -
Phase passthrough. Watermarking only modifies magnitude; phase must be preserved exactly through the iSTFT. Verify there's no accidental phase corruption.
-
Message length 40 bits. AudioSeal carries 16 bits; SilentCipher carries 40 bits per "patch" (5 bytes × 8 bits). For our use case (one watermark per utterance), 16 bits is enough — we can either use the lower 16 bits of the SilentCipher message and ignore the rest, or just embed a job_id in the full 40 bits.
Bench expectations vs AudioSeal
AudioSeal numbers from Phase 4f-g and Phase 6f.wm:
- Embed: ~30-70 ms per ~1 s audio (real CSM speech)
- Detect: ~30-50 ms
- SDR: not measured; bit accuracy 16/16 on clean signal, 12/16 on 24↔16 resample
- Phase 6f.wm: ~73 ms total cost per utterance in the converse_server (~1% overhead on a ~6.8 s TTS phase)
SilentCipher is smaller (5-10 M params vs ~30 M for AudioSeal gen+det combined), so we expect comparable or faster:
- Embed: ~20-40 ms per ~1 s audio
- Detect: ~10-30 ms
- Bit accuracy: stronger (training-time guarantee per the SilentCipher paper)
Real numbers will be in the bench comparison after porting.
Recommended order of attack for the next session
- This commit: ship port notes +
silentcipher_inspect.rs - Next: STFT helper + smoke test (highest risk; resolve early)
- Then: model scaffolds + weight loader + standalone embed/detect
- Last: integration into converse_server + A/B bench
Each step is a bounded ship — same pattern as Phase 8.4-8.10 Moonshine.
Cited sources
- Repo: https://github.com/SesameAILabs/silentcipher (Sesame's fork)
- Original: https://github.com/sony/silentcipher
- Paper: arXiv 2406.03822 (SilentCipher)
- Weights: https://huggingface.co/sony/silentcipher
- Architecture verified from
silentcipher/src/silentcipher/model.py(95 LOC, three classes)