desc:NOVA-Scale — synth + autotune-style scale lock
// =====================================================================
// NOVA-Scale  (REAPER JSFX, EEL2)
// 4-engine synth (Organ, Synth, Pad, FM Keys) wrapped with an
// autotune-style scale lock on the MIDI input.
//   Scale Mode 0 = pass through (no lock)
//   Scale Mode 1 = quantize every note to the nearest note in the chosen
//                   root + scale/mode
//   Scale Mode 2 = scale-run: every incoming note is treated as a
//                   sequential position in the scale, regardless of pitch
//                   class. Use with a sequencer / arpeggiator for instant
//                   in-key riffs.
// Drop this file in REAPER/Effects/ and it appears under "NOVA-Scale".
// Companion VST3/AU: /home/marcus/keymatrix-vst3 (build script: ./build.sh).
// Web preview: https://keymatrix.fun/nova-scale.html
// =====================================================================

slider1:0<0,3,1{Organ,Synth,Pad,FM Keys}>Engine
slider2:0.5<0,1,0.01>Master Volume
slider3:0.5<0,1,0.01>Voice Level

// Organ drawbars
slider10:8<0,8,1>16'  Drawbar
slider11:8<0,8,1>5 1/3' Drawbar
slider12:8<0,8,1>8'   Drawbar
slider13:8<0,8,1>4'   Drawbar
slider14:0<0,8,1>2 2/3' Drawbar
slider15:0<0,8,1>2'   Drawbar
slider16:0<0,8,1>1 3/5' Drawbar
slider17:0<0,8,1>1 1/3' Drawbar
slider18:0<0,8,1>1'   Drawbar

slider20:0.01<0.001,5,0.01>Attack
slider21:0.15<0.001,5,0.01>Decay
slider22:0.7<0,1,0.01>Sustain
slider23:0.4<0.001,5,0.01>Release

slider24:5000<20,18000,1>Cutoff
slider25:0.1<0,1,0.01>Resonance
slider26:0<0,1,0.01>Drive

slider30:0.15<0,1,0.01>Delay Mix
slider31:0.3<0.01,2,0.01>Delay Time
slider32:0.2<0,1,0.01>Reverb Mix
slider33:0<0,1,0.01>Chorus

// SCALE LOCK — the autotune feature
slider40:64<0,127,1>Root (MIDI note)
slider41:0<0,3,1{Off,Quantize,Reassign,Scale-Run}>Scale Mode
slider42:0<0,12,1{Chromatic,Ionian,Dorian,Phrygian,Lydian,Mixolydian,Aeolian,Locrian,Maj Pent,Min Pent,Blues,Harm Min,Mel Min}>Scale

slider45:0<-3,3,1>Octave Shift
slider46:0<-12,12,1>Semi Shift

@init
// === Scale interval table (concatenated) ===
INTV = 500;
scBase = 700; scLen = 720;
scBase[0]=0;  scLen[0]=12;
INTV[0]=0;INTV[1]=1;INTV[2]=2;INTV[3]=3;INTV[4]=4;INTV[5]=5;INTV[6]=6;INTV[7]=7;INTV[8]=8;INTV[9]=9;INTV[10]=10;INTV[11]=11;
scBase[1]=12; scLen[1]=7;
INTV[12]=0;INTV[13]=2;INTV[14]=4;INTV[15]=5;INTV[16]=7;INTV[17]=9;INTV[18]=11;
scBase[2]=19; scLen[2]=7;
INTV[19]=0;INTV[20]=2;INTV[21]=3;INTV[22]=5;INTV[23]=7;INTV[24]=9;INTV[25]=10;
scBase[3]=26; scLen[3]=7;
INTV[26]=0;INTV[27]=1;INTV[28]=3;INTV[29]=5;INTV[30]=7;INTV[31]=8;INTV[32]=10;
scBase[4]=33; scLen[4]=7;
INTV[33]=0;INTV[34]=2;INTV[35]=4;INTV[36]=6;INTV[37]=7;INTV[38]=9;INTV[39]=11;
scBase[5]=40; scLen[5]=7;
INTV[40]=0;INTV[41]=2;INTV[42]=4;INTV[43]=5;INTV[44]=7;INTV[45]=9;INTV[46]=10;
scBase[6]=47; scLen[6]=7;
INTV[47]=0;INTV[48]=2;INTV[49]=3;INTV[50]=5;INTV[51]=7;INTV[52]=8;INTV[53]=10;
scBase[7]=54; scLen[7]=7;
INTV[54]=0;INTV[55]=1;INTV[56]=3;INTV[57]=5;INTV[58]=6;INTV[59]=8;INTV[60]=10;
scBase[8]=61; scLen[8]=5;
INTV[61]=0;INTV[62]=2;INTV[63]=4;INTV[64]=7;INTV[65]=9;
scBase[9]=66; scLen[9]=5;
INTV[66]=0;INTV[67]=3;INTV[68]=5;INTV[69]=7;INTV[70]=10;
scBase[10]=71; scLen[10]=6;
INTV[71]=0;INTV[72]=3;INTV[73]=5;INTV[74]=6;INTV[75]=7;INTV[76]=10;
scBase[11]=77; scLen[11]=7;
INTV[77]=0;INTV[78]=2;INTV[79]=3;INTV[80]=5;INTV[81]=7;INTV[82]=8;INTV[83]=11;
scBase[12]=84; scLen[12]=7;
INTV[84]=0;INTV[85]=2;INTV[86]=3;INTV[87]=5;INTV[88]=7;INTV[89]=9;INTV[90]=11;

