Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
158 changes: 132 additions & 26 deletions addon/globalPlugins/clock/clockHandler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand All @@ -57,33 +67,129 @@ 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()
del self._autoAnnounceClockTimer

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()
Expand Down
9 changes: 9 additions & 0 deletions changelog.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 5 additions & 1 deletion readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down