suvradeepp's picture
Publish Tiny Hinglish Turn Detector development preview
35d483e verified
Raw
History Blame Contribute Delete
6.72 kB
"""A compact causal log-mel TCN trained from scratch."""
from __future__ import annotations
from collections.abc import Mapping, Sequence
from dataclasses import asdict, dataclass
import torch
import torch.nn.functional as F
from torch import Tensor, nn
from .common import TurnDetectionOutput
from .pooling import MaskedAttentiveStatisticsPooling
@dataclass(frozen=True)
class TinyTCNConfig:
n_mels: int = 80
channels: int = 192
num_blocks: int = 8
kernel_size: int = 5
dilation_cycle: Sequence[int] = (1, 2, 4, 8, 16, 32, 64, 128)
attention_channels: int = 128
head_hidden: int = 128
dropout: float = 0.1
auxiliary_fillers: bool = True
@classmethod
def from_mapping(cls, values: Mapping[str, object]) -> TinyTCNConfig:
known = {field.name for field in cls.__dataclass_fields__.values()}
clean = {key: value for key, value in values.items() if key in known}
if "dilation_cycle" in clean:
clean["dilation_cycle"] = tuple(int(x) for x in clean["dilation_cycle"])
return cls(**clean) # type: ignore[arg-type]
def to_dict(self) -> dict:
result = asdict(self)
result["dilation_cycle"] = list(self.dilation_cycle)
return result
class FrameLayerNorm(nn.Module):
"""Layer-normalize channels independently at every frame.
Unlike GroupNorm on a ``[B, C, T]`` tensor, this does not let the amount of
right padding change statistics for valid frames.
"""
def __init__(self, channels: int) -> None:
super().__init__()
self.norm = nn.LayerNorm(channels)
def forward(self, x: Tensor) -> Tensor:
return self.norm(x.transpose(1, 2)).transpose(1, 2)
class CausalDepthwiseSeparableBlock(nn.Module):
def __init__(
self,
channels: int,
kernel_size: int,
dilation: int,
dropout: float,
) -> None:
super().__init__()
self.left_padding = dilation * (kernel_size - 1)
self.depthwise = nn.Conv1d(
channels,
channels,
kernel_size=kernel_size,
dilation=dilation,
groups=channels,
bias=False,
)
self.pointwise = nn.Conv1d(channels, channels, kernel_size=1, bias=False)
self.norm = FrameLayerNorm(channels)
self.dropout = nn.Dropout(dropout)
def forward(self, x: Tensor, mask: Tensor | None = None) -> Tensor:
residual = x
x = F.pad(x, (self.left_padding, 0))
x = self.depthwise(x)
x = self.pointwise(x)
x = self.norm(x)
x = F.silu(x)
x = self.dropout(x)
x = x + residual
if mask is not None:
x = x * mask.unsqueeze(1).to(x.dtype)
return x
class TinyTurnDetector(nn.Module):
"""Approximately 0.4M parameter endpoint detector.
The model consumes normalized log-mel features shaped ``[B, 80, T]``. It
is causal up to the final pooling operation, so prefixes can be cached or
re-evaluated at VAD checkpoints without future-frame leakage.
"""
model_type = "tiny_tcn"
def __init__(self, config: TinyTCNConfig | None = None) -> None:
super().__init__()
config = config or TinyTCNConfig()
if config.num_blocks < 1:
raise ValueError("num_blocks must be positive")
if config.kernel_size < 2:
raise ValueError("kernel_size must be at least 2")
if not config.dilation_cycle:
raise ValueError("dilation_cycle cannot be empty")
self.config = config
self.input_projection = nn.Sequential(
nn.Conv1d(config.n_mels, config.channels, kernel_size=1, bias=False),
FrameLayerNorm(config.channels),
nn.SiLU(),
)
self.blocks = nn.ModuleList(
CausalDepthwiseSeparableBlock(
config.channels,
config.kernel_size,
int(config.dilation_cycle[index % len(config.dilation_cycle)]),
config.dropout,
)
for index in range(config.num_blocks)
)
self.pool = MaskedAttentiveStatisticsPooling(config.channels, config.attention_channels)
self.embedding = nn.Sequential(
nn.Linear(2 * config.channels, config.head_hidden),
nn.LayerNorm(config.head_hidden),
nn.SiLU(),
nn.Dropout(config.dropout),
)
self.endpoint_head = nn.Linear(config.head_hidden, 1)
if config.auxiliary_fillers:
self.midfiller_head: nn.Linear | None = nn.Linear(config.head_hidden, 1)
self.endfiller_head: nn.Linear | None = nn.Linear(config.head_hidden, 1)
else:
self.midfiller_head = None
self.endfiller_head = None
def forward(self, log_mel: Tensor, attention_mask: Tensor | None = None) -> TurnDetectionOutput:
if log_mel.ndim != 3:
raise ValueError("log_mel must have shape [batch, n_mels, frames]")
if log_mel.shape[1] != self.config.n_mels:
raise ValueError(f"expected {self.config.n_mels} mel bins, got {log_mel.shape[1]}")
if attention_mask is None:
attention_mask = torch.ones(
(log_mel.shape[0], log_mel.shape[-1]),
dtype=torch.bool,
device=log_mel.device,
)
else:
attention_mask = attention_mask.to(device=log_mel.device, dtype=torch.bool)
if attention_mask.shape != (log_mel.shape[0], log_mel.shape[-1]):
raise ValueError("attention_mask must have shape [batch, frames]")
x = log_mel * attention_mask.unsqueeze(1).to(log_mel.dtype)
x = self.input_projection(x)
x = x * attention_mask.unsqueeze(1).to(x.dtype)
for block in self.blocks:
x = block(x, attention_mask)
pooled = self.pool(x, attention_mask)
embedding = self.embedding(pooled)
endpoint = self.endpoint_head(embedding).squeeze(-1)
mid = (
self.midfiller_head(embedding).squeeze(-1) if self.midfiller_head is not None else None
)
end = (
self.endfiller_head(embedding).squeeze(-1) if self.endfiller_head is not None else None
)
return TurnDetectionOutput(endpoint, mid, end, embedding)
@torch.no_grad()
def predict_proba(self, log_mel: Tensor, attention_mask: Tensor | None = None) -> Tensor:
"""Endpoint probability convenience method for inference only."""
return torch.sigmoid(self(log_mel, attention_mask).endpoint_logits)
def model_config(self) -> dict:
return {"type": self.model_type, **self.config.to_dict()}