Okay, let’s see if we can finally get around to playing a few different chord progressions for a few bars of randomly generated note values. I think my development/test module and previous utility modules have everything we need to do so. Well, except, perhaps, for a function to generate the chord progression, get a rhythm for some number of bars, add overtones and produce the sound array/file.

I will likely start by just trying to code something in a test block and see how things go. Will add new functions when I see a need to do so.

Developmental/Test Code to Play Chord Progression

A new if block. And a series of steps. Likely a slow go and maybe resource intensive.

Get Musical Key

Well right after coding what I think is the first step, I realized new utility functions would be required. I was working on selecting, psuedo-randomly, the key for the chord progression. That involved a dozen or so lines of code. So, first utility function.

def get_rand_key() -> tuple[str, str]:
  """ Generate and return a pseudo random key

    return: tuple with root note and scale form/mode
  """
  # set up prob of selection for each root note
  pi_scale = [9, 1, 4, 1, 4, 4, 1, 4, 1, 8, 1, 4]
  pis_tot = sum(pi_scale)
  p_scale = [p/pis_tot for p in pi_scale]
  # set up prob of selection for each key form/mode
  pi_km = [5, 4, 2, 1]
  pik_tot = sum(pi_km)
  p_km = [p/pik_tot for p in pi_km]
  # select key (scale + form)
  n_scl = Notes_scale()
  k_frm = Scale_forms()
  # root note
  s_rnt = rng.choice(list(n_scl.notes.keys()), p=p_scale)
  # scale form
  s_frm = rng.choice(list(k_frm.s_forms.keys()), p=p_km)
  return s_rnt, s_frm

A quick test or two.

  if do_mk_play_cprog:
    for i in range(1, 4):
      s_rnt, s_frm = get_rand_key()
      print(f"selected key {i}: {s_rnt} {s_frm}")
(base) PS R:\learn\e_music> uv run tst_chords.py
chord progression with overtones on each chord
selected key 1: C min_har
selected key 2: D major
selected key 3: A major

Get Chords for Key

Seen this before.

  if do_mk_play_cprog:
    s_rnt, s_frm = get_rand_key()
    print(f"\nselected key: {s_rnt} {s_frm}")
    # get the chords for this scale
    s_nts = get_scale_4_note(s_rnt, s_frm)
    print(f"  scale notes: {s_nts}")
    k_chds = scale_chords(s_nts, s_frm)
    print(f"  key chords: {k_chds}")
(base) PS R:\learn\e_music> uv run tst_chords.py
chord progression with overtones on each chord

selected key: E major
  scale notes: ['E4', 'F#4', 'G#4', 'A4', 'B4', 'C#5', 'D#5']
  key chords: [('E', 'major'), ('F#', 'minor'), ('G#', 'minor'), ('A', 'major'), ('B', 'major'), ('C#', 'minor'), ('D#', 'dim')]

Get Chord Progression

Again seen this before.

if do_mk_play_cprog:
    s_rnt, s_frm = get_rand_key()
    print(f"\nselected key: {s_rnt} {s_frm}")
    # DEBUG/DEV mostly
    # get the chords for this scale
    s_nts = get_scale_4_note(s_rnt, s_frm)
    print(f"  scale notes: {s_nts}")
    k_chds = scale_chords(s_nts, s_frm)
    print(f"  key chords: {k_chds}")
    # end DEBUG/DEV mostly
    # generate a chord progression
    # set size of progression
    avl_nc = [3, 4, 5, 6, 7, 8]
    pi_nc = [6, 6, 5, 1, 1, 1]
    pi_tot = sum(pi_nc)
    p_nc = [p/pi_tot for p in pi_nc]
    n_chds = rng.choice(avl_nc, p=p_nc)
    # get a random progression
    rn_prg = make_progression(n_chds)
    print(f"  chord progression (roman numerals): {rn_prg}")
    c_prg = convert_cprog(rn_prg, s_rnt, p_qual=s_frm)
    print(f"  chord progression: {c_prg}")
(base) PS R:\learn\e_music> uv run tst_chords.py
chord progression with overtones on each chord

