Okay, have been investigating libraries I can use to play sounds from within a Python module. A more complicated process than I expected.

Sound Libraries

I ended up looking at the following:

I decided against pygame, for now, because I didn’t really need all it’s primary functionality. So, focused on the other packages.

I may down the road check out Music21.

simpleaudio

I am going to start with simpleaudio. The basic tutorial looked simple enough. I used uv to add the package to the environment. In order to save the note’s sound to file for inclusion in this post I used Python’s wave module. I had to save before playing the note from the terminal because for some reason the simpleaudio player was not returning to the module on closing. Any code after that does not get executed. I had to use wait_done() to have the note play for the full time specified in the code. Otherwise I just got a quick beep.

  if do_t3:
    # play some notes with simpleaudio (planning ahead)
    do_1note = True
    do_3note = False
    do_chord = False
    do_cprog = False

    n_syms = ("C" ,"C#","D" ,"D#","E" ,"F" ,"F#","G" ,"G#", "A", "A#", "B")
    oct_4 = {nt: get_note_freq(nt) for nt in n_syms}

    sample_rate = 44100
    n_t = 0.5   # note duration
    t = np.linspace(0, n_t, int(n_t * sample_rate), False)

    if do_1note:
      n_frq = oct_4["C"]
      w_fl_nm = "c4_half_sec.wav"
      snd = np.sin(n_frq * t * 2 * np.pi)
      # normalize to 16-bit range
      snd *= 32767 / np.max(np.abs(snd))
      # convert to 16-bit data
      snd = snd.astype(np.int16)

    # Open a WAV file
    with wave.open(f'img/w_fl_nm', 'w') as wav_file:
      # 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())

    # start playback
    play_obj = sa.play_buffer(snd, 1, 2, sample_rate)

    # wait for playback to finish before exiting
    play_obj.wait_done()    

And, the code generated this bit of noise.

sounddevice

Okay let’s do the same thing using sounddevice. Once again, package add via uv.

... ...
import sounddevice as sd
... ...
def main():
  do_t1 = False   # test function to get any notes frequency
  do_t2 = False   # play and save to file C4 for half second
  do_t3 = False   # simpleaudio
  do_t4 = True    # sounddevice
... ...
  if do_t4:
    # play some notes with sounddevice
    do_1note = True
    do_wnote = False
    do_chord = False
    do_cprog = False

    n_syms = ("C" ,"C#","D" ,"D#","E" ,"F" ,"F#","G" ,"G#", "A", "A#", "B")
    oct_4 = {nt: get_note_freq(nt) for nt in n_syms}

    sample_rate = 44100
    n_t = 0.5
    t = np.linspace(0, n_t, int(n_t * sample_rate), False)
  
    sd.default.samplerate = sample_rate
  
    if do_1note:
      n_frq = oct_4["C"]
      w_fl_nm = "sd_c4_half_sec.wav"
      snd = np.sin(n_frq * t * 2 * np.pi)
      # normalize to 16-bit range
      snd *= 32767 / np.max(np.abs(snd))
      # convert to 16-bit data
      snd = snd.astype(np.int16)
    elif do_wnote:
      # play white keys in octave 4
      ...
    elif do_chord:
      # play a single chord
      ...
    elif do_cprog:
      # play a chord progression, twice?
      # chord progression (Cmaj7-Cmaj7-Fmaj7-Gdom7)
      ...

    sd.play(snd)
    sd.wait()

    # Open a WAV file
    with wave.open(f'img/{w_fl_nm}', 'w') as wav_file:
      # 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())

To my ears those two terminal outputs sounded the same.

And, for now, I think I will go with sounddevice. So, let’s play around a little more.

Playing a Few Notes (sequentially or together)

In the above heading, together means chords (at least that’s my current thought).

Make Sine Wave Function

When I reached this point and knowing a little more about where I was heading, I realized I should really have a separate function to generate the sine wave for a given note/frequency, duration, sampling rate, etc. So, that is what I decided to do. Simple enough it is.

I am not going to normalize the resulting Numpy array to a 16-bit range. Since I know I will need to add multiple waves together for various reasons later in this experiment. So, I may eventually create a function to do that simple procedure as well—as it may be used multiple times for any given music attempt. Well, you know, just going to do that now. But I have to account for situations where I don’t want the waveform converted to np.int16 values.

I also decided to pass a note frequency rather than a note symbol. Figured in some future cases that might reduce the number of function calls needed to accomplish a task.

While we’re at it, let’s write a helper function to apply a new amplitude to a given sound wave. Must be done before array is converted to np.int16 data type.

