Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 33 additions & 4 deletions src/agents/voice/input.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,19 +19,48 @@ def _buffer_to_audio_file(
sample_width: int = 2,
channels: int = 1,
) -> tuple[str, io.BytesIO, str]:
if sample_width not in {1, 2, 3, 4}:
raise UserError("Sample width must be between 1 and 4 bytes")

if buffer.dtype == np.float32:
# convert to int16
buffer = np.clip(buffer, -1.0, 1.0)
buffer = (buffer * 32767).astype(np.int16)
clipped_buffer = np.clip(buffer, -1.0, 1.0)
if sample_width == 1:
audio_bytes = (
np.rint((clipped_buffer.astype(np.float64) + 1.0) * 127.5)
.astype(np.uint8)
.tobytes()
)
elif sample_width == 2:
# Keep the established float32-to-PCM16 quantization unchanged.
audio_bytes = (clipped_buffer * 32767).astype("<i2").tobytes()
else:
max_sample_value = (1 << (sample_width * 8 - 1)) - 1
pcm_buffer = (clipped_buffer.astype(np.float64) * max_sample_value).astype(np.int32)
if sample_width == 3:
pcm_32 = np.ascontiguousarray(pcm_buffer, dtype="<i4")
audio_bytes = pcm_32.view(np.uint8).reshape(-1, 4)[:, :3].tobytes()
else:
audio_bytes = pcm_buffer.astype("<i4").tobytes()
elif buffer.dtype != np.int16:
raise UserError("Buffer must be a numpy array of int16 or float32")
elif sample_width == 1:
audio_bytes = ((buffer.astype(np.int32) >> 8) + 128).astype(np.uint8).tobytes()
else:
pcm_buffer = buffer.astype(np.int32) << (8 * (sample_width - 2))
if sample_width == 2:
audio_bytes = pcm_buffer.astype("<i2").tobytes()
elif sample_width == 3:
pcm_32 = np.ascontiguousarray(pcm_buffer, dtype="<i4")
audio_bytes = pcm_32.view(np.uint8).reshape(-1, 4)[:, :3].tobytes()
else:
audio_bytes = pcm_buffer.astype("<i4").tobytes()

audio_file = io.BytesIO()
with wave.open(audio_file, "w") as wav_file:
wav_file.setnchannels(channels)
wav_file.setsampwidth(sample_width)
wav_file.setframerate(frame_rate)
wav_file.writeframes(buffer.tobytes())
wav_file.writeframes(audio_bytes)
audio_file.seek(0)

# (filename, bytes, content_type)
Expand Down
45 changes: 45 additions & 0 deletions tests/voice/test_input.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,51 @@ def test_buffer_to_audio_file_float32():
assert wav_file.getnframes() == len(buffer)


@pytest.mark.parametrize("dtype", [np.int16, np.float32])
@pytest.mark.parametrize("sample_width", [1, 2, 3, 4])
def test_buffer_to_audio_file_honors_sample_width(dtype, sample_width):
buffer = np.array([-1000, 0, 1000, 2000], dtype=dtype)

_, audio_file, _ = _buffer_to_audio_file(buffer, sample_width=sample_width)

with wave.open(audio_file, "rb") as wav_file:
audio_bytes = wav_file.readframes(wav_file.getnframes())
assert wav_file.getsampwidth() == sample_width
assert wav_file.getnframes() == len(buffer)
assert len(audio_bytes) == len(buffer) * sample_width


def test_buffer_to_audio_file_preserves_int16_amplitude_across_sample_widths():
buffer = np.array([-32768, -1, 0, 1, 32767], dtype=np.int16)

_, audio_file_8, _ = _buffer_to_audio_file(buffer, sample_width=1)
with wave.open(audio_file_8, "rb") as wav_file:
assert list(wav_file.readframes(wav_file.getnframes())) == [0, 127, 128, 128, 255]

_, audio_file_32, _ = _buffer_to_audio_file(buffer, sample_width=4)
with wave.open(audio_file_32, "rb") as wav_file:
decoded = np.frombuffer(wav_file.readframes(wav_file.getnframes()), dtype="<i4")
assert np.array_equal(decoded, buffer.astype(np.int32) << 16)


def test_buffer_to_audio_file_keeps_default_float32_quantization():
buffer = np.array([-0.60606706, -0.5, 0.0, 0.5, 0.89654225], dtype=np.float32)
expected = (np.clip(buffer, -1.0, 1.0) * 32767).astype(np.int16)

_, audio_file, _ = _buffer_to_audio_file(buffer)

with wave.open(audio_file, "rb") as wav_file:
decoded = np.frombuffer(wav_file.readframes(wav_file.getnframes()), dtype="<i2")
assert np.array_equal(decoded, expected)


def test_buffer_to_audio_file_rejects_unsupported_sample_width():
buffer = np.zeros(4, dtype=np.int16)

with pytest.raises(UserError, match="Sample width must be between 1 and 4 bytes"):
_buffer_to_audio_file(buffer, sample_width=5)


def test_buffer_to_audio_file_invalid_dtype():
# Create a buffer with invalid dtype (float64)
buffer = np.array([1.0, 2.0, 3.0], dtype=np.float64)
Expand Down
Loading