// === Voice pool: 16 voices, 11 slots each ===
MAX_VOICES = 16;
VSIZE = 11;
voices = freemem; freemem += MAX_VOICES * VSIZE;
// [0] active 0/1/2  [1] note  [2] vel  [3] phase  [4] env  [5] state
// [6] freq   [7] modPhase [8] lfoPhase [9] driveBuf [10] reserved
i = 0; loop(MAX_VOICES, voices[i*VSIZE] = 0; i += 1);

// === Effect buffers ===
DELAY_MAX = 384000;
delay_buf = freemem; freemem += DELAY_MAX;
delay_idx = 0;

REV_LEN = 24000;
rev_buf_l = freemem; freemem += REV_LEN * 4;
rev_buf_r = freemem; freemem += REV_LEN * 4;
rev_idx = 0;

// === Sustained notes (CC64 sustain pedal) ===
sustained = freemem; freemem += 128;
i = 0; while(i < 128) ( sustained[i] = 0; i += 1; );

// === Init running state ===
chorus_phase = 0;
engine = 0;
vol = 0.5;
voice_gain = 0.5;
filt_cut = 5000;
filt_q = 1;
drive_amt = 0;
delay_mix = 0.15;
delay_time = 0.3;
rev_mix = 0.2;
chorus_mix = 0;
scale_root = 64;
scale_mode = 0;
scale_id = 0;
octave_shift = 0;
semi_shift = 0;
att_rate = 1.0;
dec_rate = 1.0;
sus_lev = 0.7;
rel_rate = 1.0;
sustain = 0;

// === Helper: quantize to nearest scale note (may shift octaves) ===
function qtz(in_note, root, sc) (
  out = in_note;
  sc > 0 ? (
    L = scLen[sc];
    L > 1 ? (
      k = in_note - root;
      idx = ((k % L) + L) % L;
      oct = (k - idx) / L;
      out = root + oct * 12 + INTV[scBase[sc] + idx];
      out < 0 ? out = 0;
      out > 127 ? out = 127;
    );
  );
  out;
);

// === Helper: in-octave snap (preserves the original octave class) ===
//   For each input note, pick the CLOSEST pitch class that is in the scale,
//   IN THE SAME OCTAVE. Every key still sounds a unique note; nothing
//   collides, no notes are dead. Output is a valid in-scale note.
function reassign(in_note, root, sc) (
  out = in_note;
  sc > 0 ? (
    L = scLen[sc];
    L > 1 ? (
      // base octave class of the input
      base = in_note - 12 * floor(in_note / 12);
      // candidate pitch classes that are in-scale RELATIVE to base
      rel = ((base - root) % 12 + 12) % 12;
      // search +/- an octave (in semitones) for the nearest in-scale pc
      best_n = in_note;
      best_d = 999;
      d = -12;
      while(d <= 12) (
        cand = in_note + d;
        cand >= 0 && cand <= 127 ? (
          pc = ((cand - root) % 12 + 12) % 12;
          // is pc in the scale?
          j = 0;
          in_scale = 0;
          while(j < L) (
            INTV[scBase[sc] + j] == pc ? ( in_scale = 1; j = L; );
            j += 1;
          );
          in_scale == 1 ? (
            ad = abs(d);
            ad < best_d ? ( best_d = ad; best_n = cand; );
          );
        );
        d += 1;
      );
      out = best_n;
      out < 0 ? out = 0;
      out > 127 ? out = 127;
    );
  );
  out;
);

// === Scale-run remap: nth incoming note -> nth scale degree ===
// Stateful counter per channel, advances on every note-on.
run_count = 0; // global counter for scale-run mode

function runNote(sc, root) (
  L = scLen[sc];
  L <= 1 ? 60 : (
    idx = ((run_count % L) + L) % L;
    oct = floor(run_count / L);
    run_count += 1;
    out = root + oct * 12 + INTV[scBase[sc] + idx];
    out < 0 ? out = 0;
    out > 127 ? out = 127;
    out;
  );
);

@slider
engine = slider1;
vol = slider2;
voice_gain = slider3;
att_rate = 1.0 / max(slider20, 0.001) / srate;
dec_rate = 1.0 / max(slider21, 0.001) / srate;
sus_lev = slider22;
rel_rate = 1.0 / max(slider23, 0.001) / srate;
filt_cut = slider24;
filt_q = slider25 * 30;
drive_amt = slider26;
delay_mix = slider30;
delay_time = slider31;
rev_mix = slider32;
chorus_mix = slider33;
scale_root = slider40;
scale_mode = slider41;
scale_id = slider42;
octave_shift = slider45 * 12;
semi_shift = slider46;
scale_mode == 3 ? ( scale_id > 0 ? run_count = 0; );

@block
sustain = 0;
while(midirecv(offset, msg1, msg2, msg3)) (
  status = msg1 & 0xF0;
  status == 0x90 && msg3 > 0 ? (
    shift = octave_shift + semi_shift;
    in_n = msg2 + shift;
    in_n < 0 ? in_n = 0;
    in_n > 127 ? in_n = 127;
    scale_mode == 3 && scale_id > 0 ? (
      out = runNote(scale_id, scale_root);
    ) : scale_mode == 2 && scale_id > 0 ? (
      out = reassign(in_n, scale_root, scale_id);
    ) : (
      out = qtz(in_n, scale_root, scale_id);
    );
    v = -1;
    i = 0;
    while(i < MAX_VOICES && v < 0) ( voices[i*VSIZE] == 0 ? v = i; i += 1; );
    v < 0 ? v = 0;
    base = v * VSIZE;
    voices[base] = 1;
    voices[base+1] = out;
    voices[base+2] = msg3 / 127.0;
    voices[base+3] = 0; voices[base+4] = 0; voices[base+5] = 0;
    voices[base+6] = 440 * 2 ^ ((out - 69) / 12);
    voices[base+7] = 0; voices[base+8] = 0; voices[base+9] = 0; voices[base+10] = 0;
    midisend(offset, 0x90, out, msg3);
  ) : (status == 0x80 || (status == 0x90 && msg3 == 0)) ? (
    shift = octave_shift + semi_shift;
    in_n = msg2 + shift;
    in_n < 0 ? in_n = 0;
    in_n > 127 ? in_n = 127;
    out = qtz(in_n, scale_root, scale_id);
    sustain > 0 ? sustained[msg2] = 1 : (
      i = 0;
      while(i < MAX_VOICES) (
        v = i * VSIZE;
        voices[v] == 1 && voices[v+1] == out ? voices[v] = 2;
        i += 1;
      );
      midisend(offset, 0x80, out, 0);
    );
  ) : status == 0xB0 && msg2 == 64 ? (
    sustain = msg3 >= 64;
    sustain == 0 ? (
      i = 0;
      while(i < 128) (
        sustained[i] > 0 ? (
          sh = octave_shift + semi_shift;
          in_n = i + sh;
          in_n < 0 ? in_n = 0;
          in_n > 127 ? in_n = 127;
          oo = qtz(in_n, scale_root, scale_id);
          j = 0;
          while(j < MAX_VOICES) (
            vv = j * VSIZE;
            voices[vv] == 1 && voices[vv+1] == oo ? voices[vv] = 2;
            j += 1;
          );
          midisend(0, 0x80, oo, 0);
          sustained[i] = 0;
        );
        i += 1;
      );
    );
    midisend(offset, msg1, msg2, msg3);
  ) : (
    midisend(offset, msg1, msg2, msg3);
  );
);

