diff --git a/python-backend/musicmouse/__main__.py b/python-backend/musicmouse/__main__.py index eadcc9f..5dddb02 100644 --- a/python-backend/musicmouse/__main__.py +++ b/python-backend/musicmouse/__main__.py @@ -374,7 +374,15 @@ def _build_services( screen_config = app.config.general.screen if screen_config is not None: - services.append(ScreenService(app.bus, screen_config)) + general = app.config.general + services.append( + ScreenService( + app.bus, + screen_config, + alsa_device=None if general.audio_simulated else general.alsa_device, + volume=lambda: app.player.volume / 100, + ) + ) # Unconditional: a show only starts downloading once someone drops a `feed.txt` # into its folder, so there is nothing to gate here with its own config section. diff --git a/python-backend/musicmouse/blubb.py b/python-backend/musicmouse/blubb.py new file mode 100644 index 0000000..a39a529 --- /dev/null +++ b/python-backend/musicmouse/blubb.py @@ -0,0 +1,62 @@ +"""A synthesised "blubb": two bubbles, for the screen switching on or off. + +Built from samples rather than shipped as a file - the same idea as the front-end's +WebAudio blips (``web/src/lib/tippen/pop.ts``), and it lets the loudness follow the +player's volume instead of being fixed in a recording. + +A bubble is a sine whose pitch rises as it decays (an air bubble's resonance climbs as +it reaches the surface). Two of them make the "blubb"; switching on the second one is +higher, switching off it is lower, so the two directions are told apart by ear. +""" + +from __future__ import annotations + +import io +import math +import wave +from array import array + +__all__ = ["RATE", "blubb_wav"] + +RATE = 44100 + +#: (start Hz, end Hz, start s, length s) of each bubble. +_ON = ((260.0, 620.0, 0.0, 0.11), (380.0, 900.0, 0.13, 0.13)) +_OFF = ((380.0, 900.0, 0.0, 0.11), (240.0, 560.0, 0.13, 0.15)) + + +def blubb_wav(*, on: bool, gain: float) -> bytes: + """A mono 16-bit WAV. ``gain`` is the peak, 0..1 of full scale.""" + bubbles = _ON if on else _OFF + length = max(start + duration for _, _, start, duration in bubbles) + samples = [0.0] * (int(length * RATE) + 1) + for start_hz, end_hz, start, duration in bubbles: + _add_bubble(samples, start_hz, end_hz, int(start * RATE), duration) + + peak = max(abs(s) for s in samples) or 1.0 + scale = 32767 * max(0.0, min(1.0, gain)) / peak + pcm = array("h", (round(s * scale) for s in samples)) + buffer = io.BytesIO() + with wave.open(buffer, "wb") as out: + out.setnchannels(1) + out.setsampwidth(2) + out.setframerate(RATE) + out.writeframes(pcm.tobytes()) + return buffer.getvalue() + + +def _add_bubble( + samples: list[float], start_hz: float, end_hz: float, offset: int, duration: float +) -> None: + count = int(duration * RATE) + attack = int(0.004 * RATE) # a few ms of fade-in, or the first sample clicks + phase = 0.0 + for i in range(count): + t = i / count + # Exponential sweep, fast at first like the real thing. + frequency = start_hz * (end_hz / start_hz) ** (1 - (1 - t) ** 2) + phase += 2 * math.pi * frequency / RATE + envelope = min(1.0, i / attack) * math.exp(-5 * t) + index = offset + i + if index < len(samples): + samples[index] += math.sin(phase) * envelope diff --git a/python-backend/musicmouse/services/screen.py b/python-backend/musicmouse/services/screen.py index e96c04c..32d250b 100644 --- a/python-backend/musicmouse/services/screen.py +++ b/python-backend/musicmouse/services/screen.py @@ -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(), + ) diff --git a/python-backend/musicmouse/services/web/api.py b/python-backend/musicmouse/services/web/api.py index 9b3c6ef..a8216e6 100644 --- a/python-backend/musicmouse/services/web/api.py +++ b/python-backend/musicmouse/services/web/api.py @@ -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")) diff --git a/python-backend/tests/test_screen.py b/python-backend/tests/test_screen.py index a7511a9..80da646 100644 --- a/python-backend/tests/test_screen.py +++ b/python-backend/tests/test_screen.py @@ -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() diff --git a/python-backend/tests/test_web.py b/python-backend/tests/test_web.py index a0ffe27..85e8930 100644 --- a/python-backend/tests/test_web.py +++ b/python-backend/tests/test_web.py @@ -18,6 +18,7 @@ import pytest from fastapi import FastAPI from musicmouse.config import WebConfig, load_config +from musicmouse.events import ToggleScreenRequested from musicmouse.library.analysis import BeatGrid, TrackCurves from musicmouse.library.models import track_key from musicmouse.services.web.service import build_app @@ -227,6 +228,17 @@ async def test_transport_commands_reach_the_player(client: Client, sim: Simulati assert sim.player.is_playing +async def test_the_screen_toggle_is_an_intent(client: Client, sim: Simulation) -> None: + seen: list[ToggleScreenRequested] = [] + sim.bus.subscribe(ToggleScreenRequested, seen.append) + + response = await client.post("/api/screen/toggle") + await sim.bus.drain() + + assert response.status_code == 204 + assert [event.source for event in seen] == ["web"] + + async def test_seek_moves_the_position(client: Client, sim: Simulation) -> None: album = await album_by_title(client, "Fuchs") await client.post("/api/play", json={"album_id": album["id"]}) @@ -245,9 +257,7 @@ async def test_seeking_backwards_is_rejected(client: Client) -> None: # ------------------------------------------------------------------------- volume -async def test_full_volume_means_the_configured_ceiling( - client: Client, sim: Simulation -) -> None: +async def test_full_volume_means_the_configured_ceiling(client: Client, sim: Simulation) -> None: """The child sees 0..100; the config's max of 60 never crosses the API boundary.""" response = await client.post("/api/volume", json={"percent": 100}) assert response.status_code == 204 @@ -257,18 +267,14 @@ async def test_full_volume_means_the_configured_ceiling( assert (await client.get("/api/state")).json()["volume"] == 100 -async def test_volume_scales_across_the_allowed_range( - client: Client, sim: Simulation -) -> None: +async def test_volume_scales_across_the_allowed_range(client: Client, sim: Simulation) -> None: await client.post("/api/volume", json={"percent": 50}) await sim.bus.drain() assert sim.player.volume == 30 assert (await client.get("/api/state")).json()["volume"] == 50 -async def test_volume_steps_are_relative_to_the_percentage( - client: Client, sim: Simulation -) -> None: +async def test_volume_steps_are_relative_to_the_percentage(client: Client, sim: Simulation) -> None: await client.post("/api/volume", json={"percent": 50}) await sim.bus.drain() await client.post("/api/volume", json={"delta_percent": 10}) @@ -325,9 +331,10 @@ async def test_a_new_client_is_sent_a_snapshot_before_any_deltas(api: FastAPI) - async def test_a_state_change_reaches_every_client(api: FastAPI, sim: Simulation) -> None: - async with websocket_connect(api, "/api/ws") as first, websocket_connect( - api, "/api/ws" - ) as second: + async with ( + websocket_connect(api, "/api/ws") as first, + websocket_connect(api, "/api/ws") as second, + ): await first.next_json() await second.next_json() diff --git a/web/src/App.tsx b/web/src/App.tsx index cb8039c..69a0300 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -59,6 +59,11 @@ const ASSIGN_STATUS_TIMEOUT_MS = 2500; /** Volume when un-muting, matching the mockup. */ const UNMUTE_PERCENT = 60; +/** The kiosk on the device loads the page from localhost (`pi_kiosk_url` in the ansible + * repo); anything else - a tablet, a laptop - is looking at it from elsewhere, and Del + * there must not switch off a screen in another room. */ +const ON_THE_DEVICE = ["localhost", "127.0.0.1"].includes(window.location.hostname); + /** A podcast episode has no next/previous track to skip to - Next/Previous nudge the * position instead, the way scrubbing past an ad break usually works. */ const PODCAST_SKIP_SECONDS = 30; @@ -313,6 +318,9 @@ export function App() { case "mute": onMute(); break; + case "screen": + if (ON_THE_DEVICE) void api.toggleScreen(); + break; } } }, diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 56a4b08..edb09ad 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -97,6 +97,7 @@ export const api = { seek: (position: number) => post("/seek", { position }), setVolume: (percent: number) => post("/volume", { percent }), nudgeVolume: (deltaPercent: number) => post("/volume", { delta_percent: deltaPercent }), + toggleScreen: () => post("/screen/toggle"), settings: () => request("/settings"), saveSettings: (settings: Settings) => diff --git a/web/src/components/HelpOverlay.tsx b/web/src/components/HelpOverlay.tsx index c20b6c8..fb2ed20 100644 --- a/web/src/components/HelpOverlay.tsx +++ b/web/src/components/HelpOverlay.tsx @@ -36,6 +36,7 @@ const SECTIONS: Array<[title: string, rows: Array<[caps: string[], label: string "Sonstiges", [ [["A", "0-9"], "Titel auf Fernbedienungs-Taste legen"], + [["ENTF"], "Bildschirm aus / an"], [["F1"], "Diese Hilfe"], [["ESC"], "Schließen / Suche löschen"], ], diff --git a/web/src/lib/__tests__/keyboard.test.ts b/web/src/lib/__tests__/keyboard.test.ts index d06cca3..91b60cf 100644 --- a/web/src/lib/__tests__/keyboard.test.ts +++ b/web/src/lib/__tests__/keyboard.test.ts @@ -484,3 +484,11 @@ describe("selectionAt", () => { }); }); }); + +describe("Del", () => { + it("toggles the screen, on every page", () => { + for (const page of ["music", "room", "typing"] as const) { + expect(press("Delete", { ...initialUiState, page })).toEqual([{ type: "screen" }]); + } + }); +}); diff --git a/web/src/lib/keyboard.ts b/web/src/lib/keyboard.ts index 56bc0cf..b9b0754 100644 --- a/web/src/lib/keyboard.ts +++ b/web/src/lib/keyboard.ts @@ -68,7 +68,8 @@ export type Action = | { type: "seek"; delta: number } | { type: "pop"; freq: number } | { type: "assign"; digit: string } - | { type: "mute" }; + | { type: "mute" } + | { type: "screen" }; /** Matches the mockup's `/^[a-zA-Z0-9]$/`, widened to the umlauts a German title needs. */ const SEARCHABLE = /^[\p{L}\p{N}]$/u; @@ -253,6 +254,12 @@ export function handleKey( ): Action[] { const { key } = event; + // Switches the device's own monitor on and off, like the IR remote's power key - + // from every page, the typing tab included (no lesson drills Del). App.tsx only acts + // on it in the kiosk on the device itself; on a tablet it would switch a screen in + // another room. + if (key === "Delete" && !event.ctrlKey && !event.metaKey) return [{ type: "screen" }]; + // The typing tab is a second app mounted alongside this one (TippenApp.tsx) with its // own complete keyboard handling, including its own Escape/F1 bindings. It must own // every key while active - even Shift+media and Space below, which would otherwise