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:
@@ -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