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
This commit is contained in:
@@ -0,0 +1,323 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user