diff --git a/python/utils/__init__.py b/python/utils/__init__.py new file mode 100644 index 0000000..dd7ee44 --- /dev/null +++ b/python/utils/__init__.py @@ -0,0 +1 @@ +# Utils package diff --git a/python/utils/interval-timer-ding.ps1 b/python/utils/interval-timer-ding.ps1 new file mode 100644 index 0000000..6cbb2ef --- /dev/null +++ b/python/utils/interval-timer-ding.ps1 @@ -0,0 +1,77 @@ +<# +.SYNOPSIS + 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. + +.DESCRIPTION + Default: period 15 minutes, offset 15 seconds. Notification plays at :14:45, :29:45, :44:45, :59:45. + +.PARAMETER PeriodMinutes + Interval in minutes between notifications (default 15). Defines the "round" marks (0, 15, 30, 45 past the hour). + +.PARAMETER OffsetSeconds + Seconds before each round mark to play the sound (default 15). E.g. 15 means play at XX:14:45, XX:29:45, etc. + +.EXAMPLE + .\interval-timer-ding.ps1 + .\interval-timer-ding.ps1 -PeriodMinutes 30 -OffsetSeconds 10 +#> + +param( + [int] $PeriodMinutes = 15, + [int] $OffsetSeconds = 15 +) + +$ErrorActionPreference = 'Stop' + +if ($PeriodMinutes -lt 1) { throw "PeriodMinutes must be >= 1" } +if ($OffsetSeconds -lt 0) { throw "OffsetSeconds must be >= 0" } + +$periodSec = $PeriodMinutes * 60 + +function Get-SecondsSinceMidnight { + $now = Get-Date + return $now.Hour * 3600 + $now.Minute * 60 + $now.Second +} + +function Get-NextRingSeconds { + $nowSec = Get-SecondsSinceMidnight + $nextBoundarySec = [Math]::Ceiling($nowSec / $periodSec) * $periodSec + $ringSec = $nextBoundarySec - $OffsetSeconds + if ($ringSec -le $nowSec) { + $ringSec += $periodSec + } + return $ringSec +} + +function Get-WaitSeconds { + $nowSec = Get-SecondsSinceMidnight + $ringSec = Get-NextRingSeconds + $wait = $ringSec - $nowSec + if ($wait -le 0) { $wait = 1 } + return [int]$wait +} + +function Play-Ding { + try { + [System.Media.SystemSounds]::Asterisk.Play() + } catch { + [Console]::Beep(800, 200) + } +} + +$nextRing = Get-NextRingSeconds +$nextH = [Math]::Floor($nextRing / 3600) +$nextM = [Math]::Floor(($nextRing % 3600) / 60) +$nextS = $nextRing % 60 +Write-Host "Period: $PeriodMinutes min, offset: $OffsetSeconds s. Next ding at $($nextH.ToString('00')):$($nextM.ToString('00')):$($nextS.ToString('00')) (then every $PeriodMinutes min). Press Ctrl+C to stop." + +while ($true) { + $waitSec = Get-WaitSeconds + if ($waitSec -gt 0) { + Start-Sleep -Seconds $waitSec + } + Play-Ding + $ts = Get-Date -Format "HH:mm:ss" + Write-Host "[$ts] ding" +} diff --git a/python/utils/interval_timer_ding.py b/python/utils/interval_timer_ding.py new file mode 100644 index 0000000..1b33c3d --- /dev/null +++ b/python/utils/interval_timer_ding.py @@ -0,0 +1,124 @@ +#!/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 minutes, offset 15 seconds. +Notification plays at :14:45, :29:45, :44:45, :59:45. + +Works on Windows and Linux (stdlib + optional system players on Linux). +""" + +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 + + +def _play_ding_windows() -> None: + try: + import winsound + winsound.MessageBeep(winsound.MB_ICONASTERISK) + except Exception: + try: + import winsound + winsound.Beep(800, 200) + except Exception: + sys.stdout.write("\a") + sys.stdout.flush() + + +def _play_ding_linux() -> None: + 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 play_ding() -> None: + if sys.platform == "win32": + _play_ding_windows() + else: + _play_ding_linux() + + +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)", + ) + args = parser.parse_args() + + if args.period < 1: + parser.error("period must be >= 1") + if args.offset < 0: + parser.error("offset must be >= 0") + + 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." + ) + + while True: + wait_sec = _wait_seconds(period_sec, args.offset) + time.sleep(wait_sec) + play_ding() + print(f"[{datetime.now().strftime('%H:%M:%S')}] ding") + + +if __name__ == "__main__": + main()