Skip to content

[feat] LTX-2.3 audio: BWE vocoder path - #1398

Merged
SolitaryThinker merged 2 commits into
hao-ai-lab:mainfrom
FoundationResearch:ltx2.3-audio
May 29, 2026
Merged

[feat] LTX-2.3 audio: BWE vocoder path#1398
SolitaryThinker merged 2 commits into
hao-ai-lab:mainfrom
FoundationResearch:ltx2.3-audio

Conversation

@alexzms

@alexzms alexzms commented May 26, 2026

Copy link
Copy Markdown
Collaborator

Summary

Adds the LTX-2.3 audio BWE (bandwidth-extension) vocoder path to ltx2_audio_vae.py. Additive + backward-compatible: it only activates when a vocoder config contains a "bwe" key; the existing non-BWE Vocoder path is behavior-identical.

  • New: VocoderWithBWE, MelSTFT / _STFTFn, Snake / SnakeBeta, AMPBlock1, anti-alias resampling (LowPassFilter1d / UpSample1d / DownSample1d / Activation1d, kaiser_sinc_filter1d).
  • VocoderConfigurator.from_config gains the if "bwe" in vocoder_cfg branch (16 kHz base + BWE generator + mel re-analysis → 48 kHz).
  • Audio decoding stage: _resolve_audio_sample_rate (handles 48 kHz BWE vs the 24 kHz default).

Backward compatibility

Non-BWE Vocoder is unchanged: the in-loop leaky_relu(x, LRELU_SLOPE) is gated if not is_amp, and the final path stays leaky_relu() → conv_post → tanh (verified against the original forward). BWE classes are inert unless the checkpoint config carries a bwe block.

Test plan

  • py_compile + import (VocoderWithBWE present)
  • Existing LTX2 tests pass on this branch: test_ltx2_continuation + test_ltx2_stage_overrides (29)
  • Functional BWE decode on a 2.3 checkpoint (needs GPU validation)

Independent of the DiT-2.3 PR; can be reviewed/merged separately.

Port the bandwidth-extension (BWE) vocoder stack from the internal
FastVideo repo into the upstream worktree.  The change is additive:
the non-BWE vocoder path is unchanged; BWE only activates when the
vocoder config dict contains a "bwe" key.

New symbols in fastvideo/models/audio/ltx2_audio_vae.py:
  - get_padding, _sinc, kaiser_sinc_filter1d
  - LowPassFilter1d, UpSample1d, DownSample1d, Activation1d
  - Snake, SnakeBeta, AMPBlock1
  - _STFTFn, MelSTFT, VocoderWithBWE

Vocoder extended with activation / use_tanh_at_final /
apply_final_activation / use_bias_at_final params and AMP1 resblock
support to match the internal implementation.

VocoderConfigurator.from_config gains an "if bwe in vocoder_cfg"
branch that builds the 16 kHz base Vocoder + BWE generator + MelSTFT
and wraps them in VocoderWithBWE (48 kHz output).

fastvideo/pipelines/basic/ltx2/stages/ltx2_audio_decoding.py:
replace hardcoded DEFAULT_LTX2_VOCODER_OUTPUT_SAMPLE_RATE with a
_resolve_audio_sample_rate() helper so BWE's 48 kHz output is
propagated correctly while the default (24 000 Hz) is preserved.
@mergify mergify Bot added type: feat New feature or capability scope: inference Inference pipeline, serving, CLI scope: model Model architecture (DiTs, encoders, VAEs) labels May 26, 2026
@mergify

mergify Bot commented May 26, 2026

Copy link
Copy Markdown
Contributor

Merge Protections

Your pull request matches the following merge protections and will not be merged until they are valid.

🔴 PR merge requirements

Waiting for

  • #approved-reviews-by>=1
  • check-success=full-suite-passed