selected key: A major
  scale notes: ['A4', 'B4', 'C#5', 'D5', 'E5', 'F#5', 'G#5']
  key chords: [('A', 'major'), ('B', 'minor'), ('C#', 'minor'), ('D', 'major'), ('E', 'major'), ('F#', 'minor'), ('G#', 'dim')]
  chord progression (roman numerals): I-IV-V-iii
  chord progression: [('A', 'major'), ('D', 'major'), ('E', 'major'), ('C#', 'minor')]

Generate Chords in Progression

Again, nothing new.

... ...
    # generate chords and add overtones
    p_chrds = []
    for c_nt, c_frm in c_prg:
      c_nts = make_chord(c_nt, c_frm)
      p_chrds.append(c_nts)
    print(f"    [")
    for i, chd in enumerate(p_chrds):
      print(f"      {c_prg[i][0]} {c_prg[i][1]} -> {chd}")
    print(f"    ]")
base) PS R:\learn\e_music> uv run tst_chords.py
chord progression with overtones on each chord

selected key: C min_mel
  scale notes: ['C4', 'D4', 'D#4', 'F4', 'G4', 'A4', 'B4']
  key chords: [('C', 'minor'), ('D', 'minor'), ('D#', 'aug'), ('F', 'major'), ('G', 'major'), ('A', 'dim'), ('B', 'dim')]
  chord progression (roman numerals): I-iii-ii-V
  chord progression: [('C', 'minor'), ('D#', 'aug'), ('D', 'minor'), ('G', 'major')]
    [
      C minor -> ['C4', 'D#4', 'G4']
      D# aug -> ['D#4', 'F#4', 'B4']
      D minor -> ['D4', 'F4', 'A4']
      G major -> ['G4', 'B4', 'D5']
    ]

Convert Chord Symbols to Sounds

Okay, for each chord in the progression I will generate the sound array. Storing them for later use in actually generating the sound array for the chord progression played over some number of bars with random durations and volumes. I am for debugging purposes saving the set of chords to a wav file.

Some Prep Work

I had to add a new function to the em_utils module. Think I failed to do so in the past or some how deleted it since doing so. It is used to generate a series of frequency multipliers for the overtones. I also refactored get_chord_array to take some new parameters. I wanted to be able to control normalization of the chord array. I’ll let you sort out the code changes.

... ...
def get_ot_mults(ot_typ:str, n_ot:int=10) -> list[int]:
  """ Return a list of frequency multipliers for the given parameters.

    :param ot_typ: overtone series, str, one of (seq, even, odd)
    :param n_ot: number of overtones, int > 1, default 10

    :return: list of integer frequency multipliers
  """
  if ot_typ == "seq":
    ot_fs = [i for i in range(2, n_ot + 2)]
  elif ot_typ == "even":
    ot_fs = [i*2 for i in range(1, n_ot + 1)]
  elif ot_typ == "odd":
    ot_fs = [1 + (2 * i) for i in range(1, n_ot + 1)]

  return ot_fs
... ...
def get_chord_array(c_nts:list[str], n_tm:float, n_vol:float=1,
                    s_rate:int=44100, do_norm:bool=True,
                    do_np16:bool=True) -> np.typing.NDArray[np.int16]:
  """ Generate numpy array containing the wave form for the specified chord

    :paramc_nts: list the symbols for individual notes in the chord,
              e.g. D major: ["D4", "F#4", "A"]
    :param n_tm: note duration in seconds, float > 0
    :param n_vol: note volume, float > 0
    :param s_rate: sampling rate, integer > 0
    :param do_norm: if True normalize the chord array
    :parma do_np16: if True convert array values to np.int16

    :return: np array containing the wave form for the chord, int16 values
  """
... ...

Generate the Array of Chord Sounds

Okay, let’s generate the actual sound arrays, save them to file and play them on the PC speaker. They definitely sound better played from the wav file.

    # let's generate chords with overtones
    sample_rate = 44100
    c_dur, c_amp = 1.0, 1.0
    # select sequence and harmonic type at random
    ot_s = rng.choice(["even", "odd", "seq"])
    ot_w = rng.choice(["half", "saw", "sqr", "tri"])
    n_ot = 10
    nosnd = np.zeros(int(0.05 * sample_rate))

    p_chds = []
    # for now use same multipliers and frequencies for overtones
    ot_mlts = emu.get_ot_mults(ot_s, n_ot=n_ot)
    ot_amps = emu.get_ot_amps(c_amp, ot_s, h_typ=ot_w, n_ot=n_ot)
    print(f"\tmultipliers: {ot_mlts}\n\tamplitudes: {[round(oa, 4) for oa in ot_amps]}")
    for i, chd in enumerate(p_chrds):
      c_chd = emu.get_chord_array(chd, c_dur, n_vol=c_amp,
                  s_rate=sample_rate, do_norm=False, do_np16=False)
      n_elms = len(c_chd)
      chd_ots = [c_chd]
      for i, ot_mlt in enumerate(ot_mlts):
        # generate multiplied frequency wave form
        t_f = c_chd[::ot_mlt].copy()       # explicit copy, O(n)
        n_frq = np.tile(t_f, ot_mlt)
        n_frq = n_frq[:n_elms]
        # apply overtone amplitude and save
        chd_ots.append(n_frq * ot_amps[i])
        # generate wave with all overtones
      chd_ot = functools.reduce(np.add, chd_ots)
      chd_ot = emu.normalize_wave(chd_ot, do_typ=True)
      p_chds.append(chd_ot)
      if i < (len(p_chrds) - 1):
        p_chds.append(nosnd)
    p_chds = np.array(p_chds)
    print(f"  p_chds: {p_chds.shape}")
    print(f"    {p_chds.size} * {p_chds.itemsize} -> {p_chds.size * p_chds.itemsize}")

    snd = np.hstack(p_chds)

    if True:
      w_fl_nm = f"{rn_prg}_{ot_s}_{ot_w}_1.wav"
      # Open a WAV file
      with wave.open(f'img/{w_fl_nm}', 'w') as wav_file:
        print(f"writing to wave file: {w_fl_nm}")
        # Define audio parameters
        wav_file.setnchannels(1) # Mono
        wav_file.setsampwidth(2) # Two bytes per sample
        wav_file.setframerate(sample_rate)
        # Convert the NumPy array to bytes and write it to the WAV file
        wav_file.writeframes(snd.tobytes())

    sd.play(snd)
    sd.wait()
(base) PS R:\learn\e_music> uv run tst_chords.py
chord progression with overtones on each chord

selected key: D min_har
  scale notes: ['D4', 'E4', 'F4', 'G4', 'A4', 'A#4', 'C#5']
  key chords: [('D', 'minor'), ('E', 'dim'), ('F', 'aug'), ('G', 'minor'), ('A', 'major'), ('A#', 'major'), ('C#', 'dim')]
  chord progression (roman numerals): I-V-ii-iii
  chord progression: [('D', 'minor'), ('A', 'major'), ('E', 'dim'), ('F', 'aug')]
    [
      D minor -> ['D4', 'F4', 'A4']
      A major -> ['A4', 'C#5', 'E5']
      E dim -> ['E4', 'G4', 'A#4']
      F aug -> ['F4', 'G#4', 'C#5']
    ]
        multipliers: [2, 3, 4, 5, 6, 7, 8, 9, 10, 11]
        amplitudes: [0.448, 0.2604, 0.1396, 0.0818, 0.0339, 0.017, 0.0099, 0.0055, 0.0028, 0.0012]
  p_chds: (4, 44100)
    176400 * 2 -> 352800
writing to wave file: I-V-ii-iii_seq_half_1.wav

And that appears to work. May add a sound sample down the road.

Refactoring

When working on the above, I thought the chords were sounding a little high pitched. And figured I would start and octave or two lower. The functions I currently have/use assume the notes for a given scale start in the 4th octave of a piano. So, I now need to refactor the appropriate functions to take a parameter specifying the starting octave. I am hoping it does not get too complicated or messy.

Well a fair bit more fiddling than I was looking forward to. All of the refactoring was in my dev/test module, tst_chords.

... ...
def convert_cprog(cp_rn:str, r_nt:Notes_scale, r_oct:int=4, p_qual:Chords_ok="major") -> list[tuple[Notes_scale, ChordFormula]]:
  """ Convert a list of roman numerals into the actual chord progression.
      But only chord root and quality, not actual chord notes.
      Note: A0 <= r_nt+r_oct <= C6

    :param cp_rn: string of roman numerals + modifier symbols for chord progression separated by a dash
    :param r_nt: root note including any incidental for the music scale
    :param r_oct: starting octave, relative to piano, for root note, 0 <= r_oct <= 6
    :param s_qual: type/quality of scale, e.g. major, min_nat, etc.

    return: list of tuples identifying the specific chords in the progression
  """
  # print(f"DEBUG: convert_cprog({cp_rn}, {r_nt}, p_qual{p_qual})")
  # expect this to get rather messy
  # chord location in sumbitted chords
  f2loc = {"i": 0, "ii": 1, "iii": 2, "iv": 3, "v": 4, "vi": 5, "vii": 6}

  chd_prg = []
  # let's start by getting the notes for the appropriate scale
  s_nts = get_scale_4_note(r_nt, r_oct=r_oct, s_mode=p_qual)
  s_chds = scale_chords(s_nts, p_qual)
  # print(f"DEBUG: {r_nt}->{s_nts}\n{p_qual}->{s_chds}")
  # only want romman numerals, need to remove dashes
  for rn in cp_rn.split("-"):
    # for now only deal with following modifiers: leading ♭, trailing 7 or °
    if "°" in rn and "7" in rn:
      ...
    elif "°" in rn:
      t_rn = rn[:-1]
      c_rt = s_chds[f2loc[t_rn.lower()]][0]
      chd_prg.append((c_rt, "dim"))
    elif "7" in rn:
      t_rn = rn[:-1]
      c_rt = s_chds[f2loc[t_rn.lower()]][0]
      c_qual = "major" if t_rn.isupper() else "minor"
      chd_prg.append((c_rt, f"{c_qual}7"))
    elif "♭" in rn:
      t_rn = rn[1:]
      # convert roman numeral to chord's root note
      c_rt = s_chds[f2loc[t_rn.lower()]][0]
      c_qual = "major" if t_rn.isupper() else "minor"
      # for now I don't deal with notes labelled flat, bad choice perhaps
      chd_prg.append((convert_2_flat(c_rt), f"{c_qual}"))
    else:
      # print(f"s_chds[f2loc[{rn}.lower()]] -> s_chds[f2loc[{rn.lower()}]")
      chd_prg.append(s_chds[f2loc[rn.lower()]])

  return chd_prg
... ...
def get_2_oct_piano(r_nt:Notes_scale, r_oct:int=4) -> list[Notes_scale]:
  """ Return two octaves of notes on the piano beginning at r_nt and r_oct.
    Note: A0 <= r_nt+r_oct <= C6

    :param r_nt: root note symbol for the desired chromatic scale, str in Notes_scale
    :param r_oct: starting octave, relative to piano, for root note, 0 <= r_oct <= 6

    :return: list of the notes, with octave indicated, in the appropriate order for 2 octaves
  """
  h_scale = []
  r_no = f"{r_nt}{r_oct}"
  # print(f"r_oct = {r_oct} ({type(r_oct)}) -> {r_nt}")
  param_ok = r_nt in C_SCALE and (r_no == "A0" or r_no == "C6" or (r_oct >= 1 and r_oct < 6))
  # if r_nt in C_SCALE:
  if param_ok:
    h_off = get_half_tone(r_nt)
    h_scale.extend(C_SCALE[h_off:])
    h_scale.extend(C_SCALE[:h_off])
    s_oct = r_oct
    for i, nt in enumerate(h_scale):
      if nt == "C" and i > 0:
        s_oct += 1
      h_scale[i] = f"{nt}{s_oct}"
    h_sc2 = h_scale[:]
    for i, nt in enumerate(h_sc2):
      n_nw = f"{nt[:-1]}{int(nt[-1]) + 1}"
      h_scale.append(n_nw)
  return h_scale
... ...
def get_scale_4_note(r_nt:Notes_scale, r_oct:int=4, s_mode:Scale_forms="major") -> list[Notes_scale]:
  """ Get the sequence of scale notes for the specific root note
      Default is that the root note is in the 4th octave of a piano.
      Returning 2 octaves to simplify generating lengthier chords.
      Note: A0 <= r_nt+r_oct <= C6

    :param r_nt: root note symbol for the desired chromatic scale, str
    :param r_oct: starting octave, relative to piano, for root note, 0 <= r_oct <= 6
    :param s_mode: mode in which to generate scale, str, one of
                   Scale_forms.s_forms

    :return: list of the notes in the appropriate order for 2 octaves
  """
  s_frms = Scale_forms()
  # s_frms = Scale_forms().s_forms[s_mode]
  # bloody hell!, 3 minor scales, who knew?? ascending/descending?
  # melodic minor also has descending version which is natural minor
  ok_scl = ["major", "min_nat", "min_har", "min_mel"]
  step_s_2_int = {"W": 2, "H": 1, "W½": 3}

  h_scale, h_steps = [], []
  if s_mode not in ok_scl:
    return h_scale
  # get notes for two piano octaves starting at tonic note
  # print(f"r_oct = {r_oct} ({type(r_oct)})")
  t_scale = get_2_oct_piano(r_nt, r_oct=r_oct)
  # print(f"t_scale: {t_scale} ({len(t_scale)}) ")

  # generate requested scale
  # s_steps = s_frms[s_mode]
  s_steps = s_frms.s_forms[s_mode]
  # print(t_scale, "\n", s_steps)
  c_step = 0
  h_steps.append(c_step)
  h_scale.append(t_scale[c_step])
  for i, stp in enumerate(s_steps[:-1]):
    c_step += step_s_2_int[stp]
    # print(c_step, end="")
    h_steps.append(c_step)
    h_scale.append(t_scale[c_step])
  # print()
  
  # print(h_steps)

  return h_scale


def make_chord(nt:Notes_scale, c_qual:ChordFormula, r_oct:int=4) -> list[Notes_scale]:
  """ Determine and return notes for specified root note and chord quality.
      Note: A0 <= r_nt+r_oct <= C6
    
    :param nt: the root note for the chord, str
    :param c_qual, the chord quality, str, one of enum ChordFormula
    :param r_oct: starting octave, relative to piano, for root note, 0 <= r_oct <= 6

    :return: list of notes for the chord, str, note from C-SCALE plus octave number
  """
  h_tones = ChordFormula.__getitem__(c_qual).value
  # print(f"\t{h_tones}")
  # h_scale = get_scale_4_note(nt)
  # h2_scale = [f"{nt[:-1]}{int(nt[-1]) + 1}" for nt in h_scale]
  # h_scale.extend(h2_scale)
  # print(f"\th_scale: {h_scale}")
  h_scale = get_2_oct_piano(nt, r_oct=r_oct)
  chord = [h_scale[v] for v in h_tones]
  ### !!!! need to sort out octaves
  # p_oct = int(chord[0][-1])
  # for i, cnt in enumerate(chord):
  #   # if cnt == "B4":
  #   #   print(f"\n{cnt}, p_oct: {p_oct}")
  #   if int(cnt[-1]) < p_oct:
  #     chord[i] = f"{cnt[:-1]}{p_oct}"
  #   p_oct = int(chord[i][-1])
  #   # if cnt == "B4":
  #   #     print(f"\t-> {chord[i]}, p_oct: {p_oct}")
  return chord
... ...
  if do_mk_play_cprog:
    # for i in range(1, 4):
    #   s_rnt, s_frm = get_rand_key()
    #   print(f"selected key {i}: {s_rnt} {s_frm}")
    # get a key for the chord progression
    s_rnt, s_frm = get_rand_key()
    r_oct = rng.choice([1, 2, 3, 4])
    # debug
    # s_rnt, s_frm = "B", "min_nat"
    print(f"\nselected key: {s_rnt} {s_frm}")
    # DEBUG/DEV mostly
    # get the chords for this scale
    s_nts = get_scale_4_note(s_rnt, r_oct=r_oct, s_mode=s_frm)
    print(f"  scale notes: {s_nts}")
    k_chds = scale_chords(s_nts, s_frm)
    print(f"  key chords: {k_chds}")
    # end DEBUG/DEV mostly
    # generate a chord progression
    # n_chds = rng.integers(3,7,endpoint=True) -- too many long progressions
    # set size of progression
    avl_nc = [3, 4, 5, 6, 7, 8]
    pi_nc = [6, 6, 5, 1, 1, 1]
    pi_tot = sum(pi_nc)
    p_nc = [p/pi_tot for p in pi_nc]
    n_chds = rng.choice(avl_nc, p=p_nc)
    # get a random progression
    rn_prg = make_progression(n_chds)
    # debug
    # rn_prg = "I-IV-vi"
    print(f"  chord progression (roman numerals): {rn_prg}")
    c_prg = convert_cprog(rn_prg, s_rnt, r_oct=r_oct, p_qual=s_frm)
    print(f"  chord progression: {c_prg}")
    # generate chords and add overtones
    p_chrds = []
    for c_nt, c_frm in c_prg:
      c_nts = make_chord(c_nt, c_frm, r_oct=r_oct)
      p_chrds.append(c_nts)
      # print(f"\t{c_nt} {c_frm} -> {c_nts}")
    print(f"    [")
    for i, chd in enumerate(p_chrds):
      print(f"      {c_prg[i][0]} {c_prg[i][1]} -> {chd}")
    print(f"    ]")

    # let's generate chords with overtones
    sample_rate = 44100
    c_dur, c_amp = 1.0, 1.0
    # ot_s, ot_w, n_ot = "odd", "saw", 10
    # ot_s, ot_w, n_ot = "seq", "saw", 10
    # ot_s, ot_w, n_ot = "even", "saw", 10
    ot_s = rng.choice(["even", "odd", "seq"])
    ot_w = rng.choice(["half", "saw", "sqr", "tri"])
    n_ot = 10
    nosnd = np.zeros(int(0.05 * sample_rate))
    # nosnd = nosnd.astype(np.int16)

    p_chds = []
    # for now use same multipliers and frequencies for overtones
    ot_mlts = emu.get_ot_mults(ot_s, n_ot=n_ot)
    ot_amps = emu.get_ot_amps(c_amp, ot_s, h_typ=ot_w, n_ot=n_ot)
    print(f"\tmultipliers: {ot_mlts}\n\tamplitudes: {[round(oa, 4) for oa in ot_amps]}")
    for i, chd in enumerate(p_chrds):
      c_chd = emu.get_chord_array(chd, c_dur, n_vol=c_amp,
                  s_rate=sample_rate, do_norm=False, do_np16=False)
      n_elms = len(c_chd)
      chd_ots = [c_chd]
      for i, ot_mlt in enumerate(ot_mlts):
        # generate multiplied frequency wave form
        t_f = c_chd[::ot_mlt].copy()       # explicit copy, O(n)
        n_frq = np.tile(t_f, ot_mlt)
        n_frq = n_frq[:n_elms]
        # apply overtone amplitude and save
        chd_ots.append(n_frq * ot_amps[i])
        # generate wave with all overtones
      chd_ot = functools.reduce(np.add, chd_ots)
      chd_ot = emu.normalize_wave(chd_ot, do_typ=True)
      p_chds.append(chd_ot)
      if i < len(p_chrds) - 1:
        p_chds.append(nosnd)
    p_chds = np.array(p_chds)
    print(f"  p_chds: {p_chds.shape}")
    print(f"    {p_chds.size} * {p_chds.itemsize} -> {p_chds.size * p_chds.itemsize}")

    snd = np.hstack(p_chds)

    if True:
      w_fl_nm = f"{rn_prg}_{ot_s}_{ot_w}_o{r_oct}_1.wav"
      # Open a WAV file
      with wave.open(f'img/{w_fl_nm}', 'w') as wav_file:
        print(f"writing to wave file: {w_fl_nm}")
        # Define audio parameters
        wav_file.setnchannels(1) # Mono
        wav_file.setsampwidth(2) # Two bytes per sample
        wav_file.setframerate(sample_rate)
        # Convert the NumPy array to bytes and write it to the WAV file
        wav_file.writeframes(snd.tobytes())

Done

And that has confused me enough that I think I will call it a day. Will continue with note values and volume in the next post. Hopefully a lot less messy than this last bit of development—if I can actually call it that.

Until then enjoy whatever music you generate. Or maybe just play a CD; which is what I do whenever coding/blogging.