Screen switch: a blubb on every switch, and Del on the kiosk keyboard
ScreenService plays a synthesised two-bubble "blubb" (musicmouse.blubb) after each switch, through aplay on the music's ALSA device at the player's volume: rising for on, falling for off, going by the last word the toggle command prints. Nothing plays with simulated audio or at volume 0. Built from samples rather than shipped as a file, like the front-end's WebAudio blips, so its loudness follows the volume. Del maps to a new "screen" action on every page, which App.tsx sends to the new POST /api/screen/toggle only when the page is served from localhost - the kiosk on the device - so Del on a tablet does not switch off a screen in another room. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,10 @@
|
||||
A service rather than a reaction because the command is a subprocess: the bus awaits
|
||||
async handlers, so running it there would hold every other event up behind ``xset``.
|
||||
Here each press gets its own task, and a hung or failing command is only logged.
|
||||
|
||||
Each switch is answered with a "blubb" (:mod:`musicmouse.blubb`) on the music's own
|
||||
ALSA device, at the player's volume. The command's last word of output says which way
|
||||
it went - ``on`` or ``off``, as ``kiosk-screen`` prints it; anything else gets no sound.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -10,7 +14,9 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import logging
|
||||
import shlex
|
||||
from collections.abc import Callable
|
||||
|
||||
from musicmouse.blubb import blubb_wav
|
||||
from musicmouse.bus import EventBus
|
||||
from musicmouse.config import ScreenConfig
|
||||
from musicmouse.events import ToggleScreenRequested
|
||||
@@ -19,13 +25,28 @@ _log = logging.getLogger(__name__)
|
||||
|
||||
__all__ = ["ScreenService"]
|
||||
|
||||
#: The blubb's peak at full player volume, as a fraction of full scale. The music is
|
||||
#: mastered near full scale; a notification sound sits well under it.
|
||||
_BLUBB_GAIN = 0.35
|
||||
|
||||
|
||||
class ScreenService:
|
||||
name = "screen"
|
||||
|
||||
def __init__(self, bus: EventBus, config: ScreenConfig) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
bus: EventBus,
|
||||
config: ScreenConfig,
|
||||
*,
|
||||
alsa_device: str | None,
|
||||
volume: Callable[[], float],
|
||||
) -> None:
|
||||
self.bus = bus
|
||||
self.config = config
|
||||
#: Where the blubb plays, or ``None`` for no sound (simulated audio).
|
||||
self.alsa_device = alsa_device
|
||||
#: The player's volume as a fraction, read at the moment the sound plays.
|
||||
self.volume = volume
|
||||
self._running: asyncio.Task[None] | None = None
|
||||
|
||||
async def run(self) -> None:
|
||||
@@ -73,5 +94,41 @@ class ScreenService:
|
||||
process.returncode,
|
||||
text,
|
||||
)
|
||||
else:
|
||||
_log.info("Screen toggled%s", f": {text}" if text else "")
|
||||
return
|
||||
_log.info("Screen toggled%s", f": {text}" if text else "")
|
||||
state = text.split()[-1] if text else ""
|
||||
if state in ("on", "off"):
|
||||
await self.blubb(on=state == "on")
|
||||
|
||||
async def blubb(self, *, on: bool) -> None:
|
||||
gain = _BLUBB_GAIN * max(0.0, min(1.0, self.volume()))
|
||||
if self.alsa_device is None or gain <= 0:
|
||||
return
|
||||
wav = await asyncio.to_thread(blubb_wav, on=on, gain=gain)
|
||||
try:
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
"aplay",
|
||||
"-q",
|
||||
"-D",
|
||||
self.alsa_device,
|
||||
"-",
|
||||
stdin=asyncio.subprocess.PIPE,
|
||||
stdout=asyncio.subprocess.DEVNULL,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
except OSError as exc:
|
||||
_log.warning("Could not play the screen sound: %s", exc)
|
||||
return
|
||||
try:
|
||||
_, error = await asyncio.wait_for(process.communicate(wav), self.config.timeout)
|
||||
except TimeoutError:
|
||||
process.kill()
|
||||
await process.wait()
|
||||
_log.warning("Screen sound took longer than %gs; killed", self.config.timeout)
|
||||
return
|
||||
if process.returncode != 0:
|
||||
_log.warning(
|
||||
"Screen sound failed (exit %d): %s",
|
||||
process.returncode,
|
||||
error.decode(errors="replace").strip(),
|
||||
)
|
||||
|
||||
@@ -30,6 +30,7 @@ from musicmouse.events import (
|
||||
PreviousTrackRequested,
|
||||
SeekRequested,
|
||||
SetVolumeRequested,
|
||||
ToggleScreenRequested,
|
||||
)
|
||||
from musicmouse.services.web.hub import StateHub
|
||||
from musicmouse.services.web.remote_settings import read_mapping, write_mapping
|
||||
@@ -189,6 +190,13 @@ def build_router(
|
||||
def previous_track() -> Response:
|
||||
return emit(PreviousTrackRequested(source="web"))
|
||||
|
||||
@router.post("/screen/toggle", status_code=204)
|
||||
def toggle_screen() -> Response:
|
||||
# The device's own monitor - only the kiosk page on the device calls this (its
|
||||
# Del key). Without a screen section in the config nothing listens and it is a
|
||||
# no-op, like the remote's power key.
|
||||
return emit(ToggleScreenRequested(source="web"))
|
||||
|
||||
@router.post("/seek", status_code=204)
|
||||
def seek(body: SeekIn) -> Response:
|
||||
return emit(SeekRequested(position=body.position, source="web"))
|
||||
|
||||
Reference in New Issue
Block a user