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.
@@ -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()
@@ -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())
@@ -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