Package camelot_key

Python library to parse and format musical key notation and convert between Camelot key notation

Global variables

var ALTERNATE_KEYS

Alternate key mapping, keys that can be represented differently but are the same note

var CAMELOT_COLORS

Colors representing camelot key pairs

var CAMELOT_TO_MUSICAL

Mapping of camelot key number/letter pairs to musical key

var MUSICAL_LONG

'Long' format for short musical key representations, can be passed to Key

var MUSICAL_TO_CAMELOT

Mapping of musical key to camelot key number/letter pairs (inverse of CAMELOT_TO_MUSICAL)

Functions

def parse(value: str | Dict[str, Any]) ‑> Key | None
Expand source code
def parse(value: t.Union[str, t.Dict[str, t.Any]]) -> t.Optional[Key]:
    """\
    Parse a value representing a musical key into a Key instance, if possible.
    Automatically determines the format and parses the data, returning None if a
    key could not be determined.  Input formats may include:

    **Beatport API - musical key**
    ```
    {
        'letter': 'C',
        'is_sharp': True,
        'is_flat': False,
        'chord_type': {'name': 'minor'},
    }
    ```

    **Beatport API - camelot key**
    ```
    {
        'camelot_number': 12,
        'camelot_letter': 'B',
    }
    ```

    **Musical Key - string:** `C#m`, `C # Minor`, etc

    **Camelot Key - string:** `12B`
    """

    if isinstance(value, str):
        res = _parse_str__musical(value)
        if res is not None:
            return res

        res = _parse_str__camelot(value)
        if res is not None:
            return res
    else:
        res = _parse_beatport__musical(value)
        if res is not None:
            return res

        res = _parse_beatport__camelot(value)
        if res is not None:
            return res

Parse a value representing a musical key into a Key instance, if possible. Automatically determines the format and parses the data, returning None if a key could not be determined. Input formats may include:

Beatport API - musical key

{
    'letter': 'C',
    'is_sharp': True,
    'is_flat': False,
    'chord_type': {'name': 'minor'},
}

Beatport API - camelot key

{
    'camelot_number': 12,
    'camelot_letter': 'B',
}

Musical Key - string: C#m, C # Minor, etc

Camelot Key - string: 12B

Classes

