phanerozoic's picture
kernel source
27813b0 verified
Raw
History Blame
6.35 kB
"""resample-poly: polyphase rational resampling for audio.
Each filter phase stores only its own support rather than being padded to a
common width, so an output sample multiplies through its taps and nothing else.
The filter matches torchaudio's construction, so results agree to floating-point
rounding and this is a drop-in replacement.
High-level API:
Resampler(orig_freq, new_freq, ...) plan holding the polyphase bank
resample(x, orig_freq, new_freq) one-shot convenience
"""
import math
from typing import Optional
import torch
from ._ops import ops
def sinc_kernel(orig_freq: int, new_freq: int, lowpass_filter_width: int = 6,
rolloff: float = 0.99, method: str = "sinc_interp_hann",
beta: Optional[float] = None):
"""Windowed-sinc polyphase bank, matching torchaudio's construction.
Returns (kernels [L, W], width), with frequencies already reduced by their
greatest common divisor.
"""
if orig_freq <= 0 or new_freq <= 0:
raise ValueError("frequencies must be positive")
if lowpass_filter_width <= 0:
raise ValueError("lowpass_filter_width must be positive")
g = math.gcd(int(orig_freq), int(new_freq))
M = int(orig_freq) // g # input samples per block
L = int(new_freq) // g # output samples per block
base_freq = min(M, L) * rolloff
width = math.ceil(lowpass_filter_width * M / base_freq)
idx = torch.arange(-width, width + M, dtype=torch.float64) / M
t = (torch.arange(0, -L, -1, dtype=torch.float64)[:, None] / L) + idx[None, :]
t = t * base_freq
t = t.clamp_(-lowpass_filter_width, lowpass_filter_width)
if method == "sinc_interp_hann":
window = torch.cos(t * math.pi / lowpass_filter_width / 2) ** 2
elif method == "sinc_interp_kaiser":
b = 14.769656459379492 if beta is None else float(beta)
window = (torch.i0(b * torch.sqrt(
1 - (t / lowpass_filter_width) ** 2).clamp(min=0)) / torch.i0(
torch.tensor(b, dtype=torch.float64)))
else:
raise ValueError(f"unknown method {method!r}")
tp = t * math.pi
k = torch.where(tp == 0, torch.ones_like(tp), torch.sin(tp) / tp)
k = k * window * (base_freq / M)
return k.to(torch.float32), width, L, M
class Resampler:
"""Cached polyphase plan for a fixed rate pair."""
def __init__(self, orig_freq: int, new_freq: int,
lowpass_filter_width: int = 6, rolloff: float = 0.99,
method: str = "sinc_interp_hann", beta: Optional[float] = None):
kern, width, L, M = sinc_kernel(orig_freq, new_freq,
lowpass_filter_width, rolloff,
method, beta)
self.orig_freq, self.new_freq = int(orig_freq), int(new_freq)
self.L, self.M, self.width = L, M, width
self.kernel = kern
# Keep only each phase's support. Entries clamped past the filter half
# width carry a window value of exactly zero, so the support is exact
# rather than a threshold.
starts, lens, offs, taps = [], [], [], []
cursor = 0
for i in range(L):
row = kern[i]
nz = torch.nonzero(row, as_tuple=False).flatten()
if nz.numel() == 0:
starts.append(-width); lens.append(0); offs.append(cursor)
continue
j0, j1 = int(nz[0]), int(nz[-1]) + 1
starts.append(j0 - width)
lens.append(j1 - j0)
offs.append(cursor)
taps.append(row[j0:j1].contiguous())
cursor += j1 - j0
self.start = torch.tensor(starts, dtype=torch.int32)
self.len = torch.tensor(lens, dtype=torch.int32)
self.off = torch.tensor(offs, dtype=torch.int64)
self.taps = (torch.cat(taps) if taps else
torch.zeros(0, dtype=torch.float32)).contiguous()
# The fused path computes one dot product per output and reduces it.
# That reduction is fixed cost per output, so it pays only when a phase
# carries enough taps to amortize it. Measured against the conv1d bank,
# the crossover sits between 12 and 17 taps per output: 16.7 taps wins
# 1.5-1.8x, 12.1 loses 0.5x even while doing 13x fewer multiplies. Below
# the threshold the bank formulation is used instead, so this is never
# slower than the baseline it replaces.
self.taps_per_output = float(self.taps.numel()) / float(L)
self.fused = self.taps_per_output >= 16.0
@property
def density(self) -> float:
"""Stored taps over the padded-kernel size the conv1d form would use."""
return float(self.taps.numel()) / float(self.kernel.numel())
def _bank(self, x: torch.Tensor) -> torch.Tensor:
"""Padded-bank formulation, for rate pairs whose phases are too short
for the fused path to pay for its reduction."""
B, T = x.shape
pad = torch.nn.functional.pad(x, (self.width, self.width + self.M))
r = torch.nn.functional.conv1d(pad[:, None], self.kernel[:, None, :],
stride=self.M)
return r.transpose(1, 2).reshape(B, -1)[:, :self.out_len(T)]
def out_len(self, n: int) -> int:
return int(math.ceil(self.L * n / self.M))
def __call__(self, x: torch.Tensor) -> torch.Tensor:
"""x [T] or [B, T] -> resampled, matching torchaudio's output length."""
squeeze = x.dim() == 1
if squeeze:
x = x.unsqueeze(0)
if x.dim() != 2:
raise ValueError("x must be [T] or [B, T]")
x = x.to(torch.float32).contiguous()
B, T = x.shape
if not self.fused:
y = self._bank(x)
return y.squeeze(0) if squeeze else y
out = torch.empty(B, self.out_len(T), dtype=torch.float32)
ops.rp_resample(out, x, self.taps, self.start, self.len, self.off,
self.L, self.M)
return out.squeeze(0) if squeeze else out
def resample(x: torch.Tensor, orig_freq: int, new_freq: int,
**kw) -> torch.Tensor:
"""One-shot resample. Prefer Resampler when converting repeatedly."""
return Resampler(orig_freq, new_freq, **kw)(x)
__all__ = ["Resampler", "resample", "sinc_kernel"]