Okay, bit of a break. Now it’s time to get on with generating chords with overtones added.

Helper Functions

Before we get to the nitty-gritty of generating chord sounds, I figured I’d write a number of helper functions. For now I am doing so in a throw-away module (well throw away once I move the functions to their proper module/package).

I plan to write a function that returns the notes in a chord based on its root note and quality (e.g. major, minor, etc.). I plan to do this based on the halftone indices (positions) in a suitable harmonic scale. Each chord quality has a specific set of halftone indices.

So, besides that function, I figured I’d need to be able to get a note’s index in the C scale and perhaps the reverse. I would also need a function to provide the appropriate harmonic scale for the chord’s root note. Then I would need some way to get the specific set of indices for a given chord quality.

Let’s start with the first three.

C-Scale Index or Note

More or less straight forward. I am using a module global constant for the C scale notes. And, because I am going to start, as much as possible, using variable typing in my code, I had to add a class for that C scale as well. I may eventually look at adding the two functions as methods of that class. But for now, a bit of duplication.

# tst_chords_rek.py: want to test approaches to generating the list of notes
#  in a chord given the root note and the type of chord
# Expect it will take a bit of work and messing around, hopefully worthwhile.

C_SCALE = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B']

# at this point only used for variable typing
class Notes_scale:
  """The twelve standard notes in a piano octave."""
  def __init__(self):
    self.notes = {
      'C': 0, 'C#' : 1, 'D': 2, 'D#': 3, 'E': 4, 'F': 5,
      'F#': 6, 'G': 7, 'G#': 8, 'A': 9, 'A#': 10, 'B': 11,
    }


# Because I plan to use the half tone counts to generate the chords,
# a couple utility functions to given the position of the note in the
# C scale and vice versa

def get_half_tone(nt_sym:Notes_scale) -> int:
  """ Given the symbol of a note in the C scale, return the halftone
    offset for that note's symbol.

    :param nt_sym: sym for a note in the C scale, str

    :return: integer specifying half tone position of note in the C scale
  """
  if nt_sym in C_SCALE:
    return C_SCALE.index(nt_sym)
  # otherwise error
  return -1


def get_nt_sym(h_tone:int) -> Notes_scale:
  """ Given the chromatic scale halftone, return the note symbol

    :param: h_tone: offset of halftone in chromatic scale, 0 <= int <= 11

    :return: note symbol at specified chromatic halftone
  """
  # I am returning a note come hell or highwater
  return C_SCALE[h_tone % 12]

I won’t bother with any of my test code.

Harmonic Scale for Chord’s Root Note

Okay, I want to get the appropriate harmonic scale in the correct order starting with the root note. Again pretty straightforward. Except perhaps the documentation and typing.

After some further development and testing, I decided it would make life easier if the function returned two sequential octaves giving the correct octave number for each note. For now I am assuming each chord’s root note will be in a piano’s 4th octave. Not going to cover the developmental versions. This is the final version.

def get_scale_4_note(r_nt:Notes_scale) -> list[Notes_scale]:
  """ Get the sequence of scale notes for the specific root note
      I assume that the root note is in the 4th octave of a piano.
      Returning 2 octaves to simplify generating lengthier chords.

    :param r_nt: root note symbol for the desired chromatic scalle, str

    :return: list of the notes in the appropriate order for 2 octaves
  """
  h_scale = []
  if r_nt in C_SCALE:
    h_off = get_half_tone(r_nt)
    h_scale.extend(C_SCALE[h_off:])
    h_scale.extend(C_SCALE[:h_off])
    s_oct = 4
    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

And, this time I will include a quick test.

    for nt in ["C", "F#", "A#", "B"]:
      print(f"\n{nt} scale: {get_scale_4_note(nt)}")

And in the terminal I got the following (display edited for easier viewing).

PS R:\learn\e_music> uv run tst_chords_rek.py

C scale: ['C4', 'C#4', 'D4', 'D#4', 'E4', 'F4', 'F#4', 'G4', 'G#4', 'A4', 'A#4', 'B4',
          'C5', 'C#5', 'D5', 'D#5', 'E5', 'F5', 'F#5', 'G5', 'G#5', 'A5', 'A#5', 'B5']

F# scale: ['F#4', 'G4', 'G#4', 'A4', 'A#4', 'B4', 'C5', 'C#5', 'D5', 'D#5', 'E5', 'F5',
           'F#5', 'G5', 'G#5', 'A5', 'A#5', 'B5', 'C6', 'C#6', 'D6', 'D#6', 'E6', 'F6']

A# scale: ['A#4', 'B4', 'C5', 'C#5', 'D5', 'D#5', 'E5', 'F5', 'F#5', 'G5', 'G#5', 'A5',
           'A#5', 'B5', 'C6', 'C#6', 'D6', 'D#6', 'E6', 'F6', 'F#6', 'G6', 'G#6', 'A6']

B scale: ['B4', 'C5', 'C#5', 'D5', 'D#5', 'E5', 'F5', 'F#5', 'G5', 'G#5', 'A5', 'A#5',
          'B5', 'C6', 'C#6', 'D6', 'D#6', 'E6', 'F6', 'F#6', 'G6', 'G#6', 'A6', 'A#6']

As near as I can tell that works correctly.

Get Notes for Specified Chord

Okay, one of the more important functions. Though obviously subject to change.

The chord generation process and variable typing, use two enum classes I copied from Implementing Guitar Chord Generator Using Python by Pavel Cherepansky. Though this information is available in a huge number of articles/posts, doing so saved me a great deal of work. And, I thought the use of an enum was rather clever. Both of which I truly appreciate; thank you Pavel!

Not only that, with the classes and the scale generation function, getting the correct notes for the specified chord was really simple.

... ...
from enum import Enum
... ...
# the following two classes copied from 'Implementing Guitar Chord Generator Using Python'
# by Pavel Cherepansky (https://medium.com/@pavelcherepansky)
# https://medium.com/better-programming/guitar-chord-generator-using-python-bb123294b550
class Intervals(Enum):
  """Standard intervals in western music in number of half-steps"""
  P1 = 0  # perfect unison
  m2 = 3  # minor second
  M2 = 4  # major second
  m3 = 3  # minor third
  M3 = 4  # major third
  P4 = 5  # perfect fourth
  TT = 6  # tritone
  d5 = 6  # diminished fifth
  P5 = 7  # perfect fifth
  m6 = 8  # minor sixth
  M6 = 9  # major sixth
  A5 = 8   # augmented 5
  m7 = 10  # minor seventh
  M7 = 11  # major seventh
  P8 = 12  # octave
  M9 = 14  # Major ninth


class ChordFormula(Enum):
  major = Intervals.P1.value, Intervals.M3.value, Intervals.P5.value
  minor = Intervals.P1.value, Intervals.m3.value, Intervals.P5.value
  aug = Intervals.P1.value, Intervals.m3.value, Intervals.A5.value
  dim = Intervals.P1.value, Intervals.m3.value, Intervals.d5.value
  sus4 = Intervals.P1.value, Intervals.P4.value, Intervals.P5.value
  sus2 = Intervals.P1.value, Intervals.M2.value, Intervals.P5.value
  major7 = Intervals.P1.value, Intervals.M3.value, Intervals.P5.value, Intervals.M7.value
  dom7 = Intervals.P1.value, Intervals.M3.value, Intervals.P5.value, Intervals.m7.value
  minor7 = Intervals.P1.value, Intervals.m3.value, Intervals.P5.value, Intervals.m7.value
  minor7_flat5 = Intervals.P1.value, Intervals.m3.value, Intervals.d5.value, Intervals.m7.value
  dim7 = Intervals.P1.value, Intervals.m3.value, Intervals.d5.value, Intervals.M6.value
  major9 = Intervals.P1.value, Intervals.M3.value, Intervals.P5.value, Intervals.M7.value, Intervals.M9.value
  dom9 = Intervals.P1.value, Intervals.M3.value, Intervals.P5.value, Intervals.m7.value, Intervals.M9.value
... ...
def make_chord(nt:Notes_scale, c_qual:ChordFormula) -> list[Notes_scale]:
  """ Determine and return notes for specified root note and chord quality.
    
    :param nt: the root note for the chord, str
    :param c_qual, the chord quality, str, one of enum ChordFormula

    :return: list of notes for the chord, str, note from C-SCALE plus octave number
  """
  h_tones = ChordFormula.__getitem__(c_qual).value
  h_scale = get_scale_4_note(nt)
  chord = [h_scale[v] for v in h_tones]
  return chord

That is one short function. A quick test. Note, for the purposes of testing I did add some print statements to the function above.

    # get notes for a few different chords
    t_chds = [
      ("C", "dom9", ["C", "E", "G", "A#", "D"]),
      ("F", "major7", ["F", "A", "C", "E"]),
      ("A", "dom7", ["A", "C#", "E", "G"]),
      ("B", "major9", ["B", "D#", "F#", "A#", "C#"]),
    ]
    for nt, chd, nts in t_chds:
      print(f"\n{nt} {chd}")
      print(f"\t{make_chord(nt, chd)} ?= {nts}")

And, in the terminal the following ouput was produced. I will let you confirm whether or not the function is working correctly.

PS R:\learn\e_music> uv run tst_chords_rek.py

C dom9
        (0, 4, 7, 10, 14)
        ['C4', 'C#4', 'D4', 'D#4', 'E4', 'F4', 'F#4', 'G4', 'G#4', 'A4', 'A#4', 'B4',
         'C5', 'C#5', 'D5', 'D#5', 'E5', 'F5', 'F#5', 'G5', 'G#5', 'A5', 'A#5', 'B5']
        ['C4', 'E4', 'G4', 'A#4', 'D5'] ?= ['C', 'E', 'G', 'A#', 'D']

F major7
        (0, 4, 7, 11)
        ['F4', 'F#4', 'G4', 'G#4', 'A4', 'A#4', 'B4', 'C5', 'C#5', 'D5', 'D#5', 'E5',
         'F5', 'F#5', 'G5', 'G#5', 'A5', 'A#5', 'B5', 'C6', 'C#6', 'D6', 'D#6', 'E6']
        ['F4', 'A4', 'C5', 'E5'] ?= ['F', 'A', 'C', 'E']

A dom7
        (0, 4, 7, 10)
        ['A4', 'A#4', 'B4', 'C5', 'C#5', 'D5', 'D#5', 'E5', 'F5', 'F#5', 'G5', 'G#5',
         'A5', 'A#5', 'B5', 'C6', 'C#6', 'D6', 'D#6', 'E6', 'F6', 'F#6', 'G6', 'G#6']
        ['A4', 'C#5', 'E5', 'G5'] ?= ['A', 'C#', 'E', 'G']

B major9
        (0, 4, 7, 11, 14)
        ['B4', 'C5', 'C#5', 'D5', 'D#5', 'E5', 'F5', 'F#5', 'G5', 'G#5', 'A5', 'A#5',
         'B5', 'C6', 'C#6', 'D6', 'D#6', 'E6', 'F6', 'F#6', 'G6', 'G#6', 'A6', 'A#6']
        ['B4', 'D#5', 'F#5', 'A#5', 'C#6'] ?= ['B', 'D#', 'F#', 'A#', 'C#']

Adding Overtones

Okay, time to have a look at adding overtones to our chords. I see two possible approaches. Add overtones to each note then add them up to get the chord. Or, sum the bare notes to get the chord, then add overtones to the chord. Seems to me that either of those two should generate the same result. But, not 100% sure; so, need to check that out.

One other thought. I am thinking that each note should have the same overtones applied to it. The current function for adding overtones doesn’t work that way. So I believe we are looking at another helper function or two. Or perhaps a refactoring of the current add_overtones function. Though the latter strikes me as making the function pretty messy and less than focused on doing one thing and one thing only.

Okay, let’s start coding and seeing what works or doesn’t work. I will leave refactoring add_overtones as a last resort. So, a function that given an overtone series type and harmonic type returns the frequency multipliers and volumes for the specified parameters.

Get Overtone Series

We have a function that returns the overtone amplitudes for the speficied series. Let’s add a function to return the overtone frequency multiplier series based on some parameters. Those two should give us everything we really need to add the same overtones to each note and/or chord.

I have decided to go with a default of 10 overtones. So refactored a function or two in the em_utils package (won’t bother showing the changes).

def get_ot_mults(ot_typ:str, n_ot:int=10) -> list[int]:
  """ Return a list of frequecny 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

Apply Overtones to a Note

Okay, we can get the overtone multiplier and amplitude values for a given series and wave function type. So, now we need a function to apply those overtone characteristics to some note.

We’ve pretty much seen most, if not all, of this before. But I have decided, for now, to return the raw sine wave without any normalization or type change. That may change if my logic for that decision is flawed.

... ...
import numpy as np

from pkgs import em_utils as emu
... ...
def apply_overtones(n_f:int, n_tm:float, n_vl:float, ot_seq:list[int],
                    ot_amps:list[float], s_rt:int=44100) -> np.typing.NDArray[np.float_]:
  """ Generate and return waveform for fundamental note plus overtones.

    :param n_f: fundamental note's frequency, int > 0
    :param n_tm: duration for note, float > 0
    :param n_vl: amplitude for fundamental frequency, float > 0
    :param ot_seq: list of overtone frequency multipliers, int > 0 
    :param ot_amp: list of overtone amplitudes, float > 0
    :param s_rt: sampling rate to use when generating note, int > 0, default 44100

    :return: numpy 1D array with wave form for note + overtones
  """
  ots = []
  print(f"\napply_overtones({n_f}, {n_tm}, {n_vl}, ot_seq={ot_seq}, ot_amps={ot_amps}, s_rt=44100)")
  # need the fundatmental notes wave form
  ots.append(emu.get_sine_wave(n_f, n_tm, 1))
  # add the overtone wave forms
  for i, ota in enumerate(ot_amps):
    s_wv = emu.get_sine_wave(n_f * ot_seq[i], n_tm, ota)
    ots.append(s_wv)
  # add fundamental note and overtones together
  snd = functools.reduce(np.add, ots)
  return snd

Compare Two Chord Processes

Okay, now I would like to see whether or not I can get away with just applying the overtones to the base chord. I.E. sum of chord’s note waveforms without any added overtones. So let’s look at the waveform generated by our two procedures.

When I first thought about this, for the second approach, I was going to start by generating the base chord’s wave form. Then for each overtone, multiply that waveform by the appropriate frequency multiplier and overtone amplitude. But, as I slept that night, I realized that would only modify the amplitude of the chord wave form multiple times. What I needed to start for each overtone was the base frequency for the chord. But there is no such thing. So, I spent some time trying to be creative.

I ended up resampling the chord’s wave at the necessary rates for each overtone frequency multiplier. Generated a new wave form of the appropriate length and applied the appropriate overtone amplitude. Took a bit of reaserach/reading but I managed to get that to work.

Sorry, a rather messy bit of code. But, it is at this point just testing a concept or two. Once I got things working I added the timing code—curiousity.

... ...
import functools, time
... ...
  if do_ots:
    # test adding overtones to individual notes vs just the base chord
    # let's start slow and easy, ("E", "minor", ["E", "G", "B"])
    c_chrd = [("E", "minor")]
    sample_rate = 44100
    c_dur, c_amp = 0.5, 0.75
    ot_s, ot_w = "seq", "saw"
    nosnd = np.zeros(int(0.05 * sample_rate))
    nosnd = nosnd.astype(np.int16)

    for c_nt, c_ql in c_chrd:
      # Get chord note frequencies
      c_nts = make_chord(c_nt, c_ql)
      c_frqs = []
      for nt in c_nts:
        c_frqs.append(emu.get_note_freq(nt))
      print(f"{c_nt} {c_ql}: {c_nts} -> {[round(cf, 2) for cf in c_frqs]}")

      # get overtone characteristics
      ot_mlts = get_ot_mults(ot_s)
      ot_amps = emu.get_ot_amps(1, ot_s, ot_w)
      print(f"\tmultipliers: {ot_mlts}\n\tamplitudes: {[round(oa, 4) for oa in ot_amps]}")

      # chord using individual notes with overtones added
      st = time.process_time()
      for _ in range(10):
        nts_ot = []
        for n_f in c_frqs:
          nt_wot = apply_overtones(n_f, c_dur, n_vl=1, ot_seq=ot_mlts, ot_amps=ot_amps)
          nts_ot.append(nt_wot)
        # generate chord wave form
        chd_w = functools.reduce(np.add, nts_ot)
      et = time.process_time()
      print(f"overtones on note, 10 reps: {et - st}")
      
      # generate chord then add overtones
      st = time.process_time()
      for _ in range(10):
        nts_chd = []
        for n_f in c_frqs:
          nts_chd.append(emu.get_sine_wave(n_f, c_dur))
        chd_not = functools.reduce(np.add, nts_chd)
        n_elms = len(chd_not)
        chd_ots = [chd_not]
        for i, ot_mlt in enumerate(ot_mlts):
          # generate multiplied frequency wave form
          t_f = chd_not[::ot_mlt].copy()       # explicit copy, O(n)
          # generate multiple copies of chord waveform
          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)
      et = time.process_time()
      print(f"overtones on chord, 10 reps: {et - st}")

      if True:
        s_ht = ot_w
        if s_ht == "sqr":
          s_ht = "square"
        elif s_ht == "tri":
          s_ht = "triangle"

        p_f_pth = f"img\\overtones_{c_nt}_{c_ql}_1.png"

        # let's plot the notes with the overtones added
        fig, axs = plt.subplots(5, figsize=(10, 8), sharex=True)
        fig.suptitle(f"{c_nt} {c_ql} with Harmonics ({ot_s} / {s_ht})", fontsize=16, y=.98, ha="center")

        # plot each note fundamental and w/overtones
        t = np.linspace(0, c_dur, int(c_dur * sample_rate), False)
        lt = len(t)
        print(f"len(nots_ot): {len(nts_ot)}, len(c_nts): {len(c_nts)}")
        for i, ot_n in enumerate(nts_ot):
          xot = axs[i].plot(t[:500], ot_n[:500], label=f"{c_nts[i]}")
          if i == (len(c_nts) - 1):
            axs[i].set(xlabel="Time (secs)", ylabel="Amplitude")
          else:
            axs[i].set(ylabel="Amplitude")
          axs[i].legend()

        # plot chord wave form from notes with overtones added
        xot = axs[3].plot(t[:500], chd_w[:500], label=f"{c_nt} {c_ql} (overtones on notes)")
        axs[3].legend()
        axs[3].set(ylabel="Amplitude")

        # plot chord using base notes with overtones added to the chord wave form
        xot = axs[4].plot(t[:500], chd_ot[:500], label=f"{c_nt} {c_ql} (overtones after)")
        axs[4].set(xlabel="Time (secs)", ylabel="Amplitude")
        axs[4].legend()
        fig.tight_layout()
        # fig.savefig(p_f_pth)
        plt.show()

In the terminal the following was displayed.

PS R:\learn\e_music> uv run tst_chords_rek.py
E minor: ['E4', 'G4', 'B4'] -> [329.63, 392.0, 493.88]
        multipliers: [2, 3, 4, 5, 6, 7, 8, 9, 10, 11]
        amplitudes: [0.2419, 0.172, 0.1268, 0.0988, 0.0807, 0.0699, 0.0609, 0.0553, 0.0488, 0.045]
overtones on note, 10 reps: 0.375
overtones on chord, 10 reps: 0.0625

Look at the difference in time to generate the chord using the two approaches. ~6 times faster for the latter approach in this test, That approach, apparently, really takes advantage of numpy’s speed.

And, this is the plot of the notes and chords with overtones.

image of notes and chords with overtones applied

Looks like both approaches get to the same place. And, I assure you, my ears could not tell the difference between the two generated chord sounds.

New Function

I have decided to put the code for adding overtones to a base chord wave array in a new function: add_overtones_chord. Quick test says it works.

def add_overtones_chord(c_wv:np.typing.NDArray[np.float_], ot_seq:list[int],
                    ot_amps:list[float]) -> np.typing.NDArray[np.float_]:
  """ Add the specified overtones to the provide chord wave form.
      Do not normalize or convert to np.int16

    :param c_wv: chord's base wave form, i.e. no overtones, np.array[float]
    :param ot_seq: list of overtone frequency multipliers, int > 0 
    :param ot_amp: list of overtone amplitudes, float > 0

    :return: numpy 1D array with wave form for note + overtones, 
             not normalized to 16-bit range and converted to 16-bit int values

  """
  n_elms = len(c_wv)
  chd_ots = [c_wv]
  for i, ot_mlt in enumerate(ot_seq):
    # generate multiplied frequency wave form, make sure correct length
    t_f = c_wv[::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)
  return chd_ot  

Done

I had thought I would get to playing a chord progression with overtones added to each chord. But, I’ve temporarily run out of steam and need to mow at least the front lawn. So, reckon this post is at an end.

Until next time, a reminder that there is almost always more than one way to resolve a coding issue. Maybe even a mathematical one. Sometimes a little extra work is involved. But, there is often a pay-off worth the effort.

Resources