From e61f922251f7b7bf5a5b58cc71f80285125cf82a Mon Sep 17 00:00:00 2001 From: Elias Jarzombek Date: Fri, 23 Dec 2022 23:43:26 -0500 Subject: [PATCH] Add Features: Proximity Mode & Add/Delete Shape Points (#160) --- .nvmrc | 2 +- README.md | 33 +- assets/readme-images/toolbar.png | Bin 21727 -> 27690 bytes functions/utils/queries.js | 2 + functions/utils/schema.js | 6 + package.json | 2 +- schema.gql | 2 + src/components/CheckboxButton/index.jsx | 4 +- .../CheckboxButton/styles.module.css | 3 +- src/components/Downloads/Component.jsx | 8 +- src/components/HeaderMenu/index.jsx | 34 + src/components/Knob/styles.module.css | 1 + src/components/Project/index.jsx | 26 +- src/components/Shape/Component.jsx | 317 -------- src/components/Shape/ComponentV2.jsx | 30 +- src/components/Shape/Container.jsx | 718 ------------------ src/components/Shape/ContainerV2.jsx | 84 +- src/components/Shape/EdgeMidpoint.jsx | 57 ++ src/components/Shape/ShapeVertex.jsx | 44 +- src/components/Shape/Synth.js | 60 +- src/components/Shape/useShapeSynth.js | 26 +- src/components/ShapeCanvas/Component.jsx | 97 ++- src/components/ShapeCanvas/Container.jsx | 67 +- src/components/Toolbar/index.jsx | 123 ++- src/components/Toolbar/styles.module.css | 2 +- src/components/WhatsNewModalContent/index.jsx | 46 ++ src/graphql/queries.js | 2 + src/static/css/main.css | 6 +- src/utils/math.js | 6 + src/utils/music.js | 2 + src/utils/project.js | 13 + yarn.lock | 2 +- 32 files changed, 675 insertions(+), 1150 deletions(-) delete mode 100644 src/components/Shape/Component.jsx delete mode 100644 src/components/Shape/Container.jsx create mode 100644 src/components/Shape/EdgeMidpoint.jsx create mode 100644 src/components/WhatsNewModalContent/index.jsx diff --git a/.nvmrc b/.nvmrc index bce43c2..48082f7 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -v12.13.0 +12 diff --git a/README.md b/README.md index bd60dd6..62945f3 100755 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ Using this app, one can: Sound is created by drawing polygons on the canvas and clicking PLAY (or space bar). Each polygon represents a looping musical phrase. -There are two modes: Draw and Edit (toggle between them with Tab). While in DRAW mode, clicking on the canvas allows you to draw polygons by placing vertices. In EDIT mode, you can adjust each polygon by dragging its vertices, or by dragging the entire polygon to a new position. Shapes higher up on the canvas start at higher notes than shapes lower down. Moving shapes left or right places them in stereo space (left/right on your speakers or headphones). Also in EDIT mode, you can click on a shape to display a context menu with more options. +There are two modes: Draw and Edit (toggle between them with Tab). While in DRAW mode, clicking on the canvas allows you to draw polygons by placing vertices. In EDIT mode, You can **add** vertices by clicking on an edge midpoint, or **delete** vertices by double-clicking them. You can also drag each polygon to adjust its position, or drag the entire shape to a new position. Shapes higher up on the canvas start at higher notes than shapes lower down. Moving shapes left or right places them in stereo space (left/right on your speakers or headphones). Also in EDIT mode, you can click on a shape to display a context menu with more options. Each shape is a certain color. The current color with which you are drawing is controlled with the color palette in the toolbar. A shape's color determines which instrument it uses to produce sound. The sounds for each color can be controlled with the colored panels at the bottom of the screen; if the red panel is set to the "Cello" instrument, every red shape will make a cello sound. @@ -39,23 +39,24 @@ When a shape plays, a node traverses the perimeter of the shape at a constant sp ### Toolbar -![Screen Shot 2021-02-12 at 4 13 20 PM](https://user-images.githubusercontent.com/9386882/107823119-3b50ea00-6d4d-11eb-9e46-38dbeb813318.png) +![Toolbar screenshot](assets/readme-images/toolbar.png) The toolbar allows you to adjust various aspects of your project. -| Name | Description | -| --------- | ----------- | -| Play/Stop | Pressing play starts all shapes at their origin point. Shapes that are added during playback will start playing as soon as they are completed. | -| Record | Pressing record allows you to download your project as an audio file (.wav). If playback is stopped when you click record, recording will begin when you begin playback. If the project is playing when you click record, the recording will start instantly. Pressing stop or record again will end the recording and show a window where you can listen and download the file that was generated. | -| Color | Select the color of the shapes you are drawing. Different colored shapes produce different sounds. | -| Draw Tool | Draw mode allows you to create shapes. Click to place vertices. Click on the origin point to complete a shape. Right click to cancel. | -| Edit Tool  | Edit mode allows you to adjust your shapes. Drag vertices to edit the perimeter of your shape. Drag the whole shape to move it. Click on a shape to show more detailed options (see shape controls). | -| Grid | When selected, the grid is shown and all points will snap to the grid when drawn, or when shapes are moved | -| Sync | When selected, shapes will snap and lock to the same length - so that they will loop at the same time. Shapes can be “halved’ or “doubled” so that they loop half or twice as often. This allows for a defined rhythm. | -| Tempo | Change the speed of playback | -| Key | Select the root note | -| Scale | Select the musical scale (mode) | -| Clear | Delete all shapes | +| Name | Description | +|----------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| Play/Stop | Pressing play starts all shapes at their origin point. Shapes that are added during playback will start playing as soon as they are completed. | +| Record | Pressing record allows you to download your project as an audio file (.wav). If playback is stopped when you click record, recording will begin when you begin playback. If the project is playing when you click record, the recording will start instantly. Pressing stop or record again will end the recording and show a window where you can listen and download the file that was generated. | +| Color | Select the color of the shapes you are drawing. Different colored shapes produce different sounds. | +| Draw Tool | Draw mode allows you to create shapes. Click to place vertices. Click on the origin point to complete a shape. Right click to cancel. | +| Edit Tool  | Edit mode allows you to adjust your shapes. Drag vertices to edit the perimeter of your shape. Drag the whole shape to move it. Click on a shape to show more detailed options (see shape controls). | +| Grid | When selected, the grid is shown and all points will snap to the grid when drawn, or when shapes are moved | +| Sync | When selected, shapes will snap and lock to the same length - so that they will loop at the same time. Shapes can be “halved’ or “doubled” so that they loop half or twice as often. This allows for a defined rhythm. | +| Proximity Mode | Turn on Proximity Mode to experience your project spatially, where the listening position is controlled with the mouse. Hover over the dropdown icon to adjust the listening radius. | +| Tempo | Change the speed of playback | +| Key | Select the root note | +| Scale | Select the musical scale (mode) | +| Clear | Delete all shapes | ### Exporting @@ -67,7 +68,7 @@ There are two ways to export your project: as audio or as MIDI. ![image](https://user-images.githubusercontent.com/9386882/107823235-73f0c380-6d4d-11eb-94c8-5c8ce5312639.png) -The Shape menu allows you to control a shape's properties. CLICK on a shape to display this menu. +The Shape menu allows you to control a shape's properties. CLICK on a shape (in EDIT mode) to display this menu. | Control | Description | | -------------- | ------------------------------- | diff --git a/assets/readme-images/toolbar.png b/assets/readme-images/toolbar.png index c7d599ec19ab5f3bb28c9db48454d0783b042cde..7f61f0bd1040b89f5d0628bf074542e6cbaf5821 100644 GIT binary patch literal 27690 zcmbTd1y~zh)IW$7DehLJXp00b?plhM;=u`0+}#SrtqK)IX}4xQ&Ez|d`|Wp4h{}e?yb~&I5-p#ux*Bl4E#+EtEq;A z!+=;yN~*|7N>Zyh+L>8eL*U@vhJDjS0msXdx44v>yYujpoIGeam=3+e)100g=>v?#_0hM*Qt_Uo1{vuX2j8n>_|f0 z>GXk5H%jxTeJOuO^JCVxTNmQ{z~`}+dxl^OltBI3(??dMW(NB;YT?9`BbPk z&|fMAxtsbva1-jomD5&1#le^yf~V+1do9Q2Sf{7gGaXu~QyH<6^v|^^{U#r^zdy2G zd;EGybx}6jdD82y{!kWN>zF59z>c{}X=$cV^m7uYB_8#+3kqCL__r@|AH60NGM3wK z;2An?C44OiI?ZrGR~pe7_8LbGzBT`I+#izA>X$q!e;A|+Y}j{cIC#x^ zWlAD7yX??8bm^i{RZmT}wNH-RqncO@IKHxJ&?FogbHlR4Qb-C)ur-*MvAgXs90K3| zk|SHiea#?)y-q{$Ik>>7XI6TQPO*8~M_c@DqnT|f3!9QjOuV!QE>qK+&fM^Jq(QK^ zH9R=wqpy&T1`aeo8h^NVB_=|xCy@A)MeDOn`^(ox{Yc|tSg}=?+!w6|J74TuQ5<72 zrCQaqNxy&LbJ6H>>EBEK0aDvp#VNAsQ{rX zU4A}!fjw4Ip=&z$Z47O^j?m1`x5j*ZVjuqp*pMEQy`nfFTW)@HP6Gi8cA!E7Iv}zG!L6&UrT(E^@y}(V zOzRrz8W1yXa`O$;N)rQ@o(Fk6u<0FJ!65`8A|lE@j!Ab2`)ujL2Gj3yocOQ|P-Ngz zSn*%2fBr~O;)%07Cx&wH3f~ak+Mjlym%<&<4+o>Yl@A9UPn;PPGFOv9MR0<<%hERIr z5E%6VDmj`|d#nW=rJOWpthH45t1tY$yl+V>$Yw&VWk1GJ#nSf5{dqAKFfT(%z0jN9 zi$Wl2nGgRldNw;-mFJgu_K1`-=l3Wj*@zM8EypT^Vu{NP%q_Vy1W$^6sq}0glGZAA z2D8s~#@3M$UB;GLL<~I}Mmbvi8lQig3?8L7hr1XHPkW_=a@i9dJfE_sOAA6j+P)I< zCT+$o?Iu6sxnp{OJM0q0-$LU0gx(g@N~=I;if@MV4s|_9FQ_AE+@9A3cM&H{ieWTh z3(?Jli6ewB85Rjkg2hZr>`{x*Tzq*;haZRj+MhsvHU9Zq`X87-gn!`wVEzFb6RaSb ziQ7}cV$A!J^T*hczb?T$#ryN!*Lmg9oN3j&SLFmRAw-a85XPJ&$FMQ!?YtdTihTG9 zsgGDLFRMT4C<`k#$@}EwPpdx9o>ntglUJ2mw2WkKO>f&f=WY>f zf8BjCQTJm!TYj{9YJKO#Wpi6&hjO&3U@os`ggl$L5L#NRP^xj5vdHBV*WBcr*sS9B z0JkME(`?A>*sOBUHBpaym#daNh8>z@ z$1Vz4G-<0st?4b-vT~Tunu{|cwRl|)w(@8{;Z1lM(ecYfy=+#iNyALzi^jXceT{vM z`$?=xxyi1{+oHo66`N@uZ5}Hd9UD&@R-2x=q4MI&`>tr?o*MkQ;#xtI%gMzykuKkI zpX_@s6cdy*$uE+r9eT3$Cb4VIJxo1TN>Yq9Y9-@ldHqBAmC(xRy0Pmx^qtjx8i$#^ zF^)L)F}EMiroVfWOSmG{<29ltgDYvdaNJOq|+qgaqHgS zwA<9+W9`G~&2v3(N?x`$-9K&agVy}A`P3)orup*pg84{r`7)%X(67X=__4Aa%S775 zE!u~`kfIfPnsS7+_xS+oJkd0%DPbq}&*z>b*7z(Ki-dPy*E;)F_Es8E9nimoyukA4 zKVKVKuL&~F#Ldjf+&8*0k~1RjI0(@VF%Oygd@18DJrHdXMkggBT@v2d$F+&({-$x! zL1b+s+A1PTnqJB#(vxKW1#65gWlS^`ukYYh1BWZeSYo#pPJe_Rz8BljOIy$R82nKD zRfZ2r?s%dMzDjpVNHXmcL9;=;VK2j&@eb+u8D7Wh#Jj|e$6LiM(d`n3+@U~xKK%7 zlU9>bU6xq~o>N(fye5TpvFl`9b{G|Ywjv3m967g(|Ipb=hl z;69nY{*>O7VtzX(6DPM#tUoz__WKz(&Hqq+*^zh3u^DH{9ZZ>g>Gb7WuRz|dq5*G{ zCMEq+ju=5aq2e>-qa$C{?fg59>kRg?vWlaH$Umeg<~xI5hZ_0%JtoJrl_T0Cn$x3( zi3V&_P2QNVq@XzQJJk+LZa{{tQti0>M(;|V;iCT}eowpsx~rQmc6T}6wR_)esc+hB z(Uv8XKvKA)|Lam*7Gn?ANjfS+p{o2LsW;$SFuB9a(1k`bV^artz^E^d)ITvbNyy0 zd@DQ`vxZ#5_6nxjcjc@F!6`SS5XvKyNhONzomxK~8WulHaiw17dO}T4Q^f?^y%`7P#-p4 zx5#}yU;3?IeTA8te)ZiAt}C7c=h5VHytWGpEi)eS?z=PljdN^%ukUgmhdwm~HRN16 zAAVcnZ7?`5951~UXcCMOM)k2jHfrw>j6sS?6V(uUn6BPJyO+CG>(6^LJfBXnk9F95 zH}_+yKCM6P^789l$>nV>aWos&J?>pk*J4zEZN0)kcoJve4Sf>;(6A5Y9Ue8{^E5lH>~MFn`(xPA9kU@H^AX_J%f|-+ILJ z`dl9HSMMe+N7x+i>QR283bjq$x4s@F&P-7ejtSUCg+qcTgL?*S z!2=Hwc=G?+mVti-hxqq(1UR@5OE{$e*hdL?e!9g1kEdh)c}9#2hC>1V!vh}f83_Nm zHwq{N@jtiW%z*dc#MLC_26GsTd*2%)o`9yDc8+ZZD{;jqX92^n-(*s`aJ>3!T z`BRo3w4AjR6$DJ|Y(Pe)cE%8pyN&(R=fDZM3jmuo5N9K5cN=S4Cjob1n!kGp0NYP5 zv(Zrh-No5Tm_|!cg<8_i5kmb2#0_Gn5qVBcO)cbTY9{brO6ETf2mTYLv2b>_7hq#^ zb8`c^ae?d{&Dl8k`T5z{IoUWlS%E!Rojh!vjoevnooN60kpKJ~DTtGaqouvGrJXJH z)8`r)+qpOk)6hH(^k27sz9+=p^1ny2b^4Ff0#15Ifs{eKv5Y(9^2|DwggL zYb_~D8z5%D7$O{eTzo=*5BPt*_1{DObEvu##8J}D1~}4LH~*gp{vA{M ze~ih_`9FvJAMgCfkwR=wr~W@e@lTxpz6u0d0G@#? z`+MU8{=NbpPtU-EIi`?}ECzVim6HIrx68g>-0rw@E9IWoq zC%h$iXg;kZ>7XPnEva@$&Q{q?)8$g_xBBn%$xHS1>&^7T(hp2#EA5|U#F3=L{?%Pa zJcH9|W8i^aJ|VFyg1C0|ral)n)c;@I6Mv=fJJErEQvb{StG;^HE_a``oRtzb3b0?oR2y9 zmzkV_j%E?n4;pY5uJ~7}@ioey5XAf~TpMO@G;^SMN&By6i)Mr#iK(fn>0YQ1+~q14 zz02|M<|_!P$i9yZTH5m9iDsuSo7XHfjf z|NYKsG)vmm_&G#<-NjPFetn6vpT#5?lf34vEyeFOl_T=cKR=NwzM6dNXOgk`qae1< zX`b;;2?-h1)T2}a@q-uM_p&iI=75SOC<$HT#jF*iE9aI8Hvj_FoAgELS%~|k`hsvD zKVXPH_5&5c^~8=ck(}Udb@&#d_DWovbzS&in$f$=N;HnX$MfMK-;tCw z+dqPgiI*?DKap}=P=vi6O5q<*ah>nrcrv+8`!@IIlX+}noc7CHs3sv33 zORHdcMXC5?{3_C}Eb2=HcLfiM5NvG_xh`^jAWWwC@bHJe(-11nx8T! zV$rw0NjM|#?8i>?7GG7z64=W4X)(jq`jwRYc_7+I>?LLyJ+;2R)8Lyx!02)2Pz;xJrToEIS~wsd_e*!YC8l4 zk?a>-lI33f#>g>SCj_>}qfb7cJS9H= zQcX55vo%2aPQ6@+{rxS|c|nP2!EyftsxA@7S>i_sFj@Cne|lFdARa~Nweaxs9U_+z zuaQSjSP?;Q2=0&`c$c$~j~#z2EBxH$A-XJs>bWKn0Zd|5P z(A2S{_sbIKz^MAH(I{m&&~H=Jk1|HiWfu7=Ld+h4t?4Yz=Q93R`7g+k^~kf2sQKVY zxKU-?-T*P$Lv<{uoI-mwBP#Ei6P*^r>N}xVE1bB>x-b3+lL)$6yP$P@^?5oiw*+)y7dO14-zb>{u!kSrY?AR<$-bc+>IYgS#vL2U?+z8@*jtvZM;-&23)G_akRwrEIXU#TS7Vtm%=(O)Y0gbj{EPQw4daw8ubH8ej)$ zZ5^HjOaW$Ec!tcTtJR3Gb**(yT2pDe@BY6a~O`h+XEqy2R@{VAw zDFZ`nNF(wNl!`zFwTE?^Y12*{gURM~zob=I(&`1b%i7+Qw(+h@>P-mX#QK-MCiP8kU^26egnVc^-W*}nYR8S2u-J=#u`T_b({`72|sSua2-v;YYQ$}!XKoG zU7lbmKM+d42&C35Ur2iN{D>R+6!lRjN$ZBcaUWsj8N^v)zyn1LO6nCWqRo2^=wA%c zgL$9R92E+TyK!l$g;`1oL;p(EG(h-L~69`))e zJK(n7jme9xk43y?3>6zk@;+1_N8=#{Vr=I%H}^fVVB8ArEv$%sthr_BddO`yxs(z0 zvoGhR{zo`I?(GYur77S17u9YLI|tGkqzKJ78A(-RDTOx-nAMKmXsQWJh+!98lJBj+ z30&hYn(NfmE1Y^FZ(#~OgT(UfIWa2rYaYB6JN`NSC|$o~S~a89L6yhqd(KE7K;sT- zEVyrJmc2^jRyQ05su}S>mE&LSlTwYe-^bc(C!~z{(BU3cQAn4VLr_0mxBvq}s(7m=Rgmwy-@KV<#8BVRiVsP`Uao!y2I(v5fAb3%{21)*MGw9~ z%@G8xa8mFZ6g84-yR>?Dx(Fe`8}Mt?VTkKl-T+tX4eB-wm0P*>QvXf5u@eW_i)N*Q z-`2rg9u}EBmUc@At}$($54$#&gPq~Na}zq0(V!@EzRu8^`6m;3n!{70 zfva7>Hk=+o?kN-B&kIG@T>#VP`W={kE*AOcc%c3`{Q4L=gyxHm--mdh_*XlLsLsgn z20l19uho}=7{K70m%rXaCX2KYk)ijFgt}mnop&~G#o8hD3i0;9w6Y}x$x~%I_ng|A z{)U%_Vuyc=`g^gOtrbph1F_Ft%hjTZl>%Z<-!-_FXr)XCHp_dx!YSBM@q>B=Ky{h7 zKvr6bg71Mj49kiR6EnwoDvIgv&lN4&yK<*|*S2oys;5QCJHu9U8DJ4m6y6W68_4@d`~cg}DBO^3|?T{>vM zI#=Ujl%C<@1h^yMf!6A-F5dl3fyVgH{hIn7T|(y*Y8w=X?jfW=6%-JAZL8F)B?=+EeXGJtDIhRU}{- z!1=C^0q^J=fHI}L1xP~08WQ@`AMZBJPX6?je_&8ZG#nDXoNu4apH8+gWW2vgb)D_g z0WW&)7MlS$2($C!-dD4+4e;!hQAcpnAkSP;Ir>(S6hiYQ6Ne)fiXKo|I0Y4eE1Wce z{uvq+(ucMniwAFTyRzek29+alQ9u$1^e+`bz5n{tP68hUy2pVOaK>8rDJv^)Pr@2c zhXe-~+?H%=X3WSd*AsM!Wb4pNxcwfyojNdi6UprwM;q@>`Y}a0QjhL0=7#rx1H{3K zZ@g()`BZ^~+*+Um)0F6WtbZe#SP7xBl?aEXGJuUC*T*q_^JkZr$$D-JzrC+IsCXW4 zrdkF{{qC(!MBYzOs^D+vx3 z)h>Eogl!9o-W_#pcrD$gX`3ZM2C#jP5G!|n7V?N^U#TUfgeFhpXk`0pF{xK~aJ4eo!|twjqCsB7xI>2+KW!dEIQF~Q}K4EbB#B{mbsV*Uw=!$cJu zcYrG^R0;MpC4&sf))fS~w+9WwFMQ5MFP6knD5@qn?nxAuvHh+BGZlN@x%5!D0UYCk zTk)w6r5eWL{_R6@_8fUY9|{=DZ2GHt!X5EQ514B53;U{N}*d*J{XN-+#wD&i4N8 z>3NZ=oQk-AFPvQaoj)n4RaSKhcpz;@qbDq9tL$KPfROMRTjVO^dLmB*Ld2qFI9Ixv z?zdP}@r%jqa>0$)ss$_k;B7zL9vU>+W?MR8j%|>&-0$1j%v{}~|dkwJZYe7Z4% zh%fohO9>dk^_4x*6jsIeR~G6Pk*)sf#r)TQ;){R3H{l=?MTbTkJe)xHTP4@ZXAgmz zv3*iR9wd)^GsL$RDej-%ji}&F*;jXV;Hc+H-ipT1MVC+)F%#`(K^9Dj>%h&#G zu1MovhfPx@9xbCX`kRFf_$ccAHslr$vv=B&vs_kOcKJtyk;ar^ZEKsS zs;7QV<*0iv0|j88u7EbIb1cqWlWzqYp>PRq9+l#x_PWsvGhBrk!M;Z>B=fG|YE@n}0Cd+7O^*?^> z8u%FWxYX(dzl5MAJc-N*rgZ4U-H4UMD#(=+-MrZ?t-X{Jyq_8Noh(t+CKr9Zzf~uC z-KRV-)$-uNyi?Nha5F0T+lEEnZO(>wSI>NBqTu(vb=lA>qNFo)PfyPTfyrV~07_bb z{=x87|0@h{`dMBCum6CF2kZ1n{8?bh& z?T`s!mIQqBG!HZNMhsRU&Z*R3PWJwcKLA@bCV|tpty>?!PjmIc5bIP-g?4KY2GJtT$W>W4b~y}+S*I;7gqqHDOH@UcPeEJmr<5My5Hb!vGhI5 zrc#%l-EydnlBQ4isoMXp*|KKI*K1b>n!`r0NDUA)2`9&WK*!ztK@T=F*GeFu11Hck zI#U4CXvh4IuF&eVQ3qbb5*XOdrYe)&ga`I;4T+Jh5DLc1Vm`cIOE zAfh{j`IKUJwH}qXeBSOa zxN^u5yOqHI+apH!GtSnYdW3WPIx@MnVRs~{2^FUNyUD`NTltjr415q9Y#E_}(&Pp9 zg^^DvAq~jTigABg!gVv%Et*h98~b!CJErEc<5nS=%mtU+tw|~{?mIs|5S<6{WtEw)Ch?}Vg#>B z8x~hj{v?My-e2+#i`*8u+>^QC#e0&#^+=TSP;V^&<#v)Yj%SMa7d2P_%1Sd^EH{;FmQj~z=KhO((oq5>!5C9BiRmgcU+!u)5$2JdgUT%paOu`_Eb7BLs)_|EF@|} zOgl(ENC!!G^2T|R2IkVW|H4YcnDrBVuTAZO8KbH-Y$ez=)}^qx=4S%~=(t=*y3>@M zD4;Sw){V9hOG}l1YTm{EFmz6Qeo&L?+XhW-+su;~F@u~m9Z)-6vd$S{&kuBzz=#>o z01+;IRIS?bi)2XKr)R-6j!$UjKt0>iFskly(TjW$()eQ8)n{+Id{hnD1}YA-o>+}b zemqTow4Rz1T8reerdrFUy3e0+>}N%(Tc-x}kpPJDHoqI>%g-jii-E zyipr_PciD$0GXSr`LUuxTIRcLj?>74I7XMCnZnYZ(-1YiW{`(ZSH#zXSEY*?CcQNDiU@|mf2fXaHsz`7&}Q9Q?RB8XgpG*UiYMPe)7o zb0hf-r*mt#VGo?(`Ge2pf_XoKF?AM~2kd62aMKh0i4F&*u)OO@}^q)htJC3RI6(<4Rl&`!&ffZ4cF@K7%iu1~@hV z>Q|Je*`SKN64ytgm9(mAG^OWOyhwwu4@eAp0Fs}tG=
    &yYkkbYlgwI0x|*2$|Lu7~}|8Y!g_eWC!5Plt_ewD0{{6fQ3~ z)_(hK)uX|st?X$-Z=T~J1osnud`I(D7u$DvP~DGSS(g3kE15Q01eZqi-QwD`)T$1t z6z+hFLqPrD2GsrcZvVDE=HUk*?C2C>MJK+9y@o6vq8nT&CLIt2Kjy3c_SPnqxS z7_`|mzWrW^g;0tbDM0Bbw!~3bE@?$`n0}sXvIsvu^RaJ7oW|KcaISiS1(~j4v7!&h zF&klwX=QRFOKao+QD;>(@wVw-CV3F1sP2X|^+1qM7)7t;bgy)N2Nb#4y}TI5sdW@f zNRmf+VJ{XcYo!gyb>o?KZYvd$S@ir*@f24g; zrn=G2{lwDZ8csO}PrfjLqu>n*mB5HATYOFR3B!y%qZv>R%$pS6JUoG@#JbQ|^}q%3 z;gYxV<;sbAL}4g=vDpcIsnct>9xA`ay2ppx!AV^^8$BBuQ_iE)2!p%!a7ki)ccYG7 z!VMRLg|z5WUsfw0()X|*3Mve^Y-PU;3!XJ=PhTzvV(ZlA1NhXddaI0*;b5G1+7R({ z-_Eb0KK(?gB^~=Pzqe2JP01cw)o-nj-s{W;x~8+xnVuUXe&nx`Z3(-TrDIjE}4&nWUYLeb;%QqCzD}_(dl`nGa>;avMJW?g`MY6d+ z{XOVc;qLgKq~B*yVius-~p<6ZPfYQGi6#m|_}V!l#ies^**OqH~DFO5ENuSydj;ILfNW zmODIvD3-^A^jt>|X#IsQ=bc>TkUK?*H-=KJScMW(`ly#facTE50W05kWy7_r zhChZ1jmq6G*?d?%xJ_{yDn~@XAfT=~vC$|^?NJ12-1E-c2WndtSA#iOuWwf)*mQU= z4pynIT2V0Ki^x(5`Y%~s#v46Ot?@$B-AjA%06LRUMIN@hsEYd4Ym)E7R)$m}}JB!<++~rw}v8)9t zlCCw0NkGn)NT3bszdqg6EpKIITT4SJN5K|6n~_9?9thkpBS_<92)Rl4;Cxr_ySlD$$SRnMEB(MwT;BgY{q*z-> z;D-ysMYXq?3v1Ji7Hk4@79f0C+bJxqvCaQ@nuz@kcB12z=DR7Mk^GB3kl=331eUbC zIH-Z?%Ij3B4K&dBi-2m=xb_+Ibelw6VVl{iALGvjgKs^-0dXxzL7HZ!l^a^`Tbl?H zC=@ncE;A794gzSNPWqJcv`KRji8F2aS5Z*^4Rtaus89K35+ihF<3%}Tsp?-meU3&c z_exaDu+oghhb+js5mX+`J2?gWW?JF;K|ugOXhn0*EJ?P_*JiuENmlQ%aPe>?R~g*Y zY_|_bOC=Rnbg7t;R)1(?Wr-9I@r|*z(@&mw7p_G6eN)#c*SJmn5;&8N5Q6~FlC)F{ z0>vm7LJv2edzHmEvyu5663WUk#Z>O4r>KAZlb1;LjNp?O3PItX5v{Cu+j;4>R6x0> zRrh*6hG$kiM|FnKPt_=wuRuLiD7j4S$9HgyX7*&U@%Qc;|t*)BR zK;39{KZuxmY*abm`jt(#6(dWia>0wESt&}{iS%Ckn~z`?DohHB7n%8dAf^O#Tyc%p zMO2kDX32GG#*eE^1eX#;v?0&%HrI?3;04j3-Sy{D&!#p69oF6^wf~H$cJ)~~XgI9d zMS8!qSJr_QsN`k^i%XD?If-#^Yh^|3DdOUG+4&$iGO&z(yQ3ZJ0KX&0;I2|bN@r#m zd{PIX60CaPyuG8}rKw;8*LRde$1keThK#?WDF9p|Z{MWqW{%-z5922d9RD#MGuciG zhe@}Ta-l8l6}!=-2d~6L>rQtV`EPtRYoOi}$ng+%{jK*VRv%z@ zvQw|_5B=;=ep~S3fKUM-_tPfumD5=V4nHmn?}D@1T#JPN4?iVxC%{rrXFR5MU!J8R zo`9KmuZy(Q@x)Ibf0vpgN_B4IMOj3D8)N4URWHb&0YK`6kG6X*qX0a#qn=E3@o_7`~^Nrvlt8^6=wnH zMFE2+fh&>hLR?3!2<+1kXZ9yxMm$6ZQs!&4T1h+<>A#3$fl39W^%_KrbB1@2S`l9@#WT z!%jFfq#^6~Vi%8sT(5GoG^UO&b-ay%+$K&0%ThFU6*b@N)GTcZ?Ms#78$XekIy23> z@Zx9A57(PsC?ZyCU+;MX+hkujNcG#rlRmd(@=sWDPbCL@X_QEFw+1xmKDExLMu6-e z;FtX@av_$_OCw$Lb~Z&*GBl1Y3k7JOt|>|ps>rAnpIZuyIlQEeNH}kiXZP!|>W~=8 zK>&%nNzSoqgLSd-0j9-FB)R3Rc{@fZs_ z?idrJ&FosTWEd&9Fp-v|i7qj+{bY31q#|BfFiUF+yVw%drCTU_DAWPb5)@^7yNgty zL<}k@EvN<5FUMV(WXHhYrR`%#U{TI`s9OpT;_kYIVHU!YY>CYZiO(-|5Uka@`Fdki zjSJvDfR7%6(~1i!@YA{^R<_al!nJ%bf7%gzgRVM;2A!}|u!S@k-b^VF{5Z(M9;_^j ze&u7QDa$W5VKreg+UiCf{{u&C?NdJ&(pnVn;E(AezA-ldI=_x%AD zLW#9>dJV-+;MRTcG?w<##3Gw*eiBKalnKLF9$M6M=!7ERxTJUh!m_n!xB%60oZK)A zFiU8`fwtn(u_R;oAH z85d7lt7bmFp=3(}_lqvm_J&3sbb=u$&NPQ$LF!LU-z@rTZL8Xa+DosR)9>bs9vcH( zT$>(d8`r7B=9eDn=4L}F*J)gv7l3#~nkejbv8=+5PQ=2lj`oCd2fxTtl5+FQvy=zb zosG#4c81{xF6PaBd9vip(tR$rFKl!VYJqo>0ne~}y|&)3?!H%ekG806V9dquYf0{7 zx>I`PWwP^-8~)*KH*fRkgNWzZR_EHn-LZP9=eKLX9<X8!M&!oAY0joLN!fc9A36Gtnbvz( zcZqW|#VNWDaAyX9iqs#<`6e1@-0hSGfPDLYxZksE?mG639wg;5km{~A#rvdhi6alFeV2ybZsCakJrP=^ic5Fg9zYtb9tPYo(^yg1I}`HqTm6H_V}j$af=y1 zjZn}b2uO4%AYH!aB2ue8@3D$Xr|%#}9yx0cL~icV2rx3rjekI?0St2*sWtUeumb`fW-b zK+gx21ourv0AiS2ZqhT`;j}qa?O>0+BTTG$He3H#?5KVD0wevw<8&jb6yQc1=AySr zM%FjEyiI-4miiYJ(9bO%(Sjp~PX^$?>9D9>DMPBk?d(*f{2}0_nEz$$(h)>6YXAyP z5N=)#XWoG6p3nnI$@R|@UzhzpdHs{P6B-*W1;9v4zaEY8HTAunTl%iqxJnqN>;^a! za}U1&X~JX?Sfo^wath1{+0Cg{QF-w7lk^qia}u9kwU2S13GkBM2bsqW0PFBf>3bC$ zfu#+$08hF45XX{w1f;5ONsKM9MSYumdwASK#d~|$(qaSk1uW?A0RiwGjqL#_losRn zze{~xV4~|KnT>wH#Bl+cb~^|dkn_VnkJ~%`72Wu$2rh?^UWetg?Qt8?J3xfy1gzH5 z%u?sWrps9lUBd43R8DlChbKk)G>6TnV4bJxS)=BY<9L4$cpkjz4`+EHa~Qs0z^dNU z+66Mir@tEFeOyiTvLDx^YO3kLWup^cxyontsobG1`7v7Ynj z(VFjR@)9>-y!H>yEj?VUEM0Y+m+E;~^Cupyv)Xw?7>TR{T$#e*>&@Yhc%_PK$0y5b z9cr+c_M~Q$?Y!Sn*@)zsx9>=>iiot+r5S^Exl1 zZu0?A65k^byv5LdD)s*2P3C8L25Czz*LjDYiulFTs|O%lQ6xvsf~tij^*^5|cZ1Jd z#wD=)2iq<+NM4|S&bo3i6s7pPg&cyOj~?Ml96a+15TKfSmfe=6`vbQPM5-%)LN?0` z$&d>28C%y@iOR5^$0$4R4%b#3ZT-{lQ=Qm(zHW$u$UBEH2X1j8BJ!Y^Ld2=hr@o4Gt$I4F{ZgDUQF6kj?;E-JH=l zK5|V)YPJo#`SNFAzGLdn6OY_KRz188l4oeP2nojcRotd1R0CkKfq8&l1@nZ}bf7^M zwz&F~-GOwsvWQQ_p%*$Ek#rqH4>TJSvu~&0q$9N(-+TaqXm)Fc&!E5_dPo94I_&1- zLq7WWm^~-}$dMR6&XF?&MELeT!wl$9R9?VrZx**UfP7Qm+eBkbF^odz5KtwNTW(mo zFGheM+)v7LAMJ0ibDHpc}j26@Au4$;kD*bX?$%P4s8#p6=BX!NhA|$cAbbmprJNHr+Jve zaYxt^N9d2V&S}zsv1b5*O06O7h}C}vWG15?BJleV(jQreP(tXd*$^sUnB@*!MD$4V z(w$kQ{YtygdgfOv9za$C@@94gaz%s`V zEPcUS2kt(NZ^l93fyDi$`-bmKZr8rBIH$V^t zqRs)1=b}k9RrRXdC4>gMgRK$Ox}uLkqe`mL^jtNL5o04}>rrjvm?5JqQA`vSdX{r+ z+NR|%rsqErM4SMu0IwJ>U&jnnEujheJi#fdj zx#u%+B#NbEEnsmnFP}tef0l=hPCZQbh?r^e7)|frEbePnkH1S*#sd=@-1S`a;uWM-0P7L5mS4~ zku!gJ`RBH!2=QLr^NPS~$kTN*y{C#6`f5}~eKd|+Lk=c|;ZJ32oQ;30n&9!^O7s?0 zAJya!3XvRlH}^x)9!T)(La%Ejq6QDTfwzL^%MV;vW@EpefiwBtNMZLTIN`lvke}$b zhCP4CfGpkeWC@Q?iZ{hk&sy$yZ5NU5bUHoQwdb3&5Vq$r1$?|Zyp}7MxWv7L z!E3UVIm&qreodbVD=l#vGa1&GsrE^KuoCzjlHGtMHOU6|JDMFO=Veu$@5#~U80tSo zRVdudX}0jxH7l%UE`68wcOVWfKW2TV^*TgQx$!;-6+i%;!r2b>mm2MmukUu3fF#z~ zqP4!ofD4N_oh^n9(12;QIJLhMBw}>v(c50UZm0@@u{cSX-H|aIr=l=}CuA6p*ETQ4 zFLTkMBvyt<-IGez*UJc$$|Qy5U9zue)!a%2{Mt7G>)Wnj#Ge(_zT-uG>DJ#8qXVO3 z^U!Blyn2(|{DsYsd*kN) z0T`^tW!~zIa9V6zNL#zi4yqSeiSuBOr1v5Pq_ngu%z^K3tYVcICf(H0Aoby0Y(f;w z3=<2KI_e%nD%*x9n0|Xr&-Xlt^a92Uoxgej66dwWq%sv4$&Z&A-ek4saCIBL@2}Q$7#Mnh} z>(}{QB#+E249h{#Tp*g5u5p_9&_~PLkp`!md;CDS3xSy7wpH5{H_$;AHxk>^ipWb_ zROp0{AJA%aHC%f>%SYlA+#UsgGUBpfX*`PiL~gS|!>(j{*Z1qJYD5WUY8}3UeA+`o zOaH99`Rs5F8Kwjq;A&Q^kMZ55LU73kDum2G6(RtA7#^lSh+hGet%5{Nv%>{$#O^`f zrACOYzX*v;jQg9`WQKFnd$f(Zt(H;cj@D1VR{)d{_h4GuM0qt!1l>>)A!h}zmL|f} zX~^Fbknq(ezr`Aa&rHnelRGe%SQ0e711iO6aiIP7eCJsHQB1U?`ZIYbI?!gyqdR{x zM!00lw;bYg_&2dyl&!U)X>xC+sF}TL__A+?Qlx;Ty_$tVgTraB;P_4MMH3FFj%_`} z?F!m*M{l5u@QHrAJ65h8@>m3mBtihUaBjE#T)8q8EY!QAAm$8>FN%xQ<;ElKDJ$EP zf|=s1TyPo9eey*I#Pz;&2&2bf=e#2Cf+=Re>iT$BhhftH)!TXhQ~m${-^wmqM#xGT zhfri^W+yqwk-bNbEql)>>yXfrnVr4s99sw{*+f<(dw(8JeO{l->w9^Bf9U(e_wxsI z@qC`=W8Uw#>$MZlA*H91@b5{D`beWsF?>7Kx{EJx=m$XrF0Yq)Ii3rdw`nkDGUHqF z&BU;eYIm}VuW)`mXR^$a3U(dmZJ_#Ca*qF14utSVc&qie6XP)_{4@L8n&G6?-kvb# zi0GoaIHGTA7#s!yGlM*^RV0P0!+nL?BXAA;!i9W~%dtz!%uYkPoCkHu3ZDe8RQd3b ztV+h(Mti+-CirIzsD5S!g$9Q-FS)yl2603LZU=%&Vck%0tdx!Awe7g=z(m6D&*C)r z9eHMnVd^S(vVDl}H_CKuYGfinp(;8zw2Xf9cG1wgzB@E@bJ)6du%<~#n@vAO(-p;H z1xS(3*f$>JKWg?rG||8!nTl@n_e~_~5*4AcmGlv_@ZioX+1b+%Wh@qbIlrEOndkg6saXO^pQcbTbx3t=u0V|4k-gKlC2gmXpj&FJDw z(b;|<{mDjj3{+DX{qSqF)`xp9806aV&G`{NsfjgDIR*C+Sq?R9+44e+I&~|-B4(TC9%i)i!fKFc77B$d*P?KN zwKyulE^@7!w>=JMtAKK1Sq2x(3hL;3Rr4U2^3CNhIH9=5XqFBA%6JfGr0x%; znhH4(P}ZvIkrrdzh;i~cMK?IxfR`ZHjcgr148yCYv>D^qHGfK7FcXi6VmeO^dn3B# z#fqm_Oz~Svn+FRvgT%oZ@)#tN)x&44&!cc=uI-g1IQ&@NzUY?PBVam|#x0&exIs6u z_CRXzI*R2{D0B0yYo06HC-VXlaPYmqclDi4Y~87A`nYzs!5wS=TV1nwfj48VL^j@m zAosp&C21SCb8I@lXXu?>F89aC4gKxm6ji$Y72b1Uh|^iyLid;Vu(9d}q@A+3pLrxOQ%>_>SR4zID_S|ekLquYvL2*&#mj`WMq<5(Oh zb#f$aXk>lKmgjd}YmOmCt*S@k#HbWN!GU5@Wqzv^COSXvVrJX8iwiP|UUIZ?+nM>vosxN# z52P#rLGeUJ#7DL)t4^quSO@WJIDbCyl~mi|e}QQ-Z%U?D9AxPp(2 z9LvzZ?2aN-aLF?b1(xD45!ws~AA7}ep0ua=ns>L2R@fLRMAlS`leg695B=))32YPM zSTl_DArDiRpCUmiP!a{Bn+w9U^bzI*b>OBxMxts+-260__Awmg&L_lvX(FN6X!j?^ zp9`_@X53+h`u4e*koa4vXvYODxgIG{koxIGXzD@MNDrf;pt``KU8GnBZzHM~>>m-j z1#-8F(%J6)#N_g}{dVJ{_%J&*_21`XT?hqKGl{2;zr-IBy4Yc_u&jXLb?yACKnp$2 z*EjH}_O*#(qi;&MRmvI^n4>>uCBfV!N)=IoFXE+rw9*w@Fvgtz>f^uO z1H`w=*$!*+38!20zR;@>zI5*nP6RcP2OCWbc`B%eNK4}O)Q7eb^4X`|!DKoPTAE6o z_c)yVJYSyF01ho!p{i)oA0pO0rF>aJH#M2Bdbu_))`D2`28Q|2IpfLINQvX05Nr|) zCHJa$GWBZS3eW>lBMW>WZ}wM9Yt^;513=&_DgOW z=2Hg%4G;lXOwLcS0UH(5+wP`T-tOg}b84q#Gk6i^tFajxU221#tluS%g$^cGdsC_? ztea8Kc%_=pt}w*7QVC$;dm$sSR+;XmxatqOYCQmlIq&Pv$xm;LRBbY~joKi_leW-| zW7!}^tC{a$9I8*Pa_7c)lW}uy6I=o`)pQ@(HpqzBp{x%O3?^p%;bxz#Gn}lawxK9C zlkJRMh|CKzM;?X1sFoj4*{v~t=34u?<;XlU;Rv%K9)g7;mmDqGxH4IbfCVW-!_;7rxg|m5i$ap**U@>VFN9sN(^iDdM%nqtaGFSjqN&AfBVv6Lrxm67ob3 zT_tD+P@-GX^^DD7-F}t60tgPcIZK>_PFResT!SLRoPm4D<;Pljr)xPb(eq+SJTCKW zxa>)RLulmOxn=fo_!n_bi87`a!RLZI8Th-9E4@71d-v1@tme%1xcL(I+=Gwgg*@8F z*RA|hC(Krqh>G~>Ge3nge2VD?cX#hS;VaW$Pbmb6kyP)#1hX8b|AtkQxz8>RDdC4| zb&=6*IIlBwF%}lt6*9yPjyk?(<^wsUNLSVL7Ta^d1B?r0iD9jH+xI005{18y>m@LY z^%Y-@GV*Y{u;E$P&W)v_6fnH;(VkBKCS!|GbZG32$;>N+Dcz2>%VFKa`X&<>?o*My z;D!}jjAo!b>2zo|+RkB+MIr4Sp#i6WF|g-BjSV9!J1c*D$;V>ZRcQ>3JQ}-7($z-;S2Z5wOp1qe}0-xhOtr#T`8g3VLK4qXITwn zbm9u$&Av&3pIw9rO1`C--+x%`dElrg(VX8l4uyw3bOQ5%}ljIa~ly4>?+4;kTT3<1)l6zO@$UZE{LZW zwp{>}!#mywk34hKq+va*o4v61ws2&` zOJ4Tw7|b!KrMkYjW8KQ@E8@8V`Cg2aO`e0{HAzt2U$N(( z#EqRy`W;-_@m7D4E}?<1W?znL9p(2cWiEghwdJ%##D3p8)8GdaKhKg$`D2m$(=3;r zl69(iMTZ&-C`-Dmst}<125%xq*zu)bRTfg@&wY~PWO*)#26jzBg~cP8MMK1_2^3P! zQ^X^)=}m6LSqbtLkASF`(avJ-^abi_TLT)EaP?Vh`KO)IYpT*}QxUi_sVzF{-yOB{ zD!RBpyt}JG{~n@Py7NKv74)kqdYULwlwn#jJHom)p>OGhoNjn`pXy=^FU=W@Tk>r$ z+Pw@f`h+^-zIVVfmuXRJfhj!JW^_5?gbKsiRNLnq|47p3VJ07*A;jzQBWZ8tVz zt-NlL zYs4@?(Fi)TQ2rL+nJbVWc+VHQAr}-5D};@%5EPomg;7$z0bj4WMzW0>)p(z{;YX2o z`+LThvV%zQ z#ReCCUPSptQocZ{aD$mBW_`GJxiDzV7P;DF-)fo*5oao0_JBP>ZfH%@yeS6Rt0wd@ zSC*j=Ia=Tu4XlhD^Mp!Sp=LCmkU-}Eu5oaEKewM5LLl7>&X6{70OX(<;oO8ID7{XS z98LfPL$yHTb1vh{>!1=}MWJMJZX?mjVNrc%`$rp@VC6%eL58I?;w>@=Jbtb)v69j6 zw@e!tRW2G-XmD9b{Jol-<(Su$e`M0f69bK?b&xjwkU`8A5Jh^l0c;~(s?6 zMVU)gf$miXBou+l65RF;+YmHp4f3-UOAfd41dTZ%3WVnq#Gu6{twp#*XV}D%ULVpS zRsrC(cvVcT)E{8mgAT@y4nXB*2ORv$`hA2M7sfAsW_l4E z8$TS1l!Z_a%obJjF+IDC$eodY;t!*G%@8@DEx+ejT|&BM@fpyKRDh3PqXaPZE8Vle zJzCXEp@u(84gEA6Fyj2l?ph@zXKX#zuqNy};W8+iW={w@XL^j3xiMH^;+Ad%OqdYx zDnYIREk|9ze7{(xgvOoF_mR(aG4BUsx+N@?X$J4$gG_w&nE&sun}jP%-Y<2gz{S1y zt!h${FE5rVAsf2|;Q7sY7rHR@d-2nK17+Zs{ML!Lo@=3rrW8OIMMfn&;=It+s9v4A$`F1=Q#TGLn}?FmGpp!&98L(?rmAM zSC1j`f04Ex;{K6?U_gl!sgXN3Df))%ajPW9|2O*YaS@i3W!xMcL|h5kG2YvtPBphR z_ceU!ETCV5cIh(ySKgnJ7P|(=0QRM*Q)$?aPwV?Gd9fX(K@SB7CEE4&x=)N(--{5U!nzOYosj(nU0|(F!L0 zQmBP}>vspIQQt7i{?A}ISQ2jh)<0H6v6 z7riknPij~_>RFxcF)>gDplrZrMQ$1otIrJPoSzx29Yr^Cv{_9kj6yKEhalCqMl+-) z84)pA$+8D$&0AmdT~q^2LGq;XP|W!8QVvEl&8-@|(-?Sc?vbyyUCGyO;({hg=B<;x zIhCkqj-1FATq5%93ea2}YaT7e7o^iS_DC@fw+lQv4P=nA7yNql+1e>|2g>dEvmm5` zL#LUZxyyDb90C8=Hw5~I#U(K+TY6{}J+OoH4QDULUEj`~0NGEv?@Z(e0CU7bw+JX{ z)a(}*gt#oJTd*XcAU^=S{odVE(L+dLa=cj+uMMmn0$2$;SagbX1JuLk<5&ZIs58=# zk2hB<(;mXs+(4$8VqK@RiV|w=Py7e~n^qN{FchN(i9;!0X+Bs<(KKzOD!;<6Vkcn2@`{up77c;`bg-;_fDtF6Sx*` zYI%sOw^^~cs@iQqjuNk9Tq+t|DqGmut9Je$uj(v6q3FHKs^Uc*d_W0+7r_$n#&Zkx zUBlQf?=;n=^v(xz(61+0iIsN|3A$!d2K|xC1QlOx1iNEn)G7(6q`(@$65WfwXyUlm zu&E{DwT7rzc<#Wo(;x*}^yG)bD)_tNfY$5*aRFPP<*-bBV1Lc+6O~HZz#Ekeh?2jo zT;AMB1_Cj8x4>L^AM4%6Q&c&q20;;`Nj%z~xl0v9XET%Q%*gRSGm{!tm`J0h!t{?c zzz&iIJpY#r#Y^yn(h7oDDVMLb)$fYeB@qurg2m3yhkHhtO>9c9xGB|TiF0^#<7_jN zm@BF1Wve zCU#?Zcps=k7Utd+Dqem9xjX8s;?ATohA)6L=Ik^U)?M&DZ19=#Kkg1D$lYQ6FLlU7 zEO2))r2)gmW%`QGvZsE#S&CptJg0(&#K%8FLJe}}G|hpbupBhsO=PSWcQzL2=L!oq z!4K8buBjg_#{>?8DV|P@?SBq8n*X=M4Y1aTLe`qzf5}7YFM^GP3z~Ma6Zx34X(#n( z+9iVTlf3SIwubEgzO7I9$0>l`TVD19LvR!MMUUgDGSCF%7#m^EoF)8_vjiET7Yz=G zdam+IcR2r1raXa^DgRdWtf7H2<*d8ap}YEWr)Kq`%(B)EE|3v6d8b;_)86Zq0&zxu z?Vv3+jN6T(s4HMNlY;PL^!0y0uAYR9V(MT~=s@@LoA4iD3=$A1XB)@g4Np5$;N=R{ z2oXfol=^DFmW-C`JzGu9{;a0||GztMwiqI7O0c6L&X9=)Y(o0a=0lk?gB{eA5GC4NK^E00NEEh zVd4WVB-3{)pbhk8%$FQaXeo(*?&y+#^g5^gxsHN-b4Yee8~el40Ozb$A@f*4lXUM^ zsQp+Q&vc_J3+0^{;K(j_%Tn9CX3}%aNuYfkWs~hc;>sc$(7-7Dxbo02`x*gdXylGx z6#_sbqsbx_Y_9t+aZNm}B`U;mC1QstenNW}5)<>v4IIxiMZVB+OhYGWKkP;6wQor& z@CTjzWuK^(-mI*w0KFII;ZorHy5)lW}SE|4r|Bpp-okyG484O^b-I znv;Q{wZv|f>jaRy}HedRh7=QpAoB}Wgz~oqnl;E?WfCtyk*gnBzRt7P zkJGfKe|@A71Nf$@l-oaRzIkYtx&R%tmU(r^cyYsv-mK8fSeEDgzw$*Mh z_@tIq2sE7YOG?Z#wM!waFr#fbhu`T#j=GckaL%D-_7cG$+^CK(b2#y^SX(w&Sl_6w zzSi&M#zUY@{P24)c)%u~VDY0gDCNT|d+q`6nmW3q;~VAOT1R|k*;qMTyv!#3bIvJC z_pn()$TE=39Sfuu5_#ot3-o%);ve|GTCG_2uANV^8eYLdUDp z+vy-3O}q!nJNQ%ER+qevJB4TOU25c{d`%%L1j=6Z1S-YmzNoS~H)&!iF^7o#m2i_% zn8nJo*~xd*1Hbi%{66tZo23keNr9}<0!U`Cc`{}^F2C^LVi`}Sdt$mc(wz4^`8|LN z*P3C=^LbWY8gsXVF(*-25zZ^5i_)PIa9Gg%;H*BVa<9Fc>!qvV6-_iZUMz{Fe~J9& zJxI(sTS61e+knz?DWDO{`QSxBIi3{Kr9(}347S88{lzki7~MkI(eqtq>r@5cp>6tJ z+}_dn64PDE2t-Q?IMrot0g}B^yL;}nlW|ssAF3XDc!->AKXP1Qz3JKcOMe_Q>HbA? z0fa>$3COpTg*32wW9mv1Ltuye5qaT51w|!eI`@wOi^B^ps#Ca%S!grqiVr zUN|4jHJUwO3U)``!)_S3P?*|N=E`Td!H`Q;9t r2TjaT{{#c`0t}oxA|V2Q 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 diff --git a/functions/utils/queries.js b/functions/utils/queries.js index 8f529a3..49f8031 100644 --- a/functions/utils/queries.js +++ b/functions/utils/queries.js @@ -8,6 +8,8 @@ export const PROJECT_FRAGMENT = ` tonic isSnapToGridActive isAutoQuantizeActive + isProximityModeActive + proximityModeRadius isGridActive dateCreated selectedSynths diff --git a/functions/utils/schema.js b/functions/utils/schema.js index 3b0021d..1631bf5 100644 --- a/functions/utils/schema.js +++ b/functions/utils/schema.js @@ -38,6 +38,8 @@ export const typeDefs = gql` scale: String! isSnapToGridActive: Boolean! isAutoQuantizeActive: Boolean! + isProximityModeActive: Boolean + proximityModeRadius: Int tonic: String! isGridActive: Boolean! shapesList: [Shape!] @@ -56,6 +58,8 @@ export const typeDefs = gql` isGridActive: Boolean! isSnapToGridActive: Boolean! isAutoQuantizeActive: Boolean! + isProximityModeActive: Boolean! + proximityModeRadius: Int! shapesList: [ShapeInput!] selectedSynths: [Synth!] dateCreated: Long @@ -70,6 +74,8 @@ export const typeDefs = gql` isGridActive: Boolean isSnapToGridActive: Boolean isAutoQuantizeActive: Boolean + isProximityModeActive: Boolean! + proximityModeRadius: Int! shapesList: [ShapeInput!] selectedSynths: [Synth!] dateCreated: Long diff --git a/package.json b/package.json index 51da04a..c26a3c5 100644 --- a/package.json +++ b/package.json @@ -54,7 +54,7 @@ "react-scripts": "^3.1.1", "react-select": "^3.0.8", "teoria": "^2.5.0", - "tone": "^13.8.12", + "tone": "13.8.25", "utf-8-validate": "^5.0.2" }, "scripts": { diff --git a/schema.gql b/schema.gql index 8442319..4d2d568 100644 --- a/schema.gql +++ b/schema.gql @@ -25,6 +25,8 @@ type Project { isGridActive: Boolean! isSnapToGridActive: Boolean! isAutoQuantizeActive: Boolean! + isProximityModeActive: Boolean + proximityModeRadius: Int shapesList: [Shape!] userId: String! userName: String! diff --git a/src/components/CheckboxButton/index.jsx b/src/components/CheckboxButton/index.jsx index 5e1d99e..11366ee 100644 --- a/src/components/CheckboxButton/index.jsx +++ b/src/components/CheckboxButton/index.jsx @@ -26,7 +26,7 @@ function CheckboxButton(props) { backgroundColor: props.checked ? grayLightest : getDarker(props.color), color: props.checked ? props.color : grayLightest, } - : defaultStyle; + : { ...defaultStyle, ...props.labelStyle }; return (
    @@ -42,7 +42,7 @@ function CheckboxButton(props) { htmlFor={props.label.toLowerCase()} style={labelStyle} > - {props.label} + {props.renderLabel ? props.renderLabel(props.label) : props.label}
    ); diff --git a/src/components/CheckboxButton/styles.module.css b/src/components/CheckboxButton/styles.module.css index 661670b..bc4084d 100644 --- a/src/components/CheckboxButton/styles.module.css +++ b/src/components/CheckboxButton/styles.module.css @@ -9,6 +9,7 @@ } .checkboxLabel { + user-select: none; display: grid; align-items: center; height: 100%; @@ -18,6 +19,6 @@ padding: 0 7px; text-align: center; font-size: 1em; - line-height: 2em; + line-height: 1; border: 1px solid transparent; } diff --git a/src/components/Downloads/Component.jsx b/src/components/Downloads/Component.jsx index ce959cf..c66c5ad 100644 --- a/src/components/Downloads/Component.jsx +++ b/src/components/Downloads/Component.jsx @@ -1,6 +1,6 @@ import React from 'react'; import PropTypes from 'prop-types'; -import { Button } from 'antd'; +import { Alert, Button } from 'antd'; import { DownloadOutlined } from '@ant-design/icons'; import styles from './styles.module.css'; @@ -26,6 +26,12 @@ function Downloads(props) { )} +
      {downloadUrls .sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1)) diff --git a/src/components/HeaderMenu/index.jsx b/src/components/HeaderMenu/index.jsx index 7ab751d..c6fe14e 100644 --- a/src/components/HeaderMenu/index.jsx +++ b/src/components/HeaderMenu/index.jsx @@ -10,6 +10,7 @@ import { useColorThemeContext } from 'context/ColorThemeContext/useColorThemeCon import { appColors, THEMES } from 'utils/color'; import Color from 'color'; import AboutModalContent from 'components/AboutModalContent'; +import WhatsNewModalContent from 'components/WhatsNewModalContent'; const { Link: AntLink } = Typography; @@ -34,6 +35,7 @@ function HeaderMenu(props) { // TODO: reveal when dark mode works const showDarkModeButton = false; const [isAboutModalVisible, setIsAboutModalVisible] = useState(false); + const [isWhatsNewVisible, setIsWhatsNewVisible] = useState(false); return ( @@ -62,6 +64,15 @@ function HeaderMenu(props) { > + setIsWhatsNewVisible(false)} + footer={null} + // width={700} + > + +
      Create @@ -99,6 +110,29 @@ function HeaderMenu(props) { > GitHub +
      + setIsWhatsNewVisible(true)} + > + {`What's New`} +
      diff --git a/src/components/Knob/styles.module.css b/src/components/Knob/styles.module.css index e4b49b1..731d0c6 100644 --- a/src/components/Knob/styles.module.css +++ b/src/components/Knob/styles.module.css @@ -4,6 +4,7 @@ .knobTitle { color: #fff; + user-select: none; text-transform: capitalize; } diff --git a/src/components/Project/index.jsx b/src/components/Project/index.jsx index ab75b6a..5c635b2 100644 --- a/src/components/Project/index.jsx +++ b/src/components/Project/index.jsx @@ -16,7 +16,13 @@ import { getDefaultParamValues } from 'utils/synths'; import styles from './styles.module.css'; import { useRecorder } from './useRecorder'; import { useAudioOutput } from './useAudioOutput'; -import { PROJECT_ACTIONS, TOOL_TYPES, getInitState } from 'utils/project'; +import { + PROJECT_ACTIONS, + TOOL_TYPES, + getInitState, + MIN_PROXIMITY_RADIUS, + MAX_PROXIMITY_RADIUS, +} from 'utils/project'; import useUnload from 'hooks/useUnload'; export default props => { @@ -85,11 +91,24 @@ export default props => { ...state, isAutoQuantizeActive: !state.isAutoQuantizeActive, }; - case PROJECT_ACTIONS.SET_TEMPO: + case PROJECT_ACTIONS.TOGGLE_PROXIMITY_MODE: + return { + ...state, + isProximityModeActive: !state.isProximityModeActive, + }; + case PROJECT_ACTIONS.SET_PROXIMITY_MODE_RADIUS: { + const proximityModeRadius = Math.max( + Math.min(action.payload, MAX_PROXIMITY_RADIUS), + MIN_PROXIMITY_RADIUS + ); + return { ...state, proximityModeRadius }; + } + case PROJECT_ACTIONS.SET_TEMPO: { const min = 1; const max = 100; const tempo = Math.max(Math.min(action.payload, max), min); return { ...state, tempo }; + } case PROJECT_ACTIONS.SET_TONIC: return { ...state, @@ -160,8 +179,7 @@ export default props => { currentState ); - // prevent unload if user can save, there are shapes drawn, and the current state is different than the initial state - + // prevent unload if user can save, there are shapes drawn, and the current state is different from the initial state if (showSaveButton && hasShapes && !statesAreEqual) { e.preventDefault(); e.returnValue = ''; diff --git a/src/components/Shape/Component.jsx b/src/components/Shape/Component.jsx deleted file mode 100644 index 224971f..0000000 --- a/src/components/Shape/Component.jsx +++ /dev/null @@ -1,317 +0,0 @@ -/* - TODO : Remove file -*/ - -import React from 'react'; -import { number, bool, string, object, func, array } from 'prop-types'; -import { Circle, Group, Line } from 'react-konva'; - -import { getPerimeterLength } from 'utils/shape'; -import { convertValToRange } from 'utils/math'; -import { themeColors, appColors } from 'utils/color'; - -import ShapeVertex from './ShapeVertex'; -import Portal from 'react-portal'; -import ShapeEditorPopover from './ShapeEditorPopover'; -import withProjectContext from 'components/Project/withProjectContext'; -import { TOOL_TYPES } from 'utils/project'; - -const propTypes = { - scaleObj: object.isRequired, - activeTool: string.isRequired, - isPlaying: bool.isRequired, - tempo: number.isRequired, - - points: array.isRequired, - attrs: object.isRequired, - - noteIndexModifier: number.isRequired, - - colorIndex: number.isRequired, - index: number.isRequired, - volume: number.isRequired, - - isSelected: bool.isRequired, - isMuted: bool.isRequired, - isDragging: bool.isRequired, - isSoloed: bool.isRequired, - - dragBoundFunc: func.isRequired, - handleDrag: func.isRequired, - handleDragStart: func.isRequired, - handleDragEnd: func.isRequired, - - handleClick: func.isRequired, - handleMouseDown: func.isRequired, - handleMouseOver: func.isRequired, - handleMouseOut: func.isRequired, - handleVertexDragMove: func.isRequired, - - handleVolumeChange: func.isRequired, - handleMuteChange: func.isRequired, - handleSoloChange: func.isRequired, - handleColorChange: func.isRequired, - handleDelete: func.isRequired, - handleQuantizeFactorChange: func.isRequired, - handleToTopClick: func.isRequired, - handleToBottomClick: func.isRequired, - handleReverseClick: func.isRequired, - - averagePoint: object.isRequired, - editorPosition: object.isRequired, -}; - -class ShapeComponent extends React.Component { - componentDidMount() { - const { onMount } = this.props; - onMount(this); - } - - getShapeElement() { - return this.shapeElement; - } - - getGroupElement() { - return this.groupElement; - } - - getAnimCircle() { - return this.animCircle; - } - - render() { - const { - // project context - scaleObj, - isPlaying, - activeTool, - // shape - index, - editorPosition, - volume, - isMuted, - isSoloed, - colorIndex, - averagePoint, - points, - dragBoundFunc, - attrs, - isDragging, - noteIndexModifier, - isSelected, - isBuffering, - // handlers - handleVolumeChange, - handleMuteChange, - handleSoloChange, - handleColorChange, - handleDelete, - handleQuantizeFactorChange, - handleToTopClick, - handleToBottomClick, - handleClick, - handleDragStart, - handleDrag, - handleDragEnd, - handleMouseDown, - handleMouseOver, - handleMouseOut, - handleVertexDragMove, - handleReverseClick, - handleDuplicateClick, - } = this.props; - - const color = themeColors[colorIndex]; - let panningVal = parseInt( - convertValToRange(averagePoint.x, 0, window.innerWidth, -50, 50), - 10 - ); - - if (panningVal > 0) { - panningVal = `${panningVal} R`; - } else if (panningVal < 0) { - panningVal = `${Math.abs(panningVal)} L`; - } - - const animCircle = isPlaying && ( - (this.animCircle = c)} - hitGraphEnabled={false} - transformsEnabled="position" - x={-999} - y={-999} - radius={6} - strokeWidth={2} - stroke={color} - fill={color} - /> - ); - - const isEditMode = activeTool === TOOL_TYPES.EDIT; - const perimeter = getPerimeterLength(points); - const startingNoteString = scaleObj.get(noteIndexModifier + 1).toString(); - - if (isEditMode) { - return ( - (this.groupElement = c)} - draggable - dragBoundFunc={dragBoundFunc} - onDragMove={handleDrag} - onDragStart={handleDragStart} - onDragEnd={handleDragEnd} - opacity={attrs.opacity} - transformsEnabled="position" - > - {/* shape perimeter */} - (this.shapeElement = c)} - points={points} - fill={attrs.fill} - lineJoin="bevel" - stroke={attrs.stroke} - strokeWidth={attrs.strokeWidth} - closed - strokeScaleEnabled={false} - onClick={handleClick} - onMouseDown={handleMouseDown} - onMouseOver={handleMouseOver} - onMouseOut={handleMouseOut} - transformsEnabled="position" - /> - - {/* shape verteces */} - {points.map( - (p, i, arr) => - !(i % 2) && ( - - ) - )} - - {/* node that travels around the perimeter when playing */} - {animCircle} - - {/* tooltip that appears on drag */} - -
      -
      - PAN: {panningVal} -
      - NOTE: {startingNoteString} -
      -
      -
      - - {/* editor panel that opens on shape click */} - - handleDelete(index)} - onDuplicateClick={handleDuplicateClick} - /> - -
      - ); - } else { - return ( - (this.groupElement = c)} - hitGraphEnabled={false} - draggable={false} - opacity={attrs.opacity} - transformsEnabled="position" - > - (this.shapeElement = c)} - strokeScaleEnabled={false} - transformsEnabled="position" - points={points} - fill={attrs.fill} - lineJoin="miter" - stroke={color} - strokeWidth={attrs.strokeWidth} - closed - /> - - {/* loading indicator */} - -
      - Loading samples... -
      -
      - - - - {animCircle} -
      - ); - } - } -} - -ShapeComponent.propTypes = propTypes; - -export default withProjectContext(ShapeComponent); diff --git a/src/components/Shape/ComponentV2.jsx b/src/components/Shape/ComponentV2.jsx index da76040..99fc957 100644 --- a/src/components/Shape/ComponentV2.jsx +++ b/src/components/Shape/ComponentV2.jsx @@ -7,6 +7,7 @@ import { convertValToRange } from 'utils/math'; import { themeColors, appColors } from 'utils/color'; import ShapeVertex from './ShapeVertex'; +import EdgeMidpoint from './EdgeMidpoint'; import ShapeEditorPopover from './ShapeEditorPopover'; import { TOOL_TYPES } from 'utils/project'; import styles from './styles.module.css'; @@ -75,8 +76,12 @@ function ShapeComponent(props, ref) { handleMouseOver, handleMouseOut, handleVertexDragMove, + handleVertexAdd, + handleVertexDelete, handleReverseClick, handleDuplicateClick, + onMouseMove, + draggable, } = props; const color = themeColors[colorIndex]; @@ -109,8 +114,9 @@ function ShapeComponent(props, ref) { + {/* Midpoints */} + {points.map((x, i, arr) => { + if (i % 2) return null; + return ( + + ); + })} + {/* shape verteces */} {points.map( (p, i, arr) => @@ -144,7 +165,8 @@ function ShapeComponent(props, ref) { key={i} index={i} p={{ x: p, y: arr[i + 1] }} - onVertexDragMove={handleVertexDragMove(i)} + handleVertexDragMove={handleVertexDragMove(i)} + handleVertexDelete={handleVertexDelete(i)} color={color} /> ) @@ -170,7 +192,6 @@ function ShapeComponent(props, ref) {
      - {/* editor panel that opens on shape click */} {progressDot} diff --git a/src/components/Shape/Container.jsx b/src/components/Shape/Container.jsx deleted file mode 100644 index 79f8287..0000000 --- a/src/components/Shape/Container.jsx +++ /dev/null @@ -1,718 +0,0 @@ -import React, { PureComponent } from 'react'; -import { number, string, array, bool, object, func } from 'prop-types'; -import Color from 'color'; -import Tone from 'tone'; -import { themeColors, appColors } from 'utils/color'; -import { TOOL_TYPES } from 'utils/project'; - -import { convertValToRange } from 'utils/math'; -import { - getPerimeterLength, - getAveragePoint, - forEachPoint, - getNoteInfo, -} from 'utils/shape'; - -import ShapeComponent from './Component'; -import withProjectContext from 'components/Project/withProjectContext'; -import { SYNTH_PRESETS } from 'instrumentPresets'; -import { SEND_CHANNELS } from 'utils/music'; - -const propTypes = { - index: number.isRequired, - colorIndex: number.isRequired, - initialPoints: array.isRequired, - isSelected: bool.isRequired, - soloedShapeIndex: number.isRequired, - - isPlaying: bool.isRequired, - selectedSynths: array.isRequired, - knobVals: array.isRequired, - - isAutoQuantizeActive: bool.isRequired, - activeTool: string.isRequired, - tempo: number.isRequired, - scaleObj: object.isRequired, - - snapToGrid: func.isRequired, - - handleSoloChange: func.isRequired, -}; - -class ShapeContainer extends PureComponent { - constructor(props) { - super(props); - const { initialPoints, initialQuantizeFactor } = props; - - this.state = { - points: initialPoints, - quantizeFactor: initialQuantizeFactor || 1, - averagePoint: { x: 0, y: 0 }, - firstNoteIndex: 1, - noteIndexModifier: 0, - isHoveredOver: false, - isDragging: false, - editorX: 0, - editorY: 0, - animCircleX: 0, - animCircleY: 0, - isBuffering: false, - }; - this.quantizeLength = 500; - - // shape events - this.handleMouseDown = this.handleMouseDown.bind(this); - this.handleMouseOver = this.handleMouseOver.bind(this); - this.handleMouseOut = this.handleMouseOut.bind(this); - this.handleDrag = this.handleDrag.bind(this); - this.handleDragStart = this.handleDragStart.bind(this); - this.handleDragEnd = this.handleDragEnd.bind(this); - this.dragBoundFunc = this.dragBoundFunc.bind(this); - - // vertices - this.handleVertexDragMove = this.handleVertexDragMove.bind(this); - - // perimeter - this.getPointsForFixedPerimeterLength = this.getPointsForFixedPerimeterLength.bind( - this - ); - - // shape editor handlers - this.handleQuantizeFactorChange = this.handleQuantizeFactorChange.bind( - this - ); - this.handleToTopClick = this.handleToTopClick.bind(this); - this.handleToBottomClick = this.handleToBottomClick.bind(this); - this.handleReverseClick = this.handleReverseClick.bind(this); - } - - UNSAFE_componentWillMount() { - const { colorIndex, scaleObj } = this.props; - const { points, quantizeFactor } = this.state; - - this.setSynth(this.props, colorIndex); - this.part = this.getPart(); - - // TODO ugly - if (this.props.isAutoQuantizeActive) { - const newPoints = this.getPointsForFixedPerimeterLength( - points, - this.quantizeLength * quantizeFactor - ); - this.setNoteEvents(scaleObj, newPoints); - this.setState({ points: newPoints }); - } else { - this.setNoteEvents(scaleObj, points); - } - } - - componentDidMount() { - const { getShapeRef } = this.props; - const shapeRef = getShapeRef(); - this.handleDrag(); - shapeRef(this); - } - - componentWillUnmount() { - const { removeShapeRef, index } = this.props; - // this.shapeElement.destroy(); - removeShapeRef(index); - this.part.dispose(); - this.synth.dispose(); - } - - UNSAFE_componentWillUpdate(nextProps, nextState) { - /* change instrument when color's instrument changes, or when shape's color changes */ - if ( - nextProps.selectedSynths[nextProps.colorIndex] !== - this.props.selectedSynths[nextProps.colorIndex] || - nextProps.colorIndex !== this.props.colorIndex - ) { - this.setSynth(nextProps, nextProps.colorIndex); - } - - if (!nextProps.isPlaying && this.props.isPlaying) { - this.synth.triggerRelease(); - } - } - - UNSAFE_componentWillReceiveProps(nextProps) { - /* remove hover styles when switching to draw mode */ - if ( - nextProps.activeTool === TOOL_TYPES.DRAW && - this.props.activeTool === TOOL_TYPES.EDIT - ) { - this.setState({ - isHoveredOver: false, - }); - } - - /* set volume */ - if (nextProps.volume !== this.props.volume) { - this.synth.volume.exponentialRampToValueAtTime( - nextProps.volume, - Tone.now() + 0.2 - ); - } - - /* set mute */ - if (nextProps.isMuted !== this.props.isMuted) { - this.part.mute = nextProps.isMuted; - } - - /* set to fixed perimeter */ - if (nextProps.isAutoQuantizeActive && !this.props.isAutoQuantizeActive) { - const newPoints = this.getPointsForFixedPerimeterLength( - this.state.points, - this.quantizeLength * this.state.quantizeFactor - ); - this.setNoteEvents(nextProps.scaleObj, newPoints); - this.setState({ - points: newPoints, - }); - } - - /* update note events if new scale or new tonic */ - if ( - this.props.scaleObj.name !== nextProps.scaleObj.name || - this.props.scaleObj.tonic.toString() !== - nextProps.scaleObj.tonic.toString() - ) { - this.setNoteEvents(nextProps.scaleObj, this.state.points); - } - - /* on tempo update */ - if (this.props.tempo !== nextProps.tempo) { - this.part.playbackRate = nextProps.tempo / 50; - } - - /* update effect values (knobs) */ - nextProps.knobVals[this.props.colorIndex].forEach((val, i) => { - // TODO - if (this.props.knobVals[this.props.colorIndex][i] !== val) { - this.setEffectVal(val, i); - } - }); - - /* on solo update */ - if (this.props.soloedShapeIndex !== nextProps.soloedShapeIndex) { - const isSoloed = nextProps.soloedShapeIndex === nextProps.index; - this.solo.solo = isSoloed; - } - } - - /* ================================ AUDIO =============================== */ - - getPart() { - const part = new Tone.Part((time, val) => { - const dur = val.duration / this.part.playbackRate; - - // animation - Tone.Draw.schedule(() => { - const { points } = this.state; - const { colorIndex } = this.props; - const { pIndex } = val; - - const xFrom = points[pIndex - 2]; - const yFrom = points[pIndex - 1]; - const xTo = pIndex >= points.length ? points[0] : points[pIndex]; - const yTo = pIndex >= points.length ? points[1] : points[pIndex + 1]; - - const animCircle = this.shapeComponentElement.getAnimCircle(); - const shapeElement = this.shapeComponentElement.getShapeElement(); - if (animCircle) { - // TODO smooth animations... - - const shapeFill = this.getFillColor(); - shapeElement.setAttrs({ - fill: appColors.white, - }); - shapeElement.to({ - fill: shapeFill, - duration: 0.2, - }); - - animCircle.setAttrs({ - x: xFrom, - y: yFrom, - fill: appColors.white, - radius: 8, - }); - animCircle.to({ - x: xTo, - y: yTo, - duration: dur, - }); - animCircle.to({ - radius: 5, - fill: themeColors[colorIndex], - duration: 0.3, - }); - } - }, time); - - const { noteIndexModifier, isBuffering } = this.state; - const { scaleObj } = this.props; - - const noteIndex = val.noteIndex + noteIndexModifier; - const noteString = scaleObj.get(noteIndex).toString(); - - // trigger synth - if (!isBuffering) { - this.synth.triggerAttackRelease(noteString, dur, time); - } - }, []).start(0); - - part.loop = true; - part.playbackRate = this.props.tempo / 50; - const { isMuted } = this.props; - if (isMuted) { - part.mute = true; - } - - return part; - } - - setSynth(props, colorIndex) { - const selectedSynth = props.selectedSynths[colorIndex]; - const knobVals = props.knobVals[colorIndex]; - const synthObj = SYNTH_PRESETS[selectedSynth]; - - if (this.synth) { - this.synth.triggerRelease(); - - this.panner.disconnect(); - this.panner.dispose(); - this.solo.disconnect(); - this.solo.dispose(); - this.gain.disconnect(); - this.gain.dispose(); - - this.synth.volume.exponentialRampToValueAtTime( - -Infinity, - Tone.now() + 0.2 - ); - - this.synth.disconnect(); - this.synth.dispose(); - } - - this.synth = new synthObj.baseSynth(synthObj.params, () => { - console.log('LOADED'); - this.setState({ isBuffering: false }); - }); - if (this.synth instanceof Tone.Sampler) { - console.log('setting isbuffering to true'); - this.setState({ isBuffering: true }); - } - this.synth.volume.exponentialRampToValueAtTime( - this.props.volume, - Tone.now() + 0.2 - ); - - knobVals.forEach((val, i) => { - if (synthObj.dynamicParams[i].target === 'instrument') { - synthObj.dynamicParams[i].func(this, val); - } - }); - - this.panner = new Tone.Panner(0); - this.solo = new Tone.Solo(); - this.gain = new Tone.Gain().send( - `${SEND_CHANNELS.FX_PREFIX}${colorIndex}`, - 0 - ); - - this.synth.chain(this.panner, this.solo, this.gain); - } - - getMIDINoteEvents() { - const { points } = this.state; - const { scaleObj } = this.props; - let prevNoteIndex = this.state.firstNoteIndex; - - // TODO: clean this up - const noteEvents = []; - forEachPoint(points, (p, i) => { - if (i >= 2) { - const noteInfo = getNoteInfo( - points, - scaleObj, - i, - i - 2, - i - 4, - prevNoteIndex - ); - - const noteIndex = noteInfo.noteIndex + this.state.noteIndexModifier; - const noteString = scaleObj.get(noteIndex).toString(); - noteEvents.push({ note: noteString, duration: noteInfo.duration }); - - prevNoteIndex = noteInfo.noteIndex; - } - }); - - // last edge - const n = points.length; - const lastNoteInfo = getNoteInfo( - points, - scaleObj, - 0, - n - 2, - n - 4, - prevNoteIndex - ); - - const noteIndex = lastNoteInfo.noteIndex + this.state.noteIndexModifier; - const noteString = scaleObj.get(noteIndex).toString(); - noteEvents.push({ note: noteString, duration: lastNoteInfo.duration }); - - return noteEvents; - } - - setNoteEvents(scaleObj, points) { - this.part.removeAll(); - - let delay = 0; - let prevNoteIndex = this.state.firstNoteIndex; - - forEachPoint(points, (p, i) => { - if (i >= 2) { - const noteInfo = getNoteInfo( - points, - scaleObj, - i, - i - 2, - i - 4, - prevNoteIndex - ); - this.part.add(delay, noteInfo); - delay += noteInfo.duration; - prevNoteIndex = noteInfo.noteIndex; - } - }); - - // last edge - const n = points.length; - const lastNoteInfo = getNoteInfo( - points, - scaleObj, - 0, - n - 2, - n - 4, - prevNoteIndex - ); - - this.part.add(delay, lastNoteInfo); - this.part.loopEnd = delay + lastNoteInfo.duration; - } - - setPan(val) { - this.panner.pan.value = val * 0.9; - } - - setEffectVal(val, i) { - const { colorIndex } = this.props; - const synthName = this.props.selectedSynths[colorIndex]; - const { dynamicParams } = SYNTH_PRESETS[synthName]; - - // set synth value when knobs are changed - // values for connected effects are set with the colorController - if (dynamicParams[i].target === 'instrument') { - dynamicParams[i].func(this, val); - } - } - - /* ============================== HANDLERS ============================== */ - - /* --- Shape ------------------------------------------------------------ */ - - /* Click */ - handleMouseDown(e) { - this.setState({ editorX: e.evt.offsetX, editorY: e.evt.offsetY }); - } - - /* Drag */ - handleDragStart() { - this.setState({ isDragging: true }); - } - - handleDrag() { - const { points } = this.state; - const shapeElement = this.shapeComponentElement.getShapeElement(); - const absPos = shapeElement.getAbsolutePosition(); - const avgPoint = getAveragePoint(points); - - const x = parseInt(absPos.x + avgPoint.x, 10); - const y = parseInt(absPos.y + avgPoint.y, 10); - - const panVal = convertValToRange(x, 0, window.innerWidth, -1, 1); - const noteIndexVal = parseInt( - convertValToRange(y, 0, window.innerHeight, 5, -7), - 10 - ); - - this.setPan(panVal); - - this.setState({ - averagePoint: { x, y }, - noteIndexModifier: noteIndexVal, - }); - } - - handleDragEnd() { - this.setState({ - isDragging: false, - }); - } - - dragBoundFunc(pos) { - const { snapToGrid } = this.props; - return { - x: snapToGrid(pos.x), - y: snapToGrid(pos.y), - }; - } - - /* Hover */ - handleMouseOver() { - this.setState({ isHoveredOver: true }); - } - - handleMouseOut() { - this.setState({ isHoveredOver: false }); - } - - /* --- Editor Panel ----------------------------------------------------- */ - handleQuantizeFactorChange(factor) { - return () => { - const { points, quantizeFactor } = this.state; - const { scaleObj, isAutoQuantizeActive } = this.props; - - if ( - (factor < 1 && quantizeFactor >= 0.25) || - (factor > 1 && quantizeFactor <= 4) - ) { - const newPerim = isAutoQuantizeActive - ? factor * quantizeFactor * this.quantizeLength - : getPerimeterLength(points) * factor; - const newPoints = this.getPointsForFixedPerimeterLength( - points, - newPerim - ); - - this.setNoteEvents(scaleObj, newPoints); - - this.setState({ - points: newPoints, - quantizeFactor: factor * quantizeFactor, - }); - } - }; - } - - /* --- Arrangement --- */ - handleToTopClick() { - const groupElement = this.shapeComponentElement.getGroupElement(); - groupElement.moveToTop(); - // TODO way to hacky - this.setState({ - isHoveredOver: true, - }); - this.setState({ - isHoveredOver: false, - }); - } - - handleToBottomClick() { - const groupElement = this.shapeComponentElement.getGroupElement(); - groupElement.moveToBottom(); - // TODO way to hacky - this.setState({ - isHoveredOver: true, - }); - this.setState({ - isHoveredOver: false, - }); - } - - handleReverseClick() { - const { scaleObj } = this.props; - const { points } = this.state; - const reversed = [points[0], points[1]]; - for (let i = points.length - 2; i >= 2; i -= 2) { - reversed.push(points[i]); - reversed.push(points[i + 1]); - } - this.setNoteEvents(scaleObj, reversed); - this.setState({ points: reversed }); - } - - /* --- Vertices --------------------------------------------------------- */ - - handleVertexDragMove(i) { - return e => { - const { snapToGrid, isAutoQuantizeActive, scaleObj } = this.props; - const pos = e.target.position(); - - let points = this.state.points.slice(); - points[i] = snapToGrid(pos.x); - points[i + 1] = snapToGrid(pos.y); - - if (isAutoQuantizeActive) { - points = this.getPointsForFixedPerimeterLength( - points, - this.quantizeLength * this.state.quantizeFactor - ); - } - - this.setNoteEvents(scaleObj, points); - this.setState({ points }); - }; - } - - /* --- Helper ----------------------------------------------------------- */ - - getFillColor() { - const color = themeColors[this.props.colorIndex]; - const alphaAmount = this.props.isSelected ? 0.8 : 0.4; - return Color(color) - .alpha(alphaAmount) - .toString(); - } - - getPointsForFixedPerimeterLength(points, length) { - const currLen = getPerimeterLength(points); - const avgPoint = getAveragePoint(points); - const ratio = length / currLen; - - const newPoints = points.slice(); - - forEachPoint(points, (p, i) => { - newPoints[i] = p.x * ratio + (1 - ratio) * avgPoint.x; - newPoints[i + 1] = p.y * ratio + (1 - ratio) * avgPoint.y; - }); - - return newPoints; - } - - getAbsolutePoints() { - const { points } = this.state; - const shapeElement = this.shapeComponentElement.getShapeElement(); - const { x, y } = shapeElement.getAbsolutePosition(); - const absolutePoints = points.map((p, i) => (i % 2 === 0 ? p + x : p + y)); - - return absolutePoints; - } - - /* =============================== RENDER =============================== */ - - render() { - console.log('shape render'); - - const { - index, - volume, - colorIndex, - activeTool, - soloedShapeIndex, - isMuted, - isSelected, - handleClick, - handleColorChange, - handleVolumeChange, - handleDelete, - handleSoloChange, - handleMuteChange, - handleShapeDuplicate, - } = this.props; - - const { - isHoveredOver, - points, - noteIndexModifier, - isDragging, - averagePoint, - editorX, - editorY, - isBuffering, - } = this.state; - - const color = themeColors[colorIndex]; - const isSoloed = soloedShapeIndex === index; - const isEditMode = activeTool === TOOL_TYPES.EDIT; - let opacity = 1; - - if (soloedShapeIndex >= 0 && !isSoloed) { - opacity = 0.4; - } - if (isMuted) { - opacity = 0.2; - } - - const attrs = { - strokeWidth: isEditMode ? (isHoveredOver ? 4 : 2) : 2, - stroke: color, - fill: this.getFillColor(), - opacity, - }; - - return ( - (this.shapeComponentElement = c)} - index={index} - points={points} - attrs={attrs} - volume={volume} - colorIndex={colorIndex} - noteIndexModifier={noteIndexModifier} - isDragging={isDragging} - isSelected={isSelected} - isMuted={isMuted} - isSoloed={isSoloed} - isBuffering={isBuffering} - averagePoint={averagePoint} - editorPosition={{ - x: editorX, - y: editorY, - }} - // shape event handlers - dragBoundFunc={this.dragBoundFunc} - handleDrag={this.handleDrag} - handleDragStart={this.handleDragStart} - handleDragEnd={this.handleDragEnd} - handleClick={() => { - // pass points if needed for duplication - const absolutePoints = this.getAbsolutePoints(); - handleClick(index, absolutePoints); - }} - handleMouseDown={this.handleMouseDown} - handleMouseOver={this.handleMouseOver} - handleMouseOut={this.handleMouseOut} - handleVertexDragMove={this.handleVertexDragMove} - // editor panel handlers - handleColorChange={handleColorChange} - handleQuantizeClick={this.handleQuantizeClick} - handleDelete={handleDelete} - handleQuantizeFactorChange={this.handleQuantizeFactorChange} - handleVolumeChange={handleVolumeChange(index)} - handleMuteChange={handleMuteChange(index)} - handleSoloChange={() => handleSoloChange(index)} - handleToTopClick={this.handleToTopClick} - handleToBottomClick={this.handleToBottomClick} - handleReverseClick={this.handleReverseClick} - handleDuplicateClick={() => { - // pass points if needed for duplication - const absolutePoints = this.getAbsolutePoints(); - handleShapeDuplicate(index, absolutePoints); - }} - /> - ); - } -} - -ShapeContainer.propTypes = propTypes; - -export default withProjectContext(ShapeContainer); diff --git a/src/components/Shape/ContainerV2.jsx b/src/components/Shape/ContainerV2.jsx index ad199bb..796d0ae 100644 --- a/src/components/Shape/ContainerV2.jsx +++ b/src/components/Shape/ContainerV2.jsx @@ -60,6 +60,7 @@ function ShapeContainerV2(props, ref) { animCircleX: 0, animCircleY: 0, panVal: 0, + // lastCreatedVertexFromMidpoint: 2, }); const { @@ -71,6 +72,7 @@ function ShapeContainerV2(props, ref) { firstNoteIndex, quantizeFactor, panVal, + // lastCreatedVertexFromMidpoint, } = state; const [isHoveredOver, setIsHoveredOver] = useState(false); @@ -128,6 +130,7 @@ function ShapeContainerV2(props, ref) { [points, colorIndex, shapeAttrs.fill] ); + // console.log(panVal); const { isBuffering } = useShapeSynth({ colorIndex, volume, @@ -138,6 +141,7 @@ function ShapeContainerV2(props, ref) { isMuted, isSoloed, panVal, + averagePoint, }); // ======================= HOOKS ======================= @@ -190,7 +194,15 @@ function ShapeContainerV2(props, ref) { }; /* Drag */ - const handleDrag = () => { + const handleDrag = e => { + // console.log('handleMouseMove'); + // const { lastCreatedVertexFromMidpoint } = state; + // if (e && lastCreatedVertexFromMidpoint > -1) { + // console.log(e); + // handleVertexDragMove(lastCreatedVertexFromMidpoint)(e); + // return; + // } + setState(s => { const absPos = shapeRef.current.getAbsolutePosition(); const avgPoint = getAveragePoint(s.points); @@ -209,15 +221,27 @@ function ShapeContainerV2(props, ref) { }; }); }; - - const dragBoundFunc = ({ x, y }) => ({ - x: snapToGrid(x), - y: snapToGrid(y), - }); + // + // const handleMouseMove = e => { + // console.log('handleMouseMove'); + // const { lastCreatedVertexFromMidpoint } = state; + // if (e && lastCreatedVertexFromMidpoint > -1) { + // handleVertexDragMove(lastCreatedVertexFromMidpoint)(e); + // // return; + // } + // }; + + const dragBoundFunc = ({ x, y }) => { + return { + x: snapToGrid(x), + y: snapToGrid(y), + }; + }; const handleVertexDragMove = i => e => { setState(s => { const { x, y } = e.target.position(); + // console.log(x, y); let points = s.points.slice(); points[i] = snapToGrid(x); points[i + 1] = snapToGrid(y); @@ -232,6 +256,49 @@ function ShapeContainerV2(props, ref) { }); }; + const handleVertexDelete = i => e => { + setState(s => { + let points = s.points.slice(); + /* Only delete if we have more than two points */ + if (points.length <= 2 * 2) return s; + + /* remove x and y points */ + points.splice(i, 2); + + if (isAutoQuantizeActive) { + points = getPointsForFixedPerimeterLength( + points, + quantizeLength * s.quantizeFactor + ); + } + return { ...s, points }; + }); + }; + + /* Create new point at midpoint of previous and next points*/ + const handleVertexAdd = i => e => { + setState(s => { + let points = s.points.slice(); + const x = points[i]; + const y = points[i + 1]; + const nextX = points[i + 2] || points[0]; + const nextY = points[i + 3] || points[1]; + const newX = (x + nextX) / 2; + const newY = (y + nextY) / 2; + + /* remove x and y points */ + points.splice(i + 2, 0, ...[newX, newY]); + + if (isAutoQuantizeActive) { + points = getPointsForFixedPerimeterLength( + points, + quantizeLength * s.quantizeFactor + ); + } + return { ...s, points }; + }); + }; + const resetHover = () => { // hack to update Konva layout following imperative changes setIsHoveredOver(true); @@ -305,8 +372,11 @@ function ShapeContainerV2(props, ref) { averagePoint={averagePoint} editorPosition={{ x: editorX, y: editorY }} // shape event handlers + // draggable={lastCreatedVertexFromMidpoint < -1} + draggable dragBoundFunc={dragBoundFunc} handleDrag={handleDrag} + // handleMouseMove={handleMouseMove} handleDragStart={() => setIsDragging(true)} handleDragEnd={() => setIsDragging(false)} handleClick={() => { @@ -318,6 +388,8 @@ function ShapeContainerV2(props, ref) { handleMouseOver={() => setIsHoveredOver(true)} handleMouseOut={() => setIsHoveredOver(false)} handleVertexDragMove={handleVertexDragMove} + handleVertexDelete={handleVertexDelete} + handleVertexAdd={handleVertexAdd} // editor panel handlers handleColorChange={handleColorChange} handleDelete={handleDelete} diff --git a/src/components/Shape/EdgeMidpoint.jsx b/src/components/Shape/EdgeMidpoint.jsx new file mode 100644 index 0000000..a660639 --- /dev/null +++ b/src/components/Shape/EdgeMidpoint.jsx @@ -0,0 +1,57 @@ +import React, { useContext, useEffect, useRef, useState } from 'react'; +import { Circle } from 'react-konva'; +import { dist, getMidpoint } from '../../utils/math'; +import { MousePosContext } from '../ShapeCanvas/Component'; + +/* + Midpoint along a shape edge. Can be clicked on to create a new vertex +*/ +function EdgeMidpoint(props) { + const { p1, p2, color, handleMouseDown } = props; + + const ref = useRef(null); + const mousePos = useContext(MousePosContext); + const [isInRange, setIsInRange] = useState(false); + + const pt = getMidpoint(p1.x, p1.y, p2.x, p2.y); + + useEffect(() => { + let absPt = pt; + if (ref.current) { + absPt = ref.current.getAbsolutePosition(); + } + const isInRange = dist(absPt.x, absPt.y, mousePos.x, mousePos.y) < 160; + setIsInRange(isInRange); + }, [mousePos, pt]); + + const { x, y } = pt; + + const defaultRadius = 6; + const hoverRadius = 8; + const strokeWidth = 2; + const [radius, setRadius] = useState(defaultRadius); + + return ( + <> + {/**/} + { + setRadius(hoverRadius); + }} + onMouseOut={() => { + setRadius(defaultRadius); + }} + /> + + ); +} + +export default EdgeMidpoint; diff --git a/src/components/Shape/ShapeVertex.jsx b/src/components/Shape/ShapeVertex.jsx index ecbc07c..6dc887d 100644 --- a/src/components/Shape/ShapeVertex.jsx +++ b/src/components/Shape/ShapeVertex.jsx @@ -7,11 +7,18 @@ import { /* useStrictMode, */ Circle } from 'react-konva'; The shape's vertexes. Can be dragged to edit the shape. */ function ShapeVertex(props) { - const { p, index, color, dragBoundFunc, onVertexDragMove } = props; + const { + p, + index, + color, + dragBoundFunc, + handleVertexDelete, + handleVertexDragMove, + } = props; const luminosity = Color(color).luminosity(); const lightenAmount = 1.8 * (1 - luminosity); - const defaultRadius = 4; - const hoverRadius = 6; + const defaultRadius = 6; + const hoverRadius = 8; const strokeWidth = 2; const [radius, setRadius] = useState(defaultRadius); @@ -24,19 +31,24 @@ function ShapeVertex(props) { .toString(); return ( - setRadius(hoverRadius)} - onMouseOut={() => setRadius(defaultRadius)} - /> + <> + {/**/} + console.log('vertex mouse move')} + dragBoundFunc={dragBoundFunc} + onDragMove={handleVertexDragMove} + onMouseOver={() => setRadius(hoverRadius)} + onMouseOut={() => setRadius(defaultRadius)} + onDblClick={handleVertexDelete} + /> + ); } diff --git a/src/components/Shape/Synth.js b/src/components/Shape/Synth.js index 8162f37..3c4e5e6 100644 --- a/src/components/Shape/Synth.js +++ b/src/components/Shape/Synth.js @@ -2,6 +2,7 @@ import Tone from 'tone'; import { SYNTH_PRESETS } from 'instrumentPresets'; import { SEND_CHANNELS } from 'utils/music'; import { forEachPoint, getNoteInfo } from 'utils/shape'; +import { DEFAULT_PROXIMITY_MODE_RADIUS } from 'utils/project'; export function Synth({ onStartLoading, onEndLoading }) { console.log('Synth constructed'); @@ -28,6 +29,9 @@ export function Synth({ onStartLoading, onEndLoading }) { let volume = -Infinity; let panner = null; + let panner3d = null; + let pannerNode = null; + let solo = null; let gain = null; @@ -35,10 +39,17 @@ export function Synth({ onStartLoading, onEndLoading }) { let scaleObj = null; let synthObj = null; + let isProximityMode; + let proximityRadius; + let avgPt; + let panVal; + const disposeSynth = () => { synth.triggerRelease(); panner.disconnect(); panner.dispose(); + panner3d.disconnect(); + panner3d.dispose(); solo.disconnect(); solo.dispose(); gain.disconnect(); @@ -71,11 +82,38 @@ export function Synth({ onStartLoading, onEndLoading }) { synth.volume.exponentialRampToValueAtTime(volume, Tone.now() + 0.2); panner = new Tone.Panner(0); + panner3d = new Tone.Panner3D({ + panningModel: 'HRTF', + positionX: 0, + positionY: 0, + distanceModel: 'linear', + // distanceModel: 'inverse', + maxDistance: proximityRadius || DEFAULT_PROXIMITY_MODE_RADIUS, + orientationX: 1, + orientationY: 1, + orientationZ: 1, + }); + panner3d._rampTimeConstant = 0.6; + + /* TODO use below functions? */ + if (panVal) { + panner.pan.value = panVal * 0.9; + } + if (avgPt) { + panner3d.positionX = avgPt.x; + panner3d.positionY = avgPt.y; + } + if (isProximityMode) { + pannerNode = panner3d; + } else { + pannerNode = panner; + } + solo = new Tone.Solo(); // NOTE: this is where we connect to the output channel for this color gain = new Tone.Gain().send(`${SEND_CHANNELS.FX_PREFIX}${colorIndex}`, 0); - synth.chain(panner, solo, gain); + synth.chain(pannerNode, solo, gain); }, setKnobValues: knobVals => { knobVals.forEach((val, i) => { @@ -143,8 +181,28 @@ export function Synth({ onStartLoading, onEndLoading }) { part.playbackRate = tempo / 50; }, setPan: val => { + panVal = val; panner.pan.value = val * 0.9; }, + setPan3d: _avgPt => { + avgPt = _avgPt; + panner3d.positionX = avgPt.x; + panner3d.positionY = avgPt.y; + }, + setProximityMode: _isProximityMode => { + isProximityMode = _isProximityMode; + if (isProximityMode) { + pannerNode = panner3d; + } else { + pannerNode = panner; + } + synth.disconnect(); + synth.chain(pannerNode, solo, gain); + }, + setProximityRadius: _proximityRadius => { + proximityRadius = _proximityRadius; + panner3d.set('maxDistance', proximityRadius); + }, dispose: () => { part.dispose(); disposeSynth(); diff --git a/src/components/Shape/useShapeSynth.js b/src/components/Shape/useShapeSynth.js index 38f3773..96934d7 100644 --- a/src/components/Shape/useShapeSynth.js +++ b/src/components/Shape/useShapeSynth.js @@ -12,10 +12,16 @@ export const useShapeSynth = ({ isMuted, isSoloed, panVal, + averagePoint, }) => { - const { tempo, scaleObj, knobVals, selectedSynths } = useContext( - ProjectContext - ); + const { + tempo, + scaleObj, + knobVals, + selectedSynths, + isProximityModeActive, + proximityModeRadius, + } = useContext(ProjectContext); const [isBuffering, setIsBuffering] = useState(false); const selectedSynth = selectedSynths[colorIndex]; const shapeKnobVals = knobVals[colorIndex]; @@ -79,5 +85,19 @@ export const useShapeSynth = ({ synthContainer.current.setPan(panVal); }, [panVal]); + useEffect(() => { + if (averagePoint) { + synthContainer.current.setPan3d(averagePoint); + } + }, [averagePoint]); + + useEffect(() => { + synthContainer.current.setProximityRadius(proximityModeRadius); + }, [proximityModeRadius]); + + useEffect(() => { + synthContainer.current.setProximityMode(isProximityModeActive); + }, [isProximityModeActive]); + return { isBuffering }; }; diff --git a/src/components/ShapeCanvas/Component.jsx b/src/components/ShapeCanvas/Component.jsx index 6ddad49..35801e9 100644 --- a/src/components/ShapeCanvas/Component.jsx +++ b/src/components/ShapeCanvas/Component.jsx @@ -1,5 +1,5 @@ import React, { useRef, forwardRef, useImperativeHandle } from 'react'; -import { Stage, Layer, Group } from 'react-konva'; +import { Stage, Layer, Group, Circle } from 'react-konva'; import ShapesWrapper from './ShapesWrapper'; import PhantomShape from './PhantomShape'; import Grid from './Grid'; @@ -11,6 +11,8 @@ import ProjectContextProvider from 'components/Project/ProjectContextProvider'; import { useProjectContext } from 'context/useProjectContext'; import { useColorThemeContext } from 'context/ColorThemeContext/useColorThemeContext'; +export const MousePosContext = React.createContext({}); + function ShapeCanvasComponent(props, ref) { const { width, @@ -26,6 +28,7 @@ function ShapeCanvasComponent(props, ref) { deletedShapeIndeces, onContentClick, onContentMouseMove, + onDragMove, onContentMouseDown, handleShapeClick, handleShapeDelete, @@ -53,6 +56,8 @@ function ShapeCanvasComponent(props, ref) { isAltPressed, isGridActive, activeColorIndex, + isProximityModeActive, + proximityModeRadius, } = projectContext; const isEditMode = activeTool === TOOL_TYPES.EDIT; @@ -73,46 +78,74 @@ function ShapeCanvasComponent(props, ref) { height={height} onContentClick={onContentClick} onContentMouseMove={onContentMouseMove} + onDragMove={onDragMove} onContentMouseDown={onContentMouseDown} > - {isGridActive && ( + + {isProximityModeActive && ( + + + + )} + + {isGridActive && ( + + + + + + )} + - + - )} - - - + - - - - - - + + diff --git a/src/components/ShapeCanvas/Container.jsx b/src/components/ShapeCanvas/Container.jsx index 7148494..a49d360 100644 --- a/src/components/ShapeCanvas/Container.jsx +++ b/src/components/ShapeCanvas/Container.jsx @@ -1,12 +1,13 @@ import React, { Component } from 'react'; import { object, string, number, array, bool, func } from 'prop-types'; -import { dist } from 'utils/math'; +import { convertValToRange, dist } from 'utils/math'; import { themeColors } from 'utils/color'; import ShapeCanvasComponent from './Component'; import { TOOL_TYPES } from 'utils/project'; import withProjectContext from 'components/Project/withProjectContext'; +import Tone from 'tone'; export const DRAWING_STATES = { PENDING: 'pending', // not currently drawing @@ -14,6 +15,18 @@ export const DRAWING_STATES = { DRAWING: 'drawing', // drawing but not previewing }; +/* Setup Tone Listener */ +Tone.Listener.upX = 0; +Tone.Listener.upY = 0; +Tone.Listener.upZ = 1; +Tone.Listener.forwardX = 1; +Tone.Listener.forwardY = 1; +Tone.Listener.forwardZ = 0; + +const updateToneListener = (x, y) => { + Tone.Listener.setPosition(x, y, 40); +}; + const propTypes = { onMount: func.isRequired, // Context @@ -24,6 +37,7 @@ const propTypes = { isGridActive: bool.isRequired, isSnapToGridActive: bool.isRequired, isAutoQuantizeActive: bool.isRequired, + isProximityModeActive: bool.isRequired, activeColorIndex: number.isRequired, knobVals: array.isRequired, }; @@ -62,6 +76,7 @@ class ShapeCanvas extends Component { this.handleClick = this.handleClick.bind(this); this.handleMouseMove = this.handleMouseMove.bind(this); + this.handleMouseDrag = this.handleMouseDrag.bind(this); this.handleMouseDown = this.handleMouseDown.bind(this); this.handleShapeClick = this.handleShapeClick.bind(this); this.handleShapeDelete = this.handleShapeDelete.bind(this); @@ -79,6 +94,9 @@ class ShapeCanvas extends Component { this.setState({ shapesList: initShapesList || [], }); + // this.setState({ + // shapesList: this.generateRandomShapes(2, 4), + // }); } UNSAFE_componentWillReceiveProps(nextProps) { @@ -234,12 +252,30 @@ class ShapeCanvas extends Component { } } + handleMouseDrag({ evt }) { + const { isProximityModeActive } = this.props; + const { clientX, clientY } = evt; + const x = clientX; + const y = clientY - 80; + if (isProximityModeActive) { + updateToneListener(x, y); + } + this.setState({ + mousePos: { x, y }, + }); + } + handleMouseMove({ evt: { offsetX, offsetY } }) { - const { activeTool } = this.props; + const { activeTool, isProximityModeActive } = this.props; const { drawingState, currPoints } = this.state; let x = offsetX; let y = offsetY; + + if (isProximityModeActive) { + updateToneListener(x, y); + } + const originX = this.state.currPoints[0]; const originY = this.state.currPoints[1]; @@ -268,6 +304,10 @@ class ShapeCanvas extends Component { mousePos: { x: x, y: y }, drawingState: newDrawingState, }); + } else { + this.setState({ + mousePos: { x: offsetX, y: offsetY }, + }); } } @@ -349,23 +389,33 @@ class ShapeCanvas extends Component { generateRandomShapes(nShapes, nPoints) { const shapesList = []; - for (let i = 0; i < nShapes; i++) { const pointsList = []; for (let j = 0; j < nPoints * 2; j++) { + const rand = Math.random(); if (j % 2) { - pointsList.push( - parseInt(Math.random() * (window.innerHeight - 100), 10) + const y = convertValToRange( + rand, + 0, + 1, + window.innerHeight * 0.2, + window.innerHeight * 0.8 ); + pointsList.push(y); } else { - pointsList.push( - parseInt(Math.random() * (window.innerWidth - 20) + 20, 10) + const x = convertValToRange( + rand, + 0, + 1, + window.innerWidth * 0.1, + window.innerWidth * 0.9 ); + pointsList.push(parseInt(x)); } } shapesList.push({ points: pointsList, - colorIndex: 0, + colorIndex: Math.floor(Math.random() * 5), volume: this.defaultVolume, isMuted: false, }); @@ -396,6 +446,7 @@ class ShapeCanvas extends Component { gridSize={gridSize} onContentClick={this.handleClick} onContentMouseMove={this.handleMouseMove} + onDragMove={this.handleMouseDrag} onContentMouseDown={this.handleMouseDown} shapesList={shapesList} selectedShapeIndex={selectedShapeIndex} diff --git a/src/components/Toolbar/index.jsx b/src/components/Toolbar/index.jsx index 67f6d59..64e34c4 100644 --- a/src/components/Toolbar/index.jsx +++ b/src/components/Toolbar/index.jsx @@ -1,6 +1,6 @@ import React from 'react'; import cx from 'classnames'; -import { Tooltip } from 'antd'; +import { Popconfirm, Popover, Tooltip } from 'antd'; import Button from 'components/Button'; import IconButton from 'components/IconButton'; @@ -14,17 +14,23 @@ import { appColors, getDarker } from 'utils/color'; import styles from './styles.module.css'; -import { TOOL_TYPES } from 'utils/project'; -import { SCALES, TONICS } from 'utils/music'; +import { + MAX_PROXIMITY_RADIUS, + MIN_PROXIMITY_RADIUS, + TOOL_TYPES, +} from 'utils/project'; +import { PROXIMITY_MODE_RADIUS, SCALES, TONICS } from 'utils/music'; import ColorSelect from './ColorSelect'; import { PROJECT_ACTIONS } from 'utils/project'; import { useProjectContext } from 'context/useProjectContext'; import { useColorThemeContext } from 'context/ColorThemeContext/useColorThemeContext'; +import { QuestionCircleOutlined } from '@ant-design/icons'; +import CustomSlider from 'components/Slider'; -const { black, grayLightest, red } = appColors; +const { white, black, grayLightest, red } = appColors; -function ToolbarComponent(props) { +function ToolbarComponent() { const { isPlaying, isArmed, @@ -33,8 +39,9 @@ function ToolbarComponent(props) { scaleObj, tempo, isGridActive, - // isSnapToGridActive, isAutoQuantizeActive, + isProximityModeActive, + proximityModeRadius, dispatch, imperativeHandlers: { togglePlayStop, toggleRecord, clearProjectCanvas }, } = useProjectContext(); @@ -132,7 +139,11 @@ function ToolbarComponent(props) { {/* CANVAS CONTROLS */}
      */}
      - +
      +
      + { + dispatch({ type: PROJECT_ACTIONS.TOGGLE_PROXIMITY_MODE }); + }} + labelStyle={{ fontSize: '0.9em' }} + label={'Proximity Mode'} + renderLabel={label => ( +
      + + {label} + + + ( +
      + { + dispatch({ + type: PROJECT_ACTIONS.SET_PROXIMITY_MODE_RADIUS, + payload: val, + }); + }} + label={Radius} + /> +
      + )} + > +
      + +
      +
      +
      + )} + color={isDarkMode && appColors.black} + /> +
      + {/* */}
      - + +
      {/* TODO: re-enable if fullscreen bug is fixed (ShapeEditorPopover not appearing in fullscreen) */} {/*
      diff --git a/src/components/Toolbar/styles.module.css b/src/components/Toolbar/styles.module.css index 8808732..158300a 100644 --- a/src/components/Toolbar/styles.module.css +++ b/src/components/Toolbar/styles.module.css @@ -3,7 +3,7 @@ z-index: 2; grid-template-rows: 100%; grid-column-gap: 15px; - grid-template-columns: 80px 120px 180px 100px 200px 120px 250px 120px 1fr; + grid-template-columns: 80px 120px 270px 100px 200px 120px 250px 120px 1fr; min-width: 800px; top: 0px; height: 50px; diff --git a/src/components/WhatsNewModalContent/index.jsx b/src/components/WhatsNewModalContent/index.jsx new file mode 100644 index 0000000..ccc3d6b --- /dev/null +++ b/src/components/WhatsNewModalContent/index.jsx @@ -0,0 +1,46 @@ +import React from 'react'; +import { Typography } from 'antd'; +const { Paragraph, Title, Text } = Typography; + +export default ({ message }) => ( +
      + What's New + DECEMBER 2022 + Proximity Mode + +
        +
      • + Turn on Proximity Mode to experience your project spatially, where the + listening position is controlled with the mouse. +
      • +
      • + Moving the mouse around the canvas allows you to create a progression + by focusing on different shapes. +
      • + {/*
      • + NOTE: A shapes 'position' is at the center of its + points. +
      • */} +
      • + Hover over the icon to adjust the + listening radius +
      • +
      • + With Proximity Mode off, the shapes are panned + according to their horizontal position. +
      • +
      +
      + Add and Delete Shape Points + +
        +
      • + Click on a midpoint to create a new point. +
      • +
      • + Double Click to delete a point. +
      • +
      +
      +
      +); diff --git a/src/graphql/queries.js b/src/graphql/queries.js index ebec771..b39e1a8 100644 --- a/src/graphql/queries.js +++ b/src/graphql/queries.js @@ -10,6 +10,8 @@ export const ProjectFragment = gql` tonic isSnapToGridActive isAutoQuantizeActive + isProximityModeActive + proximityModeRadius isGridActive dateCreated selectedSynths diff --git a/src/static/css/main.css b/src/static/css/main.css index 7ec9d47..87ee353 100644 --- a/src/static/css/main.css +++ b/src/static/css/main.css @@ -99,7 +99,7 @@ div[tabindex='-1']:focus { height: 31px !important; text-align: center !important; z-index: 100; - boxshadow: 0 0 12px 0 rgba(0, 0, 0, 0.11) !important; + box-shadow: 0 0 12px 0 rgba(0, 0, 0, 0.11) !important; background: white !important; } @@ -116,6 +116,10 @@ div[tabindex='-1']:focus { z-index: 2000; } +.ant-popover-placement-bottomRight .ant-popover-inner-content { + padding: 0; +} + .ant-tooltip { font-weight: bold; } diff --git a/src/utils/math.js b/src/utils/math.js index a958e17..a738630 100644 --- a/src/utils/math.js +++ b/src/utils/math.js @@ -2,6 +2,12 @@ export const dist = (x0, y0, x1, y1) => { return Math.sqrt((x1 - x0) * (x1 - x0) + (y1 - y0) * (y1 - y0)); }; +export const getMidpoint = (x0, y0, x1, y1) => { + const x = (x0 + x1) / 2; + const y = (y0 + y1) / 2; + return { x, y }; +}; + export const convertValToRange = (oldVal, oldMin, oldMax, newMin, newMax) => { return ((oldVal - oldMin) * (newMax - newMin)) / (oldMax - oldMin) + newMin; }; diff --git a/src/utils/music.js b/src/utils/music.js index 93e9162..36f1975 100644 --- a/src/utils/music.js +++ b/src/utils/music.js @@ -1,3 +1,5 @@ +export const PROXIMITY_MODE_RADIUS = 400; + export const TONICS = [ { value: 'a', label: 'A' }, { value: 'a#', label: 'A#' }, diff --git a/src/utils/project.js b/src/utils/project.js index 75fca6a..d43217c 100644 --- a/src/utils/project.js +++ b/src/utils/project.js @@ -2,6 +2,10 @@ import { DEFAULT_SYNTHS } from './synths'; import { getDefaultParamValues } from 'utils/synths'; import Teoria from 'teoria'; +export const DEFAULT_PROXIMITY_MODE_RADIUS = 400; +export const MIN_PROXIMITY_RADIUS = 50; +export const MAX_PROXIMITY_RADIUS = 900; + export const TOOL_TYPES = { EDIT: 'edit', DRAW: 'draw', @@ -15,6 +19,8 @@ export const PROJECT_ACTIONS = { TOGGLE_GRID: 'TOGGLE_GRID', TOGGLE_SNAP_TO_GRID: 'TOGGLE_SNAP_TO_GRID', TOGGLE_AUTO_QUANTIZE: 'TOGGLE_AUTO_QUANTIZE', + TOGGLE_PROXIMITY_MODE: 'TOGGLE_PROXIMITY_MODE', + SET_PROXIMITY_MODE_RADIUS: 'SET_PROXIMITY_MODE_RADIUS', SET_TEMPO: 'SET_TEMPO', SET_TONIC: 'SET_TONIC', SET_MODE: 'SET_MODE', @@ -36,6 +42,9 @@ export const getInitState = initState => ({ isGridActive: !!initState.isGridActive, isSnapToGridActive: !!initState.isSnapToGridActive, isAutoQuantizeActive: !!initState.isAutoQuantizeActive, + isProximityModeActive: !!initState.isProximityModeActive, + proximityModeRadius: + initState.proximityModeRadius || DEFAULT_PROXIMITY_MODE_RADIUS, tempo: initState.tempo, scaleObj: Teoria.note(initState.tonic).scale(initState.scale), activeTool: TOOL_TYPES.DRAW, @@ -54,6 +63,8 @@ export const getProjectSaveData = projectData => { isGridActive, isSnapToGridActive, isAutoQuantizeActive, + isProximityModeActive, + proximityModeRadius, shapesList, selectedSynths, knobVals, @@ -67,6 +78,8 @@ export const getProjectSaveData = projectData => { isGridActive, isSnapToGridActive, isAutoQuantizeActive, + isProximityModeActive, + proximityModeRadius: parseInt(proximityModeRadius), shapesList, selectedSynths, knobVals, diff --git a/yarn.lock b/yarn.lock index f7073bc..61a711f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -13591,7 +13591,7 @@ tonal-midi@^0.69.7: dependencies: note-parser "^2.0.1" -tone@^13.8.12: +tone@13.8.25: version "13.8.25" resolved "https://registry.yarnpkg.com/tone/-/tone-13.8.25.tgz#057eefb39d4c749524db0ca210e34303cdc3c025" integrity sha512-8QqmLn+/R+Urib/78zf93+NqFLddXS777kQO7+EbJHwqy+nmug+SJFRp2KIytT0LQY2sJBiopNb2ceHA8uQQJg==