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.