//
// Mod Exporter Script
//
//  (note) https://www.exotica.org.uk/wiki/Protracker
//  (note) http://www.inspiredacoustics.com/en/MIDI_note_numbers_and_center_frequencies
//  (note) http://coppershade.org/articles/More!/Topics/Creating_Samples_For_Amiga/
//  (note) http://www.pouet.net/topic.php?which=11710 ("nerd talk prepping samples")
//  (note) https://en.wikipedia.org/wiki/Audio_bit_depth
//  (note) http://sox.sourceforge.net/sox.html
//  (note) http%3A%2F%2Fwww.pouet.net%2Ftopic.php%3Fwhich%3D8628&usg=AOvVaw0-aPFSgAe-AqNijXFT13b4
//  (note) http://eab.abime.net/showthread.php?t=66065 ("Tutorial: Converting samples to Amiga.")
//  (note) http://amigadev.elowar.com/read/ADCD_2.1/Hardware_Manual_guide/node00F2.html
//

use tksampler;

// ---------------------------------------------------------------------------- config
// MIDI device (channels 1..4)
String ch_dev_name = "vst_a";

// Name of the directory which stores the .wav files that will be imported as samples
//
// The name of each file must start with the samplenr, e.g.
//   01_bd.wav
//   02_rs.wav
//   03_sd.wav
//   04_cp.wav
//   05_lt.wav
//   06_mt.wav
//   07_ht.wav
//   08_ch.wav
//   09_oh.wav
//   0A_bass.wav
//   0B_strings.wav
//
// If the file name ends with a note name (c1..b3), this will be used to 
//  determine the target sample rate and note transpose (all wavs are assumed to have a root key of c-5):
//   0C_chord_maj_c3.wav
//   0D_chord_min_gs2.wav
//
//   (list of notenames: "c1,cs1,d1,ds1,e1,f1,fs1,g1,gs1,a1,as1,b,c2,..,b3"
//   (the default note is C-2, i.e. 8287 Hz sample rate)
//
// If the samplenr is followed by a sub index, the wav files will be concatenated and the start
//  offsets will be aligned to multiples of $100, i.e. suitable for the 9xx sample offset command:
//   0E_01_reso_bass.wav
//   0E_02_reso_bass.wav
//   0E_03_reso_bass.wav
//   0E_04_reso_bass.wav
//   0E_05_reso_bass.wav
//   0E_06_reso_bass.wav
//   0E_07_reso_bass.wav
//   0E_08_reso_bass.wav
//
String wav_source_dir = "$(PROJECT_DIR)/modexport_test_source_dir";

// Name of temporary batch file (on Windows)
String tmp_bat_filename = "$(PROJECT_DIR)/modexport_sample.bat";

// Name of temporary SOX generated signed 8bit sample
// String tmp_sample_filename = "$(PROJECT_DIR)/modexport_sample.dat";
String tmp_sample_filename = "$(PROJECT_DIR)/modexport_sample.wav";

// Name of the exported Protracker module file
String mod_filename = "$(PROJECT_DIR)/mod.export";

// Song name (up to 19 characters)
String song_name = "modexport";


// ---------------------------------------------------------------------------- persistent state
tag boolean b_rec;

// ---------------------------------------------------------------------------- 
int tick_nr;

PointerArray ans_all;  // 4x ActiveNoteState
ActiveNoteState *ans;  // temporary ref to current note state
int ch_dev_idx;
int cur_ch;  // 0..3 (temporary during process())
MIDIPipeFrame frame_tmp;
PointerArray pattern_data;  // 4*4 IntArrays (note/instr/fx1/fx2, 4 channels), note is stored as FloatArray
FloatArray *pat_note;
IntArray *pat_instr;
IntArray *pat_fx1;
IntArray *pat_fx2;
boolean b_clear_pat;
int cur_step;  // 0..(64*128)
IntArray cur_instr;   // 4*instr nr (1..31)
IntArray cur_vol;  // 4 channels
PointerArray sample_data;  // 31 Buffer instances
PointerArray sample_chain_offsets;  // 31 IntArray instances
FloatArray pitch_bend;
IntArray tmp_sample_buf;

FloatArray amiga_note_periods = [
   856, 808, 762, 720, 678, 640, 604, 570, 538, 508, 480, 453, 428, 404, 381, 360, 339, 320, 302, 285, 269, 254, 240, 226, 214, 202, 190, 180, 170, 160, 151, 143, 135, 127, 120, 113 
                               ];

StringArray note_names_short = [
   "c1", "cs1", "d1", "ds1", "e1", "f1", "fs1", "g1", "gs1", "a1", "as1", "b1",
   "c2", "cs2", "d2", "ds2", "e2", "f2", "fs2", "g2", "gs2", "a2", "as2", "b2",
   "c3", "cs3", "d3", "ds3", "e3", "f3", "fs3", "g3", "gs3", "a3", "as3", "b3"
                                ];

IntArray note_sample_rates = [
   4181, 4430, 4697, 4971, 5279, 5593, 5926, 6279, 6653, 7046, 7457, 7901, 8363, 8860, 9395, 9943, 10559, 11186, 11852, 12559, 13306, 14092, 14914, 15838, 16726, 17720, 18839, 19886, 21056, 22372, 23705, 25031, 26515, 28185, 29829, 31677
                              ];

IntArray sample_root_notes;  // 0..35

IntArray b_noteoff_pending;  // 4 elements (4 ch)

function InsertNoteOff() {
   // pat_fx1[cur_step] = 0xA;
   // pat_fx2[cur_step] = 0x0F;
   // (todo) append note-off steps
}

delegate ActiveNoteState:ProcessNoteOff(int _activeNoteIdx, byte _note, byte _vel) { 
   trace "xxx note off, note="+_note;
   if(0 == pat_note[cur_step])
   {
      InsertNoteOff();
      b_noteoff_pending[cur_ch] = false;
   }
   else
   {
      b_noteoff_pending[cur_ch] = true;
   }
} 

delegate ActiveNoteState:ProcessNoteOn(int _activeNoteIdx, byte _note, int _duration, byte _vel) { 
   trace "xxx note on, note="+_note+" tick_nr="+tick_nr+" cur_step="+cur_step;
   if(b_rec)
   {
      pat_note[cur_step] = _note;// + pitch_bend[cur_ch];
      pat_instr[cur_step] = cur_instr[cur_ch];
      // (todo) append note-on steps
      b_noteoff_pending[cur_ch] = false;
   }
}

