From 7603230061873c3c692069d0b7807246f4a5c3cf Mon Sep 17 00:00:00 2001 From: Henry Su Date: Mon, 10 Aug 2026 00:16:39 -0500 Subject: [PATCH 1/2] fix(helpers): reshape 1-D float32 audio in LocalAudioPlayer `LocalAudioPlayer.play()` and `play_stream()` reshape int16 input to `(-1, channels)` but left float32 input untouched. A 1-D float32 array (e.g. a mono waveform from soundfile/librosa, which is a valid input per the `play()` type signature) then broke: - `play()` raised `IndexError: tuple index out of range` from `channels=audio_content.shape[1]` when building the output stream. - `play_stream()` raised a broadcasting error in the callback when assigning a 1-D slice into the 2-D `outdata` buffer. Reshape float32 input to `(-1, channels)` in both paths, mirroring the existing int16 handling. Already-2-D input is unaffected (no-op reshape). --- src/openai/helpers/local_audio_player.py | 4 +- tests/lib/test_local_audio_player.py | 127 +++++++++++++++++++++++ 2 files changed, 130 insertions(+), 1 deletion(-) create mode 100644 tests/lib/test_local_audio_player.py diff --git a/src/openai/helpers/local_audio_player.py b/src/openai/helpers/local_audio_player.py index 8f12c27a56..da5b16e640 100644 --- a/src/openai/helpers/local_audio_player.py +++ b/src/openai/helpers/local_audio_player.py @@ -65,7 +65,7 @@ async def play( if input.dtype == np.int16 and self.dtype == np.float32: audio_content = (input.astype(np.float32) / 32767.0).reshape(-1, self.channels) elif input.dtype == np.float32: - audio_content = cast("npt.NDArray[np.float32]", input) + audio_content = cast("npt.NDArray[np.float32]", input).reshape(-1, self.channels) else: raise ValueError(f"Unsupported dtype: {input.dtype}") else: @@ -136,6 +136,8 @@ def callback( if current_buffer.dtype == np.int16 and self.dtype == np.float32: current_buffer = (current_buffer.astype(np.float32) / 32767.0).reshape(-1, self.channels) + elif current_buffer.dtype == np.float32: + current_buffer = current_buffer.reshape(-1, self.channels) except queue.Empty: outdata[frames_written:] = 0 diff --git a/tests/lib/test_local_audio_player.py b/tests/lib/test_local_audio_player.py new file mode 100644 index 0000000000..87d0d82f89 --- /dev/null +++ b/tests/lib/test_local_audio_player.py @@ -0,0 +1,127 @@ +from __future__ import annotations + +import asyncio +import threading +from typing import Any, List, Union, AsyncGenerator + +import pytest + +np = pytest.importorskip("numpy") + +from openai.helpers import local_audio_player + + +class FakeCallbackStop(Exception): + """Stand-in for `sounddevice.CallbackStop`.""" + + +class FakeOutputStream: + """Minimal `sounddevice.OutputStream` replacement. + + A real `sounddevice` stream invokes the user supplied `callback` from a + dedicated audio thread while the event loop is free to run. We mirror that + here so the code under test exercises its real control flow without needing + an audio device. + """ + + def __init__(self, *, samplerate: int, callback: Any, dtype: Any, channels: int) -> None: + self.samplerate = samplerate + self.callback = callback + self.dtype = dtype + self.channels = channels + self.written: List[Any] = [] + self.error: BaseException | None = None + self._thread: threading.Thread | None = None + self._frame_count = 2 + + def _run(self) -> None: + while True: + outdata = np.zeros((self._frame_count, self.channels), dtype=self.dtype) + try: + self.callback(outdata, self._frame_count, None, None) + except FakeCallbackStop: + break + except BaseException as exc: # pragma: no cover - only hit by the bug being fixed + self.error = exc + break + self.written.append(outdata.copy()) + + def __enter__(self) -> "FakeOutputStream": + self._thread = threading.Thread(target=self._run) + self._thread.start() + return self + + def __exit__(self, *_args: Any) -> None: + if self._thread is not None: + self._thread.join() + + +class FakeSD: + CallbackStop = FakeCallbackStop + + def __init__(self) -> None: + self.streams: List[FakeOutputStream] = [] + + def OutputStream(self, **kwargs: Any) -> FakeOutputStream: + stream = FakeOutputStream(**kwargs) + self.streams.append(stream) + return stream + + +@pytest.fixture() +def fake_sd(monkeypatch: pytest.MonkeyPatch) -> FakeSD: + sd = FakeSD() + monkeypatch.setattr(local_audio_player, "sd", sd) + return sd + + +async def test_play_accepts_one_dimensional_float32(fake_sd: FakeSD) -> None: + # A 1-D float32 array (e.g. a mono waveform from soundfile / librosa) is a + # valid input per the `play()` type signature. Before the fix this raised + # `IndexError: tuple index out of range` because float32 input was not + # reshaped to `(frames, channels)` like int16 input is. + samples = np.array([0.1, 0.2, 0.3, -0.4, -0.5, 0.6], dtype=np.float32) + + await asyncio.wait_for(local_audio_player.LocalAudioPlayer().play(samples), timeout=5) + + assert len(fake_sd.streams) == 1 + stream = fake_sd.streams[0] + assert stream.error is None + assert stream.channels == 1 + + played = np.concatenate(stream.written)[: len(samples)] + assert played.shape == (len(samples), 1) + assert np.allclose(played[:, 0], samples) + + +async def test_play_accepts_one_dimensional_int16(fake_sd: FakeSD) -> None: + # Regression guard: the pre-existing int16 path must keep working. + samples = np.array([1000, -2000, 3000, -4000], dtype=np.int16) + + await asyncio.wait_for(local_audio_player.LocalAudioPlayer().play(samples), timeout=5) + + stream = fake_sd.streams[0] + assert stream.error is None + played = np.concatenate(stream.written)[: len(samples)] + assert played.shape == (len(samples), 1) + assert np.allclose(played[:, 0], samples.astype(np.float32) / 32767.0) + + +async def test_play_stream_accepts_one_dimensional_float32(fake_sd: FakeSD) -> None: + # `play_stream` has the same root cause: float32 buffers were not reshaped + # to `(frames, channels)`, so the callback raised a broadcasting error. + samples = np.array([0.1, 0.2, 0.3, -0.4, -0.5, 0.6], dtype=np.float32) + + async def buffer_stream() -> AsyncGenerator[Union[Any, None], None]: + yield samples + yield None + + await asyncio.wait_for(local_audio_player.LocalAudioPlayer().play_stream(buffer_stream()), timeout=5) + + stream = fake_sd.streams[0] + assert stream.error is None + + played = np.concatenate(stream.written) + # drop any leading zero-fill frames emitted before the producer delivered data + nonzero = played[played[:, 0] != 0] + assert np.allclose(nonzero[:, 0], samples) From 472919a4323ff9c4ca1587438697427e61170c69 Mon Sep 17 00:00:00 2001 From: Henry Su Date: Sun, 18 Oct 2026 20:44:43 -0500 Subject: [PATCH 2/2] fix(helpers): only reshape 1-D float32 audio in LocalAudioPlayer A 2-D float32 array already carries its channel dimension, which play() uses to open the output stream. Reshaping it to self.channels flattened stereo input into mono. --- src/openai/helpers/local_audio_player.py | 6 ++++-- tests/lib/test_local_audio_player.py | 12 ++++++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/src/openai/helpers/local_audio_player.py b/src/openai/helpers/local_audio_player.py index da5b16e640..5cde9c2fb4 100644 --- a/src/openai/helpers/local_audio_player.py +++ b/src/openai/helpers/local_audio_player.py @@ -65,7 +65,9 @@ async def play( if input.dtype == np.int16 and self.dtype == np.float32: audio_content = (input.astype(np.float32) / 32767.0).reshape(-1, self.channels) elif input.dtype == np.float32: - audio_content = cast("npt.NDArray[np.float32]", input).reshape(-1, self.channels) + audio_content = cast("npt.NDArray[np.float32]", input) + if audio_content.ndim == 1: + audio_content = audio_content.reshape(-1, self.channels) else: raise ValueError(f"Unsupported dtype: {input.dtype}") else: @@ -136,7 +138,7 @@ def callback( if current_buffer.dtype == np.int16 and self.dtype == np.float32: current_buffer = (current_buffer.astype(np.float32) / 32767.0).reshape(-1, self.channels) - elif current_buffer.dtype == np.float32: + elif current_buffer.dtype == np.float32 and current_buffer.ndim == 1: current_buffer = current_buffer.reshape(-1, self.channels) except queue.Empty: diff --git a/tests/lib/test_local_audio_player.py b/tests/lib/test_local_audio_player.py index 87d0d82f89..7b18afb7ea 100644 --- a/tests/lib/test_local_audio_player.py +++ b/tests/lib/test_local_audio_player.py @@ -125,3 +125,15 @@ async def buffer_stream() -> AsyncGenerator[Union[Any, None], None]: # drop any leading zero-fill frames emitted before the producer delivered data nonzero = played[played[:, 0] != 0] assert np.allclose(nonzero[:, 0], samples) + + +async def test_play_preserves_two_dimensional_float32_channels(fake_sd: FakeSD) -> None: + samples = np.array([[0.1, 0.2], [0.3, 0.4], [0.5, 0.6]], dtype=np.float32) + + await asyncio.wait_for(local_audio_player.LocalAudioPlayer().play(samples), timeout=5) + + stream = fake_sd.streams[0] + assert stream.error is None + assert stream.channels == 2 + played = np.concatenate(stream.written)[: len(samples)] + assert np.allclose(played, samples)