From 53bc26d14d4e07b2572a4a885944229354409a60 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Fri, 6 Nov 2026 12:24:11 +0100 Subject: [PATCH] feat(patina): Tone guard for bass lines Patina detects the played note (30-500 Hz) and remembers, over about 4 s, how high each spectral bump is at a fixed frequency and relative to the note. Bumps that follow the note (a boosted harmonic the patch is built on) get less cut; bumps fixed in Hz (formants, room modes) are cut as before. On a saw line F1-F2 with harmonic 6 boosted, the cut on that harmonic drops from 5.78 dB to 0.44 dB at 100 %, while a fixed formant at 1.5 kHz stays at 5.78 dB. One held note is cut as normal, since the two cases cannot be told apart. - New parameter tone-guard (0-100 %, default 0), appended at the end; 0 % is the old behaviour and old settings load unchanged. - New Bass line preset. - Editor: Tone guard control, NOTE readout and a strip over the spectrum where cuts are held back. - No added latency, no allocation on the audio thread; patina-core now depends on common-dsp. --- Cargo.lock | 1 + docs/patina/CHANGELOG.md | 10 +++ docs/patina/README.md | 24 +++++- docs/patina/assets/editor.png | Bin 425277 -> 429582 bytes plugins/catalog.json | 1 + plugins/patina/core/Cargo.toml | 1 + plugins/patina/core/src/lib.rs | 80 ++++++++++++++++++- plugins/patina/core/src/pitch.rs | 80 +++++++++++++++++++ plugins/patina/core/src/settings.rs | 16 +++- plugins/patina/core/tests/dsp.rs | 110 ++++++++++++++++++++++++++ plugins/patina/core/tests/realtime.rs | 8 +- plugins/patina/plugin/Cargo.lock | 5 ++ plugins/patina/plugin/src/params.rs | 10 ++- plugins/patina/ui/src/lib.rs | 54 ++++++++++++- tools/editor-check/Cargo.lock | 1 + 15 files changed, 391 insertions(+), 10 deletions(-) create mode 100644 plugins/patina/core/src/pitch.rs diff --git a/Cargo.lock b/Cargo.lock index 121bd91..80a45a7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -304,6 +304,7 @@ dependencies = [ name = "patina-core" version = "0.2.0" dependencies = [ + "common-dsp", "rustfft", "serde", "serde_json", diff --git a/docs/patina/CHANGELOG.md b/docs/patina/CHANGELOG.md index 9146d59..ba7032d 100644 --- a/docs/patina/CHANGELOG.md +++ b/docs/patina/CHANGELOG.md @@ -1,5 +1,15 @@ # Patina changelog +## Unreleased + +- Add **Tone guard** (0–100 %, default 0 %). Patina follows the played note and + remembers, over about four seconds, which bumps stay in Hz and which follow the + note. Bumps that follow the note (a harmonic the bass is built on) are cut + less; resonances fixed in Hz (formant, comb and filter rings) are still cut. + At 0 % Patina works exactly as before. The display shows the note and marks + the held-back region with a blue strip. +- Add the **Bass line** preset (Tone guard 80 %, Low 150 Hz). + ## 0.3.0 - Change the CLAP ID to `io.narl.bass-foundry.patina`. Hosts see this as a new diff --git a/docs/patina/README.md b/docs/patina/README.md index 7724db9..df03ff8 100644 --- a/docs/patina/README.md +++ b/docs/patina/README.md @@ -33,6 +33,27 @@ cuts, then use Delta to hear what is removed. Restrict Low/High frequency to kee the source’s body out of reach. Attack and Release set how quickly cuts grow and recover; Transient guard briefly relaxes cuts on attacks to keep punch. +### Tone guard for bass lines + +On a bass line, a bump can mean two things. A resonance fixed in Hz (a formant, +a comb or filter ring) stays at the same frequency on every note: that is what +Patina should cut. A bump that follows the note (a harmonic the patch is built +on, or a Lathe boost) moves with every note: cutting it changes the patch +differently on each note. + +**Tone guard** tells them apart. Patina detects the played note (30–500 Hz) +and remembers, over about four seconds, how high each bump was in Hz and how +high it was relative to the note. A bump that has stayed with the note more +than in Hz gets less cut; at 100 % none. A resonance fixed in Hz is cut as +before. It needs a few different notes before it can decide: on one held note +both look the same and Patina cuts as normal. The **NOTE** readout shows what +Patina follows, and a blue strip on top of the plot shows where cuts are held +back. Start from the **Bass line** preset. At 0 % Tone guard is off. + +Measured on a saw line F1–F2 with Low at 150 Hz: a +12 dB boost on harmonic 6 +is cut by 5.8 dB without Tone guard and by 0.4 dB at 100 %; a formant at 1.5 kHz +is cut by 5.8 dB either way. + Double-click the plot to add one of four focus points. Drag a point up to cut more in that region, down to protect it. Wheel changes its width. Range handles on the plot drag the processing limits. @@ -76,7 +97,8 @@ The engine uses complex RustFFT with supplied scratch buffers, WOLA reconstructi the envelope/reference detector above, bounded soft-knee cuts, and temporal smoothing. Original bin phases are retained. This is not a minimum-phase filter bank, a live monitoring effect, a limiter, or an automatic separation of tone and -unwanted resonance. No external sidechain, Learn, M/S, or pitch detector in v0.1. +unwanted resonance. No external sidechain, Learn or M/S. The pitch detector +serves Tone guard only. ## Build and offline evaluation diff --git a/docs/patina/assets/editor.png b/docs/patina/assets/editor.png index 3a0ea4c118a8028b94e8d4c51e294462933c33b6..f2403494a9533fa1b5587247111a968804a5ac79 100644 GIT binary patch delta 20908 zcmaKU30zZW*8UAU3W87tL`l>LwJt%ZfD#E>El?NQIvr}kfTGe4OGLCJ0=Yp%pokz; zqeYWW9cx=hmQq>o@4h%B%q0Lc#c`a6{LjEANk2 z>wVa&ZuaG@ExPl9d&R;25)Y@WZg!pT^DwqjUNbZkgG{iA zQ-i6JWYpDHq9gs+`c4LxPJB>$KetY_^P>-rmI=iS{aoo$Nt$}{82p0zqS0=|1AU!^ zFvq&nOc&?cvR^u}EjVE*dEK(Z@c6THr3Np=ZEhlH;!Y{n>Q|JAE`i(j&2~m=54+7w z1Wmd#41a}n|FpE5stL)LhZrBHmGliIViU;`Cmv%Ku%IG6 zS-I=Ys{Vw%Efu!s6y4|+=Iebl1wK;L}t-kO+s{Y5&!G-y$?;>{W3G_Xe zGaFH?3eAh>>4rVL&Y7xHMAzIpSGr9*v$14$ZgaCKtjteNug~={q9N3H?71qX-T#kk z6FyzLc-JG5VZbd}a>4^ub)#EP!`{1Tn8t%51h-?--&P}gu25TMp7?~Kul6!UD~X-@ zi|s4XJy-l%-gBGtbo;#gQ_tOw+LV-j*pj-=NX&epbjR%ow?wIEwcF&cS<2EA_-14t zzgxtgHS;PG>PLmr*J)}cWz&B=mw46nM~-)`#3tZ8zG|s~sPTJSH7p zU0GGhn%m27TIr_woY%LkO42$$zGKgo%`N&k)v!lEdLJXj!jAo-xdzVP${(E1oe>CB^}n?v zWi_|&mC=)g9@KFKt7*u<3krC(VY^wW>Lf!15jEomZEQ6>&?gj*=*LGf)iX~={a`Av z2{k&up|#bWF?obx-Y`CH4v~PJ?{p|Sy2zJRQDqX9tTsWXEQOX0*NEtwVfgXnp)VJ_AoLiZn`df5QPY-0D*SCXQ z@CTp9mJW&T?bS6)uysg2yCXMou!)sWJN8%laSacKthqhgQzT4DMA;jx9sWz~cYzYI-z$L^Q%@_WUC-f%)$ZAv;M za>-D-kB<+GQM+a6$qnV%aDJ9-)aHqO}1^wN6d zFJ5FjeSf>v&={b#)Ivx}TqOKoE-mMld}r>2x^bQ?ftS4|zD_!RT=WS{m~P$T`2ao! zE_SD7_{u87`CX1~YBd7bptJvkd>K`##2Q*%QdbB~zi5TPOA4rG=nSP6d~o8{b}-h_4v&s9sp1=D>>$gBqheJS-63Em--UYHit6%B`RI86yjVJo=@?W3?co16dFU{wQST|#i*>SXg<*`X@G!f%w;l_ zIYkR+00%A;rRD#}Z=WSgLdMX|cQe@ytQt;ie5UamuPm=-x{6%N`hUL45wEne_1^~=ZeP%V?s%- ze||U;4$}Q1es;X!xtd)poK?{V;n3HyI-(=5JU(_0Jy|DJqjFyeVmIm40MjI@Ws)Y> zdS!7%&296BnXU!h`%zPmJv?bkr1bhFv6J9+d)J1AgD zpRQ4%_d%6%w8>xCm!i7VR`19Ck}?_iuMuio{`4Nh#&hQupf79PIDG9({_~c|$?L3M zF1iEpat!GYb=Xt!f6Uuno^Ct9w^VU*>U_p*E2B&HWU*KbJM!k^uORY@k29IUlIecM z@Q;tqHaD2&H8Dca|E;{`v zj=S|59FDI&Xy^Z9PFhqI~BhgJq9__r{eGKyD1aa{Hyl3LGXmZR034sprV_za$T^UEvRY#w@-a-8`NwsZ&D^YbhUUBfyx=YHOX0n=47)H5o6N zx}Dlq#WqD}JWx?rc{KIce;aq8@4)f-zR!-&PwqzzHn7cUkFF;7cO8t*$f8L-=?RScx`^gosMD2I@xH1?@y&vL0U zcwbFYjHZ$?_gVc=xG7}0JA7+0kKb* z89yEa3)*AxX8`>Zwk+X}W@~?ndVjwnBs7&u(f>?ogKX@Fj<2QsRGh%JR}Z8)sZ$<0 z62&`(uiFU|Rj4XVFyLCh;gR2i&xU;2W89vBfkAd?%B!N9F_%#J1^UQiV}dhA+-E6b z^jUw3_?cjMr*Lg3>!D*WB;hpL#CtUo3f3exDT5THWk1g{`Str&JSG49`@fF~BMq7S z5db@)x@1XqZ?4@++#{=E~=S)JJMTMXy~~%eX~S4kwq)a%iBGmHl=;7 z)lX7%Ym=X`EW`GnKp(4;6p07hbf4ok+eq8+zj{qK*-OG)RO)1tMcE^E7 z+s*p9Sd)nubS_BnvRJrLp$s4fzU_w^mgZ}-ghMjGRCoquN$hkVj;q^+6BX1Ck4)O3 zShC>NO`$vGr-~TG5WW!1&TjS=`p74dMJ4Dcv!AA0hKQ@DzrfRs;F*8@n%=^Z$!udn zza6QztyYiMR!fR_vrPKj;mC8h#+Hr$+mNXvA)z?_QEMCQnlIhhGI9^jh+z|s0bc5A zDs*F!*s4s%&796(z$Pr?{6D23@Q+2>#fkC5`V?>u>s}&dl%PogN(q)Sh)WCa>T_e zjtWy0HO2T}Zt6?!$m-COD_mWRp#*0;w^GMeqoHslC&f6JTt`l%g&@Jw%fEJ&JNl4# z!f-d;(e+noZTMdKZWHU(yH&KIA9dmVx^J@*%Y<#%A$?ry%*+D0`05CbJtczx(Kt2C zK@(Wl$7rdeM<_ui&77|bTXX%G?3>tXI5Bs3xqY4#o7)@Lm8p{BfCY`eQ!*sVDe8yw ztPA@8ypREM zxkmBusb~xAP*oE1Xv1J5UXTkP_?+Vj2x zWr?Y@*Csm~z?~!Kz^6&v2ObuVXdnE>?vN`I@)x&22V;GXT*9nifQd`*?xH*Q^@p0+ z+Z`&_hDd3J(?8nVs70Xq<5bX|f@xoDfLm)O17@cl_hYee5rlc(%c}gYgS${EBVtP`x*SR1(T;Iq`;- zAx%D&<$3W)oJ!ZzWYIC76sIC?g;^?=N0oT?$XLNhk41w|cKc~2sd$YU2{}tB>qO1_ zdSv^|q?gG^ToNrQGd_@75YH=)r#pw}cE&~XisIew`iNH$qR!Uvt9QNcP^sbXatPax z{>QUV)2_y^cN91JAQvogXorxM#N7^J_%9!B1e=A#+wFioFcx-+m#aH2X?x*X=i!xb zF9d*~a%r5YNxBHNhTD?U8=dh(yU)gotn4Q%6{~VAf6gOAHVJ_z2z1zn^q}uMgjpdu zm@@8XBnA@F}Ek&oKZ1h!3O4c`bt zk&u3|4{Sp{d3lc0c89{64ymwE;{MaW-fhyo*p^X3mM{wMdJW z(;cHicT5EO;A_x{zFiFOreu@df??%$S%vgO9O+mDH!5UqB`Z$_OLwfY4iWqimy}t( zOUHG_RCmgKVhnAt_CIVS35R zz(UWk;0X5|r%W;eC*oV~$$Q-4j*T(1(~+2M$sGU3(an3VZf>bbl3)5h=cXa%$NfPP z@!QuYXvGASU}0tpV3CguFl)v5DU=#a&;_y%CRp6GMJaGre*5(tY@BZQU%S&1yJZ*&<&n6p9H@-*p z`S0&RbEDhN5stomh^~S#cJ0e$X|!u!H(|~`!LVM1OdmWjSiz!vce~Z|pO;(DL%3J! zfUq-hw#Urlcr6@6c`9d(nH}2G%xp_$rm!b(pl3X&gVxkv{p*JOfd`%W>FbYYn`ye0 z?B|xK6_9<;uMQ}eF>i;ZeDc9+-5}AhkT#J)6TP40U^msJm^ueZw|?IBw+s_@ST=FC zktj81)pe&W8+f%&QrO)5Uj1}m{nZPIZTcwI|6C9xV9KK|IK+IX+(f1LDv?%5KHTPZ<67=%r5C*`Wr{e`;v$9K?10X(Zd~NW~`Pk>9b$Lg2bP z+9wt>3`g(>ekV;*9on`5WvdPS=`xk$9!qQ9i|{o_Q-V%Mas!rw$77)0q>qP<-&Ly;vM8XuYZWD?_sw zDlmaf1-rErlQAX(6_Z^G5tmYCz6iAujQR^5iIa^)(O&-CUmnWd3z*Falv=_zscGUM zh-$q;*OhwL614{|es4&zo#wfnNe*!~oj6=`;p5pK@^w~*NecEA0OIrch9B~WuLa1W zdj|$&pF~c70;+-L6MVFyir|RlhA<{Kne7oUoI%UJ5itGZKRUBgxx40DSfR&g%g5Cv zFslFkM)RTr@wiIt3zm-1XAb+ZCdoib6BlNNF*RJdEghTIbchX04KMNy&PboY6K47o z%txULP;_eR6xxY231*u5BqnV^DEt22YRNUX>0r9{Ha(ZGdC4D!jfc9<8i=!7OXd`h z)ZVR$ByR%16J95$njddiRX9rwideq9fy2S`DCF#S&qYj^s(bmiQsHxB*CVC70NT1p~sy zZ-wIbGTf*cxfnisRy_Oe_g_eFu;*fCuZXp&7u#1MKplm#E`-VA2j*@u`>ImH0~D%y z4lsjxHw}uHT9+Zeg3A#H0y*Vd9ePNFD4;X(lm{s;gFNnmC{1oZ;iSXE!#)j%--tVX zmP2R{#`99)EakxZ3bZpx``k{4B-CgY!)eFdqIrF;^@FZQwPRJPdIgp)P-xQ-*nf|h z*L<02h#5S0J)ug%_E%^P#Jk#hiRHv^Hv;l}PiAzt^@sKV>iFaBY~sXLfkcav{&+i& zAC{UK7FGZ0#6G^3rJE8P(4}^)yBP$gM9rcMXqHI`NQf@BM<)C#o|na1P)T4m`=**E z2=!kMC`<#g)oDVz=Rby~En!Sql-fah{L@(f>t>!}@@Y>C(fk!T+GlC%XFyd6oD`D@ ziY^cI{*8}fM{Y@8-Ks_*IloC-O^`1}(_Y>E!8ik7Uhx21(1si0!>1KrRdsXZB9)4 zt`Anjw%z-|V4rX-{L_-Z3WofJYiRmMwB|ZBI?!gSf63Ro@YUv#cb(9C+{rKabE^f9 z!|PYUc($8e&WQ(96i+TVZQmL!owt&33E-yi9IMd`F|k*Bi!WaO`s33_S|adut}_v>y&=t`3~PX&BmJ1_uHyGDDoL z&z`T|Wn~WtccoH6qB|#Lvex@hvPG%`<1r<7VfeaG;D)H*C}w9EBfhArYBoDx_cdFARQmwOTNK{s7mBFw59d8 zO&p_We~y^`uIO|;f+N|${!lB#h7^Y=bv?4kb{}ykmD0yS%2?@f(IsVhp6ZHDVB!b& z(ngAEniDFYPR00BC8vu+6_xx&-&@Cx~=GJBKLwz5sV zh%`M;ReGRU-sWQxx007D%6&g#`M5_(Sts_cN^?bQ_@`Jp3P(m$PShqER?%jLXpP-z z&AyWmr}lNolS<}FPQ33pCG1B`i|@4#K)Wvoq1dm@s?Ho_Rz?|*9A;KN-%=2hHTJ=p46Guq|P`j)17L>rlS zkxiKuh)3qo(Ng)3p(#rgssTmk{^Vz!6oZrkb%`0r9=iY-`Ru|bA9d}_)7VR5dBoGc zW*_AD>j5aJQFZyB7cGKfwd87!Dw}d~)AqEL(M!v5;ElS52&8N3O+5Bod0uLmV=x;18(YSi7qcvOwr#jJ<4Q?a-rB|e)J|F z;@{6w4rhqwM;os)ukR3qnaKCvln+pUMTVe&s^ObBlXUyAY$&S?9(Oc9Oq6BZFaHSe z^vWD8SjO5SosPV0Nqf;K5XSYVL)~*3?hlWb)N4h5>NXIcyUkO>a}_ zJXbOTD4A_hcJE19r|=BfaGQJ}kV*;B1=)-5`4l3M?tqje&Kxj%{*8A`V1zr8Q_Q$y z9rDktjXl8!o!>`Fwj`aAA1N-K`>FImbaY1Tczv!ySE7*JjvU>nX(VbE8g`3Ne<*FX zITTjE{JywAbh7S!heGEw3puf+H5P!2t2Hv?Pi1Vh+0*3c*v1$LGKkyMx0awBYrS;i z3WFO__jh8eW$Le%-n$*sBmj=Lygpzzgwti342gDG{i`+gS8mYsFH`#fDT|L7swz5# zk2`}hxivkBac_b2VWQmSbE#dVau6S4zD#C$v!^`Rr)tOV;FX}4prSvV7m3cJDu znqn1B4154{#$=ym0f~}OS2_^AC@EsclIa8Zf67D z&-&sUW=j~;+%4FFauzS_*8-Cu%`ER!;T#c}?gtDThq+j(yWDOT{M~O~kqy^Eq^#_i za@$LmKX+DKcFHkBq>c-QKHN6+Ype`_9hU(#Jit{os zdk3g0SbbHjSl;AsHzwQAsW!or*n0Qk;$mH-{Xzk0p0a<%DW~nr8>qjoBx)8I>MpeXg}L8D6S zsk&+UWt#39&7|QHnuBvTS*Wf}aQ~*jOYI%ip@J2MpEdPRi}N}UTi8U|o17)|A)F0% zQ3aw!fHe{?1G^3^PBv^Fz-8e*GV^lVz^|KfJ7kgWZg(wQ&ERIyuCDe`WdtR|-8v)~ zv^F^EA@CmxN2~z&1^9_BbQ+?l$}{Xz3@{KYV0XEAMn$`t^#q%ARJgb{^h7nty!w#i zu1Ri_pHajLy@803L`jYaz*M`bEPRUYuMv8Qn_Fe`(g_{jE~;G?o=9sCBJ*+q>i>NQ z3r`Mn53CyCB*T_C)lxDq{A&~+C8&+_50!Xwdx3F_=k1{8#?$TlbSc5q$VwL7L`i$v zyx<-2dredhbAhb)EQ{1f^@T$s^D=dIGu$cdP+*P3Q#IR2B2#PNNcI^f@(tNWG_Kho zOOFF?t+J8R04x(oN}foRXHTmIgw^#Go%l{d@nVPtj9lpoUXXFhB#=HK)CN*>x59xn zR0K;U?~@Ec;J!Jz3b%PAiR;v-I_Dp5pte6#_cs^urVi0xE{aT-A2O_}WRXIEm7)^i z5}mnCu;~dfb*2~b^x^3YnpOy>oV6G)Y5<}D6eX=k;a1}@K&{0oDNvZ^8abuxkD2Ft zb>UXP2jPe8%4z)TcN^^B;Mj(6W+TNB2O9eGpA+7wWV$dYke&<OWYGyx#wG zXkx;ill`21{Vil>{g|?Uft?*8+iE=Q145MI;VK}{I+B=E*W6le3ed?lDsP%LN!Or8 zDMT!h*ld@IsV8w5iH-f++uVRW1X=@GaY}pPW2C3{7t3tR%}I0b0=(GYWZ`*pdYRcXTfvN)smU~a%3P{=$0WTBD5gG>DB#*jd*A%|Jx^d0bqmSj(iE)$;Pessay9hLQ1 z^>7HD7KxWPK*&h)wlK8-($1QUgJPrrcF#5wdj~5dW2*zwmQa5#oKbfnaVd+W`i^yf zFaLh0?=z9Apn$oUkiBLtj52P$BfK8C$&lbCNb^Y>NF9p+D&$ytVVesPPs$}Aqko@@ z2F_NgxtD7ck6XP9Pa=zRCG*_V7}ToK&r3uly#Y{#d1UhmrE!t zI1OxEpYN4HX=6ZbZ7qpc#5BY;L^1F0<-w37r-3ZAw<8m*fCqQTj&YF9%drGLJ3SC^ zhWmP`D|{fcz#cryS`Yj%oJ!@X%RmeM+t4K2a8Qusxq!6Z8!!6s+GB&bo%{C-=Bv;e z0G75ybNH_tBF;l2J;jGiiKI6M(j;&tgHb?$%Cw5 zMSOxyY%FjJ)LzUdyM3ORnzb_}1tyqMaX|6V&A;tSs07FzdLWA4i#UR~0yhkt+#VBq z?&URR9K;@icUbls8!P-9aXW{3z$P1~;3!_jPQda(2}Efg6%LFpT$uX62}P|7?ZX5r z|GJep@Y%LHlcsB?Nww&6OCxz_orBrq1X#3dh^}-+A`PNMx=prxg)+_pATo!%*yGC{ za8V`!%#-glu{pIfh3i+DWP{V-AX8YF^~l(To@(!HNVc48ZQ27d2J*IQIRPJ<`)Puy zbkV%TOaj5-(i)M$;zVhTVmASmN1w?cd*150AI z4RN-Q0^tUrlobIsHrXZ$aNOk>M{$-Jr)C?q7)i=RV1mr7Jj1f(hXJtTg-3&N1%Tjl z=K+$fYX|o3&ogexp2%V@&JZoKFjMHxDem5^f4ctWhaU{uVH5d4+UbCJf_m;`sdlrm zywA$YN(Ik>Yt_J<4L%*SXm*u%vpE;`({}D=z~v4Ii42kjueh_K2jU8+OjMEMbeEc+ zq3ud~_1o$OfoE&K*cq|L33ZK1&LSN!vm!(l%Y!)N1~?cN1d}*AiO>UR5ivQIfHgVa!>&GdeO7YRtLcip-r+RGmBo z8bO?FUq?lcY{x2GyugYhUNPOFYKigov1*W|i%fImQHZKPZe@vIwStOMvTWJB<#fLT z#gA_6NgN0NvWIm?MfMtys@4J?tL{K#EZ9k#dVqC+RGLI1fNpl63=SL!Il!pv;2a}n zQK~=_lzhO0;+2Em3dMd29egGk+yAnb=bBK>%hv$oiPK_;=L8U?ds1IyrF!;-heNe% zq&bt(s^p;>kgJT0YZm*iWkucXmec_Gd$i#UW>s=Z82He@W^6jg%CJ|4pkY5k7Wxv39tq-v(IDJ)cmrQ?4);lnARgz<;FR(k35{u?)QoFKJCoGi-ikT8BpzOw4IP1#4 z6XzP^;^Q~)#hL;;18HFF*}x+ED7PQJZVE~p4mvv6WZrdUQ)gT1Z$X5zy1Gu+x(BW% zP+;ARQw3VsTxz!h_|^c5l16+4*Du+Hu*K8K<7zdkRs**xFk@g}GTQrudbDq{kfL+| zWnRBgt*sSmQ|Obc=%=buYO4mvB!)5OJucJQ06yq%v}sd8Y%~7MkNhUR!JshkxT!f@ zBj9EN!uL16O=!3&gm4l_Em}A+SBl-ywM6Pxq1t4$t+zs*`0RE!lw2pekInRNLVlOY z(Am->#ZE1_y&ZfGN(Vu6pKuuin_;x=tRLPv(Tz@bL&}a0c(qdVOQ`Eng>@TnD>>|@K^>@1w zqZ$HYqQS!x+rqoEk-*8@Aa=KhO)A@DjY}l5abcPRMgKA79>h{B1b`|oqFs-CwTji` z$iFKT`G~htEkgvJ+XO$5d0FwiPO2}+#7s6$I+$h0Y@l$Aj$zzQ5VgMjqAmtpCFt`L zK$vUlB-*rq+4pE?aS3Nc+OnT6KS3{c8n%^8*jDRj`dC7{S<-+)Pb&a={|mtbS3$u0 zQM?d>I&9kS5O3*2Y@mo^f?2UkpmB)ywY)4whym^;vjd&;6f=P$_=LG=Y|T4 zV!u3>G&l7H9d!n-OQfeuSJAI{YPk1ACM=^$!nVdYObaLPJC{e)VD?PC{Zq)kX*2S)6X zRt_`0!}L;&W7PVh>LxUbw<>&6aiAEt>SlDa#=2!{wn7b3jNUT(t3`%F`l{Tc7Kd+#vRkNUBY;v0{KrKxt>J=(FocVfF ztZxg>Mnm!4QoRkTGJDFexph6c%CBz(WI`PbI}8$iQV}c;Boru5ac!Irxi208)EI~p z0ZLFNAdOf8sc_#1v1p*?cIrI%v z)&L9z4q&h)lBso)`@4N5qPEnjpaaGDPDt%mr&83~?-_>03H2KHfSGHwC4E$7NT{^7 zUY=4TX|L;V|M~W9oZQ+inY`71{L${zjv(}{N|v;UCrVBet0x1uA)JPZ>ya~yuArMw zIB@ue%lsi+2Gd-RoZWQp;!ZXY`xoWFyjcSL5q<+<_Wv3PfW%&I%06(D7nn8prnOgr zW6xK)0Ww36EGP7&gGBR8(w^ka=}b;jt0xDEGk;Hg2-0a#P>}91#K?R!?R9jSZQ!j7 zu_ZNU?6kkyE%)Ap8mPj_o5EHn_B}4?bo}YEKwx>5lC8SFUYX?8OrA9-YuR7Eg!- zSAJKPhl1My4xcXT=dIGr4S;P3iJ2kCK6pJ$Yv1kr>@HDn)HkQY5dc;YX!n)W$(GOy zfJe)bXs$|Dc@vlqi})Ql{30RBwcC(X4VP_g4ACIoy`4~PILVutx}n4kUZ4#Ld?|*5 z`6JIK3P39&4vR4zN)`&56ZuFXq4x>~4g6Euzr18`~YtUc!7N`7(YTz6_-_w)CF2WNf+5{MF>7-%6m z)UE`mepC(=hd+4Mc?SsGJ00fPD2NJ-UC=}I?a$sE5XtdiRL!@?<^jL9TBvCg7)FI| z66IBidA8QYp3)y%CySlj2PY1UDYzZvcO?x9Vz*( z_lQG2jl`q8 z;ctU~UiIZ)Ek1d*ef`QTc1>*epg!-56Kx?tN9cCRx zJl!F>;zVSYe3fUm{CMX^`xs_MH<|{vWT81QN34OacdAiL&3gKX*ZK*T2oI#)GP5CnaCIo_ z9V0ryr)NW|G$RFPdm_spEo)EsPzw#2ZzM~=s`w*SkD{0Ty_vBCP6zbOWpXlD-ABrDy zov}0vWi~m^qJW?LOXf-sgUN`h%n6m^qFlA)<=z|BGk0QZhR*pTNxNB2fJ_b^43Y zp|k;Q9j*pyS|U;1D#NWfL)+}QLPH-I+A@$Q)SMQMY=Ve#O3;=faFt9ylQjD+h?vH@ zH+En74=JbBl`(wIe>~9P&nl4f^7UimxT^HK3bcV_lT9-`qCuBVwixRd56=BbR&x9w zUFj6eAtk#Ln^%?p!2E9n)F5`CY);IaO=e9N@tg8>6ZsRD`GzlmL<5JMYklLW%AKKm zKlPC>>Wyo3BGYC5mCZ&7h-(KXgXW|RK@^b2LXj|%9knn|#Kx}lZbTb@9+*Fr>RBh;_M{2)E z>Ix7biQ>ja0uJw5;8>Vk#`FO8vB7b{=z0+U>d%XHQ=!oT#ZU?_2Pc`WPpRQ=rLDEX zzS_w=$<*$M>BvyrsZJh{c1q3~g;ZP({^9!Md$g&QAQgXK{6_BxLCy|YH9)**UbHPD z@psJw1ey&tE0OLin=+VtDm73|FM)>A{uAo`sq*AB-%|Z&rT6Ugck?Goj~IgHaKZG+ z&5gvlHHLTFXI zYZB!ggh2gZ%0Y_P*l-KZm@PqZ90j_i1oRRn!RmKIqzJX>0}G#uQf`|q7e_3&0UH|u zm~>MMoE;(*$9MsA*+=zA0{R}wFk1`&9FSo~C+Y#R1iO*|cJ=$~M{Dbg8Jh1I!yD=P$Aki=n~$I?R_G&{+%0M7 zPJ7l3E2i^^m=?C<8fdhe{_~O=na$o}aG2?~n^A(dBT(ufgo$5nY_Sb_*CQ(^3(@Im z)CHVcMZGJ;=Xw`TZfz}fraXV7Q}TK@er#CnRqXKkIW)HZ;!Q(Rnl?GrL(FXTp1sI0 zyze&sL(n9c!Jfm|p3N4(6bFn}(6Ur=%F)L{emuRv(PIxXae$~iFL+cda7FdLXgki^ z9FvNyjVwQJ2M}N>5aG_GcMMOwu;&P|tTqDP_i1`m)#+<`G%=am7BEvT(0CQstxkS+ z!k3|W!e|deJG&=WEDY1n$1P>qxcWXAUaxUwDV@*_fnZEzW`Prpt9fj`4s`>M<6BY< zv7UlXz(zee zoq2JnEnfxBgnLx!LOXdyfHnzC$l1@$KgyObUIx!QJcpc1X!pjLlQ0;ZZI-)U~|AT)LM&ZoEQ}y1`j3)Nryb|iR+_~zX(y|9MP>0G2MDtf7)+n;J01&UW=&;4<0&vQC z0>0ZL-6u*~=nT5-KC;0ZL?adLLB8%qexi0+^5jUv(JNq}FO|>uk3UZe&{i<}huqqXy9y53)eN-R z6z8Yf>%r@Hr&P7MKe26@(G*nm>`T`$y;zBxYJdl0yW$SYS@5%cJRq-=6iM`TlK-#T z;Kp^aha3Vf$Bt{n&YK_+sYbx^X4D(fx*vFHz7EyD>xQAT9sm+6TF5= z&jE9EgDq{Ft>I(lMpZ{U4zmFLHD|)oE`$xP8_;#mmctsud4jR zoVuMU)T;lHE2@7k`uph6AH}cp}I)iMh+@y z;36>@G#e0U;tbv{AhUsHM9sAYIU3*mRI{I%>1I-mqqrU9{XNEwe9yufi7NmI+v!u- z09IZCAh$IkQZSELf>Q+{2ZH(poC1)|%D7ew`>2A3D1fV31m!koa13oTLO>MtQPLh0 z4dAH+J4u#cCbn*I>CE3s?;C>=aEZ|VB;L!Ldy5S$Z1x+|9F}&DNNc?eEv)K#ZBJj{ z(CW~%cS27}i=cswy_&}lXQWwNz9*BptNZ%<4=oKc>Y2KKcAG*_C9QXq_SYm?DFq_D zMw#60FI1%nMr#G$jOizgdu{Bgpa+ldhw6qyVf)nM;~Ml}OFdXczU*ka>T%@N(SgCS z-cRF>j`wfEU1>bAzF#Z{X9c7Bm9UHZ)>HHS=p0x78>}#GdM~ZBxc+6a(P)bw+>PPI z;aB{P{WC+eKZBVIY%GStn+7>_J2}TdQ|y3tJetACzX5eD^(BC?>ydNUk?QFuF$I?) z2p$u5cJ6LX9L674JoY4(LriQS zMue7>(dP+DDoF#CIk{}b+F4c`e0oBj6$~n@UXD?6KEBh+FAEj zslL8Jg%1^2*%evc02Qwl2ikX)7GF~K-0i3TdW@DvNc={PSUgW-DrkFGp-o^gs!=gdCQ)$k}SgCEvY$v?D%p?O?Zy%Y*0iT>^z7u#(Tx%qGsP90-`1PGs zTKh;LZlHdY=X(rVeg)~zc5<1EK_euY8BN0!l^3PcgZ=Qv#5u+6f-7567TZI(&Id|e ziQZe5+}{ZGksZVt%rxS}BG?GHw$tSEuZ!;f(?!|f(UVgm;3FIohP|3m07v)$@Hz%G zhV?1ET$T$RKGEWv(7EBzk!5iBHl@t0Mev*Ly|L$@sWTAaybK6X!aVpAxQ*(#$Y_`a z*SRX#B*P52it1fdJ0&7DhXib`Yyu~bRO3zJg^=bWeYzWtT;SoI?eVYx@C%`)g9pZ5 zyn=3}EcGz#x4CO!ZDSRu;)g)f$F%SsU$2S83G9Ivwi2+q3a^4U=ZsTIdD|qO_qeTB z%vPhM5UM30gVW#%S<0LF<%aTLe%ji$FVB@%Rf#j)j?V{U-m_;rgTYb-?Z&7wMX1&a z#3@22qPm>eEWw&-crv6MM!|UKHqNJ2p zP>BNH7QN}q1FxO6T1)DStyNH~^**b^snZ5f#_%p<>=sNB_=i zc5J4vX6Bn%XtYjQivm!)_l6}8OM4s!O!|2IR_Urp>8e?r>4j6wz4px9V*^f4mSS_I z?UA~X$jK_uVgy~Ig2lK))e?#XdONrT@Q)0^yh4DPfPKmh|H5Pm_rnl#Gn8%ST3nnu z=#;hr0c#Q}zFGh3=EMv`T}gUFhyz<30APqsS4>B8g|qF#VI!F^*3I5cH;m91fF-K# zB2c~|!LY1garrA$VrH^=rhz*m+(BQ%d&#i#-+!@U#ob-NR~fDWy$7@a&|3F7KxEmY zRx3_DS%O{uEEY_xaB^{Y=9Fm*Y>g`!d`7^dZ^3`k$^ix3e!JPNjd+tyj6an+91a~0 zM*1iKfTm*~)u0T0UR0>BT0xxH zEzzwLpb~ApU8Lb~q)CG#Z+3e!5Jb@5 zUbi+pJiK3lb3D~AWyQ0X@(pjtsYD(DISO!{4MMSm6S?(M?e(YjdW4!xJff+RjExh* zsrj_F2gmM(>Zio;c>@?9PBr(#=jJZxAJpmpK4D;$n3o+uxmZoQb!8h##uGeSE5%+tNGC0Iox1D8>T`Oc7?WaBKJU(q(R*HnE0^ zE7TL;^zoJN8rCJSe{BXYNIM+gl-EoByN~b9U6^UxI4l$p26@g z?(_qJ&Ti^spux@#-5qjMGYzP>F^U2+OEu(#ngL~Jj}^A0HVzD|!dK}V5R3P%&u7xX zD4C?6?grMf8}uZ-uT@|Zr&@ZQ{%RE@e1J=B%X6^L!Td*QhEFC4Wl9|z!HR}&;yh6H zH$;|ICFj7#;>XE*65+#B47XRD;zC3nk#b)Qn$v)ViS*8Me1q@swt$9%^-&B39_eTg zy2^*}Ce)p95I{b{aR@Z&w{0;%?+}Pk6}@LGV#Hw6fZSL|9`c*4Zrj3h5^{`Wa<>MH4%l=I99A1U0f7XDvJ!C9!9al(XeaW+CH9~z z!38YLF13@*pdpUA7+3oCfs_lM`?DBkNqEKTmj&7w8HIr7fPghC9ex}Q*a|oq z=sP$C)8Oifxq=}>l=SZn?3<05{~&oj%=bnvvnY>qgXD;km7q(50&sVwBQ(hsm424K$GBN z)s;8^(t@J^$O<67%rjc&&ci*e5qv314;9CF8+oJx@UE^k5t%xJC6Qc!q?<)Gz$F8v ztelvN&ZIJ&YAQzH<8QEHTU>F~*36K7A^?3S1rC?()KAa-NfVRQfv?3k0q7D*Qe`wV5 z5_?3tDaXm5CjJTm0D6TxFz`GB17>#$%qF{n2fog$p6BfG3OC08$)$Sc@cxN#*T51v!fOn^6?p{HxH z{_BY?&@7M7!p47gAKxFkeZs`0W4?P1wi(vn=^tNj>z=k5-H3%7o#lM+f&2i`1)9_` zdmo7O3NIT|pAMP#2S36CuY|QMKpKGlg3daFM{NAT+Y!(Th8i=1r%jJ%F*L+Z27+Yc zY)|n5D1jC}asO-ksv*Cl#sN2TY%4TpKYmBt%wZy?(o87e@*r-*3xzvl9dC{lCN9NM z=#OU(G@R*8eSh^LJlYzQF5cXXlMUG3Uwq0xTMvEa@VQ5-z(N?ydn10AjR*ZrhqIw- xNn5({I?$v0Ki|sXE&uUT)V)e3H-GQKTYuY2=V6>BECl|9?AWutWLwm!{|9T+6HNdB delta 16855 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 YcP{_rg!lOy82m5%{S9l1R!1HBKa}=+d;kCd diff --git a/plugins/catalog.json b/plugins/catalog.json index a07541b..4c53977 100644 --- a/plugins/catalog.json +++ b/plugins/catalog.json @@ -26,6 +26,7 @@ }, "packages": [ "common-ui", + "common-dsp", "patina-core", "patina-ui", "patina-render" diff --git a/plugins/patina/core/Cargo.toml b/plugins/patina/core/Cargo.toml index 21a25bf..e63a5dd 100644 --- a/plugins/patina/core/Cargo.toml +++ b/plugins/patina/core/Cargo.toml @@ -5,6 +5,7 @@ edition = "2021" license = "ISC" [dependencies] +common-dsp = { path = "../../common/dsp" } rustfft = "6.4" serde = { version = "1", features = ["derive"] } diff --git a/plugins/patina/core/src/lib.rs b/plugins/patina/core/src/lib.rs index 3e3f292..08f6ad1 100644 --- a/plugins/patina/core/src/lib.rs +++ b/plugins/patina/core/src/lib.rs @@ -1,5 +1,7 @@ //! Independently designed spectral attenuation. No host or GUI dependencies. +mod pitch; mod settings; +use pitch::PitchProbe; use rustfft::{num_complex::Complex32, Fft, FftPlanner}; pub use settings::{focus_offset, knee, Focus, Settings}; use std::{f32::consts::PI, sync::Arc}; @@ -22,6 +24,14 @@ const CLIP_DB: f32 = 2.; const EMPTY_DB: f32 = 24.; /// Steepest spectral tilt the reference follows, in dB per octave. const MAX_TILT: f32 = 12.; +/// Tone guard memory: bump heights are averaged over about this many seconds. +const TONE_MEMORY: f32 = 4.; +/// Tone guard starts to protect a bump this much (as a share of its height) more consistent +/// relative to the note than in Hz, and protects it fully at `TONE_FULL`. +const TONE_START: f32 = 0.2; +const TONE_FULL: f32 = 0.6; +/// Bumps lower than this (dB) do not count for the tone guard comparison. +const TONE_MIN_DB: f32 = 1.; fn grid_step() -> f32 { 1000f32.log2() / (GRID - 1) as f32 } @@ -112,6 +122,10 @@ pub struct AnalyzerFrame { pub reference: [f32; GRID], pub reduction: [f32; GRID], pub focus: [f32; GRID], + /// Share of the cut that Tone guard holds back, 0–1. + pub tone: [f32; GRID], + /// Fundamental the Tone guard follows, Hz; 0 when none. + pub pitch: f32, pub input_peak: f32, pub output_peak: f32, pub guard: f32, @@ -127,6 +141,8 @@ impl Default for AnalyzerFrame { reference: [-120.; GRID], reduction: [0.; GRID], focus: [0.; GRID], + tone: [0.; GRID], + pitch: 0., input_peak: 0., output_peak: 0., guard: 0., @@ -139,6 +155,10 @@ impl Default for AnalyzerFrame { pub fn frequency(index: usize) -> f32 { 20. * 1000f32.powf(index as f32 / (GRID - 1) as f32) } +/// Grid position of a frequency, fractional. +pub fn cell(hz: f32) -> f32 { + (hz.max(20.) / 20.).log2() / 1000f32.log2() * (GRID - 1) as f32 +} #[derive(Clone, Copy, Default)] pub struct Monitor { pub delta: bool, @@ -173,6 +193,12 @@ pub struct Engine { envelope_radius: usize, reference_radius: usize, settings: Settings, + /// Tone guard: pitch of the analysis window and two slow maps of bump height, one fixed + /// in Hz and one relative to the fundamental (index = cell + GRID − fundamental cell). + probe: PitchProbe, + fixed: [f32; GRID], + relative: [f32; 2 * GRID], + tone: [f32; GRID], frame: AnalyzerFrame, fresh: bool, fast: f32, @@ -276,6 +302,10 @@ impl Engine { envelope_radius: 0, reference_radius: 0, settings: Settings::default(), + probe: PitchProbe::new(rate), + fixed: [0.; GRID], + relative: [0.; 2 * GRID], + tone: [0.; GRID], frame: AnalyzerFrame::default(), fresh: false, fast: 0., @@ -344,6 +374,10 @@ impl Engine { } self.reduction.fill(0.); self.power = [[0.; GRID]; 2]; + self.probe.reset(); + self.fixed.fill(0.); + self.relative.fill(0.); + self.tone.fill(0.); self.pos = 0; self.hop_pos = 0; self.fast = 0.; @@ -403,6 +437,14 @@ impl Engine { ((10. * ((self.fast + 1e-20) / (self.slow + 1e-20)).log10() - 3.) / 9.).clamp(0., 1.); self.protect = onset.max(self.protect * (-1. / (self.rate * 0.03)).exp()); self.sample_power += energy; + if self.settings.tone_guard > 0. { + let mono = if self.channels == 2 { + 0.5 * (self.input[0][self.pos] + self.input[1][self.pos]) + } else { + self.input[0][self.pos] + }; + self.probe.push(mono); + } self.pos = (self.pos + 1) % self.n; self.hop_pos += 1; if self.hop_pos == self.hop { @@ -433,6 +475,26 @@ impl Engine { let mut clipped = [0f32; GRID]; let mut weight = [0f32; GRID]; let mut content = [false; GRID]; + let mut bumps = [0f32; GRID]; + // Tone guard: protect bumps that have followed the note more than they stayed in Hz. + let guard = self.settings.tone_guard / 100.; + let pitch = if guard > 0. && awake { + self.probe.estimate() + } else { + 0. + }; + let shift = cell(pitch).round() as usize; + for i in 0..GRID { + self.tone[i] = if pitch > 0. { + let rel = self.relative[i + GRID - shift]; + let fixed = self.fixed[i]; + let score = (rel - fixed) / rel.max(fixed).max(TONE_MIN_DB); + let t = ((score - TONE_START) / (TONE_FULL - TONE_START)).clamp(0., 1.); + t * t * (3. - 2. * t) * guard + } else { + 0. + }; + } for c in 0..self.channels { for i in 0..self.n { self.spectra[c][i] = @@ -524,15 +586,27 @@ impl Engine { let excess = level - reference[i] + self.curve[i] - threshold; // Only real content can ring: leakage far below nearby // material is never cut. - let target = if awake && content[i] && level > -130. { - (knee(excess, KNEE) * SLOPE).min(self.settings.depth) + let live = awake && content[i] && level > -130.; + let target = if live { + (knee(excess, KNEE) * SLOPE).min(self.settings.depth) * (1. - self.tone[i]) } else { 0. }; + if live { + bumps[i] = bumps[i].max(level - reference[i]); + } // Linked stereo: the stronger channel decides. self.target[i] = self.target[i].max(target); } } + if pitch > 0. { + let alpha = 1. - (-frame_time / TONE_MEMORY).exp(); + for i in 0..GRID { + self.fixed[i] += (bumps[i] - self.fixed[i]) * alpha; + let r = &mut self.relative[i + GRID - shift]; + *r += (bumps[i] - *r) * alpha; + } + } let cut = self.target; smooth(&cut, &mut self.target, (self.envelope_radius / 2).max(1), 1); let attack = (-(self.hop as f32) / (self.rate * self.settings.attack * 0.001)).exp(); @@ -553,6 +627,7 @@ impl Engine { * (1. - self.protect * self.settings.transient_guard / 100.)) .clamp(0., self.settings.depth); self.frame.focus[i] = self.curve[i]; + self.frame.tone[i] = self.tone[i]; } for c in 0..self.channels { for k in 0..=self.n / 2 { @@ -581,6 +656,7 @@ impl Engine { self.frame.input_peak = self.in_peak; self.frame.output_peak = self.out_peak; self.frame.guard = self.protect; + self.frame.pitch = pitch; self.frame.sample_rate = self.rate; self.frame.latency = self.n as u32; self.frame.channels = self.channels; diff --git a/plugins/patina/core/src/pitch.rs b/plugins/patina/core/src/pitch.rs new file mode 100644 index 0000000..aeac5a3 --- /dev/null +++ b/plugins/patina/core/src/pitch.rs @@ -0,0 +1,80 @@ +//! Fundamental of the newest input, for Tone guard. Allocation-free after `new`. +use common_dsp::{hz_to_midi, Biquad, Yin}; + +const MIN_HZ: f32 = 30.; +const MAX_HZ: f32 = 500.; +/// Lowpass in front of detection, removing upper harmonics that confuse YIN. +const DETECT_LOWPASS: f32 = 1000.; +const DETECT_RATE: f32 = 6000.; +const CLARITY: f32 = 0.6; +/// Two estimates in a row must agree this closely (semitones) to count. +const AGREE: f32 = 0.5; + +pub(crate) struct PitchProbe { + lowpass: [Biquad; 2], + decimate: usize, + count: usize, + rate: f32, + ring: Vec, + at: usize, + scratch: Vec, + yin: Yin, + last: f32, +} +impl PitchProbe { + pub fn new(rate: f32) -> Self { + let decimate = (rate / DETECT_RATE).round().max(1.) as usize; + let detect_rate = rate / decimate as f32; + let max_lag = (detect_rate / MIN_HZ).ceil() as usize; + let window = 2 * max_lag + 3; + let q = std::f32::consts::FRAC_1_SQRT_2; + Self { + lowpass: [Biquad::lowpass(rate, DETECT_LOWPASS, q); 2], + decimate, + count: 0, + rate: detect_rate, + ring: vec![0.; window], + at: 0, + scratch: vec![0.; window], + yin: Yin::new(max_lag + 2), + last: 0., + } + } + pub fn push(&mut self, x: f32) { + let d = self.lowpass[0].process(x); + let d = self.lowpass[1].process(d); + self.count += 1; + if self.count >= self.decimate { + self.count = 0; + self.ring[self.at] = d; + self.at = (self.at + 1) % self.ring.len(); + } + } + /// Fundamental in Hz of the newest input, or 0 when it is unclear or still changing. + pub fn estimate(&mut self) -> f32 { + let n = self.ring.len(); + for i in 0..n { + self.scratch[i] = self.ring[(self.at + i) % n]; + } + let hz = match self.yin.estimate(&self.scratch, self.rate, MIN_HZ, MAX_HZ) { + Some((hz, clarity)) if clarity >= CLARITY => hz, + _ => 0., + }; + let stable = + hz > 0. && self.last > 0. && (hz_to_midi(hz) - hz_to_midi(self.last)).abs() < AGREE; + self.last = hz; + if stable { + hz + } else { + 0. + } + } + pub fn reset(&mut self) { + for f in &mut self.lowpass { + f.reset(); + } + self.ring.fill(0.); + self.count = 0; + self.last = 0.; + } +} diff --git a/plugins/patina/core/src/settings.rs b/plugins/patina/core/src/settings.rs index 72a457f..1058b3c 100644 --- a/plugins/patina/core/src/settings.rs +++ b/plugins/patina/core/src/settings.rs @@ -37,6 +37,10 @@ pub struct Settings { pub output: f32, pub bypass: bool, pub focus: [Focus; 4], + /// 0 % cuts every bump. Higher values hold back cuts on bumps that follow the played note + /// (a harmonic the sound is built on) while bumps fixed in Hz are still cut. + #[serde(default)] + pub tone_guard: f32, } impl Default for Settings { fn default() -> Self { @@ -53,6 +57,7 @@ impl Default for Settings { output: 0., bypass: false, focus: [Focus::default(); 4], + tone_guard: 0., } } } @@ -69,6 +74,7 @@ impl Settings { (self.high, 20., 20000.), (self.mix, 0., 100.), (self.output, -12., 12.), + (self.tone_guard, 0., 100.), ] { if !v.is_finite() || !(low..=high).contains(&v) { return Err("Invalid resonance control value"); @@ -102,11 +108,12 @@ impl Settings { pub fn threshold(&self) -> f32 { 0.5 + self.selectivity * 0.05 } - pub const PRESETS: [&'static str; 4] = [ + pub const PRESETS: [&'static str; 5] = [ "Gentle cleanup", "Vocal harshness", "Snare ring", "Bright synth", + "Bass line", ]; pub fn preset(index: usize) -> Self { let mut s = Self::default(); @@ -136,6 +143,13 @@ impl Settings { s.sharpness = 25.; s.selectivity = 45.; } + 4 => { + s.low = 150.; + s.high = 12000.; + s.sharpness = 50.; + s.selectivity = 45.; + s.tone_guard = 80.; + } _ => {} } s diff --git a/plugins/patina/core/tests/dsp.rs b/plugins/patina/core/tests/dsp.rs index 720b82a..f1e7a31 100644 --- a/plugins/patina/core/tests/dsp.rs +++ b/plugins/patina/core/tests/dsp.rs @@ -359,3 +359,113 @@ fn high_sample_rates_detect_the_same_bump() { assert!(near >= 4. && away < 1., "{rate}: {near} / {away}"); } } + +/// Notes of a riddim-style line, in Hz (F1, G#1, C2, D#2, F2). +const LINE: [f32; 5] = [43.65, 51.91, 65.41, 77.78, 87.31]; +/// Additive bass at `f0`: harmonics 1/k up to 16 kHz, each scaled by `shape(k, Hz)`. +fn bass(f0: f32, seconds: f32, shape: &dyn Fn(usize, f32) -> f32) -> Vec { + let rate = 48000.; + let n = (rate * seconds) as usize; + let mut x = vec![0f32; n]; + for k in 1..=(16000. / f0) as usize { + let f = k as f32 * f0; + let a = 0.08 * shape(k, f) / k as f32; + let w = TAU * f / rate; + for (i, v) in x.iter_mut().enumerate() { + *v += a * (w * i as f32 + k as f32).sin(); + } + } + x +} +/// Plays `notes` in turn (0.4 s each, `rounds` times) and returns the mean cut near `hz` +/// (largest within ±3 grid cells) over the second half of the last note. +fn tone_cut( + guard: f32, + notes: &[f32], + rounds: usize, + shape: &dyn Fn(usize, f32) -> f32, + hz: &dyn Fn(f32) -> f32, +) -> f32 { + let mut e = Engine::new(48000., 1).unwrap(); + e.set_settings(Settings { + tone_guard: guard, + low: 150., + ..Default::default() + }) + .unwrap(); + let last = *notes.last().unwrap(); + let at = patina_core::cell(hz(last)).round() as usize; + let (mut sum, mut count) = (0., 0); + for round in 0..rounds { + for &f0 in notes { + let x = bass(f0, 0.4, shape); + let measure = round + 1 == rounds && f0 == last; + for (i, v) in x.iter().enumerate() { + e.process_sample([*v, 0.], Monitor::default()); + if let Some(frame) = e.take_analyzer() { + if measure && i > x.len() / 2 { + sum += frame.reduction[at - 3..=at + 3] + .iter() + .fold(0f32, |m, v| m.max(*v)); + count += 1; + } + } + } + } + } + sum / count as f32 +} +fn harmonic_six(k: usize, _: f32) -> f32 { + if k == 6 { + 4. + } else { + 1. + } +} +fn formant(_: usize, f: f32) -> f32 { + let z = (f / 1500.).log2() / 0.15; + 1. + 3. * (-z * z).exp() +} +#[test] +fn tone_guard_holds_back_cuts_on_bumps_that_follow_the_note() { + let h6 = |f0: f32| 6. * f0; + let plain = tone_cut(0., &LINE, 3, &harmonic_six, &h6); + let guarded = tone_cut(100., &LINE, 3, &harmonic_six, &h6); + assert!( + plain > 4., + "without guard the boosted harmonic is cut: {plain}" + ); + assert!( + guarded < 0.25 * plain, + "guard keeps the harmonic: {guarded} vs {plain}" + ); +} +#[test] +fn tone_guard_still_cuts_resonances_fixed_in_hz() { + let at = |_: f32| 1500.; + let plain = tone_cut(0., &LINE, 3, &formant, &at); + let guarded = tone_cut(100., &LINE, 3, &formant, &at); + assert!(plain > 4., "the formant is cut: {plain}"); + assert!( + guarded > 0.85 * plain, + "guard still cuts it: {guarded} vs {plain}" + ); +} +#[test] +fn tone_guard_needs_different_notes_to_tell_bumps_apart() { + let h6 = |f0: f32| 6. * f0; + let one = [LINE[2]]; + let plain = tone_cut(0., &one, 10, &harmonic_six, &h6); + let guarded = tone_cut(100., &one, 10, &harmonic_six, &h6); + assert!( + (guarded - plain).abs() < 0.5, + "one held note gives no evidence: {guarded} vs {plain}" + ); +} +#[test] +fn old_settings_without_tone_guard_still_load() { + let mut json = serde_json::to_value(Settings::default()).unwrap(); + json.as_object_mut().unwrap().remove("tone_guard"); + let s: Settings = serde_json::from_value(json).unwrap(); + assert_eq!(s.tone_guard, 0.); +} diff --git a/plugins/patina/core/tests/realtime.rs b/plugins/patina/core/tests/realtime.rs index c71d456..8a016d3 100644 --- a/plugins/patina/core/tests/realtime.rs +++ b/plugins/patina/core/tests/realtime.rs @@ -35,6 +35,7 @@ static ALLOCATOR: CheckedAllocator = CheckedAllocator; fn processing_automation_and_analysis_do_not_allocate_or_free() { let mut engine = Engine::new(48000., 2).unwrap(); let mut settings = Settings::preset(1); + settings.tone_guard = 80.; engine.set_settings(settings).unwrap(); CHECK.with(|v| v.set(true)); for i in 0..48000 { @@ -45,7 +46,12 @@ fn processing_automation_and_analysis_do_not_allocate_or_free() { settings.low = 120. + (i % 100) as f32; engine.set_settings(settings).unwrap(); } - std::hint::black_box(engine.process_sample([0.3, -0.2], Monitor { delta: i > 24000 })); + // A pitched input, so Tone guard estimates the pitch and updates its maps. + let x = 0.3 + * (i as f32 * std::f32::consts::TAU * 55. / 48000.) + .sin() + .signum(); + std::hint::black_box(engine.process_sample([x, -x], Monitor { delta: i > 24000 })); std::hint::black_box(engine.take_analyzer()); } engine.reset(); diff --git a/plugins/patina/plugin/Cargo.lock b/plugins/patina/plugin/Cargo.lock index 5f9aa23..7dc696a 100644 --- a/plugins/patina/plugin/Cargo.lock +++ b/plugins/patina/plugin/Cargo.lock @@ -306,6 +306,10 @@ dependencies = [ "memchr", ] +[[package]] +name = "common-dsp" +version = "0.1.0" + [[package]] name = "common-ui" version = "0.1.0" @@ -1434,6 +1438,7 @@ dependencies = [ name = "patina-core" version = "0.2.0" dependencies = [ + "common-dsp", "rustfft", "serde", ] diff --git a/plugins/patina/plugin/src/params.rs b/plugins/patina/plugin/src/params.rs index 94c432e..e8f3dad 100644 --- a/plugins/patina/plugin/src/params.rs +++ b/plugins/patina/plugin/src/params.rs @@ -62,6 +62,8 @@ pub struct PatinaParams { pub mix: FloatParam, #[id = "output"] pub output: FloatParam, + #[id = "tone-guard"] + pub tone_guard: FloatParam, #[id = "bypass"] pub bypass: BoolParam, #[nested(array, group = "Focus")] @@ -82,6 +84,7 @@ impl Default for PatinaParams { high: log("High frequency", d.high, 21., 20000., " Hz"), mix: linear("Mix", 100., 0., 100., " %"), output: linear("Output", 0., -12., 12., " dB"), + tone_guard: linear("Tone guard", d.tone_guard, 0., 100., " %"), bypass: BoolParam::new("Bypass", false).make_bypass(), focus: std::array::from_fn(|_| FocusParams::default()), } @@ -116,6 +119,7 @@ impl PatinaParams { &self.focus[3].frequency, &self.focus[3].width, &self.focus[3].sensitivity, + &self.tone_guard, ] } pub fn settings(&self) -> Settings { @@ -137,6 +141,7 @@ impl PatinaParams { width: self.focus[i].width.value(), sensitivity: self.focus[i].sensitivity.value(), }), + tone_guard: self.tone_guard.value(), }; // Independent automation of bounds may cross them; preserve a valid effective range. if s.low >= s.high { @@ -145,8 +150,8 @@ impl PatinaParams { s } } -/// Host float parameters: ten main controls, then four per focus point. -pub const COUNT: usize = 26; +/// Host float parameters: ten main controls, four per focus point, then Tone guard. +pub const COUNT: usize = 27; pub fn values(s: &Settings) -> [f32; COUNT] { let mut v = [0.; COUNT]; v[..10].copy_from_slice(&[ @@ -169,5 +174,6 @@ pub fn values(s: &Settings) -> [f32; COUNT] { s.focus[i].sensitivity, ]); } + v[26] = s.tone_guard; v } diff --git a/plugins/patina/ui/src/lib.rs b/plugins/patina/ui/src/lib.rs index 554ac50..91e1d78 100644 --- a/plugins/patina/ui/src/lib.rs +++ b/plugins/patina/ui/src/lib.rs @@ -43,7 +43,9 @@ impl State { self.analyzer.envelope[i] += 0.45 * (frame.envelope[i] - self.analyzer.envelope[i]); self.analyzer.reference[i] += 0.45 * (frame.reference[i] - self.analyzer.reference[i]); self.analyzer.reduction[i] += 0.5 * (frame.reduction[i] - self.analyzer.reduction[i]); + self.analyzer.tone[i] += 0.5 * (frame.tone[i] - self.analyzer.tone[i]); } + self.analyzer.pitch = frame.pitch; self.analyzer.focus = frame.focus; self.analyzer.input_peak = frame.input_peak; self.analyzer.output_peak = frame.output_peak; @@ -59,6 +61,21 @@ impl State { } } } +/// Note name of a frequency, or a dash when there is none. +fn note_name(hz: f32) -> String { + if hz <= 0. { + return "—".into(); + } + const NAMES: [&str; 12] = [ + "C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B", + ]; + let n = (69. + 12. * (hz / 440.).log2()).round() as i32; + format!( + "{}{}", + NAMES[n.rem_euclid(12) as usize], + n.div_euclid(12) - 1 + ) +} fn db(x: f32) -> f32 { 20. * x.max(1e-6).log10() } @@ -99,6 +116,7 @@ fn control( let defaults = Settings::default(); *value = match name { "Transient guard" => defaults.transient_guard, + "Tone guard" => defaults.tone_guard, "Attack" => defaults.attack, "Release" => defaults.release, "Low frequency" => defaults.low, @@ -170,7 +188,7 @@ pub fn show(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) { ui.separator(); egui::ComboBox::from_id_salt("resonance-preset") .selected_text( - (0..4) + (0..Settings::PRESETS.len()) .find(|i| *settings == Settings::preset(*i)) .map_or("Custom", |i| Settings::PRESETS[i]), ) @@ -209,6 +227,9 @@ pub fn show(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) { format!("{:>3.0} %", state.analyzer.guard * 100.), MUTED, ); + if settings.tone_guard > 0. { + readout(ui, "NOTE", note_name(state.analyzer.pitch), FOCUS); + } ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { caption( ui, @@ -368,9 +389,9 @@ pub fn show(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) { panel(ui, |ui| { ui.set_width(ui.available_width()); egui::Grid::new("engine-controls") - .num_columns(3) + .num_columns(4) .spacing([16., 12.]) - .min_col_width((ui.available_width() - 32.) / 3.) + .min_col_width((ui.available_width() - 48.) / 4.) .show(ui, |ui| { control( ui, @@ -402,6 +423,16 @@ pub fn show(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) { "Relaxes cuts on attacks to keep punch. Turn down to catch short rings.", state, ); + control( + ui, + "Tone guard", + &mut settings.tone_guard, + 0. ..=100., + " %", + false, + "Keeps bumps that follow the played note (the harmonics a bass is built on) and still cuts rings fixed in Hz. Needs a few different notes to tell them apart.", + state, + ); ui.end_row(); }); ui.add_space(6.); @@ -622,6 +653,23 @@ fn spectrum(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) { fill[1..=GRID].to_vec(), Stroke::new(1.6_f32, ACCENT), )); + // Tone guard: a strip along the top where cuts are held back because the bump follows the note. + if settings.tone_guard > 0. { + let mut strip = egui::Mesh::default(); + for i in 0..GRID { + let xx = x(frequency(i)); + let alpha = (state.analyzer.tone[i].clamp(0., 1.) * 170.) as u8; + let color = Color32::from_rgba_unmultiplied(FOCUS.r(), FOCUS.g(), FOCUS.b(), alpha); + strip.colored_vertex(Pos2::new(xx, r.top()), color); + strip.colored_vertex(Pos2::new(xx, r.top() + 8.), color); + if i > 0 { + let j = (i * 2) as u32; + strip.add_triangle(j - 2, j - 1, j); + strip.add_triangle(j - 1, j, j + 1); + } + } + p.add(egui::Shape::mesh(strip)); + } let center = r.center().y; let focus_y = |offset: f32| center - offset / 36. * r.height() * 0.65; let points: Vec<_> = (0..GRID) diff --git a/tools/editor-check/Cargo.lock b/tools/editor-check/Cargo.lock index 0877146..7610c80 100644 --- a/tools/editor-check/Cargo.lock +++ b/tools/editor-check/Cargo.lock @@ -416,6 +416,7 @@ dependencies = [ name = "patina-core" version = "0.2.0" dependencies = [ + "common-dsp", "rustfft", "serde", ]