File size: 6,346 Bytes
27813b0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
"""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"]