diff --git a/pyrit/prompt_target/openai/openai_realtime_target.py b/pyrit/prompt_target/openai/openai_realtime_target.py index 3894ddc623..3f612ce511 100644 --- a/pyrit/prompt_target/openai/openai_realtime_target.py +++ b/pyrit/prompt_target/openai/openai_realtime_target.py @@ -837,14 +837,7 @@ async def send_audio_async( """ connection = self._get_connection(conversation_id=conversation_id) - with wave.open(filename, "rb") as wav_file: - # Read WAV parameters - num_channels = wav_file.getnchannels() - sample_width = wav_file.getsampwidth() # Should be 2 bytes for PCM16 - frame_rate = wav_file.getframerate() - num_frames = wav_file.getnframes() - - audio_content = wav_file.readframes(num_frames) + audio_content, num_channels, sample_width, frame_rate = await asyncio.to_thread(self._read_wav_file, filename) receive_tasks = asyncio.create_task(self.receive_events_async(conversation_id=conversation_id)) @@ -883,3 +876,13 @@ async def _construct_message_from_response_async(self, response: Any, request: A This implementation exists to satisfy the abstract base class requirement. """ raise NotImplementedError("RealtimeTarget uses receive_events for message construction") + + @staticmethod + def _read_wav_file(filename: str) -> tuple[bytes, int, int, int]: + with wave.open(filename, "rb") as wav_file: + return ( + wav_file.readframes(wav_file.getnframes()), + wav_file.getnchannels(), + wav_file.getsampwidth(), + wav_file.getframerate(), + ) diff --git a/tests/unit/prompt_target/target/test_realtime_target.py b/tests/unit/prompt_target/target/test_realtime_target.py index 6a4959e76d..61913c18a4 100644 --- a/tests/unit/prompt_target/target/test_realtime_target.py +++ b/tests/unit/prompt_target/target/test_realtime_target.py @@ -903,6 +903,36 @@ def _write_wav( return str(path) +async def test_send_audio_async_reads_wav_off_event_loop(target, tmp_path): + connection = AsyncMock() + target._existing_conversation["conv"] = connection + target.receive_events_async = AsyncMock( + return_value=RealtimeTargetResult(audio_bytes=b"response", transcripts=["transcript"]) + ) + target.send_response_create_async = AsyncMock() + target.save_audio_async = AsyncMock(return_value="output.wav") + + pcm = b"\x01\x02" * 8 + wav_path = _write_wav(tmp_path / "input.wav", pcm=pcm) + with patch( + "pyrit.prompt_target.openai.openai_realtime_target.asyncio.to_thread", + new_callable=AsyncMock, + wraps=asyncio.to_thread, + ) as to_thread_mock: + output_path, _ = await target.send_audio_async(filename=wav_path, conversation_id="conv") + + assert output_path == "output.wav" + assert to_thread_mock.await_args.args[1] == wav_path + connection.conversation.item.create.assert_awaited_once_with( + item={ + "type": "message", + "role": "user", + "content": [{"type": "input_audio", "audio": base64.b64encode(pcm).decode("utf-8")}], + } + ) + target.save_audio_async.assert_awaited_once_with(b"response", 1, 2, 24000) + + async def test_send_prompt_audio_path_calls_send_audio_async(target, tmp_path): """An audio_path message is routed through the atomic send_audio_async path.""" wav_path = _write_wav(tmp_path / "in.wav")