From 756fd013762a3c7c89421c9dfdefd65be49a10be Mon Sep 17 00:00:00 2001 From: Dylan Pulver Date: Tue, 1 Sep 2026 18:06:10 +0300 Subject: [PATCH 1/2] Do not yield stale frames from blocks() when out= is shorter than the file The last block was sliced as out[:frames + overlap], where frames is the number of frames still unread. That is only the valid length when the iteration already carries overlap frames at the front of the buffer. On the first pass output_offset is 0, so the slice reaches overlap frames past the data actually read, and with out= those frames are whatever the caller left in the array. The valid length is output_offset + toread on every pass. Slicing to it also removes the need for the blocksize > frames + overlap guard, which did not fire when blocksize == frames + overlap and yielded the whole buffer instead. #446 fixed the same arithmetic for the out is None path by allocating min(blocksize, frames); a caller-supplied array cannot be shrunk, so that path kept the defect. --- soundfile.py | 5 +++-- tests/test_soundfile.py | 12 ++++++++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/soundfile.py b/soundfile.py index 0bf3a6e..d02d5dd 100644 --- a/soundfile.py +++ b/soundfile.py @@ -1206,8 +1206,9 @@ def blocks(self, blocksize: int | None = None, overlap: int = 0, else: overlap_memory[:] = out[-overlap:] - if blocksize > frames + overlap and fill_value is None: - block = out[:frames + overlap] + valid_frames = output_offset + toread + if valid_frames < len(out) and fill_value is None: + block = out[:valid_frames] else: block = out yield np.copy(block) if copy_out else block diff --git a/tests/test_soundfile.py b/tests/test_soundfile.py index 0c93f66..30ce37b 100644 --- a/tests/test_soundfile.py +++ b/tests/test_soundfile.py @@ -464,6 +464,18 @@ def test_blocks_inplace_modification(file_stereo_r): assert_equal_list_of_arrays(blocks, expected_blocks) +@pytest.mark.parametrize("blocksize", [len(data_stereo) + 2, len(data_stereo) + 4]) +def test_blocks_with_out_longer_than_file_and_overlap(file_stereo_r, blocksize): + # The file is shorter than out, so there is exactly one block and it must + # not reach past the end of the file. The two blocksizes cover both sides + # of blocksize > frames + overlap. + out = np.full((blocksize, 2), 999.0) + blocks = list(sf.blocks(file_stereo_r, out=out, overlap=2)) + assert len(blocks) == 1 + assert blocks[0].shape == data_stereo.shape + assert np.all(blocks[0] == data_stereo) + + def test_blocks_mono(): blocks = list(sf.blocks(filename_mono, blocksize=3, dtype='int16')) assert_equal_list_of_arrays(blocks, [[0, 1, 2], [-2, -1]]) From 5ae5cbdaf6d917d24d6c3e86a4fa49e3a9627d05 Mon Sep 17 00:00:00 2001 From: Dylan Pulver Date: Sat, 3 Oct 2026 06:32:12 +0200 Subject: [PATCH 2/2] Use an early exit for the short-file case instead of the loop condition Switches to the approach bastibe suggested in review. Rather than correcting the block-length condition inside the loop, `blocks()` now checks up front whether less than one block of data is available and, if so, reads that short count and yields it directly. The loop condition goes back to what it was on master. This covers the whole bug, not just part of it. Comparing the old condition against the actual valid-frame count across every combination of frames 1..8, blocksize 2..8 and overlap 0..4 gives 101 divergent cases, and in every one of them frames < blocksize and overlap >= 1. So the defect needs both a short file and a non-zero overlap, which is exactly the case the early exit now takes. The guard keeps `frames > 0` so an empty read still yields nothing, and keeps `fill_value is None` so a padded block is still returned whole. --- soundfile.py | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/soundfile.py b/soundfile.py index d02d5dd..c4b71bd 100644 --- a/soundfile.py +++ b/soundfile.py @@ -1189,6 +1189,17 @@ def blocks(self, blocksize: int | None = None, overlap: int = 0, blocksize = len(out) copy_out = False + if 0 < frames < blocksize and fill_value is None: + # Less than one block of data, so the loop below would run exactly + # once and `out` would keep whatever sat past the data. Reading the + # short count and yielding only that much avoids it. Only reachable + # when the caller passed `out`, since otherwise `out` is already + # sized to min(blocksize, frames). + self.read(frames, dtype, always_2d, fill_value, out[:frames]) + block = out[:frames] + yield np.copy(block) if copy_out else block + return + overlap_memory = None while frames > 0: if overlap_memory is None: @@ -1206,9 +1217,8 @@ def blocks(self, blocksize: int | None = None, overlap: int = 0, else: overlap_memory[:] = out[-overlap:] - valid_frames = output_offset + toread - if valid_frames < len(out) and fill_value is None: - block = out[:valid_frames] + if blocksize > frames + overlap and fill_value is None: + block = out[:frames + overlap] else: block = out yield np.copy(block) if copy_out else block