Files
Imrayya 0b4f739d12 feat: initial project scaffold — server, client, Android APK
- 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
2026-07-01 11:19:32 +00:00

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