From 6b755a426bdc024b0df0546618c095a1edd21c09 Mon Sep 17 00:00:00 2001 From: talksik Date: Sat, 25 Apr 2026 14:25:24 -0700 Subject: [PATCH] feat: add sound effects for hud actions Closes #174 --- js/assets/sounds/click.mp3 | Bin 0 -> 33024 bytes js/src/App.tsx | 7 +- js/src/assets.d.ts | 5 + js/src/features/settings-page.tsx | 18 ++- js/src/lib/sound-effects/engine.ts | 152 ++++++++++++++++++ .../sound-effects/sound-effects-provider.tsx | 102 ++++++++++++ js/src/lib/sound-effects/sounds.ts | 57 +++++++ js/src/lib/sound-effects/use-sound-effect.ts | 15 ++ js/src/stores/sound-effects-store.ts | 40 +++++ 9 files changed, 393 insertions(+), 3 deletions(-) create mode 100644 js/assets/sounds/click.mp3 create mode 100644 js/src/lib/sound-effects/engine.ts create mode 100644 js/src/lib/sound-effects/sound-effects-provider.tsx create mode 100644 js/src/lib/sound-effects/sounds.ts create mode 100644 js/src/lib/sound-effects/use-sound-effect.ts create mode 100644 js/src/stores/sound-effects-store.ts diff --git a/js/assets/sounds/click.mp3 b/js/assets/sounds/click.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..4fd472cafc324de51bf4302da2ca9280af9ab820 GIT binary patch literal 33024 zcmbr_^;a9u+c4nZ!Gk-+2_CG)8??B)6I_d1fd+TC;O_3;;_gL{yfyf_^)!fGP9q7GVC1#B94N3R{aW34NOb%mricq+t!ZWwANi||l>mllKWNBOy6^a~RY_dHuf?nr zpgK4q#%UT!h&W;MCm|5?a&XcCR;OWPbP=6#;c!t_SAcu?@-;|Fg){^wm^)Y*;oHeH zl&cm$LF7rX!c*>G)^%4}6;5>Y&iIqd$?J2KnR!VRRBbD(fYEF_x-7OrW3l2rR^33G zvDFE31evx=Hc%wz4kQ&Y5KnsgJT-b$wyarJ_3`EZ%9DsrsTZ;)30(h<5`7Q_X?)s~ zjR2rSaLw>lS95Zmak^2B<4xfDaPYDA4{SLphKQ_QOpEJ)%Vcu11(u(GN&}o{gcvRG z)Mcb8B#iN*EeFUMy)d1q8~2YtI*4~|&YGZ$5bv}{TIzj#^~StZ*+?hitRuS8$4|xK zclkM|_XkIH@V)1XxX+`a{hy%czX1WVepR>r<7=0f2*Z}gm(MT1zj=RTW>XQ16#vUK zbRsmZ&o9yVJA$BGh8jku0qu55 z!R*P5B(xnfFz(F?0TWG77+?W;le0Ygvsp8WpB%oYI93J9y~8ZVQNeR_ipgduAz^Rh z>fT-%Q&{xOW{DZEvytO8ReE{(Tc5I`plX$b@xPt{kS&Y5^~=v=>Q!y%?ATDbZ>7s5 z9V=m$5skzorn**!{yergXLT}!17?0D%Lh#!01QgB@i>)HzHuypKX@k)3PF}VqLiu{=WEeMH|E|r)wQs`ffEu{Azd(MAvOD%v9dbzv+7V;1=1kB|Sk;f^Sqal~4@a9SC>=-`th)tz5iU8$R)rg>QgEZV1- z|Mxr^+wy8EBN!p-cBee*g>Ih7EF{D_H)ZBJad$~XqL6n3{bn*I;~-U);xqkHe@+rz z`bo{M^=>O3vi(c>V(vLwM9IOWlulfflTUU)&fEDf)z$QKFrj{%idh<2;e*KPk=Q(` zrS~5QF9^T3)u0$F+y7aTjHU8zOSAU6LeP9kQCSC?6zy~?W#kN?nzFo)ct4m@aQs6wwh}WiPRZ1+RpK~ zAF4YVPwG<8Dzd-Sm-P2y4TT#kDR_|?{0=tY&k=Vvfiw^QD&-m*Y`Wb05_G?P{=V*0 z!;Q;=!`n6Hm(VO^N^bP1*V0m`U|e018Sz##2-R7-`-l;Ve4_;W00+*JUVGl~rR#tG zN#STIO6v8_JAkfe`>R=V0GZbZm99uQ@{b0GkN@Jo8o)M#-_DmMbX!EKpR9#gjeMa3 zb2X4dgCMQa%~kkptex-~Nx!w?9d)<+)>svMv?7#H^yG(SQ=i^QV&*KLRLpqkMO;!- z&6xKWmC>O&Cx=GHHci98-%1oQw6t@+H_e~&5**I?%(x#OuA0%3xlQEJ;GAw@VO!$X zlDgsrFRODe2dY3SG&&W%4|U85lhtZdAAZ_$uBck}LVh&$1X`A!PEV>ChFwP7Gc1YUd9P%$_nxL>$OslN&#w0Cs%SR86(EMfs{bre0VZ zyf&wwrifVZ_ubc-DaW`jQesYD@W~+~kw(^0FNtSTHcY;?(+x-Aaj8kC?r(m?+xY@& z;S79|p53b=CLqI*YI=C7*!=U2Hm65BYlcI)D1l5EJEZVj; z9Gnh|cy6_COT^*|{=L?ZX{abWNT7@9sDv0RH~F#-=e82G>f2Xax3S@We5r`O56sL9 zH9OCjnT_tS**m9=6=tJ{Oi1AC>dyK^vaxY<*v6EN#Zh#$nM)xo~s?sUT7i{ZD-5>&iLWBXdrjI-3wbS z?aDd8@@Hd%zKEH~9Lk?5GL$0d;y|KFV1VO)5$NTm2+wv8a_*oGz7*tm_3!wv8znnn zimQ%;DrS?by@((~9i|5rnV&>&H}7xuJT=kmS{;nxUEP=Y&p#m?jm}v6yTuNmn1fwm zx)-3$f?@sN_$PwL>5&%%*hyhHde~TS8{l*f^ExAZI;=7^CONgy$C-Rh!kH$mk}@7^$)$cA2eTtt#vm99Zi(bdtS`WUtXeX_X*DV2RT z$TFp6Y;&Y6aaiPvG|U5bR$g_Aj-yBrBW&#bB|S;q4aEf4PlQ(8c06}fI*Qt9hF&>o zq*W~jZ#I2&syZK8aR6sRSSU$jFArvag=ZAJn2fDJ&`gbr|C;!({5ALEQ>XQkeBGnd zJfz>YiRKOmg7Ib$VA|ZIqDg76~?dBl>a=;vJQhomGqLY<}@x`V`!iEf+RW z6mZ)VJ-pn;p6$4tlI6e1_KWf`TYkW*a3;eLjY$;9nE}jGG5n zhR7Nj2)4>2#g-T1BUvudPr53S3p+p+Q5P%iGW20hEIKoA9S}t39e6gX4c-a)DT|z) z@g98#U)@@k$yC0?jRf1ujAAgUOOffc&QvM`UE4cWD3U2efoZ&EoHtOcQt05nF6wIdrips3nWe0mx{tm6ExF9U!T(_r7xe1#T} zu%uNSLANhvP#nW9tDyNZjbQ-d7g8Y_Q&mqi24qS~6xhwi4CLG8 za)=et8|vfD4&*->Fd$EIr66mP@1FG$d%axGFrw?s^Sm+w9Vn;rWziZIym)ZM@xK*_ zLM>}-_o%m|GzJOOEF%*T@I_;3A%{TuSSzOSc13G?38*ocM+|YX&)hj*JD1}PH~0Ts zIIX@$M|l4XirLh@6k$q`Fc78VK47NxZ~qaayxmGruBYOdXaH-CRW@>r8!2fPK=!4*$bJZG1gK1&G?Nu##=b4<;HZlk2?(uOqz=E zo48Gng~Day*2$T(ZsFW$@yfI>77yje!$VtnQR6@>>s*8L=$1Y&f1OX;GRMbUsq*Y| zL&;bL=W3a@PeBr1_i?MMZH-TF+HOfZV>Z5!%85$Ei@p0UP|rp9X0{{8QYVm7u!+;e zj3Ts?v$6ewn;DaYOCJm%w5xRZ9Fq&dgimc30T%Y^e0v-Xd~kpko~fq8%Whq3NkZ51 zFg1pvScq;1zATednFTD9g(b&N^C{B=p{~9-&pmJ5YLv*T^A}l5;E_e6MS0+*MY~#s zdroS7ScaqfeN7Cj=Vj&vd-AM%8|_b*r5?uo+^}U4QzT=TU|uq+#7+2QtFO(kk@-vmRhm?##iCWgdk8v87vmhu#CgpIwO7v zOB?4u|5k7`Y|~js1yiZQnrb!cXg_T3B*Y8+i3>yp0Rh56% z$It?Ne_LA7rD?dO(QAQXYH+KEi>p|~W!&YJBM77Q$7I-5@Mz^Wv1b2li_uqdM_fBA zp7u)BdT@L(=zZblwa_K&`5to~)nAB}+aDt2L+@3yTWpEtGQ)!@$kK38_lsQ3zO=N9 zqM1?j&#mYUs;gu# za(pnDb&K30vt9+)df&b$81fulMFE8kDVL5|$d-_T-KKt;-E0)SPG8X>X`@IMOE&QG zxn?JnV`?Sy-1O zFrKk`GDF7lx`Sb)iIGtOy^mYoeQ67uLZ#6Sy}DIeh>WPp1yxMg1gSqUfNN@!>lEA6 zE-A*DMmP-mqbO949;?if7&%UgRZvK5B={B+pe(@vp`$Jg!7By_rvT&0_2uY-^>2aX z9dc;>0&oF}Fb3-ZG-%GM&9u42Ekk9}dc3(y5f#C)H5{f6mkR(%h=fvlA2m_H(G72T zC`3`i3eVPqP?5A&#CT+VkyA#IT+diz7s7oFF)7LwBGe6##OzN!t??C4xkNH$TaOhx zCc{Y&GyKIx$iPmqN+q+AugMJ*u=tQ(7uYpD1jlVCDJ*pJT98>j8>Ej=WNEW%-75&o zLyBUw*0mKdUAJ+<6f9YG%x&cG8zUeCLiAa+Uo0&ggMs@3rB|Tiiz`d0fjZcLDi>y< zg4;IOXucg|SBrV{n(*&p`1Ii`gU?g`O$A&niyWdHA1n?24P;pU^PdL5^!Jhp&Gx_L znjKI2pk_)Y=*Y|s*-*TxvMQVA71sLV?Dp^*sH{Fse1fBkLkm3^Na6wvyf$}5J3`)( zG3P{M2V((l0at{0C`8KfSZU?}@F1BWI6|W}tP(2)gOt~PnR5tE%x$Yq{aP{-(*!9j z;w@iTeAd$337UGLa45u67Lc2a$1A;77)Rib$bzF6@I8!It*8fM4Iz5zf|Yq9EAv=K zCOC(eFSE%l2yc~SpHDHxlkP(&L@FGdYD9nPNU4&b#ynPbM6y#_O1N3FeiyL}dXuof z)hpaFBBZTZW5dFcmU_=eL0NUqpUJ##E?`KFw_}V{OLDM{L5+x{dbB!)cOs5&VkTH4 z>M|i~1dGF4_7pX?*wXG={5IY{yR1I)wMDS(ezr_bZi2e^Iq69`#yW`CwGrgqR;}gg zsI+I@@_A+R^;zsw4wJHiMMP@>xK+v=B5!<`hYQUcUFkS+QOK2v5hYO6OblccI95E$ zlFE>UI)}hV3h7{smNjmwsH&L85+Xy#ng=5+3S--pMS0sZ+_mtO=on8E!j;FD<~MB?KppT_xmTC1q0xl zh+VZ`DP06L0HH31h8qGmr^hzaW-KHqB$Sj)7d`v3of6W*;+mI8#Nl_m-TeTaSMT|u z|VC6Pk$We zvZH-y;yTskntuR)5Zw!I``pC$_|=h2B(zk%VPrZ&=Eal+Ze47X?X zwY#aFh>!)&rx`jq*BTGPXBD^47YL%GOV#JYbd>KPK&eVn)P5EpB^3y{kh)skVrP)P zu@O2C1tRg&LJP6szvjvYpNrB&o5qLO{_{@`Kr6JI&~hR*Z=G1D@roWMRq3423>a=;lYLbzpe6f}5#u1>D%_?O*KC2*OQ z;UnYyWU*;b7V8r?k>Z7>cMgwRjaCBA(V?AoSgN5 z%ueoM!4Rp&gN(K+-%FphtLKFUx(t=KC=F~@_3GSi=k;icY9 zNlViszVUjflnERTaF>g!t!k&BIB9hC=L1B>fiZoGF@e(4)Z|(6K}Y@AS$eIN9S;L+i|MluM zm4nawCI8jGqQ~XsD;0ajZkdsN+n1f<*&^rrV%(rRbAZCIWZI`l)Bwa<9L!aN4%AO) zi3LD|9#JBZlcR%ycsK&`i?%{3A=g~25rV?>A9JWFU%DN}UzEt-FepznPB}?}EYAsY zR`hIWdU$51r`1t`5LC`$E6mRA(v|RLzR9~3r@-zs6~gfe#=<*77GJh99Q~v*1-h{` z<#zH3s?RRjy`Cd}o-D*J>dVxVhh=(c0Kxz?^ip{;&KRjru0DLKl^J?s$@u!SU zoJ1?tre+5!;gJ64pAwGxps94OEZ|#+S*7B^iA(gX#8un4um@)=^BPX5rKhk@Hzv}` zuzUiWA17wP;~O}*x@~agR~IZoBQ#Yd*hQWTrWE#m!qlz$>?OWec0k)Cr;46`XB1C= zrH>|o^e~0SB6FN;-maU7f$Q%J6@G5$g%Nc4v~#X*2q|Y{rJ~doyY3~?ytMLD$1^CU zlpry3;<6?M#K1U3grNFvV5|onUQNo)xs#YQ?}`xjKU!7==Ah_RsltW zVZL(=bs24HWVZ#1cq)sM0@z`v!w)9956Q!(A*$^su9Y$~mx-*}bxwtn^Zp+{obx*K zIqKajzjd_XT@vMe8QNZTxDWX*ZU5EerRUn~=E`056?HpTarY87#<GoV>H2BqXopMLXTyxCU#5KJhbmSDO4G_wr}VfC%cL(lY8}+Zd+g0k6SS`5$>105g~2?>6%#s#?|_5{R4h2)P+e$ZNwrb&OFCdo65 zMEW_-pis!_0>T1FbMf$m-fYv+@aKp-Pn3ajd$OH>SoJ0R29 zXmZ^T7p#mW9D!lntk^23%{MKf)5^TGM6DlJ`%L}D>U$0jl}=PC_(hUGmvT$XDX$y$ z9b(&O>S5^|S4<%dEGAgLi$U*BMJs~#&k=Hx%A`clTT%u_rF+p=LV=nqO}dsIXq%PhbYV3&@H^rO(%F&)L_ID@cy&jH=}f;NOs-DFpWf1 zR*!IymT)vZ5_i~42rO`^icMHku;Op>7%6&mh`Dr|SKEP}CiNgr%&AK6`9`D7=#*vV zB>@XmKF?P`1|sc&1kjcrC=3O_rI7mJl;K)nn7@egN(Fyp5a;*K_xHxIFRy@Go$6s$ zL$C;fXjJ-;qjGq^0TFL`wt3RTBLy@(#6ALZ!zD26zBS`o{~PSmU@)tdFDE0xS<9)T zp{r1N`r(sEh!ze-p*yNAw1hB}NQ0^^c7*MKNPyJ{6ni)uXm?_RDJ>LjcjxB)x-Y#> z5Rb+(p3Qj%WNw?i2xZ)7ii4_S@*d!r4<|u)K}->9cd<6H63HVjJe&Os&?>^UFQg~6 zwPmE7_(|!k0@k?xYm}7Bt3VUM1I9zqsf3e3n%5#aZf8Fe^n;RDYIuixn9`$*%ukO8 z+K5mqr|Ckf5unO@$#6MJ0604A^*E`jAg+DvDqgxnoUgiDx6t$+8x96Ju2d*G>M)9P z;DnuKlqSSJaz+U-+XpqlYjA_ z80jtZW#i&t2%EKFJEc=^mwrrDTas+1?s;y&CG+S}-H~LIxZ-WjuZ9kiuR$4!7u__i zQ{Rj}er?aXzyI@a7zI0F{pzrw#Xi^7)k13eu?XUvGQ%jJ&agZ1^5|yH$D~Pam1%Zg zS|jH!CDUJoSu{`#mnsC~V`DH*2~i_^Wjh``N+$BnJG#V*sAi#WRfP% zwC*t^cgv2V)MfS>obX)%vnHw&K?--xQrty>q#6#F^ZbVEd_P|yqb8YurRtie&aH^i z@J3%bW@;4^zV#zYZvMrVQ6GbjsPia)6U7qk9cfDLGqiGfJ;_brzhDms>ea2}c0g5b9 zm^i>&N)pfljczjmE=wz18w89UPEytiMqexLb#?4Y=0rlEdch(^Ol&;H@Bpueai z*6WRUR-Z)(7P&AoBn-YE)o5W4ChVldRcssx=S*oQ?cbswnoIS z1K52WC0#%K9abb#k_9)!rdS>oKA(VL!l=i&aYjz!@s8}Vj-FV|vt_<3ZJj{y^~j)e zm1XL*XVynf)( zI!Yx!{Yee1wWFx)=QU4)1O7MvEeD`(Xv&}4Q_mLytGGNc@rZWcvDQHria#3?{Z?95*3v z)Kz(ZGX$<4vZe4Jes2gN1>1Ek19!e{a>ZZ$o_GhYjpm|~YBR4&?`Ge4A6{Zc6jE-K zc0i3~eB*TV-cgIhD5iIN%H)02jZ6$_2!RE_xAm5MqvqK#{|u@jNaf;u7lltU3*!^% zC6=WkI@wv_H z^)vdoQgb}`K@N-%mhU`JkG-;TT=8e9Y@hVBBcF!NjPbvBwo76>id<#zv2~wK)$^^ zH+=su{(k|Wq5bo(%zssgC5-FLSV>wmM||11fb~TfRy9h=cDUZavD`7KMSheOyn6bT z^l`2Ds`-(P1J*OM@B8(G4c@Yfc|)`@~?(RSSCZB(X|*2$V*v6w#>Sm+AKg!NIC-GuV-HOd+qx zr3zIS9TrZMWgf%kR8&?&Xl3eGZVa7KzKb`NefK4kApLWi1o{ZA`aa7;Vb{%sqLsVq zsJ@~-l3b}Qo7@-M-eSz|3jYSO!_1u8Ul%tUT%3~kKIQf*X$Mh%f3WL%Z!jNe%<1sQ z*Xe)LFMeLp@I9*L#AaNw?@2C7aR&d+%;+*q;Zbq`qL=_I!~Ld`B53dtpb+!MN;tL5 zI|u@`8VC!eE2li?z`>f*YX(7ocsQJYe*XtZ0T}3CMW;%>b^Z+SI`6PmwjBUb2)|1ptzkPZmFjVTN#4{x@r65hk& z2Cs%M!d4^FYyy3k)*oraqTb+D_^nx*5nGO_2CndD9~xd)7>|r>+RHKbwElc9+(a2{ z$l7Z6UNWs?6gju8*Yk#9ZnO4?WINi#Hkp=Wi2g|$3*Qx zOnEf4kOGm?v>9g8wAK$))+xnY=Fje06Fzjm%7L0dbex7tO2B{qe*nCW8AjFLv2!aHwbP*s8i~IT#l3*bz zlcGb^`}_Od=Gh?By+9ebtfQ2>Y+sE^6*T~AtadVWmV_OL$W5FA9IzG3ct=c95~Smh zdv^AfNOUc%OwY^*&C8TvBnjjjl#Ur7f03hZ&BYa>oi*6&z1dR~u6{AP$aMK|9JeCT z^1KzULvBe%^jX5U{hRAtLk_R<_rHI9q2v7L6Y%yTYX zA;X%pSv*5G^5AeN>ZR1Zn=gxQMk1SZOG)SJ2xatqVfEYAKj;k&BI$9`*s;qQ%2-ZE z16J2wl!@;z#mG%_Ti&eL?7JV-M6sHE4hR^(N7rMwWn4#dzI@L0=<6|AqBS+teUu+; z|ElILvvI|*@?Hu%L((g8AElDnJBF(DX(PT=$6zB8O};`B4g|<_&I1O?T41SEkmNgr z1Wbc1S`rCBuWkSVbigE~3NbCRCX*d&RX+JLU)$a~x41@@&5Lv9sSUN@-yt(0R;x+6 zBP~=IWj)sHI*dCk6bLPEB;>_~@wPD9o~wDNu&Sx(Ug4MPD6D)i|BIVyzTw0SZ+Xln?;uT(P8b&MPT2Q#6xpzAosc7*Vc~2 z`?eRwjqC5+6`Py88VA3>jx8O0vftr??+kuHa90QH?+6uxz7ZfR zp@}CWzbDgzL&ITkZu>~-=dbo=pZ@S9r_F8gP^!Pb^yB$-L-ZwfEbyb@MDOI)ZMyG8 zq9m&V_h#p(zt7ndII><-bRLA_9GQ=vhRbeGXXe!Ti!K6CzU0o|;UK8MU|}d`Oq?75 z52qS2jwFF2fPn^ZMuTGSEq-XS=|hpV=uXrQTrq)U<()|h-OaAz z8l#1qjRa1>F5S;G<=zmH;Ym^R^_E~^{WcRaI( zSMfoAf!_mkQBMB-{!0{Fl!D!R}{KpBXch1^XyG2y$VlEM0GA&6Rb;FoPDG_67pJi(kSbl!H z+a9;!P<-!yYs2kzs#VX}Crl2>eLM5<*P;tzHVifUncKX|t)lXE zOOW%^B8^QCy5PD?B^S%P&*%^#!v$m>@)r53T#Klc-kDH#T}jRpS~#DoOzYvhagS+{ zGZQKv&GzHc=H(z=@@2P~#%SmmcV&~ecoxOz0n;3V(S>1h_|4YnTv;i1Q4a5)84V9? zow`dce=7+DDimJEC=)`G{9O*6_+QqZ91pEXHyWvu+Po38gN`mEXIkLswPJ^b0;dNw zL0(05ibNk+UR5>CqUoU2*c`3MOVF3g9pXr`sl|t9>y-GOUg!O5LAx&R~m)|oc&vLPH^6}z3`lfff z`+r|XD_U|JP(40=G}N)8sFIA^CQL55Y9_)v_3&xPPau*yc?RhtvFyu)D(oDKqG3sQ zC4j;IWKaraL-!+`Vm45ab!`1IyQ}4X%lIH$9v1ToI56WO>+kfZd9J316z6^SFcJ%r zprGK>C^yNjDqb!2V+gUgc=V5Bu!o&mhmZ@l)gg`WftH#yyN*PaFR(Y?D8T;bAAlC+ zG3sbTJJ^NS`7izlk#4ULsX!J&4n_8u+bPYHd1jfTdkx%JEXxRFl)0S?M%c(0{T5Y@ z7%m%y<1tT{Hd|HyY_XGf2g>Q6f}cgIj=ZMdo^er`;j_`_v1v^P{Bn3h2m+vfE2PY+ z`dLcBAe;|iZVJJND{40m_fMlQJ4~CB z7r6sJ)ZP;WnHgGwu9i-pKRMA5e@Q>Q8(W9DHeWV4yyE#*W<>7Nm{PsktAOsr_xi?} zDIc{cx-sq`x>{$aN}S1hzS96%AaF$t2XXgvPKCI2Bo(VX0=?EQM2-*W2_&@nh3?#y zMD9y~goW9HQ=! zVd1G#-H^}Ilg_!FdySK|{Ef4|YHXroi@H83;;XBJ zDIHFmwL?6!&Oq-T(^~Z#9g0GjM3k7g1JV>w2ZW`f2Qwn~6Jba$AqRsX2z0KK!2u*^ z40vZYVbN%N_9E+DPwjP40~v0eMz`8DFVeeGcaqrww;&!FFx$apYmF8u%PE6uei@%_ zerYxvU1?6r+@!t3N>_M-(QLu%5wif^4{@J-h8(#0&x>OyvxnP{t+0%112WcwxXeQ@ z6U9-|OH^$Qnei2fqxVD2F~5V8!@-90(32jZvSR+3Ad8fI<=%p|!YwBu!v93^{BP>q zWTZOz=v0J)@pXc`Psw>&c4ddFVM)$1LIONXD3_4gVX6hn@ z&#rde5~I&sc?@t~75z|_R{bV0KIWf&+5H17tZ{?yEmmrRJ3hQ*1O8+%Dr#Z;wR0sq ziEULw`DxINMQZ)zG`7Q`Nlqqb&dFmqEfiNHxm}*vZ8@kEM34R#3Ms4Q$VKi zA-*ISXrYCTSLmb|%SlW?TJ**HUxY{*BIIn@vHj5(JK7R_o$ENAh9G>k^j0PD*XVXO zk%x9_k~K}OPxrgOM9vwAMynfh76}x|F;Qk%H2o*}yWjOtUL?qDKP2>2)>s+$*~MLY zy?3p>Sm&-)BIzu6(eLnQr!6o@kt_Tf&`Q5O9nfVZ7Wj1^ltMA;=t8w9UKf+LKl4FQ zw!Bn|zGv|R9-7j$U$G_~dn>>OTL|ImCShH~77iRGK~9!ZnzU$&f(FbNegjX@u;cI+ zf}P?Yv`r5eM>A-Winm3DUCx7EMYm=IEg7I}oryM8=p@ON38G2vf}`A-$aFI@iWH?d z(fAgRcZA)UOV|4^ZC%b{nHWF zv|8qY&usOOfJmEXb=s4+-DJt@4evxq2lt0pkeMkUi)iwV`!wPTXj3PXQBbV8w2_ej9RRAg^NyQc zvD4+8TnIXC^I(c-cmzIC96;HW7t)`lNxk8X6gb1A#b?Q=k}jz84lQL!t*@#gICGRb zWi26aL9X0flP(Q7 zWpa<*Ja%==XG`3D$d7iOPyNsT0suqJYoH|y=Dw%eso;TQ{K{e-fVUeP`n88n^0M(~ zuZcH%?tm}8lRM(q6597wPVZPsLyyWM&eOmUF1{8l6SHp z4w%V{?Ge7nS#lHzD=~5bGQzNON-TIkHCa14eiRp+H+z1!)FHG2EIY95ODY+_!}cLj zJ}VaZy+MfPxO1=~PoDNw3L-*E9$u+JgW)E%6xuAsU|FG%Ttr=1E2JBUbo)c%34Bf--ZFdr z`wQQuwK36lm#-=G^CBNaIJO~oa@e%;3@KWiJj30{TWGr23jbD*(Y&Jjw~nhUdBxz5 zybdj0wFkW|jfH`W zF1N`y-MmCKdcQ9AiJSK}Ujw>p_6RMMvVz=C6E{SSMtv+eBoKe@iP1*FL}~g6Ew09! zFl|m3+72yKq(&+faPZ042G)i4Ttza0GyTmdH*J~pm>lFH#^&BLQ$Q)jG1SyBG}L{I zdz?GPB5x!DJ{wR{bjiYml^9P6Oi=se6uU(M9fwYh4>mmnE$mWoKv26=8z2oMrviku ztV|Vd0baz^Uh-LJmtG*9)(t(r2P8LQ^4KzzKDRPad)KG)pyKz-q@joNFxV?x%PhCG z!m9lT)h2s-h79fgfb?9o`d2IG+sRXagGYItPUEBLBR%af1YMLNAqzod|g#L{+ch{iTw&Bn;1 zOwjewK&8t%!^f3K5iE29#>OV`w?#h@{<-75hJl#CjL&fjHnV-8gpZc6GPVKhfn<0v zu1pf2I*rC?wJyvAFPH$029-&JGYN-;g|Ki5*!)^S^}3gyi{Z|ND3f&W!msvC&ZHcF zv>%q`yT%dt$I*0=T0fd1#)_NTw+Wk=qkLU$N0n!&`s!sLv4~saZN-Q3_D?R>r!#^b zHr04kx)feP?i&2RA?7Mv%-%0CDl<*R8U4H%@2p;Nh)6CNZr>ga6bVeoR&CnHBFU)8 zj(*;0>iwt>Ik}&K?4(5O@;PugbmJN}uWpU6nvunBz?F73wW3iP~gRY|p=Pr1bBYd*t*ZO#GLk#LC$`MNPl{a5>2 z6I(cOva+0NC9z;V{N<-_FTOv`JO}=&Q}xGWWlbKAi+clltFjh53OS7`XR`!jc#${XH zd=DQ&Pf*875wsY3g(;mR@@@?2BqtH84?WzPF3b2*ri#*%Uj6RIW2e)GBDn*c`^oF2 zbAVr#0|*2-Tz?d>*E$*}s29-gX0g%xd0|tW5aS|=Nce#1RoU&TTEem1d|dxcJ%5(p zG->kFOid2|x3@y0qUc&r+uYg7zfjl|T^+vDs&#NTYxyt4;j^hZz5(YEPmN+XG2pe2 zc!9pww-bC#&}KhOef>TUTDn~ltrC`7jS}~I-5(SNcS*xdP`?-ri&pLg6Zh)VpoYeU zg&{{EA=WrVc(BVMfK7c`fMVI?XWEUVfKT?x!+T9obH%D;V6A42z<>VfkkNWQrKju# z`C9ROqn%k0x`$tIaT^z;9z=G_rb|1yO<>FiDBw;R64R$9l~+>Ca=PG*yb~)(^;vE>)vL&SPfbBIFcEzK4@_c-KPjn za~Z~-`ejIL%&*Z0^xS+wyVdqu#wp8$|WddSv7iw&V}PoKp7+% zpi{#WHcrU6uh38u5|L3gl7y@WutL`?@Dz_sllEEdre(8&k@N_#D6x`}l7tZWT*Dk7 zq7>}?Fevh?Qjg_jzBQby@-f zLUGCi!)@4X?diTz6jimIeK%suk?^_x-J3}mF8Z;&JUb)3z(3hjOL&eyaTjlKiFr>r z8_unlGon^rF5gdw2`~rr07Ln1EKy1MKq*b7&ZH6q;czKQ`m#%G09To_1Mn{OJEuWA zD1OxP*Uuz+YN$#ul}!ghWbR(_J>ddZ|}tKbgew{t@RI9^}$U-uG&o zF!#@bH;+Mb=YozG$G=KjTzF}%bXRCLwEc+DJzX0jmW}i1%s@l<>03v}>ph~q)yn&d zLl-UrDa%WE4#>RXhS17_i26)>0qW4)6sO=>7_^QrRTG>S1r1>W+yKm}k;WKpOo>8( zv+FSc7Wl4Poc6#-9=LkmPH|X9D`+YIiz24ua%O&<`G$cE=?y)=5d>Cjlo4BuG$z5C z&}QPKVbzaP5%{-U!OBW@e3@l^KlN%{#gi)M8Rk~;qM>SVKKA<4k?oK1Lz_{(w;wc8 z4E}xgP5XYxwVnIN_Dd%h8~fi4|A(BCzgHf7Us8Qf1$!8UiK~{nTgh7#71?SuxyWGF zh&H|4#9d^j{M+{u$pl+61=-*gkTq%tB^`>w+8k7HG+xpIs)QQc&r}Xlu$#8fM&h<( z6Eg@EQ+lny>1{|Y`P;vLz_>Pui5ntIaQ(mhKTRmwjMvbTvOs@(LZzQuM23#V3O??? z{QvK6dV^Y~E;ST3-NdS|#>qDquSIARjf@p}#W>LvGV@3rB^f8AxkfA|`F6LO$&WJM z@o!Vd$xh37usv^R%918}b`%`F&bl?8v^s5_nTYiNj0pB>%|CCT-?T2>gk<<{K}f6Gd@Q-{D4MWDHGg7QI0~0ZNAkz^xXq7e)*FyF&XTka5Wc zATUsJC_D__f*C9q0xe|BPXzs5+uCxNI#)KqC3QcQ$xFFoIL?u?KyuSk<`F5QR?xCj zV#^`a!W8PKms)x~QiTQNytTLJV-uB2rZciE@I2LgY=26Bg)8JdXy0bZ|9Sgb$-(b^ z?VO*;guwK)B|8teL(Y+OM&3m3rK?ZD;Q{izWp&k(-_7c=71>-IEjHb4XTPAs&Sg8T z$jp}@>;a&pV{`gJ##c2^iKD=XilYE#6G==35@6{Pr|K!`=)WqXkT%6ZDK_9fW-!n% z7Ain@IH>|9H3{AHDPv)&vk^KqwT=#>Momr88OPZ9DEJK>3K)J8F@QBP3F6WFwn3Ho z-qwat2lp2tErFSrKzfng>6fD`#WZ>}QTnhX$Gi(o>*_K`ASs1;4W@AHWz8z+tjV89*#r2A+~oia-5%F)S7POpgP&309p z0TKww@O~Hb59VsVx~cnG&pNBlsl9j2F@u+qa8l$*xpz|xb*0fLU%Jn949r;hOJ>&B z*Ctn72ehcPW z-@i{9;#gaiNtwebx_T9Ps#TIIRT>Ei`iUhUV8ofE#%HQ9PbX(NzUS9gNqIpfdRI2` zg_^cp&Po?ypfU2bCQkM*!}9v5@)_p3xNgkfXfvq_#BEhWWn*IF(%ET4OTtLh2T~%O z?McgrR-BTAMiYjN^J$QL&Rd0Xh=}5um`Fbu{#XC$L(l@eho)471R9h4{9T!I^`&O< zFTwMwec~mD9n|b_@hAD3FW8M4%-U)G$H_a%yODg>YUN*R-fDhl_%%SaeJ=6u3{g&Z zF2QQuIyJFS38O`f3gDB*^J&%a+-hXHW>#L(bWE&R2IYAjpF8p1e`_8)q_SGAm7mY; zkHv!uG6O_s3qghNxk|c$ZAETI^LEI*H+NIp#+1H&SWtu zPGxDE^=ueRMp&P6$FMD-Nb&Fp8GfwL+)(rpbZixc6*zmPsv?7-Fqt108AQvN^38a9IBJrG&G2?>Te&dh zpBDW@yEdhM!}S3H5$W%RxpN9hCkoYD+yh&fcwQFS?!B;)~lAvy#qH$TIU*wLVRq zs*iwg$_Q8)z>OO34Y230+CjzF04FoL&k?p+IEW&7p^eI+X%(oh1+uxWn*7>cF8R$` zfu*$Is*;$E1!PR>a=gAhG^UVJ$FOrIT<&hAgy)O9v`N>!yB}CY=(n5A9DICSDGhK; z>zvShyjcIceIzu{xP4vHQTi{w+v_iAV>!YprP-H#yj)8ZM3Oob{2gdJ~JBz&^VCuBwDGHB$0` zRN{4mWP2`Df>DEj7(~Mjnl@Qtw#5Gk!69I((nn%s7#T|*?my(Gvv@h$pQ?^NcK^y zdt?a|N;?0(LX!_jojS!-l7|+NkkF^;r4nfA;bS10vFrQlXmkvjC4RlBsoi@5KV9e) zA9mT&E=o*`>2_6CaYS`9N0L;Gx47~3GVfojOxd?i7Z=z7h3dlH$j~W8Kl@4%hK;N6 zV~{`68dSQVqnlx0Kts_Yp&@Rt930F&qzj2qAojpG`Z}`06EPsO-I|OD9EyTRL>PjP z&dCUF(Wpe0>k!_FDyj9j3~R#z>e`5LNAwOkyvZcVZcFrJQMPk3HZ*cIQ)v*cU{(i; zaW`vZ)E7xyh)szg!#e6TH??lnZ(YN)^(IycG{CcA;oW;anfPEz_>K5tEFl1g^k@|;3_)v#A4SP% zCF9mtDvQj-v}dmoNe)=M&=V}v&T!LqEfkUV4lpEVMYdx6BOWvM5(0zx^J_Wc+)}q+ zlVD@eA5aF&})IKHzk5e(o@d+nPI^v%1}AlUAfAyP(UD!*w82s=ly%9&JoOe$2S*F zKX)gl1Vm2|+S=Bq?NOgrnp?9gh^l4Z!%+pI_6Ud6{*V&jM37)-DSttVPEe=iKuuQN zR?{=zC^I36-ich>*ZcfVa*p0`4QTNjkYSagQ_Qpy&SIp9O;{WHp3JT*9N5NYb#Y3n zNhtm%+*VN3G{%)c7M1*U3ab&-QItN!y()C^cotE5W-y-8t!STjnYseU8ns5HhBP~v zP}5U2l`LsYLR$Z}h!M345W4T`Sm393hH%KBaKV2af_F4j8Jh?(k7&a~EX$ZbYp1e4 z?iHAWx$euppZkbn$4$k8sj>?#v|aMBF>NBEMx*2xhtW-Z{;U7)NT|>5LsO2l3sqOu z`i@aCx($oCh^E=NP;sV;@+8Z!8olnSf>rrs8oLg%tPg#Lmzsb3rT%{X8~6nIem~kj zR+L!SF|aY?jriwd?w%(#I;_Wd%f>zfl=Hc3m#Y06}p!nSB6 z#u!72U*qDZMD&(9EJdXJ#l zgh}7+;LDtL5+Wnba~k-)zozTo_iz1OTds2$_O0JM@$n=rw|n6Zv{J-=OB4AjT-7Z3 z@klkc-1n+}drgb-=O(fwd;O<@Tkj7(&6P{CnZ$sm3hxlAdpUbVH%JAIk%}6*9zb8f zGC@Z_p(E4BzIVn?vHh#S;0>xG<8BcNDYI;J5)Bb<2{R32i+?InrLt-AeIb*0qNg(- zwlNr$1&tG{ijYJxN@khC)>O_L_UG3*nrW2O`g@f^Fj|v2D4uVUp5&vl{VG7w4#uo z2@U|rG*#=Wi=t_~%%HSq*Jw3fv&g;{aYG$bsf9)MNE@n3`9}!Oj9aEp4l2?i5$D&+j zKP^bNZ1$w}AqLLyr+V}ZKBUh*H5dl+VkgdC($aU>DW+=6*8j957cU>?CE-nLdXB4C z`8S8I*cfMqREC~43Wv?M_~Kvy^zkBd>-y}8w4 z)|pN4f|viOwTV4o7R`J*4&zY5+M**=z_;eE=61=VEsyVe&fKo;!r>k7yPRyjL>^{U zQ_$>{4;_5#u+cUpnj1{c=V$MMj>|+%i*8Ac!U@Hg4o{HLmWY+{v@!{tKvohis84%& zp+Xwu?EQi|jy4p&rb@t60*2zGi9l4iX{-suQF-nR3wys=l)}nYs$h4bJ6Ab z05x0cR>H@rQJtBOoPyDyM-OTd9&BfCVwscVS6lDI`49165~(r^941sbt%7Ca9f$Hv zv-h?J1b&wg=>`rO*x#zz1r?2XK?K-8Z(HHVW-BV8k=^a~F;8rex-ebv`J#0yz<5wm ziz%oWQ98;_NZ^a25CLobFla#uS5^rTw`gm;cek6MKM2|S7YY~`)ZJ_y{s%`>9iOOD zN*1*ERf-?xZzF(B+MC^cnyAeld7$2@ejA|c?~@|>Ve{{+f5Mei|Auy-q#^2soq0i* z-`vjMiW@xe>~SN;?3e_Wre9Idl(xT>*kD**VYj?P3MnG@ zx@3v23}+Xy0hD$g^eyXj}r9h(NG(G~;dE zL=3mTR!-Cl4?Nfu1Y4`)<-uYqOMYg%X}V^Nia$25XVJZs%ORsSDy^1+3rq8kg^GTnl?3GkzUg-=C~q z?C$SA74lv`9dTd&2@Uc@4}6`pJ_|FaP1`t z;fcLW;1OZ(8N~`=r$YLIWHGEz7eOj{b8fdP*DD^e}YhHspd8R#CHh&VnOt4Zg`r+j7 z#qi%M3n!e%8at2CY8Lm1JbaPzsGZ*qJbMfT;tHoK!5pc76%_^rC66{oq9k=nWp$R@ zdS{3VY4~X6iuhy>2jy~0I*Z=Xq z6N;wc1D$dd9IS4vc67HpGsIfO;5iHp`_o-Kfhcbtx3=pj$h|zaoB8&wec#TJrrm!r zM%(~8MSGsJndzH)%q=z!&px5E%IJHY#99*YDzl(e3N~HBt`XkH|h8N+ZPwgjv)8K9__U$aM;_Llp5Gls%xzvhZQldi|Zxx<2C} zxtmjXVHUE@JlQgA3@*CNNEngvY0aowII-zojAIa+JrG6;F9jn{%bH^F-*eJL*Ph{f zY5v~)_Jk)&HQP(o&O6iggPp=SS6!yl5QGIz2dA+^&z-2c2I(4?p zZpyx(7PGKeAs_UbE=6Z6=P`9cHgsF2@r%vKoIz7BF2pm?gj~%ojYCX7ap+38j38*;vBUxwofHs8l)?t^Q+5e1|5Yi8LMv^4i-A6sSwXxe zY%h(-$zV#u=m^UvBvAI0Xdo+tbhHh;;e9?94wg82{AZb4($D7j%;&p#ALJzTZ5?sA z@A;t@{4ucRCztS#{*HfwLvL4xf41l;xq*XdxR5|g^S*|_r09xl z9mMq`4h=)AnIea!C;|4y7BBu^H@=JTkaE zs)}q}TqdUzsNnNc_-o7aSKBT3H&?%dy!S$73quLHU5-wr9e%oRge?bzBN2_6gr;0X z(;T^OuiCwwl8NNanEa?U4lBR`TPhg((!_D&Qfgi&!{NX@u>HC;y)^)Z@B9-@wC!}u zLwYMkBj=CHti|zSW9(b4+>DA;QQm3hD*j-m$edh9gSGVY7Kh8nceCNR1T;m>Y-|HDKyIUgvAI%KZQ%`d<; zyv>kq9hE0XN{*HS??Um>hVU-G7u<9k_iU_!$phQk~(dJK%W>;fI4~`@wVI736}r3NG4bf2L+#iz zxjOlgcFHui@9$QHr_|ICL}cP=lV^GKv1j4yC8KlIcK>L!Q5OGcxpwVOFY(T;Ril=3 z?lsR7?V$FKjQeQR(p@!iewgKM%Smt1zlR00J?7yv z33i}le@FhACGI9d+?X--<1r}#2}wB^fF~+Pgc>W$zthbq^8@U`dW|{{k37ZFLM5!w zo&19pw~wL}uLAhZXiH^0v@z?%N}h$MM?cu6us|5s8)Ix<{`1h|`e(+r2s7BSJfl-R ziNRaH|9O4uJ8ZM79nZhAH;CLwrh*W<@iXf|KwQw_$*Awvjyp`z*WAh(@ z$D>T{gD5|c(o-mNoR!}w(~V|MxRA~UO9_8^_w=<}gXZ7I&iRMd_v_!e8g7d8^bv{E z1|Q|IXS2Se6!Cx$&=ozq^d?(=4jp!E8H-q4#R1c5V6om48!Ox=ttk>FH6odNJ7wOB zjx!pR?Q?q*Nxb~T*t}`kgj{TT>_$X`)Sh#R!raBTHpARB#-E84F`;}_r9a;119qBB zJW|0OX_kLQ=YE(CHh~ho0fZb84kJVkJ_5)dl?dGwQuN_ahbF8FI`^KB=7^TX~4*qTAfQDzn$SBZ<~?#yK*!u#Qj!Fi4fI5C@Di zSjK7*$y4rg2}k;r`!U+YiL(bFz%l@4xEwzZ7{A+QX{Xl_@lC9L3HW(mnQEgBJ8g2==oS|a!#e}=o@RLBcjar zTKstcVdH?n)JRZ8D4Dgoq^-aNdbs{je(Ile-q!Nn2Ja)KPa5Nia3ywf6Oz2*5X(0T z0Y>UjWU?k)U@6ec(%n%ct(DXXErWIUmf7=?h2^h@U_StjTI{6!XSRBM&_BQjMo z*JWphox-cNvx%}Ozt%YP#T80>A?>NdvS!xz?m=4d^6cvM?LT3L15eM|0m|Rgedh4| zRcKG?C&#Gs2JgheK2y7R6Ro?n_XR|ty9~=~mV-gAR z9ZuuS|J?OSc^zPz1&M{r;VPYugI%jg##!*la>S?*jN&z3|MTL4|(Q$zTA&kUrabMf1)`o>K}*$ z6qbz-w(w_HM@H!-=&42f#D0aW$i6dtlTxKhCE zEL*YuO*ivJw|_Gdu3@zH9A|6%I#*+DVz5MH<%r{}eOb~1H(jiXvYrzb1FB9uOoOdH ze_Crj+iP`v^r9%w?V$qJZ++Oa`D0uLqAj*RBR+?Ih=MbGN-L5mFFWVwS3S?>fGIyl z2)3kC>JtElF*XLa@|k}$!3Z<^y=rdRs&pbl=#7Jo78n5BIcX|XW8);_s8-DmHuC%- z@9ilRdgC8z*<|MNOBW4;u-cu#phJz9ewz8^MI@pK#U6*&BO(rXl=^jPeD#i8rxfBl zHX{jCWh(CVExJt9mlCoW=y4=5d!{+vUMfz`&p#M zm8`~w96~r4A79=t;9gx!KwHnd$6P9#Qxe3qZcUsm;Jp296Z+ z`P+)6S?-*sVIJe{kKDmamN%1k%W`JmL)lEN=R8X>w@#nfnP*Z1r|)OqEoA7SwZQ?pGAJ@? z;VHmsk>!253sc-~EKRA9-HM-52=ST4(*A^}#)nFPX$xrWMZgtA-7JOF5FX^b7>7~2 z(VLM=&qO5aGNwk~C6IGSuh?5J2vwJjma=TA^EjB4)c)iD5P;@lIsBpr^Q_Srzp#x8 zHY|9l|IPg2kS;5-s)|#=8|h6H1UDwBrT(e6S}aiWOwBTLE0}deVZ1ux%o1uhJ~`v0 zEyx^W$VlN3lqbJ1H4=L`A@%HE+G+LmUgHZ_f1P_<`Ur6v_1IJb9UCk+<_7@pm0XkV za1uHhAQ2G8N*gtOvO|JNn4K)DJ}iFAJFm^Sq(PH7nT1;okVf#yZ!UV5TpJB|tw$rhIJAK9+IW&nCEY7F>|)PSO+Volm$j zFbOG!hc}NVWO&9SQ&VC!N5=!cvStXWBS&WGM@5x7x4*=0BE^Lug(}0>B4SIJ7!$ma zQA0kzqmz;89WQ0pMVD8Oy+BNgjA!D{KY?LoP=vC_=$%DoZM0`cvk;ZOa?GJ9gz5XBIDg&zXp0Qs3Hwa+R$0s^Zep|XHIKn%W2 zuMpAU&WqzA!dVKk9QmwA6ycX9#BoZ-c&g6Mig)Ht%*;O5$&H0e1g0bd8HT_}V|jjq zBLqPx-2C+@4Zc=g8QY|G|MCAd1kKb$!I@jovoVSC!j|=&Vc!5Aj}%ATMZ48?CQvLh z|BW6?md-&P+ungZ*`o`v1-)^XE`5!Y5(W>r6uc2G0A*aHGKSviDAk>YH9fKwx97OPlD|<%- zYqE8>3(!n+aZyAZOyLsc3u^Nu;V9`{HH}G@ya?WeJ^!)TF4EK+7g;}?ZLDMs9r4#f z?F5o7e<-`x;#Jd2us2|rSnD+eL^GHfoZj-0C>B0{NU%suN=@*1!nL$UuqJ>kYn;HT z5#@<2ROt1D!elezv|SCVx|Skez4$baus?=q?hqJ}y6{jamNPTzVLIh=avQf}#wd?} zpo*gF#CFv13aOaG0?WvOWuYIzmE9o?N+``VI27u{r+KKMZKJ|DLtjLESQO>O# zmxq_4{VN{Xd7;Q5vXN=1X-$^siCCh!)(z#0{5JkTQIf<~@*C$HRoTx^?mG~W>B_$4 zdRJ%d7`^RimW4t|t|horWRxA}#d5@W=X zh+U+Pl+HGz=k(E168^XbYBw|OWBY?>N;ALV)0VRe`KZVS_-_}3Q1k3_epo>MUfeF# zmEQl%|GxpyyzJ%Ga0PFg8%299qg)KOtMIs+MOClG_FSWY{-l^TaXKEqMvcFN@Vd0S zp9OxGMI6@z85IQdR8%G|cwX*tn25^9srJrW?IR`XrBA40^Ww)5c4xS-rdufPI@L5DU56MXqa!}4u5U`+af4!hgIh>IGEW#-b@-POJQfLUi>I| z$jPQ?XCi8yKr3ZH_L6cGp`)&Dg_da< zp%S8CrlD0RphhAi$$IE8AM~Y14I!oK#9&Y`GCJtXsQK z6Tu!=mq*tF@AR0ywqSuKY*>es6{!-wxC_ZfV#vPB`QaC+Nsricu-Pb|Rz*2INulrY0S66J#u8Q1b$WcErRcVjun{Fnn z7t$V2%{@o`$ABc5O?NL)5}WQBo;TpoJvNMXNKmHLL>1o%Qts%X!C4kQ3eCt>aam!dj=@A}9OTkp0)|6ZQ7~(M3ha?-0IqWsp)?03krI>|%Hc z3m5-oX7YKME03NSU0WP;5RXr{B8tK(Dg`$LKotB`(c;rf^`Wri3<7A_h0>g;U8`{e9l38 z(+D#}SVkoo&aa|R^Fxq-caOEh5n>Xq@7F(?9D(=y6p7uIVNYWJP%D6@(k zT@3^)6tb!*P`#xfuVxoUUZYZn?i2_mAI}T6aR4VAY z2hcj@Mf|Gu?(L4fp#ohFvH1Bl_e&dUr2ovP+Nds@)QdC&YWW_dk;>lCwDzl8fAPn- zf-Uym6&O#|X(Harvi1(8Tg4{|6gQoIC^fD%V=tE)DZbEEwL5&b>iYLp3(~L zz~8e^QnRmFIw{#LG#cycij9ZjNKP-+hmr9(^NFD`t)O7*8P&^JVdbfx4b%8S!UyUo zW7?Kf?5KvHZMb#w>7i8Ug$cLv4$0UPKw%EOY#uR|gI1@R4ACt-)^ry2ZSW^2zId;o zh3tr(7;SknJ~yK`WHhNH9#i5H?XEV>DS>4B>{Tu<37x*r_xCjwPwGll7qmFHTf*&~6IY!i(V?5wmHZq_204Lbv zM48sdBQY4EhTE$6 zkjs=Z3#myhlWNFOed}LIyqfqC*}U#~Dr;EkqF48Yq5KWQLW|`P(;ZP?z7uw&9Oa?%M6GC5}qM#!@-sV7yni}iA_c_Tj zaCZZP?4c`y_~e^sMQfvv$!+4uugdMq9M4K(+tYDu`&|ecX-A6`83PgZD%1dTwX)+a zSG)*@aJyn!jqcgNu1b3=FPH&?)?k^*msj=+ZoeLVI8t zQalK}-+AP2_2DQPaZMMmU^=~cM>F%M^sD#J8Xxp5c+M0jd$-pmtsmG`e2*jZG9f%E z>^?nCvO7G|AUK)vF*_uRSPSCmf~A8iy5V_Z30Y9#{?igb9c-%?M{`rDBH zH~)Hz<2N+ZLOD-{)S8J6+iC+=oX5b*og{t6pyaI|D;%DFEYC?2k-A>*vnJj`rbb2w z96t|)357_9mT@}BMXI2q0;_mfv4p6`?-nFQ)#zrdI7mak*F6ZYnLirFAfqY_V#+Ha zC08e6a$`(qHwEe7ynKSeC@&S}Eg+h+#WjTDTAPtEX-vbMz{C1!#1_)1=!{(>wD3t%I|52AoICO<6v8i7<OqC5 zpI>8NQejW=6vp{ntb&JT%vLfD)RmS`-IMih z^+%C5Ny3akdr+2L#%5bd{Pu#6ii(V}I{}X{e{=#dlEvpas3Ov5bwadYO00Jg7;p?J zP@X>Nm2e{qid}dqVz3Z=<6Vc0gbdim{2|IosihrWs!xNTDIWxhi(AktJqx+F#d6?; zX*$G5Z|Hbxu=_I8>E{yc|e2lkw1}tf~CS#;%V{&sVRqq)p!9aem^1CLWa{n<_%>opbrJ}|h z@E`x@cxdT9vLuQcdu_a;3XZJR26T(a>U~`CfNqQRN|x&AFWaKRu6B$Y<0}S)AS2S~ z3Bn6b+-CeX9WAV>PcCZQE#rau`8ShJ^W+N*Fi?y+F@D0M~b)8U#C ziZklDWPv(H^o3u#SX|+_4t_O}?5+AlTq&42#P_O7bQlPwc@>9zHViB9g*$ccy!)Tp z5LB4vLO55G5XFZp6-s6ijL-vSo8p=l#v5VfJs1ausXH$f9+7x<7+}bQ-bh)httZvC zay!*_F(J*i9CcG{TMh5xPLNoe^N$)ED(SE#-S6x(1DQzJvlhJBaF7z0MTI}3?y&-pxu&!5EF)FX zg)Ou#!ba(|Nw-+efxNXhq2vjhUHC0y_j2q}d&xL*%U_TPzYw+ZqK3#TB8XG3LAeS7 zFQeU4C?wpfElQ5v=I_HBlP|CnYlDK-nrUr1)~Si0=?^?qH|75nJT(Pcx?QpIO&Elo z7n*{C@MEuP zzISK{A1TCZ(xc!FPXYy)kxLTzi?K;q(E^oM#f?~F!DL_1#m-^n0<$&=HK|((NU{gM zQc`V($rEeINz4@e6yoFt8L?1s&<0Do~1|TxZSxXl6l&)K&_w+05 z)I(-s^(Gf%TlPuOB4ui~X1Kqd_g>9|W_`jg3-0SP9hjgfVT@HMB@;R-mmGibZY81F zvijJDoR5sH#E-+anDBEVe3V80XUJh7g@YE|1P?7WGfyP0I-dOPTi#kFng%vG|NFH1 zdhz8zt~+N(^{)kuc8ddhj~AU^Z5`{(AM!lUn|iL?qH$zO8`Hr5@qdc-;{W=b#vug$ z$ZN>&7?o|nzlb~yUYxw>+F5^@|F3np)6 zd1WC~XaxvPeaq}0FV>^EU*Kj}SX=#ITb?CR9vNuRp;gltJV^*Qss_wPoX|qhh#@FK zVVKGSXqFJ!*W<)oSPV-~)afkK5-b1OMa6Tue*}P1Ct2+ueWA)$%&C+TFi}NOzQI&Z zfq$Rz4mxrLx_TZYz@StEZm5R#qpx~g6))uip0Jp1XVU4@*%aG^{5XkY+kEEZvA~bG zQzek5MQu4*@_M80rxE`1iH9GElha>v($U{f`=!(+9FzUse`y&7m6TZPRkt@Vzk$FFfRfWz%+ z6X(r_5BVjU+RDV3W!4=!s&(GNSrTLB%B4by`%ykG>mQ1QCT0!EruL5k#>UlR$6F5R zc$MDY!Tkl!O)7#^2$Tat)=>1bp!BoU!HeeeV?z~YNw9i<<<49=0FRE@$!01G90n~; zPE<;)4#Zpvs`gbOlcOfAF>s>r)c#+)Lt`haZ^Et8_yZ^k8lW&98D# z?ZnW9wtnUNwn@n4c~oqhZD~}vYLDiy(`p@8WQkwxm-e&CPxR2a=lUDjR< zYdME_!w*A0LgjY7Us3^+ka4h?=4~eH#2D9- zfqt~~xcLrS({xt!)Vp^a%1xzY?8(#hFJ?)KY4}ApZ)WQT-8Y{fzxX7%AmBn^Is9W+ zHq_I=pA79|;P`{1^o&o@xi$o^M+o#E|Cfnm;Xa?!R0QFzSL4EtQEue6%g8VJSI`l0 zi}enw-##h8)$*=USrQh|AQtZ|c3W!{FCv{+B)6`~lZ_>2qTL`9ILu4Q^9tKeQe@LnfL}ii`yik;bWqb3*E}8VV3zelS??f7k!p699}m zO&%htc4=47M~rULRJy4o+cAZOP1x%hwtW_IOi-{u2YmKZCDv`RN~Xb|O=4VAWTTQ2 zw*uub@FsGAhKE)B)6z&tNLB;vY-^K1N|ZYmgfHnMWwD^6unIjB2y21ntB(0{<=G-V z)|Af{<#qq$!&1e76DY)G9X1QHYH2cz#Q*URCAgdQkw(FV>{Y@(Jz9o$0su(m004jy z01ym$`5ayV7g^QoUCKw%;jmP3&sgHxIk=Vl%jTs*T-)%SjTofD2jNRU@ItKgA`rRA zNFYz$qm-8~4G=H>JY;?v<$^PrUU+SDHj|hdbizt!wk}OVLyfA{OKe4cs`_?}^WF>; zWu|ki7B@&J%oCghBqT=XNW`Gwc> zY0G)Rg0~QKBvxFf+Fl_Z3#($TIpQMS8lUXv5BJM@jzN07$#6olm&oth+JR-g=e*T7 g=MZ$VKMDTtFP!Qw&oAZ#YX2|pwg0>RKRki|16*8x`v3p{ literal 0 HcmV?d00001 diff --git a/js/src/App.tsx b/js/src/App.tsx index be7e21d..7c131b1 100644 --- a/js/src/App.tsx +++ b/js/src/App.tsx @@ -18,6 +18,7 @@ import { RouteErrorBoundary, TopLevelErrorBoundary, } from "@/components/app-error-boundary"; +import { SoundEffectsProvider } from "@/lib/sound-effects/sound-effects-provider"; const queryClient = createQueryClient(); @@ -87,8 +88,10 @@ const AppWithProviders = () => ( - - + + + + diff --git a/js/src/assets.d.ts b/js/src/assets.d.ts index 9e7051b..198d866 100644 --- a/js/src/assets.d.ts +++ b/js/src/assets.d.ts @@ -2,3 +2,8 @@ declare module "*.wav" { const src: string; export default src; } + +declare module "*.mp3" { + const src: string; + export default src; +} diff --git a/js/src/features/settings-page.tsx b/js/src/features/settings-page.tsx index d6988a9..fb6b9f0 100644 --- a/js/src/features/settings-page.tsx +++ b/js/src/features/settings-page.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from "react"; import { useNavigate } from "react-router-dom"; -import { ChevronRight, LogOut, User, Info, Shield, Mail, Mic, LifeBuoy, FileText } from "lucide-react"; +import { ChevronRight, LogOut, User, Info, Shield, Mail, Mic, LifeBuoy, FileText, Volume2 } from "lucide-react"; import { Avatar, AvatarFallback } from "@/components/ui/avatar"; import { Separator } from "@/components/ui/separator"; import { Switch } from "@/components/ui/switch"; @@ -10,6 +10,7 @@ import { Muted } from "@/components/ui/typography"; import { ScrollArea } from "@/components/ui/scroll-area"; import { CopyableEmail } from "@/components/copyable-email"; import { useAuthStore } from "@/stores/auth-store"; +import { useSoundEffectsStore } from "@/stores/sound-effects-store"; import { apiClient } from "@/api/client"; import { logError, toUserMessage } from "@/lib/errors"; import { toast } from "sonner"; @@ -73,6 +74,8 @@ export default function SettingsPage() { const [emailNotifications, setEmailNotifications] = useState( user?.email_notifications_enabled ?? true, ); + const soundEffectsEnabled = useSoundEffectsStore((s) => s.enabled); + const setSoundEffectsEnabled = useSoundEffectsStore((s) => s.setEnabled); const [version, setVersion] = useState(); useEffect(() => { @@ -146,6 +149,19 @@ export default function SettingsPage() { onCheckedChange={handleToggleEmailNotifications} /> +
+ + + + + Sound effects + + +
diff --git a/js/src/lib/sound-effects/engine.ts b/js/src/lib/sound-effects/engine.ts new file mode 100644 index 0000000..0f5adda --- /dev/null +++ b/js/src/lib/sound-effects/engine.ts @@ -0,0 +1,152 @@ +/** + * Web Audio engine for HUD sound effects. + * + * Uses AudioBufferSourceNode for low-latency, overlapping playback (HTMLAudio + * can't overlap the same source and stalls when triggered rapidly). Adds a + * small pitch jitter on each play so repeated sounds don't feel robotic — the + * trick games use to keep keypress chirps from grating. + */ + +import { logError } from "@/lib/errors"; + +type EngineOptions = { + /** Master volume 0..1 applied on top of per-call volume. */ + masterVolume: number; + /** When false, all play() calls are no-ops. */ + enabled: boolean; +}; + +type PlayOptions = { + /** 0..1, multiplied with master volume. */ + volume?: number; + /** ±fraction of playbackRate jitter; 0.05 = ±5%. */ + pitchVariance?: number; + /** Skip if the same sound was played within this many ms. Prevents audible doubling on rapid triggers. */ + throttleMs?: number; +}; + +class SoundEffectsEngine { + private ctx: AudioContext | null = null; + private masterGain: GainNode | null = null; + private limiter: DynamicsCompressorNode | null = null; + private buffers = new Map(); + private loading = new Map>(); + private lastPlayedAt = new Map(); + private options: EngineOptions = { masterVolume: 0.02, enabled: true }; + + setOptions(next: Partial) { + this.options = { ...this.options, ...next }; + if (this.masterGain) { + this.masterGain.gain.value = this.options.masterVolume; + } + } + + /** Lazy AudioContext init. Browsers (and Electron in some configs) start it suspended until a user gesture. */ + private getContext(): AudioContext | null { + if (this.ctx) return this.ctx; + try { + const Ctor = + window.AudioContext ?? + (window as unknown as { webkitAudioContext?: typeof AudioContext }) + .webkitAudioContext; + if (!Ctor) return null; + this.ctx = new Ctor(); + this.masterGain = this.ctx.createGain(); + this.masterGain.gain.value = this.options.masterVolume; + + // Brickwall limiter: caps peaks regardless of system volume so the + // click stays controlled when the OS volume is cranked. Threshold sets + // the ceiling; high ratio + fast attack make it a hard limiter. + this.limiter = this.ctx.createDynamicsCompressor(); + this.limiter.threshold.value = -24; // dB ceiling for peaks + this.limiter.knee.value = 0; + this.limiter.ratio.value = 20; + this.limiter.attack.value = 0.001; + this.limiter.release.value = 0.08; + + this.masterGain.connect(this.limiter); + this.limiter.connect(this.ctx.destination); + return this.ctx; + } catch (err) { + logError(err, { scope: "soundEffects.createContext" }); + return null; + } + } + + /** + * Preload a sound by name from a URL. Safe to call multiple times — caches. + * Missing/failed loads resolve to null and the sound silently no-ops on play. + */ + preload(name: string, url: string): Promise { + if (this.buffers.has(name)) { + return Promise.resolve(this.buffers.get(name)!); + } + const existing = this.loading.get(name); + if (existing) return existing; + + const ctx = this.getContext(); + if (!ctx) return Promise.resolve(null); + + const promise = fetch(url) + .then((res) => { + if (!res.ok) throw new Error(`HTTP ${res.status} loading ${url}`); + return res.arrayBuffer(); + }) + .then((data) => ctx.decodeAudioData(data)) + .then((buffer) => { + this.buffers.set(name, buffer); + return buffer; + }) + .catch((err) => { + logError(err, { scope: "soundEffects.preload", name }); + return null; + }); + + this.loading.set(name, promise); + return promise; + } + + play(name: string, opts: PlayOptions = {}) { + if (!this.options.enabled) return; + + const buffer = this.buffers.get(name); + if (!buffer) return; // silently skip if not loaded + + const ctx = this.ctx; + const master = this.masterGain; + if (!ctx || !master) return; + + // Resume suspended context (autoplay policies). Resume is async but + // start(0) is queued correctly once the context resumes. + if (ctx.state === "suspended") { + void ctx.resume().catch((err) => + logError(err, { scope: "soundEffects.resume" }), + ); + } + + const throttleMs = opts.throttleMs ?? 15; + const now = ctx.currentTime * 1000; + const last = this.lastPlayedAt.get(name) ?? -Infinity; + if (now - last < throttleMs) return; + this.lastPlayedAt.set(name, now); + + const source = ctx.createBufferSource(); + source.buffer = buffer; + + const variance = opts.pitchVariance ?? 0.04; + if (variance > 0) { + // Centered around 1.0; e.g. variance 0.04 → 0.96..1.04 + const jitter = 1 + (Math.random() * 2 - 1) * variance; + source.playbackRate.value = jitter; + } + + const gain = ctx.createGain(); + gain.gain.value = opts.volume ?? 1; + + source.connect(gain).connect(master); + source.start(0); + } +} + +export const soundEffects = new SoundEffectsEngine(); +export type { PlayOptions }; diff --git a/js/src/lib/sound-effects/sound-effects-provider.tsx b/js/src/lib/sound-effects/sound-effects-provider.tsx new file mode 100644 index 0000000..c22c1d7 --- /dev/null +++ b/js/src/lib/sound-effects/sound-effects-provider.tsx @@ -0,0 +1,102 @@ +import { useEffect } from "react"; +import { preloadAllSounds, playSound } from "./sounds"; + +/** + * Mounts global HUD sound behavior: + * - Preloads every registered sound file once. + * - Plays "click" on any click whose target (or ancestor) is recognizably + * clickable: