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
+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",