@sample
out_l = 0; out_r = 0;
active_voice_count = 0;
i = 0;
while(i < MAX_VOICES) (
  v = i * VSIZE;
  active = voices[v];
  active > 0 ? (
    note = voices[v+1];
    vel = voices[v+2];
    freq = voices[v+6];
    phase = voices[v+3];
    env = voices[v+4];
    state = voices[v+5];
    modPhase = voices[v+7];
    lfoPhase = voices[v+8];
    driveBuf = voices[v+9];

    state == 0 ? (
      env += att_rate;
      env >= 1 ? ( env = 1; state = 1; );
    ) : state == 1 ? (
      env -= dec_rate * (1 - sus_lev);
      env <= sus_lev ? ( env = sus_lev; state = 2; );
    ) : state == 2 ? (
      // hold
    ) : state == 3 ? (
      env -= rel_rate * sus_lev;
      env <= 0 ? ( env = 0; active = 0; );
    );
    voices[v+4] = env;
    voices[v+5] = state;
    active == 0 ? ( voices[v] = 0; i += 1; ) : (
      phase += freq / srate;
      phase >= 1 ? phase -= 1;
      voices[v+3] = phase;

      sample = 0;
      engine == 0 ? (
        // ORGAN — additive sines, drawbar-controlled
        sample = 0;
        sample += (slider10/8) * sin(2 * $pi * (phase * 0.5));
        sample += (slider11/8) * sin(2 * $pi * (phase * 1.498));
        sample += (slider12/8) * sin(2 * $pi * phase);
        sample += (slider13/8) * sin(2 * $pi * (phase * 2));
        sample += (slider14/8) * sin(2 * $pi * (phase * 2.996));
        sample += (slider15/8) * sin(2 * $pi * (phase * 4));
        sample += (slider16/8) * sin(2 * $pi * (phase * 5.04));
        sample += (slider17/8) * sin(2 * $pi * (phase * 5.992));
        sample += (slider18/8) * sin(2 * $pi * (phase * 8));
        sample *= 0.18;
      ) : engine == 1 ? (
        // SYNTH — sawtooth + sub square
        sample = (2 * phase - 1) * 0.7 + ((phase < 0.5) ? 1 : -1) * 0.4;
      ) : engine == 2 ? (
        // PAD — 3 detuned saws
        lfoPhase += 0.4 / srate;
        lfoPhase >= 1 ? lfoPhase -= 1;
        modPhase += 0.7 / srate;
        modPhase >= 1 ? modPhase -= 1;
        d1 = sin(2 * $pi * lfoPhase) * 0.008;
        d2 = sin(2 * $pi * modPhase) * 0.012;
        sample = (2 * phase - 1) * 0.6
               + (2 * (phase + d1) - 1) * 0.5
               + (2 * (phase + d2) - 1) * 0.5;
        sample *= 0.35;
        voices[v+7] = modPhase;
        voices[v+8] = lfoPhase;
      ) : (
        // FM KEYS — 2-op FM
        modPhase += (freq * 2) / srate;
        modPhase >= 1 ? modPhase -= 1;
        voices[v+7] = modPhase;
        modSig = sin(2 * $pi * modPhase);
        sample = sin(2 * $pi * phase + 3 * modSig);
      );

      // Drive
      drive_amt > 0 ? (
        k = drive_amt;
        sample = (1 + k) * sample / (1 + k * abs(sample));
      );

      sample = sample * env * vel * voice_gain;
      out_l += sample;
      out_r += sample;
      active_voice_count += 1;
    );
  );
  i += 1;
);

// Delay
delay_idx_w = delay_idx;
delay_time_samp = floor(delay_time * srate);
delay_time_samp > DELAY_MAX - 2 ? delay_time_samp = DELAY_MAX - 2;
delay_time_samp < 1 ? delay_time_samp = 1;
delay_idx_r = (delay_idx_w - delay_time_samp + DELAY_MAX) % DELAY_MAX;
dl = delay_buf[delay_idx_r];
delay_buf[delay_idx_w] = out_l + dl * 0.35;
delay_idx = (delay_idx_w + 1) % DELAY_MAX;
out_l = out_l * (1 - delay_mix) + dl * delay_mix;
out_r = out_r * (1 - delay_mix) + dl * delay_mix;

