Spaces:
Sleeping
Sleeping
| """Local TTS for voice notifications via Kokoro (hexgrad/Kokoro-82M).""" | |
| import logging | |
| from datetime import datetime, timedelta | |
| from threading import Lock | |
| from typing import Any | |
| import numpy as np | |
| import sounddevice as sd | |
| from db import queries | |
| from vital_types.core import SpeakResult | |
| from core.app_config import is_demo_mode | |
| logger = logging.getLogger(__name__) | |
| MAX_TTS_LENGTH = 150 | |
| REPEAT_COOLDOWN_MINUTES = 30 | |
| DEFAULT_VOICE = "af_heart" | |
| DEFAULT_LANG_CODE = "a" | |
| KOKORO_SAMPLE_RATE = 24000 | |
| _pipeline: Any | None = None | |
| _last_spoken_messages: dict[str, datetime] = {} | |
| # Serializes audio playback so concurrent reminders (e.g. a hydration job and a | |
| # presence break firing together) queue instead of cutting each other off. | |
| _audio_lock = Lock() | |
| def _parse_time_to_minutes(time_value: str) -> int | None: | |
| """Convert an HH:MM string into minutes since midnight.""" | |
| try: | |
| hours_text, minutes_text = time_value.split(":") | |
| return int(hours_text) * 60 + int(minutes_text) | |
| except (AttributeError, TypeError, ValueError): | |
| return None | |
| def _is_within_sleep_hours(now: datetime, wake_time: str, sleep_time: str) -> bool: | |
| """Return true when the current local time falls inside sleep hours. | |
| The sleep window runs from sleep_time to wake_time. Two layouts: | |
| - Sleep before midnight (wake 07:00, sleep 23:00): asleep when | |
| current >= 23:00 OR current < 07:00 (window crosses midnight). | |
| - Sleep at/after midnight (wake 07:00, sleep 00:00): asleep when | |
| 00:00 <= current < 07:00 (window within one day). | |
| """ | |
| wake_minutes = _parse_time_to_minutes(wake_time) | |
| sleep_minutes = _parse_time_to_minutes(sleep_time) | |
| if wake_minutes is None or sleep_minutes is None: | |
| return False | |
| current_minutes = now.hour * 60 + now.minute | |
| if sleep_minutes < wake_minutes: | |
| return sleep_minutes <= current_minutes < wake_minutes | |
| return current_minutes >= sleep_minutes or current_minutes < wake_minutes | |
| def _is_sleep_time(now: datetime | None = None) -> bool: | |
| """Return true when TTS should be suppressed due to sleep hours.""" | |
| profile = queries.get_profile() | |
| if profile is None: | |
| return False | |
| current_time = now or datetime.now() | |
| return _is_within_sleep_hours(current_time, profile.wake_time, profile.sleep_time) | |
| def _was_spoken_recently(message: str, now: datetime) -> bool: | |
| """Return true if the same message was spoken within the cooldown window.""" | |
| last_spoken_at = _last_spoken_messages.get(message) | |
| if last_spoken_at is None: | |
| return False | |
| return now - last_spoken_at < timedelta(minutes=REPEAT_COOLDOWN_MINUTES) | |
| def _record_spoken_message(message: str, spoken_at: datetime) -> None: | |
| """Track a spoken message for repeat suppression.""" | |
| _last_spoken_messages[message] = spoken_at | |
| def _get_pipeline() -> Any: | |
| """Lazy-load the Kokoro KPipeline (downloads model on first run via HF cache).""" | |
| global _pipeline | |
| if _pipeline is not None: | |
| return _pipeline | |
| logger.info("Loading Kokoro TTS — first run may download the model (~80MB).") | |
| from kokoro import KPipeline | |
| _pipeline = KPipeline(lang_code=DEFAULT_LANG_CODE) | |
| logger.info("Kokoro TTS ready.") | |
| return _pipeline | |
| def _generate_audio_samples(text: str, voice: str) -> np.ndarray | None: | |
| """Run Kokoro inference and return concatenated audio samples.""" | |
| pipeline = _get_pipeline() | |
| generator = pipeline(text, voice=voice) | |
| chunks: list[np.ndarray] = [] | |
| for _index, (_graphemes, _phonemes, audio) in enumerate(generator): | |
| chunks.append(np.asarray(audio, dtype=np.float32)) | |
| if not chunks: | |
| return None | |
| return np.concatenate(chunks) | |
| def _speak_with_kokoro(text: str, voice: str) -> bool: | |
| """Synthesize and play speech using Kokoro (one message at a time).""" | |
| try: | |
| samples = _generate_audio_samples(text, voice) | |
| if samples is None or samples.size == 0: | |
| return False | |
| with _audio_lock: | |
| sd.play(samples, KOKORO_SAMPLE_RATE) | |
| sd.wait() | |
| return True | |
| except Exception as error: | |
| logger.warning("Kokoro TTS failed: %s", error) | |
| return False | |
| def speak( | |
| text: str, | |
| voice: str = DEFAULT_VOICE, | |
| allow_long_text: bool = False, | |
| allow_repeat: bool = False, | |
| ) -> SpeakResult: | |
| """Speak a short notification message, applying Vitál TTS rules.""" | |
| if is_demo_mode(): | |
| return SpeakResult(spoken=False, skipped_reason="demo_mode") | |
| trimmed_text = text.strip() | |
| if not trimmed_text: | |
| return SpeakResult(spoken=False, skipped_reason="empty_message") | |
| if not allow_long_text and len(trimmed_text) > MAX_TTS_LENGTH: | |
| return SpeakResult(spoken=False, skipped_reason="message_too_long") | |
| now = datetime.now() | |
| if _is_sleep_time(now): | |
| return SpeakResult(spoken=False, skipped_reason="sleep_hours") | |
| if not allow_repeat and _was_spoken_recently(trimmed_text, now): | |
| logger.info("[tts] Skipped (recent duplicate): %s", trimmed_text[:60]) | |
| return SpeakResult(spoken=False, skipped_reason="recent_duplicate") | |
| if _speak_with_kokoro(trimmed_text, voice): | |
| _record_spoken_message(trimmed_text, now) | |
| logger.info("[tts] Spoke (%s chars).", len(trimmed_text)) | |
| return SpeakResult(spoken=True, engine="kokoro") | |
| logger.warning("[tts] Kokoro playback failed.") | |
| return SpeakResult(spoken=False, skipped_reason="kokoro_failed") | |
| def reset_tts_state() -> None: | |
| """Reset repeat-suppression and pipeline state (used by tests).""" | |
| global _pipeline | |
| _last_spoken_messages.clear() | |
| _pipeline = None | |