Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions src/b2aiprep/prepare/dataset.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,29 @@
DEFAULT_RESAMPLE_RATE = 16000
DEFAULT_BIT_DEPTH = 16


def _guard_resample_overshoot(resampled_audio, in_peak: float):
"""Prevent the 16-bit save from silently hard-clamping resample overshoot.

Resampling can push samples past full-scale (|x| > 1); writing those to PCM
would clamp them destructively with no error. If that happened, rescale the
resampled waveform down to the input's peak so nothing exceeds [-1, 1] --
preserving gain and waveform shape and introducing no clipping. No-op when the
resampled peak is already in range. Returns (audio, scale) with scale=None when
unchanged."""
wf = resampled_audio.waveform
if wf.numel() == 0:
return resampled_audio, None
out_peak = float(wf.abs().max())
if out_peak <= 1.0:
return resampled_audio, None
scale = min(1.0, float(in_peak)) / out_peak
return (
Audio(waveform=wf * scale, sampling_rate=resampled_audio.sampling_rate,
metadata=resampled_audio.metadata),
scale,
)

# Sensitive audio feature content that must not be present for sensitive tasks.
#
# This is a grouped spec because the per-record feature `.pt` files are nested dicts
Expand Down Expand Up @@ -107,6 +130,14 @@ def copy_one_file(src: Path, dst: Path) -> t.Optional[str]:
src_audio = Audio(filepath=src)
downmixed_audio = downmix_audios_to_mono([src_audio])[0]
audio_16k = resample_audios([downmixed_audio], DEFAULT_RESAMPLE_RATE)[0]
# Guard against the 16-bit save silently clamping resample overshoot.
in_peak = float(downmixed_audio.waveform.abs().max())
audio_16k, scale = _guard_resample_overshoot(audio_16k, in_peak)
if scale is not None:
_LOGGER.warning(
"Resample overshoot for %s; rescaled x%.5f to input peak %.4f "
"to avoid destructive 16-bit clamp.", src, scale, in_peak,
)
audio_16k.save_to_file(dst,bits_per_sample=DEFAULT_BIT_DEPTH)
else:
shutil.copyfile(src, dst)
Expand Down
31 changes: 31 additions & 0 deletions tests/test_resample_clip_guard.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
"""The audio-copy resample step must not let the 16-bit save silently clamp
overshoot; _guard_resample_overshoot rescales to the input peak instead."""
import torch
import soundfile as sf
from senselab.audio.data_structures.audio import Audio
from b2aiprep.prepare.dataset import _guard_resample_overshoot


def test_overshoot_is_rescaled_not_clamped(tmp_path):
# a waveform that exceeds full-scale (simulating resample overshoot) on a
# source whose own peak was below 1.0
wf = torch.tensor([[0.0, 0.5, -0.98, 1.047, -1.03, 0.2]])
a = Audio(waveform=wf, sampling_rate=16000)
guarded, scale = _guard_resample_overshoot(a, in_peak=0.98)
assert scale is not None and 0 < scale < 1
assert float(guarded.waveform.abs().max()) <= 1.0
# peak restored to the input peak (gain preserved), not clamped to 1.0
assert abs(float(guarded.waveform.abs().max()) - 0.98) < 1e-6
# and it actually avoids clipping through a real 16-bit save round-trip
dst = tmp_path / "g.wav"
guarded.save_to_file(str(dst), bits_per_sample=16)
x, _ = sf.read(str(dst))
assert (abs(x) >= 0.9995).mean() == 0.0


def test_in_range_is_noop():
wf = torch.tensor([[0.0, 0.5, -0.9, 0.99]])
a = Audio(waveform=wf, sampling_rate=16000)
guarded, scale = _guard_resample_overshoot(a, in_peak=0.99)
assert scale is None
assert guarded is a
Loading