// Chorus
chorus_mix > 0 ? (
  chorus_phase += 0.5 / srate;
  chorus_phase >= 1 ? chorus_phase -= 1;
  cho_amt = 0.005 + sin(2 * $pi * chorus_phase) * 0.003;
  chor = out_l * (0.5 + cho_amt * 60);
  out_l = out_l * (1 - chorus_mix) + chor * chorus_mix;
  out_r = out_r * (1 - chorus_mix) + chor * chorus_mix;
);

// Reverb (4 parallel combs)
rev_mix > 0 ? (
  rev_smp_l = 0; rev_smp_r = 0;
  c = 0;
  while(c < 4) (
    // Comb lengths: classic Schroeder room
    cl = 1116 + c * 80;
    cr = 1422 + c * 75;
    ri = (rev_idx - cl + REV_LEN) % REV_LEN;
    rj = (rev_idx - cr + REV_LEN) % REV_LEN;
    buf_l = rev_buf_l[c * REV_LEN + ri];
    buf_r = rev_buf_r[c * REV_LEN + rj];
    rev_buf_l[c * REV_LEN + rev_idx] = out_l + buf_l * 0.84;
    rev_buf_r[c * REV_LEN + rev_idx] = out_r + buf_r * 0.84;
    rev_smp_l += buf_l;
    rev_smp_r += buf_r;
    c += 1;
  );
  rev_idx = (rev_idx + 1) % REV_LEN;
  out_l = out_l * (1 - rev_mix) + rev_smp_l * rev_mix * 0.25;
  out_r = out_r * (1 - rev_mix) + rev_smp_r * rev_mix * 0.25;
);

spl0 = out_l * vol;
spl1 = out_r * vol;

@gfx 600 200
// Status display (no interactive keybed — the JSFX surface is for the synth
// controls, the virtual keyboard lives in the web companion at nova-scale.html)
gfx_clear = 0x08080a;
gfx_setfont(1, "Arial", 14, 'b');
gfx_set(0.4, 0.9, 0.6, 1);
gfx_drawstr("NOVA-Scale", 8, 8);

gfx_set(0.6, 0.6, 0.6, 1);
gfx_setfont(1, "Arial", 11, '');
mode_str = scale_mode == 0 ? "OFF" : (scale_mode == 1 ? "QUANTIZE" : (scale_mode == 2 ? "REASSIGN" : "SCALE-RUN"));
gfx_drawstr("Mode: " + mode_str, 110, 11);

gfx_set(0.6, 0.6, 0.6, 1);
gfx_drawstr("Engine: ", 8, 32);
gfx_set(1, 1, 1, 1);
engine == 0 ? gfx_drawstr("Organ", 60, 32) : engine == 1 ? gfx_drawstr("Synth", 60, 32) : engine == 2 ? gfx_drawstr("Pad", 60, 32) : gfx_drawstr("FM Keys", 60, 32);

gfx_set(0.6, 0.6, 0.6, 1);
gfx_drawstr("Root: " + (scale_root % 12 == 0 ? "C" : scale_root % 12 == 1 ? "C#" : scale_root % 12 == 2 ? "D" : scale_root % 12 == 3 ? "D#" : scale_root % 12 == 4 ? "E" : scale_root % 12 == 5 ? "F" : scale_root % 12 == 6 ? "F#" : scale_root % 12 == 7 ? "G" : scale_root % 12 == 8 ? "G#" : scale_root % 12 == 9 ? "A" : scale_root % 12 == 10 ? "A#" : "B") + "  Oct: " + (slider45 < 0 ? slider45 : "+" + slider45) + "  Voices: " + active_voice_count, 8, 52);

gfx_set(0.4, 0.9, 0.6, 1);
gfx_drawstr("Scale Mode 0=off · 1=quantize (snap) · 2=scale-run (sequential)", 8, 78);
gfx_set(0.6, 0.6, 0.6, 1);
gfx_drawstr("Web preview: keymatrix.fun/nova-scale.html  ·  VST3/AU: keymatrix-vst3/", 8, 98);
gfx_drawstr("Drop this JSFX on a track — it auto-receives MIDI and re-emits the", 8, 118);
gfx_drawstr("scale-locked note through the 4-engine synth. Sustain on CC64.", 8, 132);
