Skip to content

torchaudio.save silently saturates PCM-scale int16 input after the TorchCodec migration #4211

Description

@John6666cat

🐛 Describe the bug

Summary

In TorchAudio 2.11.0, torchaudio.save() accepts PCM-scale torch.int16 input without an exception or warning, casts it to float32 without rescaling, and forwards it to TorchCodec.

For example, an int16 sample value of 26212 becomes the float value 26212.0, although TorchCodec's AudioEncoder expects float32 samples in [-1, 1].

The resulting WAV is silently saturated: almost every non-zero sample becomes either -32768 or 32767. The sign is preserved, but the amplitude information is lost.

Even if integer input is no longer intended to be supported, silently accepting it and producing a corrupted file seems unsafe. It should either preserve the legacy PCM integer semantics or fail clearly.

Environment

  • Python: 3.12.13
  • OS: Linux x86_64
  • PyTorch: 2.11.0+cpu
  • TorchAudio: 2.11.0+cpu
  • TorchCodec: 0.15.0+cpu
  • NumPy: 2.5.1
  • CUDA: not used

The test was run in a clean, isolated CPU environment.

Minimal reproduction

from array import array
import sys
import warnings
import wave

import torch
import torchaudio

sample_rate = 24000
num_samples = sample_rate

t = torch.arange(num_samples, dtype=torch.float32) / sample_rate

normalized = (
    0.72 * torch.sin(2 * torch.pi * 440.0 * t)
    + 0.08 * torch.sin(2 * torch.pi * 997.0 * t)
).clamp(-0.8, 0.8).unsqueeze(0).contiguous()

pcm_scale_int16 = (
    torch.round(normalized * 32767.0)
    .clamp(-32767, 32767)
    .to(torch.int16)
)


def inspect_pcm16(path):
    with wave.open(path, "rb") as wav_file:
        raw = wav_file.readframes(wav_file.getnframes())

    samples = array("h")
    samples.frombytes(raw)

    if sys.byteorder != "little":
        samples.byteswap()

    values = list(samples)
    fullscale = sum(
        value <= -32768 or value >= 32767
        for value in values
    ) / len(values)

    return {
        "min": min(values),
        "max": max(values),
        "unique_values": len(set(values)),
        "fullscale_fraction": fullscale,
    }


for name, samples in [
    ("normalized_float32", normalized),
    ("pcm_scale_int16", pcm_scale_int16),
]:
    path = f"{name}.wav"

    with warnings.catch_warnings(record=True) as caught:
        warnings.simplefilter("always")
        torchaudio.save(path, samples, sample_rate)

    print(name)
    print("warnings:", len(caught))
    print(inspect_pcm16(path))

Actual behavior

Observed output:

normalized_float32
warnings: 0
{
    'min': -26213,
    'max': 26213,
    'unique_values': 18849,
    'fullscale_fraction': 0.0
}

pcm_scale_int16
warnings: 0
{
    'min': -32768,
    'max': 32767,
    'unique_values': 3,
    'fullscale_fraction': 0.999875
}

The PCM-scale int16 output contains effectively only:

-32768, 0, 32767

The same result occurs with torchaudio.save_with_torchcodec().

A direct TorchCodec AudioEncoder call rejects the original int16 tensor:

ValueError: Expected float32 samples, got samples.dtype = torch.int16.

However, TorchAudio currently converts the int16 tensor with:

if src.dtype != torch.float32:
    src = src.float()

This bypasses TorchCodec's dtype rejection but does not convert the PCM scale to the documented [-1, 1] range.

Full reproduction notebook

The following public notebook contains the complete executable reproduction, saved outputs, comparison table, and checks for:

  • torchaudio.save()
  • torchaudio.save_with_torchcodec()
  • direct AudioEncoder.to_file()
  • normalized float32 input
  • PCM-scale int16 input
  • PCM-scale float32 input
  • out-of-range float32 input

https://huggingface.co/datasets/John6666/forum3/blob/main/torchaudio_torchcodec_audio_save_reproducer/torchaudio_torchcodec_audio_save_reproducer_final.ipynb

All automated observations in the notebook evaluate to OBSERVED.

Expected behavior

Any of the following would avoid silent corruption:

  1. Preserve the previous integer PCM semantics by converting integer tensors to normalized float32 according to their dtype range before forwarding them to TorchCodec.
  2. Reject integer input with a clear ValueError explaining that normalized float32 input in [-1, 1] is required.
  3. At minimum, emit a clear warning before converting integer input in a way that changes its amplitude interpretation.

An exception would be preferable to silently writing a severely corrupted audio file.

Why this appears to be a TorchAudio wrapper issue

Direct TorchCodec rejects int16 input based on its dtype.

The silent corruption occurs because the TorchAudio compatibility wrapper accepts that input, converts it to float32 without scaling, and then passes the out-of-range values to TorchCodec.

Current implementation:

https://github.com/pytorch/audio/blob/main/src/torchaudio/_torchcodec.py

Related migration history

PR #4039 changed torchaudio.save() to rely on save_with_torchcodec() so that code using the old API could continue to run:

#4039

During that PR, an out-of-range float value of approximately -4.0688 was observed to become -1.0 after TorchCodec encoding. The test input was subsequently changed or skipped, but the legacy PCM-scale integer case does not appear to have been covered.

I did not find an existing issue that specifically reports this silent int16 compatibility failure.

Workaround

Convert PCM-scale audio back to normalized float32 before saving:

torchaudio.save(
    output_path,
    pcm_scale_audio.to(torch.float32) / 32767.0,
    sample_rate,
)

Alternatively, remain on the pre-migration TorchAudio save implementation where that is practical.

AI assistance disclosure: AI assistance was used to organize and edit this report and the accompanying reproduction notebook. I ran the experiments and reviewed all results and technical claims. The reproduction, observations, and conclusions above reflect my own review.

Versions

--2026-07-30 04:27:11-- https://raw.githubusercontent.com/pytorch/pytorch/main/torch/utils/collect_env.py
Resolving raw.githubusercontent.com (raw.githubusercontent.com)... 185.199.108.133, 185.199.109.133, 185.199.110.133, ...
Connecting to raw.githubusercontent.com (raw.githubusercontent.com)|185.199.108.133|:443... connected.
HTTP request sent, awaiting response... 200 OK
Length: 31107 (30K) [text/plain]
Saving to: ‘collect_env.py’

collect_env.py 100%[===================>] 30.38K --.-KB/s in 0.01s

2026-07-30 04:27:11 (3.02 MB/s) - ‘collect_env.py’ saved [31107/31107]

Collecting environment information...
PyTorch version: 2.11.0+cpu
Is debug build: False
CUDA used to build PyTorch: None
ROCM used to build PyTorch: N/A

OS: Ubuntu 22.04.5 LTS (x86_64)
GCC version: (Ubuntu 11.4.0-1ubuntu1~22.04.3) 11.4.0
Clang version: Could not collect
CMake version: version 3.31.10
Libc version: glibc-2.35

Python version: 3.12.13 (main, Mar 4 2026, 09:23:07) [GCC 11.4.0] (64-bit runtime)
Python platform: Linux-6.6.122+-x86_64-with-glibc2.35
Is CUDA available: False
CUDA runtime version: No CUDA
CUDA_MODULE_LOADING set to: N/A
GPU models and configuration: No CUDA
Nvidia driver version: No CUDA
cuDNN version: No CUDA
Is XPU available: False
HIP runtime version: N/A
MIOpen runtime version: N/A
Is XNNPACK available: True
Caching allocator config: N/A

CPU:
Architecture: x86_64
CPU op-mode(s): 32-bit, 64-bit
Address sizes: 48 bits physical, 48 bits virtual
Byte Order: Little Endian
CPU(s): 2
On-line CPU(s) list: 0,1
Vendor ID: AuthenticAMD
Model name: AMD EPYC 7B12
CPU family: 23
Model: 49
Thread(s) per core: 2
Core(s) per socket: 1
Socket(s): 1
Stepping: 0
BogoMIPS: 4499.99
Flags: fpu vme de pse tsc msr pae mce cx8 apic sep mtrr pge mca cmov pat pse36 clflush mmx fxsr sse sse2 ht syscall nx mmxext fxsr_opt pdpe1gb rdtscp lm constant_tsc rep_good nopl nonstop_tsc cpuid extd_apicid tsc_known_freq pni pclmulqdq ssse3 fma cx16 sse4_1 sse4_2 x2apic movbe popcnt aes xsave avx f16c rdrand hypervisor lahf_lm cmp_legacy cr8_legacy abm sse4a misalignsse 3dnowprefetch osvw topoext ssbd ibrs ibpb stibp vmmcall fsgsbase tsc_adjust bmi1 avx2 smep bmi2 rdseed adx smap clflushopt clwb sha_ni xsaveopt xsavec xgetbv1 clzero xsaveerptr arat umip rdpid
Hypervisor vendor: KVM
Virtualization type: full
L1d cache: 32 KiB (1 instance)
L1i cache: 32 KiB (1 instance)
L2 cache: 512 KiB (1 instance)
L3 cache: 16 MiB (1 instance)
NUMA node(s): 1
NUMA node0 CPU(s): 0,1
Vulnerability Gather data sampling: Not affected
Vulnerability Indirect target selection: Not affected
Vulnerability Itlb multihit: Not affected
Vulnerability L1tf: Not affected
Vulnerability Mds: Not affected
Vulnerability Meltdown: Not affected
Vulnerability Mmio stale data: Not affected
Vulnerability Reg file data sampling: Not affected
Vulnerability Retbleed: Vulnerable
Vulnerability Spec rstack overflow: Vulnerable
Vulnerability Spec store bypass: Vulnerable
Vulnerability Spectre v1: Vulnerable: __user pointer sanitization and usercopy barriers only; no swapgs barriers
Vulnerability Spectre v2: Vulnerable; IBPB: disabled; STIBP: disabled; PBRSB-eIBRS: Not affected; BHI: Not affected
Vulnerability Srbds: Not affected
Vulnerability Tsa: Not affected
Vulnerability Tsx async abort: Not affected
Vulnerability Vmscape: Not affected

Versions of relevant libraries:
[pip3] intel-cmplr-lib-ur==2025.3.3
[pip3] intel-openmp==2025.3.3
[pip3] mkl==2025.3.1
[pip3] numpy==2.0.2
[pip3] nvidia-nccl-cu12==2.30.7
[pip3] onemkl-license==2025.3.1
[pip3] optree==0.19.1
[pip3] tbb==2022.3.1
[pip3] tcmlib==1.5.0
[pip3] torch==2.11.0+cpu
[pip3] torchao==0.10.0
[pip3] torchaudio==2.11.0+cpu
[pip3] torchcodec==0.11.0+cpu
[pip3] torchdata==0.11.0
[pip3] torchsummary==1.5.1
[pip3] torchtune==0.6.1
[pip3] torchvision==0.26.0+cpu
[pip3] umf==1.0.3
[conda] Could not collect

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions