desc:GFX Head //tags: saturation tape tube colour drive //author: JGWizrad / Garden FX (GFX) // // Requires gfx-drift, gfx-input, gfx-output, gfx-ui, gfx-panel, gfx-dyn, gfx-faceplate, gfx-instrument and gfx-headsmooth (.jsfx-inc) // in the same folder (gfx-headsmooth draws the faceplate, shared with GFX Headsmoother). // // Tape-style saturator. For personal use; the sound is designed from scratch, in the spirit of the classic // one-knob tape saturators. // DRIVE - how hard the signal hits the tape stage (0 - 10, up to +22 dB into the curve). The level is // matched automatically, so DRIVE changes the character, not the loudness. // VOICE - NORMAL: flat, with tape's gentle top-end loss // MEDIUM: a broad lift around 1.4 kHz going into the curve, for body // BRIGHT: a high shelf going into the curve, so the top end saturates first // TRIM - clean output level. // Tape behaviour: treble is boosted before the curve and cut after it (pre / de-emphasis); "tight bias" (a // small, fixed asymmetry: mostly odd harmonics with a little even-order warmth), a low-end head bump that // eases off with the drive, and top-end gap loss. 2x oversampled. // As DRIVE goes up, more of the low end (below about 250 Hz) skips the curve and rejoins clean, so the // bass doesn't push the mids and highs in and out of saturation: smooth, uncluttered distortion. // DRIFT: the bias and the top-end loss wander slowly and independently on each side, like a real machine. // The DRIFT dial scales it (50% = the drift / drift speed settings, 100% = twice, 0 = none). // IN / OUT: TRIM (with the GUARD key), SAT and the OUT stage (OFF / CLIP / LIM) on the oxblood strip below. import gfx-drift.jsfx-inc import gfx-input.jsfx-inc import gfx-output.jsfx-inc import gfx-ui.jsfx-inc import gfx-panel.jsfx-inc import gfx-dyn.jsfx-inc import gfx-faceplate.jsfx-inc import gfx-instrument.jsfx-inc import gfx-headsmooth.jsfx-inc slider1:3<0,10,0.01>-Drive slider2:0<-24,6,0.1>-Trim (dB) slider3:0<0,2,1{Normal,Medium,Bright}>-Voice slider4:0<-24,12,0.1>-Input trim (dB) slider5:1<0,1,1{Off,On}>-Input guard slider6:25<0,100,1>-Drift amount (%) slider7:0.25<0.02,2,0.01>-Drift speed (Hz) slider8:0<0,100,1>-Band sat (%) slider9:0<0,100,1>-Bias (%) slider10:120<40,400,1>-Sat low band (Hz) slider11:6000<2000,16000,10>-Sat high band (Hz) slider12:-0.3<-24,0,0.1>-Ceiling / limit threshold (dB) slider13:1<0,2,1{Off,Clip,Limit}>-Output stage // DRIFT dial: scales how far the tape bias and the top-end gap loss wander on each side, and how fast slider14:50<0,100,1>-Drift (%) in_pin:left input in_pin:right input out_pin:left output out_pin:right output @init #ui_man = "gfx-head"; // the ? pop-up links to this manual // ---------- biquads (RBJ); fs = the rate the filter runs at ---------- function bq_set(type, f, q, gdb, fs) instance(b0, b1, b2, a1, a2) local(w0, cw, sw, al, amp, sa, n) ( w0 = 2 * $pi * min(f, fs * 0.45) / fs; cw = cos(w0); sw = sin(w0); al = sw / (2 * q); amp = 10 ^ (gdb / 40); type == 0 ? ( // low-pass n = 1 + al; b0 = (1 - cw) * 0.5 / n; b1 = (1 - cw) / n; b2 = b0; a1 = -2 * cw / n; a2 = (1 - al) / n; ) : type == 1 ? ( // high-pass n = 1 + al; b0 = (1 + cw) * 0.5 / n; b1 = -(1 + cw) / n; b2 = b0; a1 = -2 * cw / n; a2 = (1 - al) / n; ) : type == 2 ? ( // peak n = 1 + al / amp; b0 = (1 + al * amp) / n; b1 = -2 * cw / n; b2 = (1 - al * amp) / n; a1 = b1; a2 = (1 - al / amp) / n; ) : ( // high shelf sa = 2 * sqrt(amp) * al; n = (amp + 1) - (amp - 1) * cw + sa; b0 = amp * ((amp + 1) + (amp - 1) * cw + sa) / n; b1 = -2 * amp * ((amp - 1) + (amp + 1) * cw) / n; b2 = amp * ((amp + 1) + (amp - 1) * cw - sa) / n; a1 = 2 * ((amp - 1) - (amp + 1) * cw) / n; a2 = ((amp + 1) - (amp - 1) * cw - sa) / n; ); ); function bq_run(x) instance(b0, b1, b2, a1, a2, x1, x2, y1, y2) local(y) ( y = b0 * x + b1 * x1 + b2 * x2 - a1 * y1 - a2 * y2; x2 = x1; x1 = x; y2 = y1; y1 = y; y; ); // ---------- one channel ---------- // 8th-order Butterworth anti-alias filters (four biquads) either side of the curve, at twice the rate function hd_aa() instance(u1, u2, u3, u4, d1, d2, d3, d4) local(fc, fs2) ( fs2 = 2 * srate; fc = min(0.42 * srate, 20000); u1.bq_set(0, fc, 0.5098, 0, fs2); u2.bq_set(0, fc, 0.6013, 0, fs2); u3.bq_set(0, fc, 0.9000, 0, fs2); u4.bq_set(0, fc, 2.5629, 0, fs2); d1.bq_set(0, fc, 0.5098, 0, fs2); d2.bq_set(0, fc, 0.6013, 0, fs2); d3.bq_set(0, fc, 0.9000, 0, fs2); d4.bq_set(0, fc, 2.5629, 0, fs2); ); // voicing + tape EQ (the gap loss follows this side's drift) function hd_eq(gapmul) instance(emp, dem, vpre, vpost, bump, gap, dcb, lop) ( emp.bq_set(3, 3000, 0.7, 4, srate); dem.bq_set(3, 3000, 0.7, -4, srate); voice == 1 ? ( vpre.bq_set(2, 1400, 0.6, 3.5, srate); vpost.bq_set(2, 1400, 0.6, -1.5, srate); ) : voice == 2 ? ( vpre.bq_set(3, 6000, 0.7, 5, srate); vpost.bq_set(3, 6000, 0.7, -2, srate); ) : ( vpre.bq_set(2, 1000, 0.7, 0, srate); vpost.bq_set(2, 1000, 0.7, 0, srate); ); bump.bq_set(2, 70, 0.9, hd_bump, srate); gap.bq_set(0, (voice == 2 ? 21000 : voice == 1 ? 18000 : 16000) * gapmul, 0.55, 0, srate); dcb.bq_set(1, 10, 0.7, 0, srate); lop.bq_set(0, 250, 0.6, 0, srate); // the low band that bypasses the curve ); // the curve: tanh with a small fixed bias ("tight bias"): mostly odd harmonics with a little even-order // warmth that no longer grows with the drive. The gain is set so a signal peaking around -12 dBFS comes // out at the same level whatever the drive: DRIVE changes the character, not the loudness. function hd_sat(x, b) local(tb, c) ( tb = ost_tanh(b); c = 0.25 / ((ost_tanh(hd_g * 0.125 + b) - tb) - (ost_tanh(-hd_g * 0.125 + b) - tb)); (ost_tanh(hd_g * x + b) - tb) * c; ); // The lows are split off with a complementary filter (low + rest = the input exactly); as DRIVE goes up, // more of the low band skips the curve and rejoins untouched afterwards, so the bass stops pushing the // mids and highs in and out of saturation (no "clumping"). function hd_run(x, b) instance(emp, dem, vpre, vpost, bump, gap, dcb, lop, u1, u2, u3, u4, d1, d2, d3, d4) local(y, z, lo) ( lo = lop.bq_run(x) * hd_lb; // the part of the lows that bypasses the curve y = vpre.bq_run(emp.bq_run(x - lo)) * 0.5; // into the curve with 6 dB of headroom // 2x: zero-stuffed upsample, filter, curve, filter, keep every second sample z = u4.bq_run(u3.bq_run(u2.bq_run(u1.bq_run(y * 2)))); d4.bq_run(d3.bq_run(d2.bq_run(d1.bq_run(hd_sat(z, b))))); z = u4.bq_run(u3.bq_run(u2.bq_run(u1.bq_run(0)))); y = d4.bq_run(d3.bq_run(d2.bq_run(d1.bq_run(hd_sat(z, b))))) * 2; dcb.bq_run(gap.bq_run(bump.bq_run(vpost.bq_run(dem.bq_run(y)) + lo * hd_lg))); ); function update() ( hd_g = 10 ^ (slider1 * 2.2 / 20); slider8 = slider9; // Link saturation amount to the BOOST parameter hd_b0 = (slider9 / 100) * 0.2; // BOOST also scales tape asymmetry (0 to 0.20) hd_lb = (slider1 / 10) ^ 1.2; // how much of the low band bypasses the curve // the bypassed lows rejoin at the curve's gain around 0 VU (-18 dBFS): between its quiet-signal gain // and its unity gain at -12 dBFS, so the low end keeps its place as the drive goes up hd_c = 0.25 / ((ost_tanh(hd_g * 0.125 + hd_b0) - ost_tanh(hd_b0)) - (ost_tanh(-hd_g * 0.125 + hd_b0) - ost_tanh(hd_b0))); hd_lg = sqrt(hd_g * hd_c * (1 - ost_tanh(hd_b0) ^ 2)); hd_bump = 1.5 * (1 - 0.5 * slider1 / 10); // a gentler head bump as the drive goes up voice = slider3; trim_t = 10 ^ (slider2 / 20); drift_macro(slider14); drift = slider6 / 100 * dm_a; dspd = slider7 * dm_s; in_setup(slider4, slider5); ins_output_setup(8); ); hL.hd_aa(); hR.hd_aa(); dBL.drift_init(0.8 + rand(0.5)); dBR.drift_init(0.8 + rand(0.5)); dGL.drift_init(0.8 + rand(0.5)); dGR.drift_init(0.8 + rand(0.5)); ost_init(20000); ostL.ost_ch_init(); ostR.ost_ch_init(); in_init(); sm = 1 - exp(-1 / (0.03 * srate)); pkL = 0; pkR = 0; mpk = 0; !ui_dflt ? ui_thm = 12; !ui_dflt ? ui_hw = 1; // fixed racing-green faceplate !ui_dflt ? ui_kstyle = 0; update(); trim_s = trim_t; ui_dflt = 1; // theme defaults above apply once: @init re-runs (playback start, device reset) must not undo the saved theme @slider update(); @block update(); // drift: once a block (the EQ is recalculated with this side's gap loss) dinc = dspd * samplesblock / srate; vbL = dBL.drift_tick(dinc); vbR = dBR.drift_tick(dinc); vgL = dGL.drift_tick(dinc); vgR = dGR.drift_tick(dinc); bL = hd_b0 + vbL * drift * 0.06; bR = hd_b0 + vbR * drift * 0.06; hL.hd_eq(2 ^ (vgL * drift * 0.25)); hR.hd_eq(2 ^ (vgR * drift * 0.25)); @serialize file_var(0, ui_sz); @sample trim_s += (trim_t - trim_s) * sm; // smoothed TRIM gain (clean output level), applied post output-stage oL = hL.hd_run(spl0, bL); oR = hR.hd_run(spl1, bR); oL = ostL.ost_band(oL); oR = ostR.ost_band(oR); ost_out(oL, oR); spl0 = ost_l * trim_s; spl1 = ost_r * trim_s; mpk = max(mpk, max(abs(spl0), abs(spl1))); @gfx 780 202 ui_begin(780, 202); ui_col(0x0B0C0D); gfx_rect(0, 0, gfx_w, gfx_h); // ---------- one racing-green unit: DRIFT + INPUT TRIM | DRIVE | VOICE | TRIM | SAT + OUT | LEVEL ---------- T = 8; ui_theme(12); hs_unit(T, HS_HH, HS_GRN); gfx_gradrect(10 * sc, (T + 2) * sc, 760 * sc, (HS_HH - 4) * sc, 1, 1, 0.9, 0.05, 0, 0, 0, -0.05 / (760 * sc), 0, 0, 0, 0); hs_badge(40, T + 8, "HEAD", "TAPE SATURATOR", HS_GRN); ui_size_picker(600 * sc, (T + 11) * sc); pk_ink = HS_CRM; pk_bg = HS_GRN; pk_nut = 0; pk_rsc = 0.72; // left column: DRIFT over the input TRIM (with its GUARD key) dy_ink = HS_CRM; dy_bg = HS_GRN; dy_kst = 20; dy_nov = 1; // DRIFT and IN TRIM removed // the main three pk_bigknob(176 * sc, (T + 100) * sc, 28 * sc, 1, 0, 10, 3, 11, 0, 1, "DRIVE", "%.1f"); pk_rotsel(326 * sc, (T + 112) * sc, 3, "NORMAL", "MEDIUM", "BRIGHT"); gfx_setfont(6, "Trebuchet MS", 15 * sc, 'b'); ui_col(HS_CRM); ui_text(326 * sc, (T + 158) * sc, "VOICE"); pk_bigknob(476 * sc, (T + 100) * sc, 28 * sc, 2, -24, 6, 0, 11, -24, 3, "TRIM", "%+.1f dB"); // right column: SAT over the OUT stage keys sprintf(#dv, "%.0f%%", slider9); dy_knob(586, T + 74, 13, 9, 0, 100, 30, 0, 0xD8B040, "BOOST", #dv); // Output stage keys removed dy_nov = 0; dy_kst = 7; ui_cola(HS_CRM, 0.45); gfx_rect(628 * sc, (T + 58) * sc, 1 * sc, 100 * sc); // pinline hs_lvh = max(mpk, hs_lvh * 0.8); mpk = 0; hs_leds(700, T + 108, hs_lvh, HS_CRM); pk_end(); ui_end();