class Key (letter: Literal['A', 'B', 'C', 'D', 'E', 'F', 'G'] = None,
sharp: bool = False,
flat: bool = False,
type: Literal['major', 'minor'] = 'major',
key: str = None,
camelot_number: Literal[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12] | None = None,
camelot_letter: Literal['A', 'B'] | None = None,
camelot_color: str | None = None)
Expand source code
@dataclass(frozen=True)
class Key:
    """\
    Represents a musical/camelot key.  Keys are immutable and hashable, and can
    be compared and sorted.
    """

    letter: t.Literal['A', 'B', 'C', 'D', 'E', 'F', 'G'] = None
    """Musical key letter"""
    sharp: bool = False
    """Whether the key is sharp"""
    flat: bool = False
    """Whether the key is flat"""
    type: t.Literal['major', 'minor'] = 'major'
    """Chord type - major or minor"""
    key: str = None
    """'Short' representation of the musical key, like 'F#m'"""
    camelot_number: t.Optional[t.Literal[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]] = None
    """Number for the camelot key"""
    camelot_letter: t.Optional[t.Literal['A', 'B']] = None
    """Letter for the camelot key"""
    camelot_color: t.Optional[str] = None
    """Color to use for displaying the camelot key"""

    def __post_init__parse(self):
        """\
        On init, ensure we have musical key information.  If it was provided
        then we're fine; otherwise attempt to get it from the camelot key.  If
        no camelot key was provided in that case, it's an error.
        """

        if self.letter is None:
            if self.camelot_number is None or self.camelot_letter is None:
                raise ValueError("No key information provided")
            short_key = CAMELOT_TO_MUSICAL.get((self.camelot_number, self.camelot_letter))
            if not short_key:
                raise ValueError("No valid key information provided")
            long_key = MUSICAL_LONG.get(short_key)
            if not long_key:
                raise ValueError("No valid key information provided")
            for k, v in long_key.items():
                super().__setattr__(k, v)

        if self.sharp and self.flat:
            super().__setattr__('flat', False)

    def __post_init__key(self):
        """\
        On init, generate the short key representation/string representation
        from the musical key information.
        """

        if self.key is None:
            parts = [self.letter]

            if self.sharp:
                parts.append('#')
            elif self.flat:
                parts.append('b')

            if self.type == 'major' or not self.type:
                parts.append('M')
            elif self.type == 'minor':
                parts.append('m')

            super().__setattr__('key', ''.join(parts))

    def __post_init__camelot(self):
        """\
        On init, generate the camelot key information from the musical key
        information if possible.
        """

        if self.camelot_number is None or self.camelot_letter is None:
            for alt_key in self.alternate_keys:
                res = MUSICAL_TO_CAMELOT.get(alt_key.key)
                if res:
                    n, l = res
                    super().__setattr__('camelot_number', n)
                    super().__setattr__('camelot_letter', l)
                    break
        if self.camelot_color is None and self.camelot_number and self.camelot_letter:
            c = CAMELOT_COLORS.get(self.camelot_number, {}).get(self.camelot_letter)
            super().__setattr__('camelot_color', c)

    def __post_init__validate(self):
        """\
        On init, validate that the data in the class instance is actually valid.
        In some cases an exception might have been thrown earlier.
        """

        if self.letter is None:
            raise TypeError("letter must be present")
        if not isinstance(self.letter, str):
            raise TypeError("letter must be a string")
        if self.letter not in ('A', 'B', 'C', 'D', 'E', 'F', 'G'):
            raise ValueError("letter must be in A-G")

        if self.sharp not in (True, False):
            raise TypeError("sharp must be True or False")
        if self.flat not in (True, False):
            raise TypeError("flat must be True or False")
        if self.sharp and self.flat:
            raise ValueError("Only one of sharp or flat may be True")

        if self.type is None:
            raise TypeError("type must be present")
        if not isinstance(self.type, str):
            raise TypeError("type must be a string")
        if self.type not in ('major', 'minor'):
            raise ValueError("type must be major or minor")

        if self.key is None:
            raise TypeError("key must be present")
        if not isinstance(self.key, str):
            raise TypeError("key must be a string")

        if self.camelot_number or self.camelot_letter:
            if self.camelot_number is None or self.camelot_letter is None:
                raise ValueError("Both camelot_number and camelot_letter must be provided together")
            if not isinstance(self.camelot_number, int):
                raise TypeError("camelot_number must be an int")
            if self.camelot_number < 1 or self.camelot_number > 12:
                raise ValueError("camelot_number must be in 1-12")
            if not isinstance(self.camelot_letter, str):
                raise TypeError("camelot_letter must be a string")
            if self.camelot_letter not in ('A', 'B'):
                raise ValueError("camelot_letter must be A or B")

    def __post_init__(self):
        """\
        Run all post init tasks.  This ensures the resulting object has all of
        the data that we can determine from the given arguments, or raises an
        error if (somewhat) invalid data was provided.
        """

        self.__post_init__parse()
        self.__post_init__key()
        self.__post_init__camelot()
        self.__post_init__validate()

    @property
    def camelot_key(self) -> t.Optional[str]:
        """\
        Convert the camelot number and letter to a string representation, if
        both the number and letter are present.  If either are not present,
        returns None
        """

        if self.camelot_number and self.camelot_letter:
            return f'{self.camelot_number}{self.camelot_letter}'

    def __str__(self) -> str:
        """\
        String representation of the key, returns the short key and the camelot
        key if available.
        """

        out = self.key
        ck = self.camelot_key
        if ck:
            out = f'{out} ({ck})'
        return out

    def __hash__(self):
        """\
        Hash that is used to compare/uniquely identify a key.

        Note that two different keys representing the same note (i.e. F#/Gb) do
        not produce the same hash value; the properties must be the same
        """

        return hash((self.letter, self.sharp, self.flat, self.type))

    def __eq__(self, other: t.Self) -> bool:
        """\
        Compare two keys to see if they are the same.  Generally, two keys are
        the same if the musical key properties (letter, sharp/flat, type) are
        the same, however two keys representing the same note (i.e. F#/Gb) are
        also considered to be the same if the chord type is the same.
        """

        if isinstance(other, Key):
            for other_alt in other.alternate_keys:
                if hash(self) == hash(other_alt):
                    return True
        return False

    def __ne__(self, other: t.Self) -> bool:
        return not self.__eq__(other)

    @property
    def _camelot_key_number_repr(self) -> float:
        """\
        Return a numerical representation of the camelot key, for internal
        usage.  This numerical representation is sortable, in particular
        ensuring that the numbers sort in order, as do the letters (ex. 1A < 1B,
        1B < 2A, etc).

        For keys that do not have a camelot key, they are sorted first.

        The use case here is primarily implementing comparison outside of'
        equality, for sorting.
        """

        out = -1.0
        if self.camelot_number and self.camelot_letter:
            out = float(self.camelot_number)
            if self.camelot_letter == 'B':
                out += 0.5
        return out
    

    def __lt__(self, other: t.Self) -> bool:
        if not isinstance(other, Key):
            raise ValueError("Uncomparable types")
        return self._camelot_key_number_repr < other._camelot_key_number_repr

    def __le__(self, other: t.Self) -> bool:
        if not isinstance(other, Key):
            raise ValueError("Uncomparable types")
        return self._camelot_key_number_repr <= other._camelot_key_number_repr

    def __gt__(self, other: t.Self) -> bool:
        if not isinstance(other, Key):
            raise ValueError("Uncomparable types")
        return self._camelot_key_number_repr > other._camelot_key_number_repr

    def __ge__(self, other: t.Self) -> bool:
        if not isinstance(other, Key):
            raise ValueError("Uncomparable types")
        return self._camelot_key_number_repr >= other._camelot_key_number_repr

    @property
    def alternate_keys(self) -> t.Generator[t.Self, None, None]:
        """\
        Produce alternate keys that are technically the same key as this one.
        The current object is always produced as it is always the same, and if
        another key that is the same exists it is also produced.

        Examples:
          * F# is also Gb
          * C# is also Db
          * E# is also F (uncommon but not technically wrong...)
        """

        yield self
        alt = ALTERNATE_KEYS.get((self.letter, self.sharp, self.flat))
        if alt:
            yield self.__class__(**alt, type=self.type)

    def _copy(self, **kwargs) -> t.Self:
        """\
        Create a copy of the current object with updated properties from kwargs.
        If kwargs contains camelot key information, the musical key information
        is discarded and only camelot key is used to construct the new instance,
        and vice versa - if musical key information is provided, camelot key
        information is discarded.

        If no kwargs are given, the musical key information is used.
        """

        camelot_args = ('camelot_number', 'camelot_letter')
        musical_args = ('letter', 'sharp', 'flat', 'type')

        new_args = {
            'letter': self.letter,
            'sharp': self.sharp,
            'flat': self.flat,
            'type': self.type,
            'camelot_number': self.camelot_number,
            'camelot_letter': self.camelot_letter,
        }

        if any(((k in kwargs) for k in camelot_args)):
            for k in musical_args:
                del new_args[k]
        else:
            for k in camelot_args:
                del new_args[k]
        new_args.update(kwargs)
        return Key(**new_args)

    def shift(self, n: int):
        """\
        Shift the key by N steps on the camelot wheel/circle of fifths,
        returning a new Key object.  If the current instance does not have
        camelot key information, raises an error.

        .. important:: Note that this is NOT equivalent to a key shift such as you may find in DJ software - where key shifting is done by note (for example shifing CM up by 1 results in C#M), but rather by the adjacent key on the circle of fifths (shifting CM up by 1 results in FM).

        .. tip:: To shift by adjacent notes, use a factor of 5.
        """

        if not (self.camelot_number and self.camelot_letter):
            raise ValueError("Missing camelot key information")

        return self._copy(camelot_number=((self.camelot_number + n) % 12) or 12)

    def get_adjacent_keys(self, n: int=1) -> t.Generator[t.Tuple[int, t.Self], None, None]:
        """\
        Get keys adjacent to this key on the circle of fifths, along with the
        number of steps by which that key must be shifted to be adjacent.  When
        n>1 is passed, consider keys that are not immediately adjacent.  If n=1,
        only immediately adjacent keys are considered and the number of steps to
        shift is always 0.

        In terms of the camelot wheel, for example:
        Given a key 3A, adjacent keys are 3A (same), 3B (same, but major instead
        of minor scale), 2A, and 4A.  For all of these the shift value is 0.
        With n=2, additional adjacent keys would include:

          * 3A +/- 5: (-1, 8A), (+1, 10A)
          * 3B +/- 5: (-1, 8B), (+1, 10B)
          * 4A +/- 5: (-1, 9A), (+1, 11A)
          * 2A +/- 5: (-1, 7A), (+1, 9A)

        Generated values are a tuple of (shift, key)

        .. note:: Nothing is generated if this instance does not have camelot key information
        .. note:: The results are not in any particular order (there is a deterministic order but it does not have any meaning) but are sortable; sorting without any key results in a list sorted by shift distance, then camelot number & letter
        """

        if self.camelot_number and self.camelot_letter:
            this_key = self
            this_alt_key = self._copy(camelot_letter='B' if self.camelot_letter == 'A' else 'A')
            this_adj_prev = self.shift(-1)
            this_adj_next = self.shift(1)
            # This key
            yield 0, this_key
            # Alternate key - major vs. minor
            yield 0, this_alt_key
            # Previous # on the camelot wheel, same scale
            yield 0, this_adj_prev
            # Next # on the camelot wheel, same scale
            yield 0, this_adj_next

            for i in range(2, n + 1):
                diff = i - 1
                shift = diff * 5
                # Produce the alternate key first - when shifted by N these keys will be exactly the alternate key
                yield -diff, this_alt_key.shift(shift)
                yield diff, this_alt_key.shift(-shift)
                # Then keys that when shifted become the current key, or an ajacent key
                yield -diff, this_key.shift(shift)
                yield diff, this_key.shift(-shift)
                yield -diff, this_adj_prev.shift(shift)
                yield diff, this_adj_prev.shift(-shift)
                yield -diff, this_adj_next.shift(shift)
                yield diff, this_adj_next.shift(-shift)

Represents a musical/camelot key. Keys are immutable and hashable, and can be compared and sorted.

Instance variables

prop alternate_keys : Generator[Self, None, None]
Expand source code
@property
def alternate_keys(self) -> t.Generator[t.Self, None, None]:
    """\
    Produce alternate keys that are technically the same key as this one.
    The current object is always produced as it is always the same, and if
    another key that is the same exists it is also produced.

    Examples:
      * F# is also Gb
      * C# is also Db
      * E# is also F (uncommon but not technically wrong...)
    """

    yield self
    alt = ALTERNATE_KEYS.get((self.letter, self.sharp, self.flat))
    if alt:
        yield self.__class__(**alt, type=self.type)

Produce alternate keys that are technically the same key as this one. The current object is always produced as it is always the same, and if another key that is the same exists it is also produced.

Examples

  • F# is also Gb
  • C# is also Db
  • E# is also F (uncommon but not technically wrong…)
var camelot_color : str | None

Color to use for displaying the camelot key

prop camelot_key : str | None
Expand source code
@property
def camelot_key(self) -> t.Optional[str]:
    """\
    Convert the camelot number and letter to a string representation, if
    both the number and letter are present.  If either are not present,
    returns None
    """

    if self.camelot_number and self.camelot_letter:
        return f'{self.camelot_number}{self.camelot_letter}'

Convert the camelot number and letter to a string representation, if both the number and letter are present. If either are not present, returns None

var camelot_letter : Literal['A', 'B'] | None

Letter for the camelot key

var camelot_number : Literal[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12] | None

Number for the camelot key

var flat : bool

Whether the key is flat

var key : str

'Short' representation of the musical key, like 'F#m'

var letter : Literal['A', 'B', 'C', 'D', 'E', 'F', 'G']

Musical key letter

var sharp : bool

Whether the key is sharp

var type : Literal['major', 'minor']

Chord type - major or minor

Methods

def get_adjacent_keys(self, n: int = 1) ‑> Generator[Tuple[int, Self], None, None]
Expand source code
def get_adjacent_keys(self, n: int=1) -> t.Generator[t.Tuple[int, t.Self], None, None]:
    """\
    Get keys adjacent to this key on the circle of fifths, along with the
    number of steps by which that key must be shifted to be adjacent.  When
    n>1 is passed, consider keys that are not immediately adjacent.  If n=1,
    only immediately adjacent keys are considered and the number of steps to
    shift is always 0.

    In terms of the camelot wheel, for example:
    Given a key 3A, adjacent keys are 3A (same), 3B (same, but major instead
    of minor scale), 2A, and 4A.  For all of these the shift value is 0.
    With n=2, additional adjacent keys would include:

      * 3A +/- 5: (-1, 8A), (+1, 10A)
      * 3B +/- 5: (-1, 8B), (+1, 10B)
      * 4A +/- 5: (-1, 9A), (+1, 11A)
      * 2A +/- 5: (-1, 7A), (+1, 9A)

    Generated values are a tuple of (shift, key)

    .. note:: Nothing is generated if this instance does not have camelot key information
    .. note:: The results are not in any particular order (there is a deterministic order but it does not have any meaning) but are sortable; sorting without any key results in a list sorted by shift distance, then camelot number & letter
    """

    if self.camelot_number and self.camelot_letter:
        this_key = self
        this_alt_key = self._copy(camelot_letter='B' if self.camelot_letter == 'A' else 'A')
        this_adj_prev = self.shift(-1)
        this_adj_next = self.shift(1)
        # This key
        yield 0, this_key
        # Alternate key - major vs. minor
        yield 0, this_alt_key
        # Previous # on the camelot wheel, same scale
        yield 0, this_adj_prev
        # Next # on the camelot wheel, same scale
        yield 0, this_adj_next

        for i in range(2, n + 1):
            diff = i - 1
            shift = diff * 5
            # Produce the alternate key first - when shifted by N these keys will be exactly the alternate key
            yield -diff, this_alt_key.shift(shift)
            yield diff, this_alt_key.shift(-shift)
            # Then keys that when shifted become the current key, or an ajacent key
            yield -diff, this_key.shift(shift)
            yield diff, this_key.shift(-shift)
            yield -diff, this_adj_prev.shift(shift)
            yield diff, this_adj_prev.shift(-shift)
            yield -diff, this_adj_next.shift(shift)
            yield diff, this_adj_next.shift(-shift)

