Couple of things I am going to look at to see if I can improve the sound of the notes being played. Not really sure how to go about either one, but feel I should give it a go. Am going to start with timbre. The other will get introduced a little later or perhaps get ignored entirely.

Timbre

In music, timbre is pretty much what makes one instrument sound different from another.

One of the basic elements of music is called color, or timbre (pronounced “TAM-ber”). Timbre describes all of the aspects of a musical sound that do not have anything to do with the sound’s pitch, loudness, or length. … Timbre is caused by the fact that each note from a musical instrument is a complex wave containing more than one frequency. For instruments that produce notes with a clear and specific pitch, the frequencies involved are part of a harmonic series.
Timbre

So, I am going to write a function that takes a note and a few other parameters and generates a new wave with added harmonics. Will mess around a little but doubt I will be attempting to make the notes sound like they are being produced on a specific instrument. Just want a nicer sound than I am currently getting.

I will allow for overtones in the series to be sequential, even or odd frequencies. I will also likely look at different ways of determining the appropriate amplitude for each overtone. And, perhaps the duration. I will also likely have a parameter for how many overtones to add to the fundamental frequency’s curve.

But before getting to that function, there is a helper function that it will use (need?).

get_ot_amps()

This function returns a list of amplitudes for some requested number of overtones. But, it also generates different lists based on how the overtone frequency multiplier series is specified (sequential, even, odd) and the harmonic type we are after (half, saw, square, triangle). I did not want to include all that code in the primary overtones function.

That said, it is pretty much muddled up. But, it seems to do the job. I did also add a bit of randomness when determining each amplitude.

def get_ot_amps(f_amp:float, ot_typ:str, h_typ:str="saw", n_ot:int=6) -> list[float]:
  """ Return list of overtone amplitudes matching input arguments.
      The use of any spectrum type with any series type is allowed (for fun).

  :param: f_amp: amplitude of fundamental note, float > 0
  :param: ot_typ: overtone series, str, one of (seq, even, odd)
  :param: h_typ: = harmonic spectrum type, str, one of (half, saw, sqr, tri)
          half: each overtone is half the previous
          saw: each overtone has amplitude of its reciprocal (aka square for odd series)
          triangle: each overtone has amplitude of its reciprocal squared
  :param: n_ot: number of overtones, int > 1, default 6

  :return: list of overtone amplitudes
"""
  ot_amps = []
  if h_typ == "half":
    a_ot1 = f_amp * rng.uniform(low=0.4, high=0.6)
    ot_amps.append(a_ot1)
    for i in range (1, n_ot):
      t_amp = ot_amps[i-1] * rng.uniform(low=0.4, high=0.6)
      ot_amps.append(t_amp)
  elif h_typ == "saw" or (h_typ == "sqr" and ot_typ == "seq"):
    # saw
    ot_amps = [f_amp/(i + (1 + rng.uniform(low=0.85, high=1.15))) for i in range(n_ot)]
  elif h_typ == "sqr":
    # square, only valid if ot_typ == "even" or "odd"
    h_st = 1 if ot_typ == "even" else 2
    h_nbrs = range(0, n_ot*2, 2)
    ot_amps = [f_amp/(i + (h_st + rng.uniform(low=0.85, high=1.15))) for i in h_nbrs]
  elif h_typ == "tri":
    # triangle
    ot_amps = [(1 if i%2 else -1) * f_amp/(i + (1 + rng.uniform(low=0.85, high=1.15)))**2 for i in range(n_ot)]
  if h_typ != "tri":
    a_sum = sum(ot_amps)
    ot_amps = [amp / a_sum for amp in ot_amps]
  return ot_amps

A quick test for get_ot_amps(1, ot, ht, n_ot=6) gave me the following in the terminal.

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

series: seq
        harmonic: half
                [0.5456, 0.2241, 0.1059, 0.0587, 0.0274, 0.0153]
        harmonic: saw
                [0.4931, 0.3222, 0.251, 0.1961, 0.1629, 0.1438]
        harmonic: sqr
                [0.5087, 0.3235, 0.2546, 0.2061, 0.1679, 0.1456]
        harmonic: tri
                [-0.2248, 0.1117, -0.0636, 0.0385, -0.0288, 0.0204]

series: even
        harmonic: half
                [0.5814, 0.2539, 0.117, 0.0679, 0.0299, 0.0137]
        harmonic: saw
                [0.5025, 0.3176, 0.2575, 0.2045, 0.1659, 0.1454]
        harmonic: sqr
                [0.4961, 0.2552, 0.1681, 0.1267, 0.1007, 0.0838]
        harmonic: tri
                [-0.2249, 0.1154, -0.0581, 0.0388, -0.028, 0.0211]

series: odd
        harmonic: half
                [0.5031, 0.2913, 0.1714, 0.1027, 0.0569, 0.024]
        harmonic: saw
                [0.48, 0.3429, 0.2532, 0.2053, 0.167, 0.1446]
        harmonic: sqr
                [0.324, 0.1944, 0.1446, 0.1121, 0.0902, 0.0765]
        harmonic: tri
                [-0.2427, 0.1119, -0.0616, 0.0412, -0.0276, 0.0204]

add_overtones()

Ok on to the main course. The name, I think, does pretty much describe what it is intended to do. Expect it will be refactored/enhanced multiple times. We have pretty much described what it has to do.

Had an issue with volumes for a note with different harmonics added. Drove me nuts. Finally settled on something that comes close to keeping the volumes similar when played one after the other. Not really great but for now workable, as it isn’t likely to happen except in this experiment.

def add_overtones(n_f:int, n_tm:float, n_vl:float=1, s_rt:int=44100,
      ot_typ:str="seq", h_typ:str="saw", ot_nb:int=6) -> np.typing.NDArray[np.int16]:
  """ 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: s_rt: sampling rate to use when generating note, int > 0, default 44100
    :param: ot_typ: overtone type, str, one of (seq, even, odd)
    :param: h_typ: = harmonic spectrum type, str, one of (half, saw, sqr, tri)
            half: each overtone is half the previous
            saw: each overtone has amplitude of its reciprocal
            sqr: like saw but for odd or even ot_typ
            triangle: each overtone has amplitude of its reciprocal squared
    :param: ot_nb: number of overtones to add to funddamental note, int > 0, default 6

    :return: numpy 1D array with wave form for note + overtones, 
             normalized to 16-bit range and converted to 16-bit int values
  """
  ot_amps = get_ot_amps(1, ot_typ, h_typ, ot_nb)
  if h_typ == "tri":
    tot_ota = sum([amp for amp in ot_amps if amp>0])
    tot_ota = 0
  else:
    tot_ota = sum(ot_amps)
  t_amps = n_vl + tot_ota
  if ot_typ == "seq":
    ot_fs = [i for i in range(2, ot_nb + 2)]
  elif ot_typ == "even":
    ot_fs = [i*2 for i in range(1, ot_nb + 1)]
  elif ot_typ == "odd":
    ot_fs = [1 + (2 * i) for i in range(1, ot_nb + 1)]
  ots = []
  # need the fundatmental notes wave form
  ots.append(get_sine_wave(n_f, n_tm, 1))
  for i, ota in enumerate(ot_amps):
    s_wv = get_sine_wave(n_f * ot_fs[i], n_tm, ota)
    ots.append(s_wv)
  # add fundamental note and overtones together
  snd = functools.reduce(np.add, ots)
  # normalize to 16-bit range and convert to 16-bit data
  snd *= 32767 / np.max(np.abs(snd))
  # correct volume
  snd = snd / (1 + tot_ota) * n_vl
  snd = snd.astype(np.int16)
  return snd

Test to Produce Note with Harmonic Overtones

Okay, a bit of test code. Initially it just played each series in sequence. Then I added code to plot the notes/sounds in each series so that I could have a look at their shape (spectrum?).

The sounds played weren’t particularly enlightening. But I did think the plots were interesting. So, only the latter will be included in this post. Though the loop has to generate the notes/sounds regardless of my decision.

Here’s my test code, clunky as it is.

    if do_1:
      t_nt = "A3"
      h_typs = ["fundamental", "half", "saw", "sqr", "tri"]
      t_typs = ["seq", "even", "odd"]
      n_frq = get_note_freq(t_nt)
      n_tm = 0.75
      n_vl = .75
      
      for t_typ in t_typs:
        a_nts = [nosnd]
        ot_1 = get_sine_wave(n_frq, n_tm, n_vl)
        ot_1 = normalize_wave(ot_1, do_typ=True)
        a_nts.append(ot_1)
        a_nts.append(nosnd)
        for h_t in h_typs[1:]:
          print(f"\nseries: {t_typ}")
          a_nts.append(add_overtones(n_frq, n_tm, n_vl, s_rt=sample_rate,
              ot_typ=t_typ, h_typ=h_t, ot_nb=10))
          a_nts.append(nosnd)
        snd = np.hstack(a_nts)
        sd.play(snd)
        sd.wait()

        if True:
          # no sound after this
          snd = nosnd
          # let's plot the notes with the overtones added
          fig, axs = plt.subplots(5, figsize=(10, 8), sharex=True)
          plt.suptitle(f"{t_nt} with Harmonics ({t_typ})")
          fig.tight_layout()

          p_f_pth = f"img\\harmonics_{t_nt}_{t_typ}_3.png"
          # plot each note fundamental and w/overtones
          t = np.linspace(0, n_tm, int(n_tm * sample_rate), False)
          lt = len(t)
          f_nts = [nt for nt in a_nts if len(nt)==lt]
          for i, ot_n in enumerate(f_nts):
            # use a decent harmonic name
            s_ht = h_typs[i]
            if s_ht == "sqr":
              s_ht = "square"
            elif s_ht == "tri":
              s_ht = "triangle"
            # only plot a couple of cycles (not the whole note)
            xot = axs[i].plot(t[:500], ot_n[:500], label=f"{s_ht}")
            if i == (len(f_nts) - 1):
              axs[i].set(xlabel="Time", ylabel="Amplitude")
            else:
              axs[i].set(ylabel="Amplitude")
              axs[i].legend()
          fig.savefig(p_f_pth)
          plt.show()

And, here’s those plots.

plot of rate of change at/for 16:00 plot of rate of change at/for 16:00 plot of rate of change at/for 16:00

Play White Keys with Add Harmonic Overtones

Okay, coded it. Don’t know that my ears can tell much difference between the various overtone combinations I tried. Haven’t yet decided whether to add any samples to this post. Time will tell. And, if not, perhaps you can give it a try yourself.

Chord and Chord Progressions

Let’s try the above on chords rather than single notes. I will start by looking at one chord. Then go on to generate a chord progression with each chord having harmonic overtones added to its individual notes.

Expect we might need to refactor a function or two. And likely add a function or two to make life a touch nicer. I am going to start by thinking a little bit about what I want to do and how to get there in a reasonable way.

A Few Thoughts

One of the biggest problems I seem to have is converting the type of sound’s numpy array to np.int16. Once I do that, it is impossible to correctly add sound waves together or to modify the sound’s volume. It has definitely given me some moments of confusion and extended debugging of logical errors. I still don’t see an obvious solution. But at the moment the add_overtones() function returns a type int16 array. That will prevent me from adding the notes of the chord together to generate the single chord sound. So, do I refactor to not change the array’s type or do I add a parameter to control that behaviour?

I was going to call the add_overtones() function for each note in the chord. But, that means each note will end up with a slightly different set of harmonic overtones. Good or bad? And, when I go to a chord progression, the repeated chords will also have differing harmonics. Wouldn’t it make more sense for each of them to have the same harmonic overtones?

I wrote a function, play_note_seq(), to which I pass a list of fundamental frequencies. Along with other information to control the output. E.G. duration and volume for each note, the various harmonic parameters. It generates the final array with each of the notes, plus added harmonics, which produces the appropriate sound sequence. I am thinking perhaps something similar for chord progressions might be in order.

Not much deep thinking at this point in the day. But, that does give me a few things to sort out. Refactored functions and/or new functions. And, I may add more to this section as I go along on my usual tortuitous coding process.

Done, M’thinks

I am beginning to think, that for me, adding the chord coding to this post will make it a little longer than I would like. So, I am going to call this one finished; and, save generating and playing chords with added overtones for the next post. That will give me some more time to think about how to accomplish what I hope to hear in a reasonable fashion.

Until then, do take time to play with coding or anything else you might enjoy better.

Resources