From 857dbe3c7f6bfaf9b3e13c024a222db7580d5626 Mon Sep 17 00:00:00 2001 From: Anton Soloviev Date: Sat, 17 Oct 2026 19:51:14 -0700 Subject: [PATCH 1/3] Use the midpoint for unsigned 8-bit playback silence The convenience playback callback fills unused channels and the final partial block with silence. Use 128 for uint8 PCM, matching the PortAudio sample format, and retain zero for signed and floating formats. Cover final padding, channel mapping and looped playback. --- src/sounddevice.py | 5 ++-- tests/test_callback_context.py | 54 ++++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 2 deletions(-) create mode 100644 tests/test_callback_context.py diff --git a/src/sounddevice.py b/src/sounddevice.py index b3268cb..63cde31 100644 --- a/src/sounddevice.py +++ b/src/sounddevice.py @@ -2634,17 +2634,18 @@ def read_indata(self, indata): indata[:self.blocksize, source] def write_outdata(self, outdata): + silence = 128 if self.output_dtype == 'uint8' else 0 # 'float64' data is cast to 'float32' here: outdata[:self.blocksize, self.output_mapping] = \ self.data[self.frame:self.frame + self.blocksize] - outdata[:self.blocksize, self.silent_channels] = 0 + outdata[:self.blocksize, self.silent_channels] = silence if self.loop and self.blocksize < len(outdata): self.frame = 0 outdata = outdata[self.blocksize:] self.blocksize = min(self.frames, len(outdata)) self.write_outdata(outdata) else: - outdata[self.blocksize:] = 0 + outdata[self.blocksize:] = silence def callback_exit(self): if not self.blocksize: diff --git a/tests/test_callback_context.py b/tests/test_callback_context.py new file mode 100644 index 0000000..e325844 --- /dev/null +++ b/tests/test_callback_context.py @@ -0,0 +1,54 @@ +"""Regression checks for the NumPy convenience playback callback.""" +import unittest +from unittest.mock import patch + +import numpy as np +import sounddevice as sd + + +class PlaybackSilenceTests(unittest.TestCase): + + def test_silent_channels_and_final_padding(self): + for dtype in ('uint8', 'int16', 'float32', 'float64'): + for mapping in (None, [1], [2], [3, 1]): + with self.subTest(dtype=dtype, mapping=mapping): + data = np.arange(1, 7).reshape(3, 2).astype(dtype) + if dtype.startswith('float'): + data /= 10 + if mapping != [3, 1]: + data = data[:, :1] + context = sd._CallbackContext() + device_info = {'max_output_channels': 2} + with patch('sounddevice.query_devices', return_value=device_info): + context.frames = context.check_data(data, mapping, None) + output = np.empty((8, context.output_channels), context.output_dtype) + context.callback_enter(sd.CallbackFlags(), output) + context.write_outdata(output) + silence = 128 if dtype == 'uint8' else 0 + np.testing.assert_array_equal(output[3:], silence) + np.testing.assert_array_equal( + output[:, context.silent_channels], silence) + for channel, target in enumerate(context.output_mapping): + source = 0 if data.shape[1] == 1 else channel + expected = data[:, source].astype(output.dtype) + np.testing.assert_array_equal(output[:3, target], expected) + + def test_looped_audio_keeps_unmapped_channels_silent(self): + for dtype in ('uint8', 'int16', 'float32', 'float64'): + with self.subTest(dtype=dtype): + data = np.array([124, 128, 132], dtype=dtype) + if dtype.startswith('float'): + data /= 256 + context = sd._CallbackContext(loop=True) + context.frames = context.check_data(data, [2], None) + output = np.empty((8, 2), context.output_dtype) + context.callback_enter(sd.CallbackFlags(), output) + context.write_outdata(output) + np.testing.assert_array_equal( + output[:, 1], np.resize(data, 8).astype(output.dtype)) + np.testing.assert_array_equal( + output[:, 0], 128 if dtype == 'uint8' else 0) + + +if __name__ == '__main__': + unittest.main() From 4c9179b768b84b6b41aafceab0fae124179d60cf Mon Sep 17 00:00:00 2001 From: Anton Soloviev Date: Sat, 17 Oct 2026 19:54:54 -0700 Subject: [PATCH 2/3] Skip convenience tests when NumPy is not installed --- tests/test_callback_context.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/test_callback_context.py b/tests/test_callback_context.py index e325844..6a0b8bd 100644 --- a/tests/test_callback_context.py +++ b/tests/test_callback_context.py @@ -2,10 +2,15 @@ import unittest from unittest.mock import patch -import numpy as np import sounddevice as sd +try: + import numpy as np +except ImportError: + np = None + +@unittest.skipIf(np is None, 'NumPy required for convenience playback') class PlaybackSilenceTests(unittest.TestCase): def test_silent_channels_and_final_padding(self): From 87708d000577be72663872a49d4c04af75e25ba1 Mon Sep 17 00:00:00 2001 From: Anton Soloviev Date: Sun, 18 Oct 2026 03:48:33 -0700 Subject: [PATCH 3/3] Keep playback silence change separate from test setup --- tests/test_callback_context.py | 59 ---------------------------------- 1 file changed, 59 deletions(-) delete mode 100644 tests/test_callback_context.py diff --git a/tests/test_callback_context.py b/tests/test_callback_context.py deleted file mode 100644 index 6a0b8bd..0000000 --- a/tests/test_callback_context.py +++ /dev/null @@ -1,59 +0,0 @@ -"""Regression checks for the NumPy convenience playback callback.""" -import unittest -from unittest.mock import patch - -import sounddevice as sd - -try: - import numpy as np -except ImportError: - np = None - - -@unittest.skipIf(np is None, 'NumPy required for convenience playback') -class PlaybackSilenceTests(unittest.TestCase): - - def test_silent_channels_and_final_padding(self): - for dtype in ('uint8', 'int16', 'float32', 'float64'): - for mapping in (None, [1], [2], [3, 1]): - with self.subTest(dtype=dtype, mapping=mapping): - data = np.arange(1, 7).reshape(3, 2).astype(dtype) - if dtype.startswith('float'): - data /= 10 - if mapping != [3, 1]: - data = data[:, :1] - context = sd._CallbackContext() - device_info = {'max_output_channels': 2} - with patch('sounddevice.query_devices', return_value=device_info): - context.frames = context.check_data(data, mapping, None) - output = np.empty((8, context.output_channels), context.output_dtype) - context.callback_enter(sd.CallbackFlags(), output) - context.write_outdata(output) - silence = 128 if dtype == 'uint8' else 0 - np.testing.assert_array_equal(output[3:], silence) - np.testing.assert_array_equal( - output[:, context.silent_channels], silence) - for channel, target in enumerate(context.output_mapping): - source = 0 if data.shape[1] == 1 else channel - expected = data[:, source].astype(output.dtype) - np.testing.assert_array_equal(output[:3, target], expected) - - def test_looped_audio_keeps_unmapped_channels_silent(self): - for dtype in ('uint8', 'int16', 'float32', 'float64'): - with self.subTest(dtype=dtype): - data = np.array([124, 128, 132], dtype=dtype) - if dtype.startswith('float'): - data /= 256 - context = sd._CallbackContext(loop=True) - context.frames = context.check_data(data, [2], None) - output = np.empty((8, 2), context.output_dtype) - context.callback_enter(sd.CallbackFlags(), output) - context.write_outdata(output) - np.testing.assert_array_equal( - output[:, 1], np.resize(data, 8).astype(output.dtype)) - np.testing.assert_array_equal( - output[:, 0], 128 if dtype == 'uint8' else 0) - - -if __name__ == '__main__': - unittest.main()