Nikolay-Lysenko/rl-musician

View on GitHub
rlmusician/utils/music_theory.py

Summary

Maintainability
A
0 mins
Test Coverage
"""
Help to work with notions from music theory.

Author: Nikolay Lysenko
"""


from typing import List, NamedTuple

from sinethesizer.utils.music_theory import get_note_to_position_mapping


NOTE_TO_POSITION = get_note_to_position_mapping()
TONIC_TRIAD_DEGREES = (1, 3, 5)


class ScaleElement(NamedTuple):
    """A pitch from a diatonic scale."""

    note: str
    position_in_semitones: int
    position_in_degrees: int
    degree: int
    is_from_tonic_triad: bool


class Scale:
    """A diatonic scale."""

    def __init__(self, tonic: str, scale_type: str):
        """
        Initialize an instance.

        :param tonic:
            tonic pitch class represented by letter (like C or A#)
        :param scale_type:
            type of scale (currently, 'major', 'natural_minor', and
            'harmonic_minor' are supported)
        """
        self.tonic = tonic
        self.scale_type = scale_type

        self.elements = self.__create_elements()
        self.note_to_element = {
            element.note: element for element in self.elements
        }
        self.position_in_semitones_to_element = {
            element.position_in_semitones: element for element in self.elements
        }
        self.position_in_degrees_to_element = {
            element.position_in_degrees: element for element in self.elements
        }

    def __create_elements(self) -> List[ScaleElement]:
        """Create sorted list of scale elements."""
        patterns = {
            'major': [
                1, None, 2, None, 3, 4, None, 5, None, 6, None, 7
            ],
            'natural_minor': [
                1, None, 2, 3, None, 4, None, 5, 6, None, 7, None
            ],
            'harmonic_minor': [
                1, None, 2, 3, None, 4, None, 5, 6, None, None, 7
            ],
        }
        pattern = patterns[self.scale_type]
        tonic_position = NOTE_TO_POSITION[self.tonic + '1']
        elements = []
        position_in_degrees = 0
        for note, position_in_semitones in NOTE_TO_POSITION.items():
            remainder = (position_in_semitones - tonic_position) % len(pattern)
            degree = pattern[remainder]
            if degree is not None:
                element = ScaleElement(
                    note=note,
                    position_in_semitones=position_in_semitones,
                    position_in_degrees=position_in_degrees,
                    degree=degree,
                    is_from_tonic_triad=(degree in TONIC_TRIAD_DEGREES)
                )
                elements.append(element)
                position_in_degrees += 1
        return elements

    def get_element_by_note(self, note: str) -> ScaleElement:
        """Get scale element by its note (like 'C4' or 'A#5')."""
        try:
            return self.note_to_element[note]
        except KeyError:
            raise ValueError(
                f"Note {note} is not from {self.tonic}-{self.scale_type}."
            )

    def get_element_by_position_in_semitones(self, pos: int) -> ScaleElement:
        """Get scale element by its position in semitones."""
        try:
            return self.position_in_semitones_to_element[pos]
        except KeyError:
            raise ValueError(
                f"Position {pos} is not from {self.tonic}-{self.scale_type}."
            )

    def get_element_by_position_in_degrees(self, pos: int) -> ScaleElement:
        """Get scale element by its position in scale degrees."""
        try:
            return self.position_in_degrees_to_element[pos]
        except KeyError:
            raise ValueError(
                f"Position {pos} is out of available positions range."
            )


def check_consonance(
        first: ScaleElement, second: ScaleElement,
        is_perfect_fourth_consonant: bool = False
) -> bool:
    """
    Check whether an interval between two pitches is consonant.

    :param first:
        first pitch
    :param second:
        second pitch
    :param is_perfect_fourth_consonant:
        indicator whether to consider perfect fourth a consonant interval
        (for two voices, it usually considered dissonant)
    :return:
        indicator whether the interval is consonant
    """
    n_semitones_to_consonance = {
        0: True,
        1: False,
        2: False,
        3: True,
        4: True,
        5: is_perfect_fourth_consonant,
        6: False,
        7: True,
        8: True,
        9: True,
        10: False,
        11: False
    }
    interval = abs(first.position_in_semitones - second.position_in_semitones)
    interval %= len(n_semitones_to_consonance)
    return n_semitones_to_consonance[interval]