From 95f74d813f60c799a36778f71d652fd17ec1b32f Mon Sep 17 00:00:00 2001 From: njmb Date: Fri, 8 May 2026 17:19:11 +1000 Subject: [PATCH] v0.2 - drop fingerprinting, add LUFS cart normalisation, playlist save/load, editable history with TXT+CSV export --- .gitignore | 3 + __pycache__/audio_engine.cpython-314.pyc | Bin 18002 -> 0 bytes __pycache__/cart_widget.cpython-314.pyc | Bin 16802 -> 0 bytes __pycache__/deck_widget.cpython-314.pyc | Bin 28528 -> 0 bytes .../fingerprint_widget.cpython-314.pyc | Bin 18769 -> 0 bytes __pycache__/history_widget.cpython-314.pyc | Bin 14047 -> 0 bytes __pycache__/lufs_meter_widget.cpython-314.pyc | Bin 6332 -> 0 bytes __pycache__/midi_engine.cpython-314.pyc | Bin 8510 -> 0 bytes __pycache__/playlist_widget.cpython-314.pyc | Bin 38221 -> 0 bytes __pycache__/top_bar_widget.cpython-314.pyc | Bin 9777 -> 0 bytes audio_engine.py | 22 + cart_widget.py | 2 +- deck_widget.py | 160 +++++--- fingerprint_widget.py | 296 -------------- history_widget.py | 376 ++++++++++++++---- lufs_meter_widget.py | 120 +++--- playlist_widget.py | 105 ++++- requirements.txt | 4 +- top_bar_widget.py | 74 +++- 19 files changed, 679 insertions(+), 483 deletions(-) create mode 100644 .gitignore delete mode 100644 __pycache__/audio_engine.cpython-314.pyc delete mode 100644 __pycache__/cart_widget.cpython-314.pyc delete mode 100644 __pycache__/deck_widget.cpython-314.pyc delete mode 100644 __pycache__/fingerprint_widget.cpython-314.pyc delete mode 100644 __pycache__/history_widget.cpython-314.pyc delete mode 100644 __pycache__/lufs_meter_widget.cpython-314.pyc delete mode 100644 __pycache__/midi_engine.cpython-314.pyc delete mode 100644 __pycache__/playlist_widget.cpython-314.pyc delete mode 100644 __pycache__/top_bar_widget.cpython-314.pyc delete mode 100644 fingerprint_widget.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9b5f630 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +__pycache__/ +*.pyc +venv/ diff --git a/__pycache__/audio_engine.cpython-314.pyc b/__pycache__/audio_engine.cpython-314.pyc deleted file mode 100644 index 64d474e3213d73c4700cd34e6f72b09af8af3fef..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 18002 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%0Dg6QASb&g4;+A9e&yaYsMIHwl1bl5cX{gm%F*R#i z2x66_5(8DSA}cQXQUkeUN~V*x0WBk~B^2N5CB9$Od%vWTt<+X)gz;Bw$9?!7$2aW5 zH%mue?mX_&PBt9WU0V+vuiw$y+E~}(8Vpi3PaslLZ#!If&g&aF9l^bq3mOA#N3QLz z5qNdu*%LxF?@+yMB>TVBee33pGtc+Pq)d!_vU!L2cIn^#99K}7Bn$+jUQa+JEZ}y~ zWbonDjz&nyJ@jO!Qg;~s!aoMw=0**n-}t1Chhm^`O_N~?d8B+!ih2*?#eB*Id7kO=ky<+zWud@`u3%o z_9a(4D@-DTW*wldr43qrD#HjzhLO3rVMHXuh?|HaVboJx|Na8i$gO#`1o3BN{SbtG z_X)g(8!trfLQz1ZjxvEZs2oRGarHDFJe-I37{~_F2f4S|C~mrtFHek z_JZV82tmWuc_!}o61oS0bnYq4-fm>pUan`7d0QSrHt#Jyk0agzTEojv90 zv0!lW-%x#6*SNtr!XWwoj|f|YFZPH8(Wd;j0Ba6|OqiuSdK8!+`kJF4KEqMi9(8P= z?fA%{y62pbKS8%W@_(mM{SO*d3T`u}hEyXMLuxiC!W%tOH$N3pqYoB{YXQ~7RU#+r z*=N#Oi<9&eLWJ5@3VZ?$PEatTd>ij6wjr-}$AkMWe=z*WRgWTuPmkdn=W~Hn(OS#R zq;;rbvq;<-lt;yK+;AkGi=txDM`8&(VfaRldR8eWQyoZakl!cpGXhMpVCn=@O7_)e|d#c49z~L)cUR_|AdMOd;`DmL4dfcNv%-vaDd0n0lPkU*a8FF z3R&46^zjaOk31{|KV`i2VP-OA5K74*rIgK)HM^h4{pY57k8c<-I5Glw&U5y2fpUfs z4f{QV`-v%0CC?z>Xp4tnsF}eQz;mM>)C`8k!f*%?@TFkrf+`_d!@;q@5Ckl*ya`Rn z|C#{NaJhzni@-(#TM4ugXeYp~T4>gDD#25YzT!UhL%sd74cm!-T=*yeKA+|JRWrxu ze$3^6%-KKYoF8-9A9L0RW^Tg^^IW!)*D;a#Kh5|*MIQF@79QV)0zAyy!;=dd!H46J O5BSnm+z0UL+WbE~T~Eyb diff --git a/__pycache__/cart_widget.cpython-314.pyc b/__pycache__/cart_widget.cpython-314.pyc deleted file mode 100644 index 1f5498cd1e6cc1993e9ee4911c06ad7bfbb2339d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 16802 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 CqiyB@ diff --git a/__pycache__/deck_widget.cpython-314.pyc b/__pycache__/deck_widget.cpython-314.pyc deleted file mode 100644 index a5d7a1d6341c33448f9b65dac66bdda8aa57f61f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 28528 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

JofDFq32l4B(4>ep9Yb*wLmo0!A;5=W8YR$O1t zr&wR3c2-_rqf)}lu8sMPW^r)(_U zm_F6Xu=uv?s*r=mJ5ASPi*M)RQc9O_)|aZ)V4Pa52B*fVTUm#a6DwP;w2qz7Xw_7R%&;~g5b4VkIBqFqZ}&Ch8)QW3MczlQB*)X0@>4Ku;*zsq5J~XUrYsZe3 zmZgh)>^gd4|B3zWp8ZFB$3NfY^)S{|Qe=@TjcIzBK20Ezl4yZ$7~0+8!L#8(f#@B9 z_<6AaS@%N03l=kWBq;Pp24kWVamR*&ePC$?QV@vQ7$nqX15BgrpvqA~$e|?bQ&6vo zF2wB_fadrdvCKqKidpRaaQHMa$&}Q+7irJ{GCiNFeileJCT8uw0JU6*cxW5b2?|8f zitg}0a3~fI$t~v!;d5ZjkxPznvWopoeV7#tg-%LZaVLVu;zBsycLqHJn=%}e3uJ$3 zI6NGdU#NtnI``To5hZ~o(GISd)aiU23h{stJ&*a2>Ofdxlxb2JqbzgBOnX?UQS++i zkl2yfdhj8?Q2An==8$xSuWoy-{bu`{_P5-(+|#9CnYS6J5g+Ao1?6MMr}C>t?X!GN zimy!YmE%?8!O4Qjqe;Gr#g!!ZlCjDpUv|H=@^01EsnS*wjjm(W5tEqWJqh0Pdhz&6 zw@nS z3`lBG(xB2jr1+`?U-f$1WceNQyZhgJ=ACDzs-I5so$73)_)Q6ZQ%3Kkk{c8J#y8Dx zxo)|>Cw%|QZ-04u(}5)a^!*LB<7cKeG*LTcteUa}UzXx)5`4|;Cng`iQ}u56y)VA= z#i`nZN&XpKhWZ3wpV5v~(wpGDZ&tn4aI4|ns`r}SX_~I5lJ{tE)RD2)RB1z^v?0ki zssm2)?1V$Y4|p!6uK8s9lp)}Y9+Q#>uAw7jp^PP%Uk@3y_y{!aT; z6%B}WQOZCgR=W6z=TJ@KROyzb*L$jRXOeGc6-W!Z(n_D3I7jjqz97ZZ*vF2i${Q2q zjcl(Wj^s2<*QT9QO|$)N6w#|=Hj*A@<3u5Q zEY>TKZ~;Gl{zU}z>KM%^q<7PfmmviQ^Gn)pf0||+vTBr5+z@ozM(TYdlM=0dMP2hD zjkdk(L(&>{eQarLZ0zz{B$?$fqC|q&$HN!mm@6V1g)8u4Bm-78F_PLcz`%Uk1nCpl z1QA~cSeBhL8vnhW*MuuiK&Cck`P$C0;pxJf(S5otM$l%wXojy_yuyTeX#VEw;4Qbp zelr<9G?D3xMOtTvj1t5w6&VQs4z(=1=E7Cv_zr$ClKle~FK}jC^8@lGw$9Px?XB!h z>MC_jaq2P)h#}hypOT_``t&8_c>OY=6b(7U8J1E_2e7yhS_{1>gt7l79s)KKCwy6o zU<-MZBEA7nwtA5jYuU=G0fC8YblY7Q&t`)d{7W2LGYtlhZ%^`%u{aH0j(>iJZ(6*L zw1?O_dJtG{9d%k#$f+4NUFE91g_^ZfX|ln{1m476_K`*UZ&qu;5JeeCZe}wwUF9~nm z-so2m?T|BqelUtNySNtpF|k$Cv0QC=J(GfxUi*h6Bx&ywzJ*7Za0C8`Lnf_-D8(tT z^ecFgwt_{2qh$UO72@&%^avhg5?%6^VAQ4&JY@-N8M=`F688_;xgU5pPagkH$!-6e z71#ES6@KmDxNWAW_RewA8RSVigOsZ#;i?%wKRG1&_6TfY6S zYsb%t3}^G^wMl(pJCG271eOT7O9;X(1StFk@`B`@B9GV-;p^nRO5Ox{Z;$E@uA7G%^e3JtkZ2!T~dgBT~a`TyO$^G9fHPn;1SqfYynBTL~?$!E^N_-tvxCb@Y{4@ zdyiEq+hoU)cgQ4T95j(=W1B&A(o2Aab;?ycn8;ya5bOW@s6n8eC^+DGO^m{f5$LO8 zXI?VvA%yxqez869h|E}P%6&lI>y|lsd{9|4XTkG+op;WLC-j$9wQ~*%@mzJ?T$U7S z?sz~U>CDhMQ5?0+=II9rq!FE(zQxvUGddk7TNRlZWMwuZ(5e|V__^L}orZ|y2N}*l zCt@=)XMxeC31?C{nW}7>`hb;ydHEgz`ty-C)o55}ubyGjyx5$1q&VI5SU%Um9IQ)v z%BjWn&24QNcF-yJzCi*-?L@%g)U&81*ttI1{hJ4agUr__L^&WSQwi(tDbVE%NztpnsURtO^*0r=NGVLJUfgNWtAw)`sf1mA7jnA&ZumJY zHHRsTbD6?8*Pn;@Jbym?e18G_0)HX=LYA|LQb21W=QEl0OY{FmRtxG-iKD1lC%na`gMUleVUP|y|Z>w|bt2B0gF zq2gMtXAqWYO!$9AhhkOmenHSP$5#4)yotg&dfYn39{<6@BBjNqp(u`qgdacu+c^I; zEDbjtg9#bCv7s*X2JU2@W*2$#$w4a3+{tF$9Sq+gco0(Nu<#zexci0$y1@~Mp9zCm%>wv{41@y15it*YX>btsRDl?~ zkrWe1%`e(WDl3RYZyh9YGFd)3D2_z7AnaHX6c)m#0aDKdLef1Tkm>QD5LfK0sUyO_ zCy#m|5H=Mm$lCxdTBdUlUcMk%~9d{j@F3rx02U&O@RsIux8sWdiR`q~` zcQeKwQg^5eij#>Lex3tq!+3P1qe>1u{Y&t4ASO(D0>4KkkF*2DOirkk3zZHmFYp;JSP{2K^(WRpxa0KtK5NGy10)(pU}GoAcEvHEB<6ZZVHW zyM*(R{5iv|q7*Sp8EzG&h-Ardt0>i&Q3S7PrM4QR%TYkDOO3nPpp?41v47hZr+4e+ zPX(aT3P(a|^t^nvH-i&cg0tAtNB=ZpUoqQN*d0wv{_(D*a;Xf@wxt#1!e3w{BSmQ) zm6@%PjOH~iEnPRBR#xfXV018?-mf7jWg&H93VtnRn9$dMyZT+2Y;O(HKU}Li6N!gM zEDc-QAlL~q_YuqW^}j&Gmf*HiTZ59^$MV>gHo1a7k)B^4N0uGzek%en0f+1ImP%@I zMiCI-AjHUvlSfo$Cbx8f;+*irY^H{m)Z4;uQ8bB7aNDad#Eeo!Ub%Z=Hfh}z!PcI})5}gz}eLh67EQ|s$K9_RDV8ku_HZ}Ke;6X-26dA+Y zFhq|=ac7%uH-lKpQn|Aj!8E417Ce;sKf)7>^n{HWQw_av&8Ad! zYofaKZWUxamTg&c4&}Zp6xDg9W35xUxa`VATN@JmhH>-wQlbi=7xI^qjZ^D3A$JhBwu^KXv2*oqgk_jzN`aIE)mm*IwS|m ziXcXYBas!fvpSZDgvyAsX2jWAbj&2`BAAik4C}|&4g8Lbx{T`QGwRo~x^g?Kt{>k^ zSk!-~zJ5P_N5M6+b6D@j%(tII=Shwto@? z29Z*K%Sep|=$A%dqFriN_K|Dd7p4jSs8gEQZn^GipHEmt z8_Rt;0aq#i2IG`4Qj$W`@NhU7jQ=UZF=D9{1%7srmQ7}&*XZE6E}os6KSQBQ+`aND z`Xqs(dw_t=Q+=;`(`50tc8xaz*lwww#oM}X?S>r@AbhJ1!q4T<6FY%`+TEmJK zeXC=<0Y#e)ihAUaA+VzE%dje{PVm(z>RqvDt?TFX9R&^4p#RL#VZ-6&=W8$zL5%5` zv7U%#DZ?4bzERrXcxfae(ha2GMvU1KwG*L`;nHd+4M>Lj1huP-m{vtv?J85HrGJ9j z4P;AcwX1B;C$F6eSUD1Arq!r2KA*BiCcM>ZWZzD)^l6nz@{mGAs3s^_VIYW1r`XwO}Nu~;t#6=>%Yan>;R4Dj_*q}R#newd0T?0|v z*o!@ZfRz0S!fdpZkS5(O`hMx#rFV`cx9pKNzrJULwN}yj-dtr0(0P;aKTt~epXB`v zp02~f|Dwn_c)O_wB-x-ISihhBcJ`g8z9;FSaF0xwMGAy$_-xSGa7g%DdUJt>?eE8Z zGNM<7pHNgbXxivdApAwT3^^lZ>W`|lt{_sBGayBQniW!r5PnRa+Og9Tefpm$Yw0qR zxRW~k7ILA(M8GlVLNc`!Pd1C=p;X=WL>(+zb}W&)Em5~E$!}+{P_xKL@nq{aS(B>o zNYr;E`CU{Rglmc?f;PpI{oSPXt=wC=Qq78;B-uPkhKflqOdX#xD3;u`dxn2f18B;o zBS1y#I`4tEl8|n-)*kPpu01qGbVM;l3!KBY^8tAiFnGd4>W3aks(|%3QI1LHP&CsS z(H)w6j0vPOi{RY|H0rO9frc_FWgyA(gF@4(GmU|>0mwxQ27p#ii!s@u0XW?#_bk-H ztob-2nV!-0HAn#rYV#R@Oy#UfR(;Zk7(}~dk1h^^yK&%FoXAU3$MO#`Gr)jCy@(F!8gh$%BOQ- zt~;BZd$s*)`)E6TZ{*13BPmy1!c{jOe`91~B(-sSV&itYG;85pYkrNV=$W)rWU|$e z0a^{6B0LtKBAQkFK07-!w&ri3R^e;#Vsv5^;*TwwWY|tdu!|N=OHv21Xwhc)YHc!6 zH*+#b;u(%yN>JY_XV%**nuK}8YS3J_pP{0No))P^pk zvvS(!3RlI?$&$Xs9HcCslt1yXI9(AzYAjoInwY)f(*3ah0NF?}_~fylzPs`M)tY42%o^LZLr~C)$RB!!dljQBO({K_;06lej`c z3=uqvSH~P4vRTD(2jQ^eq%2qC(hk+aQSlkkFv}0-39{EyRPmU z-9@Apjlg$Jc@<PaM3L(WKY zg|pA2R`tN$#+VE-Pq$huhILPcll1YUYx~obzvu>YYR3u?T9TgA4@EBcYk-qqX0()P z1vqI)v-yY>%WxTR(16l2`X*8a95i5djm$4H;Gj_{Bj}j{2Y~|&pk*2kFdBu?*wbZ^ zMx~4}hfW8I^)?!Xs;kr{+pk_kW;np8l+oCAEWR;*Rq8$M)|#AgSA~-U-%yW>ZH7+PEjXXdS3sdFq(n&p3wx zN|!$Hv|RRorMYWv?(1u9)nj-zcMjj-J)b|=_4ILX-jbMJaAH20tYt~mE?Sv-(9L)} zNri|nKUu@)BDlADIx-L*3dYX}E)|(bh@VmA>85u&%DbO(Q;|u%}yEEx(NUygj>8d08X5{L~=*VndQ7W%0kyn+<+nC5BfsXT*GqtHBv8iJw zZx=YsJe@I>0!ge00F(0bb8M;rHI)?I(@40wN_^_jRU-$}@PPr}VT3<3%&v&g3jW`se!9_aGsWSnb!;g$6CMwB`e;V|jV1XcB2@<`1i$!iv!-bYAE zW1Dqy)~}*NvF{+mGKUuwc-G}CSR^W>IFr|`jyR{RcPr@XayoS`NpjRUX0muO$$Qlp zChs{R%Y+yMK=-`ITW9!9i+Ot54eV?Uv5|b1XKRJeo~lEz?9&73`%NT3AU9HH)VhYd)6q;`&Nm|RwzM+1sTq` zx6AQW%1C-Sy*yQkWmw*m(Fk6RzCVk$4c6A^E3;bduo*WEs`S$mbEzzot{WLtkI@Qv zb=l=$n%uyU167*phq?v3hBNx0p08nX%?iEex3Ir{aBi8>O^ZwGWvxrJxy7JVI#spW zIY_6fj7n`aXiG0YFkEM4=0Y#XV>ha~ZE?-mH8}g3PMJ@n?#xWnzIm;qMNK~4$yZ7& zu_Fy6EoonUrVGp-r4H@s>(IhVDD5JVA*4{}<=JKCMaiU8i|4QN>`RIuCR^dVL{9h^ z?F(yxlO)o)m_mbyJK(LzKxa(O%_QC;uYCGl%%;C{ApiLT(av;P;B@il5<;3DR5V9p zV2Q!=iah@84-A8Gv6nv;jD<-dCX`W=8evU}Q0X4@1cex7{x*ePCXWfyze^#a%pq=v zMpXIch?`!t?~d$821TPvLU#7qUFlx9N9MTy3)PWkz>sk<>D%aA>><3R33piB<`rFi z@#>40UmUl-;hb2*$ zW1-j1-aMNus;BqP>z%10Z=#64W3=(s##DWKqQ3pzmiM;5vpv;uDA94~hx`8i(BB?P z9sWY%@E4{!f>ZUU?)HVI>%+;S(^US*^&_dG#zax$Wc;m>TO+Ba-HE2%$)YD`tLm=p zd-+gW6O&cRq6RglK2cOZxg%NBst#wAhU1=aOW#nQ^0eNuB|VRShRl9((zOvEpw^Vh zs(gWQ{+>jhXDY8sl>$uV)jYCU+&TCw5oMm&9>4kcWb0e)x7t&W?MXbgXS#IH=wY>Q zOd*_Nnmo66gUTm9}F(q6A+ zG!-;H%-}pl`-j=Uu>JTGdKJl=A@6U<`)l$T0hpxFeRyDrC5E2>Am}9BU0+X! zfJg;q^ey}&g~+@{_`l?tDV3CQf}6Yo@|X_y5v6`a9?|-YloV)FulV_ZzoJa!(I{eH zfhT>?xoG{^)*FW|9a*sAM(0O#t#iSH+hTKcWo+TJ38uWbDQ|N>Y_Z$+m=^M2qcTT^ zDhmbBv%|j7R`bwqvXwow7uaeRHbE6baitIKmA1zp9x&zGybtS4w(XRsZ2{hHO5VL- z_u6tE!bCoY%{l89i0Ql3#RR+$2}69?3=fxP^L-bOMMB|ijYs3s)!CQ;I#>E!o@BpI zmV)d$HT$xj^aZ;dt<>JA5M~zx*o{SjErAEVcU@tQ>W?91}(`|zR-&<=l& zs*Yl}&?1oFgk2|Kw+2|xZ*kHnJV=7Ane;vHCj_$H!hbIId3bXclgYGTx0uWe96Wr( z3L%~|wM}uY|G;gZ;UEBFbQ^~mlwnRZ?4egq}5Y1gAKnt#J&a$Z~Wh@1!;ejnZ=aVS#MElU<9SqF7nvK>dV;l_u3+$pO3UNi7+DVGKt-s`(6Q}hy{nG#c z4Tm$Lo?<6GKJ?jo^XA>>|M6R0L1sMDe&?vX65TH|~%e<0VqbxKnbD zyChcw)@zz59d}Fa@iM7wyj&_LapsAN@k*((fvI2`8QxOG@YWYy`%FsTq$=Lf%y?~| z;OnH#QE5(TR`Jcbmj}OuK#&OhizSM6RFz|*dCc^p|2pKUHaYYPL5pSwu zq+;F-z8%Lcal93L2Pt90d^`)jODZ8TMI@$}#5hTeoy2e?#>G3JtkPNuiFcBCH}8_l z{06Dq&kPx)3Ybf`TzV`VT@b{$7>-KHY|h~Ta)V%u5XR|$-~sOt4BF4k7!Q7rVL}jF z$DB4S!hp^gUSN80X^fv>e%Tg`hXdz?OE(O%DItkoqimgziAzCAb_4=*kzgVb3eQP_ zKmv0&U6u!W7h_98??Ow$(jL)ft zwdb@;4g1_`NPFs1aij&VOV=x{=g1+@dgdPIe>&iBR@4(xr$XvM@2S2{?mn<%8jb5F!~!+L=WG_(oA+ zHW`kD0?Dw<0dGh~q<|;{LziS5G!Yf%B%m^h#n=Ui#YEXIh+<64<*_SCBuYfaDs#Ec zNQ9l(p)(UvNf6HmBeD(SkUKe)uIBoUgAIz3*CLw}LS$aV10t4UUDkL|6r$Kgg<=A= zL|V}b?y^{k+3<+TwKTe?OempwNZWwseat$p*wSqEYGsD?Y&N#NYTs;Zf3N!N za@=<@MUK}>u9RfhYLZ-&W^1x+W14MD?a4L`q?-mZ>^_oG4V}oco;2%8m1i6GrW^NW z*#3KUO{slvo=|#%)hp#&(rim=F2i;d)R|?yY1X^8C);r--Ek*0 zf-o|85{KlEkn~q!sequX3=RmGdI;kNzrlyhuE5O{&#?HnFGsrJuo`@M!mVYVv8pvS zFw=Q*%Nx|#xDl8`k(OT#P21HFZ=A7D<5nWh$EhLhsmelXozNE}ja6(RTt**msRB2h zXCXccyU^8dps@(Qjz#FkYF5j|O@7lO#%h5X&9P0j6pY419gS{gA;qrPd+nc^9%OHcnSc<4PJOwfHSmu4J7d|1_?o zTRDeXC#+mvz=xIVQG2C5A4$2s&dSl+p{-nr+RrT<)2YTn@AI@4df%t@S`9t2_g|f# z(%X4N?_rJd^Q`s_ltk)`tD}Pxx>G62NOh&?=Q_V*U@iyLKJYHJ9L!~2QG{fzn6z?tpH9xuEaK?Qt+8gWF?uwbSREI*(gQ z9j4Tpivk7C4GLV@Cn)hPmkvL3bmElf*u>=VG2X{dOisy`VKE$xEFVx6wFBVh=7w}S z+@MDigVBVdD?ma{V~fV3!q9TlH-|@`9`x{tr1%g?8Pp>wCp^6#e%U@6ONwDZ^h^pD zw1!a4J9zMM8~Qf~^#5P3zvh`dgH2j>=((Q~vuZdbW;E4gK)TGg_J9~(aU z+xsugDvz0I~A59 z0wl?H-PR8NeET}*!P>{0!kwUqTn8>Jh9zOAQ4M-@`-Qd^lnK~ZxL>phz}z~pd2lgy z9!}INy4{q=T5wusXNNpH7mg!HErjUBaj~9!^2>^c!inH)1j}#PTx3Ao_hCP{efzdg zle&sHH?WFZs?xeX>1_~ECzjm+b&LXH4uD1CWKaYcCm|O@+?jZA4j>y5MRnPR;0$CE zQI(R-BY^da2zdc)B1pp#z;TwKyol^Xwv+c!cn656C>RRmfPNKWi6Fu+qK+-I@f@%( zvrrEoL^QjRB*3db7)c6p352GEi;_Q_2*ccv_haFULTFqFFDy#3c`+Okpc{ZCCldjs zZh{>-a-a}EG_b$(7?FfqTC`##&RHoM(2-R@jw0c?b3#Z&(Oh;12)?r=M==cNj3f%u z+@jYi_G31)7>mI@#3Eo%INo?nlmHc*kL6pO$Lk7N?M{STbR@6?t+=or1rS2^E$^ZL zLMq6GQbpnyfN%gHO>e$t;vZV^x^HxjBvG(#tEuujr5<9f+c{am7M_zZQ z+1+o6Z-3{F?_}5>wX#bYwh^JDBO7%Ch1d~dPw%#@Ic|zS-T$+}4+l4zj%C>6WULCf z)Rt!3*4w|8VS~EJ)->DtuK3<}-hpNi=pucVvQ_Qrs`d=)y;r^G&5{-Ks}4+ZTybPs z?APkc2yz`vHyymyf4k{$hJC6a8M>K|*^_4Xq+pK7jF2iw%;^l}b4H&akm|N6xhZj&v~vQ25W2|&O- z3JCal2~)Etbz-A>?+VOMMV4((v+Zl8Yr|_V-)scgkR8KI~>K3_-ycm0I52 zzgBcd=(=x0|z`{Mra|S9NCCF2H8-aNc2SwxgVgUcw*y;O~IH;$@u=A_|=^e_5rasG`4et;aLXJC)60 zpjt_WLELnP>B?&tfRY&Sig3uF*17-^VjY8s#EfBkzPRzM$A`qMz#Y5**NorAl4m_p z92s$bqgrbSUy&10Q zpPV(DuJWv_G3{#1y1Z$ZcfI4OjO&OlvN!GOUGIDTj_ZZHTXU}UW?X~RjGsCgu5bHE z#dAbX>feLE(@9l#`U2bwk)2LCxwM-IUWaX>%>?W|%K4(58@0t89Z<@mep=vsw4JNF z6Mz=;Xs=e3hTN;FKEW4gW%AdyT0I5WZMl}BhG=Mv{v<>HXr=JlsKD`CPqB{5sTrwl z=}s}UMJx5_wrDsp1!dy5;-8dDc4`mvw;O;Lp}b{htGQ64pg5#aeQa6Sq?J`>WR zAM{eK1=YYS?dl!c{8EHRAegqhP$^nGcHzsd4hyGIwI)%+%N7 zY?5?AQ%LLivC&ifQ6-l+1d-w)@VvF62g9A<$!1|GEGZYr&tv2%j6@}DEJ_qY@fgX4 z*V*DYW;lV~GWwRqjEB$wN7cc^y`-3; z^yCXG`dDQk7KN*G^%@^|NI0=5guDjAArc_4Fs%wNIE|I;q40uj%G3`aX9DkXfgw1{ zvQ7{DUw3*oIaijeOLKKut})Fut~WiM;Z7ij*v>S_O*g78Sr%1-HA_yxC=|HKyCUu@ z^1OOWO>Vh+GVER=Uaa#9$VN+2}S^(6u@ zg+&SHbE7F33BLO92p2f$SwRfG(rmiAbYdll+4K?V@~G8L=dbI|y1wcxY`Iv)QweVC z%;4M)P2X8`6`I zJ!$)%U)lRMt9D&~<=QLRs{V9U|IMinUU>h7?EYuc`=7a8^~}onmipcuNL~D(`~B{0 z|M7JH@s0k8+iepY&ELGkJ^iVL;r43BEH~*49u5?e9}I%`WyY5@jgB{hS*Eeq2uo!W zaTChL!6e)S1b|=%Wn)6T0oJe4xNM7tf}$9_B*q~R%r=TeB5t3Yb*4g+QS1=$F779o zf*19_K}*VFi~Cdb-gkdc`+n_x4F0@~#3-Y|-m4BdFCiM|zl><;OXNzb$CDiX$0Qhf z2^fwItrX?nO0(4=ZPDajBz*<0K(C~gdPG~(dFv9I)ZB>jJB5s#bq^SM6K{SZH)lSI zq^!*yZ$UkBnHz@tndhD_6gcp{XMTPtiPQ}R4j+GNbnMI-$TkI5(pKeM%4Rs83E3J7 z3QMu*q_Ig8eW48IKwvUxbIiw-3nNgw@=e}_s581R{sjD-^!}8Wu57= z&P{jSdTArPer_`t6+dJdyX#8J@3#G~opCXN^9FqAF^=#rAea6KcjAHVyfOQumu|d- zN4WFDITEw|2;(s#N7zG-@Gjh#DzSnj)tz;#A~BeOF*M>tQ(E;F)o9u3VW#uKx|Io? zK^_}7Pv?))R9-QQTX<&5g0MyidRPbWQ`~a0iZ@PKXfTj^BuIW34dge)EmI85+>X}I zDV=Ip4>q`Q^AR)lR|$m;VP?ow&x8$L%d&GK3dWycI6Lf{KowwX#M5BcYKeqFY+o)r z8VpA+dB}?g&J2Jap-Ha|?hoK*A}WRFF9lR{Sg{?(DF(rli^eX_32|hgA|8FHEOr6R zQBfVp-7ILwNw(%}SPg<~gG_KSAu2Zrt$2r^PSUOoQy$u~~Ec`8#1QN34fq!vw(Pqk&aj$d&d|9Rg5wS194Huc+G zeaub!h{N=gVcQ66`fJt#;Vs`V#^fPvoGaAw$5BG$u`fyiGqb-GrJ7UEB=J2L7ogs13|6-3g^h znT^b|W-vmdFhswRH}jS!a;*RW@dyUUTX~z`++*V5Gng4CU!<0dqj404HO?>uJO+rR zj+w@@K^$KUxy#iz?a49ydFX1%ZioSn|YHa?HqzV?Jx^VAMtVMn}(F9FI*0cCi*-^O#gFCg-!hrE+_^_t_w)411& zqZwf~GG~7PGhC_)6>+=YPJ@qBDjMhf9CdVVbw>CyvKD|00E9`;Ti%UMCRNA6xW_gn~`KZf6kB|te0 z!?zkCAtFe^q-^z#o($|g0N>-;63JQVQe1%V$l`Os$lS7}`=vxIilS-BTrdvgR|vqz z6><2!!Ytv{142~GP9?Kv9(bz|8uZ9^9UGI`P*Myc?_6$Ll%#lKu(uaJTkBETY)?#F z=sn-x8;QlvCF8OcuJwTt%jP8kw5Q@cjFrHSgZWi(DM1>AQ4lKv*%&(~TgbN|pap|P zM{oi7?laMI(b$FPa6NBQVV-P343j+t`ZeF%r5WxUOHcSBqn4ATCZVoyjo5W-kdyfoj zE`~-Ad=kWqC1DIdY{AZh0fH!-aduIh2*P(Z7>Qr5nD9$WV+na)BxyWARYw4;diimw)r?wLd8GC3a(?6DJe$8H!%cNCE~GY zLIC!OimD0vBff(v=2>V#M8pqG z7U9Q6*y>;<;i$+u+JEI}ziGXd%sR$yJH~!lx>;TGw)G9`%EUtxQ*z93-|Q}G{Fp0w zZT$P=SD#B2zsJ49tHpxx_g}nuYQ6dBM&;PLYwTX_?zcOB(2?@L_u@M* zZqyEd*i_q?>b_muxAHWE-tK&(GxgHV#*Ny2E90MA8He+=iSJKb4QDvd-O~Llrn_AE z>b~r*uJo?1O|E8jXp^gaYhd-_n};_!_v)ehMHYAQiWP3}%j>Qm`n^Lb)2)$A*^w1? zvuxM(!`BYKeq_ab7huzaR}Nks->}!G$}{$sO?Tz>_G|4hs*w$MTh`r~c6Y8#+`P2m z9$p#!*u6VdyEeMEf4yPvhP!{A>%W`J{$y%)!|h$M{gbou*JV|AEAW2qd9Xl%d%I_^ zJ-ZrN8`-GnT`7ipI81h9a`%d9(_L}B?ONMvTk6RTclU}#efOsL4R<@#_QX?H+g2O@ zpyPVawVqVvT2011@K+Nn*84CO6<6msxw5MQdZFu!*A`bVtQj^cJMP=euHyR)s%8rY zTt!&ib;sC$e%_v+zT2GViv+?R?>NTWEgy`uj2~it*=`?yvgntGswc`#bFf45&H^DK zg54qe23T`GNej@dxE}+juxT}@A(i=l6*#m2+jvZ9bPKj&mj?5RWE?O_Pe(6CGGN~gjh8}DY|MUV1 zX&>+J3mmm34r+4f$Fi*^TAyem(OKEUT+-U7mC}tLSHbI?O5&zLbm-`*$uaG+s;@5? z92gjyTxP)l_~h`^==c|TGtgiV?KFp$tt8$f{u$J;Z0hN2(HAH73d^i&MM~)TvT8W$ zA%tGbK1bZpjbd`cCC);H3gY}UhQ`p-F0}|MbrRzy!Beqh1sh2X`UR_*gn(JKe zI(&zF>Mq`FDQY!lT(hp7zq#<|-@TPs?-^ZhJ(}UhwinBAO8vXj-0m#bn&w*9s&BU5 zs=nPnvc7k8y{(`Wsh7Q3dCT_WhV{PT_12LLH%e+%D(FgcUF+RXWw;~Tn}Qy0hq#bX z0&#rY-w;sr>py}5B5u`3E6}@fWl*ilLDEBrUJboNaOfeWJ9=7ehMa^}_xyjR)sIgz zb>BYjKxt{zK_|>ZDHZQN&y|T>gk}WSQ2VVYnj&Hyn??Y4Sqx^-Uc*~ zok}9wf;4c9=TGtC-@uz}Az_U~0X3^pc{v%3~MAcEGR8YF1lcf9moH3^cz! zbouFrX82K)f5Trk`KUgyEqZuIR`;-NR}?`U%|s zuo^%{0x^Cj8Jw0Q51W8|;@#d5CMy(_wC}AJe~f*3AHDa``yqPYM(+dgWJ@%;6u(4p zP`N09KQe)es(1s_N`05kfUb3*=XCCOW)dPY1@Zv_`9Ode)DxS2<%b(ev;w1``jQU= zh*^rVUJfn!crr{rDj=P_!I14lrcP!LDSO~4@z>B6@Cha{0^WU-!C-h`Wemk1G4_ub v=SNKGM~sUE+#fNO4~-^+@gajA%NTmnO!q%B4WC<|F&lMkaEbbbvWUY?Exmf+P&vCHrudRK@Ge$E$}Ol4IB@IeFf4+%@c$+%4=? z!-!WKuJ~PD%j zq_o~|XlK5fPpQk3K7(vN7L7%G7TJ30m>8Ol$knF;hZ7fH49zBHBow|FIva_haCAaU z#9~8YBqZBTjYY$g5y@wiji&%J8l8-XV(7|p>R2K!$yNSPIGPv<#Ursf(?H+Oxf+oQ zo=s9oUo0^ZicJ_WI{he^@uL;4?L>5m1=tzmzHc!WMp-jEWtcMf4dbX$g5Nl9)YARN zcD88xl=8B04$DY}q$$}dMx+@rzG#$9DM^&g;pl{f$yv`O#OaWfqEXxA{{E@NbfkYW zgjI$6r)SSb`lvLhG_#p4MnBt?Qju?d}c@Jnq%bcsD+ zHHO-I6umvMN}nHwd2S80jIWgv6bQ@sTK@rwkns}R%*M5Sz{>r=t-b2{62?*EIlc$| ztepjnyA6ILPFqMC{HA~b=WQBPTl&pBZ`Sh`p10_EE6>}se80J!d0BBEtt@P%y>4Os zY{s|+qea;P(-syr_=MAhB9TyPcHJ^)33d3by@=?b;Sw7)j_Aukks1O_8!~AK7`oNc zFgBr?1wc4EX#3RAj-}%`jUp|d5Yt2`W|B?kBeSyUwNOkpOQFeBiiYm;UJ@Je6+x;@ zZVmz~!$CrTAc6E9%I+&)eRI!} zV|zA?NZdf3Swo_P!T)YC`J2Q*>_MtUbiO-+hSvlXJ*r`p9WOypAM+4hotcMB)iZ5>QI;n~G@F zZvvw+i*D42Z4|XrR6|iMBA-EQz(*lS7+SeuGo{Qq$f+jQlTeHSx?r0wPH_ZzeS&u&2LExkAQ<9 z(c$a?yEdXqi*~s zV$tcSH0RQbdt;!mIfodDfw4q*j^oBju?s!O>{lm0H6w9SBs35^saLb?nx08TC(eqY zXnZa{nUZT#Ajyd-5S2PuN+G;$1YT7{IFbjcnHa=U5mGiGEeq-dQnF1=6$j)qNfEd1 z>hh{VA>+DWERi@rlMI&3M|>K9G}+XLh=`Imf|6@m3N-^aYX(*tH?P!fT&bnA;B9I1U3cT<*XLi)?8)xRySvinkE`7) zbxj%LoBPtw-SsqO8nV^}^OC!FN$CCao$C6BSVJ#n|BYRT+t|+yhk z;@{M{C~k8e-evmDfC2FY8t8$}jKbP!L?IS}Sa_9520xRGXNDwGBTE?kkVKGU29jd| zrb)DzCuWc!!;m*ojo-|*8jIf|0ba5N*a3qifSK52$FL%qkH#fY5lMt}6m+uRfeeYH zN_xbo;jf^rA;w6Ejl`lB6&591qVaI#B4^>UFddOX;P#S$-h$$Q&*CWmdj5p;gGx-JNX{dI*1GuBL^f|-T`bp9 zeqE@yuyLnWS|aMf&_;;qmxVLJ87ut;RXjoemJyN$W#NpmWCo@Y+WE5JH)s$CbfrRJ z!qAY7No$~TD_vhIX$3(yDfXbHU^l3=!FRe4c5dB-<;tjobF04}#-}B0rAu$??&F#^qxpU{abAtm1N>;GnD~X|aDj5;26yyxwBp`*?WLzEAqvfF zkPWgk6_L1YJu1z{A|kOr*#sM#SMajYsYpZ;X@PPrfRDh6#=KJ}LgHjJo|3CjHku4g zkVeHV9y>R7;xN}mnzL-95zt5^Mj@LI!={z1Q8tEkkBOnAY!8LQiqQ@OCz*`I!?O7+ ziD=yClFg||?3{QA4HfI<3)F>G**V!7<9>h=ZOSGkl0h+Xp{Od!_SDSTAR0(eq$r76 z*{+I5h(;GW?4m$OMnM&m&(lB+Wnxm5h!lsFqu5?0;+jve31nm8>#rv*SLq+MV}3taeN0biSr7ZCw#u1)()3v}Rkgrxpwg1It1$ zFWZ#r^pW zdzOX0s3-_qa>ACSt$}4>jAu9Jgw2`fmW5V6sSUKwCgkpVHe7dHb!5i!p3Z`&XUWsE zPzxPTEe+&79eSzxZhd3g{&BTu#nDi3H0K=6nU|Lx?WFqY1*fxZKlswJqw5dV^>-SZ zGpDn|@11z}#0_7*d&DU-!_k0E-ueU(0o?L7Cel}J;#(lOoOgF-L$}=9mV|A57Z-6dCw+F}Z`2?7TU9VPoe0ms`w}EK z*;i*mF}21W1fvpDQY;#e#1rtF5zUKydJW(VaHd5kf+0x~qi1KNNDA$1O4RC!NGwL| zQ!P@ma1gyho#^=Rtm0k~4^SyZL-?gge*V|rV37}u(4#*iW%@zD0EBozSiw<)uZFjn z0I(itD=Dn~1W}n4lTxghSIw>LRSSHoDym2}znfb#+^&HwBao*~vOi?eRPhoEw{fi8 z#;N8dHeMp|5(h7VpBHwJ;^}qr5_o%2qWF7VBH8K>MN^Tm_(jUtgJ)-=v2buE3a_PR3Ul}6tZbV|#N&|( zNv;hh;$@ZafO7WqGIdL2nj)0x>ME7^8edW{6qd0f@?Rrp=;aTK{0*DZ=I=OHluwxg z@V@l}=c4o0=BJj0Jry*8RS&Z}FT3Vl%fbd;-JBDe3qnUu=*XTfbRNug9=t6)QGz1E5V*rA}!sH##(jzSR+y}7N^!b zh)}nVwKA|1QhLjRf4!HZQmYC!RLMHLWgQ}0vX090sNmIr`?YwE?OLBDYtwfQYwOVJ zi{FapQHmQSxL2vYHad1uXM`Aul&!K*5nFL%N&iZ)wQ^gsP09jV=_`N4h`zEW$Z9*o zS#zaR>v_0M<((@Ty)xESJi}ruzYP*o_>??gbB>W0hra9`K0bQ-CI6Sjqg3?ub&Os! zH7jxEqT#&W>|ED@#^oP2nUk>1z>xQQApnZ({B-EM*xhwql-$8qGT%#W+;#1^kkO6% z$IP)PKB$R0+~JV8|FsBgaa`M08n&e4NBRqJjVev;_wFlQAPqr5*%)1>4VJ?^a#H)J zLh*1c^2C#?baa2&1m-q8>Znip8oBu{z5vt_=~9#%=Hf|85yuu^qKFGu2MqI}81Brc zVdIO$;^k_dKZ1Nj6i*Q(Nd(!N;+O8+C!mN+e5agPKePtnEj!cayyTs z8iAu_Vpguz3x;ABLbIut&Ycqp(dBBodQq>n)r?}|%T++bQ3-eF6H~D0;X6?7_!WsR zo5e%|SN&7CXpM-9RM)JH8S1xbo!;4}>knyErlx?&FywKwIhc2QVPTNX-jWkqvWDzH zHndQ)EcBp^tmceiS=jVRO;g4NbDW#+B)-2llNrmNeZTXA?Tg#+H=SEy z_FL@ERR^=fz$T0O?ZfY!eCwpzE81UqZCTj*Nn`7^X{Zv_G*tC`bwTLL30+xfS=gbM z?92%}e^y%g%_K_M$SQ2@cZXNl{tzB6^$w-fsXyH1hjeTz^kRrX#qb ztw*EB+Til0vZF6~C-HMMPEilsG#4z+dkoRL_57pERJSkyrK(+yH9s+X@Xq_28R>3!Lc7n4xjolKzjfmeub#*ECl-2O_ z#b$$isrqe{s&toB@gv&H_gmMkSDDez!)cg&xGM!o>MKeh4-?;ilzOHk@aI4Ojw%n;}%kIn9 z?MMs6vz_x!MSx|dmbSnvy$gkkYdiDQ!tk>2)LnPe<*&_uEi+tjciwb&E_~^O@x}39 zb{BdN-|RiS($#bM#QcfOww$B=4tMk3bTqFtZ7wwJ$TjU)Y23Qh;6oM<4xFwB0&~~T zN4`6G&&m3Z7#=2Al|tAW9mV4WMET9R_zW6|&m#JKs{L0$6>q&wmO=8%4!%q5#4`9( z?yMdB2!a7QWrk)n&;rpOr{M)8O6~En+9Ji^u}x)a)G1f%4putdlB=SJTOzavWuX#< zY8YIJGE40tauz8LU8~}l{3be=9SW){Oj!In71d~}wXU$i*&C4pq1X%_Wh$(_>~6-w zF+D}A;s#u79%&~EkZjR*XJ-cs9{|4p(kG)QpeMhcL((BtI{m=YXp774n70RlR<~!alw2o zep9K^6tMbDBmo{VrtWchqA?$W*2i3@s9{VqACrwdAq|c-YWqm9J%CG=;`LG@fOWzQz(n%oA_gj+9+x#0NeE0AieyNt+-vCjD%A*l&ZIv3aR}a zfo&}#wKB=Vaa7?}+D#%|Fs{E>Ok4ioZg}VA-#ThPsqM_3%hwL3Z6CWEDPPlYW$M~i zfV&$m&&|(ex(n{Mo9?#kh4)^6_w^fl3*859b{|;T+L@iawY4vO?8@Hxmo(6>g1hym zyEQxc-uZXW-{>lIJ$6I-V*}xBifZQ{Gk}GhgB9*km0H==@cEV_9TS*hrX%M zYyOMrq4^6}jJV|c?$=j3y0V9^n6EppIy0f1$NO7P!#x|@yn9uE+gwpbZVSythL7HO zpc#R!32SC-AGg!giqcOdccZp7XAJwk2gMaj1`}c zZ1@Ezif~Ln2L5rPzJ3EF9y+@CO*ED*6L>}hcaao29~r{;0{11!)nUB&l_D>4BKQdb;t4JkAi8(L9y9U# z6!DAPEK(|$md&{Oo)&LV9cS2_v2dm6A5o_8XT{uB2;>8lKBNkjQrmx0vtJ>?p9HvS z3$Cp>*VcloBj@VKySm_wa&5<}*+N}=uCBdMw>?+49WNs4dehIXxa;V7?d3PV^Tvv& z{(AM*>TAw|$CvZ?ZhLw@>E8LX4ZrO8b?=A0`GMiv-N&Jtr-yRx_AA1j`lgKe9p_ul zd_B6qRlhxb^iI7u+i+uBzWzY^=!(OmJfkQ$d^v{?T^BrFkYmBqmGgAvJ=+!rZh3aB zcpCm>F*#jpHn#1HiWU#a$G45_9lr76Hz-AlrNVaV#7Rm|Q52=ipYo&Nu?A?*onk)MA}vnsLaF|ML2#Y#u0I^gLVs_q*97#PAPCz|Oa6n@FY`%jDg|rFSAvnVHSnPAtlDjUqd$93wcbVOD z0IRBhD=3!i$f<%AOFk=69IKJyRF&$sKQ!uJkP}Bht4OihsH*!DjorHWrRkf!+uZ}s zj@@PudvD&GdGGDazR!Czhf6&!1nq0@SCXv^p})|M-t2ke-ZT*BQ3!F&aWuv#%$P;7 zj9C?H6XjWs+s5pQeT-GuF^A&Nc-G^1%&9oXT#9R~L@60_EAA$=7d0c!R*g9OD@>m? zr(5xGY%B6R?$WN`s)D0a?Tjg%Y@UL}SV2WMAskWrjLG{h(_`Z8+S z_^HvU6C(I>S`ubOC3OaDxPl^4qzE7LQK@g9iha=p@y(C~FGP4*@y#dVl9C`&e73{& z!pJEf*WEMVlNCNL@I)B&o#Y89^R@T%=2ZOY<8B~)2gegBAuh_Y?-Wdz{T;4|{&MtH zZSW6$eDALeLeIi$(%sCpDQgh@6m~gi=>r*UP3ZKVxFtse9|asbZT^M==4cq zPMg1hT9ekPkf6_RqAOHvR-Hc9{0;0gX`O=EjXG`qeyjFobF5C8@5x!<6SUq9fMn@? zm#?7ZLd$n<8vnz4^0q3gaJLt1)rRVNrJDnPiqew z+D>CHB@g=glMCC&{LD~3;V)P1aw-X-i^E|FKH_j#WySa`M6c?RMP*c45CsS&WiHpH z5HY1J#zaCtbkzz_p|Tt=$)cbQ&}36{qNu3O@JvdI3E`AP=!d1+6%9N}KYzLdG>%9j9tTiS*;G>C6%o8rctSD8CX2Bsp&yvaCV2v20-D2-7%$7=uuMa2|Ni~w z)t=6|#Jt!!%acS*=$v1ik>Z_n0TM|*F2*`zsi+**0zdq^1`Hj^MdGDOYjz+;hrSLp zLge@1@$d8KbL?4hWN^)DRTld;s~Rqp+`;Y*T$jOht36rVxT*K9_Fo&kGWa3;k>{po zz5YlR_Zy-u#5TC^Bi8BJ+Jl^)i=`{2SzPnw-ul&Jm#3F)7hEv%qHD#qfg3ZpaaGRZ zR)a$gZQzy+Zb|pu7`i@`#hr$%2K@RA*WCk8Q6vv0lty&bHTbc?6TruinPHe@f=qz! ze(e;G}L9C}FNJV;>BrQQpF_@Y*fQhq&T+=wG10cx&C!K?ko{n^#5ZIQ0 z$CT?7XWcde66Ve}y+`K+=*I471+$re0Z8VcLLi>5*7Ju2 z55jlRwjGkSWRTb2Z6bWH)*d$0K=|H=BfOtc9kN11B{H{UZ-1t%qid<;Xd*=WW7Dx*zxTC;X*^VuR`hI6n$SAmFlm0;(h_E)xnBU=^|l2nYoO)iwgKOlaJw zZV(K|q}lj9%pux_lxY2<(1%3ipx(;m78 zROf&%5}AV8exHxD!Y`pEgj%}`;Pt#j$H+ARivaND4XY>D%3GG%O;IEn8JWjCYrpUg+m^rlmvt{FbH%MDQtBVNJ7zFOdcpw z;OfX9C{o&nEFlZ$;XHbFOxidmX$P-L2O#qZ@Ch~`u9RlS5E^((=Y){P8ErFP1O@GF z1@D(+3rb~Mz{o=81l_A3+h*ir$f5bno^(tbMRN!RAE_Y(2S~$z48E6Xz7J5}gE&xc z+SI8Knl=h)&N#O88F}gDu^bwUB#-Y2r{*!>2NVcW>XrpG7xA2P3Shn@eEwPLfZfhPSMGJoOgY&Ls3 zWqjYZ00rkAtw8#S*w_|dj<$lD6KqmI+tCcIjv(&KM-?E~BRzQBIOSZS&h0T?=ufd2 zF7!U>3|a#RcDM`Of`}?Hb}5em;JG#6-#zEi>wWsIZ`qAUtXrYSL8p!>O^9tPp3f74kSC&1jfh4gkvx$fLj_%3IfZEJInWc; z+Qi-eeDlpW((mcx&-C#(`uID2Jb;IPFQFi>x{~y!>V>nk^w7?fZtY|kNfCOoBt3Kt ztz1ru3ksp14(X;W8=+@Y%nPrkWCf18R#2j{bD}gmrx3TMSgV8J2tTY43h642O0gKd zCsUaPm048naPEXtGz;SbVqC=n;a{hhWd4#|A!8S*Q>u;nqdGJ9@03+sJhyUgqwGMY?7*_kC?ROkk+!9$R+pg4`O;gr+P6{PmZ@)B^R_QL zHoa9F-qwt_b;Elw<2|_MJ+$HN&3Jon4c~h0cFXOVHSh4UZ40BSx@Fg$lCn#!7X~*g zs+Ny!0^NLJV6&of`PiMxn$@0P9#7Y;R~%l(pI1~}Qr{2^f&%?1y}Buuuj*O7gg50-T!9)<$o#F1D`Cd4^FQ4a_cQq zYmFz@D^4!sO{dwd#Xe><5Nkt6rqARSV%;|VeL{!EBuM}sqckLB87+B-!QKzM4Cw{BHSCP<@qZlL5e65 z5#lJUdh)DX*Kg5+$l%g}po&&I&p)3FC0c0``k-a2?1RVGzks8}KP*-|e&DLG*MD`W z&VJy*K%YIp{1efq_UhUcI6tZ7fyF6F5c@kOmE3tp)($+{xhi*tDm6zOO%PE#ENPc% zgql=6ruap;WuphJ9A?qT*I-Grbra?6(u}I&XLBq1141vOY0{8sw%D>V4D*0R%wPt6 e?+etHL2X~4ku@}OpFP7c0p?QlUq~Y~RsRKJ$grIN diff --git a/__pycache__/midi_engine.cpython-314.pyc b/__pycache__/midi_engine.cpython-314.pyc deleted file mode 100644 index 88a303e1ef6cd729689aab6afb7556e0e302c6fd..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8510 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 diff --git a/__pycache__/playlist_widget.cpython-314.pyc b/__pycache__/playlist_widget.cpython-314.pyc deleted file mode 100644 index f97dad9ac3a9fc4893afc72acee4f2f43ba6949c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 38221 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 diff --git a/audio_engine.py b/audio_engine.py index 1edc91a..1f32fd4 100644 --- a/audio_engine.py +++ b/audio_engine.py @@ -6,8 +6,10 @@ import jack import threading import subprocess import os +import pyloudnorm as pyln +LUFS_TARGET = -14.0 SOUNDFILE_FORMATS = {'.wav', '.flac', '.ogg', '.aiff', '.aif'} FFMPEG_FORMATS = {'.mp3', '.m4a', '.mp4', '.opus', '.aac', '.wma'} @@ -52,6 +54,18 @@ def load_audio(filepath: str, target_sr: int) -> np.ndarray: return data +def normalize_lufs(audio: np.ndarray, sr: int, + target: float = LUFS_TARGET) -> np.ndarray: + meter = pyln.Meter(sr) + loudness = meter.integrated_loudness(audio.T) + if np.isinf(loudness): + return audio + gain_db = target - loudness + gain_linear = 10 ** (gain_db / 20.0) + normalized = audio * gain_linear + return np.clip(normalized, -1.0, 1.0) + + class Track: def __init__(self, filepath: str, audio: np.ndarray, sr: int): self.filepath = filepath @@ -260,6 +274,14 @@ class AudioEngine: self.decks[deck_key].load(track) threading.Thread(target=_load, daemon=True).start() + def load_cart_track(self, cart_key: str, filepath: str): + def _load(): + audio = load_audio(filepath, self.sr) + audio = normalize_lufs(audio, self.sr) + track = Track(filepath, audio, self.sr) + self.decks[cart_key].load(track) + threading.Thread(target=_load, daemon=True).start() + def load_queue(self, deck_key: str, filepath: str): def _load(): audio = load_audio(filepath, self.sr) diff --git a/cart_widget.py b/cart_widget.py index 48a5222..283f5f7 100644 --- a/cart_widget.py +++ b/cart_widget.py @@ -156,7 +156,7 @@ class CartSlot(QFrame): self.btn_eject.clicked.connect(self._eject) def load_track(self, filepath: str): - self.engine.load_track(self.cart_key, filepath) + self.engine.load_cart_track(self.cart_key, filepath) name = os.path.basename(filepath) short = name[:28] + "..." if len(name) > 28 else name self.track_label.setText(short) diff --git a/deck_widget.py b/deck_widget.py index e120d81..6bf6cf0 100644 --- a/deck_widget.py +++ b/deck_widget.py @@ -4,7 +4,7 @@ from PySide6.QtWidgets import ( QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QFrame, QSizePolicy ) -from PySide6.QtCore import Qt, QTimer, Signal, QRect +from PySide6.QtCore import Qt, QTimer, Signal from PySide6.QtGui import QFont, QColor, QPainter, QPen import numpy as np import os @@ -24,26 +24,18 @@ class WaveformWidget(QFrame): } """) - self.waveform_data = None # Shape: (width,) - RMS per pixel - self.position_pct = 0.0 # 0.0 to 1.0 + self.waveform_data = None + self.position_pct = 0.0 + self.warning_flash = False def set_waveform(self, audio: np.ndarray): - """ - Downsample audio to fit widget width. - audio shape: (2, samples) stereo - """ if audio is None or audio.shape[1] == 0: self.waveform_data = None self.update() return - # Mix to mono - mono = np.mean(audio, axis=0) - - # Downsample to widget width - width = self.width() - if width < 10: - width = 500 # default before widget is shown + mono = np.mean(audio, axis=0) + width = max(500, self.width()) samples_per_pixel = max(1, len(mono) // width) num_pixels = len(mono) // samples_per_pixel @@ -55,9 +47,9 @@ class WaveformWidget(QFrame): self.update() - def set_position(self, position_pct: float): - """Update playhead position (0.0 to 1.0).""" - self.position_pct = max(0.0, min(1.0, position_pct)) + def set_position(self, position_pct: float, warning: bool = False): + self.position_pct = max(0.0, min(1.0, position_pct)) + self.warning_flash = warning self.update() def paintEvent(self, event): @@ -69,30 +61,39 @@ class WaveformWidget(QFrame): painter = QPainter(self) painter.setRenderHint(QPainter.Antialiasing) - rect = self.rect() + rect = self.rect().adjusted(2, 2, -2, -2) width = rect.width() height = rect.height() - # Scale waveform to fit height max_val = np.max(self.waveform_data) if np.max(self.waveform_data) > 0 else 1.0 scaled = (self.waveform_data / max_val) * (height * 0.8) - # Draw waveform bars - pen = QPen(QColor("#00ff41"), 1) - painter.setPen(pen) - x_scale = width / len(self.waveform_data) + for i, rms in enumerate(self.waveform_data): - x = int(i * x_scale) - bar_h = int(scaled[i]) - y_top = (height - bar_h) // 2 + x = rect.x() + int(i * x_scale) + bar_h = int(scaled[i]) + y_top = rect.y() + (height - bar_h) // 2 + + pct = i / len(self.waveform_data) + + if pct < self.position_pct: + color = QColor("#004400") + elif pct > 0.85: + color = QColor("#ff0000") if self.warning_flash else QColor("#aa0000") + elif pct > 0.70: + color = QColor("#ffaa00") + else: + color = QColor("#00ff41") + + pen = QPen(color, 1) + painter.setPen(pen) painter.drawLine(x, y_top, x, y_top + bar_h) - # Draw playhead - playhead_x = int(self.position_pct * width) + playhead_x = rect.x() + int(self.position_pct * width) pen = QPen(QColor("#ffffff"), 2) painter.setPen(pen) - painter.drawLine(playhead_x, 0, playhead_x, height) + painter.drawLine(playhead_x, rect.y(), playhead_x, rect.y() + height) class LCDDisplay(QFrame): @@ -188,6 +189,23 @@ class LCDDisplay(QFrame): f"color: {colour}; background: transparent;" ) + def flash_warning(self, active: bool): + """Flash all time displays red when in warning zone.""" + if active: + self.elapsed_label.setStyleSheet( + "color: #ff4444; background: transparent;" + ) + self.remaining_label.setStyleSheet( + "color: #ff4444; background: transparent;" + ) + else: + self.elapsed_label.setStyleSheet( + "color: #00ff41; background: transparent;" + ) + self.remaining_label.setStyleSheet( + "color: #00cc33; background: transparent;" + ) + def make_transport_button(text: str, color: str = "#3a3a3a", @@ -225,8 +243,13 @@ class DeckWidget(QWidget): self.engine = engine self.accent_color = accent_color self.current_theme = None + self.flash_state = False - self._build_ui(label) + # Convert "DECK 1" to "DECK_001" + deck_num = label.split()[-1] + self.deck_label = f"DECK_{int(deck_num):03d}" + + self._build_ui() self._connect_signals() self.timer = QTimer(self) @@ -234,7 +257,12 @@ class DeckWidget(QWidget): self.timer.timeout.connect(self._refresh_display) self.timer.start() - def _build_ui(self, label: str): + self.flash_timer = QTimer(self) + self.flash_timer.setInterval(500) + self.flash_timer.timeout.connect(self._toggle_flash) + self.flash_timer.start() + + def _build_ui(self): self.setStyleSheet(f""" QWidget {{ background-color: #1c1c1c; @@ -247,21 +275,20 @@ class DeckWidget(QWidget): root.setContentsMargins(10, 10, 10, 10) root.setSpacing(8) - header = QLabel(label) - header.setFont(QFont("Arial", 13, QFont.Bold)) - header.setStyleSheet(f""" + self.header = QLabel(self.deck_label) + self.header.setFont(QFont("Courier New", 24, QFont.Bold)) + self.header.setStyleSheet(f""" color: {self.accent_color}; background: transparent; border: none; - padding: 2px; + padding: 4px; """) - header.setAlignment(Qt.AlignCenter) - root.addWidget(header) + self.header.setAlignment(Qt.AlignCenter) + root.addWidget(self.header) self.lcd = LCDDisplay() root.addWidget(self.lcd) - # Waveform display self.waveform = WaveformWidget() root.addWidget(self.waveform) @@ -286,30 +313,38 @@ class DeckWidget(QWidget): root.addLayout(transport) def apply_theme(self, theme: dict): - """Apply theme colours to deck widget borders and LCD.""" self.current_theme = theme + + border_color = theme['border'] + bg_color = theme['bg2'] + self.setStyleSheet(f""" QWidget {{ - background-color: {theme['bg2']}; - border: 2px solid {theme['border']}; + background-color: {bg_color}; + border: 2px solid {border_color}; border-radius: 6px; }} """) + self.lcd.setStyleSheet(f""" QFrame {{ background-color: {theme['bg']}; - border: 2px solid {theme['border']}; + border: 2px solid {border_color}; border-radius: 4px; }} """) + self.waveform.setStyleSheet(f""" QFrame {{ background-color: {theme['bg']}; - border: 1px solid {theme['border']}; + border: 1px solid {border_color}; border-radius: 3px; }} """) + def _toggle_flash(self): + self.flash_state = not self.flash_state + def _connect_signals(self): self.btn_play.clicked.connect(self._toggle_play) self.btn_stop.clicked.connect(self._stop_eject) @@ -322,7 +357,9 @@ class DeckWidget(QWidget): self.lcd.update_track(name) self.lcd.update_status("LOADED") self.lcd.update_time("00:00", "-00:00") - # Build waveform when track loads + QTimer.singleShot(200, self._update_waveform) + + def _update_waveform(self): deck = self.engine.decks[self.deck_key] if deck.track: self.waveform.set_waveform(deck.track.audio) @@ -428,7 +465,7 @@ class DeckWidget(QWidget): if deck.queued: deck.load(deck.queued) self.lcd.update_track(os.path.splitext(os.path.basename(deck.queued.filepath))[0]) - self.waveform.set_waveform(deck.track.audio) + QTimer.singleShot(200, self._update_waveform) deck.queued = None self.lcd.update_queue("") self.lcd.update_status("LOADED") @@ -453,9 +490,38 @@ class DeckWidget(QWidget): deck.get_elapsed(), deck.get_remaining() ) - # Update waveform position + position_pct = deck.position / deck.track.num_samples - self.waveform.set_position(position_pct) + remaining_sec = deck.track.duration - (deck.position / deck.track.sr) + + # Warning if < 20 seconds OR < 15% remaining + in_warning = (remaining_sec < 20) or (position_pct > 0.85) + + # Flash header AND all numbers if in warning zone and playing + if deck.playing and in_warning: + flash_color = '#ff4444' if self.flash_state else self.accent_color + + # Flash header + self.header.setStyleSheet(f""" + color: {flash_color}; + background: transparent; + border: none; + padding: 4px; + """) + + # Flash LCD numbers + self.lcd.flash_warning(self.flash_state) + else: + # Normal colors + self.header.setStyleSheet(f""" + color: {self.accent_color}; + background: transparent; + border: none; + padding: 4px; + """) + self.lcd.flash_warning(False) + + self.waveform.set_position(position_pct, in_warning and self.flash_state) if deck.queued: self.lcd.update_queue( diff --git a/fingerprint_widget.py b/fingerprint_widget.py deleted file mode 100644 index 99324fc..0000000 --- a/fingerprint_widget.py +++ /dev/null @@ -1,296 +0,0 @@ -# fingerprint_widget.py - -import os -import json -import tempfile -import subprocess -import threading -import requests -import numpy as np -import soundfile as sf -from PySide6.QtWidgets import ( - QWidget, QVBoxLayout, QLabel, - QPushButton, QProgressBar, QFrame -) -from PySide6.QtCore import Qt, QTimer, Signal -from PySide6.QtGui import QFont - - -class FingerprintWidget(QFrame): - result_ready = Signal(object) - error_ready = Signal(str) - - def __init__(self, engine, api_key: str, parent=None): - super().__init__(parent) - self.engine = engine - self.acoustid_key = api_key.strip() - self.recording_seconds = 25 - self.last_signal_check = False - - self.setFrameStyle(QFrame.Box | QFrame.Raised) - self.setStyleSheet(""" - QFrame { - background-color: #0d0d1a; - border: 2px solid #4444aa; - border-radius: 6px; - } - """) - - self._build_ui() - self.result_ready.connect(self._show_result) - self.error_ready.connect(self._show_error) - - self.signal_timer = QTimer(self) - self.signal_timer.setInterval(500) - self.signal_timer.timeout.connect(self._check_signal) - self.signal_timer.start() - - def _build_ui(self): - root = QVBoxLayout(self) - root.setContentsMargins(10, 8, 10, 8) - root.setSpacing(6) - - header = QLabel("AUDIO FINGERPRINT") - header.setFont(QFont("Arial", 11, QFont.Bold)) - header.setStyleSheet("color: #6666cc; background: transparent; border: none;") - header.setAlignment(Qt.AlignCenter) - root.addWidget(header) - - port_info = QLabel("JACK: RadioPanel:fingerprint_L / R") - port_info.setFont(QFont("Courier New", 8)) - port_info.setStyleSheet("color: #444488; background: transparent; border: none;") - port_info.setAlignment(Qt.AlignCenter) - root.addWidget(port_info) - - self.signal_label = QLabel("● NO SIGNAL") - self.signal_label.setFont(QFont("Courier New", 14, QFont.Bold)) - self.signal_label.setStyleSheet("color: #330000; background: transparent; border: none;") - self.signal_label.setAlignment(Qt.AlignCenter) - root.addWidget(self.signal_label) - - self.progress = QProgressBar() - self.progress.setRange(0, self.recording_seconds * 10) - self.progress.setValue(0) - self.progress.setTextVisible(False) - self.progress.setFixedHeight(8) - self.progress.setStyleSheet(""" - QProgressBar { - border: 1px solid #4444aa; - border-radius: 3px; - background: #0a0a1a; - } - QProgressBar::chunk { - background-color: #6666cc; - } - """) - self.progress.hide() - root.addWidget(self.progress) - - self.status_label = QLabel("READY") - self.status_label.setFont(QFont("Courier New", 10, QFont.Bold)) - self.status_label.setStyleSheet("color: #444488; background: transparent; border: none;") - self.status_label.setAlignment(Qt.AlignCenter) - root.addWidget(self.status_label) - - result_frame = QFrame() - result_frame.setStyleSheet(""" - QFrame { - background-color: #0a0a1a; - border: 1px solid #333366; - border-radius: 3px; - } - """) - result_layout = QVBoxLayout(result_frame) - result_layout.setContentsMargins(6, 6, 6, 6) - - self.result_artist = QLabel("Artist: -") - self.result_artist.setFont(QFont("Courier New", 10, QFont.Bold)) - self.result_artist.setStyleSheet("color: #555588;") - - self.result_title = QLabel("Title: -") - self.result_title.setFont(QFont("Courier New", 11, QFont.Bold)) - self.result_title.setStyleSheet("color: #555588;") - - result_layout.addWidget(self.result_artist) - result_layout.addWidget(self.result_title) - root.addWidget(result_frame) - - self.btn_fingerprint = QPushButton("FINGERPRINT") - self.btn_fingerprint.setFixedHeight(44) - self.btn_fingerprint.setFont(QFont("Arial", 12, QFont.Bold)) - self.btn_fingerprint.setStyleSheet(""" - QPushButton { - background-color: #4444aa; - color: white; - border: 1px solid #6666cc; - border-radius: 4px; - } - QPushButton:hover { background-color: #5555bb; } - QPushButton:pressed { background-color: #333399; } - QPushButton:disabled { - background-color: #222244; - color: #666688; - } - """) - self.btn_fingerprint.clicked.connect(self._start_fingerprint) - root.addWidget(self.btn_fingerprint) - - root.addStretch() - - def _check_signal(self): - if self.engine.recording: - return - levels = self.engine.fingerprint_levels - has_signal = (levels[0] > 0.01 or levels[1] > 0.01) - if has_signal != self.last_signal_check: - self.last_signal_check = has_signal - if has_signal: - self.signal_label.setText("● SIGNAL PRESENT") - self.signal_label.setStyleSheet("color: #00ff41; background: transparent;") - else: - self.signal_label.setText("● NO SIGNAL") - self.signal_label.setStyleSheet("color: #330000; background: transparent;") - - def _start_fingerprint(self): - if not self.acoustid_key: - self.error_ready.emit("Set your AcoustID key in main.py first!") - return - if self.engine.recording: - return - - self.btn_fingerprint.setEnabled(False) - self.progress.show() - self.progress.setValue(0) - self.status_label.setText("LISTENING...") - self.status_label.setStyleSheet("color: #ccaa00;") - self.signal_label.setText("● RECORDING...") - self.signal_label.setStyleSheet("color: #ccaa00;") - self.result_artist.setText("Artist: -") - self.result_title.setText("Title: -") - - self.recording_timer = QTimer(self) - self.recording_timer.setInterval(100) - self.recording_timer.timeout.connect(self._update_progress) - self.recording_timer.start() - - self.engine.start_recording(self.recording_seconds, self._on_recording_finished) - - def _update_progress(self): - if not self.engine.recording: - self.recording_timer.stop() - self.progress.setValue(self.progress.maximum()) - return - if self.engine.record_buffer is not None: - progress = self.engine.record_position / self.engine.record_target_frames - self.progress.setValue(int(progress * self.progress.maximum())) - - def _on_recording_finished(self, audio_data: np.ndarray, sr: int): - self.recording_timer.stop() - self.status_label.setText("ANALYZING...") - self.status_label.setStyleSheet("color: #4488ff;") - self.signal_label.setText("● PROCESSING") - self.signal_label.setStyleSheet("color: #4488ff;") - threading.Thread( - target=self._fingerprint_thread, - args=(audio_data, sr), - daemon=True - ).start() - - def _fingerprint_thread(self, audio_data: np.ndarray, sr: int): - try: - result = self._identify_acoustid(audio_data, sr) - self.result_ready.emit(result) - except Exception as e: - err_str = str(e) - if "Invalid API key" in err_str: - err_str = "Invalid API Key" - elif "limit" in err_str.lower(): - err_str = "Daily limit reached" - self.error_ready.emit(err_str) - - def _identify_acoustid(self, audio_data: np.ndarray, sr: int) -> dict: - with tempfile.NamedTemporaryFile(suffix='.wav', delete=False) as f: - temp_path = f.name - try: - sf.write(temp_path, audio_data.T, sr, subtype='PCM_16') - - result = subprocess.run( - ['fpcalc', '-json', temp_path], - capture_output=True, - text=True, - timeout=15 - ) - - if result.returncode != 0: - raise RuntimeError(f"fpcalc failed: {result.stderr}") - - fp_data = json.loads(result.stdout) - fingerprint = fp_data['fingerprint'] - duration = fp_data.get('duration', 0) - - finally: - os.unlink(temp_path) - - url = 'https://api.acoustid.org/v2/lookup' - params = { - 'client': self.acoustid_key, - 'fingerprint': fingerprint, - 'duration': int(duration), - 'meta': 'recordings' - } - - response = requests.get(url, params=params, timeout=15) - data = response.json() - - if data.get('status') != 'ok': - err = data.get('error', {}).get('message', 'Unknown') - raise RuntimeError(f"AcoustID: {err}") - - results = data.get('results', []) - if not results: - return None - - best = results[0] - recordings = best.get('recordings', []) - if not recordings: - return None - - rec = recordings[0] - title = rec.get('title', 'Unknown') - artists = rec.get('artists', []) - artist = artists[0].get('name', 'Unknown') if artists else 'Unknown' - - return { - 'artist': artist, - 'title': title, - 'score': best.get('score', 0) - } - - def _show_result(self, result): - self.btn_fingerprint.setEnabled(True) - self.progress.hide() - self.signal_label.setText("● DONE") - self.signal_label.setStyleSheet("color: #00aa33;") - - if result is None: - self.status_label.setText("NO MATCH") - self.status_label.setStyleSheet("color: #aa4444;") - else: - self.status_label.setText(f"MATCH {result['score']:.0%}") - self.status_label.setStyleSheet("color: #00ff41;") - self.result_artist.setText(f"Artist: {result['artist']}") - self.result_title.setText(f"Title: {result['title']}") - self.result_artist.setStyleSheet("color: #cccccc;") - self.result_title.setStyleSheet("color: #00ff41;") - - def _show_error(self, error: str): - self.btn_fingerprint.setEnabled(True) - self.progress.hide() - self.signal_label.setText("● ERROR") - self.signal_label.setStyleSheet("color: #aa4444;") - self.status_label.setText("ERROR") - self.status_label.setStyleSheet("color: #ff4444;") - self.result_artist.setText("Error:") - self.result_title.setText(error[:60]) - self.result_artist.setStyleSheet("color: #aa4444;") - self.result_title.setStyleSheet("color: #aa4444;") \ No newline at end of file diff --git a/history_widget.py b/history_widget.py index 6febf8a..06ff245 100644 --- a/history_widget.py +++ b/history_widget.py @@ -1,11 +1,17 @@ # history_widget.py +import csv +import io +import os import threading + import musicbrainzngs from mutagen import File as MutagenFile from PySide6.QtWidgets import ( - QFrame, QVBoxLayout, QLabel, - QScrollArea, QWidget + QFrame, QVBoxLayout, QHBoxLayout, QLabel, + QScrollArea, QWidget, QPushButton, QDialog, + QLineEdit, QFormLayout, QDialogButtonBox, + QFileDialog ) from PySide6.QtCore import Qt, Signal from PySide6.QtGui import QFont @@ -18,11 +24,9 @@ def _read_file_tags(filepath: str) -> dict: tags = MutagenFile(filepath, easy=True) if tags is None: return {} - def get(key): val = tags.get(key) return val[0] if val else None - return { 'artist': get('artist'), 'title': get('title'), @@ -36,35 +40,34 @@ def _read_file_tags(filepath: str) -> dict: def _lookup_musicbrainz(artist: str, title: str) -> dict: try: result = musicbrainzngs.search_recordings( - recording=title, - artist=artist, - limit=1 + recording=title, artist=artist, limit=1 ) recordings = result.get('recording-list', []) if not recordings: return {} - - rec = recordings[0] + rec = recordings[0] releases = rec.get('release-list', []) if not releases: return {} - release = releases[0] - album = release.get('title', None) - year = None - date = release.get('date', '') - if date: - year = date[:4] - - return {'album': album, 'year': year} + return { + 'album': release.get('title'), + 'year': date[:4] if date else None, + } except Exception: return {} class HistoryEntry(QFrame): - def __init__(self, index: int, filepath: str, metadata: dict, parent=None): + deleted = Signal(object) + + def __init__(self, index: int, filepath: str, metadata: dict, + parent=None): super().__init__(parent) + self._index = index + self._filepath = filepath + self._metadata = metadata self.setFrameStyle(QFrame.Box) self.setStyleSheet(""" @@ -75,51 +78,140 @@ class HistoryEntry(QFrame): } """) - layout = QVBoxLayout(self) - layout.setContentsMargins(8, 6, 8, 6) - layout.setSpacing(2) + root = QHBoxLayout(self) + root.setContentsMargins(8, 6, 8, 6) + root.setSpacing(8) + + text_col = QVBoxLayout() + text_col.setSpacing(2) artist = metadata.get('artist') or 'Unknown Artist' title = metadata.get('title') or self._filename(filepath) - top_row = QLabel(f"{index}. {artist} — {title}") - top_row.setFont(QFont("Arial", 11, QFont.Bold)) - top_row.setStyleSheet("color: #00ff41; background: transparent; border: none;") - top_row.setWordWrap(True) - layout.addWidget(top_row) + top = QLabel(f"{index}. {artist} — {title}") + top.setFont(QFont("Arial", 11, QFont.Bold)) + top.setStyleSheet( + "color: #00ff41; background: transparent; border: none;" + ) + top.setWordWrap(True) + text_col.addWidget(top) album = metadata.get('album') or '' year = metadata.get('year') or '' + parts = [p for p in [album, year[:4] if year and len(year) >= 4 else year] if p] - sub_parts = [] - if album: - sub_parts.append(album) - if year: - sub_parts.append(year[:4]) - - if sub_parts: - sub_row = QLabel(" " + " · ".join(sub_parts)) - sub_row.setFont(QFont("Arial", 9)) - sub_row.setStyleSheet( + if parts: + sub = QLabel(" " + " · ".join(parts)) + sub.setFont(QFont("Arial", 9)) + sub.setStyleSheet( "color: #888888; background: transparent; border: none;" ) - sub_row.setWordWrap(True) - layout.addWidget(sub_row) + sub.setWordWrap(True) + text_col.addWidget(sub) + + root.addLayout(text_col, stretch=1) + + self._del_btn = QPushButton("✕") + self._del_btn.setFixedSize(24, 24) + self._del_btn.setFont(QFont("Arial", 10, QFont.Bold)) + self._del_btn.setStyleSheet(""" + QPushButton { + background-color: #5a1a1a; + color: #ff4444; + border: 1px solid #444; + border-radius: 12px; + } + QPushButton:hover { background-color: #7a2a2a; } + """) + self._del_btn.setToolTip("Remove from history") + self._del_btn.clicked.connect(lambda: self.deleted.emit(self)) + root.addWidget(self._del_btn, alignment=Qt.AlignTop) def _filename(self, filepath: str) -> str: - import os + if not filepath: + return "Unknown" name = os.path.basename(filepath) return os.path.splitext(name)[0] + @property + def filepath(self) -> str: + return self._filepath + + @property + def metadata(self) -> dict: + return self._metadata + + +class AddEntryDialog(QDialog): + def __init__(self, parent=None): + super().__init__(parent) + self.setWindowTitle("Add to History") + self.setFixedWidth(380) + self.setStyleSheet(""" + QDialog { + background-color: #1e1e1e; + color: #cccccc; + border: 2px solid #444; + border-radius: 6px; + } + QLabel { + color: #aaaaaa; + background: transparent; + border: none; + } + QLineEdit { + background-color: #111; + color: #00ff41; + border: 1px solid #333; + border-radius: 3px; + padding: 6px; + font-family: Courier New; + font-size: 12px; + } + QPushButton { + background-color: #2a2a2a; + color: #cccccc; + border: 1px solid #444; + border-radius: 3px; + padding: 6px 16px; + font-weight: bold; + } + QPushButton:hover { background-color: #444; } + """) + + layout = QFormLayout(self) + layout.setContentsMargins(20, 20, 20, 20) + layout.setSpacing(10) + + self._artist = QLineEdit() + self._artist.setPlaceholderText("e.g. The Beatles") + layout.addRow("Artist:", self._artist) + + self._title = QLineEdit() + self._title.setPlaceholderText("e.g. Hey Jude") + layout.addRow("Title:", self._title) + + buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) + buttons.button(QDialogButtonBox.Ok).setText("Add") + buttons.accepted.connect(self.accept) + buttons.rejected.connect(self.reject) + layout.addRow(buttons) + + def values(self) -> dict: + return { + 'artist': self._artist.text().strip(), + 'title': self._title.text().strip(), + } + class HistoryWidget(QFrame): _metadata_ready = Signal(str, dict) def __init__(self, parent=None): super().__init__(parent) - - self._history = [] - self._pending = {} + self._history = [] + self._pending = {} + self._entry_index = 0 self.setFrameStyle(QFrame.Box | QFrame.Raised) self.setStyleSheet(""" @@ -138,21 +230,43 @@ class HistoryWidget(QFrame): root.setContentsMargins(8, 8, 8, 8) root.setSpacing(6) + # Header header = QLabel("PLAY HISTORY") header.setFont(QFont("Arial", 11, QFont.Bold)) - header.setStyleSheet(""" - color: #336633; - background: transparent; - border: none; - padding: 2px; - """) + header.setStyleSheet( + "color: #336633; background: transparent; border: none; padding: 2px;" + ) header.setAlignment(Qt.AlignCenter) root.addWidget(header) - self.scroll = QScrollArea() - self.scroll.setWidgetResizable(True) - self.scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) - self.scroll.setStyleSheet(""" + # Toolbar + bar = QHBoxLayout() + bar.setSpacing(4) + + self._add_btn = QPushButton("+ ADD") + self._add_btn.setFixedHeight(28) + self._add_btn.setFont(QFont("Arial", 9, QFont.Bold)) + self._add_btn.setStyleSheet(""" + QPushButton { + background-color: #2a4a2a; + color: #00ff41; + border: 1px solid #336633; + border-radius: 3px; + padding: 0 10px; + } + QPushButton:hover { background-color: #3a5a3a; } + """) + self._add_btn.clicked.connect(self._add_manual_entry) + bar.addWidget(self._add_btn) + + bar.addStretch() + root.addLayout(bar) + + # Scroll area + self._scroll = QScrollArea() + self._scroll.setWidgetResizable(True) + self._scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + self._scroll.setStyleSheet(""" QScrollArea { border: none; background: transparent; @@ -168,18 +282,56 @@ class HistoryWidget(QFrame): } """) - self.list_widget = QWidget() - self.list_widget.setStyleSheet("background: transparent;") - self.list_layout = QVBoxLayout(self.list_widget) - self.list_layout.setContentsMargins(0, 0, 0, 0) - self.list_layout.setSpacing(0) - self.list_layout.addStretch() + self._list_widget = QWidget() + self._list_widget.setStyleSheet("background: transparent;") + self._list_layout = QVBoxLayout(self._list_widget) + self._list_layout.setContentsMargins(0, 0, 0, 0) + self._list_layout.setSpacing(0) + self._list_layout.addStretch() - self.scroll.setWidget(self.list_widget) - root.addWidget(self.scroll) + self._scroll.setWidget(self._list_widget) + root.addWidget(self._scroll) + + # Export bar + export_bar = QHBoxLayout() + export_bar.setSpacing(4) + + self._export_txt_btn = QPushButton("EXPORT TXT") + self._export_txt_btn.setFixedHeight(28) + self._export_txt_btn.setFont(QFont("Arial", 9, QFont.Bold)) + self._export_txt_btn.setStyleSheet(""" + QPushButton { + background-color: #2a3a2a; + color: #aaaaaa; + border: 1px solid #444; + border-radius: 3px; + padding: 0 10px; + } + QPushButton:hover { background-color: #3a4a3a; } + """) + self._export_txt_btn.clicked.connect(self._export_txt) + + self._export_csv_btn = QPushButton("EXPORT CSV") + self._export_csv_btn.setFixedHeight(28) + self._export_csv_btn.setFont(QFont("Arial", 9, QFont.Bold)) + self._export_csv_btn.setStyleSheet(""" + QPushButton { + background-color: #2a3a2a; + color: #aaaaaa; + border: 1px solid #444; + border-radius: 3px; + padding: 0 10px; + } + QPushButton:hover { background-color: #3a4a3a; } + """) + self._export_csv_btn.clicked.connect(self._export_csv) + + export_bar.addWidget(self._export_txt_btn) + export_bar.addWidget(self._export_csv_btn) + export_bar.addStretch() + root.addLayout(export_bar) def apply_theme(self, theme: dict): - """Apply theme to history border.""" self.setStyleSheet(f""" QFrame {{ background-color: {theme['bg2']}; @@ -189,41 +341,69 @@ class HistoryWidget(QFrame): """) def add_track(self, filepath: str): - index = len(self._history) + 1 + idx = self._entry_index + 1 metadata = _read_file_tags(filepath) self._history.append((filepath, metadata)) - self._insert_entry(index, filepath, metadata) + self._entry_index = idx + + entry = self._insert_entry(idx, filepath, metadata) if not metadata.get('artist') or not metadata.get('album'): - self._pending[filepath] = index + self._pending[filepath] = idx threading.Thread( target=self._enrich_metadata, args=(filepath, metadata), daemon=True ).start() - def _insert_entry(self, index: int, filepath: str, metadata: dict): + def _add_manual_entry(self): + dlg = AddEntryDialog(self) + if dlg.exec() != QDialog.Accepted: + return + + vals = dlg.values() + if not vals['artist'] and not vals['title']: + return + + idx = self._entry_index + 1 + metadata = { + 'artist': vals['artist'] or 'Unknown Artist', + 'title': vals['title'] or 'Unknown Title', + 'album': None, + 'year': None, + } + self._history.append(("", metadata)) + self._entry_index = idx + self._insert_entry(idx, "", metadata) + + def _insert_entry(self, index: int, filepath: str, + metadata: dict) -> HistoryEntry: entry = HistoryEntry(index, filepath, metadata) - self.list_layout.insertWidget(0, entry) - self.scroll.verticalScrollBar().setValue(0) + entry.deleted.connect(self._delete_entry) + self._list_layout.insertWidget(0, entry) + self._scroll.verticalScrollBar().setValue(0) + return entry + + def _delete_entry(self, entry: HistoryEntry): + idx = entry._index - 1 + if 0 <= idx < len(self._history): + self._history.pop(idx) + entry.deleteLater() + self._rebuild_list() def _enrich_metadata(self, filepath: str, existing: dict): artist = existing.get('artist') title = existing.get('title') - if not artist or not title: return - mb_data = _lookup_musicbrainz(artist, title) if not mb_data: return - merged = dict(existing) if not merged.get('album') and mb_data.get('album'): merged['album'] = mb_data['album'] if not merged.get('year') and mb_data.get('year'): merged['year'] = mb_data['year'] - self._metadata_ready.emit(filepath, merged) def _on_metadata_ready(self, filepath: str, metadata: dict): @@ -234,15 +414,57 @@ class HistoryWidget(QFrame): self._rebuild_list() def _rebuild_list(self): - while self.list_layout.count() > 1: - item = self.list_layout.takeAt(0) + while self._list_layout.count() > 1: + item = self._list_layout.takeAt(0) if item.widget(): item.widget().deleteLater() for i, (filepath, metadata) in enumerate(reversed(self._history)): index = len(self._history) - i entry = HistoryEntry(index, filepath, metadata) - self.list_layout.insertWidget( - self.list_layout.count() - 1, - entry - ) \ No newline at end of file + entry.deleted.connect(self._delete_entry) + self._list_layout.insertWidget( + self._list_layout.count() - 1, entry + ) + + def _iter_entries(self): + for i, (fp, md) in enumerate(reversed(self._history)): + idx = len(self._history) - i + artist = md.get('artist') or 'Unknown Artist' + title = md.get('title') or (os.path.splitext(os.path.basename(fp))[0] if fp else 'Unknown') + album = md.get('album') or '' + year = md.get('year') or '' + yield idx, artist, title, album, year + + def _export_txt(self): + path, _ = QFileDialog.getSaveFileName( + self, "Export History (TXT)", + os.path.expanduser("~/play_history.txt"), + "Text Files (*.txt)" + ) + if not path: + return + with open(path, 'w') as f: + for idx, artist, title, album, year in self._iter_entries(): + f.write(f"{idx}. {artist} — {title}") + if album or year: + parts = [p for p in [album, + year[:4] if year and len(year) >= 4 else year] + if p] + if parts: + f.write(f"\n {' · '.join(parts)}") + f.write("\n") + + def _export_csv(self): + path, _ = QFileDialog.getSaveFileName( + self, "Export History (CSV)", + os.path.expanduser("~/play_history.csv"), + "CSV Files (*.csv)" + ) + if not path: + return + with open(path, 'w', newline='') as f: + w = csv.writer(f) + w.writerow(["#", "Artist", "Title", "Album", "Year"]) + for idx, artist, title, album, year in self._iter_entries(): + w.writerow([idx, artist, title, album, year]) diff --git a/lufs_meter_widget.py b/lufs_meter_widget.py index e8033df..51f606a 100644 --- a/lufs_meter_widget.py +++ b/lufs_meter_widget.py @@ -1,15 +1,12 @@ # lufs_meter_widget.py -from PySide6.QtWidgets import QFrame, QVBoxLayout, QLabel -from PySide6.QtCore import Qt, QTimer, QRect -from PySide6.QtGui import QFont, QPainter, QColor, QLinearGradient +from PySide6.QtWidgets import QFrame, QVBoxLayout, QLabel, QWidget +from PySide6.QtCore import Qt, QTimer +from PySide6.QtGui import QFont, QPainter, QColor, QLinearGradient, QPen class LUFSMeterWidget(QFrame): - """ - Vertical LUFS meter for broadcast monitoring. - EBU R128 standard: Target -23 LUFS ±1 LU (Loudness Units). - """ + """Vertical LUFS meter with -14 LUFS target for streaming/broadcast.""" def __init__(self, engine, parent=None): super().__init__(parent) @@ -29,7 +26,7 @@ class LUFSMeterWidget(QFrame): self.timer = QTimer(self) self.timer.setInterval(100) - self.timer.timeout.connect(self.update) + self.timer.timeout.connect(self._update_meter) self.timer.start() def _build_ui(self): @@ -42,74 +39,101 @@ class LUFSMeterWidget(QFrame): header.setAlignment(Qt.AlignCenter) layout.addWidget(header) - # Meter canvas - self.meter_canvas = QFrame() - self.meter_canvas.setStyleSheet(""" - QFrame { - background-color: #000000; - border: 1px solid #333333; - border-radius: 3px; - } - """) + self.meter_canvas = MeterCanvas(self.engine) layout.addWidget(self.meter_canvas, stretch=1) - # Numeric readout self.readout = QLabel("-70.0") self.readout.setFont(QFont("Courier New", 12, QFont.Bold)) self.readout.setStyleSheet("color: #00ff41; background: transparent; border: none;") self.readout.setAlignment(Qt.AlignCenter) layout.addWidget(self.readout) - def paintEvent(self, event): - super().paintEvent(event) - + def _update_meter(self): lufs = self.engine.lufs_current self.readout.setText(f"{lufs:.1f}") + self.meter_canvas.update() - painter = QPainter(self.meter_canvas) - rect = self.meter_canvas.rect().adjusted(2, 2, -2, -2) - # LUFS scale: -70 (bottom) to 0 (top) - # Target zone: -24 to -22 (±1 LU around -23) - lufs_range = 70.0 # -70 to 0 +class MeterCanvas(QWidget): + """Custom painted LUFS bar with -14 LUFS target.""" - if lufs < -70: - lufs = -70 - if lufs > 0: - lufs = 0 + def __init__(self, engine, parent=None): + super().__init__(parent) + self.engine = engine + self.setStyleSheet(""" + QWidget { + background-color: #000000; + border: 1px solid #333333; + border-radius: 3px; + } + """) + self.setMinimumHeight(200) - fill_pct = (lufs + 70) / lufs_range + def paintEvent(self, event): + painter = QPainter(self) + painter.setRenderHint(QPainter.Antialiasing) + + rect = self.rect().adjusted(3, 3, -3, -3) + lufs = self.engine.lufs_current + + # Scale: -50 LUFS (bottom) to 0 (top) - makes -14 more readable + lufs_min = -50 + lufs_max = 0 + lufs_range = lufs_max - lufs_min + + lufs = max(lufs_min, min(lufs_max, lufs)) + fill_pct = (lufs - lufs_min) / lufs_range fill_h = int(rect.height() * fill_pct) - # Draw gradient meter - gradient = QLinearGradient(0, rect.height(), 0, 0) + # Color zones optimized for -14 LUFS target + gradient = QLinearGradient(0, rect.bottom(), 0, rect.top()) - # Color zones (broadcast standard) if lufs < -30: - gradient.setColorAt(0, QColor("#003300")) # Very quiet - dark green - gradient.setColorAt(1, QColor("#005500")) - elif lufs < -24: - gradient.setColorAt(0, QColor("#00aa00")) # Quiet - green + # Very quiet - dark green + gradient.setColorAt(0, QColor("#002200")) + gradient.setColorAt(1, QColor("#004400")) + elif lufs < -18: + # Quiet - green + gradient.setColorAt(0, QColor("#00aa00")) gradient.setColorAt(1, QColor("#00dd00")) - elif lufs < -22: - gradient.setColorAt(0, QColor("#00ff00")) # Target zone - bright green + elif lufs < -12: + # Target zone -18 to -12 LUFS (±4 LU around -14) - bright green + gradient.setColorAt(0, QColor("#00ff00")) gradient.setColorAt(1, QColor("#00ff00")) - elif lufs < -9: - gradient.setColorAt(0, QColor("#ffcc00")) # Getting hot - yellow + elif lufs < -6: + # Getting hot - yellow + gradient.setColorAt(0, QColor("#ffcc00")) gradient.setColorAt(1, QColor("#ffaa00")) else: - gradient.setColorAt(0, QColor("#ff0000")) # Too hot - red + # Too hot - red + gradient.setColorAt(0, QColor("#ff0000")) gradient.setColorAt(1, QColor("#ff0000")) painter.fillRect( rect.x(), - rect.y() + rect.height() - fill_h, + rect.bottom() - fill_h, rect.width(), fill_h, gradient ) - # Draw target line at -23 LUFS - target_y = rect.y() + int(rect.height() * (1.0 - ((-23 + 70) / lufs_range))) - painter.setPen(QColor("#ffffff")) - painter.drawLine(rect.x(), target_y, rect.x() + rect.width(), target_y) \ No newline at end of file + # Draw reference lines + painter.setPen(QPen(QColor("#ffffff"), 1)) + + # Target line at -14 LUFS (main target) + target_pct = ((-14 - lufs_min) / lufs_range) + target_y = rect.bottom() - int(rect.height() * target_pct) + painter.drawLine(rect.x(), target_y, rect.right(), target_y) + + # Upper boundary at -12 LUFS (dotted) + painter.setPen(QPen(QColor("#888888"), 1, Qt.DotLine)) + upper_pct = ((-12 - lufs_min) / lufs_range) + upper_y = rect.bottom() - int(rect.height() * upper_pct) + painter.drawLine(rect.x(), upper_y, rect.right(), upper_y) + + # Lower boundary at -16 LUFS (dotted) + lower_pct = ((-16 - lufs_min) / lufs_range) + lower_y = rect.bottom() - int(rect.height() * lower_pct) + painter.drawLine(rect.x(), lower_y, rect.right(), lower_y) + + painter.end() \ No newline at end of file diff --git a/playlist_widget.py b/playlist_widget.py index 780c586..ca71e94 100644 --- a/playlist_widget.py +++ b/playlist_widget.py @@ -8,6 +8,7 @@ from PySide6.QtWidgets import ( ) from PySide6.QtCore import Qt, QTimer from PySide6.QtGui import QFont, QColor, QAction +import json import os @@ -264,6 +265,17 @@ class BasePlaylistPanel(QWidget): else: item.setForeground(QColor("#cccccc")) + def get_filepaths(self, lst: QListWidget) -> list: + paths = [] + for i in range(lst.count()): + item = lst.item(i) + if isinstance(item, PlaylistItem): + paths.append(item.filepath) + return paths + + def clear_list(self, lst: QListWidget): + lst.clear() + # ───────────────────────────────────────── # TRACK PLAYLIST PANEL @@ -574,18 +586,58 @@ class CartPlaylistPanel(BasePlaylistPanel): # ───────────────────────────────────────── class PlaylistWidget(QWidget): + PLAYLIST_JSON_FILTER = "JSON Files (*.json)" + def __init__(self, engine, deck_widgets: dict, cart_slots: list, parent=None): super().__init__(parent) self.engine = engine - # Expandable - no fixed width self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) self.setMinimumWidth(200) root = QVBoxLayout(self) root.setContentsMargins(0, 0, 0, 0) - root.setSpacing(0) + root.setSpacing(4) + + # Toolbar + bar = QHBoxLayout() + bar.setSpacing(4) + + self._save_btn = QPushButton("SAVE") + self._save_btn.setFixedHeight(28) + self._save_btn.setFont(QFont("Arial", 9, QFont.Bold)) + self._save_btn.setStyleSheet(""" + QPushButton { + background-color: #2a4a2a; + color: #aaaaaa; + border: 1px solid #444; + border-radius: 3px; + padding: 0 10px; + } + QPushButton:hover { background-color: #3a5a3a; } + """) + self._save_btn.clicked.connect(self._save_playlist) + + self._load_btn = QPushButton("LOAD") + self._load_btn.setFixedHeight(28) + self._load_btn.setFont(QFont("Arial", 9, QFont.Bold)) + self._load_btn.setStyleSheet(""" + QPushButton { + background-color: #3a2a2a; + color: #aaaaaa; + border: 1px solid #444; + border-radius: 3px; + padding: 0 10px; + } + QPushButton:hover { background-color: #4a3a3a; } + """) + self._load_btn.clicked.connect(self._load_playlist) + + bar.addWidget(self._save_btn) + bar.addWidget(self._load_btn) + bar.addStretch() + root.addLayout(bar) splitter = QSplitter(Qt.Vertical) splitter.setStyleSheet(""" @@ -602,4 +654,51 @@ class PlaylistWidget(QWidget): splitter.addWidget(self.cart_panel) splitter.setSizes([600, 400]) - root.addWidget(splitter) \ No newline at end of file + root.addWidget(splitter) + + def _save_playlist(self): + path, _ = QFileDialog.getSaveFileName( + self, "Save Playlist", + os.path.expanduser("~/playlist.json"), + self.PLAYLIST_JSON_FILTER + ) + if not path: + return + data = { + "version": 1, + "tracks": self.track_panel.get_filepaths(self.track_panel.playlist), + "cart_tracks": self.cart_panel.get_filepaths(self.cart_panel.playlist), + } + with open(path, 'w') as f: + json.dump(data, f, indent=2) + + def _load_playlist(self): + path, _ = QFileDialog.getOpenFileName( + self, "Load Playlist", + os.path.expanduser("~"), + self.PLAYLIST_JSON_FILTER + ) + if not path: + return + try: + with open(path) as f: + data = json.load(f) + except Exception: + return + + tracks = data.get("tracks", []) + cart = data.get("cart_tracks", []) + + self.track_panel.playlist.clear() + for fp in tracks: + if os.path.exists(fp): + self.track_panel._add_item_to(self.track_panel.playlist, fp) + self.track_panel._update_count(self.track_panel.playlist, + self.track_panel.count_label) + + self.cart_panel.playlist.clear() + for fp in cart: + if os.path.exists(fp): + self.cart_panel._add_item_to(self.cart_panel.playlist, fp) + self.cart_panel._update_count(self.cart_panel.playlist, + self.cart_panel.count_label) \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index cd3368f..c35d656 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,9 +1,9 @@ PySide6 numpy soundfile -requests +pyloudnorm mutagen musicbrainzngs python-rtmidi PyJACK -librosa \ No newline at end of file +librosa diff --git a/top_bar_widget.py b/top_bar_widget.py index 2e30805..8a2d9e5 100644 --- a/top_bar_widget.py +++ b/top_bar_widget.py @@ -7,7 +7,7 @@ from datetime import datetime THEMES = [ - # Original 4 + # Dark themes { 'name': 'GREEN', 'accent': '#00ff41', @@ -16,6 +16,7 @@ THEMES = [ 'bg2': '#111111', 'border': '#1a3a1a', 'text': '#cccccc', + 'is_light': False, }, { 'name': 'AMBER', @@ -25,6 +26,7 @@ THEMES = [ 'bg2': '#111100', 'border': '#3a3000', 'text': '#ddddcc', + 'is_light': False, }, { 'name': 'BLUE', @@ -34,6 +36,7 @@ THEMES = [ 'bg2': '#001122', 'border': '#001a3a', 'text': '#ccd8dd', + 'is_light': False, }, { 'name': 'RED', @@ -43,8 +46,8 @@ THEMES = [ 'bg2': '#110000', 'border': '#3a0000', 'text': '#ddcccc', + 'is_light': False, }, - # 10 New Bold Themes { 'name': 'PURPLE', 'accent': '#cc00ff', @@ -53,6 +56,7 @@ THEMES = [ 'bg2': '#1a001a', 'border': '#3a003a', 'text': '#ddccdd', + 'is_light': False, }, { 'name': 'CYAN', @@ -62,6 +66,7 @@ THEMES = [ 'bg2': '#001a1a', 'border': '#003a3a', 'text': '#ccdddd', + 'is_light': False, }, { 'name': 'ORANGE', @@ -71,6 +76,7 @@ THEMES = [ 'bg2': '#1a1100', 'border': '#3a2200', 'text': '#ddccbb', + 'is_light': False, }, { 'name': 'PINK', @@ -80,6 +86,7 @@ THEMES = [ 'bg2': '#1a000a', 'border': '#3a0015', 'text': '#ddccdd', + 'is_light': False, }, { 'name': 'LIME', @@ -89,6 +96,7 @@ THEMES = [ 'bg2': '#111a00', 'border': '#223a00', 'text': '#ddddcc', + 'is_light': False, }, { 'name': 'GOLD', @@ -98,6 +106,7 @@ THEMES = [ 'bg2': '#1a1a0a', 'border': '#3a3a15', 'text': '#ddddbb', + 'is_light': False, }, { 'name': 'VIOLET', @@ -107,6 +116,7 @@ THEMES = [ 'bg2': '#0a0015', 'border': '#15002a', 'text': '#ccbbdd', + 'is_light': False, }, { 'name': 'TURQUOISE', @@ -116,6 +126,7 @@ THEMES = [ 'bg2': '#001a15', 'border': '#00382d', 'text': '#ccddda', + 'is_light': False, }, { 'name': 'CRIMSON', @@ -125,6 +136,7 @@ THEMES = [ 'bg2': '#1a0005', 'border': '#3a000a', 'text': '#ddcccc', + 'is_light': False, }, { 'name': 'CHARTREUSE', @@ -134,6 +146,48 @@ THEMES = [ 'bg2': '#0a1a00', 'border': '#153a00', 'text': '#ddddcc', + 'is_light': False, + }, + # Light themes + { + 'name': 'LIGHT BLUE', + 'accent': '#0066cc', + 'accent_dim': '#0044aa', + 'bg': '#f5f5f5', + 'bg2': '#e8e8e8', + 'border': '#bbbbbb', + 'text': '#222222', + 'is_light': True, + }, + { + 'name': 'LIGHT GREEN', + 'accent': '#00aa44', + 'accent_dim': '#008833', + 'bg': '#f0f5f0', + 'bg2': '#e0e8e0', + 'border': '#aaccaa', + 'text': '#112211', + 'is_light': True, + }, + { + 'name': 'LIGHT AMBER', + 'accent': '#cc8800', + 'accent_dim': '#aa6600', + 'bg': '#f5f3f0', + 'bg2': '#e8e5e0', + 'border': '#ccbb99', + 'text': '#221100', + 'is_light': True, + }, + { + 'name': 'LIGHT PURPLE', + 'accent': '#8844cc', + 'accent_dim': '#6622aa', + 'bg': '#f3f0f5', + 'bg2': '#e5e0e8', + 'border': '#ccbbdd', + 'text': '#221122', + 'is_light': True, }, ] @@ -197,9 +251,6 @@ class TopBarWidget(QFrame): # Date self.date_label = QLabel("") self.date_label.setFont(QFont("Arial", 20, QFont.Bold)) - self.date_label.setStyleSheet( - "color: #aaaaaa; background: transparent; border: none;" - ) self.date_label.setAlignment(Qt.AlignCenter | Qt.AlignVCenter) layout.addWidget(self.date_label) @@ -210,7 +261,7 @@ class TopBarWidget(QFrame): self.clock_label.setFont(QFont("Courier New", 42, QFont.Bold)) self.clock_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter) self.clock_label.setMinimumWidth(220) - self._style_clock() + self._style_clock_and_date() layout.addWidget(self.clock_label) def _style_theme_btn(self): @@ -224,24 +275,29 @@ class TopBarWidget(QFrame): }} QPushButton:hover {{ background-color: {theme['accent']}; - color: #000000; + color: {'#ffffff' if not theme['is_light'] else '#000000'}; }} QPushButton:pressed {{ background-color: {theme['accent_dim']}; }} """) - def _style_clock(self): + def _style_clock_and_date(self): theme = THEMES[self._theme_index] self.clock_label.setStyleSheet( f"color: {theme['accent']}; background: transparent; border: none;" ) + # Date color adapts to light/dark + date_color = '#555555' if theme['is_light'] else '#aaaaaa' + self.date_label.setStyleSheet( + f"color: {date_color}; background: transparent; border: none;" + ) def _cycle_theme(self): self._theme_index = (self._theme_index + 1) % len(THEMES) self._apply_bar_style() self._style_theme_btn() - self._style_clock() + self._style_clock_and_date() self.theme_changed.emit(THEMES[self._theme_index]) def _update(self):