feat: styles own their ringing tone (simple beeps, blink plays music)

Move audio ownership out of the shared runner and into each style via an
injected Sound capability (play_music(path) / play_beep()). The runner now
only owns button sampling and cleanup; the style kicks off its own tone in
start() and drives the LED.

- simple: a repeating square-wave beep (ordinary alarm-clock tone), no music
  file required. The beep is synthesised in memory as signed-16-bit stereo
  PCM (stdlib array+math) and played via pygame.mixer.Sound — no shipped
  audio asset, no new dependency.
- blink: unchanged behaviour — music on an endless loop from --music-file /
  MUSIC_FILE / the default track, via pygame.mixer.music.
- AlarmStyle contract gains sound + music_file in __init__ and a start()
  lifecycle hook; AlarmSound Protocol documents the audio seam for future
  styles that handle their own tone.
- setup() no longer loads music (that is the style's job now).

README updated: per-style ringing tone, the --music-file note (blink only),
and the extended plugin contract.
This commit is contained in:
2026-08-02 16:29:40 +02:00
parent 88a57dab6e
commit 92eba762ce
8 changed files with 291 additions and 87 deletions
+9 -7
View File
@@ -33,13 +33,13 @@ Please refer to the [arcade-button-wiring.md](arcade-button-wiring.md) file for
# Use the default music file or the MUSIC_FILE environment variable
python3 wecker.py
# Or pass a custom music file directly
# Or pass a custom music file (used by the blink style; ignored by simple)
python3 wecker.py --music-file /path/to/your/alarm.mp3
# Choose an alarm style (default: blink; see Alarm Styles below)
python3 wecker.py --style simple
```
The music file is resolved in this order: `--music-file` argument, `MUSIC_FILE` environment variable, default `Laid Back - Sunshine Reggae.mp3`. The `--style` argument selects the alarm behaviour and defaults to `blink` (so existing cron entries keep working unchanged).
`--style` selects the alarm behaviour and defaults to `blink` (so existing cron entries keep working unchanged). Each style owns its own ringing tone: `blink` plays a music file on loop (resolved from `--music-file`, then the `MUSIC_FILE` env var, then the default `Laid Back - Sunshine Reggae.mp3`); `simple` ignores the music file and beeps.
## Automating and Managing Alarms (GraphQL API)
@@ -145,12 +145,14 @@ Returns `true` if the alarm was stopped, `false` if it wasn't ringing.
Each alarm has a **style** that decides how you turn it off. Set it per alarm via the `style` field of `setAlarm` (or the `style` argument of `startRinging`). Available styles:
| Style | Behaviour |
|---|---|
| `simple` | Press the button once to stop. The LED stays solid on so the button is findable in the dark. **Default for new alarms.** |
| `blink` | The memory-and-attention puzzle described in [How the Puzzle Works](#how-the-puzzle-works). |
| Style | Ringing tone | How to stop |
|---|---|---|
| `simple` | A repeating, ordinary-alarm-clock **beep** (no music file needed). | Press the button once. The LED stays solid on so the button is findable in the dark. **Default for new alarms.** |
| `blink` | A music file on an endless loop (from `--music-file` / `MUSIC_FILE` / the default track). | The memory-and-attention puzzle described in [How the Puzzle Works](#how-the-puzzle-works). |
Adding a style is a plugin-style drop-in: add a class under `styles/` implementing the `AlarmStyle` contract (`__init__(set_led)` + `update(now, is_pressed) -> bool`) and register one line in `styles/__init__.py`.
**The style owns its ringing tone.** The runner hands each style an audio capability (`play_music(path)` and `play_beep()`); the style decides which to use and when. This is the seam for future styles that handle their own tone.
Adding a style is a plugin-style drop-in: add a class under `styles/` implementing the `AlarmStyle` contract (`__init__(set_led, sound, music_file)` + `start()` + `update(now, is_pressed) -> bool`) and register one line in `styles/__init__.py`.
**Backward compatibility:** existing cron entries created before this feature have no `--style` flag and keep running the `blink` puzzle, so an upgrade never silently changes an alarm. To switch an existing alarm to `simple`, re-save it with `setAlarm(id: ..., cronExpression: ..., style: "simple")`.
+35 -7
View File
@@ -1,20 +1,48 @@
from abc import ABC, abstractmethod
from typing import Callable
from typing import Callable, Protocol
class AlarmSound(Protocol):
"""Audio capability injected into a style so it can own its ringing tone.
Styles stay free of pygame/GPIO imports (the API process imports them only
for name validation); the runner supplies a concrete implementation.
"""
def play_music(self, path: str) -> None:
"""Load and play a music file on an endless loop."""
...
def play_beep(self) -> None:
"""Play one short beep tone."""
...
class AlarmStyle(ABC):
"""A pluggable alarm behaviour.
A style decides *when* the alarm stops and what the LED does while it
rings. It never touches hardware directly: ``set_led`` is injected by the
runner, so styles stay import-safe in the API process (no GPIO/pygame).
A style owns *its own ringing tone* (via the injected ``sound``) and
decides when the alarm stops and what the LED does. It never touches
hardware directly: ``set_led`` and ``sound`` are injected by the runner, so
styles stay import-safe in the API process (no GPIO/pygame).
Lifecycle: construct once with ``set_led``, then call ``update`` every
tick. Return ``False`` from ``update`` to stop the alarm.
Lifecycle: construct once with ``set_led``/``sound``/``music_file``; call
``start()`` to begin ringing (kick off the tone and initial LED); then call
``update`` every tick. Return ``False`` from ``update`` to stop the alarm.
"""
def __init__(self, set_led: Callable[[bool], None]):
def __init__(
self,
set_led: Callable[[bool], None],
sound: AlarmSound,
music_file: str | None = None,
):
self.set_led = set_led
self.sound = sound
self.music_file = music_file
def start(self) -> None:
"""Begin ringing. Override to start the style's tone / initial LED."""
@abstractmethod
def update(self, now: float, is_pressed: bool) -> bool:
+16 -3
View File
@@ -21,8 +21,8 @@ class BlinkStyle(AlarmStyle):
exactly that many times. A wrong count starts a fresh sequence.
"""
def __init__(self, set_led):
super().__init__(set_led)
def __init__(self, set_led, sound, music_file=None):
super().__init__(set_led, sound, music_file)
self.state = STATE_RINGING
self.target_blinks = 0
self.user_presses = 0
@@ -32,6 +32,12 @@ class BlinkStyle(AlarmStyle):
self._blink_phases = 0
self._blink_next_toggle: float | None = None
def start(self):
# The puzzle rings with music on an endless loop; the LED stays off
# until the button is pressed to start the blink sequence.
self.sound.play_music(self.music_file)
self.set_led(False)
def update(self, now, is_pressed):
button_just_pressed = False
if is_pressed and not self.button_was_pressed:
@@ -124,7 +130,14 @@ class BlinkStyle(AlarmStyle):
if __name__ == "__main__":
# Self-check: a correct blink sequence stops the alarm.
style = BlinkStyle(lambda on: None)
class _StubSound:
def play_music(self, path):
pass
def play_beep(self):
pass
style = BlinkStyle(lambda on: None, _StubSound(), "x.mp3")
assert style.update(100.0, True) is True
assert style.state == STATE_WAIT_BEFORE_BLINK
+39 -5
View File
@@ -1,18 +1,52 @@
from styles.base import AlarmStyle
# Time between beep retriggers; a short beep followed by this gap gives the
# classic alarm-clock "beep ... beep ... beep".
BEEP_INTERVAL = 0.5
class SimpleStyle(AlarmStyle):
"""Press the button once to stop the alarm."""
"""Press the button once to stop. Rings with a repeating beep.
def update(self, now, is_pressed):
This style plays no music file; it owns its tone via ``sound.play_beep``.
"""
def __init__(self, set_led, sound, music_file=None):
super().__init__(set_led, sound, music_file)
self._next_beep = 0.0
def start(self):
# LED solid on so the button is findable in the dark.
self.set_led(True)
def update(self, now, is_pressed):
self.set_led(True)
if now >= self._next_beep:
self.sound.play_beep()
self._next_beep = now + BEEP_INTERVAL
return not is_pressed # first press -> False -> stop
if __name__ == "__main__":
# Self-check: not pressed keeps ringing; a press stops it.
style = SimpleStyle(lambda on: None)
# Self-check: not pressed keeps ringing and beeps; a press stops it.
class _StubSound:
def __init__(self):
self.beeps = 0
def play_music(self, path):
raise AssertionError("simple style must never play music")
def play_beep(self):
self.beeps += 1
sound = _StubSound()
style = SimpleStyle(lambda on: None, sound)
style.start()
assert style.update(0.0, False) is True
assert style.update(0.1, True) is False
assert sound.beeps == 1 # first tick beeps
assert style.update(0.4, False) is True # before interval -> no beep
assert sound.beeps == 1
assert style.update(0.5, False) is True # at interval -> beep
assert sound.beeps == 2
assert style.update(0.6, True) is False # press -> stop
print("simple self-check ok")
+23 -14
View File
@@ -1,3 +1,5 @@
from unittest.mock import MagicMock
from styles.blink import (
STATE_BLINKING,
STATE_EVALUATING,
@@ -8,23 +10,31 @@ from styles.blink import (
)
def _led():
"""Return (calls list, set_led callable recording each call)."""
def _make_blink(music_file="music.mp3"):
"""Return (style, sound_mock) for a BlinkStyle ready to start."""
sound = MagicMock()
style = BlinkStyle(lambda on: None, sound, music_file)
return style, sound
def test_start_plays_music_and_led_off():
calls = []
return calls, lambda on: calls.append(on)
sound = MagicMock()
style = BlinkStyle(lambda on: calls.append(on), sound, "track.mp3")
style.start()
sound.play_music.assert_called_once_with("track.mp3")
assert calls == [False] # LED off while music plays until a press starts the puzzle
assert style.state == 0 # STATE_RINGING until a press
def test_press_starts_puzzle():
_, set_led = _led()
style = BlinkStyle(set_led)
style, _ = _make_blink()
assert style.update(100.0, True) is True
assert style.state == STATE_WAIT_BEFORE_BLINK
def test_blink_correct_sequence_stops(monkeypatch):
_, set_led = _led()
style = BlinkStyle(set_led)
style, _ = _make_blink()
style.update(100.0, True) # press -> start puzzle
assert style.state == STATE_WAIT_BEFORE_BLINK
@@ -48,8 +58,7 @@ def test_blink_correct_sequence_stops(monkeypatch):
def test_blink_incorrect_retries():
_, set_led = _led()
style = BlinkStyle(set_led)
style, _ = _make_blink()
style.state = STATE_WAIT_FOR_INPUT
style.target_blinks = 3
@@ -65,8 +74,9 @@ def test_blink_incorrect_retries():
def test_blinking_non_blocking():
calls, set_led = _led()
style = BlinkStyle(set_led)
calls = []
style, _ = _make_blink()
style.set_led = calls.append
style.state = STATE_BLINKING
style.target_blinks = 2
@@ -84,8 +94,7 @@ def test_blinking_non_blocking():
def test_blinking_advances_time():
_, set_led = _led()
style = BlinkStyle(set_led)
style, _ = _make_blink()
style.state = STATE_BLINKING
style.target_blinks = 4
+36 -10
View File
@@ -1,23 +1,49 @@
from unittest.mock import MagicMock
from styles.simple import SimpleStyle
def _make_style():
sound = MagicMock()
style = SimpleStyle(lambda on: None, sound)
return style, sound
def test_simple_keeps_ringing_when_not_pressed():
calls = []
style = SimpleStyle(lambda on: calls.append(on))
style, _ = _make_style()
assert style.update(0.0, False) is True
assert calls[-1] is True # LED solid on while ringing
def test_simple_stops_on_first_press():
calls = []
style = SimpleStyle(lambda on: calls.append(on))
style, _ = _make_style()
assert style.update(0.0, True) is False # press -> stop
def test_simple_led_off_on_release_after_press():
# After a press the style stops; verify it never turns the LED off itself
# (the runner's cleanup handles that). It only ever asserts LED on.
def test_simple_led_solid_on():
calls = []
style = SimpleStyle(lambda on: calls.append(on))
style.update(0.0, False)
sound = MagicMock()
style = SimpleStyle(lambda on: calls.append(on), sound)
style.start()
assert calls == [True]
style.update(0.0, False)
assert calls[-1] is True # stays on every tick
def test_simple_beeps_on_interval():
style, sound = _make_style()
style.start()
style.update(100.0, False) # first tick -> beep
assert sound.play_beep.called
sound.play_beep.reset_mock()
style.update(100.4, False) # before interval -> no beep
assert not sound.play_beep.called
style.update(100.5, False) # at interval -> beep
assert sound.play_beep.called
def test_simple_never_plays_music():
style, sound = _make_style()
style.start()
style.update(0.0, False)
style.update(0.0, True) # press -> stop
assert not sound.play_music.called
+68 -15
View File
@@ -32,11 +32,14 @@ def test_set_led(mock_gpio):
mock_gpio.output.assert_called_with(wecker.LED_PIN, mock_gpio.HIGH)
@patch("wecker.time.time")
def test_run_alarm_plays_music(mock_time, mock_gpio, mock_pygame):
mock_pygame.mixer.get_init.return_value = True
wecker.run_alarm(test_mode=True)
assert mock_pygame.mixer.music.play.called
def test_setup_inits_mixer_and_gpio_not_music(mock_gpio, mock_pygame):
wecker.setup()
assert mock_pygame.mixer.pre_init.called
assert mock_pygame.mixer.init.called
assert mock_gpio.setmode.called
assert mock_gpio.setup.called
# setup() no longer loads music — that is now the style's job.
assert not mock_pygame.mixer.music.load.called
def test_run_alarm_unknown_style_raises(mock_gpio, mock_pygame):
@@ -47,8 +50,7 @@ def test_run_alarm_unknown_style_raises(mock_gpio, mock_pygame):
def test_run_alarm_uses_selected_style(mock_gpio, mock_pygame):
"""run_alarm looks up the style by name and drives the returned style."""
mock_pygame.mixer.get_init.return_value = True
"""run_alarm looks up the style, injects a Sound, and calls start()."""
fake = MagicMock()
fake.update.return_value = True # keep ringing
fake_style_cls = MagicMock(return_value=fake)
@@ -57,17 +59,68 @@ def test_run_alarm_uses_selected_style(mock_gpio, mock_pygame):
wecker.run_alarm(style_name="whatever", test_mode=True)
get_style.assert_called_once_with("whatever")
fake_style_cls.assert_called_once_with(wecker.set_led)
args = fake_style_cls.call_args.args
assert args[0] is wecker.set_led
assert isinstance(args[1], wecker.Sound)
fake.start.assert_called_once()
fake.update.assert_called()
def test_setup_uses_env_music_file(mock_gpio, mock_pygame, monkeypatch):
monkeypatch.setenv("MUSIC_FILE", "/custom/track.mp3")
wecker.setup()
mock_pygame.mixer.music.load.assert_called_with("/custom/track.mp3")
def test_run_alarm_default_music_file(mock_gpio, mock_pygame, monkeypatch):
monkeypatch.delenv("MUSIC_FILE", raising=False)
fake = MagicMock()
fake.update.return_value = True
fake_style_cls = MagicMock(return_value=fake)
with patch("wecker.get_style", return_value=fake_style_cls):
wecker.run_alarm(test_mode=True)
assert fake_style_cls.call_args.args[2] == wecker.DEFAULT_MUSIC_FILE
def test_setup_music_file_argument_overrides_env(mock_gpio, mock_pygame, monkeypatch):
def test_run_alarm_env_music_file(mock_gpio, mock_pygame, monkeypatch):
monkeypatch.setenv("MUSIC_FILE", "/env/track.mp3")
wecker.setup(music_file="/arg/track.mp3")
mock_pygame.mixer.music.load.assert_called_with("/arg/track.mp3")
fake = MagicMock()
fake.update.return_value = True
fake_style_cls = MagicMock(return_value=fake)
with patch("wecker.get_style", return_value=fake_style_cls):
wecker.run_alarm(test_mode=True)
assert fake_style_cls.call_args.args[2] == "/env/track.mp3"
def test_run_alarm_music_file_arg_overrides_env(mock_gpio, mock_pygame, monkeypatch):
monkeypatch.setenv("MUSIC_FILE", "/env/track.mp3")
fake = MagicMock()
fake.update.return_value = True
fake_style_cls = MagicMock(return_value=fake)
with patch("wecker.get_style", return_value=fake_style_cls):
wecker.run_alarm(music_file="/arg/track.mp3", test_mode=True)
assert fake_style_cls.call_args.args[2] == "/arg/track.mp3"
@patch("wecker.time.time")
def test_run_alarm_blink_plays_music(mock_time, mock_gpio, mock_pygame):
"""The default (blink) style plays music on an endless loop."""
wecker.run_alarm(test_mode=True)
assert mock_pygame.mixer.music.play.called
assert mock_pygame.mixer.music.play.call_args.args[0] == -1 # endless loop
def test_run_alarm_simple_beeps_and_plays_no_music(mock_gpio, mock_pygame):
"""The simple style beeps and never touches the music stream."""
wecker.run_alarm(style_name="simple", test_mode=True)
assert mock_pygame.mixer.Sound.return_value.play.called # beep played
assert not mock_pygame.mixer.music.play.called # no music
def test_sound_play_music_loads_and_loops(mock_pygame):
s = wecker.Sound()
s.play_music("/track.mp3")
mock_pygame.mixer.music.load.assert_called_with("/track.mp3")
mock_pygame.mixer.music.set_volume.assert_called_with(1.0)
mock_pygame.mixer.music.play.assert_called_with(-1)
def test_sound_play_beep_plays_buffer(mock_pygame):
s = wecker.Sound()
s.play_beep()
mock_pygame.mixer.Sound.assert_called_once_with(wecker._BEEP_BUFFER)
mock_pygame.mixer.Sound.return_value.play.assert_called_once()
+65 -26
View File
@@ -1,10 +1,13 @@
import RPi.GPIO as GPIO
import time
import logging
import pygame
import sys
import array
import math
import os
import sys
import time
import fcntl
import logging
import pygame
import RPi.GPIO as GPIO
# Use PulseAudio/PipeWire audio driver to avoid ALSA device-busy errors.
os.environ.setdefault("SDL_AUDIODRIVER", "pulse")
@@ -69,11 +72,12 @@ LED_PIN = 27
DEFAULT_MUSIC_FILE = "Laid Back - Sunshine Reggae.mp3"
def setup(music_file: str | None = None):
"""Initialize GPIO and audio hardware. Call once before running."""
if music_file is None:
music_file = os.environ.get("MUSIC_FILE", DEFAULT_MUSIC_FILE)
def setup():
"""Initialize GPIO and audio hardware. Call once before running.
Audio playback itself is owned by each style via the injected ``Sound``
capability, so this only brings up the mixer and pins.
"""
GPIO.setmode(GPIO.BCM)
GPIO.setup(LED_PIN, GPIO.OUT)
GPIO.setup(BUTTON_PIN, GPIO.IN, pull_up_down=GPIO.PUD_UP)
@@ -82,14 +86,47 @@ def setup(music_file: str | None = None):
pygame.mixer.pre_init(frequency=44100, size=-16, channels=2, buffer=4096)
pygame.mixer.init()
try:
pygame.mixer.music.load(music_file)
pygame.mixer.music.set_volume(1.0)
except Exception as e:
logging.error(f"Error loading audio file: {e}")
# Don't exit in test mode
if "pytest" not in sys.modules:
sys.exit(1)
def _make_beep_buffer(
freq: int = 1000, duration: float = 0.15, rate: int = 44100
) -> bytes:
"""Synthesize a short square-wave beep as signed-16-bit stereo PCM.
Matches the mixer format from ``pre_init`` (rate, -16, 2 channels) so it
feeds straight into ``pygame.mixer.Sound``. No audio file, no extra dep.
"""
n = int(rate * duration)
buf = array.array("h")
for i in range(n):
v = 32767 if math.sin(2 * math.pi * freq * (i / rate)) >= 0 else -32767
buf.append(v)
buf.append(v) # duplicate to stereo
return buf.tobytes()
_BEEP_BUFFER = _make_beep_buffer()
class Sound:
"""Audio capability injected into styles so each can own its ringing tone."""
def __init__(self):
self._beep = pygame.mixer.Sound(_BEEP_BUFFER)
def play_music(self, path: str):
"""Load and play a music file on an endless loop."""
try:
pygame.mixer.music.load(path)
pygame.mixer.music.set_volume(1.0)
pygame.mixer.music.play(-1)
except Exception as e:
logging.error(f"Error loading audio file: {e}")
if "pytest" not in sys.modules:
sys.exit(1)
def play_beep(self):
"""Play one short beep tone."""
self._beep.play()
def set_led(on):
@@ -108,16 +145,18 @@ def run_alarm(
):
"""Run the alarm clock with the given style until the style signals stop.
The runner owns everything shared (music loop, button sampling, cleanup);
the style only decides when to stop and what the LED does.
The runner owns only the shared button sampling and cleanup; each style
owns its own ringing tone (via the injected ``Sound``) and LED behaviour.
"""
style = get_style(style_name)(set_led)
setup(music_file=music_file)
logging.info("Alarm clock started. Music is playing in an endless loop.")
if "pygame" in globals() and hasattr(pygame, "mixer") and pygame.mixer.get_init():
pygame.mixer.music.play(-1)
style_cls = get_style(style_name) # validate before touching hardware
if music_file is None:
music_file = os.environ.get("MUSIC_FILE", DEFAULT_MUSIC_FILE)
set_led(False) # LED off at start; the style drives it from here.
setup()
sound = Sound()
style = style_cls(set_led, sound, music_file)
logging.info("Alarm clock started.")
style.start() # style kicks off its own tone + initial LED
try:
while True:
@@ -146,7 +185,7 @@ if __name__ == "__main__":
parser.add_argument(
"--music-file",
default=os.environ.get("MUSIC_FILE", DEFAULT_MUSIC_FILE),
help="Path to the MP3 file to play (default: $MUSIC_FILE or default track).",
help="Music file for the blink style (default: $MUSIC_FILE or default track).",
)
parser.add_argument(
"--style",