From edcd32cb9df5ddeaca0e092ae9c559c611c76c5e Mon Sep 17 00:00:00 2001 From: J0J0 Todos Date: Mon, 25 May 2026 09:25:11 +0200 Subject: [PATCH] Draft sort by circle of fiths feature --- beets/config_default.yaml | 1 + beets/dbcore/__init__.py | 2 + beets/dbcore/sort.py | 38 +++++++++++++++ beets/dbcore/types.py | 28 +++-------- beets/library/models.py | 7 ++- beets/util/musictheory.py | 99 +++++++++++++++++++++++++++++++++++++++ 6 files changed, 152 insertions(+), 23 deletions(-) create mode 100644 beets/util/musictheory.py diff --git a/beets/config_default.yaml b/beets/config_default.yaml index 8df7f61e61..21d5ee966b 100644 --- a/beets/config_default.yaml +++ b/beets/config_default.yaml @@ -155,6 +155,7 @@ format_raw_length: no sort_album: albumartist+ album+ sort_item: artist+ album+ disc+ track+ sort_case_insensitive: yes +sort_initial_key_harmonic: yes # --------------- Autotagger --------------- diff --git a/beets/dbcore/__init__.py b/beets/dbcore/__init__.py index 441d5f4464..01236695d2 100644 --- a/beets/dbcore/__init__.py +++ b/beets/dbcore/__init__.py @@ -30,12 +30,14 @@ query_from_strings, sort_from_strings, ) +from .sort import HarmonicKeySort from .types import Type __all__ = [ "AndQuery", "Database", "FieldQuery", + "HarmonicKeySort", "Index", "InvalidQueryError", "MatchQuery", diff --git a/beets/dbcore/sort.py b/beets/dbcore/sort.py index 1b0419b479..45d2f54ad5 100644 --- a/beets/dbcore/sort.py +++ b/beets/dbcore/sort.py @@ -205,6 +205,44 @@ def __hash__(self) -> int: return 0 +class HarmonicKeySort(FieldSort): + """Sort by musical key in Circle of Fifths order when + ``sort_initial_key_harmonic`` is enabled; falls back to standard + case-insensitive alphabetical ordering otherwise. + + Applies enharmonic normalization before lookup, so keys stored in either + flat or sharp notation sort correctly relative to each other. Keys not + present in the Circle of Fifths (or missing entirely) sort to the end. + """ + + @staticmethod + def _harmonic_enabled() -> bool: + import beets + + return beets.config["sort_initial_key_harmonic"].get(bool) + + def is_slow(self) -> bool: + return self._harmonic_enabled() + + def order_clause(self) -> str | None: + if self._harmonic_enabled(): + return None # Python-level sort; no SQL ORDER BY fragment + return FixedFieldSort( + self.field, self.ascending, self.case_insensitive + ).order_clause() + + def sort(self, objs: list[AnyModel]) -> list[AnyModel]: + if not self._harmonic_enabled(): + return FieldSort.sort(self, objs) + from beets.util.musictheory import harmonic_sort_key + + return sorted( + objs, + key=lambda obj: harmonic_sort_key(obj.get(self.field)), + reverse=not self.ascending, + ) + + class SmartArtistSort(FieldSort): """Sort by artist (either album artist or track artist), prioritizing the sort field over the raw field. diff --git a/beets/dbcore/types.py b/beets/dbcore/types.py index caedf669b4..db3a118570 100644 --- a/beets/dbcore/types.py +++ b/beets/dbcore/types.py @@ -16,14 +16,14 @@ from __future__ import annotations -import re import time import typing from abc import ABC -from typing import TYPE_CHECKING, Any, ClassVar, Generic, TypeVar, cast +from typing import TYPE_CHECKING, Any, Generic, TypeVar, cast import beets from beets import util +from beets.util.musictheory import normalize_key from beets.util.units import human_seconds_short, raw_seconds_short from . import pathutils, query @@ -438,29 +438,15 @@ class MusicalKey(String): The standard format is C, Cm, C#, C#m, etc. """ - ENHARMONIC: ClassVar[dict[str, str]] = { - r"db": "c#", - r"eb": "d#", - r"gb": "f#", - r"ab": "g#", - r"bb": "a#", - } - null = None - def parse(self, key): - key = key.lower() - for flat, sharp in self.ENHARMONIC.items(): - key = re.sub(flat, sharp, key) - key = re.sub(r"[\W\s]+minor", "m", key) - key = re.sub(r"[\W\s]+major", "", key) - return key.capitalize() + def parse(self, string): + return normalize_key(string) - def normalize(self, key): - if key is None: + def normalize(self, value): + if value is None: return None - else: - return self.parse(key) + return self.parse(value) class DurationType(Float): diff --git a/beets/library/models.py b/beets/library/models.py index 7c27e24ada..8f8b9c0a98 100644 --- a/beets/library/models.py +++ b/beets/library/models.py @@ -16,7 +16,7 @@ from beets import dbcore, logging, plugins, util from beets.dbcore import types from beets.dbcore.pathutils import normalize_path_for_db -from beets.dbcore.sort import SmartArtistSort +from beets.dbcore.sort import HarmonicKeySort, SmartArtistSort from beets.util import ( MoveOperation, bytestring_path, @@ -697,7 +697,10 @@ class Item(LibModel): _formatter = FormattedItemMapping - _sorts: ClassVar[dict[str, type[FieldSort]]] = {"artist": SmartArtistSort} + _sorts: ClassVar[dict[str, type[FieldSort]]] = { + "artist": SmartArtistSort, + "initial_key": HarmonicKeySort, + } @cached_classproperty def _queries(cls) -> dict[str, FieldQueryType]: diff --git a/beets/util/musictheory.py b/beets/util/musictheory.py new file mode 100644 index 0000000000..e13a4b83a2 --- /dev/null +++ b/beets/util/musictheory.py @@ -0,0 +1,99 @@ +# This file is part of beets. +# Copyright 2016, Adrian Sampson. +# +# Permission is hereby granted, free of charge, to any person obtaining +# a copy of this software and associated documentation files (the +# "Software"), to deal in the Software without restriction, including +# without limitation the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the Software, and to +# permit persons to whom the Software is furnished to do so, subject to +# the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. + +"""Music theory utilities: key normalization and Circle of Fifths ordering.""" + +from __future__ import annotations + +import re + +# Flat-to-sharp enharmonic equivalents. Keys are lowercase regex patterns; +# values are the canonical sharp equivalents. Used by normalize_key() and +# referenced by beets.dbcore.types.MusicalKey. +ENHARMONIC: dict[str, str] = { + r"db": "c#", + r"eb": "d#", + r"gb": "f#", + r"ab": "g#", + r"bb": "a#", +} + +# Keys in Circle of Fifths order, using canonical sharp-based notation +# (as produced by normalize_key()). Major keys first, then their relative +# minors at the matching positions. +CIRCLE_OF_FIFTHS: tuple[str, ...] = ( + # Major keys + "C", + "G", + "D", + "A", + "E", + "B", + "F#", + "C#", + "G#", + "D#", + "A#", + "F", + # Relative minor keys + "Am", + "Em", + "Bm", + "F#m", + "C#m", + "G#m", + "D#m", + "A#m", + "Fm", + "Cm", + "Gm", + "Dm", +) + +_POSITION: dict[str, int] = {k: i for i, k in enumerate(CIRCLE_OF_FIFTHS)} +_UNKNOWN: int = len(CIRCLE_OF_FIFTHS) + + +def normalize_key(key: str) -> str: + """Normalize a musical key string to canonical form. + + Applies flat-to-sharp enharmonic conversion, handles ``minor``/``major`` + suffixes, and capitalizes the result. Mirrors the logic in + ``beets.dbcore.types.MusicalKey.parse()``. + + Examples:: + + normalize_key("Db") == "C#" + normalize_key("A minor") == "Am" + normalize_key("F# major") == "F#" + """ + key = key.lower() + for flat, sharp in ENHARMONIC.items(): + key = re.sub(flat, sharp, key) + key = re.sub(r"[\W\s]+minor", "m", key) + key = re.sub(r"[\W\s]+major", "", key) + return key.capitalize() + + +def harmonic_sort_key(key: str | None) -> int: + """Return the Circle of Fifths position index for harmonic sorting. + + Applies enharmonic normalization before lookup, so flat variants (e.g. + ``Db``) resolve correctly to their sharp equivalent (``C#``). Keys not + present in :data:`CIRCLE_OF_FIFTHS` — including ``None`` or empty strings + — sort to the end (index ``len(CIRCLE_OF_FIFTHS)``). + """ + if not key: + return _UNKNOWN + return _POSITION.get(normalize_key(key), _UNKNOWN)