Skip to content

Commit c08c568

Browse files
Merge pull request #4 from dhunstack/istft
[feat] Add ONNX exportable ISTFT implementation
2 parents 8243cf3 + 1a47a07 commit c08c568

3 files changed

Lines changed: 221 additions & 9 deletions

File tree

demucs/istft.py

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
# Copyright (C) 2025 Mixxx Development Team
2+
# All rights reserved.
3+
#
4+
# This source code is licensed under the license found in the
5+
# LICENSE file in the root directory of this source tree.
6+
# First author is Anmol Mishra.
7+
"""
8+
This module implements a custom ISTFT process that is compatible with ONNX export.
9+
It uses PyTorch's convolution operations to compute the inverse STFT, avoiding the use of
10+
complex numbers directly, which can be problematic for ONNX export.
11+
"""
12+
import torch
13+
import enum
14+
15+
16+
# Constants set for Demucs
17+
NFFT = 4096 # Number of FFT components for the STFT process
18+
HOP_LENGTH = 1024 # Number of samples between successive frames in the STFT
19+
WINDOW_TYPE = 'hann' # Type of window function used in the STFT
20+
WINDOW_LENGTH = NFFT # Length of the window function
21+
NORMALIZED = True # Whether to normalize the window function
22+
MAX_SIGNAL_LENGTH = int(44100 * 8) # Maximum length of the audio signal WITH padding (8 seconds at 44100 Hz)
23+
MAX_FRAMES = MAX_SIGNAL_LENGTH // HOP_LENGTH + 1 # Maximum number of frames for the audio length after STFT processed.
24+
CENTER = True # Whether to center the input signal before STFT
25+
26+
# Enum for window types
27+
class WindowType(enum.StrEnum):
28+
BARTLETT = 'bartlett'
29+
BLACKMAN = 'blackman'
30+
HAMMING = 'hamming'
31+
HANN = 'hann'
32+
KAISER = 'kaiser'
33+
34+
def __call__(self, window_length):
35+
match self:
36+
case WindowType.BARTLETT:
37+
return torch.bartlett_window(window_length)
38+
case WindowType.BLACKMAN:
39+
return torch.blackman_window(window_length)
40+
case WindowType.HAMMING:
41+
return torch.hamming_window(window_length)
42+
case WindowType.HANN:
43+
return torch.hann_window(window_length)
44+
case WindowType.KAISER:
45+
return torch.kaiser_window(window_length, periodic=True, beta=12.0)
46+
case _:
47+
raise NotImplementedError(f"Window type {self} doesn't yet have a function.")
48+
49+
class ISTFT_Process(torch.nn.Module):
50+
def __init__(self, n_fft=NFFT, hop_len=HOP_LENGTH, window_type=WINDOW_TYPE, window_length=WINDOW_LENGTH, normalized=NORMALIZED, max_frames=MAX_FRAMES, center=CENTER):
51+
super(ISTFT_Process, self).__init__()
52+
self.n_fft = n_fft
53+
self.hop_len = hop_len
54+
self.window_type = window_type
55+
self.window_length = window_length
56+
self.normalized = normalized
57+
self.max_frames = max_frames
58+
self.center = center
59+
self.half_n_fft = n_fft // 2 # Precompute once
60+
61+
# Get window function and compute window once
62+
if self.window_length != self.n_fft:
63+
raise NotImplementedError(f"The case of window length not equal to n_fft is not implemented in {self.__class__.__name__}.")
64+
window = WindowType(window_type)(self.window_length).float()
65+
66+
# Check if center is false
67+
if not self.center:
68+
raise NotImplementedError("No centering is not supported in this implementation.")
69+
70+
# ISTFT forward pass preparation
71+
# Pre-compute fourier basis
72+
fourier_basis = torch.fft.fft(torch.eye(n_fft, dtype=torch.float32))
73+
fourier_basis = torch.vstack([
74+
torch.real(fourier_basis[:self.half_n_fft + 1, :]),
75+
torch.imag(fourier_basis[:self.half_n_fft + 1, :])
76+
]).float()
77+
78+
# Create forward and inverse basis
79+
forward_basis = window * fourier_basis[:, None, :]
80+
inverse_basis = window * torch.linalg.pinv((fourier_basis * n_fft) / hop_len).T[:, None, :]
81+
82+
# Calculate window sum for overlap-add
83+
n = n_fft + hop_len * (max_frames - 1)
84+
window_sum = torch.zeros(n, dtype=torch.float32)
85+
window_normalized = window / window.abs().max()
86+
87+
# Pad window if needed
88+
total_pad = n_fft - window_normalized.shape[0]
89+
if total_pad > 0:
90+
pad_left = total_pad // 2
91+
pad_right = total_pad - pad_left
92+
win_sq = torch.nn.functional.pad(window_normalized ** 2, (pad_left, pad_right), mode='constant', value=0)
93+
else:
94+
win_sq = window_normalized ** 2
95+
96+
# Calculate overlap-add weights
97+
for i in range(max_frames):
98+
sample = i * hop_len
99+
window_sum[sample: min(n, sample + n_fft)] += win_sq[: max(0, min(n_fft, n - sample))]
100+
101+
# Normalize window if needed
102+
if normalized:
103+
inverse_basis = inverse_basis * torch.sqrt(torch.tensor([n_fft], dtype=torch.float32))
104+
105+
# Register buffers
106+
self.register_buffer("forward_basis", forward_basis)
107+
self.register_buffer("inverse_basis", inverse_basis)
108+
self.register_buffer("window_sum_inv", n_fft / (window_sum * hop_len + 1e-8)) # Add epsilon to avoid division by zero
109+
110+
def forward(self, real, imag, length=None):
111+
# Calculate magnitude and phase from real and imaginary parts
112+
magnitude = torch.sqrt(real ** 2 + imag ** 2)
113+
phase = torch.atan2(imag, real + torch.finfo(real.dtype).eps) # Add epsilon to avoid division by zero
114+
115+
# Pre-compute trig values
116+
cos_phase = torch.cos(phase)
117+
sin_phase = torch.sin(phase)
118+
119+
# Prepare input for transposed convolution
120+
complex_input = torch.cat((magnitude * cos_phase, magnitude * sin_phase), dim=1)
121+
122+
# Perform transposed convolution
123+
inverse_transform = torch.nn.functional.conv_transpose1d(
124+
complex_input,
125+
self.inverse_basis,
126+
stride=self.hop_len,
127+
padding=0,
128+
)
129+
130+
# Apply window correction
131+
output_len = inverse_transform.size(-1)
132+
start_idx = self.half_n_fft
133+
end_idx = output_len
134+
135+
output = inverse_transform[:, :, start_idx:end_idx] * self.window_sum_inv[start_idx:end_idx]
136+
137+
# If length is specified, trim the output to the desired length
138+
if length:
139+
pad_len = torch.clamp(torch.tensor(length) - output.size(-1), min=0)
140+
141+
# Create a zero pad tensor regardless of need
142+
pad = torch.zeros(
143+
output.size(0), output.size(1), pad_len,
144+
dtype=output.dtype, device=output.device
145+
)
146+
147+
# Always cat, pad_len will be 0 if not needed
148+
output = torch.cat([output, pad], dim=-1)
149+
150+
# Crop in all cases to enforce exact length
151+
output = output[..., :length]
152+
153+
output = output.squeeze(dim=1)
154+
return output
155+
156+
demucs_istft = ISTFT_Process(
157+
n_fft=NFFT,
158+
hop_len=HOP_LENGTH,
159+
window_type=WINDOW_TYPE,
160+
window_length=WINDOW_LENGTH,
161+
normalized=NORMALIZED,
162+
max_frames=MAX_FRAMES,
163+
center=CENTER,
164+
)

demucs/spec.py

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
import torch as th
1010
from .stft import demucs_stft
11+
from .istft import demucs_istft
1112

1213

1314
def spectro(x, n_fft=512, hop_length=None, pad=0, onnx_exportable=False):
@@ -47,15 +48,7 @@ def ispectro(z, hop_length=None, length=None, pad=0, onnx_exportable=False):
4748
is_mps_xpu = z.device.type in ['mps', 'xpu']
4849
if is_mps_xpu:
4950
z = z.cpu()
50-
z = th.view_as_complex(z) # Convert to complex tensor
51-
x = th.istft(z,
52-
n_fft,
53-
hop_length,
54-
window=th.hann_window(win_length).to(z.real),
55-
win_length=win_length,
56-
normalized=True,
57-
length=length,
58-
center=True)
51+
x = demucs_istft(z[..., 0], z[..., 1], length=length)
5952
_, length = x.shape
6053
return x.view(*other, length)
6154

tests/test_istft.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
# Copyright (C) 2025 Mixxx Development Team
2+
# All rights reserved.
3+
#
4+
# This source code is licensed under the license found in the
5+
# LICENSE file in the root directory of this source tree.
6+
# First author is Anmol Mishra.
7+
"""
8+
Test to compare the iSTFT outputs from PyTorch's iSTFT and ONNX exportable iSTFT.
9+
"""
10+
from demucs.spec import ispectro
11+
import pytest
12+
import torch
13+
14+
15+
def ispectroAPI(z, hop_length=None, length=None, pad=0):
16+
return NotImplementedError("This function is just a placeholder for the ispectro function.")
17+
18+
@pytest.fixture(scope='session')
19+
def input_spectrogram():
20+
""" Fixture to generate a random spectrogram for testing """
21+
batch, sources, channels = 1, 4, 2
22+
samples, n_fft, hop_length = int(7.8 * 44100), 4096, 1024
23+
freq_bins, time_steps = n_fft//2 + 1 , samples//hop_length + 1
24+
25+
z = torch.randn(batch, sources, channels, freq_bins, time_steps, 2) # (batch, sources, channels, freq_bins, time_steps, 2)
26+
return z, samples, hop_length
27+
28+
def test_istft_shape_pytorch(input_spectrogram):
29+
""" Test the shape of the iSTFT output using PyTorch's iSTFT """
30+
z, samples, hop_length = input_spectrogram
31+
batch, sources, channels = z.shape[0], z.shape[1], z.shape[2]
32+
x = ispectro(torch.view_as_complex(z), hop_length=hop_length, length=samples, onnx_exportable=False)
33+
34+
assert x.shape == (batch, sources, channels, samples) # (batch, sources, channels, samples)
35+
36+
def test_istft_shape_onnx_compatible(input_spectrogram):
37+
""" Test the shape of the iSTFT output using ONNX compatible iSTFT """
38+
z, samples, hop_length = input_spectrogram
39+
batch, sources, channels = z.shape[0], z.shape[1], z.shape[2]
40+
x = ispectro(z, hop_length=hop_length, length=samples, onnx_exportable=True)
41+
42+
assert x.shape == (batch, sources, channels, samples) # (batch, sources, channels, samples)
43+
44+
def test_compare_istfts(input_spectrogram):
45+
""" Compare the iSTFTs from PyTorch and ONNX compatible iSTFT """
46+
z, samples, hop_length = input_spectrogram
47+
48+
x_pytorch = ispectro(torch.view_as_complex(z), hop_length=hop_length, length=samples-hop_length, onnx_exportable=False)
49+
x_onnx = ispectro(z, hop_length=hop_length, length=samples-hop_length, onnx_exportable=True)
50+
51+
# Calculate the mean difference between the two iSTFTs
52+
mean_diff = torch.abs(x_pytorch - x_onnx).mean().item()
53+
print("\niSTFT Result: Mean Difference =", mean_diff)
54+
55+
assert mean_diff < 1e-4, "iSTFTs do not match!"

0 commit comments

Comments
 (0)