Feature Extraction
Transformers
Safetensors
cage_detector
audio
watermark
watermark-detection
provenance
vocbulwark
custom_code
Instructions to use mlr2000/vocoder-small-watermark-detector with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- Transformers
How to use mlr2000/vocoder-small-watermark-detector with Transformers:
# Use a pipeline as a high-level helper from transformers import pipeline pipe = pipeline("feature-extraction", model="mlr2000/vocoder-small-watermark-detector", trust_remote_code=True)# Load model directly from transformers import AutoModel model = AutoModel.from_pretrained("mlr2000/vocoder-small-watermark-detector", trust_remote_code=True, device_map="auto") - Notebooks
- Google Colab
- Kaggle
Upload folder using huggingface_hub
Browse files- README.md +91 -0
- __init__.py +4 -0
- cage_config.py +23 -0
- cage_extractor.py +138 -0
- config.json +50 -0
- configuration_detector.py +11 -0
- example_roundtrip.ipynb +125 -0
- model.safetensors +3 -0
- modeling_detector.py +91 -0
README.md
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
library_name: transformers
|
| 3 |
+
tags:
|
| 4 |
+
- audio
|
| 5 |
+
- watermark
|
| 6 |
+
- watermark-detection
|
| 7 |
+
- provenance
|
| 8 |
+
- vocbulwark
|
| 9 |
+
license: cc-by-4.0
|
| 10 |
+
---
|
| 11 |
+
|
| 12 |
+
# VocBulwark watermark detector
|
| 13 |
+
|
| 14 |
+
Standalone detector for the fixed **32-bit** provenance watermark embedded by
|
| 15 |
+
the companion VocBulwark vocoder. Given an audio clip, it extracts the watermark
|
| 16 |
+
bits with the Cage extractor and compares them to the known fixed code, reporting
|
| 17 |
+
how many bits match.
|
| 18 |
+
|
| 19 |
+
Self-contained: loads with `trust_remote_code=True`, no training repo required.
|
| 20 |
+
|
| 21 |
+
## Companion Models
|
| 22 |
+
|
| 23 |
+
This detector is part of a set of 6 repositories:
|
| 24 |
+
|
| 25 |
+
| Repo | Role |
|
| 26 |
+
|------|------|
|
| 27 |
+
| `mlr2000/vocoder-large` | Large vocoder |
|
| 28 |
+
| `mlr2000/vocoder-large-watermark-detector` | Watermark detector for the large model |
|
| 29 |
+
| `mlr2000/vocoder-large-speaker-encoder` | Speaker encoder for the large model |
|
| 30 |
+
| `mlr2000/vocoder-small` | Small vocoder (generates the watermarked audio) |
|
| 31 |
+
| `mlr2000/vocoder-small-watermark-detector` | Watermark detector (this repo) |
|
| 32 |
+
| `mlr2000/vocoder-small-speaker-encoder` | Speaker encoder for the small model |
|
| 33 |
+
|
| 34 |
+
## Usage
|
| 35 |
+
|
| 36 |
+
```python
|
| 37 |
+
import torchaudio
|
| 38 |
+
from transformers import AutoModel
|
| 39 |
+
|
| 40 |
+
det = AutoModel.from_pretrained("mlr2000/vocoder-small-watermark-detector", trust_remote_code=True).eval()
|
| 41 |
+
|
| 42 |
+
wav, sr = torchaudio.load("clip.wav") # [C, T]
|
| 43 |
+
res = det.detect(wav, input_sample_rate=sr) # resampled to 24 kHz internally
|
| 44 |
+
print(res)
|
| 45 |
+
# {'matches': 32, 'n_bits': 32, 'p_value': 8.9e-16} # from our model
|
| 46 |
+
```
|
| 47 |
+
|
| 48 |
+
See **`example_roundtrip.ipynb`** in this repo for an end-to-end example
|
| 49 |
+
(generate with the vocoder → detect here).
|
| 50 |
+
|
| 51 |
+
## How detection works
|
| 52 |
+
|
| 53 |
+
`detect()` returns a dict with:
|
| 54 |
+
|
| 55 |
+
- `matches`: how many of the 32 extracted bits equal the fixed code.
|
| 56 |
+
- `n_bits`: 32.
|
| 57 |
+
- `p_value`: the probability an *unrelated* clip matches at least this well under
|
| 58 |
+
`Binomial(32, 0.5)`.
|
| 59 |
+
|
| 60 |
+
A clip generated by our vocoder matches **all 32 bits** (`p_value` ~ 0); an
|
| 61 |
+
unrelated clip matches about half of them (`p_value` ~ 1). There is no built-in
|
| 62 |
+
yes/no threshold — read `matches` / `p_value` and pick whatever operating point
|
| 63 |
+
you need. Requiring all 32 bits gives an astronomically small false-positive
|
| 64 |
+
rate; allowing a few mismatches trades that for robustness to lossy channels.
|
| 65 |
+
|
| 66 |
+
## Notes
|
| 67 |
+
|
| 68 |
+
- Detection runs on **24 kHz mono**. Other inputs are resampled when you pass `input_sample_rate`, and multi-channel audio is downmixed to mono.
|
| 69 |
+
- This detector is paired with `mlr2000/vocoder-small`. It will not correctly verify audio generated by the large vocoder (`mlr2000/vocoder-large`), which uses a different fixed 50-bit watermark.
|
| 70 |
+
- A fixed public watermark is for **attribution** ("did our model make this"), not a secret mark.
|
| 71 |
+
- Not intended for surveillance or any use that violates the privacy of individuals.
|
| 72 |
+
|
| 73 |
+
## Citation
|
| 74 |
+
|
| 75 |
+
If you use this model, please cite:
|
| 76 |
+
|
| 77 |
+
```bibtex
|
| 78 |
+
@misc{muletta2026,
|
| 79 |
+
title = {Training a Discriminator-Free Foundation Vocoder
|
| 80 |
+
with Integrated Audio Watermarking},
|
| 81 |
+
author = {Muletta, Romolo and Deriu, Jan},
|
| 82 |
+
year = {2026},
|
| 83 |
+
note = {VT2 Project Report, ZHAW School of Engineering}
|
| 84 |
+
}
|
| 85 |
+
```
|
| 86 |
+
|
| 87 |
+
## License
|
| 88 |
+
|
| 89 |
+
`cc-by-4.0`. Trained on MLS (CC-BY-4.0) and Common Voice (CC0). Builds
|
| 90 |
+
on BigVGAN (MIT) and wav2vec 2.0 (Apache-2.0). Please retain attribution
|
| 91 |
+
when redistributing or building on this model.
|
__init__.py
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from .configuration_detector import CageDetectorConfig
|
| 2 |
+
from .modeling_detector import CageDetector, DetectionOutput
|
| 3 |
+
|
| 4 |
+
__all__ = ["CageDetectorConfig", "CageDetector", "DetectionOutput"]
|
cage_config.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from transformers import PretrainedConfig
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
class CageExtractorConfig(PretrainedConfig):
|
| 5 |
+
model_type = "cage_extractor"
|
| 6 |
+
|
| 7 |
+
def __init__(
|
| 8 |
+
self,
|
| 9 |
+
watermark_bits: int = 100,
|
| 10 |
+
in_channels: int = 1,
|
| 11 |
+
fine_kernel: int = 3,
|
| 12 |
+
mid_kernel: int = 5,
|
| 13 |
+
coarse_kernel: int = 7,
|
| 14 |
+
sample_rate: int = 24000,
|
| 15 |
+
**kwargs,
|
| 16 |
+
):
|
| 17 |
+
super().__init__(**kwargs)
|
| 18 |
+
self.watermark_bits = watermark_bits
|
| 19 |
+
self.in_channels = in_channels
|
| 20 |
+
self.fine_kernel = fine_kernel
|
| 21 |
+
self.mid_kernel = mid_kernel
|
| 22 |
+
self.coarse_kernel = coarse_kernel
|
| 23 |
+
self.sample_rate = sample_rate
|
cage_extractor.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Coarse-to-Fine Gated Extractor (Cage) from VocBulwark (Appendix B.2).
|
| 3 |
+
|
| 4 |
+
Architecture from paper:
|
| 5 |
+
- Three branches (fine/mid/coarse), each with 4 GSCMs
|
| 6 |
+
- Channel progression: 1 → 32 → 64 → 128 → 128
|
| 7 |
+
- Fine: k=3, s=2, p=1; Mid: k=5, s=2, p=2; Coarse: k=7, s=2, p=3
|
| 8 |
+
- Each GSCM has InstanceNorm + LeakyReLU
|
| 9 |
+
- Each branch output → 1D adaptive average pooling
|
| 10 |
+
- Fusion: GSCM(384→256, k=1, s=1, p=0) → GSCM(256, k=3, s=1, p=1)
|
| 11 |
+
- Dual-path pooling (AAP + AMP) → arithmetic mean
|
| 12 |
+
- Decoder: Linear(256, 512) → Linear(512, watermark_bits)
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
import torch
|
| 16 |
+
import torch.nn as nn
|
| 17 |
+
from transformers import PreTrainedModel
|
| 18 |
+
|
| 19 |
+
from .cage_config import CageExtractorConfig
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class GatedSeparableConvModule(nn.Module):
|
| 23 |
+
"""GSCM: Gated Separable Convolution Module (Sec 4.3 + Appendix B.2).
|
| 24 |
+
|
| 25 |
+
Depthwise separable conv with gating mechanism.
|
| 26 |
+
Uses InstanceNorm and LeakyReLU as per paper.
|
| 27 |
+
Supports stride and different input/output channels.
|
| 28 |
+
"""
|
| 29 |
+
|
| 30 |
+
def __init__(
|
| 31 |
+
self,
|
| 32 |
+
in_channels: int,
|
| 33 |
+
out_channels: int,
|
| 34 |
+
kernel_size: int = 3,
|
| 35 |
+
stride: int = 1,
|
| 36 |
+
padding: int = 1,
|
| 37 |
+
):
|
| 38 |
+
super().__init__()
|
| 39 |
+
|
| 40 |
+
# Content branch (DSC): depthwise → pointwise → InstanceNorm → LeakyReLU
|
| 41 |
+
self.content = nn.Sequential(
|
| 42 |
+
nn.Conv1d(in_channels, in_channels, kernel_size,
|
| 43 |
+
stride=stride, padding=padding, groups=in_channels),
|
| 44 |
+
nn.Conv1d(in_channels, out_channels, 1),
|
| 45 |
+
nn.InstanceNorm1d(out_channels),
|
| 46 |
+
nn.LeakyReLU(0.2),
|
| 47 |
+
)
|
| 48 |
+
|
| 49 |
+
# Gating branch: same structure but Sigmoid output
|
| 50 |
+
self.gate = nn.Sequential(
|
| 51 |
+
nn.Conv1d(in_channels, in_channels, kernel_size,
|
| 52 |
+
stride=stride, padding=padding, groups=in_channels),
|
| 53 |
+
nn.Conv1d(in_channels, out_channels, 1),
|
| 54 |
+
nn.InstanceNorm1d(out_channels),
|
| 55 |
+
nn.Sigmoid(),
|
| 56 |
+
)
|
| 57 |
+
|
| 58 |
+
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
| 59 |
+
return self.content(x) * self.gate(x)
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def _make_branch(kernel_size: int, stride: int, padding: int):
|
| 63 |
+
"""Build a 4-GSCM branch with channel progression 1 → 32 → 64 → 128 → 128."""
|
| 64 |
+
return nn.Sequential(
|
| 65 |
+
GatedSeparableConvModule(1, 32, kernel_size, stride, padding),
|
| 66 |
+
GatedSeparableConvModule(32, 64, kernel_size, stride, padding),
|
| 67 |
+
GatedSeparableConvModule(64, 128, kernel_size, stride, padding),
|
| 68 |
+
GatedSeparableConvModule(128, 128, kernel_size, stride, padding),
|
| 69 |
+
)
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
class CageExtractor(PreTrainedModel):
|
| 73 |
+
config_class = CageExtractorConfig
|
| 74 |
+
|
| 75 |
+
def __init__(self, config: CageExtractorConfig):
|
| 76 |
+
super().__init__(config)
|
| 77 |
+
|
| 78 |
+
# Three branches processing raw audio directly (paper B.2)
|
| 79 |
+
# Fine-grained: k=3, s=2, p=1
|
| 80 |
+
self.fine_branch = _make_branch(
|
| 81 |
+
kernel_size=config.fine_kernel, stride=2, padding=1)
|
| 82 |
+
# Mid-grained: k=5, s=2, p=2
|
| 83 |
+
self.mid_branch = _make_branch(
|
| 84 |
+
kernel_size=config.mid_kernel, stride=2, padding=2)
|
| 85 |
+
# Coarse-grained: k=7, s=2, p=3
|
| 86 |
+
self.coarse_branch = _make_branch(
|
| 87 |
+
kernel_size=config.coarse_kernel, stride=2, padding=3)
|
| 88 |
+
|
| 89 |
+
# Fusion: concat 3 branches (3*128=384) → reduce to 256 → refine
|
| 90 |
+
# GSCM (k=1, s=1, p=0) to reduce channel dimension to 256
|
| 91 |
+
self.fusion_reduce = GatedSeparableConvModule(
|
| 92 |
+
384, 256, kernel_size=1, stride=1, padding=0)
|
| 93 |
+
# GSCM (k=3, s=1, p=1) that maintains the dimensions
|
| 94 |
+
self.fusion_refine = GatedSeparableConvModule(
|
| 95 |
+
256, 256, kernel_size=3, stride=1, padding=1)
|
| 96 |
+
|
| 97 |
+
# Decoder: two FC layers (paper B.2)
|
| 98 |
+
self.decoder = nn.Sequential(
|
| 99 |
+
nn.Linear(256, 512),
|
| 100 |
+
nn.LeakyReLU(0.2),
|
| 101 |
+
nn.Linear(512, config.watermark_bits),
|
| 102 |
+
)
|
| 103 |
+
|
| 104 |
+
def forward(self, audio: torch.Tensor) -> torch.Tensor:
|
| 105 |
+
"""
|
| 106 |
+
Args:
|
| 107 |
+
audio: [B, 1, T] waveform (or [B, T] which gets unsqueezed)
|
| 108 |
+
Returns:
|
| 109 |
+
logits: [B, watermark_bits] raw logits
|
| 110 |
+
"""
|
| 111 |
+
if audio.dim() == 2:
|
| 112 |
+
audio = audio.unsqueeze(1)
|
| 113 |
+
|
| 114 |
+
# Multi-scale branches (each processes raw audio)
|
| 115 |
+
x_fine = self.fine_branch(audio) # [B, 128, T']
|
| 116 |
+
x_mid = self.mid_branch(audio) # [B, 128, T'']
|
| 117 |
+
x_coarse = self.coarse_branch(audio) # [B, 128, T''']
|
| 118 |
+
|
| 119 |
+
# 1D adaptive average pooling to standardize temporal dimension
|
| 120 |
+
pool_t = min(x_fine.shape[-1], x_mid.shape[-1], x_coarse.shape[-1])
|
| 121 |
+
x_fine = nn.functional.adaptive_avg_pool1d(x_fine, pool_t)
|
| 122 |
+
x_mid = nn.functional.adaptive_avg_pool1d(x_mid, pool_t)
|
| 123 |
+
x_coarse = nn.functional.adaptive_avg_pool1d(x_coarse, pool_t)
|
| 124 |
+
|
| 125 |
+
# Fusion
|
| 126 |
+
x_fuse = torch.cat([x_fine, x_mid, x_coarse], dim=1) # [B, 384, pool_t]
|
| 127 |
+
x_fuse = self.fusion_reduce(x_fuse) # [B, 256, pool_t]
|
| 128 |
+
x_fuse = self.fusion_refine(x_fuse) # [B, 256, pool_t]
|
| 129 |
+
|
| 130 |
+
# Dual-path Pooling: AAP + AMP → arithmetic mean
|
| 131 |
+
x_aap = nn.functional.adaptive_avg_pool1d(x_fuse, 1).squeeze(-1) # [B, 256]
|
| 132 |
+
x_amp = nn.functional.adaptive_max_pool1d(x_fuse, 1).squeeze(-1) # [B, 256]
|
| 133 |
+
x_pool = (x_aap + x_amp) / 2
|
| 134 |
+
|
| 135 |
+
# Decode
|
| 136 |
+
logits = self.decoder(x_pool) # [B, watermark_bits]
|
| 137 |
+
|
| 138 |
+
return logits
|
config.json
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"model_type": "cage_detector",
|
| 3 |
+
"architectures": [
|
| 4 |
+
"CageDetector"
|
| 5 |
+
],
|
| 6 |
+
"auto_map": {
|
| 7 |
+
"AutoConfig": "configuration_detector.CageDetectorConfig",
|
| 8 |
+
"AutoModel": "modeling_detector.CageDetector"
|
| 9 |
+
},
|
| 10 |
+
"watermark_bits": 32,
|
| 11 |
+
"in_channels": 1,
|
| 12 |
+
"fine_kernel": 3,
|
| 13 |
+
"mid_kernel": 5,
|
| 14 |
+
"coarse_kernel": 7,
|
| 15 |
+
"sample_rate": 24000,
|
| 16 |
+
"fixed_watermark": [
|
| 17 |
+
1,
|
| 18 |
+
0,
|
| 19 |
+
1,
|
| 20 |
+
1,
|
| 21 |
+
0,
|
| 22 |
+
0,
|
| 23 |
+
1,
|
| 24 |
+
0,
|
| 25 |
+
1,
|
| 26 |
+
1,
|
| 27 |
+
0,
|
| 28 |
+
1,
|
| 29 |
+
0,
|
| 30 |
+
0,
|
| 31 |
+
1,
|
| 32 |
+
1,
|
| 33 |
+
0,
|
| 34 |
+
1,
|
| 35 |
+
1,
|
| 36 |
+
0,
|
| 37 |
+
1,
|
| 38 |
+
0,
|
| 39 |
+
0,
|
| 40 |
+
1,
|
| 41 |
+
1,
|
| 42 |
+
1,
|
| 43 |
+
0,
|
| 44 |
+
0,
|
| 45 |
+
1,
|
| 46 |
+
0,
|
| 47 |
+
1,
|
| 48 |
+
1
|
| 49 |
+
]
|
| 50 |
+
}
|
configuration_detector.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Config for the standalone Cage watermark detector."""
|
| 2 |
+
from .cage_config import CageExtractorConfig
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
class CageDetectorConfig(CageExtractorConfig):
|
| 6 |
+
model_type = "cage_detector"
|
| 7 |
+
|
| 8 |
+
def __init__(self, fixed_watermark=None, **kwargs):
|
| 9 |
+
super().__init__(**kwargs)
|
| 10 |
+
# The fixed signature this detector checks extracted bits against.
|
| 11 |
+
self.fixed_watermark = fixed_watermark
|
example_roundtrip.ipynb
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"cells": [
|
| 3 |
+
{
|
| 4 |
+
"cell_type": "markdown",
|
| 5 |
+
"metadata": {},
|
| 6 |
+
"source": [
|
| 7 |
+
"# VocBulwark watermark round-trip\n",
|
| 8 |
+
"\n",
|
| 9 |
+
"The full pipeline, in three self-contained models loaded from the Hub:\n",
|
| 10 |
+
"\n",
|
| 11 |
+
"1. **Speaker encoder** — a reference clip → a fixed-size speaker embedding (*whose* voice).\n",
|
| 12 |
+
"2. **Vocoder** — a mel spectrogram (*what* is said) + that embedding → a 24 kHz waveform, with the fixed provenance watermark embedded automatically.\n",
|
| 13 |
+
"3. **Detector** — the waveform → how many watermark bits match.\n",
|
| 14 |
+
"\n",
|
| 15 |
+
"In a real TTS system you usually already have the mel (from an acoustic model) and the speaker embedding (precomputed once per speaker), so step 1 is only needed to *make* an embedding from audio. Here we reconstruct one clip (use it as both content and speaker reference) to exercise the watermark.\n",
|
| 16 |
+
"\n",
|
| 17 |
+
"Needs only `torch`, `transformers`, `soundfile`, `torchaudio`. Put one or more `.wav` files in an `example_audio/` folder next to this notebook. If the repos are private, run `huggingface-cli login` first."
|
| 18 |
+
]
|
| 19 |
+
},
|
| 20 |
+
{
|
| 21 |
+
"cell_type": "code",
|
| 22 |
+
"execution_count": null,
|
| 23 |
+
"metadata": {},
|
| 24 |
+
"outputs": [],
|
| 25 |
+
"source": [
|
| 26 |
+
"from pathlib import Path\n",
|
| 27 |
+
"\n",
|
| 28 |
+
"SPK_REPO = \"mlr2000/vocoder-small-speaker-encoder\" # <- speaker encoder\n",
|
| 29 |
+
"GEN_REPO = \"mlr2000/vocoder-small\" # <- vocoder (paired with SPK_REPO)\n",
|
| 30 |
+
"DET_REPO = \"mlr2000/vocoder-small-watermark-detector\" # <- matching detector\n",
|
| 31 |
+
"TGT_SR = 24000 # vocoder output SR\n",
|
| 32 |
+
"MAX_SAMPLES = 16 * TGT_SR\n",
|
| 33 |
+
"\n",
|
| 34 |
+
"AUDIO_DIR = next(p for p in [Path(\"example_audio\"), Path(\"../example_audio\")] if p.exists())"
|
| 35 |
+
]
|
| 36 |
+
},
|
| 37 |
+
{
|
| 38 |
+
"cell_type": "code",
|
| 39 |
+
"execution_count": null,
|
| 40 |
+
"metadata": {},
|
| 41 |
+
"outputs": [],
|
| 42 |
+
"source": [
|
| 43 |
+
"import torch\n",
|
| 44 |
+
"from transformers import AutoModel\n",
|
| 45 |
+
"\n",
|
| 46 |
+
"enc = AutoModel.from_pretrained(SPK_REPO, trust_remote_code=True).eval()\n",
|
| 47 |
+
"gen = AutoModel.from_pretrained(GEN_REPO, trust_remote_code=True).eval()\n",
|
| 48 |
+
"det = AutoModel.from_pretrained(DET_REPO, trust_remote_code=True).eval()\n",
|
| 49 |
+
"RAW_SR = enc.config.raw_sample_rate # SR the speaker encoder expects\n",
|
| 50 |
+
"print(f\"embedding dim: {enc.config.embedding_size} | watermark: {len(gen.config.fixed_watermark)} bits\")"
|
| 51 |
+
]
|
| 52 |
+
},
|
| 53 |
+
{
|
| 54 |
+
"cell_type": "code",
|
| 55 |
+
"execution_count": null,
|
| 56 |
+
"metadata": {},
|
| 57 |
+
"outputs": [],
|
| 58 |
+
"source": [
|
| 59 |
+
"import soundfile as sf\n",
|
| 60 |
+
"import torchaudio.functional as AF\n",
|
| 61 |
+
"from transformers import WhisperFeatureExtractor\n",
|
| 62 |
+
"\n",
|
| 63 |
+
"# Mel front-end — must match the trained vocoder (n_fft=1024, hop=256, mel channels from config).\n",
|
| 64 |
+
"N_MELS = gen.config.hifigan_in_channels\n",
|
| 65 |
+
"mel_fe = WhisperFeatureExtractor(sampling_rate=TGT_SR, n_fft=1024, feature_size=N_MELS, hop_length=256)\n",
|
| 66 |
+
"mel_fe.n_samples = MAX_SAMPLES\n",
|
| 67 |
+
"mel_fe.chunk_length = MAX_SAMPLES / TGT_SR\n",
|
| 68 |
+
"\n",
|
| 69 |
+
"def load_inputs(path):\n",
|
| 70 |
+
" \"\"\"A clip -> (mel = content, raw = speaker reference @ RAW_SR).\"\"\"\n",
|
| 71 |
+
" w, sr = sf.read(str(path))\n",
|
| 72 |
+
" w = torch.tensor(w, dtype=torch.float32)\n",
|
| 73 |
+
" if w.dim() == 2:\n",
|
| 74 |
+
" w = w.mean(1) # mono\n",
|
| 75 |
+
" raw = AF.resample(w, sr, RAW_SR) if sr != RAW_SR else w # speaker ref @ RAW_SR\n",
|
| 76 |
+
" tgt = AF.resample(raw, RAW_SR, TGT_SR) # content @ 24 kHz\n",
|
| 77 |
+
" mel = mel_fe(tgt.numpy(), sampling_rate=TGT_SR, padding=\"longest\",\n",
|
| 78 |
+
" return_tensors=\"pt\")[\"input_features\"]\n",
|
| 79 |
+
" return mel, raw.unsqueeze(0)"
|
| 80 |
+
]
|
| 81 |
+
},
|
| 82 |
+
{
|
| 83 |
+
"cell_type": "code",
|
| 84 |
+
"execution_count": null,
|
| 85 |
+
"metadata": {},
|
| 86 |
+
"outputs": [],
|
| 87 |
+
"source": [
|
| 88 |
+
"from IPython.display import Audio, display\n",
|
| 89 |
+
"\n",
|
| 90 |
+
"for path in sorted(AUDIO_DIR.glob(\"*.wav\")):\n",
|
| 91 |
+
" mel, raw = load_inputs(path)\n",
|
| 92 |
+
" with torch.no_grad():\n",
|
| 93 |
+
" emb = enc.embed(raw) # [1, embedding_size]\n",
|
| 94 |
+
" # The vocoder embeds its fixed watermark automatically.\n",
|
| 95 |
+
" audio = gen(mel_spectrogram=mel, speaker_embedding=emb).audio.squeeze(1)\n",
|
| 96 |
+
" r = det.detect(audio)\n",
|
| 97 |
+
" print(f\"{path.name}: {r['matches']}/{r['n_bits']} bits matched p={r['p_value']:.1e}\")\n",
|
| 98 |
+
" display(Audio(audio[0].numpy(), rate=TGT_SR))"
|
| 99 |
+
]
|
| 100 |
+
},
|
| 101 |
+
{
|
| 102 |
+
"cell_type": "markdown",
|
| 103 |
+
"metadata": {},
|
| 104 |
+
"source": [
|
| 105 |
+
"Sanity check: an **unrelated** clip (here, random noise) is not from our model, so it matches only about half the bits with a large `p_value`."
|
| 106 |
+
]
|
| 107 |
+
},
|
| 108 |
+
{
|
| 109 |
+
"cell_type": "code",
|
| 110 |
+
"execution_count": null,
|
| 111 |
+
"metadata": {},
|
| 112 |
+
"outputs": [],
|
| 113 |
+
"source": [
|
| 114 |
+
"print(\"random noise:\", det.detect(torch.randn(1, TGT_SR)))"
|
| 115 |
+
]
|
| 116 |
+
}
|
| 117 |
+
],
|
| 118 |
+
"metadata": {
|
| 119 |
+
"language_info": {
|
| 120 |
+
"name": "python"
|
| 121 |
+
}
|
| 122 |
+
},
|
| 123 |
+
"nbformat": 4,
|
| 124 |
+
"nbformat_minor": 5
|
| 125 |
+
}
|
model.safetensors
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:a1e9d11e749c65af36893011c5871409ffd5f74e3a42ef72c7387a3daac0bfb9
|
| 3 |
+
size 2614304
|
modeling_detector.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Standalone VocBulwark Cage watermark detector.
|
| 2 |
+
|
| 3 |
+
Loadable with `AutoModel.from_pretrained(path, trust_remote_code=True)` without
|
| 4 |
+
the training repository. Wraps the Cage extractor and compares the extracted
|
| 5 |
+
bits to a fixed provenance watermark baked into the config.
|
| 6 |
+
"""
|
| 7 |
+
from dataclasses import dataclass
|
| 8 |
+
from math import comb
|
| 9 |
+
|
| 10 |
+
import torch
|
| 11 |
+
from transformers import PreTrainedModel
|
| 12 |
+
from transformers.modeling_outputs import ModelOutput
|
| 13 |
+
|
| 14 |
+
from .configuration_detector import CageDetectorConfig
|
| 15 |
+
from .cage_config import CageExtractorConfig
|
| 16 |
+
from .cage_extractor import CageExtractor
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
@dataclass
|
| 20 |
+
class DetectionOutput(ModelOutput):
|
| 21 |
+
bits: torch.Tensor = None
|
| 22 |
+
logits: torch.Tensor = None
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
class CageDetector(PreTrainedModel):
|
| 26 |
+
config_class = CageDetectorConfig
|
| 27 |
+
|
| 28 |
+
def __init__(self, config):
|
| 29 |
+
super().__init__(config)
|
| 30 |
+
ext_cfg = CageExtractorConfig(
|
| 31 |
+
watermark_bits=config.watermark_bits,
|
| 32 |
+
in_channels=config.in_channels,
|
| 33 |
+
fine_kernel=config.fine_kernel,
|
| 34 |
+
mid_kernel=config.mid_kernel,
|
| 35 |
+
coarse_kernel=config.coarse_kernel,
|
| 36 |
+
sample_rate=config.sample_rate,
|
| 37 |
+
)
|
| 38 |
+
self.cage_extractor = CageExtractor(ext_cfg)
|
| 39 |
+
|
| 40 |
+
def _prep(self, audio, input_sample_rate):
|
| 41 |
+
"""Coerce input to [B, T] mono at the detector sample rate."""
|
| 42 |
+
if not torch.is_tensor(audio):
|
| 43 |
+
audio = torch.as_tensor(audio)
|
| 44 |
+
p = next(self.parameters())
|
| 45 |
+
audio = audio.to(device=p.device, dtype=p.dtype)
|
| 46 |
+
if audio.dim() == 1: # [T] -> [1, T]
|
| 47 |
+
audio = audio.unsqueeze(0)
|
| 48 |
+
elif audio.dim() == 3: # [B, C, T] -> [B, T]
|
| 49 |
+
audio = audio.mean(dim=1) if audio.shape[1] > 1 else audio.squeeze(1)
|
| 50 |
+
if input_sample_rate is not None and input_sample_rate != self.config.sample_rate:
|
| 51 |
+
import torchaudio
|
| 52 |
+
audio = torchaudio.functional.resample(
|
| 53 |
+
audio, input_sample_rate, self.config.sample_rate)
|
| 54 |
+
return audio
|
| 55 |
+
|
| 56 |
+
@staticmethod
|
| 57 |
+
def _p_value(matches, n):
|
| 58 |
+
"""P(Binomial(n, 0.5) >= matches): chance an unrelated clip matches this well."""
|
| 59 |
+
return sum(comb(n, i) for i in range(int(matches), n + 1)) / (2 ** n)
|
| 60 |
+
|
| 61 |
+
@torch.no_grad()
|
| 62 |
+
def extract(self, audio, input_sample_rate=None):
|
| 63 |
+
"""Return (bits, logits). bits: [B, n] in {0,1}; logits: [B, n]."""
|
| 64 |
+
audio = self._prep(audio, input_sample_rate)
|
| 65 |
+
logits = self.cage_extractor(audio)
|
| 66 |
+
bits = (logits > 0).to(torch.int64)
|
| 67 |
+
return bits, logits
|
| 68 |
+
|
| 69 |
+
@torch.no_grad()
|
| 70 |
+
def detect(self, audio, input_sample_rate=None):
|
| 71 |
+
"""Detect the fixed watermark. Returns a dict (or list of dicts for B>1):
|
| 72 |
+
{matches, n_bits, p_value}. `matches` is how many of the model's fixed
|
| 73 |
+
bits the clip carries; `p_value` is the chance an unrelated clip matches
|
| 74 |
+
at least this well under Binomial(n_bits, 0.5). A clip from our model
|
| 75 |
+
matches all n_bits (p_value ~ 0); an unrelated clip matches about half."""
|
| 76 |
+
bits, _ = self.extract(audio, input_sample_rate)
|
| 77 |
+
wstar = torch.tensor(self.config.fixed_watermark,
|
| 78 |
+
device=bits.device, dtype=bits.dtype)
|
| 79 |
+
matches = (bits == wstar).sum(dim=-1).tolist()
|
| 80 |
+
n = int(self.config.watermark_bits)
|
| 81 |
+
results = [{
|
| 82 |
+
"matches": int(m),
|
| 83 |
+
"n_bits": n,
|
| 84 |
+
"p_value": self._p_value(m, n),
|
| 85 |
+
} for m in matches]
|
| 86 |
+
return results[0] if len(results) == 1 else results
|
| 87 |
+
|
| 88 |
+
@torch.no_grad()
|
| 89 |
+
def forward(self, audio=None, input_sample_rate=None, **kwargs):
|
| 90 |
+
bits, logits = self.extract(audio, input_sample_rate)
|
| 91 |
+
return DetectionOutput(bits=bits, logits=logits)
|