Sih / src /features.py
CypherNotHere's picture
Upload src/features.py
37f519e verified
Raw History Blame Contribute Delete
8.42 kB
"""Hybrid Feature Extraction Module for ASVspoof 5 Telephony Audio.
Implements:
1. Branch A (Physics-Based Vocal Tract & Glottal Features, 0-4 kHz):
- Linear Prediction Analysis (LPC) via Levinson-Durbin recursion.
- Linear Prediction Cepstral Coefficients (LPCC) capturing the all-pole vocal tract transfer function.
- Glottal inverse filtering residual energy and excitation constraints.
2. Branch B (Spectrogram / Filterbank Features, 0-4 kHz):
- Log-Mel Spectrogram and Linear Frequency Cepstral Coefficients (LFCC).
"""
from typing import Dict, Optional, Tuple
import math
import torch
import torch.nn as nn
import torchaudio.transforms as T
def compute_autocorr(frames: torch.Tensor, order: int) -> torch.Tensor:
"""Compute autocorrelation for framed signals using FFT.
Args:
frames: Tensor of shape (..., num_frames, frame_len)
order: Maximum lag to compute (P)
Returns:
Autocorrelation tensor of shape (..., num_frames, order + 1)
"""
frame_len = frames.shape[-1]
window = torch.hamming_window(frame_len, device=frames.device, dtype=frames.dtype)
w_frames = frames * window
n_fft = 2 ** ((2 * frame_len - 1).bit_length())
freq = torch.fft.rfft(w_frames, n=n_fft, dim=-1)
power = freq.abs() ** 2
r = torch.fft.irfft(power, n=n_fft, dim=-1)[..., :order + 1]
return r
def levinson_durbin(r: torch.Tensor, order: int, eps: float = 1e-8) -> Tuple[torch.Tensor, torch.Tensor]:
"""Vectorized Levinson-Durbin algorithm.
Solves R * a = -r for all-pole vocal tract filter coefficients.
Args:
r: Autocorrelation tensor of shape (..., order + 1)
order: LPC order P
Returns:
a: LPC filter coefficients of shape (..., order)
e: Prediction error / residual power of shape (...)
"""
batch_shape = r.shape[:-1]
r_flat = r.reshape(-1, order + 1)
B = r_flat.shape[0]
a = torch.zeros(B, order + 1, device=r.device, dtype=r.dtype)
a[:, 0] = 1.0
e = torch.clamp(r_flat[:, 0].clone(), min=eps)
for i in range(1, order + 1):
acc = torch.zeros(B, device=r.device, dtype=r.dtype)
for j in range(1, i):
acc += a[:, j] * r_flat[:, i - j]
k = -(r_flat[:, i] + acc) / e
# Reflection coefficient stability clamp
k = torch.clamp(k, -0.999, 0.999)
new_a = a.clone()
for j in range(1, i):
new_a[:, j] = a[:, j] + k * a[:, i - j]
new_a[:, i] = k
a = new_a
e = e * (1.0 - k ** 2)
e = torch.clamp(e, min=eps)
lpc_coeffs = a[:, 1:].reshape(*batch_shape, order)
residual_power = e.reshape(*batch_shape)
return lpc_coeffs, residual_power
def lpc_to_lpcc(lpc: torch.Tensor, num_cepstrum: int) -> torch.Tensor:
"""Recursively convert LPC coefficients to Linear Prediction Cepstral Coefficients (LPCC).
Captures the cepstral envelope of the vocal tract transfer function.
"""
*batch_shape, P = lpc.shape
lpc_flat = lpc.reshape(-1, P)
B = lpc_flat.shape[0]
cep = torch.zeros(B, num_cepstrum, device=lpc.device, dtype=lpc.dtype)
cep[:, 0] = -lpc_flat[:, 0]
for n in range(1, num_cepstrum):
idx = n + 1
sum_val = torch.zeros(B, device=lpc.device, dtype=lpc.dtype)
limit = min(n, P)
for k in range(limit):
k_1idx = k + 1
sum_val += (1.0 - k_1idx / idx) * lpc_flat[:, k] * cep[:, n - k_1idx]
if n < P:
cep[:, n] = -lpc_flat[:, n] - sum_val
else:
cep[:, n] = -sum_val
return cep.reshape(*batch_shape, num_cepstrum)
class PhysicsVocalTractExtractor(nn.Module):
"""Branch A: Physics-based Vocal Tract & Glottal Feature Extractor (0-4 kHz).
Extracts:
1. Linear Prediction Cepstral Coefficients (LPCC): vocal tract resonance shape.
2. Glottal residual energy ratio & predictability: glottal excitation constraints.
"""
def __init__(
self,
sample_rate: int = 8000,
frame_len: int = 200, # 25 ms @ 8 kHz
hop_len: int = 80, # 10 ms @ 8 kHz
lpc_order: int = 14, # Appropriate for 8kHz narrowband speech
num_lpcc: int = 16
):
super().__init__()
self.sample_rate = sample_rate
self.frame_len = frame_len
self.hop_len = hop_len
self.lpc_order = lpc_order
self.num_lpcc = num_lpcc
self.out_channels = num_lpcc + 2 # LPCC + log residual power + residual ratio
def forward(self, waveform: torch.Tensor) -> torch.Tensor:
"""Extract physics-based features.
Args:
waveform: (B, num_samples) at 8 kHz
Returns:
features: (B, out_channels, num_frames)
"""
# 1. Frame signal
# (B, num_frames, frame_len)
frames = waveform.unfold(dimension=-1, size=self.frame_len, step=self.hop_len)
# 2. Compute Autocorrelation
r = compute_autocorr(frames, self.lpc_order)
# 3. Levinson-Durbin LPC & Residual Power
lpc, res_power = levinson_durbin(r, self.lpc_order)
# 4. Convert LPC to LPCC
lpcc = lpc_to_lpcc(lpc, self.num_lpcc) # (B, num_frames, num_lpcc)
# 5. Glottal residual constraints
# Frame signal energy
frame_energy = torch.clamp(r[..., 0], min=1e-8)
log_res_power = torch.log(torch.clamp(res_power, min=1e-8)).unsqueeze(-1)
res_ratio = (res_power / frame_energy).unsqueeze(-1) # Predictability ratio
# Combine: (B, num_frames, num_lpcc + 2)
phys_features = torch.cat([lpcc, log_res_power, res_ratio], dim=-1)
# Permute to (B, channels, time) for 1D convolutions
return phys_features.transpose(1, 2)
class SpectrogramExtractor(nn.Module):
"""Branch B: Spectrogram & Filterbank Extractor (0-4 kHz).
Supports Log-Mel Spectrogram and Linear Frequency Cepstral Coefficients (LFCC).
"""
def __init__(
self,
sample_rate: int = 8000,
n_fft: int = 512,
frame_len: int = 200, # 25 ms @ 8 kHz
hop_len: int = 80, # 10 ms @ 8 kHz
n_mels: int = 64,
f_min: float = 0.0,
f_max: float = 4000.0,
feature_type: str = "mel" # "mel" or "lfcc"
):
super().__init__()
self.sample_rate = sample_rate
self.feature_type = feature_type
self.n_mels = n_mels
self.mel_transform = T.MelSpectrogram(
sample_rate=sample_rate,
n_fft=n_fft,
win_length=frame_len,
hop_length=hop_len,
n_mels=n_mels,
f_min=f_min,
f_max=f_max,
power=2.0
)
def forward(self, waveform: torch.Tensor) -> torch.Tensor:
"""Extract spectral features.
Args:
waveform: (B, num_samples) at 8 kHz
Returns:
features: (B, 1, freq_bins, time_frames)
"""
# Mel spectrogram: (B, n_mels, T)
mel = self.mel_transform(waveform)
log_mel = torch.log(torch.clamp(mel, min=1e-6))
# Add channel dimension for 2D CNN: (B, 1, n_mels, T)
return log_mel.unsqueeze(1)
class HybridFeatureExtractor(nn.Module):
"""Dual-branch hybrid feature extractor orchestrating physics and spectral branches."""
def __init__(
self,
sample_rate: int = 8000,
frame_len: int = 200,
hop_len: int = 80,
lpc_order: int = 14,
num_lpcc: int = 16,
n_mels: int = 64
):
super().__init__()
self.branch_a = PhysicsVocalTractExtractor(
sample_rate=sample_rate,
frame_len=frame_len,
hop_len=hop_len,
lpc_order=lpc_order,
num_lpcc=num_lpcc
)
self.branch_b = SpectrogramExtractor(
sample_rate=sample_rate,
frame_len=frame_len,
hop_len=hop_len,
n_mels=n_mels
)
def forward(self, waveform: torch.Tensor) -> Tuple[torch.Tensor, torch.Tensor]:
"""Extract both Branch A and Branch B features.
Args:
waveform: (B, num_samples) at 8 kHz
Returns:
phys_features: (B, 18, T)
spec_features: (B, 1, 64, T)
"""
phys = self.branch_a(waveform)
spec = self.branch_b(waveform)
return phys, spec