refactor: styles own their ringing tone directly (Pattern C)

Drop the injected AlarmSound Protocol and the wecker.Sound class. Each style
now owns its tone by using pygame directly; the runner owns only the audio
engine lifecycle (mixer init/quit), button sampling, and cleanup. This is the
seam for future styles that handle their own tone — a 'talk' style would just
import pygame and play speech, with no shared interface to extend.

- styles/base.py: AlarmStyle.__init__(set_led, music_file) + start() + update()
- styles/blink.py: start() loads+plays music via pygame.mixer.music
- styles/simple.py: owns its beep — synthesises a square-wave buffer in
  module (stdlib array+math) and plays it via pygame.mixer.Sound
- wecker.py: setup() brings up the mixer only; run_alarm constructs the style
  and guards start() with a clean log+exit on failure

Tests: a shared tests/conftest.py stubs RPi.GPIO/pygame in sys.modules before
any SUT import (order-independent, removes duplicated inline mocking); the
wecker mock_pygame fixture patches one fresh pygame mock into wecker + both
style modules so assertions see the same calls.

README: tone-ownership and plugin-contract updated.
This commit is contained in:
2026-08-02 21:50:00 +02:00
parent 92eba762ce
commit 3a7b6ac7a3
10 changed files with 167 additions and 190 deletions
+2 -2
View File
@@ -150,9 +150,9 @@ Each alarm has a **style** that decides how you turn it off. Set it per alarm vi
| `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.** | | `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). | | `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). |
**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. **The style owns its ringing tone.** The runner brings up the audio engine (the pygame mixer) and hands each style the button + LED; the style then produces its own tone directly — `blink` plays a music file, `simple` synthesises a beep, a future `talk` style would play speech. Styles use pygame directly (it's the audio engine, not hardware); the runner still owns mixer init and cleanup. This direct ownership 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`. Adding a style is a plugin-style drop-in: add a class under `styles/` implementing the `AlarmStyle` contract (`__init__(set_led, 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")`. **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")`.
+11 -29
View File
@@ -1,44 +1,26 @@
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from typing import Callable, Protocol from typing import Callable
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): class AlarmStyle(ABC):
"""A pluggable alarm behaviour. """A pluggable alarm behaviour.
A style owns *its own ringing tone* (via the injected ``sound``) and A style owns *its own ringing tone*: it uses the audio engine (pygame)
decides when the alarm stops and what the LED does. It never touches directly and decides when the alarm stops and what the LED does. It never
hardware directly: ``set_led`` and ``sound`` are injected by the runner, so touches hardware directly: ``set_led`` is injected by the runner, and the
styles stay import-safe in the API process (no GPIO/pygame). runner owns the audio engine lifecycle (mixer init/quit). Styles stay
import-safe in the API process — ``import pygame`` does not initialise
audio, so the API can import styles just to validate names.
Lifecycle: construct once with ``set_led``/``sound``/``music_file``; call Lifecycle: construct once with ``set_led``/``music_file``; call ``start()``
``start()`` to begin ringing (kick off the tone and initial LED); then call to begin ringing (kick off the tone and initial LED); then call ``update``
``update`` every tick. Return ``False`` from ``update`` to stop the alarm. every tick. Return ``False`` from ``update`` to stop the alarm.
""" """
def __init__( def __init__(
self, self, set_led: Callable[[bool], None], music_file: str | None = None
set_led: Callable[[bool], None],
sound: AlarmSound,
music_file: str | None = None,
): ):
self.set_led = set_led self.set_led = set_led
self.sound = sound
self.music_file = music_file self.music_file = music_file
def start(self) -> None: def start(self) -> None:
+9 -11
View File
@@ -1,6 +1,8 @@
import logging import logging
import random import random
import pygame
from styles.base import AlarmStyle from styles.base import AlarmStyle
# State Machine states for the puzzle flow. # State Machine states for the puzzle flow.
@@ -21,8 +23,8 @@ class BlinkStyle(AlarmStyle):
exactly that many times. A wrong count starts a fresh sequence. exactly that many times. A wrong count starts a fresh sequence.
""" """
def __init__(self, set_led, sound, music_file=None): def __init__(self, set_led, music_file=None):
super().__init__(set_led, sound, music_file) super().__init__(set_led, music_file)
self.state = STATE_RINGING self.state = STATE_RINGING
self.target_blinks = 0 self.target_blinks = 0
self.user_presses = 0 self.user_presses = 0
@@ -35,7 +37,9 @@ class BlinkStyle(AlarmStyle):
def start(self): def start(self):
# The puzzle rings with music on an endless loop; the LED stays off # The puzzle rings with music on an endless loop; the LED stays off
# until the button is pressed to start the blink sequence. # until the button is pressed to start the blink sequence.
self.sound.play_music(self.music_file) pygame.mixer.music.load(self.music_file)
pygame.mixer.music.set_volume(1.0)
pygame.mixer.music.play(-1)
self.set_led(False) self.set_led(False)
def update(self, now, is_pressed): def update(self, now, is_pressed):
@@ -130,14 +134,8 @@ class BlinkStyle(AlarmStyle):
if __name__ == "__main__": if __name__ == "__main__":
# Self-check: a correct blink sequence stops the alarm. # Self-check: a correct blink sequence stops the alarm.
class _StubSound: # (Does not call start(), which needs the audio engine.)
def play_music(self, path): style = BlinkStyle(lambda on: None, "x.mp3")
pass
def play_beep(self):
pass
style = BlinkStyle(lambda on: None, _StubSound(), "x.mp3")
assert style.update(100.0, True) is True assert style.update(100.0, True) is True
assert style.state == STATE_WAIT_BEFORE_BLINK assert style.state == STATE_WAIT_BEFORE_BLINK
+47 -17
View File
@@ -1,3 +1,8 @@
import array
import math
import pygame
from styles.base import AlarmStyle from styles.base import AlarmStyle
# Time between beep retriggers; a short beep followed by this gap gives the # Time between beep retriggers; a short beep followed by this gap gives the
@@ -5,48 +10,73 @@ from styles.base import AlarmStyle
BEEP_INTERVAL = 0.5 BEEP_INTERVAL = 0.5
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 used by wecker.setup() (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 SimpleStyle(AlarmStyle): class SimpleStyle(AlarmStyle):
"""Press the button once to stop. Rings with a repeating beep. """Press the button once to stop. Rings with a repeating beep.
This style plays no music file; it owns its tone via ``sound.play_beep``. This style plays no music file; it owns its tone via a synthesised square
wave played through ``pygame.mixer.Sound``.
""" """
def __init__(self, set_led, sound, music_file=None): def __init__(self, set_led, music_file=None):
super().__init__(set_led, sound, music_file) super().__init__(set_led, music_file)
self._next_beep = 0.0 self._next_beep = 0.0
self._beep = None
def start(self): def start(self):
# LED solid on so the button is findable in the dark. # LED solid on so the button is findable in the dark.
self.set_led(True) self.set_led(True)
self._beep = pygame.mixer.Sound(_BEEP_BUFFER)
def update(self, now, is_pressed): def update(self, now, is_pressed):
self.set_led(True) self.set_led(True)
if now >= self._next_beep: if now >= self._next_beep:
self.sound.play_beep() self._beep.play()
self._next_beep = now + BEEP_INTERVAL self._next_beep = now + BEEP_INTERVAL
return not is_pressed # first press -> False -> stop return not is_pressed # first press -> False -> stop
if __name__ == "__main__": if __name__ == "__main__":
# Self-check: not pressed keeps ringing and beeps; a press stops it. # Self-check: the timer beeps on the interval and a press stops it.
class _StubSound: # Bypasses start() (which needs the audio engine) by stubbing the beep.
class _Beep:
def __init__(self): def __init__(self):
self.beeps = 0 self.plays = 0
def play_music(self, path): def play(self):
raise AssertionError("simple style must never play music") self.plays += 1
def play_beep(self): style = SimpleStyle(lambda on: None)
self.beeps += 1 style._beep = _Beep()
sound = _StubSound()
style = SimpleStyle(lambda on: None, sound)
style.start()
assert style.update(0.0, False) is True assert style.update(0.0, False) is True
assert sound.beeps == 1 # first tick beeps assert style._beep.plays == 1 # first tick beeps
assert style.update(0.4, False) is True # before interval -> no beep assert style.update(0.4, False) is True # before interval -> no beep
assert sound.beeps == 1 assert style._beep.plays == 1
assert style.update(0.5, False) is True # at interval -> beep assert style.update(0.5, False) is True # at interval -> beep
assert sound.beeps == 2 assert style._beep.plays == 2
assert style.update(0.6, True) is False # press -> stop assert style.update(0.6, True) is False # press -> stop
print("simple self-check ok") print("simple self-check ok")
+14
View File
@@ -0,0 +1,14 @@
"""Shared test setup: stub hardware/audio modules before any SUT import.
wecker and the alarm styles import RPi.GPIO (hardware) and pygame (audio
engine). The tests run off the Pi and without a real audio device, so these
are replaced with MagicMocks in sys.modules before wecker/styles are imported.
Loaded by pytest before any test module in this directory is collected.
"""
import sys
from unittest.mock import MagicMock
sys.modules["RPi"] = MagicMock()
sys.modules["RPi.GPIO"] = MagicMock()
sys.modules["pygame"] = MagicMock()
+4 -9
View File
@@ -1,14 +1,9 @@
import pytest import pytest
import sys from unittest.mock import patch
from unittest.mock import MagicMock, patch
# Mock hardware modules before importing wecker (tests run off the Pi). # Hardware/audio mocks live in tests/conftest.py (shared across all test files).
sys.modules["RPi"] = MagicMock() import common
sys.modules["RPi.GPIO"] = MagicMock() import wecker
sys.modules["pygame"] = MagicMock()
import common # noqa: E402
import wecker # noqa: E402
@pytest.fixture @pytest.fixture
+11 -12
View File
@@ -1,4 +1,4 @@
from unittest.mock import MagicMock from unittest.mock import MagicMock, patch
from styles.blink import ( from styles.blink import (
STATE_BLINKING, STATE_BLINKING,
@@ -11,20 +11,20 @@ from styles.blink import (
def _make_blink(music_file="music.mp3"): def _make_blink(music_file="music.mp3"):
"""Return (style, sound_mock) for a BlinkStyle ready to start.""" """Return (style, set_led calls) for a BlinkStyle ready to update."""
sound = MagicMock() calls = []
style = BlinkStyle(lambda on: None, sound, music_file) style = BlinkStyle(lambda on: calls.append(on), music_file)
return style, sound return style, calls
def test_start_plays_music_and_led_off(): def test_start_plays_music_and_led_off():
calls = [] style, calls = _make_blink("track.mp3")
sound = MagicMock() with patch("styles.blink.pygame", MagicMock()) as pg:
style = BlinkStyle(lambda on: calls.append(on), sound, "track.mp3")
style.start() style.start()
sound.play_music.assert_called_once_with("track.mp3") pg.mixer.music.load.assert_called_with("track.mp3")
pg.mixer.music.set_volume.assert_called_with(1.0)
pg.mixer.music.play.assert_called_with(-1)
assert calls == [False] # LED off while music plays until a press starts the puzzle 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(): def test_press_starts_puzzle():
@@ -74,8 +74,7 @@ def test_blink_incorrect_retries():
def test_blinking_non_blocking(): def test_blinking_non_blocking():
calls = [] style, calls = _make_blink()
style, _ = _make_blink()
style.set_led = calls.append style.set_led = calls.append
style.state = STATE_BLINKING style.state = STATE_BLINKING
style.target_blinks = 2 style.target_blinks = 2
+22 -16
View File
@@ -1,49 +1,55 @@
from unittest.mock import MagicMock from unittest.mock import MagicMock, patch
from styles.simple import SimpleStyle from styles.simple import SimpleStyle
def _make_style(): def _make_style():
sound = MagicMock() calls = []
style = SimpleStyle(lambda on: None, sound) return SimpleStyle(lambda on: calls.append(on)), calls
return style, sound
def test_simple_keeps_ringing_when_not_pressed(): def test_simple_keeps_ringing_when_not_pressed():
style, _ = _make_style() style, _ = _make_style()
with patch("styles.simple.pygame", MagicMock()):
style.start()
assert style.update(0.0, False) is True assert style.update(0.0, False) is True
def test_simple_stops_on_first_press(): def test_simple_stops_on_first_press():
style, _ = _make_style() style, _ = _make_style()
with patch("styles.simple.pygame", MagicMock()):
style.start()
assert style.update(0.0, True) is False # press -> stop assert style.update(0.0, True) is False # press -> stop
def test_simple_led_solid_on(): def test_simple_led_solid_on():
calls = [] style, calls = _make_style()
sound = MagicMock() with patch("styles.simple.pygame", MagicMock()):
style = SimpleStyle(lambda on: calls.append(on), sound)
style.start() style.start()
assert calls == [True]
style.update(0.0, False) style.update(0.0, False)
assert calls[-1] is True # stays on every tick assert calls == [True, True] # start sets LED on; update keeps it on
def test_simple_beeps_on_interval(): def test_simple_beeps_on_interval():
style, sound = _make_style() style, _ = _make_style()
with patch("styles.simple.pygame", MagicMock()) as pg:
style.start() style.start()
style.update(100.0, False) # first tick -> beep style.update(100.0, False) # first tick -> beep
assert sound.play_beep.called assert pg.mixer.Sound.return_value.play.called
sound.play_beep.reset_mock() pg.mixer.Sound.return_value.play.reset_mock()
style.update(100.4, False) # before interval -> no beep style.update(100.4, False) # before interval -> no beep
assert not sound.play_beep.called assert not pg.mixer.Sound.return_value.play.called
style.update(100.5, False) # at interval -> beep style.update(100.5, False) # at interval -> beep
assert sound.play_beep.called assert pg.mixer.Sound.return_value.play.called
def test_simple_never_plays_music(): def test_simple_never_plays_music():
style, sound = _make_style() style, _ = _make_style()
with patch("styles.simple.pygame", MagicMock()) as pg:
style.start() style.start()
style.update(0.0, False) style.update(0.0, False)
style.update(0.0, True) # press -> stop style.update(0.0, True) # press -> stop
assert not sound.play_music.called assert not pg.mixer.music.play.called
assert not pg.mixer.music.load.called
+21 -30
View File
@@ -1,13 +1,9 @@
import pytest import pytest
import sys
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
# Mock RPi.GPIO and pygame before importing wecker import styles.blink
sys.modules["RPi"] = MagicMock() import styles.simple
sys.modules["RPi.GPIO"] = MagicMock() import wecker
sys.modules["pygame"] = MagicMock()
import wecker # noqa: E402
@pytest.fixture @pytest.fixture
@@ -20,7 +16,18 @@ def mock_gpio():
@pytest.fixture @pytest.fixture
def mock_pygame(): def mock_pygame():
with patch.object(wecker, "pygame") as mock: """One fresh pygame mock shared by wecker and both styles.
Under Pattern C the styles use pygame directly, so a single mock must be
patched into wecker (setup) and the style modules (start) for assertions
to see the same calls.
"""
mock = MagicMock()
with (
patch.object(wecker, "pygame", mock),
patch.object(styles.blink, "pygame", mock),
patch.object(styles.simple, "pygame", mock),
):
yield mock yield mock
@@ -50,7 +57,7 @@ def test_run_alarm_unknown_style_raises(mock_gpio, mock_pygame):
def test_run_alarm_uses_selected_style(mock_gpio, mock_pygame): def test_run_alarm_uses_selected_style(mock_gpio, mock_pygame):
"""run_alarm looks up the style, injects a Sound, and calls start().""" """run_alarm looks up the style, injects set_led + music_file, calls start()."""
fake = MagicMock() fake = MagicMock()
fake.update.return_value = True # keep ringing fake.update.return_value = True # keep ringing
fake_style_cls = MagicMock(return_value=fake) fake_style_cls = MagicMock(return_value=fake)
@@ -61,7 +68,7 @@ def test_run_alarm_uses_selected_style(mock_gpio, mock_pygame):
get_style.assert_called_once_with("whatever") get_style.assert_called_once_with("whatever")
args = fake_style_cls.call_args.args args = fake_style_cls.call_args.args
assert args[0] is wecker.set_led assert args[0] is wecker.set_led
assert isinstance(args[1], wecker.Sound) assert isinstance(args[1], str) # resolved music file
fake.start.assert_called_once() fake.start.assert_called_once()
fake.update.assert_called() fake.update.assert_called()
@@ -73,7 +80,7 @@ def test_run_alarm_default_music_file(mock_gpio, mock_pygame, monkeypatch):
fake_style_cls = MagicMock(return_value=fake) fake_style_cls = MagicMock(return_value=fake)
with patch("wecker.get_style", return_value=fake_style_cls): with patch("wecker.get_style", return_value=fake_style_cls):
wecker.run_alarm(test_mode=True) wecker.run_alarm(test_mode=True)
assert fake_style_cls.call_args.args[2] == wecker.DEFAULT_MUSIC_FILE assert fake_style_cls.call_args.args[1] == wecker.DEFAULT_MUSIC_FILE
def test_run_alarm_env_music_file(mock_gpio, mock_pygame, monkeypatch): def test_run_alarm_env_music_file(mock_gpio, mock_pygame, monkeypatch):
@@ -83,7 +90,7 @@ def test_run_alarm_env_music_file(mock_gpio, mock_pygame, monkeypatch):
fake_style_cls = MagicMock(return_value=fake) fake_style_cls = MagicMock(return_value=fake)
with patch("wecker.get_style", return_value=fake_style_cls): with patch("wecker.get_style", return_value=fake_style_cls):
wecker.run_alarm(test_mode=True) wecker.run_alarm(test_mode=True)
assert fake_style_cls.call_args.args[2] == "/env/track.mp3" assert fake_style_cls.call_args.args[1] == "/env/track.mp3"
def test_run_alarm_music_file_arg_overrides_env(mock_gpio, mock_pygame, monkeypatch): def test_run_alarm_music_file_arg_overrides_env(mock_gpio, mock_pygame, monkeypatch):
@@ -93,11 +100,10 @@ def test_run_alarm_music_file_arg_overrides_env(mock_gpio, mock_pygame, monkeypa
fake_style_cls = MagicMock(return_value=fake) fake_style_cls = MagicMock(return_value=fake)
with patch("wecker.get_style", return_value=fake_style_cls): with patch("wecker.get_style", return_value=fake_style_cls):
wecker.run_alarm(music_file="/arg/track.mp3", test_mode=True) wecker.run_alarm(music_file="/arg/track.mp3", test_mode=True)
assert fake_style_cls.call_args.args[2] == "/arg/track.mp3" assert fake_style_cls.call_args.args[1] == "/arg/track.mp3"
@patch("wecker.time.time") def test_run_alarm_blink_plays_music(mock_gpio, mock_pygame):
def test_run_alarm_blink_plays_music(mock_time, mock_gpio, mock_pygame):
"""The default (blink) style plays music on an endless loop.""" """The default (blink) style plays music on an endless loop."""
wecker.run_alarm(test_mode=True) wecker.run_alarm(test_mode=True)
assert mock_pygame.mixer.music.play.called assert mock_pygame.mixer.music.play.called
@@ -109,18 +115,3 @@ def test_run_alarm_simple_beeps_and_plays_no_music(mock_gpio, mock_pygame):
wecker.run_alarm(style_name="simple", test_mode=True) wecker.run_alarm(style_name="simple", test_mode=True)
assert mock_pygame.mixer.Sound.return_value.play.called # beep played assert mock_pygame.mixer.Sound.return_value.play.called # beep played
assert not mock_pygame.mixer.music.play.called # no music 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()
+13 -51
View File
@@ -1,5 +1,3 @@
import array
import math
import os import os
import sys import sys
import time import time
@@ -73,10 +71,10 @@ DEFAULT_MUSIC_FILE = "Laid Back - Sunshine Reggae.mp3"
def setup(): def setup():
"""Initialize GPIO and audio hardware. Call once before running. """Initialize GPIO and the audio engine. Call once before running.
Audio playback itself is owned by each style via the injected ``Sound`` Each style owns its own ringing tone via pygame directly; this only brings
capability, so this only brings up the mixer and pins. up the mixer and the GPIO pins.
""" """
GPIO.setmode(GPIO.BCM) GPIO.setmode(GPIO.BCM)
GPIO.setup(LED_PIN, GPIO.OUT) GPIO.setup(LED_PIN, GPIO.OUT)
@@ -87,48 +85,6 @@ def setup():
pygame.mixer.init() pygame.mixer.init()
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): def set_led(on):
"""Turns the LED on or off (LOW = ON, HIGH = OFF)""" """Turns the LED on or off (LOW = ON, HIGH = OFF)"""
if "GPIO" in globals() and hasattr(GPIO, "output"): if "GPIO" in globals() and hasattr(GPIO, "output"):
@@ -145,18 +101,24 @@ def run_alarm(
): ):
"""Run the alarm clock with the given style until the style signals stop. """Run the alarm clock with the given style until the style signals stop.
The runner owns only the shared button sampling and cleanup; each style The runner owns only the audio engine lifecycle (mixer init/quit), button
owns its own ringing tone (via the injected ``Sound``) and LED behaviour. sampling, and cleanup; each style owns its own ringing tone (via pygame
directly) and LED behaviour.
""" """
style_cls = get_style(style_name) # validate before touching hardware style_cls = get_style(style_name) # validate before touching hardware
if music_file is None: if music_file is None:
music_file = os.environ.get("MUSIC_FILE", DEFAULT_MUSIC_FILE) music_file = os.environ.get("MUSIC_FILE", DEFAULT_MUSIC_FILE)
setup() setup()
sound = Sound() style = style_cls(set_led, music_file)
style = style_cls(set_led, sound, music_file)
logging.info("Alarm clock started.") logging.info("Alarm clock started.")
try:
style.start() # style kicks off its own tone + initial LED style.start() # style kicks off its own tone + initial LED
except Exception:
logging.exception("Error starting alarm")
if "pytest" not in sys.modules:
sys.exit(1)
raise
try: try:
while True: while True: