From a2cfe07dee9ed534d1d51c0fab88af926871c5c6 Mon Sep 17 00:00:00 2001 From: Jon Beniston <jon@beniston.com> Date: Fri, 3 Mar 2023 16:14:09 +0000 Subject: [PATCH] Add RTTY demodulator --- doc/img/RTTYDemod_plugin.png | Bin 0 -> 32734 bytes plugins/channelrx/demodrtty/CMakeLists.txt | 63 + plugins/channelrx/demodrtty/readme.md | 103 ++ plugins/channelrx/demodrtty/rttydemod.cpp | 780 ++++++++++ plugins/channelrx/demodrtty/rttydemod.h | 218 +++ .../channelrx/demodrtty/rttydemodbaseband.cpp | 181 +++ .../channelrx/demodrtty/rttydemodbaseband.h | 103 ++ plugins/channelrx/demodrtty/rttydemodgui.cpp | 669 +++++++++ plugins/channelrx/demodrtty/rttydemodgui.h | 130 ++ plugins/channelrx/demodrtty/rttydemodgui.ui | 1323 +++++++++++++++++ .../channelrx/demodrtty/rttydemodplugin.cpp | 93 ++ plugins/channelrx/demodrtty/rttydemodplugin.h | 50 + .../channelrx/demodrtty/rttydemodsettings.cpp | 213 +++ .../channelrx/demodrtty/rttydemodsettings.h | 88 ++ plugins/channelrx/demodrtty/rttydemodsink.cpp | 670 +++++++++ plugins/channelrx/demodrtty/rttydemodsink.h | 170 +++ .../demodrtty/rttydemodwebapiadapter.cpp | 52 + .../demodrtty/rttydemodwebapiadapter.h | 50 + sdrbase/util/baudot.cpp | 192 +++ sdrbase/util/baudot.h | 77 + sdrbase/util/movingmaximum.h | 99 ++ sdrbase/webapi/webapirequestmapper.cpp | 14 + sdrbase/webapi/webapiutils.cpp | 4 + 23 files changed, 5342 insertions(+) create mode 100644 doc/img/RTTYDemod_plugin.png create mode 100644 plugins/channelrx/demodrtty/CMakeLists.txt create mode 100644 plugins/channelrx/demodrtty/readme.md create mode 100644 plugins/channelrx/demodrtty/rttydemod.cpp create mode 100644 plugins/channelrx/demodrtty/rttydemod.h create mode 100644 plugins/channelrx/demodrtty/rttydemodbaseband.cpp create mode 100644 plugins/channelrx/demodrtty/rttydemodbaseband.h create mode 100644 plugins/channelrx/demodrtty/rttydemodgui.cpp create mode 100644 plugins/channelrx/demodrtty/rttydemodgui.h create mode 100644 plugins/channelrx/demodrtty/rttydemodgui.ui create mode 100644 plugins/channelrx/demodrtty/rttydemodplugin.cpp create mode 100644 plugins/channelrx/demodrtty/rttydemodplugin.h create mode 100644 plugins/channelrx/demodrtty/rttydemodsettings.cpp create mode 100644 plugins/channelrx/demodrtty/rttydemodsettings.h create mode 100644 plugins/channelrx/demodrtty/rttydemodsink.cpp create mode 100644 plugins/channelrx/demodrtty/rttydemodsink.h create mode 100644 plugins/channelrx/demodrtty/rttydemodwebapiadapter.cpp create mode 100644 plugins/channelrx/demodrtty/rttydemodwebapiadapter.h create mode 100644 sdrbase/util/baudot.cpp create mode 100644 sdrbase/util/baudot.h create mode 100644 sdrbase/util/movingmaximum.h diff --git a/doc/img/RTTYDemod_plugin.png b/doc/img/RTTYDemod_plugin.png new file mode 100644 index 0000000000000000000000000000000000000000..58acef1fd5864ba3ac1732b4f7979b64170c6557 GIT binary patch literal 32734 zcmZsC1ymhd)8&OgupmK$yK8VKxNC5CcXtbf;O_43?u6j(?h@SHr+MG|=AW50tOZT? zz1@BKbe&zhs&)m-$%w+kV!?tyAb4>xAq5Z!+!q7_`wa~a9B~#*Cjd5J4ho_Ipo%e^ z17HWjgkPE;1gegNd)9*l_F-(r)Eqz{gg@`UVEr~lh9HoSgSZgCqO0~vI*cdUZu47^ zG?-dkK2a2efPc}#M9DAX;)SRr$<kl-lgw-w%Xqxf(?y>fufeSd--xIn!kb+=ZW8*w z*p%e-4u```5)e9Blo#&{BO{P}7UE|$nk0DOJ7aV({yUOJOU|(uwXVXax3=fC=CY<X z_LRDpzI(FwSpPnHU+teT=V^zOjPJwr%`kDetq{Z5xEkZKnaJW#Pfuq&fwO;oJgSsU zV<(&jz5<@j)lN6$E~>*i1)0=v@_aQeD<y@)Xeid{{gOAEQBhG5OD@|`SBJx4zjJ@F zJq65J7hlWtkpi+&dltPU>@NC7|Le2O6lu);Xm=+GDJeV-`}sn(!Bh_>i#e(l6YWF- z4yU8o7f51}Fm|s;M+b-61uo#e&q1iYwezHYm~7T76WM}h$vKTLPj|6Vzi0%GoXi$$ z`fvS<E&2KRrKCoDUiW-n*WX^&lbK8g#7fbRa_U37s(s#G6SbT%-A<S4OWWEssXcx} zVrV)}3V-<cDJyT|<!&8AxafE{QHL%JqCA>)^L+CUp_zlNk$AS4?~|m1kbvFG#_u1I zcsvpq2=2(p$ftiN{F#fRn9weKzP-=fPm@Y6hrc7w&z^@iXIm5Cq?H|y=E>zpPCUbp zJ<nyxzwP)qF1pvW3#ABF(6#qv>2KuCK$L&%-mt-_16sB<b6?Be+B&a4<123h5;i zp>y2)Q>|OXQBma->U3;312GPVLs(@qxRb9n?mY-emZ@)@h2W~b)uraF84V{)%9DqL zgb1P2Lc<wF|2Rig1IFYpc%6?5Pp{b&wUa<Jkf`JRV23~}cZtz?5BdI`xL)?Sk<n4F zxu4k9P5k$!zZCr6?jzr_795}NFYeYn&YmBx#3{+8k_c8z{bCwd?1y>oRs^F@JG`C- z)X=x1oD~?T5-8cVYmG-Qw)=(E)U)!2cMtn9nN6eqQWD@RtdmM6tav}22>dXc7TbVn zA;?NfRoNCn-xfoO{3IU6Oo}WQ6UO*Ex|bac#&#G*h33WY^zG?&9s9{JA;+=pq6c@y zyT7l~Q7jevtl9gU3bW&bU`T0k<u_G|P@Ft*Mjx$GztJPSq_N{^`~@0XYt~B7fxaed z-b$%&O4jJ{&|#$uZEmZcTE>yO)_$LdSnB2no}IKqz(Kyz_;2_9gM)*4DUm$43v=Zf z!Xu(^aU{gRtT?hTx?h`4P3~p-WLQ+}(EB_L1OIvhw&L2X)#CMZTbSW~2)r@DpPvZJ z^W6<U2K9u6>C2_8@IIdlCU5?(()%OEZsgBoJo1HcC;$x?7q_zgcIjqbCo?%&{8B3A za#-gz#xU?Yu!7ZiB#H3FGD}zMSG0Jr5Ik!Ca9#8_;*Br;lEbfiXIpQrHSsBdZ<T?| z8W~9{xzEpZHI+}zTWO6KHY+7tJoDI>DcPk*@3x*NF26QGC><hzFg5EHT)oT@Nhbku zHAfYZn8a<R!y&4A>LcxT;$6+Y=Lba_E<84fXc5Eev-&4CU6{IYIIpWc#LeP}obOp- z$9(0wXV4x71hV|xzF($RTLFx{q@*MzB?SP4v&|s<j!40OCklP-U>F$+0fzt|Su6bU z@sU_GLT(ONaAI(Qso$unPa~~YnxZ{>Ry8*XvPd9v*FCS`XK!Dh&Zhkt%&qIb>75p4 zdb<NwO{ZRG`gnanw9|fEGfa_3wr!H>)!=bw-QL;o3{0ENT3a%>1+2e=z5VM&q>rMq z^6S~#E4>y<7&co(_bIU46zn#o_J@%j?Fi^F#F`W7b!5c-(J915kaee^4@we9TbLkt z9*2%96x9)H2$~Mwv>%-3o(&rwF9ruAY~D^r$<*_HC0mp`Rp<=d+6M#%IM*XcC*`Av zs)kvvpAT6K?eui**gZ+aY<N}_U(N4&3{_^FU@MO$?<wNANc=AF{VvkSc%AUM#GJf1 zqe256?^HoiO6m0g5t>-32agAZOv~2#Gr9#!>CK4FU5vq{VZUTf+@5+-_b6i34S~eR zEE_?seE?Y~yYdloii?8-&V%@#35(NX1*gQs85Phu^78VcYUhqF5f<8|#;_jCxK5jX z>_p|s+n^4UD5Cz1nSdoGJZAhIl(ILHEcm%_jV?i4Q&LiLzFfm)s|N-)&WzU1&aS$; zx{hGWc!+40fNisqUeW?s__%!D+~V{s1Ux+Hd3W(D+HQ{rg~bDzuh--)Gg<C+o2FiB z`sAWt>VK<ecR<X3Yo1bSq+R{IG5*%DYa`K}bU~C)OOZ=XLSbc<`Caa{v~%4xze;WA z5wbqMf_Ps&jLw8Yobc@N=BSqynu2mm*PejQo{X5@T>tSIyF$geaRIJ~h2;0mK|Uh) z`M1{{cbiM<g^ptHyBjv>U=LS&9>j>f^40h;L7TCghv3Ls-Bj@#$^76-)IlS<<vf4$ z;s7^P*(-%<d<rM%?X>>*>mxVXjwGMLhTA6>o$<rPD%%VPHn@2V>ncsSuZv#;Q<(bq zM8H8~#zk=XNs)P>Ey^Kkfcc6ENOIO$EwdtP!SaabQv~GGypMCvU2Bfg;};4c#Lbz{ z+fxRQhZvmrYH>gB+w)Z*wu=c|+)etKXJLMRpgDRQur%8ID>`3JY^JXJut|)?#l?4| zoXd~--X6x+UvC>TJuhK;Cn9+t_xtgEq@p>saH6p5ozK?V4L#4=?*=wqI-hW@0y}6} zY+j!qW{7!T@3%=3d|t0IDWP8nWxa_`q42p~t12oYY6kpP0zO(8LGD}~>Aa~|{ub~G ze94XEjq%3W6ojDa!X0y4;@RjgiTBaEJEI|6p1&;~Xz1~%a49Nx%qN~vA))=;yhBD8 zDik9=BuO*0-4<85!e_JjF&PD<>5qHhJwRXG9tdv}1-@qcVqqZ+77{k!L8h2IQMS|6 z>OK82L3fy&HIvh*CPlV1KHpKqcssdcp-K)-0q-8WS`%)&Ud0ulI9`~C0zvj-rKZU_ zgG)<GjqBd23FOi#ju*WcjrH}oT+U%JI|SVI+KK28MB=|l0<+;T=-PH-B#p;2q_YVZ z#hb-<M8dvgb4+&wPUY>X^DQ+_VH*H~Jv2wF#Twxh_SxClElle2<3ya(W#Pxi1%pU= zW+`<U0Ihz;<V_IW!6DF^8XaJ5rC3xv0MjI~X=7qSu4VEGi<yFn=?d_5NLW&G=p0e0 zdyqjNS;Fd+4Plha)r}N%1pYd0<DGY|W%cCkkcKold#(gN>sYkyx%ba#3aDNWe2SRN zS<PyHV^L`B{}GQtm+;y<b}2&rwD%a4f8E%?Yl+*Ka#YtQi9J)kd~k)oP=>&ms$#9N zd8&1}1rM4Cn!L-=S7R_5it7!<_-^>1^LC??r(ihwl$6lx&w42Z4aI|@X$y9cmO#1! z-fN;3O>O-pbz&$4QedI;uiP>P^w{X=EVC--K?rm!p5DA_m9F2H#9892mah=$>^{+3 zWk|BRc0HrIeS01n2Zw#<*C~;jl@q&vh$Fd)P_)^SR-{z$sj%_pSJO`T3&y20rR+!& zEpOE5{P?OHv3Rkom>+dt(-FV-(9KI>;D7WlMgmPFW^KfPufx%|$vM}gU0+}SfI{TG z8et-l=@vlP5gDub5_J3sdAd+<GM+&WSose`Ue}vh6-o+{C%|WRc)vW3^FD<=Nl+w> zjg4vN5p!^;%|yk-*jGj@TepU%8GS`S>{X+Y(e!@UMKxLXx?8=txR`;Ny3}%6bvnpT z4AI=A47IyGU6q_Xsh`*8GU)qc&+dM`PhHtY#GK^6QC3DXr3<@N*|Y^~Pi&$VMtq1W z_lK_IUO)MFHVLK?tEjQ9e<u_-?_LWwEB8x)YZ?{DV)l%sslJQz9SaLbeA&a`3f+0e z5geyX-6%j(pjPeral7fVNHER(a0bHD0#_mM&7JrrR#2*nu1JHnJk!KaeyV7<YIC;< zV<MJaT|wrjw>lq5?COlyrD5A3^(Kd#*rUbE#x;ji(e%X=ai#Nck)gBD%mXx=>Kz2c z$(+goH;I}6%nS=N^YbR6&qAk9XVtqkVspTSlBcZ6fg~o+l~lA?%%<|_JD)EBTB=jM zMM!0VOfq$OvfQZSyl9ly>TgleEMgd3SOb};X+H?yjBM;VIVmZvR!dw3jOixeWiuJ{ zz{~eZf+~>3c=0{Y`H}yI(zTc_))?&&huRgnjq}_AJ_7E*(b)L!{$wt|hWI#fA~N5e z_u}K@fhGIY`Fhl;u>UsmT{aq<V_2WFXh0B=hh!#^?mM3A1{f_Cz?G7<i;*GdySSkv z+;$PSlr|>WXonSE&leCYR9nswLkt#KJWbWTV3#=O>F?2g2&r1PU5I#tk!0spNs>oY zeaR+RDCf%l<CXf5|9riAH~u|SZ>j41b@hPgDBsbj+~?2wvdY+`M7&HfMq}AS>;fXw z_Ft~}dYR%f?gBP!QpfsD)RQGoldWyxo7PI7CZ|=;f<?oXXJ7q#bP#Am4ntz;7KmEN z$;e_LLS*70Tv<gu9FyM>bRaoKUCmE*P6J{^(rqi4T(d%}Ks2|P(TqtrGcKPEHXyLZ z+`>Y<@J6JX+v#w|<Ft7XV9dQygo0v@;`HL%E+<R7j-28@DGy(E>3zfzfN%%>^|7W< zHj}K8ZQax1w?E|Fs*8@cHXbF9*4Hoq@iw~rP$9;Xnf6v))=B?yzY`_(3o2o~0B8K; z4o?R4PE&{;H6}Vcw%wLqKR_W&Tn@!k1AzkBaRLxE{Gj7B^}C(tI%_j%VF!$9BNlKu zvPVunH>EoH1qHk>*VEzQ;r$V~DByxQjQTy$HQ(jLoMRLiLjaT&QbVq~zrQcnX!Hiw zrXlJlY*rdt+VBv$z?2T;$C^x^*N5wa>Cn(nahkzGAV!jwmd-PbljNrr?_h{YsZg!< z&r9i+D5ueahlU}bkfNujFQiZ-3C3hHj(Sw^lo}@SQzY+gePnf@?*kl}))Hq(QZEAw zjGL$^RQW~AQAKHG<=b@Ts}PpulD2jPWDwh`W309dOtz*YWtH8wh~6ZsIS_J!x#UyH z`Y=!(DdT58K*LqO?*0MbACB^+pYN4FD3kR^gs6l>h%JHX)c_+vt(Pp9z8hw@H`uIq z$ao@P0a58rn#0(nyH-q5G_hz*MN~l43qaCQ;bLM|fd~W@Hn!s(xbmcSDBZQxk&KEf zJ1&Q)#U)}Lf%QYW(*O{F3lSO(Oi`T#LoPnPOvCsRFTh~|vCp?l5n+nNmY2;xL9t=c zh-lfqHSO&$q4XUSLkg7ASnud0U&H$;w=2EJex^vCNW6q!ztX0*3gvyl!;nypE!%WT zTWd5}nwgqrk(8(VZve}>roMi*u04m~fUeii{f4xN0F%vTjb@rNa46NAeTsO4x}r(I z$BHQHC<_YTGYTEgeV=fNzq^Qk9s?jF>o;7eK)lH(2Vr^WRNR@rVKbE{9w#h*sD5|6 zC|Hws<{?}u375dr;Bsw*@3!-KN`9&kK<Ajeqvf=rA&H?EfKvz%(nHqg?UGF8jRqm& zYuA}ZD?1OxQKEub;Jc9#K9AaX69@R#)#l{|#Az5t-$y$iueEb!u_9tI|AI(T7lvJZ zIm!Pi{OA}U>HVs&uTOL`kt-T0ffBT(WyP=qklAjEtfTtrLfPrS)VC`^Fc1hS?912g z+WGvfZ~-1^UvYw7+uU#eJ8Y++G!+m4AG8HR{VxS%CwUBs=s)4HMJCB<ug}&q6_fK4 zQ5|=O;?>m@tj%{otBwHxZOS0)QWW|EqfTtqoh=vyZdKd#h8uH28W?g7^APk2Q-F(( zjO-^uC-Z~9Oz7>c^Tl~8Gcb(v$kXeVy0DtH$)Okl;T8}|ot~qY-orv8spG@+;v&oq zB*=GTU#2n?_(q}=9D`ZYz(*jE{uVhkImr*cl78n4Tm*zff}{&<2tFkRWyQwCw8_4_ z0nsuG8{04ha2NzG^oc(e@?O^G@w=sEnQB8GU@+^i;hnW{8x(3Ou{1+Yj*3cuIgajY zATdeN?2fL|%9jNEbGtEBI6SJYfZz+XLDTz)cW&eJq;atoM;a%L;Q2MTCwZws$`i-2 zA-CpheG|zK6V)gra#Ae~Q>VZDUcjiX^+v$kJnl|i*1c5TvSr}kLx+L7h&j&mEQPq` zu3`#1lJhzy01x{Y&v#<&vq^ps={`y(*U4=T*bWe(wSm~+;FO|&b2q86z_wn$KS<8! z(4Z<juj@(RMGglbFe)TSQ8*oyqE0C&2#AXK%Y0&z2r<CMC8DvGbcz|8qjgpU9*o2I zZ41Nx_m`)>ogJ#9D^;)(J3qPIe2$zLBaQeKoUcQpVG^X3$tBA62?GAWu!ZQ+!94+t z1F{p%)9lG$q=Q9o9mLqeWTY!46lRi%HA!)i-%RpI*_1dnng!EqYbZHJopsd(sVC+t zditWv`ucmtuq%J;2CwYYEovTX9HEaJH^u=M_NC~3WXU7XaMkxc4NZX{?XCMRqJDIk zHrDIrR*M^6eN4vCI+&_S(;tkD74jk3RY$P(yJY~;0h*Pa)bUpcc;NjmRio=}4U%U9 zZXiyz08%2v2>zw1_LuA~pEUJ7Lp8}S(Q-qr3_0$Ro;98p-~^BmWdr#0io>|q%gwxg zGCBoi0x3*H{ur$2Pt?tTu$Yyd2v&^1VvW=o2P4NJyc8;VE%4v7bJ1yiaAbz;_C~1b z@>5ymRkQgErp&<AkkA%j8RXT~-O~i&rt&5aH5+oivdj;PreLQvOXLc!#FcBb<?tva zr58)=K5{bFL?!T4?aIvr2Irt-7hRSt=qnFS)isE?AttGegy$Th9bI6d0P|AR+i&;@ zAZ9c#?Ju{>!Zq_^Vq$XY8se>#4ch8u8?m^+t;evQQ^Nf68t-Nkf9X^yicHcp{CosF z5BqYLV*v2^w@Uv5==Vvk+KlkMJ6or>0uF-qG5f-?O)}uk=E~F<fm?w<JbzMcI?Q-* z3Ey|zQCB{^ucq#g5MO`|^oM^}(YN<S<;ws9`{%=N6q){OHAbPD;Ce=DZEm6wIMKn@ z1lsWUY}RRFz!0EevVGu#A?`hxws>Flv!s?8?CuZdJ`|$#A&`vA$M)y-Az=O<N$+2Z zWI5%v`}Kt6&Sq9a^lM4rh)h~JpwtRO>QSZ$BB}0qlx;oE3>ZgtmM8@KOG!Yx4nm9> zHrc16=T0wjdQ85aR`M08`!iO{ZbxVa;N8;jT;iQ~`F((T@b`Sa2uKa~T>;J(Vkbn` zr3CLdpGL7Jdv^H%ytd5%RbQx2U%q`#avA8ymu*Uh9srEvTL6ka*y$O*w|n<!z?(I2 zDp4Ae%iPQJpA!zf=RbEQuQ&3qK+f`E7s3qvUQMs7K5%Z7dea;mm>X#FYjB!e29E#% z+wlwPP`bz$-6vW6B3ZAj*FyZXM2)yKzRBrPY`=MQbv7FaZH<Bdj7%GX{kQz&gxsds z1i6!$WYOKc!=KK4K8HWH$rFU!g=-8PyImRUv))TX;pO$qnH;v-OO30q8EERra#q<+ zSK>`e1;n{Mjx+mWkL+O}kgc-7OLm3eNY+@CW4O46_)n9<qz#JCi)f_YPRZ&f!*g3d z578g+7T|2Vj}`Na#l_QU=OhP{3eai+gyS~*5!S8Pq(5N9v*NIGSD0Cd!(J`9^b5rb zDsJHbLz^bHdz8&@26%vV;3WaGBV`!3)936s01-&kTO2-Y%!CtO-3uqhm@K~~WwpH+ zAwoKvb_5qA>1O~rYeoFKA)RG<z3=7-J07P&+gw~GEJhPY7eeaym$&t>^Sn-6s0g@F z-8(;&p#{AQM$%Q!e;-k{YzCjr2gv{ns{sQr23z!Rm!*ny7IBpMv&|#fKjX^b!*Q$a zJ~|DHNm`enE|vr65qi`#i^3vRK*1rC@~LjFzMi_EMd=tK>BvTq+Cs@*{ij0Xg8b}n zl2uWNk*7C3xU-Im&9~~eP<na&4wraU>n0C-qH(gOz4NB(oSs0^5k2>x14yh9)|1#P zqvL>?{>45;|Jm>$Sjr^ZE>wEBZ@Oge;tdA+HuGn9o+7tAC9Rrp?UuE@g|w&Fxvtkt za030qOd3T0RN8_FlvA$TQT&Xd*=%f7H2N~XIaTaOO9*xYeuQbAIdK)%uaLJWyx{Ja z#Wj7``NwZTud)XVj}@vi^cF4`mi``iHhf|!{FKFCJms}ECTcV<VO_3j8zx#Fezpc? zTZ!n~r#;gkSN;CPgGnm+9XoMRdmR{7odIDd7EBwW;}?hRZGC<Ft%f1O{e`Xkx!lg3 zzY($2sZHUrnq|&Sz189IbB`9pU8o)wAzF<rAV^+=yih4Edo{$nV}+Kuq-w#jv<JtF zt@%owXa3b>COd;5DyWFoyhPh8X@d8~z_w|g8XZC9>{rnJk!i&+ZsMSN<f#h)4Izvr zT2|-vnb7B$Or}Pw<k<~t(b-2%X?26Gt>#JS-a-4pFlR*9+hZe%Q-vc9&jj$Yd7bR8 z=7?a%UEE?2l@<xQ7CP(*Ph`jKZ<yVfuLn~ipTe<o>wL}^Ws*^?J7DtOCxJ}U>I#MP z&ORQE_mmn~&vn_W`nj*+B(x&oV(A>Hi=4Ym^ngE?+@`AM`t7c1g*%sGkyT%=Mdy}X zpDkaFU{DUB>T;GW2#XWzKKsD9iw4}eQn4-NY3S?wa^!RGt(V43<uW-&HOA&N8O!G| zK;$mJnpa^bcezbiAEy(K5$6G$1r`WDM8haO!|f-~{)ZV_boCKk**3uK)_T+D=BXBs zXwc+nGChM8X?{IT+Zd2OHFdCSg_$y&QXUOm9;w0}nd_y6NV=Zq@f@X;QP!u!qO5J# z$2?0Rz@NQ}g_>_$=k<F0j_BocB&hQCbiGY#u~r(b?asEPvEHgE&+Gd3yYnh8qRRrV z9Q$*y)fJSpQc)*YSbr86$O{Uwcw0+XB={JXGC+GWV~9)4#JqL&n_j7dnv&($s}j{L z#Y5|}19Riva+|9_SKf_>^|w=U^09a`Y&xvRSq(1UcK4;gH6D0tm67|SlRp{S?N%qe z$)%sAtZvhAt+<%D(XYThfWGxi5nqA%Yy&`QZk?Q{v!MKTih@gIL^iS1=HOiSU!+a= zlLcV%cZVnZy|?H6-CX^}*l!kBV~dll0JeOV`30G@o_+0is$IagVN688_Rm4bPy8NG z)@1)d>knY84$e)0XZmF;;De|d;>)vRaNDMPA88!X{1-eKS+z;+vnK$PwM6$zEhm5# z5_H2<gIt`Qy-(mSC*7Q-(-^3zquW&+{xY)_eOa}HDFKXV5n0hH+wMP3LfiL+-?dK* z>#A}c8`t5l?H`SxNGgeWHlpe9Otg=R8m0h9RW$r(0MCRGZdZQ?A)u+VACdZ-?6wU7 zPx$Q@68;u+J_xuRsEeHEl9UGc2Xz18U++qY{jVPd4*I^Iet<s;bHSsFyaCkchUr?R z3Ud7rdG85YuxP&3dY~}3vhn}3na^SclJ%n*;GWJ1{5tNk4`fUF`rjXT5+!nfyT{fE zqq7ym&onZ0r+OXmn5ROTV-x$>w5ERH@mQ{FWh*vuK|QuYu<^b=w%V3eIFiUIU1SrM ztt}33i(YaY{gvRLfuw6OE&|qZogwO7P}Ukx7em*2+@ZDZBZM=OI+Xd;QMdMZn71yv zk=<7Od>5;YHtO8Su$B6<dDz@G7^Y*hSet)JKZ5n(k;-O=VMA1Jt5=ss(vwlxBQ7wa z^+H%HBwV|Q=rBz@RC=nR6uoY_EqiGK*%mlCcf2ktTg!L95e=SyI?FTF^()3f1KHMM z{57cRRw^Y%>`jx?XevWx_^tb?6j8@5zO2QfY#_@h@wI2Uwlwfnguj->no6grjC$h{ z8*;RRa=V50NcoCviX}j<hQic&D^5Jt`!>xvcyt*9mwZrqH&RNC&E<wJIV{BZE&p44 zpPtaDtit>;qRUBefw2y2qb`d@xrk%hGL>H=>m%d>4_TqMwP({~M`2uGTkMiz7n<ra z+=$yWPvVpsr6Gr>D{E@~(=27A?K1JYk5l?n5dPw5!v2m5X-w*<H<iRs9NMY%qF?9j zqnwm5tvv~UxWax}-%9VMG9I2XW#IY$vStl}G`@~at2ynUcoL^d3|Ymq8oed4PdP#K ztNUjF5QndBK20s(zU-kNE0CQm=BN9PBv-(zoek5&{RtATwW0E{mRR*#ab9nsggZbQ z3LCIy-J9}2m&uXjOO6q;uU)JZTvQqxZLIk2UGTkTV0{_fIhYm$GkWDXm9Zk#arNwh z<>IXJd&N_G#dP>{J8gyAY3Z;lU5a<iv9_U~0&nBD9)lR*v;-lU+84s8xkuEEu$Z5) zqW-EA^71i+m&Bi2z@u{=^5Eu!TVrN4DEX+P1fAJDGo8a<+rl9BT?a`c#Du(SIcAu5 zXH{0DL;Z~#86ah5I2C13(?}fc%6}fI!x48bwluxWYsArZ{!O=zJGPqq#x{8#n>Wzt zTS0fTg$+O2K+uwc>uT{K#5YxW1f5-(xer~m0>Y=seKkY;+5M^ctkb=;t!oNiijPsa zRs^Z#qxboWz%J!id(?~3za%xGJTJ}Y52FR>6{$q(UxYnUvF<H)pSDQs8W}ickP&kX zY(hVZOpAASBRF;W5rW?h@QL@RzCn$@Nb&yd3KGvbT0v;4pF0C1XO~F$OVUq)E3FhR zA!D9OJiaLL*>-7eG4v<^D&M|YjpPE)mfqj^j?C2A=0BTCeyiIjo{fLGfN;zI$pt1C zvWjgY1*8)t<snm6Q?nM^KRP@8b6)$pB{f(?WT;sQYPD5qZB6hp`(W=SLRBq$Lv>B! zH3k45>~Pgc8v#wK&4wbaP~0B3jHE7RBz!^diUA>Jbnum>Il9EQiKU4mlERx1aM5io zSn$zP>4{_Q53Mcls1mzo(=aQj3KIcqqM2`Cr$wzZB45Id->r{X9Ntn`)nK_eHFJ~t zUVM%^Fx^P(d^U1qQT6@j(V!nhA5@$3DDb@r@KZal8DEz^?tS1!p9y_`g8Id?peptB z;S&AOba;4pG`P5-5(Klk)eqLDwgnB9s(-yd0=Xd&$WNEFOUnQ1K7FmNcZ|~{1KQyT z-62*24=xITR}d2F9QY?->x&2Z&o%(;h+Yr!cLJ@3_m#7gBTt3@o`w56J_bJ<<KJ%| z>4PEa1;R16&O`4I{sXz!4F7i|gEZvPb@g{N_C1sb?Vq<n;V}La+xYTr{Br_<<-Z}1 z?pKI*pEvL3_h{Q!j=_3`y?JLB?;I#)>>$6N+cFA-Pn3!5uF!D69`uKQx7h#p?R32m z?}22O$yi!;C*vyH7j9SEx7U}0N~u!BfFpdZBgh=1J<)+v@Gf$8CjLCVCnR*Im%TIx z8GZdNAgKuy=*Y`WxU{si$YnDu&CCvtkGbzw90H@g8-ZtF;%T4D6&fCkj9P^RhCc!k z{j(gD$!@0)lx%@KCAaf&67vl_P_VHB4gmRSL|jfqRrfa+Y+)`wWM>S}t1lCZ5LSaM z|92#jO%<K*I{vK5tlVt|o$_^8#Ck3Of4tUzLQ+=A6aug>x;)s`(7{io<MFT*g<J-w z#{W2u`USMFXG<l5_2E=PDdSalW+0k4pI=@kgBueYTe4rU;0akU2*Jw6rm#zf1W1?< zn@tyZJzw+_Ey4-Sk0CythIkfz=K>z+J)7Rs)8k4E<ZJP`oR5Ko9v2rE5T_Bq`Qm|0 z$o#&4&0%*wR$aUPGxxdbldWS<{z|L6Q@!EMp~v?v!=IgU56j@RDCj$MRiwFj{G!6* zKkJ~NW|cR^qG0?igGhF@o2NOJc*9yxwC&iH*Rj`DsjyE1Aw<OkS2W<!&=9ISk#W%A z@mj~M3?+9?#0Dt7o@JPBVUP7<=t!k6y=x36pw$Lsx@{k?&Fo{19wClJ^YB!s5hZw5 zmdw7(Z`plxeKz0sgt_OgekM#Ell*HSr99AU`k^p~W#)Ggxvx9;%^}P|BB*~=`|;d! zH;|<f_S9qZ<l+jygI@UWqwq@4g*;;om5U!+%70<aV_%Fd`b17X&IK=#>vc}!tYT@@ zlJbtnv-Yyv%dqJTa`!I?QP?y(yn*yT>8Dkl`0!%wTa{8Ko7*i;b+M#$EI}@g@#3D| zkg|QDHW?AUejxp7x`<Nqo$g_xhGrUSfMPKGWIqb#=k1Ra>HfBOwf(w5c`95(TSY34 zF}b%sk_^6`xu1OzJIqd2Fm^Lzs<g?`2`iHniMjBczip{f2$N|dtPD*s`9>4WV-`Kg zcfLJTvMKlgBi!;rWIK&M;0af9VN<To7w53ROTO<eN4%<Kym{TTez@;wExnC;cx|=m zFb8Kk_1f!YK~(8oc^epw^W13;j*N9Ue6cuHbinUS#VDwmZ@Xx+hig3Zy69lR<?GPU zGG-B>D0i|t=`FT%T-Ya&uWP7dPv(<eo2(;>q=doM1%M0*%{e}9`LAw&Y(;*;-zXE& z?@Dr=dovalIWOIj{TY3Hovy>g^LbR^lM_sJa~xOq9Y{TsETqeb5~A@d^qwwO(hh&4 zs+%?+G>w>P*|E2b$F*sOCn!Ds3cXGD-}zudelS9n{e;6FMfbhU(VXZGP$cb8N}E8| zaqOkLWc0d{4OS{Nmwk09meTm0S1}~t-_kMHa--BNgZ!IwHQ4})>&HSfarUVK-NUy# zL2C;Y`<<nI&M0q{6ZM#ikIRii$T(>s@_r}9U+^)e6)7PSGL0oo$k{dArlca%qvp6$ zA|MD+%xWu^o|IXHwHeEQEVd*yiUs@Iak#9n+;q3|ElGfXr962RJ?m&csEEIl@hlUg zY%et*&r9n+#d(`+=hH8d7>-;^Y`M|nEN+b}wN7?D)8bPf43AuC-@KT@FE<>ETuXWU z<gkzMCkQc<&xiLV-fRp*$Fo}R&&NmVX`DOdCt(2*bxYB0ckcm4^z%^40%FH{p~Pt? z0T&)ITbfaMo`Z7=iPVv(Dz73IKtXol(Lb(!wUU19Z*;aCx!!vxoLJK+$V1S|bxXxW z-sj{Lc+5ze*BY8xKK4_wWo}_<LavanJ@z{qRA8GYJ<Wivd26Y4HL;9#liZww5vFiA z=SNS6%Xlss3c)@qt!VFJ@AJ^tb)U<yO<#9{8WBJYW;#Kj$6*0fC5sMdVlE|B^ddCP zbmfgL)rAGW9u>?`tK_sx11UPP5|w6NT?2|OLp>829Gy$3Rm5g%i|=-HI@t5;M(`4c z3M}HQ^_y(r-@mF?aiZ}A2Zt7Rwz~^FGC@f5@Ex6&Gq4Xss066Z_f>1ew(97(4X2M+ zb(ffo^uXBgjnl?Gja=D`r^OGUqY2m7Y<aAkyQh;bTtbl1t!@L-I<gBSdjaQzY(aNd z;^w4vI8#&Gn7_)bzy8QIs^XTL=C3obspWPa+bg@f4>^l*t}Q*yVVBwvqVwF&1#saH z2Oou`ikZ{YVrB%kh?=@!a6y`IQ601pS{*2yXk!a5!#VQy*IX^~@jm2aGBRpE9R9kz zsTO<dj6D$yp=uN4eHj<w<l=hx>;NP%9QBsw7S@{L*Yh?${63z;3}x{`irtRm6ZpwH zq-z*QBW<Pm7mua-_hV37JtdhFYkI`BV7^Qv5J1bv;#$$2=J*3Mbk2>2S<w)&^B&?x zRTC&>2)<7hVV4!I$BD4qSs7jD>jaMlC)t|AI9$&*VwAqEP(h&Umsg{xjmA=c8*8bm z2{fd75i#hOe50CQG$?f<I{T+cG~ewLVa3G<Y!wHd9^iWiSH_Vu`xP1ltDbI#f~X#P zp*1{DPhe0CTAUlt>Q{mYNdQAKEiZN8#Ms*%b#{C!cxd|k5<iAA1Q{%U&UZ8B)ilop zrJ#H%S@_eTIyUm8x_5{SJ+MlW5#LeqLc;}FH2yWgUIgagfYHur4{aEq@hBk~{5{ic zmEWgepqhFQuUkAx#g3K>d6d*K+iWT9L4`4vZ>E7Q%rMhRo`-Mn;vwd1Bb{+Qg7?<X zerM_RH@kd%qC2lHlSchTxBoe4=JvZ$$i-6DJON=g104@fdq+n`LVkT})scZW-#0b6 zG^)P%V1QsVXIO!8CA>z4FMcI7Mm~{hV5T(oueqxv#P*qko;{TilXKi2BLosfetSl| zZuW2TFPM1Qrfh3%n#I4$WcgmMX<9Etv@&kJQ+!Ei$T*VeWQ|f*S9A#Rqs{{&gU?Y! zS3K$u+snjk6?dqpcXZ?4O!;;=UkQ|DR*sugG0$uG59*KuuT@qo?SFj#N(H=J7WE_a zQrUdD!qv$JZ5oY9Ci1+Kh&kNC`M0M)4V6l|?{j52s$rO9K9#PM<wFjo%55_CoYcn1 z!X`sID;g$KMxb~}YVV6VgoD;rt!&fqu(B_eb?pXctBtRQQ%CV}!$WN^E3jG>ssx`3 zg9#^bg>=bL2>n*S9GW^#b~;M+jIKc3Miq8fvL~$!q(Z_(Xu^gjLuu(^6dIAtObRC~ zoA$K-UI%VqZ8XY5ZE7O(n$gg@0M<-f9$XgN>9R~a1b5elSHfdR0MSbQyLu*hsYyS% zDbKL==Zygoy%=Xa|Bx?#uE?Mv2~cu+0Q|pcbs%ZnY^6>o99`4KaqER~j`fA5wB>B- zQ+e4swX!lhdTplQ#}}Yru=owv!(NwP<bxUtBl-@75(0KIEAh|AYuU$#;737Y21w_> zJ91LQoYJ?UqvFab+>e@RA(QYP)~w}Li~5}KYAQ4pRql5I*GDWQ^V5*R`S{~4PB)?c z!t-@+50mRQc1})KZnti`=*Heetw-bIXS<M%&l#R)2_2;-PVv00fhDGINgTWvu@!}I z6dF5pDfc3iXOywoI)!?RD@hq5r{FfGjS*N_X~idbbmhm^)=D&;aB|fI)-N@E2$z)E zM+Y}bA$3&o_KG>P&1F;#3x+d<%Y+M;PdR(7JrO!yr46l`vV2tKr8Xz-uakubVgYcA zrd$gIvIgcAZ9H2eN>~q9W3Sy6x~6Z!N5hGL{+AD6c9Hmeyq@>AlXuAkTuz|?vqf+l zV)P1c>5ZTgP9qpwX%acSPl|sL3y2>}rt<k2%Rw_Rw;C3!tU=>Ffb(^$h{x#<gc{)s zyyhG+R9Xmn-CVhiVh<8C*K3Z+;VR0MKI2b-VQM+eDgkn_sDq9k08!W7_R{4KS**@{ z{_wOodoQ@C>^L0GP##WhZEgK@m9h~|q@N*dsIQMs=M*)A<N=LIffwov>2YCx01+sD z!sAZr4t>{yod8mcVQ4jV4UNfNzcsKzeTc1jB*#pFQ~z_q<<F!8A|E`8wz0qJ_k5xL ziHILThj@*}jz$a*U4_{U*BxXj8b^Q)<huyI(Lme-<!=t}XLc=$LAr+VZ>dt)GO6?~ zLhQeC((z$hf#@Lz11RPJta-81$EW5Kz|vD5Ka$I&ncA2{@*&`Hs{*8bF`Q!QA-bv+ zbPko4OfZFnsHX<aU<Zg@f%=~hP|*Tni!5DhK+KSmhIHME3)EwzuF)rJu80j?z&23c zPdO*+0^$_VKH)jmd;EgLO7On_?Mu}c|2079q6s9eK;I}P_<renAi4rYqIab!2(;RF zjl_%B{r+J$=-=)Cr$m%hS(QnQLa-uqB&Z6E{QhsM4d7Ocut0oYm`x{q!9I9;n{e5W z(eYt_^39BT+^SuO{)Wm2DgCkyaMB1o9;#{3{phdH#3JEXEQSL}$+rYkV|1DDAG>@A zx;_iMe{*sWARm+XzFiNO!}PM5ytLya)I=x8my5cfM+CsG#(>Ip5CX0{pa_|gjrYa^ zBCcpRc`-#;29XSYmviq|p^e2r6ufJ@eTi#86`0CmG57w40W}B)8rsWsA>YeyL~kS% z6u}rzz*vwC4zQF0bbKk7*#AQ9kiAEE`%IrO84ZDIC~yis<!Klu(|aA9{~XZK$HvCO z!NS(m)Bq6S4@k?Yu=d8`J3%f^PA69%>|@_U@oRW7FZ%ZDsn*k$P$8F*q_3^cCq!TI z06Dg+RE(p`-;rdO6+0-*Tss60K1lZ5ylfIdItL&qnG#Urq@t2wL~er88!?|S!Q9`2 znwpKx%R3ISdb+jJh#`RwJ0@Ko=vz>Z9%ZVb7E;eAO66)1SMM$%Y*6}AQbVlkQ|jyw z9fZ$xC4XA**;kDCx4Z&jtJ&cn+@Br1rre5xe`xV}_t!h$019i<yo(;A0qgm8#rJmg zE^lhI#>E7}Fx$T)n6M*&g}MQv;5GN7azIf)22W<VNx&eUFehg9rWnUwCM@-Bm%8bP zIJy-ZE6HbD(RhP~F8ReT8po*88_eZV9C4y*lI;z=UutYLnVr5bswYY@#w1u$kCxGX z86PMKr`PJKYv7D5<Pg58c$A*)R*TV+kFAU|k~daPj?vjB95)8PX*4JQWxEiJsxyqh z$TzgBDcEWTL9jJIVOP*V`~B%)sBcEUbEX<2tB4KIiF9i!tk7ySytffd7f6Q&2WRgB z1{U=Q3(sq}B_tpqps9&d%n14WH)7%(ZGwlN>g3F1U44X_+1n09ywna(yhOzGczUi` zrU9gQrgSzEB7Y+gDBvhbjDd&N*RwJ^_w@7*D;5=LXEe6C2T4JvBw`rm)7YR7V_Rrl ze23@JnTqMYUs57_Nl!ghZ!JlQH>=RDpNr?6Z_W95XtOAT^m#$2P*yUmk{*Q|nQKVm zZ@43o(=_xq!#|Jbi=oZurhfRnUYH0WVU(VX5S8j({pd!}R%pazMa_>R?6f6yh{FZ4 znKJxUU(8?U8atfs&o=?-8ZMAv0BqFo^C2|>d<K&V!U1#qp)wMUxb03KYX!z*YQl<q zeMdz+y2jC*iW5m8<`dkic1SGQhxd3&!ZK}F<hF}Pm#pqD86&cBVV`5&jRf?<hzF0z zRCw}5MY?*-fOgzL%xVr;qso;`qCT<}9QX~?XIQQ4q0>H$i{R;<m<VeuFleqkD%6Fq zV0MNiO-@9=tC`6gq9MJ3l-CN5O=idd1toe0Dse-8ioQ@up(-4~_gVRg9YrHAyxK(w z%V%ol_b_8(9<VX*r{(nyK!3Uh=npBs7XZuVM^6%j>NkYE{&x$kFYqTs+6aRzK8cqp zXimFjb*i4FDfrnQmqJtSj?6fv&xM6uH*|gN3%VjJA;Gw^!1>8k@72qAM|J})zKFJ) zT(9QVVmeRtOnNla1%CDIg<r8Iw>Ms!;$uYYev35D74Rs(xYa55NuB-`Jjg}B>3eEQ z#iC{_n;4-y&3_E}%reet&jO)Kb7{Ur^<e-IFerSe18>+QEIt5A0Tj=qUZn9S`}<kG z_YqvDViFR7d~Lghh7jK$LIb*rAw<Go0OM7Gh)$VC)u1(z`NUzWS3JM|_4XI&2)hi( z=W*G5<%4b)nu@Vv38aNQRIT#`*Uxv(6}=zUU-$++gD*rnIt&-0napxbSaR8%YJMcB z2SFfU_D+4p2_B|7Nq+MNh(cY>1giJq>FL@MXTCh%pvbBRe@Wb00_dURcm~gPVnSg0 zJ)p<y1vfhn#L%W-Uhr8+;_T-^mPz~+!lge86_>GaMw)(03y^U2^}Wl^^1e{i7_ZiJ zDtcE#DsDp+OKMq~=~IZ(g?;*do?jzd8rxO%Xnv1BDDs*)=>vI$Sa?Zg#id;8&r|PL znZc3F_WROdirLo!MG>!Ng4AnA*_`}6ZiAR_uEzPu;mL0Djqv6eo0b@bHmGR?$|)rZ zjMUcnT(8IF1RY{u?N<)_A?A4qPWH6TtxOfmtcM?IyPnB@D+GClQxN8our1olaS{HJ z-vqLlGy6Qi=;El1DBYehDS0y30+be*7#~9K;UvWuLoeg&@p5NG0Q;SU_OO%c;Ek)x zV_5C2Tx|hBWeNykos^5xTaQ=O9Cyu+#AHzP^!$lce%4Fb=HWDlJN+XR0S)4bGq%|d z;lm;~KkLyP$xZmbH(1rz2M1L_hd5H6=XVl`+oBmqeMAHrsQ_Ub;0>c_aJXswXC%wy zIw3t{kr~mGcEis|tJ_a;_5GF1aH}4%!5TRe%T2?Eoi0^ZlS`R7>y98>ItD*GC&Vsw z2xQJqlhEqXtIzH9_wUtK^S&ln-L5#2w#S7(i#yW8B<DmO!zM4bwds~C43rd6D$b#+ za_9BJEUlNM6A)GTz_v8IE0q)VMOiRv3F~v`r;31x;MbZ9hsLz#TVva)l&?=D<3(;x z3q18pv$OU66x3!3!$Vc-3MHJxl&E@%|4@~AM-Ly}UPX&~Q{MrtI#6aMzw8dq0C6R8 z0uSIIe+}<)dOtT&9Gy&m?)Vx^y8Ib-ov#i|sQ@bZUYm;b45_<vFSDBud#c)?pZ8%0 zCAn%ZCzKBBtA2ngujBp%AmQj?1o<&KA1}xQG&}(6v#Yb44Fl*E<=9fpF+F8@F0~pB z-=FLzoylZuRz*j;Q^IVwo95L#>M;XgSr&6;9G>?KM(|%4HUO>9Zb!5v>iAC}g}ZTF z0O%)CClx>s7ybC}!b+m4+1%nY!08PS4^t+fKni|ET9pJ`{?pBo=w!KU3tJxGwh2rR z(OWYW4tc~RD2u#q7foEw)_8#CncqL4VyWP6@-<-{M8mO!tIrN+OMx7w<X1xEz)nA2 zXk3x*b-5N-%uT2*6msA?kI;Dq?<rZ=^9{hrb>!vkE_cKgR+5sEY`l&%w)-Q=-PW2N z_EK0Z-n$rp{Av_muUTGyj-?{ft4A0AIS_~(FE`4PfuNBd58RtGLc3~*J$`_lXX>7T z1)*F#GX;Nrffds?^MknGj^rb=B3tDFbWygsG_cXRMrF()XJFG{Usx@Zq(Df}j(;c4 zl*!6|)zuE0`~YKb&CW`A=W7u_Q{er%9_%ld;{`jwg#f(T>`;<#nL<G)yeP~!7%=Q9 z7I_nsLa7fMP(MDx6Me-7-nP^-5{F6RJ5!U*`3jEicoC`hxsE^@Cr0}*S!mKr$M2VK zu97d1$%lZ2Ie)K>5^+w#+0F#EVY$Ks1oIJnvZ4bbxZ)?XFzx@7IJZM|1v2ITVWHXI z_+D+EkN{Tt-{gDv!Jq>-BtWo10bu`b;op@&Af%7~@>;-+0lV#^#or6Z(l~OxMmPw1 z+5)as3OGJ1W#bJG5#JG($yqroPUJ#qBZZglr^AAQ^!&Vg|0nwn{*tAnqB1`iORk}= z4$#?mVYXga5$uhsvW!p>D9F4v*B_7P+}~7F_)HNR1dRbO-z1<!59p90ak<E({TTFn zegXU(5W;%xPh@vbp`d94a(aH1B2mRxKnHFSkMfzSYjq`3UUXobL^j6`tzC`~M4$<@ zSW>XCyrW^_K^G>EyQ6`@c9DFpkb(~3OCNp9e$H#hUr|oLlz=q3QMX0lYQG}K=j=*C z`7A*~8GLyi|Ax4R#0K?(2RxwMa6XwUN@)!Tqll*TMXPi_5O~T>;aBz_0?K+qhNEd5 zS9CJ`{J(*2LYp~$alOIuah)dHO(23I4?(JdC1xD|c-TU2dwruyRC+}qkm;XLeu_O{ zHTU_l+GQM;*yrC751dwF4}>V@)t82Lj{*xDu%*jzwl&pi{a!&TBO@c5_M5o^=~O(o zooJwyY4^RKaxcR}jMwPv6YPnYl+?T0-gP^iO(v69E9E#k9xGS~EaButa)4GPdM=8n zRQbPutqG_VqUV6W;Gh8Phg8K$ngSXwE`D^LsiDt<0P~h0fdtZ(;{yXtTOrgRfes?I z9VV-#x-+jc560zO0m+Y_zMQX~dAnR}^>#k3o>{jXkRd+-KqFrt_b#k;0D2h^u$aYK z9{ut`CUMOBMHGWkw;NeYR5&n<oBSfdq6I6MF>xNHXVM2DtyWI;$+L~U^1S1Oh)k4? z<q5*d>#5y1#F)*yR8vC@T9$dyP0OdVw|$4Rje}O?Cb)Hz-DU3&qwMDt$aya_$JYC& zD@v(UjGCNoSNq1cN~uV+XTf;RTrj?d2+b_MFaTi=Yj(>?!%!IHE9S3wQZr1KYifVG z()_(pX7*#LDIxGfO&wZ8F+{?qhlp?p*0DLje4z98`ksT9awXt|N7?ZiWto?A<pH2o z@6XThjR}7-1$5H<($8wepIf1u%z7>T41^6V?bt{c;9%^k?FYjxD`6XtD_WkE$F|QX z&baB&fy#sQ%Lxn9UHjFTQf(Nn)yheJAk(_`pVd<j56{i=j1*+~U-0u$%6f^RSU`n> z`^uo&tj><Uji>gUx7sPB!Nj`#%x=llEa5z4RgC&ig8*(wjY}ot6_zwExs2h*@5_d> zz<fpa{ke+Aa4h}8_oi2kP;%G%l$X9s!8ZIUqD)L!y;+M+yiOB;x%(MVi1<EBJ3B=W zDSUAU9@N;xWZik$3TR822m0hFYdWsd&sG4w$iczE2XrM4ZoYRK45F$9ykAC%OG#2+ z{P!sb70^As+-L**6lxAl8ET`~R?3Y7v1kV18QMMWypO>!bzC<C0n8RtA+Av%<_*fy zI9ghYUts^7M<&;19ukTIRE$!1TYs!V1f7>r*!5h{L^QjK1pi0HD@Szs!DOl)n6ry> z>@!U#>5@9zTgCF`v(pkD{fA1MGhBS8V0uxQ%U@2EjuSgM*gx#<=1fi>Cx^-{qmQpM zb?@dP+K29gy@g@}v;sed!KQIqv=zQCl@r~}rN+bOQ#VfSA_tWVRQ##)Yi;bfXy{mc zHyIYo;sD0x-u{Mx5D;in<dv+o<-O--Y945Gcm^6G+#YrlT~1e$Q&X`qFff2%7mhB` zV!JQg@NMG<;(o~^urT9$EuWx1l2U3GK-*8|2n$oacgg{vL!{s`<qBIYo+=F~JVlQB z>V4O`o-g*eWKo$fqA(x{r?}h*blH71=o2G<JE!b|!8EJJ4Tmqc<S^j3v&<Fj={*_L z-r54n-#DAejoRYU%He3{7mEm=mFNGfO{Pbu18-&`DOj6J%DY!NFg?rhekxHbo+4W) zcfK1j>0U#iUDSB*kf5ip>;lShbUcm&w0EK9K5JPcKGzTZr{ja-sfG?(Un#BT0@`g} zlr;VByE9>fBiB78lPUVBi*6g&o|EVw$*kt2d{PBQGm4L27CMbJw)PqTy=Nd0U&wJw zeBSA_y**hLjbYX64zL5J28$>zS+RNGp($EUb+&Uo4trzFYy_I7<USLoEPU(r_eu zirJ3!{I_GuY3z164GjsNK>8@xWz8)J{qXRx<9-V+*$T39Dl8pDjxj7ZE1si9d7XG| z@0<}!-!NxowMAyJwXL5*m0wt^;C>jkl9FajyiwCmMHb47>2fkVq#>PGUfFqZD-{b4 zx214K-||>4Os$i6u=ZX)z-G0~66|ga<{vgBY`vZ!<WbC1TG-7=T^(O>8>S7-@DY?Z zMnmI~)hA}tls8^`Xxo_NB5%9lUjAzB+H0K`Z=jNmpqy8JP!8is-Y=a;ox<us+fbUd zv3UC^Do+-rUBc}idYSa9ItZY9{PJ#0Px_IUMiM@V06eNwe6_L$UzE%->9{6o{?(7* zMyuX@HkOt9cELb^-3T*+qKL2@Z@@Al0hJaNApx2Vh4&0>JfmG|P>afcHQ<wIAm>j_ znr(IJGV5b?Y})7(rLSs^;#2D|D1R&6{hBNa0EDQu?%Cz;I26S#g{3}edBb~eh?-%} zZUSPK6K#Xt<r5Kgo(8S{(|MiBGS8wa+gBbNnWiJKj@^*UTda!|?CO#u(L3l`p_wwm za_bSasWAht+dvGwaXrEAvYt~b8=0(hsaCHu9dS|~s%QX-Ej0SqV1E9#<sliPUm;d+ z`)g%%miqS6@;?(0GO1tf0MUj8`gR}PIapDl)#?oN>OKP;_7Q&!h@QmSch}lI_#Sp* z0Y~(mqRniYOs{aDzP?@wz4i<rT8~g3ht={+;5^wt(VJ8d4NAo8?>!`D;Zpc;*hpn2 zbkssty4aewZb^uhrM^@9)GB7@Q1QW9)cU&&pK&-3@-Noc1w5(^6n~pNU^5T;03F6> zJBD;AqT6_7zKL{1Px<~laeKeoR)fm(uaBqJ?%$K$tWu;dxS1Ho3%Xb1JCQ3Z-a;yL z48ckqyX*PHGkUL8QkaF7AcnIZ$wnkt%LM+M%+B6C`gCTk*rzV(+gKI&9H?Fi^ILuj z+&S@_G~Q!y1==vg_@5d0fc_}JiJt=LxHrIk-~YgWCLkpZB;p>zWzg-isBCuzJWo_~ z5&#JRlX(O9PN+i8N0bb;%Li)2x+ry2LLr67kt%^5bL)aUQlj|)eDE{|E|!Aa(OyB- z;%pd9(q_gCqZX>nNeSDXp{j-1IsO<cy^;7zoUazsbDSNPftHMa;+)CFeVp!`lQ;HT z9_paZlrx@YVFkI-$b@i?cTr?KV?Pai>b|m-c^u4{m3=0Wwm!(KgR)6)Zfp-HzwQ8P z!|lW;IBNAFb;#OOGlUXNOMDp&7IXd58Y^VDHRfn$Crg*dS3|TdN>xtW7d=0Sic5QL zt~1wD-0pr@!`!NG64mogZm~5qFVsX@MWca%h<@61JQQ?vc#o#Apytoa%(UHag`ys4 z_i=bV)-g=!*t_n=s|*l@K0pb#SUxZP8n09|ABgz#A)3?^B4rYJb30gztInef9)4`* z7Hv4K{J6tP$uc6A1`Td&?tiuR)=^b<{reywNGc5q64H%;fFRx74U*EOv@}R}r*t>c zUDDlM(%oIoK0eR;{^q;Z%&eJNzkd#KpL6!U?_Hm|u8Yz9fSBltc<PQuSR)qH(r7pQ z6Q}=`P@Bme`x+lFuA7w;hvm`mPCAFS&R>X4{nzIR@!WUTh~%qZEbCMpnoVa0m5i39 zMHPfsN<@im2Ivz`vc}`z2esx=HPRB%yR9b?Nt*A5$Fc_@(KsS+7X|yoQZ;X_kBcfd zsx~R_wC{U7I(d(yuc~z)^?PTIhgT~%*&SCCc(}(fgnh0_1}UK1X4O9*94n;eJCfg! z?oZTyNVkNB@<E-u{tE2pKY$}Z?u}M^2GOyOQmXr%X@%T$<8h0mRRxr&N;XN!n@F+} z0<-T<DSb+EWUSd!NRdUi2~lx!rIhIGuYUm~QE9_oAvlqH?TB=NgL2$-9`;1K`gY~n zSY1$RDH;?SgL9Qf9l%XkHy+kBG&G!@*)-~24<@o8<IvUJZY2X8*Fz_!Y(!UO2iUqb zYEGgg8NRK0!Bdg^IRtnfaFjucQ@(xsMypzyjr3<9;4DjOm!sS{^Upw}Saao8Xy-GE zXGFZzTVa(Fl9GR3m-rxE2d}VD2<&I<b1yy%17l|0R6-k=4>Q^>hpcRJ(>{opy@&f{ zt#MrsM_)h~Rh;PpSloTvz@U?qHYCcZJMG2?U<%WqvC9*hA)!pDfvYR>jX1C$iT&M` z{&{7Oz%CFo2d~gj!`f8Su7DB%vIu8qXJ6q{na`Ad8sR!e@hz`NfUlT2;d8~`009RR zGcz+w%cWXtB6_*bhrOb*CvdCLGK9Snp+akD7uc#=V<{Pd`3B&5Lf~uFV}YO+hZzu} zfK-BLI3aeNmB5T4fKTQ|8_kyMbzt27>q2DSKQ<LC90VejHJwQ`BHdpfdkk}I_xd3D zw%(yi?`QOjCv*F!lgfAsmcBp_%X{~O>swS913_fQ+a%7X&Opqy^E?1p#)<3I5oY}B zJ4JeU5zfnvW*-UpAbshre%tXA%MX~jV{<0}Up`=WoleWj$tc}{`TJi_?d=)ksNgFk zjhC&$`B0b{Q+^2W9Uw^mUFZhR89|VLfQr@z?k|#sF#`#Xo)Pz7uMiC;<S$O_zmOJB z7lga9*P7W7R}w&CnvXyEyR?B{L1F}F70^2XEM<V9B!G}LZ*3VRLf*vZsfK(4m>BYA z)i|cg$PW(>|6E!B$)$Qt{-Vn!Q%6QrajK$gSS1lD4p!8crPPKkt>E3eeC~S7FfK=A zHDwNo*o6%}$QN~ZUMh|Pqsba1$rvyR0n)hFXIy?Wp=2#z$>s<7Yi3Fljg)Wg`DHH% z2qg9nU=!um))OE_aRIRZg_A+1Hz@xI0XQ^f%k*k1^Jolw$|;7dZ10iA6*>7eIk<QH ztPPf_R#-U|;!o5+N=A_+vJ6vR>X9k_c>mhO+L88)hz8xCSAlJsmd}f-<!NO^E(--` z62R#^@7}5kA}8sxdZ&2ffXToa6h9@7@{9CGA+iATu1~@RpJ;K2iaN}4qy1-A1-)`- z7XVasNszNB6#r<5DF};A_L<}oQ5^P}D`1j_pP<_xMDK38lqhuP(#o+k&%(eR!4>zw zudO*1UclK3z&T^vI$_hmiw%L(IK@8f>&oq>TPWPv>P2_^ryt>ZZ`%Nl)&<zGT|c_< zAe1nD?@1|osMqWKo*S1|bi92YE*_Lj)8#fj&1r0?2+$sAV<q;3vl_J@!k7H2Z6-#k z%STt3aE4F6G9^V?9O$r=`gN;mTMKAexFk!yDp6gUpDrcK8<Nqb&0!nfstp22+!{VM zqz8_p?h_i{u|HtpLG2i*c?7HnQl`For|T*81#p=pMwncCs=lWt2;Hx5vMErE3eI6* zuS=WU8@&iO*rpi6O1MxhDCD*JJ!p0!UL>bmnB!LDY&~Z*WgkpHJHHmkV8~|qd3HRZ z%`YL*HzJ1~9}{A|LtW4p!zDPK^-;>EaFWNFdPV8a1%V18kbQXFRsmIJ>MLjDg%Dz} zp;q2GEbTibjx=~t`%^DCmZhkjUCWEW`LSAuRk#2vMNxy7;>dJRP435hT?p<5F45vZ zA(K}L0+`4;9V(t_E=L@eI0%3rNUg>v6l%aFgDeFS+niDxlUI<BifsE_E{J#gOlrey zGuJ<essI`1-J@l-gF`h)9t0|Y{JSDT2|vxN<iZ)9eH@yHcE&kBm3Wei$^PCoje?pg z`g-Owdv?DygKDOyGDw?5zf?Q}$H&Ll-)KZoXj17aW2+SWg~}}wI^@#Cx>|r!O$sli zUV^k$(*iU}{l$iPMrVPuNIy97CnO$ecpW6aG=24|>%)_!GJfspypMgCb9E>M=_gC2 zuCs-ezei;jZLoer`WJR1Qw_~5XTka7e}?Yn25Yl&gh$fDy7kc^5>{JGEM2TM7AtN| z|EjmvR0@KwTfJc=cq`60IU~D<OBegI6mK0;!tbEV3{V76>8hu+FpyvPWLN)pW9y*) zpMZD&x%lKBbOub|f1+YnA#XSo80OGsYtzL&nLdH6u4hXh*jtSOcM;}~(o%+jRtOk3 z4JPER3FvbhBgiE?wb71`@HUXQf&9lp&0(~~f=Xlx84HzhU|eO1exMOMTFcrTHgmiI zmHFVB(&quLuoc50^#zm{;+Ygl8r?yX$@SRX6c<AHCdU0FsUPVsiVS*W=ndupa$|=S zFb+(nOk>d)!mY!p8&;qCvSzXjmF80q6~}U@8`3ATcBdwwFRulyX(W?#In60F%eqAl zMs6q%pkOl^Hh;b)Zp?hQycrd^$EA)euXJ3v^4UtjWP#76@_kEOd_Rri*#I}PkCXl8 z-d?w{Ji6FBsB}h*Dkx*de${E54Q2KLJKM{%7_3rdaY>|!{a}T-fj9kBYDF82%nlX5 z>8(6tht7T*_{UdxDNCM7#$#49=X|fBY;lP7f7(Of$Im72o{10qG_BQaRuc6stJ%-b z$i8$wbPa*ky4b!;_L6)K1Q$)Uh9U#g5mY=Yw{y+i@)4(#@k!aH`*KI7uKRz}9|iLA zI5<)7TUTqjbHiAQTxCkd$B0<_q`z=p!_4a=H*ctK*pt3Y>ZV*uczry-Yx@iuiZ}{w zPjWDcRWb=C5C7WJvSBE@;MAs1`dr4|VoKU6IC<<oJHCG*8Yb;ow)WnPao6@tFWzg` zTMfa(%^Am=qVZ<71Cy^gxIvNYWKpgopG<O-leJP*xB9WvL4FGq#JmInlb{G~OAeD4 zFI7AX25Me^sM<^B{>;?WML&9<a0GoH1J8K5U%7ZK@mk&75PWUCuz!$rx^=FLrHQt- z92xdc*Q4BdXT3{-YnAw_&-j*=y);ykbQga_I6(CEb@LsP_w=eRT!Ih7@|Mh7<h&4r z2#bWV4-9XX*HUlRArIVLDVdv{Tl+;lwa{m#EIb=U)7HycO-tgWqH~2h;@l1Dz}?a~ zf^jS9Yn2`?t>lfz`>j1`1|8C6@Wh##E~hoa_nkt{L5#ZpfDgeLA&I*vTBp)7@M<2> zgEpYqcf6^on5S83K)hdDA+@ZS^+mQ{Z5{O0xI!o@(Z{Bk%Sw&M^I<HKiUfJTUs`z& zbsy&+*&=j&@2=2Y0)~#BIP>m{46?B7BC`q8>v=8-0m4=UaFZ-Hagi7G#;WHpZ_=hM zC{UzHmJScl6e`=AAz_+VR^>&pc4%sOxK!l6n^4ZaW?Fj2);uHu2A`Z3p@~XSY++QZ zxljdNaFkW4Ba=@|RaDfZB$I&H(9&xJEYvSoW0h%{gQc^ovlvYXzO^5~`Xok6%LMV- z^qXZey<6&j5Jna0&!Z|*?K5`qzMm9u3WjV%&fOue1u^#FcDIQVLLj!lNjq>lKRBJ} zCa#)fl>3$2IXLM5-K&V-16CcAgvj570N%gmjm!`M_W<>`hW@6DPe+XR4}ykg=)`KT zs4sWOQKJ2zy3bXMdCld9uu`YfHJfQ4o9WM@(1jPiD6uE;u|6M5B$69M&wm!hm1rcl z_#-ilm9>YF)v(nY43=Z}p`3HRD4`Uhe=`#J9YwR}Iy^u2p!-rG?%g{gQo`7zpNnYb zSq<@a{$mY~Nn_4uIqxiEd<ZbYQKH8T6xCY@ZIL@X-atd4i#U?#{=&_g^bfrx;?-x_ z@1+iUHC>5v%l<8r(!my|UhC91&JEpO-2szox4bxRK4C7CHet61mS>KgA*rE2>hUJp z3yOkA9}~WSDD;fyE80Sn)V^xz#d}6@>aAi;O+3H96Z7BnQ+sX7xEWm2fZAkuUr@k9 zp`)<76pHaGEG(#Rhv#jDS*#S#txzYCC=_4zSeD@E&_U7ta$W<X>lsPfB(ve=!Neh@ z91@z!Xt>SPz<HdiW^?yIEBEekrzpt9ntaL}vPgZF+${cEx&9KlNxQd{DpV(V(^is> zy|bgYCI6=bwl!_Cg1BqMV8x@mOv~gxepfjcrxB@TVGZ=axq_5=@aQ@MRKR;@CC@q4 zumdCM2w3m{93AKwZ4Hxt(NLheyU&bap8lq1o3A7L*jKTAfxjHD=@7?;No49r*5FhA z^d1LaNUnd7bM@J>rT-1)tE1_kWuFiuPqQB5(lSv7MAQihPz}Z>om2;uED<#B-z2Zn zeFy_&lNldjEN=8;ICHn27%=|y%SRsdFY3p=EegM@Y*|2U8dC2xS~x|?YSuv#D=yl6 z=B{p-jAdx`t$s?Hix-dtSYr~ApWeG|b~hJb=+u?G(^mdSaQBSgUm!V94DHTpVc`KK z_p0*P-X+bInhSq3?cuDb`{toj+6IBMMaknQ?qv@;)Tp`70b%)k7BH!T_pos<g&IoR z?ObAG%P}+=x$6mo=k4SM&6=<+c)g;~8K>A(RT*_s<D=HrDN?zw&IWiMEO<}7>sI0w z%1y26yB<&Hy<@Q0tgM4>EsaR@BhY+W7OrfTK3laCMKk*9Wj(~|gmKQwEGVdy1+QlP z0rV}-p{e-ZTfb^}theqGntJmSvA*3rF1hb*yr}gSCpw^zJA1ZXSDD7tka(a=!6RW) zHt)PfOgN)BqrEZCd$Q%5O}=^SwQO{OdHq{oRldn^`tX|dR^b&}|JjZj5wio6F}K%* zx(%4?%>t;)&v4E$Zg(V`q_sQAn-)`)e;cF{Vk%YBwl>MV$m?>`Dng8L75J1=nr<S7 zhIs?aaw>n7iMJQFNvO7Fxkk+Pi=XRIoM?fx!E*mylS?lxLyfybvf>-zfvZYxMxA9n z)ZgDY@|)}A<3xMzB2Ig-CYkJNp!fJWXv0XM(rb6d{LAwtr#Wdl>|B&ve(xBbk+3PL zC-0=P^_<xF*;_s@JP*=3op)jr#{Ik83gc=8X-%vGSl8UsLXzk-jXNhvix~azC|>qo z@O+Kn{f+4Y)w_RaiwdUsxLBij%@lW6q~X1rY|BP2E_=U+Q&urMv3@uy6!BU>x;4L| zj&ENu*uI2_Az%^?o9q$mPxkiqkQD3KHNA<HHpKHdM&pMm=1%;MuL<5c>`WpKNhEsm z{0BtIkNl;^^bGgfg?ukW7=t4N`Qi777ccx1#*J;}5Rs4Hkx<*6^@Yj#O1Y!F-AdrN z2My}eiGM596U+^A<`|KWr`!wveF~m6f2pR&<WKS@wsoth9^Bv^gg2t`ya-Wph~;`~ zq@16t6E`K}%8$nv-Z0$!(m{RrT?YR&wu$OZwuAchv+4n@bo>B{s$1mmN(J%UZ(WdB zSfdy3L)Kg?BZeu_+UU`1CmB~ERq2nP=CNa7pEyAyrrT{qPvgUZFdk?y0?e?7E1jZN ztH%U4F7QvwUagL+$0?u|<-});uv`a|Xx9_+pZ{J2^$Pr$h5qF`nc}>sk+T1%EB|~f zLTJ=N3_LHs&o*&?Q{PY*1phj}o`2z}djFgPK3=+TAHX)xVd)oiH|nh5(8We?y-*Wm z@kI71{mHqPf2;pB=Ld1g>^a@d&#DpFvM1o-2k3fZuWoHpuN6^;WI78MtSCtvUA0#T z<P`H6eU6>##_bnpg*l%g4j;eKcnZW}6BMnVoH__f8pP42D^lru-}HXC<<*f*9~fOI zv`C*-R<a;=WX(=d0sG)9*ezKGTlUZq1GAy2IxT8qkMC1Zz5}-G*i~3ayrz&(H1G?x z_+_UNGrL$d6ggx$P9aaONV%@P$5<;kEsZOfN2_v~Ov=>B8I*{JOq77kWS9dc-pcS} zd-%OFs*f5~j1^>tjQt_o-KK%3r?-L08)$Z}1#c4k9zcC#08RsG+rw3{wv0Gk*PG#K z*u4=`VE%0M7wctM-7ZR3Uyc{oP519|&N@ndQ$x80RefmOz~U8`>_7h`>~~*0^zqYa zBKtg*S^5*+(A2jz=+YQ2a;BxJ3PCFN2`%n9+lloF$=mdcmL%7p2=glV5=bA#NY$Wm zY|O&gi{`jgSQp|<*bQ$#iX0GHf-e~)Sz(dTP@p;n3`E8Ds8TGn1vd7Cs-`m28o0@e z7{5Wf+f$5O40>2QhNvw6G*_s_GDdl$pKe32Tj+K%uEM)%Z;S77(=1?XPq{kim3L|1 zB#8-4-b<<dV#xfc@|=qRKcR?p*~q|;RXXT@5qAGGj}FX)O4?b{UaOk}K_k-kl`hu# z+TE2A>3;1@X)qWwy-GquUtC;<lQ*{K?ct)iwoPbAqD$S1i<wM+81)r$9}H3JO;{IR z9!RUGNXjxs^FpSPKm#W93~t|y)$6@vzUQPFEx|=|l|g3flFFa@|8W?i-x|hNf1cjC zkXDV&PjQowyfo5De=p_T`BU?w3$0okg%*wSj6Rd7BDG7GQ>g~i{Z19Fwqz2Iw&h$Z z*oF5lq!+2!V%gs7P#dVQ<wbM;j-L7<6PG&O1)3LPZ03bs(rTW_31F0~ur`@&)LFAE z)z&Uo>AQ|mj@{aXo1YXv(0s$wYG4XoLPkEIPyawWrA{_GG|8px6>T-BWVYljWn*#U zRBh-2{04K9lGlkg1J=fDs%09q=49q>iBv01T#UXt^lHu;xbR@6bX~MSQ5udYV+U19 zNFf)u^M37`^z}_WwcQjebs?@zEW-X?#k9OF^^(oRd~8$}L%z8{yO~e-uJLQuU6)yn zgsltr!mpsBm6=KQ@g+5i(_)cEjub9#L`hOG3G#Cw2X{qmVI)pkBlEnu#Jc%F-BX3< zIEo_f@UVtz$&aIeP<8y{CJIB<7~|;*rzxMMd1(`K^n@)F5=88@e53Y*VWTMXjC{Jj z7NNty4DP9KhG(Ro?t6QATTQETrfoUZ|GkeP$ni|$m)lsLB(iq^C!xm?EfS4lqf`Ql z8&hkwWE_=Bh8@YoG%8AR%e$N#+#0KsM`@FK-bm}0!bfSb2@Jw$n^9_V<HOe^HP22G zO5g-7R9EE~2KpY1x0^|F5JFm-$ALVt>We3|_q&L`F|M!)Lzm`F$)-&G=46ffn&0#j z9ky9N*qQ%K$O>eSAiEX;U;9iODR5$2MQyK4)a54soFb#p?yy`uxu%cXY;YqjluC1G zxAWwJj4l++^vt*~2=8X7C@kp?BpIhD1SR?@Se2+KvJFq~P-~;><$N*$GZeKXlhiaC z)j@S0o<04v?R!KxnUN7yrFKJAq}|=r3ga<CoRV>6-v;6Ac?-$gD%s#VR%(|>EpDA) zQyEHg*}vmrLa_m3(STgk%vMxcc^f<lpL$jiWH!|o0dXOw)S{TDX*!07w8+GVLoQtw z*Q+%@CKIU1DT<lLoBvGC67S7FYNw=#Js=>DK2Wa_ow3nKB!RC@o_n^uxK|T7rY}Q@ zFN+Kre<}(EBf-po+VY%a(gARTeBoc%M*NhTCAz&GP#GX@gE3|#Nt=YNJ>egk*ro4~ z_q5Fkb^cxGqw)XqSb+YY%m07(MR0y(lP`c2YYmw=#S6cKAUEu}4qg3)Bn4zr>`W4) zd?f8ycXF6Ymg|#uDkhskF5n2Fsd{d)P~)Rczp(pLMwYISq{$*6G039EtlALvoQa}< zpayOTw$l2=tLua?ON}h=qW)&-D%J(NkD+Wn$$dR_J*M{w?b{irYK#N#iz9z84;guM zsNS=N@Emnx&f^{K%|ypY<5RMP?j!DOptSE&g79012s?>xykuXK{623kIrH~0aOkar z(qsv@NHwYY#@>#)ro`JK@z^Fa?NHX|#_C>>m<IiNk8v#ZE$bE!MPnvKWe(qY{tz8G z>VQK(-r>(;3EkSasaIQ})uk8pX7#|!XHad58%#|;DqQNF*OPxIbU!^nI1%(2d#byU zogtx+jEsWSS0d3WuM5}NdG~aC^ws?OeVu*QqchR;%{bn=^7pn<cf=I-oWyDyqR(Kn zG>e6`v+>)wz;uA(o81{ktJ3zW+>UY%=n6=$cUEl1;GL@cK50^#J52w+S#DIB_1KQO z5C()I3ML!i5;xSo9$pksX8f`WXH``sZ9Wo5r7P;!jwvgqPY;GqH2O%d1W(*~<9PPD zMEMN&Tci64nW?y*vs<J?B40I@nkvTVYwU|%cqjx$KtmiST`jTpzD4X#v$4RJQRKNe z7N0YM+9l22k=bn9O4LC%A68d1n)2q+ZvB(h;_TXZ)ahlibERVSq5<sOx*^pOql$9< zv%B6NE7ey_VLCTmRYK?d_J7;xV0jI&3k{cO(_x|2`n@rssYi=%L)xRXX{==so5G~= zQj}+$_M()`kYY3G!t*`DcMM}K6=Lc)HxF>M_?GgZ?+XvuI>%*@-i-L8=UeA8q}>@X z6}Cmu+k`)_GN$`k)D9Vh$(Zh$xcXR@fI_7v@uZdy$`M42hdfMGJ>j1wLa&nb)^Q1E zr$%(M@QfD!KJ=Z7XHY)TR*3H!-Hnl?ekT<zj3w<7#$G>-tQI$z)$RoCVXGd}Se8!+ z+1g*8ZGNMsUav%9XSW<B{Qk4yx3PNclAqdwquiimlq3^wcxXOvnCuUu5Zq_;EOE0_ ztA^pbL0DA2oAd5SnKUmKWEj6U+-a6))0scEr0AK16E#xIUTNy)<keiV6k{zYiStn! zBgpWA6vl2U6f&GP2GZjTa#zpcy{jKztA?4qnX<T_8#uR71{Bii8AO~0vdbv{yM10< zIh7m|8kCnW{`skrTsRx+dz9Fm+C_DFHwL@nw1n;KT_?$>`ZkxKpolD;g+?h46as|U zDS8UG;_2N<mS||Jq#dF2k<n9^&D99GxsXwVfF8GJ*c=~1kv`Io)U>jE?*YzTJ6<b( zKb(DT3SAQPw&^(~ax2XSN4y1dzxZzga60n!4TN7UVCs`jYiN~b8--YJ`{$LtQm!-2 zEQ#dkH_gbQQ{>-rJ%aj{tVS3q^kiCX#IEoayKo8oGuB0di)H$~bnV$#ZId^wR=|8y zN>-;TDxy|0t9)>>?!vHEp0f-{`Q*cxw93q{g5_pns5<KLP|#l0V<&6|Dq`VUYGlHv zhK=v(K<p>yu*z-IR<qwuR@ncg`xlH&=Rtpto2z}<t|WnZ$~fcj@Vlb(ibYBpX}!$p z8c&&Fxm0(+x;8d)X>KTU8IzFk%C!79h}MGSSUdzwlg`NWb*&Q=8NGb-K`z&FXK(NB zpmL;F5&XKdGy9{tXaOBk!3YHd|9@+USsInUeMpJw`hbg4)_(GX<rY89+300qmowWd z5^Bvf1NL92qD8cABqELXv_jgCtc=gqFHCFnw(=DEd=o7wMt<K*=yd*S+Z9w~+g&lq zq#F|{o7!U&RPD6MQ@09sH&*<*KAeZ){H(04Ss?YI7x%eA@(bhmjIO4T@>jv-0(UjD zDa(KkZbA^fe7ExByrbg<5f}3a$FG(=Wu4;{Tp|nO@4qXDs$%-8Pj-zK2`cT2ys0uP z??0+X(6)@uy6x#5`{VH{w(~CN)VL-cZg3P!lvT>3zfENEZ_O(vz`7b6>rJfaOToUm z5ONUxr&EWT0;y_07M5S8O&d+jZNe=yZ(LqH2dnMfOaA1oA9`++xm;HSR33Nu+(&b| zlXFIs@sfsNwPDNwB+nmG=<?%+T8xQYO;&tx0#L~gPk6qCwIE+}<13HaZk1ohOnnzP z4C<v$zL|#mz#SP*+JWet_xdMS?IVXPzm%}>&Xt0rtThxLDuxI&+c3wkhO0P&HOY{c zkMd8|fb>I`%Q*Q|98DPIhFg6!Y}2rG&}QM~N_v}sM-lge5rU}Ou!v!0JdE&6is)X9 z^c#cXBm8|*jLekWp1Z{>V`iq-5r$_C&67(L_)w$Y?L<AB2XzhZ4#L<1j6QCM?93Mo zoG5zH8-HhVon3a*k-|@?GEqho4sqb6MAlc@j;%ise4hd%smJ1$mu7IYS2i-2Pj`*c zv+aQB^T{0VzR-qDdhT<_S#&Qn5&C_~7QsT=_oaGEkDDsC<Y5LzwPSLfJa(uy9Y{?i zkk%j^`#Sp_6#b6rsJEd4SLkj*X3}uixSVOpX`(@@B*1j|H3dfnGPU&e{lK5CIb9Tj zFZP5wkuB;tB}kb8b4N3u`C)=e53J!TM}znA_RHtsy-O}SL206Ii*XibaGnLTo_!a_ z20EgN3W?X!Nc+=b4Yz9p8~*?53NaKztv=^AGr{-*Y6mOq5d2-$?03nqc{h3ZjHOHF z<)?9Bt?9~kr(w4BXyTv*UFeLJc$5L;18c7S-l#*A40CPDk>4i_i|%ZNazlxNMS&O8 z$q47er^q9u7=<YmKe=lWiTuO$J~Uc*yyOgVDYyr>&({M=xlx^j?`}nBkD&Lrf9P@N z-FK%>zMYFzlQB17#}6^Bc^#*iT2?gg4cc5SxjsO!Ouwu@8$hNPV6EQ_#woZnw@P+D zdxMP=GCw3T6`yONZ*308#Et&TNl1WuEMlmh{?y?H_NWpFN9cBz$4lj*(c-M`zJS_c zau)NHW?!bW=9Kp*fz0HPx5bKiVbv+q-R^7&drhb68t8F~ahrtpRaRi!QLgwXkqd`u zZMrm($`KsLSrZLf*rK;hoDxH6Vr;hp_~iEM5{YSlr&aX1t+VXx2EMu@U9-%C^E#}j z|H%U$_5Lsem;$eVX`eYeKZEK(G!lfs`dra)aUDTU6~bd*R3xd^>AialK~cIKa}Po) z=b?lc{%2+Of4CsoVQ0Ce!(B*{vMnr^v5OcHOwR{*wm<(Y?2$$@k4(r77z<jMW9@BM zEBfK!H=<@B;FcT5Dn7tEI0&&w)zFHeqF<R#T$G2yowEOlj*E%!o(T$nspiU01&pV( zW?25jc{}>JOOx4BVI_ll9PS22BqrIz##RT1<5`(N!J*G(jyt)dfsB(wbC54|0$<3^ z$T+$z@R`x~%=)5S_B{W%+)U)f)n-Ta8veJT2WjCeU%~<B_jI;>AGA|Ox+((*78Tv^ zwZl`mp(=1UY0Z%r6$i^-8*jYfBy5L}e6}IFBQ&T_;a~T-7qU;8uiP@ZLyKkJla0#Q zC$R=Q+o^3eSZ2GqyHES$*52s)rYw1&qzSRfr}&41Bmz`<<^rnTPwNH4>HgPdvj@zH zYsnu!(=nTbZ^oYiJo9NAsp?`l{WT{?*b=Vq!D0K_3M=>&RQeb<^cFj>Ceoz5zQh^h z5))EC2J#D?M@%A?s&1ul#3HS)ylgqT6N@&CMBI}jKGB)Uev89|SRW^MzXIPDs&&^7 z%J?5hj$3$K_?DHz@o+`iV$yL*gKtpRFJ`NY{2otXt0<Qiyn$*TsH{v4*^gQJPfd*A zv#ZJJa32~om8e-n!s)uD(`a?t^rCz4l!HgMDgulvU21K%@F4fSX~bAqAg^;SuA#3j ziLm<O(qR5I_EA}~fIoJ7G9>uagaJmk32!@55A|MU+pt7=v9h`t`&y-%(knS0x7XrR zt}?d<`ignqg_<Nny5Al9VMzA3ZxSkzlF0(bIv{w#e5&+5)f=Q*b%*9y@D&_{eWSKF zy&#M}#A5I8qi~~g=LdrzuuLIh<4`c}Bj=n_5S}0*4DcspI*h^ozsaZmU(W3RBwPPK zTtxc;MM*zbQ%0gmfkob^pGIA8DUQLCl;3LvH)NryXr`wX)mg;aKBHe_T|{i`rB*HV zYSnz2HA)PmtTIaG)B;}{@PkgfMS^O%kjtQp^Kkc49a`JMJiv-0_WcE@#90CuU8ye| zMdEb$X{z*?n~6b1bi!oftw@>`3JwhO?2geFCq6&8Y^6mci3Cb@zg3DwP0qy72rrJs zZ2HaDQH{lx*6owdjNeoX!wnQAvIKtpW~#5QLYQ+f4IX29xD7yd0=uMKr`4U=a%6WR z57Z-3W3t)V*;x!PV}n2$fcr)9sQUPdvm4P=S2NIauxbO3L9Q(|>tE-B`A9i~t$}UR zvQQOY+*>O_TPKEOGTsEl1q71ztLMlD1AL%?4a96RnhpvAJ*pT1*q0pnhQIvUb1Adu z(s8{TCq6EuY-KWe%s>HPnuwxC=4UIuvuY@>ng)8+R&b)~F#%^64gEq;jm!vy+M+h1 zVj}NAdRDoL%#?t#S!r1S8y1~sehMv3hgAChY6^=+SY10-UW+tBz#M6?!|p|p1^N8r z`cds0@T^ZnPGtFy(-Y#!;yYvsCD@Mbx+rB7sdm>idh!|Z$|wU)#!8>h%+u*n7piIj zRVq}1%5*In2h)aE9L9n>cvy4H71IrtOV%I>3bh)#3YVa-9kqy`R|w4*&Om!{w}7uy zrU7!#XL#uqH>y+SxfQPYKNyIr?%Top`N}Yxpll%#1>EnAuoG2wI*tZ{GUsO%cNYqb z2Ay<I_O;>!G`L+g^nGy#!w{(AEhjJdT)HS&+yh^=q^lB$X@j1|C?-;r8ddF@S7l#d z!8VQ~SED>0{ss=Ul*gr|zToDAk-h!$P4RrEz^;~JevDzge-iyna9%nXhGXV0t9<oX zB;Ky5kSeL2k5Zs^`C?~`Sx*)~UVzNcbmcK1bOcR){L6<0MFbLygiJR7%S3nwp<#x) z(Ov5d1cFKth%&0G@tZ3^nhI5L6kOn=;s6L0=(2wjjZ10a<E!Ba=HW=T@V^v)b<>e{ z_XM-bVzb4g*Pe)nUW^FrqimVw;9rj_Z|CUwv?`L_GPy*xXk4XEusJp_&}`hxZWy>$ zBx}&VaF{Z4q8`AQi;3E_3*EG<WjZ49sZZ(VHn3p+i1T!S`H5P^huxe0Riw6qW<S1> zTB}NB{g6-l*mS!E`@tO9V?m`Q;`z13dqa72#!Dx2O=6cpt0_)8JgHY^$PY^By&}Ca zd^kvJPP>R}xDrmE3ea057(+AQ&f~;y7-q1~v;$xMLPffG3j&zQJh+>;%~bKgwEc2d zf2uxB7%%C1q417A^0gAmRFHUNl+~c!RHL!p>MUD`baBj_0tN3vr1Er>`udZhN!kiY zMSqNIe-uXxo4<R)0e2ouVzROdzD_=^rR0fAptV?u*4verB_jwmOh-e;!V<v!T4JfW znL^o$QK!wVHPcQdaSP0Jp8xo8$jqp@x5;<^)B5nu0b3ki)LiDm52-7MhG`SCa*d21 zfEICrx{Z6wa0+JNIX=Rb(%aeKt2H-XY@R>jb7!XfgpA@}TII?p0}s8f=3Ze!+q2$H zR}h%r@O!lgjJ)KZsnD_Lm8ETlF&+t>*)4-R5Y<b74>VXRhsqT@Ge)8aJrD*y8jPcG zv9`)jDL+Dc!NmpnbhmeOu%UXw6$dSRTidCpS+B8NV~raYlYfKQ$a{DCKTox_^oAFM zuk-a9e_GhW%oaQyR9PJ|rKz#jx#S^PMrnI>A>V#%aBT9v_r2p3KR`IyK+G;;>R|hI z1rJ9ORs_&KG6arajfRrQE1zZfA44CjX>aw@p?-y*tte0t|IrPK%gouUPlU~PMD>#3 z#dYWY_SXQ3;>EA)pDu7CFMg-d8@LcFg^tm@UtdW*T|&XBqS|{i@x1N<C*WvuAP&iS z17&j5n(hyc*$ey?t*8{N)w_pT)1^6s=^L~1V^-&$zm|UEx4&J~Dj<MP;R4St^=6I? z4Z5i-67eNDAnTM)MDRI6DA!SZt_)0ey3&IN)}>JpyEuJNl1{=qo~O>!UVBxTBQY25 zef^e@7dhDPHi@Kf086Tctw(dk?8a^eBn&>0x*n15SNOe%+{sNts9>VFI9GR?W4Us& z6!~4#;S_Hul^?I8quGymg;Hf(zNE&pm8@JNF25BQU->bVP#N!}V!}d&V1ux-%kg`o zW8n^#+MYFg7%pj@PJUUe^GD34_uWE#pF**>9-&BHH$R6jX%Rv=hF-95u**37hTl0- z!8STYqNvNc{i&N)Q?UBm)PXUhN6a3V80X*%mIoNRT(R+^IVYL+4SU<!CVA~U;m1(g zbtJcA&}S81!)jcl7SQFTq<C(F@8L#N)o|3*7C%O@ILPSda-(&QHTNj$SVo82@4Nq# z&}HT+Vq0FI>~EensNN&KPLMsoUwl;b?V8o{O5`m0q`Z`}-i2_4yJw!WJOaDW{R*+T z=x2v5uV;XW$vgC_I}C0v*ddU;LAwe4urBM7WEH3PTy>S4@Xp-(&%u<ow|oB9QRm{j zy{`{=u=<Zd9`Rjo_Mk$-vo1cQ;&<G^^Pu1scAH@1oh-7j^^A$KIBf_+YeY)^u}~O~ zI&+;MkYUs;B+X|TrQ8Z-eYvh-e?NWW$O%{7)3lz|{`Q0Nj$=Po!XsLjt^<3xDp**5 zRLuN!C{Y(W_!{tJVN5Xs(0rf<gyb&#peiTI24i)!cdoGs|2)PNDPofws$wM_i<}O( z*FI?se*fGy`>r)l3?uC6%2wajZ1J)p$mOEz*@PdNVYJODf)4_}!7MESs|G*_xpWVF zy(oJ<^-bM;-Bd6^xwnj(=d>orxtB4WWt0&GUvg-|GHh1mku?i9<l|6S+jWAYEBgo7 zJfuHU^cvZF){f!v1s}{Kh+5@5RL%UQ4eVeSr(T_&eTOk-f7=9aac%P^kF%^7yX@nw zYfKJuH;H9oW|Dw#OJHV}b2M!#_qlHPZPfUk_www!GDWi8j23ZTxag4~@je<G!@|oW zNCi!<OT%Ow-&HyKo`0~}6!tV0#h!z1dFa;_-6HBiJM3?7&;#{GoH)YV*#wdIfIZ%$ zxm0>yk@Fme>)GSNZA0j0a-n6|CWm>rPM%`Ai&XC6NmhG%Dh+4CtDHP@+qmZ*O=Rtl z0?nn9;m<6sLViE7e~N@1o4R=fx4?3PMGnrS)n}*F{lMJ|I+n%&l@M<}I?(Y5Ln1Ri z5R^hhr!zlr1EJ8%|8k*ta=VMPJt6&#$OyprfkP9-cA)>^!T<kZA+A1QOlPswhyluY zP{rftveNi6EfeBifhyoUDxg&H9F>p`6LN9qHK^_d+Zsd@a;Ej^QW*CWnKTaB2O2yH z=FbB1%o2D~eSmjW3if4DQ-<bWpCK{~U?xCi{KM0AD?}HlM5B5S)Rsr9JcDqg_n8)M zPyj-5yH2ydkgAQaZa+|*obb0Qm)2;T<9jK@Z3u8GPV+tjG$4R>vIRgRc`il;Js#GH z6crT>{Uppa8#8I%QRj}&&X$s|>&ITPDUA4pVBqvK(>S?WgoFrVmE0=DoD_#(7(>|I z>9~eiPZY3U!Y8WpDDRYdq0Hk|Pm#S-_0obgg@$<J^uiRBlSflXJA?XrZP(K-K!Gey zD_>hJ9vEs6)O}f-kx?Qj+5O(Kg!EKP?#;qZg7gQKDoWgFPld#U$+^P!%&p%|lVlAw z3!`u8fVKq{7mUgqY_lT^Q3JXnFU)AnTKh!2x0Ku6B_jqel_kP#sRz&okP~dZ4NSun zsLerr^}h-i2t2TAUzP#h72y7W*x6#!w{t)XGi;fZ1jH-k+7<wBYJMM3hOkvD4*E>1 zXznND<@*#SZdIt{_9IsMNZ#`KRQ484$(`1?qaJzH8CI0zd$tpln!&Q?LO*yudX@Z~ zT7vk3#0NLzZE<z;oC!%r+2rS*@tq754BC#*J^-eIjF*3KD1(`kU@FV&3!j|m@GVs2 zQ^+i+7BDi7aZbFfpJB<CnFMP##3+aVt>Z-Z(SdOVDxiQkr&zPr3V@q|U_|k|3izM; zYeqd?68vOerDbTxF4zI`&Wscv&8Qd+{T)Y1nZ8%WtD0p2XtN2V-41mriPDaanIpl- z%QY?@5&9koo3^J|n?IBXhy}P@9jMpaYyuUA2LK5aFY@F?0-A>sG%&JAfq?AB@J&Ba z*P7AQJC#zL!Ly7#IOK$<8k?cv*ss(sT7_hSL|GMgi}m#+qc0JjCgY1Mr*djQMh^;s zcN)eOFx>zodCUD#BjC4QC2(9Eo1LX%K?!)JjDKV3J!n08=2KZg!`9R1=Z@Q1q9da4 ziB22a2uX^WUa8UQH0VlcYjSi|N||P+@%Rv<WSEQI(Nrr-kcjXezN|%UvQ4F2->pp< zGzP`(eXAIgtpvd?v_d8_fMj9nK^7cee)$v51>o-i90Pzsi8}J{KTUpue}XE1cpx(a z7HHC{&fn+=-{`+|u*3jDKMv*zJ8)lyhh-vvWBx~Zmw#Z18sdpwur8wN1%^vgP*A*l z&72K!q*nfi<gQN?e>^wfMyP^>UEH46LPdL1{8wU^c>?#pqPi^f@Sjw6AiU?Jd0%+p zmgeRlwlrWoQE=L$+>jE^*LCa<s)ktSZZEFU>?9=mK@h9&J-8nrzgCf(D?+CO7_nCF zm#-NBa8tq^6p;gwBhYzApKY+y0YxPdWQ{q%788ok&OlFZ32F+#nDE$cg$!)oEv!62 z#JPxgToc~B7g9&z2kq>9S8Iaj0`hJh9UVlEJEHSv0KNiv0#0>)0IIBjie54Gj+IWs z_)k|wI*&m?5xO2Xzx_Wt9)*O44h;{t0PWnC%MxqxJZ;^(i`#v++8Qf&-G=w?=tA)( zh;$P>x9pR?QxTV(=g5zRaLTf)T*B&tF&Sn5g8W5Q6<0V1Kq<EYj6aS}U0?u$^HtSx ztG3%!h|+%I*z^OMuR&1&%`5P|AV4K9Jpv+*OP=xgi|FYjSm$94=w2;ZyPxA1egTZR zSR%MsSP1Md<ZCDsc&~>$Ri`-}fyU~1KC{P?<V(ZDCLbT4Z|2xNl3JE|ymEKt<&o9y zXTQelZh!Y(F7|RsN{^c=%R-5O2X9QTs`a%CVBD!NDWD;GRX~pF1n6O%0{VsAO(2gI zeKF2#IJ7w`0JOGV8|drz@?*+tK=AmWJm}Zc<GuB4c}8jJ5h&OGO!kt1^J0)?B*Ny; zWtj&7;7bEMy1prBhA!SOXmLvy%M~e<=t84z*|l01Zg2b!tLof-tq(8FtnfM4xf#lL z2v}upN?$foQbx_nq%hwC(Tn=XZ#28Mmq-@s;eY=2mNC2BCeQ$Fb*|EwosA8tD`(Sk zp(fB!pGK4}`Z+r*D@4_=syLQhJeAFI{{Fc24?qf~a@hLOK@ffj!|D9rmw_cqBe3=d z2)rI+X;x}DD|mj_a}18q!03!yLc-M)uBMvUUfE4JxEf{p)~{4g_Op-@iWKZAl`)}j z65k=7fMaaKrrw9sY=HNs9TnhdQ-y%waa2f2NeLFwgttnZ&lS+CUa7TC!?}MtFy<xo z;Km~9>FV0t*a-Nb7844aODPj4Nhsyvxs*pgq<WNA`{h}V<I%jETgxj)3S}AgxXR)l zkH0OVT5ilOpjFlA3e3Ve2XA^4>P@1>0r-0gU<gUA*YgohXS2}ZpnUsQPiBmkJ?1R0 zwx*1(!Nc)R@?frD9ACS@L%IQxZvS)z02Wpc1o6Ms5O`AnH8Ff5qLG}+VEs9O6&{7@ z8VKh)>y`O?6EVU-aB&-uiGZj{#ymVK;UMjf4QI+m**1u*^MJ(*n4AF_qoO*U%@<VY z%AFWgRN1uBP$X-hVr#`Q-{kg7Ah$2OA32tK=6Kn#p~OO7miX_$;kvFShH{xB!+eKo zy_z>SH8lllvHBdWtjfuhH>6=|6#g+WST<<l{4Oqlb>3Ypiq&}d%_)CBfKCx?x&mT2 zK$Ft6m1Mr`0px3d-YL*LCL76A%5EdfvsvUwLnLWjD*8aQl1_Dg?7Ev{zbG;dZ-u5Y zuq+j2{X*J<_|M(aW%5OX>3`-Caw_S_vS0d&^FICfSlZ)(adu;sl!W*IDB{a5ZvA57 zRna>;JuUJwDQvtW5R+1D*7SFrrd8kI;N;pWAW$S`XTOFhT=(6A6+mr;6K;Z@X@N?7 zw9vAVz%|$29n=-v8rOZhuhDhSyP%505$hUvs?f4Vw-(M}t@c57zBh@XWPzPayDZ@C zPW-sz3a=;^+F^AnR0vp+!x&tmsnUX@M~r&^@&Uf$cD~3I4hFP;?q|_yqi4<6%aW%@ zKv5c~1PU)BA|hsIWw#Wml-z-N2Y^NQKt(y?Q%sI8L_Tj3?BY>`ijmOIo=YV%3v<C) z;(fz0{Pjd$e7iqQjeI+yyhN&W37XwO2Jtc0)l#L+T~ras1cQ*daFG4Bu(E>i*$k(A z>^cKH9pXA=jYph8DdG~;FCk#^PJc?kR@Y#?!UGiVOufh)?yn93$2Y(R&sG6N?v76k zGhm_pfv~}32KZ6oAC2(Kwx0SMEW6h4vLvD;$xVT6nYiI?3E9}gPF8pmU4$ke$38+x zU>3sK=f&!JdOY^X^GzRE(}V<LhABEfErZS8Sv|e=Xcs{=<W*!s!iO)bZICm?j~C!b z&VbG!Jw1KZ8$0zs#_u2};xOJK4A<x02+;YS&Q1s`q0t?P^<V<L8!$}C!4Y+}(HA`q zd8^Rh%@BYbsTwYgy&jfeq!6QIgY;W(xA3)NtwDZibZ(9stLmd6#Kth`NsBbze`_KE mJ_wcW81XmP>8UL&|HR8HojTG8d!PfkA}%Z=R4SnB{eJ*BU(7lH literal 0 HcmV?d00001 diff --git a/plugins/channelrx/demodrtty/CMakeLists.txt b/plugins/channelrx/demodrtty/CMakeLists.txt new file mode 100644 index 000000000..87d5c3a7f --- /dev/null +++ b/plugins/channelrx/demodrtty/CMakeLists.txt @@ -0,0 +1,63 @@ +project(demodrtty) + +set(demodrtty_SOURCES + rttydemod.cpp + rttydemodsettings.cpp + rttydemodbaseband.cpp + rttydemodsink.cpp + rttydemodplugin.cpp + rttydemodwebapiadapter.cpp +) + +set(demodrtty_HEADERS + rttydemod.h + rttydemodsettings.h + rttydemodbaseband.h + rttydemodsink.h + rttydemodplugin.h + rttydemodwebapiadapter.h +) + +include_directories( + ${CMAKE_SOURCE_DIR}/swagger/sdrangel/code/qt5/client +) + +if(NOT SERVER_MODE) + set(demodrtty_SOURCES + ${demodrtty_SOURCES} + rttydemodgui.cpp + rttydemodgui.ui + ) + set(demodrtty_HEADERS + ${demodrtty_HEADERS} + rttydemodgui.h + ) + + set(TARGET_NAME demodrtty) + set(TARGET_LIB "Qt::Widgets") + set(TARGET_LIB_GUI "sdrgui") + set(INSTALL_FOLDER ${INSTALL_PLUGINS_DIR}) +else() + set(TARGET_NAME demodrttysrv) + set(TARGET_LIB "") + set(TARGET_LIB_GUI "") + set(INSTALL_FOLDER ${INSTALL_PLUGINSSRV_DIR}) +endif() + +add_library(${TARGET_NAME} SHARED + ${demodrtty_SOURCES} +) + +target_link_libraries(${TARGET_NAME} + Qt::Core + ${TARGET_LIB} + sdrbase + ${TARGET_LIB_GUI} +) + +install(TARGETS ${TARGET_NAME} DESTINATION ${INSTALL_FOLDER}) + +# Install debug symbols +if (WIN32) + install(FILES $<TARGET_PDB_FILE:${TARGET_NAME}> CONFIGURATIONS Debug RelWithDebInfo DESTINATION ${INSTALL_FOLDER} ) +endif() diff --git a/plugins/channelrx/demodrtty/readme.md b/plugins/channelrx/demodrtty/readme.md new file mode 100644 index 000000000..34f55893a --- /dev/null +++ b/plugins/channelrx/demodrtty/readme.md @@ -0,0 +1,103 @@ +<h1>RTTY demodulator plugin</h1> + +<h2>Introduction</h2> + +This plugin can be used to demodulate RTTY (Radioteletype) transmissions. +RTTY using BFSK (Binary Frequency Shift Keying), where transmission of data alternates between two frequencies, +the mark frequency and the space frequency. The RTTY Demodulor should be centered in between these frequencies. +The baud rate, frequency shift (difference between mark and space frequencies), bandwidth and baudot character set are configurable. + +<h2>Interface</h2> + +The top and bottom bars of the channel window are described [here](../../../sdrgui/channel/readme.md) + + + +<h3>1: Frequency shift from center frequency of reception</h3> + +Use the wheels to adjust the frequency shift in Hz from the center frequency of reception. Left click on a digit sets the cursor position at this digit. Right click on a digit sets all digits on the right to zero. This effectively floors value at the digit position. Wheels are moved with the mousewheel while pointing at the wheel or by selecting the wheel with the left mouse click and using the keyboard arrows. Pressing shift simultaneously moves digit by 5 and pressing control moves it by 2. + +<h3>2: Channel power</h3> + +Average total power in dB relative to a +/- 1.0 amplitude signal received in the pass band. + +<h3>3: Level meter in dB</h3> + + - top bar (green): average value + - bottom bar (blue green): instantaneous peak value + - tip vertical bar (bright green): peak hold value + +<h3>4: RTTY Presets</h3> + +From the presets dropdown, you can select common baud rate and frequency shift settings, or choose Custom to set these individually. + +<h3>5: Baud rate</h3> + +Specifies the baud rate, in symbols per second. +The tooltip will display an estimate of the received baud rate (Which will be accurate to around 5 baud), providing that the frequency shift has been set correctly. + +<h3>6: Frequency shift</h3> + +Specifies the frequency shift in Hertz between the mark frequency and the space frequency. +The tooltip will display an estimate of the frequency shift (Which will be accurate to around 10-20Hz), assuming that the bandwidth has been set wide enough to contain the signal. + +<h3>7: RF Bandwidth</h3> + +This specifies the bandwidth of a filter that is applied to the input signal to limit the RF bandwidth. This should be set wide enough to contain the mark and space frequencies and sidebands, +but not so wide to accept noise or adjacent signals. + +<h3>8: UDP</h3> + +When checked, received characters are forwarded to the specified UDP address (9) and port (10). + +<h3>9: UDP address</h3> + +IP address of the host to forward received characters to via UDP. + +<h3>10: UDP port</h3> + +UDP port number to forward received characters to. + +<h3>11: Squelch</h3> + +Sets the squelch power. Characters received with average power lower than this setting will be discarded. + +<h3>12: Baudot Character Set</h3> + +The baudot character set dropdown determines how the received Baudot encodings will be mapped to Unicode characters. The following character sets are supported: + +* ITA 2 +* UK +* European +* US +* Russian +* Murray + +<h3>13: Bit ordering</h3> + +Specifies whether bits are transmitted least-significant-bit first (LSB) or most-significant-bit first (MSB). + +<h3>14: Mark/Space Frequency</h3> + +When unchecked, the mark frequency is the higher frequency, when checked space frequency is higher. + +<h3>15: Suppress CR LF</h3> + +When checked the CR CR LF sequence is just displayed as CR. + +<h3>16: Unshift on Space</h3> + +When checked, the Baudot character set will shift to letters when a space character (' ') is received. + +<h3>17: Start/stop Logging Messages to .txt File</h3> + +When checked, writes all received characters to the .txt file specified by (16). + +<h3>18: .txt Log Filename</h3> + +Click to specify the name of the .txt file which received characters are logged to. + +<h3>19: Received Text</h3> + +The received text area shows characters as they are received. + diff --git a/plugins/channelrx/demodrtty/rttydemod.cpp b/plugins/channelrx/demodrtty/rttydemod.cpp new file mode 100644 index 000000000..a36cf3414 --- /dev/null +++ b/plugins/channelrx/demodrtty/rttydemod.cpp @@ -0,0 +1,780 @@ +/////////////////////////////////////////////////////////////////////////////////// +// Copyright (C) 2015-2018 Edouard Griffiths, F4EXB. // +// Copyright (C) 2023 Jon Beniston, M7RCE // +// // +// This program is free software; you can redistribute it and/or modify // +// it under the terms of the GNU General Public License as published by // +// the Free Software Foundation as version 3 of the License, or // +// (at your option) any later version. // +// // +// This program is distributed in the hope that it will be useful, // +// but WITHOUT ANY WARRANTY; without even the implied warranty of // +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // +// GNU General Public License V3 for more details. // +// // +// You should have received a copy of the GNU General Public License // +// along with this program. If not, see <http://www.gnu.org/licenses/>. // +/////////////////////////////////////////////////////////////////////////////////// + +#include "rttydemod.h" + +#include <QTime> +#include <QDebug> +#include <QNetworkAccessManager> +#include <QNetworkReply> +#include <QBuffer> +#include <QThread> + +#include <stdio.h> +#include <complex.h> + +#include "SWGChannelSettings.h" +#include "SWGWorkspaceInfo.h" +#include "SWGRTTYDemodSettings.h" +#include "SWGChannelReport.h" +#include "SWGMapItem.h" + +#include "dsp/dspengine.h" +#include "dsp/dspcommands.h" +#include "device/deviceapi.h" +#include "feature/feature.h" +#include "settings/serializable.h" +#include "util/db.h" +#include "maincore.h" + +MESSAGE_CLASS_DEFINITION(RttyDemod::MsgConfigureRttyDemod, Message) +MESSAGE_CLASS_DEFINITION(RttyDemod::MsgCharacter, Message) +MESSAGE_CLASS_DEFINITION(RttyDemod::MsgModeEstimate, Message) + +const char * const RttyDemod::m_channelIdURI = "sdrangel.channel.rttydemod"; +const char * const RttyDemod::m_channelId = "RTTYDemod"; + +RttyDemod::RttyDemod(DeviceAPI *deviceAPI) : + ChannelAPI(m_channelIdURI, ChannelAPI::StreamSingleSink), + m_deviceAPI(deviceAPI), + m_basebandSampleRate(0) +{ + setObjectName(m_channelId); + + m_basebandSink = new RttyDemodBaseband(this); + m_basebandSink->setMessageQueueToChannel(getInputMessageQueue()); + m_basebandSink->setChannel(this); + m_basebandSink->moveToThread(&m_thread); + + applySettings(m_settings, true); + + m_deviceAPI->addChannelSink(this); + m_deviceAPI->addChannelSinkAPI(this); + + m_networkManager = new QNetworkAccessManager(); + QObject::connect( + m_networkManager, + &QNetworkAccessManager::finished, + this, + &RttyDemod::networkManagerFinished + ); + QObject::connect( + this, + &ChannelAPI::indexInDeviceSetChanged, + this, + &RttyDemod::handleIndexInDeviceSetChanged + ); +} + +RttyDemod::~RttyDemod() +{ + qDebug("RttyDemod::~RttyDemod"); + QObject::disconnect( + m_networkManager, + &QNetworkAccessManager::finished, + this, + &RttyDemod::networkManagerFinished + ); + delete m_networkManager; + m_deviceAPI->removeChannelSinkAPI(this); + m_deviceAPI->removeChannelSink(this); + + if (m_basebandSink->isRunning()) { + stop(); + } + + delete m_basebandSink; +} + +void RttyDemod::setDeviceAPI(DeviceAPI *deviceAPI) +{ + if (deviceAPI != m_deviceAPI) + { + m_deviceAPI->removeChannelSinkAPI(this); + m_deviceAPI->removeChannelSink(this); + m_deviceAPI = deviceAPI; + m_deviceAPI->addChannelSink(this); + m_deviceAPI->addChannelSinkAPI(this); + } +} + +uint32_t RttyDemod::getNumberOfDeviceStreams() const +{ + return m_deviceAPI->getNbSourceStreams(); +} + +void RttyDemod::feed(const SampleVector::const_iterator& begin, const SampleVector::const_iterator& end, bool firstOfBurst) +{ + (void) firstOfBurst; + m_basebandSink->feed(begin, end); +} + +void RttyDemod::start() +{ + qDebug("RttyDemod::start"); + + m_basebandSink->reset(); + m_basebandSink->startWork(); + m_thread.start(); + + DSPSignalNotification *dspMsg = new DSPSignalNotification(m_basebandSampleRate, m_centerFrequency); + m_basebandSink->getInputMessageQueue()->push(dspMsg); + + RttyDemodBaseband::MsgConfigureRttyDemodBaseband *msg = RttyDemodBaseband::MsgConfigureRttyDemodBaseband::create(m_settings, true); + m_basebandSink->getInputMessageQueue()->push(msg); +} + +void RttyDemod::stop() +{ + qDebug("RttyDemod::stop"); + m_basebandSink->stopWork(); + m_thread.quit(); + m_thread.wait(); +} + +bool RttyDemod::handleMessage(const Message& cmd) +{ + if (MsgConfigureRttyDemod::match(cmd)) + { + MsgConfigureRttyDemod& cfg = (MsgConfigureRttyDemod&) cmd; + qDebug() << "RttyDemod::handleMessage: MsgConfigureRttyDemod"; + applySettings(cfg.getSettings(), cfg.getForce()); + + return true; + } + else if (DSPSignalNotification::match(cmd)) + { + DSPSignalNotification& notif = (DSPSignalNotification&) cmd; + m_basebandSampleRate = notif.getSampleRate(); + m_centerFrequency = notif.getCenterFrequency(); + // Forward to the sink + DSPSignalNotification* rep = new DSPSignalNotification(notif); // make a copy + qDebug() << "RttyDemod::handleMessage: DSPSignalNotification"; + m_basebandSink->getInputMessageQueue()->push(rep); + // Forward to GUI if any + if (m_guiMessageQueue) { + m_guiMessageQueue->push(new DSPSignalNotification(notif)); + } + + return true; + } + else if (RttyDemod::MsgCharacter::match(cmd)) + { + // Forward to GUI + RttyDemod::MsgCharacter& report = (RttyDemod::MsgCharacter&)cmd; + if (getMessageQueueToGUI()) + { + RttyDemod::MsgCharacter *msg = new RttyDemod::MsgCharacter(report); + getMessageQueueToGUI()->push(msg); + } + + // Forward via UDP + if (m_settings.m_udpEnabled) + { + QByteArray bytes = report.getCharacter().toUtf8(); + m_udpSocket.writeDatagram(bytes, bytes.size(), + QHostAddress(m_settings.m_udpAddress), m_settings.m_udpPort); + } + + // Write to log file + if (m_logFile.isOpen()) { + m_logStream << report.getCharacter(); + } + + return true; + } + else if (RttyDemod::MsgModeEstimate::match(cmd)) + { + // Forward to GUI + RttyDemod::MsgModeEstimate& report = (RttyDemod::MsgModeEstimate&)cmd; + if (getMessageQueueToGUI()) + { + RttyDemod::MsgModeEstimate *msg = new RttyDemod::MsgModeEstimate(report); + getMessageQueueToGUI()->push(msg); + } + + return true; + } + else if (MainCore::MsgChannelDemodQuery::match(cmd)) + { + qDebug() << "RttyDemod::handleMessage: MsgChannelDemodQuery"; + sendSampleRateToDemodAnalyzer(); + + return true; + } + else + { + return false; + } +} + +ScopeVis *RttyDemod::getScopeSink() +{ + return m_basebandSink->getScopeSink(); +} + +void RttyDemod::setCenterFrequency(qint64 frequency) +{ + RttyDemodSettings settings = m_settings; + settings.m_inputFrequencyOffset = frequency; + applySettings(settings, false); + + if (m_guiMessageQueue) // forward to GUI if any + { + MsgConfigureRttyDemod *msgToGUI = MsgConfigureRttyDemod::create(settings, false); + m_guiMessageQueue->push(msgToGUI); + } +} + +void RttyDemod::applySettings(const RttyDemodSettings& settings, bool force) +{ + qDebug() << "RttyDemod::applySettings:" + << " m_logEnabled: " << settings.m_logEnabled + << " m_logFilename: " << settings.m_logFilename + << " m_streamIndex: " << settings.m_streamIndex + << " m_useReverseAPI: " << settings.m_useReverseAPI + << " m_reverseAPIAddress: " << settings.m_reverseAPIAddress + << " m_reverseAPIPort: " << settings.m_reverseAPIPort + << " m_reverseAPIDeviceIndex: " << settings.m_reverseAPIDeviceIndex + << " m_reverseAPIChannelIndex: " << settings.m_reverseAPIChannelIndex + << " force: " << force; + + QList<QString> reverseAPIKeys; + + if ((settings.m_inputFrequencyOffset != m_settings.m_inputFrequencyOffset) || force) { + reverseAPIKeys.append("inputFrequencyOffset"); + } + if ((settings.m_rfBandwidth != m_settings.m_rfBandwidth) || force) { + reverseAPIKeys.append("rfBandwidth"); + } + if ((settings.m_baudRate != m_settings.m_baudRate) || force) { + reverseAPIKeys.append("baudRate"); + } + if ((settings.m_frequencyShift != m_settings.m_frequencyShift) || force) { + reverseAPIKeys.append("frequencyShift"); + } + if ((settings.m_udpEnabled != m_settings.m_udpEnabled) || force) { + reverseAPIKeys.append("udpEnabled"); + } + if ((settings.m_udpAddress != m_settings.m_udpAddress) || force) { + reverseAPIKeys.append("udpAddress"); + } + if ((settings.m_udpPort != m_settings.m_udpPort) || force) { + reverseAPIKeys.append("udpPort"); + } + if ((settings.m_characterSet != m_settings.m_characterSet) || force) { + reverseAPIKeys.append("characterSet"); + } + if ((settings.m_suppressCRLF != m_settings.m_suppressCRLF) || force) { + reverseAPIKeys.append("suppressCRLF"); + } + if ((settings.m_unshiftOnSpace != m_settings.m_unshiftOnSpace) || force) { + reverseAPIKeys.append("unshiftOnSpace"); + } + if ((settings.m_msbFirst != m_settings.m_msbFirst) || force) { + reverseAPIKeys.append("msbFirst"); + } + if ((settings.m_spaceHigh != m_settings.m_spaceHigh) || force) { + reverseAPIKeys.append("spaceHigh"); + } + if ((settings.m_squelch != m_settings.m_squelch) || force) { + reverseAPIKeys.append("squelch"); + } + if ((settings.m_logFilename != m_settings.m_logFilename) || force) { + reverseAPIKeys.append("logFilename"); + } + if ((settings.m_logEnabled != m_settings.m_logEnabled) || force) { + reverseAPIKeys.append("logEnabled"); + } + if (m_settings.m_streamIndex != settings.m_streamIndex) + { + if (m_deviceAPI->getSampleMIMO()) // change of stream is possible for MIMO devices only + { + m_deviceAPI->removeChannelSinkAPI(this); + m_deviceAPI->removeChannelSink(this, m_settings.m_streamIndex); + m_deviceAPI->addChannelSink(this, settings.m_streamIndex); + m_deviceAPI->addChannelSinkAPI(this); + } + + reverseAPIKeys.append("streamIndex"); + } + + RttyDemodBaseband::MsgConfigureRttyDemodBaseband *msg = RttyDemodBaseband::MsgConfigureRttyDemodBaseband::create(settings, force); + m_basebandSink->getInputMessageQueue()->push(msg); + + if (settings.m_useReverseAPI) + { + bool fullUpdate = ((m_settings.m_useReverseAPI != settings.m_useReverseAPI) && settings.m_useReverseAPI) || + (m_settings.m_reverseAPIAddress != settings.m_reverseAPIAddress) || + (m_settings.m_reverseAPIPort != settings.m_reverseAPIPort) || + (m_settings.m_reverseAPIDeviceIndex != settings.m_reverseAPIDeviceIndex) || + (m_settings.m_reverseAPIChannelIndex != settings.m_reverseAPIChannelIndex); + webapiReverseSendSettings(reverseAPIKeys, settings, fullUpdate || force); + } + + if ((settings.m_logEnabled != m_settings.m_logEnabled) + || (settings.m_logFilename != m_settings.m_logFilename) + || force) + { + if (m_logFile.isOpen()) + { + m_logStream.flush(); + m_logFile.close(); + } + if (settings.m_logEnabled && !settings.m_logFilename.isEmpty()) + { + m_logFile.setFileName(settings.m_logFilename); + if (m_logFile.open(QIODevice::WriteOnly | QIODevice::Append | QIODevice::Text)) + { + qDebug() << "RttyDemod::applySettings - Logging to: " << settings.m_logFilename; + m_logStream.setDevice(&m_logFile); + } + else + { + qDebug() << "RttyDemod::applySettings - Unable to open log file: " << settings.m_logFilename; + } + } + } + + m_settings = settings; +} + +void RttyDemod::sendSampleRateToDemodAnalyzer() +{ + QList<ObjectPipe*> pipes; + MainCore::instance()->getMessagePipes().getMessagePipes(this, "reportdemod", pipes); + + if (pipes.size() > 0) + { + for (const auto& pipe : pipes) + { + MessageQueue *messageQueue = qobject_cast<MessageQueue*>(pipe->m_element); + MainCore::MsgChannelDemodReport *msg = MainCore::MsgChannelDemodReport::create( + this, + RttyDemodSettings::RTTYDEMOD_CHANNEL_SAMPLE_RATE + ); + messageQueue->push(msg); + } + } +} + +QByteArray RttyDemod::serialize() const +{ + return m_settings.serialize(); +} + +bool RttyDemod::deserialize(const QByteArray& data) +{ + if (m_settings.deserialize(data)) + { + MsgConfigureRttyDemod *msg = MsgConfigureRttyDemod::create(m_settings, true); + m_inputMessageQueue.push(msg); + return true; + } + else + { + m_settings.resetToDefaults(); + MsgConfigureRttyDemod *msg = MsgConfigureRttyDemod::create(m_settings, true); + m_inputMessageQueue.push(msg); + return false; + } +} + +int RttyDemod::webapiSettingsGet( + SWGSDRangel::SWGChannelSettings& response, + QString& errorMessage) +{ + (void) errorMessage; + response.setRttyDemodSettings(new SWGSDRangel::SWGRTTYDemodSettings()); + response.getRttyDemodSettings()->init(); + webapiFormatChannelSettings(response, m_settings); + return 200; +} + +int RttyDemod::webapiWorkspaceGet( + SWGSDRangel::SWGWorkspaceInfo& response, + QString& errorMessage) +{ + (void) errorMessage; + response.setIndex(m_settings.m_workspaceIndex); + return 200; +} + +int RttyDemod::webapiSettingsPutPatch( + bool force, + const QStringList& channelSettingsKeys, + SWGSDRangel::SWGChannelSettings& response, + QString& errorMessage) +{ + (void) errorMessage; + RttyDemodSettings settings = m_settings; + webapiUpdateChannelSettings(settings, channelSettingsKeys, response); + + MsgConfigureRttyDemod *msg = MsgConfigureRttyDemod::create(settings, force); + m_inputMessageQueue.push(msg); + + qDebug("RttyDemod::webapiSettingsPutPatch: forward to GUI: %p", m_guiMessageQueue); + if (m_guiMessageQueue) // forward to GUI if any + { + MsgConfigureRttyDemod *msgToGUI = MsgConfigureRttyDemod::create(settings, force); + m_guiMessageQueue->push(msgToGUI); + } + + webapiFormatChannelSettings(response, settings); + + return 200; +} + +int RttyDemod::webapiReportGet( + SWGSDRangel::SWGChannelReport& response, + QString& errorMessage) +{ + (void) errorMessage; + response.setRttyDemodReport(new SWGSDRangel::SWGRTTYDemodReport()); + response.getRttyDemodReport()->init(); + webapiFormatChannelReport(response); + return 200; +} + +void RttyDemod::webapiUpdateChannelSettings( + RttyDemodSettings& settings, + const QStringList& channelSettingsKeys, + SWGSDRangel::SWGChannelSettings& response) +{ + if (channelSettingsKeys.contains("inputFrequencyOffset")) { + settings.m_inputFrequencyOffset = response.getRttyDemodSettings()->getInputFrequencyOffset(); + } + if (channelSettingsKeys.contains("rfBandwidth")) { + settings.m_rfBandwidth = response.getRttyDemodSettings()->getRfBandwidth(); + } + if (channelSettingsKeys.contains("baudRate")) { + settings.m_baudRate = response.getRttyDemodSettings()->getBaudRate(); + } + if (channelSettingsKeys.contains("frequencyShift")) { + settings.m_frequencyShift = response.getRttyDemodSettings()->getFrequencyShift(); + } + if (channelSettingsKeys.contains("udpEnabled")) { + settings.m_udpEnabled = response.getRttyDemodSettings()->getUdpEnabled(); + } + if (channelSettingsKeys.contains("udpAddress")) { + settings.m_udpAddress = *response.getRttyDemodSettings()->getUdpAddress(); + } + if (channelSettingsKeys.contains("udpPort")) { + settings.m_udpPort = response.getRttyDemodSettings()->getUdpPort(); + } + if (channelSettingsKeys.contains("characterSet")) { + settings.m_characterSet = (Baudot::CharacterSet)response.getRttyDemodSettings()->getCharacterSet(); + } + if (channelSettingsKeys.contains("suppressCRLF")) { + settings.m_suppressCRLF = response.getRttyDemodSettings()->getSuppressCrlf(); + } + if (channelSettingsKeys.contains("unshiftOnSpace")) { + settings.m_unshiftOnSpace = response.getRttyDemodSettings()->getUnshiftOnSpace(); + } + if (channelSettingsKeys.contains("msbFirst")) { + settings.m_msbFirst = response.getRttyDemodSettings()->getMsbFirst(); + } + if (channelSettingsKeys.contains("spaceHigh")) { + settings.m_spaceHigh = response.getRttyDemodSettings()->getSpaceHigh(); + } + if (channelSettingsKeys.contains("squelch")) { + settings.m_squelch = response.getRttyDemodSettings()->getSquelch(); + } + if (channelSettingsKeys.contains("logFilename")) { + settings.m_logFilename = *response.getAdsbDemodSettings()->getLogFilename(); + } + if (channelSettingsKeys.contains("logEnabled")) { + settings.m_logEnabled = response.getAdsbDemodSettings()->getLogEnabled(); + } + if (channelSettingsKeys.contains("rgbColor")) { + settings.m_rgbColor = response.getRttyDemodSettings()->getRgbColor(); + } + if (channelSettingsKeys.contains("title")) { + settings.m_title = *response.getRttyDemodSettings()->getTitle(); + } + if (channelSettingsKeys.contains("streamIndex")) { + settings.m_streamIndex = response.getRttyDemodSettings()->getStreamIndex(); + } + if (channelSettingsKeys.contains("useReverseAPI")) { + settings.m_useReverseAPI = response.getRttyDemodSettings()->getUseReverseApi() != 0; + } + if (channelSettingsKeys.contains("reverseAPIAddress")) { + settings.m_reverseAPIAddress = *response.getRttyDemodSettings()->getReverseApiAddress(); + } + if (channelSettingsKeys.contains("reverseAPIPort")) { + settings.m_reverseAPIPort = response.getRttyDemodSettings()->getReverseApiPort(); + } + if (channelSettingsKeys.contains("reverseAPIDeviceIndex")) { + settings.m_reverseAPIDeviceIndex = response.getRttyDemodSettings()->getReverseApiDeviceIndex(); + } + if (channelSettingsKeys.contains("reverseAPIChannelIndex")) { + settings.m_reverseAPIChannelIndex = response.getRttyDemodSettings()->getReverseApiChannelIndex(); + } + if (settings.m_scopeGUI && channelSettingsKeys.contains("scopeConfig")) { + settings.m_scopeGUI->updateFrom(channelSettingsKeys, response.getRttyDemodSettings()->getScopeConfig()); + } + if (settings.m_channelMarker && channelSettingsKeys.contains("channelMarker")) { + settings.m_channelMarker->updateFrom(channelSettingsKeys, response.getRttyDemodSettings()->getChannelMarker()); + } + if (settings.m_rollupState && channelSettingsKeys.contains("rollupState")) { + settings.m_rollupState->updateFrom(channelSettingsKeys, response.getRttyDemodSettings()->getRollupState()); + } +} + +void RttyDemod::webapiFormatChannelSettings(SWGSDRangel::SWGChannelSettings& response, const RttyDemodSettings& settings) +{ + response.getRttyDemodSettings()->setInputFrequencyOffset(settings.m_inputFrequencyOffset); + response.getRttyDemodSettings()->setRfBandwidth(settings.m_rfBandwidth); + response.getRttyDemodSettings()->setBaudRate(settings.m_baudRate); + response.getRttyDemodSettings()->setFrequencyShift(settings.m_frequencyShift); + response.getRttyDemodSettings()->setUdpEnabled(settings.m_udpEnabled); + response.getRttyDemodSettings()->setUdpAddress(new QString(settings.m_udpAddress)); + response.getRttyDemodSettings()->setUdpPort(settings.m_udpPort); + response.getRttyDemodSettings()->setCharacterSet(settings.m_characterSet); + response.getRttyDemodSettings()->setSuppressCrlf(settings.m_suppressCRLF); + response.getRttyDemodSettings()->setUnshiftOnSpace(settings.m_unshiftOnSpace); + response.getRttyDemodSettings()->setMsbFirst(settings.m_msbFirst); + response.getRttyDemodSettings()->setSpaceHigh(settings.m_spaceHigh); + response.getRttyDemodSettings()->setSquelch(settings.m_squelch); + response.getRttyDemodSettings()->setLogFilename(new QString(settings.m_logFilename)); + response.getRttyDemodSettings()->setLogEnabled(settings.m_logEnabled); + + response.getRttyDemodSettings()->setRgbColor(settings.m_rgbColor); + if (response.getRttyDemodSettings()->getTitle()) { + *response.getRttyDemodSettings()->getTitle() = settings.m_title; + } else { + response.getRttyDemodSettings()->setTitle(new QString(settings.m_title)); + } + + response.getRttyDemodSettings()->setStreamIndex(settings.m_streamIndex); + response.getRttyDemodSettings()->setUseReverseApi(settings.m_useReverseAPI ? 1 : 0); + + if (response.getRttyDemodSettings()->getReverseApiAddress()) { + *response.getRttyDemodSettings()->getReverseApiAddress() = settings.m_reverseAPIAddress; + } else { + response.getRttyDemodSettings()->setReverseApiAddress(new QString(settings.m_reverseAPIAddress)); + } + + response.getRttyDemodSettings()->setReverseApiPort(settings.m_reverseAPIPort); + response.getRttyDemodSettings()->setReverseApiDeviceIndex(settings.m_reverseAPIDeviceIndex); + response.getRttyDemodSettings()->setReverseApiChannelIndex(settings.m_reverseAPIChannelIndex); + + if (settings.m_scopeGUI) + { + if (response.getRttyDemodSettings()->getScopeConfig()) + { + settings.m_scopeGUI->formatTo(response.getRttyDemodSettings()->getScopeConfig()); + } + else + { + SWGSDRangel::SWGGLScope *swgGLScope = new SWGSDRangel::SWGGLScope(); + settings.m_scopeGUI->formatTo(swgGLScope); + response.getRttyDemodSettings()->setScopeConfig(swgGLScope); + } + } + if (settings.m_channelMarker) + { + if (response.getRttyDemodSettings()->getChannelMarker()) + { + settings.m_channelMarker->formatTo(response.getRttyDemodSettings()->getChannelMarker()); + } + else + { + SWGSDRangel::SWGChannelMarker *swgChannelMarker = new SWGSDRangel::SWGChannelMarker(); + settings.m_channelMarker->formatTo(swgChannelMarker); + response.getRttyDemodSettings()->setChannelMarker(swgChannelMarker); + } + } + + if (settings.m_rollupState) + { + if (response.getRttyDemodSettings()->getRollupState()) + { + settings.m_rollupState->formatTo(response.getRttyDemodSettings()->getRollupState()); + } + else + { + SWGSDRangel::SWGRollupState *swgRollupState = new SWGSDRangel::SWGRollupState(); + settings.m_rollupState->formatTo(swgRollupState); + response.getRttyDemodSettings()->setRollupState(swgRollupState); + } + } +} + +void RttyDemod::webapiFormatChannelReport(SWGSDRangel::SWGChannelReport& response) +{ + double magsqAvg, magsqPeak; + int nbMagsqSamples; + getMagSqLevels(magsqAvg, magsqPeak, nbMagsqSamples); + + response.getRttyDemodReport()->setChannelPowerDb(CalcDb::dbPower(magsqAvg)); + response.getRttyDemodReport()->setChannelSampleRate(m_basebandSink->getChannelSampleRate()); +} + +void RttyDemod::webapiReverseSendSettings(QList<QString>& channelSettingsKeys, const RttyDemodSettings& settings, bool force) +{ + SWGSDRangel::SWGChannelSettings *swgChannelSettings = new SWGSDRangel::SWGChannelSettings(); + webapiFormatChannelSettings(channelSettingsKeys, swgChannelSettings, settings, force); + + QString channelSettingsURL = QString("http://%1:%2/sdrangel/deviceset/%3/channel/%4/settings") + .arg(settings.m_reverseAPIAddress) + .arg(settings.m_reverseAPIPort) + .arg(settings.m_reverseAPIDeviceIndex) + .arg(settings.m_reverseAPIChannelIndex); + m_networkRequest.setUrl(QUrl(channelSettingsURL)); + m_networkRequest.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); + + QBuffer *buffer = new QBuffer(); + buffer->open((QBuffer::ReadWrite)); + buffer->write(swgChannelSettings->asJson().toUtf8()); + buffer->seek(0); + + // Always use PATCH to avoid passing reverse API settings + QNetworkReply *reply = m_networkManager->sendCustomRequest(m_networkRequest, "PATCH", buffer); + buffer->setParent(reply); + + delete swgChannelSettings; +} + +void RttyDemod::webapiFormatChannelSettings( + QList<QString>& channelSettingsKeys, + SWGSDRangel::SWGChannelSettings *swgChannelSettings, + const RttyDemodSettings& settings, + bool force +) +{ + swgChannelSettings->setDirection(0); // Single sink (Rx) + swgChannelSettings->setOriginatorChannelIndex(getIndexInDeviceSet()); + swgChannelSettings->setOriginatorDeviceSetIndex(getDeviceSetIndex()); + swgChannelSettings->setChannelType(new QString("RttyDemod")); + swgChannelSettings->setRttyDemodSettings(new SWGSDRangel::SWGRTTYDemodSettings()); + SWGSDRangel::SWGRTTYDemodSettings *swgRttyDemodSettings = swgChannelSettings->getRttyDemodSettings(); + + // transfer data that has been modified. When force is on transfer all data except reverse API data + + if (channelSettingsKeys.contains("inputFrequencyOffset") || force) { + swgRttyDemodSettings->setInputFrequencyOffset(settings.m_inputFrequencyOffset); + } + if (channelSettingsKeys.contains("rfBandwidth") || force) { + swgRttyDemodSettings->setRfBandwidth(settings.m_rfBandwidth); + } + if (channelSettingsKeys.contains("baudRate") || force) { + swgRttyDemodSettings->setBaudRate(settings.m_baudRate); + } + if (channelSettingsKeys.contains("frequencyShift") || force) { + swgRttyDemodSettings->setFrequencyShift(settings.m_frequencyShift); + } + if (channelSettingsKeys.contains("udpEnabled") || force) { + swgRttyDemodSettings->setUdpEnabled(settings.m_udpEnabled); + } + if (channelSettingsKeys.contains("udpAddress") || force) { + swgRttyDemodSettings->setUdpAddress(new QString(settings.m_udpAddress)); + } + if (channelSettingsKeys.contains("udpPort") || force) { + swgRttyDemodSettings->setUdpPort(settings.m_udpPort); + } + if (channelSettingsKeys.contains("characterSet") || force) { + swgRttyDemodSettings->setCharacterSet(settings.m_characterSet); + } + if (channelSettingsKeys.contains("suppressCRLF") || force) { + swgRttyDemodSettings->setSuppressCrlf(settings.m_suppressCRLF); + } + if (channelSettingsKeys.contains("unshiftOnSpace") || force) { + swgRttyDemodSettings->setUnshiftOnSpace(settings.m_unshiftOnSpace); + } + if (channelSettingsKeys.contains("msbFirst") || force) { + swgRttyDemodSettings->setMsbFirst(settings.m_msbFirst); + } + if (channelSettingsKeys.contains("spaceHigh") || force) { + swgRttyDemodSettings->setSpaceHigh(settings.m_spaceHigh); + } + if (channelSettingsKeys.contains("squelch") || force) { + swgRttyDemodSettings->setSquelch(settings.m_squelch); + } + if (channelSettingsKeys.contains("logFilename") || force) { + swgRttyDemodSettings->setLogFilename(new QString(settings.m_logFilename)); + } + if (channelSettingsKeys.contains("logEnabled") || force) { + swgRttyDemodSettings->setLogEnabled(settings.m_logEnabled); + } + if (channelSettingsKeys.contains("rgbColor") || force) { + swgRttyDemodSettings->setRgbColor(settings.m_rgbColor); + } + if (channelSettingsKeys.contains("title") || force) { + swgRttyDemodSettings->setTitle(new QString(settings.m_title)); + } + if (channelSettingsKeys.contains("streamIndex") || force) { + swgRttyDemodSettings->setStreamIndex(settings.m_streamIndex); + } + + if (settings.m_scopeGUI && (channelSettingsKeys.contains("scopeConfig") || force)) + { + SWGSDRangel::SWGGLScope *swgGLScope = new SWGSDRangel::SWGGLScope(); + settings.m_scopeGUI->formatTo(swgGLScope); + swgRttyDemodSettings->setScopeConfig(swgGLScope); + } + + if (settings.m_channelMarker && (channelSettingsKeys.contains("channelMarker") || force)) + { + SWGSDRangel::SWGChannelMarker *swgChannelMarker = new SWGSDRangel::SWGChannelMarker(); + settings.m_channelMarker->formatTo(swgChannelMarker); + swgRttyDemodSettings->setChannelMarker(swgChannelMarker); + } + + if (settings.m_rollupState && (channelSettingsKeys.contains("rollupState") || force)) + { + SWGSDRangel::SWGRollupState *swgRollupState = new SWGSDRangel::SWGRollupState(); + settings.m_rollupState->formatTo(swgRollupState); + swgRttyDemodSettings->setRollupState(swgRollupState); + } +} + +void RttyDemod::networkManagerFinished(QNetworkReply *reply) +{ + QNetworkReply::NetworkError replyError = reply->error(); + + if (replyError) + { + qWarning() << "RttyDemod::networkManagerFinished:" + << " error(" << (int) replyError + << "): " << replyError + << ": " << reply->errorString(); + } + else + { + QString answer = reply->readAll(); + answer.chop(1); // remove last \n + qDebug("RttyDemod::networkManagerFinished: reply:\n%s", answer.toStdString().c_str()); + } + + reply->deleteLater(); +} + +void RttyDemod::handleIndexInDeviceSetChanged(int index) +{ + if (index < 0) { + return; + } + + QString fifoLabel = QString("%1 [%2:%3]") + .arg(m_channelId) + .arg(m_deviceAPI->getDeviceSetIndex()) + .arg(index); + m_basebandSink->setFifoLabel(fifoLabel); +} + diff --git a/plugins/channelrx/demodrtty/rttydemod.h b/plugins/channelrx/demodrtty/rttydemod.h new file mode 100644 index 000000000..926b1d5b8 --- /dev/null +++ b/plugins/channelrx/demodrtty/rttydemod.h @@ -0,0 +1,218 @@ +/////////////////////////////////////////////////////////////////////////////////// +// Copyright (C) 2015-2018 Edouard Griffiths, F4EXB. // +// Copyright (C) 2023 Jon Beniston, M7RCE // +// // +// This program is free software; you can redistribute it and/or modify // +// it under the terms of the GNU General Public License as published by // +// the Free Software Foundation as version 3 of the License, or // +// (at your option) any later version. // +// // +// This program is distributed in the hope that it will be useful, // +// but WITHOUT ANY WARRANTY; without even the implied warranty of // +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // +// GNU General Public License V3 for more details. // +// // +// You should have received a copy of the GNU General Public License // +// along with this program. If not, see <http://www.gnu.org/licenses/>. // +/////////////////////////////////////////////////////////////////////////////////// + +#ifndef INCLUDE_RTTYDEMOD_H +#define INCLUDE_RTTYDEMOD_H + +#include <QNetworkRequest> +#include <QUdpSocket> +#include <QThread> +#include <QFile> +#include <QTextStream> + +#include "dsp/basebandsamplesink.h" +#include "channel/channelapi.h" +#include "util/message.h" + +#include "rttydemodbaseband.h" +#include "rttydemodsettings.h" + +class QNetworkAccessManager; +class QNetworkReply; +class QThread; +class DeviceAPI; +class ScopeVis; + +class RttyDemod : public BasebandSampleSink, public ChannelAPI { +public: + class MsgConfigureRttyDemod : public Message { + MESSAGE_CLASS_DECLARATION + + public: + const RttyDemodSettings& getSettings() const { return m_settings; } + bool getForce() const { return m_force; } + + static MsgConfigureRttyDemod* create(const RttyDemodSettings& settings, bool force) + { + return new MsgConfigureRttyDemod(settings, force); + } + + private: + RttyDemodSettings m_settings; + bool m_force; + + MsgConfigureRttyDemod(const RttyDemodSettings& settings, bool force) : + Message(), + m_settings(settings), + m_force(force) + { } + }; + + // Sent from Sink when character is decoded + class MsgCharacter : public Message { + MESSAGE_CLASS_DECLARATION + + public: + QString getCharacter() const { return m_character; } + + static MsgCharacter* create(const QString& character) + { + return new MsgCharacter(character); + } + + private: + QString m_character; + + MsgCharacter(const QString& character) : + m_character(character) + {} + }; + + // Sent from Sink when an estimate is made of the baud rate + class MsgModeEstimate : public Message { + MESSAGE_CLASS_DECLARATION + + public: + int getBaudRate() const { return m_baudRate; } + int getFrequencyShift() const { return m_frequencyShift; } + + static MsgModeEstimate* create(int baudRate, int frequencyShift) + { + return new MsgModeEstimate(baudRate, frequencyShift); + } + + private: + int m_baudRate; + int m_frequencyShift; + + MsgModeEstimate(int baudRate, int frequencyShift) : + m_baudRate(baudRate), + m_frequencyShift(frequencyShift) + {} + }; + + RttyDemod(DeviceAPI *deviceAPI); + virtual ~RttyDemod(); + virtual void destroy() { delete this; } + virtual void setDeviceAPI(DeviceAPI *deviceAPI); + virtual DeviceAPI *getDeviceAPI() { return m_deviceAPI; } + + using BasebandSampleSink::feed; + virtual void feed(const SampleVector::const_iterator& begin, const SampleVector::const_iterator& end, bool po); + virtual void start(); + virtual void stop(); + virtual void pushMessage(Message *msg) { m_inputMessageQueue.push(msg); } + virtual QString getSinkName() { return objectName(); } + + virtual void getIdentifier(QString& id) { id = objectName(); } + virtual QString getIdentifier() const { return objectName(); } + virtual const QString& getURI() const { return getName(); } + virtual void getTitle(QString& title) { title = m_settings.m_title; } + virtual qint64 getCenterFrequency() const { return m_settings.m_inputFrequencyOffset; } + virtual void setCenterFrequency(qint64 frequency); + + virtual QByteArray serialize() const; + virtual bool deserialize(const QByteArray& data); + + virtual int getNbSinkStreams() const { return 1; } + virtual int getNbSourceStreams() const { return 0; } + + virtual qint64 getStreamCenterFrequency(int streamIndex, bool sinkElseSource) const + { + (void) streamIndex; + (void) sinkElseSource; + return 0; + } + + virtual int webapiSettingsGet( + SWGSDRangel::SWGChannelSettings& response, + QString& errorMessage); + + virtual int webapiWorkspaceGet( + SWGSDRangel::SWGWorkspaceInfo& response, + QString& errorMessage); + + virtual int webapiSettingsPutPatch( + bool force, + const QStringList& channelSettingsKeys, + SWGSDRangel::SWGChannelSettings& response, + QString& errorMessage); + + virtual int webapiReportGet( + SWGSDRangel::SWGChannelReport& response, + QString& errorMessage); + + static void webapiFormatChannelSettings( + SWGSDRangel::SWGChannelSettings& response, + const RttyDemodSettings& settings); + + static void webapiUpdateChannelSettings( + RttyDemodSettings& settings, + const QStringList& channelSettingsKeys, + SWGSDRangel::SWGChannelSettings& response); + + ScopeVis *getScopeSink(); + double getMagSq() const { return m_basebandSink->getMagSq(); } + + void getMagSqLevels(double& avg, double& peak, int& nbSamples) { + m_basebandSink->getMagSqLevels(avg, peak, nbSamples); + } +/* void setMessageQueueToGUI(MessageQueue* queue) override { + ChannelAPI::setMessageQueueToGUI(queue); + m_basebandSink->setMessageQueueToGUI(queue); + }*/ + + uint32_t getNumberOfDeviceStreams() const; + + static const char * const m_channelIdURI; + static const char * const m_channelId; + +private: + DeviceAPI *m_deviceAPI; + QThread m_thread; + RttyDemodBaseband* m_basebandSink; + RttyDemodSettings m_settings; + int m_basebandSampleRate; //!< stored from device message used when starting baseband sink + qint64 m_centerFrequency; + QUdpSocket m_udpSocket; + QFile m_logFile; + QTextStream m_logStream; + + QNetworkAccessManager *m_networkManager; + QNetworkRequest m_networkRequest; + + virtual bool handleMessage(const Message& cmd); + void applySettings(const RttyDemodSettings& settings, bool force = false); + void sendSampleRateToDemodAnalyzer(); + void webapiReverseSendSettings(QList<QString>& channelSettingsKeys, const RttyDemodSettings& settings, bool force); + void webapiFormatChannelSettings( + QList<QString>& channelSettingsKeys, + SWGSDRangel::SWGChannelSettings *swgChannelSettings, + const RttyDemodSettings& settings, + bool force + ); + void webapiFormatChannelReport(SWGSDRangel::SWGChannelReport& response); + +private slots: + void networkManagerFinished(QNetworkReply *reply); + void handleIndexInDeviceSetChanged(int index); + +}; + +#endif // INCLUDE_RTTYDEMOD_H + diff --git a/plugins/channelrx/demodrtty/rttydemodbaseband.cpp b/plugins/channelrx/demodrtty/rttydemodbaseband.cpp new file mode 100644 index 000000000..e54076203 --- /dev/null +++ b/plugins/channelrx/demodrtty/rttydemodbaseband.cpp @@ -0,0 +1,181 @@ +/////////////////////////////////////////////////////////////////////////////////// +// Copyright (C) 2019 Edouard Griffiths, F4EXB // +// Copyright (C) 2023 Jon Beniston, M7RCE // +// // +// This program is free software; you can redistribute it and/or modify // +// it under the terms of the GNU General Public License as published by // +// the Free Software Foundation as version 3 of the License, or // +// (at your option) any later version. // +// // +// This program is distributed in the hope that it will be useful, // +// but WITHOUT ANY WARRANTY; without even the implied warranty of // +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // +// GNU General Public License V3 for more details. // +// // +// You should have received a copy of the GNU General Public License // +// along with this program. If not, see <http://www.gnu.org/licenses/>. // +/////////////////////////////////////////////////////////////////////////////////// + +#include <QDebug> + +#include "dsp/dspengine.h" +#include "dsp/dspcommands.h" +#include "dsp/downchannelizer.h" + +#include "rttydemodbaseband.h" + +MESSAGE_CLASS_DEFINITION(RttyDemodBaseband::MsgConfigureRttyDemodBaseband, Message) + +RttyDemodBaseband::RttyDemodBaseband(RttyDemod *packetDemod) : + m_sink(packetDemod), + m_running(false) +{ + qDebug("RttyDemodBaseband::RttyDemodBaseband"); + + m_sink.setScopeSink(&m_scopeSink); + m_sampleFifo.setSize(SampleSinkFifo::getSizePolicy(48000)); + m_channelizer = new DownChannelizer(&m_sink); +} + +RttyDemodBaseband::~RttyDemodBaseband() +{ + m_inputMessageQueue.clear(); + + delete m_channelizer; +} + +void RttyDemodBaseband::reset() +{ + QMutexLocker mutexLocker(&m_mutex); + m_inputMessageQueue.clear(); + m_sampleFifo.reset(); +} + +void RttyDemodBaseband::startWork() +{ + QMutexLocker mutexLocker(&m_mutex); + QObject::connect( + &m_sampleFifo, + &SampleSinkFifo::dataReady, + this, + &RttyDemodBaseband::handleData, + Qt::QueuedConnection + ); + connect(&m_inputMessageQueue, SIGNAL(messageEnqueued()), this, SLOT(handleInputMessages())); + m_running = true; +} + +void RttyDemodBaseband::stopWork() +{ + QMutexLocker mutexLocker(&m_mutex); + disconnect(&m_inputMessageQueue, SIGNAL(messageEnqueued()), this, SLOT(handleInputMessages())); + QObject::disconnect( + &m_sampleFifo, + &SampleSinkFifo::dataReady, + this, + &RttyDemodBaseband::handleData + ); + m_running = false; +} + +void RttyDemodBaseband::setChannel(ChannelAPI *channel) +{ + m_sink.setChannel(channel); +} + +void RttyDemodBaseband::feed(const SampleVector::const_iterator& begin, const SampleVector::const_iterator& end) +{ + m_sampleFifo.write(begin, end); +} + +void RttyDemodBaseband::handleData() +{ + QMutexLocker mutexLocker(&m_mutex); + + while ((m_sampleFifo.fill() > 0) && (m_inputMessageQueue.size() == 0)) + { + SampleVector::iterator part1begin; + SampleVector::iterator part1end; + SampleVector::iterator part2begin; + SampleVector::iterator part2end; + + std::size_t count = m_sampleFifo.readBegin(m_sampleFifo.fill(), &part1begin, &part1end, &part2begin, &part2end); + + // first part of FIFO data + if (part1begin != part1end) { + m_channelizer->feed(part1begin, part1end); + } + + // second part of FIFO data (used when block wraps around) + if(part2begin != part2end) { + m_channelizer->feed(part2begin, part2end); + } + + m_sampleFifo.readCommit((unsigned int) count); + } +} + +void RttyDemodBaseband::handleInputMessages() +{ + Message* message; + + while ((message = m_inputMessageQueue.pop()) != nullptr) + { + if (handleMessage(*message)) { + delete message; + } + } +} + +bool RttyDemodBaseband::handleMessage(const Message& cmd) +{ + if (MsgConfigureRttyDemodBaseband::match(cmd)) + { + QMutexLocker mutexLocker(&m_mutex); + MsgConfigureRttyDemodBaseband& cfg = (MsgConfigureRttyDemodBaseband&) cmd; + qDebug() << "RttyDemodBaseband::handleMessage: MsgConfigureRttyDemodBaseband"; + + applySettings(cfg.getSettings(), cfg.getForce()); + + return true; + } + else if (DSPSignalNotification::match(cmd)) + { + QMutexLocker mutexLocker(&m_mutex); + DSPSignalNotification& notif = (DSPSignalNotification&) cmd; + qDebug() << "RttyDemodBaseband::handleMessage: DSPSignalNotification: basebandSampleRate: " << notif.getSampleRate(); + setBasebandSampleRate(notif.getSampleRate()); + m_sampleFifo.setSize(SampleSinkFifo::getSizePolicy(notif.getSampleRate())); + + return true; + } + else + { + return false; + } +} + +void RttyDemodBaseband::applySettings(const RttyDemodSettings& settings, bool force) +{ + if ((settings.m_inputFrequencyOffset != m_settings.m_inputFrequencyOffset) || force) + { + m_channelizer->setChannelization(RttyDemodSettings::RTTYDEMOD_CHANNEL_SAMPLE_RATE, settings.m_inputFrequencyOffset); + m_sink.applyChannelSettings(m_channelizer->getChannelSampleRate(), m_channelizer->getChannelFrequencyOffset()); + } + + m_sink.applySettings(settings, force); + + m_settings = settings; +} + +int RttyDemodBaseband::getChannelSampleRate() const +{ + return m_channelizer->getChannelSampleRate(); +} + +void RttyDemodBaseband::setBasebandSampleRate(int sampleRate) +{ + m_channelizer->setBasebandSampleRate(sampleRate); + m_sink.applyChannelSettings(m_channelizer->getChannelSampleRate(), m_channelizer->getChannelFrequencyOffset()); +} + diff --git a/plugins/channelrx/demodrtty/rttydemodbaseband.h b/plugins/channelrx/demodrtty/rttydemodbaseband.h new file mode 100644 index 000000000..d45b1eb5d --- /dev/null +++ b/plugins/channelrx/demodrtty/rttydemodbaseband.h @@ -0,0 +1,103 @@ +/////////////////////////////////////////////////////////////////////////////////// +// Copyright (C) 2019 Edouard Griffiths, F4EXB // +// Copyright (C) 2023 Jon Beniston, M7RCE // +// // +// This program is free software; you can redistribute it and/or modify // +// it under the terms of the GNU General Public License as published by // +// the Free Software Foundation as version 3 of the License, or // +// (at your option) any later version. // +// // +// This program is distributed in the hope that it will be useful, // +// but WITHOUT ANY WARRANTY; without even the implied warranty of // +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // +// GNU General Public License V3 for more details. // +// // +// You should have received a copy of the GNU General Public License // +// along with this program. If not, see <http://www.gnu.org/licenses/>. // +/////////////////////////////////////////////////////////////////////////////////// + +#ifndef INCLUDE_RTTYDEMODBASEBAND_H +#define INCLUDE_RTTYDEMODBASEBAND_H + +#include <QObject> +#include <QRecursiveMutex> + +#include "dsp/samplesinkfifo.h" +#include "dsp/scopevis.h" +#include "util/message.h" +#include "util/messagequeue.h" + +#include "rttydemodsink.h" + +class DownChannelizer; +class ChannelAPI; +class RttyDemod; +class ScopeVis; + +class RttyDemodBaseband : public QObject +{ + Q_OBJECT +public: + class MsgConfigureRttyDemodBaseband : public Message { + MESSAGE_CLASS_DECLARATION + + public: + const RttyDemodSettings& getSettings() const { return m_settings; } + bool getForce() const { return m_force; } + + static MsgConfigureRttyDemodBaseband* create(const RttyDemodSettings& settings, bool force) + { + return new MsgConfigureRttyDemodBaseband(settings, force); + } + + private: + RttyDemodSettings m_settings; + bool m_force; + + MsgConfigureRttyDemodBaseband(const RttyDemodSettings& settings, bool force) : + Message(), + m_settings(settings), + m_force(force) + { } + }; + + RttyDemodBaseband(RttyDemod *packetDemod); + ~RttyDemodBaseband(); + void reset(); + void startWork(); + void stopWork(); + void feed(const SampleVector::const_iterator& begin, const SampleVector::const_iterator& end); + MessageQueue *getInputMessageQueue() { return &m_inputMessageQueue; } //!< Get the queue for asynchronous inbound communication + void getMagSqLevels(double& avg, double& peak, int& nbSamples) { + m_sink.getMagSqLevels(avg, peak, nbSamples); + } + void setMessageQueueToChannel(MessageQueue *messageQueue) { m_sink.setMessageQueueToChannel(messageQueue); } + void setBasebandSampleRate(int sampleRate); + int getChannelSampleRate() const; + ScopeVis *getScopeSink() { return &m_scopeSink; } + void setChannel(ChannelAPI *channel); + double getMagSq() const { return m_sink.getMagSq(); } + bool isRunning() const { return m_running; } + void setFifoLabel(const QString& label) { m_sampleFifo.setLabel(label); } + +private: + SampleSinkFifo m_sampleFifo; + DownChannelizer *m_channelizer; + RttyDemodSink m_sink; + MessageQueue m_inputMessageQueue; //!< Queue for asynchronous inbound communication + RttyDemodSettings m_settings; + ScopeVis m_scopeSink; + bool m_running; + QRecursiveMutex m_mutex; + + bool handleMessage(const Message& cmd); + void calculateOffset(RttyDemodSink *sink); + void applySettings(const RttyDemodSettings& settings, bool force = false); + +private slots: + void handleInputMessages(); + void handleData(); //!< Handle data when samples have to be processed +}; + +#endif // INCLUDE_RTTYDEMODBASEBAND_H + diff --git a/plugins/channelrx/demodrtty/rttydemodgui.cpp b/plugins/channelrx/demodrtty/rttydemodgui.cpp new file mode 100644 index 000000000..8785f3a9d --- /dev/null +++ b/plugins/channelrx/demodrtty/rttydemodgui.cpp @@ -0,0 +1,669 @@ +/////////////////////////////////////////////////////////////////////////////////// +// Copyright (C) 2016 Edouard Griffiths, F4EXB // +// Copyright (C) 2023 Jon Beniston, M7RCE // +// // +// This program is free software; you can redistribute it and/or modify // +// it under the terms of the GNU General Public License as published by // +// the Free Software Foundation as version 3 of the License, or // +// (at your option) any later version. // +// // +// This program is distributed in the hope that it will be useful, // +// but WITHOUT ANY WARRANTY; without even the implied warranty of // +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // +// GNU General Public License V3 for more details. // +// // +// You should have received a copy of the GNU General Public License // +// along with this program. If not, see <http://www.gnu.org/licenses/>. // +/////////////////////////////////////////////////////////////////////////////////// + +#include <QDockWidget> +#include <QMainWindow> +#include <QDebug> +#include <QAction> +#include <QClipboard> +#include <QFileDialog> +#include <QScrollBar> +#include <QMenu> + +#include "rttydemodgui.h" + +#include "device/deviceuiset.h" +#include "dsp/dspengine.h" +#include "dsp/dspcommands.h" +#include "ui_rttydemodgui.h" +#include "plugin/pluginapi.h" +#include "util/simpleserializer.h" +#include "util/db.h" +#include "gui/basicchannelsettingsdialog.h" +#include "gui/devicestreamselectiondialog.h" +#include "dsp/dspengine.h" +#include "dsp/glscopesettings.h" +#include "gui/crightclickenabler.h" +#include "gui/tabletapandhold.h" +#include "gui/dialogpositioner.h" +#include "channel/channelwebapiutils.h" +#include "feature/featurewebapiutils.h" +#include "maincore.h" + +#include "rttydemod.h" +#include "rttydemodsink.h" + +RttyDemodGUI* RttyDemodGUI::create(PluginAPI* pluginAPI, DeviceUISet *deviceUISet, BasebandSampleSink *rxChannel) +{ + RttyDemodGUI* gui = new RttyDemodGUI(pluginAPI, deviceUISet, rxChannel); + return gui; +} + +void RttyDemodGUI::destroy() +{ + delete this; +} + +void RttyDemodGUI::resetToDefaults() +{ + m_settings.resetToDefaults(); + displaySettings(); + applySettings(true); +} + +QByteArray RttyDemodGUI::serialize() const +{ + return m_settings.serialize(); +} + +bool RttyDemodGUI::deserialize(const QByteArray& data) +{ + if(m_settings.deserialize(data)) { + displaySettings(); + applySettings(true); + return true; + } else { + resetToDefaults(); + return false; + } +} + +void RttyDemodGUI::characterReceived(QString c) +{ + // Is the scroll bar at the bottom? + int scrollPos = ui->text->verticalScrollBar()->value(); + bool atBottom = scrollPos >= ui->text->verticalScrollBar()->maximum(); + + // Move cursor to end where we want to append new text + // (user may have moved it by clicking / highlighting text) + ui->text->moveCursor(QTextCursor::End); + + // Restore scroll position + ui->text->verticalScrollBar()->setValue(scrollPos); + + if ((c == '\r') && (m_previousChar[1] == '\r') && m_settings.m_suppressCRLF) + { + // Don't insert yet + } + else if ((c == '\n') && (m_previousChar[0] == '\r') && (m_previousChar[1] == '\r') && m_settings.m_suppressCRLF) + { + // Change \r\r\n to \r + } + else if ((c != '\n') && (m_previousChar[0] == '\r') && (m_previousChar[1] == '\r') && m_settings.m_suppressCRLF) + { + ui->text->insertPlainText("\r"); // Insert \r we skipped + ui->text->insertPlainText(c); + } + else if (c == '\b') + { + ui->text->textCursor().deletePreviousChar(); + } + else + { + ui->text->insertPlainText(c); + } + + // Scroll to bottom, if we we're previously at the bottom + if (atBottom) { + ui->text->verticalScrollBar()->setValue(ui->text->verticalScrollBar()->maximum()); + } + + // Save last 2 previous characters + m_previousChar[0] = m_previousChar[1]; + m_previousChar[1] = c; +} + +bool RttyDemodGUI::handleMessage(const Message& message) +{ + if (RttyDemod::MsgConfigureRttyDemod::match(message)) + { + qDebug("RttyDemodGUI::handleMessage: RttyDemod::MsgConfigureRttyDemod"); + const RttyDemod::MsgConfigureRttyDemod& cfg = (RttyDemod::MsgConfigureRttyDemod&) message; + m_settings = cfg.getSettings(); + blockApplySettings(true); + ui->scopeGUI->updateSettings(); + m_channelMarker.updateSettings(static_cast<const ChannelMarker*>(m_settings.m_channelMarker)); + displaySettings(); + blockApplySettings(false); + return true; + } + else if (DSPSignalNotification::match(message)) + { + DSPSignalNotification& notif = (DSPSignalNotification&) message; + m_deviceCenterFrequency = notif.getCenterFrequency(); + m_basebandSampleRate = notif.getSampleRate(); + ui->deltaFrequency->setValueRange(false, 7, -m_basebandSampleRate/2, m_basebandSampleRate/2); + ui->deltaFrequencyLabel->setToolTip(tr("Range %1 %L2 Hz").arg(QChar(0xB1)).arg(m_basebandSampleRate/2)); + updateAbsoluteCenterFrequency(); + return true; + } + else if (RttyDemod::MsgCharacter::match(message)) + { + RttyDemod::MsgCharacter& report = (RttyDemod::MsgCharacter&) message; + QString c = report.getCharacter(); + characterReceived(c); + return true; + } + else if (RttyDemod::MsgModeEstimate::match(message)) + { + RttyDemod::MsgModeEstimate& report = (RttyDemod::MsgModeEstimate&) message; + ui->baudRate->setToolTip(QString("Baud rate (symbols per second)\n\nEstimate: %1 baud").arg(report.getBaudRate())); + ui->frequencyShift->setToolTip(QString("Frequency shift in Hz (Difference between mark and space frequency)\n\nEstimate: %1 Hz").arg(report.getFrequencyShift())); + ui->modeEst->setText(QString("%1/%2").arg(report.getBaudRate()).arg(report.getFrequencyShift())); + return true; + } + + return false; +} + +void RttyDemodGUI::handleInputMessages() +{ + Message* message; + + while ((message = getInputMessageQueue()->pop()) != 0) + { + if (handleMessage(*message)) + { + delete message; + } + } +} + +void RttyDemodGUI::channelMarkerChangedByCursor() +{ + ui->deltaFrequency->setValue(m_channelMarker.getCenterFrequency()); + m_settings.m_inputFrequencyOffset = m_channelMarker.getCenterFrequency(); + applySettings(); +} + +void RttyDemodGUI::channelMarkerHighlightedByCursor() +{ + setHighlighted(m_channelMarker.getHighlighted()); +} + +void RttyDemodGUI::on_deltaFrequency_changed(qint64 value) +{ + m_channelMarker.setCenterFrequency(value); + m_settings.m_inputFrequencyOffset = m_channelMarker.getCenterFrequency(); + updateAbsoluteCenterFrequency(); + applySettings(); +} + +void RttyDemodGUI::on_rfBW_valueChanged(int value) +{ + float bw = value; + ui->rfBWText->setText(formatFrequency((int)bw)); + m_channelMarker.setBandwidth(bw); + m_settings.m_rfBandwidth = bw; + applySettings(); +} + +void RttyDemodGUI::on_baudRate_currentIndexChanged(int index) +{ + (void) index; + m_settings.m_baudRate = ui->baudRate->currentText().toFloat(); + applySettings(); +} + +void RttyDemodGUI::on_frequencyShift_valueChanged(int value) +{ + ui->frequencyShiftText->setText(formatFrequency(value)); + m_settings.m_frequencyShift = value; + applySettings(); +} + +void RttyDemodGUI::on_squelch_valueChanged(int value) +{ + ui->squelchText->setText(QString("%1 dB").arg(value)); + m_settings.m_squelch = value; + applySettings(); +} + +void RttyDemodGUI::on_characterSet_currentIndexChanged(int index) +{ + m_settings.m_characterSet = (Baudot::CharacterSet) index; + applySettings(); +} + +void RttyDemodGUI::on_suppressCRLF_clicked(bool checked) +{ + m_settings.m_suppressCRLF = checked; + applySettings(); +} + +void RttyDemodGUI::on_mode_currentIndexChanged(int index) +{ + QString mode = ui->mode->currentText(); + + bool custom = mode == "Custom"; + if (!custom) + { + QStringList settings = mode.split("/"); + int baudRate = settings[0].toInt(); + int frequencyShift = settings[1].toInt(); + int bandwidth = frequencyShift * 2 + baudRate; + ui->baudRate->setCurrentText(settings[0]); + ui->frequencyShift->setValue(frequencyShift); + ui->rfBW->setValue(bandwidth); + } + + ui->baudRateLabel->setEnabled(custom); + ui->baudRate->setEnabled(custom); + ui->frequencyShiftLabel->setEnabled(custom); + ui->frequencyShift->setEnabled(custom); + ui->frequencyShiftText->setEnabled(custom); + ui->rfBWLabel->setEnabled(custom); + ui->rfBW->setEnabled(custom); + ui->rfBWText->setEnabled(custom); + + //m_settings.m_mode = index; + applySettings(); +} + + +void RttyDemodGUI::on_filter_currentIndexChanged(int index) +{ + m_settings.m_filter = (RttyDemodSettings::FilterType)index; + applySettings(); +} + +void RttyDemodGUI::on_atc_clicked(bool checked) +{ + m_settings.m_atc = checked; + applySettings(); +} + +void RttyDemodGUI::on_endian_clicked(bool checked) +{ + m_settings.m_msbFirst = checked; + if (checked) { + ui->endian->setText("MSB"); + } else { + ui->endian->setText("LSB"); + } + applySettings(); +} + +void RttyDemodGUI::on_spaceHigh_clicked(bool checked) +{ + m_settings.m_spaceHigh = checked; + if (checked) { + ui->spaceHigh->setText("M-S"); + } else { + ui->spaceHigh->setText("S-M"); + } + applySettings(); +} + +void RttyDemodGUI::on_clearTable_clicked() +{ + ui->text->clear(); +} + +void RttyDemodGUI::on_udpEnabled_clicked(bool checked) +{ + m_settings.m_udpEnabled = checked; + applySettings(); +} + +void RttyDemodGUI::on_udpAddress_editingFinished() +{ + m_settings.m_udpAddress = ui->udpAddress->text(); + applySettings(); +} + +void RttyDemodGUI::on_udpPort_editingFinished() +{ + m_settings.m_udpPort = ui->udpPort->text().toInt(); + applySettings(); +} + +void RttyDemodGUI::on_channel1_currentIndexChanged(int index) +{ + m_settings.m_scopeCh1 = index; + applySettings(); +} + +void RttyDemodGUI::on_channel2_currentIndexChanged(int index) +{ + m_settings.m_scopeCh2 = index; + applySettings(); +} + +void RttyDemodGUI::onWidgetRolled(QWidget* widget, bool rollDown) +{ + (void) widget; + (void) rollDown; + + getRollupContents()->saveState(m_rollupState); + applySettings(); +} + +void RttyDemodGUI::onMenuDialogCalled(const QPoint &p) +{ + if (m_contextMenuType == ContextMenuChannelSettings) + { + BasicChannelSettingsDialog dialog(&m_channelMarker, this); + dialog.setUseReverseAPI(m_settings.m_useReverseAPI); + dialog.setReverseAPIAddress(m_settings.m_reverseAPIAddress); + dialog.setReverseAPIPort(m_settings.m_reverseAPIPort); + dialog.setReverseAPIDeviceIndex(m_settings.m_reverseAPIDeviceIndex); + dialog.setReverseAPIChannelIndex(m_settings.m_reverseAPIChannelIndex); + dialog.setDefaultTitle(m_displayedName); + + if (m_deviceUISet->m_deviceMIMOEngine) + { + dialog.setNumberOfStreams(m_rttyDemod->getNumberOfDeviceStreams()); + dialog.setStreamIndex(m_settings.m_streamIndex); + } + + dialog.move(p); + new DialogPositioner(&dialog, false); + dialog.exec(); + + m_settings.m_rgbColor = m_channelMarker.getColor().rgb(); + m_settings.m_title = m_channelMarker.getTitle(); + m_settings.m_useReverseAPI = dialog.useReverseAPI(); + m_settings.m_reverseAPIAddress = dialog.getReverseAPIAddress(); + m_settings.m_reverseAPIPort = dialog.getReverseAPIPort(); + m_settings.m_reverseAPIDeviceIndex = dialog.getReverseAPIDeviceIndex(); + m_settings.m_reverseAPIChannelIndex = dialog.getReverseAPIChannelIndex(); + + setWindowTitle(m_settings.m_title); + setTitle(m_channelMarker.getTitle()); + setTitleColor(m_settings.m_rgbColor); + + if (m_deviceUISet->m_deviceMIMOEngine) + { + m_settings.m_streamIndex = dialog.getSelectedStreamIndex(); + m_channelMarker.clearStreamIndexes(); + m_channelMarker.addStreamIndex(m_settings.m_streamIndex); + updateIndexLabel(); + } + + applySettings(); + } + + resetContextMenuType(); +} + +RttyDemodGUI::RttyDemodGUI(PluginAPI* pluginAPI, DeviceUISet *deviceUISet, BasebandSampleSink *rxChannel, QWidget* parent) : + ChannelGUI(parent), + ui(new Ui::RttyDemodGUI), + m_pluginAPI(pluginAPI), + m_deviceUISet(deviceUISet), + m_channelMarker(this), + m_deviceCenterFrequency(0), + m_doApplySettings(true), + m_tickCount(0) +{ + setAttribute(Qt::WA_DeleteOnClose, true); + m_helpURL = "plugins/channelrx/demodrtty/readme.md"; + RollupContents *rollupContents = getRollupContents(); + ui->setupUi(rollupContents); + setSizePolicy(rollupContents->sizePolicy()); + rollupContents->arrangeRollups(); + connect(rollupContents, SIGNAL(widgetRolled(QWidget*,bool)), this, SLOT(onWidgetRolled(QWidget*,bool))); + connect(this, SIGNAL(customContextMenuRequested(const QPoint &)), this, SLOT(onMenuDialogCalled(const QPoint &))); + + m_rttyDemod = reinterpret_cast<RttyDemod*>(rxChannel); + m_rttyDemod->setMessageQueueToGUI(getInputMessageQueue()); + + connect(&MainCore::instance()->getMasterTimer(), SIGNAL(timeout()), this, SLOT(tick())); // 50 ms + + ui->deltaFrequencyLabel->setText(QString("%1f").arg(QChar(0x94, 0x03))); + ui->deltaFrequency->setColorMapper(ColorMapper(ColorMapper::GrayGold)); + ui->deltaFrequency->setValueRange(false, 7, -9999999, 9999999); + ui->channelPowerMeter->setColorTheme(LevelMeterSignalDB::ColorGreenAndBlue); + + m_scopeVis = m_rttyDemod->getScopeSink(); + m_scopeVis->setGLScope(ui->glScope); + ui->glScope->connectTimer(MainCore::instance()->getMasterTimer()); + ui->scopeGUI->setBuddies(m_scopeVis->getInputMessageQueue(), m_scopeVis, ui->glScope); + + // Scope settings to display the IQ waveforms + ui->scopeGUI->setPreTrigger(1); + GLScopeSettings::TraceData traceDataI, traceDataQ; + traceDataI.m_projectionType = Projector::ProjectionReal; + traceDataI.m_amp = 1.0; // for -1 to +1 + traceDataI.m_ofs = 0.0; // vertical offset + traceDataQ.m_projectionType = Projector::ProjectionImag; + traceDataQ.m_amp = 1.0; + traceDataQ.m_ofs = 0.0; + ui->scopeGUI->changeTrace(0, traceDataI); + ui->scopeGUI->addTrace(traceDataQ); + ui->scopeGUI->setDisplayMode(GLScopeSettings::DisplayXYV); + ui->scopeGUI->focusOnTrace(0); // re-focus to take changes into account in the GUI + + GLScopeSettings::TriggerData triggerData; + triggerData.m_triggerLevel = 0.1; + triggerData.m_triggerLevelCoarse = 10; + triggerData.m_triggerPositiveEdge = true; + ui->scopeGUI->changeTrigger(0, triggerData); + ui->scopeGUI->focusOnTrigger(0); // re-focus to take changes into account in the GUI + + m_scopeVis->setLiveRate(RttyDemodSettings::RTTYDEMOD_CHANNEL_SAMPLE_RATE); + m_scopeVis->configure(500, 1, 0, 0, true); // not working! + //m_scopeVis->setFreeRun(false); // FIXME: add method rather than call m_scopeVis->configure() + + m_channelMarker.blockSignals(true); + m_channelMarker.setColor(Qt::yellow); + m_channelMarker.setBandwidth(m_settings.m_rfBandwidth); + m_channelMarker.setCenterFrequency(m_settings.m_inputFrequencyOffset); + m_channelMarker.setTitle("RTTY Demodulator"); + m_channelMarker.blockSignals(false); + m_channelMarker.setVisible(true); // activate signal on the last setting only + + setTitleColor(m_channelMarker.getColor()); + m_settings.setChannelMarker(&m_channelMarker); + m_settings.setScopeGUI(ui->scopeGUI); + m_settings.setRollupState(&m_rollupState); + + m_deviceUISet->addChannelMarker(&m_channelMarker); + + connect(&m_channelMarker, SIGNAL(changedByCursor()), this, SLOT(channelMarkerChangedByCursor())); + connect(&m_channelMarker, SIGNAL(highlightedByCursor()), this, SLOT(channelMarkerHighlightedByCursor())); + connect(getInputMessageQueue(), SIGNAL(messageEnqueued()), this, SLOT(handleInputMessages())); + + ui->scopeContainer->setVisible(false); + + // Hide developer only settings + ui->filterSettingsWidget->setVisible(false); + ui->filterLine->setVisible(false); + + displaySettings(); + makeUIConnections(); + applySettings(true); +} + +RttyDemodGUI::~RttyDemodGUI() +{ + delete ui; +} + +void RttyDemodGUI::blockApplySettings(bool block) +{ + m_doApplySettings = !block; +} + +void RttyDemodGUI::applySettings(bool force) +{ + if (m_doApplySettings) + { + RttyDemod::MsgConfigureRttyDemod* message = RttyDemod::MsgConfigureRttyDemod::create( m_settings, force); + m_rttyDemod->getInputMessageQueue()->push(message); + } +} + +QString RttyDemodGUI::formatFrequency(int frequency) const +{ + QString suffix = ""; + if (width() > 450) { + suffix = " Hz"; + } + return QString("%1%2").arg(frequency).arg(suffix); +} + +void RttyDemodGUI::displaySettings() +{ + m_channelMarker.blockSignals(true); + m_channelMarker.setBandwidth(m_settings.m_rfBandwidth); + m_channelMarker.setCenterFrequency(m_settings.m_inputFrequencyOffset); + m_channelMarker.setTitle(m_settings.m_title); + m_channelMarker.blockSignals(false); + m_channelMarker.setColor(m_settings.m_rgbColor); // activate signal on the last setting only + + setTitleColor(m_settings.m_rgbColor); + setWindowTitle(m_channelMarker.getTitle()); + setTitle(m_channelMarker.getTitle()); + + blockApplySettings(true); + + ui->deltaFrequency->setValue(m_channelMarker.getCenterFrequency()); + + ui->mode->setCurrentText("Custom"); + ui->rfBWText->setText(formatFrequency((int)m_settings.m_rfBandwidth)); + ui->rfBW->setValue(m_settings.m_rfBandwidth); + QString baudRate; + if (m_settings.m_baudRate < 46.0f && m_settings.m_baudRate > 45.0f) { + baudRate = "45.45"; + } else { + baudRate = QString("%1").arg(m_settings.m_baudRate); + } + ui->baudRate->setCurrentIndex(ui->baudRate->findText(baudRate)); + ui->frequencyShiftText->setText(formatFrequency(m_settings.m_frequencyShift)); + ui->frequencyShift->setValue(m_settings.m_frequencyShift); + ui->squelchText->setText(QString("%1 dB").arg(m_settings.m_squelch)); + ui->squelch->setValue(m_settings.m_squelch); + ui->characterSet->setCurrentIndex((int)m_settings.m_characterSet); + ui->suppressCRLF->setChecked(m_settings.m_suppressCRLF); + ui->filter->setCurrentIndex((int)m_settings.m_filter); + ui->atc->setChecked(m_settings.m_atc); + ui->endian->setChecked(m_settings.m_msbFirst); + if (m_settings.m_msbFirst) { + ui->endian->setText("MSB"); + } else { + ui->endian->setText("LSB"); + } + ui->spaceHigh->setChecked(m_settings.m_spaceHigh); + if (m_settings.m_spaceHigh) { + ui->spaceHigh->setText("M-S"); + } else { + ui->spaceHigh->setText("S-M"); + } + + updateIndexLabel(); + + ui->udpEnabled->setChecked(m_settings.m_udpEnabled); + ui->udpAddress->setText(m_settings.m_udpAddress); + ui->udpPort->setText(QString::number(m_settings.m_udpPort)); + + ui->channel1->setCurrentIndex(m_settings.m_scopeCh1); + ui->channel2->setCurrentIndex(m_settings.m_scopeCh2); + + ui->logFilename->setToolTip(QString(".txt log filename: %1").arg(m_settings.m_logFilename)); + ui->logEnable->setChecked(m_settings.m_logEnabled); + + getRollupContents()->restoreState(m_rollupState); + updateAbsoluteCenterFrequency(); + blockApplySettings(false); +} + +void RttyDemodGUI::leaveEvent(QEvent* event) +{ + m_channelMarker.setHighlighted(false); + ChannelGUI::leaveEvent(event); +} + +void RttyDemodGUI::enterEvent(EnterEventType* event) +{ + m_channelMarker.setHighlighted(true); + ChannelGUI::enterEvent(event); +} + +void RttyDemodGUI::tick() +{ + double magsqAvg, magsqPeak; + int nbMagsqSamples; + m_rttyDemod->getMagSqLevels(magsqAvg, magsqPeak, nbMagsqSamples); + double powDbAvg = CalcDb::dbPower(magsqAvg); + double powDbPeak = CalcDb::dbPower(magsqPeak); + ui->channelPowerMeter->levelChanged( + (100.0f + powDbAvg) / 100.0f, + (100.0f + powDbPeak) / 100.0f, + nbMagsqSamples); + + if (m_tickCount % 4 == 0) { + ui->channelPower->setText(QString::number(powDbAvg, 'f', 1)); + } + + m_tickCount++; +} + +void RttyDemodGUI::on_logEnable_clicked(bool checked) +{ + m_settings.m_logEnabled = checked; + applySettings(); +} + +void RttyDemodGUI::on_logFilename_clicked() +{ + // Get filename to save to + QFileDialog fileDialog(nullptr, "Select file to log received text to", "", "*.txt"); + fileDialog.setAcceptMode(QFileDialog::AcceptSave); + if (fileDialog.exec()) + { + QStringList fileNames = fileDialog.selectedFiles(); + if (fileNames.size() > 0) + { + m_settings.m_logFilename = fileNames[0]; + ui->logFilename->setToolTip(QString(".txt log filename: %1").arg(m_settings.m_logFilename)); + applySettings(); + } + } +} + +void RttyDemodGUI::makeUIConnections() +{ + QObject::connect(ui->deltaFrequency, &ValueDialZ::changed, this, &RttyDemodGUI::on_deltaFrequency_changed); + QObject::connect(ui->rfBW, &QSlider::valueChanged, this, &RttyDemodGUI::on_rfBW_valueChanged); + QObject::connect(ui->baudRate, QOverload<int>::of(&QComboBox::currentIndexChanged), this, &RttyDemodGUI::on_baudRate_currentIndexChanged); + QObject::connect(ui->frequencyShift, &QSlider::valueChanged, this, &RttyDemodGUI::on_frequencyShift_valueChanged); + QObject::connect(ui->squelch, &QDial::valueChanged, this, &RttyDemodGUI::on_squelch_valueChanged); + QObject::connect(ui->characterSet, QOverload<int>::of(&QComboBox::currentIndexChanged), this, &RttyDemodGUI::on_characterSet_currentIndexChanged); + QObject::connect(ui->suppressCRLF, &ButtonSwitch::clicked, this, &RttyDemodGUI::on_suppressCRLF_clicked); + QObject::connect(ui->mode, QOverload<int>::of(&QComboBox::currentIndexChanged), this, &RttyDemodGUI::on_mode_currentIndexChanged); + QObject::connect(ui->filter, QOverload<int>::of(&QComboBox::currentIndexChanged), this, &RttyDemodGUI::on_filter_currentIndexChanged); + QObject::connect(ui->atc, &QCheckBox::clicked, this, &RttyDemodGUI::on_atc_clicked); + QObject::connect(ui->endian, &QCheckBox::clicked, this, &RttyDemodGUI::on_endian_clicked); + QObject::connect(ui->spaceHigh, &QCheckBox::clicked, this, &RttyDemodGUI::on_spaceHigh_clicked); + QObject::connect(ui->clearTable, &QPushButton::clicked, this, &RttyDemodGUI::on_clearTable_clicked); + QObject::connect(ui->udpEnabled, &QCheckBox::clicked, this, &RttyDemodGUI::on_udpEnabled_clicked); + QObject::connect(ui->udpAddress, &QLineEdit::editingFinished, this, &RttyDemodGUI::on_udpAddress_editingFinished); + QObject::connect(ui->udpPort, &QLineEdit::editingFinished, this, &RttyDemodGUI::on_udpPort_editingFinished); + QObject::connect(ui->logEnable, &ButtonSwitch::clicked, this, &RttyDemodGUI::on_logEnable_clicked); + QObject::connect(ui->logFilename, &QToolButton::clicked, this, &RttyDemodGUI::on_logFilename_clicked); + QObject::connect(ui->channel1, QOverload<int>::of(&QComboBox::currentIndexChanged), this, &RttyDemodGUI::on_channel1_currentIndexChanged); + QObject::connect(ui->channel2, QOverload<int>::of(&QComboBox::currentIndexChanged), this, &RttyDemodGUI::on_channel2_currentIndexChanged); +} + +void RttyDemodGUI::updateAbsoluteCenterFrequency() +{ + setStatusFrequency(m_deviceCenterFrequency + m_settings.m_inputFrequencyOffset); +} + diff --git a/plugins/channelrx/demodrtty/rttydemodgui.h b/plugins/channelrx/demodrtty/rttydemodgui.h new file mode 100644 index 000000000..7d5723a2e --- /dev/null +++ b/plugins/channelrx/demodrtty/rttydemodgui.h @@ -0,0 +1,130 @@ +/////////////////////////////////////////////////////////////////////////////////// +// Copyright (C) 2016 Edouard Griffiths, F4EXB // +// Copyright (C) 2023 Jon Beniston, M7RCE // +// // +// This program is free software; you can redistribute it and/or modify // +// it under the terms of the GNU General Public License as published by // +// the Free Software Foundation as version 3 of the License, or // +// (at your option) any later version. // +// // +// This program is distributed in the hope that it will be useful, // +// but WITHOUT ANY WARRANTY; without even the implied warranty of // +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // +// GNU General Public License V3 for more details. // +// // +// You should have received a copy of the GNU General Public License // +// along with this program. If not, see <http://www.gnu.org/licenses/>. // +/////////////////////////////////////////////////////////////////////////////////// + +#ifndef INCLUDE_RTTYDEMODGUI_H +#define INCLUDE_RTTYDEMODGUI_H + +#include "channel/channelgui.h" +#include "dsp/channelmarker.h" +#include "dsp/movingaverage.h" +#include "util/messagequeue.h" +#include "settings/rollupstate.h" +#include "rttydemod.h" +#include "rttydemodsettings.h" + +class PluginAPI; +class DeviceUISet; +class BasebandSampleSink; +class ScopeVis; +class RttyDemod; +class RttyDemodGUI; + +namespace Ui { + class RttyDemodGUI; +} +class RttyDemodGUI; + +class RttyDemodGUI : public ChannelGUI { + Q_OBJECT + +public: + static RttyDemodGUI* create(PluginAPI* pluginAPI, DeviceUISet *deviceUISet, BasebandSampleSink *rxChannel); + virtual void destroy(); + + void resetToDefaults(); + QByteArray serialize() const; + bool deserialize(const QByteArray& data); + virtual MessageQueue *getInputMessageQueue() { return &m_inputMessageQueue; } + virtual void setWorkspaceIndex(int index) { m_settings.m_workspaceIndex = index; }; + virtual int getWorkspaceIndex() const { return m_settings.m_workspaceIndex; }; + virtual void setGeometryBytes(const QByteArray& blob) { m_settings.m_geometryBytes = blob; }; + virtual QByteArray getGeometryBytes() const { return m_settings.m_geometryBytes; }; + virtual QString getTitle() const { return m_settings.m_title; }; + virtual QColor getTitleColor() const { return m_settings.m_rgbColor; }; + virtual void zetHidden(bool hidden) { m_settings.m_hidden = hidden; } + virtual bool getHidden() const { return m_settings.m_hidden; } + virtual ChannelMarker& getChannelMarker() { return m_channelMarker; } + virtual int getStreamIndex() const { return m_settings.m_streamIndex; } + virtual void setStreamIndex(int streamIndex) { m_settings.m_streamIndex = streamIndex; } + +public slots: + void channelMarkerChangedByCursor(); + void channelMarkerHighlightedByCursor(); + +private: + Ui::RttyDemodGUI* ui; + PluginAPI* m_pluginAPI; + DeviceUISet* m_deviceUISet; + ChannelMarker m_channelMarker; + RollupState m_rollupState; + RttyDemodSettings m_settings; + qint64 m_deviceCenterFrequency; + bool m_doApplySettings; + ScopeVis* m_scopeVis; + + RttyDemod* m_rttyDemod; + int m_basebandSampleRate; + uint32_t m_tickCount; + MessageQueue m_inputMessageQueue; + QString m_previousChar[2]; + + explicit RttyDemodGUI(PluginAPI* pluginAPI, DeviceUISet *deviceUISet, BasebandSampleSink *rxChannel, QWidget* parent = 0); + virtual ~RttyDemodGUI(); + + void blockApplySettings(bool block); + void applySettings(bool force = false); + void displaySettings(); + bool handleMessage(const Message& message); + void makeUIConnections(); + void updateAbsoluteCenterFrequency(); + qint64 getFrequency(); + QString formatFrequency(int frequency) const; + void characterReceived(QString c); + + void leaveEvent(QEvent*); + void enterEvent(EnterEventType*); + +private slots: + void on_deltaFrequency_changed(qint64 value); + void on_rfBW_valueChanged(int index); + void on_baudRate_currentIndexChanged(int index); + void on_frequencyShift_valueChanged(int value); + void on_squelch_valueChanged(int value); + void on_characterSet_currentIndexChanged(int index); + void on_suppressCRLF_clicked(bool checked=false); + void on_mode_currentIndexChanged(int index); + void on_filter_currentIndexChanged(int index); + void on_atc_clicked(bool checked); + void on_endian_clicked(bool checked); + void on_spaceHigh_clicked(bool checked); + void on_clearTable_clicked(); + void on_udpEnabled_clicked(bool checked); + void on_udpAddress_editingFinished(); + void on_udpPort_editingFinished(); + void on_logEnable_clicked(bool checked=false); + void on_logFilename_clicked(); + void on_channel1_currentIndexChanged(int index); + void on_channel2_currentIndexChanged(int index); + void onWidgetRolled(QWidget* widget, bool rollDown); + void onMenuDialogCalled(const QPoint& p); + void handleInputMessages(); + void tick(); +}; + +#endif // INCLUDE_RTTYDEMODGUI_H + diff --git a/plugins/channelrx/demodrtty/rttydemodgui.ui b/plugins/channelrx/demodrtty/rttydemodgui.ui new file mode 100644 index 000000000..f94ac1453 --- /dev/null +++ b/plugins/channelrx/demodrtty/rttydemodgui.ui @@ -0,0 +1,1323 @@ +<?xml version="1.0" encoding="UTF-8"?> +<ui version="4.0"> + <class>RttyDemodGUI</class> + <widget class="RollupContents" name="RttyDemodGUI"> + <property name="geometry"> + <rect> + <x>0</x> + <y>0</y> + <width>411</width> + <height>814</height> + </rect> + </property> + <property name="sizePolicy"> + <sizepolicy hsizetype="Expanding" vsizetype="Expanding"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="minimumSize"> + <size> + <width>352</width> + <height>0</height> + </size> + </property> + <property name="font"> + <font> + <family>Liberation Sans</family> + <pointsize>9</pointsize> + </font> + </property> + <property name="focusPolicy"> + <enum>Qt::StrongFocus</enum> + </property> + <property name="windowTitle"> + <string>Packet Demodulator</string> + </property> + <widget class="QWidget" name="settingsContainer" native="true"> + <property name="geometry"> + <rect> + <x>0</x> + <y>0</y> + <width>390</width> + <height>181</height> + </rect> + </property> + <property name="minimumSize"> + <size> + <width>350</width> + <height>0</height> + </size> + </property> + <property name="windowTitle"> + <string>Settings</string> + </property> + <layout class="QVBoxLayout" name="verticalLayout"> + <property name="spacing"> + <number>3</number> + </property> + <property name="leftMargin"> + <number>2</number> + </property> + <property name="topMargin"> + <number>2</number> + </property> + <property name="rightMargin"> + <number>2</number> + </property> + <property name="bottomMargin"> + <number>2</number> + </property> + <item> + <layout class="QHBoxLayout" name="powLayout"> + <property name="topMargin"> + <number>2</number> + </property> + <item> + <widget class="QLabel" name="deltaFrequencyLabel"> + <property name="minimumSize"> + <size> + <width>16</width> + <height>0</height> + </size> + </property> + <property name="text"> + <string>Df</string> + </property> + </widget> + </item> + <item> + <widget class="ValueDialZ" name="deltaFrequency" native="true"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Maximum" vsizetype="Maximum"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="minimumSize"> + <size> + <width>32</width> + <height>16</height> + </size> + </property> + <property name="font"> + <font> + <family>Liberation Mono</family> + <pointsize>12</pointsize> + </font> + </property> + <property name="cursor"> + <cursorShape>PointingHandCursor</cursorShape> + </property> + <property name="focusPolicy"> + <enum>Qt::StrongFocus</enum> + </property> + <property name="toolTip"> + <string>Demod shift frequency from center in Hz</string> + </property> + </widget> + </item> + <item> + <widget class="QLabel" name="deltaUnits"> + <property name="text"> + <string>Hz </string> + </property> + </widget> + </item> + <item> + <widget class="Line" name="line"> + <property name="orientation"> + <enum>Qt::Vertical</enum> + </property> + </widget> + </item> + <item> + <spacer name="horizontalSpacer"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>40</width> + <height>20</height> + </size> + </property> + </spacer> + </item> + <item> + <layout class="QHBoxLayout" name="channelPowerLayout"> + <item> + <widget class="QLabel" name="channelPower"> + <property name="toolTip"> + <string>Channel power</string> + </property> + <property name="layoutDirection"> + <enum>Qt::RightToLeft</enum> + </property> + <property name="text"> + <string>0.0</string> + </property> + </widget> + </item> + <item> + <widget class="QLabel" name="channelPowerUnits"> + <property name="text"> + <string> dB</string> + </property> + </widget> + </item> + </layout> + </item> + </layout> + </item> + <item> + <layout class="QHBoxLayout" name="powerLayout"> + <item> + <widget class="QLabel" name="channelPowerMeterUnits"> + <property name="text"> + <string>dB</string> + </property> + </widget> + </item> + <item> + <widget class="LevelMeterSignalDB" name="channelPowerMeter" native="true"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Expanding" vsizetype="MinimumExpanding"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="minimumSize"> + <size> + <width>0</width> + <height>24</height> + </size> + </property> + <property name="font"> + <font> + <family>Liberation Mono</family> + <pointsize>8</pointsize> + </font> + </property> + <property name="toolTip"> + <string>Level meter (dB) top trace: average, bottom trace: instantaneous peak, tip: peak hold</string> + </property> + </widget> + </item> + </layout> + </item> + <item> + <widget class="Line" name="line_5"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + </widget> + </item> + <item> + <layout class="QHBoxLayout" name="phySettingsLayout"> + <item> + <widget class="QComboBox" name="mode"> + <property name="minimumSize"> + <size> + <width>86</width> + <height>0</height> + </size> + </property> + <property name="toolTip"> + <string>RTTY baud rate and frequency shift</string> + </property> + <item> + <property name="text"> + <string>45.45/170</string> + </property> + </item> + <item> + <property name="text"> + <string>50/170</string> + </property> + </item> + <item> + <property name="text"> + <string>50/450</string> + </property> + </item> + <item> + <property name="text"> + <string>75/170</string> + </property> + </item> + <item> + <property name="text"> + <string>75/850</string> + </property> + </item> + <item> + <property name="text"> + <string>Custom</string> + </property> + </item> + </widget> + </item> + <item> + <widget class="Line" name="baudRateLine"> + <property name="orientation"> + <enum>Qt::Vertical</enum> + </property> + </widget> + </item> + <item> + <widget class="QLabel" name="baudRateLabel"> + <property name="text"> + <string>Baud</string> + </property> + </widget> + </item> + <item> + <widget class="QComboBox" name="baudRate"> + <property name="minimumSize"> + <size> + <width>60</width> + <height>0</height> + </size> + </property> + <property name="toolTip"> + <string>Baud rate in symbols per second</string> + </property> + <property name="currentIndex"> + <number>1</number> + </property> + <item> + <property name="text"> + <string>45</string> + </property> + </item> + <item> + <property name="text"> + <string>45.45</string> + </property> + </item> + <item> + <property name="text"> + <string>50</string> + </property> + </item> + <item> + <property name="text"> + <string>75</string> + </property> + </item> + <item> + <property name="text"> + <string>100</string> + </property> + </item> + <item> + <property name="text"> + <string>110</string> + </property> + </item> + <item> + <property name="text"> + <string>150</string> + </property> + </item> + <item> + <property name="text"> + <string>200</string> + </property> + </item> + </widget> + </item> + <item> + <widget class="Line" name="frequencyShiftLine"> + <property name="orientation"> + <enum>Qt::Vertical</enum> + </property> + </widget> + </item> + <item> + <widget class="QLabel" name="frequencyShiftLabel"> + <property name="text"> + <string>Shift</string> + </property> + </widget> + </item> + <item> + <widget class="QSlider" name="frequencyShift"> + <property name="toolTip"> + <string>Frequency shift in Hz (Difference between mark and space frequency)</string> + </property> + <property name="minimum"> + <number>10</number> + </property> + <property name="maximum"> + <number>1000</number> + </property> + <property name="pageStep"> + <number>1</number> + </property> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + </widget> + </item> + <item> + <widget class="QLabel" name="frequencyShiftText"> + <property name="text"> + <string>850Hz</string> + </property> + </widget> + </item> + <item> + <widget class="Line" name="rfBWLine"> + <property name="orientation"> + <enum>Qt::Vertical</enum> + </property> + </widget> + </item> + <item> + <widget class="QLabel" name="rfBWLabel"> + <property name="text"> + <string>BW</string> + </property> + </widget> + </item> + <item> + <widget class="QSlider" name="rfBW"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Expanding" vsizetype="Minimum"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="minimumSize"> + <size> + <width>0</width> + <height>0</height> + </size> + </property> + <property name="toolTip"> + <string>RF bandwidth</string> + </property> + <property name="minimum"> + <number>100</number> + </property> + <property name="maximum"> + <number>2000</number> + </property> + <property name="pageStep"> + <number>1</number> + </property> + <property name="value"> + <number>250</number> + </property> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + </widget> + </item> + <item> + <widget class="QLabel" name="rfBWText"> + <property name="minimumSize"> + <size> + <width>40</width> + <height>0</height> + </size> + </property> + <property name="text"> + <string>500Hz</string> + </property> + <property name="alignment"> + <set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set> + </property> + </widget> + </item> + </layout> + </item> + <item> + <widget class="Line" name="filterLine"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + </widget> + </item> + <item> + <widget class="QWidget" name="filterSettingsWidget" native="true"> + <layout class="QHBoxLayout" name="filterSettings"> + <item> + <widget class="QLabel" name="filterLabel"> + <property name="text"> + <string>Filter</string> + </property> + </widget> + </item> + <item> + <widget class="QComboBox" name="filter"> + <item> + <property name="text"> + <string>LPF</string> + </property> + </item> + <item> + <property name="text"> + <string>Raised Cosine b=1</string> + </property> + </item> + <item> + <property name="text"> + <string>Raised Cosine b=0.75</string> + </property> + </item> + <item> + <property name="text"> + <string>Raised Cosine b=0.5</string> + </property> + </item> + <item> + <property name="text"> + <string>Rasied Cosine b=1 BW=0.75</string> + </property> + </item> + <item> + <property name="text"> + <string>Raised Cosine b=1 BW=1.25</string> + </property> + </item> + <item> + <property name="text"> + <string>MAV</string> + </property> + </item> + <item> + <property name="text"> + <string>Filtered MAV</string> + </property> + </item> + </widget> + </item> + <item> + <widget class="ButtonSwitch" name="atc"> + <property name="maximumSize"> + <size> + <width>24</width> + <height>16777215</height> + </size> + </property> + <property name="toolTip"> + <string>Automatic threshold correction</string> + </property> + <property name="text"> + <string>ATC</string> + </property> + </widget> + </item> + <item> + <spacer name="horizontalSpacer_2"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>40</width> + <height>20</height> + </size> + </property> + </spacer> + </item> + <item> + <widget class="QLabel" name="modeEstLabel"> + <property name="text"> + <string>Est.</string> + </property> + </widget> + </item> + <item> + <widget class="QLabel" name="modeEst"> + <property name="toolTip"> + <string>Estimated baud rate and frequency shift</string> + </property> + <property name="text"> + <string>50/170</string> + </property> + </widget> + </item> + </layout> + </widget> + </item> + <item> + <widget class="Line" name="line_2"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + </widget> + </item> + <item> + <layout class="QHBoxLayout" name="udpLayout"> + <item> + <widget class="QCheckBox" name="udpEnabled"> + <property name="toolTip"> + <string>Send messages via UDP</string> + </property> + <property name="layoutDirection"> + <enum>Qt::RightToLeft</enum> + </property> + <property name="text"> + <string>UDP</string> + </property> + </widget> + </item> + <item> + <widget class="QLineEdit" name="udpAddress"> + <property name="minimumSize"> + <size> + <width>120</width> + <height>0</height> + </size> + </property> + <property name="focusPolicy"> + <enum>Qt::ClickFocus</enum> + </property> + <property name="toolTip"> + <string>Destination UDP address</string> + </property> + <property name="inputMask"> + <string>000.000.000.000</string> + </property> + <property name="text"> + <string>127.0.0.1</string> + </property> + </widget> + </item> + <item> + <widget class="QLabel" name="udpSeparator"> + <property name="text"> + <string>:</string> + </property> + <property name="alignment"> + <set>Qt::AlignCenter</set> + </property> + </widget> + </item> + <item> + <widget class="QLineEdit" name="udpPort"> + <property name="minimumSize"> + <size> + <width>50</width> + <height>0</height> + </size> + </property> + <property name="maximumSize"> + <size> + <width>50</width> + <height>16777215</height> + </size> + </property> + <property name="focusPolicy"> + <enum>Qt::ClickFocus</enum> + </property> + <property name="toolTip"> + <string>Destination UDP port</string> + </property> + <property name="inputMask"> + <string>00000</string> + </property> + <property name="text"> + <string>9998</string> + </property> + </widget> + </item> + <item> + <spacer name="udpSpacer"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>40</width> + <height>20</height> + </size> + </property> + </spacer> + </item> + <item> + <widget class="Line" name="squelchLine"> + <property name="orientation"> + <enum>Qt::Vertical</enum> + </property> + </widget> + </item> + <item> + <widget class="QLabel" name="squelchLabel"> + <property name="text"> + <string>Squelch</string> + </property> + </widget> + </item> + <item> + <widget class="QDial" name="squelch"> + <property name="maximumSize"> + <size> + <width>24</width> + <height>24</height> + </size> + </property> + <property name="toolTip"> + <string>Squelch. Characters received with average power below this setting will be discarded.</string> + </property> + <property name="minimum"> + <number>-120</number> + </property> + <property name="maximum"> + <number>0</number> + </property> + </widget> + </item> + <item> + <widget class="QLabel" name="squelchText"> + <property name="minimumSize"> + <size> + <width>46</width> + <height>0</height> + </size> + </property> + <property name="text"> + <string>-100 dB</string> + </property> + <property name="alignment"> + <set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set> + </property> + </widget> + </item> + </layout> + </item> + <item> + <widget class="Line" name="line_3"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + </widget> + </item> + <item> + <layout class="QHBoxLayout" name="toolbarLayout"> + <item> + <widget class="QLabel" name="characterSetLabel"> + <property name="text"> + <string>Baudot</string> + </property> + </widget> + </item> + <item> + <widget class="QComboBox" name="characterSet"> + <property name="minimumSize"> + <size> + <width>80</width> + <height>0</height> + </size> + </property> + <property name="toolTip"> + <string>Baudot character set</string> + </property> + <property name="currentIndex"> + <number>0</number> + </property> + <item> + <property name="text"> + <string>ITA 2</string> + </property> + </item> + <item> + <property name="text"> + <string>UK</string> + </property> + </item> + <item> + <property name="text"> + <string>European</string> + </property> + </item> + <item> + <property name="text"> + <string>US</string> + </property> + </item> + <item> + <property name="text"> + <string>Russian</string> + </property> + </item> + <item> + <property name="text"> + <string>Murray</string> + </property> + </item> + </widget> + </item> + <item> + <widget class="ButtonSwitch" name="endian"> + <property name="minimumSize"> + <size> + <width>30</width> + <height>0</height> + </size> + </property> + <property name="maximumSize"> + <size> + <width>24</width> + <height>16777215</height> + </size> + </property> + <property name="toolTip"> + <string>Whether LSB (Least significant bit) or MSB (Most significant bit) is transmitted first</string> + </property> + <property name="text"> + <string>LSB</string> + </property> + </widget> + </item> + <item> + <widget class="ButtonSwitch" name="spaceHigh"> + <property name="minimumSize"> + <size> + <width>30</width> + <height>0</height> + </size> + </property> + <property name="maximumSize"> + <size> + <width>24</width> + <height>16777215</height> + </size> + </property> + <property name="toolTip"> + <string>Whether mark is high frequency (unchecked) or low frequency (checked)</string> + </property> + <property name="text"> + <string>S-M</string> + </property> + </widget> + </item> + <item> + <widget class="ButtonSwitch" name="suppressCRLF"> + <property name="maximumSize"> + <size> + <width>24</width> + <height>16777215</height> + </size> + </property> + <property name="toolTip"> + <string>When checked the CR CR LF sequence is just displayed as CR</string> + </property> + <property name="text"> + <string>CR</string> + </property> + </widget> + </item> + <item> + <widget class="ButtonSwitch" name="unshiftOnSpace"> + <property name="maximumSize"> + <size> + <width>24</width> + <height>16777215</height> + </size> + </property> + <property name="toolTip"> + <string>Unshift on space - Set character set to letter when a space character is received</string> + </property> + <property name="text"> + <string>US</string> + </property> + </widget> + </item> + <item> + <spacer name="toolbarSpacer"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>40</width> + <height>20</height> + </size> + </property> + </spacer> + </item> + <item> + <widget class="ButtonSwitch" name="logEnable"> + <property name="maximumSize"> + <size> + <width>24</width> + <height>16777215</height> + </size> + </property> + <property name="toolTip"> + <string>Start/stop logging of received characters to .txt file</string> + </property> + <property name="text"> + <string/> + </property> + <property name="icon"> + <iconset resource="../../../sdrgui/resources/res.qrc"> + <normaloff>:/record_off.png</normaloff>:/record_off.png</iconset> + </property> + </widget> + </item> + <item> + <widget class="QToolButton" name="logFilename"> + <property name="toolTip"> + <string>Set log .csv filename</string> + </property> + <property name="text"> + <string>...</string> + </property> + <property name="icon"> + <iconset resource="../../../sdrgui/resources/res.qrc"> + <normaloff>:/save.png</normaloff>:/save.png</iconset> + </property> + <property name="checkable"> + <bool>false</bool> + </property> + </widget> + </item> + <item> + <widget class="QPushButton" name="clearTable"> + <property name="toolTip"> + <string>Clear messages</string> + </property> + <property name="text"> + <string/> + </property> + <property name="icon"> + <iconset resource="../../../sdrgui/resources/res.qrc"> + <normaloff>:/bin.png</normaloff>:/bin.png</iconset> + </property> + </widget> + </item> + </layout> + </item> + </layout> + </widget> + <widget class="QWidget" name="dataContainer" native="true"> + <property name="geometry"> + <rect> + <x>0</x> + <y>190</y> + <width>391</width> + <height>251</height> + </rect> + </property> + <property name="sizePolicy"> + <sizepolicy hsizetype="Expanding" vsizetype="Expanding"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="windowTitle"> + <string>Received Messages</string> + </property> + <layout class="QVBoxLayout" name="verticalLayout_2"> + <item> + <widget class="QPlainTextEdit" name="text"> + <property name="toolTip"> + <string>Received text</string> + </property> + <property name="readOnly"> + <bool>true</bool> + </property> + </widget> + </item> + </layout> + </widget> + <widget class="QWidget" name="scopeContainer" native="true"> + <property name="geometry"> + <rect> + <x>0</x> + <y>440</y> + <width>716</width> + <height>341</height> + </rect> + </property> + <property name="minimumSize"> + <size> + <width>714</width> + <height>0</height> + </size> + </property> + <property name="windowTitle"> + <string>Waveforms</string> + </property> + <layout class="QVBoxLayout" name="transmittedLayout_2"> + <property name="spacing"> + <number>2</number> + </property> + <property name="leftMargin"> + <number>3</number> + </property> + <property name="topMargin"> + <number>3</number> + </property> + <property name="rightMargin"> + <number>3</number> + </property> + <property name="bottomMargin"> + <number>3</number> + </property> + <item> + <layout class="QHBoxLayout" name="scopelLayout"> + <item> + <widget class="QLabel" name="channel1Label"> + <property name="text"> + <string>Real</string> + </property> + </widget> + </item> + <item> + <widget class="QComboBox" name="channel1"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Expanding" vsizetype="Fixed"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <item> + <property name="text"> + <string>I</string> + </property> + </item> + <item> + <property name="text"> + <string>Q</string> + </property> + </item> + <item> + <property name="text"> + <string>Mag Sq</string> + </property> + </item> + <item> + <property name="text"> + <string>Sample Idx</string> + </property> + </item> + <item> + <property name="text"> + <string>abs(Sum1)</string> + </property> + </item> + <item> + <property name="text"> + <string>abs(Sum2)</string> + </property> + </item> + <item> + <property name="text"> + <string>Bit</string> + </property> + </item> + <item> + <property name="text"> + <string>Bit Cnt</string> + </property> + </item> + <item> + <property name="text"> + <string>Got SOP</string> + </property> + </item> + <item> + <property name="text"> + <string>Real(exp)</string> + </property> + </item> + <item> + <property name="text"> + <string>Imag(exp)</string> + </property> + </item> + <item> + <property name="text"> + <string>abs(sum1)Filt</string> + </property> + </item> + <item> + <property name="text"> + <string>abs(sum2)Filt</string> + </property> + </item> + <item> + <property name="text"> + <string>Diff</string> + </property> + </item> + <item> + <property name="text"> + <string>DiffFilt</string> + </property> + </item> + <item> + <property name="text"> + <string>data</string> + </property> + </item> + <item> + <property name="text"> + <string>clock</string> + </property> + </item> + <item> + <property name="text"> + <string>Env1</string> + </property> + </item> + <item> + <property name="text"> + <string>Env2</string> + </property> + </item> + <item> + <property name="text"> + <string>Bias1</string> + </property> + </item> + <item> + <property name="text"> + <string>Bias2</string> + </property> + </item> + <item> + <property name="text"> + <string>Unbiased data</string> + </property> + </item> + <item> + <property name="text"> + <string>Biased data</string> + </property> + </item> + </widget> + </item> + <item> + <widget class="QLabel" name="channel2Label"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Preferred" vsizetype="Preferred"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="text"> + <string>Imag</string> + </property> + </widget> + </item> + <item> + <widget class="QComboBox" name="channel2"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Expanding" vsizetype="Fixed"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <item> + <property name="text"> + <string>I</string> + </property> + </item> + <item> + <property name="text"> + <string>Q</string> + </property> + </item> + <item> + <property name="text"> + <string>Mag Sq</string> + </property> + </item> + <item> + <property name="text"> + <string>Sample Idx</string> + </property> + </item> + <item> + <property name="text"> + <string>abs(Sum1)</string> + </property> + </item> + <item> + <property name="text"> + <string>abs(Sum2)</string> + </property> + </item> + <item> + <property name="text"> + <string>Bit</string> + </property> + </item> + <item> + <property name="text"> + <string>Bit Cnt</string> + </property> + </item> + <item> + <property name="text"> + <string>Got SOP</string> + </property> + </item> + <item> + <property name="text"> + <string>Real(exp)</string> + </property> + </item> + <item> + <property name="text"> + <string>imag(exp)</string> + </property> + </item> + <item> + <property name="text"> + <string>abs(sum1)Filt</string> + </property> + </item> + <item> + <property name="text"> + <string>abs(sum2)Filt</string> + </property> + </item> + <item> + <property name="text"> + <string>Diff</string> + </property> + </item> + <item> + <property name="text"> + <string>DiffFilt</string> + </property> + </item> + <item> + <property name="text"> + <string>data</string> + </property> + </item> + <item> + <property name="text"> + <string>clock</string> + </property> + </item> + <item> + <property name="text"> + <string>Env1</string> + </property> + </item> + <item> + <property name="text"> + <string>Env2</string> + </property> + </item> + <item> + <property name="text"> + <string>Bias1</string> + </property> + </item> + <item> + <property name="text"> + <string>Bias2</string> + </property> + </item> + <item> + <property name="text"> + <string>Unbiased data</string> + </property> + </item> + <item> + <property name="text"> + <string>Biased data</string> + </property> + </item> + </widget> + </item> + </layout> + </item> + <item> + <widget class="GLScope" name="glScope" native="true"> + <property name="minimumSize"> + <size> + <width>200</width> + <height>250</height> + </size> + </property> + <property name="font"> + <font> + <family>Liberation Mono</family> + <pointsize>8</pointsize> + </font> + </property> + </widget> + </item> + <item> + <widget class="GLScopeGUI" name="scopeGUI" native="true"/> + </item> + </layout> + </widget> + </widget> + <customwidgets> + <customwidget> + <class>ButtonSwitch</class> + <extends>QToolButton</extends> + <header>gui/buttonswitch.h</header> + </customwidget> + <customwidget> + <class>RollupContents</class> + <extends>QWidget</extends> + <header>gui/rollupcontents.h</header> + <container>1</container> + </customwidget> + <customwidget> + <class>ValueDialZ</class> + <extends>QWidget</extends> + <header>gui/valuedialz.h</header> + <container>1</container> + </customwidget> + <customwidget> + <class>LevelMeterSignalDB</class> + <extends>QWidget</extends> + <header>gui/levelmeter.h</header> + <container>1</container> + </customwidget> + <customwidget> + <class>GLScope</class> + <extends>QWidget</extends> + <header>gui/glscope.h</header> + <container>1</container> + </customwidget> + <customwidget> + <class>GLScopeGUI</class> + <extends>QWidget</extends> + <header>gui/glscopegui.h</header> + <container>1</container> + </customwidget> + </customwidgets> + <tabstops> + <tabstop>deltaFrequency</tabstop> + <tabstop>mode</tabstop> + <tabstop>baudRate</tabstop> + <tabstop>frequencyShift</tabstop> + <tabstop>rfBW</tabstop> + <tabstop>filter</tabstop> + <tabstop>atc</tabstop> + <tabstop>udpEnabled</tabstop> + <tabstop>squelch</tabstop> + <tabstop>characterSet</tabstop> + <tabstop>endian</tabstop> + <tabstop>spaceHigh</tabstop> + <tabstop>suppressCRLF</tabstop> + <tabstop>unshiftOnSpace</tabstop> + <tabstop>logEnable</tabstop> + <tabstop>logFilename</tabstop> + <tabstop>clearTable</tabstop> + <tabstop>text</tabstop> + <tabstop>channel1</tabstop> + <tabstop>channel2</tabstop> + </tabstops> + <resources> + <include location="../../../sdrgui/resources/res.qrc"/> + </resources> + <connections/> +</ui> diff --git a/plugins/channelrx/demodrtty/rttydemodplugin.cpp b/plugins/channelrx/demodrtty/rttydemodplugin.cpp new file mode 100644 index 000000000..dcfb9e4d5 --- /dev/null +++ b/plugins/channelrx/demodrtty/rttydemodplugin.cpp @@ -0,0 +1,93 @@ +/////////////////////////////////////////////////////////////////////////////////// +// Copyright (C) 2016 Edouard Griffiths, F4EXB // +// Copyright (C) 2023 Jon Beniston, M7RCE // +// // +// This program is free software; you can redistribute it and/or modify // +// it under the terms of the GNU General Public License as published by // +// the Free Software Foundation as version 3 of the License, or // +// (at your option) any later version. // +// // +// This program is distributed in the hope that it will be useful, // +// but WITHOUT ANY WARRANTY; without even the implied warranty of // +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // +// GNU General Public License V3 for more details. // +// // +// You should have received a copy of the GNU General Public License // +// along with this program. If not, see <http://www.gnu.org/licenses/>. // +/////////////////////////////////////////////////////////////////////////////////// + +#include <QtPlugin> +#include "plugin/pluginapi.h" + +#ifndef SERVER_MODE +#include "rttydemodgui.h" +#endif +#include "rttydemod.h" +#include "rttydemodwebapiadapter.h" +#include "rttydemodplugin.h" + +const PluginDescriptor RttyDemodPlugin::m_pluginDescriptor = { + RttyDemod::m_channelId, + QStringLiteral("RTTY Demodulator"), + QStringLiteral("7.11.0"), + QStringLiteral("(c) Jon Beniston, M7RCE"), + QStringLiteral("https://github.com/f4exb/sdrangel"), + true, + QStringLiteral("https://github.com/f4exb/sdrangel") +}; + +RttyDemodPlugin::RttyDemodPlugin(QObject* parent) : + QObject(parent), + m_pluginAPI(0) +{ +} + +const PluginDescriptor& RttyDemodPlugin::getPluginDescriptor() const +{ + return m_pluginDescriptor; +} + +void RttyDemodPlugin::initPlugin(PluginAPI* pluginAPI) +{ + m_pluginAPI = pluginAPI; + + m_pluginAPI->registerRxChannel(RttyDemod::m_channelIdURI, RttyDemod::m_channelId, this); +} + +void RttyDemodPlugin::createRxChannel(DeviceAPI *deviceAPI, BasebandSampleSink **bs, ChannelAPI **cs) const +{ + if (bs || cs) + { + RttyDemod *instance = new RttyDemod(deviceAPI); + + if (bs) { + *bs = instance; + } + + if (cs) { + *cs = instance; + } + } +} + +#ifdef SERVER_MODE +ChannelGUI* RttyDemodPlugin::createRxChannelGUI( + DeviceUISet *deviceUISet, + BasebandSampleSink *rxChannel) const +{ + (void) deviceUISet; + (void) rxChannel; + return 0; +} +#else +ChannelGUI* RttyDemodPlugin::createRxChannelGUI(DeviceUISet *deviceUISet, BasebandSampleSink *rxChannel) const +{ + return RttyDemodGUI::create(m_pluginAPI, deviceUISet, rxChannel); +} +#endif + +ChannelWebAPIAdapter* RttyDemodPlugin::createChannelWebAPIAdapter() const +{ + return new RttyDemodWebAPIAdapter(); +} + diff --git a/plugins/channelrx/demodrtty/rttydemodplugin.h b/plugins/channelrx/demodrtty/rttydemodplugin.h new file mode 100644 index 000000000..dd61061cb --- /dev/null +++ b/plugins/channelrx/demodrtty/rttydemodplugin.h @@ -0,0 +1,50 @@ +/////////////////////////////////////////////////////////////////////////////////// +// Copyright (C) 2016 Edouard Griffiths, F4EXB // +// Copyright (C) 2023 Jon Beniston, M7RCE // +// // +// This program is free software; you can redistribute it and/or modify // +// it under the terms of the GNU General Public License as published by // +// the Free Software Foundation as version 3 of the License, or // +// (at your option) any later version. // +// // +// This program is distributed in the hope that it will be useful, // +// but WITHOUT ANY WARRANTY; without even the implied warranty of // +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // +// GNU General Public License V3 for more details. // +// // +// You should have received a copy of the GNU General Public License // +// along with this program. If not, see <http://www.gnu.org/licenses/>. // +/////////////////////////////////////////////////////////////////////////////////// + +#ifndef INCLUDE_RTTYDEMODPLUGIN_H +#define INCLUDE_RTTYDEMODPLUGIN_H + +#include <QObject> +#include "plugin/plugininterface.h" + +class DeviceUISet; +class BasebandSampleSink; + +class RttyDemodPlugin : public QObject, PluginInterface { + Q_OBJECT + Q_INTERFACES(PluginInterface) + Q_PLUGIN_METADATA(IID "sdrangel.channel.rttydemod") + +public: + explicit RttyDemodPlugin(QObject* parent = NULL); + + const PluginDescriptor& getPluginDescriptor() const; + void initPlugin(PluginAPI* pluginAPI); + + virtual void createRxChannel(DeviceAPI *deviceAPI, BasebandSampleSink **bs, ChannelAPI **cs) const; + virtual ChannelGUI* createRxChannelGUI(DeviceUISet *deviceUISet, BasebandSampleSink *rxChannel) const; + virtual ChannelWebAPIAdapter* createChannelWebAPIAdapter() const; + +private: + static const PluginDescriptor m_pluginDescriptor; + + PluginAPI* m_pluginAPI; +}; + +#endif // INCLUDE_RTTYDEMODPLUGIN_H + diff --git a/plugins/channelrx/demodrtty/rttydemodsettings.cpp b/plugins/channelrx/demodrtty/rttydemodsettings.cpp new file mode 100644 index 000000000..ab55bfeef --- /dev/null +++ b/plugins/channelrx/demodrtty/rttydemodsettings.cpp @@ -0,0 +1,213 @@ +/////////////////////////////////////////////////////////////////////////////////// +// Copyright (C) 2015 Edouard Griffiths, F4EXB. // +// Copyright (C) 2023 Jon Beniston, M7RCE // +// // +// This program is free software; you can redistribute it and/or modify // +// it under the terms of the GNU General Public License as published by // +// the Free Software Foundation as version 3 of the License, or // +// (at your option) any later version. // +// // +// This program is distributed in the hope that it will be useful, // +// but WITHOUT ANY WARRANTY; without even the implied warranty of // +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // +// GNU General Public License V3 for more details. // +// // +// You should have received a copy of the GNU General Public License // +// along with this program. If not, see <http://www.gnu.org/licenses/>. // +/////////////////////////////////////////////////////////////////////////////////// + +#include <QColor> + +#include "dsp/dspengine.h" +#include "util/simpleserializer.h" +#include "settings/serializable.h" +#include "rttydemodsettings.h" + +RttyDemodSettings::RttyDemodSettings() : + m_channelMarker(nullptr), + m_scopeGUI(nullptr), + m_rollupState(nullptr) +{ + resetToDefaults(); +} + +void RttyDemodSettings::resetToDefaults() +{ + m_inputFrequencyOffset = 0; + m_rfBandwidth = 400.0f; // OBW for 2FSK = 2 * deviation + data rate. Then add a bit for carrier frequency offset + m_baudRate = 45.45; + m_frequencyShift = 170; + m_udpEnabled = false; + m_udpAddress = "127.0.0.1"; + m_udpPort = 9999; + m_characterSet = Baudot::ITA2; + m_suppressCRLF = false; + m_filter = LOWPASS; + m_atc = true; + m_msbFirst = false; + m_spaceHigh = false; + m_squelch = -70; + m_logFilename = "rtty_log.csv"; + m_logEnabled = false; + m_scopeCh1 = 0; + m_scopeCh2 = 1; + + m_rgbColor = QColor(180, 205, 130).rgb(); + m_title = "RTTY Demodulator"; + m_streamIndex = 0; + m_useReverseAPI = false; + m_reverseAPIAddress = "127.0.0.1"; + m_reverseAPIPort = 8888; + m_reverseAPIDeviceIndex = 0; + m_reverseAPIChannelIndex = 0; + m_workspaceIndex = 0; + m_hidden = false; +} + +QByteArray RttyDemodSettings::serialize() const +{ + SimpleSerializer s(1); + s.writeS32(1, m_inputFrequencyOffset); + s.writeS32(2, m_streamIndex); + + s.writeFloat(3, m_rfBandwidth); + s.writeFloat(4, m_baudRate); + s.writeS32(5, m_frequencyShift); + s.writeS32(6, (int)m_characterSet); + s.writeBool(7, m_suppressCRLF); + s.writeBool(8, m_unshiftOnSpace); + s.writeS32(9, (int)m_filter); + s.writeBool(10, m_atc); + s.writeBool(34, m_msbFirst); + s.writeBool(35, m_spaceHigh); + s.writeS32(36, m_squelch); + + if (m_channelMarker) { + s.writeBlob(11, m_channelMarker->serialize()); + } + + s.writeU32(12, m_rgbColor); + s.writeString(13, m_title); + s.writeBool(14, m_useReverseAPI); + s.writeString(15, m_reverseAPIAddress); + s.writeU32(16, m_reverseAPIPort); + s.writeU32(17, m_reverseAPIDeviceIndex); + s.writeU32(18, m_reverseAPIChannelIndex); + + s.writeBool(22, m_udpEnabled); + s.writeString(23, m_udpAddress); + s.writeU32(24, m_udpPort); + + s.writeS32(31, m_scopeCh1); + s.writeS32(32, m_scopeCh2); + s.writeBlob(33, m_scopeGUI->serialize()); + + s.writeString(25, m_logFilename); + s.writeBool(26, m_logEnabled); + + if (m_rollupState) { + s.writeBlob(27, m_rollupState->serialize()); + } + + s.writeS32(28, m_workspaceIndex); + s.writeBlob(29, m_geometryBytes); + s.writeBool(30, m_hidden); + + return s.final(); +} + +bool RttyDemodSettings::deserialize(const QByteArray& data) +{ + SimpleDeserializer d(data); + + if(!d.isValid()) + { + resetToDefaults(); + return false; + } + + if(d.getVersion() == 1) + { + QByteArray bytetmp; + uint32_t utmp; + QString strtmp; + + d.readS32(1, &m_inputFrequencyOffset, 0); + d.readS32(2, &m_streamIndex, 0); + + d.readFloat(3, &m_rfBandwidth, 450.0f); + d.readFloat(4, &m_baudRate, 45.45f); + d.readS32(5, &m_frequencyShift, 170); + d.readS32(6, (int *)&m_characterSet, (int)Baudot::ITA2); + d.readBool(7, &m_suppressCRLF, false); + d.readBool(8, &m_unshiftOnSpace, false); + d.readS32(9, (int *)&m_filter, (int) LOWPASS); + d.readBool(10, &m_atc, true); + d.readBool(34, &m_msbFirst, false); + d.readBool(35, &m_spaceHigh, false); + d.readS32(36, &m_squelch, -70); + + if (m_channelMarker) + { + d.readBlob(11, &bytetmp); + m_channelMarker->deserialize(bytetmp); + } + + d.readU32(12, &m_rgbColor, QColor(180, 205, 130).rgb()); + d.readString(13, &m_title, "RTTY Demodulator"); + d.readBool(14, &m_useReverseAPI, false); + d.readString(15, &m_reverseAPIAddress, "127.0.0.1"); + d.readU32(16, &utmp, 0); + + if ((utmp > 1023) && (utmp < 65535)) { + m_reverseAPIPort = utmp; + } else { + m_reverseAPIPort = 8888; + } + + d.readU32(17, &utmp, 0); + m_reverseAPIDeviceIndex = utmp > 99 ? 99 : utmp; + d.readU32(18, &utmp, 0); + m_reverseAPIChannelIndex = utmp > 99 ? 99 : utmp; + + + d.readBool(22, &m_udpEnabled); + d.readString(23, &m_udpAddress); + d.readU32(24, &utmp); + + if ((utmp > 1023) && (utmp < 65535)) { + m_udpPort = utmp; + } else { + m_udpPort = 9999; + } + + d.readS32(31, &m_scopeCh1, 0); + d.readS32(32, &m_scopeCh2, 0); + if (m_scopeGUI) + { + d.readBlob(33, &bytetmp); + m_scopeGUI->deserialize(bytetmp); + } + + d.readString(25, &m_logFilename, "rtty_log.csv"); + d.readBool(26, &m_logEnabled, false); + + if (m_rollupState) + { + d.readBlob(27, &bytetmp); + m_rollupState->deserialize(bytetmp); + } + + d.readS32(28, &m_workspaceIndex, 0); + d.readBlob(29, &m_geometryBytes); + d.readBool(30, &m_hidden, false); + + return true; + } + else + { + resetToDefaults(); + return false; + } +} + diff --git a/plugins/channelrx/demodrtty/rttydemodsettings.h b/plugins/channelrx/demodrtty/rttydemodsettings.h new file mode 100644 index 000000000..6cd37a1af --- /dev/null +++ b/plugins/channelrx/demodrtty/rttydemodsettings.h @@ -0,0 +1,88 @@ +/////////////////////////////////////////////////////////////////////////////////// +// Copyright (C) 2017 Edouard Griffiths, F4EXB. // +// Copyright (C) 2023 Jon Beniston, M7RCE // +// // +// This program is free software; you can redistribute it and/or modify // +// it under the terms of the GNU General Public License as published by // +// the Free Software Foundation as version 3 of the License, or // +// (at your option) any later version. // +// // +// This program is distributed in the hope that it will be useful, // +// but WITHOUT ANY WARRANTY; without even the implied warranty of // +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // +// GNU General Public License V3 for more details. // +// // +// You should have received a copy of the GNU General Public License // +// along with this program. If not, see <http://www.gnu.org/licenses/>. // +/////////////////////////////////////////////////////////////////////////////////// + +#ifndef INCLUDE_RTTYDEMODSETTINGS_H +#define INCLUDE_RTTYDEMODSETTINGS_H + +#include <QByteArray> + +#include "util/baudot.h" + +class Serializable; + +struct RttyDemodSettings +{ + qint32 m_inputFrequencyOffset; + Real m_rfBandwidth; + Real m_baudRate; + int m_frequencyShift; + bool m_udpEnabled; + QString m_udpAddress; + uint16_t m_udpPort; + Baudot::CharacterSet m_characterSet; + bool m_suppressCRLF; + bool m_unshiftOnSpace; + enum FilterType { + LOWPASS, + COSINE_B_1, + COSINE_B_0_75, + COSINE_B_0_5, + COSINE_B_1_BW_0_75, + COSINE_B_1_BW_1_25, + MAV, + FILTERED_MAV + } m_filter; + bool m_atc; + bool m_msbFirst; // false = LSB first, true = MSB first + bool m_spaceHigh; // false = mark high frequency, true = space high frequency + int m_squelch; // In dB + + quint32 m_rgbColor; + QString m_title; + Serializable *m_channelMarker; + int m_streamIndex; //!< MIMO channel. Not relevant when connected to SI (single Rx). + bool m_useReverseAPI; + QString m_reverseAPIAddress; + uint16_t m_reverseAPIPort; + uint16_t m_reverseAPIDeviceIndex; + uint16_t m_reverseAPIChannelIndex; + + int m_scopeCh1; + int m_scopeCh2; + + QString m_logFilename; + bool m_logEnabled; + Serializable *m_scopeGUI; + Serializable *m_rollupState; + int m_workspaceIndex; + QByteArray m_geometryBytes; + bool m_hidden; + + static const int RTTYDEMOD_CHANNEL_SAMPLE_RATE = 1000; + + RttyDemodSettings(); + void resetToDefaults(); + void setChannelMarker(Serializable *channelMarker) { m_channelMarker = channelMarker; } + void setRollupState(Serializable *rollupState) { m_rollupState = rollupState; } + void setScopeGUI(Serializable *scopeGUI) { m_scopeGUI = scopeGUI; } + QByteArray serialize() const; + bool deserialize(const QByteArray& data); +}; + +#endif /* INCLUDE_RTTYDEMODSETTINGS_H */ + diff --git a/plugins/channelrx/demodrtty/rttydemodsink.cpp b/plugins/channelrx/demodrtty/rttydemodsink.cpp new file mode 100644 index 000000000..d4792f0f8 --- /dev/null +++ b/plugins/channelrx/demodrtty/rttydemodsink.cpp @@ -0,0 +1,670 @@ +/////////////////////////////////////////////////////////////////////////////////// +// Copyright (C) 2019 Edouard Griffiths, F4EXB // +// Copyright (C) 2023 Jon Beniston, M7RCE // +// // +// This program is free software; you can redistribute it and/or modify // +// it under the terms of the GNU General Public License as published by // +// the Free Software Foundation as version 3 of the License, or // +// (at your option) any later version. // +// // +// This program is distributed in the hope that it will be useful, // +// but WITHOUT ANY WARRANTY; without even the implied warranty of // +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // +// GNU General Public License V3 for more details. // +// // +// You should have received a copy of the GNU General Public License // +// along with this program. If not, see <http://www.gnu.org/licenses/>. // +/////////////////////////////////////////////////////////////////////////////////// + +#include <QDebug> +#include <QRegularExpression> + +#include <complex.h> + +#include "dsp/dspengine.h" +#include "dsp/scopevis.h" +#include "util/db.h" +#include "maincore.h" + +#include "rttydemod.h" +#include "rttydemodsink.h" + +RttyDemodSink::RttyDemodSink(RttyDemod *packetDemod) : + m_rttyDemod(packetDemod), + m_channelSampleRate(RttyDemodSettings::RTTYDEMOD_CHANNEL_SAMPLE_RATE), + m_channelFrequencyOffset(0), + m_magsqSum(0.0f), + m_magsqPeak(0.0f), + m_magsqCount(0), + m_messageQueueToChannel(nullptr), + m_expLength(600), + m_prods1(nullptr), + m_prods2(nullptr), + m_exp(nullptr), + m_clockHistogram(100), + m_shiftEstMag(m_fftSize), + m_fftSequence(-1), + m_fft(nullptr), + m_fftCounter(0), + m_sampleIdx(0), + m_sampleBufferIndex(0) +{ + m_magsq = 0.0; + + m_sampleBuffer.resize(m_sampleBufferSize); + + applySettings(m_settings, true); + applyChannelSettings(m_channelSampleRate, m_channelFrequencyOffset, true); + + FFTFactory *fftFactory = DSPEngine::instance()->getFFTFactory(); + if (m_fftSequence >= 0) { + fftFactory->releaseEngine(m_fftSize, false, m_fftSequence); + } + m_fftSequence = fftFactory->getEngine(m_fftSize, false, &m_fft); + m_fftCounter = 0; +} + +RttyDemodSink::~RttyDemodSink() +{ + delete[] m_exp; + delete[] m_prods1; + delete[] m_prods2; +} + +void RttyDemodSink::sampleToScope(Complex sample) +{ + if (m_scopeSink) + { + Real r = std::real(sample) * SDR_RX_SCALEF; + Real i = std::imag(sample) * SDR_RX_SCALEF; + m_sampleBuffer[m_sampleBufferIndex++] = Sample(r, i); + + if (m_sampleBufferIndex == m_sampleBufferSize) + { + std::vector<SampleVector::const_iterator> vbegin; + vbegin.push_back(m_sampleBuffer.begin()); + m_scopeSink->feed(vbegin, m_sampleBufferSize); + m_sampleBufferIndex = 0; + } + } +} + +void RttyDemodSink::feed(const SampleVector::const_iterator& begin, const SampleVector::const_iterator& end) +{ + Complex ci; + + for (SampleVector::const_iterator it = begin; it != end; ++it) + { + Complex c(it->real(), it->imag()); + c *= m_nco.nextIQ(); + + if (m_interpolatorDistance < 1.0f) // interpolate + { + while (!m_interpolator.interpolate(&m_interpolatorDistanceRemain, c, &ci)) + { + processOneSample(ci); + m_interpolatorDistanceRemain += m_interpolatorDistance; + } + } + else // decimate + { + if (m_interpolator.decimate(&m_interpolatorDistanceRemain, c, &ci)) + { + processOneSample(ci); + m_interpolatorDistanceRemain += m_interpolatorDistance; + } + } + } +} + +void RttyDemodSink::processOneSample(Complex &ci) +{ + // Calculate average and peak levels for level meter + double magsqRaw = ci.real()*ci.real() + ci.imag()*ci.imag();; + Real magsq = magsqRaw / (SDR_RX_SCALED*SDR_RX_SCALED); + m_movingAverage(magsq); + m_magsq = m_movingAverage.asDouble(); + m_magsqSum += magsq; + if (magsq > m_magsqPeak) + { + m_magsqPeak = magsq; + } + m_magsqCount++; + + // Sum power while data is being received + if (m_gotSOP) + { + m_rssiMagSqSum += magsq; + m_rssiMagSqCount++; + } + + ci /= SDR_RX_SCALEF; + + // Use FFT to estimate frequency shift + m_fft->in()[m_fftCounter] = ci; + m_fftCounter++; + if (m_fftCounter == m_fftSize) + { + estimateFrequencyShift(); + m_fftCounter = 0; + } + + // Correlate with expected mark and space frequencies + Complex exp = m_exp[m_expIdx]; + m_expIdx = (m_expIdx + 1) % m_expLength; + //Complex exp = m_exp[m_sampleIdx]; + //qDebug() << "IQ " << real(ci) << imag(ci); + Complex corr1 = ci * exp; + Complex corr2 = ci * std::conj(exp); + + // Filter + Real abs1, abs2; + Real abs1Filt, abs2Filt; + if (m_settings.m_filter == RttyDemodSettings::LOWPASS) + { + // Low pass filter + abs1Filt = abs1 = std::abs(m_lowpassComplex1.filter(corr1)); + abs2Filt = abs2 = std::abs(m_lowpassComplex2.filter(corr2)); + } + else if ( (m_settings.m_filter == RttyDemodSettings::COSINE_B_1) + || (m_settings.m_filter == RttyDemodSettings::COSINE_B_0_75) + || (m_settings.m_filter == RttyDemodSettings::COSINE_B_0_5) + ) + { + // Rasised cosine filter + abs1Filt = abs1 = std::abs(m_raisedCosine1.filter(corr1)); + abs2Filt = abs2 = std::abs(m_raisedCosine2.filter(corr2)); + } + else + { + // Moving average + + // Calculating moving average (well windowed sum) + Complex old1 = m_prods1[m_sampleIdx]; + Complex old2 = m_prods2[m_sampleIdx]; + m_prods1[m_sampleIdx] = corr1; + m_prods2[m_sampleIdx] = corr2; + m_sum1 += m_prods1[m_sampleIdx] - old1; + m_sum2 += m_prods2[m_sampleIdx] - old2; + m_sampleIdx = (m_sampleIdx + 1) % m_samplesPerBit; + + // Square signals (by calculating absolute value of complex signal) + abs1 = std::abs(m_sum1); + abs2 = std::abs(m_sum2); + + // Apply optional low-pass filter to try to avoid extra zero-crassings above the baud rate + if (m_settings.m_filter == RttyDemodSettings::FILTERED_MAV) + { + abs1Filt = m_lowpass1.filter(abs1); + abs2Filt = m_lowpass2.filter(abs2); + } + else + { + abs1Filt = abs1; + abs2Filt = abs2; + } + } + + // Envelope calculation + m_movMax1(abs1Filt); + m_movMax2(abs2Filt); + Real env1 = m_movMax1.getMaximum(); + Real env2 = m_movMax2.getMaximum(); + + // Automatic threshold correction to compensate for frequency selective fading + // http://www.w7ay.net/site/Technical/ATC/index.html + Real bias1 = abs1Filt - 0.5 * env1; + Real bias2 = abs2Filt - 0.5 * env2; + Real unbiasedData = abs1Filt - abs2Filt; + Real biasedData = bias1 - bias2; + + // Save current data for edge detection + m_dataPrev = m_data; + // Set data according to stongest correlation + if (m_settings.m_spaceHigh) { + m_data = m_settings.m_atc ? biasedData < 0 : unbiasedData < 0; + } else { + m_data = m_settings.m_atc ? biasedData > 0 : unbiasedData > 0; + } + + if (!m_gotSOP) + { + // Look for falling edge which indicates start bit + if (!m_data && m_dataPrev) + { + m_gotSOP = true; + m_bits = 0; + m_bitCount = 0; + m_clockCount = 0; + m_clock = false; + m_cycleCount = 0; + } + } + else + { + // Sample in middle of symbol + if (m_clockCount == m_samplesPerBit/2) + { + receiveBit(m_data); + m_clock = true; + } + m_clockCount = (m_clockCount + 1) % m_samplesPerBit; + if (m_clockCount == 0) { + m_clock = false; + } + + // Count cycles between edges, to estimate baud rate + m_cycleCount++; + if (m_data != m_dataPrev) + { + if (m_cycleCount < m_clockHistogram.size()) + { + m_clockHistogram[m_cycleCount]++; + m_edgeCount++; + + // Every 100 edges, calculate estimate + if (m_edgeCount == 100) { + estimateBaudRate(); + } + } + m_cycleCount = 0; + } + } + + // Select signals to feed to scope + Complex scopeSample; + switch (m_settings.m_scopeCh1) + { + case 0: + scopeSample.real(ci.real()); + break; + case 1: + scopeSample.real(ci.imag()); + break; + case 2: + scopeSample.real(magsq); + break; + case 3: + scopeSample.real(m_sampleIdx); + break; + case 4: + scopeSample.real(abs(m_sum1)); + break; + case 5: + scopeSample.real(abs(m_sum2)); + break; + case 6: + scopeSample.real(m_bit); + break; + case 7: + scopeSample.real(m_bitCount); + break; + case 8: + scopeSample.real(m_gotSOP); + break; + case 9: + scopeSample.real(real(exp)); + break; + case 10: + scopeSample.real(imag(exp)); + break; + case 11: + scopeSample.real(abs1Filt); + break; + case 12: + scopeSample.real(abs2Filt); + break; + case 13: + scopeSample.real(abs2 - abs1); + break; + case 14: + scopeSample.real(abs2Filt - abs1Filt); + break; + case 15: + scopeSample.real(m_data); + break; + case 16: + scopeSample.real(m_clock); + break; + case 17: + scopeSample.real(env1); + break; + case 18: + scopeSample.real(env2); + break; + case 19: + scopeSample.real(bias1); + break; + case 20: + scopeSample.real(bias2); + break; + case 21: + scopeSample.real(unbiasedData); + break; + case 22: + scopeSample.real(biasedData); + break; + } + switch (m_settings.m_scopeCh2) + { + case 0: + scopeSample.imag(ci.real()); + break; + case 1: + scopeSample.imag(ci.imag()); + break; + case 2: + scopeSample.imag(magsq); + break; + case 3: + scopeSample.imag(m_sampleIdx); + break; + case 4: + scopeSample.imag(abs(m_sum1)); + break; + case 5: + scopeSample.imag(abs(m_sum2)); + break; + case 6: + scopeSample.imag(m_bit); + break; + case 7: + scopeSample.imag(m_bitCount); + break; + case 8: + scopeSample.imag(m_gotSOP); + break; + case 9: + scopeSample.imag(real(exp)); + break; + case 10: + scopeSample.imag(imag(exp)); + break; + case 11: + scopeSample.imag(abs1Filt); + break; + case 12: + scopeSample.imag(abs2Filt); + break; + case 13: + scopeSample.imag(abs2 - abs1); + break; + case 14: + scopeSample.imag(abs2Filt - abs1Filt); + break; + case 15: + scopeSample.imag(m_data); + break; + case 16: + scopeSample.imag(m_clock); + break; + case 17: + scopeSample.imag(env1); + break; + case 18: + scopeSample.imag(env2); + break; + case 19: + scopeSample.imag(bias1); + break; + case 20: + scopeSample.imag(bias2); + break; + case 21: + scopeSample.imag(unbiasedData); + break; + case 22: + scopeSample.imag(biasedData); + break; + } + sampleToScope(scopeSample); +} + +void RttyDemodSink::estimateFrequencyShift() +{ + // Perform FFT + m_fft->transform(); + // Calculate magnitude + for (int i = 0; i < m_fftSize; i++) + { + Complex c = m_fft->out()[i]; + Real v = c.real() * c.real() + c.imag() * c.imag(); + Real magsq = v / (m_fftSize * m_fftSize); + m_shiftEstMag[i] = magsq; + } + // Fink peaks in each half + Real peak1 = m_shiftEstMag[0]; + int peak1Bin = 0; + for (int i = 1; i < m_fftSize/2; i++) + { + if (m_shiftEstMag[i] > peak1) + { + peak1 = m_shiftEstMag[i]; + peak1Bin = i; + } + } + Real peak2 = m_shiftEstMag[m_fftSize/2]; + int peak2Bin = m_fftSize/2; + for (int i = m_fftSize/2+1; i < m_fftSize; i++) + { + if (m_shiftEstMag[i] > peak2) + { + peak2 = m_shiftEstMag[i]; + peak2Bin = i; + } + } + // Convert bin to frequency offset + double frequencyResolution = RttyDemodSettings::RTTYDEMOD_CHANNEL_SAMPLE_RATE / (double)m_fftSize; + double freq1 = frequencyResolution * peak1Bin; + double freq2 = -frequencyResolution * (m_fftSize - peak2Bin); + m_freq1Average(freq1); + m_freq2Average(freq2); + //int shift = m_freq1Average.instantAverage() - m_freq2Average.instantAverage(); + //qDebug() << "Freq est " << freq1 << freq2 << shift; +} + +int RttyDemodSink::estimateBaudRate() +{ + // Find most frequent entry in histogram + auto histMax = max_element(m_clockHistogram.begin(), m_clockHistogram.end()); + int index = std::distance(m_clockHistogram.begin(), histMax); + + // Calculate baud rate as weighted average + Real baud1 = RttyDemodSettings::RTTYDEMOD_CHANNEL_SAMPLE_RATE / (Real)(index-1); + int count1 = m_clockHistogram[index-1]; + Real baud2 = RttyDemodSettings::RTTYDEMOD_CHANNEL_SAMPLE_RATE / (Real)(index); + int count2 = m_clockHistogram[index]; + Real baud3 = RttyDemodSettings::RTTYDEMOD_CHANNEL_SAMPLE_RATE / (Real)(index+1); + int count3 = m_clockHistogram[index+1]; + Real total = count1 + count2 + count3; + Real estBaud = count1/total*baud1 + count2/total*baud2 + count3/total*baud3; + m_baudRateAverage(estBaud); + + // Send estimate to GUI + if (getMessageQueueToChannel()) + { + int estFrequencyShift = m_freq1Average.instantAverage() - m_freq2Average.instantAverage(); + RttyDemod::MsgModeEstimate *msg = RttyDemod::MsgModeEstimate::create(m_baudRateAverage.instantAverage(), estFrequencyShift); + getMessageQueueToChannel()->push(msg); + } + + // Restart estimation + std::fill(m_clockHistogram.begin(), m_clockHistogram.end(), 0); + m_edgeCount = 0; + + return estBaud; +} + +void RttyDemodSink::receiveBit(bool bit) +{ + m_bit = bit; + + // Store in shift reg. + if (m_settings.m_msbFirst) { + m_bits = (m_bit & 0x1) | (m_bits << 1); + } else { + m_bits = (m_bit << 6) | (m_bits >> 1); + } + m_bitCount++; + + if (m_bitCount == 7) + { + if ( (!m_settings.m_msbFirst && ((m_bits & 0x40) != 0x40)) + || (m_settings.m_msbFirst && ((m_bits & 0x01) != 0x01))) + { + //qDebug() << "No stop bit"; + } + else + { + QString c = m_rttyDecoder.decode((m_bits >> 1) & 0x1f); + if ((c != '\0') && (c != '<') && (c != '>') && (c != '^')) + { + // Calculate average power over received byte + float rssi = CalcDb::dbPower(m_rssiMagSqSum / m_rssiMagSqCount); + if (rssi > m_settings.m_squelch) + { + // Slow enough to send individually to be displayed + if (getMessageQueueToChannel()) + { + RttyDemod::MsgCharacter *msg = RttyDemod::MsgCharacter::create(c); + getMessageQueueToChannel()->push(msg); + } + } + } + } + m_gotSOP = false; + } +} + +void RttyDemodSink::applyChannelSettings(int channelSampleRate, int channelFrequencyOffset, bool force) +{ + qDebug() << "RttyDemodSink::applyChannelSettings:" + << " channelSampleRate: " << channelSampleRate + << " channelFrequencyOffset: " << channelFrequencyOffset; + + if ((m_channelFrequencyOffset != channelFrequencyOffset) || + (m_channelSampleRate != channelSampleRate) || force) + { + m_nco.setFreq(-channelFrequencyOffset, channelSampleRate); + } + + if ((m_channelSampleRate != channelSampleRate) || force) + { + m_interpolator.create(16, channelSampleRate, m_settings.m_rfBandwidth / 2.2); + m_interpolatorDistance = (Real) channelSampleRate / (Real) RttyDemodSettings::RTTYDEMOD_CHANNEL_SAMPLE_RATE; + m_interpolatorDistanceRemain = m_interpolatorDistance; + } + + m_channelSampleRate = channelSampleRate; + m_channelFrequencyOffset = channelFrequencyOffset; +} + +void RttyDemodSink::init() +{ + m_sampleIdx = 0; + m_expIdx = 0; + m_sum1 = 0.0; + m_sum2 = 0.0; + for (int i = 0; i < m_samplesPerBit; i++) + { + m_prods1[i] = 0.0f; + m_prods2[i] = 0.0f; + } + m_bit = 0; + m_bits = 0; + m_bitCount = 0; + m_gotSOP = false; + m_clockCount = 0; + m_clock = 0; + m_rssiMagSqSum = 0.0; + m_rssiMagSqCount = 0; + m_rttyDecoder.init(); +} + +void RttyDemodSink::applySettings(const RttyDemodSettings& settings, bool force) +{ + qDebug() << "RttyDemodSink::applySettings:" + << " m_rfBandwidth: " << settings.m_rfBandwidth + << " m_baudRate: " << settings.m_baudRate + << " m_frequencyShift: " << settings.m_frequencyShift + << " m_characterSet: " << settings.m_characterSet + << " m_unshiftOnSpace: " << settings.m_unshiftOnSpace + << " force: " << force; + + if ((settings.m_rfBandwidth != m_settings.m_rfBandwidth) || force) + { + m_interpolator.create(16, m_channelSampleRate, settings.m_rfBandwidth / 2.2); + m_interpolatorDistance = (Real) m_channelSampleRate / (Real) RttyDemodSettings::RTTYDEMOD_CHANNEL_SAMPLE_RATE; + m_interpolatorDistanceRemain = m_interpolatorDistance; + } + + if ((settings.m_baudRate != m_settings.m_baudRate) || (settings.m_filter != m_settings.m_filter) || force) + { + m_envelope1.create(301, RttyDemodSettings::RTTYDEMOD_CHANNEL_SAMPLE_RATE, 2); + m_envelope2.create(301, RttyDemodSettings::RTTYDEMOD_CHANNEL_SAMPLE_RATE, 2); + m_lowpass1.create(301, RttyDemodSettings::RTTYDEMOD_CHANNEL_SAMPLE_RATE, m_settings.m_baudRate * 1.1); + m_lowpass2.create(301, RttyDemodSettings::RTTYDEMOD_CHANNEL_SAMPLE_RATE, m_settings.m_baudRate * 1.1); + //m_lowpass1.printTaps("lpf"); + + m_lowpassComplex1.create(301, RttyDemodSettings::RTTYDEMOD_CHANNEL_SAMPLE_RATE, m_settings.m_baudRate * 1.1); + m_lowpassComplex2.create(301, RttyDemodSettings::RTTYDEMOD_CHANNEL_SAMPLE_RATE, m_settings.m_baudRate * 1.1); + //m_lowpass1.printTaps("lpfc"); + + // http://w7ay.net/site/Technical/Extended%20Nyquist%20Filters/index.html + // http://w7ay.net/site/Technical/EqualizedRaisedCosine/index.html + float beta = 1.0f; + float bw = 1.0f; + if (settings.m_filter == RttyDemodSettings::COSINE_B_0_5) { + beta = 0.5f; + } else if (settings.m_filter == RttyDemodSettings::COSINE_B_0_75) { + beta = 0.75f; + } else if (settings.m_filter == RttyDemodSettings::COSINE_B_1_BW_0_75) { + bw = 0.75f; + } else if (settings.m_filter == RttyDemodSettings::COSINE_B_1_BW_1_25) { + bw = 1.25f; + } + m_raisedCosine1.create(beta, 7, RttyDemodSettings::RTTYDEMOD_CHANNEL_SAMPLE_RATE/(m_settings.m_baudRate/bw), false); + m_raisedCosine2.create(beta, 7, RttyDemodSettings::RTTYDEMOD_CHANNEL_SAMPLE_RATE/(m_settings.m_baudRate/bw), false); + //m_raisedCosine1.printTaps("rcos"); + } + + if ((settings.m_characterSet != m_settings.m_characterSet) || force) { + m_rttyDecoder.setCharacterSet(settings.m_characterSet); + } + if ((settings.m_unshiftOnSpace != m_settings.m_unshiftOnSpace) || force) { + m_rttyDecoder.setUnshiftOnSpace(settings.m_unshiftOnSpace); + } + + if ((settings.m_baudRate != m_settings.m_baudRate) || (settings.m_frequencyShift != m_settings.m_frequencyShift) || force) + { + delete[] m_exp; + delete[] m_prods1; + delete[] m_prods2; + m_samplesPerBit = RttyDemodSettings::RTTYDEMOD_CHANNEL_SAMPLE_RATE / settings.m_baudRate; + m_exp = new Complex[m_expLength]; + m_prods1 = new Complex[m_samplesPerBit]; + m_prods2 = new Complex[m_samplesPerBit]; + Real f0 = 0.0f; + for (int i = 0; i < m_expLength; i++) + { + m_exp[i] = Complex(cos(f0), sin(f0)); + f0 += 2.0f * (Real)M_PI * (settings.m_frequencyShift/2.0f) / RttyDemodSettings::RTTYDEMOD_CHANNEL_SAMPLE_RATE; + } + init(); + + // Due to start and stop bits, we should get mark and space at least every 8 bits + // while something is being transmitted + m_movMax1.setSize(m_samplesPerBit * 8); + m_movMax2.setSize(m_samplesPerBit * 8); + + m_edgeCount = 0; + std::fill(m_clockHistogram.begin(), m_clockHistogram.end(), 0); + + m_baudRateAverage.reset(); + m_freq1Average.reset(); + m_freq2Average.reset(); + } + + m_settings = settings; +} + diff --git a/plugins/channelrx/demodrtty/rttydemodsink.h b/plugins/channelrx/demodrtty/rttydemodsink.h new file mode 100644 index 000000000..e99e0397a --- /dev/null +++ b/plugins/channelrx/demodrtty/rttydemodsink.h @@ -0,0 +1,170 @@ +/////////////////////////////////////////////////////////////////////////////////// +// Copyright (C) 2019 Edouard Griffiths, F4EXB // +// Copyright (C) 2023 Jon Beniston, M7RCE // +// // +// This program is free software; you can redistribute it and/or modify // +// it under the terms of the GNU General Public License as published by // +// the Free Software Foundation as version 3 of the License, or // +// (at your option) any later version. // +// // +// This program is distributed in the hope that it will be useful, // +// but WITHOUT ANY WARRANTY; without even the implied warranty of // +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // +// GNU General Public License V3 for more details. // +// // +// You should have received a copy of the GNU General Public License // +// along with this program. If not, see <http://www.gnu.org/licenses/>. // +/////////////////////////////////////////////////////////////////////////////////// + +#ifndef INCLUDE_RTTYDEMODSINK_H +#define INCLUDE_RTTYDEMODSINK_H + +#include "dsp/channelsamplesink.h" +#include "dsp/nco.h" +#include "dsp/interpolator.h" +#include "dsp/firfilter.h" +#include "dsp/raisedcosine.h" +#include "dsp/fftfactory.h" +#include "dsp/fftengine.h" +#include "util/movingaverage.h" +#include "util/movingmaximum.h" +#include "util/doublebufferfifo.h" +#include "util/messagequeue.h" + +#include "rttydemodsettings.h" + +class ChannelAPI; +class RttyDemod; +class ScopeVis; + + +class RttyDemodSink : public ChannelSampleSink { +public: + RttyDemodSink(RttyDemod *packetDemod); + ~RttyDemodSink(); + + virtual void feed(const SampleVector::const_iterator& begin, const SampleVector::const_iterator& end); + + void setScopeSink(ScopeVis* scopeSink) { m_scopeSink = scopeSink; } + void applyChannelSettings(int channelSampleRate, int channelFrequencyOffset, bool force = false); + void applySettings(const RttyDemodSettings& settings, bool force = false); + void setMessageQueueToChannel(MessageQueue *messageQueue) { m_messageQueueToChannel = messageQueue; } + void setChannel(ChannelAPI *channel) { m_channel = channel; } + + double getMagSq() const { return m_magsq; } + + void getMagSqLevels(double& avg, double& peak, int& nbSamples) + { + if (m_magsqCount > 0) + { + m_magsq = m_magsqSum / m_magsqCount; + m_magSqLevelStore.m_magsq = m_magsq; + m_magSqLevelStore.m_magsqPeak = m_magsqPeak; + } + + avg = m_magSqLevelStore.m_magsq; + peak = m_magSqLevelStore.m_magsqPeak; + nbSamples = m_magsqCount == 0 ? 1 : m_magsqCount; + + m_magsqSum = 0.0f; + m_magsqPeak = 0.0f; + m_magsqCount = 0; + } + + +private: + struct MagSqLevelsStore + { + MagSqLevelsStore() : + m_magsq(1e-12), + m_magsqPeak(1e-12) + {} + double m_magsq; + double m_magsqPeak; + }; + + ScopeVis* m_scopeSink; // Scope GUI to display baseband waveform + RttyDemod *m_rttyDemod; + RttyDemodSettings m_settings; + ChannelAPI *m_channel; + int m_channelSampleRate; + int m_channelFrequencyOffset; + + NCO m_nco; + Interpolator m_interpolator; + Real m_interpolatorDistance; + Real m_interpolatorDistanceRemain; + + double m_magsq; + double m_magsqSum; + double m_magsqPeak; + int m_magsqCount; + MagSqLevelsStore m_magSqLevelStore; + + MessageQueue *m_messageQueueToChannel; + + MovingAverageUtil<Real, double, 16> m_movingAverage; + Lowpass<Real> m_envelope1; + Lowpass<Real> m_envelope2; + Lowpass<Real> m_lowpass1; + Lowpass<Real> m_lowpass2; + Lowpass<Complex> m_lowpassComplex1; + Lowpass<Complex> m_lowpassComplex2; + RaisedCosine<Complex> m_raisedCosine1; + RaisedCosine<Complex> m_raisedCosine2; + + MovingMaximum<Real> m_movMax1; + MovingMaximum<Real> m_movMax2; + + int m_expLength; + int m_samplesPerBit; + Complex *m_prods1; + Complex *m_prods2; + Complex *m_exp; + Complex m_sum1; + Complex m_sum2; + int m_sampleIdx; + int m_expIdx; + int m_bit; + bool m_data; + bool m_dataPrev; + int m_clockCount; + bool m_clock; + double m_rssiMagSqSum; + int m_rssiMagSqCount; + + unsigned short m_bits; + int m_bitCount; + bool m_gotSOP; + BaudotDecoder m_rttyDecoder; + + // For baud rate estimation + int m_cycleCount; + std::vector<int> m_clockHistogram; + int m_edgeCount; + MovingAverageUtil<Real, Real, 5> m_baudRateAverage; + + // For frequency shift estimation + std::vector<Real> m_shiftEstMag; + int m_fftSequence; + FFTEngine *m_fft; + int m_fftCounter; + static const int m_fftSize = 128; // ~7Hz res + MovingAverageUtil<Real, Real, 16> m_freq1Average; + MovingAverageUtil<Real, Real, 16> m_freq2Average; + + SampleVector m_sampleBuffer; + static const int m_sampleBufferSize = RttyDemodSettings::RTTYDEMOD_CHANNEL_SAMPLE_RATE / 20; + int m_sampleBufferIndex; + + void processOneSample(Complex &ci); + MessageQueue *getMessageQueueToChannel() { return m_messageQueueToChannel; } + void sampleToScope(Complex sample); + void init(); + void receiveBit(bool bit); + int estimateBaudRate(); + void estimateFrequencyShift(); +}; + +#endif // INCLUDE_RTTYDEMODSINK_H + diff --git a/plugins/channelrx/demodrtty/rttydemodwebapiadapter.cpp b/plugins/channelrx/demodrtty/rttydemodwebapiadapter.cpp new file mode 100644 index 000000000..e809ab01d --- /dev/null +++ b/plugins/channelrx/demodrtty/rttydemodwebapiadapter.cpp @@ -0,0 +1,52 @@ +/////////////////////////////////////////////////////////////////////////////////// +// Copyright (C) 2019 Edouard Griffiths, F4EXB. // +// Copyright (C) 2023 Jon Beniston, M7RCE // +// // +// This program is free software; you can redistribute it and/or modify // +// it under the terms of the GNU General Public License as published by // +// the Free Software Foundation as version 3 of the License, or // +// (at your option) any later version. // +// // +// This program is distributed in the hope that it will be useful, // +// but WITHOUT ANY WARRANTY; without even the implied warranty of // +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // +// GNU General Public License V3 for more details. // +// // +// You should have received a copy of the GNU General Public License // +// along with this program. If not, see <http://www.gnu.org/licenses/>. // +/////////////////////////////////////////////////////////////////////////////////// + +#include "SWGChannelSettings.h" +#include "rttydemod.h" +#include "rttydemodwebapiadapter.h" + +RttyDemodWebAPIAdapter::RttyDemodWebAPIAdapter() +{} + +RttyDemodWebAPIAdapter::~RttyDemodWebAPIAdapter() +{} + +int RttyDemodWebAPIAdapter::webapiSettingsGet( + SWGSDRangel::SWGChannelSettings& response, + QString& errorMessage) +{ + (void) errorMessage; + response.setRttyDemodSettings(new SWGSDRangel::SWGRTTYDemodSettings()); + response.getRttyDemodSettings()->init(); + RttyDemod::webapiFormatChannelSettings(response, m_settings); + + return 200; +} + +int RttyDemodWebAPIAdapter::webapiSettingsPutPatch( + bool force, + const QStringList& channelSettingsKeys, + SWGSDRangel::SWGChannelSettings& response, + QString& errorMessage) +{ + (void) force; + (void) errorMessage; + RttyDemod::webapiUpdateChannelSettings(m_settings, channelSettingsKeys, response); + + return 200; +} diff --git a/plugins/channelrx/demodrtty/rttydemodwebapiadapter.h b/plugins/channelrx/demodrtty/rttydemodwebapiadapter.h new file mode 100644 index 000000000..25746811d --- /dev/null +++ b/plugins/channelrx/demodrtty/rttydemodwebapiadapter.h @@ -0,0 +1,50 @@ +/////////////////////////////////////////////////////////////////////////////////// +// Copyright (C) 2019 Edouard Griffiths, F4EXB. // +// Copyright (C) 2023 Jon Beniston, M7RCE // +// // +// This program is free software; you can redistribute it and/or modify // +// it under the terms of the GNU General Public License as published by // +// the Free Software Foundation as version 3 of the License, or // +// (at your option) any later version. // +// // +// This program is distributed in the hope that it will be useful, // +// but WITHOUT ANY WARRANTY; without even the implied warranty of // +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // +// GNU General Public License V3 for more details. // +// // +// You should have received a copy of the GNU General Public License // +// along with this program. If not, see <http://www.gnu.org/licenses/>. // +/////////////////////////////////////////////////////////////////////////////////// + +#ifndef INCLUDE_RTTYDEMOD_WEBAPIADAPTER_H +#define INCLUDE_RTTYDEMOD_WEBAPIADAPTER_H + +#include "channel/channelwebapiadapter.h" +#include "rttydemodsettings.h" + +/** + * Standalone API adapter only for the settings + */ +class RttyDemodWebAPIAdapter : public ChannelWebAPIAdapter { +public: + RttyDemodWebAPIAdapter(); + virtual ~RttyDemodWebAPIAdapter(); + + virtual QByteArray serialize() const { return m_settings.serialize(); } + virtual bool deserialize(const QByteArray& data) { return m_settings.deserialize(data); } + + virtual int webapiSettingsGet( + SWGSDRangel::SWGChannelSettings& response, + QString& errorMessage); + + virtual int webapiSettingsPutPatch( + bool force, + const QStringList& channelSettingsKeys, + SWGSDRangel::SWGChannelSettings& response, + QString& errorMessage); + +private: + RttyDemodSettings m_settings; +}; + +#endif // INCLUDE_RTTYDEMOD_WEBAPIADAPTER_H diff --git a/sdrbase/util/baudot.cpp b/sdrbase/util/baudot.cpp new file mode 100644 index 000000000..29bd0938d --- /dev/null +++ b/sdrbase/util/baudot.cpp @@ -0,0 +1,192 @@ +/////////////////////////////////////////////////////////////////////////////////// +// Copyright (C) 2023 Jon Beniston, M7RCE // +// // +// This program is free software; you can redistribute it and/or modify // +// it under the terms of the GNU General Public License as published by // +// the Free Software Foundation as version 3 of the License, or // +// (at your option) any later version. // +// // +// This program is distributed in the hope that it will be useful, // +// but WITHOUT ANY WARRANTY; without even the implied warranty of // +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // +// GNU General Public License V3 for more details. // +// // +// You should have received a copy of the GNU General Public License // +// along with this program. If not, see <http://www.gnu.org/licenses/>. // +/////////////////////////////////////////////////////////////////////////////////// + +#include <QDebug> + +#include "baudot.h" + +// https://en.wikipedia.org/wiki/Baudot_code +// We use < for FIGS and > for LTRS and ^ for Cyrillic +// Unicode used for source file encoding + +const QString Baudot::m_ita2Letter[] = { + "\0", "E", "\n", "A", " ", "S", "I", "U", + "\r", "D", "R", "J", "N", "F", "C", "K", + "T", "Z", "L", "W", "H", "Y", "P", "Q", + "O", "B", "G", "<", "M", "X", "V", ">" +}; + +const QString Baudot::m_ita2Figure[] = { + "\0", "3", "\n", "-", " ", "\'", "8", "7", + "\r", "\x5", "4", "\a", ",", "!", ":", "(", + "5", "+", ")", "2", "£", "6", "0", "1", + "9", "?", "&", "<", ".", "/", "=", ">" +}; + +const QString Baudot::m_ukLetter[] = { + "\0", "A", "E", "/", "Y", "U", "I", "O", + "<", "J", "G", "H", "B", "C", "F", "D", + " ", "-", "X", "Z", "S", "T", "W", "V", + "\b", "K", "M", "L", "R", "Q", "N", "P" +}; + +const QString Baudot::m_ukFigure[] = { + "\0", "1", "2", "⅟", "3", "4", "³⁄", "5", + " ", "6", "7", "¹", "8", "9", "⁵⁄", "0", + ">", ".", "⁹⁄", ":", "⁷⁄", "²", "?", "\'", + "\b", "(", ")", "=", "-", "/", "£", "+" +}; + +const QString Baudot::m_europeanLetter[] = { + "\0", "A", "E", "É", "Y", "U", "I", "O", + "<", "J", "G", "H", "B", "C", "F", "D", + " ", "t", "X", "Z", "S", "T", "W", "V", + "\b", "K", "M", "L", "R", "Q", "N", "P" +}; + +const QString Baudot::m_europeanFigure[] = { + "\0", "1", "2", "&", "3", "4", "º", "5", + " ", "6", "7", "H̱", "8", "9", "F̱", "0", + ">", ".", ",", ":", ";", "!", "?", "\'", + "\b", "(", ")", "=", "-", "/", "№", "%" +}; + +const QString Baudot::m_usLetter[] = { + "\0", "E", "\n", "A", " ", "S", "I", "U", + "\r", "D", "R", "J", "N", "F", "C", "K", + "T", "Z", "L", "W", "H", "Y", "P", "Q", + "O", "B", "G", "<", "M", "X", "V", ">" +}; + +const QString Baudot::m_usFigure[] = { + "\0", "3", "\n", "-", " ", "\a", "8", "7", + "\r", "\x5", "4", "\'", ",", "!", ":", "(", + "5", "\"", ")", "2", "#", "6", "0", "1", + "9", "?", "&", "<", ".", "/", ";", ">" +}; + +const QString Baudot::m_russianLetter[] = { + "\0", "Е", "\n", "А", " ", "С", "И", "У", + "\r", "Д", "П", "Й", "Н", "Ф", "Ц", "К", + "Т", "З", "Л", "В", "Х", "Ы", "P", "Я", + "О", "Б", "Г", "<", "М", "Ь", "Ж", ">" +}; + +const QString Baudot::m_russianFigure[] = { + "\0", "3", "\n", "-", " ", "\'", "8", "7", + "\r", "Ч", "4", "Ю", ",", "Э", ":", "(", + "5", "+", ")", "2", "Щ", "6", "0", "1", + "9", "?", "Ш", "<", ".", "/", ";", ">" +}; + +const QString Baudot::m_murrayLetter[] = { + " ", "E", "?", "A", ">", "S", "I", "U", + "\n", "D", "R", "J", "N", "F", "C", "K", + "T", "Z", "L", "W", "H", "Y", "P", "Q", + "O", "B", "G", "<", "M", "X", "V", "\b" +}; + +const QString Baudot::m_murrayFigure[] = { + " ", "3", "?", " ", ">", "'", "8", "7", + "\n", "²", "4", "⁷⁄", "-", "⅟", "(", "⁹⁄", + "5", ".", "/", "2", "⁵⁄", "6", "0", "1", + "9", "?", "³⁄", "<", ",", "£", ")", "\b" +}; + +BaudotDecoder::BaudotDecoder() +{ + setCharacterSet(Baudot::ITA2); + setUnshiftOnSpace(false); + init(); +} + +void BaudotDecoder::setCharacterSet(Baudot::CharacterSet characterSet) +{ + m_characterSet = characterSet; + switch (m_characterSet) + { + case Baudot::ITA2: + m_letters = Baudot::m_ita2Letter; + m_figures = Baudot::m_ita2Figure; + break; + case Baudot::UK: + m_letters = Baudot::m_ukLetter; + m_figures = Baudot::m_ukFigure; + break; + case Baudot::EUROPEAN: + m_letters = Baudot::m_europeanLetter; + m_figures = Baudot::m_europeanFigure; + break; + case Baudot::US: + m_letters = Baudot::m_usLetter; + m_figures = Baudot::m_usFigure; + break; + case Baudot::RUSSIAN: + m_letters = Baudot::m_russianLetter; + m_figures = Baudot::m_russianFigure; + break; + case Baudot::MURRAY: + m_letters = Baudot::m_murrayLetter; + m_figures = Baudot::m_murrayFigure; + break; + default: + qDebug() << "BaudotDecoder::BaudotDecoder: Unsupported character set " << m_characterSet; + m_letters = Baudot::m_ita2Letter; + m_figures = Baudot::m_ita2Figure; + m_characterSet = Baudot::ITA2; + break; + } +} + +void BaudotDecoder::setUnshiftOnSpace(bool unshiftOnSpace) +{ + m_unshiftOnSpace = unshiftOnSpace; +} + +void BaudotDecoder::init() +{ + m_figure = false; +} + +QString BaudotDecoder::decode(char bits) +{ + QString c = m_figure ? m_figures[bits] : m_letters[bits]; + + if ((c == '>') || (m_unshiftOnSpace && (c == " "))) + { + // Switch to letters + m_figure = false; + if (m_characterSet == Baudot::RUSSIAN) { + m_letters = Baudot::m_ita2Letter; + } + } + if (c == '<') + { + // Switch to figures + m_figure = true; + } + if ((m_characterSet == Baudot::RUSSIAN) && (c == '\0')) + { + // Switch to Cyrillic + m_figure = false; + m_letters = Baudot::m_russianLetter; + c = '^'; + } + + return c; +} + diff --git a/sdrbase/util/baudot.h b/sdrbase/util/baudot.h new file mode 100644 index 000000000..3f714c9be --- /dev/null +++ b/sdrbase/util/baudot.h @@ -0,0 +1,77 @@ +/////////////////////////////////////////////////////////////////////////////////// +// Copyright (C) 2023 Jon Beniston, M7RCE // +// // +// This program is free software; you can redistribute it and/or modify // +// it under the terms of the GNU General Public License as published by // +// the Free Software Foundation as version 3 of the License, or // +// (at your option) any later version. // +// // +// This program is distributed in the hope that it will be useful, // +// but WITHOUT ANY WARRANTY; without even the implied warranty of // +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // +// GNU General Public License V3 for more details. // +// // +// You should have received a copy of the GNU General Public License // +// along with this program. If not, see <http://www.gnu.org/licenses/>. // +/////////////////////////////////////////////////////////////////////////////////// + +#ifndef INCLUDE_UTIL_BAUDOT_H +#define INCLUDE_UTIL_BAUDOT_H + +#include <QString> +#include <QDateTime> +#include <QMap> + +#include "export.h" + +class SDRBASE_API Baudot { + +public: + + enum CharacterSet { + ITA2, + UK, + EUROPEAN, + US, + RUSSIAN, // MTK-2 + MURRAY + }; + + // QString used for fractions in figure set + static const QString m_ita2Letter[]; + static const QString m_ita2Figure[]; + static const QString m_ukLetter[]; + static const QString m_ukFigure[]; + static const QString m_europeanLetter[]; + static const QString m_europeanFigure[]; + static const QString m_usLetter[]; + static const QString m_usFigure[]; + static const QString m_russianLetter[]; + static const QString m_russianFigure[]; + static const QString m_murrayLetter[]; + static const QString m_murrayFigure[]; + +}; + +class SDRBASE_API BaudotDecoder { + +public: + + BaudotDecoder(); + void setCharacterSet(Baudot::CharacterSet characterSet=Baudot::ITA2); + void setUnshiftOnSpace(bool unshiftOnSpace); + void init(); + QString decode(char bits); + +private: + + Baudot::CharacterSet m_characterSet; + bool m_unshiftOnSpace; + const QString *m_letters; + const QString *m_figures; + bool m_figure; + +}; + +#endif // INCLUDE_UTIL_BAUDOT_H + diff --git a/sdrbase/util/movingmaximum.h b/sdrbase/util/movingmaximum.h new file mode 100644 index 000000000..9d18bb5a3 --- /dev/null +++ b/sdrbase/util/movingmaximum.h @@ -0,0 +1,99 @@ +/////////////////////////////////////////////////////////////////////////////////////// +// // +// Copyright (C) 2023 Jon Beniston, M7RCE // +// // +// This program is free software; you can redistribute it and/or modify // +// it under the terms of the GNU General Public License as published by // +// the Free Software Foundation as version 3 of the License, or // +// (at your option) any later version. // +// // +// This program is distributed in the hope that it will be useful, // +// but WITHOUT ANY WARRANTY; without even the implied warranty of // +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // +// GNU General Public License V3 for more details. // +// // +// You should have received a copy of the GNU General Public License // +// along with this program. If not, see <http://www.gnu.org/licenses/>. // +/////////////////////////////////////////////////////////////////////////////////////// + +#ifndef INCLUDE_UTIL_MOVINGMAXIMUM_H +#define INCLUDE_UTIL_MOVINGMAXIMUM_H + +#include <algorithm> +#include <QDebug> + +// Calculates moving maximum over a number of samples +template <typename T> +class MovingMaximum +{ +public: + + MovingMaximum() : + m_samples(nullptr), + m_size(0) + { + reset(); + } + + ~MovingMaximum() + { + delete[] m_samples; + } + + void reset() + { + m_count = 0; + m_index = 0; + m_max = NAN; + } + + void setSize(int size) + { + delete[] m_samples; + m_samples = new T[size](); + m_size = size; + reset(); + } + + void operator()(T sample) + { + if (m_count < m_size) + { + m_samples[m_count++] = sample; + if (m_count == 1) { + m_max = sample; + } else { + m_max = std::max(m_max, sample); + } + } + else + { + T oldest = m_samples[m_index]; + m_samples[m_index] = sample; + m_index = (m_index + 1) % m_size; + m_max = std::max(m_max, sample); + if (oldest >= m_max) + { + // Find new maximum, that will be lower than the oldest sample + m_max = m_samples[0]; + for (unsigned int i = 1; i < m_size; i++) { + m_max = std::max(m_max, m_samples[i]); + } + } + } + } + + T getMaximum() const { + return m_max; + } + +private: + T *m_samples; + unsigned int m_size; // Max number of samples + unsigned int m_count; // Number of samples used + unsigned int m_index; // Current index + T m_max; +}; + +#endif /* INCLUDE_UTIL_MOVINGMAXIMUM_H */ + diff --git a/sdrbase/webapi/webapirequestmapper.cpp b/sdrbase/webapi/webapirequestmapper.cpp index 383a49822..99712a4f3 100644 --- a/sdrbase/webapi/webapirequestmapper.cpp +++ b/sdrbase/webapi/webapirequestmapper.cpp @@ -4543,6 +4543,11 @@ bool WebAPIRequestMapper::getChannelSettings( channelSettings->setInterferometerSettings(new SWGSDRangel::SWGInterferometerSettings()); channelSettings->getInterferometerSettings()->fromJsonObject(settingsJsonObject); } + else if (channelSettingsKey == "NavtexDemodSettings") + { + channelSettings->setNavtexDemodSettings(new SWGSDRangel::SWGNavtexDemodSettings()); + channelSettings->getNavtexDemodSettings()->fromJsonObject(settingsJsonObject); + } else if (channelSettingsKey == "M17DemodSettings") { channelSettings->setM17DemodSettings(new SWGSDRangel::SWGM17DemodSettings()); @@ -4624,6 +4629,11 @@ bool WebAPIRequestMapper::getChannelSettings( channelSettings->setRemoteTcpSinkSettings(new SWGSDRangel::SWGRemoteTCPSinkSettings()); channelSettings->getRemoteTcpSinkSettings()->fromJsonObject(settingsJsonObject); } + else if (channelSettingsKey == "RTTYDemodSettings") + { + channelSettings->setRttyDemodSettings(new SWGSDRangel::SWGRTTYDemodSettings()); + channelSettings->getRttyDemodSettings()->fromJsonObject(settingsJsonObject); + } else if (channelSettingsKey == "SigMFFileSinkSettings") { channelSettings->setSigMfFileSinkSettings(new SWGSDRangel::SWGSigMFFileSinkSettings()); @@ -5382,6 +5392,7 @@ void WebAPIRequestMapper::resetChannelSettings(SWGSDRangel::SWGChannelSettings& channelSettings.setDsdDemodSettings(nullptr); channelSettings.setHeatMapSettings(nullptr); channelSettings.setIeee802154ModSettings(nullptr); + channelSettings.setNavtexDemodSettings(nullptr); channelSettings.setNfmDemodSettings(nullptr); channelSettings.setNfmModSettings(nullptr); channelSettings.setNoiseFigureSettings(nullptr); @@ -5394,6 +5405,7 @@ void WebAPIRequestMapper::resetChannelSettings(SWGSDRangel::SWGChannelSettings& channelSettings.setRemoteSinkSettings(nullptr); channelSettings.setRemoteSourceSettings(nullptr); channelSettings.setRemoteTcpSinkSettings(nullptr); + channelSettings.setRttyDemodSettings(nullptr); channelSettings.setSsbDemodSettings(nullptr); channelSettings.setSsbModSettings(nullptr); channelSettings.setUdpSourceSettings(nullptr); @@ -5418,6 +5430,7 @@ void WebAPIRequestMapper::resetChannelReport(SWGSDRangel::SWGChannelReport& chan channelReport.setDatvModReport(nullptr); channelReport.setDsdDemodReport(nullptr); channelReport.setHeatMapReport(nullptr); + channelReport.setNavtexDemodReport(nullptr); channelReport.setNfmDemodReport(nullptr); channelReport.setNfmModReport(nullptr); channelReport.setNoiseFigureReport(nullptr); @@ -5427,6 +5440,7 @@ void WebAPIRequestMapper::resetChannelReport(SWGSDRangel::SWGChannelReport& chan channelReport.setRadioClockReport(nullptr); channelReport.setRadiosondeDemodReport(nullptr); channelReport.setRemoteSourceReport(nullptr); + channelReport.setRttyDemodReport(nullptr); channelReport.setSsbDemodReport(nullptr); channelReport.setSsbModReport(nullptr); channelReport.setUdpSourceReport(nullptr); diff --git a/sdrbase/webapi/webapiutils.cpp b/sdrbase/webapi/webapiutils.cpp index 0d0f5ef3b..68a9e9008 100644 --- a/sdrbase/webapi/webapiutils.cpp +++ b/sdrbase/webapi/webapiutils.cpp @@ -48,6 +48,7 @@ const QMap<QString, QString> WebAPIUtils::m_channelURIToSettingsKey = { {"sdrangel.channeltx.freedvmod", "FreeDVModSettings"}, {"sdrangel.channel.freqtracker", "FreqTrackerSettings"}, {"sdrangel.channel.heatmap", "HeatMapSettings"}, + {"sdrangel.channel.navtexemod", "NavtexDemodSettings"}, {"sdrangel.channel.m17demod", "M17DemodSettings"}, {"sdrangel.channeltx.modm17", "M17ModSettings"}, {"sdrangel.channel.nfmdemod", "NFMDemodSettings"}, @@ -66,6 +67,7 @@ const QMap<QString, QString> WebAPIUtils::m_channelURIToSettingsKey = { {"sdrangel.demod.remotesink", "RemoteSinkSettings"}, {"sdrangel.demod.remotetcpsink", "RemoteTCPSinkSettings"}, {"sdrangel.channeltx.remotesource", "RemoteSourceSettings"}, + {"sdrangel.channel.rttydemod", "RTTYDemodSettings"}, {"sdrangel.channeltx.modssb", "SSBModSettings"}, {"sdrangel.channel.ssbdemod", "SSBDemodSettings"}, {"sdrangel.channel.ft8demod", "FT8DemodSettings"}, @@ -162,6 +164,7 @@ const QMap<QString, QString> WebAPIUtils::m_channelTypeToSettingsKey = { {"IEEE_802_15_4_Mod", "IEEE_802_15_4_ModSettings"}, {"M17Demod", "M17DemodSettings"}, {"M17Mod", "M17ModSettings"}, + {"NavtexDemod", "NavtexDemodSettings"}, {"NFMDemod", "NFMDemodSettings"}, {"NFMMod", "NFMModSettings"}, {"NoiseFigure", "NoiseFigureSettings"}, @@ -176,6 +179,7 @@ const QMap<QString, QString> WebAPIUtils::m_channelTypeToSettingsKey = { {"RemoteSink", "RemoteSinkSettings"}, {"RemoteSource", "RemoteSourceSettings"}, {"RemoteTCPSink", "RemoteTCPSinkSettings"}, + {"RTTYDemodSettings", "RTTYDemodSettings"}, {"SSBMod", "SSBModSettings"}, {"SSBDemod", "SSBDemodSettings"}, {"FT8Demod", "FT8DemodSettings"},