feat: Implement host system volume control and update player service to use it
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
251
app/services/volume.py
Normal file
251
app/services/volume.py
Normal file
@@ -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()
|
||||
Reference in New Issue
Block a user