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 # Use the default music file or the MUSIC_FILE environment variable
python3 wecker.py 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 python3 wecker.py --music-file /path/to/your/alarm.mp3
# Choose an alarm style (default: blink; see Alarm Styles below) # Choose an alarm style (default: blink; see Alarm Styles below)
python3 wecker.py --style simple 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) ## 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: 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 | | Style | Ringing tone | How to stop |
|---|---| |---|---|---|
| `simple` | Press the button once to stop. 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` | 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). |
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")`. **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 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): class AlarmStyle(ABC):
"""A pluggable alarm behaviour. """A pluggable alarm behaviour.
A style decides *when* the alarm stops and what the LED does while it A style owns *its own ringing tone* (via the injected ``sound``) and
rings. It never touches hardware directly: ``set_led`` is injected by the decides when the alarm stops and what the LED does. It never touches
runner, so styles stay import-safe in the API process (no GPIO/pygame). 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 Lifecycle: construct once with ``set_led``/``sound``/``music_file``; call
tick. Return ``False`` from ``update`` to stop the alarm. ``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.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 @abstractmethod
def update(self, now: float, is_pressed: bool) -> bool: 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. exactly that many times. A wrong count starts a fresh sequence.
""" """
def __init__(self, set_led): def __init__(self, set_led, sound, music_file=None):
super().__init__(set_led) super().__init__(set_led, sound, 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
@@ -32,6 +32,12 @@ class BlinkStyle(AlarmStyle):
self._blink_phases = 0 self._blink_phases = 0
self._blink_next_toggle: float | None = None 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): def update(self, now, is_pressed):
button_just_pressed = False button_just_pressed = False
if is_pressed and not self.button_was_pressed: if is_pressed and not self.button_was_pressed:
@@ -124,7 +130,14 @@ 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.
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.update(100.0, True) is True
assert style.state == STATE_WAIT_BEFORE_BLINK assert style.state == STATE_WAIT_BEFORE_BLINK
+39 -5
View File
@@ -1,18 +1,52 @@
from styles.base import AlarmStyle 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): 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. # LED solid on so the button is findable in the dark.
self.set_led(True) 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 return not is_pressed # first press -> False -> stop
if __name__ == "__main__": if __name__ == "__main__":
# Self-check: not pressed keeps ringing; a press stops it. # Self-check: not pressed keeps ringing and beeps; a press stops it.
style = SimpleStyle(lambda on: None) 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.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") print("simple self-check ok")
+23 -14
View File
@@ -1,3 +1,5 @@
from unittest.mock import MagicMock
from styles.blink import ( from styles.blink import (
STATE_BLINKING, STATE_BLINKING,
STATE_EVALUATING, STATE_EVALUATING,
@@ -8,23 +10,31 @@ from styles.blink import (
) )
def _led(): def _make_blink(music_file="music.mp3"):
"""Return (calls list, set_led callable recording each call).""" """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 = [] 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(): def test_press_starts_puzzle():
_, set_led = _led() style, _ = _make_blink()
style = BlinkStyle(set_led)
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
def test_blink_correct_sequence_stops(monkeypatch): def test_blink_correct_sequence_stops(monkeypatch):
_, set_led = _led() style, _ = _make_blink()
style = BlinkStyle(set_led)
style.update(100.0, True) # press -> start puzzle style.update(100.0, True) # press -> start puzzle
assert style.state == STATE_WAIT_BEFORE_BLINK assert style.state == STATE_WAIT_BEFORE_BLINK
@@ -48,8 +58,7 @@ def test_blink_correct_sequence_stops(monkeypatch):
def test_blink_incorrect_retries(): def test_blink_incorrect_retries():
_, set_led = _led() style, _ = _make_blink()
style = BlinkStyle(set_led)
style.state = STATE_WAIT_FOR_INPUT style.state = STATE_WAIT_FOR_INPUT
style.target_blinks = 3 style.target_blinks = 3
@@ -65,8 +74,9 @@ def test_blink_incorrect_retries():
def test_blinking_non_blocking(): def test_blinking_non_blocking():
calls, set_led = _led() calls = []
style = BlinkStyle(set_led) style, _ = _make_blink()
style.set_led = calls.append
style.state = STATE_BLINKING style.state = STATE_BLINKING
style.target_blinks = 2 style.target_blinks = 2
@@ -84,8 +94,7 @@ def test_blinking_non_blocking():
def test_blinking_advances_time(): def test_blinking_advances_time():
_, set_led = _led() style, _ = _make_blink()
style = BlinkStyle(set_led)
style.state = STATE_BLINKING style.state = STATE_BLINKING
style.target_blinks = 4 style.target_blinks = 4
+36 -10
View File
@@ -1,23 +1,49 @@
from unittest.mock import MagicMock
from styles.simple import SimpleStyle 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(): def test_simple_keeps_ringing_when_not_pressed():
calls = [] style, _ = _make_style()
style = SimpleStyle(lambda on: calls.append(on))
assert style.update(0.0, False) is True 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(): def test_simple_stops_on_first_press():
calls = [] style, _ = _make_style()
style = SimpleStyle(lambda on: calls.append(on))
assert style.update(0.0, True) is False # press -> stop assert style.update(0.0, True) is False # press -> stop
def test_simple_led_off_on_release_after_press(): def test_simple_led_solid_on():
# 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.
calls = [] calls = []
style = SimpleStyle(lambda on: calls.append(on)) sound = MagicMock()
style.update(0.0, False) style = SimpleStyle(lambda on: calls.append(on), sound)
style.start()
assert calls == [True] 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) mock_gpio.output.assert_called_with(wecker.LED_PIN, mock_gpio.HIGH)
@patch("wecker.time.time") def test_setup_inits_mixer_and_gpio_not_music(mock_gpio, mock_pygame):
def test_run_alarm_plays_music(mock_time, mock_gpio, mock_pygame): wecker.setup()
mock_pygame.mixer.get_init.return_value = True assert mock_pygame.mixer.pre_init.called
wecker.run_alarm(test_mode=True) assert mock_pygame.mixer.init.called
assert mock_pygame.mixer.music.play.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): 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): def test_run_alarm_uses_selected_style(mock_gpio, mock_pygame):
"""run_alarm looks up the style by name and drives the returned style.""" """run_alarm looks up the style, injects a Sound, and calls start()."""
mock_pygame.mixer.get_init.return_value = True
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)
@@ -57,17 +59,68 @@ def test_run_alarm_uses_selected_style(mock_gpio, mock_pygame):
wecker.run_alarm(style_name="whatever", test_mode=True) wecker.run_alarm(style_name="whatever", test_mode=True)
get_style.assert_called_once_with("whatever") 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() fake.update.assert_called()
def test_setup_uses_env_music_file(mock_gpio, mock_pygame, monkeypatch): def test_run_alarm_default_music_file(mock_gpio, mock_pygame, monkeypatch):
monkeypatch.setenv("MUSIC_FILE", "/custom/track.mp3") monkeypatch.delenv("MUSIC_FILE", raising=False)
wecker.setup() fake = MagicMock()
mock_pygame.mixer.music.load.assert_called_with("/custom/track.mp3") 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") monkeypatch.setenv("MUSIC_FILE", "/env/track.mp3")
wecker.setup(music_file="/arg/track.mp3") fake = MagicMock()
mock_pygame.mixer.music.load.assert_called_with("/arg/track.mp3") 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()
+59 -20
View File
@@ -1,10 +1,13 @@
import RPi.GPIO as GPIO import array
import time import math
import logging
import pygame
import sys
import os import os
import sys
import time
import fcntl import fcntl
import logging
import pygame
import RPi.GPIO as GPIO
# Use PulseAudio/PipeWire audio driver to avoid ALSA device-busy errors. # Use PulseAudio/PipeWire audio driver to avoid ALSA device-busy errors.
os.environ.setdefault("SDL_AUDIODRIVER", "pulse") os.environ.setdefault("SDL_AUDIODRIVER", "pulse")
@@ -69,11 +72,12 @@ LED_PIN = 27
DEFAULT_MUSIC_FILE = "Laid Back - Sunshine Reggae.mp3" DEFAULT_MUSIC_FILE = "Laid Back - Sunshine Reggae.mp3"
def setup(music_file: str | None = None): def setup():
"""Initialize GPIO and audio hardware. Call once before running.""" """Initialize GPIO and audio hardware. Call once before running.
if music_file is None:
music_file = os.environ.get("MUSIC_FILE", DEFAULT_MUSIC_FILE)
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.setmode(GPIO.BCM)
GPIO.setup(LED_PIN, GPIO.OUT) GPIO.setup(LED_PIN, GPIO.OUT)
GPIO.setup(BUTTON_PIN, GPIO.IN, pull_up_down=GPIO.PUD_UP) GPIO.setup(BUTTON_PIN, GPIO.IN, pull_up_down=GPIO.PUD_UP)
@@ -82,15 +86,48 @@ def setup(music_file: str | None = None):
pygame.mixer.pre_init(frequency=44100, size=-16, channels=2, buffer=4096) pygame.mixer.pre_init(frequency=44100, size=-16, channels=2, buffer=4096)
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: try:
pygame.mixer.music.load(music_file) pygame.mixer.music.load(path)
pygame.mixer.music.set_volume(1.0) pygame.mixer.music.set_volume(1.0)
pygame.mixer.music.play(-1)
except Exception as e: except Exception as e:
logging.error(f"Error loading audio file: {e}") logging.error(f"Error loading audio file: {e}")
# Don't exit in test mode
if "pytest" not in sys.modules: if "pytest" not in sys.modules:
sys.exit(1) 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)"""
@@ -108,16 +145,18 @@ 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 everything shared (music loop, button sampling, cleanup); The runner owns only the shared button sampling and cleanup; each style
the style only decides when to stop and what the LED does. owns its own ringing tone (via the injected ``Sound``) and LED behaviour.
""" """
style = get_style(style_name)(set_led) style_cls = get_style(style_name) # validate before touching hardware
setup(music_file=music_file) if music_file is None:
logging.info("Alarm clock started. Music is playing in an endless loop.") music_file = os.environ.get("MUSIC_FILE", DEFAULT_MUSIC_FILE)
if "pygame" in globals() and hasattr(pygame, "mixer") and pygame.mixer.get_init():
pygame.mixer.music.play(-1)
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: try:
while True: while True:
@@ -146,7 +185,7 @@ if __name__ == "__main__":
parser.add_argument( parser.add_argument(
"--music-file", "--music-file",
default=os.environ.get("MUSIC_FILE", DEFAULT_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( parser.add_argument(
"--style", "--style",