This rule is failing.
  • #approved-reviews-by>=1
  • check-success=full-suite-passed
  • check-success=fastcheck-passed
  • check-success~=pre-commit
  • title~=(?i)^\[(feat|feature|bugfix|fix|refactor|perf|ci|doc|docs|misc|chore|kernel|new.?model|skill|skills|infra)\]

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces BigVGAN v2 and Bandwidth Extension (BWE) helper modules, including low-pass filters, upsampling/downsampling, Snake activations, and a VocoderWithBWE wrapper. It also updates the audio decoding pipeline to dynamically resolve the output sample rate. The code review highlights critical dtype mismatch bugs in LowPassFilter1d and _STFTFn when processing half-precision tensors, potential silent failures due to initializing STFT and Mel basis buffers with zeros, redundant custom sinc functions that should be replaced with torch.sinc, and a potential sample rate mismatch in the configurator's default fallback values.

Comment on lines +546 to +555
def forward(self, x: torch.Tensor) -> torch.Tensor:
_, n_channels, _ = x.shape
if self.padding:
x = F.pad(x, (self.pad_left, self.pad_right), mode=self.padding_mode)
return F.conv1d(
x,
self.filter.expand(n_channels, -1, -1),
stride=self.stride,
groups=n_channels,
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

The self.filter buffer is initialized as float32. If the input x is in half precision (e.g., bfloat16 or float16), F.conv1d will crash due to a dtype mismatch. Cast self.filter to x.dtype and x.device before calling F.conv1d, similar to how it is done in UpSample1d.

Suggested change
def forward(self, x: torch.Tensor) -> torch.Tensor:
_, n_channels, _ = x.shape
if self.padding:
x = F.pad(x, (self.pad_left, self.pad_right), mode=self.padding_mode)
return F.conv1d(
x,
self.filter.expand(n_channels, -1, -1),
stride=self.stride,
groups=n_channels,
)
def forward(self, x: torch.Tensor) -> torch.Tensor:
_, n_channels, _ = x.shape
if self.padding:
x = F.pad(x, (self.pad_left, self.pad_right), mode=self.padding_mode)
filt = self.filter.to(dtype=x.dtype, device=x.device).expand(
n_channels, -1, -1
)
return F.conv1d(
x,
filt,
stride=self.stride,
groups=n_channels,
)

Comment on lines +1546 to +1556
def forward(self, y: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
if y.dim() == 2:
y = y.unsqueeze(1)
left_pad = max(0, self.win_length - self.hop_length)
y = F.pad(y, (left_pad, 0))
spec = F.conv1d(y, self.forward_basis, stride=self.hop_length, padding=0)
n_freqs = spec.shape[1] // 2
real, imag = spec[:, :n_freqs], spec[:, n_freqs:]
magnitude = torch.sqrt(real**2 + imag**2)
phase = torch.atan2(imag.float(), real.float()).to(real.dtype)
return magnitude, phase

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

The self.forward_basis buffer is initialized as float32. If the input y is in half precision (e.g., bfloat16 or float16), F.conv1d will crash due to a dtype mismatch. Cast self.forward_basis to y.dtype and y.device before calling F.conv1d.

Suggested change
def forward(self, y: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
if y.dim() == 2:
y = y.unsqueeze(1)
left_pad = max(0, self.win_length - self.hop_length)
y = F.pad(y, (left_pad, 0))
spec = F.conv1d(y, self.forward_basis, stride=self.hop_length, padding=0)
n_freqs = spec.shape[1] // 2
real, imag = spec[:, :n_freqs], spec[:, n_freqs:]
magnitude = torch.sqrt(real**2 + imag**2)
phase = torch.atan2(imag.float(), real.float()).to(real.dtype)
return magnitude, phase
def forward(self, y: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
if y.dim() == 2:
y = y.unsqueeze(1)
left_pad = max(0, self.win_length - self.hop_length)
y = F.pad(y, (left_pad, 0))
basis = self.forward_basis.to(dtype=y.dtype, device=y.device)
spec = F.conv1d(y, basis, stride=self.hop_length, padding=0)
n_freqs = spec.shape[1] // 2
real, imag = spec[:, :n_freqs], spec[:, n_freqs:]
magnitude = torch.sqrt(real**2 + imag**2)
phase = torch.atan2(imag.float(), real.float()).to(real.dtype)
return magnitude, phase

Comment on lines +1542 to +1544
n_freqs = filter_length // 2 + 1
self.register_buffer("forward_basis", torch.zeros(n_freqs * 2, 1, filter_length))
self.register_buffer("inverse_basis", torch.zeros(n_freqs * 2, 1, filter_length))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Initializing forward_basis and inverse_basis to all zeros makes the model entirely dependent on the checkpoint containing these exact buffers. If the checkpoint does not contain them, or if the model is initialized without a checkpoint (e.g., in unit tests), it will silently produce zero outputs. Consider initializing them with the actual Fourier basis functions, or at least raise a warning if they remain all zeros after loading the state dict.

Comment on lines +1569 to +1570
n_freqs = filter_length // 2 + 1
self.register_buffer("mel_basis", torch.zeros(n_mel_channels, n_freqs))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Initializing mel_basis to all zeros makes the model entirely dependent on the checkpoint containing this exact buffer. If the checkpoint does not contain it, or if the model is initialized without a checkpoint (e.g., in unit tests), it will silently produce zero outputs. Consider initializing it with the actual Mel scale filterbank (e.g., using librosa or torchaudio), or at least raise a warning if it remains all zeros after loading the state dict.

Comment on lines +486 to +491
def _sinc(x: torch.Tensor) -> torch.Tensor:
return torch.where(
x == 0,
torch.tensor(1.0, device=x.device, dtype=x.dtype),
torch.sin(math.pi * x) / math.pi / x,
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The custom _sinc helper function can be completely replaced by PyTorch's native torch.sinc function, which is highly optimized, fully differentiable, and handles the $x=0$ case safely without potential NaN gradient issues.

Suggested change
def _sinc(x: torch.Tensor) -> torch.Tensor:
return torch.where(
x == 0,
torch.tensor(1.0, device=x.device, dtype=x.dtype),
torch.sin(math.pi * x) / math.pi / x,
)
# Removed in favor of torch.sinc

if cutoff == 0:
filter_ = torch.zeros_like(time)
else:
filter_ = 2 * cutoff * window * _sinc(2 * cutoff * time)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Use torch.sinc directly instead of the custom _sinc helper.

Suggested change
filter_ = 2 * cutoff * window * _sinc(2 * cutoff * time)
filter_ = 2 * cutoff * window * torch.sinc(2 * cutoff * time)

vocoder=base_vocoder,
bwe_generator=bwe_generator,
mel_stft=mel_stft,
input_sampling_rate=bwe_cfg.get("input_sampling_rate", 16000),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

There is a potential sample rate mismatch if bwe_cfg does not contain "input_sampling_rate". In that case, base_vocoder's output_sample_rate (lines 1751-1754) defaults to nested_vocoder_cfg.get("output_sampling_rate", 24000), while VocoderWithBWE's input_sampling_rate defaults to 16000. This mismatch will cause incorrect resampling ratios and shape mismatches. Consider resolving the base vocoder sample rate once and passing it to both places.

@alexzms
alexzms marked this pull request as ready for review May 27, 2026 01:40
@SolitaryThinker

Copy link
Copy Markdown
Collaborator

Hi @alexzms — automated review from Gob, one of @SolitaryThinker's AI reviewers. Findings aren't all human-verified; ping @SolitaryThinker if anything looks off.

Thanks for the focused BWE vocoder patch. I found two S1 issues that should be fixed before merge:

  1. S1: BWE mel front-end can silently run with all-zero signal-processing buffers. In fastvideo/models/audio/ltx2_audio_vae.py, _STFTFn registers forward_basis as zeros and immediately uses it for F.conv1d (lines 1543, 1551), and MelSTFT registers mel_basis as zeros and uses it for the mel projection (lines 1570, 1577). The new BWE path then calls this from VocoderWithBWE.forward() before the residual generator (lines 1633-1635). Since VocoderLoader loads the vocoder with strict=False (fastvideo/models/loader/component_loader.py:856-857), a converted checkpoint that omits these deterministic buffers will not fail; it will produce a constant log-mel input for BWE instead. Please initialize the Fourier/mel bases deterministically in the constructors, or validate that those buffers were loaded before enabling the BWE path.

  2. S1: The new BWE vocoder path has no CI-tracked unit/parity coverage. The PR changes only fastvideo/models/audio/ltx2_audio_vae.py and fastvideo/pipelines/basic/ltx2/stages/ltx2_audio_decoding.py; there is no matching test under fastvideo/tests/, and the existing tests/local_tests/ltx2/test_ltx2_audio_vae.py is skip-heavy/local-only. Please add at least a small fastvideo/tests/ test for the BWE config path that would catch missing STFT/mel buffers and verify waveform shape/sample-rate behavior.

— Gob (@SolitaryThinker's AI reviewer). Full review (including S3 items) is archived locally.

Addresses Gob's S1 finding on hao-ai-lab#1398: ``_STFTFn.forward_basis`` /
``_STFTFn.inverse_basis`` and ``MelSTFT.mel_basis`` were registered as
``torch.zeros(...)`` placeholders and used immediately by ``F.conv1d``
and ``torch.matmul``.  Because ``VocoderLoader`` loads with
``strict=False`` (``fastvideo/models/loader/component_loader.py:829``),
a converted vocoder checkpoint that omits these deterministic
signal-processing buffers no longer raises — the buffers stay at zero,
``magnitude = sqrt(0 + 0) = 0``, ``log_mel = log(clamp(0, 1e-5))`` is
a constant ≈ -11.51, and the BWE generator runs on a constant feature,
silently producing no high-band synthesis.

Fix: add ``_build_stft_basis`` (Hann-windowed FFT basis as a Conv1d
kernel) and ``_build_mel_basis`` (Slaney-normalised triangular mel
filterbank, librosa-compatible defaults) and use them in the
constructors:

- ``_STFTFn.__init__`` initialises ``forward_basis`` and
  ``inverse_basis`` from the windowed FFT basis instead of zeros.
- ``MelSTFT.__init__`` gains a required ``sampling_rate`` argument and
  initialises ``mel_basis`` from the mel filterbank instead of zeros.
- The construction site in the vocoder factory now passes
  ``bwe_cfg["input_sampling_rate"]`` (default 16000) into ``MelSTFT``.

A loaded checkpoint still overrides the deterministic init exactly as
before; the only behavioural change is that an *omitted* buffer no
longer silently zeros the signal-processing path.

Adds ``fastvideo/tests/audio/test_ltx2_bwe.py`` (11 CPU-only regression
tests) covering forward/mel basis non-zero invariants, shape,
sampling-rate dependence, mel filterbank non-negativity, end-to-end
log-mel of a sine tone not constant, and the standalone helpers.
@mergify mergify Bot added the scope: infra CI, tests, Docker, build label May 29, 2026
@alexzms

alexzms commented May 29, 2026

Copy link
Copy Markdown
Collaborator Author

Pushed 07903b2b to address both S1 findings.

S1 #1 (zero-init buffers). Added _build_stft_basis (Hann-windowed FFT basis as a Conv1d kernel) and _build_mel_basis (Slaney-normalised triangular mel filterbank, librosa-compatible defaults), and call them in _STFTFn.__init__ and MelSTFT.__init__. MelSTFT now requires a sampling_rate argument; the vocoder factory site (ltx2_audio_vae.py:1783) passes bwe_cfg["input_sampling_rate"] (default 16000). A loaded checkpoint still overrides the deterministic init exactly as before — the only behavioural change is that an omitted buffer no longer silently zeros the STFT/mel front-end, so a converted checkpoint that drops these keys is no longer a silent-failure path.

S1 #2 (no CI coverage). Added fastvideo/tests/audio/test_ltx2_bwe.py (11 CPU-only regression tests) covering forward / mel basis non-zero invariants, shape, sampling-rate dependence, mel filterbank non-negativity, an end-to-end log-mel of a 1 kHz sine tone not being constant (the regression assertion for the silent-zero bug — would fail if either basis were re-introduced as zero), and the standalone basis helpers. All pass.

@SolitaryThinker
SolitaryThinker merged commit 84214c8 into hao-ai-lab:main May 29, 2026
9 of 10 checks passed
@SolitaryThinker
SolitaryThinker deleted the ltx2.3-audio branch May 29, 2026 23:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

scope: inference Inference pipeline, serving, CLI scope: infra CI, tests, Docker, build scope: model Model architecture (DiTs, encoders, VAEs) type: feat New feature or capability

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants