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:
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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()
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user