Get keys adjacent to this key on the circle of fifths, along with the number of steps by which that key must be shifted to be adjacent. When n>1 is passed, consider keys that are not immediately adjacent. If n=1, only immediately adjacent keys are considered and the number of steps to shift is always 0.

In terms of the camelot wheel, for example: Given a key 3A, adjacent keys are 3A (same), 3B (same, but major instead of minor scale), 2A, and 4A. For all of these the shift value is 0. With n=2, additional adjacent keys would include:

  • 3A +/- 5: (-1, 8A), (+1, 10A)
  • 3B +/- 5: (-1, 8B), (+1, 10B)
  • 4A +/- 5: (-1, 9A), (+1, 11A)
  • 2A +/- 5: (-1, 7A), (+1, 9A)

Generated values are a tuple of (shift, key)

Note: Nothing is generated if this instance does not have camelot key information

Note: The results are not in any particular order (there is a deterministic order but it does not have any meaning) but are sortable; sorting without any key results in a list sorted by shift distance, then camelot number & letter

def shift(self, n: int)
Expand source code
def shift(self, n: int):
    """\
    Shift the key by N steps on the camelot wheel/circle of fifths,
    returning a new Key object.  If the current instance does not have
    camelot key information, raises an error.

    .. important:: Note that this is NOT equivalent to a key shift such as you may find in DJ software - where key shifting is done by note (for example shifing CM up by 1 results in C#M), but rather by the adjacent key on the circle of fifths (shifting CM up by 1 results in FM).

    .. tip:: To shift by adjacent notes, use a factor of 5.
    """

    if not (self.camelot_number and self.camelot_letter):
        raise ValueError("Missing camelot key information")

    return self._copy(camelot_number=((self.camelot_number + n) % 12) or 12)

Shift the key by N steps on the camelot wheel/circle of fifths, returning a new Key object. If the current instance does not have camelot key information, raises an error.

Important: Note that this is NOT equivalent to a key shift such as you may find in DJ software - where key shifting is done by note (for example shifing CM up by 1 results in C#M), but rather by the adjacent key on the circle of fifths (shifting CM up by 1 results in FM).

Tip: To shift by adjacent notes, use a factor of 5.