speaking-clock 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- speaking_clock/__init__.py +18 -0
- speaking_clock/audio.py +84 -0
- speaking_clock/cli.py +105 -0
- speaking_clock/clock.py +385 -0
- speaking_clock/config.py +154 -0
- speaking_clock/const.py +48 -0
- speaking_clock/data/__init__.py +11 -0
- speaking_clock/data/clock-chime.mp3 +0 -0
- speaking_clock/data/defaults/config.yml +33 -0
- speaking_clock/languages/__init__.py +3 -0
- speaking_clock/languages/pl.yml +71 -0
- speaking_clock/utils/__init__.py +7 -0
- speaking_clock/utils/audio_processor.py +100 -0
- speaking_clock/utils/number_converter.py +176 -0
- speaking_clock-1.0.0.dist-info/METADATA +248 -0
- speaking_clock-1.0.0.dist-info/RECORD +19 -0
- speaking_clock-1.0.0.dist-info/WHEEL +5 -0
- speaking_clock-1.0.0.dist-info/entry_points.txt +3 -0
- speaking_clock-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Speaking Clock - A Python package that speaks the current time
|
|
3
|
+
using ElevenLabs API and caches audio for reuse
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from .clock import SpeakingClock
|
|
7
|
+
from .config import ConfigManager
|
|
8
|
+
from .audio import AudioCache
|
|
9
|
+
from .utils.number_converter import PolishTimeFormatter, PolishNumberConverter
|
|
10
|
+
|
|
11
|
+
__version__ = "0.1.0"
|
|
12
|
+
__author__ = "Carlos"
|
|
13
|
+
__email__ = "carlos@example.com"
|
|
14
|
+
|
|
15
|
+
# Expose main function for easy imports
|
|
16
|
+
def speak_time():
|
|
17
|
+
"""Main entry point to speak the current time"""
|
|
18
|
+
SpeakingClock().speak_time()
|
speaking_clock/audio.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""
|
|
2
|
+
##################################################################################
|
|
3
|
+
#
|
|
4
|
+
# ▄▀▀▄ █ ▀ ▄▀▀▄ ▀█ █
|
|
5
|
+
# ▀▄▄ █▀▀▄ ▄▀▀▄ ▄▀▀▄ █ ▄▀ ▀█ █▀▀▄ ▄▀▀█ █ █ ▄▀▀▄ ▄▀▀▄ █ ▄▀
|
|
6
|
+
# █ █ █ █▀▀ ▄▄█ █▀▄ █ █ █ █ █ █ █ █ █ █ █▀▄
|
|
7
|
+
# ▀▄▄▀ █▄▄▀ ▀▄▄▀ ▀▄▄▀ █ █ ▄█▄ █ █ ▀▄▄█ ▀▄▄▀ ▄█▄ ▀▄▄▀ ▀▄▄▀ █ █
|
|
8
|
+
# █ ▄▄▀
|
|
9
|
+
#
|
|
10
|
+
# @project Speaking Clock - time announcer using ElevenLabs TTS API
|
|
11
|
+
# @author Marcin Orlowski <mail (#) marcinOrlowski (.) com>
|
|
12
|
+
# @copyright 2025 Marcin Orlowski
|
|
13
|
+
# @license https://www.opensource.org/licenses/mit-license.php MIT
|
|
14
|
+
# @link https://github.com/MarcinOrlowski/speaking-clock
|
|
15
|
+
#
|
|
16
|
+
##################################################################################
|
|
17
|
+
|
|
18
|
+
Audio caching functionality for the speaking clock
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
import os
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Optional
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class AudioCache:
|
|
27
|
+
"""Audio cache manager for speaking clock"""
|
|
28
|
+
def __init__(self, cache_dir: str, language: str):
|
|
29
|
+
self.base_dir = Path(os.path.expanduser(cache_dir))
|
|
30
|
+
self.base_dir.mkdir(parents=True, exist_ok=True)
|
|
31
|
+
self.language = language
|
|
32
|
+
|
|
33
|
+
def get_cache_filename(self, voice_id: str, hour: int, minute: int) -> str:
|
|
34
|
+
"""
|
|
35
|
+
Get cache filename using the format: LANG-VOICE-HH-MM.mp3 (lowercased)
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
voice_id: ElevenLabs voice ID or name
|
|
39
|
+
hour: Hour (00-23)
|
|
40
|
+
minute: Minute (00-59)
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
Cache filename
|
|
44
|
+
"""
|
|
45
|
+
return f"{self.language}-{voice_id}-{hour:02d}-{minute:02d}.mp3".lower()
|
|
46
|
+
|
|
47
|
+
def get_cached_file_path(self, voice_id: str, hour: int, minute: int) -> Path:
|
|
48
|
+
"""Get full path for a cached audio file"""
|
|
49
|
+
filename = self.get_cache_filename(voice_id, hour, minute)
|
|
50
|
+
return self.base_dir / filename
|
|
51
|
+
|
|
52
|
+
def get_cached_audio(self, voice_id: str, hour: int, minute: int) -> Optional[Path]:
|
|
53
|
+
"""
|
|
54
|
+
Get cached audio file if it exists
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
voice_id: ElevenLabs voice ID
|
|
58
|
+
hour: Hour (0-23)
|
|
59
|
+
minute: Minute (0-59)
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
Path to cached file or None if not found
|
|
63
|
+
"""
|
|
64
|
+
cache_path = self.get_cached_file_path(voice_id, hour, minute)
|
|
65
|
+
return cache_path if cache_path.exists() else None
|
|
66
|
+
|
|
67
|
+
def save_audio(self, audio_data: bytes, voice_id: str, hour: int, minute: int) -> Path:
|
|
68
|
+
"""
|
|
69
|
+
Save audio data to cache
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
audio_data: Audio data bytes
|
|
73
|
+
voice_id: ElevenLabs voice ID
|
|
74
|
+
hour: Hour (0-23)
|
|
75
|
+
minute: Minute (0-59)
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
Path to cached file
|
|
79
|
+
"""
|
|
80
|
+
cache_path = self.get_cached_file_path(voice_id, hour, minute)
|
|
81
|
+
with open(cache_path, 'wb') as file:
|
|
82
|
+
file.write(audio_data)
|
|
83
|
+
|
|
84
|
+
return cache_path
|
speaking_clock/cli.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""
|
|
2
|
+
##################################################################################
|
|
3
|
+
#
|
|
4
|
+
# ▄▀▀▄ █ ▀ ▄▀▀▄ ▀█ █
|
|
5
|
+
# ▀▄▄ █▀▀▄ ▄▀▀▄ ▄▀▀▄ █ ▄▀ ▀█ █▀▀▄ ▄▀▀█ █ █ ▄▀▀▄ ▄▀▀▄ █ ▄▀
|
|
6
|
+
# █ █ █ █▀▀ ▄▄█ █▀▄ █ █ █ █ █ █ █ █ █ █ █▀▄
|
|
7
|
+
# ▀▄▄▀ █▄▄▀ ▀▄▄▀ ▀▄▄▀ █ █ ▄█▄ █ █ ▀▄▄█ ▀▄▄▀ ▄█▄ ▀▄▄▀ ▀▄▄▀ █ █
|
|
8
|
+
# █ ▄▄▀
|
|
9
|
+
#
|
|
10
|
+
# @project Speaking Clock - time announcer using ElevenLabs TTS API
|
|
11
|
+
# @author Marcin Orlowski <mail (#) marcinOrlowski (.) com>
|
|
12
|
+
# @copyright 2025 Marcin Orlowski
|
|
13
|
+
# @license https://www.opensource.org/licenses/mit-license.php MIT
|
|
14
|
+
# @link https://github.com/MarcinOrlowski/speaking-clock
|
|
15
|
+
#
|
|
16
|
+
##################################################################################
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
Command-line interface for the speaking clock
|
|
20
|
+
|
|
21
|
+
Examples:
|
|
22
|
+
# Speak the current time
|
|
23
|
+
speak-time
|
|
24
|
+
|
|
25
|
+
# Speak a specific time
|
|
26
|
+
speak-time --time 14:30
|
|
27
|
+
|
|
28
|
+
# Short form
|
|
29
|
+
speak-time -t 14:30
|
|
30
|
+
|
|
31
|
+
# With custom config
|
|
32
|
+
speak-time -t 08:15 --config ~/my-config.yml
|
|
33
|
+
|
|
34
|
+
# Disable the chime
|
|
35
|
+
speak-time --no-chime
|
|
36
|
+
|
|
37
|
+
# Enable the chime (overriding config)
|
|
38
|
+
speak-time --chime
|
|
39
|
+
|
|
40
|
+
# Set audio volume (0.0 to 1.0)
|
|
41
|
+
speak-time --volume 0.5
|
|
42
|
+
|
|
43
|
+
# Combine options
|
|
44
|
+
speak-time -t 08:15 --no-chime --volume 0.8
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
import argparse
|
|
48
|
+
import sys
|
|
49
|
+
|
|
50
|
+
from speaking_clock.const import Const
|
|
51
|
+
from .clock import SpeakingClock
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def main():
|
|
55
|
+
"""Main entry point for CLI"""
|
|
56
|
+
parser = argparse.ArgumentParser(description="Speaking Clock")
|
|
57
|
+
parser.add_argument("--cache",
|
|
58
|
+
default=Const.DEFAULT_CACHE_DIR,
|
|
59
|
+
help='Location of file cache. Default: %(default)s')
|
|
60
|
+
parser.add_argument("--config", '-c',
|
|
61
|
+
default=Const.DEFAULT_CONFIG_PATH,
|
|
62
|
+
help='Configuration file path. Default: %(default)s')
|
|
63
|
+
parser.add_argument("--time", '-t',
|
|
64
|
+
help='Specify time in format "HH:MM" (e.g. 14:30). If not provided, current time will be used.')
|
|
65
|
+
# Create a mutually exclusive group for chime options
|
|
66
|
+
chime_group = parser.add_mutually_exclusive_group()
|
|
67
|
+
chime_group.add_argument("--chime",
|
|
68
|
+
action='store_true',
|
|
69
|
+
help='Enable the chime sound (overrides config file)')
|
|
70
|
+
chime_group.add_argument("--no-chime",
|
|
71
|
+
action='store_true',
|
|
72
|
+
help='Disable the chime sound (overrides config file)')
|
|
73
|
+
parser.add_argument("--volume", '-v',
|
|
74
|
+
type=float,
|
|
75
|
+
help='Set audio volume level (0.0 to 1.0)')
|
|
76
|
+
args = parser.parse_args()
|
|
77
|
+
|
|
78
|
+
# Prepare command line overrides for config
|
|
79
|
+
config_overrides = {}
|
|
80
|
+
if args.chime:
|
|
81
|
+
config_overrides['audio'] = {'play_chime': True}
|
|
82
|
+
elif args.no_chime:
|
|
83
|
+
config_overrides['audio'] = {'play_chime': False}
|
|
84
|
+
|
|
85
|
+
# Add volume override if provided
|
|
86
|
+
if args.volume is not None:
|
|
87
|
+
if 0.0 <= args.volume <= 1.0:
|
|
88
|
+
if 'audio' not in config_overrides:
|
|
89
|
+
config_overrides['audio'] = {}
|
|
90
|
+
config_overrides['audio']['volume'] = args.volume
|
|
91
|
+
else:
|
|
92
|
+
print("Error: Volume must be between 0.0 and 1.0", file=sys.stderr)
|
|
93
|
+
return 1
|
|
94
|
+
|
|
95
|
+
try:
|
|
96
|
+
clock = SpeakingClock(config_path=args.config, config_overrides=config_overrides)
|
|
97
|
+
success = clock.speak_time(custom_time=args.time)
|
|
98
|
+
return 0 if success else 1
|
|
99
|
+
except Exception as e:
|
|
100
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
101
|
+
return 1
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
if __name__ == "__main__":
|
|
105
|
+
sys.exit(main())
|
speaking_clock/clock.py
ADDED
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
"""
|
|
2
|
+
##################################################################################
|
|
3
|
+
#
|
|
4
|
+
# ▄▀▀▄ █ ▀ ▄▀▀▄ ▀█ █
|
|
5
|
+
# ▀▄▄ █▀▀▄ ▄▀▀▄ ▄▀▀▄ █ ▄▀ ▀█ █▀▀▄ ▄▀▀█ █ █ ▄▀▀▄ ▄▀▀▄ █ ▄▀
|
|
6
|
+
# █ █ █ █▀▀ ▄▄█ █▀▄ █ █ █ █ █ █ █ █ █ █ █▀▄
|
|
7
|
+
# ▀▄▄▀ █▄▄▀ ▀▄▄▀ ▀▄▄▀ █ █ ▄█▄ █ █ ▀▄▄█ ▀▄▄▀ ▄█▄ ▀▄▄▀ ▀▄▄▀ █ █
|
|
8
|
+
# █ ▄▄▀
|
|
9
|
+
#
|
|
10
|
+
# @project Speaking Clock - time announcer using ElevenLabs TTS API
|
|
11
|
+
# @author Marcin Orlowski <mail (#) marcinOrlowski (.) com>
|
|
12
|
+
# @copyright 2025 Marcin Orlowski
|
|
13
|
+
# @license https://www.opensource.org/licenses/mit-license.php MIT
|
|
14
|
+
# @link https://github.com/MarcinOrlowski/speaking-clock
|
|
15
|
+
#
|
|
16
|
+
##################################################################################
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import datetime
|
|
20
|
+
import sys
|
|
21
|
+
import threading
|
|
22
|
+
import time
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Tuple, Optional
|
|
25
|
+
|
|
26
|
+
from elevenlabs.client import ElevenLabs
|
|
27
|
+
from elevenlabs import play
|
|
28
|
+
|
|
29
|
+
from .audio import AudioCache
|
|
30
|
+
from .config import ConfigManager
|
|
31
|
+
from .utils.number_converter import PolishTimeFormatter
|
|
32
|
+
from .utils.audio_processor import adjust_volume
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class SpeakingClock:
|
|
36
|
+
"""Main class for speaking clock functionality"""
|
|
37
|
+
|
|
38
|
+
def __init__(self, config_path: str = None, config_overrides: dict = None):
|
|
39
|
+
self.config = ConfigManager(config_path, config_overrides)
|
|
40
|
+
|
|
41
|
+
language_code = self.config.get_language_code()
|
|
42
|
+
self.time_formatter = PolishTimeFormatter(language_code)
|
|
43
|
+
|
|
44
|
+
self.cache = AudioCache(cache_dir=self.config.get_cache_directory(), language=self.config.get_language_code())
|
|
45
|
+
|
|
46
|
+
# Initialize ElevenLabs client
|
|
47
|
+
self.el_client = ElevenLabs(api_key=self.config.get_elevenlabs_api_key())
|
|
48
|
+
|
|
49
|
+
def get_current_time(self) -> Tuple[int, int]:
|
|
50
|
+
"""
|
|
51
|
+
Get current hour and minute
|
|
52
|
+
|
|
53
|
+
Returns:
|
|
54
|
+
Tuple of (hour, minute)
|
|
55
|
+
"""
|
|
56
|
+
now = datetime.datetime.now()
|
|
57
|
+
hour = now.hour
|
|
58
|
+
|
|
59
|
+
# Convert to 12-hour format if needed
|
|
60
|
+
if not self.config.use_24h_clock() and hour > 12:
|
|
61
|
+
hour = hour % 12
|
|
62
|
+
if hour == 0:
|
|
63
|
+
hour = 12
|
|
64
|
+
|
|
65
|
+
return hour, now.minute
|
|
66
|
+
|
|
67
|
+
def parse_time_string(self, time_str: str) -> Tuple[int, int]:
|
|
68
|
+
"""
|
|
69
|
+
Parse time string in format "HH:MM"
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
time_str: Time string in format "HH:MM"
|
|
73
|
+
|
|
74
|
+
Returns:
|
|
75
|
+
Tuple of (hour, minute)
|
|
76
|
+
|
|
77
|
+
Raises:
|
|
78
|
+
ValueError: If time_str is not in valid format
|
|
79
|
+
"""
|
|
80
|
+
try:
|
|
81
|
+
parts = time_str.split(':')
|
|
82
|
+
if len(parts) != 2:
|
|
83
|
+
raise ValueError("Time must be in format HH:MM")
|
|
84
|
+
|
|
85
|
+
hour = int(parts[0])
|
|
86
|
+
minute = int(parts[1])
|
|
87
|
+
|
|
88
|
+
if hour < 0 or hour > 23:
|
|
89
|
+
raise ValueError("Hour must be between 0 and 23")
|
|
90
|
+
|
|
91
|
+
if minute < 0 or minute > 59:
|
|
92
|
+
raise ValueError("Minute must be between 0 and 59")
|
|
93
|
+
|
|
94
|
+
# Convert to 12-hour format if needed
|
|
95
|
+
display_hour = hour
|
|
96
|
+
if not self.config.use_24h_clock() and hour > 12:
|
|
97
|
+
display_hour = hour % 12
|
|
98
|
+
if display_hour == 0:
|
|
99
|
+
display_hour = 12
|
|
100
|
+
|
|
101
|
+
return display_hour, minute
|
|
102
|
+
|
|
103
|
+
except (ValueError, IndexError) as e:
|
|
104
|
+
if isinstance(e, ValueError) and str(e) in ["Time must be in format HH:MM",
|
|
105
|
+
"Hour must be between 0 and 23",
|
|
106
|
+
"Minute must be between 0 and 59"]:
|
|
107
|
+
raise
|
|
108
|
+
raise ValueError("Time must be in format HH:MM (e.g. 14:30)")
|
|
109
|
+
|
|
110
|
+
def format_time_text(self, hour: int, minute: int) -> str:
|
|
111
|
+
"""
|
|
112
|
+
Format time as text in Polish
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
hour: Hour (adjusted for 12/24h format)
|
|
116
|
+
minute: Minute (0-59)
|
|
117
|
+
|
|
118
|
+
Returns:
|
|
119
|
+
Time formatted as Polish text
|
|
120
|
+
"""
|
|
121
|
+
return self.time_formatter.format_time(hour, minute)
|
|
122
|
+
|
|
123
|
+
def generate_speech(self, text: str) -> Optional[bytes]:
|
|
124
|
+
"""
|
|
125
|
+
Generate speech using ElevenLabs API
|
|
126
|
+
|
|
127
|
+
Args:
|
|
128
|
+
text: Text to convert to speech
|
|
129
|
+
|
|
130
|
+
Returns:
|
|
131
|
+
Audio data as bytes or None if generation failed
|
|
132
|
+
"""
|
|
133
|
+
try:
|
|
134
|
+
voice_id = self.config.get_elevenlabs_voice_id()
|
|
135
|
+
model_id = self.config.get_elevenlabs_model_id()
|
|
136
|
+
|
|
137
|
+
# Check if voice_id is still the default placeholder
|
|
138
|
+
if voice_id == "polish-voice-id" or not voice_id:
|
|
139
|
+
print(
|
|
140
|
+
"*** Error: You need to set a valid ElevenLabs voice ID in your config.yml file",
|
|
141
|
+
file=sys.stderr)
|
|
142
|
+
print("*** Available voices:", file=sys.stderr)
|
|
143
|
+
available_voices = self.el_client.voices.get_all()
|
|
144
|
+
for voice in available_voices.voices:
|
|
145
|
+
print(f"*** - {voice.name}: {voice.voice_id}", file=sys.stderr)
|
|
146
|
+
return None
|
|
147
|
+
|
|
148
|
+
# Get the generator from generate and convert to bytes
|
|
149
|
+
audio_generator = self.el_client.generate(
|
|
150
|
+
text=text,
|
|
151
|
+
voice=voice_id,
|
|
152
|
+
model=model_id,
|
|
153
|
+
stream=False # Ensure we get a single response, not a stream
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
# Combine all chunks into one bytes object
|
|
157
|
+
if hasattr(audio_generator, '__iter__'):
|
|
158
|
+
audio_data = b''.join(chunk for chunk in audio_generator)
|
|
159
|
+
return audio_data
|
|
160
|
+
else:
|
|
161
|
+
return audio_generator
|
|
162
|
+
except ValueError as e:
|
|
163
|
+
print(f"*** Error generating speech: {e}", file=sys.stderr)
|
|
164
|
+
return None
|
|
165
|
+
except Exception as e:
|
|
166
|
+
print(f"*** Unexpected error generating speech: {e}", file=sys.stderr)
|
|
167
|
+
return None
|
|
168
|
+
|
|
169
|
+
def play_audio_file(self, audio_path: Path) -> None:
|
|
170
|
+
"""
|
|
171
|
+
Play audio file using elevenlabs built-in player with volume adjustment
|
|
172
|
+
|
|
173
|
+
Args:
|
|
174
|
+
audio_path: Path to the audio file
|
|
175
|
+
"""
|
|
176
|
+
try:
|
|
177
|
+
with open(audio_path, 'rb') as f:
|
|
178
|
+
audio_data = f.read()
|
|
179
|
+
|
|
180
|
+
# Apply volume adjustment (in memory only, not modifying the file)
|
|
181
|
+
volume = self.config.get_audio_volume()
|
|
182
|
+
adjusted_audio = adjust_volume(audio_data, volume)
|
|
183
|
+
|
|
184
|
+
# Play using built-in elevenlabs player
|
|
185
|
+
play(adjusted_audio)
|
|
186
|
+
except Exception as e:
|
|
187
|
+
print(f"*** Error playing audio file: {e}", file=sys.stderr)
|
|
188
|
+
|
|
189
|
+
def play_audio_data(self, audio_data: bytes) -> None:
|
|
190
|
+
"""
|
|
191
|
+
Play audio data using elevenlabs built-in player with volume adjustment
|
|
192
|
+
|
|
193
|
+
Args:
|
|
194
|
+
audio_data: Audio data bytes
|
|
195
|
+
"""
|
|
196
|
+
if audio_data:
|
|
197
|
+
try:
|
|
198
|
+
# Apply volume adjustment (in memory only)
|
|
199
|
+
volume = self.config.get_audio_volume()
|
|
200
|
+
adjusted_audio = adjust_volume(audio_data, volume)
|
|
201
|
+
|
|
202
|
+
play(adjusted_audio)
|
|
203
|
+
except Exception as e:
|
|
204
|
+
print(f"*** Error playing audio data: {e}", file=sys.stderr)
|
|
205
|
+
else:
|
|
206
|
+
print("*** Cannot play audio: No audio data available", file=sys.stderr)
|
|
207
|
+
|
|
208
|
+
def play_chime(self) -> Optional[bytes]:
|
|
209
|
+
"""
|
|
210
|
+
Play the chime audio file if enabled in config
|
|
211
|
+
|
|
212
|
+
Returns:
|
|
213
|
+
Original chime audio data if successfully loaded, None otherwise
|
|
214
|
+
"""
|
|
215
|
+
if not self.config.should_play_chime():
|
|
216
|
+
return None
|
|
217
|
+
|
|
218
|
+
chime_file = self.config.get_chime_file()
|
|
219
|
+
|
|
220
|
+
# Try to find chime in different locations
|
|
221
|
+
chime_paths = [
|
|
222
|
+
Path(chime_file), # Current directory
|
|
223
|
+
Path(__file__).parent / "data" / chime_file, # Package data directory
|
|
224
|
+
Path(__file__).parent.parent / chime_file, # Project root
|
|
225
|
+
]
|
|
226
|
+
|
|
227
|
+
chime_path = None
|
|
228
|
+
for path in chime_paths:
|
|
229
|
+
if path.exists():
|
|
230
|
+
chime_path = path
|
|
231
|
+
break
|
|
232
|
+
|
|
233
|
+
if not chime_path:
|
|
234
|
+
print(f"*** Warning: Chime file not found: {chime_file}", file=sys.stderr)
|
|
235
|
+
return None
|
|
236
|
+
|
|
237
|
+
try:
|
|
238
|
+
with open(chime_path, 'rb') as f:
|
|
239
|
+
chime_data = f.read()
|
|
240
|
+
|
|
241
|
+
# Only play the chime here if we're not overlaying speech
|
|
242
|
+
# Note: We apply volume adjustment at playback time, not here
|
|
243
|
+
if not self.config.should_overlay_speech():
|
|
244
|
+
volume = self.config.get_audio_volume()
|
|
245
|
+
adjusted_chime = adjust_volume(chime_data, volume)
|
|
246
|
+
play(adjusted_chime)
|
|
247
|
+
|
|
248
|
+
# Return the original chime data (not volume adjusted)
|
|
249
|
+
# Volume adjustment will be applied when playing
|
|
250
|
+
return chime_data
|
|
251
|
+
|
|
252
|
+
except Exception as e:
|
|
253
|
+
print(f"*** Error loading chime: {e}", file=sys.stderr)
|
|
254
|
+
return None
|
|
255
|
+
|
|
256
|
+
def speak_time(self, custom_time: str = None) -> bool:
|
|
257
|
+
"""
|
|
258
|
+
Speak the current time or a custom time
|
|
259
|
+
|
|
260
|
+
Args:
|
|
261
|
+
custom_time: Optional time string in format "HH:MM"
|
|
262
|
+
|
|
263
|
+
Returns:
|
|
264
|
+
True if successful, False if any critical error occurred
|
|
265
|
+
"""
|
|
266
|
+
if custom_time:
|
|
267
|
+
try:
|
|
268
|
+
hour, minute = self.parse_time_string(custom_time)
|
|
269
|
+
except ValueError as e:
|
|
270
|
+
print(f"*** Error: {e}", file=sys.stderr)
|
|
271
|
+
return False
|
|
272
|
+
else:
|
|
273
|
+
hour, minute = self.get_current_time()
|
|
274
|
+
time_text = self.format_time_text(hour, minute)
|
|
275
|
+
|
|
276
|
+
# Get voice ID and potential cached file path
|
|
277
|
+
voice_id = self.config.get_elevenlabs_voice_id()
|
|
278
|
+
audio_path = self.cache.get_cached_file_path(voice_id, hour, minute)
|
|
279
|
+
|
|
280
|
+
# Event to track if speech audio generation is complete
|
|
281
|
+
speech_ready = threading.Event()
|
|
282
|
+
speech_audio_data = None
|
|
283
|
+
speech_error = False
|
|
284
|
+
|
|
285
|
+
# Check if we need to generate the speech audio
|
|
286
|
+
speech_needs_generation = not audio_path.exists()
|
|
287
|
+
|
|
288
|
+
# Start speech generation in a separate thread if needed
|
|
289
|
+
if speech_needs_generation:
|
|
290
|
+
def generate_audio():
|
|
291
|
+
nonlocal speech_audio_data, speech_error
|
|
292
|
+
try:
|
|
293
|
+
result = self.generate_speech(time_text)
|
|
294
|
+
if result is None:
|
|
295
|
+
speech_error = True
|
|
296
|
+
else:
|
|
297
|
+
speech_audio_data = result
|
|
298
|
+
except Exception as e:
|
|
299
|
+
print(f"*** Unexpected error in speech generation thread: {e}", file=sys.stderr)
|
|
300
|
+
speech_error = True
|
|
301
|
+
finally:
|
|
302
|
+
# Always set the event, even on error, to unblock the main thread
|
|
303
|
+
speech_ready.set()
|
|
304
|
+
|
|
305
|
+
gen_thread = threading.Thread(target=generate_audio)
|
|
306
|
+
gen_thread.start()
|
|
307
|
+
else:
|
|
308
|
+
# Load speech audio from cache
|
|
309
|
+
try:
|
|
310
|
+
with open(audio_path, 'rb') as f:
|
|
311
|
+
speech_audio_data = f.read()
|
|
312
|
+
except Exception as e:
|
|
313
|
+
print(f"*** Error loading cached audio: {e}", file=sys.stderr)
|
|
314
|
+
speech_error = True
|
|
315
|
+
speech_ready.set()
|
|
316
|
+
|
|
317
|
+
# This will return the chime data but not play it if overlay is enabled
|
|
318
|
+
chime_data = self.play_chime()
|
|
319
|
+
|
|
320
|
+
# Handle speech with or without overlay
|
|
321
|
+
if self.config.should_play_chime() and self.config.should_overlay_speech() and chime_data:
|
|
322
|
+
# Get configured offset in milliseconds
|
|
323
|
+
offset_ms = self.config.get_speech_offset_ms()
|
|
324
|
+
offset_sec = offset_ms / 1000.0
|
|
325
|
+
|
|
326
|
+
# Start playing the chime now with volume adjustment
|
|
327
|
+
volume = self.config.get_audio_volume()
|
|
328
|
+
adjusted_chime = adjust_volume(chime_data, volume)
|
|
329
|
+
|
|
330
|
+
chime_thread = threading.Thread(
|
|
331
|
+
target=play,
|
|
332
|
+
args=(adjusted_chime,) # Play adjusted chime in a separate thread
|
|
333
|
+
)
|
|
334
|
+
chime_thread.start()
|
|
335
|
+
|
|
336
|
+
start_time = time.time()
|
|
337
|
+
|
|
338
|
+
# Wait for speech to be ready, with a timeout
|
|
339
|
+
if not speech_ready.wait(timeout=30): # Wait up to 30 seconds
|
|
340
|
+
print("*** Timeout waiting for speech generation", file=sys.stderr)
|
|
341
|
+
return False
|
|
342
|
+
|
|
343
|
+
# If there was an error in speech generation, stop processing
|
|
344
|
+
if speech_error or speech_audio_data is None:
|
|
345
|
+
print("*** Speech generation failed, cannot continue", file=sys.stderr)
|
|
346
|
+
return False
|
|
347
|
+
|
|
348
|
+
# Calculate how much time has passed since we started the chime
|
|
349
|
+
# If speech is ready earlier than the offset, wait until offset time
|
|
350
|
+
elapsed = time.time() - start_time
|
|
351
|
+
if elapsed < offset_sec:
|
|
352
|
+
wait_time = offset_sec - elapsed
|
|
353
|
+
time.sleep(wait_time)
|
|
354
|
+
|
|
355
|
+
if speech_needs_generation:
|
|
356
|
+
# Save the original audio to cache (without volume adjustment)
|
|
357
|
+
self.cache.save_audio(speech_audio_data, voice_id, hour, minute)
|
|
358
|
+
|
|
359
|
+
# Play the speech with volume adjustment
|
|
360
|
+
volume = self.config.get_audio_volume()
|
|
361
|
+
adjusted_speech = adjust_volume(speech_audio_data, volume)
|
|
362
|
+
play(adjusted_speech)
|
|
363
|
+
else:
|
|
364
|
+
# Standard sequential playback (chime already played in play_chime if enabled)
|
|
365
|
+
# Wait for speech to be ready, with a timeout
|
|
366
|
+
if not speech_ready.wait(timeout=30): # Wait up to 30 seconds
|
|
367
|
+
print("*** Timeout waiting for speech generation", file=sys.stderr)
|
|
368
|
+
return False
|
|
369
|
+
|
|
370
|
+
# If there was an error in speech generation, stop processing
|
|
371
|
+
if speech_error or speech_audio_data is None:
|
|
372
|
+
print("*** Speech generation failed, cannot continue", file=sys.stderr)
|
|
373
|
+
return False
|
|
374
|
+
|
|
375
|
+
if speech_needs_generation:
|
|
376
|
+
# Save the original audio to cache (without volume adjustment)
|
|
377
|
+
self.cache.save_audio(speech_audio_data, voice_id, hour, minute)
|
|
378
|
+
|
|
379
|
+
# If chime is disabled or overlay is disabled, play speech now with volume adjustment
|
|
380
|
+
if not self.config.should_play_chime() or not self.config.should_overlay_speech():
|
|
381
|
+
volume = self.config.get_audio_volume()
|
|
382
|
+
adjusted_speech = adjust_volume(speech_audio_data, volume)
|
|
383
|
+
play(adjusted_speech)
|
|
384
|
+
|
|
385
|
+
return True
|