From 8f44fd2460b83bdb055a7a870ce71b74516344bc Mon Sep 17 00:00:00 2001 From: nvfuture Date: Sat, 10 Jan 2026 23:50:02 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A4=D0=B8=D0=BA=D1=81=20=D0=BF=D1=80=D0=BE?= =?UTF-8?q?=D0=B8=D0=B7=D0=BD=D0=BE=D1=88=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=B4?= =?UTF-8?q?=D0=B0=D1=82=20=D0=B8=20=D1=82=D0=B5=D0=BC=D0=BF=D0=B5=D1=80?= =?UTF-8?q?=D0=B0=D1=82=D1=83=D1=80=D1=8B=20+=20wheather=20-=D0=BB=D0=B8?= =?UTF-8?q?=D1=88=D0=BD=D0=B8=D0=B5=20=D1=84=D0=B0=D0=B9=D0=BB=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/audio/tts.py | 96 ++++++++++-- app/audio/wakeword.py | 14 +- app/core/cleaner.py | 2 +- app/core/config.py | 5 + app/features/weather.py | 144 ++++++++++++++++++ app/main.py | 68 +++++++-- .../Alexandr_en_linux_v4_0_0.ppn | Bin 4528 -> 0 bytes ding.wav | Bin 13274 -> 0 bytes scripts/generate_ding.py | 28 ---- tests/test_cleaner.py | 20 --- 10 files changed, 295 insertions(+), 82 deletions(-) create mode 100644 app/features/weather.py delete mode 100644 assets/models/Alexandr_en_linux_v4_0_0/Alexandr_en_linux_v4_0_0.ppn delete mode 100644 ding.wav delete mode 100644 scripts/generate_ding.py delete mode 100644 tests/test_cleaner.py diff --git a/app/audio/tts.py b/app/audio/tts.py index deab8ad..b149bef 100644 --- a/app/audio/tts.py +++ b/app/audio/tts.py @@ -20,6 +20,8 @@ from ..core.config import TTS_SPEAKER, TTS_EN_SPEAKER, TTS_SAMPLE_RATE # Подавляем предупреждения Silero о длинном тексте (мы сами его режем) warnings.filterwarnings("ignore", message="Text string is longer than 1000 symbols") +_EN_WORD_RE = re.compile(r"[A-Za-z][A-Za-z0-9'-]*") + class TextToSpeech: """Класс синтеза речи с поддержкой прерывания.""" @@ -109,19 +111,52 @@ class TextToSpeech: return [c for c in chunks if c] - def speak(self, text: str, check_interrupt=None, language: str = "ru") -> bool: + def _split_mixed_language(self, text: str) -> list[tuple[str, str]]: """ - Основная функция: генерирует аудио и воспроизводит его. - - Args: - text: Текст для озвучки. - check_interrupt: Функция, возвращающая True, если надо прерваться (например, check_wakeword_once). - language: "ru" или "en". - - Returns: - True, если договорил до конца. - False, если был прерван. + Разбивает текст на сегменты русского и английского текста. + Английские слова (латиница) будут озвучены английской моделью. """ + matches = list(_EN_WORD_RE.finditer(text)) + if not matches: + return [(text, "ru")] + + segments = [] + idx = 0 + for match in matches: + if match.start() > idx: + segments.append((text[idx : match.start()], "ru")) + segments.append((match.group(0), "en")) + idx = match.end() + + if idx < len(text): + segments.append((text[idx:], "ru")) + + # Склеиваем соседние сегменты и прикрепляем чистую пунктуацию к предыдущему. + merged = [] + for segment, lang in segments: + if not segment: + continue + if not any(ch.isalnum() for ch in segment): + if merged: + merged[-1] = (merged[-1][0] + segment, merged[-1][1]) + else: + merged.append((segment, lang)) + continue + if merged and merged[-1][1] == lang: + merged[-1] = (merged[-1][0] + segment, lang) + else: + merged.append((segment, lang)) + + if merged and not any(ch.isalnum() for ch in merged[0][0]) and len(merged) > 1: + merged[1] = (merged[0][0] + merged[1][0], merged[1][1]) + merged = merged[1:] + + return merged + + def _speak_single_language( + self, text: str, check_interrupt=None, language: str = "ru" + ) -> bool: + """Озвучивание текста одной моделью языка.""" if not text.strip(): return True @@ -197,6 +232,45 @@ class TextToSpeech: else: return False + def _speak_mixed( + self, segments: list[tuple[str, str]], check_interrupt=None + ) -> bool: + """Озвучивание текста с переключением RU/EN по сегментам.""" + for segment, lang in segments: + if not segment.strip(): + continue + completed = self._speak_single_language( + segment, check_interrupt=check_interrupt, language=lang + ) + if not completed: + return False + return True + + def speak(self, text: str, check_interrupt=None, language: str = "ru") -> bool: + """ + Основная функция: генерирует аудио и воспроизводит его. + + Args: + text: Текст для озвучки. + check_interrupt: Функция, возвращающая True, если надо прерваться (например, check_wakeword_once). + language: "ru" или "en". + + Returns: + True, если договорил до конца. + False, если был прерван. + """ + if not text.strip(): + return True + + if language == "ru": + segments = self._split_mixed_language(text) + if any(lang == "en" for _, lang in segments): + return self._speak_mixed(segments, check_interrupt=check_interrupt) + + return self._speak_single_language( + text, check_interrupt=check_interrupt, language=language + ) + def _check_interrupt_worker(self, check_interrupt): """ Фоновая функция для потока: постоянно опрашивает check_interrupt. diff --git a/app/audio/wakeword.py b/app/audio/wakeword.py index f07ed4d..c885046 100644 --- a/app/audio/wakeword.py +++ b/app/audio/wakeword.py @@ -22,7 +22,6 @@ class WakeWordDetector: self.pa = None self._stream_closed = True # Флаг состояния потока (закрыт/открыт) self._last_hit_ts = 0.0 - self._hit_streak = 0 def initialize(self): """Инициализация Porcupine и PyAudio.""" @@ -136,16 +135,11 @@ class WakeWordDetector: keyword_index = self.porcupine.process(pcm) if keyword_index >= 0: now = time.time() - if now - self._last_hit_ts < 0.6: - self._hit_streak += 1 - else: - self._hit_streak = 1 + if now - self._last_hit_ts < 0.4: + return False self._last_hit_ts = now - - if self._hit_streak >= 2: - self._hit_streak = 0 - print("🛑 Wake word подтвержден во время ответа!") - return True + print("🛑 Wake word обнаружен во время ответа!") + return True return False except Exception: return False diff --git a/app/core/cleaner.py b/app/core/cleaner.py index 282ee59..7275388 100644 --- a/app/core/cleaner.py +++ b/app/core/cleaner.py @@ -237,7 +237,7 @@ def numbers_to_words(text: str) -> str: preps_list = "|".join(map(re.escape, PREPOSITION_CASES.keys())) text = re.sub( - rf"(?i)\b((?:{preps_list})\s+)?(\d+(?:[.,]\d+)?)(?=(\s+[а-яА-ЯёЁ]+))?\b", + rf"(?i)(? str: + """Decodes WMO weather code to Russian description.""" + codes = { + 0: "ясно", + 1: "преимущественно ясно", + 2: "переменная облачность", + 3: "пасмурно", + 45: "туман", + 48: "изморозь", + 51: "легкая морось", + 53: "умеренная морось", + 55: "плотная морось", + 56: "ледяная морось", + 57: "плотная ледяная морось", + 61: "слабый дождь", + 63: "умеренный дождь", + 65: "сильный дождь", + 66: "ледяной дождь", + 67: "сильный ледяной дождь", + 71: "слабый снег", + 73: "снегопад", + 75: "сильный снегопад", + 77: "снежные зерна", + 80: "слабый ливень", + 81: "умеренный ливень", + 82: "сильный ливень", + 85: "слабый снегопад", + 86: "сильный снегопад", + 95: "гроза", + 96: "гроза с градом", + 99: "сильная гроза с градом" + } + return codes.get(code, "осадки") + +def get_weather_report() -> str: + """ + Fetches detailed weather report. + Structure: + 1. Current temp and precipitation. + 2. Today's min/max temp. + 3. Next 4 hours forecast (temp + precipitation). + """ + if not all([WEATHER_LAT, WEATHER_LON, WEATHER_CITY]): + return "Настройки погоды не найдены. Проверьте конфигурацию." + + url = "https://api.open-meteo.com/v1/forecast" + params = { + "latitude": WEATHER_LAT, + "longitude": WEATHER_LON, + "current": "temperature_2m,precipitation,weather_code", + "hourly": "temperature_2m,precipitation,weather_code", + "daily": "temperature_2m_max,temperature_2m_min", + "timezone": "auto", + "forecast_days": 2 + } + + try: + response = requests.get(url, params=params, timeout=5) + response.raise_for_status() + data = response.json() + + # --- 1. Current Weather --- + curr = data["current"] + temp_now = round(curr["temperature_2m"]) + precip_now = curr["precipitation"] + code_now = curr["weather_code"] + desc_now = get_wmo_description(code_now) + + report = f"Сейчас в городе {WEATHER_CITY} {temp_now} градусов, {desc_now}." + if precip_now > 0: + report += f" Выпало {precip_now} миллиметров осадков." + + # --- 2. Today's Range --- + # daily arrays usually start from today [0] + daily = data["daily"] + t_max = round(daily["temperature_2m_max"][0]) + t_min = round(daily["temperature_2m_min"][0]) + + report += f" Сегодня температура будет от {t_min} до {t_max} градусов." + + # --- 3. Forecast Next 4 Hours --- + current_hour = datetime.now().hour + hourly_temps = data["hourly"]["temperature_2m"] + hourly_precip = data["hourly"]["precipitation"] + hourly_codes = data["hourly"]["weather_code"] + + # Start from next hour + start_idx = current_hour + 1 + end_idx = min(start_idx + 4, len(hourly_temps)) + + next_temps = hourly_temps[start_idx:end_idx] + next_precip = hourly_precip[start_idx:end_idx] + next_codes = hourly_codes[start_idx:end_idx] + + if next_temps: + report += " Прогноз на ближайшие 4 часа: " + + # Group by roughly similar weather to avoid repetition? + # Or just list them simply. + # "В 14:00 -5, ясно. В 15:00 -5, снег." -> a bit verbose. + # Simplified: "Температура около -5, возможен слабый снег." + + # Let's verify if weather changes significantly. + # If consistent, summarize. If not, list. + + # Simple approach for TTS: + avg_temp = round(sum(next_temps) / len(next_temps)) + + # Check if any precipitation is expected + will_precip = any(p > 0 for p in next_precip) + unique_codes = set(next_codes) + + # Determine dominant weather description + if len(unique_codes) == 1: + weather_desc = get_wmo_description(list(unique_codes)[0]) + else: + # Priority to precipitation codes + precip_codes = [c for c in unique_codes if c > 3] # >3 implies not clear/cloudy + if precip_codes: + weather_desc = get_wmo_description(max(precip_codes)) # Take the most severe + else: + weather_desc = "переменная облачность" + + report += f"температура около {avg_temp} градусов, {weather_desc}." + + if will_precip: + report += " Ожидаются осадки." + else: + report += " Без существенных осадков." + + return report + + except Exception as e: + print(f"❌ Ошибка получения погоды: {e}") + return "Не удалось получить полные данные о погоде." \ No newline at end of file diff --git a/app/main.py b/app/main.py index 31f48b9..c14e108 100644 --- a/app/main.py +++ b/app/main.py @@ -23,7 +23,13 @@ import os from collections import deque # Для воспроизведения звуков (mp3) -from pygame import mixer +try: + from pygame import mixer +except Exception as exc: + mixer = None + _MIXER_IMPORT_ERROR = exc +else: + _MIXER_IMPORT_ERROR = None # Импорт наших модулей from .audio.wakeword import ( @@ -38,6 +44,7 @@ from .core.cleaner import clean_response from .audio.tts import speak, initialize as init_tts from .audio.sound_level import set_volume, parse_volume_text from .features.alarm import get_alarm_clock +from .features.weather import get_weather_report # Список стоп-слов, чтобы прервать диалог или остановить ассистента STOP_WORDS = { @@ -134,15 +141,25 @@ def main(): # Предварительная инициализация моделей print("⏳ Инициализация моделей...") - # Инициализация звуковой системы для эффектов - mixer.init() - ding_sound_path = "assets/sounds/ding.wav" + # Инициализация звуковой системы для эффектов (опционально) ding_sound = None - if os.path.exists(ding_sound_path): - ding_sound = mixer.Sound(ding_sound_path) - ding_sound.set_volume(0.3) + if mixer is None: + print( + "Warning: pygame mixer not available; sound effects disabled." + f" ({_MIXER_IMPORT_ERROR})" + ) else: - print(f"⚠️ Звук {ding_sound_path} не найден") + try: + mixer.init() + except Exception as exc: + print(f"Warning: pygame mixer init failed; sound effects disabled. ({exc})") + else: + ding_sound_path = "assets/sounds/ding.wav" + if os.path.exists(ding_sound_path): + ding_sound = mixer.Sound(ding_sound_path) + ding_sound.set_volume(0.3) + else: + print(f"⚠️ Звук {ding_sound_path} не найден") get_recognizer().initialize() # Подключение к Deepgram init_tts() # Загрузка нейросети для синтеза речи (Silero) @@ -242,8 +259,10 @@ def main(): # Проверка команд будильника ("поставь будильник на 7") alarm_response = alarm_clock.parse_command(user_text) if alarm_response: - speak(alarm_response) - last_response = alarm_response + clean_alarm_response = clean_response(alarm_response, language="ru") + speak(clean_alarm_response) + last_response = clean_alarm_response + skip_wakeword = False continue # Проверка команды громкости ("громкость 5") @@ -256,8 +275,9 @@ def main(): if level is not None: if set_volume(level): msg = f"Громкость установлена на {level}" - speak(msg) - last_response = msg + clean_msg = clean_response(msg, language="ru") + speak(clean_msg) + last_response = clean_msg else: speak("Не удалось установить громкость.") else: @@ -265,12 +285,36 @@ def main(): "Я не понял число громкости. Скажите число от одного до десяти." ) + skip_wakeword = True continue except Exception as e: print(f"❌ Ошибка громкости: {e}") speak("Не удалось изменить громкость.") + skip_wakeword = True continue + # Проверка команды "Погода" + weather_triggers = [ + "погода", + "погоду", + "что на улице", + "какая температура", + "сколько градусов", + "холодно ли", + "жарко ли", + "нужен ли зонт", + "брать ли зонт", + "прогноз погоды", + ] + + if any(trigger in user_text.lower() for trigger in weather_triggers): + weather_report = get_weather_report() + clean_report = clean_response(weather_report, language="ru") + speak(clean_report) + last_response = clean_report + skip_wakeword = True + continue + # Проверка запроса на перевод translation_request = parse_translation_request(user_text) if translation_request: diff --git a/assets/models/Alexandr_en_linux_v4_0_0/Alexandr_en_linux_v4_0_0.ppn b/assets/models/Alexandr_en_linux_v4_0_0/Alexandr_en_linux_v4_0_0.ppn deleted file mode 100644 index b874c61cf40a88f67652369f9f26287eb2c93565..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4528 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 O#B0FH2IKd3urYB1w(8vg diff --git a/ding.wav b/ding.wav deleted file mode 100644 index 451843fecc157ac4ac7c52c01dec719ae0268bc3..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 13274 zcmWNUbyyT#6vnrU-DQELySuv^lrlgOQ4pj-#KQdSRuCjq6s1&3q&uX$ySrI(m+jq; z=REVrow;{@=e+N^&)hkE+|F)58vt;(JZl?p<)#D<0001w!X_60czq5C00D3SKcAaE zc}H^qfN1Q7$coZw1CVX!$yc5{{w`NUZ-(5J{?GK$-$!>JrrcM)t#Yk6c=l4;*^iDs z=H6OmGKqXjh+kCwP3*L1uR{a8v?}{;%I8nOnAtaWFKnN#JXU=o_$=dP!#kgkSCZP( z&*tq{M7ERT`YQcKLna!`}}x|3lpGi^N-RXrs8g@W z&-I`2KKuAQ>(z<(w?4v?Hq*d=)XMFfWc$C*yx7X2%_C@hO)_|`YO^?pYiG2*%K{g} zlWz{*xpV*7gWdb}cUiZJBKCs^e8SHyIeS>`>y9fJ30q>s*sNWy#h1gc+ia>A@?ZUO zPrjP45k39p+{>WnJkQU+(0`r&KI-G*mv?DFxt?WajT^nX)B77l3L4JA+a!h1$TfL! zEX>vP;+Vh06~^`Rw^i;1-bdW$y8HUpod{wur|-(SL>DV-oW8tLvB&~;4lunBTYfv5 z(BWFUS@W9^*?=fNTYG0+i5O|^ZviCJ0>hH(4FS==CIUh^S8!CE+Cxg~)$)->l zo^}bK+7F|CEi`z%`dxbaCFJ)c=UWLE$j~f42_GK;gbk1OjLw#)b z!->_^@1#huHP^5hTRG7n!Y1H^s|UYtd~o5_3pbzL7P~8ZH|h4Xn`>7mg0J{icubuj z+o&4;R*4cvbDw~`I-FU1HksBF&_F4Dm@AaNnS_o{i2n3e{I%YznOD4T2cs}?`Cope z4rhy%c>lZ6r8yqH0w9(FgE?13KuWQCAyxs--tKyrx&jg5qt_K8cW*1+8Hyy{_;|H0 zILa4)KH_A9t*X(O>Xt+t4;^}n@^9nzbXs3%6TSRS9`tAZx7Gyp7}Wc~H!iOmUpKvV zh<1o8{1TpeIr~QO!#eLy!YF$wWFG)Xz%Yf73Nbo?7J-goXWYC%0T;qtuC?8)iIl$$ zi@bP4=jzwsbYH*ow3E;6WQ+moR#H5?-{Cy8uUr1J$pc|6jLK{IyT3kvfBva6_DNLn z+psr(-`s!K9{nM%=8JhMEo-3Ycdc7T;mGI3sohhob`BvyKDjupK(o-}cU`Yv(D%y= zX}Eg%#`9a^k(#$^ulIz92j~0dp1*g>*dAvht`RN$hR+DuLyy>YnU5O^ZlhKQ7fxng zO|kkC632*!y^ndD_SW$I$qzGe178GFzh)U1P1LxyKN&V&aNo&dKv1UqiZWj{LQEp; zU!RIOAL7dl(hjFxv%87A<#v<*x>vYCaIx?D`Q20N_6H`~ngcRq{%+J$runYs;`5Qq z9mHC%;^u7oG|*Rc!u^;FQI+q$zGJB_-CvB$CGnET3R)@D54}P5u z*t$mj0Q=12B~h;Oz~Gs6wsZH{Uav%dj!@yNRoCXOzq(!+5qPB_=!dVp=fBf($G@6c zXq(8F2~A-70WAm1E9WMT_slielqcpRgyl&}-iy zPeIqx-hB5s^lK#xf!Ylc(0ef>?^mH*}U{XU)oOyWqm7klnQZuH#`)v;F+ z9v#u1WLg%$tMZ$L`z5A7uX`LCfHAmiku`;6oiy`-;}};hPetFcK(#QDt6w5gA~dg_ z3_}D3`abduay4}jv5?b!s^})#f&)Ud$d5L7XG90;TEwe;3p2AYX*pj%B`$pY6jS@b zGbZHYa>Bo)t(4Q5lldW)b4`MMyOU;Xc>8e@p{x*UZljM|bSjDU_c5 z^XVt-N7NsJPIm)ss7(v3#%>!P-GN^1DkD)K(3} zZEiTXoS|Q!`4$9XL*Z8vuT+HH2-3x9jdGaH<`Kacq@MeVLKjw7RZ zUrBPQLJdraoE$RY@s5J}@eu z>A&Z+95wFJQ`)#v0W6Tt3P_v#mht&tf?2#|d}spSXW?(5sl?xDe?7`s^$}fBqiT!l zI|1};_%Kh6xRJ7)o|i@4ai!DGJc_(C{Op3FLT-c}gy4ev{LOtrJOkaFoOGXcqFsaZN*W6GwT)39apK<5AXcFJ2&;+Rjlf;tGFTbT6(L(Eb(h*}2mj7sHi}4hfkv1f0j$i38qn~H6~p1OSvh ziDSwEx`Sp{?1fI$ow4`);a%bT)(`7%<)`4=?xk@))a`?Ffi1zrTU$XfSj>)R2_C{= z?A9;OOx*6vZ^^9ruf#D=AyX;c=g0WBuU~V%!jp4<#HMFuF61ecUaq~}YS*7P`C+AT zPnP)sF~zeY_FD0WHp(>1*4r8HR^TDybUNaufZaym8S8I*R&hp8itP9LZ@<5dCOfA9GnO+q^Oz;j+SQg9 zef1N!m!I!8(mCPQ+=imv@{^i2#w*sp9D`2_pZk4*?JeND;d|4!{8Ht`=N@uyh0exy zQ>Fzvqe>AH-+5o6oY{Ot#Jc?qdC<5+tDat7P!O79{>v=w=8vu9tmHr6Nh!B8l(HCk zlO_2zms)yyr^W-9oOgn0iO_u9M`3n?1%QH?Lj&pV$b`%^T{ zC8g#^SjxGy`#(Fg4E`3BNY-3we$b;fc6oui)kCfZrC`nqR7z#5K=pFW9@zyupK_Hs zH{j{w72}=YebH;v^Vqq^t{KiPc1$yWJpomH=>-8}YykK*rGa3)h#!yWz0#sw+gy4& ze?9x_uiNP_Q+rd~Qt|1?U-H?P|7Mr0R)d=7y8TAO=chK`AD#zFp&R({k`yH;ojsFI zn}3ewr&7)aod;hG_Db-2b#>msTwhdPj0FaB);k_yD zsQ5|C*jT`t&q3^@&>6L~MF&A?$#GgOmzJB_u^Pyd=xrctJ+LFwo&|4f0dY<04 z8@h6NYH(npJ+WS@@>3BiFFH%>7a#+eu9yBJ!{9e1yZ>)VNp!V+Q*#$#_{VJZx)d>w zWrED*85d2HU(tAF7-SiK?72&vYm~d+dF+K37dkv=JX+7jo<8mjui%Ws5L}ttMua76s6L`!Vcu%D?u0%qdY02;;(Vm%;&~|#!?T*Fg`7e501G99 zB=s=4dXZA@s|YNs=-}yk@~rHLVK=$yZq0b9YQgJVO6J>Nwm(&W+WdN+xsr1!f1$*q zI=`{EGiiu8UAH>EC&{=0%g2=nU6P4WRn)_nN!p%te0DP1t=v7$!}t85hxfVkGaaWE zofvjX7S9c&HKgU!ME~(5BQLPUNRUmf`PR|Jp7a)}y1V6dg|IxY?7`nxerf&^`>m4| zoLiI+Ep@EE-sscG8dRC?Ua8+*p$S1vIaLH(q_&hF>0CDX&${$D%|*}E>ulgT2M>rx zqC4!&g;VKH1Un~-5rYhke);=i_4r0q8sN#{;pBa!vx zx8ZO0?~SbeTx@|xsa>^cBcmg9@XFNEa=}g!wGG^d$>P_NbW$4Esxq3e6g?KpL$?U)}Zn}jT9Z@;Ge2MR%}@MVY>DClYO8yZ@? zuv>9-IGOIcat7^=b3Zs!?izFw<5X&Q%fid>jV6~Ow?q~n2`vEDruyylEPt448;tM7 zHM!Q@Dtld+oL8U2%yQ4_&kD{F%9||sQF^wT(BRVHGk}^@UCJc{kd1*H9Hn?+F(LU+ z>bLZh%(iVVI1IR0pH6jKJ%c?fa0cPJc%sxX+U|ixg5i#4hGL&Y3_l2K4|z=M+I3tN zo$(pg?#^%Cu7y>giaGN6{^;gh$!^Hj%B{$ADqJWHsUB#6bgcIWOn5JrZaE$T07XbU zo=+lgWErX>x=a&y>w#nEo##$^xYoEK&KS6zI(_cMIY%$M`xcFcj#_F;FC@bS;G9>X zt#sA>hP7X_ha1a|sDEqytopb$pE*ERWgGIo>~kJss=Hbai!&JJsy6?;vM)-D1t~ht^*u zO(|#` z07=zIOqs@NEfyMRd(V1E77V*M8J;_SdTCt~J#hq{9KiM~Rfn1gbpK z-ZxA!zh@J1?3Uvr=T|OoUEVmqbbNg*$)?R*#;8_1PX#5rC2|#?&(Q&zp~Chqt#Qmr zj8*j!+ZP%?)yh}?C`A|FDx52DDXcA0DUGg}r~x$|v?cYZvC8}nSfruHv8fq`*VBzHF{MtFosoQ~KuVeGUoX==Ro2K$qHd@qx_d9w5jHaL1 z|F`~N{`RRF)HtK z%pyda5qluGxwarM*)$00-fl^($JPW?WR}hpV~dT8FPA(ki>rK7>(RK^X5Onlk~tl> zJVZEkxW#&Nbky(^I4iL%zoT|Rm(Lh&p<)waUvV7oc*}9p;rQ`JJ6CIn`H~?-=Z@-G zxl*wLJ|FBN)Royq4BK*9ikh+-e${iW4d3{*wxg0-#$T#jVo+jQs$VW%wN)F_$Y>Mq zT^!b&K3KvK0!VXAZ&*7P$iFETE*GIXq5a7)&g`ER*6zl!)#LsSgyRp7iQA1?6`9o< zqI9xVi{wnjFY)VfPQpT2fWvY^-ZEpldIa9L*B;YEs#C9atZ*%JDm5$>DcdVAtU6QI z+lXt2_5K)coVu`dV(S@kl3@aUkNLyZYTHn4SP*>FMip~JeZ|RLe8Ks;bleMOFvMW(MZ<&X}oIgW<6*7!ru58 z=-9YjvrV@p-SmPXTbrcnCa)>+Q=k=Bgm?yUrI_wsT#dpYYW3tW@ehA#Ke+Z>uM z{d1_{uKHKuUNKf-UG=f1=3hnAz4qN6`0$^}js^P-^gfdc1hJ4~T#r4xI(@BE?ZE>|hd9Y-wpdNP!>H+8t?f3q{#>>UKQwzi6 zy%8Oi%`Nq*wP&lhDuXIJEAiE4wN~|f&41ddJu5>#lV=uk)*bc)sSLmr;yX@Wz*}5R z?mrcMEeHJ!V|{aq<&5=$4baxe=A#wbqR`}nLB6)U8dSks(otxh+mIt1{Fy#*pu07> zG&Oy4)UkiDQ=wJ4ky`hyMx^?`s)nld>fPF&`g_f5?a1D)A+gEzd4cuVT@wlb&EIDjf}=jvnr|IL(KI|OblKf(bN@+BwK}^^8nHAkQ4@q z7(q~4F`BIys~m9aPHc;9I#s_|>s*siT~pmulUaAEVYykkL#nrGXmR4@{QI@Joggv? zJ0E6-{l;4$;xC=9cvd}Br_bQ7$vJaB%THEYR(@72i+ZzS<9YovM@pVjU`ZMai{b0g z2GCR{_u{hiViR&h4L!gPQge2L^1mmw1vS+*8MXfZs15EdH#@9*3x^6Ol;=@v z`a9na#aKU~0vK<+uP{Q&L}5dfr4^(vWDGZxwFtMIw!CJkYQbZsZ2VYXM4MABRKY^( zr*J+#62k>cV#$*ScS_gR<{wUe7*_2I?eu5`H{Gf)tLv@(SNo<;q5gAYUrS3z=n?JJ z#MoTS>Zk27k|8q{LgP^65fI9k7?*pZ60Zr;t1>JxSu(S-=&-nNan{`1^qUdC{-hQ~ z^?|~DDTIh2uMBnp_KvMfq3;sc^%f_l4n`9D2fM1;E;RpZ*!j0x*IIY|-(J0G)5%t` z&TqXHL;e%db7HG(f(Xfz@e{m&qHzriToAu6E26BTk*sskz}Gm{l*hc-{IU5xv!5m~ zqu+XQT4Sm&6mq3}MJjkZusQG>028XnzVhbdCCwSPG5UaEk7!3p3rCY;1HQih-=+H6 zhP9@V)`y*gy=_BI6Hap_D-nbs;!Aoys2|yf`^1kGlaQ%YY*jPV2IzAdd6_JlCYe1o zOEO(H4l&f!v(d^|eW@@aRV1Rt7sMHcI0H1H=^c1(H7-A&Eg1J2O78uy6VvA2?AK`A zFk64RA-3^Lvv1o{Cr{t(knsf1oZHI!R?R^K9w9o0{DU zy(P8DuW`5GSYvS0i561Z=`N2x){xf3!Yq9`WJ~a1pE?a_hJVDW@;(v1D+!c`soc>x zrW2w+X!yX`+vJ`}m$AE%gn@?c2Te!S2nC$9sVI{FEp8YI1j{hakg~Up)&v*4rUXYX z4jA=RckH&#Hh*Y>Hd!^N8><*h=;i4$Z5L~;ZWd`aZ}K}44eL*Qp1r*Ma&v3%5k;9j0=j$WhBR#-ApNl%~lCsF-Tp)Mo3B>Jtsz3=a(^^Ghp6dSA;foVjX9-J< z8?(Er@`7qkY|+0%OYbR=U zs|6|FlxIod#o7dvc$_h|a5*51en>>`c&?KdcV|2%%ts0aDtrC9OFK(Df;u`oW;;{5 z5q+G4IU~Ij;j?#^rq|!?gp;n*p95pz&oRe%x&#=atx{t02TBHNiyE6+$8~5&;i$8% zwWeXA##YjhpOX?1BMCg@)OE$3bz&E-7LW&fiPquH;BOLnE;%IoOL0r(n|hw6u=cd} zf%Zjh5iMPfJk=PbWx0wYny_Fik2dxiA|8~_7&{c-`@HGC5;ng&H9F=v>^!jAE7b$) ze&1E!mD(-ed!nCvC}7lbGGW$r>B@Tlb{J8W1^^5|KXAx$J>~l-Y$p*VS^w|(c94XXFzS(bgXZ3V%B9zbX|Y@{Q-jdm8AhGLyB;oz?%pT zi=w1@WVsc4lqssO)$eO`Y24R%qfS#DP!?1ilNFNM6g@8(i9f-4i2M&iX5FG9iLKjp z>%iq7b3dn~#wA9w2iy7|_l@*+_4@WD_WKRC3>S?_O(Evomq6=eg2KU5$^kP3JdBXS zn(+t;WQg=iyp!pXf2UNXa#qb;%e=ub>7qqm!R!8XC2UFab zxgZ)`6{Ew=;JYLIN!&^Ly_|#MBV}n-O|_qD57lZ^{Z#HKvE@Os?<9YUUKDJ=FLL&y zzCzsqSo%1rWp{PcX%(_4I-4>1b4+o>U}$21Yhb?LalmPCeh4;NI6gIXf9}cB!rBMI zo&6|s4TA)fhg+c4xt4iP3SAb%Njb@)6l|1sl~JnEs`pj=DhA6($rwmRin zm`2UFAJt}0Sl=r@{7L@-NQB-)8F9Ao-~Ec6DF|xh#Zxkz(Je9+gx0U7v1&j6>3p$Fcvo$$Se!*Tud`o|Q?IJFDUD?;;WJhGP|-y z@<0U}1%QHqJV93W=!2*yE-sQQu!^6-eMjpdiokO0Z#0?1-o0!>}<5^EN|DKLQF!u6uxAmkyHY-jr3Vafil?fwm$Rn(H| zeECell;kAG#Le-G<1OQP6Wmi<(*|=}3xJi7buj|xo)+mgwU4O|dIf7mEn>U4Bl(5| zr$nBK*GooAFUkCq6_aDiddb?z6iDSr7>fA`>j;$K_i+^T5F!p@3n0_W$nghXcP2J3 ztf?)ZUs#`AoIWw7H5oGzJn?TLZgOVo`%LHDrA4=uZ|kZA#x8>>PCdu`3><(`kPJ*8 zR}gQxK#A}fu^0(2sS4@uG6-3UOqfiN^o}G{qEM6}w9Oxj-@^%F_>p8tJ0OV>NeLsq z-JRZwSa(@@wkR;qGyC7P$5ig*oypqC2UCBi-DdsgrxyRN46oY|c6WP-y_7Wu0w@dB zKni2Vao+e8{#YR;Q6F)2$!Mwj(tFao(zm7GNGV937nc!@7E0mw$FJhFFsG4jP+Oob zQdjQ`RM7N`8QdIn;o=f*m3PCJK-kSBzM?#0 zJO(_1gd;4_>ztlE@A<9?f<$!0fD(R^?owk?D^ic7eo0=G$P$YY5fpOb*TRqBtT8W; znb1mL8*_$=JUqKUu>E@T=~~zFmBsM+k=e|d_33ZZ_0#8Oyk`67vKN+@?yg?kcthCR zeM>w>5n})V^Wb857zYb`k^4X1vjWpXDA8dtBMBYJI>~X#=aO|2QQ`-pWZ`7NT|P2T z4bBOZk0e3mL2j&9wC+Q#gQlJLTZ!wNEAdNT769|Tv&h-}nbsM{S(CZM`7?_*me*Eu zH%gCuyFtWJn(43E4qzbs2TBO*$8{5L#$P44Aej{0gT`12^tWULF@N_R;pYZyt$ev*QEB0DPI0bfwrAFIPHf(10lEZV zxwr<}+$7-k{E2hqn{3eIw*PL5u8*y(EuCHzSvWpFJvTOIJP%pW zSgcw~Ss7pR-c;CD-t#7wla=XtENxIeR0!#Ze$IK7TZH!+f0Ez@;Rca5(J-+ru_t0g zQHW@fFkA>KP|PdBV~aDy08!a6GjKcm3}c&;Lb|nocc*wuW8-iYy>e&Caxr9qG`}>j zzd%?3FGVg}tX^1e*bLov*}FviOx~b5vxM)JMO;J^ zMIVR)M5RSGgxm$M@tg8Cabt1P7z}C@_71EIm}4YTeGg9@1nw3S%r`mLRacXj1D4({ zVi)%pj{YSVCzo_rmRFb8RkupF@9YH=ACn7cM5Yn&KS(~j4ONACjKlDF@Ots{3f>WV zDy%8;ROF@zLd0BHSSU?klrVEy1EfFDzjCQ6nfp4|JpeSAxH!*T83 z%A4h!C50v6lKxW1Qt2{nC1&mB#`i76PWIkC;#KlvS{kzhumgs|A*eZY3@4h~5pT;! z;y)vJNr*={OgKasBYalqnBXeE9-k>5$o-ac4vj_$!m$tvV2;^K>nHCJwfBGSoFEu% z2CUDo=B{)uTP<@h8!y)^C$2QETCO8E`3OEci+dl5fn;~uIi?RF2$dbYuC@A5uz4oCk@! zC%5gk9&NDKx>q+>F0RO|SgsVU+*^%ZJ6zA$j3?CZNbeUC1IW5GekK6025N!EAWS$K zFoHNEZZUifFE78iz}OM-v>;CKfj}bvWxi#+G>;V5I`$EofHa0*hkOOrvo`56)LVxu z2T%5#cTN%FH-$I0)=+DAR`pkpul`x}S@T;j*s$Fa+*a5P-Jc*{A&bza8Nb;PAT8(| z{3dD%t-xu(#mVyvPv&LwRq@LUSPNhU-td?4ec{!`M{#H2qOmsU0i*@|Gh`kp#|~#S zQq{<1#DIO9UH9!@Te_R54Tbf%HODpAwX8L#b>od|o10r1+ljlC`$*CQGK!YNIKu{j zG9U)vBo$kcP!5oeu4KpA1A*yKZ-w|Z-}=O|Bz>oOA)7n1)~2V&ETaFanLjN zI>VDTM}9)GJJ8?r*eNDBZOLugZse_BTK8K2y>7I@yQ#PJiJ-fK+QS`KkUo+Dw8sn% z_9vhiBoRhN7;>D!SaY&)kGY$8n(o9wOQ+g&>^_O2W}B;}J? zR4+z5OAq)1Oov(_!caHRC$S99>s)!ArA$a)W`08K&&uvJ6@>JA!$_2GPryU(S?oy)z(z094?EyEqmbrW}-a|`2x z&O?nOR$;S{c2G9p9qSsyj}}6Cci2VLIcV8?v-@}_Z<}wsf{;WgCvb0PZr|Q{uv@Yx za`1`q-j#1vAS<1#t~V}sqs25@F`R&pkCnsb(5 zX&7z{o1+O8h}?p^!~Q@HL3+TO>>?(E?n9i$&pqaD|L*86Ztvb6 zet&Ae?_iUtboiPKqQ=p581u|{wjJ;Qln7CP{e&ZsPN*=B0JJ`40&^0ZfX&86W38~Q z7;%gv`UHm>ihziQ^TVPbB+waPAsfevWUSGyP!W{6!vxX?;@<=6{?&c%{iVI7Bhs~f z`jLv!#8^_vA%*NlZJ`}w3^Rk-48VI3H{>mJ8)k$ELEb|JaHyc$(Z-m^m@k+Y7;DT3 z+8q6eBMJ2p8HP}T_d^391W*`ojqT4`V?@$F8v@_&b~NX0}r@#%s50eSymU+^IG zVCCQq@f6AA(4HJl$)f`4evAR89lH{s0!jeyLrh@T;Zcb9$PknaM+JvGIt=|9eGhGg zUf}TL_=}oD?jn}pwXkPUeaJNEK9CPk$Z};e>7}%%R6mLj`QBj(iHj6RG$wKoK}1Pn zAaRK3PZB)bKAa_QQm`~@`YXl=Q;Gc;FbI?YUxIvu{)Lso(-7B?5~y4hFUL8KNRBHU z<{X=-o2Ut-1o9Z-EZhmE1_eM`K+k}t01B&=`5)sHU5}Ey`gT&-uO9Kpn4j@sQNq~r}i7(0*^#Js_HOE08tQH`mo6iG@I`5M`ptWCBg zhms4(Je24oRu}ax&4aGT5NC3+;A{YZ4A=yYf-1mKkW)|)><3I19tU5AOCrn>RtQDJ zAv_aq32%g{!(KtVArOc<_|#FCJOg}X7qbSKRE9DmlwL^_rNvPtsEw4zlrt1tiVG!z zl0#urf~o7&2Q&%#5Iuq6!_;AMu$S32fH%1q67H@76v~CO#^k0Xmf0Rb}Va`d5+n^uxIqsL+Cv8ZrT^xZCW5Lg!Y)0 zNt>rB)1T9~=$9CCj9?~>8N*Uyx3Dh(sDNmo2q+yS4vqzrz)p~ONEd_%;ec{MVbD#; zKgbJ+F=QG11k3~e1`-8*267%LNnu}MFSC4D%ghKSl$pUe!9Xy2=xOwq^gHx>^mp`Z z`Y4^7ah{ROKpoAPVV-7nux!~4Y%@SPKpmJ0#DF3|ogfjg5BMv%4ZH@Xg8>iD$N3I1O)g}da2p9#Z0{;WH0y#kTpnIU7pcc>!XbZFp+5k;}8bB$a zNRTZE4e9~D1zH0sfE>UD02a`|j%2H`*I8MtaF!7ZeN<%&^Di@n`GuLpOlKA|yO~=@ zeQ3(M!b)RJviR6;?6@P}H~~(8SAaSI5g-FR3A_#b3@ke0jsRzXv%m>pFR%`n1&jh- z0XhJsj!2z=WWW`G*%57?UCe&N_F