0b4f739d12
- FastAPI server with audio mixer (Windows + Voicemeeter), media tracking, notifications (progress + alerts), telemetry, and system tray .exe build - Test suite covering notifications, audio mixer, media, telemetry, and API - E-Ink web client (vanilla JS, DOM API, block meters, swipe gestures) - Android WebView APK for BOOX Go 7 Color Gen II (Android 13, Kotlin) - Design decision records in .pi/docs/design/ - PyInstaller build script for server .exe
324 lines
11 KiB
Python
324 lines
11 KiB
Python
"""Audio mixer abstraction with pluggable backends.
|
|
|
|
Supports Windows Core Audio (default output) and Voicemeeter as backends.
|
|
Both expose the same interface so the client sees a unified audio view.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import logging
|
|
from abc import ABC, abstractmethod
|
|
from dataclasses import dataclass, field
|
|
from typing import Literal
|
|
|
|
logger = logging.getLogger("paperdash.audio")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Data Models
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@dataclass
|
|
class AudioChannel:
|
|
"""A single audio channel/strip."""
|
|
|
|
name: str
|
|
volume: float = 0.0 # 0.0 - 1.0
|
|
muted: bool = False
|
|
peak_left: float = 0.0
|
|
peak_right: float = 0.0
|
|
|
|
|
|
@dataclass
|
|
class AudioMixerState:
|
|
"""Unified audio mixer state from any backend."""
|
|
|
|
backend: Literal["windows", "voicemeeter"]
|
|
master_volume: float = 0.0
|
|
master_muted: bool = False
|
|
channels: list[AudioChannel] = field(default_factory=list)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Backend Interface
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class AudioMixerBackend(ABC):
|
|
"""Abstract interface for audio mixer backends."""
|
|
|
|
@property
|
|
@abstractmethod
|
|
def name(self) -> str: ...
|
|
|
|
@abstractmethod
|
|
async def get_state(self) -> AudioMixerState: ...
|
|
|
|
@abstractmethod
|
|
async def set_volume(self, channel: str, value: float) -> None: ...
|
|
|
|
@abstractmethod
|
|
async def set_mute(self, channel: str, muted: bool) -> None: ...
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Windows Core Audio Backend
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class WindowsAudioBackend(AudioMixerBackend):
|
|
"""Backend for the default Windows audio output device."""
|
|
|
|
@property
|
|
def name(self) -> str:
|
|
return "windows"
|
|
|
|
async def get_state(self) -> AudioMixerState:
|
|
"""Read master volume and mute from Windows Core Audio."""
|
|
try:
|
|
import pycaw # type: ignore
|
|
from pycaw.pycaw import AudioUtilities, IAudioEndpointVolume # type: ignore
|
|
|
|
devices = AudioUtilities.GetSpeakers()
|
|
interface = devices.Activate(IAudioEndpointVolume._iid_, 0, None)
|
|
volume = interface.QueryInterface(IAudioEndpointVolume)
|
|
|
|
master_volume = volume.MasterVolumeLevelScalar
|
|
master_muted = volume.Mute
|
|
|
|
return AudioMixerState(
|
|
backend="windows",
|
|
master_volume=master_volume,
|
|
master_muted=master_muted,
|
|
channels=[
|
|
AudioChannel(
|
|
name="Master",
|
|
volume=master_volume,
|
|
muted=master_muted,
|
|
)
|
|
],
|
|
)
|
|
except ImportError:
|
|
logger.warning("pycaw not installed — Windows audio unavailable")
|
|
return AudioMixerState(backend="windows")
|
|
except Exception as e:
|
|
logger.error(f"Windows audio error: {e}")
|
|
return AudioMixerState(backend="windows")
|
|
|
|
async def set_volume(self, channel: str, value: float) -> None:
|
|
try:
|
|
from pycaw.pycaw import AudioUtilities, IAudioEndpointVolume
|
|
|
|
devices = AudioUtilities.GetSpeakers()
|
|
interface = devices.Activate(IAudioEndpointVolume._iid_, 0, None)
|
|
volume = interface.QueryInterface(IAudioEndpointVolume)
|
|
volume.MasterVolumeLevelScalar = max(0.0, min(1.0, value))
|
|
except Exception as e:
|
|
logger.error(f"Failed to set Windows volume: {e}")
|
|
|
|
async def set_mute(self, channel: str, muted: bool) -> None:
|
|
try:
|
|
from pycaw.pycaw import AudioUtilities, IAudioEndpointVolume
|
|
|
|
devices = AudioUtilities.GetSpeakers()
|
|
interface = devices.Activate(IAudioEndpointVolume._iid_, 0, None)
|
|
volume = interface.QueryInterface(IAudioEndpointVolume)
|
|
volume.Mute = muted
|
|
except Exception as e:
|
|
logger.error(f"Failed to set Windows mute: {e}")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Voicemeeter Backend
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class VoicemeeterBackend(AudioMixerBackend):
|
|
"""Backend for Voicemeeter Remote API."""
|
|
|
|
@property
|
|
def name(self) -> str:
|
|
return "voicemeeter"
|
|
|
|
async def get_state(self) -> AudioMixerState:
|
|
"""Read Voicemeeter strip states."""
|
|
try:
|
|
import voicemeeter_api # type: ignore
|
|
|
|
vm = voicemeeter_api.VoicemeeterRemote()
|
|
vm.connect()
|
|
|
|
channels = []
|
|
# Read hardware inputs (A1, A2, A3)
|
|
for i in range(1, 4):
|
|
gain = vm.get_input_gain(i)
|
|
mute = bool(vm.get_input_mute(i))
|
|
channels.append(
|
|
AudioChannel(
|
|
name=f"A{i}",
|
|
volume=max(0.0, min(1.0, (gain + 40) / 40)),
|
|
muted=mute,
|
|
)
|
|
)
|
|
|
|
# Read virtual outputs (B1, B2, B3, B4, B5)
|
|
for i in range(1, 6):
|
|
gain = vm.get_output_gain(i)
|
|
mute = bool(vm.get_output_mute(i))
|
|
channels.append(
|
|
AudioChannel(
|
|
name=f"B{i}",
|
|
volume=max(0.0, min(1.0, (gain + 40) / 40)),
|
|
muted=mute,
|
|
)
|
|
)
|
|
|
|
vm.disconnect()
|
|
|
|
return AudioMixerState(
|
|
backend="voicemeeter",
|
|
master_volume=sum(c.volume for c in channels) / max(len(channels), 1),
|
|
master_muted=all(c.muted for c in channels) if channels else False,
|
|
channels=channels,
|
|
)
|
|
except ImportError:
|
|
logger.warning("voicemeeter-api not installed — Voicemeeter unavailable")
|
|
return AudioMixerState(backend="voicemeeter")
|
|
except Exception as e:
|
|
logger.error(f"Voicemeeter error: {e}")
|
|
return AudioMixerState(backend="voicemeeter")
|
|
|
|
async def set_volume(self, channel: str, value: float) -> None:
|
|
try:
|
|
import voicemeeter_api
|
|
|
|
vm = voicemeeter_api.VoicemeeterRemote()
|
|
vm.connect()
|
|
|
|
# Map channel names to Voicemeeter strip indices
|
|
if channel.startswith("A"):
|
|
strip = int(channel[1])
|
|
# Convert 0-1 to Voicemeeter gain (-40 to 0 dB)
|
|
gain = (value * -40) + 40
|
|
vm.set_input_gain(strip, gain)
|
|
elif channel.startswith("B"):
|
|
strip = int(channel[1])
|
|
gain = (value * -40) + 40
|
|
vm.set_output_gain(strip, gain)
|
|
|
|
vm.disconnect()
|
|
except Exception as e:
|
|
logger.error(f"Failed to set Voicemeeter volume for {channel}: {e}")
|
|
|
|
async def set_mute(self, channel: str, muted: bool) -> None:
|
|
try:
|
|
import voicemeeter_api
|
|
|
|
vm = voicemeeter_api.VoicemeeterRemote()
|
|
vm.connect()
|
|
|
|
if channel.startswith("A"):
|
|
strip = int(channel[1])
|
|
vm.set_input_mute(strip, int(muted))
|
|
elif channel.startswith("B"):
|
|
strip = int(channel[1])
|
|
vm.set_output_mute(strip, int(muted))
|
|
|
|
vm.disconnect()
|
|
except Exception as e:
|
|
logger.error(f"Failed to set Voicemeeter mute for {channel}: {e}")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Manager (orchestrates backends and polling)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class AudioMixerManager:
|
|
"""Manages audio mixer backends and periodic state polling."""
|
|
|
|
def __init__(self) -> None:
|
|
self._backends: list[AudioMixerBackend] = []
|
|
self._current_state: AudioMixerState = AudioMixerState(backend="windows")
|
|
self._poll_interval: float = 2.0
|
|
|
|
# Initialize available backends
|
|
self._init_backends()
|
|
|
|
def _init_backends(self) -> None:
|
|
"""Try to initialize backends in priority order."""
|
|
# Voicemeeter first (primary focus)
|
|
try:
|
|
self._backends.append(VoicemeeterBackend())
|
|
logger.info("Voicemeeter backend initialized")
|
|
except Exception:
|
|
logger.debug("Voicemeeter not available")
|
|
|
|
# Windows Core Audio as fallback
|
|
try:
|
|
self._backends.append(WindowsAudioBackend())
|
|
logger.info("Windows audio backend initialized")
|
|
except Exception:
|
|
logger.debug("Windows audio not available")
|
|
|
|
if not self._backends:
|
|
logger.warning("No audio backends available")
|
|
|
|
async def run(self) -> None:
|
|
"""Periodically poll audio state."""
|
|
while True:
|
|
try:
|
|
self._current_state = await self._get_best_state()
|
|
except Exception as e:
|
|
logger.error(f"Audio poll error: {e}")
|
|
await asyncio.sleep(self._poll_interval)
|
|
|
|
async def _get_best_state(self) -> AudioMixerState:
|
|
"""Get state from the best available backend."""
|
|
for backend in self._backends:
|
|
try:
|
|
state = await backend.get_state()
|
|
if state.channels or state.master_volume > 0 or state.master_muted:
|
|
return state
|
|
except Exception:
|
|
continue
|
|
return self._current_state
|
|
|
|
def get_state(self) -> dict:
|
|
"""Return current state as a serializable dict."""
|
|
return {
|
|
"backend": self._current_state.backend,
|
|
"master_volume": self._current_state.master_volume,
|
|
"master_muted": self._current_state.master_muted,
|
|
"channels": [
|
|
{
|
|
"name": ch.name,
|
|
"volume": ch.volume,
|
|
"muted": ch.muted,
|
|
"peak_left": ch.peak_left,
|
|
"peak_right": ch.peak_right,
|
|
}
|
|
for ch in self._current_state.channels
|
|
],
|
|
}
|
|
|
|
async def set_volume(self, bus: str, value: float) -> None:
|
|
"""Set volume on the active backend."""
|
|
for backend in self._backends:
|
|
try:
|
|
await backend.set_volume(bus, value)
|
|
return
|
|
except Exception:
|
|
continue
|
|
|
|
async def set_mute(self, bus: str, muted: bool) -> None:
|
|
"""Set mute on the active backend."""
|
|
for backend in self._backends:
|
|
try:
|
|
await backend.set_mute(bus, muted)
|
|
return
|
|
except Exception:
|
|
continue
|