From 84c1f0da357a0d4f0cb66263aac397b82b4095d3 Mon Sep 17 00:00:00 2001 From: Nick Date: Mon, 13 Apr 2026 20:57:13 +0200 Subject: [PATCH] web console --- .tf5_mixer_cache/channels_cache.json | 117 +++-- __pycache__/config.cpython-312.pyc | Bin 11145 -> 11179 bytes __pycache__/main.cpython-312.pyc | Bin 0 -> 15590 bytes __pycache__/mixer_controller.cpython-312.pyc | Bin 118238 -> 117398 bytes config.py | 1 + conversations/4762864.json | 160 +++---- main.py | 249 ++++++++++ mixer_controller.py | 464 +++++++++---------- static/index.html | 389 ++++++++++++++++ 9 files changed, 1023 insertions(+), 357 deletions(-) create mode 100644 __pycache__/main.cpython-312.pyc create mode 100644 main.py create mode 100644 static/index.html diff --git a/.tf5_mixer_cache/channels_cache.json b/.tf5_mixer_cache/channels_cache.json index 3697599..d452a92 100644 --- a/.tf5_mixer_cache/channels_cache.json +++ b/.tf5_mixer_cache/channels_cache.json @@ -11,7 +11,7 @@ "channel": 2, "name": "Gelato", "on": false, - "level_db": null, + "level_db": -8.4, "pan": 0 }, "3": { @@ -31,15 +31,15 @@ "5": { "channel": 5, "name": "Vox1", - "on": false, - "level_db": null, + "on": true, + "level_db": 0.8, "pan": 0 }, "6": { "channel": 6, "name": "Vox2", - "on": false, - "level_db": null, + "on": true, + "level_db": 2.75, "pan": 0 }, "7": { @@ -59,50 +59,50 @@ "9": { "channel": 9, "name": "Kick", - "on": false, - "level_db": null, + "on": true, + "level_db": -1.25, "pan": 0 }, "10": { "channel": 10, "name": "Snare", "on": false, - "level_db": null, + "level_db": -2.35, "pan": 0 }, "11": { "channel": 11, "name": "Tom 1", "on": false, - "level_db": null, + "level_db": -3.55, "pan": 0 }, "12": { "channel": 12, "name": "Tom 2", "on": false, - "level_db": null, + "level_db": -5.9, "pan": 0 }, "13": { "channel": 13, "name": "Tom3", "on": false, - "level_db": null, + "level_db": -8.65, "pan": 0 }, "14": { "channel": 14, "name": "Pan SX", "on": false, - "level_db": null, + "level_db": 0.85, "pan": 0 }, "15": { "channel": 15, "name": "Pan dx", "on": false, - "level_db": null, + "level_db": 0.45, "pan": 0 }, "16": { @@ -136,8 +136,8 @@ "20": { "channel": 20, "name": "Tast", - "on": false, - "level_db": null, + "on": true, + "level_db": 0.15, "pan": 0 }, "21": { @@ -157,15 +157,15 @@ "23": { "channel": 23, "name": " Vox3", - "on": false, - "level_db": null, + "on": true, + "level_db": 4.6, "pan": 0 }, "24": { "channel": 24, "name": "Chit cnt", - "on": false, - "level_db": null, + "on": true, + "level_db": -1.95, "pan": 0 }, "25": { @@ -185,7 +185,7 @@ "27": { "channel": 27, "name": "Vox 4", - "on": false, + "on": true, "level_db": null, "pan": 0 }, @@ -228,14 +228,14 @@ "channel": 33, "name": "PC", "on": true, - "level_db": -17.5, + "level_db": -92.0, "pan": 0 }, "34": { "channel": 34, "name": "PC", "on": true, - "level_db": -17.5, + "level_db": -92.0, "pan": 63 }, "35": { @@ -328,7 +328,7 @@ "mix": 8, "name": "Aux 8", "on": true, - "level_db": -9.0 + "level_db": -2.15 }, "9": { "mix": 9, @@ -403,12 +403,71 @@ "level_db": 6.0 } }, - "timestamp": 1770577942.7806144, + "timestamp": 1776106373.6489363, "steinch": {}, - "fxrtn": {}, - "dcas": {}, - "matrices": {}, - "stereo": {}, - "mono": {}, + "fxrtn": { + "1": { + "channel": 1, + "level_db": -18.0 + }, + "2": { + "channel": 2, + "level_db": -18.0 + }, + "3": { + "channel": 3, + "on": true, + "level_db": -20.0 + }, + "4": { + "channel": 4, + "on": true, + "level_db": -20.0 + } + }, + "dcas": { + "3": { + "dca": 3, + "level_db": 0.25 + }, + "4": { + "dca": 4, + "level_db": 1.2 + }, + "2": { + "dca": 2, + "level_db": 1.3 + } + }, + "matrices": { + "3": { + "matrix": 3, + "level_db": -30.5 + }, + "1": { + "matrix": 1, + "level_db": -17.3 + }, + "2": { + "matrix": 2, + "level_db": -16.8 + } + }, + "stereo": { + "1": { + "bus": 1, + "name": "Stereo", + "on": true, + "level_db": -2.15 + }, + "2": { + "bus": 2, + "on": true, + "level_db": -2.15 + } + }, + "mono": { + "on": true + }, "mute_masters": {} } \ No newline at end of file diff --git a/__pycache__/config.cpython-312.pyc b/__pycache__/config.cpython-312.pyc index aa1bf7b782f15ae5cf11ad2836df117b6728ee5a..5c5bc4511865e34501f5938786198e844d7df36f 100644 GIT binary patch delta 989 zcmYLI+iMh87@u#Fy(N2@+0E?L%+6l2n@Oxttu}?YHE7~PFwq)O24z|IoS2YJR?cpk zW=oB>fnw=PIDHWn1fR;j6h|NQKajkL;DumY3w=-#iw_bg7W$n@&@;^8cfRlU-F|20 z^2zqezB(mCR#Yi9NL9a&RBuW$LNltr73|T4aCvjv6PXgaC zbx|!LNh{K{n0Ce%Q9Sd&M zLSlaYxTESyBaGRl&bzW6Mrg_{&(JRCd3jW@M9mG@#e7`z6fk_)GXf!g!L!q+pl8DL zu{5_mE4{klspn|3T&q_rwekXOF4fr_+k^I^J?VWdM;%I6b%s{w%IE8>$@ZhKWFPAu zae>OYG7dierxF95Zz^BF6#rBm!GaBS8128Q2O!Abs(I+)(eN>BVJ1B07R|F5za9P? zgRVpj7`N|5esm2+(B9G}Xrr;XP%)|%tZ6h^{p(7z!o)W-u>p*ICW2GEmiQJkzep57 z;i2S^H-u(0fKyE-KXK!YiUQv!=di$=WE?Kpp47e$i13o0f)uy(J`BF7=eELx7;SwJ zV*H7o#fX3PB;2&M^iMJlawF4^-1`{=hkcbf?H2mA`uIPYI44;J(tI&niaQ$lg^csF z-%wVbJ8C|&8A+Z@^bAr7hc&6THvKHDN@;mv%VA%iV z--ye9T_|XdIDvzXC~S7V-e^=?2@_DzoAZz?JXal8vSZ?l3z22FwGp#KlmhV7OB delta 1012 zcmYjQOK1~O6rDS5er+e2N!u}*G?QtPCLKRTMf_1e#UCjCuwaQ`h#&WCgN>CxM?v1GFcJYU4pN zmqbG9Ka)%hOx7n-VMq#b`mabIVv|56^Y@&_vqj7Wsnk!iBvT)hB$mc@dWl7prtJ44 zfrJ*TxqOgHIp5z;b9bbBGRZdO=s(&;xCr(DzYPbyC71_@@Os;HPz;Q9@{euRfQeA| z_O(BgLE{ig$zOq+p#l>3ghn9BuZC7fV#qmUN0`ZwI{7cYR)48hYL3RSo`@PrQR|62|2a>5+LP&1V`sLjk@6{jh=QYq+G3FC!}0E!|Ldt z_tezJwsaifs12y-sksVt{>jYt`KeX6GD9(E zx#27eFc&jE@XX8&8C!qYKUh@IFl&s=%+wsqtyZSpnlnu;QnMIs(4nCrrV2uVKk83# zHM;{FI+9%jCcl#1p71rgCTOEPRl7i$DE>UMAMdA`aX#o^4`tNAN?9Y+4J$ISmq8J=>; bxzltzyNLnf?{zowiIwZU`zyoHE_ic1?dSE}G8z%vRBtQ~88{#2D5=oKNeNf^pjV{YNNy~;IbW;Qc0(7G(i3~U+ zA3LUZwKep*GUCLZ3SE_|k=II%XET*(wz5?^Y)8pV5;U7i!yP*7t<~0K_7^K6?oC2bfsz}FBlj+4XWI5kX-n}$sY;}|36am%npfysz<+%{}eU=*e#Wt)Zxu>Aw0iUVzTMRkPUzTWUxV$^u}gBW-Aw80#IT1cgSZ@O zwiW2dt=IJA)T{vP_5#@DI(7w1jTo59*GYYBUdGE-F;#3eQ_WV7n3$T^$>AENmaPT6 zI)LlgdZzyLGX%kv!wT5Pc0SrZ>ttxgcX>s24X4l)@;Cejqwe<`W%EEV&UZh%Teco! zPacYePP4pRrh|RqcqkT)vLW7&({*-4~1oPqADmL!C-rxD!lwcS@B`P7w(rpcLR}ig+LG$5ReM{WEMJ zvqsPqUI;SIMn>YeG5W#U-YqAN#91zW;#n>hW~TuqOb-t9p6H8(Cdb(*A3wp5tUnp! zPmBk{(bkD6*%AnZqhUS}n67$cgsoaxIW!)}$ND^x>Rdld-m=u+ffONI&$1`u$}Wbq zWy1M*Ca=NYoih;aArLHE0*&R)7xHn?Xzw*R-+utLerUL8{60lNjRY~4+oFn2j8PdN z=aWDhO(R&Tjl>1|PmO00=K>TUI8!hbVkda61d@=Lm|&yK`vwj#Gp9@o_d?TL87?XxNF zZ6vI|2k|1P^{RU_`XSm#Ui587k1Ux&09ybHmWvpJZ+;lV78sN=WTwlp-FB43)z{EHY6v^gsHXa9xerP^;qZAT&9e@l86r58Z z@j?wj%owah!tnU{=|p1Gh~z>YscyNsf#6B#NC}MuXGFvhaiQfban3kIoJBbVp{|TI z4rCDE*>k*X?LUVJ1WtRi`x?v7Y2&K_&!jY`XfJ(g^Urh9G#Wme3fBRud85P}BbPHKzzn zKYru>>7w?{9j)yfHnq05uHT4R?`SN}`%Rn$D$A~MER8U8&WIz~9E?vzL*bZg4{>ae zX9IjNep>eEG!js2U_r#n?wo8E#8!X_!czI6{ zL{zT2^xdV3t|aNcP1+^0LLe)aDqFUg7*mgH3BEb0z(PFi7RIik7cG37&3w0 zA%3in1aoNy&7kk13tj5>EYf&3wAG@VJJa;K-O)4QARVURNI=Af>2Z8Q=x~(g+3|@O z4W}!@(ilfqC0qB)MG976assNc?;B*2pPFE05}&K-mHiwS<5-%Rp|>9WqPb@?ajhQptS7J09ROc1MLiqI>t!T{D=GoydK>DkC9kig zFuWe&{EbSy+1guWykT(mdW<(*xb%jH!Z}qWzz?wpti~$rVcw{*2OvEnmqpB>e+iR- z+R>b2t}_hiHxt%_ew%|)fRT*p2T`j-fKALG%?6!8j1m9S(unvTN)V^}2m*R<%W2z@ zt*=2EGb`8!d5X~5%xR@qtDF5kMJoju*JE3u3XVl*b>DIbv5a9|E-&y{kypYPR`T)!<4717XF&mB z%(Keir0_ni`WWU|d4Va;5fXW|=g@^eYarT*I67;_v)dmytl+W?W+%ME+7mmT=o~ zMi*|v9#Ezhh=46Eo79AC1gRlcV$l(dL;~7y1M$i6@gO%PS7;@f5$OdUtw5QVo?WJ8 z!#wp&9aouyo?VP_GxG>XM`GL#JXCBIa6LHc#SxZrTt7r$W;-Kb5C!z*l}kdC90w|D zP8Ovs#zV#ww-z^nBiu}U6c%3fwn(+TLT#^D+b`Ab7HW6jT$wy{M65l! zT(d^1*(ucQ6l;2lfhgv1kkC0S zKJ&a-enN7cNV-m>d>wPn+fGlOT>hd77oNUUNpr-;?p z0eIAg1L0-7Oloey^#rgH5Wi{wUx#khX?a=Kkj73z*ewkIG`5F`Lqt9^naZ*E{nY;H z5=?U8P+K_4u;*Gw`SFO1xM^yVA8FYH=9`6$hGI-OdI|`bjDcr}!?Tb{tg}He%6QdO z3-}lmaX2~we{oFv3aQ?<>iH}*9%0St`a14%Obx$L|o_w;qq_tDdxg{UHELp!UwDux7HQ5ULI1^@DApM z?22Q7HbrRGiz37f`?=kK?=Mo>r{ZJ5Mg(^RA}~il7;#52SOorO7JGKFM@r$aG8F+Y zQ(Sg}kYd1d6N&-Tk1`xSD2ks3oK@J&;_xw+jiGVoA;v0~>WS-sh*H9St zA;7ubK=rIKUw_7o;WmWx+m*PpuGek6QDT7n8)XQ?ZX>{WKh$dnkb#_R9|8G$G8j4y z3W03mSbma=KH}@dr}54|Lv&H4b*x9^1?>bq&Q$``tn#249tYgXhNWN9U@3PLWH>|ub*JuXS!aaU?|o77f?!K z`o^`iazD90Oq~Dft5fQ5hIUSqEubp+&0xFoTzCRL6Ok}4TR70AxH#C#M#Zol0m9~G z+tFZTl2wdw#qRY}TnrlGCLjVE7<`1Fkl~MPJ`;?<6$Ky1>jGX_%@6BR)R_tQGon7$!hwptvT^+gMSaj|C!c2G?(=MW@B1x9tyK5!fm|Pv+ zPhFMRl=c4Zw%v{C*8x8Al)W5~(iEf~#IeKd)ivFX#82zHJ%(#e1Ax~`5RN_8ZXb26 zPRXGWhJ8ky-)QY_H(pzdAm+xQ`a=Q%GiXJ~-i57s!>Ni{V5|j*4Q$0s?hIIl1u=AH zIRg%&f*3mC%ox80g9N7#R;?k-kU*ZgP)d+`|5O4oCVX!=XW-TqBw$mRm@$J5VpeV! zTeGA)rW2^z2adC3Ip)mfYEXhom^3v*7Xld}IAf0fU+Bg1guN6JKacO_m(Yv#346ge zQhDJU<}wex*q*Q#UASet`i5Ru=*9koz32|WZ{14=^irhs((@JLp}3lCUmAo}Cm6j-y9TB>iV}tyE99sYU|?KIO5G^~Q!-=a z^|uHK-K9EX(^p_!BDY6c>nHmiYskVH#1w^f)wp1JX||jx!%A9oXTq8@Gg@mY`#l3T zUc&ZxoP-s7cLCG;O`}#Z6E=*mj(~5{`WgfAmLD{6`S|`BcOivy3Pcs-!SnWBD- zGJ=0)zEPpbAudOkDXbk?v$~I_>-<~itaAjxt18P((c|S6Mlm_%g#-oT zH+?{=*^jJ^`Ke_%_FyW3sYy`p5KQfxPKfIg7KrPAtb6Ss8sA z8EDQ&Srz-&tqM3P3eV1$uyY1xMHDd&aP4Z+C_%lC2Lms&$^-!h9)4p<%}_(c9ChJS z&B)t`Q$JSXaV6fa#14qb3!f?RpXR$%G$$OC8ZW%2#D|o)0b;|2J4$>*iQj%qU3B65 zfP<7v<^Kb&8D4RzH15YQy6|fq4impjfps{^%7!q*?Zat!*u$&lAUx($9sJz0xWEX` zaM=j1Z|)!@x#w^mn5L?kFB6J$KgXw!+PVE05BqPxvk#3zj|XtVf$dIc46c2nOgscx zdawY5eNlJ*1XRaAL>Gxu*w3jOXAEKtno`*ZQ+t61qX}8zZXS*yng&w`NRfQgs=+@d zXAB@gQ};Av+srA&rf6QsntXcCrkon! zmBNFtZC>O(synk}xIAjO|_BtX8hh8c6V~ zKI#X*(5QMf;fU*|nC8QQBh{rHZ@+!Na%_I%hoMe40AwLC{7-Dzy@BSI2%yUeQ_Wjgx9O#=}d;=S>>>rt~gc| ze?5oSTXizoz$t}Kt!xO5%I3%!xK$g6Hyp93@>Mdp;8`U@`C<(4`BCu51CBo)x@?0- zSs4zXyogmiQaC!VJ|VS_uzYA#We(E~4|{K0x552hFcR--%|W^jt;P?*F}MeBEZya^ z{mbQWg)q-ujLaF*hME#@itfGIbM@tGeOEW!EV;QQdGy#{Z@bx=tQ(peOx5qWYPi~d zjl6Q^>cOki$>$DTOI(d6Ymdwg+{RZ<+l0DpSB{Bw{d0rL<J|)(U&JDl|52#+dU8voDWdtt303Ba?Z5C=aUuhO=y00D* zYIe=_!%b41Z=rO7oZm9nx9s&x-u0q){c?5PJonDVrREJ%^LC+myVTq*GjvHr%2Dpsy4t&5>M5|y>IPJ)zK-s zG1a&#RoC!^m0D5$zX{4yk!~R>8q(Vcw|92$mtMe6de{H%uA8Xzfg7&GE4#5@uEhI5 zd8qh@J!|_7h8tA3vu^|SQCS@%KH5O_?XZ5d)eP{*25WyY^|2E{EIW0^t~|d3Cp(rh z9QL`Xo^~e|1-B-4E?|N(b@%r6AL^D(ho0@wlHzWh~_#kf?x_Ej@n%Gi`kY!yZEsOB9h*#=k)I})6XfR`5r zB$Fp&v4~uXD{CWy0~H$?QS-DRW%85-#AC`$<3VN8${^HHWpcEM!QFZWsnBh-T$wun zU>EwlxjsBfZ-HX91AnpNXyv|((d?Pyjh~8f-@`d3-~@p%11}aw@yCkY(W(#I2=b?rz+r3)#>VTzy@T6=wFB;>zU=%X$cSUPq;n7j*s0z&MxcPh z(F{iW807ms<;EuEaQ)!eo5$vki;lYU1F2Ha#g%WZoNs=+?fjlpQSlE3-yED96pL!k z_kW&2JUrhnI%>}kz_VkjblFulH}JN7mP$Jbi*v48G}kPhOp_HR?e)poI>E|1z&OV{DZ>j%? z)PG9oKa~telM_>7=d{!^{azfS^)EVnm(NOVJB2ot)dYm@Kr%Rz9DP}A<0L=#Ugr~_ z4K6w!9$QH04JFyJWMoooKO?O^qmFIYqNDNWE2WNJp`&-H@37Rz3Vkqz(d1ZM?BJy} z{6grqyK&*rPmaBJ?9%X+Qn6{7cIF~3a*ust3z;ge7Nb#bE0df zZ8VI{9`T!rM{BKWr~ZQUbn9Tv92e7jNbZtiJe<-OQmy*YLO5%-%hEdpVoAPH^q~R-3tF(Yj(8D6Z^}D}Ge5 z;JJiE?<&!=de(N=L|eUg2M|%yAXTpss@Gh4Uaa0Mc{b15mYp8SSuZ&2|AhRB?LAv^ zUEj^&Wbm}GIwI2JqBAO4qJky*xwSZ1*05-8DA>HtBW}B^C3lnHZc>E%0ipRo^5D_r zaiEKF(H)hX(ODxrK=(8iI$6sW2RzMLXj-VaWd6{0DRE`{P47*6^3c(w^VluRaTvke z_C+gQcmNs&>$6cFxPC{VzjL!cdFX{?;FRbbl`Nx4%jhHhwEuPGFODaV1(MrdN;-qL zEGN@e!sfeo_jw~xRF1FhN>i?qe@Efl=F$i0Qew>%x}R#9zMt}Q>fh=!_fu>5`(Ikr zgY_W2f@qEd&Px7%sv)BOHBH=4HAnCN+Tn+z_aGfXMEREcDNFQzx`-%_!K0f}!Wu>o z;xSP4Uh6guZ9>;<_Tx0%19lUTxb8v6JIL$h9RT03pyTVw8&(D0ijHq0Z*0TxN3H1i zR`R1Z41erH$GganS77)PHyT3ZC!Vzc{}t7XhDhVzn7Yl7`lSO6xyWBSG1jjLG*m|Z z3Mp_W8Y(A$wGP97+l+?1cMhXQESX2@S-g^ z5$23I3wuhG#}{%*Ccjm49>mpGLuFdN`sQc^|4fm?HneP>JQEJZxTyN*4?78DBm58z z-W|p!;aP?gH;w%_vIP{9><>G*4Y)vg=8gT^s#=K6IpraTYUC)oVFK4BnQ(|#GzJde zs>nueGMfMM3d=V2cR*d-CxC)2?|3W3K=}yWF%hWv9|=4B|F6VWf!O*PvFbBo^*<8M z|79W2+W%&9B;Bn`rnd9O&k5rj)>o{v{%tF8Rz|% R8vlW8gCCrbRmu-e{vTI=QDXoA literal 0 HcmV?d00001 diff --git a/__pycache__/mixer_controller.cpython-312.pyc b/__pycache__/mixer_controller.cpython-312.pyc index 02c0736d151a9ff3e2796f93f17eedcfcd1a402f..4853ee12044a8273ae551813b4091739985d7021 100644 GIT binary patch delta 31496 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 Xmvhexr`{wpV%DXR=^sfD(p>)^?D~x* delta 31360 zcmd6Q34B!5)%e`mGn33@nJklavXWs5`;r)tKnNrzVM&mkFyu|bKr$2FOjzQ?K}8!8 z6@2Oo0yVTQp>EjNhFYuOR;;#8gqAp^YWsb*w%Rs`SVe39=iE2To5>KmeBb}~rS{By z=Wgeod+s^so_pWp!3R|D9aibyjEmDq;7|4bqqa{UIH)_XyI&=d@Di(JEny^Ur3Wuh z^Dr@1O^ck-?3J&LWwd)GYvY($fI21)pq|kIjA!%!6QE>bi3T7A2DnI1|O?SW{>@C1V0yF2&_SStL=(^;_^lH)l324^MiQWh6gIBnL@}Y z2xb)d8&@;q0AEP)0>#*PG2n_QE<}VGaN{U$d{~DQ0AC!$2g<9NiGVj#yay`;C|5I+ zAY**6LWqzOz)xWAr#X{@W1q5Dv36pxv@rImP;!!=htnWk!c4bLwq_bZ3@BF4%mD0^ z0M-*k&jj>TW)_{fM3|Y`@H}l)mvf-Y>9orbM@pgO42lbkP|1`5ZYISA2B&1M1Kg}2 zxk7ypvcDWMW(P9@ji{NqfS(h@2O3f{^8jB;@!|7(J>biN_)t6Z0e>CEhls8KTsf2F z0rm$9DwzdS zK*qvgMrf?nfUl(Z5bZU9TSRf84sQTl6~zVWDw$fqE%pzm4$@0tIQ1~#kOVFDz<8=D zGSvJsC|W~tq2`wZ?goETDAay z!;uRE_iO>=28s+Hxef3egZMxrYGy0on_x5n#><&)(EX<1AmmINl&}PaTF$h?^JYfe zx{|R2t{HKjU_mA003^e7_-T_f+u_+tp99@185VFYer`A+-O9K?V{`_`vjcEj{P{Z} zZS$vhL3(RM+1-HiY-75Zn_4A|`#Xf`J|bgoW<2n;$NzK-<6&-PZflj&yuHlrqP#o8 zN`8y!5#`;e}09Z{`q0~>=(&j5+I3p#EWjFQJ{7nCEQ_XFC&JjmP)BSOP-kAGBZ z=3ZcNhd(EPRWb(vyB)9rjGQ?L7?$B_3xe=*fN}1=&)n-vY^<7DXWM0E7dq@N*3s5x zWxM(`+^%dCap$G^S)jbxOaMOlPK(WT zoUqyO;3NP{Yz}5-ak24Pq>vjQ{}{>ThT@IVeg*gTu5`|nnJGU&*vkV&36DtSZpqeM z?wBT>d;eDoZk@pZH3Cof8YYr)^y%r8MOrg7O8>Tw=MwUhIM16g+=Ap%J=uTRr3mCP zvE07o%-H?E+$CEh*-7#xE)DlqQYPn3PAG|SOAkvJ%~9DCNG1Fc$r=eG>6YFs-T#j8 zyOZpc_-k^X(=$uTIQbk|ldBW+tT1WGR*usm|o7 zFl|n%PL`c1*H_2O-q0$l(M%Gyf_}pght+vpzX% zZQj8a-)CA8R|L;&833~a#@}R0OP&u8PMmiW{s|&i%I!B5k%oZ*(*THWVHR1z6&9{e zkV0LuTgJ$`WsJOA?ox5@73TBz#gkq$h-Wf$e z&;g_4w;v=14d7!LSvuhPmmVe=6ZC-s-J0$gSCYTB#2q6v9n96bHQb5B@k%D%CV4{2 zok~nsy0uJ#D~_v~oHaeM94dlE^t8m9LrY@~S zl5W?yum}3bVHeR#{DzW zRXnD2Y3(k?93fp=cB6>+|4_Q3(Kt%F1RBSdE{*-S%=ifD(y(tvQu=>Dx}ptvlynJ0 zzPfa2>@%4OSCK9an=Tsm|CV%3{AZ+V(wNc}W50_jiIA=swo^p>e<)qiXdES70*zx! zm)iaXGdV)K)a>sfDg8enUD1X-O1gw0UtPM?_F87jRisPJP8ALN-;gf7NL%Y8EMt7Y zGENOiS3ENfEMw;M(Ux(#JAMqysIq^DnSs*P9I`hmcE5;tDqW-IOi!JGpp{`p+se^Y z`)y@3sz=G2K=s)2rn2jqnGy1)WWT&N?GYt{qX-%~v8^0Ej(^=&MjP@dc@u_wb$L_T zo0(ZxkvAo~L^P;>L*5cZ@|F-GZ;1hUn;nw3L}m`i+h1mlmbX%O;u!L#u>XiDi;y=3 z`&|+7(d8}Sn({W9YQMZiqk5FQ2~>|QZwh-6b6teIDcJaH(;iV`Yak@_W}nMcM97+q9d~WoBT9@d zYoo{UughArA$VfJbr!~1nC~lO4`UQ!`-`GFAA$Hr(vOF zaV$}k+Yp*7h!jEKiID|nnMiiaAS#(D%sGT=vboxY8Ko=SMhJR{<`^QAvToze#{IK} zUr}UI9NuZ*`WEKL$ox^qM((YJgWSQ&pC#ZKD~p2VBPJioW2X(YF8ZTvT09Gp8wp#B z8L~F3-4|oEceYzui_6N^V@@K?vDsZd`BsO`&en0g)wyIY_k4A>5(B*KO76Yt>&bd9 zyC$2oaC2)mllfd(bBbP$_&6u>sVrSZ{O?HRN*0S3Pm7I5Rmuqs#g@JiDu<|1S0R^Wo1v`2I~(hz(0Yro9t}OR*8##Tr-5$ve#}&}R%eI9 z?zFP=;n67v5C}$crCX{Y&zypCh?c=mU02rUIzeZ8lc!V1X~c;5Nt(YN6>+QMSzmU zvfQmZ%1i#GDxKUXJIc+t4ltGsKR(jJHsG&pn{~G^lcKmXw|r-@^!sGsrk&rFYLQ&_ z1q7=&bCG$vm{@G=0Ooat=uU|+>&+P)wwzBoF8O)dwWPWCm-pAR5RU?l8!0M(TZ z!Te-xaMn+g0sT#-(i^RiXI8S$zz_6dYj*h*HkY;C$-YT3Z8oQibz>R-kYtX{TYY4k zkL>WtX+hD5pct*zHJ%ThK#IYJFx^#E3}q~X#)bfCgwuQr(0OD zaLJ;k%9SfB8mpJqo23F9V}xN^T17H3;+Z3Thj8QfJX8EOHb?|2{WY|0)*aTirU*GA zw=5esjDna7H9!!xDPC>%Bc1&>ez)6enlY?T_o~y`TqNlTH+8#_J9x{JTDtI&WOgmL zpRxRtC|3NCBK!s<*5{|k)ImB4$H4cun9rS zzyo|18HMHMA4=uk?OheWEuuJN*yGzHkuM(F9`A@mUjNXxr0o$%c;rTS12^7e%Z>#riDu?>gyNPHn1OJ-J=LT zLhu&^lvNnKVNs&ld7R_O{c*Se>1>*_Kr6-{?cU&%Hg93?Kr|8rYtGr^u+xe0sX8q7 zCf2gk7lR2Z!YpOTJ}geTupjHl+gb{*HL)LNIAOC=qaVz=@10n?KOL@OeRTl&-rGL!qxQ-!v0I<6#` zV75%Q_6~>BWpO!7789icPE{$dkP4s7ve^l`maZVms}dZ+{YVckZ=V_`oUuBaol1Cw zRRw=w{-!)VM|m@{?L}bQc&_*9UNCY~pSdlZvX@TW;6Y}v;e=gQV^hDo<33Z>Y7;vf zJDh_6XI7w4PAT-o!jXMb)#|2(+NF&RqPZ2*TZ=U#CRju&*!q3XE>Yfvw7$UQo~-2x zzVE^#`d5tnO)JS)+*1Q9;ziuV*6KO^bAu{XroJpZd24YsoP#^W+)e;`YbibhZpTy$(Rw0)6S_WNz6{q`rjFCwM5ENi*Fr*?!y{kPoZ)25^dQE!2UL7`=E^UtZHNqkpawS9-pVQRM6Ep1j4 z+lqvQGcBNcDfy#ho>G1dnKueD&;9J>3h5as7x&65z~7-)tRyE2wQMDlun0jFADC(& zU%qA;C_AcqBh-yR^b)LrYn0sv;P@!+hU>j>k@vnM-U`2Y*kV=jk<`k=r(j$rB{f=>~k-^E@+@EHQnWrUpoe2R9wj%C&h=E#Tm2oJmqanB+6 z4T7sWH#cGKTmZ*4B2$JNNS~IDifLjjE(>}$6Z}r!L}WHyQj;)+)9=JU0vb5s56s+s zZB>iUG7Y6K*01Pr!n-&NpH5d49Xm2ta13zLuSm;G)ZBF` zOw0mPF>68EHv{kyGt7A!mZoz+-KtHP5r?*>N!ULyo2l0WC8-gotB!hkA7MMV{wTxD zYG4GAEHp*M2L>-;(M=PEjSTR`I4yRY%hqLOoNCAs4W0aQ2~iEy{BkxSxr6dfvVia> z)(|~^VI$G6z|HB<5g{Zh^hmASvBl3gN{S|UavlOl) z#q5Bpt@!pI+m;;`o?y8_V!?uB=2Jz20txP$}VBk4`(Eqy4H zZn2IN_H}F>C+tex3Y)dV*5+t+m>8R>D_&56Hd8T`r!vzOg%j+k8T?F~F`v%ma9P@@ z<+fYfoRn5yLU5DV|Ms66#Z|foj)zxq1pRv7J;;mleU9-`f(R5KEjkk~yi8RJS{+1xo zWqaeYzW!7x&(K^-g0`QkD6A}yy_{bmubiTOwLnohNkvI$mhu;~iDAHfA(<3Y5vQvH z7hF9{=Sb?4mP}!P42aM+@JGL?g)4>1_ok5)uJpa4)wCk|{5}<9by;j}PB1Je z$!Hg`PPpvZyrl`6F+n>V7QY|+R?xn~ z4R7x6mnch6PP6!DEo8>PcYl9K0b9hmpG@%JR*VMC>IKes;&U{WfVPX>fw_Of6g5m= zV2Uc|FEMom!DR$gyX=C(V3jhk4qc~ou))>Ic0tL7qA_0EEU~+xtUe zN%j>pJSxN&EGSJ__!S-^98r|d88wiJ3TU?F5Hd2qH1te+ZS$$>Q%zZpaTdx zl%rki(u?KF4cPs+c(00#=N`Ri3Ha1+e>yv?^Mp&q)wFYPEyUZopCD~(5EtPTN`>C3 zJ*vRLE1(cHFJM-0y)+Nv>8CI4gYHh>K`wB`muu3?uoYpf*r>?O;}R}qbKkqH5hz%K z<>+FgornCo(@-sJ+aKZ5i7EK|_S3b5(p{(M;_Pu<*tz1{uTGx zmBqr|PfQfBq`A=nTZ+lJ!J(u@n;J@ABa#*|KYr<;^wCLbizE%G^clIKFOs|M1e;D`G1kgsTyNU4f4i4e#MQAH>5#!&=akdqo^ zKN2(;Bq*QXr63NV3byb3X(g!^FLi!n8c9W7=_;e|-RU$nA)~@Yo_!H<*C7R>mB!z! zBDK;18UL1w9Q11!z9@z)5u$2Akq(MS;^M37na$WixE!;%=tu3?QFPP!XI0=_{ym1w zRK9@YzZJ(np5Lq?>jjT?pOn<__wFPGb#!-YGdba4&`RAsIGh2m!3k+ND6@d;2WL_x z!VwL%5I9SI2Qy*^^v{~+3eVd>)F`uzqqz$}S8BlD^b-|uJK6nMUrQ)%mh40ppdCJ$2GdcXR1*9l9Bd1UPxb~R#as4sq$#}{0r7_}r`-18a6`Iw=v>tUvoL$y`~6bjQB$r+?f`jw1donu{GlFNa-Q+}2#%_c`g`usr?sS#poBC5o!**Lb5 z4v`r}zk>@GB9n4zl#%Aoo5&K_m78+OYkqso>+;EBA#xT@lh`1mMa-+XiXjdW6*9zs zKvs=1#QgbuvOtxN3_25HP(DV-_`Lr zoH3J)et(vKcs!}dqCQb*3PcXASU}Pn9SttQ9|~X3?0fw52_!e21_UV)uR^`Bbz+}D z=95zXnF-`j(4CW7`68H-A`qLeEJXZ|nJ-F9$b8Ym-fNn# zSey=~+0w-Coka@Cs=@EgBL79mg+bWWYW+thpCg&6u&+zyvJS;tB94cy$bo}VH?`pL z(h&xAw%`8|4x?#o3E zGY0n%C_Sj0!$|2tDx#CpQBG#5x=>I(68^+2h`yaI54fbyNcmglle9t<8ev`qPcxcO zNcoYSWRi(yJ&NT=g@AzQt~vZc9m(JiEFfC$^LG^!tgLobvNYDzhC`%rhfW-Ev;Hr0ZCYDY}jom{KATuj6bY z@dOt#Z9_~JudgSCP0`H05*Aprl?_)lC|%-}O-UMKWpnjpmUK|gzgbUu;&MSCe5%cs zHjBO4%3IVVg}-1XukfYINNF;44ML(-i5ytOe`^`3Ul-0e7U{c^Z1T4=`Z5O_0|f%A zhZ3m85k*%$QwL_l5#l~hDEP# z)LC5Y)ep2O?{dN?)_>$miHt`gBW72LP18U6g1ZmiSihwaKwBdjFa4RE?`)G2M1|Xl?C0uXsV1=qIEIn;5i!AWYH}Q5B}fvog81T_=OW>F{x-HQPwgiuXodQwOjfsYqh_og+3N-R+L2B3>-m)IJ{sD45 z9nZC<^Y5)EbI_zDqy~JzG-^`72Sk&yYVmUmPbvm#&LoG~j?O2_`X`=fe|6!Rm{;n( z3md!*tGyX(&~~h&wqsqG?MMULG5GTh#3s$5e5HzY0WudwGerwI*kqi5M1e7*v zrU&3rd?dsE0P{1Ds{1kZLja*5wG5)vyv;%uNdF=qe9A%!i2NiJ;BZm41??JN@9e?5tBII1);myNa8Ji5+8FBxTS{n=BLs^ zJF-2f@9WuU5~2~d6Ey2lEPn@Z4`(iTq73P-Qeiu>dKby|)L?&VC_pb!%y}q-F2YFl zZ!q~ih0UGL=vKggb)p>|3OJt^UVVse2;u^Tz!v2BFFFWH3x_D)@xddkxn;Xx&28IF z%*y#lY!(f4<86m4vEcB-An^hMyB!zb%QF7(&EQp(@xQ*A6bpOz4#*X+yzN+;>i12U zLYWC$c~SCNEp|g!UM!~Q%8SF44uN>(nGoS$c(?}tvz13_39dZwL_3{LHfGl~SD#N$ z!zpABnM@i6r|uzeq(#@DkN@OWGFkAd=Tqf`*YAw{f!m2{Yt*A6JfAkV0-wH+u)3;-~2agLH|4uyH^_S1yvdYVy)f z*W?AlUSZT_SxTx7>7}8-#gGa({FJgW9R9UEaK__7$_^l9X8xx=L^$Y%qtZ3|$f5|Y zv5EZC`#}05&PFK()K{L3G8(Q|;jJplu?XercbF-K|BSa!FFp&0T}}MTyI_v@@dxe- zc>MIPHgCV1EJ~p6Mu?w@QZD|v6yA3?(M7w!%7$or#gVnr4NI^&8Xmz3>Po=N*WugM2(gih_Wb@AiXv7$xJlFqg9Ezr zLD*V9ui&3~kgS6}AiN317x3h{+K65iwAY9w4R0FJsr7dfF`=SP3VO*L>1hSOv6rm$ zpIqF1h*SzHPq6O7>G-8XB)K_!ja-fgVrlRAUc(rhV|Kb|{YG0DAU;9YW7|WdN_hqa zY9Bx0C}@`7K12fd-g(^-vM{Pktm zNWqUgO77C;Vh1h&%~G!78N=YvQ8FL}8+v;mF$*@-37KMn$HG*sx)gOQ8yA_V*ax;N zRN#XpXcn7oY9% zMRA#l9SDBwF;XdgPceAn7}+Eh^yGTvUn=zH4>NG94##<-oqKl0)^)n9AO@%zOoFec z_TLF~__U188F-*N9ri7B6jV(8UMc0m)u#dJ*Q4&?UwVX0mX0X+kw-`mhDYL!gZDj3 zCJ|E_GKQ|m2#!+j-9`BKA0z1`jlTjv3s(xvp_?B{%SIe)F#>vp&%b!Afbfx4>T`)+ z_(N(k`JTr~ChvTlOqG7A;2(RO+znc9Mn5@CKp;Lp4m-zGKI40E9Yt6<{I>5A4@AX2 z{vJtRgO&{r&7VV(>5LUalPs<&pP~^y2+6W^gO)`Rov{02kNf$w1xd6axF4$=0T4Eg z8=oN4h>Ab>1bGwO1$b5WEI;KbGB17!mfnu_1f|8_{1nNXfpkF2lmzUZWwAuCZ;T2C zdx0ESsGywAJq6Y(jn_Yo2Y`dnMFJV!{JqbRyi{tycVd&f00b|W@@JkQWm*$xSGEf? z>5DDK!J-pzev-2dG1Tbcduqa#5E5`2i;o@r)@Mm!ayzD}Gb>U{{3Fkj+#;B~wT-pZR_Am?syS?@FtyUcG2V*}_hig}}n(IxC3sWV~+pN1Pk8%%@z& zT9+)V@~Ppsihj^jxeQ)1T55;r*&ZYicl|zvYj=m$*_9rxHR1XqhL5m41+=qhIeV)V zo!{GlzOIaDmHf9Hpj@C>YP4c?wq1WTK*Q0;fuX^!lxX!DTx{*CtJedoW>)#+$OoTN zpx>wRljM^@r(n#XZJ)FOzAAwBfIGD}g@5_SWQC{e+ZvPq4?aN)uf2nYu(g@ss<(rM zS8FUgO$@v$)sB2N6;GKob=qvR$p#0v3#uAS5O81}PNxm_KaP@MGY!q)GMd&{+AUiw zrpBr=6Vx%yHBFd0ht`^Cnmr31rw9+lrm65SY4$XK-V}U-JK~|{P}boETR!szxDDin zG`whv_klq@QK|m~P(hiM@VUc^Lyg>9hOF@90Q=rfu2^`GC>JQ}Zn{y#4 ztv7|6>y0)2H~-`dBttOXs#l>DFNxt!SyH)YZi(m4rDSs4ZFwHIj7@=83ZzEJXJq*4 zO;#8B;4S3PmkyqQRf>NNlHCgUz65$D^v|UZq*|l}5FA z*B`8`L*Txp%>$?9!bcA9(toEN0&Do`1&0atM8eFJuvO59z$vOiR>NaGn3Y;Y*CE)2 zpc}!>2>t*dLRBU=by!%ZwTaF(d<|FVva7g}yUiI%qpS&MsIV69vklQ3+|eK`A2 zcE9|raZ-46o|<7p_IX3WkfGqLp{QrkyLv-!a_{y-X?>0F7|j<_ z(|c$3B_1l}ZuY{raN)4c@Wp2eNkYbv8n1rbrxFsAaN}@nJbXZ5L;>k5PBg5yR>V}q zNlwSSd_)4mX%4$jBa8tTc{8r#z+VC+;3|>i61q6N#7(%7_>}k^gq_F` zM(QT;Q4*O;#=U3A;g_9+1IldpHf;=k3YC!`kv4FDjZgKs2}(h?96t9#Uoq)cxKx4W z0jI>=fFyJ);p4T^X6Y{JwshdR!mYqxXPlkD7i0*drX9w(6~P|ho3?J5sJs%@tTV-5 zUh7seOH^H;P=U@^S~)JzB{ZR=L`vLJhL(Ykz|dbpWCfQ+`BZlDjo-%^o!nmf*%gONBuXeU#?W1V^L|bm*2*KEyJKZUSG6k-1~t z8n>1b1s|z`t_<#2fXVc;w<+{9lBq{EOxh83gT!pK48URuYK&TIYbzCh{O(3uo5Qbe z1YvjD?4DK!Ae-siekGbME-OXf`sYlt=w0C^tJpjZ}ls8E|efga5ZtL1;+Md-dBzv>d8+EhGs(q@a(%9z6{ zgv1o;RNLXJ5(2p{TStHipmR5Su;xV54l8h^scuo@q7_Y*i$MQ^!(p}na#jnOEngZ``nTk=Jn#oQ) zl$km)EI%3T5blHMJ8o5;25{}DH+nx(hnFJZGv6>=Ukn{Ij#>D8LknaHueYh-doUsX zv+vM4@W~PpUjlDT<)(ke!J^)zwc8Imb?o--Vbhg8bS-u$f`cD&f=oBtu z2K_}*-w#7QXB@N@kVyI~oJYoBV2sL9S0)E%5gwXmP%|F}K7eLUm&7LZ7MxEXKLr12 z#}8*04(E>VpWScvPOpBe;Ee11(x#!MP3M;~Lra;nOIyy?w0f(zcynwQWyF9tj7S_n z)XC@dIYauKKGl%ExJN#mkbGe4P(p5x@`53?SKhnoP#kLZ7|#d#_ydd2=?i;z0D#U; zEIzsTWU04ujd$%P?;4AD+UB98W^ZisFqEq~ryt*E1VD+Jf691f`RS}v`ZJy0mFvBY z8@wBuyqg%WwQXoqySK^i&2b=2+bK-|m6Akb@6>}CJxchjdat3k;tW?=Q`-pJEQtc@tJh*w8o*NmEPEu zA0(xoPbwZtD(+7@n>49Mjm@SWtm_*;WSGg32WjM_=oSxfj z?91 zyi%LA1ZMd^vPrVQYmfMW^+5B03%5y%Q8o#sD_~>PZUqzLH!&Kj&EX5{LJJ}j?5q;l$|1P=2Z^)Exxm(2~(V-jMN@~L708@^tnA9UGFkWev z$v{X@9SX+F)d}y`fik8>$|g2o7SO8H5K+tJ5ti`SrfV!S1y<;vGe%7=%5T;P;vxhOY2Xj&B*TX)JUSLPRUx-V}BM?GQ)Cwr;=*ERTf^8fH;OUAB%n^>m z2Ir(98}Mx`h02z_2Rg=Yi&+?W=|Xrv7J@o`n1xE8J%%X|Y5&12-I)g!KMhcrbt3JL zil6-sf~ns{_y_{p(c_q!fWy24TZ^LoxzkOClpM$uuHeI~AA-7fcIDDzFG1luA3%-& zC^Q5bKjr_Y#`naa#{W+>d9qQYX@`@Nd({7(nA%HvXZ2MaD*IJpQO|7R3A>WQ@{YP~5HJ&T7^GtQ@)hf>Y`sd0Km~uXG z;!xtm{?%s_XZ0)`)+hFOhBI=`XG|H&m~tZNY{tw3>fv~ZlN{6!=j0A&WDlE);YMF> z;$?|EA!#I5l3)zx7(-qiYwdNo=Cwcirpg$2zNm^EHvgMS7EwjTLzjOqnz~5 zO+;E<&d`0x6HpcZ7%A%PfV%BgQ1^Z!c;$*9PU%*npI#27_m|2gE-kHyb!B^D)4-F5 z;p+RTBy~ZhJTUg#dMd-+FjWzRVPOQw72nPTX)zGl9Z{ck3bQL49MB6X>%zofgwH9G_p6 z2ioxe?y$j?7n7ya1$%Uttr?t1*vlhP@Tnt+t3Znm2zeCyV^X3)j;(!>gASL=F^PKe zI6B9yRy({MCfFA6*aMes=!pF~Iy7Q7=z?==Kd8Ir`{Z-bmE?c&JRDz@l(#t`qT`%b zQr-$f+ttC&`v7$f`nDfQzStx28XJZRXZ4YjxhI$WXu=ue*|HT!asl&Y=neF)_=dpr zQ?mq4dLKwhmo7N(NU#+S?NAx}3?bXqBtR23T zN%e6*Dpb^i^u_PMD^Jw(RtIEIaLG|0W%SlODeJF1 zk$SxDY;oDi0YU+9K29gr{GhhvRnXL2Qh<P21Fy|AE0CIQWw(OS9PvmSfNbI?ap&lC zu<%ZaS=;qwQ01)vX|=gJZBE$C+XNK?UK15*aGx-$X4`CQ162ZxxYG{0#vi9Bc2Gyk zY8I4&pkBss6zd)B0ljH8IXckCgs&l$m>R66dWd^W#HeAA5UOZJ>VSssBYZJ>aS^c4AAoIsdn6Zi;PnjaQ+k>6*(F2ppT1-` z&pcdM(!aCc=AB*l7UOMLb-tl_sG<3M!>)l+tI#wpd0=AN5cea=`jG`0FzE9^N9 zy}7-OhsJrQR=;Dcfu(2A!P1M34-b-L_aY5lp}r9-9l$6Na?Ck%bLeT~P) zdAz0d@8mAMsF0~%PH9lvtnPK5w|ck?G>Vi^ed@KfM!w!6O0ac6&z5n!fAR0fZ63!H#K2I zq^Zfr|LFy|UTeZUJo#cLASgwETRwIv0_qT>tHGk0WYM7z-cr?L%8LL$%))*L!J7bl z^34uMn{Y+oH;AN%qaR_)jev%)KgQHw5X^zo7ztaBYJLHN8xX8OunNHj1e*{vBd{T8 zN6>+QMQ{@WdYAG6Ov$mmBLsg!@CkyyAQ(aL8G^qdxPsto1X5U3tQ>&~fd+vdfdPRh1z{$F z90Vl@rXZMspcKJ81Pc%>LV)oFwgy2hf_enY5i}rJiC{H?wFu}z>t;-~BIrc08-W{v zhn|Mf^^TD)7JXEf?tdP|M{0w=kEtIbcocJU5#Tn4#p4hPP!^m`AZ&Xj7o{?V8ss?3 z-0!|5(JPWa&C@C7d}_*6%>1-GTahv{pH#>dQ~H`ON$_{1il_(w_7Ztes*6=vNPod4 z3I2{`4jz7)yi9a)iY=tya7lu{7mEhBy$VN`fZRsG`K+h? z5~eSvOjc<6l13!()9<7|r)2c!Oywm+UffJ(K?$e{KPR9B{G6I`34bnj5F^xT7?Hrw zN#G6qT%47u(Dgwv_&LFRhCjkQ;S|BQW_0ia8hW=g-Y0c9*_UzR<{~)3fBgozE last_timestamp: + state = { + "channels": mixer_controller.get_all_channels_summary().get("channels", []), + "steinch": mixer_controller.get_all_steinch_summary().get("channels", []), + "mixes": mixer_controller.get_all_mixes_summary().get("mixes", []), + "dcas": mixer_controller.get_all_dca_summary().get("dcas", []), + "fxrtn": mixer_controller.get_all_fxrtn_summary().get("channels", []), + "stereo": mixer_controller.get_stereo_info(1).get("level_db", -120), + "stereo_on": mixer_controller.get_stereo_info(1).get("on", False) + } + await manager.broadcast(json.dumps({"type": "state_update", "data": state})) + last_timestamp = current_timestamp + + await asyncio.sleep(0.1) + +@app.get("/") +async def get(): + with open("static/index.html", "r", encoding="utf-8") as f: + return HTMLResponse(f.read()) + +@app.websocket("/ws") +async def websocket_endpoint(websocket: WebSocket): + await manager.connect(websocket) + # Invia lo stato iniziale appena si connette + with mixer_controller._cache_lock: + mixer_controller._cache['timestamp'] = time.time() # Forza l'invio + + try: + while True: + data = await websocket.receive_text() + cmd = json.loads(data) + await handle_command(cmd) + except WebSocketDisconnect: + manager.disconnect(websocket) + +def get_actual_level(response_str: str, fallback_db: float) -> float: + try: + parts = response_str.strip().split() + for part in reversed(parts): + try: + val_int = int(part) + return val_int / 100.0 if val_int > -32768 else float('-inf') + except ValueError: + continue + except Exception: + pass + return fallback_db + +async def handle_command(cmd: dict): + action = cmd.get("action") + target_type = cmd.get("type") + value = cmd.get("value") + + raw_id = cmd.get("id", 1) + try: + ch_id = int(raw_id) + except ValueError: + ch_id = 1 + + # ========================================== + # 1. GESTIONE LIVELLI (MAIN FADERS) + # ========================================== + if action == "set_level": + val_float = float(value) + res = {} + if target_type == "channel": res = await asyncio.to_thread(mixer_controller.set_channel_level, ch_id, val_float) + elif target_type == "steinch": res = await asyncio.to_thread(mixer_controller.set_steinch_level, ch_id, val_float) + elif target_type == "mix": res = await asyncio.to_thread(mixer_controller.set_mix_level, ch_id, val_float) + elif target_type == "dca": res = await asyncio.to_thread(mixer_controller.set_dca_level, ch_id, val_float) + elif target_type == "fxrtn": res = await asyncio.to_thread(mixer_controller.set_fxrtn_level, ch_id, val_float) + elif target_type == "stereo": res = await asyncio.to_thread(mixer_controller.set_stereo_level, 1, val_float) + + actual_db = get_actual_level(res.get("response", ""), val_float) + + with mixer_controller._cache_lock: + cache_key = "channels" if target_type == "channel" else target_type + if target_type == "mix": cache_key = "mixes" + if target_type == "dca": cache_key = "dcas" + + str_id = str(ch_id) + if target_type == "stereo": + mixer_controller._cache.setdefault("stereo", {}).setdefault("1", {})["level_db"] = actual_db + elif cache_key in mixer_controller._cache and str_id in mixer_controller._cache[cache_key]: + mixer_controller._cache[cache_key][str_id]["level_db"] = actual_db + mixer_controller._cache['timestamp'] = time.time() + + # ========================================== + # 2. GESTIONE ON/OFF (MAIN FADERS) + # ========================================== + elif action == "set_on": + val_bool = bool(value) + if target_type == "channel": await asyncio.to_thread(mixer_controller.set_channel_on_off, ch_id, val_bool) + elif target_type == "steinch": await asyncio.to_thread(mixer_controller.set_steinch_on_off, ch_id, val_bool) + elif target_type == "mix": await asyncio.to_thread(mixer_controller.set_mix_on_off, ch_id, val_bool) + elif target_type == "dca": await asyncio.to_thread(mixer_controller.set_dca_on_off, ch_id, val_bool) + elif target_type == "fxrtn": await asyncio.to_thread(mixer_controller.set_fxrtn_on_off, ch_id, val_bool) + elif target_type == "stereo": await asyncio.to_thread(mixer_controller.set_stereo_on_off, 1, val_bool) + + with mixer_controller._cache_lock: + cache_key = "channels" if target_type == "channel" else target_type + if target_type == "mix": cache_key = "mixes" + if target_type == "dca": cache_key = "dcas" + + str_id = str(ch_id) + if target_type == "stereo": + mixer_controller._cache.setdefault("stereo", {}).setdefault("1", {})["on"] = val_bool + elif cache_key in mixer_controller._cache and str_id in mixer_controller._cache[cache_key]: + mixer_controller._cache[cache_key][str_id]["on"] = val_bool + mixer_controller._cache['timestamp'] = time.time() + + # ========================================== + # 3. GESTIONE "SENDS ON FADERS" (NUOVO!) + # ========================================== + elif action == "get_mix_sends": + target_mix = int(cmd.get("mix", 1)) + + # Funzione per interrogare velocemente tutti i 40 canali per un mix specifico + def fetch_sends(): + sends = [] + for ch in range(1, 41): # 40 Input Channels + try: + lvl_cmd = f"get MIXER:Current/InCh/ToMix/Level {ch-1} {target_mix-1}" + on_cmd = f"get MIXER:Current/InCh/ToMix/On {ch-1} {target_mix-1}" + + lvl_raw = mixer_controller._send_command(lvl_cmd) + on_raw = mixer_controller._send_command(on_cmd) + + lvl_db = mixer_controller._internal_to_level(mixer_controller._parse_value(lvl_raw)) + + # --- LA CORREZIONE È QUI --- + # Usa il sanitizzatore per convertire -Infinity in -120.0 + lvl_db = mixer_controller._sanitize_value(lvl_db) + + is_on = mixer_controller._parse_value(on_raw) == "1" + + # Prendi il nome dal canale master nella cache + name = f"CH {ch}" + with mixer_controller._cache_lock: + if str(ch) in mixer_controller._cache.get("channels", {}): + name = mixer_controller._cache["channels"][str(ch)]["name"] + + sends.append({ + "id": ch, + "name": name, + "level_db": lvl_db, + "on": is_on + }) + except Exception: + pass + return sends + + # Esegui in background per non bloccare FastAPI + sends_data = await asyncio.to_thread(fetch_sends) + await manager.broadcast(json.dumps({"type": "mix_sends_data", "mix": target_mix, "data": sends_data})) + + elif action == "set_send_level": + target_mix = int(cmd.get("mix", 1)) + val_float = float(value) + # Invia e recupera la risposta (per i log e l'allineamento) + res = await asyncio.to_thread(mixer_controller.set_channel_to_mix_level, ch_id, target_mix, val_float) + + # Logghiamo l'effettivo valore applicato dal mixer (OKm) + actual_db = get_actual_level(res.get("response", ""), val_float) + print(f"DEBUG: Mandata CH{ch_id} -> MIX{target_mix} impostata a {actual_db} dB") + + elif action == "set_send_on": + target_mix = int(cmd.get("mix", 1)) + val_bool = bool(value) + await asyncio.to_thread(mixer_controller.set_channel_to_mix_on_off, ch_id, target_mix, val_bool) + print(f"DEBUG: Mandata CH{ch_id} -> MIX{target_mix} {'ACCESA' if val_bool else 'SPENTA'}") + +if __name__ == "__main__": + uvicorn.run(app, host="0.0.0.0", port=8000) \ No newline at end of file diff --git a/mixer_controller.py b/mixer_controller.py index aeb788f..bfc021b 100644 --- a/mixer_controller.py +++ b/mixer_controller.py @@ -32,6 +32,8 @@ class TF5MixerController: self._is_running = threading.Event() self._is_connected = threading.Event() + self._init_meter_state() + self.start() def start(self): @@ -63,15 +65,22 @@ class TF5MixerController: with self._cache_lock: is_cache_empty = not self._cache.get("channels") - if is_cache_empty: print("Cache iniziale vuota. Avvio refresh completo...") self.refresh_cache() - self._socket_reader() + # Piccola pausa per assicurarsi che il buffer sia pulito + time.sleep(0.2) + + # Sottoscrivi DOPO il refresh (cache occupa il socket, meter devono aspettare) + self.subscribe_meters(interval_ms=100) + + self._socket_reader() # blocca qui finché connesso + except socket.error as e: print(f"🔥 Errore di connessione: {e}. Riprovo tra 5 secondi...") finally: + self.unsubscribe_meters() # tenta di cancellare prima di chiudere self._is_connected.clear() if self.socket: self.socket.close() @@ -112,6 +121,16 @@ class TF5MixerController: print(f"🔥 Errore di lettura dal socket: {e}") break + def _send_raw(self, command: str): + """Invia un comando senza attendere risposta.""" + if not self._is_connected.is_set(): + return + try: + print(f"SEND (raw): {command}") + self.socket.sendall((command + '\n').encode('utf-8')) + except socket.error as e: + print(f"⚠️ Errore invio raw: {e}") + def _handle_notify(self, message: str): """ Analizza un messaggio NOTIFY e aggiorna la cache in modo robusto. @@ -120,6 +139,11 @@ class TF5MixerController: print(f"RECV NOTIFY: {message}") parts = message.split() + + if len(parts) >= 2 and parts[1] == "mtrinfo": + self._handle_meter_notify(message) + return + if len(parts) < 2: return @@ -371,6 +395,68 @@ class TF5MixerController: finally: print(f" -> LOCK RILASCIATO.") + def _handle_prminfo_notify(self, message: str): + """ + Gestisce: NOTIFY prminfo ... + I valori sono: [ch0_type0, ch0_type1, ..., ch1_type0, ch1_type1, ...] + cioè interleaved per canale. + """ + parts = message.split() + # parts[0]="NOTIFY", parts[1]="prminfo", parts[2]=meter_id, parts[3:]=values + if len(parts) < 4: + return + try: + meter_id = int(parts[2]) + except ValueError: + return + + if meter_id not in self._METER_SUBSCRIPTIONS: + return + + info = self._METER_SUBSCRIPTIONS[meter_id] + key = info["key"] + num_ch = info["channels"] + num_types = info["types"] + + try: + values = [int(v) for v in parts[3:]] + except ValueError: + return + + expected = num_ch * num_types + if len(values) < expected: + return + + with self._meter_lock: + for ch in range(num_ch): + for t in range(num_types): + idx = ch * num_types + t + self._meter_data[key][t][ch] = values[idx] + + def _handle_meter_notify(self, message: str): + parts = message.split() + # NOTIFY mtrinfo ... + try: + meter_id = int(parts[2]) + except (IndexError, ValueError): + return + if meter_id not in self._METER_SUBSCRIPTIONS: + return + info = self._METER_SUBSCRIPTIONS[meter_id] + key = info["key"] + num_ch = info["channels"] + num_types = info["types"] + try: + values = [int(v) for v in parts[3:]] + except ValueError: + return + if len(values) < num_ch * num_types: + return + with self._meter_lock: + for ch in range(num_ch): + for t in range(num_types): + self._meter_data[key][t][ch] = values[ch * num_types + t] + def _send_command(self, command: str) -> str: """Invia un comando al mixer e attende la risposta.""" if not self._is_connected.wait(timeout=5): @@ -788,16 +874,20 @@ class TF5MixerController: def get_all_channels_summary(self) -> dict: if not self._is_cache_valid(): self.refresh_cache() with self._cache_lock: - channels_copy = list(self._cache.get("channels", {}).values()) + items_copy = list(self._cache.get("channels", {}).values()) cache_age = time.time() - self._cache.get("timestamp", 0) - channels = sorted([ - {"channel": info["channel"], "name": info["name"], "on": info["on"], - "level_db": self._sanitize_value(self._normalize_level_from_cache(info.get("level_db")))} - for info in channels_copy - ], key=lambda x: x["channel"]) - return {"status": "success", "total_channels": len(channels), "channels": channels, - "cache_age_seconds": int(cache_age), - "message": f"Riepilogo di {len(channels)} canali (cache: {int(cache_age)}s fa)"} + + results = [] + for info in items_copy: + ch_num = info.get("channel", 0) + results.append({ + "channel": ch_num, + "name": info.get("name") or f"CH {ch_num}", + "on": info.get("on", False), + "level_db": self._sanitize_value(self._normalize_level_from_cache(info.get("level_db"))) + }) + results.sort(key=lambda x: x["channel"]) + return {"status": "success", "total_channels": len(results), "channels": results, "cache_age_seconds": int(cache_age)} def mute_multiple_channels(self, channels: List[int]) -> dict: results = [self.set_channel_on_off(ch, False) for ch in channels] @@ -963,19 +1053,22 @@ class TF5MixerController: "message": f"StInCh {channel} → Mono send {'acceso' if state else 'spento'}", "response": response} def get_all_steinch_summary(self) -> dict: - """Restituisce il riepilogo di tutti i canali stereo di ingresso dalla cache.""" if not self._is_cache_valid(): self.refresh_cache() with self._cache_lock: - steinch_copy = list(self._cache.get("steinch", {}).values()) + items_copy = list(self._cache.get("steinch", {}).values()) cache_age = time.time() - self._cache.get("timestamp", 0) - channels = sorted([ - {"channel": info["channel"], "name": info["name"], "on": info["on"], - "level_db": self._sanitize_value(self._normalize_level_from_cache(info.get("level_db")))} - for info in steinch_copy - ], key=lambda x: x["channel"]) - return {"status": "success", "total_channels": len(channels), "channels": channels, - "cache_age_seconds": int(cache_age), - "message": f"Riepilogo di {len(channels)} canali stereo (cache: {int(cache_age)}s fa)"} + + results = [] + for info in items_copy: + ch_num = info.get("channel", 0) + results.append({ + "channel": ch_num, + "name": info.get("name") or f"ST IN {ch_num}", + "on": info.get("on", False), + "level_db": self._sanitize_value(self._normalize_level_from_cache(info.get("level_db"))) + }) + results.sort(key=lambda x: x["channel"]) + return {"status": "success", "total_channels": len(results), "channels": results, "cache_age_seconds": int(cache_age)} # ========================================================================= # FX RETURN CHANNELS (FxRtnCh) @@ -1090,19 +1183,22 @@ class TF5MixerController: "message": f"FxRtnCh {channel} → St pan impostato a {pan_value}", "response": response} def get_all_fxrtn_summary(self) -> dict: - """Restituisce il riepilogo di tutti i canali FX return dalla cache.""" if not self._is_cache_valid(): self.refresh_cache() with self._cache_lock: - fxrtn_copy = list(self._cache.get("fxrtn", {}).values()) + items_copy = list(self._cache.get("fxrtn", {}).values()) cache_age = time.time() - self._cache.get("timestamp", 0) - channels = sorted([ - {"channel": info["channel"], "name": info["name"], "on": info["on"], - "level_db": self._sanitize_value(self._normalize_level_from_cache(info.get("level_db")))} - for info in fxrtn_copy - ], key=lambda x: x["channel"]) - return {"status": "success", "total_channels": len(channels), "channels": channels, - "cache_age_seconds": int(cache_age), - "message": f"Riepilogo di {len(channels)} canali FX return (cache: {int(cache_age)}s fa)"} + + results = [] + for info in items_copy: + ch_num = info.get("channel", 0) + results.append({ + "channel": ch_num, + "name": info.get("name") or f"FX RTN {ch_num}", + "on": info.get("on", False), + "level_db": self._sanitize_value(self._normalize_level_from_cache(info.get("level_db"))) + }) + results.sort(key=lambda x: x["channel"]) + return {"status": "success", "total_channels": len(results), "channels": results, "cache_age_seconds": int(cache_age)} # ========================================================================= # DCA GROUPS @@ -1158,14 +1254,26 @@ class TF5MixerController: with self._cache_lock: dcas_copy = list(self._cache.get("dcas", {}).values()) cache_age = time.time() - self._cache.get("timestamp", 0) - dcas = sorted([ - {"dca": info["dca"], "name": info["name"], "on": info["on"], - "level_db": self._sanitize_value(self._normalize_level_from_cache(info.get("level_db")))} - for info in dcas_copy - ], key=lambda x: x["dca"]) + + dcas = [] + for info in dcas_copy: + try: + # Usiamo .get() con valori di default per evitare KeyError + dca_num = info.get("dca", 0) + dcas.append({ + "dca": dca_num, + "name": info.get("name", f"DCA {dca_num}"), # Se manca il nome, usa "DCA 1" ecc. + "on": info.get("on", False), # Se manca lo stato, usa False + "level_db": self._sanitize_value(self._normalize_level_from_cache(info.get("level_db"))) + }) + except Exception as e: + print(f"⚠️ Errore nel processare DCA info: {e}") + + dcas.sort(key=lambda x: x["dca"]) + return {"status": "success", "total_dcas": len(dcas), "dcas": dcas, "cache_age_seconds": int(cache_age), - "message": f"Riepilogo di {len(dcas)} DCA (cache: {int(cache_age)}s fa)"} + "message": f"Riepilogo di {len(dcas)} DCA"} # ========================================================================= # MIX BUSES @@ -1248,16 +1356,20 @@ class TF5MixerController: def get_all_mixes_summary(self) -> dict: if not self._is_cache_valid(): self.refresh_cache() with self._cache_lock: - mixes_copy = list(self._cache.get("mixes", {}).values()) + items_copy = list(self._cache.get("mixes", {}).values()) cache_age = time.time() - self._cache.get("timestamp", 0) - mixes = sorted([ - {"mix": info["mix"], "name": info["name"], "on": info["on"], - "level_db": self._sanitize_value(self._normalize_level_from_cache(info.get("level_db")))} - for info in mixes_copy - ], key=lambda x: x["mix"]) - return {"status": "success", "total_mixes": len(mixes), "mixes": mixes, - "cache_age_seconds": int(cache_age), - "message": f"Riepilogo di {len(mixes)} mix (cache: {int(cache_age)}s fa)"} + + results = [] + for info in items_copy: + mix_num = info.get("mix", 0) + results.append({ + "mix": mix_num, + "name": info.get("name") or f"MIX {mix_num}", + "on": info.get("on", False), + "level_db": self._sanitize_value(self._normalize_level_from_cache(info.get("level_db"))) + }) + results.sort(key=lambda x: x["mix"]) + return {"status": "success", "total_mixes": len(results), "mixes": results, "cache_age_seconds": int(cache_age)} def search_mixes_by_name(self, search_term: str) -> dict: if not self._is_cache_valid(): self.refresh_cache() @@ -1585,212 +1697,68 @@ class TF5MixerController: "message": f"Scena salvata nel banco {bank.upper()}{scene_number}.", "response": response} # ========================================================================= - # METER READING - # Riferimento prminfo: - # 2000 InCh 40ch 3 tipi: 0=PreHPF, 1=PreFader, 2=PostOn - # 2001 StInCh 4ch 3 tipi: 0=PreEQ, 1=PreFader, 2=PostOn - # 2002 FxRtnCh 4ch 2 tipi: 0=PreFader, 1=PostOn - # 2100 Mix 20ch 3 tipi: 0=PreEQ, 1=PreFader, 2=PostOn - # 2101 Mtrx 4ch 3 tipi: 0=PreEQ, 1=PreFader, 2=PostOn - # 2102 St 2ch 3 tipi: 0=PreEQ, 1=PreFader, 2=PostOn - # 2103 Mono 1ch 3 tipi: 0=PreEQ, 1=PreFader, 2=PostOn - # - # Il valore raw è un intero 0–127. - # Conversione: 0 = -inf dB, 1–127 lineare su scala ~-72..+18 dBFS. - # Formula Yamaha TF: dBFS = (raw / 127) * 90 - 72 (approssimazione lineare) - # Oppure si restituisce direttamente il raw per display proporzionale. + # METER READING (via prminfo subscription) # ========================================================================= - # Mappa dei meter disponibili - _METER_DEFS = { - "InCh": {"path": "MIXER:Current/Meter/InCh", "channels": 40, "types": ["PreHPF", "PreFader", "PostOn"]}, - "StInCh": {"path": "MIXER:Current/Meter/StInCh", "channels": 4, "types": ["PreEQ", "PreFader", "PostOn"]}, - "FxRtnCh": {"path": "MIXER:Current/Meter/FxRtnCh", "channels": 4, "types": ["PreFader", "PostOn"]}, - "Mix": {"path": "MIXER:Current/Meter/Mix", "channels": 20, "types": ["PreEQ", "PreFader", "PostOn"]}, - "Mtrx": {"path": "MIXER:Current/Meter/Mtrx", "channels": 4, "types": ["PreEQ", "PreFader", "PostOn"]}, - "St": {"path": "MIXER:Current/Meter/St", "channels": 2, "types": ["PreEQ", "PreFader", "PostOn"]}, - "Mono": {"path": "MIXER:Current/Meter/Mono", "channels": 1, "types": ["PreEQ", "PreFader", "PostOn"]}, + _METER_SUBSCRIPTIONS = { + 2000: {"key": "InCh", "channels": 40, "types": 3}, + 2001: {"key": "StInCh", "channels": 4, "types": 3}, + 2002: {"key": "FxRtnCh", "channels": 4, "types": 2}, + 2100: {"key": "Mix", "channels": 20, "types": 3}, + 2101: {"key": "Mtrx", "channels": 4, "types": 3}, + 2102: {"key": "St", "channels": 2, "types": 3}, + 2103: {"key": "Mono", "channels": 1, "types": 3}, } + # Dizionario meter: { "InCh": [[ch1t0, ch1t1, ch1t2], [ch2t0,...], ...], ... } + # Oppure più semplice: { "InCh": {type_idx: [val_ch1, val_ch2, ...]}, ... } + _meter_data: dict = {} + _meter_lock: threading.Lock = None # verrà inizializzato in __init__ + + def _init_meter_state(self): + """Inizializza la struttura dati per i meter.""" + self._meter_lock = threading.Lock() + self._meter_data = {} + for info in self._METER_SUBSCRIPTIONS.values(): + key = info["key"] + self._meter_data[key] = { + t: [0] * info["channels"] for t in range(info["types"]) + } + + def subscribe_meters(self, interval_ms: int = 100): + # Il comando corretto per i meter Yamaha TF è "mtrinfo" + for meter_id in self._METER_SUBSCRIPTIONS: + self._send_raw(f"mtrinfo {meter_id} {interval_ms}") + time.sleep(0.05) + + def unsubscribe_meters(self): + for meter_id in self._METER_SUBSCRIPTIONS: + self._send_raw(f"mtrinfo {meter_id} 0") + + def get_meter_snapshot(self) -> dict: + """Restituisce l'ultimo snapshot meter ricevuto.""" + with self._meter_lock: + # Costruisce il formato atteso dal frontend + snapshot = {} + for meter_id, info in self._METER_SUBSCRIPTIONS.items(): + key = info["key"] + frontend_key = key.lower() + # Il frontend si aspetta { readings: [{channel, raw, level_db}] } + type_idx = info["types"] - 1 # Usiamo l'ultimo tipo (PostOn) + readings = [] + for ch_idx, raw in enumerate(self._meter_data[key][type_idx]): + readings.append({ + "channel": ch_idx + 1, + "raw": raw, + "level_db": self._meter_raw_to_db(raw) + }) + snapshot[frontend_key] = { + "readings": readings, + "type_name": "PostOn" + } + return snapshot + def _meter_raw_to_db(self, raw: int) -> float: - """ - Converte il valore raw del meter (0-127) in dBFS approssimato. - Scala Yamaha TF: 127 = +18 dBFS, 76 = 0 dBFS, 1 = -72 dBFS, 0 = -inf. - """ if raw <= 0: return float('-inf') - # Interpolazione lineare: 0..127 → -72..+18 dBFS (range 90 dB) - return round((raw / 127.0) * 90.0 - 72.0, 1) - - def _parse_meter_response(self, response: str) -> Optional[int]: - """Estrae il valore intero raw dal response di un comando get meter.""" - try: - first_line = response.split('\n')[0].strip() - parts = first_line.split() - if parts and parts[0] == "OK": - return int(parts[-1]) - except (ValueError, IndexError): - pass - return None - - def get_meter(self, section: str, channel: int, meter_type: int = 2) -> dict: - """ - Legge il livello meter di un singolo canale. - - Args: - section: Sezione del mixer: "InCh", "StInCh", "FxRtnCh", - "Mix", "Mtrx", "St", "Mono" - channel: Numero canale (1-based) - meter_type: Indice del tipo meter (0-based, vedi _METER_DEFS per i nomi) - - Returns: - dict con raw (0-127), level_db (dBFS), section, channel, type_name - """ - if section not in self._METER_DEFS: - return {"status": "error", - "message": f"Sezione '{section}' non valida. Valori: {list(self._METER_DEFS.keys())}"} - - defn = self._METER_DEFS[section] - num_ch = defn["channels"] - num_types = len(defn["types"]) - - if not 1 <= channel <= num_ch: - return {"status": "error", "message": f"Canale {section} deve essere tra 1 e {num_ch}"} - if not 0 <= meter_type < num_types: - return {"status": "error", - "message": f"Tipo meter deve essere tra 0 e {num_types - 1} per {section}: {defn['types']}"} - - ch_idx = channel - 1 - command = f"get {defn['path']} {ch_idx} {meter_type}" - response = self._send_command(command) - raw = self._parse_meter_response(response) - - if raw is None: - return {"status": "error", "message": f"Risposta non valida: {response}"} - - level_db = self._meter_raw_to_db(raw) - type_name = defn["types"][meter_type] - - return { - "status": "success", - "section": section, - "channel": channel, - "type": meter_type, - "type_name": type_name, - "raw": raw, - "level_db": level_db, - "message": f"{section} ch{channel} [{type_name}]: {raw}/127 → {level_db} dBFS" - } - - def get_all_meters(self, section: str, meter_type: int = 2) -> dict: - """ - Legge tutti i canali meter di una sezione. - - Args: - section: "InCh", "StInCh", "FxRtnCh", "Mix", "Mtrx", "St", "Mono" - meter_type: Indice tipo meter (0-based) - - Returns: - dict con lista di letture meter per tutti i canali della sezione - """ - if section not in self._METER_DEFS: - return {"status": "error", - "message": f"Sezione '{section}' non valida. Valori: {list(self._METER_DEFS.keys())}"} - - defn = self._METER_DEFS[section] - num_ch = defn["channels"] - num_types = len(defn["types"]) - - if not 0 <= meter_type < num_types: - return {"status": "error", - "message": f"Tipo meter deve essere tra 0 e {num_types - 1} per {section}: {defn['types']}"} - - type_name = defn["types"][meter_type] - readings = [] - - for ch in range(1, num_ch + 1): - ch_idx = ch - 1 - command = f"get {defn['path']} {ch_idx} {meter_type}" - response = self._send_command(command) - raw = self._parse_meter_response(response) - if raw is not None: - level_db = self._meter_raw_to_db(raw) - readings.append({ - "channel": ch, - "raw": raw, - "level_db": level_db - }) - else: - readings.append({ - "channel": ch, - "raw": None, - "level_db": None, - "error": response - }) - - return { - "status": "success", - "section": section, - "type": meter_type, - "type_name": type_name, - "total_channels": num_ch, - "readings": readings, - "message": f"Meter {section} [{type_name}]: {num_ch} canali letti" - } - - def get_meter_snapshot(self, meter_type: int = 2) -> dict: - """ - Legge un snapshot dei meter di tutte le sezioni principali. - - Args: - meter_type: Tipo meter da usare per tutte le sezioni. - Nota: FxRtnCh ha solo 2 tipi (0-1), se si passa 2 - viene automaticamente usato il tipo 1 (PostOn). - - Returns: - dict con un sotto-dict per ogni sezione, contenente le letture. - """ - snapshot = {} - for section, defn in self._METER_DEFS.items(): - # Clamp meter_type al massimo disponibile per la sezione - safe_type = min(meter_type, len(defn["types"]) - 1) - result = self.get_all_meters(section, safe_type) - snapshot[section] = result - - sections_ok = sum(1 for r in snapshot.values() if r["status"] == "success") - return { - "status": "success", - "meter_type_requested": meter_type, - "sections": snapshot, - "message": f"Snapshot meter: {sections_ok}/{len(self._METER_DEFS)} sezioni lette" - } - - def get_meter_types(self, section: str = None) -> dict: - """ - Restituisce i tipi di meter disponibili per una sezione (o tutte). - - Args: - section: Nome sezione opzionale. Se None, restituisce tutte. - """ - if section: - if section not in self._METER_DEFS: - return {"status": "error", - "message": f"Sezione '{section}' non valida. Valori: {list(self._METER_DEFS.keys())}"} - defn = self._METER_DEFS[section] - return { - "status": "success", - "section": section, - "channels": defn["channels"], - "types": {i: name for i, name in enumerate(defn["types"])} - } - else: - return { - "status": "success", - "sections": { - sec: { - "channels": d["channels"], - "types": {i: name for i, name in enumerate(d["types"])} - } - for sec, d in self._METER_DEFS.items() - } - } \ No newline at end of file + return round((raw / 127.0) * 90.0 - 72.0, 1) \ No newline at end of file diff --git a/static/index.html b/static/index.html new file mode 100644 index 0000000..27d9d26 --- /dev/null +++ b/static/index.html @@ -0,0 +1,389 @@ + + + + + + + TF5 Web Mixer + + + + + + +
+ +
+

YAMAHA TF5 Web Controller

+
+ + + + + {{ wsConnected ? 'Connesso' : 'Disconnesso' }} +
+
+ + + + + +
+ SELEZIONA MIX: + +
Caricamento fader...
+
+ + +
+ + +
+ +
+ Nessun fader disponibile in questa vista. +
+ + +
+ +
+ {{ fader.name }} +
+ + + + +
+ +
+
+
+ +
+
+ +100-20-60-∞ +
+ +
+
+ +
+ {{ formatDb(fader.level_db) }} +
+
{{ getFaderLabel(fader.id) }}
+
+ +
 
+
+ + +
+ + +
+
+ MIX {{ activeMixId }} + {{ currentMixMaster.name }} +
+ + +
+
+
+ +
+
+
{{ formatDb(currentMixMaster.level_db) }}
+
MASTER
+
+ + +
+
+ Stereo L/R
+ + +
+
+
+ +
+
+
{{ formatDb(state.stereo) }}
+
MAIN
+
+ +
+
+
+ + + + + \ No newline at end of file