desc:GFX Model 15 //tags: channel strip preamp tape saturation lo-fi hiss portable cassette //author: JGWizrad / Garden FX (GFX) // // Requires gfx-output, gfx-input, gfx-ui, gfx-panel and gfx-dyn (.jsfx-inc) in the same folder. // // A single-channel strip in the spirit of the early-80s ultra-portable cassette multitracker ecosystem: // a low-voltage, battery-driven preamp, a fixed broad-stroke tone stage, a sluggish tape-head saturation // with a level-dependent top-end roll-off, and an open, breathing hiss floor (no sophisticated noise // reduction - just a simple single-band lift/unlift). Use it to push stems toward a compressed, boxy, // warm little-recorder character (personal use; the sound is designed from scratch). // // GAIN - the low-voltage op-amp preamp: pushed past noon it clips early and asymmetrically, smearing // transients and thickening the low-mids. // TREBLE / BASS - fixed broad shelves (10 kHz / 100 Hz). Push them hard and the shelf itself turns a // little gritty and grainy rather than staying clean. // TAPE DRIVE - the tape saturation / hysteresis amount: a sluggish magnetic memory plus a dynamic // top-end roll-off that closes down as level rises, gluing transients together. // WOW - wow and flutter in the transport. // HISS - the tape's own noise floor: it ducks a little under signal and swells back in the gaps. // AGE - an older, worn tape: the top end goes further and dropouts creep in. // NR - the simple B-type-style noise reduction, in / out. // MIX - blend of the coloured signal against the dry input. // IN/OUT - input TRIM and GUARD (gentle auto-leveller), OUTPUT level, and the output band-saturation / // clipper / limiter stage. // PRESETS. import gfx-output.jsfx-inc import gfx-input.jsfx-inc import gfx-ui.jsfx-inc import gfx-panel.jsfx-inc import gfx-dyn.jsfx-inc slider1:6<-6,24,0.1>-Gain (dB) slider2:0<-10,10,0.1>-Treble (10 kHz) (dB) slider3:0<-10,10,0.1>-Bass (100 Hz) (dB) slider4:35<0,100,1>-Tape drive (%) slider5:25<0,100,1>-Wow / flutter (%) slider6:50<0,100,1>-Hiss (%) slider7:15<0,100,1>-Age (%) slider8:1<0,1,1{Off,On}>-Noise reduction slider9:100<0,100,1>-Mix (%) slider10:0<-24,12,0.1>-Output (dB) slider11:0<0,100,1>-Band sat (%) slider12:30<0,100,1>-Bias (%) slider13:120<40,400,1>-Sat low band (Hz) slider14:6000<2000,16000,10>-Sat high band (Hz) slider15:-0.3<-24,0,0.1>-Ceiling / limit threshold (dB) slider16:1<0,2,1{Off,Clip,Limit}>-Output stage slider17:0<-24,12,0.1>-Input trim (dB) slider18:1<0,1,1{Off,On}>-Input guard in_pin:left input in_pin:right input out_pin:left output out_pin:right output @init #ui_man = "gfx-model-15"; WBSZ = 8192; WBMSK = WBSZ - 1; // wow delay lines: 2 x 8192 from 10000 memset(10000, 0, WBSZ * 2); // ---------- the low-voltage preamp: asymmetric soft clip, unity small-signal gain ---------- function f15_pre(x) ( ost_curve(x * g_lin, pre_drv, pre_bias) ); // ---------- fixed shelving EQ: the added band itself saturates a little ---------- function f15_eq_set() instance(ts, bsf) ( ts.bq_set(3, 10000, 0.6, tr_db); bsf.bq_set(4, 100, 0.6, bs_db); ); function f15_eq(x) instance(ts, bsf) local(y, addhi, bsy, addlo) ( y = ts.bq_run(x); addhi = y - x; addhi = ost_tanh(addhi * shelf_drv) / max(shelf_drv, 0.0001); y = x + addhi; bsy = bsf.bq_run(y); addlo = bsy - y; addlo = ost_tanh(addlo * shelf_drv * 0.6) / max(shelf_drv * 0.6, 0.0001); y + addlo; ); // ---------- the tape: hysteresis lag + saturation, dynamic top-end roll-off, hiss, simple NR ---------- function f15_tape_set() instance(hb) ( hb.bq_set(2, 80, 1.1, 2.0); ); // fixed little head bump function f15_tape(x, buf, nz, nron) instance(hy, env, lp, bhr, ber, bhp, bep, hsv, hb) local(y, h, k, cutoff, lpc, rp, i, fr, duck) ( // record-side NR encode: lift the quiet highs before the "tape" nron ? ( bhr += (x - bhr) * b_c; h = x - bhr; ber += (abs(h) - ber) * nr_c; k = 2.0 * (1 - min(ber / 0.06, 1)); y = x + k * h; ) : y = x; // hysteresis: a sluggish magnetic memory blended in ahead of the saturation curve hy += (y - hy) * hy_c; y = 0.6 * y + 0.4 * hy; y = y + tape_wet * (ost_curve(y, drv_k, bias_tape) - y); // fixed head bump y = hb.bq_run(y); // transport: wow / flutter (shared wdel, each side its own delay line) buf[wpos] = y; rp = wpos - wdel; rp < 0 ? rp += WBSZ; i = floor(rp); fr = rp - i; y = buf[i & WBMSK] * (1 - fr) + buf[(i + 1) & WBMSK] * fr; // dynamic top-end roll-off: closes down as level rises (the "gluing" playback head loss) env += (abs(y) - env) * env_c; cutoff = max(hf_min, hf_max - (hf_max - hf_min) * min(env * env_gain, 1)); lpc = 1 - exp(-2 * $pi * min(cutoff, srate * 0.45) / srate); lp += (y - lp) * lpc; y = lp * drop_g; // the tape's own hiss: ducks under signal, swells back in the gaps hsv += (abs(x) - hsv) * (abs(x) > hsv ? hiss_fast : hiss_slow); duck = 1 / (1 + 4 * hsv); y += nz * hiss_g * duck; // playback-side NR decode: the inverse lift, taken back down nron ? ( bhp += (y - bhp) * b_c; h = y - bhp; bep += (abs(h) - bep) * nr_c; k = 2.0 * (1 - min(bep / 0.06, 1)); y -= k / (1 + k) * h; ); y; ); function update() ( g_db = slider1; g_lin = 10 ^ (g_db / 20); pre_pos = max(g_db, 0) / 24; pre_drv = 1 + 2.2 * pre_pos; pre_bias = 0.05 + 0.11 * pre_pos; tr_db = slider2; bs_db = slider3; shelf_drv = 1 + (abs(tr_db) + abs(bs_db)) / 10 * 1.1; tape_amt = slider4 / 100; hy_c = 1 - exp(-1 / (0.008 * srate)); drv_k = 1 + 2 * tape_amt; bias_tape = 0.03 + 0.05 * tape_amt; tape_wet = min(tape_amt * 1.4, 1); age = slider7 / 100; hf_max = 9500; hf_min = max(2800, hf_max - 6000 * tape_amt - 2000 * age); env_c = 1 - exp(-1 / (0.015 * srate)); env_gain = 2 + 3 * tape_amt; b_c = 1 - exp(-2 * $pi * 3000 / srate); nr_c = 1 - exp(-1 / (0.008 * srate)); wow_a = slider5 / 100; hiss_g = (slider6 / 50) * 10 ^ (-50 / 20); hiss_fast = 1 - exp(-1 / (0.01 * srate)); hiss_slow = 1 - exp(-1 / (0.4 * srate)); nr_on = slider8; mix_t = slider9 / 100; out_t = 10 ^ (slider10 / 20); eqL.f15_eq_set(); eqR.f15_eq_set(); tpL.f15_tape_set(); tpR.f15_tape_set(); in_setup(slider17, slider18); ost_setup(slider11 / 100, slider12 / 100, slider13, slider14, slider15, slider16); ); // ---------- presets (gain, treble, bass, tape, wow, hiss, age, nr, mix, output) ---------- function setp(g, tr, bs, dr, w, h, a, n, mx) ( slider1 = g; slider2 = tr; slider3 = bs; slider4 = dr; slider5 = w; slider6 = h; slider7 = a; slider8 = n; slider9 = mx; ); function pname(p) ( p == 1 ? "Bedroom Demo" : p == 2 ? "Hot and Boxy" : p == 3 ? "No NR, Open Tape" : p == 4 ? "Worn-out Portable" : p == 5 ? "Clean-ish Portable" : "Reset all to defaults"; ); function load_preset(p) ( p == 1 ? setp(6, 1, 1, 45, 25, 50, 15, 1, 100) : // Bedroom Demo p == 2 ? setp(15, 2, 2, 70, 35, 60, 25, 1, 100) : // Hot and Boxy p == 3 ? setp(9, 0, 0, 55, 30, 90, 20, 0, 100) : // No NR, Open Tape p == 4 ? setp(12, -1, 3, 60, 70, 75, 65, 1, 100) : // Worn-out Portable p == 5 ? setp(0, 0, 0, 15, 15, 40, 5, 1, 100); // Clean-ish Portable p >= 1 && p <= 5 ? slider_automate(1023); p == 6 ? ( // Reset all to defaults setp(6, 0, 0, 35, 25, 50, 15, 1, 100); slider10 = 0; slider11 = 0; slider12 = 30; slider13 = 120; slider14 = 6000; slider15 = -0.3; slider16 = 1; slider17 = 0; slider18 = 1; slider_automate(262143); ); ); ost_init(40000); ostL.ost_ch_init(); ostR.ost_ch_init(); in_init(); sm = 1 - exp(-1 / (0.02 * srate)); vuc = 1 - exp(-1 / (0.3 * srate)); dr_r = 1 - exp(-1 / (0.03 * srate)); wpos = 0; drop_g = 1; drop_t = 1; cur_preset = 1; !ui_dflt ? ui_thm = 5; !ui_dflt ? ui_hw = 1; update(); mix_s = mix_t; out_s = out_t; ui_dflt = 1; @slider update(); @block update(); @serialize file_var(0, ui_sz); @sample in_process(spl0, spl1); inL = in_l; inR = in_r; // ---------- transport: wow (0.6 Hz + a slow wander) and flutter (6 Hz) ---------- wph += 0.6 / srate; wph -= floor(wph); fph += 6.2 / srate; fph -= floor(fph); wnd += ((rand(2) - 1) - wnd) * 0.00002; wdel = (2 + wow_a * (0.45 * sin(2 * $pi * wph) + 1.5 * wnd + 0.06 * sin(2 * $pi * fph))) * 0.001 * srate + 2; // dropouts (AGE) age > 0 && rand(1) < age * age * 1.2 / srate ? ( drop_t = 10 ^ (-(2 + rand(6)) / 20); drop_n = (0.01 + rand(0.04)) * srate; ); drop_n > 0 ? drop_n -= 1 : drop_t = 1; drop_g += (drop_t - drop_g) * dr_r; // hiss: an independent noise per side, gently pink NZ0 += ((rand(2) - 1) - NZ0) * 0.5; NZ1 += ((rand(2) - 1) - NZ1) * 0.5; pL = f15_pre(inL); pR = f15_pre(inR); eL = eqL.f15_eq(pL); eR = eqR.f15_eq(pR); tL = tpL.f15_tape(eL, 10000, NZ0, nr_on); tR = tpR.f15_tape(eR, 10000 + WBSZ, NZ1, nr_on); wpos = (wpos + 1) & WBMSK; mix_s += (mix_t - mix_s) * sm; out_s += (out_t - out_s) * sm; oL = inL * (1 - mix_s) + tL * mix_s; oR = inR * (1 - mix_s) + tR * mix_s; oL = ostL.ost_band(oL) * out_s; oR = ostR.ost_band(oR) * out_s; ost_out(oL, oR); spl0 = ost_l; spl1 = ost_r; vmL += (spl0 * spl0 - vmL) * vuc; vmR += (spl1 * spl1 - vmR) * vuc; pkL = max(pkL, abs(spl0)); pkR = max(pkR, abs(spl1)); @gfx 760 404 ui_begin(760, 404); PNL = 0x1B1B1D; INK = 0xEDEAE2; ACC = 0xE8502A; WIN = 0x0E0E10; dy_ink = INK; dy_bg = PNL; dy_kst = 20; dy_nov = 1; ui_col(0x0A0A0B); gfx_rect(0, 0, gfx_w, gfx_h); pk_chassis(8 * sc, 4 * sc, 744 * sc, 396 * sc, PNL); ui_col(INK); gfx_circle(40 * sc, 34 * sc, 14 * sc, 1, 1); gfx_setfont(4); ui_col(PNL); ui_text(40 * sc, 29 * sc, "GFX"); gfx_setfont(6, "Trebuchet MS", 24 * sc, 'b'); ui_col(INK); gfx_x = 62 * sc; gfx_y = 20 * sc; gfx_drawstr("MODEL 15"); gfx_setfont(4); ui_col(ACC); gfx_x = 212 * sc; gfx_y = 30 * sc; gfx_drawstr("LO-FI CHANNEL"); ui_button(388 * sc, 20 * sc, 26 * sc, 28 * sc, "<", 0, 1) ? ( cur_preset = cur_preset <= 1 ? 6 : cur_preset - 1; load_preset(cur_preset); ); ui_button(416 * sc, 20 * sc, 26 * sc, 28 * sc, ">", 0, 1) ? ( cur_preset = cur_preset >= 6 ? 1 : cur_preset + 1; load_preset(cur_preset); ); ui_button(450 * sc, 20 * sc, 90 * sc, 28 * sc, "PRESETS", 0, 1) ? ( gfx_x = 450 * sc; gfx_y = 48 * sc; cur_preset = gfx_showmenu("Bedroom Demo|Hot and Boxy|No NR, Open Tape|Worn-out Portable|Clean-ish Portable|Reset all to defaults"); cur_preset > 0 ? load_preset(cur_preset); ); ui_size_picker(560 * sc, 22 * sc); pk_lcd(624 * sc, 16 * sc, 120 * sc, 32 * sc, 0); pk_lcdfont(9, 9 * sc); ui_col(pk_lcdc); gfx_x = 630 * sc; gfx_y = 22 * sc; gfx_drawstr("PATCH"); pk_lcdfont(9, 11 * sc); ui_col(pk_lcdc); gfx_x = 630 * sc; gfx_y = 33 * sc; gfx_drawstr(pname(cur_preset)); ui_col(ACC); gfx_rect(14 * sc, 60 * sc, 732 * sc, 3 * sc); // ---------- CHANNEL: gain, EQ, trim / guard, mix ---------- ui_cola(INK, 0.06); gfx_rect(22 * sc, 72 * sc, 368 * sc, 316 * sc); gfx_setfont(6, "Trebuchet MS", 13 * sc, 'b'); ui_col(ACC); gfx_x = 34 * sc; gfx_y = 82 * sc; gfx_drawstr("CHANNEL"); ui_col(ACC); gfx_rect(34 * sc, 98 * sc, 344 * sc, 1 * sc); LPK = max(max(pkL, pkR), LPK * 0.9); pkL = 0; pkR = 0; pk_led(206 * sc, 112 * sc, 3.5 * sc, LPK > 0.001, dy_grn); sprintf(#v, "%+.1f dB", slider1); dy_knob(100, 170, 24, 1, -6, 24, 6, 0, ACC, "GAIN", #v); sprintf(#v, "%+.1f dB", slider2); dy_knob(206, 170, 16, 2, -10, 10, 0, 0, 0xB8BCC2, "TREBLE", #v); sprintf(#v, "%+.1f dB", slider3); dy_knob(312, 170, 16, 3, -10, 10, 0, 0, 0xB8BCC2, "BASS", #v); sprintf(#v, "%+.1f dB", slider17); dy_knob(100, 270, 16, 17, -24, 12, 0, 0, 0xB8BCC2, "TRIM", #v); dy_btn(188, 254, slider18, dy_grn, "GUARD") ? ( slider18 = 1 - slider18; ui_auto(18); ); sprintf(#v, "%.0f%%", slider9); dy_knob(312, 270, 16, 9, 0, 100, 100, 0, 0xF2F2EE, "MIX", #v); // ---------- meters ---------- vuL.ui_vu(400 * sc, 72 * sc, 170 * sc, 104 * sc, 10 * log10(vmL + 0.0000000001) + 18, "L"); vuR.ui_vu(576 * sc, 72 * sc, 170 * sc, 104 * sc, 10 * log10(vmR + 0.0000000001) + 18, "R"); // ---------- tape + output ---------- ui_cola(INK, 0.06); gfx_rect(400 * sc, 188 * sc, 346 * sc, 200 * sc); gfx_setfont(6, "Trebuchet MS", 13 * sc, 'b'); ui_col(ACC); gfx_x = 410 * sc; gfx_y = 194 * sc; gfx_drawstr("TAPE"); ui_col(ACC); gfx_rect(410 * sc, 210 * sc, 326 * sc, 1 * sc); dy_nov = 0; sprintf(#v, "%.0f%%", slider4); dy_knob(436, 250, 17, 4, 0, 100, 35, 0, ACC, "DRIVE", #v); sprintf(#v, "%.0f", slider5); dy_knob(504, 250, 17, 5, 0, 100, 25, 0, 0xB8BCC2, "WOW", #v); sprintf(#v, "%.0f", slider6); dy_knob(572, 250, 17, 6, 0, 100, 50, 0, 0xB8BCC2, "HISS", #v); sprintf(#v, "%.0f", slider7); dy_knob(640, 250, 17, 7, 0, 100, 15, 0, 0xB8BCC2, "AGE", #v); sprintf(#v, "%+.1f dB", slider10); dy_knob(710, 250, 20, 10, -24, 12, 0, 0, 0xF2F2EE, "OUTPUT", #v); dy_btn(650, 330, slider8, dy_grn, "NR") ? ( slider8 = 1 - slider8; ui_auto(8); ); dy_lbl(574, 338, "STANDARD SPEED", 0.7); dy_lbl(574, 352, "B-TYPE NR", 0.7); pk_end(); ui_end();