def get_sine_wave(n_fq: float, n_tm: float, n_vol:float=1, s_rate:int=44100) -> np.typing.NDArray[np.number[int | float]]:
  """ Generate numpy array containing the fundamental wave form for the specified note,
      amplitude (volume), and duration.
    The wave values are not normalized to the 16-bit range, nor converted to int16.
      This is so they can be used to generate additive sounds if desired.

    :param: nt: frequency for note/overtone, float > 0
    :param: n_tm: note duration in seconds, float > 0
    :param: n_vol: note volume, float > 0
    :param: s_rate: sampling rate, integer > 0

    :return: np array containing the fundamental wave form for the note for the specified duration
  """
  t = np.linspace(0, n_tm, int(n_tm * s_rate), False)
  t_snd = n_vol * np.sin(n_fq * t * 2 * np.pi)
  return t_snd


def normalize_wave(np_wv: np.typing.NDArray[np.number[int | float]], do_typ:bool=False) -> np.typing.NDArray[np.number[int | float]]:
  """ Normalize presented 1D array values to 16 bit range
      Maybe convert values to np.init16

    :param: np_wv: numpy 1D array contaning values of a sine wave, float
    :param: do_typ: if true convert to np.typ16, otherwise do not

    :return: numpy 1D array of normalized sine values if do_typ false
             numpy 1D array of normalized sine values converted to int16 if do_typ true
  """
  # normalize to 16-bit range
  snd *= 32767 / np.max(np.abs(np_wv))
  if do_typ:
    # convert to 16-bit data
    snd = snd.astype(np.int16)
  return snd


def apply_amplitude(np_wv:np.typing.NDArray[np.number[int | float]], n_amp:float, do_typ:bool=True) -> np.typing.NDArray[np.number[int | float] | np.int16]:
  """ Apply the specified amplitude to the given sine wave,
        convert to np.int16 if requested to do so

    :param: np_wv: the sine wave to have its amplitude modified, numpy array, any number type
    :param: n_amp: the new amplitude to apply against the sine wave
    :param: do_typ: if true convert to np.int16, otherwise do not modify array dtype

    :return: suitably modified numpy array
  """
  n_snd = n_amp * np_wv
  if do_typ:
    n_snd = n_snd.astype(np.int16)
  return n_snd

Seems like a lot of code and documentation for such a simple procedure. But it will, hopefully, overtime prove to make things tidier.

Some Sequential Notes

Ok, let’s play all the white keys in octave 4. It will hopefully sound familiar to you. With perhaps the exception of the changing volume. I couldn’t resist giving that a try.

As usual a lot of code duplication between blocks. Possibly when push comes to shove, I will correct that situation. For now I am mostly just playing around and testing ideas and code.

... ...
def main():
  rng = np.random.default_rng()

  do_t1 = False   # test function to get any notes frequency
  do_t2 = False   # play and save to file C4 for half second
  do_t3 = False   # simpleaudio
  do_t4 = True    # sounddevice
... ...
  if do_t4:
    # play some notes with sounddevice
    do_1note = False
    do_wnote = True
    do_chord = False
    do_cprog = False
... ...
    nosnd = np.zeros(int(0.05 * sample_rate))
    nosnd = nosnd.astype(np.int16)
... ...
    if do_1note:
... ...
    elif do_wnote:
      # add changing volume for each note
      w_fl_nm = "sd_wh_keys_chg_vol.wav"

      notes = [nosnd]
      vols = []
      for nt in oct_4:
        if nt[-1] != "#":
          t_snd = get_sine_wave(oct_4[nt], n_t, s_rate=sample_rate)
          t_snd = normalize_wave(t_snd, do_typ=False)
          # random volume for each note
          vol = rng.uniform(low=0.33, high=0.8)
          vols.append(vol)
          t_snd = apply_amplitude(t_snd, vol, do_typ=True)
          notes.append(t_snd)
          notes.append(nosnd)
      # add a higher C, C%
      t_frq = get_note_freq("C5")
      t_snd = get_sine_wave(t_frq, n_t, s_rate=sample_rate)
      t_snd = normalize_wave(t_snd, do_typ=False)
      # random volume for each note
      vol = rng.uniform(low=0.33, high=0.8)
      vols.append(vol)
      t_snd = apply_amplitude(t_snd, vol, do_typ=True)
      notes.append(t_snd)
      notes.append(nosnd)
      print(len(notes), len(notes[0]), len(notes[1]), len(notes[-2]))
      print(vols)
      snd = np.hstack(notes)
    elif do_chord:
      # play a single chord
      ...
    elif do_cprog:
      # play a chord progression, twice?
      # chord progression (Cmaj7-Cmaj7-Fmaj7-Gdom7)
      ...

    # didn't always want to save to file while testing/playing around
    if True:
      # Open a WAV file and save current audio stream
      with wave.open(f'img/{w_fl_nm}', 'w') as wav_file:
        # 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()

In the terminal, I got the following list of selected volumes. (Note: I reduced the number of decimals for inclusion here.)

(base) PS R:\learn\e_music> uv run main.py
[0.6169, 0.5478, 0.4358, 0.4922, 0.7545, 0.4598, 0.5813, 0.4074]

And, the saved audio. Recognize it?

A Single Chord

When I started working on this I realized that for the next bit of playing around, I would really need a function to generate chord audio arrays/waves. So, that’s what I did. But then I decided to play both C Major and C Major 7th in the same audio clip. Though, I guess it is effectively still only one chord.

I used the reduce() function (from functools module) with np.add to add the individual notes together into a single array/wave. That’s how we get the sound wave for the chord.

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

    :param: c_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

    :return: np array containing the wave form for the chord, int16 values
  """
  notes = []
  t = np.linspace(0, n_tm, int(n_tm * s_rate), False)
  # generate wave array for each note in chord
  for nt in c_nts:
      n_f = get_note_freq(nt)
      t_snd = get_sine_wave(n_f, n_tm, n_vol)
      notes.append(t_snd)
  # for chord add all the waves/arrays together
  snd = functools.reduce(np.add, notes)
  # normalize to 16-bit range and convert to 16-bit data
  snd = normalize_wave(snd, do_typ=True)
  return snd
... ...
    elif do_chord:
      chrds = [["C", "E", "G"], ["C", "E", "G", "B"]]
      # play for a bit longer
      n_t = 0.75

      w_fl_nm = "sd_c_major_twice.wav"
      notes = [nosnd]
      chrd = get_chord_array(chrds[0], n_t)
      notes.append(chrd)
      notes.append(nosnd)
      chrd = get_chord_array(chrds[1], n_t)
      notes.append(chrd)
      notes.append(nosnd)
      snd = np.hstack(notes)
... ...
  # save file if approp and play audio track

And, the saved audio follows. I do believe I can hear a difference. The extra note, with a higher pitch, seems to show up in the sound of the 7th.

A Chord Progression

Okay let’s give our final bit of playing around for this post a go. Basically going to be combining the concepts/code in the previous two experiments. Would truly have been a horrible bunch of code without that function for generating chord arrays/waves.

The progression I am looking at playing is: C major 7h - C major 7th - F major 7th - G dominant 7th.

I am going to initially code it with chords of the same duration and volume with a slight silence between each one. Then I am going to add a random duration and volume for each chord. For curiosity’s sake I will print the chord durations and volumes to the terminal. And because I could, I went for 4 repeats of the progression.

... ...
    do_cprog = True
... ...
    elif do_cprog:
      # play a chord progression, repeat?
      # chord progression (Cmaj7-Cmaj7-Fmaj7-Gdom7)
      chords = [
        ["C4", "E4", "G4", "B4"],
        ["C4", "E4", "G4", "B4"],
        ["F4", "A4", "C5", "E5"],
        ["G4", "B4", "D5", "F5"]
      ]
      n_t = 0.5
      rpts = 4
      w_fl_nm = "sd_c_major_rpt.wav"
      notes = [nosnd]

      c_tms, c_vols = [], []
      for _ in range(rpts):
        for chrd in chords:
          c_vol = rng.uniform(low=0.25, high=1.0)
          n_t = rng.uniform(low=0.2, high=0.6)
          c_vols.append(round(c_vol, 3))
          c_tms.append(round(n_t, 2))
          snd = get_chord_array(chrd, n_t, n_vol=c_vol)
          notes.append(snd)
          notes.append(nosnd)
        # notes.extend(notes[1:])
        snd = np.hstack(notes)
    print(len(notes), len(notes[0]), len(notes[1]), len(notes[-2]))
    print(f"c_tms: {c_tms}\nc_vols: {c_vols}")

In the terminal I got the following.

(base) PS R:\learn\e_music> uv run main.py
33 4410 24521 10437
c_tms: [0.56, 0.28, 0.21, 0.22, 0.38, 0.21, 0.34, 0.41, 0.53, 0.52, 0.33, 0.41, 0.4, 0.32, 0.56, 0.24]
c_vols: [0.65, 0.625, 0.504, 0.98, 0.536, 0.854, 0.861, 0.571, 0.985, 0.965, 0.585, 0.696, 0.261, 0.458, 0.382, 0.643]

And, the saved audio.

Well, hardly music, but some concepts explored and coded. I call that progress.

Fini

Calling this post done. Do believe it is more than long enough for its purpose. Have learned a bit about generating simple, not particularly melodic, sounds. Next time I am going to see if I can improve on that a little.

Until then, may your code produce melodies not just sounds.

Resources