Skip to content

Fix float-to-integer PCM conversion in to_wav (int8 crash, norm=False silence, truncation bias)#444

Open
gaoflow wants to merge 1 commit into
LCAV:masterfrom
gaoflow:fix-to-wav-float-to-pcm
Open

Fix float-to-integer PCM conversion in to_wav (int8 crash, norm=False silence, truncation bias)#444
gaoflow wants to merge 1 commit into
LCAV:masterfrom
gaoflow:fix-to-wav-float-to-pcm

Conversation

@gaoflow

@gaoflow gaoflow commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

MicrophoneArray.to_wav converts a float signal to the requested bitdepth with a bare
np.array(signal, dtype=bitdepth), i.e. no float-to-PCM conversion at all. That breaks the
write side of the same dtype handling #440 (read) and #443 (normalize) already fixed:

  • norm=False + any integer bitdepth silently writes an all-zero file. Simulation signals are
    floats in ~[-1, 1]; the bare cast truncates every sample to 0. No error, just silence on disk.
  • bitdepth=np.int8 always raises ValueError: Unsupported data type 'int8'. WAV 8-bit PCM is
    unsigned; the documented int8 option can't work with a signed cast. This is the write side of
    the unsigned-8-bit format Fix to_float32 producing nan/inf for unsigned-integer (8-bit WAV) input #440 taught to_float32 to read.
  • Truncation instead of rounding on every float->int cast gives a sign-dependent ~1 LSB bias
    (mean -0.5 LSB for x>0, +0.5 for x<0). This hits the documented happy path too
    (examples/room_from_rt60.py uses norm=True, bitdepth=np.int16), and the exported to_16b.

#440 made an 8-bit WAV readable; this makes one writable. The conversion is now the exact inverse of
to_float32: scale by full scale 2**(bits-1), round, clip so the cast can't overflow, and write
int8 as unsigned offset-binary. So to_wav -> wavfile.read -> to_float32 round-trips.

Round-trip residual (write -> read -> to_float32, 1 kHz tone), before vs after:

bitdepth   norm     before                 after
int8       False    ValueError             r=1.000 (uint8 on disk)
int8       True     ValueError             r=1.000 (uint8 on disk)
int16      False    all-zero file          resid 2.0e-05
int16      True     resid 2.85e-05         resid 1.6e-05   (rounding)
int32/64   False    all-zero file          resid ~3e-08
to_16b     n/a      err [-1, +1] LSB       err [-0.5, +0.5] LSB, no bias

One judgment call: norm=False + a float signal + integer bitdepth goes from "all-zero garbage" to
"input assumed full-scale [-1, 1], clipped" (the soundfile/librosa convention). Nothing sane consumes
the current all-zero output, but if you'd rather raise for out-of-range input than clip, happy to
switch that hunk.

Tests in tests/test_to_wav.py cover every bitdepth x norm round-trip (including the previously
crashing int8 and all-zero norm=False rows), the uint8-on-disk / silence->128 behavior,
full-scale saturation without wraparound (int64 is the sharp case: 2**63-1 isn't float64
representable), rounding, multichannel, integer-input passthrough, and the to_16b bias. Ran
tests/test_to_wav.py plus test_to_float32, test_normalize, test_microphone_array,
test_metrics, test_create_noisy_signal locally (59 passing); did not run the heavy
simulation/DOA suites.

MicrophoneArray.to_wav wrote np.array(signal, dtype=bitdepth) with no
quantization, so the float->integer-PCM path was broken three ways:

- bitdepth=np.int8 raised ValueError (WAV 8-bit PCM is unsigned); it is
  now written as unsigned offset-binary, the inverse of what to_float32
  reads back.
- norm=False with any integer bitdepth truncated a [-1, 1] float signal
  to an all-zero file. Float signals are now scaled to full scale.
- casts truncated toward zero, giving a sign-dependent ~1 LSB bias.
  Samples are rounded and clipped so the cast cannot overflow.

to_16b rounds as well.
@fakufaku
fakufaku self-requested a review July 18, 2026 00:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant