#!/usr/bin/env python2

# Copyright (C) 2011 Stefan Westerfeld
#
# This library is free software; you can redistribute it and/or modify it
# under the terms of the GNU Lesser General Public License as published by the
# Free Software Foundation; either version 3 of the License, or (at your
# option) any later version.
#
# This library is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
# FITNESS FOR A PARTICULAR PURPOSE.  See the GNU Lesser General Public License
# for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>.

import sys, os, subprocess, re, argparse, multiprocessing

class Config:
  def __init__ (self):
    self.auto_loop_count   = 0
    self.auto_tune_count   = 0
    self.auto_volume_count = 0
    self.auto_volume_from_loop_count = 0
    self.global_volume_count = 0
    self.cut_count         = 0
    self.cut               = []
    self.dir_count         = 0
    self.sample_count      = 0
    self.samples           = []
    self.set_markers_count = 0
    self.tune_all_frames_count = 0
    self.smooth_tune_count = 0
    self.name_count        = 0
    self.short_name_count  = 0
    self.encoder_args_count = 0
    self.soundfont_count   = 0
    self.encoder_config    = []
    self.encoder_config_count = 0

class BuilderConfig:
  def __init__ (self):
    self.jobs_count = 0
    self.jobs = multiprocessing.cpu_count()
    self.cache_count = 0
    self.output_dir_count = 0
    self.lpc_order_count = 0
    self.pre_encoder_config = []
    self.post_encoder_config = []
    self.have_encoder_config = False

def die (message):
  print >> sys.stderr, "sminstbuilder: " + message
  exit (1)

def tokenize (input_str):
  class TState:
    BLANK = 1
    STRING = 2
    COMMENT = 3
    QUOTED_STRING = 4
    QUOTED_STRING_ESCAPED = 5

  def string_chars (ch):
    if ((ch >= 'a' and ch <= 'z') or
        (ch >= 'A' and ch <= 'Z') or
        (ch >= '0' and ch <= '9') or
         ch in ".:=/-_%$"):
      return True
    return False

  def white_space (ch):
    return (ch == ' ' or ch == '\n' or ch == '\t' or  ch == '\r');

  input_str += "\n"
  state = TState.BLANK

  s = ""
  tokens = []

  for ch in input_str:
    if state == TState.BLANK and string_chars (ch):
      s += ch
      state = TState.STRING
    elif state == TState.BLANK and ch == '"':
      state = TState.QUOTED_STRING
    elif state == TState.BLANK and white_space (ch):
      pass # ignore more whitespaces if we've already seen one
    elif state == TState.STRING and string_chars (ch):
      s += ch
    elif ((state == TState.STRING and white_space (ch)) or
          (state == TState.QUOTED_STRING and ch == '"')):
      tokens += [ s ]
      s = "";
      state = TState.BLANK
    elif state == TState.QUOTED_STRING and ch == '\\':
      state = TState.QUOTED_STRING_ESCAPED
    elif state == TState.QUOTED_STRING:
      s += ch
    elif state == TState.QUOTED_STRING_ESCAPED:
      s += ch;
      state = TState.QUOTED_STRING
    elif ch == '#':
      state = TState.COMMENT
    elif state == TState.COMMENT:
      pass # ignore comments
    else:
      raise Exception ("Tokenizer error in char '" + ch + "'")
  if state != TState.BLANK and state != TState.COMMENT:
    raise Exception ("Parse Error in String: \"" + input_str + "\"")
  return tokens

def tokenize_expand (builder_config, input_str):
  tokens = tokenize (input_str)
  for t in range (len (tokens)):
    tokens[t] = re.sub ('\$ROOT', builder_config.root_dir, tokens[t])

  return tokens

def parse_config (filename, builder_config):
  config = Config()
  try:
    f = open (filename)
  except:
    die ("file '%s' missing" % filename)
  line_number = 1
  for line in f:
    tokens = tokenize_expand (builder_config, line)
    parse_ok = False
    if re.match ("^#", line): # comment
      tokens = []
    if len (tokens) == 0: # blank line: ok
      parse_ok = True
    if len (tokens) == 1:
      if tokens[0] == 'auto-tune':
        config.auto_tune_count += 1
        parse_ok = True
      elif tokens[0] == 'auto-volume-from-loop':
        config.auto_volume_from_loop_count += 1
        parse_ok = True
    if len (tokens) == 2:
      if tokens[0] == 'auto-loop':
        config.auto_loop = tokens[1]
        config.auto_loop_count += 1
        parse_ok = True
      elif tokens[0] == 'auto-volume':
        config.auto_volume = tokens[1]
        config.auto_volume_count += 1
        parse_ok = True
      elif tokens[0] == 'global-volume':
        config.global_volume = tokens[1]
        config.global_volume_count += 1
        parse_ok = True
      elif tokens[0] == 'tune-all-frames':
        config.tune_all_frames = tokens[1]
        config.tune_all_frames_count += 1
        parse_ok = True
      elif tokens[0] == 'dir':
        config.dir = tokens[1]
        config.dir_count += 1
        parse_ok = True
      elif tokens[0] == 'set-markers':
        config.set_markers = tokens[1]
        config.set_markers_count += 1
        parse_ok = True
      elif tokens[0] == 'name':
        config.name = tokens[1]
        config.name_count += 1
        parse_ok = True
      elif tokens[0] == 'short-name':
        config.short_name = tokens[1]
        config.short_name_count += 1
        parse_ok = True
      elif tokens[0] == 'encoder-args':
        config.encoder_args = tokens[1]
        config.encoder_args_count += 1
        parse_ok = True
    if len (tokens) == 3:
      if tokens[0] == 'sample':
        config.samples += [ tokens[1:] ]
        config.sample_count += 1
        parse_ok = True
      elif tokens[0] == 'samples':
        for i in range (int (tokens[1]), int (tokens[2]) + 1):
          config.samples += [ [ i, "note-%d.wav" % i ] ]
          config.sample_count += 1
        parse_ok = True
      elif tokens[0] == 'soundfont':
        config.soundfont = tokens[1]
        config.soundfont_preset = tokens[2]
        config.soundfont_count += 1
        parse_ok = True
    if len (tokens) == 4:
      if tokens[0] == 'smooth-tune':
        config.smooth_tune = tokens[1:]
        config.smooth_tune_count += 1
        parse_ok = True
    if len (tokens) >= 3:
      if tokens[0] == 'encoder-config':
        config.encoder_config += [ tokens[1:] ]
        config.encoder_config_count += 1
        parse_ok = True
    if len (tokens) >= 4:
      if tokens[0] == 'cut':
        config.cut += [ tokens[1:] ]
        config.cut_count += 1
        parse_ok = True
    if not parse_ok:
      die ("parse error in line %d '%s'" % (line_number, line.strip()));
    line_number += 1
  if config.auto_tune_count > 1:
    die ("auto-tune command can be used at most once in config")
  if config.auto_loop_count > 1:
    die ("auto-loop command can be used at most once in config")
  if config.auto_volume_count > 1:
    die ("auto-volume command can be used at most once in config")
  if config.auto_volume_from_loop_count > 1:
    die ("auto-volume-from-loop command can be used at most once in config")
  if config.global_volume_count > 1:
    die ("global-volume command can be used at most once in config")
  if config.dir_count != 1:
    die ("dir command must occur exactly once in config")
  if config.name_count != 1 or config.short_name_count != 1:
    die ("name/short_name command must occur exactly once in config")
  if config.sample_count < 1 and config.soundfont_count < 1:
    die ("sample/samples command must occur at least once in config")
  if config.set_markers_count > 1:
    die ("set-markers command must occur at most once in config")
  if config.tune_all_frames_count > 1:
    die ("tune-all-frames command can be used at most once in config")
  if config.smooth_tune_count > 1:
    die ("smooth-tune command can be used at most once in config")
  if config.encoder_args_count > 1:
    die ("encoder_args command can be used at most once in config")
  if config.tune_all_frames_count + config.auto_tune_count + config.smooth_tune_count > 1:
    die ("auto-tune/tune-all-frames/smooth_tune command should not be used together")
  if config.soundfont_count > 1:
    die ("sountfont command can be used at most once in config")
  return config

def parse_builder_config():
  builder_config = BuilderConfig()
  builder_config.root_dir = os.getcwd()
  filename = "sminstbuilder.cfg"
  try:
    f = open (filename)
  except:
    die ("file '%s' missing" % filename)
  line_number = 1
  for line in f:
    tokens = tokenize_expand (builder_config, line)
    parse_ok = False
    if len (tokens) == 0: # blank line: ok
      parse_ok = True
    if len (tokens) == 2:
      if tokens[0] == 'jobs':
        builder_config.jobs = int (tokens[1])
        builder_config.jobs_count += 1
        parse_ok = True
      elif tokens[0] == 'cache':
        builder_config.cache = int (tokens[1])
        builder_config.cache_count += 1
        parse_ok = True
      elif tokens[0] == 'output-dir':
        builder_config.output_dir = tokens[1]
        builder_config.output_dir_count += 1
        parse_ok = True
      elif tokens[0] == 'lpc-order':
        builder_config.lpc_order = int (tokens[1])
        builder_config.lpc_order_count += 1
        parse_ok = True
    if len (tokens) >= 3:
      if tokens[0] == 'pre-encoder-config':
        builder_config.pre_encoder_config += [ tokens[1:] ]
        builder_config.have_encoder_config = True
        parse_ok = True
      elif tokens[0] == 'post-encoder-config':
        builder_config.post_encoder_config += [ tokens[1:] ]
        builder_config.have_encoder_config = True
        parse_ok = True
    if not parse_ok:
      die ("parse error in line %d '%s'" % (line_number, line.strip()));
    line_number += 1
  if builder_config.cache_count != 1:
    die ("cache command must occur exactly once in config")
  if builder_config.output_dir_count != 1:
    die ("output-dir command must occur exactly once in config")
  if builder_config.jobs_count > 1:
    die ("jobs command can be used at most once in config")
  return builder_config

def system_or_die (command):
  print "+++ %s" % command
  return_code = subprocess.call (command, shell=True)
  if return_code != 0:
    die ("executing command '%s' failed, return_code=%d" % (command, return_code))

def build_instrument_soundfont (inst_dir, builder_config, cmdline_args, config, final_smset):
  import_args = "--output instrument.smset"
  import_args += " -j %d" % builder_config.jobs
  import_args += " --mono-flat"
  if (builder_config.cache > 0):
    import_args += " --cache"

  if config.encoder_config_count > 0 or builder_config.have_encoder_config:
    import_args += " --config smenc.config"

  system_or_die ("cd %s; smsfimport import '%s' '%s' %s" % (config.dir, config.soundfont, config.soundfont_preset, import_args))

def build_instrument_samples (inst_dir, builder_config, cmdline_args, config, final_smset):
  ### Cut instrument using imiscutter (if necessary)
  if (config.cut_count > 0):
    for args in config.cut:
      if len (args) < 3:
        die ("bad args to cut")
      if len (args) < 4:
        args += [ ":" ]
      if len (args) < 5:
        args += [ "note-%d.wav" ]
      if len (args) < 6:
        args += [ "" ]
      src, regions, note_start, thresholds, pattern, step = args
      # parse thresholds (for example -40:-20)
      extra_args = ""
      tmin, tmax = thresholds.split (":")
      if (tmin != ""):
        extra_args += " --silence %s" % tmin
      if (tmax != ""):
        extra_args += " --signal %s" % tmax
      if (step != ""):
        extra_args += " --step %s" % step
      system_or_die ("imiscutter %s %s %s %s %s" % (src, regions, note_start, pattern, extra_args))

  system_or_die ("smwavset init %s/instrument.wset" % config.dir);
  system_or_die ("smwavset init %s/instrument-clipped.wset" % config.dir);

  for sample in config.samples:
    system_or_die ("smwavset add %s/instrument.wset %s %s" % (config.dir, sample[0], sample[1]));
    system_or_die ("smwavset add %s/instrument-clipped.wset %s clipped-note-%s.wav" % (config.dir, sample[0], sample[0]));
  # missing: samples command with loop
  system_or_die ("smsampleedit clip %s/instrument.wset clip_markers clipped-note-%%d.wav" % config.dir);

  if (builder_config.cache > 0):
    smencargs = "--smenc smenccache"
  else:
    smencargs = ""

  encoder_args = "-O1 -s --keep-samples"
  if (config.encoder_args_count > 0):
    encoder_args += " " + config.encoder_args
  if config.encoder_config_count > 0 or builder_config.have_encoder_config:
    encoder_args += " --config %s/smenc.config" % config.dir

  pwd = os.getcwd()
  system_or_die ("smwavset -j %s %s encode %s/instrument-clipped.wset %s/instrument.smset "
                 "--data-dir %s/%s --args '%s'" %
                 (builder_config.jobs, smencargs, config.dir, config.dir, pwd, config.dir, encoder_args));
  final_smset = config.dir + "/instrument.smset";
  system_or_die ("smwavset link %s" % final_smset);

def build_instrument (inst_dir, builder_config, cmdline_args):
  old_path = os.getcwd()
  try:
    os.chdir (inst_dir)
  except:
    die ("directory '%s' not found" % inst_dir)

  config = parse_config ("config", builder_config)

  # create data dir
  system_or_die ("rm -rf %s" % config.dir)
  system_or_die ("mkdir -p %s" % config.dir)

  # build smenc.config (used by samples and soundfont)
  encoder_config = builder_config.pre_encoder_config + config.encoder_config + builder_config.post_encoder_config
  if len (encoder_config) > 0:
    f = open ("%s/smenc.config" % config.dir, "w")
    for cfg in encoder_config:
      print >>f, " ".join (cfg)
    f.close()

  final_smset = config.dir + "/instrument.smset"
  if (config.soundfont_count > 0):
    build_instrument_soundfont (inst_dir, builder_config, cmdline_args, config, final_smset)
  else:
    build_instrument_samples (inst_dir, builder_config, cmdline_args, config, final_smset)

  if (config.auto_tune_count > 0):
    if cmdline_args.untuned:
      print "+++ skip smtool %s auto-tune (untuned version)" % final_smset
    else:
      system_or_die ("smtool %s auto-tune" % final_smset);

  if (config.tune_all_frames_count > 0):
    if cmdline_args.untuned:
      print "+++ skip smtool %s tune-all-frames (untuned version)" % final_smset
    else:
      system_or_die ("smtool %s tune-all-frames %s" % (final_smset, config.tune_all_frames))

  if (config.smooth_tune_count > 0):
    if cmdline_args.untuned:
      print "+++ skip smtool %s smooth-tune (untuned version)" % final_smset
    else:
      system_or_die ("smtool %s smooth-tune %s" % (final_smset, " ".join (config.smooth_tune)))

  if (config.auto_loop_count > 0):
    system_or_die ("smtool %s auto-loop %s" % (final_smset, config.auto_loop));

  if (config.set_markers_count > 0):
    system_or_die ("smwavset set-markers %s %s" % (final_smset, config.set_markers))

  if (config.auto_volume_count > 0):
    system_or_die ("smtool %s auto-volume %s" % (final_smset, config.auto_volume))

  if (config.auto_volume_from_loop_count > 0):
    system_or_die ("smtool %s auto-volume-from-loop" % final_smset)

  if (config.global_volume_count > 0):
    system_or_die ("smtool %s global-volume %s" % (final_smset, config.global_volume))

  # lpc generation needs to be done after auto tuning
  if (builder_config.lpc_order_count > 0):
    system_or_die ("smtool %s lpc %d" % (final_smset, builder_config.lpc_order))

  system_or_die ("smwavset set-names %s '%s' '%s'" % (final_smset, config.name, config.short_name))
  system_or_die ("ls -l %s" % final_smset);

  out_instrument = inst_dir
  if cmdline_args.untuned:
    out_instrument += "-untuned"

  system_or_die ("cp %s %s/%s.smset" % (final_smset, builder_config.output_dir, out_instrument));

  os.chdir (old_path)

def main():
  parser = argparse.ArgumentParser (prog='sminstbuilder')
  parser.add_argument ('--untuned', action='store_true', help='build instruments without auto-tune')
  parser.add_argument ('instruments', nargs='+', help='instrument directories to be processed')
  cmdline_args = parser.parse_args()

  print "SpectMorph Instrument Builder, Version 0.4.1"
  builder_config = parse_builder_config()

  for inst_dir in cmdline_args.instruments:
    build_instrument (inst_dir, builder_config, cmdline_args)

main()
