def __init__(self, length: int = None, mode: ModeOption = None, octave: int = 4, orientation: Orientation = Orientation.ABOVE):
     self._orientation = orientation
     self._mode = mode or ModeOption.DORIAN
     self._length = length or 8 + math.floor(random() * 5) #todo: replace with normal distribution
     self._octave = octave
     self._mr = ModeResolver(self._mode)
     gcf = GenerateCantusFirmus(self._length, self._mode, self._octave)
     cf = None 
     #if the Cantus Firmus doesn't generate, we have to try again
     #also, for anything above the first species, the Cantus Firmus must end stepwise
     while cf is None or abs(cf.get_note(self._length - 2).get_scale_degree_interval(cf.get_note(self._length - 1))) > 2:
         cf = gcf.generate_cf()
     self._cantus_object = cf
     self._cantus = cf.get_notes()
 def __init__(self, mode: ModeOption, hexachord: HexachordOption,
              highest: Note, lowest: Note):
     self._mode = mode
     self._length = randint(3, 6)
     self._hexachord = hexachord
     self._mr = ModeResolver(self._mode)
     self._attempt_params = {
         "first_note": None,
         "second_note": None,
         "second_note_must_appear_by": None,
         "second_note_has_been_placed": None,
         "highest_has_been_placed": None,
         "highest": highest,
         "lowest": lowest
     }
     self._insertion_checks = [
         self._handles_adjacents, self._handles_interval_order,
         self._handles_nearby_augs_and_dims,
         self._handles_nearby_leading_tones,
         self._handles_ascending_minor_sixth,
         self._handles_ascending_quarter_leaps,
         self._handles_descending_quarter_leaps, self._handles_repetition,
         self._handles_highest, self._handles_resolution_of_anticipation,
         self._handles_repeated_two_notes,
         self._handles_quarter_between_two_leaps,
         self._handles_upper_neighbor, self._stops_quarter_leaps,
         self._stops_leaps_outside_of_hexachord
     ]
     self._rhythm_filters = [
         self._handles_consecutive_quarters, self._handles_repeated_note,
         self._handles_rhythm_after_descending_quarter_leap,
         self._handles_repeated_dotted_halfs, self._handles_slow_beginning,
         self._handles_consecutive_whole_notes,
         self._handles_consecutive_syncopation,
         self._prevents_two_note_quarter_runs,
         self._no_quarter_runs_after_whole_notes
     ]
     self._index_checks = [self._both_notes_placed]
     self._change_params = [self._check_for_highest_and_second_note]
     self._final_checks = [
         self._has_only_one_octave,
         # self._starts_with_longer
     ]
     self._params = ["second_note_has_been_placed"]
Example #3
0
 def __init__(self,
              length: int = None,
              mode: ModeOption = None,
              range_option: RangeOption = None):
     self._mode = mode or ModeOption.AEOLIAN
     self._length = length or 8 + math.floor(
         random() * 5)  #todo: replace with normal distribution
     self._range = range_option if range_option is not None else RangeOption.ALTO
     self._mr = ModeResolver(self._mode, range_option=self._range)
     self._insertion_checks = [
         self._handles_adjacents, self._handles_interval_order,
         self._handles_nearby_augs_and_dims,
         self._handles_nearby_leading_tones,
         self._handles_ascending_minor_sixth,
         self._handles_ascending_quarter_leaps,
         self._handles_descending_quarter_leaps, self._handles_repetition,
         self._handles_eigths, self._handles_highest,
         self._handles_resolution_of_anticipation,
         self._handles_repeated_two_notes,
         self._handles_quarter_between_two_leaps,
         self._handles_upper_neighbor
     ]
     self._rhythm_filters = [
         self._handles_consecutive_quarters, self._handles_penultimate_bar,
         self._handles_first_eighth, self._handles_sharp_durations,
         self._handles_whole_note_quota, self._handles_repeated_note,
         self._handles_rhythm_after_descending_quarter_leap,
         self._handles_dotted_whole_after_quarters,
         self._handles_repeated_dotted_halfs, self._handles_runs
     ]
     self._index_checks = [self._highest_and_lowest_placed]
     self._change_params = [
         self._check_for_highest_and_lowest,
         self._check_for_on_beat_whole_note,
         # self._check_if_new_run_is_added,
         self._check_if_eighths_are_added
     ]
     self._final_checks = [
         self._parameters_are_correct, self._has_only_one_octave
     ]
     self._params = [
         "highest_has_been_placed", "lowest_has_been_placed",
         "num_on_beat_whole_notes_placed", "eighths_have_been_placed"
     ]
    def __init__(self,
                 length: int = None,
                 mode: ModeOption = None,
                 octave: int = 4,
                 orientation: Orientation = Orientation.ABOVE):
        self._orientation = orientation
        self._mode = mode or MODES_BY_INDEX[math.floor(random() * 6)]
        self._length = length or 8 + math.floor(
            random() * 5)  #todo: replace with normal distribution
        self._octave = octave
        self._mr = ModeResolver(self._mode)
        gcf = GenerateCantusFirmus(self._length, self._mode, self._octave)
        cf = None
        #if the Cantus Firmus doesn't generate, we have to try again
        #also, if we are below the Cantus Firmus, the Cantus Firmus must end stepwise
        while cf is None or (
                cf.get_note(self._length - 2).get_scale_degree_interval(
                    cf.get_note(self._length - 1)) < -2
                and orientation == Orientation.BELOW):
            cf = gcf.generate_cf()
        self._cantus_object = cf
        self._cantus = cf.get_notes()

        #determined through two randomly generated booleans if we will start on the offbeat
        #or onbeat and whether the penultimate measure will be divided
        #IMPORTANT: define these in the constructor rather than at initialization otherwise we'll get length mismatches among solutions
        self._start_on_beat = True if random() > .5 else False
        self._penult_is_whole = True if random() > .5 else False

        #keep track of which measures are divided
        self._divided_measures = set([
            i for i in range(self._length -
                             2 if self._penult_is_whole else self._length - 1)
        ])

        #keep track of all indices of notes (they will be in the form (measure, beat))
        #assume measures are four beats and beats are quarter notes
        indices = [(0, 0), (0, 2)] if self._start_on_beat else [(0, 2)]
        for i in range(1, self._length):
            indices += [(i, 0),
                        (i, 2)] if i in self._divided_measures else [(i, 0)]
        self._all_indices = indices
 def __init__(self,
              length: int = None,
              mode: ModeOption = None,
              octave: int = 4,
              orientation: Orientation = Orientation.ABOVE):
     self._orientation = orientation
     self._mode = mode or MODES_BY_INDEX[math.floor(random() * 6)]
     self._length = length or 8 + math.floor(
         random() * 5)  #todo: replace with normal distribution
     self._octave = octave
     self._mr = ModeResolver(self._mode)
     gcf = GenerateCantusFirmus(self._length, self._mode, self._octave)
     self._cf = None
     #if the Cantus Firmus doesn't generate, we have to try again
     #also, if we are below the Cantus Firmus, the Cantus Firmus must end stepwise
     while self._cf is None or (
             self._cf.get_note(self._length - 2).get_scale_degree_interval(
                 self._cf.get_note(self._length - 1)) < -2
             and orientation == Orientation.BELOW):
         self._cf = gcf.generate_cf()
    def _initialize_cf(self, range_option: RangeOption):
        self._mr = ModeResolver(self._mode, range_option)
        self._cf = CantusFirmus(self._length, self._mr, self._octave)
        #"final" is equal to the mode's starting scale degree.  All notes in the cantus firmus will be whole notes
        final = Note(self._mode.value["starting"], self._octave, 8) 
        last_note = Note(self._mode.value["starting"], self._octave, 16) 
        while self._mr.get_lowest().get_scale_degree_interval(final) > 8:
            final = self._mr.get_default_note_from_interval(final, -8)
            last_note = self._mr.get_default_note_from_interval(final, -8)
        while self._mr.get_lowest().get_scale_degree_interval(final) < 0:
            final = self._mr.get_default_note_from_interval(final, 8)
            last_note = self._mr.get_default_note_from_interval(final, 8)
        last_note.set_duration(16)
        #add the "final" to the first and last pitches 
        self._cf.insert_note(final, 0)
        self._cf.insert_note(last_note, self._length - 1)
        #find all notes eligible to be highest note
        possible_highest_notes = []
        for interval in VALID_MELODIC_INTERVALS_SCALE_DEGREES:
            if interval > 1:
                possible_highest_notes += self._get_notes_from_interval(final, interval)
        #filter out two particular cases (highest note can't be B natural in Dorian and can't be F in Phrygian)
        def remove_edge_cases(tpl: tuple) -> bool:
            note = tpl[1]
            if self._mode == ModeOption.PHRYGIAN and note.get_scale_degree() == 4: 
                return False
            if self._mode == ModeOption.DORIAN and note.get_scale_degree() == 7 and note.get_accidental() == ScaleOption.NATURAL:
                return False
            return True
        possible_highest_notes = list(filter(remove_edge_cases, possible_highest_notes))
        
        final_to_highest_interval, highest_note = possible_highest_notes[math.floor(random() * len(possible_highest_notes)) ]
        #based on the highest we've chosen, find possible lowest notes
        possible_lowest_notes = []
        for interval in GET_POSSIBLE_INTERVALS_TO_LOWEST[final_to_highest_interval]:
            possible_lowest_notes += self._get_notes_from_interval(final, interval)
        #remove candidates that form tritones or cross relations with highest note (sevenths are permissible)
        def check_range_interval(tpl: tuple) -> bool:
            note = tpl[1]
            if highest_note.get_chromatic_with_octave() - note.get_chromatic_with_octave() in [6, 11, 13]:
                return False 
            return True
        possible_lowest_notes = list(filter(check_range_interval, possible_lowest_notes))
        final_to_lowest_interval, lowest_note = possible_lowest_notes[math.floor(random() * len(possible_lowest_notes))]
        #note that we exclude the highest interval when calculating possible valid notes since highest note can only appear once
        valid_intervals_from_final = list(range(1, final_to_highest_interval)) 
        if final_to_lowest_interval < 0: valid_intervals_from_final += list(range(final_to_lowest_interval, -1)) 
        valid_pitches = []
        for interval in valid_intervals_from_final:
            valid_pitches += self._get_notes_from_interval(final, interval)
        self._valid_pitches = list(map(lambda tpl: tpl[1], valid_pitches))

        #see whether it's possible to end Cantus Firmus from below (either by step or from the "dominant")
        final_to_dom_interval = -4 if self._mode.value["most_common"] == 5 else -5
        can_end_from_dominant = final_to_dom_interval >= final_to_lowest_interval
        can_end_from_step_below = final_to_lowest_interval <= -2

        #set penultimate note as a step above the final as default
        final_to_penult_interval, penult_note = self._get_notes_from_interval(final, 2)[0]
        if random() > .9: #note that in no cases do we need to worry about b-flat vs b-natural here
            if can_end_from_dominant:
                final_to_penult_interval, penult_note = self._get_notes_from_interval(final, final_to_dom_interval)[0]
            elif can_end_from_step_below:
                final_to_penult_interval, penult_note = self._get_notes_from_interval(final, -2)[0]
        if self._mode == ModeOption.PHRYGIAN:
            penult_note = self._mr.get_default_note_from_interval(last_note, -5)


        #insert the penultimate note into the Cantus Firmus
        self._cf.insert_note(penult_note, self._length - 2)

        #initialize remaining indices
        remaining_indices = [i for i in range(1, self._length - 2)]
        remaining_indices.reverse()
        self._remaining_indices = remaining_indices

        #if we haven't already added the highest note, add it:
        if final_to_penult_interval != final_to_highest_interval:
            self._add_highest(highest_note)
        
        #if we haven't already added the lowest note, add it:
        if final_to_lowest_interval != final_to_penult_interval and final_to_lowest_interval != 1:
            self._add_lowest(lowest_note)
 def __init__(self, length: int = None, mode: ModeOption = None, octave: int = 4):
     self._mode = mode or MODES_BY_INDEX[math.floor(random() * 6)]
     self._length = length or 8 + math.floor(random() * 5) #todo: replace with normal distribution
     self._octave = octave
     self._mr = ModeResolver(self._mode)
class GenerateCantusFirmus:
    #for our constructor function, if no default length or mode is given, generate a random one
    def __init__(self, length: int = None, mode: ModeOption = None, octave: int = 4):
        self._mode = mode or MODES_BY_INDEX[math.floor(random() * 6)]
        self._length = length or 8 + math.floor(random() * 5) #todo: replace with normal distribution
        self._octave = octave
        self._mr = ModeResolver(self._mode)
        
    def generate_cf(self, range_option: RangeOption = RangeOption.TENOR) -> CantusFirmus:
        run_count = 1
        self._solutions = []
        self._initialize_cf(range_option)
        self._backtrack_cf()
        while len(self._solutions) == 0 and run_count < 100:
            run_count += 1
            self._initialize_cf(range_option)
            self._backtrack_cf()
        self._solutions = sorted(self._solutions, key = self._steps_are_proportional)
        if len(self._solutions) > 0:
            for i, note in enumerate(self._solutions[0]):
                self._cf.insert_note(note, i)
            return self._cf
        else: return None
        
    def _steps_are_proportional(self, solution: list[Note]) -> int:
        steps = 0
        for i in range(1, len(solution)):
            if abs(solution[i - 1].get_scale_degree_interval(solution[i])) == 2:
                steps += 1
        proportion = steps / (len(solution) - 1)
        return abs(proportion - AVERAGE_STEPS_PERCENTAGE)

    def _initialize_cf(self, range_option: RangeOption):
        self._mr = ModeResolver(self._mode, range_option)
        self._cf = CantusFirmus(self._length, self._mr, self._octave)
        #"final" is equal to the mode's starting scale degree.  All notes in the cantus firmus will be whole notes
        final = Note(self._mode.value["starting"], self._octave, 8) 
        last_note = Note(self._mode.value["starting"], self._octave, 16) 
        while self._mr.get_lowest().get_scale_degree_interval(final) > 8:
            final = self._mr.get_default_note_from_interval(final, -8)
            last_note = self._mr.get_default_note_from_interval(final, -8)
        while self._mr.get_lowest().get_scale_degree_interval(final) < 0:
            final = self._mr.get_default_note_from_interval(final, 8)
            last_note = self._mr.get_default_note_from_interval(final, 8)
        last_note.set_duration(16)
        #add the "final" to the first and last pitches 
        self._cf.insert_note(final, 0)
        self._cf.insert_note(last_note, self._length - 1)
        #find all notes eligible to be highest note
        possible_highest_notes = []
        for interval in VALID_MELODIC_INTERVALS_SCALE_DEGREES:
            if interval > 1:
                possible_highest_notes += self._get_notes_from_interval(final, interval)
        #filter out two particular cases (highest note can't be B natural in Dorian and can't be F in Phrygian)
        def remove_edge_cases(tpl: tuple) -> bool:
            note = tpl[1]
            if self._mode == ModeOption.PHRYGIAN and note.get_scale_degree() == 4: 
                return False
            if self._mode == ModeOption.DORIAN and note.get_scale_degree() == 7 and note.get_accidental() == ScaleOption.NATURAL:
                return False
            return True
        possible_highest_notes = list(filter(remove_edge_cases, possible_highest_notes))
        
        final_to_highest_interval, highest_note = possible_highest_notes[math.floor(random() * len(possible_highest_notes)) ]
        #based on the highest we've chosen, find possible lowest notes
        possible_lowest_notes = []
        for interval in GET_POSSIBLE_INTERVALS_TO_LOWEST[final_to_highest_interval]:
            possible_lowest_notes += self._get_notes_from_interval(final, interval)
        #remove candidates that form tritones or cross relations with highest note (sevenths are permissible)
        def check_range_interval(tpl: tuple) -> bool:
            note = tpl[1]
            if highest_note.get_chromatic_with_octave() - note.get_chromatic_with_octave() in [6, 11, 13]:
                return False 
            return True
        possible_lowest_notes = list(filter(check_range_interval, possible_lowest_notes))
        final_to_lowest_interval, lowest_note = possible_lowest_notes[math.floor(random() * len(possible_lowest_notes))]
        #note that we exclude the highest interval when calculating possible valid notes since highest note can only appear once
        valid_intervals_from_final = list(range(1, final_to_highest_interval)) 
        if final_to_lowest_interval < 0: valid_intervals_from_final += list(range(final_to_lowest_interval, -1)) 
        valid_pitches = []
        for interval in valid_intervals_from_final:
            valid_pitches += self._get_notes_from_interval(final, interval)
        self._valid_pitches = list(map(lambda tpl: tpl[1], valid_pitches))

        #see whether it's possible to end Cantus Firmus from below (either by step or from the "dominant")
        final_to_dom_interval = -4 if self._mode.value["most_common"] == 5 else -5
        can_end_from_dominant = final_to_dom_interval >= final_to_lowest_interval
        can_end_from_step_below = final_to_lowest_interval <= -2

        #set penultimate note as a step above the final as default
        final_to_penult_interval, penult_note = self._get_notes_from_interval(final, 2)[0]
        if random() > .9: #note that in no cases do we need to worry about b-flat vs b-natural here
            if can_end_from_dominant:
                final_to_penult_interval, penult_note = self._get_notes_from_interval(final, final_to_dom_interval)[0]
            elif can_end_from_step_below:
                final_to_penult_interval, penult_note = self._get_notes_from_interval(final, -2)[0]
        if self._mode == ModeOption.PHRYGIAN:
            penult_note = self._mr.get_default_note_from_interval(last_note, -5)


        #insert the penultimate note into the Cantus Firmus
        self._cf.insert_note(penult_note, self._length - 2)

        #initialize remaining indices
        remaining_indices = [i for i in range(1, self._length - 2)]
        remaining_indices.reverse()
        self._remaining_indices = remaining_indices

        #if we haven't already added the highest note, add it:
        if final_to_penult_interval != final_to_highest_interval:
            self._add_highest(highest_note)
        
        #if we haven't already added the lowest note, add it:
        if final_to_lowest_interval != final_to_penult_interval and final_to_lowest_interval != 1:
            self._add_lowest(lowest_note)

    def _valid_melodic_interval(self, first_note: Note, second_note: Note) -> bool:
        scale_interval = first_note.get_scale_degree_interval(second_note)
        chro_interval = first_note.get_chromatic_interval(second_note)
        if scale_interval not in VALID_MELODIC_INTERVALS_SCALE_DEGREES: return False 
        if chro_interval not in VALID_MELODIC_INTERVALS_CHROMATIC: return False
        return True

    #inserts the highest note into a random unoccupied place in the Cantus Firmus that is not the center
    def _add_highest(self, note: Note) -> None:
        def remove_center_position(index: int) -> bool:
            if self._length % 2 == 1 and index == math.floor(self._length / 2):
                return False 
            return True
        possible_indices = list(filter(remove_center_position, [num for num in self._remaining_indices]))
        shuffle(possible_indices)
        correct_index = None
        for index in possible_indices:
            correct_index = index
            prev_note = self._cf.get_note(index - 1)
            next_note = self._cf.get_note(index + 1)
            if prev_note is None and next_note is None:
                break
            if prev_note is not None and self._valid_melodic_interval(prev_note, note):
                break 
            if prev_note is not None and not self._valid_melodic_interval(prev_note, note):
                continue 
            if next_note is not None and not self._valid_melodic_interval(note, next_note):
                continue
            if next_note is not None and self._valid_melodic_interval(note, next_note):
                #we need to check if the final three notes are handled correctly
                if note.get_scale_degree_interval(next_note) == -2:
                    break 
                #if we leap down, this can't be followed by a downward step
                final = self._cf.get_note(index + 2)
                if next_note.get_scale_degree_interval(final) == -2:
                    continue
                #otherwise we're legal
                break 
        self._cf.insert_note(note, correct_index)
        self._remaining_indices.remove(correct_index)


    def _add_lowest(self, note: Note) -> None:
        possible_indices = [num for num in self._remaining_indices]
        shuffle(possible_indices)
        correct_index = None
        for index in possible_indices:
            #for each index, there are several scenarios:
            #index is preceded by a note (either the highest or the lowest)
            #index is followed by a note (either the highest followed by a blank, the penultimate, or the highest followed by penultimate)
            correct_index = index
            prev_note = self._cf.get_note(index - 1)
            next_note = self._cf.get_note(index + 1)
            note_after_next = self._cf.get_note(index + 2)
            if prev_note is None and next_note is None:
                break
            if prev_note is not None and not self._valid_melodic_interval(prev_note, note):
                continue 
            if next_note is not None and not self._valid_melodic_interval(note, next_note):
                continue
            if next_note is None or note_after_next is None:
                break
            if next_note is not None and note_after_next is not None: #we are in the third to last or fourth to last position
                if index == self._length - 3:
                    #if index is the antipenultimate position, there are no scenarios in which
                    #lowest note -> penultimate -> final have an improper sequence of intervals
                    break 
                #otherwise we need to make sure that a leap to the highest note is handled properly
                final = self._cf.get_note(index + 3)
                lowest_to_highest = note.get_scale_degree_interval(next_note)
                lowest_to_penult = note.get_scale_degree_interval(note_after_next)
                lowest_to_final = note.get_scale_degree_interval(final)
                if lowest_to_highest > 3 and lowest_to_penult != lowest_to_highest - 1 and lowest_to_final != lowest_to_highest - 1:
                    continue
                else:
                    break
        self._cf.insert_note(note, correct_index)
        self._remaining_indices.remove(correct_index)

    def _backtrack_cf(self) -> None:
        if len(self._remaining_indices) == 0:
            solution = []
            for i in range(self._length):
                solution.append(self._cf.get_note(i))
            if self._ascending_intervals_are_handled(solution) and self._no_intervalic_sequences(solution):
                self._solutions.append(solution) 
            return
        index = self._remaining_indices.pop()
        prev_note = self._cf.get_note(index - 1) #will never be None
        next_note = self._cf.get_note(index + 1) #may be None
        def intervals_are_valid(possible_note: Note) -> bool:
            if not self._valid_melodic_interval(prev_note, possible_note): return False 
            if next_note is not None and not self._valid_melodic_interval(possible_note, next_note): return False 
            return True
        possible_pitches = list(filter(intervals_are_valid, self._valid_pitches))
        possible_pitches = sorted(possible_pitches, key = lambda n: abs(prev_note.get_chromatic_interval(n)))
        for possible_note in possible_pitches:
            self._cf.insert_note(possible_note, index)
            if self._current_chain_is_legal():
                self._backtrack_cf()
            self._cf.insert_note(None, index)

        self._remaining_indices.append(index)

    def _current_chain_is_legal(self) -> bool:
        #check for the following:
        #1. no dissonant intervals outlined in "segments" (don't check last segment)
        #2. no dissonant intervals outlined in "leap chains"
        #3. ascending minor sixths followed by descending minor seconds
        #4. in each segment, intervals must become progressively smaller (3 -> 2 or -2 -> -3, etc)
        #5. check if ascending leaps greater than a fourth are followed by descending second (to high degree of proability)
        #6. make sure there are no sequences of two notes that are immediately repeated
        #7. check for cross relations

        #keep track of whether we have a b-flat or b-natural
        has_b_natural = False
        has_b_flat = False

        #start by getting current chain of notes and keeping track of b's and b-flats:
        current_chain = []
        for i in range(self._length):
            note = self._cf.get_note(i)
            if note is None: break
            current_chain.append(note)
            if note.get_scale_degree() == 7:
                if note.get_accidental() == ScaleOption.FLAT: 
                    has_b_flat = True 
                else: 
                    has_b_natural = True 
        if has_b_natural and has_b_flat:
            return False 
        #next, get the segments (consecutive notes that move in the same direction)
        #and the leap chains (consecutive notes separated by leaps)
        segments = [[current_chain[0]]]
        leap_chains = [[current_chain[0]]]
        prev_interval = None 
        for i in range(1, len(current_chain)):
            note = current_chain[i]
            prev_note = current_chain[i - 1]
            current_interval = prev_note.get_scale_degree_interval(note)
            if prev_interval is None or (prev_interval > 0 and current_interval > 0) or (prev_interval < 0 and current_interval < 0):
                segments[-1].append(note)
            else:
                segments.append([prev_note, note])
            if abs(current_interval) <= 2:
                leap_chains.append([note])
            else:
                leap_chains[-1].append(note)
            prev_interval = current_interval
        #we only need to examine segments and chains of length 3 or greater
        leap_chains = list(filter(lambda chain: len(chain) >= 3, leap_chains))
        segments = list(filter(lambda seg: len(seg) >= 3, segments))

        #check segments
        for i, seg in enumerate(segments):
            #check for dissonant intervals except in last segment unless we're checking the completed Cantus Firmus
            if i < len(segments) - 1 or len(current_chain) == self._length:
                if self._segment_outlines_illegal_interval(seg):
                    return False 
            if self._segment_has_illegal_interval_ordering(seg):
                return False

        #check leap chains
        for chain in leap_chains:
            if self._leap_chain_is_illegal(chain):
                return False 

        #check for ascending intervals 
        for i in range(1, len(current_chain) - 1):
            first_interval = current_chain[i - 1].get_scale_degree_interval(current_chain[i])
            if first_interval == 6:
                second_interval_chromatic = current_chain[i].get_chromatic_interval(current_chain[i + 1])
                if second_interval_chromatic != -1:
                    return False 
            if first_interval > 3:
                second_interval_sdg = current_chain[i].get_scale_degree_interval(current_chain[i + 1])
                if second_interval_sdg != -2 and random() > .5:
                    return False 

        #check for no sequences
        for i in range(3, len(current_chain)):
            if current_chain[i - 3].get_chromatic_interval(current_chain[i - 1]) == 0 and current_chain[i - 2].get_chromatic_interval(current_chain[i]) == 0:
                return False 
        
        return True

    def _leap_chain_is_illegal(self, chain: list[Note]) -> bool:
        for i in range(len(chain) - 2):
            for j in range(i + 2, len(chain)):
                chro_interval = chain[i].get_chromatic_interval(chain[j])
                if chro_interval not in CONSONANT_MELODIC_INTERVALS_CHROMATIC:
                    return True 
        return False

    def _segment_outlines_illegal_interval(self, seg: list[Note]) -> bool:
        chro_interval = seg[0].get_chromatic_interval(seg[-1])
        return chro_interval not in CONSONANT_MELODIC_INTERVALS_CHROMATIC

    def _segment_has_illegal_interval_ordering(self, seg: list[Note]) -> bool:
        prev_interval = seg[0].get_scale_degree_interval(seg[1])
        for i in range(1, len(seg)):
            current_interval = seg[i - 1].get_scale_degree_interval(seg[i])
            if current_interval > prev_interval:
                return True
            prev_interval = current_interval
        return False

    def _ascending_intervals_are_handled(self, solution: list[Note]) -> bool:
        for i in range(1, len(solution) - 1):
            interval = solution[i - 1].get_scale_degree_interval(solution[i])
            if interval > 2:
                filled_in = False 
                for j in range(i + 1, len(solution)):
                    if solution[i].get_scale_degree_interval(solution[j]) == -2:
                        filled_in = True
                        break
                if not filled_in: return False 
        return True

    
    def _no_intervalic_sequences(self, solution: list[Note]) -> bool:
        #check if an intervalic sequence of four or more notes repeats
        intervals = []
        for i in range(1, len(solution)):
            intervals.append(solution[i - 1].get_scale_degree_interval(solution[i]))
        for i in range(len(solution) - 6):
            seq = intervals[i: i + 3]
            for j in range(i + 3, len(solution) - 4):
                possible_match = intervals[j: j + 3]
                if seq == possible_match:
                    return False 

        #check to remove pattern leap down -> step up -> step down -> leap up
        for i in range(len(solution) - 4):
            if intervals[i] < -2 and intervals[i + 1] == 2 and intervals[i + 2] == -2 and intervals[i + 3] > 2:
                if random() < .8:
                    return False
        #check if three exact notes repeat
        for i in range(len(solution) - 5):
            for j in range(i + 3, len(solution) - 2):
                if solution[i].get_chromatic_interval(solution[j]) == 0 and solution[i + 1].get_chromatic_interval(solution[j + 1]) == 0 and solution[i + 2].get_chromatic_interval(solution[j + 2]) == 0:
                    return False 
        return True
        


    #returns valid notes, if any, at the specified interval.  "3" returns a third above.  "-5" returns a fifth below
    def _get_notes_from_interval(self, note: Note, interval: int) -> list[Note]: 
        sdg = note.get_scale_degree()
        octv = note.get_octave()
        adjustment_value = -1 if interval > 0 else 1
        new_sdg, new_octv = sdg + interval + adjustment_value, octv
        if new_sdg < 1:
            new_octv -= 1
            new_sdg += 7
        elif new_sdg > 7:
            new_octv += 1
            new_sdg -= 7
        new_note = Note(new_sdg, new_octv, 8)
        valid_notes = [new_note]
        if (self._mode == ModeOption.DORIAN or self._mode == ModeOption.LYDIAN) and new_sdg == 7:
            valid_notes.append(Note(new_sdg, new_octv, 8, accidental = ScaleOption.FLAT))
        def valid_interval(next_note: Note) -> bool:
            chro_interval = next_note.get_chromatic_with_octave() - note.get_chromatic_with_octave()
            return chro_interval in CONSONANT_MELODIC_INTERVALS_CHROMATIC
        return list(map(lambda n: (interval, n), list(filter(valid_interval, valid_notes))))
class GenerateTwoPartFourthSpecies:

    def __init__(self, length: int = None, mode: ModeOption = None, octave: int = 4, orientation: Orientation = Orientation.ABOVE):
        self._orientation = orientation
        self._mode = mode or ModeOption.DORIAN
        self._length = length or 8 + math.floor(random() * 5) #todo: replace with normal distribution
        self._octave = octave
        self._mr = ModeResolver(self._mode)
        gcf = GenerateCantusFirmus(self._length, self._mode, self._octave)
        cf = None 
        #if the Cantus Firmus doesn't generate, we have to try again
        #also, for anything above the first species, the Cantus Firmus must end stepwise
        while cf is None or abs(cf.get_note(self._length - 2).get_scale_degree_interval(cf.get_note(self._length - 1))) > 2:
            cf = gcf.generate_cf()
        self._cantus_object = cf
        self._cantus = cf.get_notes()

    def print_counterpoint(self):
        print("  CANTUS FIRMUS:       COUNTERPOINT:")
        for i in range(self._length):
            for j in [0, 2]:
                cntpt_note = self._counterpoint[(i, j)] if (i, j) in self._counterpoint else ""
                if j == 0:
                    print("  " + str(self._cantus[i]) + "  " + str(cntpt_note))
                else:
                    print("                       " + str(cntpt_note))

    def get_optimal(self):
        if len(self._solutions) == 0:
            return None 
        optimal = self._solutions[0]
        print("optimal solution")
        for n in optimal: print(n)
        self._map_solution_onto_counterpoint_dict(optimal)
        self.print_counterpoint()
        return [optimal, self._cantus]
            
    def generate_2p4s(self):
        start_time = time()
        print("MODE = ", self._mode.value["name"])
        self._solutions = []
        def attempt():
            self._num_backtracks = 0
            self._solutions_this_attempt = 0
            initialized = self._initialize()
            while not initialized:
                initialized = self._initialize()
            self._backtrack()
        attempts = 0
        attempt()
        while len(self._solutions) < 1 and attempts < 20:
            print("attempt", attempts)
            attempt()
            attempts += 1
        print("number of attempts:", attempts)
        print("number of solutions:", len(self._solutions))
        if len(self._solutions) > 0:
            self._solutions.sort(key = lambda sol: self._score_solution(sol)) 

    def _initialize(self) -> bool:
        indices = []
        for i in range(self._length - 1):
            indices += [(i, 0), (i, 2)]
        indices += [(self._length - 1, 0)]
        self._all_indices = indices[:]
        self._remaining_indices = indices[:]
        self._remaining_indices.reverse()
        #initialize counterpoint data structure, that will map indices to notes
        counterpoint = {}
        for index in self._all_indices: counterpoint[index] = None 
        self._counterpoint = counterpoint
        lowest, highest = None, None
        vocal_range = randint(5, 8)
        if self._orientation == Orientation.ABOVE:
            lowest = self._mr.get_default_note_from_interval(self._cantus_object.get_highest_note(), [-3, -2, 1, 2, 3, 4][randint(0, 5)])
            highest = self._mr.get_default_note_from_interval(lowest, vocal_range)
        else:
            highest = self._mr.get_default_note_from_interval(self._cantus_object.get_lowest_note(), [-4, -3, -2, 1, 2, 3][randint(0, 5)])
            lowest = self._mr.get_default_note_from_interval(highest, vocal_range * -1)

        valid_pitches = [lowest, highest] #order is unimportant
        for i in range(2, vocal_range):
            valid_pitches += self._mr.get_notes_from_interval(lowest, i)



        self._highest, self._lowest = highest, lowest
        self._highest_must_appear_by = randint(2, self._length - 2)
        self._lowest_must_appear_by = randint(2 if self._highest_must_appear_by >= 4 else 4, self._length - 1)
        self._valid_pitches = valid_pitches
        self._highest_has_been_placed = False 
        self._lowest_has_been_placed = False 
        return True 

    def _backtrack(self) -> None:
        if (self._num_backtracks > 2000 and self._solutions_this_attempt == 0) or self._num_backtracks > 20000:
            return 
        if self._solutions_this_attempt >= 100:
            return 
        self._num_backtracks += 1
        if len(self._remaining_indices) == 0:
            # print("found possible solution!")
            sol = []
            for i in range(len(self._all_indices)):
                note_to_add = self._counterpoint[self._all_indices[i]]
                note_to_add_copy = Note(note_to_add.get_scale_degree(), note_to_add.get_octave(), note_to_add.get_duration(), note_to_add.get_accidental())
                sol.append(note_to_add_copy)
            if self._passes_final_checks(sol):
                # print("FOUND SOLUTION!")
                self._solutions.append(sol)
                self._solutions_this_attempt += 1
            return 
        (bar, beat) = self._remaining_indices.pop() 
        candidates = None
        if bar == 0 and (beat == 0 or self._counterpoint[(0, 0)].get_accidental() == ScaleOption.REST):
            candidates = list(filter(lambda n: self._cantus[0].get_chromatic_interval(n) in [-12, 0, 7, 12], self._valid_pitches))
        else:
            candidates = list(filter(lambda n: self._passes_insertion_checks(n, (bar, beat)), self._valid_pitches)) 
        if bar == 0 and beat == 0:
            candidates.append(Note(1, 0, 4, accidental = ScaleOption.REST))
            shuffle(candidates)
        if bar == self._length - 1:
            candidates = list(filter(lambda n: self._valid_last_note(n), candidates))
        for candidate in candidates:
            #start by making a copy of the note
            candidate = Note(candidate.get_scale_degree(), candidate.get_octave(), 8, candidate.get_accidental())
            temp_highest_placed, temp_lowest_placed = self._highest_has_been_placed, self._lowest_has_been_placed
            if self._mr.is_unison(candidate, self._highest): self._highest_has_been_placed = True  
            if self._mr.is_unison(candidate, self._lowest): self._lowest_has_been_placed = True 
            (may_be_tied, must_be_tied) = self._get_tied_options(candidate, (bar, beat))
            if not must_be_tied:
                candidate.set_duration(4 if bar != self._length - 1 else 16)
                self._counterpoint[(bar, beat)] = candidate 
                if self._current_chain_is_legal((bar, beat)):
                    self._backtrack()
            if may_be_tied or (bar == self._length - 2 and beat == 0):
                candidate.set_duration(8)
                index_to_remove = (bar, 2) if beat == 0 else (bar + 1, 0)
                index_position_all, index_position_remaining = self._all_indices.index(index_to_remove), self._remaining_indices.index(index_to_remove)
                self._all_indices.remove(index_to_remove)
                self._remaining_indices.remove(index_to_remove)
                del self._counterpoint[index_to_remove]
                self._counterpoint[(bar, beat)] = candidate 
                if self._current_chain_is_legal((bar, beat)):
                    self._backtrack()
                self._all_indices.insert(index_position_all, index_to_remove)
                self._remaining_indices.insert(index_position_remaining, index_to_remove)
                self._counterpoint[index_to_remove] = None 
            self._highest_has_been_placed, self._lowest_has_been_placed = temp_highest_placed, temp_lowest_placed
        self._counterpoint[(bar, beat)] = None 
        self._remaining_indices.append((bar, beat))

    def _passes_insertion_checks(self, note: Note, index: tuple) -> bool:
        if not self._is_valid_melodic_insertion(note, index): return False #checks for properly resolved ascending sixths and non-adjacent cross relations 
        if not self._valid_harmonic_insertion(note, index): return False #makes sure all dissonances are resolved
        if not self._doesnt_create_parallels(note, index): return False #eliminates parallels and hidden fifths
        if not self._no_large_parallel_leaps(note, index): return False 
        if not self._no_cross_relations_with_cantus_firmus(note, index): return False 
        if not self._handles_highest_and_lowest(note, index): return False 
        return True 

    def _is_valid_melodic_insertion(self, note: Note, index: tuple) -> bool:
        (bar, beat) = index
        prev_note = self._get_prev_note(index)
        if not self._is_valid_adjacent(prev_note, note): return False 
        #check to make sure an ascending minor sixth is handled 
        note_before_prev = self._get_prev_note(self._get_prev_index(index))
        if note_before_prev is not None and note_before_prev.get_accidental() == ScaleOption.REST: note_before_prev = None
        if note_before_prev is not None and note_before_prev.get_chromatic_interval(prev_note) == 8 and prev_note.get_chromatic_interval(note) != -1:
            return False
        if note_before_prev is not None and note_before_prev.get_scale_degree_interval(note) == 1 and note_before_prev.get_chromatic_interval(note) != 0:
            return False
        if bar == self._length - 1 and abs(prev_note.get_scale_degree_interval(note)) != 2: return False
        if bar == self._length - 1 and prev_note.get_scale_degree_interval(note) == 2 and not self._mr.is_unison(prev_note, self._mr.get_leading_tone_of_note(note)): return False
        return True 

    def _valid_harmonic_insertion(self, note: Note, index: tuple) -> bool:
        (bar, beat) = index
        #case 1: beat == 0 -> test for dissonance in previous measure 
        #case 2: if previous note is tied: if dissonant, resolve it.  If not, see if it is consonant or a passing tone 
        if beat == 0:
            if not self._is_valid_harmonically(note, self._cantus[bar]): return False 
            if bar != 0 and not self._is_valid_harmonically(self._get_prev_note(index), self._cantus[bar - 1]):
                prev_note = self._get_prev_note(index)
                if self._counterpoint[(bar - 1, 0)].get_scale_degree_interval(prev_note) != prev_note.get_scale_degree_interval(note):
                    return False 
            return True 
        elif (bar, 0) in self._counterpoint:
            if self._is_valid_harmonically(note, self._cantus[bar]): return True 
            if abs(self._get_prev_note(index).get_scale_degree_interval(note)) != 2: return False 
            return True 
        else:
            if not self._is_valid_harmonically(self._get_prev_note(index), self._cantus[bar]) and self._get_prev_note(index).get_scale_degree_interval(note) != -2:
                return False 
            if not self._is_valid_harmonically(self._cantus[bar], note): return False 
            return True 

    def _doesnt_create_parallels(self, note: Note, index: tuple) -> bool:
        (bar, beat) = index 
        if beat != 0 or bar == 0: return True 
        if self._cantus[bar].get_chromatic_interval(note) not in [-19, -12, -7, 0, 7, 12, 19]: return True 
        if (bar - 1, 0) in self._counterpoint and self._cantus[bar].get_chromatic_interval(note) == self._cantus[bar].get_chromatic_interval(self._counterpoint[(bar - 1, 0)]):
            return False
        lower_interval, higher_interval = self._cantus[bar - 1].get_scale_degree_interval(self._cantus[bar]), self._get_prev_note(index).get_scale_degree_interval(note)
        if (lower_interval > 0 and higher_interval > 0) or (lower_interval < 0 and higher_interval < 0):
            return False 
        return True 

    def _no_large_parallel_leaps(self, note: Note, index: tuple) -> bool:
        (bar, beat) = index 
        if beat == 2 or (bar - 1, 2) not in self._counterpoint: return True 
        lower_interval, higher_interval = self._cantus[bar - 1].get_scale_degree_interval(self._cantus[bar]), self._get_prev_note(index).get_scale_degree_interval(note)
        if abs(lower_interval > 2) and abs(higher_interval) > 2 and (abs(lower_interval) > 4 or abs(higher_interval) > 4) and ((lower_interval > 0 and higher_interval > 0) or (lower_interval < 0) and higher_interval < 0):
            return False 
        return True 

    def _no_cross_relations_with_cantus_firmus(self, note: Note, index: tuple) -> bool:
        (bar, beat) = index
        if bar > 0 and beat == 0 and self._cantus[bar - 1].get_scale_degree_interval(note) in [-8, 1, 8] and self._cantus[bar - 1].get_chromatic_interval(note) not in [-12, 0, 12]:
            return False 
        if beat == 2 and self._cantus[bar + 1].get_scale_degree_interval(note) in [-8, 1, 8] and self._cantus[bar + 1].get_chromatic_interval(note) not in [-12, 0, 12]:
            return False 
        return True 

    def _handles_highest_and_lowest(self, note: Note, index: tuple) -> bool:
        (bar, beat) = index 
        if self._highest_must_appear_by == bar and not self._highest_has_been_placed and not self._mr.is_unison(self._highest, note): return False 
        if self._highest_has_been_placed and self._mr.is_unison(note, self._highest): return False 
        if self._lowest_must_appear_by == bar and not self._lowest_has_been_placed and not self._mr.is_unison(self._lowest, note): return False 
        return True 

    def _valid_last_note(self, note: Note) -> bool:
        return self._cantus[self._length - 1].get_chromatic_interval(note) in [-12, 0, 12]

    def _get_tied_options(self, note: Note, index: tuple) -> tuple:
        (bar, beat) = index 
        if bar >= self._length - 2 or beat == 0 or not self._is_valid_harmonically(self._cantus[bar], note): return (False, False)
        (may_be_tied, must_be_tied) = (False, False)
        next_harmonic_interval = self._cantus[bar + 1].get_scale_degree_interval(note)
        if next_harmonic_interval in [-9, -2, 4, 7, 11] or (next_harmonic_interval == -5 and self._cantus[bar + 1].get_chromatic_interval(note) == -8):
            may_be_tied, must_be_tied = True, True
        elif self._is_valid_harmonically(self._cantus[bar + 1], note):
            may_be_tied = True 
        return (may_be_tied, must_be_tied)

    def _current_chain_is_legal(self, index: tuple) -> bool:
        current_chain = []
        for i in range(self._all_indices.index(index) + 1):
            next_note = self._counterpoint[self._all_indices[i]]
            current_chain.append(next_note)
        result = self._span_is_valid(current_chain)
        return result

    def _span_is_valid(self, span: list[Note]) -> bool:
        if len(span) < 3: return True 
        last_second_species_chain = [span[-1]]
        search_index = len(span) - 2
        while search_index >= 0 and span[search_index].get_duration() == 4:
            last_second_species_chain.append(span[search_index])
            search_index -= 1
        if len(last_second_species_chain) < 3: return True 
        last_second_species_chain.reverse()
        intervals = []
        for i in range(len(last_second_species_chain) - 1):
            intervals.append(last_second_species_chain[i].get_scale_degree_interval(last_second_species_chain[i + 1]))
        for i, interval in enumerate(intervals):
            if interval >= 4:
                for j in range(i - 1, -1, -1):
                    if intervals[j] < 0: break 
                    if intervals[j] > 2: return False 
            if interval <= -4:
                for j in range(i + 1, len(intervals)):
                    if intervals[j] > 0: break
                    if intervals[j] < -2: return False 
        #check for repeated notes in end of chain:
        if len(span) > 3 and self._mr.is_unison(span[-4], span[-2]) and self._mr.is_unison(span[-3], span[-1]): 
            return False 
        if len(span) > 4 and self._mr.is_unison(span[-1], span[-3]) and self._mr.is_unison(span[-1], span[-5]):
            return False 
        if len(span) > 5:
            repetitions = 0
            for i in range(-5, 0): 
                if self._mr.is_unison(span[-6], span[i]): repetitions += 1
                if repetitions == 2: 
                    return False
        return True 

    def _passes_final_checks(self, solution: list[Note]) -> bool:
        if len(solution) >= self._length * 1.4: return False
        return self._leaps_filled_in(solution)

    def _leaps_filled_in(self, solution: list[Note]) -> bool:
        # print("running leaps filled in with solution:")
        # for n in solution: print(n)
        # for i in range(1, len(solution) - 1):
        #     interval = solution[i - 1].get_scale_degree_interval(solution[i])
        #     #for leaps down, we either need the note below the top note or any higher note 
        #     if interval < -2:
        #         handled = False 
        #         for j in range(i + 1, len(solution)):
        #             if solution[i - 1].get_scale_degree_interval(solution[j]) >= -2:
        #                 handled = True
        #                 break
        #         if not handled: 
        #             print("intervals not properly handled")
        #             return False 
        return True

    def _map_solution_onto_counterpoint_dict(self, solution: list[Note]) -> None:
        self._counterpoint = {}
        bar, beat = 0, 0
        self._all_indices = []
        for note in solution:
            self._counterpoint[(bar, beat)] = note
            self._all_indices.append((bar, beat))
            beat += note.get_duration() / 2 
            while beat >= 4:
                beat -= 4
                bar += 1

    def _score_solution(self, solution: list[Note]) -> int:
        score = 0
        self._map_solution_onto_counterpoint_dict(solution)
        #add 5 points for every measure that isn't a suspension and 12 points for every measure that isn't tied
        for i in range(1, self._length - 1):
            if (i, 0) in self._counterpoint: score += 12
            elif self._is_valid_harmonically(self._counterpoint[(i - 1, 2)], self._cantus[i]): score += 5
        #add an additional 15 points if penultimate measure is not tied
        if (self._length - 2, 0) in self._counterpoint: score += 15
        return score 

    def _is_valid_adjacent(self, note1: Note, note2: Note) -> bool:
        sdg_interval = note1.get_scale_degree_interval(note2)
        chro_interval = note1.get_chromatic_interval(note2)
        if (self._mr.is_leading_tone(note1) or self._mr.is_leading_tone(note2)) and abs(sdg_interval) > 3: return False 
        if ( sdg_interval in LegalIntervalsFourthSpecies["adjacent_melodic_scalar"] 
            and chro_interval in LegalIntervalsFourthSpecies["adjacent_melodic_chromatic"]
            and (sdg_interval, chro_interval) not in LegalIntervalsFourthSpecies["forbidden_combinations"] ):
            return True 
        return False 

    def _is_valid_outline(self, note1: Note, note2: Note) -> bool:
        sdg_interval = note1.get_scale_degree_interval(note2)
        chro_interval = note1.get_chromatic_interval(note2)
        if ( sdg_interval in LegalIntervalsFourthSpecies["outline_melodic_scalar"] 
            and chro_interval in LegalIntervalsFourthSpecies["outline_melodic_chromatic"]
            and (sdg_interval, chro_interval) not in LegalIntervalsFourthSpecies["forbidden_combinations"] ):
            return True 
        return False 

    def _is_valid_harmonically(self, note1: Note, note2: Note) -> bool:
        sdg_interval = note1.get_scale_degree_interval(note2)
        chro_interval = note1.get_chromatic_interval(note2)
        if ( sdg_interval in LegalIntervalsFourthSpecies["harmonic_scalar"] 
            and chro_interval in LegalIntervalsFourthSpecies["harmonic_chromatic"]
            and (sdg_interval, chro_interval) not in LegalIntervalsFourthSpecies["forbidden_combinations"] ):
            return True 
        return False 

    def _get_prev_note(self, index: tuple) -> Note:
        prev_index = self._get_prev_index(index)
        return None if prev_index is None else self._counterpoint[prev_index]

    def _get_next_note(self, index: tuple) -> Note:
        next_index = self._get_next_index(index)
        return None if next_index is None else self._counterpoint[next_index]

    def _get_prev_index(self, index: tuple) -> tuple:
        if index is None: return None
        i = self._all_indices.index(index)
        if i == 0: return None
        return self._all_indices[i - 1]

    def _get_next_index(self, index: tuple) -> tuple:
        i = self._all_indices.index(index)
        if i == len(self._all_indices) - 1: return None 
        return self._all_indices[i + 1]
 def __init__(self,
              length: int = None,
              mode: ModeOption = None,
              range_option: RangeOption = None):
     self._mode = mode or ModeOption.AEOLIAN
     self._length = length or 8 + math.floor(
         random() * 5)  #todo: replace with normal distribution
     self._range = range_option if range_option is not None else RangeOption.ALTO
     self._mr = ModeResolver(self._mode, range_option=self._range)
     self._melodic_insertion_checks = [
         self._handles_adjacents,
         self._handles_interval_order,
         self._handles_nearby_augs_and_dims,
         self._handles_nearby_leading_tones,
         self._handles_ascending_minor_sixth,
         self._handles_ascending_quarter_leaps,
         self._handles_descending_quarter_leaps,
         self._handles_repetition,
         self._handles_eigths,
         self._handles_highest,
         self._handles_resolution_of_anticipation,
         self._handles_repeated_two_notes,
         self._handles_quarter_between_two_leaps,
         self._handles_upper_neighbor,
         self._handles_antipenultimate_bar,
     ]
     self._harmonic_insertion_checks = [
         self._filters_dissonance_on_downbeat, self._resolves_suspension,
         self._prepares_weak_quarter_dissonance,
         self._resolves_weak_quarter_dissonance,
         self._resolves_cambiata_tail, self._prepares_weak_half_note,
         self._resolves_dissonant_quarter_on_weak_half_note,
         self._resolves_passing_half_note, self._handles_hiddens,
         self._handles_parallels, self._handles_doubled_leading_tone
     ]
     self._melodic_rhythm_filters = [
         self._handles_runs, self._handles_consecutive_quarters,
         self._handles_penultimate_bar, self._handles_first_eighth,
         self._handles_sharp_durations, self._handles_whole_note_quota,
         self._handles_repeated_note,
         self._handles_rhythm_after_descending_quarter_leap,
         self._handles_dotted_whole_after_quarters,
         self._handles_repeated_dotted_halfs,
         self._handles_antipenultimate_rhythm,
         self._handles_half_note_chain, self._handles_missing_syncopation,
         self._handles_quarters_after_whole,
         self._handles_repetition_on_consecutive_syncopated_measures
     ]
     self._harmonic_rhythm_filters = [
         self._prepares_suspension,
         self._resolves_cambiata,
         self._handles_weak_half_note_dissonance,
     ]
     self._index_checks = [self._highest_and_lowest_placed]
     self._change_params = [
         self._check_for_highest_and_lowest,
         self._check_for_on_beat_whole_note,
         # self._check_if_new_run_is_added,
         self._check_if_eighths_are_added
     ]
     self._final_checks = [
         # self._parameters_are_correct,
         self._has_only_one_octave,
         self._no_unresolved_leading_tones
     ]
     self._params = [
         "highest_has_been_placed", "lowest_has_been_placed",
         "num_on_beat_whole_notes_placed", "eighths_have_been_placed"
     ]
     gcf = GenerateCantusFirmus(self._length, self._mode, 4)
     cf = None
     #limit ourselves to Cantus Firmuses that end with a descending step to allow for easier cadences
     while cf is None or cf.get_note(self._length -
                                     2).get_scale_degree_interval(
                                         cf.get_note(self._length -
                                                     1)) != -2:
         cf = gcf.generate_cf()
     self._cantus_object = cf
     self._cantus = cf.get_notes()
class GenerateTwoPartFifthSpecies:
    def __init__(self,
                 length: int = None,
                 mode: ModeOption = None,
                 range_option: RangeOption = None):
        self._mode = mode or ModeOption.AEOLIAN
        self._length = length or 8 + math.floor(
            random() * 5)  #todo: replace with normal distribution
        self._range = range_option if range_option is not None else RangeOption.ALTO
        self._mr = ModeResolver(self._mode, range_option=self._range)
        self._melodic_insertion_checks = [
            self._handles_adjacents,
            self._handles_interval_order,
            self._handles_nearby_augs_and_dims,
            self._handles_nearby_leading_tones,
            self._handles_ascending_minor_sixth,
            self._handles_ascending_quarter_leaps,
            self._handles_descending_quarter_leaps,
            self._handles_repetition,
            self._handles_eigths,
            self._handles_highest,
            self._handles_resolution_of_anticipation,
            self._handles_repeated_two_notes,
            self._handles_quarter_between_two_leaps,
            self._handles_upper_neighbor,
            self._handles_antipenultimate_bar,
        ]
        self._harmonic_insertion_checks = [
            self._filters_dissonance_on_downbeat, self._resolves_suspension,
            self._prepares_weak_quarter_dissonance,
            self._resolves_weak_quarter_dissonance,
            self._resolves_cambiata_tail, self._prepares_weak_half_note,
            self._resolves_dissonant_quarter_on_weak_half_note,
            self._resolves_passing_half_note, self._handles_hiddens,
            self._handles_parallels, self._handles_doubled_leading_tone
        ]
        self._melodic_rhythm_filters = [
            self._handles_runs, self._handles_consecutive_quarters,
            self._handles_penultimate_bar, self._handles_first_eighth,
            self._handles_sharp_durations, self._handles_whole_note_quota,
            self._handles_repeated_note,
            self._handles_rhythm_after_descending_quarter_leap,
            self._handles_dotted_whole_after_quarters,
            self._handles_repeated_dotted_halfs,
            self._handles_antipenultimate_rhythm,
            self._handles_half_note_chain, self._handles_missing_syncopation,
            self._handles_quarters_after_whole,
            self._handles_repetition_on_consecutive_syncopated_measures
        ]
        self._harmonic_rhythm_filters = [
            self._prepares_suspension,
            self._resolves_cambiata,
            self._handles_weak_half_note_dissonance,
        ]
        self._index_checks = [self._highest_and_lowest_placed]
        self._change_params = [
            self._check_for_highest_and_lowest,
            self._check_for_on_beat_whole_note,
            # self._check_if_new_run_is_added,
            self._check_if_eighths_are_added
        ]
        self._final_checks = [
            # self._parameters_are_correct,
            self._has_only_one_octave,
            self._no_unresolved_leading_tones
        ]
        self._params = [
            "highest_has_been_placed", "lowest_has_been_placed",
            "num_on_beat_whole_notes_placed", "eighths_have_been_placed"
        ]
        gcf = GenerateCantusFirmus(self._length, self._mode, 4)
        cf = None
        #limit ourselves to Cantus Firmuses that end with a descending step to allow for easier cadences
        while cf is None or cf.get_note(self._length -
                                        2).get_scale_degree_interval(
                                            cf.get_note(self._length -
                                                        1)) != -2:
            cf = gcf.generate_cf()
        self._cantus_object = cf
        self._cantus = cf.get_notes()

    def print_counterpoint(self):
        print("  FIFTH SPECIES:")
        for i in range(self._length):
            for j in range(4):
                cf_note = str(
                    self._cantus[i]) if j == 0 else "                   "
                cntpt_note = str(self._counterpoint_obj[(i, j)]) if (
                    i, j) in self._counterpoint_obj else ""
                if cntpt_note is None: cntpt_note = "None"
                if (i, j + 0.5) in self._counterpoint_obj:
                    cntpt_note += "  " + str(
                        self._counterpoint_obj[(i, j + 0.5)])
                show_index = "    "
                if j == 0:
                    show_index = str(i) + ":  " if i < 10 else str(i) + ": "
                print(show_index + "  " + cf_note + "  " + str(cntpt_note))

    def get_optimal(self):
        if len(self._solutions) == 0:
            return None
        optimal = self._solutions[0]
        self._map_solution_onto_counterpoint_dict(optimal)
        self.print_counterpoint()
        return [optimal, self._cantus]

    def generate_2p5s(self):
        print("MODE = ", self._mode.value["name"])
        self._solutions = []

        def attempt():
            self._num_backtracks = 0
            self._solutions_this_attempt = 0
            initialized = self._initialize()
            self._backtrack()

        attempts = 0
        while len(self._solutions) < 100 and attempts < 1:
            print("attempt", attempts)
            attempt()
            attempts += 1
        print("number of attempts:", attempts)
        print("number of solutions:", len(self._solutions))
        if len(self._solutions) > 0:
            shuffle(self._solutions)
            self._solutions.sort(key=lambda sol: self._score_solution(sol))

    def _initialize(self) -> bool:
        indices = []
        for i in range(self._length - 1):
            indices += [(i, 0), (i, 1), (i, 1.5), (i, 2), (i, 3)]
        indices += [(self._length - 1, 0)]
        self._all_indices = indices[:]
        self._remaining_indices = indices[:]
        self._remaining_indices.reverse()
        print(self._all_indices)
        #initialize counterpoint data structure, that will map indices to notes
        self._counterpoint_obj = {}
        for index in self._all_indices:
            self._counterpoint_obj[index] = None
        #also initialize counterpoint data structure as list
        self._counterpoint_lst = []
        #initialize parameters for this attempt
        self._attempt_params = {
            "lowest": None,
            "highest": None,
            "highest_must_appear_by": None,
            "lowest_must_appear_by": None,
            "highest_has_been_placed": False,
            "lowest_has_been_placed": False,
            "max_on_beat_whole_notes": None,
            "num_on_beat_whole_notes_placed": 0,
            "eighths_have_been_placed": False,
            "run_indices": set()
        }
        vocal_range = randint(8, 10)
        self._attempt_params[
            "lowest"] = self._mr.get_default_note_from_interval(
                self._mr.get_lowest(), randint(1, 13 - vocal_range))
        self._attempt_params[
            "highest"] = self._mr.get_default_note_from_interval(
                self._attempt_params["lowest"], vocal_range)
        self._attempt_params["highest_must_appear_by"] = randint(
            3, self._length - 1)
        self._attempt_params["lowest_must_appear_by"] = randint(
            3 if self._attempt_params["highest_must_appear_by"] >= 5 else 5,
            self._length - 1)
        self._attempt_params["max_on_beat_whole_notes"] = randint(1, 2)

        self._place_runs()

        self._store_params = []
        self._stored_indices = []
        self._valid_pitches = [
            self._attempt_params["lowest"], self._attempt_params["highest"]
        ]  #order is unimportant
        for i in range(2, vocal_range):
            self._valid_pitches += self._mr.get_notes_from_interval(
                self._attempt_params["lowest"], i)

        return True

    def _place_runs(self) -> None:
        runs = [randint(5, 11)]
        if self._length > 8: runs.append(4)
        shuffle(runs)
        start_beats = []
        for run in runs:
            pos = randint(3, (self._length - 2) * 4 - run)
            while pos % 4 == 0:
                pos = randint(3, (self._length - 2) * 4 - run)
            start_beats.append(pos)
        if len(runs) == 2 and start_beats[0] + runs[0] + 12 >= start_beats[1]:
            start_beats.pop()
            runs.pop()
        for i in range(len(runs)):
            for j in range(runs[i]):
                total_beats = start_beats[i] + j
                index = (total_beats // 4, total_beats % 4)
                self._attempt_params["run_indices"].add(index)

    def _backtrack(self) -> None:
        if (self._num_backtracks >
                100000) or (self._solutions_this_attempt == 0
                            and self._num_backtracks > 10000):
            return
        self._num_backtracks += 1
        if self._num_backtracks % 10000 == 0:
            print("backtrack number:", self._num_backtracks)
        if len(self._remaining_indices) == 0:
            if self._passes_final_checks():
                if self._solutions_this_attempt == 0:
                    print("FOUND SOLUTION!")
                self._solutions.append(self._counterpoint_lst[:])
                self._solutions_this_attempt += 1
            return
        (bar, beat) = self._remaining_indices.pop()
        if self._passes_index_checks((bar, beat)):
            candidates = list(
                filter(lambda n: self._passes_insertion_checks(n, (bar, beat)),
                       self._valid_pitches))
            shuffle(candidates)
            if bar == 0 and beat == 0:
                candidates.append(Note(1, 0, 4, accidental=ScaleOption.REST))
            # print("candidates for index", bar, beat, ": ", len(candidates))
            notes_to_insert = []
            for candidate in candidates:
                durations = self._get_valid_durations(candidate, (bar, beat))
                for dur in durations:
                    notes_to_insert.append(
                        Note(candidate.get_scale_degree(),
                             candidate.get_octave(),
                             dur,
                             accidental=candidate.get_accidental()))
            shuffle(notes_to_insert)
            for note_to_insert in notes_to_insert:
                self._insert_note(note_to_insert, (bar, beat))
                self._backtrack()
                self._remove_note(note_to_insert, (bar, beat))
        self._remaining_indices.append((bar, beat))

    def _passes_index_checks(self, index: tuple) -> bool:
        for check in self._index_checks:
            if not check(index): return False
        return True

    def _passes_insertion_checks(self, note: Note, index: tuple) -> bool:
        (bar, beat) = index
        if bar == 0 and (beat == 0 or
                         (beat == 2
                          and self._counterpoint_lst[0].get_accidental()
                          == ScaleOption.REST)):
            return self._check_starting_pitch(note)
        if bar == self._length - 1:
            if not self._check_last_pitch(note): return False
        for check in self._melodic_insertion_checks:
            if not check(note, (bar, beat)):
                # print("failed insertion check:", str(note), index, "on function", check.__name__)
                return False
        # print("passed insertion checks!", str(note), index)
        for check in self._harmonic_insertion_checks:
            if not check(note, (bar, beat)):
                # print("failed insertion check:", str(note), index, "on function", check.__name__)
                return False
        return True

    def _get_valid_durations(self, note: Note, index: tuple) -> set:
        (bar, beat) = index
        if bar == self._length - 1: return {16}
        if note.get_accidental() == ScaleOption.REST: return {4}
        if bar == 0 and beat == 0: return self._get_first_beat_options(note)
        durs = self._get_durations_from_beat(index)
        prev_length = len(durs)
        for check in self._melodic_rhythm_filters:
            durs = check(note, index, durs)
            if len(durs) == 0: break
        for check in self._harmonic_rhythm_filters:
            durs = check(note, index, durs)
            if len(durs) == 0: break
        return durs

    def _insert_note(self, note: Note, index: tuple) -> set:
        self._counterpoint_lst.append(note)
        self._counterpoint_obj[index] = note
        self._store_params.append({})
        for param in self._params:
            self._store_params[-1][param] = self._attempt_params[param]
        self._bury_indices(note, index)
        for check in self._change_params:
            check(note, index)

    def _remove_note(self, note: Note, index: tuple) -> set:
        self._counterpoint_lst.pop()
        self._counterpoint_obj[index] = None
        for param in self._params:
            self._attempt_params[param] = self._store_params[-1][param]
        self._store_params.pop()
        self._unbury_indices(note, index)

    def _passes_final_checks(self) -> bool:
        for check in self._final_checks:
            if not check():
                # print("failed final check:", check.__name__)
                return False
        return True

    ######################################
    ### melodic insertion checks #########

    def _check_starting_pitch(self, note: Note) -> bool:
        if note.get_accidental() == ScaleOption.REST:
            return True
        if self._cantus[0].get_scale_degree_interval(note) not in [
                -8, 1, 5, 8
        ]:
            return False
        return note.get_accidental() == ScaleOption.NATURAL

    def _check_last_pitch(self, note: Note) -> bool:
        if self._mr.get_final() != note.get_scale_degree(
        ) or note.get_accidental() != ScaleOption.NATURAL:
            return False
        if self._counterpoint_lst[-1].get_scale_degree_interval(note) == 2:
            return self._mr.is_unison(self._mr.get_leading_tone_of_note(note),
                                      self._counterpoint_lst[-1])
        if self._counterpoint_lst[-1] != -2: return False
        return self._mr.is_unison(self._counterpoint_lst[-1],
                                  self._mr.get_leading_tone_of_note(note))

    def _handles_adjacents(self, note: Note, index: tuple) -> bool:
        (bar, beat) = index
        (sdg_interval,
         chro_interval) = self._mr.get_intervals(self._counterpoint_lst[-1],
                                                 note)
        if sdg_interval not in LegalIntervalsFifthSpecies[
                "adjacent_melodic_scalar"]:
            return False
        if chro_interval not in LegalIntervalsFifthSpecies[
                "adjacent_melodic_chromatic"]:
            return False
        if (sdg_interval, chro_interval
            ) in LegalIntervalsFifthSpecies["forbidden_combinations"]:
            return False
        return True

    def _handles_interval_order(self, note: Note, index: tuple) -> bool:
        potential_interval = self._counterpoint_lst[
            -1].get_scale_degree_interval(note)
        if potential_interval >= 3:
            for i in range(len(self._counterpoint_lst) - 2, -1, -1):
                interval = self._counterpoint_lst[i].get_scale_degree_interval(
                    self._counterpoint_lst[i + 1])
                if interval < 0: return True
                if interval > 2: return False
        if potential_interval == 2:
            segment_has_leap = False
            for i in range(len(self._counterpoint_lst) - 2, -1, -1):
                interval = self._counterpoint_lst[i].get_scale_degree_interval(
                    self._counterpoint_lst[i + 1])
                if interval < 0: return True
                if segment_has_leap: return False
                segment_has_leap = interval > 2
        if potential_interval == -2:
            segment_has_leap = False
            for i in range(len(self._counterpoint_lst) - 2, -1, -1):
                interval = self._counterpoint_lst[i].get_scale_degree_interval(
                    self._counterpoint_lst[i + 1])
                if interval > 0: return True
                if segment_has_leap or interval == -8: return False
                segment_has_leap = interval < -2
        if potential_interval <= -3:
            for i in range(len(self._counterpoint_lst) - 2, -1, -1):
                interval = self._counterpoint_lst[i].get_scale_degree_interval(
                    self._counterpoint_lst[i + 1])
                if interval > 0: return True
                if interval < -2: return False
        return True

    def _handles_nearby_augs_and_dims(self, note: Note, index: tuple) -> bool:
        if len(self._counterpoint_lst) < 2: return True
        if self._mr.is_cross_relation(
                note, self._counterpoint_lst[-2]
        ) and self._counterpoint_lst[-1].get_duration() <= 2:
            return False
        if self._counterpoint_lst[-2].get_duration(
        ) != 2 and self._counterpoint_lst[-1].get_duration() != 2:
            return True
        (sdg_interval,
         chro_interval) = self._mr.get_intervals(self._counterpoint_lst[-2],
                                                 note)
        return (abs(sdg_interval) != 2
                or abs(chro_interval) != 3) and (abs(sdg_interval) != 3
                                                 or abs(chro_interval) != 2)

    def _handles_nearby_leading_tones(self, note: Note, index: tuple) -> bool:
        (bar, beat) = index
        if beat == 2 and (
                bar - 1,
                2) in self._counterpoint_obj and self._counterpoint_obj[
                    (bar - 1, 2)].get_duration() > 4:
            if self._mr.is_sharp(self._counterpoint_obj[(
                    bar - 1, 2)]) and self._counterpoint_obj[
                        (bar - 1, 2)].get_chromatic_interval(note) != 1:
                return False
        if beat == 0 and bar != 0:
            for i, num in enumerate([0, 1, 1.5, 2, 3]):
                if (bar - 1,
                        num) in self._counterpoint_obj and self._mr.is_sharp(
                            self._counterpoint_obj[(bar - 1, num)]):
                    resolved = False
                    for j in range(i + 1, 5):
                        next_index = (bar - 1, [0, 1, 1.5, 2, 3][j])
                        if next_index in self._counterpoint_obj and self._counterpoint_obj[
                            (bar - 1, num)].get_chromatic_interval(
                                self._counterpoint_obj[next_index]) == 1:
                            resolved = True
                    if not resolved and self._counterpoint_obj[
                        (bar - 1, num)].get_chromatic_interval(note) != 1:
                        return False
        return True

    def _handles_ascending_minor_sixth(self, note: Note, index: tuple) -> bool:
        if len(self._counterpoint_lst) < 2: return True
        if self._counterpoint_lst[-2].get_chromatic_interval(
                self._counterpoint_lst[-1]) == 8:
            return self._counterpoint_lst[-1].get_chromatic_interval(
                note) == -1
        return True

    def _handles_ascending_quarter_leaps(self, note: Note,
                                         index: tuple) -> bool:
        (bar, beat) = index
        if self._counterpoint_lst[-1].get_scale_degree_interval(note) > 2:
            if beat % 2 == 1: return False
            if len(self._counterpoint_lst
                   ) >= 2 and self._counterpoint_lst[-1].get_duration() == 2:
                if self._counterpoint_lst[-2].get_scale_degree_interval(
                        self._counterpoint_lst[-1]) > 0:
                    return False
        return True

    def _handles_descending_quarter_leaps(self, note: Note,
                                          index: tuple) -> bool:
        (bar, beat) = index
        if len(self._counterpoint_lst) < 2: return True
        if self._counterpoint_lst[-2].get_scale_degree_interval(
                self._counterpoint_lst[-1]
        ) < -2 and self._counterpoint_lst[-1].get_duration() == 2:
            if self._counterpoint_lst[-1].get_scale_degree_interval(note) == 2:
                return True
            return self._counterpoint_lst[-2].get_scale_degree_interval(
                note) in [-2, 1, 2]
        return True

    def _handles_repetition(self, note: Note, index: tuple) -> bool:
        (bar, beat) = index
        if self._mr.is_unison(self._counterpoint_lst[-1], note) and (
                beat != 2 or self._counterpoint_lst[-1].get_duration() != 2):
            return False
        return True

    def _handles_eigths(self, note: Note, index: tuple) -> bool:
        (bar, beat) = index
        if beat == 1.5 and abs(self._counterpoint_lst[-1].
                               get_scale_degree_interval(note)) != 2:
            return False
        if beat != 2: return True
        if self._counterpoint_lst[-1].get_duration() != 1: return True
        first_interval = self._counterpoint_lst[-3].get_scale_degree_interval(
            self._counterpoint_lst[-2])
        second_interval = self._counterpoint_lst[-2].get_scale_degree_interval(
            self._counterpoint_lst[-1])
        third_interval = self._counterpoint_lst[-1].get_scale_degree_interval(
            note)
        if abs(third_interval) != 2 or (second_interval == 2 and third_interval
                                        == -2) or (first_interval == 2
                                                   and second_interval == -2):
            return False
        return True

    def _handles_highest(self, note: Note, index: tuple) -> bool:
        if self._attempt_params[
                "highest_has_been_placed"] and self._mr.is_unison(
                    self._attempt_params["highest"], note):
            return False
        return True

    def _handles_resolution_of_anticipation(self, note: Note,
                                            index: tuple) -> bool:
        if len(self._counterpoint_lst) < 2 or not self._mr.is_unison(
                self._counterpoint_lst[-2], self._counterpoint_lst[-1]):
            return True
        return self._counterpoint_lst[-1].get_scale_degree_interval(note) == -2

    def _handles_repeated_two_notes(self, note: Note, index: tuple) -> bool:
        (bar, beat) = index
        if len(self._counterpoint_lst) < 3: return True
        if not self._mr.is_unison(
                self._counterpoint_lst[-3],
                self._counterpoint_lst[-1]) or not self._mr.is_unison(
                    self._counterpoint_lst[-2], note):
            return True
        if self._counterpoint_lst[-1].get_scale_degree_interval(note) != 2:
            return False
        if self._counterpoint_lst[-2].get_duration() != 8 or beat != 0:
            return False
        return True

    def _handles_quarter_between_two_leaps(self, note: Note,
                                           index: tuple) -> bool:
        if self._counterpoint_lst[-1].get_duration() != 2 or len(
                self._counterpoint_lst) < 2:
            return True
        first_interval, second_interval = self._counterpoint_lst[
            -2].get_scale_degree_interval(
                self._counterpoint_lst[-1]
            ), self._counterpoint_lst[-1].get_scale_degree_interval(note)
        if abs(first_interval) == 2 or abs(second_interval) == 2:
            return True
        if first_interval > 0 and second_interval < 0: return False
        if first_interval == -8 and second_interval == 8: return False
        return True

    def _handles_upper_neighbor(self, note: Note, index: tuple) -> bool:
        (bar, beat) = index
        if (beat % 2 == 0 and self._counterpoint_lst[-1].get_duration() == 2
                and self._counterpoint_lst[-1].get_scale_degree_interval(note)
                == -2 and len(self._counterpoint_lst) >= 2
                and self._counterpoint_lst[-2].get_scale_degree_interval(
                    self._counterpoint_lst[-1]) == 2
                and self._counterpoint_lst[-2].get_duration() != 2):
            return False
        return True

    def _handles_antipenultimate_bar(self, note: Note, index: tuple) -> bool:
        if index == (self._length - 3, 2):
            if note.get_accidental(
            ) != ScaleOption.NATURAL or note.get_scale_degree(
            ) != self._mr.get_final():
                return False
        return True

    ######################################
    ###### harmonic insertion checks ######

    def _filters_dissonance_on_downbeat(self, note: Note,
                                        index: tuple) -> bool:
        (bar, beat) = index
        if beat != 0: return True
        cf_note = self._cantus[bar]
        return self._is_consonant(cf_note, note)

    def _resolves_suspension(self, note: Note, index: tuple) -> bool:
        (bar, beat) = index
        if beat not in [1, 2] or (bar, 0) in self._counterpoint_obj:
            return True
        susp_index = (bar - 1,
                      2) if (bar - 1,
                             2) in self._counterpoint_obj else (bar - 1, 0)
        cf_note, susp = self._cantus[bar], self._counterpoint_obj[susp_index]
        if cf_note.get_scale_degree_interval(
                susp) in LegalIntervalsFifthSpecies["resolvable_dissonance"]:
            return susp.get_scale_degree_interval(note) == -2
        return True

    def _prepares_weak_quarter_dissonance(self, note: Note,
                                          index: tuple) -> bool:
        (bar, beat) = index
        if beat % 2 != 1 or self._is_consonant(self._cantus[bar], note):
            return True
        if not self._is_consonant(self._cantus[bar],
                                  self._counterpoint_lst[-1]):
            return False
        return abs(
            self._counterpoint_lst[-1].get_scale_degree_interval(note)) == 2

    def _resolves_weak_quarter_dissonance(self, note: Note,
                                          index: tuple) -> bool:
        (bar, beat) = index
        if beat % 2 != 0 or self._counterpoint_lst[-1].get_duration() != 2:
            return True
        if self._is_consonant(self._cantus[bar if beat > 0 else bar - 1],
                              self._counterpoint_lst[-1]):
            return True
        first_interval = self._counterpoint_lst[-2].get_scale_degree_interval(
            self._counterpoint_lst[-1])
        second_interval = self._counterpoint_lst[-1].get_scale_degree_interval(
            note)
        if second_interval not in [-3, -2, 1]: return False
        if first_interval == 2 and second_interval == -3: return False
        return True

    def _resolves_cambiata_tail(self, note: Note, index: tuple) -> bool:
        (bar, beat) = index
        if beat == 1.5: return True
        first_index, second_index = (bar - 1, 1), (bar - 1, 2)
        if beat in [1, 2]:
            first_index, second_index = (bar - 1, 3), (bar, 0)
        if beat == 3:
            first_index, second_index = (bar, 1), (bar, 2)
        if first_index not in self._counterpoint_obj or second_index not in self._counterpoint_obj:
            return True
        cf_note = self._cantus[bar if beat == 3 else bar - 1]
        if not self._is_consonant(
                cf_note, self._counterpoint_obj[first_index]
        ) and self._counterpoint_obj[first_index].get_scale_degree_interval(
                self._counterpoint_obj[second_index]) == -3:
            return self._counterpoint_lst[-1].get_scale_degree_interval(
                note) == 2
        return True

    def _prepares_weak_half_note(self, note: Note, index: tuple) -> bool:
        (bar, beat) = index
        if beat != 2: return True
        cf_note = self._cantus[bar]
        if self._is_consonant(cf_note, note): return True
        if (bar, 0) not in self._counterpoint_obj or self._counterpoint_obj[
            (bar, 0)].get_duration() != 4:
            return False
        return abs(
            self._counterpoint_obj[(bar,
                                    0)].get_scale_degree_interval(note)) == 2

    def _resolves_dissonant_quarter_on_weak_half_note(self, note: Note,
                                                      index: tuple) -> bool:
        (bar, beat) = index
        if beat != 3 or (bar, 2) not in self._counterpoint_obj: return True
        if self._is_consonant(self._cantus[bar],
                              self._counterpoint_obj[(bar, 2)]):
            return True
        return self._counterpoint_obj[(
            bar, 2)].get_scale_degree_interval(note) == -2

    def _resolves_passing_half_note(self, note: Note, index: tuple) -> bool:
        (bar, beat) = index
        if beat != 0 or (
                bar - 1,
                2) not in self._counterpoint_obj or self._counterpoint_obj[
                    (bar - 1, 2)].get_duration() != 4:
            return True
        if self._is_consonant(self._counterpoint_obj[(bar - 1, 2)],
                              self._cantus[bar - 1]):
            return True
        return self._counterpoint_lst[-2].get_scale_degree_interval(
            self._counterpoint_lst[-1]
        ) == self._counterpoint_lst[-1].get_scale_degree_interval(note)

    def _handles_hiddens(self, note: Note, index: tuple) -> bool:
        (bar, beat) = index
        if beat != 0 or self._cantus[bar].get_chromatic_interval(note) not in [
                -19, -12, -7, 0, 7, 12, 19
        ]:
            return True
        upper_interval = self._counterpoint_lst[-1].get_scale_degree_interval(
            note)
        lower_interval = self._cantus[bar - 1].get_scale_degree_interval(
            self._cantus[bar])
        if (upper_interval > 0
                and lower_interval > 0) or (upper_interval < 0
                                            and lower_interval < 0):
            return False
        return True

    def _handles_parallels(self, note: Note, index: tuple) -> bool:
        (bar, beat) = index
        if beat == 2 and self._counterpoint_lst[-1].get_duration(
        ) >= 8 and self._cantus[bar].get_chromatic_interval(note) in [
                -19, -12, 0, 12, 19
        ]:
            return self._cantus[bar].get_chromatic_interval(
                note) != self._cantus[bar - 1].get_chromatic_interval(
                    self._counterpoint_lst[-1])
        if beat != 0 or self._cantus[bar].get_chromatic_interval(note) not in [
                -19, -12, 0, 12, 19
        ]:
            return True
        if (bar - 1, 2) in self._counterpoint_obj and self._cantus[
                bar - 1].get_chromatic_interval(self._counterpoint_obj[(
                    bar - 1,
                    2)]) == self._cantus[bar].get_chromatic_interval(note):
            return False
        index_to_check = (bar - 1, 0)
        if index_to_check not in self._counterpoint_obj or self._counterpoint_obj[
                index_to_check].get_duration() == 2:
            return True
        if self._cantus[bar - 1].get_chromatic_interval(
                self._counterpoint_obj[index_to_check]
        ) == self._cantus[bar].get_chromatic_interval(note):
            return False
        return True

    def _handles_doubled_leading_tone(self, note: Note, index: tuple) -> bool:
        (bar, beat) = index
        if beat != 0 or self._cantus[bar].get_chromatic_interval(note) not in [
                -12, 0, 12
        ]:
            return True
        if (note.get_scale_degree() + 1) % 7 == self._mr.get_final():
            return False
        return True

    ######################################
    ###### melodic rhythms filters #######

    def _handles_runs(self, note: Note, index: tuple, durs: set) -> set:
        (bar, beat) = index
        if index in self._attempt_params["run_indices"]:
            return {2} if 2 in durs else set()
        if (bar, beat + 1) in self._attempt_params["run_indices"]:
            for d in [4, 6, 8, 12]:
                durs.discard(d)
        two_beats_next = (bar, beat + 2) if beat == 0 else (bar + 1, 0)
        if two_beats_next in self._attempt_params["run_indices"]:
            for d in [2, 6, 8, 12]:
                durs.discard(d)
        three_beats_next = (bar, beat + 3) if beat == 0 else (bar + 1, 1)
        if three_beats_next in self._attempt_params["run_indices"]:
            for d in [2, 8, 12]:
                durs.discard(d)
        if (bar + 1, 2) in self._attempt_params["run_indices"]:
            durs.discard(12)
        return durs

    def _get_durations_from_beat(self, index: tuple) -> set:
        (bar, beat) = index
        if beat == 1.5: return {1}
        if beat == 3: return {2}
        if beat == 1: return {1, 2}
        if beat == 2: return {2, 4, 6, 8}
        if beat == 0: return {2, 4, 6, 8, 12} if bar == 0 else {2, 4, 6, 8}

    def _handles_consecutive_quarters(self, note: Note, index: tuple,
                                      durs: set) -> set:
        (bar, beat) = index
        if self._counterpoint_lst[-1].get_duration() != 2: return durs
        if self._counterpoint_lst[-1].get_scale_degree_interval(
                note) > 0 and beat == 2:
            durs.discard(4)
        if beat == 2 and self._counterpoint_lst[-2].get_duration(
        ) == 2 and self._counterpoint_lst[-3].get_duration() != 2:
            durs.discard(4)
        for i in range(len(self._counterpoint_lst) - 2, -1, -1):
            if self._counterpoint_lst[i].get_duration() != 2:
                return durs
            if abs(self._counterpoint_lst[i].get_scale_degree_interval(
                    self._counterpoint_lst[i + 1])) > 2:
                durs.discard(2)
                return durs

    def _handles_penultimate_bar(self, note: Note, index: tuple,
                                 durs: set) -> set:
        (bar, beat) = index
        if bar != self._length - 2: return durs
        if beat == 2:
            durs.discard(8)
            durs.discard(6)
            durs.discard(2)
        if beat == 0:
            durs.discard(12)
        return durs

    def _handles_first_eighth(self, note: Note, index: tuple,
                              durs: set) -> set:
        (bar, beat) = index
        if (beat == 1 and
                abs(self._counterpoint_lst[-1].get_scale_degree_interval(note))
                != 2) or self._attempt_params["eighths_have_been_placed"]:
            durs.discard(1)
        if beat == 2 and self._counterpoint_lst[-1].get_duration(
        ) == 1 and self._counterpoint_lst[-3].get_duration() == 2:
            durs.discard(4)
            durs.discard(8)
            durs.discard(6)
        return durs

    def _handles_sharp_durations(self, note: Note, index: tuple,
                                 durs: set) -> set:
        if self._mr.is_sharp(note): durs.discard(12)
        return durs

    def _handles_whole_note_quota(self, note: Note, index: tuple,
                                  durs: set) -> set:
        (bar, beat) = index
        if self._attempt_params[
                "num_on_beat_whole_notes_placed"] == self._attempt_params[
                    "max_on_beat_whole_notes"]:
            if beat == 0:
                durs.discard(8)
                durs.discard(12)
        return durs

    def _handles_repeated_note(self, note: Note, index: tuple,
                               durs: set) -> set:
        if self._mr.is_unison(self._counterpoint_lst[-1], note):
            durs.discard(4)
            durs.discard(2)
        return durs

    def _handles_rhythm_after_descending_quarter_leap(self, note: Note,
                                                      index: tuple,
                                                      durs: set) -> set:
        (bar, beat) = index
        if self._counterpoint_lst[-1].get_duration(
        ) == 2 and self._counterpoint_lst[-1].get_scale_degree_interval(
                note) < -2:
            durs.discard(8)
            durs.discard(12)
            durs.discard(6)
        return durs

    def _handles_dotted_whole_after_quarters(self, note: Note, index: tuple,
                                             durs: set) -> set:
        if self._counterpoint_lst[-1].get_duration() == 2: durs.discard(12)
        return durs

    def _handles_repeated_dotted_halfs(self, note: Note, index: tuple,
                                       durs: set) -> set:
        (bar, beat) = index
        if beat != 0: return durs
        if (bar - 1, 0) in self._counterpoint_obj and self._counterpoint_obj[(
                bar - 1, 0)].get_duration() == 6:
            durs.discard(6)
        if bar % 2 == 0 and (
                bar - 2,
                0) in self._counterpoint_obj and self._counterpoint_obj[(
                    bar - 2, 0)].get_duration() == 6:
            durs.discard(6)
        return durs

    def _handles_antipenultimate_rhythm(self, note: Note, index: tuple,
                                        durs: set) -> set:
        if index == (self._length - 3, 2):
            durs.discard(4)
            durs.discard(2)
        if index == (self._length - 3, 0):
            durs.discard(8)
            durs.discard(6)
        return durs

    def _handles_half_note_chain(self, note: Note, index: tuple,
                                 durs: set) -> set:
        (bar, beat) = index
        if beat == 2 and len(self._counterpoint_lst) >= 3:
            if self._counterpoint_lst[-3].get_duration(
            ) == 4 and self._counterpoint_lst[-2].get_duration(
            ) == 4 and self._counterpoint_lst[-1].get_duration() == 4:
                durs.discard(4)
        return durs

    def _handles_missing_syncopation(self, note: Note, index: tuple,
                                     durs: set) -> set:
        (bar, beat) = index
        if beat != 0 or (bar - 1, 0) not in self._counterpoint_obj or (
                bar - 2, 0) not in self._counterpoint_obj or (
                    bar - 3, 0) not in self._counterpoint_obj:
            return durs
        if self._counterpoint_obj[
            (bar - 3, 0)].get_duration() >= 4 and self._counterpoint_obj[
                (bar - 2, 0)].get_duration() >= 4 and self._counterpoint_obj[
                    (bar - 1, 0)].get_duration() >= 4:
            durs.discard(4)
            durs.discard(6)
            durs.discard(8)
        return durs

    def _handles_quarters_after_whole(self, note: Note, index: tuple,
                                      durs: set) -> set:
        (bar, beat) = index
        if beat == 0 and self._counterpoint_lst[-1].get_duration() == 8:
            durs.discard(2)
        return durs

    def _handles_repetition_on_consecutive_syncopated_measures(
            self, note: Note, index: tuple, durs: set) -> set:
        (bar, beat) = index
        if beat == 2 and (bar, 0) not in self._counterpoint_obj and (
                bar - 1, 2) in self._counterpoint_obj and self._mr.is_unison(
                    self._counterpoint_obj[(bar - 1, 2)], note):
            durs.discard(6)
            durs.discard(8)
        return durs

    ##########################################
    ###### harmonic rhythm filters ###########

    def _get_first_beat_options(self, note: Note) -> set:
        durs = {4, 6, 8, 12}
        if not self._is_consonant(
                self._cantus[1],
                note) and self._cantus[1].get_scale_degree_interval(
                    note
                ) not in LegalIntervalsFifthSpecies["resolvable_dissonance"]:
            durs.discard(12)
        return durs

    def _prepares_suspension(self, note: Note, index: tuple, durs: set) -> set:
        (bar, beat) = index
        if bar == self._length - 1 or self._is_consonant(
                self._cantus[bar + 1], note):
            return durs
        if self._cantus[bar + 1].get_scale_degree_interval(
                note) in LegalIntervalsFifthSpecies["resolvable_dissonance"]:
            return durs
        if beat == 0:
            durs.discard(12)
        if beat == 2:
            durs.discard(8)
            durs.discard(6)
        return durs

    def _resolves_cambiata(self, note: Note, index: tuple, durs: set) -> set:
        (bar, beat) = index
        if beat % 2 != 0: return durs
        index_to_check = (bar - 1, 3) if beat == 0 else (bar, 1)
        if index_to_check not in self._counterpoint_obj: return durs
        if self._is_consonant(self._cantus[bar - 1 if beat == 0 else bar],
                              self._counterpoint_obj[index_to_check]):
            return durs
        if self._counterpoint_obj[index_to_check].get_scale_degree_interval(
                note) != -3:
            return durs
        durs.discard(12)
        durs.discard(8)
        durs.discard(6)
        return durs

    def _handles_weak_half_note_dissonance(self, note: Note, index: tuple,
                                           durs: set) -> set:
        (bar, beat) = index
        if beat != 2 or self._is_consonant(self._cantus[bar], note):
            return durs
        durs.discard(6)
        durs.discard(8)
        if self._counterpoint_lst[-1].get_scale_degree_interval(note) == 2:
            durs.discard(2)
        return durs

    ##########################################
    ########### index checks #################

    def _highest_and_lowest_placed(self, index: tuple) -> bool:
        (bar, beat) = index
        if bar >= self._attempt_params[
                "highest_must_appear_by"] and not self._attempt_params[
                    "highest_has_been_placed"]:
            return False
        if bar >= self._attempt_params[
                "lowest_must_appear_by"] and not self._attempt_params[
                    "lowest_has_been_placed"]:
            return False
        return True

    ##########################################
    ########### insert functions #############

    def _bury_indices(self, note: Note, index: tuple) -> None:
        (bar, beat) = index
        self._stored_indices.append([])
        for i in range(1, note.get_duration()):
            new_beat, new_bar = beat + (i / 2), bar
            while new_beat >= 4:
                new_beat -= 4
                new_bar += 1
            if (new_bar, new_beat) in self._counterpoint_obj:
                self._stored_indices[-1].append((new_bar, new_beat))
                del self._counterpoint_obj[(new_bar, new_beat)]
                self._all_indices.remove((new_bar, new_beat))
                self._remaining_indices.remove((new_bar, new_beat))

    def _unbury_indices(self, note: Note, index: tuple) -> None:
        i = len(self._counterpoint_lst) + 1
        while len(self._stored_indices[-1]) > 0:
            next_index = self._stored_indices[-1].pop()
            self._all_indices.insert(i, next_index)
            self._remaining_indices.append(next_index)
            self._counterpoint_obj[next_index] = None
        self._stored_indices.pop()

    def _check_for_highest_and_lowest(self, note: Note, index: tuple) -> None:
        if self._mr.is_unison(note, self._attempt_params["highest"]):
            self._attempt_params["highest_has_been_placed"] = True
        if self._mr.is_unison(note, self._attempt_params["lowest"]):
            self._attempt_params["lowest_has_been_placed"] = True

    def _check_for_on_beat_whole_note(self, note: Note, index: tuple) -> None:
        (bar, beat) = index
        if beat == 0 and note.get_duration() >= 8:
            self._attempt_params["num_on_beat_whole_notes_placed"] += 1

    def _check_if_new_run_is_added(self, note: Note, index: tuple) -> None:
        run_length = 0
        for i in range(len(self._counterpoint_lst) - 1, -1, -1):
            dur = self._counterpoint_lst[i].get_duration()
            if dur > 2:
                break
            run_length += dur / 2
        # if run_length > 7:
        #     print("run length:", run_length)
        if run_length == 4:
            self._attempt_params["num_runs_placed"] += 1
        if run_length == self._attempt_params["min_length_of_max_quarter_run"]:
            self._attempt_params["max_quarter_run_has_been_placed"] = True

    def _check_if_eighths_are_added(self, note: Note, index: tuple) -> None:
        (bar, beat) = index
        if beat == 1.5: self._attempt_params["eighths_have_been_placed"] = True

    ##########################################
    ########### final checks #################

    def _parameters_are_correct(self) -> bool:
        # print("num runs placed:", self._attempt_params["num_runs_placed"])
        # print("max run has been placed?", self._attempt_params["max_quarter_run_has_been_placed"])
        return self._attempt_params["num_runs_placed"] >= self._attempt_params[
            "min_runs_of_length_4_or_more"] and self._attempt_params[
                "max_quarter_run_has_been_placed"]

    def _has_only_one_octave(self) -> bool:
        num_octaves = 0
        for i in range(
                0 if
                self._counterpoint_lst[0].get_accidental() != ScaleOption.REST
                else 1,
                len(self._counterpoint_lst) - 1):
            if abs(self._counterpoint_lst[i].get_scale_degree_interval(
                    self._counterpoint_lst[i + 1])) == 8:
                num_octaves += 1
                if num_octaves > 1:
                    return False
        return True

    def _no_unresolved_leading_tones(self) -> bool:
        for i in range(1, self._length - 2):
            if (i, 0) not in self._counterpoint_obj:
                notes_to_check = []
                for index in [(i - 1, 0), (i - 1, 1), (i - 1, 2), (i, 2)]:
                    if index in self._counterpoint_obj:
                        notes_to_check.append(self._counterpoint_obj[index])
                for j, note in enumerate(notes_to_check):
                    if self._mr.is_sharp(note):
                        resolved = False
                        for k in range(j + 1, len(notes_to_check)):
                            if note.get_chromatic_interval(
                                    notes_to_check[k]) == 1:
                                resolved = True
                        if not resolved: return False
        return True

    ##########################################
    ########### scoring ######################

    def _score_solution(self, solution: list[Note]) -> int:
        score = 0
        self._map_solution_onto_counterpoint_dict(solution)
        num_ties = 0
        num_tied_dotted_halfs = 0
        num_tied_wholes = 0
        ties = [False] * (self._length - 2)
        for i in range(1, self._length - 1):
            if (i, 0) not in self._counterpoint_obj:
                ties[i - 1] = True
                num_ties += 1
                if (i - 1, 2) in self._counterpoint_obj:
                    if self._counterpoint_obj[(i - 1, 2)].get_duration() == 6:
                        num_tied_dotted_halfs += 1
                    else:
                        num_tied_wholes += 1
        ideal_ties = 3 if self._length < 12 else 4
        score += abs(ideal_ties - num_ties) * 10
        score += abs(num_tied_wholes - num_tied_dotted_halfs) * 5
        has_isolated_tie = False
        for i in range(1, len(ties) - 1):
            if ties[i - 1] == False and ties[i] == True and ties[i +
                                                                 1] == False:
                has_isolated_tie = True
        if has_isolated_tie: score -= 12
        num_quarter_runs_starting_on_beat = 0
        num_quarter_runs_starting_on_beat_of_length_two = 0
        num_other_two_note_quarter_runs = 0
        for i, index in enumerate(self._all_indices):
            (bar, beat) = index
            if beat == 0 and self._counterpoint_lst[i].get_duration(
            ) == 2 and self._counterpoint_lst[i - 1].get_duration() != 2:
                num_quarter_runs_starting_on_beat += 1
                if self._counterpoint_lst[i + 2].get_duration() != 2:
                    num_quarter_runs_starting_on_beat_of_length_two += 1
            if beat == 2 and self._counterpoint_lst[i].get_duration(
            ) == 2 and self._counterpoint_lst[i - 1].get_duration() != 2:
                if self._counterpoint_lst[i + 2].get_duration() != 2:
                    num_other_two_note_quarter_runs += 1
        score += num_quarter_runs_starting_on_beat * 60 + num_quarter_runs_starting_on_beat_of_length_two * 60 + num_other_two_note_quarter_runs * 45
        num_fifths, num_octaves = 0, 0
        for i in range(1, self._length - 2):
            if (i, 0) in self._counterpoint_obj:
                intvl = self._cantus[i].get_chromatic_interval(
                    self._counterpoint_obj[(i, 0)])
                if intvl in [-19, -7, 7, 19]: num_fifths += 1
                elif intvl in [-12, 0, 12]: num_octaves += 1
        score += num_fifths * 30 + num_octaves * 10
        num_steps = 0
        for i in range(len(self._counterpoint_lst) - 1):
            if abs(self._counterpoint_lst[i].get_scale_degree_interval(
                    self._counterpoint_lst[i + 1])) == 2:
                num_steps += 1
        score += math.floor(
            150 * abs(.712 - (num_steps / (len(self._counterpoint_lst) - 1))))
        return score

    def _map_solution_onto_counterpoint_dict(self,
                                             solution: list[Note]) -> None:
        self._counterpoint_obj = {}
        self._counterpoint_lst = []
        self._all_indices = []
        bar, beat = 0, 0
        self._all_indices = []
        for note in solution:
            self._counterpoint_obj[(bar, beat)] = note
            self._counterpoint_lst.append(note)
            self._all_indices.append((bar, beat))
            beat += note.get_duration() / 2
            while beat >= 4:
                beat -= 4
                bar += 1

    def _is_consonant(self, note1: Note, note2: Note) -> bool:
        (sdg_interval, chro_interval) = self._mr.get_intervals(note1, note2)
        if sdg_interval not in LegalIntervalsFifthSpecies["harmonic_scalar"]:
            return False
        if chro_interval not in LegalIntervalsFifthSpecies[
                "harmonic_chromatic"]:
            return False
        if (sdg_interval, chro_interval
            ) in LegalIntervalsFifthSpecies["forbidden_combinations"]:
            return False
        return True
Example #12
0
    def __init__(self,
                 length: int = None,
                 height: int = 3,
                 cf_index: int = 1,
                 mode: ModeOption = None):
        self._height = height if height <= 6 else 6
        self._mode = mode or ModeOption.AEOLIAN
        self._length = length or 8 + math.floor(
            random() * 5)  #todo: replace with normal distribution
        self._range = [
            RangeOption.BASS, RangeOption.TENOR, RangeOption.ALTO,
            RangeOption.SOPRANO
        ] if height == 4 else [
            RangeOption.BASS, RangeOption.ALTO, RangeOption.SOPRANO
        ]
        if height == 5:
            self._range = [
                RangeOption.BASS, RangeOption.TENOR, RangeOption.ALTO,
                RangeOption.SOPRANO, RangeOption.SOPRANO
            ]
        if height == 6:
            self._range = [
                RangeOption.BASS, RangeOption.TENOR, RangeOption.TENOR,
                RangeOption.ALTO, RangeOption.SOPRANO, RangeOption.SOPRANO
            ]
        self._mr = [
            ModeResolver(self._mode, range_option=self._range[i])
            for i in range(self._height)
        ]
        self._cf_index = cf_index
        self._melodic_insertion_checks = [
            self._handles_adjacents, self._handles_repetition,
            self._handles_repeated_two_notes,
            self._handles_repeated_three_note,
            self._handles_ascending_minor_sixth, self._handles_highest,
            self._handles_final_leading_tone,
            self._handles_final_note_in_top_voice, self._handles_sharp_notes,
            self._handles_large_leaps
        ]

        self._harmonic_insertion_checks = [
            self._handles_dissonance_with_bottom_voice,
            self._handles_dissonance_with_inner_voices,
            self._handles_hiddens_from_above, self._handles_hiddens_from_below,
            self._handles_parallels, self._handles_leaps_in_parallel_motion,
            self._handles_doubled_leading_tone, self._handles_first_note,
            self._handles_last_note, self._handles_three_voices_unison,
            self._handles_sixth_in_penultimate_bar
        ]

        self._index_checks = [self._highest_and_lowest_placed]
        self._change_params = [
            self._check_for_highest_and_lowest,
        ]
        self._final_checks = [
            self._top_ends_by_step, self._augs_and_dims_resolved,
            self._no_closely_repeated_sonorities, self._check_last_top_note
        ]
        self._params = [
            "highest_has_been_placed",
            "lowest_has_been_placed",
        ]
class GenerateImitationTheme:
    def __init__(self, mode: ModeOption, hexachord: HexachordOption,
                 highest: Note, lowest: Note):
        self._mode = mode
        self._length = randint(3, 6)
        self._hexachord = hexachord
        self._mr = ModeResolver(self._mode)
        self._attempt_params = {
            "first_note": None,
            "second_note": None,
            "second_note_must_appear_by": None,
            "second_note_has_been_placed": None,
            "highest_has_been_placed": None,
            "highest": highest,
            "lowest": lowest
        }
        self._insertion_checks = [
            self._handles_adjacents, self._handles_interval_order,
            self._handles_nearby_augs_and_dims,
            self._handles_nearby_leading_tones,
            self._handles_ascending_minor_sixth,
            self._handles_ascending_quarter_leaps,
            self._handles_descending_quarter_leaps, self._handles_repetition,
            self._handles_highest, self._handles_resolution_of_anticipation,
            self._handles_repeated_two_notes,
            self._handles_quarter_between_two_leaps,
            self._handles_upper_neighbor, self._stops_quarter_leaps,
            self._stops_leaps_outside_of_hexachord
        ]
        self._rhythm_filters = [
            self._handles_consecutive_quarters, self._handles_repeated_note,
            self._handles_rhythm_after_descending_quarter_leap,
            self._handles_repeated_dotted_halfs, self._handles_slow_beginning,
            self._handles_consecutive_whole_notes,
            self._handles_consecutive_syncopation,
            self._prevents_two_note_quarter_runs,
            self._no_quarter_runs_after_whole_notes
        ]
        self._index_checks = [self._both_notes_placed]
        self._change_params = [self._check_for_highest_and_second_note]
        self._final_checks = [
            self._has_only_one_octave,
            # self._starts_with_longer
        ]
        self._params = ["second_note_has_been_placed"]

    def print_counterpoint(self):
        print("  FIFTH SPECIES:")
        for i in range(self._length):
            for j in range(4):
                cntpt_note = str(self._counterpoint_obj[(i, j)]) if (
                    i, j) in self._counterpoint_obj else ""
                if cntpt_note is None: cntpt_note = "None"
                show_index = "    "
                if j == 0:
                    show_index = str(i) + ":  " if i < 10 else str(i) + ": "
                print(show_index + "   " + str(cntpt_note))

    def get_optimal(self):
        if len(self._solutions) == 0:
            return None
        optimal = self._solutions[0]
        self._map_solution_onto_counterpoint_dict(optimal)
        # self.print_counterpoint()
        return [optimal]

    def generate_theme(self):
        self._solutions = []
        attempts = 1
        while len(self._solutions) < 1 and attempts < 100:
            # if attempts % 1000 == 0:
            # print("attempt", attempts)
            self._num_backtracks = 0
            self._solutions_this_attempt = 0
            initialized = self._initialize()
            self._backtrack()
            attempts += 1
        # print("number of attempts:", attempts)
        # print("number of solutions:", len(self._solutions))
        if len(self._solutions) > 0:
            shuffle(self._solutions)
            self._solutions.sort(key=lambda sol: self._score_solution(sol))

    def _initialize(self) -> bool:
        indices = []
        for i in range(self._length):
            indices += [(i, 0), (i, 1), (i, 2), (i, 3)]
        self._all_indices = indices[:]
        self._remaining_indices = indices[:]
        self._remaining_indices.reverse()
        #initialize counterpoint data structure, that will map indices to notes
        self._counterpoint_obj = {}
        for index in self._all_indices:
            self._counterpoint_obj[index] = None
        #also initialize counterpoint data structure as list
        self._counterpoint_lst = []
        #initialize parameters for this attempt
        hex_notes = self._mr.get_scale_degrees_of_outline(self._hexachord)
        self._attempt_params["first_note"], self._attempt_params[
            "second_note"] = hex_notes[0], hex_notes[1]
        if random() < 0.5:
            self._attempt_params["first_note"], self._attempt_params[
                "second_note"] = hex_notes[1], hex_notes[0]
        self._attempt_params["second_note_must_appear_by"] = randint(
            1, self._length - 2)
        self._store_params = []
        self._stored_indices = []
        self._valid_pitches = [
            self._attempt_params["lowest"], self._attempt_params["highest"]
        ]  #order is unimportant
        for i in range(
                2, self._attempt_params["lowest"].get_scale_degree_interval(
                    self._attempt_params["highest"])):
            self._valid_pitches.append(
                self._mr.get_default_note_from_interval(
                    self._attempt_params["lowest"], i))
        return True

    def _backtrack(self) -> None:
        if (self._solutions_this_attempt > 0 or self._num_backtracks > 50000
            ) or (self._solutions_this_attempt == 0
                  and self._num_backtracks > 1000000):
            return
        self._num_backtracks += 1
        # if self._num_backtracks % 10000 == 0:
        # print("backtrack number:", self._num_backtracks)
        if len(self._remaining_indices) == 0:
            #print("found possible solution")
            if self._passes_final_checks():
                # if self._solutions_this_attempt == 0:
                # print("FOUND SOLUTION!")
                self._solutions.append(self._counterpoint_lst[:])
                self._solutions_this_attempt += 1
            return
        (bar, beat) = self._remaining_indices.pop()

        if self._passes_index_checks((bar, beat)):
            candidates = list(
                filter(lambda n: self._passes_insertion_checks(n, (bar, beat)),
                       self._valid_pitches))
            shuffle(candidates)
            # print("candidates for index", bar, beat, ": ", len(candidates))
            notes_to_insert = []
            for candidate in candidates:
                durations = self._get_valid_durations(candidate, (bar, beat))
                for dur in durations:
                    if dur in [2, 6, 4, 8, 12, 16]:
                        notes_to_insert.append(
                            Note(candidate.get_scale_degree(),
                                 candidate.get_octave(),
                                 dur,
                                 accidental=candidate.get_accidental()))
            shuffle(notes_to_insert)
            for note_to_insert in notes_to_insert:
                self._insert_note(note_to_insert, (bar, beat))
                self._backtrack()
                self._remove_note(note_to_insert, (bar, beat))
        self._remaining_indices.append((bar, beat))

    def _passes_index_checks(self, index: tuple) -> bool:
        for check in self._index_checks:
            if not check(index): return False
        return True

    def _passes_insertion_checks(self, note: Note, index: tuple) -> bool:
        (bar, beat) = index
        if bar == 0 and beat == 0:
            return self._check_starting_pitch(note)
        for check in self._insertion_checks:
            if not check(note, (bar, beat)):
                # print("failed insertion check", check.__name__, str(note), index)
                return False
        # print("passed insertion checks!", str(note), index)
        return True

    def _get_valid_durations(self, note: Note, index: tuple) -> set:
        (bar, beat) = index
        if bar == 0 and beat == 0:
            if self._length == 3:
                return {6}
            else:
                return {16, 12, 8, 6}
        durs = self._get_durations_from_beat(beat)
        prev_length = len(durs)
        for check in self._rhythm_filters:
            durs = check(note, index, durs)
            if len(durs) == 0: break
        return durs

    def _insert_note(self, note: Note, index: tuple) -> set:
        self._counterpoint_lst.append(note)
        self._counterpoint_obj[index] = note
        self._store_params.append({})
        for param in self._params:
            self._store_params[-1][param] = self._attempt_params[param]
        self._bury_indices(note, index)
        for check in self._change_params:
            check(note, index)

    def _remove_note(self, note: Note, index: tuple) -> set:
        self._counterpoint_lst.pop()
        self._counterpoint_obj[index] = None
        for param in self._params:
            self._attempt_params[param] = self._store_params[-1][param]
        self._store_params.pop()
        self._unbury_indices(note, index)

    def _passes_final_checks(self) -> bool:
        for check in self._final_checks:
            if not check():
                # print("failed final check:", check.__name__)
                return False
        return True

    ######################################
    ########## insertion checks ##########

    def _check_starting_pitch(self, note: Note) -> bool:
        if note.get_accidental() != ScaleOption.NATURAL: return False
        if note.get_scale_degree() != self._attempt_params["first_note"]:
            return False
        return True

    def _handles_adjacents(self, note: Note, index: tuple) -> bool:
        (bar, beat) = index
        (sdg_interval,
         chro_interval) = self._mr.get_intervals(self._counterpoint_lst[-1],
                                                 note)
        if sdg_interval not in LegalIntervalsFifthSpecies[
                "adjacent_melodic_scalar"]:
            return False
        if chro_interval not in LegalIntervalsFifthSpecies[
                "adjacent_melodic_chromatic"]:
            return False
        if (sdg_interval, chro_interval
            ) in LegalIntervalsFifthSpecies["forbidden_combinations"]:
            return False
        return True

    def _handles_interval_order(self, note: Note, index: tuple) -> bool:
        potential_interval = self._counterpoint_lst[
            -1].get_scale_degree_interval(note)
        if potential_interval >= 3:
            for i in range(len(self._counterpoint_lst) - 2, -1, -1):
                interval = self._counterpoint_lst[i].get_scale_degree_interval(
                    self._counterpoint_lst[i + 1])
                if interval < 0: return True
                if interval > 2: return False
        if potential_interval == 2:
            segment_has_leap = False
            for i in range(len(self._counterpoint_lst) - 2, -1, -1):
                interval = self._counterpoint_lst[i].get_scale_degree_interval(
                    self._counterpoint_lst[i + 1])
                if interval < 0: return True
                if segment_has_leap: return False
                segment_has_leap = interval > 2
        if potential_interval == -2:
            segment_has_leap = False
            for i in range(len(self._counterpoint_lst) - 2, -1, -1):
                interval = self._counterpoint_lst[i].get_scale_degree_interval(
                    self._counterpoint_lst[i + 1])
                if interval > 0: return True
                if segment_has_leap or interval == -8: return False
                segment_has_leap = interval < -2
        if potential_interval <= -3:
            for i in range(len(self._counterpoint_lst) - 2, -1, -1):
                interval = self._counterpoint_lst[i].get_scale_degree_interval(
                    self._counterpoint_lst[i + 1])
                if interval > 0: return True
                if interval < -2: return False
        return True

    def _handles_nearby_augs_and_dims(self, note: Note, index: tuple) -> bool:
        if len(self._counterpoint_lst) < 2: return True
        if self._mr.is_cross_relation(
                note, self._counterpoint_lst[-2]
        ) and self._counterpoint_lst[-1].get_duration() <= 2:
            return False
        if self._counterpoint_lst[-2].get_duration(
        ) != 2 and self._counterpoint_lst[-1].get_duration() != 2:
            return True
        (sdg_interval,
         chro_interval) = self._mr.get_intervals(self._counterpoint_lst[-2],
                                                 note)
        return (abs(sdg_interval) != 2
                or abs(chro_interval) != 3) and (abs(sdg_interval) != 3
                                                 or abs(chro_interval) != 2)

    def _handles_nearby_leading_tones(self, note: Note, index: tuple) -> bool:
        (bar, beat) = index
        if beat == 2 and (
                bar - 1,
                2) in self._counterpoint_obj and self._counterpoint_obj[
                    (bar - 1, 2)].get_duration() > 4:
            if self._mr.is_sharp(self._counterpoint_obj[(
                    bar - 1, 2)]) and self._counterpoint_obj[
                        (bar - 1, 2)].get_chromatic_interval(note) != 1:
                return False
        if beat == 0 and bar != 0:
            prev_measure_notes = []
            for i, num in enumerate([0, 1, 1.5, 2, 3]):
                if (bar - 1,
                        num) in self._counterpoint_obj and self._mr.is_sharp(
                            self._counterpoint_obj[(bar - 1, num)]):
                    resolved = False
                    for j in range(i + 1, 5):
                        next_index = (bar - 1, [0, 1, 1.5, 2, 3][j])
                        if next_index in self._counterpoint_obj and self._counterpoint_obj[
                            (bar - 1, num)].get_chromatic_interval(
                                self._counterpoint_obj[next_index]) == 1:
                            resolved = True
                    if not resolved and self._counterpoint_obj[
                        (bar - 1, num)].get_chromatic_interval(note) != 1:
                        return False
        return True

    def _handles_ascending_minor_sixth(self, note: Note, index: tuple) -> bool:
        if len(self._counterpoint_lst) < 2: return True
        if self._counterpoint_lst[-2].get_chromatic_interval(
                self._counterpoint_lst[-1]) == 8:
            return self._counterpoint_lst[-1].get_chromatic_interval(
                note) == -1
        return True

    def _handles_ascending_quarter_leaps(self, note: Note,
                                         index: tuple) -> bool:
        (bar, beat) = index
        if self._counterpoint_lst[-1].get_scale_degree_interval(note) > 2:
            if beat % 2 == 1: return False
            if len(self._counterpoint_lst
                   ) >= 2 and self._counterpoint_lst[-1].get_duration() == 2:
                if self._counterpoint_lst[-2].get_scale_degree_interval(
                        self._counterpoint_lst[-1]) > 0:
                    return False
        return True

    def _handles_descending_quarter_leaps(self, note: Note,
                                          index: tuple) -> bool:
        (bar, beat) = index
        if len(self._counterpoint_lst) < 2: return True
        if self._counterpoint_lst[-2].get_scale_degree_interval(
                self._counterpoint_lst[-1]
        ) < -2 and self._counterpoint_lst[-1].get_duration() == 2:
            if self._counterpoint_lst[-1].get_scale_degree_interval(note) == 2:
                return True
            return self._counterpoint_lst[-2].get_scale_degree_interval(
                note) in [-2, 1, 2]
        return True

    def _handles_repetition(self, note: Note, index: tuple) -> bool:
        (bar, beat) = index
        if self._mr.is_unison(self._counterpoint_lst[-1], note) and (
                beat != 2 or self._counterpoint_lst[-1].get_duration() != 2):
            return False
        return True

    def _handles_highest(self, note: Note, index: tuple) -> bool:
        if self._attempt_params[
                "highest_has_been_placed"] and self._mr.is_unison(
                    self._attempt_params["highest"], note):
            return False
        return True

    def _handles_resolution_of_anticipation(self, note: Note,
                                            index: tuple) -> bool:
        if len(self._counterpoint_lst) < 2 or not self._mr.is_unison(
                self._counterpoint_lst[-2], self._counterpoint_lst[-1]):
            return True
        return self._counterpoint_lst[-1].get_scale_degree_interval(note) == -2

    def _handles_repeated_two_notes(self, note: Note, index: tuple) -> bool:
        (bar, beat) = index
        if len(self._counterpoint_lst) < 3: return True
        if not self._mr.is_unison(
                self._counterpoint_lst[-3],
                self._counterpoint_lst[-1]) or not self._mr.is_unison(
                    self._counterpoint_lst[-2], note):
            return True
        if self._counterpoint_lst[-1].get_scale_degree_interval(note) != 2:
            return False
        if self._counterpoint_lst[-2].get_duration() != 8 or beat != 0:
            return False
        return True

    def _handles_quarter_between_two_leaps(self, note: Note,
                                           index: tuple) -> bool:
        if self._counterpoint_lst[-1].get_duration() != 2 or len(
                self._counterpoint_lst) < 2:
            return True
        first_interval, second_interval = self._counterpoint_lst[
            -2].get_scale_degree_interval(
                self._counterpoint_lst[-1]
            ), self._counterpoint_lst[-1].get_scale_degree_interval(note)
        if abs(first_interval) == 2 or abs(second_interval) == 2:
            return True
        if first_interval > 0 and second_interval < 0: return False
        if first_interval == -8 and second_interval == 8: return False
        return True

    def _handles_upper_neighbor(self, note: Note, index: tuple) -> bool:
        (bar, beat) = index
        if (beat % 2 == 0 and self._counterpoint_lst[-1].get_duration() == 2
                and self._counterpoint_lst[-1].get_scale_degree_interval(note)
                == -2 and len(self._counterpoint_lst) >= 2
                and self._counterpoint_lst[-2].get_scale_degree_interval(
                    self._counterpoint_lst[-1]) == 2
                and self._counterpoint_lst[-2].get_duration() != 2):
            return False
        return True

    def _stops_quarter_leaps(self, note: Note, index: tuple) -> bool:
        if index != (0, 0) and self._counterpoint_lst[-1].get_duration(
        ) == 2 and abs(self._counterpoint_lst[-1].get_scale_degree_interval(
                note)) != 2:
            return False
        return True

    def _stops_leaps_outside_of_hexachord(self, note: Note,
                                          index: tuple) -> bool:
        if index != (0, 0) and abs(self._counterpoint_lst[-1].
                                   get_scale_degree_interval(note)) > 2:
            hex_notes = [
                self._attempt_params["first_note"],
                self._attempt_params["second_note"]
            ]
            if self._counterpoint_lst[-1].get_scale_degree(
            ) not in hex_notes and note.get_scale_degree() not in hex_notes:
                return False
        return True

    ######################################
    ########## rhythms filters ###########

    def _get_durations_from_beat(self, beat: int) -> set:
        if beat == 3: return {2}
        if beat == 1: return {1, 2}
        if beat == 2: return {2, 4, 6, 8}
        if beat == 0: return {2, 4, 6, 8, 12}

    def _handles_consecutive_quarters(self, note: Note, index: tuple,
                                      durs: set) -> set:
        (bar, beat) = index
        if self._counterpoint_lst[-1].get_duration() != 2: return durs
        if self._counterpoint_lst[-1].get_scale_degree_interval(
                note) > 0 and beat == 2:
            durs.discard(4)
        if beat == 2 and self._counterpoint_lst[-2].get_duration(
        ) == 2 and self._counterpoint_lst[-1].get_duration(
        ) == 2 and self._counterpoint_lst[-3].get_duration() != 2:
            durs.discard(4)
        for i in range(len(self._counterpoint_lst) - 2, -1, -1):
            if self._counterpoint_lst[i].get_duration() != 2:
                return durs
            if abs(self._counterpoint_lst[i].get_scale_degree_interval(
                    self._counterpoint_lst[i + 1])) > 2:
                durs.discard(2)
                return durs
        return durs

    def _handles_repeated_note(self, note: Note, index: tuple,
                               durs: set) -> set:
        if self._mr.is_unison(self._counterpoint_lst[-1], note):
            durs.discard(4)
            durs.discard(2)
        return durs

    def _handles_rhythm_after_descending_quarter_leap(self, note: Note,
                                                      index: tuple,
                                                      durs: set) -> set:
        (bar, beat) = index
        if self._counterpoint_lst[-1].get_duration(
        ) == 2 and self._counterpoint_lst[-1].get_scale_degree_interval(
                note) < -2:
            durs.discard(8)
            durs.discard(6)
        return durs

    def _handles_repeated_dotted_halfs(self, note: Note, index: tuple,
                                       durs: set) -> set:
        (bar, beat) = index
        if (bar - 1,
                beat) in self._counterpoint_obj and self._counterpoint_obj[(
                    bar - 1, beat)].get_duration() == 6:
            durs.discard(6)
        return durs

    def _handles_slow_beginning(self, note: Note, index: tuple,
                                durs: set) -> set:
        (bar, beat) = index
        if bar <= self._length - 6:
            durs.discard(2)
            durs.discard(4)
            durs.discard(6)
        if bar <= self._length - 5:
            durs.discard(2)
            durs.discard(4)
        return durs

    def _handles_consecutive_whole_notes(self, note: Note, index: tuple,
                                         durs: set) -> set:
        (bar, beat) = index
        if len(self._counterpoint_lst
               ) >= 2 and self._counterpoint_lst[-2].get_duration(
               ) == 8 and self._counterpoint_lst[-1].get_duration() == 8:
            durs.discard(8)
        return durs

    def _handles_consecutive_half_notes(self, note: Note, index: tuple,
                                        durs: set) -> set:
        (bar, beat) = index
        if len(self._counterpoint_lst
               ) >= 2 and self._counterpoint_lst[-2].get_duration(
               ) == 4 and self._counterpoint_lst[-1].get_duration() == 4:
            durs.discard(4)
        return durs

    def _handles_consecutive_syncopation(self, note: Note, index: tuple,
                                         durs: set) -> set:
        (bar, beat) = index
        if beat == 2 and (bar, 0) not in self._counterpoint_obj:
            durs.discard(6)
            durs.discard(8)
        return durs

    def _prevents_two_note_quarter_runs(self, note: Note, index: tuple,
                                        durs: set) -> set:
        if len(self._counterpoint_lst) < 3: return durs
        if self._counterpoint_lst[-3].get_duration(
        ) != 2 and self._counterpoint_lst[-2].get_duration(
        ) == 2 and self._counterpoint_lst[-1].get_duration() == 2:
            return {2} if 2 in durs else set()
        return durs

    def _no_quarter_runs_after_whole_notes(self, note: Note, index: tuple,
                                           durs: set) -> set:
        (bar, beat) = index
        if bar != 0 and beat == 0 and self._counterpoint_lst[-1].get_duration(
        ) >= 8:
            durs.discard(2)
        return durs

    ##########################################
    ########### index checks #################

    def _both_notes_placed(self, index: tuple) -> bool:
        (bar, beat) = index
        if bar >= self._attempt_params[
                "second_note_must_appear_by"] and not self._attempt_params[
                    "second_note_has_been_placed"]:
            return False
        return True

    ##########################################
    ########### insert functions #############

    def _bury_indices(self, note: Note, index: tuple) -> None:
        (bar, beat) = index
        self._stored_indices.append([])
        for i in range(1, note.get_duration()):
            new_beat, new_bar = beat + (i / 2), bar
            while new_beat >= 4:
                new_beat -= 4
                new_bar += 1
            if (new_bar, new_beat) in self._counterpoint_obj:
                self._stored_indices[-1].append((new_bar, new_beat))
                del self._counterpoint_obj[(new_bar, new_beat)]
                self._all_indices.remove((new_bar, new_beat))
                self._remaining_indices.remove((new_bar, new_beat))

    def _unbury_indices(self, note: Note, index: tuple) -> None:
        i = len(self._counterpoint_lst) + 1
        while len(self._stored_indices[-1]) > 0:
            next_index = self._stored_indices[-1].pop()
            self._all_indices.insert(i, next_index)
            self._remaining_indices.append(next_index)
            self._counterpoint_obj[next_index] = None
        self._stored_indices.pop()

    def _check_for_highest_and_second_note(self, note: Note,
                                           index: tuple) -> None:
        if self._mr.is_unison(note, self._attempt_params["highest"]):
            self._attempt_params["highest_has_been_placed"] = True
        if note.get_accidental(
        ) == ScaleOption.NATURAL and note.get_scale_degree(
        ) == self._attempt_params["second_note"]:
            self._attempt_params["second_note_has_been_placed"] = True

    ##########################################
    ########### final checks #################

    def _has_only_one_octave(self) -> bool:
        num_octaves = 0
        for i in range(
                0 if
                self._counterpoint_lst[0].get_accidental() != ScaleOption.REST
                else 1,
                len(self._counterpoint_lst) - 1):
            if abs(self._counterpoint_lst[i].get_scale_degree_interval(
                    self._counterpoint_lst[i + 1])) == 8:
                num_octaves += 1
                if num_octaves > 1:
                    return False
        return True

    def _starts_with_longer(self) -> bool:
        if self._counterpoint_lst[0].get_duration() <= 8: return False
        return True

    ##########################################
    ########### scoring ######################

    def _score_solution(self, solution: list[Note]) -> int:
        score = 0
        self._map_solution_onto_counterpoint_dict(solution)
        if (self._length - 2, 0) in self._counterpoint_obj: score += 500
        num_ties = 0
        num_tied_dotted_halfs = 0
        num_tied_wholes = 0
        ties = [False] * (self._length - 2)
        for i in range(1, self._length - 1):
            if (i, 0) not in self._counterpoint_obj:
                ties[i - 1] = True
                num_ties += 1
                if (i - 1, 2) in self._counterpoint_obj:
                    if self._counterpoint_obj[(i - 1, 2)].get_duration() == 6:
                        num_tied_dotted_halfs += 1
                    else:
                        num_tied_wholes += 1
        ideal_ties = 1
        score += abs(ideal_ties - num_ties) * 10
        score += abs(num_tied_wholes - num_tied_dotted_halfs) * 7
        if self._counterpoint_lst[0].get_duration() == 6: score -= 1000
        return score

    def _map_solution_onto_counterpoint_dict(self,
                                             solution: list[Note]) -> None:
        self._counterpoint_obj = {}
        self._counterpoint_lst = []
        self._all_indices = []
        bar, beat = 0, 0
        self._all_indices = []
        for note in solution:
            self._counterpoint_obj[(bar, beat)] = note
            self._counterpoint_lst.append(note)
            self._all_indices.append((bar, beat))
            beat += note.get_duration() / 2
            while beat >= 4:
                beat -= 4
                bar += 1
class GenerateTwoPartSecondSpecies:
    def __init__(self,
                 length: int = None,
                 mode: ModeOption = None,
                 octave: int = 4,
                 orientation: Orientation = Orientation.ABOVE):
        self._orientation = orientation
        self._mode = mode or MODES_BY_INDEX[math.floor(random() * 6)]
        self._length = length or 8 + math.floor(
            random() * 5)  #todo: replace with normal distribution
        self._octave = octave
        self._mr = ModeResolver(self._mode)
        gcf = GenerateCantusFirmus(self._length, self._mode, self._octave)
        cf = None
        #if the Cantus Firmus doesn't generate, we have to try again
        #also, if we are below the Cantus Firmus, the Cantus Firmus must end stepwise
        while cf is None or (
                cf.get_note(self._length - 2).get_scale_degree_interval(
                    cf.get_note(self._length - 1)) < -2
                and orientation == Orientation.BELOW):
            cf = gcf.generate_cf()
        self._cantus_object = cf
        self._cantus = cf.get_notes()

        #determined through two randomly generated booleans if we will start on the offbeat
        #or onbeat and whether the penultimate measure will be divided
        #IMPORTANT: define these in the constructor rather than at initialization otherwise we'll get length mismatches among solutions
        self._start_on_beat = True if random() > .5 else False
        self._penult_is_whole = True if random() > .5 else False

        #keep track of which measures are divided
        self._divided_measures = set([
            i for i in range(self._length -
                             2 if self._penult_is_whole else self._length - 1)
        ])

        #keep track of all indices of notes (they will be in the form (measure, beat))
        #assume measures are four beats and beats are quarter notes
        indices = [(0, 0), (0, 2)] if self._start_on_beat else [(0, 2)]
        for i in range(1, self._length):
            indices += [(i, 0),
                        (i, 2)] if i in self._divided_measures else [(i, 0)]
        self._all_indices = indices

    def print_counterpoint(self):
        print("  CANTUS FIRMUS:       COUNTERPOINT:")
        for i in range(self._length):
            cntpt_note = self._counterpoint[(i, 0)] if (
                i, 0) in self._counterpoint else "REST"
            print("  " + str(self._cantus[i]) + "  " + str(cntpt_note))
            if i in self._divided_measures:
                print("                       " +
                      str(self._counterpoint[(i, 2)]))

    def get_optimal(self):
        if len(self._solutions) == 0:
            return None
        optimal = self._solutions[0]
        self._map_solution_onto_counterpoint_dict(optimal)
        sol = [Note(1, 0, 4, ScaleOption.REST), self._counterpoint[(0, 2)]
               ] if not self._start_on_beat else [
                   self._counterpoint[(0, 0)], self._counterpoint[(0, 2)]
               ]
        for i in range(1, self._length):
            sol.append(self._counterpoint[(i, 0)])
            if i in self._divided_measures:
                sol.append(self._counterpoint[(i, 2)])
        return [sol, self._cantus]

    def generate_2p2s(self):
        start_time = time()
        print("MODE = ", self._mode.value["name"])
        self._solutions = []

        @timeout_decorator.timeout(5)
        def attempt():
            initialized = self._initialize()
            while not initialized:
                initialized = self._initialize()
            self._backtrack()

        attempts = 0
        while len(self._solutions) < 30 and time() - start_time < 7:
            try:
                attempt()
                attempts += 1
            except:
                print("timed out")
        print("number of attempts:", attempts)
        print("number of solutions:", len(self._solutions))
        if len(self._solutions) > 0:
            solutions = self._solutions[:100]
            solutions.sort(key=lambda sol: self._score_solution(sol))
            self._solutions = solutions

    def _initialize(self) -> bool:
        #initialize counterpoint data structure, that will map indices to notes
        counterpoint = {}
        for index in self._all_indices:
            counterpoint[index] = None

        #initialize range to 8.  we'll modify it based on probability
        vocal_range = 8
        range_alteration = random()
        if range_alteration < .1: vocal_range -= math.floor(random() * 3)
        elif range_alteration > .5: vocal_range += math.floor(random() * 3)

        cantus_final = self._cantus[0]
        cantus_first_interval = cantus_final.get_scale_degree_interval(
            self._cantus[1])
        cantus_last_interval = self._cantus[-2].get_scale_degree_interval(
            self._cantus[-1])

        first_note, last_note, penult_note = None, None, None
        highest_so_far, lowest_so_far = None, None
        lowest, highest = None, None
        if self._orientation == Orientation.ABOVE:
            start_interval_options = [
                1, 5, 8
            ] if cantus_first_interval < 0 and self._cantus_object.get_upward_range(
            ) < 3 else [5, 8]
            shuffle(start_interval_options)
            start_interval = start_interval_options[0]
            first_note = self._get_default_note_from_interval(
                cantus_final, start_interval)
            highest_so_far, lowest_so_far = first_note, first_note
            last_interval_options = [1, 1, 5] if start_interval == 1 else [
                8, 8, 5
            ] if start_interval == 8 else [1, 1, 8, 8, 5]
            if self._cantus[-2].get_scale_degree_interval(
                    self._cantus[-1]) < 0:
                if 1 in last_interval_options: last_interval_options.remove(1)
            if cantus_last_interval == 5:
                last_interval_options = [5]
            if cantus_last_interval in [2, 4]:
                if 5 in last_interval_options: last_interval_options.remove(5)
                if len(last_interval_options) == 0:
                    last_interval_options = [8]
                    first_note = self._get_default_note_from_interval(
                        cantus_final, 8)
                    highest_so_far, lowest_so_far = first_note, first_note
            shuffle(last_interval_options)
            last_interval = last_interval_options[0]
            last_note = self._get_default_note_from_interval(
                cantus_final, last_interval)
            if highest_so_far.get_scale_degree_interval(last_note) > 1:
                highest_so_far = last_note
            if lowest_so_far.get_scale_degree_interval(last_note) < 0:
                lowest_so_far = last_note
            penult_note = self._get_leading_tone_of_note(
                last_note
            ) if cantus_last_interval == -2 else self._get_default_note_from_interval(
                last_note, 2)
            if last_interval == 5:
                self._mr.make_default_scale_option(penult_note)
            if highest_so_far.get_scale_degree_interval(penult_note) > 1:
                highest_so_far = penult_note
            if lowest_so_far.get_scale_degree_interval(penult_note) < 0:
                lowest_so_far = penult_note
            #we have to figure out how many lower notes it is possible to assign
            gap_so_far = self._cantus_object.get_highest_note(
            ).get_scale_degree_interval(lowest_so_far)
            leeway = vocal_range - lowest_so_far.get_scale_degree_interval(
                highest_so_far) + 1
            allowance = 1 if first_note.get_scale_degree_interval(
                last_note) == 1 and penult_note.get_scale_degree_interval(
                    last_note) > 0 else 0
            max_available_lower_scale_degrees = min(
                max(1, gap_so_far + 2 if gap_so_far > 0 else gap_so_far + 4),
                leeway - allowance)
            interval_to_lowest = math.ceil(random() *
                                           max_available_lower_scale_degrees)
            lowest = self._get_default_note_from_interval(
                lowest_so_far, interval_to_lowest *
                -1) if interval_to_lowest > 1 else lowest_so_far
            highest = self._get_default_note_from_interval(lowest, vocal_range)
            if vocal_range == 8:
                highest.set_accidental(lowest.get_accidental())
            while (lowest.get_chromatic_interval(first_note) % 12 == 6
                   or first_note.get_chromatic_interval(highest) % 12 == 6
                   or lowest.get_chromatic_interval(last_note) % 12 == 6
                   or last_note.get_chromatic_interval(highest) % 12 == 6
                   or lowest.get_chromatic_interval(highest) % 12 == 6):
                interval_to_lowest = math.ceil(
                    random() * max_available_lower_scale_degrees)
                lowest = self._get_default_note_from_interval(
                    lowest_so_far, interval_to_lowest *
                    -1) if interval_to_lowest > 1 else lowest_so_far
                highest = self._get_default_note_from_interval(
                    lowest, vocal_range)
                if vocal_range == 8:
                    highest.set_accidental(lowest.get_accidental())

        if self._orientation == Orientation.BELOW:
            if self._cantus_object.get_downward_range(
            ) >= 3 or cantus_first_interval < 0 or cantus_last_interval < 0 or random(
            ) > .5:
                first_note = self._get_default_note_from_interval(
                    cantus_final, -8)
            else:
                first_note = self._get_default_note_from_interval(
                    cantus_final, -8)
            last_note = first_note
            if cantus_last_interval > 0:
                penult_note = self._get_default_note_from_interval(
                    last_note, 2)
                lowest_so_far, highest_so_far = last_note, penult_note
            else:
                penult_note = self._get_leading_tone_of_note(last_note)
                lowest_so_far, highest_so_far = penult_note, last_note
            leeway = vocal_range - 1
            gap_so_far = highest_so_far.get_scale_degree_interval(
                self._cantus_object.get_lowest_note())
            max_available_higher_scale_degrees = min(
                leeway,
                max(1, gap_so_far + 2 if gap_so_far > 0 else gap_so_far + 4))
            allowance = 1 if cantus_last_interval > 0 else 0
            interval_to_highest = math.floor(
                random() * (max_available_higher_scale_degrees -
                            allowance)) + 1 + allowance
            highest = self._get_default_note_from_interval(
                highest_so_far, interval_to_highest)
            lowest = self._get_default_note_from_interval(
                highest, vocal_range * -1)
            if lowest.get_scale_degree_interval(penult_note) == 1:
                penult_note.set_accidental(lowest.get_accidental())
            while (lowest.get_chromatic_interval(first_note) % 12 == 6
                   or first_note.get_chromatic_interval(highest) % 12 == 6
                   or lowest.get_chromatic_interval(highest) % 12 == 6):
                interval_to_highest = math.floor(
                    random() * (max_available_higher_scale_degrees -
                                allowance)) + 1 + allowance
                highest = self._get_default_note_from_interval(
                    highest_so_far, interval_to_highest)
                lowest = self._get_default_note_from_interval(
                    highest, vocal_range * -1)
                if lowest.get_scale_degree_interval(penult_note) == 1:
                    penult_note.set_accidental(lowest.get_accidental())

        #add counterpoint dict and remaining indices
        first_note.set_duration(4)
        last_note.set_duration(16)
        if self._length - 2 in self._divided_measures:
            penult_note.set_duration(4)
        counterpoint[self._all_indices[0]] = first_note
        counterpoint[self._all_indices[-2]] = penult_note
        counterpoint[self._all_indices[-1]] = last_note
        self._counterpoint = counterpoint
        self._remaining_indices = self._all_indices[1:-2]

        #generate valid pitches
        valid_pitches = [lowest]
        for i in range(2, vocal_range):  #we don't include the highest note
            valid_pitches += self._get_notes_from_interval(lowest, i)
        self._valid_pitches = valid_pitches

        #add highest and lowest notes if they're not already present
        if highest_so_far.get_scale_degree_interval(highest) != 1:
            if not self._place_highest(highest):
                return False
        if lowest.get_scale_degree_interval(lowest_so_far) != 1:
            if not self._place_lowest(lowest):
                return False
        self._remaining_indices.sort(reverse=True)
        return True

    def _place_highest(self, note: Note) -> bool:
        possible_indices = self._remaining_indices[:]
        if self._length % 2 == 1:
            possible_indices.remove((math.floor(self._length / 2), 0))
        shuffle(possible_indices)
        index = None
        while len(possible_indices) > 0:
            index = possible_indices.pop()
            if not self._passes_insertion_check(note, index):
                continue
            #if it passes insertion checks, make sure last two intervals are not invalid
            next_note = self._get_next_note(index)
            if next_note is not None:
                last_note = self._counterpoint[(self._length - 1, 0)]
                if next_note.get_scale_degree_interval(
                        last_note
                ) == -2 and note.get_scale_degree_interval(next_note) < -2:
                    continue
            break
        if len(possible_indices) == 0:
            return False
        self._counterpoint[index] = note
        self._remaining_indices.remove(index)
        return True

    def _place_lowest(self, note: Note) -> bool:
        possible_indices = self._remaining_indices[:]
        shuffle(possible_indices)
        index = None
        while len(possible_indices) > 0:
            index = possible_indices.pop()
            if not self._passes_insertion_check(note, index):
                continue
            #if it passes insertion checks, find a span it may be attached to and evaluate
            span = [note]
            lower_index, upper_index = self._get_prev_index(
                index), self._get_next_index(index)
            while lower_index is not None and self._counterpoint[
                    lower_index] is not None:
                span = [self._counterpoint[lower_index]] + span
                lower_index = self._get_prev_index(lower_index)
            while upper_index is not None and self._counterpoint[
                    upper_index] is not None:
                span.append(self._counterpoint[upper_index])
                upper_index = self._get_next_index(upper_index)
            if not self._span_is_valid(
                    span, check_beginning=False, check_ending=False):
                continue
            break
        if len(possible_indices) == 0:
            return False
        self._counterpoint[index] = note
        self._remaining_indices.remove(index)
        return True

    def _backtrack(self) -> None:
        if len(self._solutions) >= 50: return
        if len(self._remaining_indices) == 0:
            sol = []
            for i in range(len(self._all_indices)):
                sol.append(self._counterpoint[self._all_indices[i]])
            if self._passes_final_checks(sol):
                self._solutions.append(sol)
            return
        index = self._remaining_indices.pop()
        candidates = list(
            filter(lambda n: self._passes_insertion_check(n, index),
                   self._valid_pitches))
        for candidate in candidates:
            self._counterpoint[index] = candidate
            if self._current_chain_is_legal():
                self._backtrack()
        self._counterpoint[index] = None
        self._remaining_indices.append(index)

    def _get_leading_tone_of_note(self, note: Note) -> Note:
        lt = self._get_default_note_from_interval(note, -2)
        if lt.get_scale_degree() in [
                1, 4, 5
        ] or (lt.get_scale_degree() == 2 and self._mode == ModeOption.AEOLIAN):
            lt.set_accidental(ScaleOption.SHARP)
        if lt.get_scale_degree() == 7:
            lt.set_accidental(ScaleOption.NATURAL)
        return lt

    def _get_default_note_from_interval(self, note: Note,
                                        interval: int) -> Note:
        candidates = self._get_notes_from_interval(note, interval)
        if len(candidates) == 0: return None
        note = candidates[0]
        self._mr.make_default_scale_option(note)
        return note

    #returns valid notes, if any, at the specified interval.  "3" returns a third above.  "-5" returns a fifth below
    def _get_notes_from_interval(self, note: Note,
                                 interval: int) -> list[Note]:
        sdg = note.get_scale_degree()
        octv = note.get_octave()
        adjustment_value = -1 if interval > 0 else 1
        new_sdg, new_octv = sdg + interval + adjustment_value, octv
        if new_sdg < 1:
            new_octv -= 1
            new_sdg += 7
        else:
            while new_sdg > 7:
                new_octv += 1
                new_sdg -= 7
        new_note = Note(new_sdg, new_octv, 8)
        valid_notes = [new_note]
        if (self._mode == ModeOption.DORIAN
                or self._mode == ModeOption.LYDIAN) and new_sdg == 7:
            valid_notes.append(
                Note(new_sdg, new_octv, 8, accidental=ScaleOption.FLAT))
        if self._mode == ModeOption.AEOLIAN and new_sdg == 2:
            valid_notes.append(
                Note(new_sdg, new_octv, 8, accidental=ScaleOption.SHARP))
        if new_sdg in [1, 4, 5]:
            valid_notes.append(
                Note(new_sdg, new_octv, 8, accidental=ScaleOption.SHARP))
        return valid_notes

    def _is_valid_adjacent(self, note1: Note, note2: Note) -> bool:
        sdg_interval = note1.get_scale_degree_interval(note2)
        if (note1.get_accidental() == ScaleOption.SHARP
                or note2.get_accidental()
                == ScaleOption.SHARP) and abs(sdg_interval) > 3:
            return False
        #if a sharp is not followed by a step up, we'll give it an arbitrary 50% chance of passing
        is_leading_tone = note1.get_accidental == ScaleOption.SHARP or (
            note1.get_scale_degree() == 7
            and self._mode in [ModeOption.DORIAN, ModeOption.LYDIAN])
        if sdg_interval != 2 and is_leading_tone and random() > .5:
            return False

        chro_interval = note1.get_chromatic_interval(note2)
        if (sdg_interval in LegalIntervals["adjacent_melodic_scalar"] and
                chro_interval in LegalIntervals["adjacent_melodic_chromatic"]
                and (sdg_interval, chro_interval)
                not in LegalIntervals["forbidden_combinations"]):
            return True
        return False

    def _is_valid_outline(self, note1: Note, note2: Note) -> bool:
        sdg_interval = note1.get_scale_degree_interval(note2)
        chro_interval = note1.get_chromatic_interval(note2)
        if (sdg_interval in LegalIntervals["outline_melodic_scalar"] and
                chro_interval in LegalIntervals["outline_melodic_chromatic"]
                and (sdg_interval, chro_interval)
                not in LegalIntervals["forbidden_combinations"]):
            return True
        return False

    def _is_valid_harmonically(self, note1: Note, note2: Note) -> bool:
        sdg_interval = note1.get_scale_degree_interval(note2)
        chro_interval = note1.get_chromatic_interval(note2)
        if (sdg_interval in LegalIntervals["harmonic_scalar"]
                and chro_interval in LegalIntervals["harmonic_chromatic"]
                and (sdg_interval, chro_interval)
                not in LegalIntervals["forbidden_combinations"]):
            return True
        return False

    def _is_unison(self, note1: Note, note2: Note) -> bool:
        return note1.get_scale_degree_interval(
            note2) == 1 and note1.get_chromatic_interval(note2) == 0

    def _get_prev_note(self, index: tuple) -> Note:
        prev_index = self._get_prev_index(index)
        return None if prev_index is None else self._counterpoint[prev_index]

    def _get_next_note(self, index: tuple) -> Note:
        next_index = self._get_next_index(index)
        return None if next_index is None else self._counterpoint[next_index]

    def _get_prev_index(self, index: tuple) -> tuple:
        i = self._all_indices.index(index)
        if i == 0: return None
        return self._all_indices[i - 1]

    def _get_next_index(self, index: tuple) -> tuple:
        i = self._all_indices.index(index)
        if i == len(self._all_indices) - 1: return None
        return self._all_indices[i + 1]

    def _passes_insertion_check(self, note: Note, index: tuple) -> bool:
        (i, j) = index
        prev_note, next_note = self._get_prev_note(index), self._get_next_note(
            index)
        if prev_note is not None and not self._is_valid_adjacent(
                prev_note, note):
            return False
        if next_note is not None and not self._is_valid_adjacent(
                note, next_note):
            return False
        if not self._valid_harmonic_insertion(note, index): return False
        if not self._doesnt_create_parallels(note, index): return False
        if not self._no_large_parallel_leaps(note, index): return False
        if not self._no_cross_relations_with_cantus_firmus(note, index):
            return False
        if not self._no_octave_leap_with_perfect_harmonic_interval(
                note, index):
            return False
        return True

    def _valid_harmonic_insertion(self, note: Note, index: tuple) -> bool:
        (i, j) = index
        cf_note = self._cantus[i]
        if self._is_valid_harmonically(note, cf_note):
            if j == 0:
                prev_note, cf_prev = self._counterpoint[(i - 1,
                                                         2)], self._cantus[i -
                                                                           1]
                if prev_note is not None and not self._is_valid_harmonically(
                        prev_note, cf_prev):
                    if (i - 1, 0) in self._counterpoint and self._counterpoint[
                        (i - 1, 0)].get_scale_degree_interval(
                            prev_note) != prev_note.get_scale_degree_interval(
                                note):
                        return False
            return True
        if j == 0: return False
        if cf_note.get_chromatic_interval(note) == 0: return True
        prev_note = self._counterpoint[(i, 0)]
        if prev_note is None:
            return False  #the highest or lowest note cannot be a passing tone
        if abs(prev_note.get_scale_degree_interval(note)) != 2: return False
        next_note = self._counterpoint[(i + 1, 0)]
        if next_note is None: return True
        return note.get_scale_degree_interval(
            next_note) == prev_note.get_scale_degree_interval(note)

    def _doesnt_create_parallels(self, note: Note, index: tuple) -> bool:
        (i, j) = index
        cf_note, next_note, cf_next = self._cantus[i], self._counterpoint[(
            i + 1, 0)], self._cantus[i + 1]
        if next_note is not None and abs(
                next_note.get_chromatic_interval(cf_next)) in [0, 7, 12, 19]:
            #next measure is a perfect interval.  check for parallels first
            if note.get_chromatic_interval(
                    cf_note) == next_note.get_chromatic_interval(cf_next):
                return False
            #check for hidden intervals
            if (j == 2 and
                ((note.get_scale_degree_interval(next_note) > 0
                  and cf_note.get_scale_degree_interval(cf_next) > 0) or
                 (note.get_scale_degree_interval(next_note) < 0
                  and cf_note.get_scale_degree_interval(cf_next) < 0))):
                return False
        #if j is 2 we don't have to check what comes before
        if j == 0 and abs(
                note.get_chromatic_interval(cf_note)) in [0, 7, 12, 19]:
            cf_prev = self._cantus[i - 1]
            #check previous downbeat if it exists
            if i - 1 != 0 or self._start_on_beat:
                prev_downbeat = self._counterpoint[(i - 1, 0)]
                if prev_downbeat is not None and note.get_chromatic_interval(
                        cf_note) == prev_downbeat.get_chromatic_interval(
                            cf_prev):
                    return False
            #previous weak beat will always exist when we check an insertion
            prev_note = self._counterpoint[(i - 1, 2)]
            if prev_note is not None and note.get_chromatic_interval(
                    cf_note) == prev_note.get_chromatic_interval(cf_prev):
                return False
            #check for hiddens
            if (prev_note is not None and
                ((prev_note.get_scale_degree_interval(note) > 0
                  and cf_prev.get_scale_degree_interval(cf_note) > 0) or
                 (prev_note.get_scale_degree_interval(note) < 0
                  and cf_prev.get_scale_degree_interval(cf_note) < 0))):
                return False
        return True

    def _no_large_parallel_leaps(self, note: Note, index: tuple) -> bool:
        (i, j) = index
        cf_prev, cf_note, cf_next = self._cantus[
            i - 1], self._cantus[i], self._cantus[i + 1]
        if j == 2:
            next_note = self._counterpoint[(i + 1, 0)]
            if next_note is not None:
                next_interval, cf_next_interval = note.get_scale_degree_interval(
                    next_note), cf_note.get_scale_degree_interval(cf_next)
                if ((abs(next_interval) > 2 and abs(cf_next_interval) > 2
                     and (abs(next_interval) > 4 or abs(cf_next_interval) > 4)
                     and ((next_interval > 0 and cf_next_interval > 0) or
                          (next_interval < 0 and cf_next_interval < 0)))):
                    return False
        else:
            prev_note = self._counterpoint[(
                i - 1, 2)]  #this index will always exist when we check this
            if prev_note is not None:
                prev_interval, cf_prev_interval = prev_note.get_scale_degree_interval(
                    note), cf_prev.get_scale_degree_interval(cf_note)
                if ((abs(prev_interval) > 2 and abs(cf_prev_interval) > 2
                     and (abs(prev_interval) > 4 or abs(cf_prev_interval) > 4)
                     and ((prev_interval > 0 and cf_prev_interval > 0) or
                          (prev_interval < 0 and cf_prev_interval < 0)))):
                    return False
        return True

    def _no_cross_relations_with_cantus_firmus(self, note: Note,
                                               index: tuple) -> bool:
        (i, j) = index
        cf_note = self._cantus[i - 1 if j == 0 else i + 1]
        if abs(cf_note.get_scale_degree_interval(note)) in [1, 8]:
            return cf_note.get_accidental() == note.get_accidental()
        return True

    def _no_octave_leap_with_perfect_harmonic_interval(self, note: Note,
                                                       index: tuple) -> bool:
        (i, j) = index
        if i not in self._divided_measures or abs(
                self._cantus[i].get_scale_degree_interval(note)) not in [
                    1, 5, 8, 12
                ]:
            return True
        other_note = self._counterpoint[(i, 0 if j == 2 else 2)]
        if other_note is not None and abs(
                note.get_scale_degree_interval(other_note)) == 8:
            return False
        return True

    def _current_chain_is_legal(self) -> bool:
        current_chain = []
        index = (0, 0) if self._start_on_beat else (0, 2)
        while index is not None and self._counterpoint[index] is not None:
            current_chain.append(self._counterpoint[index])
            index = self._get_next_index(index)
        result = self._span_is_valid(current_chain)
        return result

    def _span_is_valid(self,
                       span: list[Note],
                       check_beginning: bool = True,
                       check_ending: bool = False) -> bool:
        if len(span) < 3: return True
        if self._remaining_indices == 0: check_ending = True
        if not self._segments_and_chains_are_legal(span, check_beginning,
                                                   check_ending):
            return False
        if not self._no_illegal_repetitions(span): return False
        if not self._ascending_intervals_handled(span): return False
        if not self._no_nearby_cross_relations(span): return False
        return True

    def _segments_and_chains_are_legal(self, span: list[Note],
                                       check_beggining: bool,
                                       check_ending: bool) -> bool:
        intervals = [
            span[i - 1].get_scale_degree_interval(span[i])
            for i in range(1, len(span))
        ]
        for i in range(1, len(intervals)):
            if ((intervals[i - 1] > 0 and intervals[i] > 0) or
                (intervals[i - 1] < 0
                 and intervals[i] < 0)) and intervals[i] > intervals[i - 1]:
                return False
        span_indices_ending_segments = [0] if check_beggining else []
        for i in range(1, len(intervals)):
            if ((intervals[i - 1] > 0 and intervals[i] < 0)
                    or (intervals[i - 1] < 0 and intervals[i] > 0)):
                span_indices_ending_segments.append(i)
        span_indices_ending_segments += [len(span) - 1] if check_ending else []
        for i in range(1, len(span_indices_ending_segments)):
            start_note, end_note = span[span_indices_ending_segments[
                i - 1]], span[span_indices_ending_segments[i]]
            if not self._is_valid_outline(start_note, end_note): return False
        #next check leap chains
        chains = []
        prev_interval = None
        for i in range(len(intervals)):
            if abs(intervals[i]) > 2:
                if prev_interval is None or abs(prev_interval) <= 2:
                    chains.append([span[i], span[i + 1]])
                else:
                    chains[-1].append(span[i + 1])
            prev_interval = intervals[i]
        for chain in chains:
            for i in range(len(chain) - 2):
                for j in range(i + 2, len(chain)):
                    if not self._is_valid_outline(chain[i], chain[j]):
                        return False
        return True

    def _no_illegal_repetitions(self, span: list[Note]) -> bool:
        for i in range(len(span) - 5):
            count = 1
            for j in range(i + 1, i + 6):
                if span[i].get_scale_degree_interval(span[j]) == 1: count += 1
            if count >= 3: return False
        for i in range(len(span) - 3):
            if span[i].get_scale_degree_interval(
                    span[i + 2]) == 1 and span[i +
                                               1].get_scale_degree_interval(
                                                   span[i + 3]) == 1:
                return False
        return True

    def _no_nearby_cross_relations(self, span: list[Note]) -> bool:
        for i in range(len(span) - 2):
            if span[i].get_scale_degree_interval(
                    span[i + 2]) == 1 and span[i].get_chromatic_interval(
                        span[i + 2]):
                return False
        return True

    def _ascending_intervals_handled(self, span: list[Note]) -> bool:
        for i in range(1, len(span) - 1):
            if span[i - 1].get_chromatic_interval(
                    span[i]) == 8 and span[i].get_chromatic_interval(
                        span[i + 1]) != -1:
                return False
            elif span[i - 1].get_scale_degree_interval(
                    span[i]) > 3 and span[i].get_scale_degree_interval(
                        span[i + 1]) != -2 and random() > .5:
                return False
        return True

    def _passes_final_checks(self, solution: list[Note]) -> bool:
        return self._leaps_filled_in(solution) and self._handles_sequences(
            solution)

    def _leaps_filled_in(self, solution: list[Note]) -> bool:
        for i in range(1, len(solution) - 1):
            interval = solution[i - 1].get_scale_degree_interval(solution[i])
            if interval > 2:
                filled_in = False
                for j in range(i + 1, len(solution)):
                    if solution[i].get_scale_degree_interval(
                            solution[j]) == -2:
                        filled_in = True
                        break
                if not filled_in: return False
            #for leaps down, we either need the note below the top note or any higher note
            if interval < -2:
                handled = False
                for j in range(i + 1, len(solution)):
                    if solution[i - 1].get_scale_degree_interval(
                            solution[j]) >= -2:
                        handled = True
                        break
                if not handled: return False
        return True

    def _handles_sequences(self, solution: list[Note]) -> bool:
        #check if an intervalic sequence of four or more notes repeats
        intervals = []
        for i in range(1, len(solution)):
            intervals.append(solution[i - 1].get_scale_degree_interval(
                solution[i]))
        for i in range(len(solution) - 6):
            seq = intervals[i:i + 3]
            for j in range(i + 3, len(solution) - 4):
                possible_match = intervals[j:j + 3]
                if seq == possible_match:
                    return False
        #check to remove pattern leap down -> step up -> step down -> leap up
        for i in range(len(solution) - 4):
            if intervals[i] < -2 and intervals[i + 1] == 2 and intervals[
                    i + 2] == -2 and intervals[i + 3] > 2:
                if random() < .8:
                    return False
        #check if three exact notes repeat
        for i in range(len(solution) - 5):
            for j in range(i + 3, self._length - 2):
                if solution[i].get_chromatic_interval(
                        solution[j]) == 0 and solution[
                            i + 1].get_chromatic_interval(
                                solution[j + 1]) == 0 and solution[
                                    i + 2].get_chromatic_interval(
                                        solution[j + 2]) == 0:
                    return False
        return True

    def _map_solution_onto_counterpoint_dict(self,
                                             solution: list[Note]) -> None:
        for i, note in enumerate(solution):
            (measure, beat) = self._all_indices[i]
            if measure in self._divided_measures:
                note = Note(note.get_scale_degree(), note.get_octave(), 4,
                            note.get_accidental())
            self._counterpoint[(measure, beat)] = note

    def _score_solution(self, solution: list[Note]) -> int:
        score = 0  #violations will result in increases to score
        #start by determining ratio of steps
        num_steps = 0
        num_leaps = 0
        for i in range(1, len(solution)):
            if abs(solution[i - 1].get_scale_degree_interval(
                    solution[i])) == 2:
                num_steps += 1
            elif abs(solution[i - 1].get_scale_degree_interval(
                    solution[i])) > 3:
                num_leaps += 1
        ratio = num_steps / (len(solution) - 1)
        if ratio > .712: score += math.floor((ratio - .712) * 20)
        elif ratio < .712: score += math.floor((.712 - ratio) * 100)
        if num_leaps == 0: score += 15

        #next, find the frequency of the most repeated note
        most_frequent = 1
        for i, note in enumerate(solution):
            freq = 1
            for j in range(i + 1, len(solution)):
                if note.get_chromatic_interval(solution[j]) == 0:
                    freq += 1
            most_frequent = max(most_frequent, freq)
        max_acceptable = MAX_ACCEPTABLE_REPITITIONS_BASED_ON_LENGTH[len(
            solution)]
        if most_frequent > max_acceptable:
            score += (most_frequent - max_acceptable) * 15

        #finally, assess the number of favored harmonic intervals
        # if len(solution) != len(self._all_indices):
        #     self.print_counterpoint()
        #     print(self._all_indices)
        for i, note in enumerate(solution):
            (measure, beat) = self._all_indices[i]
            if beat == 0:
                harmonic_interval = abs(solution[i].get_scale_degree_interval(
                    self._cantus[measure]))
                if harmonic_interval in [5, 12]: score += 40
                if harmonic_interval in [1, 8]: score += 10
        return score
class GenerateTwoPartFirstSpecies:
    def __init__(self,
                 length: int = None,
                 mode: ModeOption = None,
                 octave: int = 4,
                 orientation: Orientation = Orientation.ABOVE):
        self._orientation = orientation
        self._mode = mode or MODES_BY_INDEX[math.floor(random() * 6)]
        self._length = length or 8 + math.floor(
            random() * 5)  #todo: replace with normal distribution
        self._octave = octave
        self._mr = ModeResolver(self._mode)
        gcf = GenerateCantusFirmus(self._length, self._mode, self._octave)
        self._cf = None
        #if the Cantus Firmus doesn't generate, we have to try again
        #also, if we are below the Cantus Firmus, the Cantus Firmus must end stepwise
        while self._cf is None or (
                self._cf.get_note(self._length - 2).get_scale_degree_interval(
                    self._cf.get_note(self._length - 1)) < -2
                and orientation == Orientation.BELOW):
            self._cf = gcf.generate_cf()

    def print_counterpoint(self) -> None:
        print("  CANTUS FIRMUS:       COUNTERPOINT:")
        for i in range(self._length):
            print("  " + str(self._cf.get_note(i)) + "  " +
                  str(self._counterpoint[i]))

    def get_optimal(self) -> list[list[Note]]:
        if self._solutions is None or len(self._solutions) == 0:
            return None
        return [self._cf.get_notes(), self._solutions[0]]

    def get_worst(self) -> list[list[Note]]:
        if self._solutions is None or len(self._solutions) == 0:
            return None
        return [self._cf.get_notes(), self._solutions[-1]]

    def generate_2p1s(self):
        print("MODE = ", self._mode.value["name"])
        self._solutions = []

        def attempt():
            initialized = self._initialize()
            while not initialized:
                initialized = self._initialize()
            self._backtrack()

        attempt()
        attempts = 1
        while len(self._solutions) < 30 and attempts < 1000:
            attempts += 1
            attempt()
        print("number of attempts:", attempts)
        print("number of solutions:", len(self._solutions))
        if len(self._solutions) > 0:
            self._solutions.sort(key=lambda sol: self._score_solution(sol))
            optimal = self._solutions[0]
            worst = self._solutions[-1]
            self._counterpoint = optimal
            self.print_counterpoint()

    #create the list we will backtrack through, find first, last, highest and lowest notes
    def _initialize(self) -> bool:
        #initializae the list we will use to store our counterpoint
        self._counterpoint = [None] * self._length
        starting_interval_candidates = [
            5, 8
        ] if self._orientation == Orientation.ABOVE else [-8]
        cf_first = self._cf.get_note(0)
        cf_second = self._cf.get_note(1)
        cf_penult = self._cf.get_note(self._length - 2)
        cf_last = self._cf.get_note(self._length - 1)
        cf_first_interval = cf_first.get_scale_degree_interval(cf_second)
        cf_last_interval = cf_penult.get_scale_degree_interval(cf_last)
        cf_highest = self._cf.get_highest_note()
        cf_lowest = self._cf.get_lowest_note()
        if self._orientation == Orientation.BELOW:
            if cf_first_interval > 0 and cf_last_interval < 0 and cf_first.get_scale_degree_interval(
                    cf_lowest) >= -2:
                starting_interval_candidates.append(1)
        else:
            if cf_first_interval < 0 and cf_first.get_scale_degree_interval(
                    cf_highest) <= 2:
                starting_interval_candidates.append(1)
        starting_interval = starting_interval_candidates[math.floor(
            random() * len(starting_interval_candidates))]
        ending_interval = None
        end_to_penult_interval = None
        if self._orientation == Orientation.BELOW:
            ending_interval = starting_interval
        else:
            ending_interval_candidates = []
            if cf_last_interval == 5:
                ending_interval_candidates = [5]
            elif cf_last_interval == 4 or cf_last_interval == 2:
                ending_interval_candidates = [
                    starting_interval
                ] if starting_interval != 5 else [1, 8]
            else:
                ending_interval_candidates = [
                    5
                ] if starting_interval == 1 else [5, 8]
            ending_interval = ending_interval_candidates[math.floor(
                random() * len(ending_interval_candidates))]
        if Orientation == Orientation.BELOW:
            end_to_penult_interval = cf_last_interval
        else:
            if cf_last_interval == 4:
                end_to_penult_interval = 2 if random() > .5 else -2
            elif cf_last_interval > 0:
                end_to_penult_interval = 2
            elif random() > .85 and ending_interval == 8 and self._mode.value[
                    "most_common"] == 4:
                end_to_penult_interval = -5
            else:
                end_to_penult_interval = -2
        first_note = self._get_default_note_from_interval(
            cf_first, starting_interval)
        last_note = self._get_default_note_from_interval(
            cf_last, ending_interval)
        penult_note = self._get_default_note_from_interval(
            last_note, end_to_penult_interval)
        range_so_far = max(
            max(abs(first_note.get_scale_degree_interval(last_note)),
                abs(first_note.get_scale_degree_interval(penult_note))),
            abs(penult_note.get_scale_degree_interval(last_note)))

        #adjust penult_note
        if end_to_penult_interval == -2 and ending_interval != 5:  #that is, if we're approaching the mode final from below
            if self._mode in [
                    ModeOption.DORIAN, ModeOption.MIXOLYDIAN,
                    ModeOption.AEOLIAN
            ] and random() > .5:
                penult_note.set_accidental(ScaleOption.SHARP)

        #find lowest note so far
        lowest_so_far = first_note if (
            starting_interval < ending_interval and end_to_penult_interval > -5
        ) else last_note if end_to_penult_interval > 0 else penult_note
        #get possible lowest notes
        lowest_note_candidates = [lowest_so_far]
        for i in range(1, 8 - range_so_far):
            candidate = self._get_default_note_from_interval(
                lowest_so_far, (i + 1) * -1)
            if self._valid_outline(first_note,
                                   candidate) and self._valid_outline(
                                       last_note, candidate):
                if self._orientation == Orientation.BELOW or cf_highest.get_scale_degree_interval(
                        candidate) >= -3:
                    lowest_note_candidates.append(candidate)
        lowest_note = lowest_note_candidates[math.floor(
            random() * len(lowest_note_candidates))]
        range_so_far += lowest_note.get_scale_degree_interval(
            lowest_so_far) - 1
        #find highest note so far
        highest_so_far = first_note if starting_interval > ending_interval else penult_note if end_to_penult_interval > 0 else last_note
        #get possible highest notes
        highest_note_candidates = [highest_so_far] if (
            (starting_interval > ending_interval or end_to_penult_interval > 0)
            and range_so_far >= 6
        ) or self._orientation == Orientation.BELOW else []
        for i in range(max(6 - range_so_far, 0), 8 - range_so_far):
            candidate = self._get_default_note_from_interval(
                highest_so_far, i + 1)
            if candidate.get_accidental(
            ) != ScaleOption.SHARP and self._valid_range(
                    lowest_note, candidate) and self._valid_outline(
                        first_note, candidate) and self._valid_outline(
                            last_note, candidate):
                if self._orientation == Orientation.ABOVE or cf_lowest.get_scale_degree_interval(
                        candidate) <= -3:
                    if not (self._mode == ModeOption.DORIAN and
                            candidate.get_accidental() == ScaleOption.NATURAL
                            and candidate.get_scale_degree() == 7):
                        highest_note_candidates.append(candidate)
        highest_note = highest_note_candidates[math.floor(
            random() * len(highest_note_candidates))]

        #initialize list of remaining indices
        remaining_indices = list(range(1, self._length - 2))
        remaining_indices.reverse()
        self._remaining_indices = remaining_indices

        #find all valid notes (include lowest, but don't include highest)
        valid_notes = [lowest_note]

        #define the filter function that eliminates cross relations
        def remove_cross_relations(candidate: Note) -> bool:
            for fixed in [
                    first_note, penult_note, last_note, highest_note,
                    lowest_note
            ]:
                if fixed.get_scale_degree_interval(
                        candidate
                ) == 1 and fixed.get_chromatic_interval(candidate) != 0:
                    return False
            return True

        for i in range(2, lowest_note.get_scale_degree_interval(highest_note)):
            valid_notes += self._get_notes_from_interval(lowest_note, i)
        self._valid_notes = list(filter(remove_cross_relations, valid_notes))

        #add three notes to counterpoint
        self._counterpoint[0] = first_note
        self._counterpoint[-2] = penult_note
        self._counterpoint[-1] = last_note

        #add highest and lowest notes if they're not already in
        if highest_so_far.get_chromatic_interval(highest_note) != 0:
            added_high_note = self._add_highest(highest_note)
            if not added_high_note:
                return False
        if lowest_note.get_chromatic_interval(lowest_so_far) != 0:
            added_low_note = self._add_lowest(lowest_note)
            if not added_low_note:
                return False
        return True

    def _add_highest(self, note: Note) -> bool:
        remaining_indices = self._remaining_indices[:]
        if self._length % 2 == 1:
            remaining_indices.remove(math.floor(self._length / 2))
        shuffle(remaining_indices)
        index = None
        while len(remaining_indices) > 0:
            index = remaining_indices.pop()
            #we will need to see if the position works 1. harmonically, 2. melooically, 3. does not create parallels
            prev_note = self._counterpoint[index - 1]
            next_note = self._counterpoint[index + 1]
            cf_note = self._cf.get_note(index)
            #check if placement is melodically valid
            if prev_note is not None and not self._valid_adjacent(
                    prev_note, note):
                continue
            if next_note is not None and not self._valid_adjacent(
                    note, next_note):
                continue
            #check if placement is harmonically valid
            if not self._valid_harmonically(note, cf_note):
                continue
            #check if placement creates parallel or hidden fifths or octaves
            if not self._doesnt_create_hiddens_or_parallels(note, index):
                continue
            #check that placement doesn't create an illegal segment
            if next_note is not None:
                note_after_next = self._counterpoint[index + 2]
                if next_note.get_scale_degree_interval(
                        note_after_next
                ) < 0 and not self._segment_has_legal_shape(
                    [note, next_note, note_after_next]):
                    continue
            break
        if len(remaining_indices) == 0:
            return False
        self._remaining_indices.remove(index)
        self._counterpoint[index] = note
        return True

    def _add_lowest(self, note: Note) -> bool:
        remaining_indices = self._remaining_indices[:]
        shuffle(remaining_indices)
        index = None
        while len(remaining_indices) > 0:
            index = remaining_indices.pop()
            #we will need to see if the position works 1. harmonically, 2. melooically, 3. does not create parallels
            prev_note = self._counterpoint[index - 1]
            next_note = self._counterpoint[index + 1]
            cf_note = self._cf.get_note(index)
            #check if placement is melodically valid
            if prev_note is not None and not self._valid_adjacent(
                    prev_note, note):
                continue
            if next_note is not None and not self._valid_adjacent(
                    note, next_note):
                continue
            #check if placement is harmonically valid
            if not self._valid_harmonically(note, cf_note):
                continue
            #check if placement creates parallel or hidden fifths or octaves
            if not self._doesnt_create_hiddens_or_parallels(note, index):
                continue
            #get total span of consecutive notes
            start_index, end_index = index, index + 1
            while start_index != 0 and self._counterpoint[start_index -
                                                          1] is not None:
                start_index -= 1
            while end_index < self._length and self._counterpoint[
                    end_index] is not None:
                end_index += 1
            #will have maximum length of 4
            span = self._counterpoint[start_index:end_index]
            span[index - start_index] = note
            if len(span) < 3:
                break
            leap_chain = [span[0], span[1]] if abs(
                span[0].get_scale_degree_interval(span[1])) > 2 else [span[1]]
            for i in range(2, len(span)):
                if abs(span[i - 1].get_scale_degree_interval(span[i])) <= 2:
                    break
                leap_chain.append(span[i])
            if not self._leap_chain_is_legal(leap_chain):
                continue
            first_interval, second_interval = span[
                0].get_scale_degree_interval(
                    span[1]), span[1].get_scale_degree_interval(span[2])
            segment = [
                span[0], span[1]
            ] if (first_interval > 0 and second_interval > 0) or (
                first_interval < 0 and second_interval < 0) else [span[1]]
            for i in range(2, len(span)):
                ith_interval = span[i - 1].get_scale_degree_interval(span[i])
                if not (second_interval > 0 and ith_interval > 0) and not (
                        second_interval < 0 and ith_interval < 0):
                    break
                segment.append(span[i])
            if not self._segment_has_legal_shape(segment):
                continue
            break
        if len(remaining_indices) == 0:
            return False
        self._remaining_indices.remove(index)
        self._counterpoint[index] = note
        return True

    def _backtrack(self) -> None:
        if len(self._remaining_indices) == 0:
            if self._passes_final_checks():
                self._solutions.append(self._counterpoint[:])
            return
        index = self._remaining_indices.pop()
        against = self._cf.get_note(index)
        prev_note = self._counterpoint[index - 1]
        next_note = self._counterpoint[index + 1]
        #filter out notes that are 1. not harmonically valid, 2. not melodically valid, 3. create parallels and 4. create a cross relation with an already added note
        possible_notes = self._valid_notes[:]
        possible_notes = list(
            filter(lambda n: self._valid_harmonically(n, against),
                   possible_notes))
        possible_notes = list(
            filter(lambda n: self._valid_adjacent(prev_note, n),
                   possible_notes))
        if next_note is not None:
            possible_notes = list(
                filter(lambda n: self._valid_adjacent(n, next_note),
                       possible_notes))
        possible_notes = list(
            filter(
                lambda n: self._doesnt_create_hiddens_or_parallels(n, index),
                possible_notes))
        possible_notes = list(
            filter(lambda n: self._no_large_parallel_leaps(n, index),
                   possible_notes))
        possible_notes = list(
            filter(lambda n: self._no_cross_relations_with_previously_added(n),
                   possible_notes))

        #we will find all solutions so sorting possible_notes isn't necessary
        for candidate in possible_notes:
            self._counterpoint[index] = candidate
            if self._current_chain_is_legal():
                self._backtrack()
        self._counterpoint[index] = None
        self._remaining_indices.append(index)

    def _current_chain_is_legal(self) -> bool:
        #check for the following:
        #1. no dissonant intervals outlined in "segments" (don't check last segment)
        #2. no dissonant intervals outlined in "leap chains"
        #3. ascending minor sixths followed by descending minor seconds
        #4. in each segment, intervals must become progressively smaller (3 -> 2 or -2 -> -3, etc)
        #5. check if ascending leaps greater than a fourth are followed by descending second (to high degree of proability)
        #6. make sure there are no sequences of two notes that are immediately repeated

        #start by getting current chain of notes
        current_chain = []
        for i in range(self._length):
            if self._counterpoint[i] is None: break
            current_chain.append(self._counterpoint[i])

        #next, get the segments (consecutive notes that move in the same direction)
        #and the leap chains (consecutive notes separated by leaps)
        segments = [[current_chain[0]]]
        leap_chains = [[current_chain[0]]]
        prev_interval = None
        for i in range(1, len(current_chain)):
            note = current_chain[i]
            prev_note = current_chain[i - 1]
            current_interval = prev_note.get_scale_degree_interval(note)
            if prev_interval is None or (prev_interval > 0 and current_interval
                                         > 0) or (prev_interval < 0
                                                  and current_interval < 0):
                segments[-1].append(note)
            else:
                segments.append([prev_note, note])
            if abs(current_interval) <= 2:
                leap_chains.append([note])
            else:
                leap_chains[-1].append(note)
            prev_interval = current_interval

        #check segments
        for i, seg in enumerate(segments):
            #check for dissonant intervals except in last segment unless we're checking the completed Cantus Firmus
            if i < len(segments) - 1 or len(current_chain) == self._length:
                if not self._segment_outlines_legal_interval(seg):
                    return False
            if not self._segment_has_legal_shape(seg):
                return False

        #check leap chains
        for chain in leap_chains:
            if not self._leap_chain_is_legal(chain):
                return False

        #check for ascending intervals
        for i in range(1, len(current_chain) - 1):
            first_interval = current_chain[i - 1].get_scale_degree_interval(
                current_chain[i])
            if first_interval == 6:
                second_interval_chromatic = current_chain[
                    i].get_chromatic_interval(current_chain[i + 1])
                if second_interval_chromatic != -1:
                    return False
            if first_interval > 3:
                second_interval_sdg = current_chain[
                    i].get_scale_degree_interval(current_chain[i + 1])
                if second_interval_sdg != -2 and random() > .5:
                    return False

        #check for no sequences
        for i in range(3, len(current_chain)):
            if current_chain[i - 3].get_chromatic_interval(
                    current_chain[i - 1]) == 0 and current_chain[
                        i - 2].get_chromatic_interval(current_chain[i]) == 0:
                return False
        return True

    def _passes_final_checks(self) -> bool:
        return self._no_intervalic_sequences(
        ) and self._ascending_intervals_handled(
        ) and self._no_extended_parallel_motion()

    def _no_intervalic_sequences(self) -> bool:
        #check if an intervalic sequence of four or more notes repeats
        intervals = []
        for i in range(1, self._length):
            intervals.append(self._counterpoint[i -
                                                1].get_scale_degree_interval(
                                                    self._counterpoint[i]))
        for i in range(self._length - 6):
            seq = intervals[i:i + 3]
            for j in range(i + 3, self._length - 4):
                possible_match = intervals[j:j + 3]
                if seq == possible_match:
                    return False

        #check to remove pattern leap down -> step up -> step down -> leap up
        for i in range(self._length - 4):
            if intervals[i] < -2 and intervals[i + 1] == 2 and intervals[
                    i + 2] == -2 and intervals[i + 3] > 2:
                if random() < .8:
                    return False
        #check if three exact notes repeat
        for i in range(self._length - 5):
            for j in range(i + 3, self._length - 2):
                if self._counterpoint[i].get_chromatic_interval(
                        self._counterpoint[j]
                ) == 0 and self._counterpoint[i + 1].get_chromatic_interval(
                        self._counterpoint[j + 1]) == 0 and self._counterpoint[
                            i + 2].get_chromatic_interval(
                                self._counterpoint[j + 2]) == 0:
                    return False
        return True

    def _ascending_intervals_handled(self) -> bool:
        for i in range(1, self._length - 1):
            interval = self._counterpoint[i - 1].get_scale_degree_interval(
                self._counterpoint[i])
            if interval > 2:
                filled_in = False
                for j in range(i + 1, self._length):
                    if self._counterpoint[i].get_scale_degree_interval(
                            self._counterpoint[j]) == -2:
                        filled_in = True
                        break
                if not filled_in: return False
        return True

    def _no_extended_parallel_motion(self) -> bool:
        prev_harmonic_interval = None
        count = 0
        for i in range(self._length):
            cur_harmonic_interval = self._counterpoint[
                i].get_scale_degree_interval(self._cf.get_note(i))
            if cur_harmonic_interval == prev_harmonic_interval:
                count += 1
            else:
                count = 0
            if count == 4:
                print("too much parallel motion")
                return False
        return True

    def _valid_adjacent(self, note1: Note, note2: Note) -> bool:
        chro_interval = note1.get_chromatic_interval(note2)
        sdg_interval = note1.get_scale_degree_interval(note2)
        if chro_interval in VALID_MELODIC_INTERVALS_CHROMATIC and (
                abs(sdg_interval),
                abs(chro_interval)) not in FORBIDDEN_INTERVAL_COMBINATIONS:
            if note1.get_accidental(
            ) == ScaleOption.NATURAL or note2.get_accidental(
            ) == ScaleOption.NATURAL or abs(chro_interval) == 2:
                return True
        return False

    def _valid_outline(self, note1: Note, note2: Note) -> bool:
        chro_interval = note1.get_chromatic_interval(note2)
        sdg_interval = note1.get_scale_degree_interval(note2)
        if chro_interval in CONSONANT_MELODIC_INTERVALS_CHROMATIC and (
                abs(sdg_interval),
                abs(chro_interval)) not in FORBIDDEN_INTERVAL_COMBINATIONS:
            return True
        return False

    def _valid_range(self, note1: Note, note2: Note) -> bool:
        if self._valid_outline(note1, note2): return True
        if note1.get_scale_degree_interval(note2) == 7: return True
        return False

    def _valid_harmonically(self, note1: Note, note2: Note) -> bool:
        chro_interval = note1.get_chromatic_interval(note2)
        if chro_interval == 0: return False
        sdg_interval = note1.get_scale_degree_interval(note2)
        if sdg_interval in CONSONANT_HARMONIC_INTERVALS_SCALE_DEGREES and abs(
                chro_interval) % 12 in CONSONANT_HARMONIC_INTERVALS_CHROMATIC:
            combo = (abs(sdg_interval if sdg_interval <= 8 else sdg_interval -
                         7), abs(chro_interval) % 12)
            if combo not in FORBIDDEN_INTERVAL_COMBINATIONS:
                return True
        return False

    def _no_large_parallel_leaps(self, note: Note, index: int) -> bool:
        prev_note = self._counterpoint[index - 1]
        next_note = self._counterpoint[index + 1]
        cf_note = self._cf.get_note(index)
        cf_prev_note = self._cf.get_note(index - 1)
        cf_next_note = self._cf.get_note(index + 1)
        if prev_note is not None:
            prev_interval = prev_note.get_scale_degree_interval(note)
            cf_prev_interval = cf_prev_note.get_scale_degree_interval(cf_note)
            if (prev_interval > 2
                    and cf_prev_interval > 2) or (prev_interval < -2
                                                  and cf_prev_interval < -2):
                if abs(prev_interval) > 4 or abs(cf_prev_interval) > 4:
                    return False
        if next_note is not None:
            next_interval = note.get_scale_degree_interval(next_note)
            cf_next_interval = cf_note.get_scale_degree_interval(cf_next_note)
            if (next_interval > 2
                    and cf_next_interval > 2) or (next_interval < -2
                                                  and cf_next_interval < -2):
                if abs(next_interval) > 4 or abs(cf_next_interval) > 4:
                    return False
        return True

    def _doesnt_create_hiddens_or_parallels(self, note: Note,
                                            index: int) -> bool:
        chro_interval = note.get_chromatic_interval(self._cf.get_note(index))
        if abs(chro_interval) not in [7, 12]:
            return True
        prev_note = self._counterpoint[index - 1]
        next_note = self._counterpoint[index + 1]
        if prev_note is not None:
            prev_interval = prev_note.get_scale_degree_interval(note)
            cf_prev_interval = self._cf.get_note(index -
                                                 1).get_scale_degree_interval(
                                                     self._cf.get_note(index))
            if (prev_interval > 0
                    and cf_prev_interval > 0) or (prev_interval < 0
                                                  and cf_prev_interval < 0):
                return False
        if next_note is not None:
            next_interval = note.get_scale_degree_interval(next_note)
            cf_next_interval = self._cf.get_note(
                index).get_scale_degree_interval(self._cf.get_note(index + 1))
            if (next_interval > 0
                    and cf_next_interval > 0) or (next_interval < 0
                                                  and cf_next_interval < 0):
                next_chro_interval = next_note.get_chromatic_interval(
                    self._cf.get_note(index + 1))
                if abs(next_chro_interval) in [7, 12]:
                    return False
        return True

    def _no_cross_relations_with_previously_added(self, note: Note) -> bool:
        for n in self._counterpoint:
            if n is not None and n.get_scale_degree_interval(
                    note) == 1 and n.get_chromatic_interval(note) != 0:
                return False
        return True

    def _segment_has_legal_shape(self, seg: list[Note]) -> bool:
        if len(seg) < 3: return True
        prev_interval = seg[0].get_scale_degree_interval(seg[1])
        for i in range(1, len(seg) - 1):
            cur_interval = seg[i].get_scale_degree_interval(seg[i + 1])
            if cur_interval > prev_interval:
                if len(seg) > 4 or cur_interval not in [
                        3, -2
                ] or prev_interval < -3:
                    return False
            prev_interval = cur_interval
        return True

    def _segment_outlines_legal_interval(self, seg: list[Note]) -> bool:
        if len(seg) < 3: return True
        return self._valid_outline(seg[0], seg[-1])

    def _leap_chain_is_legal(self, chain: list[Note]) -> bool:
        if len(chain) < 3: return True
        for i in range(len(chain) - 2):
            for j in range(i + 2, len(chain)):
                if not self._valid_outline(chain[i], chain[j]):
                    return False
        return True

    def _score_solution(self, solution: list[Note]) -> int:
        score = 0  #violations will result in increases to score
        #start by determining ratio of steps
        num_steps = 0
        num_leaps = 0
        for i in range(1, self._length):
            if abs(solution[i - 1].get_scale_degree_interval(
                    solution[i])) == 2:
                num_steps += 1
            elif abs(solution[i - 1].get_scale_degree_interval(
                    solution[i])) > 3:
                num_leaps += 1
        ratio = num_steps / (self._length - 1)
        if ratio > AVERAGE_STEPS_PERCENTAGE:
            score += math.floor((ratio - AVERAGE_STEPS_PERCENTAGE) * 20)
        elif ratio < AVERAGE_STEPS_PERCENTAGE:
            score += math.floor((AVERAGE_STEPS_PERCENTAGE - ratio) * 100)
        if num_leaps == 0: score += 15

        #next, find the frequency of the most repeated note
        most_frequent = 1
        for i, note in enumerate(solution):
            freq = 1
            for j in range(i + 1, self._length):
                if note.get_chromatic_interval(solution[j]) == 0:
                    freq += 1
            most_frequent = max(most_frequent, freq)
        max_acceptable = MAX_ACCEPTABLE_REPITITIONS_BASED_ON_LENGTH[
            self._length]
        if most_frequent > max_acceptable:
            score += (most_frequent - max_acceptable) * 15

        #next, see if sharps are follwed by an ascending step
        for i, note in enumerate(solution):
            if note.get_accidental() == ScaleOption.SHARP:
                next_interval = note.get_scale_degree_interval(
                    solution[i + 1]
                )  #note that a sharp will never be in the last position
                if next_interval == 3:
                    score += 5
                elif next_interval != 2:
                    score += 15

        #finally, assess the number of favored harmonic intervals
        for i in range(1, self._length - 1):
            harmonic_interval = abs(solution[i].get_scale_degree_interval(
                self._cf.get_note(i)))
            if harmonic_interval == 10: score += 2
            if harmonic_interval in [5, 8]: score += 5

        return score

    def _get_default_note_from_interval(self, note: Note,
                                        interval: int) -> Note:
        candidates = self._get_notes_from_interval(note, interval)
        if len(candidates) == 0: return None
        note = candidates[0]
        self._mr.make_default_scale_option(note)
        return note

    #returns valid notes, if any, at the specified interval.  "3" returns a third above.  "-5" returns a fifth below
    def _get_notes_from_interval(self, note: Note,
                                 interval: int) -> list[Note]:
        sdg = note.get_scale_degree()
        octv = note.get_octave()
        adjustment_value = -1 if interval > 0 else 1
        new_sdg, new_octv = sdg + interval + adjustment_value, octv
        if new_sdg < 1:
            new_octv -= 1
            new_sdg += 7
        elif new_sdg > 7:
            new_octv += 1
            new_sdg -= 7
        new_note = Note(new_sdg, new_octv, 8)
        valid_notes = [new_note]
        if (self._mode == ModeOption.DORIAN
                or self._mode == ModeOption.LYDIAN) and new_sdg == 7:
            valid_notes.append(
                Note(new_sdg, new_octv, 8, accidental=ScaleOption.FLAT))
        if self._mode == ModeOption.AEOLIAN and new_sdg == 2:
            valid_notes.append(
                Note(new_sdg, new_octv, 8, accidental=ScaleOption.SHARP))
        if new_sdg in [1, 4, 5]:
            valid_notes.append(
                Note(new_sdg, new_octv, 8, accidental=ScaleOption.SHARP))
        return valid_notes