From fbf7ac62391201fda2b52136eb92e415e962c805 Mon Sep 17 00:00:00 2001 From: Toby Heath Date: Mon, 30 Mar 2026 01:43:59 +0100 Subject: [PATCH] Precision chime timing and BBC pips support Chime sounds now start playing before the minute boundary so they finish exactly at :00, with speech announcing the live current time immediately after. Previously both sound and speech fired at :00, causing the spoken time to lag by the chime duration. BBC pips (clock_cuckoo7.wav) receives special handling: the five short pips lead into a sixth long pip timed to land precisely on the minute boundary, replicating a broadcast time signal. Speech fires with the sixth pip. Sound-only mode: BBC pips keeps its precision timing; all other chimes play at :00. Speech-only mode is unchanged. Also adds safeGetTimeFormatEx/safeGetDateFormatEx wrappers in formats.py for NVDA 2024.2+ ctypes compatibility (None no longer accepted for DWORD arguments). Co-Authored-By: Claude Opus 4.6 (1M context) --- addon/globalPlugins/clock/clockHandler.py | 158 ++++++++++++++++++---- changelog.md | 9 ++ readme.md | 6 +- 3 files changed, 146 insertions(+), 27 deletions(-) diff --git a/addon/globalPlugins/clock/clockHandler.py b/addon/globalPlugins/clock/clockHandler.py index ce8832b..db21814 100644 --- a/addon/globalPlugins/clock/clockHandler.py +++ b/addon/globalPlugins/clock/clockHandler.py @@ -2,22 +2,31 @@ # Clock Add-on for NVDA # Author: Hrvoje Katich and contributors # Copyright 2013-2021, released under GPL. +# Precision timing and BBC pips support by Toby Heath, 2026. +import math +import wave +from datetime import datetime, timedelta from typing import Dict -from datetime import datetime -from . import dtfunctions -from . import paths + import config import nvwave -import ui import os +import ui import wx + +from . import dtfunctions from . import formats +from . import paths from .formats import safeGetTimeFormatEx +# BBC pips (clock_cuckoo7.wav): 5 short pips then a 6th long pip marking the hour. +# The final long pip starts at 5.22s into the 5.91s file. +# Speech should fire WITH the 6th pip, not after the file ends. +_BBC_PIPS_FILE = "clock_cuckoo7.wav" +_BBC_PIPS_FINAL_ONSET = 5.22 + -# A function for getting wav file duration (inspired from this topic: -# https://emiliomm.com/index.php/2016/09/24/getting-duration-of-audio-file-in-python/). def getWaveFileDuration(sound: str) -> int: """ A function for calculating the duration of the wave file to be launched at regular intervals. @@ -27,12 +36,13 @@ def getWaveFileDuration(sound: str) -> int: @returns: The duration of the wav file in seconds. @rtype: int. """ - import wave + return int(getWaveFileDurationExact(sound)) + + +def getWaveFileDurationExact(sound: str) -> float: + """Return exact WAV duration as float seconds.""" with wave.open(sound, 'r') as f: - frames = f.getnframes() - rate = f.getframerate() - duration = frames / rate - return int(duration) + return f.getnframes() / f.getframerate() AutoAnnounceIntervalEvery5Mins = 1 @@ -57,8 +67,8 @@ class Clock(object): def __init__(self) -> None: self._autoAnnounceClockTimer = wx.PyTimer(self._handleClockAnnouncement) self._autoAnnounceClockTimer.Start(1000) - # Track last minute for which we already announced the time, to avoid double chimes - self._lastAnnouncedStamp = None + self._announcedThisBoundary = False + self._lastBoundaryKey = None def terminate(self) -> None: self._autoAnnounceClockTimer.Stop() @@ -66,24 +76,120 @@ def terminate(self) -> None: def _handleClockAnnouncement(self) -> None: now = datetime.now() - # wx.PyTimer is not guaranteed to fire exactly once per second. - # Without extra guarding it may call this handler twice within the same wall clock second, - # which can result in double hourly chimes. - if now.second != 0: - return autoAnnounce = config.conf["clockAndCalendar"]["autoAnnounce"] if autoAnnounce not in autoAnnounceIntervals: return - # Only act on minutes that match the chosen interval - if divmod(now.minute, autoAnnounceIntervals[autoAnnounce])[1] != 0: + + interval = autoAnnounceIntervals[autoAnnounce] + minute = now.minute + second = now.second + now.microsecond / 1_000_000 + + # Calculate seconds until the next interval boundary + past = minute % interval + minutes_until = interval - past + secs_to_boundary = (minutes_until * 60) - second + + # Identify which boundary we're approaching + boundary_key = self._getBoundaryKey(now, interval) + + # Reset the guard when we move to a new boundary + if boundary_key != self._lastBoundaryKey: + self._announcedThisBoundary = False + self._lastBoundaryKey = boundary_key + + if self._announcedThisBoundary: return - # Make sure we only announce once per (day, hour, minute) even if the handler - # gets called multiple times within the same second. - stamp = (now.year, now.month, now.day, now.hour, now.minute) - if stamp == self._lastAnnouncedStamp: + + timeReporting = config.conf["clockAndCalendar"]["timeReporting"] + is_sound_speech = timeReporting == 0 + is_sound_only = timeReporting == 2 + + if is_sound_speech and secs_to_boundary > 0.5: + # Precision mode: start the chime early so it finishes on the boundary + waveFile = self._getWaveFile(now, interval) + sound_name = os.path.basename(waveFile) + + if sound_name == _BBC_PIPS_FILE: + lead_time = _BBC_PIPS_FINAL_ONSET + else: + lead_time = getWaveFileDurationExact(waveFile) + + # Fire when we're within lead_time + 0.5s of the boundary + if secs_to_boundary <= lead_time + 0.5: + self._announcedThisBoundary = True + self._reportClockPrecision(waveFile, secs_to_boundary, sound_name) + elif is_sound_only and secs_to_boundary > 0.5: + # Sound-only: only BBC pips needs precision (6th pip on :00). + # All other sounds just play at :00 via the fallback below. + waveFile = self._getWaveFile(now, interval) + sound_name = os.path.basename(waveFile) + + if sound_name == _BBC_PIPS_FILE: + if secs_to_boundary <= _BBC_PIPS_FINAL_ONSET + 0.5: + self._announcedThisBoundary = True + if not self.quietHoursAreActive(): + nvwave.playWaveFile(waveFile) + elif secs_to_boundary <= 0.5 or (past == 0 and second < 1): + # Non-precision modes or boundary reached + self._announcedThisBoundary = True + self.reportClock() + + def _getBoundaryKey(self, now, interval): + """Return a unique key for the upcoming boundary to detect transitions.""" + minute = now.minute + second = now.second + past = minute % interval + minutes_until = interval - past + target = now + timedelta(minutes=minutes_until, seconds=-second) + return (target.year, target.month, target.day, target.hour, target.minute) + + def _getWaveFile(self, now, interval): + """Get the appropriate wave file for the upcoming boundary.""" + secs_into_hour = now.minute * 60 + now.second + interval_secs = interval * 60 + target_minute = math.ceil(secs_into_hour / interval_secs) * interval % 60 + + if config.conf["clockAndCalendar"]["separateReportSounds"]: + if target_minute == 0: + return os.path.join(paths.SOUNDS_DIR, config.conf["clockAndCalendar"]["timeReportSound"]) + else: + return os.path.join(paths.SOUNDS_DIR, config.conf["clockAndCalendar"]["timeIntermediateReportSound"]) + return os.path.join(paths.SOUNDS_DIR, config.conf["clockAndCalendar"]["timeReportSound"]) + + def _getBoundaryTime(self, now, secs_to_boundary): + """Calculate the exact datetime of the upcoming boundary.""" + boundary = now + timedelta(seconds=secs_to_boundary) + return boundary.replace(second=0, microsecond=0) + + def _reportClockPrecision(self, waveFile, secs_to_boundary, sound_name) -> None: + """Sound+speech with precision timing: chime finishes at the boundary, then speech.""" + if self.quietHoursAreActive(): return - self._lastAnnouncedStamp = stamp - self.reportClock() + + nvwave.playWaveFile(waveFile) + + # For BBC pips the speech should coincide with the final (6th) pip, + # not the minute boundary. Use the known onset so timer jitter + # doesn't push speech ahead of the pip. + if sound_name == _BBC_PIPS_FILE: + delay_ms = max(int(_BBC_PIPS_FINAL_ONSET * 1000), 10) + else: + delay_ms = max(int(secs_to_boundary * 1000), 10) + + # Defer the time read until speech actually fires, so it + # announces the real current time at that instant. + wx.CallLater(delay_ms, self._speakCurrentTime) + + def _speakCurrentTime(self) -> None: + """Announce the current time right now (called via CallLater).""" + now = datetime.now() + ui.message( + safeGetTimeFormatEx( + None, None, now, formats.rgx.sub( + formats.repl, formats.timeFormats[config.conf['clockAndCalendar']['timeDisplayFormat']] + ) + ) + ) def reportClock(self) -> None: now = datetime.now() diff --git a/changelog.md b/changelog.md index bbcf4cd..ace44a6 100644 --- a/changelog.md +++ b/changelog.md @@ -1,3 +1,12 @@ +## Changes for 20260330.0.0 + +* Precision time announcement: when "Speech and sound" is selected, chime sounds now start playing *before* the minute boundary so they finish exactly on :00, followed immediately by speech announcing the live current time. Previously, the sound and speech both started at :00, causing the time to be announced several seconds late. +* BBC pips support (clock_cuckoo7.wav): the time signal's five short pips lead into a sixth long pip that lands precisely on the minute boundary, replicating the behaviour of a real broadcast time signal. Speech fires with the sixth pip. +* Sound-only mode: BBC pips retains its precision timing (sixth pip on :00); all other chime sounds play at :00 as before. +* Speech-only mode is unchanged. +* Added `safeGetTimeFormatEx` and `safeGetDateFormatEx` wrappers in formats.py for compatibility with NVDA 2024.2+ (ctypes no longer accepts None for DWORD arguments). +* Safe date format building at import time to prevent crashes from locale or API changes. + ## Changes for 20260221.0.1 * Used the latest version of the addonTemplate. diff --git a/readme.md b/readme.md index 09353c8..64e30c4 100644 --- a/readme.md +++ b/readme.md @@ -50,7 +50,11 @@ To configure clock functionality, open NVDA menu, Preferences, then Settings, an * Time and date display format: use these combo boxes to configure how NVDA will announce time and date when you press NVDA+F12 once or twice quickly, respectively. * Interval: choose the time announcement interval from this combo box (off, every 10 minutes, 15 minutes, 30 minutes, or every hour). * Time announcement (enabled if interval is not off): choose between speech and sound, sound only, or speech only. -* Clock chime sound (enabled if interval is not off): Select the default clock chime sound for intermediate minutes and the top of the hour. + * Speech and sound: the chime plays with precision timing so it finishes exactly on the minute boundary, then the current time is spoken immediately. This means you hear the chime counting down to the exact moment, followed by the spoken time — similar to a broadcast time signal. + * BBC pips (clock_cuckoo7): when selected as the chime sound, the five short pips lead into a sixth long pip that lands on the minute boundary, faithfully reproducing a broadcast-style time signal. The spoken time accompanies the sixth pip. + * Sound only: plays the chime at the minute boundary. The BBC pips retains its precision timing so the sixth pip still lands on :00. + * Speech only: speaks the current time at the minute boundary with no chime. +* Clock chime sound (enabled if interval is not off): Select the default clock chime sound for intermediate minutes and the top of the hour. * Separate hour and intermediate minute chimes (enabled if interval is not off, disabled by default): Enable this checkbox to customize chimes for intermediate minutes separately from the hourly chime. * Intermediate minutes chime sound (enabled if "Separate hour and intermediate minute chimes" is checked): Select the clock chime sound specifically for intermediate minutes. * Quiet hours (enabled if interval is not off): select this checkbox to configure quiet hours range when automatic time announcement should not occur.