From 4b8496d0253c3069835867d8d2fe44e9c471915b Mon Sep 17 00:00:00 2001 From: JSC Date: Sat, 27 Sep 2025 03:33:11 +0200 Subject: [PATCH] feat: Implement host system volume control and update player service to use it --- app/services/player.py | 34 +++-- app/services/vlc_player.py | 42 ++---- app/services/volume.py | 251 ++++++++++++++++++++++++++++++++++ pyproject.toml | 2 + tests/services/test_player.py | 53 ++++--- uv.lock | 51 +++++++ 6 files changed, 375 insertions(+), 58 deletions(-) create mode 100644 app/services/volume.py diff --git a/app/services/player.py b/app/services/player.py index db895b9..ffbd6d5 100644 --- a/app/services/player.py +++ b/app/services/player.py @@ -16,6 +16,7 @@ from app.models.sound_played import SoundPlayed from app.repositories.playlist import PlaylistRepository from app.repositories.sound import SoundRepository from app.services.socket import socket_manager +from app.services.volume import volume_service from app.utils.audio import get_sound_file_path logger = get_logger(__name__) @@ -46,8 +47,11 @@ class PlayerState: """Initialize player state.""" self.status: PlayerStatus = PlayerStatus.STOPPED self.mode: PlayerMode = PlayerMode.CONTINUOUS - self.volume: int = 80 - self.previous_volume: int = 80 + + # Initialize volume from host system or default to 80 + host_volume = volume_service.get_volume() + self.volume: int = host_volume if host_volume is not None else 80 + self.previous_volume: int = self.volume self.current_sound_id: int | None = None self.current_sound_index: int | None = None self.current_sound_position: int = 0 @@ -153,8 +157,8 @@ class PlayerService: ) self._position_thread.start() - # Set initial volume - self._player.audio_set_volume(self.state.volume) + # Set VLC to 100% volume - host volume is controlled separately + self._player.audio_set_volume(100) logger.info("Player service started") @@ -378,7 +382,7 @@ class PlayerService: logger.debug("Seeked to position: %sms", position_ms) async def set_volume(self, volume: int) -> None: - """Set playback volume (0-100).""" + """Set playback volume (0-100) by controlling host system volume.""" volume = max(0, min(100, volume)) # Clamp to valid range # Store previous volume when muting (going from >0 to 0) @@ -386,18 +390,30 @@ class PlayerService: self.state.previous_volume = self.state.volume self.state.volume = volume - self._player.audio_set_volume(volume) + + # Control host system volume instead of VLC volume + if volume == 0: + # Mute the host system + volume_service.set_mute(muted=True) + else: + # Unmute and set host volume + if volume_service.is_muted(): + volume_service.set_mute(muted=False) + volume_service.set_volume(volume) + + # Keep VLC at 100% volume + self._player.audio_set_volume(100) await self._broadcast_state() - logger.debug("Volume set to: %s", volume) + logger.debug("Host volume set to: %s", volume) async def mute(self) -> None: - """Mute the player (stores current volume as previous_volume).""" + """Mute the host system (stores current volume as previous_volume).""" if self.state.volume > 0: await self.set_volume(0) async def unmute(self) -> None: - """Unmute the player (restores previous_volume).""" + """Unmute the host system (restores previous_volume).""" if self.state.volume == 0 and self.state.previous_volume > 0: await self.set_volume(self.state.previous_volume) diff --git a/app/services/vlc_player.py b/app/services/vlc_player.py index b11ad02..9eca552 100644 --- a/app/services/vlc_player.py +++ b/app/services/vlc_player.py @@ -70,12 +70,14 @@ class VLCPlayerService: ) return "vlc" - async def play_sound(self, sound: Sound, volume: int | None = None) -> bool: + async def play_sound(self, sound: Sound) -> bool: """Play a sound using a new VLC subprocess instance. + VLC always plays at 100% volume. Host system volume is controlled separately + by the player service. + Args: sound: The Sound object to play - volume: Volume level (0-100). If None, uses current player volume. Returns: bool: True if VLC process was launched successfully, False otherwise @@ -88,19 +90,6 @@ class VLCPlayerService: logger.error("Sound file not found: %s", sound_path) return False - # Get volume from player service if not provided - if volume is None: - try: - from app.services.player import get_player_service # noqa: PLC0415 - player = get_player_service() - volume = player.state.volume - except RuntimeError: - logger.warning("Could not get player volume, using default 80") - volume = 80 - - # Ensure volume is in valid range and is an integer - volume = max(0, min(100, int(volume))) if volume is not None else 80 - # VLC command arguments for immediate playback cmd = [ self.vlc_executable, @@ -111,7 +100,7 @@ class VLCPlayerService: "--no-video", # Audio only "--no-repeat", # Don't repeat "--no-loop", # Don't loop - f"--volume={volume}", # Set volume to match player + "--volume=100", # Always use 100% VLC volume ] # Launch VLC process asynchronously without waiting @@ -347,7 +336,10 @@ class VLCPlayerService: """ from fastapi import HTTPException, status # noqa: PLC0415, I001 - from app.services.credit import CreditService, InsufficientCreditsError # noqa: PLC0415 + from app.services.credit import ( + CreditService, + InsufficientCreditsError, + ) # noqa: PLC0415 if not self.db_session_factory: raise HTTPException( @@ -384,19 +376,8 @@ class VLCPlayerService: ), ) from e - # Get current player volume - try: - from app.services.player import get_player_service # noqa: PLC0415 - player = get_player_service() - current_volume = player.state.volume - except RuntimeError: - logger.warning( - "Could not get player volume for credit play, using default 80", - ) - current_volume = 80 - - # Play the sound using VLC with current player volume - success = await self.play_sound(sound, current_volume) + # Play the sound using VLC (always at 100% VLC volume) + success = await self.play_sound(sound) # Deduct credits based on success await credit_service.deduct_credits( @@ -433,4 +414,3 @@ def get_vlc_player_service( if vlc_player_service is None: vlc_player_service = VLCPlayerService(db_session_factory) return vlc_player_service - return vlc_player_service diff --git a/app/services/volume.py b/app/services/volume.py new file mode 100644 index 0000000..a77309f --- /dev/null +++ b/app/services/volume.py @@ -0,0 +1,251 @@ +"""Volume service for host system volume control.""" + +import platform + +from app.core.logging import get_logger + +logger = get_logger(__name__) + +# Constants +MIN_VOLUME = 0 +MAX_VOLUME = 100 + + +class VolumeService: + """Service for controlling host system volume.""" + + def __init__(self) -> None: + """Initialize volume service.""" + self._system = platform.system().lower() + self._pycaw_available = False + self._pulsectl_available = False + + # Try to import Windows volume control + if self._system == "windows": + try: + from comtypes import ( # noqa: PLC0415 + CLSCTX_ALL, # type: ignore[import-untyped] + ) + from pycaw.pycaw import ( # type: ignore[import-untyped] # noqa: PLC0415 + AudioUtilities, + IAudioEndpointVolume, + ) + + self._AudioUtilities = AudioUtilities + self._IAudioEndpointVolume = IAudioEndpointVolume + self._CLSCTX_ALL = CLSCTX_ALL + self._pycaw_available = True + logger.info("Windows volume control (pycaw) initialized") + except ImportError as e: + logger.warning("pycaw not available: %s", e) + + # Try to import Linux volume control + elif self._system == "linux": + try: + import pulsectl # type: ignore[import-untyped] # noqa: PLC0415 + + self._pulsectl = pulsectl + self._pulsectl_available = True + logger.info("Linux volume control (pulsectl) initialized") + except ImportError as e: + logger.warning("pulsectl not available: %s", e) + + else: + logger.warning("Volume control not supported on %s", self._system) + + def get_volume(self) -> int | None: + """Get the current system volume as a percentage (0-100). + + Returns: + Volume level as percentage, or None if not available + + """ + try: + if self._system == "windows" and self._pycaw_available: + return self._get_windows_volume() + if self._system == "linux" and self._pulsectl_available: + return self._get_linux_volume() + except Exception: + logger.exception("Failed to get volume") + return None + else: + logger.warning("Volume control not available for this system") + return None + + def set_volume(self, volume: int) -> bool: + """Set the system volume to a percentage (0-100). + + Args: + volume: Volume level as percentage (0-100) + + Returns: + True if successful, False otherwise + + """ + if not (MIN_VOLUME <= volume <= MAX_VOLUME): + logger.error( + "Volume must be between %s and %s, got %s", + MIN_VOLUME, + MAX_VOLUME, + volume, + ) + return False + + try: + if self._system == "windows" and self._pycaw_available: + return self._set_windows_volume(volume) + if self._system == "linux" and self._pulsectl_available: + return self._set_linux_volume(volume) + except Exception: + logger.exception("Failed to set volume") + return False + else: + logger.warning("Volume control not available for this system") + return False + + def is_muted(self) -> bool | None: + """Check if the system is muted. + + Returns: + True if muted, False if not muted, None if not available + + """ + try: + if self._system == "windows" and self._pycaw_available: + return self._get_windows_mute_status() + if self._system == "linux" and self._pulsectl_available: + return self._get_linux_mute_status() + except Exception: + logger.exception("Failed to get mute status") + return None + else: + logger.warning("Mute status not available for this system") + return None + + def set_mute(self, *, muted: bool) -> bool: + """Set the system mute status. + + Args: + muted: True to mute, False to unmute + + Returns: + True if successful, False otherwise + + """ + try: + if self._system == "windows" and self._pycaw_available: + return self._set_windows_mute(muted=muted) + if self._system == "linux" and self._pulsectl_available: + return self._set_linux_mute(muted=muted) + except Exception: + logger.exception("Failed to set mute status") + return False + else: + logger.warning("Mute control not available for this system") + return False + + def _get_windows_volume(self) -> int: + """Get Windows volume using pycaw.""" + devices = self._AudioUtilities.GetSpeakers() + interface = devices.Activate( + self._IAudioEndpointVolume._iid_, self._CLSCTX_ALL, None, + ) + volume = interface.QueryInterface(self._IAudioEndpointVolume) + current_volume = volume.GetMasterVolume() + + # Convert from scalar (0.0-1.0) to percentage (0-100) + return int(current_volume * MAX_VOLUME) + + def _set_windows_volume(self, volume_percent: int) -> bool: + """Set Windows volume using pycaw.""" + devices = self._AudioUtilities.GetSpeakers() + interface = devices.Activate( + self._IAudioEndpointVolume._iid_, self._CLSCTX_ALL, None, + ) + volume = interface.QueryInterface(self._IAudioEndpointVolume) + + # Convert from percentage (0-100) to scalar (0.0-1.0) + volume_scalar = volume_percent / MAX_VOLUME + volume.SetMasterVolume(volume_scalar, None) + logger.info("Windows volume set to %s%%", volume_percent) + return True + + def _get_windows_mute_status(self) -> bool: + """Get Windows mute status using pycaw.""" + devices = self._AudioUtilities.GetSpeakers() + interface = devices.Activate( + self._IAudioEndpointVolume._iid_, self._CLSCTX_ALL, None, + ) + volume = interface.QueryInterface(self._IAudioEndpointVolume) + return bool(volume.GetMute()) + + def _set_windows_mute(self, *, muted: bool) -> bool: + """Set Windows mute status using pycaw.""" + devices = self._AudioUtilities.GetSpeakers() + interface = devices.Activate( + self._IAudioEndpointVolume._iid_, self._CLSCTX_ALL, None, + ) + volume = interface.QueryInterface(self._IAudioEndpointVolume) + volume.SetMute(muted, None) + logger.info("Windows mute set to %s", muted) + return True + + def _get_linux_volume(self) -> int: + """Get Linux volume using pulsectl.""" + with self._pulsectl.Pulse("volume-service") as pulse: + # Get the default sink (output device) + default_sink = pulse.get_sink_by_name(pulse.server_info().default_sink_name) + if default_sink is None: + logger.error("No default audio sink found") + return MIN_VOLUME + + # Get volume as percentage (PulseAudio uses 0.0-1.0, we convert to 0-100) + volume = default_sink.volume + avg_volume = sum(volume.values) / len(volume.values) + return int(avg_volume * MAX_VOLUME) + + def _set_linux_volume(self, volume_percent: int) -> bool: + """Set Linux volume using pulsectl.""" + with self._pulsectl.Pulse("volume-service") as pulse: + # Get the default sink (output device) + default_sink = pulse.get_sink_by_name(pulse.server_info().default_sink_name) + if default_sink is None: + logger.error("No default audio sink found") + return False + + # Convert percentage to PulseAudio volume (0.0-1.0) + volume_scalar = volume_percent / MAX_VOLUME + + # Set volume for all channels + pulse.volume_set_all_chans(default_sink, volume_scalar) + logger.info("Linux volume set to %s%%", volume_percent) + return True + + def _get_linux_mute_status(self) -> bool: + """Get Linux mute status using pulsectl.""" + with self._pulsectl.Pulse("volume-service") as pulse: + # Get the default sink (output device) + default_sink = pulse.get_sink_by_name(pulse.server_info().default_sink_name) + if default_sink is None: + logger.error("No default audio sink found") + return False + + return bool(default_sink.mute) + + def _set_linux_mute(self, *, muted: bool) -> bool: + """Set Linux mute status using pulsectl.""" + with self._pulsectl.Pulse("volume-service") as pulse: + # Get the default sink (output device) + default_sink = pulse.get_sink_by_name(pulse.server_info().default_sink_name) + if default_sink is None: + logger.error("No default audio sink found") + return False + + # Set mute status + pulse.mute(default_sink, muted) + logger.info("Linux mute set to %s", muted) + return True + + +# Global volume service instance +volume_service = VolumeService() diff --git a/pyproject.toml b/pyproject.toml index 2167afa..c8ad658 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,6 +24,8 @@ dependencies = [ "yt-dlp==2025.9.26", "asyncpg==0.30.0", "psycopg[binary]==3.2.10", + "pycaw>=20240210", + "pulsectl>=24.12.0", ] [tool.uv] diff --git a/tests/services/test_player.py b/tests/services/test_player.py index 19dccee..7c20187 100644 --- a/tests/services/test_player.py +++ b/tests/services/test_player.py @@ -26,12 +26,17 @@ class TestPlayerState: def test_init_creates_default_state(self) -> None: """Test that player state initializes with default values.""" - state = PlayerState() + # Mock volume service to return a specific volume + with patch("app.services.player.volume_service") as mock_volume_service: + mock_volume_service.get_volume.return_value = 80 - assert state.status == PlayerStatus.STOPPED - assert state.mode == PlayerMode.CONTINUOUS - assert state.volume == 80 - assert state.previous_volume == 80 + state = PlayerState() + + assert state.status == PlayerStatus.STOPPED + assert state.mode == PlayerMode.CONTINUOUS + assert state.volume == 80 + assert state.previous_volume == 80 + mock_volume_service.get_volume.assert_called_once() assert state.current_sound_id is None assert state.current_sound_index is None assert state.current_sound_position == 0 @@ -181,7 +186,9 @@ class TestPlayerService: mock_socket_manager, ): """Create a player service instance for testing.""" - return PlayerService(mock_db_session_factory) + with patch("app.services.player.volume_service") as mock_volume_service: + mock_volume_service.get_volume.return_value = 80 + return PlayerService(mock_db_session_factory) def test_init_creates_player_service( self, @@ -217,7 +224,8 @@ class TestPlayerService: assert player_service._loop is not None assert player_service._position_thread is not None assert player_service._position_thread.daemon is True - player_service._player.audio_set_volume.assert_called_once_with(80) + # VLC is now always set to 100% volume + player_service._player.audio_set_volume.assert_called_once_with(100) @pytest.mark.asyncio async def test_stop_cleans_up_service(self, player_service) -> None: @@ -399,23 +407,32 @@ class TestPlayerService: @pytest.mark.asyncio async def test_set_volume(self, player_service) -> None: """Test setting volume.""" - with patch.object(player_service, "_broadcast_state", new_callable=AsyncMock): - await player_service.set_volume(75) + with patch("app.services.player.volume_service") as mock_volume_service: + with patch.object(player_service, "_broadcast_state", new_callable=AsyncMock): + mock_volume_service.is_muted.return_value = False - assert player_service.state.volume == 75 - player_service._player.audio_set_volume.assert_called_once_with(75) + await player_service.set_volume(75) + + assert player_service.state.volume == 75 + # VLC volume is always set to 100%, host volume is controlled separately + player_service._player.audio_set_volume.assert_called_once_with(100) + # Verify host volume was set + mock_volume_service.set_volume.assert_called_once_with(75) @pytest.mark.asyncio async def test_set_volume_clamping(self, player_service) -> None: """Test volume clamping to valid range.""" - with patch.object(player_service, "_broadcast_state", new_callable=AsyncMock): - # Test upper bound - await player_service.set_volume(150) - assert player_service.state.volume == 100 + with patch("app.services.player.volume_service") as mock_volume_service: + with patch.object(player_service, "_broadcast_state", new_callable=AsyncMock): + mock_volume_service.is_muted.return_value = False - # Test lower bound - await player_service.set_volume(-10) - assert player_service.state.volume == 0 + # Test upper bound + await player_service.set_volume(150) + assert player_service.state.volume == 100 + + # Test lower bound + await player_service.set_volume(-10) + assert player_service.state.volume == 0 @pytest.mark.asyncio async def test_set_mode(self, player_service) -> None: diff --git a/uv.lock b/uv.lock index f6b1e8a..98fcad4 100644 --- a/uv.lock +++ b/uv.lock @@ -234,6 +234,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, ] +[[package]] +name = "comtypes" +version = "1.4.12" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b6/b8/3af03195b9de515448292169c6d6d7a630de02bedf891a47b809638c186f/comtypes-1.4.12.zip", hash = "sha256:3ff06c442c2de8a2b25785407f244eb5b6f809d21cf068a855071ba80a76876f", size = 280541 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2d/01/89285549c5138009db68f26c80f2174d0ec82a858547df0cc40a8b0a47d6/comtypes-1.4.12-py3-none-any.whl", hash = "sha256:e0fa9cc19c489fa7feea4c1710f4575c717e2673edef5b99bf99efd507908e44", size = 253704 }, +] + [[package]] name = "coverage" version = "7.10.7" @@ -699,6 +708,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538 }, ] +[[package]] +name = "psutil" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b3/31/4723d756b59344b643542936e37a31d1d3204bcdc42a7daa8ee9eb06fb50/psutil-7.1.0.tar.gz", hash = "sha256:655708b3c069387c8b77b072fc429a57d0e214221d01c0a772df7dfedcb3bcd2", size = 497660 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/62/ce4051019ee20ce0ed74432dd73a5bb087a6704284a470bb8adff69a0932/psutil-7.1.0-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:76168cef4397494250e9f4e73eb3752b146de1dd950040b29186d0cce1d5ca13", size = 245242 }, + { url = "https://files.pythonhosted.org/packages/38/61/f76959fba841bf5b61123fbf4b650886dc4094c6858008b5bf73d9057216/psutil-7.1.0-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:5d007560c8c372efdff9e4579c2846d71de737e4605f611437255e81efcca2c5", size = 246682 }, + { url = "https://files.pythonhosted.org/packages/88/7a/37c99d2e77ec30d63398ffa6a660450b8a62517cabe44b3e9bae97696e8d/psutil-7.1.0-cp36-abi3-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:22e4454970b32472ce7deaa45d045b34d3648ce478e26a04c7e858a0a6e75ff3", size = 287994 }, + { url = "https://files.pythonhosted.org/packages/9d/de/04c8c61232f7244aa0a4b9a9fbd63a89d5aeaf94b2fc9d1d16e2faa5cbb0/psutil-7.1.0-cp36-abi3-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c70e113920d51e89f212dd7be06219a9b88014e63a4cec69b684c327bc474e3", size = 291163 }, + { url = "https://files.pythonhosted.org/packages/f4/58/c4f976234bf6d4737bc8c02a81192f045c307b72cf39c9e5c5a2d78927f6/psutil-7.1.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7d4a113425c037300de3ac8b331637293da9be9713855c4fc9d2d97436d7259d", size = 293625 }, + { url = "https://files.pythonhosted.org/packages/79/87/157c8e7959ec39ced1b11cc93c730c4fb7f9d408569a6c59dbd92ceb35db/psutil-7.1.0-cp37-abi3-win32.whl", hash = "sha256:09ad740870c8d219ed8daae0ad3b726d3bf9a028a198e7f3080f6a1888b99bca", size = 244812 }, + { url = "https://files.pythonhosted.org/packages/bf/e9/b44c4f697276a7a95b8e94d0e320a7bf7f3318521b23de69035540b39838/psutil-7.1.0-cp37-abi3-win_amd64.whl", hash = "sha256:57f5e987c36d3146c0dd2528cd42151cf96cd359b9d67cfff836995cc5df9a3d", size = 247965 }, + { url = "https://files.pythonhosted.org/packages/26/65/1070a6e3c036f39142c2820c4b52e9243246fcfc3f96239ac84472ba361e/psutil-7.1.0-cp37-abi3-win_arm64.whl", hash = "sha256:6937cb68133e7c97b6cc9649a570c9a18ba0efebed46d8c5dae4c07fa1b67a07", size = 244971 }, +] + [[package]] name = "psycopg" version = "3.2.10" @@ -751,6 +776,28 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5a/dd/464bd739bacb3b745a1c93bc15f20f0b1e27f0a64ec693367794b398673b/psycopg_binary-3.2.10-cp314-cp314-win_amd64.whl", hash = "sha256:d5c6a66a76022af41970bf19f51bc6bf87bd10165783dd1d40484bfd87d6b382", size = 2973554 }, ] +[[package]] +name = "pulsectl" +version = "24.12.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/c5/f070a8c5f0a5742f7aebb5d90869ee1805174c03928dfafd3833de58bd57/pulsectl-24.12.0.tar.gz", hash = "sha256:288d6715232ac6f3dcdb123fbecaa2c0b9a50ea4087e6e87c3f841ab0a8a07fc", size = 41200 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a9/5b119f86dd1a053c55da7d0355fca2ad215bae6f7f4777d46b307a8cc3e9/pulsectl-24.12.0-py2.py3-none-any.whl", hash = "sha256:13a60be940594f03ead3245b3dfe3aff4a3f9a792af347674bde5e716d4f76d2", size = 35133 }, +] + +[[package]] +name = "pycaw" +version = "20240210" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "comtypes" }, + { name = "psutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3b/1a/f1fa3ceca06eceb5184b907413306b99dd790855ffdf2aee8210fa0fc192/pycaw-20240210.tar.gz", hash = "sha256:55e49359e9f227053f4fa15817a02d4a7bc52fc3db32e123894719a123c41d06", size = 22417 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/e2/89e3e096d8926f19cbcf2991ae86d19e6705ea75ad0212862461cb4b83d8/pycaw-20240210-py3-none-any.whl", hash = "sha256:fbbe0ee67a7d32714240e26913266a386ae4375778c417a7e8ad6076eca62f1e", size = 24760 }, +] + [[package]] name = "pydantic" version = "2.11.7" @@ -1091,6 +1138,8 @@ dependencies = [ { name = "gtts" }, { name = "httpx" }, { name = "psycopg", extra = ["binary"] }, + { name = "pulsectl" }, + { name = "pycaw" }, { name = "pydantic-settings" }, { name = "pyjwt" }, { name = "python-socketio" }, @@ -1125,6 +1174,8 @@ requires-dist = [ { name = "gtts", specifier = "==2.5.4" }, { name = "httpx", specifier = "==0.28.1" }, { name = "psycopg", extras = ["binary"], specifier = "==3.2.10" }, + { name = "pulsectl", specifier = ">=24.12.0" }, + { name = "pycaw", specifier = ">=20240210" }, { name = "pydantic-settings", specifier = "==2.11.0" }, { name = "pyjwt", specifier = "==2.10.1" }, { name = "python-socketio", specifier = "==5.13.0" },