From 22080eb997a0f56a0065ae4bdaf9fa31826f5cde Mon Sep 17 00:00:00 2001 From: Ralph Mayola Date: Fri, 12 Jun 2026 23:57:59 +0200 Subject: [PATCH] =?UTF-8?q?Zonza=20:=20dict=C3=A9e=20vocale=20locale=20mac?= =?UTF-8?q?OS=20(MLX-Whisper=20+=20cercle=20pulsant)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reconstruction propre de l'ancien projet Dictée. Bundle id neuf (cloud.mrtechlab.zonza), chemin de build unique vers /Applications (plus jamais de builds /tmp enregistrés dans LaunchServices). Co-Authored-By: Claude Fable 5 --- .gitignore | 6 ++ LICENSE | 21 +++++ README.md | 79 ++++++++++++++++++ app.py | 87 ++++++++++++++++++++ assets/icon.icns | Bin 0 -> 99491 bytes assets/icon.png | Bin 0 -> 11151 bytes assets/make_icns.sh | 22 +++++ assets/make_icon.py | 64 +++++++++++++++ audio_level.py | 30 +++++++ build_app.sh | 71 ++++++++++++++++ conftest.py | 2 + controller.py | 125 +++++++++++++++++++++++++++++ core.py | 46 +++++++++++ engine.py | 112 ++++++++++++++++++++++++++ pulse_window.py | 165 ++++++++++++++++++++++++++++++++++++++ requirements.txt | 8 ++ tests/test_audio_level.py | 56 +++++++++++++ tests/test_core.py | 61 ++++++++++++++ 18 files changed, 955 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 app.py create mode 100644 assets/icon.icns create mode 100644 assets/icon.png create mode 100755 assets/make_icns.sh create mode 100644 assets/make_icon.py create mode 100644 audio_level.py create mode 100755 build_app.sh create mode 100644 conftest.py create mode 100644 controller.py create mode 100644 core.py create mode 100644 engine.py create mode 100644 pulse_window.py create mode 100644 requirements.txt create mode 100644 tests/test_audio_level.py create mode 100644 tests/test_core.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..36c3d5f --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.venv/ +__pycache__/ +*.pyc +.pytest_cache/ +.DS_Store +assets/icon.iconset/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..ce9eaa8 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Ralph Mayola + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..ec93a06 --- /dev/null +++ b/README.md @@ -0,0 +1,79 @@ +# Zonza 🎙️ + +Dictée vocale **100% locale** pour macOS (Apple Silicon). Appuie sur un raccourci, un +cercle pulse au rythme de ta voix, et ton texte est transcrit puis collé dans l'app +active. Inspiré de Superwhisper — open source, hors-ligne, rien ne quitte ta machine. + +- **Transcription locale** : MLX-Whisper `medium` (français), aucun envoi réseau. +- **Cercle pulsant** : une fenêtre flottante visualise tes intonations en temps réel. +- **Barre de menu** : l'app vit dans la barre de menu macOS (jamais dans le Dock). +- **Toggle `Cmd+Ctrl+D`** : démarre / arrête la dictée. + +## Installation + +```bash +git clone ssh://git@git.mrtechlab.cloud:2222/rmayola/zonza.git +cd zonza +python3 -m venv .venv +.venv/bin/pip install -r requirements.txt +./build_app.sh # installe /Applications/Zonza.app (lanceur lié à ce dépôt) +``` + +`Zonza.app` est un **lanceur léger** : il exécute le code via le venv du dépôt (MLX y +tourne nativement, là où py2app/PyInstaller échouent sur les shaders Metal). Ne supprime +donc ni le dossier du dépôt ni `.venv`. C'est aussi l'**unique chemin de build** — on ne +construit jamais dans `/tmp` ni dans le dépôt, pour ne laisser aucune entrée fantôme dans +LaunchServices (cause historique d'icônes en double). + +Au tout premier lancement, l'app télécharge le modèle Whisper (~512 Mo, une seule fois). + +## Permissions macOS + +Réglages Système → Confidentialité et sécurité : +- **Microphone** — popup automatique au 1er enregistrement. +- **Surveillance de l'entrée / Accessibilité** — pour la hotkey globale et le collage + (`Cmd+V` simulé). Si non accordée, le texte reste dans le presse-papier : fais `Cmd+V` + à la main. + +## Usage + +- L'icône 🎙️ apparaît dans la barre de menu (🔴 enregistrement, ⏳ transcription). +- **`Cmd+Ctrl+D`** : le cercle apparaît, parle. +- **`Cmd+Ctrl+D`** : transcription + collage dans l'app active. +- Menu : activer/désactiver les sons, quitter. +- Sécurité : arrêt automatique après 2 minutes d'enregistrement. + +## Lancer depuis les sources (développement) + +```bash +source .venv/bin/activate +python3 app.py +``` + +## Configuration + +Édite `CONFIG` dans `core.py` : `model`, `language`, `hotkey`, `sounds`, +`min_duration_s`, `max_duration_s`, `fps`, `circle_base_radius`, `circle_amplitude`, +`circle_color_rgb`, `level_gain`, `level_attack`, `level_release`, `wave_rings`, +`wave_speed`. + +## Tests + +```bash +.venv/bin/python -m pytest tests/ -v +``` + +## Architecture + +- `core.py` — config + utilitaires purs (seuil durée, nettoyage texte). +- `audio_level.py` — niveau RMS du micro (alimente l'animation). +- `engine.py` — capture, transcription MLX-Whisper, collage, sons. +- `pulse_window.py` — fenêtre Cocoa + cercle pulsant + anneaux d'onde. +- `controller.py` — machine à états, branche audio → animation → transcription. +- `app.py` — app de barre de menu (point d'entrée). + +L'icône de l'app est générée par script : `python assets/make_icon.py && ./assets/make_icns.sh`. + +## Licence + +MIT — voir [LICENSE](LICENSE). diff --git a/app.py b/app.py new file mode 100644 index 0000000..378ea1f --- /dev/null +++ b/app.py @@ -0,0 +1,87 @@ +"""Point d'entrée : application macOS de barre de menu (rumps). + +Possède la boucle d'événements Cocoa. Enregistre la hotkey globale, gère l'état du menu, +lance le warmup du modèle en fond, et instancie le contrôleur + la fenêtre de pulsation. + +La politique d'activation est forcée en « accessory » : Zonza vit dans la barre de menu, +jamais dans le Dock — même quand la fenêtre de pulsation Cocoa s'affiche. +""" +import threading + +import rumps +from AppKit import NSApplication, NSApplicationActivationPolicyAccessory +from PyObjCTools import AppHelper +from pynput import keyboard + +from controller import ZonzaController +from core import CONFIG, validate_config +from pulse_window import PulseWindow + +_STATE_TITLES = { + "idle": "🎙️", + "recording": "🔴", + "transcribing": "⏳", +} + + +class ZonzaApp(rumps.App): + def __init__(self): + super().__init__("🎙️", quit_button=None) + self.pulse = PulseWindow.alloc().init() + self.controller = ZonzaController( + CONFIG, self.pulse, on_state=self._set_state, + run_on_main=AppHelper.callAfter, + ) + self.menu = [ + rumps.MenuItem("Sons", callback=self._toggle_sounds), + None, + rumps.MenuItem("Quitter", callback=self._quit), + ] + self.menu["Sons"].state = CONFIG["sounds"] + self._start_hotkey() + self._warmup_async() + + def _set_state(self, state): + # appelé depuis des threads de fond (transcription) ; toucher le titre du menu + # (AppKit) doit se faire sur le thread principal → on marshale via callAfter. + AppHelper.callAfter(self._apply_title, state) + + def _apply_title(self, state): + self.title = _STATE_TITLES.get(state, "🎙️") + + def _toggle_sounds(self, sender): + CONFIG["sounds"] = not CONFIG["sounds"] + sender.state = CONFIG["sounds"] + + def _quit(self, _): + rumps.quit_application() + + def _start_hotkey(self): + # pynput exécute ce callback sur SON thread d'écoute, pas le thread principal. + # toggle() touche NSWindow/NSTimer (Cocoa) → on marshale sur le thread principal. + def on_activate(): + AppHelper.callAfter(self.controller.toggle) + self._listener = keyboard.GlobalHotKeys({CONFIG["hotkey"]: on_activate}) + self._listener.start() + + def _warmup_async(self): + def run(): + try: + self.controller.transcriber.warmup() + except Exception as e: + print(f"[erreur] warmup modèle : {e}") + rumps.notification("Zonza", "Modèle", f"Échec du chargement : {e}") + threading.Thread(target=run, daemon=True).start() + + +def main(): + validate_config(CONFIG) + # barre de menu uniquement, jamais d'icône Dock — même en lançant depuis les sources + NSApplication.sharedApplication().setActivationPolicy_( + NSApplicationActivationPolicyAccessory + ) + ZonzaApp().run() + + +if __name__ == "__main__": + main() diff --git a/assets/icon.icns b/assets/icon.icns new file mode 100644 index 0000000000000000000000000000000000000000..b952996d327d69f91c8f5c0aa6e7b5f040fb533b GIT binary patch literal 99491 zcmb@tWmH^E6fM|>LvV-SuECu~f=h7s5FmIUxHRtWPJ#u31qkj=aCZ;x7Oa8cdoxeg zn)x|Ds_RyD-Ku*|Ro%5~?{jS|99#gv-~tx;HfFUm@t^S%r z|4Yb-ufJtB&TFq3&_!KV5>PotcJNw|w$zpXq^t~Je!WHp0K;qmkpG&zCX&|#0D$v> z0Pt%L{I4z_^uPXv=7azD^?waz)Fi84yV#YNmeBM7?il#m;SJ#XbGPhWHFWt9p)1%c zNPWh^(FCC=fbJbuv2E>U%btwcdK`Ci5ZH za{5m!3VZ2t$lh8pLPh;Y>x9c}R_`o(`uE`?CvS12ITG@@BUh3V)|G9BcT4w^n&3Y+ zFM}i_9rI_U0;PY`p~=~#iEzZ0Wx0(jiBz{_>Y|7_ZYUCBR5yygA@ zNx)A#gkB3`=~G$CF=G9z`y*}JyOPv>nX=g>`MX$LP^j5#+jcs($bz#1M14ac3jAhL zT_`%Kre>{KawNQUq${w@ymc^nYO~jpV5Cux`SAe}0iOTOGKZ~)1Ns$*9I2inr2~OA zu%cV-R|)@V#cxuRpKFf@cR_u&rMi{}3PX;2lVECfZ!feDb%|+w7=MYPD0Z6c z5Q?z=-4B}E-!f5Zzx;ig7_)Gm?fGiaEKruF6W-^bH>^&;^9>=c-A0dU6DpM#99Wc- zHXt+jfP{VT<6^n;Po;~eOlDXp?~D@o<`<9GiPQO~<#z{bR7{#X7+3p`x$mdi0C>0G zoZF`?C)a8Ert>A4++#6Z?s!__$2dp*cRs79+qi`@YBKtEZNF1?FiZ+Tu?Z;HWJ*U= z_wD*8=by4_DC3(HGSm`Vd`OJIvloC<*47sPhe2QWYlKiGvyeZt%)t6#aq6vkJ5IG7 zDI0p|ImTQB#xRGqHcY;aB^b*sAfL{*f3h2Y2ajEWBL}P~aP5WEb}_{pdDBhpvB;oUy2M(*W0j*@tNl&Om+ zqwBGD6(tInPk=x4118hmp-mmY$y?>tf zt_^y49BP#3m&i;5h|}wp>4Zg8HSU8!%n!zQ{kwXqAq!KnpPP?oPIRN>-b>J%xjR`R)lG z&o;v8AK5tH50@k#&NcZ3f5B~vxq_Vp8pY>ttY_VeC!5*0_a_mVVun#V;2aoG};FT>KdMg_owUDcsUq z()HgMZ)Tp&6a%V2Bh(E1S^tc0=Fh5pIzkm#;QUTLEJxIA5_Sk3@F77V&@=~(H(hR> zI0{iJ7UCP zO+jldQ#rRWCGT12r$>Ypn9yNLI_9F0W6!lw|IR)aY$H{w)i^fSOUz0D9Z9tS+<(@n zyuZMgY%i}lS1&Vg%I@t@L~A}X8~Y(IwOrxkh)%vAwQ4bY_pVh)iVuZDJ@DVd6cYx~ z>ngevA_!NsbxCH=KKaHyuw--bhuKf`y|@1rw#b0-`ivgcrPuQ2#wZaYdaSQ;*;MVNkUWm<$E*lm(aDP)kCm_3b~7&-CHo{k6%B& z$5oy09v-9J%G>F43W?FSyPf&fs=`K6*`7M-y)X1^7KVc0fd?RTP7_1fd&;!oBp(KA zA`z~_)kU#~ z%;5@9U?Tmg+c`vNO75ey_f6*w$D=SyK8K)t&Cbr;;_T1m;A+y5_W0d_H~yQxT_U$Y zzxBtj(=|ErZyxYE@fkSUeaamU4~a;`wt~{lR9T0D56=EZyz^)sZ}a-NSUj_LqZ}~v zhhu!XmSAj>Kv#-*SMjhy&3&{nbegJ7UwFLL%k^Fr2{m3DxUzrI3_&a{=JHHrKD+~jRhw^0D!~y|AE0^ zugzfp1B3O5nGL@d{(r$>u>S*t{Wk(YE*9br0AQ#8-x%zK9L)(&t7d)V*x?z4%106l z-9I7jk19|Nj}awL3j0)LoQzo^=dGVg)tIp8Xhet5Fw4&|;h!_O0?_x;cxva;?-B4J zcr%hgIPFvcSd>^bc^RJbqQmj4ACv47S4UfiT4z4F?cF|ny*)aeU!CruR5>}w`lH@f zlJYiR@qVGtGuvjHcDGq#FF@eNI&S$-A}P;m2Z4Y=PXL8!{>kjL;bUa#*Yd~#WIa~L zX4BoFcBT2sIl;V6i4GzG*EG5`19McT%7C&*^Tizdvk(DrHmjBMt`2dvdcP?H=RHm3 z)eUAx&XavP;`(RAyMc1>PzgBKCVH6PrGJ1qkpUzAGP9Bd3}uhSFF&~ylk+iE0!5^Q zUO@UWaRGH1iq#7h)hU?egJO#$y>;op$VyiE#2PL%;`96Hp&+7~A7zgT`(Cyi&Xx8c za4%LNeK?b=(UacZL)6N6E{Fay7ycwQ;@$G~Wkh13Na%Op!Z##iZn9PU5)F-H^s`=b zpBDPQo+QX>yBklg#AfyZZdI8Tl~q5X5iwrT(KE%WII=fyyNJC5mKxR7J{xS$pZb5X zKb^Gpj?4r`7N@*1P28h<|0PC3RC@LOa+r%Th!a}YMaM9Wy7dSvUadD(PUHA~z{iYu zcU0G-)A(^72TyPh`y%I)5qdthira#7-GGDlb&dl|R$!exqKC)B=0zi`Cs>e`wCQK* zqu>A=3|NB^U)>Hn%&Xm4ZJj3pm#QIpIC&ii@ViuXlV;QT6_oJL#ksI{5yhuRD^Ys# zo)d^QH+u*5n+4(dT0eYyMs=we$Z~c6bGI1MG`jXDqnHmd&yr5cB9DZN4`ro-g&G?|j+4?^Q<NEZ7gHkR zsr`_sX*OV#Df343eS9F!#BpHvu-KD&K36tHuU{`<(<{}DG&M$R)!V3RwYU|%(0+Xwxj zaap4`PGqHD7PCfVw-%Jd_*n1D35ZEl9|t!t8BA&per>{2I;nH6L7LK==F~FUp=3c9 zVZtdal;7xQ`wi0x<1TSM&+WpS2aw2f^^c#62y;tMEDxV)Ox(3(rXspwz#(usS6XXh z3jS;IRP;7P1__m15EC(1V^W|-?wFuPk<`v6;P`0`sc+eKqZ3o?+*^>*!J7*t>y0@J z_5Zh#9JCT!X_9$zUEbX}oM?7^jlR!IJCr`#pX|6rQ5yEt;&a}Y$-MCE8;NEzYUFV4 zLx3o!r|Ji6z5u+0H;=w#&ADfxfd;jk-IGiepr<`@V~?{Ug=BPNr*FO0q@>m2i_t)k z9Q>P3VfXJV7s|Gv73Qrh!p8|d-7E7VD(vG=q!Suhy8$}X>#`Nr&9&Ljntq2UZM{X{vhBWLo73AF^f^;V-z8{S-z+U+N{-4of+MXTm8XU>H36)S)r7(v!!%%lMO*r<-MOwx;-KY!qsi&ZUVo5Kz ztsTyLNT~846cMY160s@JLMgR(RYeBv)4o8t+_a4S#J=X;`jkaz$%mQ7fNC7*JfP+S zeDCI>4*MQxfy%x=)q^3l1p3bB3W-1wqH5D{=cg^9Glpjo75p%BXeV9mpqJqdX zn!WE@$S&|uEto715Q|NZ<3l=~kg(x!^i_7h7M1k|7G)kbfzwICJ#HGn_`!ZugOyUc zA|icPg6;=Vp9Cr?Mjg(d_o=`w`e5BYk9R-fUdlnLSJG(joUw zQCwgw_JWAd;;Ee(O8_xujV=_4NX2yK<-3^fwj7LT#0Kr$EJ}8h6B?yv6^9lQgLp^~ z*@0jYVEzXv3tF(CZt%bh6b>xsJivGn&#S3!V%U@dY^ z3Gjk5x74B`#*V-?afLejXYtQxkp?i%eeYzl>m9_(Ii$&;aX(_x7l7<6`^X03 zB5zvivjcJQb-yidrM!;^mM>b1ZAK-U&A~;81WUn%mWUEXBAxc6Du4mmy110zzGgoa zf&(IBnm!1{WF>%oITWNmDTkG9Gy62v3UKH=*dJ@0v`I-ZEe6YHx+yrlo{!atUgMHof1IS~TW3 zzGuIvYM^;6fIQ|rHuzBLN|JUdaN-_o4^#p0C9CCV=C@=EsAYSihncrb_Gvb6%@I(o_-2}7U{~!&FFr^_v(Z+T7>FA7a z`W4G|(`D1^VVOgvdl>DITS7n8T=SlOW}tR*q|zWJI$V_#+YVA7CxFjR(HYbHb=a#0 zF1P~!(7uTo&d`;1SSwOrnyRyT|7i>e-nVme5wQBi@t_p_qS`-ArnTHN~3>b0kd&X2-DLLTGuq z9PKUxxM1zhDfJJY9)@mi3wIAjkZ;0)rEG3PiLQ%}QoQ}tv0ZaX{aEKCjKiJ=c&Hwr{c(4>n=zp`iF$jv8*h*z9ks)|+$8;@ zF8uG?yHC^3G}p&HAK10OamOZ!o8VO$uU^Pr87$PDTO-Ok-cZtv^2{(+RoARo34A$Z zpC5kX6=QKqOT3Opa7xv1f-XUex`!MYd5)1SotZ@&K=!tkd#u>k6uG3gv+ZUfL!Gi>r=~j1P$9h>Q-u0In=%er~#74d_m80!6 zXs)Wfuv!cImqp=X;=NF>fr4d|IxTiJ{Z@{GtiS?XN1-oE##9NS9t{oO)AwU&av;UI zpg(tw{oJ}2)^Bt)RU|vHPZb+)v`c4Z@C7x)FQghA_zV??<;TkcgsXH=X*a<%F}#=P zZexmNd#l4z{J$FX-<$YvOHM|iP+C5ICOMM)VR4)~NowB(`aA4@9zr!?az5Rl zb$)juY>!J=9fK-Y$Xw8F_s-U-jEDVfODJv2=}JH4ZCF-bn&r->#`5n)=iC?S0sa|(7f|74v*5qL};O@1+0O1L!H!mh*g49?5D86;{tTat2_V%>U+63bx!K>qcX*V^OM`?)ozl z^M>s?xI5kO`+*;GmnBQd2jL>y1oq@nngOFKng3JQpG~7lz7YY2b0DoZzGhE((%YSfYmA4d)@4fzr zxwul{09dRWKj&6m^cZF^`A&Y|9Cxng>x2bYnrPjUTO=NpPHfI-JaoKmcPOT9T63C# zGG_L^RLaTcf9AbyZ@$U2l9F{%K}Z+A2u`u4V^`Bntfj1u&`|pH)`x8DQB7$4K26@r zg|rwnk`wLyoPW56&4_|%n%%i_Vu#QRSac=2=9ATp2D8^Z-Kx*m?2e~uiwq!yVh5T5J~J-04>;<<_dx{5lTU7jGA$(R^H zY06*|NvZy#_q?rYR9=wJF88TSeUtwdSrIuMg}fJ**3C=eOx0$6c(~$^va=;| z`6E_vHkleQQT!nc+Zy7vLF4wzd{t}eRqxXEdjYOJwqdP{i`xo0aT#+4IDCDf;FS_mTejv7|BtKr->lI)ZUBIu`2Wos{U^c!0sn(FGW_!6(|@J^ zACx;F;D575|BV0uSRInc0|0d1{|{@FgOX0x_jSPJ>+Q&KTeE6#FI_xTQbv-pI>bI8 znwEAEIRIl<1M;YKq9& zjMA|&6OI?%*#0b5r68*q%q+Ld{9&^MnO2C|mJv%N_5S_sJ-RaS%3nQ5y82sdcoAFG zd7p#zJ9FU1Ohqy=e^YEx@|4&{Wt3u0Lee~!|&r88u5DU2uVBP%LBoz$HM3t2zVTzif#e%LhsSq?p61`#98fuWlVF3$K;wSIGM2~gFa1ehTLLrBI#FOPyCr25H9!hJEKefF!U%!-9* z5M5TrDW0GZ$-;u9n#7hSTm90-hw&705i=|z-~^B3?ND>EN#7xS)-XUVf_Q?^W; z7XoNU0m=g|9o}jbJKL=)k*j^Vs+UEoaAHrrQyoI+6gO_ZwVT_0Nfb036ZJKcE!f+{ z*L=Xy!H_nw22rDv+0QAYmovfV>PIPlkssX`_tEI1k*Cd{E6@YlZ!$y)rR|;wsE;Jy z&L1)TBtLAMR}bo_p{MMD7{v+QKdXQI;|`p-&d9N1tjqPZ(>=7Ac}&g(vapeFjyH`OgEClph$ox(Co z;7=@o!;-r!VxH)@M!sd|9|Pi=!7(eI7GR~(N%3ok^s!qmQB%U6LIKxU7#N7Pp95pH zp5`%{YZUoz6VdUsTR_8i`z*bFMxYGf8C?_7j_UR6E*l+~Ji2XzoqycH2`Axg-4$qh zXGz}*U5eA&pDt+yh!zgAsrZM<{k%=DM?u_uYrOr%Vb~60K_E-=-jF z|6|Nag8#N<{QNM9FYDrHY4j#Bqz3B)D%=4an}#Z7I11aMIKO{?g+WTm2yD&lUWoj#lv?unD}`%r0LITK8Xw{wlmM4f*L%qsu z#&&UCd_+dQ^LSsaTDE0XzpYgW%oWFA@`J&|A=$G*pDrts-{yJmZ4cbq?3m{90u(1P zEkb#oKaEvH-(LX1GeGepXk^L4S5dApPMRo(b}uV?lU|iPNN2;YL&t3XzKJUl`~in8 z7YM{+gaGxd3J!k7&AhES!>tgR=X4fPYk@hEZjGlheNd5z|*#oz#zf#<7DjCfZcp3t+?_ zV2PjL)SSOhNCi?#22VQEk}Vb`39Ra!Nr1cll@kifgG=7lg`^OAhahMK%HRl8x~hqi zc>P(6Lw-mIa`({MaAcwVgw{+TJl*>UH%{-yL{3hI4iF=_(xxc+T?-G(49UxgHkXq? zd9=K74TvW1yrsiQDX~~Vt;^NNVun=*hmXoLGq$8>THr>@x!ntF(FasV^CilF?f?LC zG8%BrODAekgtl=vFE#qXQnmC{OUOF&LD(pcSD@;40s4_ngQ;eiyUk?+C1Dn zY6+qN-oc%>J0zn~|8v%kY-b%#t7fB@PZ{6kyl9bF0pj_DWR~iBU8aYb1aJN6DGGUI z5cC{na3+6hX5R5>rg$?$JxdKx9(yLBp|Ql^p`})A<%IFNGn9}A&qcq_qEktAuxcc7 zqXI_j+efse+Zh-x%x92Zow;L(^zp%1!?kCWd*0RCqB;w3x4WXu;PkU2a9nVlmh*q~ zGJWr%gJrNkkp7HzQV-TT8mfrW)|pn_A^qzB?IM8 zkZ$e#AkUuLm~tkdn)m2)&NwZLRS?qDn;`N-Xt7Wyz3&|8?rev@w)hM+zyh?Ph~_?j zVIxK;@#H?aK?>=959RR|YqTN|{1Z62L@YSb8U!;sh&7}N^%9YHrWvo`*1;x92l5*WOijdzs#*RvGF z6*2Thf>H^>8g+ZA`On6}N>wm6w|F!b5KmEGGa$&{lWg!80Rp9rfezR~%h}~GhI}ks z2CzD(#<(=gRUH0X9>H0#T8sE1G5RJ~2&`zPuwWTEM0jtI92o`}LX$kEzjQJowj)wyWQ&+!)a{0x%X0RA&!92R5PiBE&sn@LKP4b3;WkC?Vh^2 zhaZ)t$un4uH4Kz7^##9hGnnR1?%b12KtF9JtT)1u`^xY!#NR2*{vIF*5c1?(|`l0qaG=P~)g=13bU9TpgstT6=rFYIYG&-IPi zC5@A^7LlYa5)7(k#klmn*8BU6Hx!>gd^PeaIAf~4(2j~d<|mh^wlT_HGaxQ$TTYzm z2T35t3fUq%?WjVH;TkWTwi;+w2z)RucGZJ}q6wC z{<4v49^XQ=U)n?59Fzp~L$S>y0D2#gd+E@mR=I3(S%4p26tzK~(ySI=i` z>*VZ{^%lZ!yoiTsWEzoH0sGItuk<>aV{-5BC6*fbPSMe_+deWknf~ECgL7d)pn@p| z%r>=Dss&fOyb}!K^HuwzevWp%{hXhC@GErPf^)skDajX7sIjq}cBG(BPvtT3084e_ zh(=`^mB^{U4q|DB?8$9sN4lZWZ=y_Ie`URGRuwBgR%3PInaf8~?a8%egt)vRz{E?0 z{eVy1l^ANun!r!bJ9+(vY`0B=DqAdwMjyIi1=I2SI;eU`k@$1J*t#O|f!(8VFm9<~ z{Tn+6UGBu^Mp&N;^N`Hu3OjopUDcReH+fc?*_3#svSDlL4#C$sf*vsHgS0n$z#^pTl&xD z+9vJDT2EuY*%kh+*w+xS;Ih6MR>rggIK*H~G+aNBi@t2s9NxE3zed3H{HRxRCt&{C z7Pnr+kpJz>-O|jdJxOHN{keqnPxD2KmO*eQoC9Oa-s+By7TeAToVURVL)@5~f%!0a z28`QS%}=awyg`+Vyj1O%71NXD7F!=gwF{S!IWwT41TE6!h>yaPA|HjR@ zd5F6aD(d$-u?Im7+t*{Ztty=*T2TQYtchMI82Llj;L`KBk7xYVt}!f8E52}0sne1l z!&#gCUmb;n1hpikAT|szh@x{L!mz}aDbN<%uPW^3U~xgsqBaP=OZltnS1;Z%FwqlB zOQ)biC(H5(>`Y0AB}>=`Nf4R{!!R9Loy{Z3p6V1y_HWk7zp=I6BhMk>5!Z)pNExq+ zU?IcOXm6jVI74G25Gi^4lqHi*K4y9<4}96&>JOf1!-8Q?pcPR+BowvB1fdK0`&nRH zplDB&S<+>r<5q(e< z5Y-iayo<2dzfiMc*!Eq<9a{wZ^~W>{A)6w9_?}5=u}(Gc-ZQG|kH(A9-NT3&1tsC>0cA>8lSxkS_9?97}( zjJ{tsjmhc-d`r^)L<1m&`Ik?dCpMi+&~$fPayawDKQY|t}151bI5xI;Jl_!fJG38+aikLAf?*#N3z+x)c z%vyA`f!Niw$ChJgSH=hXU9>r2;vMa)4TZShrij7hdCA{&r!R_e8|^Cb*`M z>pa1M@Uk^>;a+oh`o4}pD(D3<4I&mYD-aphNq~Wa2>y;w67L-N=hU`J2^mD0KWto z5lSp5NEcZ%QL^iM+UY`9p>WccNtgP@7WXLuxXeokCoRmU=Ux#Y1@QNz;lo14dwm8d zJ`xTX^o-LPO8I9jtTMFef3w6{C|dnF!JxB zvBAiYQ~(5O8ij}g4%otl0siNVbdm-_`0T$ar*k6RVn)sH0O={XE{?>BfKkvTE_($! zdoJoo9wW2#Vr3 zh@?&KpXFWqJu${^Dkm8e&@w(s>BQlmFPlx$<#nqsp!Ae-BYiL`ICWB@|M*zVo_kR{ z4$%d}hQnaj*HcowZ+P}+hGKXw3SwLzX1vPmkD)AqW+zc$ISNPGu0>2j)xz>&w{-VS za$=KdPqkC&?skzSHVR*qz2FB|h?6!NhdJX^pKiwfeDbn95L<$4ll~@CNpN7?)A)y_ zkpAj=k!-XI5H@Dr%k z+MbV`-a-3)eymtUD8lC)?;-4D4Xb}v_W9x`OH(SgCIZqde%`S94hmK8e+%Hd0bHPR z9h1)&E7Ny82c_(ff8?}pk*w_C>L7D)x|lzu9vn8Tr}^Ts@_p3+Hy4#@B=^mk>Qq;o zyt)X{*L!3Zv(!aDNgh|tX!hnW$VT_>1=(EjU!3y#+#Ohj%#fN5Czvd+Jk|@f#wiAu z#7Ybo@?wRXA~AwQ-U{ezjV8bMcCWMY012_=1*(CnkpIwBz-wDs`poC*<$HT(!&WT zql2{^Rdmr@N-l19v2SZ|OsACI=817ncoH}cx3FC=)h^ltw>=b&S_Q$ny5Y0SpN`xk z`JN-ZJ5j&3EM|7tDw5iW5Yj0H$8i4H4gSqZ`0q!{{cn>I5MDm&Q0sFo=7}2t@J8FS zC@jmr2btuHPjq6dAJnUQRwRe6&4jM`HwXEb{+F6m$#i7Wd4dUCOg(G5@(@oyEsF0_ zcQ=)`H=3x8xiZk`0E0E#BApjU|D_uxaS}x7eHZ>1>L9{j)^Ja&S?r|?za0#)^pnYR z)>AAN9znPC!bg{G-B+_3;V4kR-|-0)3GE`$8*yPd?Q%;OtL$aD+Dh_WR>)`DcL#BA zA+q(H!za(nRpibD7lYp^%&3%V1kG5YLwF6%kf8v)&;pB@sA??{Q7R9g4=FA z@oT1njs?82&%6GWy)Zh*pYlOskKciK5&tSpiQ(PDrAbEzCn>Vo&o@R4Y8?`j<-dkG4>}x{vH7P`{jiny3OzFg_@in|^ zmEl|C-@WVITs8Gz=;T`OP43r*#$4iFT)hd_cl;lBN^XS|^E>|aXm}|5YyCrir(wpV z6v9U0@Ak3j?iDeGq{?>GMBjuk+)hNjo%OOo=6|wgwKtvhSp69$+OO`n_U}w(;?nBP z#QMs~uL~D~kZ;u^^*oJmYY0sr7T`TIlA0FbYHeRe%_UCX#P-kq;dRlBHIIxO>mm96(4Bx3zi8pw9V5VSgxJaFZ~g9NI?icj;W&s~p;W2ET5I4LC!7yMQmk9Pn;@H9~E!Zp!9Qbfq_F21w11mK4=x&)0KBmQ28JMebXDy#6b-9KUYB;?RXXta-7Sb zxQU!P>KLiI61%9afNPt8rhWW0*)g5Zu}lRu0hsyl^Q~;=5saPd(iomIG@65C?pnwA z1KH-wnC&)R+5MrHHJPJSio*re7a!`I=s;e`1yGTYYcYOZL)s+SG_1T6?aBN}4w8c>sHYIYDZ zO9?Rw(Ksly`2wLN&yEyJLs@>OE-K16XdV4h3&2XMilgPB^A8i%~qlEF(4Ig(r9pKqoW75_~f5_-n<1ThT^GVr}=+`xeWkR28a zif1;4n|`Q31AU(DOn=ZH}?GgLuWm6L9JJQ>_bM;Pu_& zzgrGd94yVZufXpTrjsbEhgG=nfe}$^QTC+}<*VzTUN5T`nAw01;c7Gcz?TzvevCW2 zkn~xF=+?10EdxCANitPvcyjOcQ2Tmz@cC0_pkV0{zh%+iXZ%5{V3KkUQ6^9O2st=` zVRi!cP3@3h;E+su>BJ0^P>FyG>6SK|JhI;2`YQ>1x52=g!UF-jP*^=WEhnbqo!s;#AbDT!t@f9qD}m(MA&_0x`qZd-9OX~~bxfj6TYf%e;> zEtbA42J7~I1J3~E^f`{7fcO~syyqRhvQqxW1ovPD{WiO?$;EGLuElEYFBas``&6Sd zraf&Ij0TIGw(mq7bc1dx0T9M#i&>#Fo%pp4?>Kb#;S_*ElcD091H{(@Cg(Z(2Owjr z6xR1z@5yB&e`9#6RC_|dG0Hg>fNquL+r)~ifm=O#)AI+8ngrAyUvyyihsZ%N@^u2> z6Yv4Ka^c+tas~aT#W^+i$O*wvi>EVeR9-!uyBd3Nnf>k8T_`<9$_W=Nk+^ zAo)PZ?~n29Smyx%2n;hG8vz-T@`_SFC?Xz!fE36?mpCYfcPJigR#w;J{;dy~;!RF_ zSTdbXX^tf;p1j9R&wokTk0G28WN#E2f6588S8Qjzu|a?wob%etf+Ep9IKUX$l*x(1 zsoQm!e9?PIgWaz!7)iW~4nSk-G+f-tG~D+C3HyWTT>wTb0>BUpWw5fNwa=u>Fj93h zMOmy?a8c(2{4n9zuQp_7ZI2BizEU>Q7JdfBdPVI4jG@=kgJkdxY%NVA9fA971$`bn z&3`og517$YDBFh~-p-<;YM$`Vwknfr#iXaZ?zkIEpDbEUylm5E&Bv>W5BL$Pf@H{t6=Q zPyjY(Awra0!0XGO6Xf*`B2I~=4$?&> zn+kki5A8&IeWe*Qc3~n1C%>L#sMvHrqCr~!bNBzO4E z0=9h!q`*RE0-^&Y^{WR7kUgmKZOWXM#l0i2{9YN%Kq+Xg-I0UQ`=BK1LddIo_(7TP z|1lSF;+)3_P=f*hSk>~zygDDPTc29(Yl%7?Fo28%v_J@)rZcSX_m_FcN@cy1VmYt? z79ijcrK$D1IbfZEXDwgk6@wP>q71tP0AdKrEIyg6%VeWC_b?^>1U6*7Lky_K{m`r5 zVVSWjWHEA;g-co-yHqZhv*7G!u)rM$4}e`v4A{Og}&aC!A&0i3KS zU#CB-n$iPi!4T#EfWcGpzsSEciv+EkP@ku+fL|CS2iu(JKY~$VBHHZ0_r*gwZ8a~9 z=wv{r@rT8eJc{$>oo0FvrCyU=ybBhxATZ-(8v#m+bgXC>UoIpCxR+(p?+*pu6Ta$X zuih6zhy8PzI#47a$LPFgAo1m!%v~7JfPkrxCG{T$wfk^6J&WUh!v+ko26$lJCU*J47HBGB)tn^A(oAESLPxRXg%&fCrW-%Mt%=g2VV>BXMjH(`xh$-ymS{KZ&dvy0r>Ap3?bRXVv7ECE|pV|&bF%YwVupQ z+y%-)ZfZCt=#{+PUdx+(5uep(8-sUb6{twTVio^riu0ZHeidQMH;V%h;U#!BYlUoj!n4P6FpX!-h3=D$Tr#B!7Zo>_=Y7o-be)cH=arAz}oW2Ye?o z>Uq8{Y5b6+9(^40W*$np5BYeUSA)~STC}>Rq3?|cn0jQXdFiWBR-6I&bOOHSw;saK zH}Ct_UpzKu(cEun0bC?NVNY!x6}^8hMpf-XmVLrT{T;7riUd97&u1l;8|~qQdY4=u ztLKE=sigu$r)%J{!eYZkGt6j#r;Z`G@eN3VCjYL@*b0AK{Ce(=Zg!Cez+k15+jcFa zthG8k*PFkCckrtC?MuEog3;#p^KDSo=tC5HL-W%}F;D;#eij+!Kc?Slfx}7m%7i$f z3^uOUEw5McofxzhP9-5y0@(E<*1DK^x*_>u2aI~V^>*Z(UlufHOw z$m|fC$GesX$kFQLKFvhblY2(qVv!BBAn%ZHz=HtDf90Vh;w?0-A73I(I{3V8OahN; z0r7*<$hy=JykMxw#x^n5J`1qqf<>sC`-`sw9LL@G)mq(7Ny^Xf30X#oFqNUp%7(4noN=FO;a+K5%YJyO3H|Px{QXY!#2Uk#+An7RN-qV9AWq5z=qa#hy zzrF*!6#{O;cyE+u7+hiSt;Rxg)*BGbOdXp1Uanm($eI>&er zZo6;5-FdmuezL4-AoiS{o^%}o%ZgwrW^D-_0P)w_n}Qq#{=8(KviSr-`>?TS^7@4x zuex%UJ#nk@`hViOi{Gn{d*9)c#GiqnT%7KgTbcS(9nyvCgMp^O+4_gshWh&}RIe;M zBQS$1Ny@`Bd;L$_21la8>c#$_DEGUZl|*C!e^Q!?F`tO!Ay?GMk=|1VrABCRimT)H ztMDZYojV^6JGyss@VPA>^xIF+s+~(^E}+8)I26C9`B8JzRQ~3Pm_pWxN>PSfKR^*_ zeMhB}d|mi!<_JHJy0~KEKPM(HcqJ>C>UC}oBjpvx>s=pl#FCG>MMN?wKdXmTz%1zq z@W0B0ynq#912=XvNx8-(qN@Im+w%@@8+cD58go44W7p6WroapUL(cE`GYb#W4%dWN zGGVoLHT@Ty-;DXh{sw@8QgX&Ejey!D%_HYVsG#x&=65^tL21oHEuZvGP}*w(D!j)p zW*N33QhDPl+Fae4)YI6;I#FTQ!OMrWNvSdQyh6k3|6GfG5IMNSGTwK}rPhSX83@+G zLZlxo?hrulsW%)V1`M1t>MQ;w)bKeMm<(qg4Kqy}Ru-vvIZifDyDa7Yl#@Tec&*Inyf_rv{kmtQ2u zbIzWb-Dc0;zd0lM4;c0j81@es_752L4;c0j81@es_752L4;c0j81@es_752L4;c0j z81@es_752L4;c0j81@es_752L4;c0j81@es_752L4;c0j81@es_752L4;c0j81@es z_752L4;c0j81@es_752L4;c3UFJPD`06@RVgU9%QNAt+#JRy+gAp!94o}*9#$7dfrO{&~Qiq3c-s;>6ZkkEab&=0{j&e02IE1;JY6;f#U;wf`cE2V4zQe(P)4K z6&xHCf)Wh^{}2L$0{jC4{JgyU0>B?Af1dy!A75`&UOvj(*T=`-$H&vl%hSD;K)`z( z|MJ1Olezh=5D07jk1*~SS27pRWddQv%LU_#aVB$md3+J(gmK2$li8&25eWO~HW+)1 zEt!o?>gZ^yjSa>&*b08%GBMfmey|nr0Am3+eVmp3*c4+y^1#f(6m3RgY+zuFHiMg* zo0*szhZ>O@n;HT7Mut!H$qkM4(ExByPe0@yOkd9cWCZxJ{W~FA&^y}N;HYkEY2Oaf zlmS(Y z5}Bgb0)eokt%y;=D3Zym&k+a<8uA!Lv;u`Z(*F3$5h;&Wz{ruwyxJiU_FltYqg_eek*(4;`h@qunQZkZK5@<;hv1`}F(2{ToDM>L2 z@lY{RaS1U%R7~uN2vkf=1PwriMMOe`VIsoU{)%dTaV2DqVr6L_00>iyOH(1!WK&Cv zlZ1(xNz6Fp&mYVU}|jp4>d%&*`d@cR0J z!TS2(A3y`<8~iJNbQJ#;^Npkd_q_&T0TSRiTxnRkh`W(#pywrR0@W zrJ#UHO3FiuVCAKy005p=1!}Nn-~aQQcc69xP~tc5HhjAFz^nf+zIg}zU%q*F+yVel z?G!%&kHrE`zIiuHfwuAt99t#GeECxOY5M+CDL`ssBNzjrM!1o?(O#hZgU*pynsO_& z;cmaiaE(GKs?4zIymth~n`YQObJiEd3ty>dZ zuKlgH{Nn%OruQi!mEtf|OtMe7z>@##S6D$fK>@&9{>hK7HurV>y0njFp-#$=u1fCP zj9X3Ocr*D~RVy4tB6kr~)GJI67lI#?ZiRP6q+pzyV#X{6rF{eX>8kX((K~~(6qHYc zfA8y!3Zcfs=~i@-p)lZ)!><|#a9f@JZq|dQnCjJtM~gMVX7#)2pf+pRhnY?WCAQcHb9KlSiORZ!(Jp3^ltc}3mr+x8cpgZ4`S znp+~Usd4aiV@y8Q;x3ZjV5B%@dbD^i)w`OfRPvftGJvJ|Zih|LiD!Vtv>B8Hs(ck- zXM_b6@o4Tiqt{Ns=#f|oj1khpu;lYj9aFEs5T_|=?@#ZQiq|Wf{`#YNbi?r5l072| zfOd5J)ScW7^;mxcsWW?FVQx2+Ny`{Lu;)oO(lO)K$An0r4@hYE+3#62HIul$<$#U8 zVI?o37M;Bf-~orteyt`|)Vt)3&`kZkSd-b%nlKY8nF0WTRS00SNu*tB*F&$m zVZ4;jzo&GW7&{De%THhb zlp7t3X*wgA=dOAR2org}x8=OQ{?3~A%Fpf^4!!r8ffpI{`{S&Ss^}a!kGn6y=DHX%iYayV zuViIKzpQfg`g2gWWQK!vsmeV?&4tn{drsY`#L!SjU0eG zvGR@?!F4E0A(9PH116`?ifC~-NCyQXS(9Ha5fzgU`s`u+H9g3 z<1nku?s==b`b$uZ2^<(`5EM~5s+wt)mXDiAOTfLtos|s+AGT7#ifbY(f_P&m0|c}l zz}!v&iabHALp&1iB$d;jGget}-RzFbLV$`(-mHrxfs~(0TKxu|30J$DKEE?sxierY z-r|8%Jqk!C0T!pdn}={Q^?vWa2KvdEx)eoE%&823e8Y@T+V!F@K7c_Pvev7A=N`4o z2_Bib>U5QVuoVV&f;ZON!uNLTMq@9%4XLvD`B=Ckga))g@I@QghS!gR0mpvfubZ)9 zbUSxRmt`W$0gLVzap9LGpQux{);{;E&voDJ|0^DPyL%Q5I{=Q4it4bJKk0Bea4~kl zgUad>@GNJqsTKZi5_MDWX?iNIJF#{D)UiO*8SzN$OshDP>!5Q?p4%$71b?;vFNySp zMhzHX{yH;gyey7+0mINVQexLX@+O9)@fP?Ny-FF{F>d4FZob02yG#4Wph$}^Bxv=v zK(`!yv&;I2OyzUlWpi3P9CVF0kF`cX+nMH!XpF_a^N($TBexgEBzM_>8FJJhbr8<4 z8yC}lXivzzaCj{67qB9+S_4b&m0ukDYq!;NRPJ;%L?!+L#(0}i#*rC#KqTvq$xyF-%v4@J)Sx#SA1;8= zg#npqjidQ{9~(#U6Qk=6n$W*2Pd}aEN%1(=vrhY~T0k7~4+(m%5$91plpc49@Y&&9 z+uv?-DOqHxBhFN0yJiNq)>D{CUT%lf&M~=7F250os}yAl!E0$iqUn!C4uPGe_-ShU zYC2-*tdX&7+1}GYadAJ_Tr+hmVC6}4O0taO!`4!(-J`s;=2$wvvGT)OgX`E5e{IJG6S3@CDl;*h|BqQZuI>>wpcW}Ns zfsTzR21AB3juhl+GGjkRA}D5jfrp<(_v3hD=Fe58+7Pn3z1BjGdnqJu!AGuA8;?%$ zg4m^^e81yb^Cfq+GB^*8?H3WLieVpy1jgl>wGS?#Q;GpalB0pb6Pv^WnAvu9d%?c z30h9(!T3Q9WOlvOsx}oTHhxQEi)dx>Mi$ES_}*v)cHR`oY(3a44t&zjac;Flga2FM z-{#0CR1kBtd8Vv%xXl^$pt9aX7whUweqf;a&cheBKLevHW8}jBjt`A|4=>`beVjhi z7ieCo8~bfp?|x+u6#IeQIf-rGBhx~P_LyDB3fKmzLXdV%aZ$@IQex>sUkotT#A|kt z=eQl1RJoUyDI8_=*JUhG>Mmc9^hnANmBr6il6zHAS%9-gTeMiD;3u53@cwqM$yrocZh|R-&`#_vsUv zmng6jQbYA}srI>lY}^6kDG`$r!a1rDYCt8HSe1yz31ZDseqzUoCOmu=QZ_- z%*jOsvDs6xMJaTzbaP%BbtVz)I+s0?f^4Z=yS|fR{GWdp3Y2GTELyP~d2uXWP%h~g zvZvF&Eifow*J>a+@EqdGZ-e5aQD-(BadNi_kK&m3k6jG?<3|(|F$#OSv<<-}PMv z4u46CaQL&JIT!``@XH*wtoed}*^^B9_!2&Oo3*LjYV%6l@@bx%S&&+eoSjX}oO@MS z8aPz4_4k^;{Bh&MjzcDI|G`tugHzoFk_%-cGPV+w{t_T^ZnR5k=LTk_#8kY zJqOK72Bb`zATkfzH%7~veVB-o1}jxqc>a9p?cWib(11%a$#($kA$`fc1JgFPMAfr9 zd_=yIi(VOnTaQbo9bOfwvc!imLye06JaMGlsx4c%v!_rjk9rr8MmAafqkAES}pFb@o(!A&jJnox6Y_2%Na;za@A zMuv5A(9}U`U90Vh2_>MX>DYSta|}FxC!9hCSVlo)<8pvK(9(8IRz?UB-b zHQ7q}W7PoXQ7_ng7oT!O923+yg}Lu8K+nJTpkHI(@za|fY9c$>>GAd$w?gdU2DCb6 zxRep_so6i(AizEr8lxBf)t@LKBsK(EV;);=oVp*)hA4X^;%<-F+c+!`2eds|4 ztDuSL!~NFICn#d9MUO-nLSr>4Jzm_bnPm5Gq^ZnK5b(TR3}i{~_cnRvL#7V38XD(` z-5Og3=EKz1=-v}ee1}fS7!GPXE2S1>FnF1>vjot++WISa7=_RwoV~Vo zmdLY4dh*~G`Fz{%?Z2vNqPm2Dosdqmf$ox_QR@j5iTvN#6dZ*-)bDGKC9>2@|u+{4NChI8#y$nmGzM0!TeR3B9JADbuznx=x z-KX1L@Vt?ry(bA^s;QkwpzFRIOr!t?85f15xgQD%eZ97M*VLOC$;o%#X|O)z>d}^8 z>ZC8x1nE^Wkap1W8^{$))e&>8nC}(*K#j9(OE*E+>iXYF5sh4fzjpz^1FKlHP)jSH z@5)#MEA=`l(jAY~Y_IL1t_r98bvH=FF7%EBdXoAqZ?c z@vqupbC739h1qJ2guB_&sR4ziqc=g%{-?XVSpHpjrO%A6I?1vqxi$;on%6@+`dc)! zEuvSH|Gtk{h7Qn-sYsx01K!dIS=x`iH3Iky4?bDM(cYgOt@*i3>hH2CugxlfN=#8bsv!p{ZI&@E%&K+R}O2d(}bYIm`$XQXb!?oY;2Y(gP+oh zY-?=CMB@wzwjJ|;GIaOrv#L;kLU`yOTO#8+uF{`1&sh$6$#p1~J!z>xg=U9^#O9NV z8Cek70br;p3~Usr2#=Wo-XcNDn^L>(M{U~AqnZBNUlr9(P=BAEWqHYlxnF0BcV+|l zoX8dFpE755PXE!k&$8nQ=Kpg$jN)<6d!z6lP@ti2d2GV`!GG^LQOpD*!xtC!cDH3u!6Dtv|MRsL+#xA=JRf`+q|sL}Va@0`eY? z`CG|hW#7NfU_MdGrl%qbR@N?TDW>Z#z4bU2%i-GSS;aWHuU&Ov% zCKm^%RxHx<9J;cUO+^lUAQxiDrA|I^6G;e2lWLn$H6zSUNyg?6&#_*Q91Cicy;Z1t zBvv>pdwzG)t6zU{_}%9>*0>K1k6wC-i6f91kkx0%Ian}WBxkx2b*|wt4Dy*5c{T3G|uQVtOdHIfpky)3C8G0`i@%vx)&B)x|}2>qUPN4fs)TRwa4=k5At2FK9m zPCD8o{gll3xKx1%&ujQVA*FgcaxSCVm>*T0o6H!{pGRKD%&3{?%s7|(^x7QM`!hGo zF3*VUbM&}gO8rkIxez?-Cm+ETO+1 zv>aJ}__XEHoLc19(1F|U#u>r610e%YFkF?iehu^y)A4f||!C;&mq{z@>pWQbIF$Zn^9b#6N6 z7M#2zcbVrXFe&LnYRWlr&CgzRikRA0NiFKaAy}?#4!-MMePQ~io*AP7gv*_+P$UV) zNRIHQnzFsN=7V0d{mRk;uE@9AHfSZAW=g-*HKK}Ilc~Tv$Ut0gdC-6~+3|xR?-Jv}!c95B50sq!&^oW&u#a>GS z3%^i94a#NdMj{07f zvN%T@_^R4R@*{HU;Pe<{MY8L!BwR5)UG-TbD?U zcUCG&{IPe}FF%YQDVy0@pZZlz%eT%k8Yh66o<053*%~p<&R}=6Wx=ivl~F>-K-+2j ztrm|&Ccop>=$Id^rCPS1_nbQC-fpelvb4|Ck~d_F20hOo1_bb|E$YUk3DUJKQ++3B z%$=$we;Pl^q3;=ASu2*hGk4m3;&b6-$>gOymfo6vI;bx*mI~39rZw__0e>aPf5BWd z^&pztCOkQ>?+;TvZ>hAI@9Ks1bQ19dHbU>7od!Cb2AoDPTX2)QtH9#?swzwo>$TRK zsjGSPHv4pOYz!MgDHsj)KC_S|?%!5{Hu*viv8s-2G@piTOyKs9xYsGlj~_2-r3!;X z2=ki`>Od3R|$FZPB~3zHE-bb zL%nQni$CfI;G97NGkFS#<9~C*eo)wnxYIwLdb~cJB$a&7(718b@S!d7!BVyJOW&o} zr*tk~yPCJntw7m`U z??Jmit^P!mTMen4uzXk|>%R76ogqfxbYWp_a2niw?-TP+Di4>XtU3{L4MqeIKl9a< zT!WrGt!`>`vS$(ca<82>C z<=DAP+^-gQPvOT&Z~Hzwg^`(Gp@#mT2ehf^;}sAqH{JJmnB$*Rf-5Vy*42~_y`*Tl z0(FnVOYA=zLGdtvhJVR#g$>$%eJyQ2wz-{ms$(N{Z4YY-;ZflY7a05ge?Mg03il|M zt9HA-6tW&>{Bc~4s{1$nENS07Hi()0mx5(fr~%w$={-{>kBoXb(pvIa&HZqT@D7kMHvcjIW`)v;uX|qT^elY z^$ojfWkUw8sCB_UW>AoaQO-2VYn`Urwiq+-x%ve3Q(?3;D|VaBMzc|=UT_2Q3{xrD zxGR{sZk*=+gH}{JrI(3~S4^!-KSg2lTM1`jxFH9c22(|~q93_^7I}0LQoSdqn{xzC zhtE`$;P3U&bgI0yPDT)6qwiLkw?Lg@{_5ku1>2Z2XE~J8OnkJ z_>q-m8*}iAl2NUse;W^h0uG8N?s|=8EQZ8AFN?8PP#!Q2f`woAqz!?r>kLg4g#)90 zo)$jR`%u5l+|IAza^5u?6om_}Tj~MIv@DHI5#BQvNM_Oj%r~uahKG|fku(?+${n`` zoJ8Yo)Rgd!p!a0%xa^j(xJA!ULH!DQeB~dWx(`r3nBu*k*=T)=W?9!|32sutWC3l} z;@iS7-ffDNayg_xQ>q5rO%|XSQ3{2Z4~}+7#JMFAs%(e6_zMlvQKTKGzduEu2j#hD zFgGD(L|4N_LKq1W{XkeZV&5ZkE|e>TtQj3l7{fq`la@0+QzGCTONP!pmBX0mq1!c- z3n69l+9{dHob}dH^PHD*xCA|l#92icMg^TX4!<`7^s*!Qya znxs7Xh(K}f-~!YGqD`}Q83{x-+~sNeyNWMoK>$AknZQaWxZFWRfJ*hI;clB}if4vI zUA4O@e9k7N;!fOC7U3(PcewO2M&l$0ZUDN!e7fAfSxhh^3W>89OyJWOzNd_MvtOe# zJwG;aV4uy>^(t9EIN3Ut8J)}|*-uOfAdfu=88L-00hYiKp-(hpiHj2ifTcZIT@Xbr zZK(9OW9TR73=$~@i&NNSCLgMPYfdb4o{u9b%ntGs$r%MFE*OoN5P;piu**NGT`sEIR6o^foyZrswAqdtwts7iUVm)~ zF#?25%Klpe`GIP)Y2|bN?+OP)eJq}`hm9xsh)vKs=8mJHzXM`rw&1R%W)UzR(Hz1wq;UdHD=RlomY>pn}x9{yNXVkL~q!BG=$XveoKdy&h)@j=R9PWP+MF z(h6|gp7uV+#optEFsQRurB02k#F2V}J(=rRffBF!LDv6t>5md2N@5Otwkz%TiChYJ zL4_MG;I<|9x(j?ZL%ojbWDNSXG&h-*8eLYS3LBwDqnjwOL(I?SNJm4X4R>zTL5u!R z+y+~H_&ZAMq*V&A)#%?Rc^6?mu+`t+xE)Vg1w+aVp1PB4rWL5Zh`J~0Jw&EwfFZHA z*3)UC7XHjbI~p)@*VU`{croK|p$*9GY~!lrbTUD8uIhb!eG*jX>oMZKM0K{%t>qp` z6PQp%@1=a|aN*A_7DxBU)>ocV$pu#$v-~o5!-=9C1lj4_xhhZWY{xNEMhY%Y61!K# z4+iDp>>R`_4x$=-_J4a-K`$RT=A6~${@!ly(bTm=Ryx(%SM%$(tuA=(q79BiPGpY1LfP=FmkHh^j- zt<)4wWPnNzunkpl>5nX+CC4`nLb}2hqAFg$4(4LJO@)WzMVVYrx+w!v>A#}xM$9B2 zJHgA_I;%>=5f)qoNBFjYi-$NuQT|#5BzX6S6S+_qFWY3_a}#X|>36?mqj^E?bZdJ0 z=ESpH0}VC2z2E_nO4Ko*h6*^XiBvo^K)>^#XyFV|DJMWh<0^GhIZ;Y>hKD!VcYZxZ zYQi#>rLN3)6vc!x((g=)LE@yKqSEN2^dJLx#Hu?wY2P`aw+eR!3d-H7j;z?3uY+TiuoT5C zmq)t|CCV)&X+eIqUeK0V20tb_;UN>5ps~<9Z#ygW2a8;CcDgz)8w~IG)eo(PY)<9x zmndbEoc+$M?j=?mU-P2JtMMX~9h{J6B(IUh>mh5rKU_L`T`TEU|MB*|UmB{&WVOxM z;91Vcxy28zwcY;7dq~>wQ()eOR%@qfx%T0Yui$5IFiY+TPR-~bepf(lpHca0?g@@?+ zc`|O!0;tEUof;)YC0-AdNJnUT@N-yFHo z`=+UT6EZic%tJ*@w>$OP|BsXlul+=IBO`tl(H>Ib=yF%9Z*)o{Oo0k2aaaW(g6Gc*7(h2v(|^wE;J|?N*4u!i3#?AXcSsqZ(rkY+ z1*GX;(m6;&(c5=B}NYg!_%cMh$=ZJW$|!jk;VJm+!EQBdj@`$yfOMNY8RS zSH^O+e5M=82M<4(e{JBEp>NYPL3tae%jP^VxkbQ)DhIBk)*)?b1ia624PCndYw+#j|))agvGmu=)m@;xs@?kWUip z$WpIARok@6*lYhUI>3Aj^=Gp~^-)|`wxsxQ>M=F`EOVxYcl~5g2GIRpdbpxq3Lgq~ zUaFX+DfE269eYzb8)#7DMSBQ}4mb@8S0wWq*T6TdvIui;5AG??cL;yhd1QL%Xxb~C z>zPV}F{A9GQLdiZz6i?2f<1CQk9A-!$(m{a9nb*(=JapR>w zdaBOro=Y0RtIb#R_=>~)jwibL(_=gBmltd~`fYlS8h-SNw(bw#Xnrpdqk*6*)hM22 z8V!^iv@3Cq2{I@dqMV@#C{bmP|3ZbF5AWg3R)6Gd)KHw`9*r$i>~GTmS!eUU!S(?| z-p3mov6*Hsvzd@M3$3Ut#(qyEy3;f1=hz25eDz8KC}$Ab)s|Y-F*`*|cI+?at+GLr z0tppzM#3PwXS;a0jx6zm(pd9?JG|)T@d(GxbmQtL4-J`*KD#ii8=QX)x3Id#d_u?5 z)suB>)$=Cv;cMv+p33vuCP_JW^2g^;2dg^?%cCP*5PiBPP*vdX#rn9;Ro_Zi=J(&O zGGLy&!pQIb>&eTOi{7r{gC!~=%O80KX{0?U3`{9wWk|LvNIeKU^F2<__5O7BU{^;& zEI}xQ8;e@Dj8W*HzW14^&x1w~=N!c@>numL z;150Y@b;nvuWVsb^X_OlFS0iXlbU`l>j0C`qg3e?N5w=x*UBw1 z8J8Nk!DTJ*TpHjcor6IOr3ijj?|Qx~B65A6B$2e>CHOha_m`8xMgWz0L+4KEcNdR} z!@rlPCV%!cm|xp@P+x-bdR$8lhQ~JKKO1B&;w8r7D}$=OI{BoXn}^6An2%Zojdz(| z3M0eYTL@mi_T8e^AT7F5g#A$Ns0TN&oa?DDuaunBW13xO6xUC$@?VPVR$yU;?((F0 zpv|w>1B<8o!>u*h*y+Y1a!yMI30YL`PrA?2{kU3aXv1Q%%F?Ea{jHj*kX318OaA>T7$jyTFYviQs)POJ z-Z@W&#^D&Zb3O`1F?3s=kr(;>OjYS5MH$HmVOQK-=csa7D)+p(pt`rRT-|tX`^dEAS=LcH52^F{9tOB^ z2~503Pu%;+uKJPnFd4rizdQeanMC#nBYoB#mQud``NwGY9W$2frSIYWJkUp!x)VS6 zbE_rq$EFIQK7pt4xSdmP-CLIQ%jvKBDx!5guZ7Xdx@`J9yRiE9QZ#CnA1)Iuy4IgS zF;hjr8E?MvolUSDZ}h;8xAk|Mtc-4pZhiE>*}QQ1htu#x!g9c(Vd3v?wP6|R){Qd( z=P{CCRQpUPI?z)Csc+-2=Q6ZjirV@3StCDaUxPM_2YQ3^91joa+tcY6%UokUAysO{ zjuw~prwXfCe8#qJyWPB;u*W0+G4>IU=8+Lkv5N!yD|e=^BlE9=`Hok)gbg$8 z{4O;Y#_iSHxOapy>uVidYu(J9R6JH^hheLmh}+PvKG7Iv`&a3+jf(>` zl)IT<3=TKli`~B`C!a%o|GAvss@c@rPYV}#qCPdthk_-xSjGo(v@#1}wQV{6-BsR` zr`=bsy*2Jqoloic>VT6YQIgS#S}2Y_Uf!viZVlgo7^kn?c(|`5f_jwyK!1iRE*4Az zCRpbXDp^{B$lAn7LiiSY+7;gZZr!{jS3#W{+mC6oZF}gtDHl{(L>;)@mio#l)#DH> zwRT)a@B>cdA9axXCUv#Kgbmxa_^9Slxq};(9$Xjq1v67rJXG%+L8KMV4Qle%VGPTR zKRNGhtltY(D3Z#qe=^u_+zO8(} zpj)wCGFoG(sjPKm)Zh?uRJ4@O8EFIg@-R_7qDUFqeJi*m0k`{&Aw60i7}{mVSEP9^ z^ebD}s~E<=purSTNrt*h)E32Y&ay!Cmln2uQC)0*wqV`R`qH!90z8s&Hpp+SR~|Jm z{5?``PLcUIFQjlKDs+ab{IYrT9z5H;Ywtr6>x$7pVq-8_!^K0m^f)6nn#Z=7esJT{XB!#%GGp(sCfnbnH#ha4waGrO+D4Mc0X!COCXQy`7T9!bGn3cC_?&htDk;t0|(ClA+6+3ElP@lJ-hiR*13< zS=C-u5$xbaEZU}O?I^{e5NEQh%!A5_STo}&NI^OH^r*)l+`3(MN4=Kid>q>W{xmWs z-UvDWV^oFx_k>(AU7L*-nCZIL_b?{qV#sB;ed zOjmvy%)#7y6m6&<`>@-e?4UEPEgrda&b!N=D$ID|gj8`R~IM4?e_z;N3QZ_Nce4 zE>3Pl zkZ~`*N2|apE`t*&OTLuu9;{0344j1Qy!c?in!2kvRcvfW-1Bp3K8NZTr#Nc%-uUX; zco_&+m?s^8^<@hOFm(HD>cfnofYz`iV&ogD*55qwpwfs`{UKO9b!0wrP|;u0E#b#- z8624%ACd+6fWzz-;xwM&*NV3GBD0Tn1-#Y#KCRrN8WT(5_gQiGzGuRwQU*Vk6Rz4X zp&q_$p9<1Es^&Wt{`L}>e(caS`Y3h^I>3(|JoaZJ?&mQ`LCQ3*Ke)}rxRy>g6xp!d z((*0Uhs~nk4E3>mL&aYUzQJXs(6n&aV>tD3omm|`uKI#vs=IQNVo0vPi?ORq`4rQ7 zbDi6tQmY@N*+T4Mw$uGgd9v;cf`;UmD<{=kD-hbwr^wT8S>ThoL`Z4nT z9*Moapv+U4ac}GmT-fg6=Y&h1R9xi5ii=9xdSvgHG~a=+qSd7b>W=geyfK%*cz zf<6-rk-^Z58uJ5MtcR(A3@-TA@)Z&|3tJv7n>(o;mLx<+uR@d3%u$Lxx;v%w=GP)d zarM`?`@}`6Zj3qL9(Dh|2fGiZb)LO_!+rt(7+nNbT`I84p*B5E5@*|kW*)7lC4pJ2 zl{~3-nr8)W%aH0{y)k>|V=7l~6xbA&gUcx)Y4`hs%x#jwdf&S5?b}VczVBFJhW!CE z$cN@UYr0=!a|)zi)h7{ppC#k)lcqS3_TImb21k z71WfcFV6%n`VTu}TXx@Ng?>ETl8e3+SY3cp(wpkEIed|_5*l(D`}-a z{t#5kmGeuW4u1teJ>d?+@D6LNehUfsZ$vnZL^&)+Hba89V-*Hkqr|}N%Po`@0yRfT zjp^A8($csdwbhyGr1wGormp5%Ti#7y0&1?$%@3xjnsaQ5Y3_*JnJy&vPzF^>ru7^g z(+6|lc`*D={PVn_ek!l3BPKkh*IBWGq4hw^KYlyVFJflfi|~PGQ?6nKGehft*27Ms z{+UU)a?AD_$6`T-bGv)7xsM`%`A$`~R{a&s3)`PQBVnOgN&dt24iced*I8LY`8|JD z))O@_Y2noLX0GV!clRR6yUns-afKD)LN*k3dRZQf^oqxj9FB5rqKn0W^~Jb^^w748 zXe1w$L6GBqKex<(=-7{-`Epf1R^cEP5ZLL#tRHZ-@+7f53ns}_1vxuT@R4LNlREq` zZC-&o%#V*A+%C10xs}_{|7Qi%&QvhVzG_&&L(J>my@==p#;bHyZr6Zbla=M!den{9 zKu=A*MRE;vRnpc4*o=0frzc+deKJ}(5!DgxpkMA0)M8C_J@4uZVUqV zxgnFQw;nC=$}4igF+*SiJaYdkKunweS3G4s&PF4)!A_UwLW%gei8~;@h&0WVzx3P$ z!Q}VxJu4k#=u&<0HEJON)vXX@6d0+f+_jHnCicDp3_bANcC;rb{V1@9QMzY8!imXl z0dN8AL6KN@QX%64Ju_Dfsr^anUmfalX4>Mn0Qd0&$Oo92W$ARB^RRyMzGJeR83>bd z0V}I`e*6En00e`?PwSrHIw9^tkw>px`VmC_n=(CfUrmwR?!E`31=WBC?4ljIPMydm zt9#(EGP%`>aCLe+Fowbdp>f1A%Fi#Lr_fW64LHff)-K%{L!}JI1wUP9Y9#amE2B*coV#&y|r~xOZx?m_^zG>7YmV{ zlCUAWEbVjt_c_R|OlyTNQHN0kPeeT5zQIa*Mn~Gye6LMyXENB?xLfoR&ZpogPT}b8 z1wkZG&;wo z4iy8Z#(1;Zz3=UEJZ32)&%F^AN>2ma_msDUrU|XG&wKH54#usIADI-B1xeRj$k|KP zNAfT%BAi#$r4SDwNMXY{YNp!AybJ0W`FQMyz^)>!6IaigwAg3A^ z32|Iu$A#=4ly4S1?jqQ4|9;D;a#T|scKOpp9K#ij;N!}U!j;IUy5tWXRK2cs{|fCy z6*N8X;#NirpeNqIubilpBdE-#AEuB*#&Usf1|elFuSOK~Od=?}-V7;vqVYDKLI5h7 zhLNXFwD=OJ6k-mvkBLU&cC5uZ%pYOI?&TaS8u+gE9^65sr870!X$q?+n+1z(S>x7! z1ezCp>1E>f3M2v!cH+uquBc~3N<&Z=6-UFN^aa5W(DqSElwc#j=@mBqi#455#)=>L z48d9lD=eBwxQ_X3^7^$0|zRF(EMUn^C8a%Tayr zIW1Kg@eE<((yAI~?T;1O=rp!Ei(6oXh;T-V!@znH+oXyBkzaPIVC^Wj%tz#;S_(S5 zpGs0uR78Fmfg!Zr^Oq7Qb!!^XPMqFcEI+ARb3@`{ZX2)tX*n@K1(1{mFsXS(%vK(KFHFOt^02S1!59fvH=Nb z&ZoN4M%qa15`fWzogu-$U|uFxaS<1xIXtY7>hO=(BL0KRuW^DQ^D!!wST+}uucgh# zTVAS#G5m*f?}Ub%3YR$FJ5PP^I@DR+g{qcVPX(X}%=qs}FuO=OHE#U$riV>sa_j_&vw%S$ zHMH6DEn@wzq@_+pP7#7y97IVGMTcY`RmV67-A0%nSBQgNrZ%Rm<_{+!Ih1s5z>n_!lCE$+jX zEjpgm$PJwpTw#>z8qpIUBi)=T5fHTZHj{VFv_POW9}syDV^RTJy1#nTg@n^*N|)LU zfH6f92(K{x!z_J-j_)NAV)rIYkuJa)8bCJ`#NEB?b+%1RifG;-e%!M~MHB#N1&+T3;J3*^`n$R}e4d~mMezXGc%sTMJ|1@wF+euI$5HvN9H2Hycwk!*$cj!irF>hk~^eUlfae}}6?z@8N z8$wd`I;232^)>c~1&p{8XCaKq*d5(*HSr&veb7m8FpQT5&V0T;0gq`gWG?&h{UT9Q zW*MGA(RdS)d%r$W1EP1yp#(MzbEF`l6Sh=qGkx-=t^0)(ptM? zY%NfW72x1Ik z-*R2*p2Glk4>GUD4F96sK-6qkqyWLAZMK6(yId8dRGoUFCG;wJXtScGGc}yNqNw)E2_UIM zto66t`HtEmsObHP6VOQ%C-%YiH+qKzE~~zSxT)gm1VC z%S8&EoC67|Ter!8(OCAUKg`tup@hg$Vpuhkjr5!|7n*oF#nO`gaTY*&&lpSzzAt|w zECm`4v6moQ<~Mf=T-xLsg^ya&GtvO$C)gas;7o?0*~m#$ZwvpCbHu;CsTbaL7hJ^9 zP6F?SxSu`27+FqL6YbnX2}wFLUqO(Icln*#c)T00?5YF&%x=~B|7pmN2RpuYJt2K` znKac|j0xP0=ZAi;zn6195`xKgTR)j6{xINu2_09#yh_=67ftq3aW;Uog~2ccxeXBe zMGUC%ftZ1A)aScZ@85nX4J2J&e_ErTPn8;-M7zL4LNX{Np8DU~MI7{AR@Ysd2s>K? z(MgHha=Svevtg3EH~01j;&RW+`ZWI9{Nbe;S4qogGb)axt!!sOn67IZUG>Ir+(^m9!6&OHNM`acp?|;g0M4zVob+Ocp)tlcBzCmu za4W;_F3EOm7~gk=WU%knDGvp_a@Wx4;0z4wl5s#_L@cQ%OxP>F(o1&tL{1XMZ*TLr8riqcC^ z6hWE-f=EeJ2o}mCDoW8<0UIbq1cX?@0-~ZwlUPCNQl;ga9Z-^-bKd*j@BHg9omo7eTuw-~sp|dxjIx!`ZVOMHvIg+;y%n{%H0-@-EGL z?%IzSKJgBj-^&)ly!X!Mya8ShR=l`%zPzQxdV5t#{0=v)$z>RvqD<_;*&WY6p02$Y zQ2e8|QO2+lPIPN~azI&v8$1>|#7vKzR9WIUb|FdQ*Jqw#Wi(1;ZzvesJN2IXhpD|6 z&ne`d`f|&MaQxLv_3ngyJ1EBkZKvoyudKxb6`;SEJ;}$-mU7aw6KzPC`aVBoyQX|j zB}r9(dz}jR_1da^Dw0;SX?hYvRQzhl&iwXuM!?;la`$$ZC*-A6RWw^6tjWtleoM|J zE80yztR!95bg<5!XBqC}#PNK7_ctSGg9`Am{oEKNj_y5|>~(YEBEr!j2%`5h>c6U_ ze-3<`8&fc=J5Oq`Qr+2hLfE+JoS3YV@BOzML7 z^hc(mVF4UA<@!D^ti&1U@^v$4T}c@zMYSb zDL6520a8kwKujJ|9L$_}F{U7;h;DG*S!D3)o%OZ|>&UcmX#OGtnS;@jMBW|dF_CJA z=H#U+|N0X8XqkMI!^TDb5a{!|svmjkyjdJ-;Ctiqfjri3iP`VGKM$8u#>pfr`#7Jv za$%tRas@Z{e%#M;#Ro^mA!@Jdl)2Ar3yI~=?*FrK4tdv><9R%OL`yR&?FJsEDQR|T zUs|Zy<)YAXWIUp_GLwp(UZ?lE{cgPJVBs+5CX(E)vz}cmddA(pM*sKjSyGF~nM!T; ze7jRBQRN3|cEJv%T)m?Gv~oOEqqKx9c_ZSJD9yzM7~e_5~|-$jnA7}=?( z(N5)0v;L0V+yAg9wucv#K^W)Ah64i^{)ssebjb6@t2ws|LKd9aLPFvg>9KYOsjnvY zLa#(>#d<1xuqnAbxeVZ$zaYMciHiit7cBDC8=sB1>_pmy6LRCDmr-8 zB$n+oJ(zcvfP0=dpE)#t_p?zn8FbP8y>Qb{YvgHc!@a%h9LpEnSaRCelyY1Z^U@nX zBz-=HSZeR1(%8Oot@6H4Q)TUPzvtbmeXbdK{E7}@O8==u;My$FX;S&h+76xn**T11K4@=!G}q-+W$?YP&*-Lj%M9-EbER(sD| zU!Nm8@IdEmRmhqtSG(2Mol8Mjr+-Mw?|Vzide=SITEunMkn@|d!1Vr4$pxp~?dq6W z9i8VBp63q+{4Q}qSZCk;I5<$kUORbe>o}KjiuE~!8?!7^rwo-yVP z^tb7AF6o+3myVH}U7CEY6)*aR+ZnEA1=|CcwN3R-oYWg@=UY3u+iA96aowy~0v4f= zlZrVu@2#EDo1umzLIWQ0V4s+o9@@4`d7DVlQFf$$uxH}eAMy5_n$Ce~=X!R3JKKP; z!dOu=q8FoCJA&-v4qwEu)B7(Upy^R~GLs@*Iyi9f)~BQw-M7-+mbKMgiVDg3JPSE9 z=Q4x7yK2?i9se{F6n;)`y+jJ#HR+7ulT_T2Y$J8JI^;jPa-jVB;itJJzM}c+TRsMQ z%9ytvlSo8Zkxsq|nAh@MR&{IcvR^M*k-?I{q{fyJzPP~t#VSPK#pKJ|HN6OOeE)1N zcfelG{&d~WL1QE6y0zN5BlEU7;l*hhYlUj*;mMx3W0W&f_4nl&haU{w`qB5^l>Khd zXOM`z6*CD~CO*dL2Hl6O^pBOu#Ie#Ylg~Ik=E0{xdV0QN;e%G4a^<{g!D~P zYtvNOh?M5N+t5<|dI=|C;ahUlKUIdt59s;-+&Y+;?T>LyIUh>gtSsx(Bflw~c)oxAOvlRLxw~FDF%Nwe1E9~nYD>a6p;Ez@0v@y)-8W9f4nP=6)*oGcIu0dHnOv};g0>CSyYpr0AKE@Qn8S0 z`+qJy^tAJ3rDy6g$L;37&SVAlhs+P&^|J5+76#4a_0yV1UOqkZj0 z``V56wHxhgH`>>3w6EQ0U%S!1cB6gmM*G^0_O%=BYd6~04zI+$Guqb0yV1UOct^0&zILO1?MD0B@mfBN_O(OFPNRM8M*G^0_O%=B zYd6~0ZnUr6XkR;MNIu%v4r;6@^`m|5M*G^0_O%=BYd6~0ZnUo*z9EeEwHxhgH`>>3 zw6C2&UpsXKA$c#~ylMpl5s@%H(jUPU892Ur++l=Z{Vs+}_+Wn!LFlnuB#e$AmS?#l z4#?Pwvs|=R(lDM&SbY@!U*-UJ_K?@VM0aO;GTl+<{oOF`cc3%V zJ@R{>1V@H^4n2X3R*S9_Jx%aK>t}qv-v&atj zCj$&1{1f*bOy}^OVouwbnLmDJGPgN}?+kYm+u=mBxOd-z=CmW+>Bx4`Z4MUM_bnW@ z9oZhfP0YdP&70P?H*b6#!ncLf#2j4Du@Vzm=Ug4aX^4He1Hs;kVM(yEXF7=3JK8y( zwG(l$v$a2KOLVZ?W^Eg5O|rG2A(pnWRs>sXAg!gfRg48{Wn*y=5wWnaw2U$rv9z)@ z2X<^Vx3GxVDq>-7VTyAKF+O2R*t7v2Y&1399JNto(yWMMOk&p)P1YH%TYqGo$Z8{_)$otW+O_MMYema`RX_Dx%}gK?Jz9NKtz9eA#hpbcj0pVFaxH(h^}#FKQO94(^!|OAHGaX zSKIl*m#-I{wROXn9n~XgF1&hUq2^J&Bf6qG8cJ7hC~4>%(GAxT)7(^8*w9p1xJffy zCtO=h%b0m2H1r75SSwunh!#O}F+-KGSTkG;)zQ*Et0|(Tsi}2VgQ%sctF9TVK1M@b z6;abrKcxo0hNA{NTB@!VvlLZRUkW^0vQ$kiYKe%N+R`P!ils}IE{#|M|EQ_rJQAsI zYHW;X5NT{`YJjKpP0hceeu%WSH2nZZeQR!tsY4rEzQc^)8tYDd8&lWx6?ymdN9-43 z{pXrbbw@rkJ{|ci^5YAqKAc0U{qp0-m)dX+;XCKZN70(belEAau_pW@;cE@Enov~_ zZ2D0ZUVWqr0l`LoAE{zipdYIHfz$&vAD9))a`b%_?^E@AW;yd6T2{gPR9VJ+$9#*v zDd&Cq@P_%8`38OUj`!*PE9RT9Qju3*YkPqOeVnha!b-zRL|%UH0`7`*c7J~vR>FLV z7Pk(-Dub=X%$Lj;BF~E(27y6?4aLuyFT$UR6&00z>+b$mR#X)J>}VnB>BH}JkDeYa zJo<$6nEkEp!Q-P(jyw{5(cJvIx4HSnkw@VV#U4DZtgNW0tbF<){NYjdq5LCk^hy4c zv-u*A?&m!?n@4<u~>gIz{$l}!5q9}IU`uGwi~g1|3mA!LNWbm0vkz^5tjAA*Rm2#5&$OMw4H zSm>XpM1&Z*w(1=173YjUuB-QiHk&a1a*{Lq zXL0q!BA`UF z-rUw^Zij`(x!x-d6sqbv_OV?uu@S4J{99i2I~?nM9p{lxB;#*m0gUec_YRpQYif<8 zl4o*4H?4ek#)YWV`Zii4SO>=kGiLK5ShCk(Gs$y5gqcaKI9*6uK#E{@ui_G8AOX7>z(X!m)vzd}D=SNGbl- zm||VFp+{JoM`K#pfde4D_P{B$>|7V zGKw-LTWYv3fFBon?(Q5*RO(R?+n>NA11wLCr(;x`!rq$}NU5ji+m|md8kk&{UOPl4 z3N_6XlS`wNzLgatBQkCKe`TUF1Le>6Pag)Jj;Wvz>GnEjou{L8z58qJQb-{`yXK$! zGdqhx$5@14oLmYrHW(>8mmeD9?)XLO5A+@LsO0*am-c+0jfB5F=CR;*eaqET^H%&_ z{0?1}_HB$$)&Twaq}&3Op5K>yyQd}Z!BzNqv0)5ZX>P+7T*Yv@AN6reXC?fs9=HRWhUhclki5&0t;n{xN?92@qILI>&wNXX7{K zM}#ra<#1?7w6Wr$KZt>zFF+5{7u-l6GYNV5`C9tzCn53{mnMz?Kv{>8(>x4_$iiNm zp44#9@?qnJuW$p@(2{A7q($~U{TwpaKX*hrG3sI9a1i`VP*d@uK?~Kp>qa<8{47Q$ zRCXC6rKMH5gX3;D9pwW@4ZzX3IT6X&({(FkwsY>xztqI2Nibz_EfMLA&}`XX6Zm&oPX<5xL2g+MhJ_=BJB0*ALUZ5h1U+V3+DEHY7LN1(7`fK5U^R!4h zg%&z+Ru; zo!9)zZ|V7M-hB9yDamtMKW+?+@@+D-F_pT|6>ru5ihXB3KX6lkO;?voAXl}m`ZxOA znauy}JKtc0L*AUo#k@Y+>-Je$deL3G~ z0@_VFjKoJEoUSuzJq6r{tt$Meb||U_kW~W_T3hXx7nKS8!sum7`%0nJBb=I4-zsif z?BQBInZj5OjP9Uc2f}P6B(;AGIJ&jfwj8T?DKLHw?JiG^g&*B|2U-S& zmJN>zxU#n=^Uq7W#$G*>ZAzd&;g-eHoA@VGjbtm%WUJzjv{dYu^64s_ZOfRjf<6{L|uU zy_f70Bue+=dQUriZNxI-g21qELF+Y;w4SJrUz;TOxw2QRLk{(sj9B*91vsXh9dufixuI!&(BThKt&15CurBHX%Gv7OThQ`B|Hbg1~&Hrc_rl zXEs8LcT*IAgG$*-ZN&K#6;PCXj9*5G%3_9j5>zM9%8(zXzqAC_GL#LML3_wp$86&u zfrW7PWa}ayJL3#$CY^~D;t=~M+QKdC4z==Q&UdPvbd38h%7irH3gMQ`k)b$~9)(DI z-1sC&Ys`?L%H!_mPZ#D1;Gj5@@=2La!P$G{(K9&#cqmr1CJK8MGHZsss}LbWiRJ|& z>|AcBj*FbYd}Nih?{-KtLJsj){OivS>4=i7W#@RLWl+VFH9-J8YAoB@k8nc<$!k#O zI>%l&x+bK;JBS~-DFyEXmo?8ub_6&}3#>?`ShMzjPAl&rwG1-#6{Sa9Jbc+sQt<6I z9H8V{vjZbO1#g?WE}_Ol;(E6&K@f36);As!Z+vzLP#9cA)T9MzZ4N0mpAs*?@g?FJ zITCDnWOCGab^cug%>P-mxEaQ9UzU9MdwAiIBy7U2J+fE~NL|R8?e7f*w1BggQHe9t zwn9!|SA|8-60h;3cs=uwwZIsf2_w#rkS#(hE~(TMkU7e(Ii@_~YyP}gBY+a7iDovV zUZR*LGyI6a6NK>=ea8db#V1PWi8rc_4`OTYi4rhO2x9}BDiYP+s0nbG&emoum_hLB z<7)2}9`FLaVlXD{e%_1SSs(1$=e*)~UvLU95R)zt;mF#z7u+0NsQw~NfK#fc*($+5 zQ)O-L_Km0Cj+9RqkQ~Nx($TqM2Mk&UB?F|-Y6wtdi z;FnPDDZnv0Hkskj+i}N0tbROwNsy|z0E`s!qO=}dWth#Wp9KiYn$4JtbDDKYG?$8y-;IAFfDppaK`lav>?W@7lxkLBAZwv0T<~Qu=e}e?WI{8zq|4mXs z$osgD@eL3P#pEI~-9 zDeN0Kf6r&Uc+S5^!!MVUuJY*PAxV%DGI+y>W&Z6RD3~(|ekr}m`d?q37a~f3#Wh&o zE+t6k@HwjA4gge?l`^fbrI|ODp5f?Sz2L8 z+Fp^yzu}mnEhK~T;atrTNhIN08&z;X(JZeHZ$jVa+TRP#b6>qN^yPO+!3M8nNCtlY z(L!jQKwml2Ku9k(kiceXyw4ECi+zEPa7-V)5~6__;}Tfkc5dRnD!~CXIR>*<1k}sX z+Vi5oAXWHLY1-H3cwGUQ$IhlAa#!EY6ZGC@OOO%c=KOmCAhK1_jX`286=d4gZlU)R zC8h;W;B9#|+{dSo+P~QlUB6I~WHX z(4I((f!nD_w%bk#LDh^l<{Uczo3Uzlh#zv<d%FMXbR9d zviHX{&H^gU@>})asYZ51R69_#?N+J3z6iumaA+>|HfX zO9E;7z$E(Jf?rh=>Ga}F6M1yhl1{$IHmH83ZXiLvwW)m?PZUbc;dj*ESAFDxz&2tO zNemo&V(hl_Ku7X=POMvu(!;w8iL^tJiT+yc*x>9lZgOwB*fd-ZvP+ksI1+NJOPx397x^3OO%A(W9Z)4}zkj(aa%OwFwejZyn$1 zQpB=7>bz~TLxqae7ZmQ~g-1h4F#eFT+y=QcRUxp)$rhumsodVm$vhEsP$@o$~W`*L-sLJIQyr zz+*uS*>|;}%kaMuh9xS)z1@4;Ts`to=GUfJoSW|_f){B0tBV)JEG8-S#Kk04T*xK7 z8BQ{!VH^#l=e@^+_GPDd+&?Va!*LE)4U7;!KC?B_B1qSG=CvWtOdB8Q&};_ zad^;kj5H90ZlbvZ-tCwLQJHlL-o=mh#9nvcooW~gAnXb5zCR+N4m31&B@!0co@ja4wa4?g>v*}Cyz zU+$!b#f^u@I*^I+skSu(Rl+u+GFHoc(+lot>Gw6HOa@hBtpckMWv8xy51vQc^Ui9e zZerw@jJHqYWbnZg0dslvx41u98c!!}^^}))*3XxvCD_N~q*)>ccFF8+8SGaK1`tNn zbsy8rw}0Pl$9(ZzhpZv>5(zvvNiz*q^C8`@2k#bzAwaX&-0Q?jsZwDkw z!EqxkOpyr`z9eDU9~DgOP!B2eb{J5b3ZOa3QV^#WRxijw=|A)*n^zU9ohn2l8w#a^ zc~W?a?r-V@u$V`Y3XQ@?Me9NA|PzRvZA-)DI#s>iahPXF{2{2W>X>-g1f zlggXqm7qJ8rw#6)ww3Z}A+Y3FW!fgS9)jwJ4*8Zt>eW85G-W;L-Hn;g!AoPWMrBsc z_N}sQAx`V|n>ff@^Cl?H%_mJfb39$llBi8FmhtM3st)p2fDr+}<<7>Yv#|0bam)U( zRRK~4lvW)r_E7Lo4fa1Z$1GmVGFvLG{$5OL2%0Zfx|k<*?l86c{U+R>S_xoF)&$2G z%;fm)L7%4Ly1~i@Yp9h%!2jl)p&OMfo>VZq^&q{RIhz4Ph}Pulyv|9r)tMju34&MX z>}<54zhcG|zh65y(8AbN_P=jVwy8Raze;`ytYGV@*a$iZjFSNVNp~|fc;y!vf^GbO-x0U%}_)pp(>X6)yv%+iIL>}E4@|C-Bg@fQKA=oJ_eebMA7Xf38s zICch9XU{Z3>UpjEregSL$oF-WnRo+bF$&>WEH%Q^*p-lasjSIN4<4@&CnGDej09sk z80!WgeIw~LuIG7lzeP*~A|fr_8n7VTg%_0hKt`q!am_bf&)#-L?h1pfC)1h}u>KRz z1Y0Un5leFY5Bvq7l+n@I3~U)8H8mj17)}7d->TxBnr{M&hfEY;u!XH|^XBP%{D|*J z1b2Pt?19-c90nUD>lWF6Bc34pC(d;_xday#OAwW8?X|epjUgO6G1hMLb`{j#^S;j; zJy)MP=bRApubI31%wBx-Y8ah7d9BcKAQR5fnNAU4R~aRw zJc)|^hfGv5=;aReU5b%7R3IacHkVX^hd7UU%mm~&V94;ECuhvgje3+(X(%G$FF(d3 z@(sL4*#b*tEPjrw>lVVX$mGOL<>9D()a-pV2>^-Leb@&BbYJlYNnv6afW*c6iYY90 zLxofGwDSN9MI}jj$}^7k@S+UE30%R5E$A-0xS;QcL8Y(S3^N=###*%C*S#6H)b2Qn zQ50vkzPAWWf2{hHr_+&O+e`O&o8U5k1#HW@HB*>4hu#ix2y(S3O(&$5>)q$sa>x)MH!gUb7aYX84U)7ty9Nv*HHPjdwbXarO`<)e zHOF@jPg~}LS(rb$0JM0RsJuVq#i|D%oxfT+FF-;X zw&)plOd9K3<*{*^Uan+{c1gvH!;UAj@H2~Ed)V}u64CE>pLq-c3 zv`^@~PeTA3#6-hL5H2d>HI_%_r%eyWqv(Kye`0s-_N5?l?71*t?#w75Q#2mXlUI9< zZz*Yp?A(LvI`m>OWkPDV?ZDYR4>eRH-K5l^IAW>8Yresn$JWS=BPdQ=H(`Q54;U1 z-tN6HnI#USc*vxU{Z<$=h6A@I__t`HKD{xvet}@ zJz25MPVmkC0{f+NTu-*;q5 zObsZR5t)n&3E%r$z^-k<)aM9%iI>wLcsY##FA#<}&_p~rOvt9c29bNMa}m$0Y@zhL zd&S)qx(Of!r?Et8neSU$fZxj&!_%O`@k$Gr?^dPD5QA|uAeUig0(+MJ#(8|4hjH34g?8`dwfqx+pny-7 z7XB(ma0+ZZ2uh@W3ce5Wt)~S3?7st~OHVHBJpw|cql?(`#%=0dx zfS)<|@i(fGTm?azECQM=I@!cmqJ!gmLn*DsZQsIJKByNHZDpUIPdj;eto3|a~kD@oQ6_7WX>mR^}GqZs>+SXY@)?p1$Va?Pe zH-5^)Szr~M`B%O8mk2*>k}h9Png{~(9c23Zt_p!{K9M{JG;5A~!W}{KvZsN#Ok4CP z8a50+<1*0ka)g;+DhWWQB4;k`5U5AM4-p`=8G8hC34q}$kS-#>zlK&PEd3)1MXv=;BC(<`r8$R&?2HHx?0k-6vS|kV?W({ij ze^2DJnx=g&$1MKBjA?@~d{c^m*e@d-ZAQXBOj73sN&oVm@et5_$O;2v`IqtV#{__U zLhePuAHHpdo+tDUaHiBcSPM z`bY@2aDXk`Zj(^bOHm1e?qglG_@@P@aufJ?w->tdRTJVEg#sd>%f|_3@rkp51+(@U zneYLI8_YB?n5xPDQtSY*J-(HEL=X{aFm}>o)c+JO4^uB3{Lg<&WQYh@_^%#8@T;_! z-I;VxysC$-2AkA+?>mzO2t`@F2vnv<|A~;wxByLDHe?B*nL^6a|8XiOl$P?c zvpmiVV3X&A1&nszBrQ03z%af|a5ECbXETWG=DQo){tn^&L5pMW`?3^yd-?C{_4aIMB**i% z*hH#o2BbE_JPMu(un7vXQ7FiEKPNB~yo=E4yW;V%)QG7)J`3`8SKfXXv=S=;l>c(t zw9az-RW6zP|Jbz>@EG)3zHEZUo$yd**Z6Da1!a0Y>;mhtJ9t!W1U^a;n7pEo%R2=y zL>OX#qS$H;p*T|oFrz{@Iapu@sw%j-s)T3r_@{;AVJ;Ys1ETNX%+J5#JlI#~Q7hmQ z9RDPIVO&0`ViOrnJJaLS&YJuMxEI{N4m?pAPEPi4GMXKy8G=xhpXQgRoI=LssqB~S)q1N5b!xQ2nWj(xo|M!WYaKGc+Y=TMr`tm<)j;a`fk!gA zS(3o$!xOS~Wj_vtIGpizJkzCk7>}4jLSOSk2b@?eIIXB(&Dus>r1p@SOHLi6XD{0f zmBc|HwY~M1tndgdr+I~j=^8u)i-|KHwI``kjb2E@ad-)sz&l~{V-ZF+%9OojcD>r5 z(5QLKB1l|D+tEt)|MIe_d)Am}VL(7Pfz!N6=fZfDxLJIfJFoz%hNp{ zk5{{APJkLiutIITJuP z{d;wH-yVXE*@q9{w$C^1vklP7_x=%=nB@ua2Vl)bztvEAs3xnG7wwkst&8)6VT3a4 zyd2rH*1`#iwG!}VnyAl3iQgrr*{ha>WI5y71Z9M|-nmrjHb3wk*i%4?)XSP;5C#SH zlMS1B^xmRv4pGO-6l?Gx&lTdVgx!+6z0NIqXY~afu1y@H$;Zr>{xuPd-Nf;a=)2Fg zXoFN2!P#QRE&C>1z}M449D7eYbJL#R==u`;bzdMCq?dC6yV<^>%saB7mbr=^B1Kg_ z3TE8+N5)@c$&*cXDSEe-KoybiC9Cd7MI`dRRTyLkt?Dm)DAriJxT^fS0fgLLl?Lpiu5 zpkGV&nG_SruzfGwwRo)vzzQi@@8BMp`C!kU1Bsrq!xTV4Gg0-A%e~tUxNGV~VFl=w zR}$(ab^|}-@tXEZn5HtI3c|$%(prc9F$ov(d_Lqkya!sD2!awl0Fdz_06S5pb%QnM zyCTR?5-}nD+^jad9OE#YKo7ezw}H5?W9zOQzG?JRLaVTxmxJC5qQ19d%_&H9DRJa8 z8S$P!i4(=NEatkN-V8E>Z@lAl{iFa)Y&zp$-Osyu{gN6pet(Yob~bmd7$tdLn!${* zZ4M8->nZl2>DXOR`ueQQdB%9zc9HBunMUti7YP4biyP{%GSa_$xAC7KAOsCE+g{T~ z%rJf=VGuy{Mbs*!#lSPC{7$@B)RJZ*n+2zj`(lRrnnwCb-TS7UkrX(X>rGVjz0h>= z1Hp)n9cjl>yia)Wq;V=p=~U+fGcoEv*pXwO4c~6X)Ac#hUdk0CO^-E{@`Eiq|{Kz-b(=!Yv`7v&sIeTn;P8LtLtYlyE)W&vXD3 z1MG8#DbJ*Ck+J+$l5p(%^Ww#N3=A#L{}`LF9W`;750lH;!*1F?69Hz=)!4ezW9#Zyn&CxOv_-&yq3QsbMfseF`pWrG(CMFOaZp_e0GbE@O;le8 zq1hV?qd$Gtex&Q$ulB{4hE5Aa_3;Q zt90*Y>s8}nAu0rfsmo(d0=K3ylv>p6yg!}W;jt%j%qdFIY}M}0pki&OaS`|0tuGn( zfw_Y_AO!5PM{+2_kVciY%y<<9KFS>1YDlxe#mfbtw+n8J0f&R#ia4x-d-h_uy;@g00*G@ zd%54i8)w)slw>>NKsa;Il1UA{Rd`0+8uZgz5wC%>6YuC9za_Q(=P#<-d{mYg5V)*c zD@nq=Lls^-3e-BfX-YW4m_WK?8y?{7`S{Y?1hMt9MSCNQ_Ld|nd*Y|u3~$YlvUTtW|oMo9%5EI=r4gN2lOI{2{ zEdT)SsI+RIg|Y5oQL@R)N>Y9^9GS!^5*=MRP44;;lA^hIXmG^EVQZlk9|_ZvmhN23 zxV2Vc$uFXaJ9>lpXH!V2Sg|Rj=+)_K;>fY!_O#yF+rO9Y2k-gUS-mD!Q z1hyMv;322@Rl?v4q_1%BQEa~vy&~-##=5}1SMK$+Jau1%9?If}=&zoB2?@7`=h| zh2-`$bVZKH$_KJH)l@seV_DwVy3+>z9Gc}VTLXmGRB~jWyg=RY&?ifA*6m%ySW+9cErK{6Subt zwxZeMPC=_0%FtO2fyR1WFYkpgwx<@FrPkC>Df~Y1{?1N;*|4>Y8?sqRG83)pVt#Qp zsWGI`LVISCeY!#zxxHb#x0qSN3b1QX#DTCb?Tmxk=G&w@_MDw|rjPZfY+1$1!`_#> zzW3h#d;AnU_TCAbTxUcRp40q6id>{*n~~&sU2Kkwe^0M&*Ax@IWmf=SItFP%Y_8=a zI3#h-upb@k+FoTikVrBvD@oq%!+4om{Ic%x>nPoJ1^lueLUpd)VnWBZF<#2%Ny<(+ zTX&*|vbmRoGE;Mzsn6D{G24r_+{KINqv41UeeZxP47-Vam#Ae*M85Q1a{!qiXvug~ zUiGMa+q{#H_B&m(xrwuBhO9oF< zTtDA<1cpI^ogIJv_x&oPjwlBMjPBelJF9O$e$iV7&F!c=e*c8G@v-es^zk=s!ObtU z>&K&nCd7bjO1{M6fHUi2u2zXL781KsQ*+BJCvB8$m##aadef9lKo0>6$GGRYNP+sY zm=d3JL)S=pAMtidKFpSYs;x*~Oi^(zL=fh>;AMMClzPWcN<>R9#D4+sO@^yL>*tl{ z-I|!a|Lu$XPwS9d7u)gt=}Fma8^hQc72&wefVt`3^4h#whW)VLoYYho3QLj|AQDhM zkpv2Ghwk^BSISzyk*$5R;9T4U0(uXae6KGl0LqP|y&hCMnwcD7)6tS0A{sR(#VBO( z7|6A=NN`NQNSCOakn2%G?495>z9A`g=L$OJ0Q#EW>!JbWOLI7A4lYwriIk8##x*Mi zR~gQC?GO^x?ydBCI@=PRG4LiTO5HJiF0hmxfA07FjLe02>~l80?GEjIDI{CdB#-l- z#m3I&*s8C;ZgLV~JcdOc7n|wu_UE+eN##4&QZLcHw>-U-vPVk01VL0P@m>IDaVrcb zP+sZ8>iHa0cSn`93F`o%?rO7*+d&*eF#5@7uL9g$R5_!s1+K{z&EX`ROtEprHs9Cc37KlRWuUW(cqnTqf-p-Wn;KOupE;p^75m=R^Y(ENR`>#9^|a~!St(qLCE6q7 zToyoms#f7}$*ET;yVzT$a{_%~wO%LyGn1s>yJLnZxkAop!fK_~tFiZ*4EFS$gxnJ2 z0QijAg`Z~N>a&QmVwoRz%SLJ(-FxkDe)9$Rg)jQ`aFjj~ZPS>$j~pal23KxPg@zGs z=WalPQ7K9kS5-51=z)^_lsHV9P!~O%QhpAAJ`b3J*N-?7ZP`qh&=us%6TO;Ozf*n! zrUFh#Duje5JGO&Bf0b_iM%V7U>WVHRLA|7Fl`JUg`w378rPJI6`3uHXRJk!8%pt=E zEt&Ac`YH_u*@Eh1A1#gn-HhJICI#o8OF086n7Xi`tO`R8q(&~t6tB^XBWIU-;%WIe zWG#i^DrZwe2*e(KO~tXF%{hn{RIO!!`?B1`ZaZK{y$awRuaMmeJYgN7tBAVs()wbS zJxrckPc7X7_Q2#`6YhiFCMprPb*@w=x=Q#Lm&<7A!Uew~qhs?x{G5KC#w~EEC40q1ye`L5k)xhF zl%*|&Rw1nRG4Z&wbz0ogRB2L2Ab7rc=2b(f$t4nSkM4VO3VyhcAVHZVKK=}q_Q_y4 zcEy)aIip0ou=worFyN2BiN;Xzoe!RC^00Q91vJ~WkmEtm`q*X6F@+M|;ktj-tw49P zD{j2dLpMjc@ah;@FHJT_l>S3yVp^-ieiR?ipCiw?LN^5;prF#k& z>ydaN1L}APcpZC6R0NHG~F4THG z_oISKFph5YI*w-kug|um>!;Zh7@GMVyH^D_3|+&f;of0DSJ!$x>?I3p$^#l3ELcB|e53gB&472{u0h5~amDDq(MlGk(qf{dk7$I7vujk~(*buC`<0^0*LSwZk7D?D}CO>o3^ zw^jG0*T1SUc!zV1y&p#JpAm(}j4=fOeZv$hmnkxXr)cBW;iR|#X7xhl1v!|UvTG)F z?5+#bLT1Pev>mv-0{@CXr?7e+b?oZ*X`$b&J5+}29{5ulaHoKQxxDr*Oy@Y1*TNYM z_zQ8T;bJ5L${-#lFyajFUiz|4KTetXdsB>(1iO zF^(ySfAS}1-rJDtQdE^J+ZJ*z_9ng{T_A>4c)frP^p3L!<#Bru%E2edl5ZMSffC2v^f#N0qr?QuhVW9B;i|ZUH zoo+IGH3P?qw}2v~p%fRn>J1?^{3VqFq@oD+?!X_7m?z%Y;Ym=~Oi>M^H9R8!nE_)# zHiVUt+{QzCXe}H~4A5j>RTRp$odnfBDWm?k+s@GYWW}_aM%4UIT^_hJ4dK&*HV@^V zvmrlCfAn{`p+7()5YULL5QF1~kcdR`f5e4_4oRPNP&+@I`({|9`9#*~8+&-N0KyB- z>c4>p{LK&2Lw8~RPRMuz>i*{9Q=t?q0Zcg|{vz%ZkFb+!Y7INPb(z1wml%}=n(=>s zhv!3Y!+k>T@YKuyBR^9|0se z3{(hhe>iIJlY#LIxcBR-u@G%Ofi3!E zeUTUX7y;ZAVp*mi%kZdfaj8~hC?{h#sZ{JNC!`m>AQ=Ds%R>nt^P)pTXtE~*JkDNvL*!V2mqu>U#j_CnJo*VStlel0d)&oBRL=8KyuS(?q{9U_budwQ0P=k1@Un^5Me26^~HE zbQLZ4`qEp&P=I#XuKJ_d3=c?4l$tCw5z4G4;6a5ueQ))q4`UCB;z+HbL@_uscqP{V zi-K@INQu`V(s=Fl6kZ79FBQp;A=>%)$MCNA7c)#92&Qf=f_o!>zeHe{MCrp9K1ODM zR01&5csG8c;!_vODqv=k>;fTXzJtu8UFPj^Jf9BS1!a!T#qRO(cr+ak0F0;ej-&h& zVVsk2Ogwp9_~Lsc(PQw5K~Jj3$A;t!SMi|cYLwgN7rbP%w(HF36buPv98&3MOAwUBUqLse~x|4#mnXdAzE=!gJ*zbMb|7oaa&4z-!wE*GIOT-O9U>F)I5opcF) ze{_3!*)ZAu(0M0p>N^is2vvVMLDJW$F)mI}3E1nw;Tejo^YipL@btKy*+>$PCo^Z} zLxq*tPQl(zknjU!Z|`;$LUt*bt)&m#AR@poZzFlMKhWZ2hWI{0n|G#I5H`#hNDjdm zp{mc9fXGW?x=_t)5tR27eNg|`jvOfcEKNf}kP(vsw#m6ycM37y3>Yu<^1FbjTwg3mU{uTGOEeBk4ZIt`)joqH;T zwBReSFGXlMnPv5_$Eto2_Mdtw^dH2nP1nCTcR9|5$Fm}&_5yk`NEv$;w81kqLn z-Bz9Oe5W8L)O7Hgyt~VUbbB74J#R(DNMA@E%bWlcr{;5&f@rf1dDTJLDaC)pcz6lM z4bZ5fN^M^usG&tDefYG=M-wY3t|LTe`u-F$K5y~zqW?cvq_X#E2H6PJ#X^V}(EhiD z$V9|D!;Kdv7~$jzVAlza4HYBb@Jw+en7g(`h&NmK{n6cqPqqHjZll@P#I309`|R<( zU3lvw&w(5C*u|am|J*$t-ji&h+B19THVfY_g~2U`|HaFUru_FcC4R`fpJ$=agWvqK z-M~2fFI*I`tk3j#*NaCq^DCTuzBZga`Y$sR{_SSt(Y!YJ(Yk!jMnbh?4^&L?OIZDC z&US~OuBZ3~^s+4@OW-iG|I>|3GNL2U4Hn0bVFnh#Y{}3S|B)4R;2h#pwjRH@$-iSH z!H$vS)GyR!W+ZdN|1NQZSoJjh*09(>N539Vp%yZDVoSg4o%4*F12u%`+^B29I|3{e zbH+{RK)DwVlzY23c8}NDFu!U6xKW;-MZ__2=O0Nd-W9pXY}yd5^B_F_Gu`ze>Dw0h^#`>c9qv3>4u!K3SZ zQ$fqXRtcoLz(A8a2SdK) zaVh>!w|ledoIgt;bwPTNdg2FqFL~O4E!*)&YDiniKj^fB!UQEwg5?HZKl9CxLWu5h zcLbNi)_|c{W3n9&8;?xOfScQNR>UT}-2>nIiu1jGr4yLCZOahmlwcuhw-HN%216Wf z>LQ;z*BkKTD}s?_Fg?6s)rZ)D^ucMgyyg_4U{1Ao|M#;7TtCsT-7 z2c6LIxRoLVxGv)vOlk0-5NZCuuOp`$GZ} zjPR%H_}))mpxU5|be-y%PQNyw`u9@C5dF?5sdlz=`v^4z)wA!y1nEFcWjDe=GhbQk zW7u^;r>&QAroEXP{1kNNQj3sg5=+G0Ug-z?dQkIh)lkO~zIh)@W&fOw^ttpm9^7wwEtBL3gr4@&o(tfkhrn7(qHkO5;rom2H-LF#j8 zzjk0dUe(4&MVc?T>^~QojtCM@ev(zv>H)xCfn!<0F^xi-LnG-7$>TB=eI-YBFD-_Xq3!NSCwh-Gt24Bvz>j%5YvlYV8X(ABOqTUvT5@|Y4h zj<$bhpwNBD!)%!s+%Gxi$l-%RE#Z8W(|(B4)6&IR?FvO50syk&*yf(x?R0Zw%fc85 zA?43Q<5Q9F6dO`>H$im?KhBh*nQ7muxMwa*LbQLnNeco`Gh^79Bag@4KtE(eDhnP< z#!0vhbQ$d0CxLw4xq@z*CbXg?dO(f4N7I4OL-P>oxaG4jtwEe_njy}ra(?z$014>X zdH>^JVcHEO`;L~r;DSVJ=?_69jA}(H{d{TLO94nt&1kRgau+x2s*4tMSSMYX(V;DX zB=F%Vd*(u9+t;WW{c}Z=H?NQtaAX+oiRbcrUAu$(qbDKUNygmTN)gvQfh9_Bv73)P zI{CBhQ0!*<{BS|BtGDO;xXxsDd}dbrSewjbZ+V2*6pWzOq2#rtea6+nsqAy-mAr+H zh%LnN4g|`VG;wyj;DPtP#I)aC25vWz`T?<}c;Pjl;>t~)%MKXG%Ckq9*vNuz|iPO&<+KO11zY*+qNsYj= z?s5q+8c6s9F~W)mP~pi(xkX}5ZiA0rSs>N{M~@5n_cx?h-$$w6ohGrLKi3h|lYO#9 zyAgG7s^ zkUd-Kl4Y{D*t6W0QTDOTchrr+zn_2Jd4K0U=RD`xoaa2xa~@3VR!B}q3@h2qx(+NR zA3HLl^5F^+GueiBTen6mH3rKAxFp96X7aH3HOuOO{QCrc0rFMTgn;C_U(GU)*QF?~ z$56Cp|39B|lg6@>1V0vJscq6tN@u7yM^qx{A{>Pyy|(IE~m=SAVbhkCNa75)w;&#a4+a__Wx(k<9 zWDZQ$-PNK0@a3}%EwYz3g`!~cQT+*9U)i!+BC=`NM+9cNSrb4b$0ju5^a5`sF+Km; z1N|AszKL+w>;=7xpcEu*06yD4b38<;w#aA|=9FW7)%}|-(g2BAUOdvE5|EATd;o?A}vJF7Rrbawx zMEjz4M(8%kVQ}NHIQ_skiOj5tJQ!Ag`IY~&AH{Q>=EjK!juPgVyOs}i5XT_Q>0l^+ z_jVQ59VV6xPgi6b*AK9P%cnNNb(J&X!=kn$5*oit5X1J7-!!o#!v;PD?}z4a#xudt^_q2plgLL~ zY|+~fCHTkf6~m_HvHH~lM-ZTAygu~pi7q1yO+DScj^7T$6PZTwr=V{x@e{zb-OzUm zw7!<$hHRcmRqllIqK>UWyOQ5cWiXko?9g&w=As$H?EY8DUOJ?1ZlLW?fO+ z)EY?{AzMm;c)dFfnE~aq3lBur!3DAakasH?W_D$Ha4NrAGFcz(DKogv;#=TwUd%1x zhinLD&FGD=bsJzOE2FHSyyomX5(r6N-&HJwA9*EU@)J)WT!rn?vl}799fmbA-3#E9 z_lpg0)hKHA>uyfz0AwhNML`}%emCDVY1Td}BUfDtxEhtp!ZEm)1M4*TBZU0&qi&hI z;!v>;>HVQ#sEBct<^FHL8}iKQ&xfEaf>n;>CXg+_dJ;Z8qI>pROX~d}Mhl(K*E_AcVD%BL$M9yj8fQTJraOpCqbPoo7tYwg5P{bKKWweeEzgE<5;h@i2-vQNxNU3ne91$bE3zcP zut`1#)A={>_ZJM6(FePfZp{uU2K6jF4Wd-k^{j0&LFBR!bLO)&CX-EZmqR8AM4;%vt-Vh&v98B$E{cpeggVw-E@~;T>DopklwGg&kt< zqK1&c;Kdj9LS+yZ2Zl*&AH3wJEza7KVc5Ru?zl6in#Nx*B3M?qKv7{KRbN(R(#bB>0$ucBT* z;{HpA2HGR!mulnY2Q@%+l*`M->lj>PY0|1Y2&BvjF#}xvzcwPEuJG*>3{m>>r#=qB zd!6@ne@QAWv+59`vTJ7y#(D)c$lvA6%}ddF=UJ zS?bVW;%Y8Cwhf-ReE)UP`+g);W;{#UtBO@AlwYkZ6Zi}%zwF*c7=ObF&G3(p1q4x| z!a+dvFY*`wrIvKSJh2)DwZ^4=4P<9~my_~l)!6zdK${xPHJ;gtf}WaZRe$GT6Z$<- zY_<6wFKsIGcfI^;+>pF=sp1xyZO-Y2TC0nT*2cD_Wb#);f}%y%nz7B==;#93kX6b@ z1Jb74=k3`6n%!MUM*5iOvXfe?rgdHgC4}sc_e%h$Dc@ZwJbhO#@) zbJ_c~`GFXs8)^q&_SlC+!PVUY76~&TBQ@f)_5kmF&Db7m4tB(|7$4hJ+GQi;7p?0{ z>Dl0T$r_nl`G|tWnR^^r-Tzo%r~`JCk9(!SF{o4n4($qp{alCdtbu2St*ta`zN8Jn zi^`!ambTd842F;HiCR^U%}DtL@a_-Jqo>S_ zCreIYWj*=ge?+yeY{yH{v@t(ptyl{&;AjmTi32z0A%@!D>HqA%!V_~#?XCKsP>XEG z;;U_&)Sr|?;7?c6P$NeT7X)e$%7L=4?3-&4LKyHF=0^=e(&fuj;N|~OgAl?{S8EXd zm;rhm;xGxS9bQ<;JFws=G`DrO(d1n|Pyb*L>y~8DAQYh~A9)om?XW=Qm%e}I!p&zv zsj0X(@q^b+>KNyxMIs-Wy?D-nO1t#m5!C`3zl#f>kW1KqPk&3*dH81`#}nOm_ulsS zk2hXyt|+TNpJcEvQNQ>tgVB6@Qspq5?`wLt`ZPnC9DBV-O>Y7A*%ZrvlvfLT1s-^q z=iSuqqj!@foiGhsZI6Y{=nPb9B4+g-pg-9#Cdl*!B68c3rK^mv@Ne|_oRYE^ZdMdO zUz@LIp3Y!WsqUw(>{{)N=Fm0j&ctz1XFgIIvp`LILa`UgLl6K5qFy|G!}@Z)7l?DbP$q~Y26j}WEKWjahB+NSWyO!s(2(XOu0hw)#Y3_$Pyh1!Ez2LAOfI!Zaaa+*rG|4={wx;*xw&Ta0NXp=ql{bFScAShv<8+2{MwREQ`3K+4BTRUdbt?e1oH+ z%VAf0{CvgF4@#--#|$bw3-S8BR=-iM%G0UVpqJgVbTqe{?>oJY>~>j20x$4F=78Ou z^cMQmJuXe%i|srLxtf0!^GsCWa$Y)JcU}Bm7bhd0S%OY(RoqkacG$1vRTz9e>(m2l zfeFKl$2EU@xuifzAHaO1?(a8>M3 zTXk2-K)b!vS;A9jD!`hUeLIym{A6n{LR9o~-;A{q(4PGDF54K&7qeL67qUpy5tBc)Gceyk%wEVQ@ACa?4hfg}ARH zk>o<~uoSl^;+`A1wo!k^Sg)NPj zkAk2eDO*fD?A;yh(EOmu(Hn2?ZSO{Kk|sKycO*J`;2lj|@C1*QF@;6=sZ`3AR#A-D z9;=@tt#q{b`O}ePV|Z4>1@8?OVo$u3CbrkY@T}2~1+ecF5|{eph_1fzS!;V|Pye9n lpox^P4~F`95bq&pGE=ciSzPrQTye4**b~Gkf~K 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 n&QK7kG3h{k(v_^)bVjbKlMeN=8jn)c3+BvNFg/dev/null + DBL=$((SZ * 2)) + sips -z $DBL $DBL "$PNG" --out "$ICONSET/icon_${SZ}x${SZ}@2x.png" >/dev/null +done + +iconutil -c icns "$ICONSET" -o "$ICNS" +rm -rf "$ICONSET" +echo "icns écrit : $ICNS" diff --git a/assets/make_icon.py b/assets/make_icon.py new file mode 100644 index 0000000..24f8e58 --- /dev/null +++ b/assets/make_icon.py @@ -0,0 +1,64 @@ +"""Génère assets/icon.png (1024×1024) : un micro stylisé sur un carré violet dégradé. + +Reproductible et sans dépendance externe (Pillow seulement). Lancer : python assets/make_icon.py +""" +import os + +from PIL import Image, ImageDraw + +SIZE = 1024 +BG_TOP = (130, 80, 235) # violet +BG_BOT = (85, 50, 180) # violet plus foncé (dégradé vertical) +MIC = (255, 255, 255) + + +def _rounded_gradient(size, radius): + """Carré arrondi avec dégradé vertical.""" + base = Image.new("RGBA", (size, size), (0, 0, 0, 0)) + grad = Image.new("RGBA", (size, size)) + for y in range(size): + t = y / (size - 1) + r = int(BG_TOP[0] * (1 - t) + BG_BOT[0] * t) + g = int(BG_TOP[1] * (1 - t) + BG_BOT[1] * t) + b = int(BG_TOP[2] * (1 - t) + BG_BOT[2] * t) + for x in range(size): + grad.putpixel((x, y), (r, g, b, 255)) + mask = Image.new("L", (size, size), 0) + ImageDraw.Draw(mask).rounded_rectangle([0, 0, size, size], radius=radius, fill=255) + base.paste(grad, (0, 0), mask) + return base + + +def make_icon(path): + img = _rounded_gradient(SIZE, radius=int(SIZE * 0.22)) + d = ImageDraw.Draw(img) + cx = SIZE / 2 + # corps du micro (capsule arrondie) + mic_w, mic_h = SIZE * 0.26, SIZE * 0.40 + top = SIZE * 0.20 + d.rounded_rectangle( + [cx - mic_w / 2, top, cx + mic_w / 2, top + mic_h], + radius=mic_w / 2, fill=MIC, + ) + # arceau (support en U sous le micro) + arc_r = SIZE * 0.20 + arc_cy = top + mic_h * 0.55 + d.arc( + [cx - arc_r, arc_cy - arc_r, cx + arc_r, arc_cy + arc_r], + start=20, end=160, fill=MIC, width=int(SIZE * 0.035), + ) + # pied du micro + foot_top = arc_cy + arc_r + d.line([cx, foot_top, cx, foot_top + SIZE * 0.10], fill=MIC, width=int(SIZE * 0.035)) + d.line( + [cx - SIZE * 0.10, foot_top + SIZE * 0.10, cx + SIZE * 0.10, foot_top + SIZE * 0.10], + fill=MIC, width=int(SIZE * 0.035), + ) + os.makedirs(os.path.dirname(path), exist_ok=True) + img.save(path) + print(f"icône écrite : {path}") + + +if __name__ == "__main__": + here = os.path.dirname(os.path.abspath(__file__)) + make_icon(os.path.join(here, "icon.png")) diff --git a/audio_level.py b/audio_level.py new file mode 100644 index 0000000..a22fc58 --- /dev/null +++ b/audio_level.py @@ -0,0 +1,30 @@ +"""Pont audio → animation : calcule un niveau de volume normalisé. + +Unité pure (numpy seulement) — aucune dépendance matérielle, testable sans micro. +""" +import numpy as np + + +def compute_rms(frames): + """Niveau RMS d'un bloc audio float32, normalisé et borné dans [0.0, 1.0]. + + frames : tableau numpy 1-D d'échantillons float32 (typiquement dans [-1, 1]). + Retourne 0.0 pour un bloc vide ou silencieux. + """ + if frames.size == 0: + return 0.0 + rms = float(np.sqrt(np.mean(np.square(frames, dtype="float64")))) + return min(1.0, max(0.0, rms)) + + +def shape_level(rms, gain): + """Met en forme un niveau RMS brut pour l'animation : amplifie et borne dans [0, 1]. + + Les RMS vocaux sont faibles (~0.02–0.15) ; appliqués tels quels, le cercle bouge à + peine. On applique un gain puis une courbe en racine carrée : les niveaux faibles sont + boostés (forte réactivité à voix normale) tout en restant bornés à 1.0 (pas de + saturation visuelle). gain=0 → toujours 0 (aucune réaction). + """ + boosted = max(0.0, rms) * gain + shaped = boosted ** 0.5 + return min(1.0, max(0.0, shaped)) diff --git a/build_app.sh b/build_app.sh new file mode 100755 index 0000000..472edbf --- /dev/null +++ b/build_app.sh @@ -0,0 +1,71 @@ +#!/bin/bash +# Construit Zonza.app — un LANCEUR léger (pas un bundle autonome). +# +# Pourquoi un lanceur plutôt que PyInstaller/py2app : empaqueter MLX + multiprocessing + +# hotkey globale sans compte développeur Apple se bat en permanence contre macOS (bombe à +# fork, permissions TCC réinitialisées à chaque rebuild, shaders Metal non bundlés). Le +# lanceur exécute le code via le venv du dépôt, où TOUT fonctionne de façon stable. +# +# RÈGLE DE PROPRETÉ : ce script est l'UNIQUE chemin de build. Il construit DIRECTEMENT +# dans /Applications — jamais dans /tmp ni dans le dépôt. C'est ce qui garantit qu'une +# seule entrée existe dans LaunchServices (l'historique de builds éparpillés est ce qui +# avait créé des icônes en double avec l'ancienne app Dictée). +# +# Limite : non distribuable seul (l'utilisateur doit cloner le dépôt + créer le venv). +# +# Usage : ./build_app.sh → installe directement /Applications/Zonza.app +set -euo pipefail +HERE="$(cd "$(dirname "$0")" && pwd)" +APP="/Applications/Zonza.app" +CONTENTS="$APP/Contents" +PYBIN="$HERE/.venv/bin/python3" + +[ -x "$PYBIN" ] || { echo "Erreur : venv introuvable ($PYBIN). Lance : python3 -m venv .venv && .venv/bin/pip install -r requirements.txt"; exit 1; } + +# icône (générée si absente) +[ -f "$HERE/assets/icon.icns" ] || { "$PYBIN" "$HERE/assets/make_icon.py" && "$HERE/assets/make_icns.sh"; } + +echo "→ construction du lanceur $APP …" +rm -rf "$APP" +mkdir -p "$CONTENTS/MacOS" "$CONTENTS/Resources" + +# exécutable : un script qui lance l'app via le python du venv (PWD = dépôt) +cat > "$CONTENTS/MacOS/zonza" < "$CONTENTS/Info.plist" <<'PLIST' + + + + + CFBundleNameZonza + CFBundleDisplayNameZonza + CFBundleIdentifiercloud.mrtechlab.zonza + CFBundleVersion1.0 + CFBundleShortVersionString1.0 + CFBundlePackageTypeAPPL + CFBundleExecutablezonza + CFBundleIconFileicon + LSUIElement + NSMicrophoneUsageDescription + Zonza a besoin du micro pour transcrire votre voix localement. + + +PLIST + +# enregistrement propre dans LaunchServices (remplace toute entrée précédente du même chemin) +LSREGISTER=/System/Library/Frameworks/CoreServices.framework/Versions/A/Frameworks/LaunchServices.framework/Versions/A/Support/lsregister +"$LSREGISTER" -f "$APP" 2>/dev/null || true +touch "$APP" + +echo "✓ $APP installé." +echo " Lance Zonza depuis Spotlight, accorde Surveillance de l'entrée + Accessibilité une" +echo " fois (à 'python3' ou 'Zonza' selon ce que macOS propose), c'est définitif." diff --git a/conftest.py b/conftest.py new file mode 100644 index 0000000..fd80bc4 --- /dev/null +++ b/conftest.py @@ -0,0 +1,2 @@ +# présence à la racine : garantit que pytest ajoute le dossier du projet à sys.path, +# pour que les tests importent les modules (core, audio_level) sans installation. diff --git a/controller.py b/controller.py new file mode 100644 index 0000000..f868409 --- /dev/null +++ b/controller.py @@ -0,0 +1,125 @@ +"""Chef d'orchestre : machine à états idle <-> recording. + +Branche le micro sur l'animation (niveau RMS → cercle) pendant l'enregistrement, puis +lance la transcription sur un thread de fond et colle le texte. Ne meurt jamais sur une +erreur : tout est attrapé et ramène à idle (fenêtre fermée). + +Les méthodes touchant l'UI (PulseWindow show/hide) doivent être invoquées sur le thread +principal — c'est le rôle de l'appelant (app via la hotkey, qui s'exécute déjà dans le +contexte de l'app). La transcription, elle, part sur un thread de fond. +""" +import threading + +from audio_level import compute_rms, shape_level +from core import is_long_enough +from engine import Recorder, Transcriber, inject_text, play_sound + + +class ZonzaController: + """Machine à états pilotée par toggle(). on_state : callback(str) pour l'UI (menu).""" + + def __init__(self, config, pulse_window, on_state=None, run_on_main=None): + self.config = config + self.pulse = pulse_window + self.on_state = on_state or (lambda s: None) + # run_on_main : marshale un appel sur le thread principal Cocoa (requis pour l'UI). + # Par défaut exécute directement (utile pour les tests sans boucle Cocoa). + self.run_on_main = run_on_main or (lambda fn: fn()) + self.recorder = Recorder(config["sample_rate"], on_block=self._on_audio_block) + self.transcriber = Transcriber(config["model"], config["language"]) + self.recording = False + self._lock = threading.Lock() + self._smoothed_level = 0.0 + self._safety_timer = None # arrêt auto si l'enregistrement traîne trop + + def _on_audio_block(self, block): + # thread audio : RMS brut → mise en forme (gain + courbe) → lissage attack/release + # → push vers la fenêtre (thread-safe). Le lissage évite les saccades : montée + # rapide quand on parle, retombée douce dans les silences. + target = shape_level(compute_rms(block), self.config["level_gain"]) + if target > self._smoothed_level: + coef = self.config["level_attack"] + else: + coef = self.config["level_release"] + self._smoothed_level += (target - self._smoothed_level) * coef + self.pulse.set_level(self._smoothed_level) + + def toggle(self): + with self._lock: + if not self.recording: + self._start() + else: + self._stop_and_process() + + def _start(self): + try: + self._smoothed_level = 0.0 + self.recorder.start() + self.recording = True + self.pulse.show() + play_sound("start") + self.on_state("recording") + self._arm_safety_timer() + except Exception as e: + print(f"[erreur] démarrage enregistrement : {e}") + play_sound("error") + self.recording = False + self.pulse.hide() + self.on_state("idle") + + def _arm_safety_timer(self): + # sécurité : si l'enregistrement n'est pas arrêté manuellement après max_duration_s, + # on l'arrête automatiquement → la fenêtre du cercle ne peut jamais rester figée. + self._cancel_safety_timer() + self._safety_timer = threading.Timer( + self.config["max_duration_s"], self._safety_stop + ) + self._safety_timer.daemon = True + self._safety_timer.start() + + def _cancel_safety_timer(self): + if self._safety_timer is not None: + self._safety_timer.cancel() + self._safety_timer = None + + def _safety_stop(self): + # déclenché par le timer (thread de fond) → l'arrêt touche l'UI (pulse.hide), donc + # on marshale toggle() sur le thread principal Cocoa. + print("[sécurité] durée max atteinte → arrêt auto de l'enregistrement") + self.run_on_main(self.toggle) + + def _stop_and_process(self): + self.recording = False + self._cancel_safety_timer() + try: + audio = self.recorder.stop() + self.pulse.hide() + play_sound("done") + if not is_long_enough(len(audio), self.config["sample_rate"], self.config["min_duration_s"]): + self.on_state("idle") + return + self.on_state("transcribing") + threading.Thread(target=self._transcribe_and_inject, args=(audio,), daemon=True).start() + except Exception as e: + print(f"[erreur] arrêt enregistrement : {e}") + play_sound("error") + self.pulse.hide() + self.on_state("idle") + + def _transcribe_and_inject(self, audio): + # thread de fond : transcription lente + collage + try: + text = self.transcriber.transcribe(audio) + if not text: + return + print(f"[texte] {text}") + try: + inject_text(text) + except Exception as e: + print(f"[erreur] collage échoué ({e}) — texte dans le presse-papier, fais Cmd+V") + play_sound("error") + except Exception as e: + print(f"[erreur] transcription : {e}") + play_sound("error") + finally: + self.on_state("idle") diff --git a/core.py b/core.py new file mode 100644 index 0000000..95bef4a --- /dev/null +++ b/core.py @@ -0,0 +1,46 @@ +"""Unités pures de Zonza — aucune dépendance matérielle. + +Tout ce qui est ici est testable sans micro, sans clavier, sans modèle. +""" + +import re + +CONFIG = { + "model": "mlx-community/whisper-medium", + "language": "fr", + "hotkey": "++d", # Cmd+Ctrl+D (libre : ni macOS ni VS Code ne l'utilisent) + "sounds": True, + "min_duration_s": 0.3, + "max_duration_s": 120.0, # sécurité : arrêt auto après 2 min (fenêtre jamais figée) + "sample_rate": 16000, + "fps": 30, + "circle_base_radius": 20.0, + "circle_amplitude": 30.0, + "circle_color_rgb": (0.40, 0.50, 0.95), + "level_gain": 8.0, # amplification du niveau brut pour l'animation + "level_attack": 0.6, # lissage à la montée (0–1, proche de 1 = très réactif) + "level_release": 0.15, # lissage à la descente (plus petit = retombée douce) + "wave_rings": 3, # nombre d'anneaux d'onde concentriques + "wave_speed": 0.04, # vitesse de propagation des anneaux par frame +} + + +def validate_config(config): + """Valide la config ; lève ValueError si une valeur est aberrante.""" + if config["min_duration_s"] < 0: + raise ValueError("min_duration_s doit être >= 0") + if config["sample_rate"] <= 0: + raise ValueError("sample_rate doit être > 0") + if not config["hotkey"]: + raise ValueError("hotkey ne peut pas être vide") + + +def is_long_enough(num_samples, sample_rate, min_duration_s): + """Renvoie True si l'enregistrement dépasse le seuil de durée minimal.""" + duration_s = num_samples / sample_rate + return duration_s >= min_duration_s + + +def clean_transcript(text): + """Nettoie le texte de Whisper : trim + espaces/retours multiples écrasés.""" + return re.sub(r"\s+", " ", text).strip() diff --git a/engine.py b/engine.py new file mode 100644 index 0000000..4ee46a7 --- /dev/null +++ b/engine.py @@ -0,0 +1,112 @@ +"""Moteur de dictée — capture, transcription, collage, son. + +Aucune dépendance UI : importable par le contrôleur et l'app. +""" +import subprocess +import time + +import mlx_whisper +import numpy as np +import pyperclip +import sounddevice as sd +from pynput.keyboard import Controller, Key + +from core import CONFIG, clean_transcript + +SOUNDS = { + "start": "/System/Library/Sounds/Pop.aiff", + "done": "/System/Library/Sounds/Glass.aiff", + "error": "/System/Library/Sounds/Basso.aiff", +} + +_kbd = Controller() + + +def play_sound(name): + """Joue un son système macOS sans bloquer. Silencieux si CONFIG['sounds'] est False.""" + if not CONFIG["sounds"]: + return + path = SOUNDS.get(name) + if not path: + return + try: + subprocess.Popen(["afplay", path]) + except Exception: + pass # le son ne doit jamais casser le daemon + + +def inject_text(text): + """Copie le texte dans le presse-papier puis colle avec Cmd+V dans l'app active.""" + if not text: + return + pyperclip.copy(text) + time.sleep(0.05) # laisser le presse-papier se mettre à jour + with _kbd.pressed(Key.cmd): + _kbd.press("v") + _kbd.release("v") + + +class Recorder: + """Capture le micro en mémoire (16 kHz mono float32). Start/stop par le contrôleur. + + on_block : callback optionnel appelé à chaque bloc audio avec le tableau numpy du bloc + (utilisé pour alimenter l'animation en temps réel). + """ + + def __init__(self, sample_rate, on_block=None): + self.sample_rate = sample_rate + self.on_block = on_block + self._frames = [] + self._stream = None + + def _callback(self, indata, frames, time_info, status): + block = indata.copy() + self._frames.append(block) + if self.on_block is not None: + self.on_block(block.flatten()) + + def start(self): + self._frames = [] + self._stream = sd.InputStream( + samplerate=self.sample_rate, + channels=1, + dtype="float32", + callback=self._callback, + ) + self._stream.start() + + def stop(self): + """Arrête le flux et renvoie l'audio concaténé (numpy 1-D float32).""" + if self._stream is not None: + self._stream.stop() + self._stream.close() + self._stream = None + if not self._frames: + return np.zeros(0, dtype="float32") + return np.concatenate(self._frames, axis=0).flatten() + + +class Transcriber: + """Transcrit l'audio en français avec MLX-Whisper. + + Note : mlx_whisper.transcribe charge/met en cache le modèle au premier appel. + Le premier lancement télécharge le modèle depuis Hugging Face. + """ + + def __init__(self, model, language): + self.model = model + self.language = language + + def warmup(self): + """Force le téléchargement/chargement du modèle au démarrage (1 fois).""" + silence = np.zeros(16000, dtype="float32") + self.transcribe(silence) + + def transcribe(self, audio): + """Transcrit l'audio (numpy float32) et renvoie le texte nettoyé.""" + result = mlx_whisper.transcribe( + audio, + path_or_hf_repo=self.model, + language=self.language, + ) + return clean_transcript(result.get("text", "")) diff --git a/pulse_window.py b/pulse_window.py new file mode 100644 index 0000000..69642c9 --- /dev/null +++ b/pulse_window.py @@ -0,0 +1,165 @@ +"""Fenêtre flottante translucide affichant un cercle qui pulse avec le niveau audio. + +Cocoa natif (pyobjc). Tout le dessin se fait sur le thread principal via un NSTimer. +Le niveau audio est poussé depuis un thread de fond par set_level() (écriture atomique +d'un float — sûr en CPython). +""" +import threading + +from objc import super + +from AppKit import ( + NSBackingStoreBuffered, + NSBezierPath, + NSColor, + NSScreen, + NSTimer, + NSView, + NSWindow, + NSWindowStyleMaskBorderless, +) +from Foundation import NSMakeRect, NSObject + +from core import CONFIG + +_WINDOW_SIZE = 200.0 # côté de la fenêtre carrée, en points (contient cercle + anneaux) +_BOTTOM_MARGIN = 80.0 # marge au-dessus du bas de l'écran (passe au-dessus du Dock) + + +def _oval(cx, cy, radius): + return NSBezierPath.bezierPathWithOvalInRect_( + NSMakeRect(cx - radius, cy - radius, 2 * radius, 2 * radius) + ) + + +class PulseView(NSView): + """Vue centrée : cercle plein qui pulse + anneaux d'onde qui se propagent vers + l'extérieur. Le rayon du cercle suit le niveau audio ; les anneaux avancent d'une + phase à chaque frame et leur intensité dépend du niveau (plus on parle fort, plus + les ondes sont visibles).""" + + def initWithFrame_(self, frame): + self = super().initWithFrame_(frame) + if self is None: + return None + self._level = 0.0 + self._phase = 0.0 + return self + + def setLevel_(self, level): + self._level = level + + def drawRect_(self, rect): + # fond transparent + NSColor.clearColor().set() + NSBezierPath.fillRect_(rect) + + level = self._level + base = CONFIG["circle_base_radius"] + amp = CONFIG["circle_amplitude"] + radius = base + level * amp + cx = rect.size.width / 2.0 + cy = rect.size.height / 2.0 + r, g, b = CONFIG["circle_color_rgb"] + + # anneaux d'onde : chacun part du cercle et grandit vers l'extérieur selon la + # phase ; l'opacité décroît avec la distance → effet d'ondes qui se propagent. + # la portée est bornée par l'espace dispo dans la fenêtre (rayon max + ondes ≤ moitié + # du côté) pour que les anneaux ne soient jamais coupés, quelle que soit la taille. + n_rings = CONFIG["wave_rings"] + half = min(rect.size.width, rect.size.height) / 2.0 + max_radius = base + amp # rayon du cercle au volume maximal + max_reach = max(0.0, half - max_radius - 4.0) # marge de 4px contre le bord + for i in range(n_rings): + # fraction de progression de cet anneau (décalé pour étaler les ondes) + frac = (self._phase + i / float(n_rings)) % 1.0 + ring_radius = radius + frac * max_reach + # opacité : forte près du cercle, nulle au bout, modulée par le volume + alpha = (1.0 - frac) * 0.5 * level + if alpha <= 0.01: + continue + ring = NSColor.colorWithCalibratedRed_green_blue_alpha_(r, g, b, alpha) + ring.set() + path = _oval(cx, cy, ring_radius) + path.setLineWidth_(3.0) + path.stroke() + + # halo doux autour du cercle principal + halo = NSColor.colorWithCalibratedRed_green_blue_alpha_(r, g, b, 0.20) + halo.set() + _oval(cx, cy, radius + 14).fill() + + # cercle principal plein + main = NSColor.colorWithCalibratedRed_green_blue_alpha_(r, g, b, 0.9) + main.set() + _oval(cx, cy, radius).fill() + + # fait avancer la phase des ondes pour la frame suivante + self._phase = (self._phase + CONFIG["wave_speed"]) % 1.0 + + +class PulseWindow(NSObject): + """Gère la fenêtre flottante : show(), hide(), set_level(). Thread-safe pour le niveau.""" + + def init(self): + self = super().init() + if self is None: + return None + self._level = 0.0 + self._lock = threading.Lock() + self._window = None + self._view = None + self._timer = None + return self + + def _ensure_window(self): + if self._window is not None: + return + screen = NSScreen.mainScreen().frame() + # bas-centre : centré horizontalement, posé en bas avec une marge (Cocoa : y=0 en bas) + x = (screen.size.width - _WINDOW_SIZE) / 2.0 + y = _BOTTOM_MARGIN + frame = NSMakeRect(x, y, _WINDOW_SIZE, _WINDOW_SIZE) + win = NSWindow.alloc().initWithContentRect_styleMask_backing_defer_( + frame, NSWindowStyleMaskBorderless, NSBackingStoreBuffered, False + ) + win.setOpaque_(False) + win.setBackgroundColor_(NSColor.clearColor()) + win.setLevel_(25) # au-dessus des fenêtres normales (NSStatusWindowLevel ≈ 25) + win.setIgnoresMouseEvents_(True) + win.setHasShadow_(False) + view = PulseView.alloc().initWithFrame_(NSMakeRect(0, 0, _WINDOW_SIZE, _WINDOW_SIZE)) + win.setContentView_(view) + self._window = win + self._view = view + + def set_level(self, level): + """Pousse le dernier niveau audio (appelable depuis un thread de fond).""" + with self._lock: + self._level = level + + def _tick_(self, timer): + with self._lock: + level = self._level + if self._view is not None: + self._view.setLevel_(level) + self._view.setNeedsDisplay_(True) + + def show(self): + """Affiche la fenêtre et démarre l'animation. À appeler sur le thread principal.""" + self._ensure_window() + with self._lock: + self._level = 0.0 + self._window.orderFrontRegardless() + interval = 1.0 / float(CONFIG["fps"]) + self._timer = NSTimer.scheduledTimerWithTimeInterval_target_selector_userInfo_repeats_( + interval, self, "_tick:", None, True + ) + + def hide(self): + """Arrête l'animation et masque la fenêtre. À appeler sur le thread principal.""" + if self._timer is not None: + self._timer.invalidate() + self._timer = None + if self._window is not None: + self._window.orderOut_(None) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..e8f9c81 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,8 @@ +mlx-whisper>=0.4.3 +sounddevice>=0.5.5 +numpy>=2.4 +pynput>=1.8.2 +pyperclip>=1.11 +rumps>=0.4.0 +pillow>=11 +pytest>=8.0 diff --git a/tests/test_audio_level.py b/tests/test_audio_level.py new file mode 100644 index 0000000..145e8e5 --- /dev/null +++ b/tests/test_audio_level.py @@ -0,0 +1,56 @@ +import numpy as np + +from audio_level import compute_rms, shape_level + + +def test_silence_is_zero(): + frames = np.zeros(1000, dtype="float32") + assert compute_rms(frames) == 0.0 + + +def test_empty_is_zero(): + frames = np.zeros(0, dtype="float32") + assert compute_rms(frames) == 0.0 + + +def test_full_scale_is_one(): + # signal constant à l'amplitude max → RMS normalisé proche de 1.0 + frames = np.ones(1000, dtype="float32") + assert compute_rms(frames) == 1.0 + + +def test_result_is_bounded(): + # même un signal qui dépasse [-1, 1] reste borné à 1.0 + frames = np.full(1000, 5.0, dtype="float32") + level = compute_rms(frames) + assert 0.0 <= level <= 1.0 + + +def test_louder_gives_higher_level(): + quiet = np.full(1000, 0.1, dtype="float32") + loud = np.full(1000, 0.5, dtype="float32") + assert compute_rms(loud) > compute_rms(quiet) + + +def test_shape_level_zero_stays_zero(): + assert shape_level(0.0, gain=8.0) == 0.0 + + +def test_shape_level_boosts_quiet_voice(): + # un RMS vocal typique (~0.05) doit devenir nettement perceptible après mise en forme + shaped = shape_level(0.05, gain=8.0) + assert shaped > 0.3 + + +def test_shape_level_is_bounded(): + # un niveau déjà fort reste borné à 1.0 (pas de débordement) + assert shape_level(0.9, gain=8.0) == 1.0 + + +def test_shape_level_monotonic(): + assert shape_level(0.2, gain=8.0) > shape_level(0.05, gain=8.0) + + +def test_shape_level_gain_zero_is_zero(): + # gain nul → aucune réaction + assert shape_level(0.5, gain=0.0) == 0.0 diff --git a/tests/test_core.py b/tests/test_core.py new file mode 100644 index 0000000..c0801f3 --- /dev/null +++ b/tests/test_core.py @@ -0,0 +1,61 @@ +from core import CONFIG, validate_config, is_long_enough, clean_transcript + + +def test_config_has_required_keys(): + for key in ("model", "language", "hotkey", "sounds", "min_duration_s", "sample_rate"): + assert key in CONFIG + + +def test_config_defaults(): + assert CONFIG["language"] == "fr" + assert CONFIG["min_duration_s"] == 0.3 + assert CONFIG["sample_rate"] == 16000 + + +def test_validate_config_accepts_default(): + validate_config(CONFIG) # ne doit pas lever + + +def test_validate_config_rejects_bad_duration(): + import pytest + bad = dict(CONFIG, min_duration_s=-1) + with pytest.raises(ValueError): + validate_config(bad) + + +def test_is_long_enough_true(): + # 1 seconde d'audio à 16000 Hz = 16000 échantillons + assert is_long_enough(16000, sample_rate=16000, min_duration_s=0.3) is True + + +def test_is_long_enough_false_too_short(): + # 0.1 s = 1600 échantillons < seuil 0.3 s + assert is_long_enough(1600, sample_rate=16000, min_duration_s=0.3) is False + + +def test_is_long_enough_empty(): + assert is_long_enough(0, sample_rate=16000, min_duration_s=0.3) is False + + +def test_clean_transcript_trims(): + assert clean_transcript(" bonjour ") == "bonjour" + + +def test_clean_transcript_collapses_internal_whitespace(): + assert clean_transcript("crée moi un\noutil") == "crée moi un outil" + + +def test_clean_transcript_empty_stays_empty(): + assert clean_transcript(" ") == "" + + +def test_config_has_ui_keys(): + for key in ("fps", "circle_base_radius", "circle_amplitude", "circle_color_rgb"): + assert key in CONFIG + + +def test_config_ui_defaults(): + assert CONFIG["fps"] == 30 + assert CONFIG["circle_base_radius"] > 0 + assert CONFIG["circle_amplitude"] > 0 + assert len(CONFIG["circle_color_rgb"]) == 3