"""Module for generating improvised melody phrases. This module provides functionality to generate natural melody phrases based on given scales and chord progressions, following music theory principles. """ import random from improvisation_lab.domain.music_theory import ChordTone, Notes, Scale class PhraseGenerator: """Class for generating improvised melody phrases. This class generates melody phrases based on given scales and chord progressions, following music theory rules. The next note selection depends on whether the current note is a chord tone or not, with chord tones having more freedom in movement while non-chord tones move to adjacent notes. """ def is_chord_tone(self, note: str, chord_tones: list[str]) -> bool: """Check if a note is a chord tone. Args: note: The note to check. chord_tones: The list of chord tones. Returns: True if the note is a chord tone, False otherwise. """ return note in chord_tones def get_adjacent_notes(self, note: str, scale_notes: list[str]) -> list[str]: """Get adjacent notes to a given note. Args: note: The note to get adjacent notes to. scale_notes: The list of notes in the scale. Returns: The list of adjacent notes in order (lower note first, then higher note). """ length_scale_notes = len(scale_notes) if note in scale_notes: note_index = scale_notes.index(note) return [ scale_notes[(note_index - 1) % length_scale_notes], scale_notes[(note_index + 1) % length_scale_notes], ] return [ self._find_closest_note_in_direction(note, scale_notes, -1), self._find_closest_note_in_direction(note, scale_notes, 1), ] def _find_closest_note_in_direction( self, note: str, scale_notes: list[str], direction: int ) -> str: """Find the closest note in a given direction within the scale. Args: start_index: Starting index in the chromatic scale. all_notes: List of all notes (chromatic scale). scale_notes: List of notes in the target scale. direction: Direction to search (-1 for lower, 1 for higher). Returns: The closest note in the given direction that exists in the scale. """ all_notes = [note.value for note in Notes] # Chromatic scale note_index = all_notes.index(note) current_index = note_index while True: current_index = (current_index + direction) % 12 current_note = all_notes[current_index] if current_note in scale_notes: return current_note if current_index == note_index: # If we've gone full circle break return all_notes[current_index] def get_next_note( self, current_note: str, scale_notes: list[str], chord_tones: list[str] ) -> str: """Get the next note based on the current note, scale, and chord tones. Args: current_note: The current note. scale_notes: The list of notes in the scale. chord_tones: The list of chord tones. Returns: The next note. """ is_current_chord_tone = self.is_chord_tone(current_note, chord_tones) if is_current_chord_tone: # For chord tones, freely move to any scale note available_notes = [note for note in scale_notes if note != current_note] return random.choice(available_notes) # For non-chord tones, move to adjacent notes only adjacent_notes = self.get_adjacent_notes(current_note, scale_notes) return random.choice(adjacent_notes) def select_first_note( self, scale_notes: list[str], chord_tones: list[str], prev_note: str | None = None, prev_note_was_chord_tone: bool = False, ) -> str: """Select the first note of a phrase. Args: scale_notes: The list of notes in the scale. chord_tones: The list of chord tones. prev_note: The last note of the previous phrase (default: None). prev_note_was_chord_tone: Whether the previous note was a chord tone (default: False). Returns: The selected first note. """ # For the first phrase, randomly select from scale notes if prev_note is None: return random.choice(scale_notes) # Case: previous note was a chord tone, can move freely if prev_note_was_chord_tone: available_notes = [note for note in scale_notes if note != prev_note] return random.choice(available_notes) # Case: previous note was not a chord tone if prev_note in chord_tones: # If it's a chord tone in the current chord, can move freely available_notes = [note for note in scale_notes if note != prev_note] return random.choice(available_notes) # If it's not a chord tone, can only move to adjacent notes adjacent_notes = self.get_adjacent_notes(prev_note, scale_notes) return random.choice(adjacent_notes) def generate_phrase( self, scale_root: str, scale_type: str, chord_root: str, chord_type: str, prev_note: str | None = None, prev_note_was_chord_tone: bool = False, length=8, ) -> list[str]: """Generate a phrase of notes. Args: scale_root: The root note of the scale. scale_type: The type of scale (e.g., "major", "natural_minor"). chord_root: The root note of the chord. chord_type: The type of chord (e.g., "maj", "maj7"). prev_note: The last note of the previous phrase (default: None). prev_note_was_chord_tone: Whether the previous note was a chord tone (default: False). length: The length of the phrase (default: 8). Returns: A list of note names in the phrase. """ # Get scale notes and chord tones scale_notes = Scale.get_scale_notes(scale_root, scale_type) chord_tones = ChordTone.get_chord_tones(chord_root, chord_type) # Generate the phrase phrase = [] # Select the first note current_note = self.select_first_note( scale_notes, chord_tones, prev_note, prev_note_was_chord_tone ) phrase.append(current_note) # Generate remaining notes for _ in range(length - 1): current_note = self.get_next_note(current_note, scale_notes, chord_tones) phrase.append(current_note) return phrase