#!/usr/bin/env python3 """ Plays a ding/timer sound at regular intervals aligned to round period marks (e.g. every 15 min), with an optional offset so the sound plays a few seconds before each mark. Default: period 15 min, offset 15 s, 1 ding, sound asterisk. Notification at :14:45, :29:45, :44:45, :59:45. Windows + Linux (stdlib). python scripts/python/utils/interval_timer_ding.py python scripts/python/utils/interval_timer_ding.py -p 30 -o 10 python scripts/python/utils/interval_timer_ding.py -s exclamation -n 2 python scripts/python/utils/interval_timer_ding.py -s /path/to/notify.wav """ import argparse import math import subprocess import sys import time from datetime import datetime def _seconds_since_midnight(dt: datetime) -> int: return dt.hour * 3600 + dt.minute * 60 + dt.second def _next_ring_seconds(period_sec: int, offset_sec: int, now_sec: int) -> int: next_boundary = math.ceil(now_sec / period_sec) * period_sec ring_sec = next_boundary - offset_sec if ring_sec <= now_sec: ring_sec += period_sec return ring_sec def _wait_seconds(period_sec: int, offset_sec: int) -> int: now = datetime.now() now_sec = _seconds_since_midnight(now) ring_sec = _next_ring_seconds(period_sec, offset_sec, now_sec) wait = ring_sec - now_sec return max(1, wait) if wait > 0 else 1 # Gap between repeated dings (Windows MessageBeep is async so need ~0.5s+) _DING_GAP_SEC = 1.7 _WIN_BEEP_MS = 350 # Sound choice: None = use default, "asterisk"|"exclamation"|... = Windows preset, or file path _sound: str | None = None def _play_ding_windows() -> None: import winsound sound = _sound or "asterisk" if sound in ("asterisk", "exclamation", "hand", "question", "beep"): try: if sound == "asterisk": winsound.MessageBeep(winsound.MB_ICONASTERISK) elif sound == "exclamation": winsound.MessageBeep(winsound.MB_ICONEXCLAMATION) elif sound == "hand": winsound.MessageBeep(winsound.MB_ICONHAND) elif sound == "question": winsound.MessageBeep(winsound.MB_ICONQUESTION) else: # beep winsound.Beep(800, _WIN_BEEP_MS) return except Exception: pass # Custom file path (SND_FILENAME only = blocking until sound finishes) try: winsound.PlaySound(sound, winsound.SND_FILENAME) return except Exception: pass try: winsound.Beep(800, _WIN_BEEP_MS) except Exception: sys.stdout.write("\a") sys.stdout.flush() def _play_ding_linux() -> None: sound = _sound if sound: for cmd in (["paplay", sound], ["aplay", sound]): try: subprocess.run(cmd, capture_output=True, timeout=3) return except (FileNotFoundError, subprocess.TimeoutExpired, OSError): continue candidates = [ ["paplay", "/usr/share/sounds/freedesktop/stereo/bell.oga"], ["paplay", "/usr/share/sounds/freedesktop/stereo/message.oga"], ["aplay", "/usr/share/sounds/alsa/Front_Center.wav"], ["aplay", "/usr/share/sounds/speech-dispatcher/test.wav"], ] for cmd in candidates: try: subprocess.run(cmd, capture_output=True, timeout=2) return except (FileNotFoundError, subprocess.TimeoutExpired, OSError): continue sys.stdout.write("\a") sys.stdout.flush() def main() -> None: parser = argparse.ArgumentParser( description="Play a ding every N minutes, offset seconds before each round mark." ) parser.add_argument( "-p", "--period", type=int, default=15, metavar="MIN", help="Interval in minutes between notifications (default: 15)", ) parser.add_argument( "-o", "--offset", type=int, default=15, metavar="SEC", help="Seconds before each round mark to play (default: 15)", ) parser.add_argument( "-s", "--sound", type=str, default=None, metavar="PRESET|PATH", help="Notification sound: Windows presets asterisk|exclamation|hand|question|beep, or path to .wav file (default: asterisk)", ) parser.add_argument( "-n", "--dings", type=int, default=1, metavar="N", help="Number of dings per notification (default: 1, max: 10)", ) args = parser.parse_args() global _sound _sound = args.sound if args.period < 1: parser.error("period must be >= 1") if args.offset < 0: parser.error("offset must be >= 0") if not 1 <= args.dings <= 10: parser.error("dings must be 1..10") period_sec = args.period * 60 now = datetime.now() now_sec = _seconds_since_midnight(now) next_ring = _next_ring_seconds(period_sec, args.offset, now_sec) h = next_ring // 3600 m = (next_ring % 3600) // 60 s = next_ring % 60 print( f"Period: {args.period} min, offset: {args.offset} s. " f"Next ding at {h:02d}:{m:02d}:{s:02d} (then every {args.period} min). " "Press Ctrl+C to stop." ) play = _play_ding_windows if sys.platform == "win32" else _play_ding_linux while True: wait_sec = _wait_seconds(period_sec, args.offset) time.sleep(wait_sec) for i in range(args.dings): play() if i < args.dings - 1: time.sleep(_DING_GAP_SEC) print(f"[{datetime.now().strftime('%H:%M:%S')}] ding") if __name__ == "__main__": main()