function OnReset() {
   // Called when replay starts
   trace "xxx modexport: OnReset";
   ActiveNoteState *ans;
   if(ans_all.numElements != 4)
   {
      // Init
      ans_all.free();
      loop(4)
      {
         ans <= new ActiveNoteState;
         delegate ans:"note_on"  = ProcessNoteOn; 
         delegate ans:"note_off" = ProcessNoteOff; 
         ans_all.add(#(deref ans));
      }

      pattern_data.free();
      int colIdx = 0;
      loop(4*4)
      {
         if(0 == colIdx)
         {
            FloatArray fa <= new FloatArray;
            fa.alloc(64 * 128);
            fa.useAll();
            fa.fill(0.0f);
            pattern_data.add(#(deref fa));
         }
         else
         {
            IntArray ia <= new IntArray;
            ia.alloc(64 * 128);
            ia.useAll();
            ia.fill(0);
            pattern_data.add(#(deref ia));
         }
         // trace "xxx pattern_data="+#(pattern_data);
         colIdx = (colIdx + 1) & 3;
      }

      cur_instr.alloc(4);
      cur_instr.useAll();
      cur_instr.fill(16);

      cur_vol.alloc(4);
      cur_vol.useAll();
      cur_vol.fill(64);

      b_noteoff_pending.alloc(4);
      b_noteoff_pending.useAll();
      b_noteoff_pending.fill(0);

      pitch_bend.alloc(4);
      pitch_bend.useAll();
      pitch_bend.fill(0);
   }

   foreach ans in ans_all
   {
      ans.reset();
   }

   STX_MIDIOutDevice outDev <= MIDI.GetMIDIOutDeviceByAliasOrName(ch_dev_name);
   if(null != outDev)
   {
      ch_dev_idx = outDev.dev_idx;
   }
   else
   {
      trace "[---] modexport: could not find device \""+ch_dev_name+"\"";
      ch_dev_idx = -1;
   }

   b_clear_pat = b_rec;

   b_noteoff_pending.fill(false);

   pitch_bend.fill(0);
}

function OnSeek(int ticks) {
   // Called when replay starts or stops, preceded by OnReset()
   trace "xxx modexport: OnSeek("+ticks+")";
   tick_nr = ticks;

   if(b_clear_pat)
   {
      // Replay started
      b_clear_pat = false;

      // Clear patterns
      trace "xxx modexport: START: clear pat";
      int i = 0;
      loop(4*4)
      {
         if(0 == (i & 3))
         {
            FloatArray fa <= pattern_data.get(i++);
            fa.fill(0);
         }
         else
         {
            IntArray ia <= pattern_data.get(i++);
            ia.fill(0);
         }
      }

      cur_step = 0;
   }
   else
   {
      // Replay stopped
      trace "xxx modexport: STOP";
   }
}

function WriteString(Stream ofs, String s, int num) {
   int i = 0;
   loop(num)
   {
      ofs.i8 = s.getc(i++);
   }
}

function Exec(String exe, String args) {
   // // String cmdLine = Utils.FixPathname_psystem_exe(exe)+" "+args;
   String cmdLine = "\""+exe+"\" "+args;
   local String tmpBatFileName;

   if(Utils.IsWindows())
   {
      // popen() cannot handle arguments with spaces (oh. my.)
      // the workaround is to write the cmdline to a .bat file, then exec that
      tmpBatFileName = Utils.SubstitutePathVars(tmp_bat_filename);
      cmdLine.saveLocal(tmpBatFileName);
      cmdLine = tmpBatFileName;
      cmdLine = Utils.FixPathname_psystem_exe(cmdLine);
   }

   Global.Debug("execBinary: cmdLine=>>"+cmdLine+"<<");

   String log; log.alloc(65535);
   psystem(cmdLine, 'r', log);
   Global.Debug("exec SOX: cmdLine=>>"+cmdLine+"<< log=\n"+log+"\n");
   log.free();     
}

function LoadTempSample(String _filename) {
   FloatArray sam;
   boolean b;
   Integer sampleRate, numCh;

   // Load
   try {
      
      String retFileInfo;

      b = WavIO.LoadLocal(_filename, sam, sampleRate, numCh, retFileInfo, null);

      if(b)
      {
         trace "[...] loaded sample len="+(sam.numElements / numCh)+" #ch="+numCh+" rate="+sampleRate;

         // Truncate bits
         tmp_sample_buf.alloc(sam.numElements);
         int i =0;
         loop(sam.numElements)
         {
            short sam16 = sam[i++] * 32767;  // -32767..32767
            float samf;
            byte sam8;
            if(sam16 >= 0)
            {
               sam16 &= 0x7F00;
               samf = (sam16 / 258.0) + 0.5;
               sam8 = samf;
            }
            else
            {
               sam16 = -sam16;
               sam16 &= 0x7F00;
               sam16 = -sam16;
               samf = (sam16 / 258.0) - 0.5;
               sam8 = samf;
            }
            // trace "xxx sam16="+sam16+" sam8="+sam8;
            tmp_sample_buf.add(sam8 & 255);
         }
      }
      else
      {
         tmp_sample_buf.free();
      }
      // trace "b="+b;
   }
   catch(Error e) {
      trace "[---] caught "+e.fullName+": \""+e.message+"\".";
   }
}

function ConvertSamples() {

   sample_data.alloc(31);
   sample_data.useAll();

   sample_chain_offsets.alloc(31);
   IntArray *sc;
   loop(31)
   {
      sc <= new IntArray;
      sample_chain_offsets.add(#(deref sc));
   }

   local String soxExe = Utils.SubstitutePathVars(STConfig.audiotool_sox_pathname);

   if(Utils.IsWindows())
      soxExe.append("\\sox.exe");
   else
      soxExe.append("/sox");

   sample_root_notes.alloc(31);
   sample_root_notes.useAll();
   sample_root_notes.fill(12*2);  // C-2

   String wavSourceDir = Utils.SubstitutePathVars(wav_source_dir);
   String tmpSampleFileName = Utils.SubstitutePathVars(tmp_sample_filename);

   local StringArray a <= tkfileutils_read_directory(wavSourceDir);
   if(null != a)
   {
      local String *fileEntry;
      foreach fileEntry in a
      {
         local StringArray attribs <= fileEntry.splitSpace(true);
         if('d' != fileEntry.getc(0))
         {
            local String fileName <= attribs.get(1);

            if(fileName.getc(2) == '_')
            {
               int sampleNr = "$"+fileName.substring(0, 2);  // "01", "02", .., "1F"
               if(1 <= sampleNr <= 31)
               {
                  int sampleIdx = sampleNr - 1;
                  if((fileName >= ".wav") || (fileName >= ".WAV"))
                  {
                     local String baseName;
                     fileName.replace(".wav", "") => baseName;
                     baseName.replace(".WAV", "");
                     int rateIdx = baseName.lastIndexOf("_");
                     if(-1 != rateIdx)
                     {
                        local String noteName;
                        baseName.substring(rateIdx+1, 99) => noteName;
                        int noteIdx = note_names_short.indexOfObject(noteName, 0);
                        if(-1 != noteIdx)
                        {
                           trace "[dbg] modexport: sample["+sampleNr+"] noteIdx="+noteIdx+" => dest rate = "+note_sample_rates.get(noteIdx)+" Hz";
                           sample_root_notes[sampleIdx] = noteIdx;
                        }
                     }

                     fileName = wavSourceDir+"/"+fileName;

                     int destSampleRate = note_sample_rates[sample_root_notes[sampleIdx]];
                     // local String soxArgs = "\""+fileName+"\" -t s8 -r "+destSampleRate+" -c 1 -e signed-integer --norm=0.0 \""+tmpSampleFileName+"\"";  // noisy
                     local String soxArgs = "\""+fileName+"\" -c 1 -r "+destSampleRate+" --norm=0.0 \""+tmpSampleFileName+"\"";

                     Exec(soxExe, soxArgs);

                     LoadTempSample(tmpSampleFileName);

                     // (todo) use one buffer per sample ?
                     int readOff = 0;
                     Buffer *b;
                     int sampleNumFrames;
                     int sampleNumFramesAligned;
                     if(false)
                     {
                        local File f;
                        if(f.openLocal(tmpSampleFileName, IOS_IN))
                        {
                           b <= sample_data.get(sampleIdx);
                           if(null == b)
                           {
                              b <= new Buffer;
                              sample_data[sampleIdx] = deref b;
                           }
                           readOff = b.size;
                           if(readOff > 0)
                           {
                              // Align to $100 frame boundary (for 9xx effect)
                              readOff = ((readOff+255)>>8)<<8;
                              b.resize(readOff);
                           }
                           sc <= sample_chain_offsets[sampleIdx];
                           sc.add(readOff);
                           sampleNumFrames = f.size;
                           trace "[dbg] modexport: sample["+sampleNr+"] #frames="+sampleNumFrames;
                           sampleNumFramesAligned = ((sampleNumFrames+1)>>1)<<1;  // word-align
                           b.resize(b.size + sampleNumFramesAligned);
                           f.readBuffer(b, readOff, sampleNumFrames, false/*bResize*/);
                           readOff += sampleNumFrames;
                           if(sampleNumFramesAligned > sampleNumFrames)
                              b.pokeI8(readOff++, 128);  // zero-pad
                           f.close();
                        }
                        else
                        {
                           trace "[!!!] modexport: failed to load converted sample nr "+sampleNr+" (src filename=\""+fileName+"\")";
                           b.size = 2;
                           b.pokeI8(0, 128);
                           b.pokeI8(1, 128);
                        }
                     }
                     else
                     {
                        if(tmp_sample_buf.numElements > 0)
                        {
                           b <= sample_data.get(sampleIdx);
                           if(null == b)
                           {
                              b <= new Buffer;
                              sample_data[sampleIdx] = deref b;
                           }
                           readOff = b.size;
                           if(readOff > 0)
                           {
                              // Align to $100 frame boundary (for 9xx effect)
                              readOff = ((readOff+255)>>8)<<8;
                              b.resize(readOff);
                           }
                           sc <= sample_chain_offsets[sampleIdx];
                           sc.add(readOff);
                           sampleNumFrames = tmp_sample_buf.numElements;
                           trace "[dbg] modexport: sample["+sampleNr+"] #frames="+sampleNumFrames;
                           sampleNumFramesAligned = ((sampleNumFrames+1)>>1)<<1;  // word-align
                           b.resize(b.size + sampleNumFramesAligned);

                           int frameIdx = 0;
                           loop(sampleNumFrames)
                           {
                              b.pokeI8(readOff++, tmp_sample_buf[frameIdx++]);
                           }
                           readOff += sampleNumFrames;
                           if(sampleNumFramesAligned > sampleNumFrames)
                              b.pokeI8(readOff++, 128);  // zero-pad
                        }
                        else
                        {
                           trace "[!!!] modexport: failed to load converted sample nr "+sampleNr+" (src filename=\""+fileName+"\")";
                           b.size = 2;
                           b.pokeI8(0, 128);
                           b.pokeI8(1, 128);
                        }
                     }
                  }
               }
               else
               {
                  trace "[~~~] modexport: invalid samplenr="+sampleNr+" in wav file name \""+fileName+"\" (skipping)";
               }
            }
         }
      }
   }
   else
   {
      trace "[---] modexport: failed to read directory \""+wavSourceDir+"\"";
   }
}

function SaveModFile() {
   String pathname = Utils.SubstitutePathVars(mod_filename);
   if((pathname & "mod.") || (pathname >= ".mod"))
   {
      trace "[...] modexport: writing mod to \""+pathname+"\"";
      local File f;
      if(f.openLocal(pathname, IOS_OUT))
      {
         f.byteOrder = YAC_BIG_ENDIAN;
         Buffer *b;

         //    0: Song name (20 chars, NULL terminated)
         WriteString(f, song_name, 20);

         //   20: Instruments (31)
         int sampleIdx = 0;
         int slen;
         loop(31)
         {
            b <= sample_data[sampleIdx];

            //   0: Name
            WriteString(f, "sample"+(sampleIdx+1), 22);

            //  22: Sample length in words
            slen = (null != b) ? b.size : 0;
            if(slen > 131070)
               trace "[~~~] modexport: truncating sample["+(sampleIdx+1)+"] dword length to 0xFFFF";
            slen = mathClampi(slen, 0, 131070);
            f.i16 = (0 == slen) ? 1 : (slen / 2);

            //  24: Finetune (LSB)
            f.i8 = 0;

            //  25: Volume
            f.i8 = 64;

            //  26: Repeat start in words
            f.i16 = 0;

            //  28: Repeat length in words (1= no loop / loop first two bytes)
            f.i16 = 1;

            sampleIdx++;
         }

         //  950: Song length (num positions)
         f.i8 = 127;

         //  951: Max position (ignored by PT)
         //  (note) ProTracker v2.3D for Windows only supports 100 patterns (0..99)
         f.i8 = 99;

         //  952: Song position list
         int songIdx = 0;
         loop(128)
         {
            f.i8 = songIdx++;
            if(songIdx > 99)
               songIdx=99;
         }

         // 1080: "M.K." (Michael Kleps, 31 instr)
         WriteString(f, "M.K.", 4);

         // 1084: Pattern data
         int stepIdx = 0;
         loop(64 * 100)
         {
            int chIdx = 0;
            loop(4)
            {
               // trace "xxx pattern_data="+#(pattern_data);
               FloatArray patNote  <= pattern_data.get(chIdx *4 + 0);
               IntArray   patInstr <= pattern_data.get(chIdx *4 + 1);
               IntArray   patFx1   <= pattern_data.get(chIdx *4 + 2);
               IntArray   patFx2   <= pattern_data.get(chIdx *4 + 3);

               // trace "xxx cur_ch="+cur_ch+" pat_note.numElements="+pat_note.numElements;
               // trace "xxx cur_ch="+cur_ch+" pat_instr.numElements="+pat_instr.numElements;
               // trace "xxx cur_ch="+cur_ch+" pat_fx1.numElements="+pat_fx1.numElements;
               // trace "xxx cur_ch="+cur_ch+" pat_fx2.numElements="+pat_fx2.numElements;

               int amigaDMAPeriod;

               if(0 != patNote[stepIdx])
               {
                  float ptNote = patNote[stepIdx] - (3*12);  // C-3..B-5 => pt C-1..B-3
                  if(0 <= ptNote <= 35)  // C-1..B-3
                  {
                     amigaDMAPeriod = amiga_note_periods.winLinear(ptNote);
                     trace "xxx stepIdx="+stepIdx+" patNote="+patNote[stepIdx]+" period="+amigaDMAPeriod;
                  }
                  else
                  {
                     trace "[~~~] modexport: pat "+(stepIdx/64)+" step "+(stepIdx&63)+" note "+ptNote+" is out of range";
                  }
               }
               else
                  amigaDMAPeriod = 0;

               f.i8 = ((patInstr[stepIdx] >> 4) << 4) | (amigaDMAPeriod >> 8);
               f.i8 = (amigaDMAPeriod & 255);
               if(0 != amigaDMAPeriod)
                  trace "xxx stepIdx="+stepIdx+" note instr="+patInstr[stepIdx];
               f.i8 = ((patInstr[stepIdx] & 15) << 4) | patFx1[stepIdx];
               f.i8 = patFx2[stepIdx];

               // Next channel
               chIdx++;
            }

            // Next step
            stepIdx++;
         }

         // Sample Data
         sampleIdx = 0;
         loop(31)
         {
            b <= sample_data[sampleIdx];
            slen = (null != b) ? b.size : 0;
            slen = mathClampi(slen, 0, 131070);
            IntArray sc <= sample_chain_offsets[sampleIdx];
            if(slen > 0)
            {
               trace "[dbg] modexport: sample["+(sampleIdx+1)+"] slen="+slen+" sc="+#(sc);
               f.writeBuffer(b, 0, slen);
            }
            else
            {
               // Empty sample (two signed 8bit zeros)
               trace "[dbg] modexport: sample["+(sampleIdx+1)+"] slen=2 (EMPTY)";
               f.i8 = 128;
               f.i8 = 128;
            }

            // Next sample
            sampleIdx++;
         }

         trace "[...] modexport: wrote "+f.offset+" bytes";

         f.close();
      }
   }
}

function OnAction(ui::Layer layer, String id, String acName, MIDIPipeFrame frameOut) {
   if(id == "bt_save")
   {
      trace "xxx modexport: save ";
      ConvertSamples();
      SaveModFile();
   }
}

function OnProcess(MIDIPipeFrame framePlay,
                   MIDIPipeFrame frameRec,
                   boolean       bMuted,
                   boolean       bPlaySeq
                   ) {
   if(!bMuted)
   {
      frame_tmp.empty();

      for(cur_ch = 0; cur_ch < 4; cur_ch++)
      {
         pat_note  <= pattern_data.get(cur_ch *4 + 0);
         pat_instr <= pattern_data.get(cur_ch *4 + 1);
         pat_fx1   <= pattern_data.get(cur_ch *4 + 2);
         pat_fx2   <= pattern_data.get(cur_ch *4 + 3);

         ans <= ans_all.get(cur_ch);
         ans.process(framePlay, ch_dev_idx, cur_ch);

         if(b_noteoff_pending[cur_ch])
         {
            if(0 == pat_note[cur_step])
            {
               InsertNoteOff();
               b_noteoff_pending[cur_ch] = false;
            }

         }

         short pb = framePlay.getFilteredPitchbend(ch_dev_idx, cur_ch);
         if(-1 != pb)
         {
            pitch_bend[cur_ch] = Utils.SignedInt14ToFloat(pb);
         }

         pat_fx1[cur_step] = 0xA; // xxxxx
         pat_fx2[cur_step] = 0x03;
      }

      if(bPlaySeq)
      {
         tick_nr++;
         if(0 == (tick_nr % (6/*spd*/ * 8)))
            cur_step++;
      }
   }
}
