Download _net.py from SpeechAntiSpoofingBenchmarks/LaST-Net: direct link, hf CLI and curl.
- Browser
- Download file 12.9 kB
-
https://huggingface.co/SpeechAntiSpoofingBenchmarks/LaST-Net/resolve/main/_net.py
- Command line
-
hf download hf://SpeechAntiSpoofingBenchmarks/LaST-Net/_net.py
-
curl -L -o _net.py https://huggingface.co/SpeechAntiSpoofingBenchmarks/LaST-Net/resolve/main/_net.py
12.9 kB
| """LaST-Net (Length-Aware Layer and Scale-Adaptive Temporal Network), inference only. | |
| Vendored from github.com/JunXue-tech/LaST-Net @ 3838f0c7108313f771f6c1e208ac8f1e3bde60b0 (MIT, Copyright (c) 2026 | |
| LaST-Net authors; see LICENSE.source): | |
| model.py duration_features, LengthCalibratedLayerSelector, TemporalResidualBlock, | |
| LengthSelectiveKernelBlock, LengthPriorResidualExchange, AttentiveStatisticsPooling, LaSTNet | |
| load_dataset.py crop_or_repeat (prefix crop / np.tile repeat), `available` seconds = min(len, samples) / 16000 | |
| infer.py/eval.py score = log_softmax(logits)[:, 1] ("bonafide_log_score"; label bonafide = 1) | |
| Edits for the Arena wrapper (math unchanged): | |
| 1. SSLModel builds the installed fairseq 0.12.2 Wav2Vec2Model from the vendored model cfg of xlsr2_300m.pt | |
| (xlsr2_300m_model_cfg.json, merged onto Wav2Vec2Config defaults as in W2V2-AASIST/_net.py) instead of | |
| load_model_ensemble_and_task([xlsr2_300m.pt]); the fine-tuned checkpoint overwrites every tensor (strict load). | |
| extract_feat is verbatim: model(wav, mask=False, features_only=True, layer=23)["layer_results"]. | |
| 2. LaSTNet.__init__ takes no ssl_path; forward returns the (B, 2) logits only (return_aux dropped). | |
| 3. crop_np = crop_or_repeat (plus an empty-clip -> silence guard, which the source rejects with ValueError). | |
| 4. nn.init.zeros_ calls dropped (every tensor is overwritten by the strict load); code reflowed. | |
| Everything else is copied verbatim (the training-only layer-dropout branch is inert in eval()). | |
| """ | |
| import json | |
| import math | |
| import os | |
| import numpy as np | |
| import torch | |
| from torch import nn | |
| from torch.nn import functional as F | |
| _DIR = os.path.dirname(os.path.abspath(__file__)) | |
| XLSR_CFG = os.path.join(_DIR, "xlsr2_300m_model_cfg.json") | |
| SAMPLE_RATE = 16000 | |
| SECONDS = 4 # infer.py --seconds 4 (Arena 4 s cap) | |
| WINDOW = SECONDS * SAMPLE_RATE # 64000: infer.py/eval.py use seconds * 16000 | |
| def crop_np(wav, samples=WINDOW): | |
| """load_dataset.crop_or_repeat + the `available` seconds that eval.py/infer.py pass to the model.""" | |
| x = np.asarray(wav, dtype=np.float32).reshape(-1) | |
| if x.shape[0] == 0: # (not in source: it raises) empty clip -> silence, available = 0 (clamped to 1 ms) | |
| return np.zeros(samples, dtype=np.float32), 0.0 | |
| available = min(len(x), samples) / SAMPLE_RATE | |
| if len(x) < samples: | |
| x = np.tile(x, math.ceil(samples / len(x))) | |
| return np.ascontiguousarray(x[:samples], dtype=np.float32), available | |
| def build_xlsr(): | |
| from fairseq.dataclass.utils import merge_with_parent | |
| from fairseq.models.wav2vec.wav2vec2 import Wav2Vec2Config, Wav2Vec2Model | |
| from omegaconf import OmegaConf | |
| cfg = OmegaConf.create(json.load(open(XLSR_CFG))) | |
| return Wav2Vec2Model.build_model(merge_with_parent(Wav2Vec2Config(), cfg, remove_missing=True), task=None) | |
| class SSLModel(nn.Module): | |
| """XLS-R frontend returning all 24 transformer-layer representations.""" | |
| def __init__(self): | |
| super().__init__() | |
| self.model = build_xlsr() # edit 1 | |
| def extract_feat(self, wav): | |
| output = self.model(wav, mask=False, features_only=True, layer=23) | |
| return output["layer_results"] | |
| def duration_features(wav, available_seconds=None): | |
| requested = wav.new_full((len(wav),), wav.shape[-1] / 16000.0) | |
| available = requested if available_seconds is None else available_seconds.to(wav) | |
| available = available.clamp(0.001, 6.0) | |
| return torch.stack( | |
| ( | |
| requested.clamp_max(6.0) / 6.0, | |
| torch.log1p(requested.clamp_max(6.0)) / torch.log(requested.new_tensor(7.0)), | |
| available / requested.clamp_min(0.001), | |
| ), | |
| dim=1, | |
| ) | |
| class LengthCalibratedLayerSelector(nn.Module): | |
| """Fuse the 24 XLS-R layers using length and content-conditioned weights.""" | |
| def __init__(self, dimension=1024, temperature=0.8, uniform_floor=0.05, layer_dropout=0.10): | |
| super().__init__() | |
| self.temperature = temperature | |
| self.uniform_floor = uniform_floor | |
| self.layer_dropout = layer_dropout | |
| self.content_scorer = nn.Sequential( | |
| nn.LayerNorm(2 * dimension), nn.Linear(2 * dimension, 128), nn.GELU(), nn.Linear(128, 1), | |
| ) | |
| self.length_scorer = nn.Sequential(nn.Linear(3, 64), nn.GELU(), nn.Linear(64, 24)) | |
| self.layer_bias = nn.Parameter(torch.zeros(24)) | |
| def forward(self, layers, lengths): | |
| if len(layers) != 24: | |
| raise RuntimeError("LaST-Net expects all 24 XLS-R layers") | |
| sequences = [layer[0].transpose(0, 1) for layer in layers] | |
| descriptors = [] | |
| for sequence in sequences: | |
| mean = sequence.mean(dim=1) | |
| std = (sequence - mean.unsqueeze(1)).square().mean(dim=1).sqrt() | |
| descriptors.append(torch.cat((mean, std), dim=-1)) | |
| descriptors = torch.stack(descriptors, dim=1) | |
| content_scores = self.content_scorer(descriptors.float()).squeeze(-1) | |
| length_scores = self.length_scorer(lengths.float()) | |
| scores = (self.layer_bias.unsqueeze(0) + content_scores + length_scores) / self.temperature | |
| if self.training and self.layer_dropout > 0.0: | |
| if len(scores) % 2 == 0: | |
| mask = torch.rand(len(scores) // 2, 24, device=scores.device) < self.layer_dropout | |
| mask = mask.repeat(2, 1) | |
| else: | |
| mask = torch.rand_like(scores) < self.layer_dropout | |
| scores = scores.masked_fill(mask, -1e4) | |
| conditional = F.softmax(scores, dim=1) | |
| weights = (1.0 - self.uniform_floor) * conditional + self.uniform_floor / 24.0 | |
| fused = sum(sequence * weights[:, index, None, None].to(sequence) for index, sequence in enumerate(sequences)) | |
| return fused, weights | |
| class TemporalResidualBlock(nn.Module): | |
| def __init__(self, dimension, dilation): | |
| super().__init__() | |
| self.norm = nn.LayerNorm(dimension) | |
| self.depthwise = nn.Conv1d(dimension, dimension, 5, padding=2 * dilation, dilation=dilation, | |
| groups=dimension, bias=False) | |
| self.pointwise = nn.Conv1d(dimension, 2 * dimension, 1) | |
| self.output = nn.Linear(dimension, dimension) | |
| self.scale = nn.Parameter(torch.full((dimension,), 0.1)) | |
| def forward(self, x): | |
| y = self.depthwise(self.norm(x).transpose(1, 2)) | |
| value, gate = self.pointwise(y).chunk(2, dim=1) | |
| update = (value * torch.sigmoid(gate)).transpose(1, 2) | |
| return x + self.scale * self.output(update) | |
| class LengthSelectiveKernelBlock(nn.Module): | |
| """Length-guided local temporal filtering at three receptive fields.""" | |
| def __init__(self, dimension): | |
| super().__init__() | |
| self.norm = nn.LayerNorm(dimension) | |
| self.convs = nn.ModuleList([ | |
| nn.Conv1d(dimension, dimension, 5, padding=2 * dilation, dilation=dilation, groups=dimension, bias=False) | |
| for dilation in (1, 2, 4) | |
| ]) | |
| self.content_gate = nn.Linear(dimension, 2) | |
| self.pointwise = nn.Conv1d(dimension, 2 * dimension, 1) | |
| self.output = nn.Linear(dimension, dimension) | |
| self.scale = nn.Parameter(torch.full((dimension,), 0.1)) | |
| def forward(self, x, lengths): | |
| normalized = self.norm(x) | |
| channels = normalized.transpose(1, 2) | |
| features = [conv(channels) for conv in self.convs] | |
| duration = lengths[:, :1].float() | |
| residual = 0.1 * torch.tanh(self.content_gate(normalized.mean(dim=1).float())) | |
| gate2 = torch.sigmoid((duration - 0.35) / 0.15 + residual[:, :1]).to(x) | |
| gate4 = torch.sigmoid((duration - 0.65) / 0.15 + residual[:, 1:]).to(x) | |
| mixed = ( | |
| features[0] + gate2.unsqueeze(-1) * features[1] + gate4.unsqueeze(-1) * features[2] | |
| ) / (1.0 + gate2 + gate4).unsqueeze(-1) | |
| value, gate = self.pointwise(mixed).chunk(2, dim=1) | |
| update = (value * torch.sigmoid(gate)).transpose(1, 2) | |
| return x + self.scale * self.output(update) | |
| class LengthPriorResidualExchange(nn.Module): | |
| """Process three temporal scales and exchange length-relevant evidence.""" | |
| def __init__(self, dimension, selective_local=False): | |
| super().__init__() | |
| self.selective_local = selective_local | |
| self.blocks = nn.ModuleList([ | |
| LengthSelectiveKernelBlock(dimension) if selective_local else TemporalResidualBlock(dimension, 1), | |
| TemporalResidualBlock(dimension, 2), | |
| TemporalResidualBlock(dimension, 4), | |
| ]) | |
| self.edge_bias = nn.Parameter(torch.zeros(6)) | |
| self.content_gate = nn.Linear(dimension, 6) | |
| self.fusion_scale = nn.Parameter(torch.full((3, dimension), 0.1)) | |
| def resize(x, size): | |
| if x.shape[1] == size: | |
| return x | |
| return F.interpolate(x.transpose(1, 2), size=size, mode="linear", align_corners=False).transpose(1, 2) | |
| def forward(self, scales, lengths): | |
| processed = [] | |
| for index, (block, scale) in enumerate(zip(self.blocks, scales)): | |
| if index == 0 and self.selective_local: | |
| processed.append(block(scale, lengths)) | |
| else: | |
| processed.append(block(scale)) | |
| duration = lengths[:, 0].clamp(0.0, 1.0) | |
| source_prior = torch.stack((1.0 - duration, 4.0 * duration * (1.0 - duration), duration), dim=1) | |
| content = sum(x.mean(dim=1) for x in processed) / 3.0 | |
| content_residual = 0.1 * torch.tanh(self.content_gate(content.float())) | |
| outputs = [] | |
| all_weights = [] | |
| edge_index = 0 | |
| for target in range(3): | |
| sources = [source for source in range(3) if source != target] | |
| logits = [] | |
| for local_index, source in enumerate(sources): | |
| index = edge_index + local_index | |
| logits.append(self.edge_bias[index] + 2.0 * (source_prior[:, source] - 0.5) | |
| + content_residual[:, index]) | |
| positive = F.softplus(torch.stack(logits, dim=1)).to(processed[target]) | |
| weights = positive / positive.sum(dim=1, keepdim=True).clamp_min(1e-6) | |
| incoming = sum( | |
| weights[:, index, None, None] * self.resize(processed[source], processed[target].shape[1]) | |
| for index, source in enumerate(sources) | |
| ) | |
| outputs.append(processed[target] + self.fusion_scale[target] * incoming) | |
| all_weights.append(weights) | |
| edge_index += 2 | |
| return outputs, torch.cat(all_weights, dim=1) | |
| class AttentiveStatisticsPooling(nn.Module): | |
| def __init__(self, dimension=192): | |
| super().__init__() | |
| self.attention = nn.Sequential(nn.Linear(dimension, 128), nn.Tanh(), nn.Linear(128, 1)) | |
| def forward(self, x): | |
| weights = F.softmax(self.attention(x).float(), dim=1).to(x) | |
| mean = (weights * x).sum(dim=1) | |
| second = (weights * x.square()).sum(dim=1) | |
| std = (second - mean.square()).clamp_min(1e-5).sqrt() | |
| return torch.cat((mean, std), dim=-1) | |
| class LaSTNet(nn.Module): | |
| architecture = "LaST-Net" | |
| # checkpoint args: router_temperature 0.8, uniform_floor 0.05, layer_dropout 0.1 (eval.py load_model reads these) | |
| def __init__(self, temperature=0.8, uniform_floor=0.05, layer_dropout=0.10, dimension=192): | |
| super().__init__() | |
| self.ssl_model = SSLModel() | |
| self.router = LengthCalibratedLayerSelector(temperature=temperature, uniform_floor=uniform_floor, | |
| layer_dropout=layer_dropout) | |
| self.input_projection = nn.Sequential(nn.Linear(1024, dimension), nn.LayerNorm(dimension)) | |
| self.exchanges = nn.ModuleList([ | |
| LengthPriorResidualExchange(dimension, selective_local=True), | |
| LengthPriorResidualExchange(dimension, selective_local=False), | |
| ]) | |
| self.pools = nn.ModuleList([AttentiveStatisticsPooling(dimension) for _ in range(3)]) | |
| self.classifier = nn.Sequential( | |
| nn.LayerNorm(6 * dimension), nn.Linear(6 * dimension, 256), nn.GELU(), nn.Dropout(0.2), nn.Linear(256, 2), | |
| ) | |
| def forward(self, wav, available_seconds=None): # (B, T) 16 kHz, (B,) seconds -> logits (B, 2); class 1 = bona fide | |
| layers = self.ssl_model.extract_feat(wav) | |
| lengths = duration_features(wav, available_seconds) | |
| fused, _ = self.router(layers, lengths) | |
| x0 = self.input_projection(fused) | |
| scales = [ | |
| x0, | |
| F.avg_pool1d(x0.transpose(1, 2), 2, 2).transpose(1, 2), | |
| F.avg_pool1d(x0.transpose(1, 2), 4, 4).transpose(1, 2), | |
| ] | |
| for exchange in self.exchanges: | |
| scales, _ = exchange(scales, lengths) | |
| embedding = torch.cat([pool(scale) for pool, scale in zip(self.pools, scales)], dim=-1) | |
| return self.classifier(embedding) | |