Merge pull request #10 from iGavroche/claude/project-thread-agfj22

v2.3.29: Seed-VC only bridges tonal f0 gaps, not consonants
This commit is contained in:
Gavroche
2026-10-20 09:46:40 -04:00
committed by GitHub
7 changed files with 48 additions and 12 deletions
+5
View File
@@ -2,6 +2,11 @@
## [2.3.29] - 2026-10-19
### Fixed
- **`ROCmSeedVoiceConversion`: consonants sung as pitched buzz in singing mode**: the f0 gap bridge (2.3.18) filled every unvoiced gap of up to 10 frames (100 ms), but most of those gaps are consonants and breaths between notes, not tracker dropouts. On the 35 s SongCover test stem, 29 of the 33 bridged gaps were noise-like (spectral flatness 0.03-0.28, zero-crossing rate ~0.42) and only 4 were real dropouts inside vowels (flatness <=0.006). Each consonant got a pitch in the conditioning, and the converted vocal came out voiced in 67% of those frames, so every "s", "t" and breath was rendered as a buzz, about once a second through the song. A gap is now bridged only when its audio is tonal (mean spectral flatness <=0.02): 4 gaps / 15 frames are bridged on that stem instead of 33 / 190, and the reference prompt goes from 11 bridged gaps to 2. Real dropouts inside sustained notes are still bridged, so note continuity is unchanged.
## [2.3.28] - 2026-10-16
### Fixed
+4
View File
@@ -36,6 +36,10 @@ After running:
**ROCm Ninodes** is a custom node collection tuned for AMD GPUs with ROCm (especially gfx1151). It includes optimized VAE decode, KSampler, checkpoint/diffusion/GGUF/LoRA loaders, LTX2 prompt generation, SamplerCustomAdvanced drop-in, and monitoring nodes to maximize performance on AMD hardware with mature ROCm drivers.
## 🚀 What's new in v2.3.29
- **Seed-VC singing no longer buzzes on consonants**: the f0 gap bridge was giving a pitch to most consonants and breaths between notes, so the converted vocal sang them as a voiced buzz. Only tonal tracker dropouts inside notes are bridged now.
## 🚀 What's new in v2.3.27
- **Audio node moved into the package category tree**: `ROCmSeedVoiceConversion` now lives under `ROCm Ninodes/Audio` instead of the stray `🎛 ROCm/audio` menu branch.
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "rocm-ninodes",
"display_name": "ROCm Ninodes",
"description": "ROCm-optimized nodes for ComfyUI: VAE decode, sampling (KSampler + SamplerCustomAdvanced), checkpoint/diffusion/GGUF/LoRA loaders, LTX2 prompt generation, Seed-VC voice conversion, and monitoring — tuned for AMD GPUs (gfx1151) with ROCm.",
"version": "2.3.28",
"version": "2.3.29",
"author": "iGavroche",
"author_email": "nino2k@proton.me",
"repository": "https://github.com/iGavroche/rocm-ninodes",
+1 -1
View File
@@ -66,7 +66,7 @@ With `separate_source=True` the node also needs the `audio-separator` package an
- The DiT estimator runs under fp16 autocast (upstream behavior); the vocoder dtype follows `precision`.
- The full model set (~3.2 GB of checkpoints) is cached in a module-level `_wrappers` dict keyed by `(device, half_vocoder)` and reused across executions; `unload_model=True` clears it.
- Chunking: each diffusion window is `30 s − prompt_length`, crossfaded over 64 mel frames (~745 ms). A 35 s singing job with the default 10 s prompt is 2 chunks; a longer prompt means more chunks and more seams.
- RMVPE dropouts inside sustained notes (≤10 frames, ~100 ms) are linearly bridged on both the source and prompt f0 curves before conditioning, so notes continue instead of re-attacking.
- RMVPE dropouts inside sustained notes (≤10 frames, ~100 ms) are linearly bridged on both the source and prompt f0 curves before conditioning, so notes continue instead of re-attacking. A gap is only bridged when its audio is tonal (mean spectral flatness ≤0.02): short unvoiced gaps between notes are mostly consonants and breaths, and giving those a pitch made the model sing them as a buzz.
- `vibrato_transfer` post-process: the diffusion reproduces note centers (global f0 corr ~0.995) but its micro-pitch wobble is only ~0.84 correlated with the source's; the warp raises that to ~0.88–0.93 with no duration or note-center change. Costs one extra RMVPE pass (~1 s per 35 s).
- The `vibrato_transfer` warp ramps in over 100 ms after each detected note onset: the residual skeleton straddles note transitions, so without the ramp the warp transferred the source's attack transient as fast jitter at the note hit (attack wobble speed 31.4/s → 28.5/s with the ramp, matching the no-warp baseline).
- Fast note runs (>2.5 note onsets/s, fading out by 5/s) are stepped in the conditioning f0 (median-filtered contour) and the `vibrato_transfer` warp fades there: the model sings exactly the contour it is given, so a glissando source would otherwise come out as fast glides. Inter-onset CV in fast runs 0.47 → 0.32; note centers unchanged.
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "rocm-ninodes",
"version": "2.3.28",
"version": "2.3.29",
"description": "ROCM-optimized ComfyUI nodes for AMD GPU performance",
"author": "Nino",
"email": "nino2k@proton.me",
@@ -25,7 +25,7 @@
"display_name": "RocM Ninodes",
"description": "ROCM-optimized nodes for AMD GPU performance",
"author": "Nino",
"version": "2.3.28",
"version": "2.3.29",
"category": "Optimization",
"tags": ["rocm", "amd", "gpu", "optimization", "vae", "sampling"],
"nodes": [
+2 -2
View File
@@ -1,7 +1,7 @@
[project]
name = "rocm-ninodes"
description = "ROCM Optimized Nodes for ComfyUI - High-performance VAE decode and sampling nodes specifically tuned for AMD GPUs with ROCm support, particularly targeting gfx1151 architecture"
version = "2.3.28"
version = "2.3.29"
license = {file = "LICENSE"}
dependencies = [
"numpy>=1.20.0",
@@ -28,7 +28,7 @@ requires-comfyui = ">=0.0.1"
name = "rocm-ninodes"
display_name = "ROCm Ninodes"
description = "ROCm-optimized nodes for ComfyUI: VAE decode, sampling, SamplerCustomAdvanced drop-in, loaders, LTX2 prompt generation, and monitoring — tuned for AMD GPUs (gfx1151) with ROCm."
version = "2.3.28"
version = "2.3.29"
author = "iGavroche"
author_email = "nino2k@proton.me"
repository = "https://github.com/iGavroche/rocm-ninodes"
+33 -6
View File
@@ -120,10 +120,30 @@ def _load_stage(config_path: str, checkpoint_path: str, device: torch.device):
return model, to_mel
F0_GAP_BRIDGE_FRAMES = 10
# Spectral flatness above which an unvoiced gap is a consonant or breath, not a
# tracker dropout. On the SongCover stem the 4 real dropouts measure <=0.006 and
# the 29 consonant gaps 0.03-0.28; voiced frames have a median of 0.006.
F0_GAP_MAX_FLATNESS = 0.02
def _bridge_f0_gaps(f0: torch.Tensor, max_gap: int) -> torch.Tensor:
"""Linear-interpolate short unvoiced dropouts so sustained notes stay continuous."""
def _spectral_flatness_16k(wave_16k: torch.Tensor) -> np.ndarray:
"""Per-frame spectral flatness on RMVPE's 10 ms frame grid (hop 160, centered)."""
x = wave_16k.reshape(-1).float()
window = torch.hann_window(1024, device=x.device)
power = torch.stft(x, 1024, hop_length=160, window=window, center=True,
return_complex=True).abs().pow(2).clamp_min(1e-10)
flatness = torch.exp(power.log().mean(dim=0)) / power.mean(dim=0)
return flatness.cpu().numpy()
def _bridge_f0_gaps(f0: torch.Tensor, max_gap: int,
flatness: Optional[np.ndarray] = None) -> torch.Tensor:
"""Linear-interpolate short unvoiced dropouts so sustained notes stay continuous.
With `flatness`, a gap is only bridged when the audio under it is tonal:
most short gaps between notes are consonants and breaths, and giving those
a pitch makes the model sing them as a buzz.
"""
flat = f0.reshape(-1)
voiced = flat > 1
if voiced.sum() < 2:
@@ -138,8 +158,13 @@ def _bridge_f0_gaps(f0: torch.Tensor, max_gap: int) -> torch.Tensor:
for s in starts:
after = [e for e in ends if e > s]
e = after[0] if after else flat.numel() - 1
if 0 < e - s <= max_gap:
out[s + 1:e + 1] = filled[s + 1:e + 1]
if not 0 < e - s <= max_gap:
continue
if flatness is not None:
gap = flatness[s + 1:min(e + 1, flatness.size)]
if gap.size == 0 or gap.mean() > F0_GAP_MAX_FLATNESS:
continue
out[s + 1:e + 1] = filled[s + 1:e + 1]
return out.reshape(f0.shape)
@@ -471,9 +496,11 @@ class SeedVCWrapper:
shifted_f0_alt[voiced_mask] = torch.exp(log_alt - log_alt.median() + log_ori.median())
if pitch_shift != 0:
shifted_f0_alt[voiced_mask] *= 2 ** (pitch_shift / 12)
shifted_f0_alt = _bridge_f0_gaps(shifted_f0_alt, F0_GAP_BRIDGE_FRAMES)
shifted_f0_alt = _bridge_f0_gaps(shifted_f0_alt, F0_GAP_BRIDGE_FRAMES,
_spectral_flatness_16k(converted_waves_16k))
shifted_f0_alt = _quantize_fast_runs(shifted_f0_alt)
F0_ori = _bridge_f0_gaps(F0_ori, F0_GAP_BRIDGE_FRAMES)
F0_ori = _bridge_f0_gaps(F0_ori, F0_GAP_BRIDGE_FRAMES,
_spectral_flatness_16k(prompt_waves_16k))
else:
F0_ori = shifted_f0_alt = None