Merge branch 'fix/review-2083' into fix/community-integration

# Conflicts:
#	CHANGELOG.md
#	docs/install/troubleshooting.md
This commit is contained in:
Palash Debnath
2026-09-17 12:38:36 +05:30
6 changed files with 256 additions and 4 deletions
+2
View File
@@ -22,6 +22,8 @@ the frozen-backend fallback mirror it for their toolchains.
- Include cuDNN 8 compatibility libraries for CTranslate2 in CUDA containers (#2072) — thanks @basil-k-aji-dev!
- Preserve audio reads, writes, and reference amplitude without TorchCodec (#2083) — thanks @Moep90!
## [0.5.3] — 2026-09-17
**Highlights**
+42 -1
View File
@@ -204,6 +204,42 @@ def _safe_torchaudio_save(
fmt, e,
)
torchaudio.save(path_or_buf, tensor, sample_rate, format=fmt)
except (ImportError, RuntimeError) as e:
if isinstance(e, RuntimeError) and "could not load libtorchcodec" not in str(e).lower():
raise # Unrelated encoder failures must remain visible.
# torchaudio >= 2.9 routes save() through TorchCodec, which needs
# FFmpeg *shared libraries* on the system. Where those are absent the
# write raises ImportError and every generation fails. #1931 guarded
# set_audio_backend() against that torchaudio but left save() itself
# unprotected; arm64 CUDA hosts reach it unavoidably, since torch
# 2.8.0 publishes no aarch64 wheel. soundfile is already a locked
# dependency and the tensor is normalized by this point, so hand it to
# the audited sibling helper rather than failing the request.
logger.warning(
"torchaudio.save needs TorchCodec (%s); writing via soundfile", e
)
if hasattr(path_or_buf, "seek") and hasattr(path_or_buf, "truncate"):
try:
path_or_buf.seek(0)
path_or_buf.truncate(0)
except (OSError, io.UnsupportedOperation):
pass # Non-seekable streams cannot be rewound; preserve fallback behavior.
_subtype = {
"wav": "FLOAT" if bits_per_sample == 32 else "PCM_16",
"flac": "PCM_16",
"ogg": "VORBIS",
"mp3": "MPEG_LAYER_III",
}.get(fmt, "PCM_16")
try:
_safe_soundfile_write(
path_or_buf,
tensor.transpose(0, 1).contiguous().numpy(),
sample_rate,
subtype=_subtype,
format=fmt.upper(),
)
except Exception as e2:
raise _describe_write_failure(e2, path_or_buf) from e2
except Exception as e:
# #1221: libsndfile reports OS-level write failures as a bare
# "LibsndfileError: System error." — no path, no errno, nothing the
@@ -267,6 +303,7 @@ def _safe_soundfile_write(
sample_rate: int,
*,
subtype: str = "PCM_16",
format: str | None = None,
) -> None:
"""Sibling helper for the one in-tree ``sf.write`` site.
@@ -284,6 +321,10 @@ def _safe_soundfile_write(
subtype: Soundfile subtype string. ``"PCM_16"`` (default) for
standard 16-bit PCM WAV; ``"PCM_24"``, ``"FLOAT"`` etc.
also work.
format: Container format (``"WAV"``, ``"FLAC"``, ``"OGG"``,
``"MP3"``). ``None`` lets soundfile infer it from the path's
extension — which it cannot do for a file-like object, so
callers passing a buffer must name it.
Raises:
ValueError: if the array is empty.
@@ -321,7 +362,7 @@ def _safe_soundfile_write(
samples = np.ascontiguousarray(samples)
_ensure_audio_parent(path)
sf.write(path, samples, sample_rate, subtype=subtype)
sf.write(path, samples, sample_rate, subtype=subtype, format=format)
def atomic_save_wav(
+4
View File
@@ -1152,3 +1152,7 @@ remove the app binary itself are in
### Pedalboard illegal-instruction crashes
VoiceStudio pins pedalboard to `>=0.9.14,<0.9.21` while [upstream portable-wheel repair #466](https://github.com/spotify/pedalboard/pull/466) remains open. Re-sync the locked environment after updating from a build with newer affected wheels. This keeps the existing effects API floor while avoiding the reported Linux CPU import crash.
### TorchCodec unavailable
When torchaudio requires an unavailable TorchCodec installation, VoiceStudio writes through soundfile and reads reference audio through its FFmpeg fallback. Reference amplitude is normalized using the decoded sample representation, including 8-, 24-, and 32-bit PCM.
+14 -3
View File
@@ -58,10 +58,21 @@ def load_audio(audio_path: str, sampling_rate: int):
waveform, prompt_sampling_rate = torchaudio.load(
audio_path, backend="soundfile"
)
except (RuntimeError, OSError):
# Fallback via pydub+ffmpeg for formats torchaudio can't handle
except (ImportError, RuntimeError, OSError):
# Fallback via pydub+ffmpeg for formats torchaudio can't handle.
# ImportError belongs here too: torchaudio >= 2.9 routes load()
# through TorchCodec and raises ImportError when its FFmpeg shared
# libraries are absent. The ``backend="soundfile"`` argument above
# does NOT avoid that — 2.9 accepts and ignores it.
aseg = AudioSegment.from_file(audio_path)
audio_data = np.array(aseg.get_array_of_samples()).astype(np.float32) / 32768.0
# Scale by the decoded sample width instead of a hardcoded 16-bit
# divisor. pydub reports 8-bit as sample_width 1 and widens 24-bit to
# a full-range int32 (sample_width 4), so /32768 makes 24- and 32-bit
# references 32768x too loud and 8-bit ones 256x too quiet.
audio_data = (
np.array(aseg.get_array_of_samples()).astype(np.float32)
/ aseg.max_possible_amplitude
)
if aseg.channels == 1:
waveform = torch.from_numpy(audio_data).unsqueeze(0)
else:
+86
View File
@@ -372,3 +372,89 @@ def test_atomic_save_wav_delegates_to_safe_helper(tmp_path):
decoded, _ = sf.read(str(target))
assert abs(decoded).max() <= 1.0 + 1e-3
assert abs(decoded).max() > 0.5
# ── torchaudio 2.9 + no TorchCodec (#1931 follow-up) ───────────────────────
#
# torchaudio >= 2.9 routes save() through TorchCodec, which needs FFmpeg
# *shared libraries* on the system. Where those are absent every write raises
# ImportError. #1931 guarded set_audio_backend() against that torchaudio but
# left save() unprotected; arm64 CUDA hosts reach it unavoidably, since torch
# 2.8.0 publishes no aarch64 wheel. Without the soundfile fallback these three
# tests raise instead of producing a file.
def _torchcodec_missing(*_a, **_kw):
raise ImportError(
"TorchCodec is required for save_with_torchcodec. "
"Please install torchcodec to use this function."
)
def test_safe_save_falls_back_to_soundfile_when_torchcodec_missing(
tmp_path, monkeypatch
):
import torchaudio
monkeypatch.setattr(torchaudio, "save", _torchcodec_missing)
target = tmp_path / "fallback.wav"
_safe_torchaudio_save(str(target), _sine_tensor(), 24000)
info = sf.info(str(target))
assert info.subtype == "PCM_16"
assert info.frames == 24000
decoded, sr = sf.read(str(target))
assert sr == 24000
assert abs(decoded).max() > 0.1
def test_safe_save_buffer_falls_back_when_torchcodec_missing(monkeypatch):
"""The OpenAI-compatible /v1/audio/speech path writes to a BytesIO."""
import torchaudio
monkeypatch.setattr(torchaudio, "save", _torchcodec_missing)
buf = io.BytesIO()
_safe_torchaudio_save(buf, _sine_tensor(), 24000, format="wav")
assert buf.getvalue()[:4] == b"RIFF"
buf.seek(0)
decoded, sr = sf.read(buf)
assert sr == 24000
assert len(decoded) == 24000
def test_safe_save_flac_buffer_fallback_names_the_format(monkeypatch):
"""soundfile cannot infer a container from a file-like object, so the
fallback must pass ``format=`` explicitly — otherwise this raises."""
import torchaudio
monkeypatch.setattr(torchaudio, "save", _torchcodec_missing)
buf = io.BytesIO()
_safe_torchaudio_save(buf, _sine_tensor(), 24000, format="flac")
assert buf.getvalue()[:4] == b"fLaC"
buf.seek(0)
decoded, sr = sf.read(buf)
assert sr == 24000
assert len(decoded) == 24000
@pytest.mark.parametrize("message, fallback", [
("Could not load libtorchcodec. Missing FFmpeg shared libraries", True),
("unrelated encoder failure", False),
])
def test_save_native_loader_failure_only_uses_fallback(tmp_path, monkeypatch, message, fallback):
import torchaudio
def fail(*args, **kwargs):
raise RuntimeError(message)
monkeypatch.setattr(torchaudio, "save", fail)
target = tmp_path / "codec.wav"
if fallback:
_safe_torchaudio_save(str(target), _sine_tensor(), 24000)
assert sf.info(target).frames == 24000
else:
with pytest.raises(RuntimeError, match="unrelated encoder failure"):
_safe_torchaudio_save(str(target), _sine_tensor(), 24000)
@@ -0,0 +1,108 @@
"""``load_audio`` must survive torchaudio 2.9 without TorchCodec (#1931).
torchaudio >= 2.9 routes ``load()`` through TorchCodec, which needs FFmpeg
*shared libraries* on the system. Where those are absent the call raises
``ImportError`` — but ``load_audio`` caught only ``(RuntimeError, OSError)``,
so the pydub fallback written for precisely this situation never ran. Every
voice-clone reference read, watermark check and dub segment load then failed
with "TorchCodec is required for load_with_torchcodec".
This is the read-side twin of the ``_safe_torchaudio_save`` regression in
``tests/backend/services/test_audio_io.py``, and it reaches the same users:
#1931 guarded ``set_audio_backend()`` against torchaudio 2.9 but left
``load()`` unprotected. arm64 CUDA hosts reach it unavoidably, since torch
2.8.0 publishes no aarch64 wheel.
Note that ``backend="soundfile"`` does not avoid this — torchaudio 2.9
accepts that argument and ignores it.
"""
from __future__ import annotations
import numpy as np
import pytest
import soundfile as sf
import torch
from omnivoice.utils.audio import load_audio
def _write_sine_wav(path, *, seconds: float = 0.5, sample_rate: int = 24000):
n = int(seconds * sample_rate)
t = np.arange(n, dtype=np.float32) / sample_rate
sf.write(str(path), 0.5 * np.sin(2 * np.pi * 440.0 * t), sample_rate,
subtype="PCM_16")
return sample_rate
def _torchcodec_missing(*_a, **_kw):
raise ImportError(
"TorchCodec is required for load_with_torchcodec. "
"Please install torchcodec to use this function."
)
def test_load_audio_falls_back_when_torchcodec_missing(tmp_path, monkeypatch):
"""Without the ImportError catch this raises instead of returning audio."""
import torchaudio
ref = tmp_path / "ref.wav"
sample_rate = _write_sine_wav(ref)
monkeypatch.setattr(torchaudio, "load", _torchcodec_missing)
waveform = load_audio(str(ref), sample_rate)
assert isinstance(waveform, torch.Tensor)
assert waveform.ndim == 2, f"expected (1, T), got {tuple(waveform.shape)}"
assert waveform.shape[0] == 1, "load_audio must return mono"
assert waveform.shape[-1] > 0
assert waveform.abs().max() > 0.05, "fallback produced silence"
def test_load_audio_fallback_resamples_to_target(tmp_path, monkeypatch):
"""The fallback path must still honour the requested sampling rate."""
import torchaudio
ref = tmp_path / "ref_16k.wav"
_write_sine_wav(ref, seconds=0.5, sample_rate=16000)
monkeypatch.setattr(torchaudio, "load", _torchcodec_missing)
waveform = load_audio(str(ref), 24000)
# 0.5 s resampled 16k -> 24k is ~12000 samples; allow resampler edge slack.
assert abs(waveform.shape[-1] - 12000) <= 64, (
f"expected ~12000 samples at 24 kHz, got {waveform.shape[-1]}"
)
@pytest.mark.parametrize("subtype", ["PCM_U8", "PCM_16", "PCM_24", "PCM_32", "FLOAT"])
def test_load_audio_fallback_amplitude_matches_bit_depth(
tmp_path, monkeypatch, subtype
):
"""The fallback must scale by the decoded width, not a fixed 32768.
pydub reports 8-bit as ``sample_width`` 1 and widens 24-bit to a
full-range int32 (``sample_width`` 4, contrary to the stale comment in
its own source). Dividing every decode by 32768 therefore returned 24-
and 32-bit references 32768x too loud and 8-bit ones 256x too quiet.
Nothing downstream clamps, so a clone reference silently became noise.
Before the ImportError catch this path was rare; on torchaudio >= 2.9
without TorchCodec it is the only path, which is what makes it a bug
worth fixing here.
"""
import torchaudio
sample_rate = 24000
peak = 0.5
t = np.arange(sample_rate // 2, dtype=np.float64) / sample_rate
ref = tmp_path / f"ref_{subtype.lower()}.wav"
sf.write(str(ref), peak * np.sin(2 * np.pi * 440.0 * t), sample_rate,
subtype=subtype)
monkeypatch.setattr(torchaudio, "load", _torchcodec_missing)
waveform = load_audio(str(ref), sample_rate)
assert waveform.abs().max().item() == pytest.approx(peak, abs=0.02), (
f"{subtype} decoded at the wrong scale: peak "
f"{waveform.abs().max().item():.6f}, expected ~{peak}"
)