Last post I mentioned that the next thing I wanted to do was refactor my code to allow using a common progression specified via a command line argument. Specifying such a progression does not currently actually use it to generate an otherwise random chord progression sound track. So, let’s give that a go. Hopefully not too messy a job.

Specify and Use a Common Chord Progression

There is a reasonably lengthy list of common chord progressions in the em_chords package. Given a progression name it returns the progression in roman numeral format. But, I do not currently allow passing that progression to the appropriate function to generate a sound track using said progression. And, unfortunately, that choice of a random progression is embedded in the function em_chords.chords().

def make_cp_chords(s_rnt:emn.Notes_scale, s_frm:emn.Scale_forms, r_oct:int=4,
                      t_sig:tuple[int, int]=(4, 4), n_chds:int=3,
                      dbg:bool=False) -> tuple[list[emn.Notes_scale], list[emn.Notes_scale]]:
... ...
  # get a random progression
  rn_prg = make_progression(n_chds)

I am going to propose adding another parameter that specifies the progression name or has the value random. That paramter will control a code block that sets the value of the rn_prog variable.

But, that also raises another small problem. If using a named common progression it has a defined number of chords in it. If the user specifies a different value for the number of chords my current code might have problems. Need to check that out.

It looks like for this function, at least, that is not a problem. Have not yet reviewed the rest of the packages or the main module.

The refactored function looks like the following (well the relevant bits).

def make_cp_chords(s_rnt:emn.Notes_scale, s_frm:emn.Scale_forms, r_oct:int=4,
                    t_sig:tuple[int, int]=(4, 4), cp_nm:str="random",
                    n_chds:int=3,
                    dbg:bool=False) -> tuple[list[emn.Notes_scale], list[emn.Notes_scale]]:
... ...
    :param cp_nm: a commom progression label/name or 'random' if generating a randome progression
... ...
  if cp_nm[:3] == "ran":
    # get a random progression
    rn_prg = make_progression(n_chds)
  else:
    rn_prg = common_prog[cp_nm]
  if dbg:
    print(f"  chord progression (roman numerals, {cp_nm}): {rn_prg}")

The function em_ui.get_args_do_m_cp already checks that a passed in common progression label is valid. So we should be good to go that way. A quick test seems to so indicate.

(base) PS R:\learn\e_m_311> uv run main.py -wd m_cp -mtk 'F# major' -nbr 6 -cpn 5 -cpt doo-wop

command line args are ok: True
whatdo: m_cp
filepath_in:
filepath_wv:
filepath_mid:
track_key: ('F#', 'major')
nbr_bars: 6
play_track: False
no_save_wv: False
no_save_mid: False
cp_type: doo-wop
cp_nbr: 5
instr: []

selected key: F# major (octave: 4)
  scale notes: ['F#4', 'G#4', 'A#4', 'B4', 'C#5', 'D#5', 'F5']
  key chords: [('F#', 'major'), ('G#', 'minor'), ('A#', 'minor'), ('B', 'major'), ('C#', 'major'), ('D#', 'minor'), ('F', 'dim')]
  chord progression (roman numerals, doo-wop): I-vi-IV-V
  chord progression: [('F#', 'major'), ('D#', 'minor'), ('B', 'major'), ('C#', 'major')]
    [
      F# major -> ['F#4', 'A#4', 'C#5']
      D# minor -> ['D#4', 'F#4', 'A#4']
      B major -> ['B4', 'D#5', 'F#5']
      C# major -> ['C#4', 'F4', 'G#4']
    ]
        multipliers: [2, 4, 6, 8, 10, 12, 14, 16, 18, 20]
        amplitudes: [0.5435, 0.2353, 0.1065, 0.058, 0.0261, 0.0153, 0.0077, 0.0044, 0.0022, 0.0009]
        {'whl': 2.0, 'hlf': 1.0, 'qtr': 0.5, '8th': 0.25, '16th': 0.125, '32nd': 0.0625}

n_bars: 6, cp_len: 4, len cp_rhy: 6, len cp_durs: 6, len c_amps: 24

calling make_cp_sound
  ['8th', '8th', 'qtr', 'hlf']
    0: 8th -> 0.25 * 0.4600 -> 0: ('F#', 'major')
    1: 8th -> 0.25 * 0.3616 -> 1: ('D#', 'minor')
    2: qtr -> 0.5 * 0.4737 -> 2: ('B', 'major')
    3: hlf -> 1.0 * 0.3380 -> 3: ('C#', 'major')
  ['qtr', 'qtr', 'qtr', 'qtr']
    0: qtr -> 0.5 * 0.8224 -> 0: ('F#', 'major')
    1: qtr -> 0.5 * 0.3758 -> 1: ('D#', 'minor')
    2: qtr -> 0.5 * 0.3971 -> 2: ('B', 'major')
    3: qtr -> 0.5 * 0.4735 -> 3: ('C#', 'major')
  ['qtr', 'qtr', 'qtr', 'qtr']
    0: qtr -> 0.5 * 0.5124 -> 0: ('F#', 'major')
    1: qtr -> 0.5 * 0.4147 -> 1: ('D#', 'minor')
    2: qtr -> 0.5 * 0.6137 -> 2: ('B', 'major')
    3: qtr -> 0.5 * 0.4445 -> 3: ('C#', 'major')
  ['8th', '8th', 'hlf', 'qtr']
    0: 8th -> 0.25 * 0.6573 -> 0: ('F#', 'major')
    1: 8th -> 0.25 * 0.4949 -> 1: ('D#', 'minor')
    2: hlf -> 1.0 * 0.4465 -> 2: ('B', 'major')
    3: qtr -> 0.5 * 0.5478 -> 3: ('C#', 'major')
  ['qtr', 'qtr', 'qtr', 'qtr']
    0: qtr -> 0.5 * 0.4889 -> 0: ('F#', 'major')
    1: qtr -> 0.5 * 0.1182 -> 1: ('D#', 'minor')
    2: qtr -> 0.5 * 0.3660 -> 2: ('B', 'major')
    3: qtr -> 0.5 * 0.3603 -> 3: ('C#', 'major')
  ['qtr', '8th', 'hlf', '8th']
    0: qtr -> 0.5 * 0.7769 -> 0: ('F#', 'major')
    1: 8th -> 0.25 * 0.3251 -> 1: ('D#', 'minor')
    2: hlf -> 1.0 * 0.5613 -> 2: ('B', 'major')
    3: 8th -> 0.25 * 0.4111 -> 3: ('C#', 'major')
         8th -> 0.25 * 0.4111 -> 3: ('F#', 'major')
make_c_sound done: 0.0000
writing to wave file: img/I-vi-IV-V_4-4_even_half_o4_1.wav

This wee test seems to indicate that, if supplied, a common chord progression will now be used. Let’s move on.

MIDI

Time to add midi conversion to our new main module. We will start with the simple version. I.E. using predict_and_save. May, in future, not use this approach very often. Or perhaps at all. But, it provides a quick test of our UI and generating a midi file from a wave file.

Check Command Line Arguments for this Activity

When checking the command line args for the activity to generate a chord progession track and save to wave file, the function did not check for a valid input file path. In this case we need to have that. So, do I do that in the if do_w2m_ps: block, or do I add another function to the em_ui package. I am going to go with the latter. The new function will check the arguments for both wave to midi activities. In both cases, a path to a wave file will be required. In one case, we will also need to check for the instrument to use for each track in the midi output. (Will add the latter later.)

I am re-purposing the play-track command line argument. If present, I will play the midi file after creating and/or saving it.

def get_args_do_w2m(cli_args:argparse.Namespace) -> tuple[bool, dict[str, Union[str, int, bool]]]:
  """ Parse the passed command line arguments to make sure that any required args are
      present and any optional arguments that have values are recorded.

    :param cli_args: argparse container with the current values of the command line parameters

    :return: boolean indicating whether or not arguments are good,
        a dictionary containing the value for each possible argument
  """
  is_ok = True
  t_args = A_DEF.copy()
  # t_args["whatdo"] = "m_cp"
  for a_key in t_args.keys():
    a_val = getattr(cli_args, a_key)
    if a_key == "whatdo":
      # continue
      t_args["whatdo"] = a_val
    # if (a_key != "filepath_in") or (a_key != "filepath_mid"):
    if a_key not in ["filepath_in", "filepath_mid"]:
      continue
    match a_key:
      case "filepath_in":
        if a_val is None:
          # filepath required, must be wav file
          t_args[a_key] = f"bad file: path to source wave file is required"
        else:
          # else chk path exists
          f_pth = Path(a_val)
          if f_pth.exists() and f_pth.suffix == ".wav":
            t_args[a_key] = a_val
          else:
            # if not flage error
            t_args[a_key] = f"bad path or file type: {a_val}"
            is_ok = False
      case "filepath_mid":
        if a_val is None:
          # no filepath specified is okay for this activity
          continue
        # otherwise check that any parent directories exist
        f_dir = a_val.rsplit("/", 1)
        d_pth = Path(f_dir[0])
        # f_parent = f_pth.parents[0]
        # f_parent = f_pth.parent
        # print(f"\n\t'{f_pth}'")
        if d_pth.exists():
          t_args[a_key] = a_val
        else:
          t_args[a_key] = f"bad path dir: {a_val}"
          is_ok = False
      case "play_track":
        t_args[a_key] = a_val
  
  return is_ok, t_args

New Activity, do_w2m_ps

Added a number of extra, currently not needed, imports and booleans. Planning for the future. I also moved the command line argument verification into the code for determining the activity to process for the current module run. As usual lots of debug prints.

... ...
from pathlib import Path

import tensorflow as tf
from basic_pitch.inference import predict, predict_and_save, Model
from basic_pitch import ICASSP_2022_MODEL_PATH
from midi2audio import FluidSynth
import mido
from mido import Message, MidiFile, MidiTrack
from sf_gu_gs import GS_soundfile
... ...
# globals

SFF = Path("R:/learn/e_music/rek/GeneralUser_GS_v2.0.3--doc_r6/GeneralUser-GS/GeneralUser-GS.sf2")
PPTH = Path("R:/learn/e_m_311")
... ...
  # what to do, currently unused booleans just planning for the future
  #   and validate command line args
  (do_mk_cprog, do_w2m_pi, do_w2m_ps, do_m_play, do_w_play, do_sav_mid, do_m2w,
    do_i_chg, do_i_shw) = (False, False, False, False, False, False, False, False, False)
  match cl_args.whatdo:
    case "m_cp":
      do_mk_cprog = True
      args_ok, args = emui.get_args_do_m_cp(cl_args)
    case "w2m_pi":
      do_w2m_pi = True
      args_ok, args = emui.get_args_do_w2m(cl_args)
    case "w2m_ps":
      do_w2m_ps = True
      args_ok, args = emui.get_args_do_w2m(cl_args)
    case "i_chg":
      do_i_chg = True
    case "i_shw":
      do_i_shw = True
    case "m2w":
      do_m2w = True
    case "play":
      # really not sure about this one, but might allow me to play midi from within module
      if args["filepath_in"]:
        do_m_play = (args["filepath_in"][-4:] == ".mid")
        do_w_play = (args["filepath_in"][-4:] == ".wav")

  # if generating midi need to know if should be played within code
  # and whether or not a filename has been specified for the midi file,
  # if not use default name
  if args["whatdo"] in ["w2m_pi", "w2m_ps"]:
    do_m_play = args["play_track"]
    do_sav_mid = not args["no_save_mid"]
    midi_fl = args["filepath_mid"]
    basic_pitch_model = Model(ICASSP_2022_MODEL_PATH)

  print(f"\ncommand line args are ok: {args_ok}")
  for ky, val in args.items():
    print(f"{ky}: {val}")

  if not args_ok:
    print("\nOne or more supplied arguments are in error, please try again.")
    for ky, val in args.items():
      if isinstance(val, str) and val[:3] == "bad":
        print(f"\t{ky}: {val}")
    exit(1)
... ...
  # no save needed for do_w2m_ps, but will be required for do_w2m_pi
  if (do_m_play and midi_fl) or do_sav_mid:
    # instantiate midi2audio's FluidSynth class, passing it the path to the soundfont file
    # if want higher gain, change following value
    w_gain = 0.2
    fs = FluidSynth(str(SFF), gain=w_gain)

  if do_w2m_ps:
    if do_m_play:
      midi_fl = args["filepath_in"]
      midi_fl = midi_fl.replace(".wav", "_basic_pitch.mid")

    # process the specified file
    audio_fl = Path(PPTH/args["filepath_in"])
    # save mid file to same directory wav file was in
    fl_pth = audio_fl.parent

    # convert and save wave to midi
    st = time.perf_counter()
    predict_and_save(
      [audio_fl],
      fl_pth,
      True,
      True,
      False,
      False,
      basic_pitch_model
    )
    et = time.perf_counter()
    # print(et)
    print(f"audio to midi took {(et-st):.4f} sec")

  if do_m_play and midi_fl:
    fs.play_midi(str(midi_fl))

And a quick test. Have removed all the warnings generated by the various libraries.

PS R:\learn\e_m_311> uv run main.py -wd w2m_ps -fpi img/I-vi-IV-V_4-4_even_half_o4_1.wav -ply
... ...
command line args are ok: True
whatdo: w2m_ps
filepath_in: img/I-vi-IV-V_4-4_even_half_o4_1.wav
filepath_wv:
track_key: random
nbr_bars: 6
play_track: True
no_save_wv: False
no_save_mid: False
cp_type: random
cp_nbr: 0
... ...
Predicting MIDI for R:\learn\e_m_311\img\I-vi-IV-V_4-4_even_half_o4_1.wav...

  Creating midi...
  💅 Saved to R:\learn\e_m_311\img\I-vi-IV-V_4-4_even_half_o4_1_basic_pitch.mid

  Creating midi sonification...
  🎧 Saved to R:\learn\e_m_311\img\I-vi-IV-V_4-4_even_half_o4_1_basic_pitch.wav
audio to midi took 7.0267 sec
FluidSynth runtime version 2.5.4
Copyright (C) 2000-2026 Peter Hanappe and others.
Distributed under the LGPL license.
SoundFont(R) is a registered trademark of Creative Technology Ltd.

And I assure you, the midi file was played.

Couple Other Activities

I think I am going to leave the activities involving changing or displaying the instrument on any midi track for the next post.. I am, for the rest of this post, just going to look at coding the other remaing activities.

Midi to Wave

I am, again, going to allow playing the wave file after conversion from midi.

New Argument Verification Function

Added the following to em_ui package. Pretty much a duplicate of the one for the wave to midi case. Should likely think about combining them.

def get_args_do_m2w(cli_args:argparse.Namespace) -> tuple[bool, dict[str, Union[str, int, bool]]]:
  """ Parse the passed command line arguments to make sure that any required args are
      present and any optional arguments that have values are recorded.

    :param cli_args: argparse container with the current values of the command line parameters

    :return: boolean indicating whether or not arguments are good,
        a dictionary containing the value for each possible argument
  """
  is_ok = True
  t_args = A_DEF.copy()
  # t_args["whatdo"] = "m_cp"
  for a_key in t_args.keys():
    a_val = getattr(cli_args, a_key)
    if a_key == "whatdo":
      # continue
      t_args["whatdo"] = a_val
    # if (a_key != "filepath_in") or (a_key != "filepath_mid"):
    if a_key not in ["filepath_in", "filepath_wv", "play_track"]:
      continue
    match a_key:
      case "filepath_in":
        if a_val is None:
          # filepath required, must be mid file
          t_args[a_key] = f"bad file: path to source wave file is required"
        else:
          # else chk path exists
          f_pth = Path(a_val)
          # print(f"get_args_do_w2m() f_pth: {f_pth}")
          if f_pth.exists() and f_pth.suffix == ".mid":
            t_args[a_key] = a_val
          else:
            # if not flage error
            t_args[a_key] = f"bad path or file type: {a_val}"
            is_ok = False
      case "filepath_wv":
        if a_val is None:
          # no filepath specified is okay for this activity
          continue
        # otherwise check that any parent directories exist
        f_dir = a_val.rsplit("/", 1)
        d_pth = Path(f_dir[0])
        # f_parent = f_pth.parents[0]
        # f_parent = f_pth.parent
        # print(f"\n\t'{f_pth}'")
        if d_pth.exists():
          t_args[a_key] = a_val
        else:
          t_args[a_key] = f"bad path dir: {a_val}"
          is_ok = False
      case "play_track":
        t_args[a_key] = a_val
  
  return is_ok, t_args

Back in main.py

Here’s the changes to our main.py module.

... ...
  if not args_ok:
... ...
  # code from previous module uses these variables, too lazy to figure out proper solution
  if args["filepath_in"]:
    audio_fl = Path(PPTH/args["filepath_in"])

    # save mid file to same directory wav file was in
    fl_pth = audio_fl.parent

  if do_mk_cprog:
... ...
    case "m2w":
      do_m2w = True
      args_ok, args = emui.get_args_do_m2w(cl_args)
      do_w_play =  args["play_track"]
... ...
  # note moved this here from lower down before refactoring if statement
  # no save needed for do_w2m_ps, but will be required for do_w2m_pi
  if do_m2w or (do_m_play and midi_fl) or do_sav_mid:
    # instantiate midi2audio's FluidSynth class, passing it the path to the soundfont file
    # if want higher gain, change following value
    w_gain = 0.2
    fs = FluidSynth(str(SFF), gain=w_gain)
  
  if do_m2w:
    if args["filepath_wv"]:
      wv_fl = args["filepath_wv"]
    else:
      wv_fl = Path(f"{fl_pth}/{audio_fl.stem}_m2w_g{fs.gain}.wav")
    # print(f"wav file: {wv_fl}")
    fs.midi_to_audio(str(audio_fl), str(wv_fl))
... ...
  if (do_m2w and do_w_play):
    with wave.open(str(wv_fl)) as wav_file:
      metadata = wav_file.getparams()
      frames = wav_file.readframes(metadata.nframes)
    snd = np.frombuffer(frames, dtype=np.int16)
    sd.play(snd)
    sd.wait()
(base) PS R:\learn\e_m_311> uv run main.py -wd m2w -fpi img/I-vi-IV-V_4-4_even_half_o4_1_basic_pitch.mid -ply
... ...
command line args are ok: True
whatdo: m2w
filepath_in: img/I-vi-IV-V_4-4_even_half_o4_1_basic_pitch.mid
filepath_wv:
filepath_mid:
track_key: random
nbr_bars: 6
play_track: True
no_save_wv: False
no_save_mid: False
cp_type: random
cp_nbr: 0
instr: []

FluidSynth runtime version 2.5.4
Copyright (C) 2000-2026 Peter Hanappe and others.
Distributed under the LGPL license.
SoundFont(R) is a registered trademark of Creative Technology Ltd.

loaded SoundFont has ID 1 and bankofs=0
Rendering audio to file 'R:\learn\e_m_311\img\I-vi-IV-V_4-4_even_half_o4_1_basic_pitch_m2w_g0.2.wav'..

And it did play from within the script.

Play File From Within Script

Not sure how often I would use this but it may yet prove to be useful.

New Argument Verification Script

Lots of repetition in these scripts not sure at this point how to fix that.

def get_args_do_play(cli_args:argparse.Namespace) -> tuple[bool, dict[str, Union[str, int, bool]]]:
  """ Parse the passed command line arguments to make sure that any required args are
      present and any optional arguments that have values are recorded.

    :param cli_args: argparse container with the current values of the command line parameters

    :return: boolean indicating whether or not arguments are good,
        a dictionary containing the value for each possible argument
  """
  is_ok = True
  t_args = A_DEF.copy()
  # t_args["whatdo"] = "m_cp"
  for a_key in t_args.keys():
    a_val = getattr(cli_args, a_key)
    if a_key == "whatdo":
      # continue
      t_args["whatdo"] = a_val
    # if (a_key != "filepath_in") or (a_key != "filepath_mid"):
    if a_key not in ["filepath_in", "play_track"]:
      continue
    match a_key:
      case "filepath_in":
        if a_val is None:
          # filepath required, must be mid file
          t_args[a_key] = f"bad file: path to source wave file is required"
        else:
          # else chk path exists
          f_pth = Path(a_val)
          # print(f"get_args_do_w2m() f_pth: {f_pth}")
          if f_pth.exists() and ((f_pth.suffix == ".mid") or (f_pth.suffix == ".wav")):
            t_args[a_key] = a_val
          else:
            # if not flage error
            t_args[a_key] = f"bad path or file type: {a_val}"
            is_ok = False
      case "play_track":
        t_args[a_key] = a_val
  
  return is_ok, t_args

Back in main.py

Here are the relevant code changes.

... ...
    case "play":
      # really not sure about this one, but might allow me to play midi from within module
      args_ok, args = emui.get_args_do_play(cl_args)
      if args["filepath_in"]:
        do_m_play = (args["filepath_in"][-4:] == ".mid")
        do_w_play = (args["filepath_in"][-4:] == ".wav")
        if do_m_play:
          midi_fl = args["filepath_in"]
        else:
          wv_fl = args["filepath_in"]
... ...
  if ((not do_mk_cprog) and do_w_play):
    with wave.open(str(wv_fl)) as wav_file:

A couple of quick tests played the following files.

  • uv run main.py -wd play -fpi img/I-vi-IV-V_4-4_even_half_o4_1_basic_pitch.mid
  • uv run main.py -wd play -fpi img/I-vi-IV-V_4-4_even_half_o4_1_basic_pitch_m2w_g0.2.wav

This One Done, I Believe

Still lots to look at with respect to manipulating the midi files. But I think I am going to call this post done.

Has occurred to me that most of the code in the various if blocks, coded in the last post or two, should be in functions. That would give me considerably more flexibility in future code. So, next post will cover my planned refactoring. We will get back to midi files in the following post.

Until next time, I hope you, unlike me, are in the habit of using/writing functions/methods from the get go.