Source code for djalgo.harmony

import random
import itertools
from . import utils
from . import harmony

[docs] class MusicTheoryConstants(): """ The Base class defines a set of musical scales, intervals, and notes. - scale_to_triad method returns the intervals for a triad based on the given scale intervals. - note_to_triad method converts a note to a triad based on the given scale intervals. """ chromatic_scale = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'] scale_intervals = { 'major': [0, 2, 4, 5, 7, 9, 11], # Ionian 'minor': [0, 2, 3, 5, 7, 8, 10], # Aeolian 'diminished': [0, 2, 3, 5, 6, 8, 9, 11], 'major pentatonic': [0, 2, 4, 7, 9], 'minor pentatonic': [0, 3, 5, 7, 10], 'chromatic': [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11], 'lydian': [0, 2, 4, 6, 7, 9, 11], 'mixolydian': [0, 2, 4, 5, 7, 9, 10], 'dorian': [0, 2, 3, 5, 7, 9, 10], 'phrygian': [0, 1, 3, 5, 7, 8, 10], 'locrian': [0, 1, 3, 5, 6, 8, 10], 'harmonic minor': [0, 2, 3, 5, 7, 8, 11], 'melodic minor ascending': [0, 2, 3, 5, 7, 9, 11], 'melodic minor descending': [0, 2, 3, 5, 7, 8, 10], # same as natural minor } intervals = {'P1': 0, 'm2': 1, 'M2': 2, 'm3': 3, 'M3': 4, 'P4': 5, 'P5': 7, 'm6': 8, 'M6': 9, 'm7': 10, 'M7': 11, 'P8': 12}
[docs] @staticmethod def convert_flat_to_sharp(note): # Mapping of flat notes to their equivalent sharp notes flat_to_sharp = { 'Bb': 'A#', 'Db': 'C#', 'Eb': 'D#', 'Gb': 'F#', 'Ab': 'G#', 'B-': 'A#', 'D-': 'C#', 'E-': 'D#', 'G-': 'F#', 'A-': 'G#' } return flat_to_sharp.get(note, note)
[docs] @staticmethod def scale_to_triad(scale): """Returns the intervals for a triad based on the given scale intervals.""" return [scale[i] for i in [0, 2, 4]] # root, third, fifth
[docs] class Scale(MusicTheoryConstants): """ Represents a musical scale. Args: tonic (str): The tonic note of the scale. mode (str or list): The type of scale. Defaults to 'major'. If a list is provided, it represents a custom scale. Raises: ValueError: If the tonic note is not a valid note or if the scale type is not a valid scale. Attributes: tonic (str): The tonic note of the scale. mode (str or list): The type of scale. """ def __init__(self, tonic, mode='major'): if tonic not in self.chromatic_scale: tonic = self.convert_flat_to_sharp(tonic) if tonic not in self.chromatic_scale: raise ValueError(f"'{tonic}' is not a valid tonic note. Select one among '{self.chromatic_scale}'.") self.tonic = tonic if isinstance(mode, list): self.scale_intervals['custom'] = mode mode = 'custom' elif mode not in self.scale_intervals.keys(): raise ValueError(f"'{mode}' is not a valid scale. Select one among '{self.scale_intervals.keys()}' or a list of half steps such as [0, 2, 4, 5, 7, 9, 11] for a major scale.") self.mode = mode
[docs] def generate(self): """ Generates the full range of the scale. Returns: list: A list of MIDI note numbers representing the full range of the scale. """ tonic_note = self.chromatic_scale.index(self.tonic) scale = self.scale_intervals.get(self.mode, self.scale_intervals['major']) full_range_scale = [] added_notes = set() # Keep track of added notes for octave in range(11): for interval in scale: note = (tonic_note + interval) % 12 + octave * 12 if note <= 127 and note not in added_notes: full_range_scale.append(note) added_notes.add(note) full_range_scale.sort() return full_range_scale
[docs] class Progression(MusicTheoryConstants): """A class representing a musical progression generator based on the circle of fifths (or any other interval).""" def __init__(self, tonic_pitch='C4', circle_of='P5', type='chords', radius=[3, 3, 1], weights=None): """ Initialize a Progression object. Args: tonic_pitch (str): The tonic pitch of the progression. Defaults to 'C4'. circle_of (str): The interval to form the circle of fifths. Defaults to 'P5'. type (str): The type of progression to generate. Can be 'chords' or 'pitches'. Defaults to 'chords'. radius (list): A list defining the range for major, minor, and diminished chords. Defaults to [3, 3, 1]. weights (list): The weights for selecting chord types. If not provided, the radius values will be used as weights. Raises: ValueError: If the circle_of value is not among the available intervals. ValueError: If the type value is not 'chords' or 'pitches'. """ self.tonic_midi = utils.cde_to_midi(tonic_pitch) self.circle_of = circle_of self.type = type self.radius = radius self.weights = weights if weights else radius if self.circle_of not in self.intervals.keys(): raise ValueError(f"Select a circle_of among {self.intervals.keys()}.") if self.type not in ['chords', 'pitches']: raise ValueError("Type must either be 'pitches' or 'chords'.")
[docs] def compute_circle(self): """ Compute chords based on the circle of fifths, thirds, etc., within the specified radius. Returns: tuple: A tuple containing lists of root MIDI notes for major, minor, and diminished chords. """ n_semitones = self.intervals[self.circle_of] circle_notes = [self.tonic_midi] for _ in range(max(self.radius)): next_note = (circle_notes[-1] + n_semitones) % 12 + (circle_notes[-1] // 12) * 12 circle_notes.append(next_note) major_roots = circle_notes[:self.radius[0]] minor_roots = circle_notes[:self.radius[1]] diminished_roots = circle_notes[:self.radius[2]] return major_roots, minor_roots, diminished_roots
[docs] def generate_chord(self, root_note_midi, chord_type): """ Generate a chord based on root MIDI note and chord type. Args: root_note_midi (int): The root MIDI note of the chord. chord_type (str): The type of chord to generate. Can be 'major', 'minor', or 'diminished'. Returns: list: A list of MIDI notes representing the generated chord. """ chord_intervals = { 'major': [0, 4, 7], 'minor': [0, 3, 7], 'diminished': [0, 3, 6] } intervals = chord_intervals.get(chord_type, [0, 4, 7]) chord_notes = [(root_note_midi + interval) for interval in intervals] chord_notes = [note if note <= 127 else note - 12 for note in chord_notes] return chord_notes
[docs] def generate(self, length=4, seed=None): """ Generate a musical progression. Args: length (int): The length of the progression in number of chords. Defaults to 4. seed (int): The seed value for the random number generator. Defaults to None. Returns: list: A list of lists, where each inner list represents a chord in the progression. """ if seed is not None: random.seed(seed) major_roots, minor_roots, diminished_roots = self.compute_circle() chord_roots = [major_roots, minor_roots, diminished_roots] progression = [] for _ in range(length): chord_type_index = random.choices(range(3), weights=self.weights, k=1)[0] if chord_roots[chord_type_index]: root_note_midi = random.choice(chord_roots[chord_type_index]) chord_type = ['major', 'minor', 'diminished'][chord_type_index] if isinstance(root_note_midi, list): print('Warning: root_note_midi was a list, taking first element.') root_note_midi = root_note_midi[0] chosen_chord = self.generate_chord(root_note_midi, chord_type) progression.append(chosen_chord) return progression
[docs] class Voice(MusicTheoryConstants): """ A class to represent a musical voice. """ def __init__(self, mode='major', tonic='C', degrees=[0, 2, 4]): """ Constructs all the necessary attributes for the voice object. Parameters ---------- mode : str, optional The type of the scale (default is 'major'). tonic : str, optional The tonic note of the scale (default is 'C'). degrees : list, optional Relative degrees for chord formation (default is [0, 2, 4]). """ self.tonic = tonic self.scale = harmony.Scale(tonic, mode).generate() # a list of MIDI notes for the scale self.degrees = degrees # relative degrees for chord formation
[docs] def pitch_to_chord(self, pitch): """ Convert a MIDI note to a chord based on the scale using the specified degrees. Parameters ---------- pitch : int The MIDI note to convert. Returns ------- list A list of MIDI notes representing the chord. """ # to get the degree, I need a the tonic in the right octave, i.e. the tonic midi pitch octave = utils.get_octave(pitch) tonic_cde_pitch = self.tonic + str(octave) tonic_midi_pitch = utils.cde_to_midi(tonic_cde_pitch) # the degrees of the whole scale scale_degrees = [utils.get_degree_from_pitch(p, scale_list=self.scale, tonic_pitch=tonic_midi_pitch) for p in self.scale] pitch_degree = utils.get_degree_from_pitch(pitch, scale_list=self.scale, tonic_pitch=tonic_midi_pitch) pitch_degree = int(round(pitch_degree)) # round the degree if the pitch is out of scale chord = [] for degree in self.degrees: absolute_degree = pitch_degree + degree absolute_index = scale_degrees.index(absolute_degree) chord.append(self.scale[absolute_index]) return chord # Chord is now directly from the scale
[docs] def generate(self, notes, durations=None, arpeggios=False): """ Generate chords or arpeggios based on the given notes. Args: notes (list or tuple): The notes to generate chords or arpeggios from. durations (list, optional): The durations of each note. If not provided, defaults to [1]. arpeggios (bool, optional): If True, generate arpeggios instead of chords. Defaults to False. Returns: list: The generated chords or arpeggios. """ if isinstance(notes, tuple): notes = [notes] if isinstance(notes[0], int): # if notes are in fact pitches if durations is None: durations = [1] durations_cycle = itertools.cycle(durations) current_offset = 0 for i,p in enumerate(notes): d = next(durations_cycle) notes[i] = (p, d, current_offset) current_offset = current_offset + d chords = [(self.pitch_to_chord(p), d, o) for p, d, o in notes] if not arpeggios: return chords else: arpeggios_p = [] for n in chords: pitches = n[0] for p in pitches: arpeggios_p.append(p) arpeggios_n = [] durations_cycle = itertools.cycle(durations) # reset cycle current_offset = 0 for p in arpeggios_p: d = next(durations_cycle) arpeggios_n.append((p, d, current_offset)) current_offset = current_offset + d return arpeggios_n
[docs] class Ornament(MusicTheoryConstants): def __init__( self, type='grace_note', tonic=None, mode=None, by=1.0, grace_note_type='acciaccatura', grace_pitches=None, trill_rate=0.125, arpeggio_degrees=None, slide_length=4.0 ): """ Initializes an Ornament object. Args: type (str): The type of ornament to be processed. Supported types include 'grace_note', 'trill', and 'mordent'. tonic (str): The tonic note for the scale. mode (str): The type of scale to generate. by (float): The pitch step for the trill. grace_note_type (str): Specifies the type of grace note ('acciaccatura' or 'appoggiatura') if applicable. grace_pitches (list): The list of pitches for the grace note. trill_rate (float): The duration of each individual note in the trill. arpeggio_degrees (list of integers): degrees in the scale to run the arpeggio slide_length (float): length of the slide """ self.type = type if tonic and mode: self.tonic_index = self.chromatic_scale.index(tonic) # Index in chromatic scale self.scale = self.generate_scale(tonic, mode) # This will be a list of MIDI notes for the scale if arpeggio_degrees: self.arpeggio_voice = Voice(mode=mode, tonic=tonic, degrees=arpeggio_degrees) else: self.arpeggio_voice = None else: self.scale = None self.arpeggio_voice = None self.by = by self.grace_note_type = grace_note_type self.grace_pitches = grace_pitches self.trill_rate = trill_rate self.slide_length = slide_length
[docs] def generate_scale(self, tonic, mode): """ Generate a complete scale based on the tonic and scale type. This function is the same as the one in the Voice class. Args: tonic (str): The tonic note for the scale. mode (str): The type of scale to generate. Returns: list: A list of MIDI notes for the complete scale. """ scale_pattern = self.scale_intervals[mode] scale_notes = [(self.tonic_index + interval) % 12 for interval in scale_pattern] # Pitch classes complete_scale = [] # This will store the full scale in MIDI numbers for octave in range(-1, 10): # Covering all MIDI octaves for note in scale_notes: midi_note = 12 * octave + note if 0 <= midi_note <= 127: # Valid MIDI range complete_scale.append(midi_note) return complete_scale
[docs] def add_grace_note(self, notes, note_index): """ Adds a grace note (either acciaccatura or appoggiatura) to a specified note in the list. Args: notes (list): The list of notes to be processed. note_index (int): The index of the note to which the trill will be added. Returns: list: The list of notes with the specified grace note added. """ main_pitch, main_duration, main_offset = notes[note_index] ornament_pitch = random.choice(self.grace_pitches) if self.grace_note_type == 'acciaccatura': # Acciaccatura is very brief, does not alter the main note's start time. grace_duration = main_duration * 0.125 # Typically very short modified_main = (main_pitch, main_duration, main_offset + grace_duration) new_notes = notes[:note_index] + [(ornament_pitch, grace_duration, main_offset), modified_main] + notes[note_index + 1:] elif self.grace_note_type == 'appoggiatura': # Appoggiatura takes half the time of the main note and delays its start. grace_duration = main_duration / 2 modified_main = (main_pitch, grace_duration, main_offset + grace_duration) new_notes = notes[:note_index] + [(ornament_pitch, grace_duration, main_offset), modified_main] + notes[note_index + 1:] else: # If neither, return the list unchanged new_notes = notes return new_notes
[docs] def add_trill(self, notes, note_index): """ Simulates a trill ornament by alternating between the original pitch and one step above. Args: notes (list): The list of notes to be processed. note_index (int): The index of the note to which the trill will be added. Returns: list: The list of notes with the specified trill applied to the specified note. """ main_pitch, main_duration, main_offset = notes[note_index] trill_notes = [] current_offset = main_offset # Determine the pitch to trill with based on the scale or semitone adjustment if self.scale and main_pitch in self.scale: pitch_index = self.scale.index(main_pitch) trill_pitch = self.scale[(pitch_index + int(round(self.by))) % len(self.scale)] # Ensure the index wraps around the scale else: trill_pitch = main_pitch + self.by # Default step if no scale is given or pitch is not in scale # Generate the sequence of trill notes to insert while current_offset < main_offset + main_duration: trill_notes.append((main_pitch, self.trill_rate, current_offset)) trill_notes.append((trill_pitch, self.trill_rate, current_offset + self.trill_rate)) current_offset += 2 * self.trill_rate # Insert the trill notes into the original list, replacing the original note at note_index new_notes = notes[:note_index] + trill_notes + notes[note_index + 1:] return new_notes
[docs] def add_mordent(self, notes, note_index): """ Simulates a mordent ornament by rapidly alternating between the original pitch and one step defined in `self.by`. Args: notes (list): The list of notes to be processed. note_index (int): The index of the note to which the trill will be added. Returns: list: A list containing the notes that make up the mordent. """ main_pitch, main_duration, main_offset = notes[note_index] if self.scale and main_pitch in self.scale: pitch_index = self.scale.index(main_pitch) mordent_pitch = self.scale[pitch_index + int(round(self.by))] else: # If no scale is provided, default to moving a semitone down for a lower mordent # or a semitone up for an upper mordent mordent_pitch = main_pitch + self.by # The mordent splits the duration into three parts part_duration = main_duration / 3 mordent_notes = [ (main_pitch, part_duration, main_offset), (mordent_pitch, part_duration, main_offset + part_duration), (main_pitch, part_duration, main_offset + 2 * part_duration) ] new_notes = notes[:note_index] + mordent_notes + notes[note_index + 1:] return new_notes
[docs] def add_arpeggiation(self, notes, note_index, voice): """ Applies arpeggiation to a chord at a specified index in the list of notes using the degrees from a Voice instance. Args: notes (list): The list of notes to be processed. note_index (int): The index of the note to which the arpeggiation will be added. voice (Voice): An instance of the Voice class. Returns: list: The list of notes with the specified arpeggiation applied to the specified chord. """ root_note = notes[note_index][0] main_duration, main_offset = notes[note_index][1], notes[note_index][2] # Generate the arpeggio notes based on the Voice's degrees arpeggio_notes = [] for degree in voice.degrees: # Calculate pitch from the scale degree scale_degree_index = (voice.scale.index(root_note) + degree) % len(voice.scale) arpeggio_pitch = voice.scale[scale_degree_index] # Add an arpeggio note for each degree note_duration = main_duration / len(voice.degrees) note_offset = main_offset + voice.degrees.index(degree) * note_duration arpeggio_notes.append((arpeggio_pitch, note_duration, note_offset)) # Replace the original note with the arpeggio notes new_notes = notes[:note_index] + arpeggio_notes + notes[note_index + 1:] return new_notes
[docs] def add_turn(self, notes, note_index): """ Simulates a turn ornament by playing the note above, the note itself, the note below, and returning to the note. Args: notes (list): The list of notes to be processed. note_index (int): The index of the note to which the turn will be added. Returns: list: The list of notes with the specified turn applied to the specified note. """ main_pitch, main_duration, main_offset = notes[note_index] part_duration = main_duration / 4 # Splitting the total duration among the four notes of the turn if self.scale and main_pitch in self.scale: pitch_index = self.scale.index(main_pitch) upper_pitch = self.scale[(pitch_index + 1) % len(self.scale)] lower_pitch = self.scale[(pitch_index - 1 + len(self.scale)) % len(self.scale)] else: upper_pitch = main_pitch + self.by # Assuming 'by' is the interval step lower_pitch = main_pitch - self.by turn_notes = [ (upper_pitch, part_duration, main_offset), (main_pitch, part_duration, main_offset + part_duration), (lower_pitch, part_duration, main_offset + 2 * part_duration), (main_pitch, part_duration, main_offset + 3 * part_duration) ] new_notes = notes[:note_index] + turn_notes + notes[note_index + 1:] return new_notes
[docs] def add_slide(self, notes, note_index, slide_length=4): """ Simulates a slide from the current note to the next by incrementally changing the pitch. Args: notes (list): The list of notes to be processed. note_index (int): The index of the note from which the slide will start. slide_length (int): The number of steps in the slide. Returns: list: The list of notes with the specified slide applied. """ if note_index < len(notes) - 1: # Ensure there is a following note to slide into start_pitch, _, start_offset = notes[note_index] end_pitch, end_duration, end_offset = notes[note_index + 1] # Calculate pitch steps for the slide, assuming a linear slide for simplicity pitch_step = (end_pitch - start_pitch) / slide_length slide_duration = (end_offset - start_offset) / slide_length slide_notes = [(int(start_pitch + pitch_step * i), slide_duration, start_offset + slide_duration * i) for i in range(slide_length)] # Insert the slide notes, remove the original note and the next one since they are now part of the slide new_notes = notes[:note_index] + slide_notes + notes[note_index + 2:] else: # No following note to slide into, so return the original notes unchanged new_notes = notes return new_notes
[docs] def generate(self, notes, note_index=None): """ Applies the specified ornamentation action and type to the list of notes. Args: notes (list): The list of notes to be processed. note_index (int): The index of the note to ornament. If None, a note will be chosen randomly. Returns: list: The list of notes with the specified ornamentation applied. """ if note_index is None: note_index = random.randint(0, len(notes) - 1) if self.type == 'grace_note': return self.add_grace_note(notes, note_index) elif self.type == 'trill': return self.add_trill(notes, note_index) elif self.type == 'mordent': return self.add_mordent(notes, note_index) elif self.type == 'arpeggio': return self.add_arpeggiation(notes, note_index, self.arpeggio_voice) elif self.type == 'turn': return self.add_turn(notes, note_index) elif self.type == 'slide': return self.add_slide(notes, note_index, self.slide_length) else: return notes # Return original if ornament type is not recognized or required data is missing