Co-authored-by: Clément Drouin <clement.drouin@mistral.ai> Co-authored-by: Clément Sirieix <clement.sirieix@mistral.ai> Co-authored-by: Gauthier Guinet <43207538+Gguinet@users.noreply.github.com> Co-authored-by: Kim-Adeline Miguel <kimadeline.miguel@mistral.ai> Co-authored-by: Michel Thomazo <51709227+michelTho@users.noreply.github.com> Co-authored-by: Quentin <torroba.q@gmail.com> Co-authored-by: Simon <80467011+sorgfresser@users.noreply.github.com> Co-authored-by: Simon Van de Kerckhove <simon.vandekerckhove@mistral.ai> Co-authored-by: Vincent G <10739306+VinceOPS@users.noreply.github.com> Co-authored-by: angelapopopo <angele.lenglemetz@mistral.ai> Co-authored-by: Mistral Vibe <vibe@mistral.ai>
206 lines
7.1 KiB
Python
206 lines
7.1 KiB
Python
from __future__ import annotations
|
|
|
|
import io
|
|
import struct
|
|
from unittest.mock import MagicMock, patch
|
|
import wave
|
|
|
|
import pytest
|
|
|
|
try:
|
|
import sounddevice as sd
|
|
except OSError:
|
|
pytest.skip("PortAudio library not available", allow_module_level=True)
|
|
|
|
from vibe.core.audio_player.audio_player import AudioPlayer
|
|
from vibe.core.audio_player.audio_player_port import (
|
|
AlreadyPlayingError,
|
|
AudioBackendUnavailableError,
|
|
AudioFormat,
|
|
NoAudioOutputDeviceError,
|
|
UnsupportedAudioFormatError,
|
|
)
|
|
|
|
|
|
def _make_wav_bytes(
|
|
n_frames: int = 1024, sample_rate: int = 48_000, channels: int = 1
|
|
) -> bytes:
|
|
buf = io.BytesIO()
|
|
with wave.open(buf, "wb") as wf:
|
|
wf.setnchannels(channels)
|
|
wf.setsampwidth(2)
|
|
wf.setframerate(sample_rate)
|
|
wf.writeframes(
|
|
struct.pack(f"<{n_frames * channels}h", *([1000] * n_frames * channels))
|
|
)
|
|
return buf.getvalue()
|
|
|
|
|
|
def _get_callback(mock_stream_cls: MagicMock):
|
|
return mock_stream_cls.call_args.kwargs["callback"]
|
|
|
|
|
|
def _get_finished_callback(mock_stream_cls: MagicMock):
|
|
return mock_stream_cls.call_args.kwargs["finished_callback"]
|
|
|
|
|
|
class TestAudioPlayerInitialState:
|
|
def test_not_playing(self) -> None:
|
|
player = AudioPlayer()
|
|
assert player.is_playing is False
|
|
|
|
|
|
class TestPlayback:
|
|
@patch("vibe.core.audio_player.audio_player.sd.RawOutputStream")
|
|
def test_play_sets_playing_state(self, mock_stream_cls: MagicMock) -> None:
|
|
player = AudioPlayer()
|
|
player.play(_make_wav_bytes(), AudioFormat.WAV)
|
|
assert player.is_playing is True
|
|
mock_stream_cls.return_value.start.assert_called_once()
|
|
|
|
@patch("vibe.core.audio_player.audio_player.sd.RawOutputStream")
|
|
def test_play_when_already_playing_raises(self, mock_stream_cls: MagicMock) -> None:
|
|
player = AudioPlayer()
|
|
player.play(_make_wav_bytes(), AudioFormat.WAV)
|
|
with pytest.raises(AlreadyPlayingError):
|
|
player.play(_make_wav_bytes(), AudioFormat.WAV)
|
|
|
|
@patch("vibe.core.audio_player.audio_player.sd.RawOutputStream")
|
|
def test_callback_feeds_audio_data(self, mock_stream_cls: MagicMock) -> None:
|
|
wav_data = _make_wav_bytes(n_frames=512)
|
|
player = AudioPlayer()
|
|
player.play(wav_data, AudioFormat.WAV)
|
|
|
|
callback = _get_callback(mock_stream_cls)
|
|
outdata = bytearray(512 * 2) # 512 frames * 2 bytes per sample
|
|
callback(outdata, 512, {}, sd.CallbackFlags())
|
|
|
|
assert outdata != bytearray(512 * 2)
|
|
|
|
@patch("vibe.core.audio_player.audio_player.sd.RawOutputStream")
|
|
def test_callback_pads_silence_at_end(self, mock_stream_cls: MagicMock) -> None:
|
|
wav_data = _make_wav_bytes(n_frames=256)
|
|
player = AudioPlayer()
|
|
player.play(wav_data, AudioFormat.WAV)
|
|
|
|
callback = _get_callback(mock_stream_cls)
|
|
# First callback consumes all 256 frames
|
|
outdata1 = bytearray(256 * 2)
|
|
callback(outdata1, 256, {}, sd.CallbackFlags())
|
|
|
|
# Second callback should raise CallbackStop (no data left)
|
|
outdata2 = bytearray(256 * 2)
|
|
with pytest.raises(sd.CallbackStop):
|
|
callback(outdata2, 256, {}, sd.CallbackFlags())
|
|
|
|
@patch("vibe.core.audio_player.audio_player.sd.RawOutputStream")
|
|
def test_on_finished_called_after_natural_completion(
|
|
self, mock_stream_cls: MagicMock
|
|
) -> None:
|
|
finished = []
|
|
player = AudioPlayer()
|
|
player.play(
|
|
_make_wav_bytes(),
|
|
AudioFormat.WAV,
|
|
on_finished=lambda: finished.append(True),
|
|
)
|
|
|
|
finished_callback = _get_finished_callback(mock_stream_cls)
|
|
finished_callback()
|
|
|
|
assert player.is_playing is False
|
|
assert len(finished) == 1
|
|
|
|
@patch("vibe.core.audio_player.audio_player.sd.RawOutputStream")
|
|
def test_can_play_multiple_times(self, mock_stream_cls: MagicMock) -> None:
|
|
player = AudioPlayer()
|
|
|
|
player.play(_make_wav_bytes(), AudioFormat.WAV)
|
|
finished_callback = _get_finished_callback(mock_stream_cls)
|
|
finished_callback()
|
|
assert player.is_playing is False
|
|
|
|
player.play(_make_wav_bytes(), AudioFormat.WAV)
|
|
assert player.is_playing is True
|
|
|
|
@patch("vibe.core.audio_player.audio_player.sd.RawOutputStream")
|
|
def test_creates_stream_with_correct_params(
|
|
self, mock_stream_cls: MagicMock
|
|
) -> None:
|
|
wav_data = _make_wav_bytes(sample_rate=24_000, channels=1)
|
|
player = AudioPlayer()
|
|
player.play(wav_data, AudioFormat.WAV)
|
|
|
|
call_kwargs = mock_stream_cls.call_args.kwargs
|
|
assert call_kwargs["samplerate"] == 24_000
|
|
assert call_kwargs["channels"] == 1
|
|
assert call_kwargs["dtype"] == "int16"
|
|
|
|
|
|
class TestStop:
|
|
@patch("vibe.core.audio_player.audio_player.sd.RawOutputStream")
|
|
def test_stop_closes_stream(self, mock_stream_cls: MagicMock) -> None:
|
|
player = AudioPlayer()
|
|
player.play(_make_wav_bytes(), AudioFormat.WAV)
|
|
player.stop()
|
|
mock_stream_cls.return_value.close.assert_called_once()
|
|
|
|
@patch("vibe.core.audio_player.audio_player.sd.RawOutputStream")
|
|
def test_finished_callback_resets_state(self, mock_stream_cls: MagicMock) -> None:
|
|
player = AudioPlayer()
|
|
player.play(_make_wav_bytes(), AudioFormat.WAV)
|
|
|
|
finished_callback = _get_finished_callback(mock_stream_cls)
|
|
finished_callback()
|
|
|
|
assert player.is_playing is False
|
|
|
|
def test_stop_when_not_playing_is_noop(self) -> None:
|
|
player = AudioPlayer()
|
|
player.stop()
|
|
assert player.is_playing is False
|
|
|
|
@patch("vibe.core.audio_player.audio_player.sd.RawOutputStream")
|
|
def test_stop_triggers_on_finished_via_callback(
|
|
self, mock_stream_cls: MagicMock
|
|
) -> None:
|
|
finished = []
|
|
player = AudioPlayer()
|
|
player.play(
|
|
_make_wav_bytes(),
|
|
AudioFormat.WAV,
|
|
on_finished=lambda: finished.append(True),
|
|
)
|
|
|
|
# Simulate sounddevice calling finished_callback after stop
|
|
finished_callback = _get_finished_callback(mock_stream_cls)
|
|
finished_callback()
|
|
|
|
assert len(finished) == 1
|
|
|
|
|
|
class TestUnsupportedFormat:
|
|
def test_unsupported_format_raises(self) -> None:
|
|
player = AudioPlayer()
|
|
with pytest.raises(UnsupportedAudioFormatError):
|
|
player.play(b"fake data", "mp3") # type: ignore[arg-type]
|
|
|
|
|
|
class TestGuardAudioOutput:
|
|
def test_raises_when_no_sounddevice(self) -> None:
|
|
with patch("vibe.core.audio_player.audio_player.sd", None):
|
|
player = AudioPlayer()
|
|
with pytest.raises(AudioBackendUnavailableError):
|
|
player.play(_make_wav_bytes(), AudioFormat.WAV)
|
|
|
|
@patch("vibe.core.audio_player.audio_player.sd.RawOutputStream")
|
|
def test_raises_when_no_output_device(self, mock_stream_cls: MagicMock) -> None:
|
|
with patch(
|
|
"vibe.core.audio_player.audio_player.sd.query_devices",
|
|
side_effect=sd.PortAudioError(-1),
|
|
):
|
|
player = AudioPlayer()
|
|
with pytest.raises(NoAudioOutputDeviceError):
|
|
player.play(_make_wav_bytes(), AudioFormat.WAV)
|
|
assert player.is_playing is False
|
|
mock_stream_cls.assert_not_called()
|