Files
scripts/python/utils/interval_timer_ding.py
Dobromir Popov 4a2d11399e sound options
2026-02-17 10:50:56 +02:00

178 lines
5.5 KiB
Python

#!/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()