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:
2026-10-03 17:00:37 +02:00
parent 2d0caeb732
commit 92305bece0
11 changed files with 262 additions and 19 deletions

View File

@@ -3,13 +3,17 @@
from __future__ import annotations
import asyncio
import io
import os
import shlex
import sys
import wave
from collections.abc import AsyncIterator
from pathlib import Path
import pytest
from musicmouse.blubb import RATE, blubb_wav
from musicmouse.bus import EventBus
from musicmouse.config import ScreenConfig
from musicmouse.events import ToggleScreenRequested
@@ -36,8 +40,14 @@ async def _settle(service: ScreenService) -> None:
await asyncio.sleep(0.01)
async def _started(bus: EventBus, config: ScreenConfig) -> tuple[ScreenService, asyncio.Task[None]]:
service = ScreenService(bus, config)
async def _started(
bus: EventBus,
config: ScreenConfig,
*,
alsa_device: str | None = None,
volume: float = 0.5,
) -> tuple[ScreenService, asyncio.Task[None]]:
service = ScreenService(bus, config, alsa_device=alsa_device, volume=lambda: volume)
task = asyncio.create_task(service.run())
await asyncio.sleep(0) # let it subscribe
return service, task
@@ -108,3 +118,69 @@ async def test_a_hung_command_is_killed(bus: EventBus, caplog: pytest.LogCapture
task.cancel()
assert "killed" in caplog.text
# ------------------------------------------------------------------------ the blubb
def test_the_blubb_is_a_short_mono_wav() -> None:
with wave.open(io.BytesIO(blubb_wav(on=True, gain=0.5))) as wav:
assert (wav.getnchannels(), wav.getsampwidth(), wav.getframerate()) == (1, 2, RATE)
assert 0.1 < wav.getnframes() / RATE < 0.5
def test_on_and_off_sound_different() -> None:
assert blubb_wav(on=True, gain=0.5) != blubb_wav(on=False, gain=0.5)
@pytest.fixture
def fake_aplay(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
"""An ``aplay`` on PATH that records its arguments and what it was fed."""
bin_dir = tmp_path / "bin"
bin_dir.mkdir()
record = tmp_path / "aplay"
script = bin_dir / "aplay"
script.write_text(
f'#!/bin/sh\necho "$@" > {shlex.quote(str(record))}.args\n'
f"cat > {shlex.quote(str(record))}.wav\n"
)
script.chmod(0o755)
monkeypatch.setenv("PATH", f"{bin_dir}:{os.environ['PATH']}")
return record
async def test_a_switch_plays_the_blubb_its_way(bus: EventBus, fake_aplay: Path) -> None:
service, task = await _started(
bus, ScreenConfig(toggle_command=_python("print('off')")), alsa_device="softvol_effects"
)
try:
await bus.emit_and_wait(ToggleScreenRequested(source="lirc"))
await _settle(service)
finally:
task.cancel()
assert Path(f"{fake_aplay}.args").read_text().split() == ["-q", "-D", "softvol_effects", "-"]
assert Path(f"{fake_aplay}.wav").read_bytes() == blubb_wav(on=False, gain=0.35 * 0.5)
@pytest.mark.parametrize(
("output", "alsa_device", "volume"),
[
("print('on')", None, 0.5), # simulated audio
("print('on')", "softvol_effects", 0.0), # muted
("print('done')", "softvol_effects", 0.5), # no direction to go by
],
)
async def test_no_blubb(
bus: EventBus, fake_aplay: Path, output: str, alsa_device: str | None, volume: float
) -> None:
service, task = await _started(
bus, ScreenConfig(toggle_command=_python(output)), alsa_device=alsa_device, volume=volume
)
try:
await bus.emit_and_wait(ToggleScreenRequested(source="lirc"))
await _settle(service)
finally:
task.cancel()
assert not Path(f"{fake_aplay}.wav").exists()