Skip to content

Conversation

@Exafto
Copy link

@Exafto Exafto commented Dec 26, 2025

Description

This PR adds a new plugin, harmonicmix, which calculates the harmonic compatibility between tracks. Playlist curators may use this to offer more harmonically coherent lists. DJs may use this to search for compatible songs around the same bpm as their current track.

This is my first contribution to Beets! I really enjoy using the tool and wanted to give back using my music theory knowledge. I hope this is helpful, and I am happy to make any changes needed to get this merged. My coding skills are not that sharp, so any feedback is much appreciated! I tried to follow community standards, but please let me know if I missed anything.

Features:

  • Harmonic Matching: Finds tracks compatible with the source song's key using the Circle of Fifths. For example, it is easy to move from a song in C to a song in F, G, or Am. It is really difficult to move to a song in G#.

  • Enharmonic Support: Handles standard keys (e.g., C# and Db are treated as the same), as well as more nuanced options (like E#).

  • BPM Filtering: Limits results to a mixable BPM range (+/- 8%).

Future Improvements

  • Make the BPM range configurable (currently fixed at +/- 8%).

  • Integrate calls to autobpm and keyfinder to fill in missing metadata.

To Do

  • Documentation.
  • Changelog.
  • Tests.

@Exafto Exafto requested a review from a team as a code owner December 26, 2025 22:28
Copy link
Contributor

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 2 issues, and left some high level feedback:

  • Consider normalizing the key string (e.g., uppercasing and/or handling a trailing "major"/"minor" suffix) in get_compatible_keys so that common variations like "am" or "C major" still resolve correctly against CIRCLE_OF_FIFTHS.
  • get_bpm_range assumes bpm is numeric, but library items often store fields as strings; it might be safer to coerce source_bpm to float with error handling before passing it to get_bpm_range to avoid runtime type errors.
  • When building the BPM query (bpm:{min_b}..{max_b}), you may want to format or round the bounds to a reasonable precision (e.g., integers) to keep the query predictable and avoid edge cases with long float representations.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- Consider normalizing the key string (e.g., uppercasing and/or handling a trailing "major"/"minor" suffix) in `get_compatible_keys` so that common variations like `"am"` or `"C major"` still resolve correctly against `CIRCLE_OF_FIFTHS`.
- `get_bpm_range` assumes `bpm` is numeric, but library items often store fields as strings; it might be safer to coerce `source_bpm` to `float` with error handling before passing it to `get_bpm_range` to avoid runtime type errors.
- When building the BPM query (`bpm:{min_b}..{max_b}`), you may want to format or round the bounds to a reasonable precision (e.g., integers) to keep the query predictable and avoid edge cases with long float representations.

## Individual Comments

### Comment 1
<location> `test/test_harmonicmix.py:20-29` </location>
<code_context>
+from beetsplug.harmonicmix import HarmonicLogic
+
+
+def test_standard_compatibility():
+    """Verify standard Circle of Fifths relationships."""
+    # Case: C Major should contain G (Dominant), F (Subdominant), Am (Relative Minor)
+    keys = HarmonicLogic.get_compatible_keys("C")
+    assert "G" in keys
+    assert "F" in keys
+    assert "Am" in keys
+
+    # Case: Am should match Em
+    keys_am = HarmonicLogic.get_compatible_keys("Am")
+    assert "Em" in keys_am
+
+
</code_context>

<issue_to_address>
**suggestion (testing):** Cover additional edge cases for `get_compatible_keys`, such as whitespace and unknown keys.

Current tests cover valid keys and empty/None, but miss a few behaviors worth pinning down:

- Inputs with surrounding whitespace (e.g. `" C "`) should resolve correctly because of `.strip()`.
- Unknown/invalid keys (e.g. `"H#"`, `"foo"`) should return an empty list via `dict.get(key, [])`.

Consider adding tests like:
- `assert HarmonicLogic.get_compatible_keys(" C ") == HarmonicLogic.get_compatible_keys("C")`
- `assert HarmonicLogic.get_compatible_keys("H#") == []`

Suggested implementation:

```python
"""Tests for harmonicmix plugin. Tests only cover the logic class."""

from beetsplug.harmonicmix import HarmonicLogic

```

```python
def test_standard_compatibility():
    """Verify standard Circle of Fifths relationships."""
    # Case: C Major should contain G (Dominant), F (Subdominant), Am (Relative Minor)
    keys = HarmonicLogic.get_compatible_keys("C")
    assert "G" in keys
    assert "F" in keys
    assert "Am" in keys

    # Case: Am should match Em (relative compatibility)
    keys_am = HarmonicLogic.get_compatible_keys("Am")
    assert "Em" in keys_am


def test_whitespace_keys_are_equivalent():
    """Inputs with surrounding whitespace should resolve correctly."""
    keys_plain = HarmonicLogic.get_compatible_keys("C")
    keys_spaced = HarmonicLogic.get_compatible_keys(" C ")
    assert keys_spaced == keys_plain


def test_unknown_keys_return_empty_list():
    """Unknown/invalid keys should return an empty list."""
    assert HarmonicLogic.get_compatible_keys("H#") == []
    assert HarmonicLogic.get_compatible_keys("foo") == []

```
</issue_to_address>

### Comment 2
<location> `test/test_harmonicmix.py:45-50` </location>
<code_context>
+    assert "E#" in keys_c
+
+
+def test_bpm_range_calculation():
+    """Verify the BPM range logic (+/- 8%)."""
+    # 100 BPM -> Range should be 92 to 108
+    min_b, max_b = HarmonicLogic.get_bpm_range(100, 0.08)
+    assert min_b == 92.0
+    assert max_b == 108.0
+
+
</code_context>

<issue_to_address>
**suggestion (testing):** Add tests for `get_bpm_range` when `bpm` is 0 or `None`, and for the default range_percent.

`test_bpm_range_calculation` validates the formula with an explicit `range_percent`, but the function also has behaviors that should be locked in via tests:

- `bpm is None` (or other falsy values) should return `(0, 0)`, e.g. `assert HarmonicLogic.get_bpm_range(None) == (0, 0)`.
- If `bpm` being `0` is intentional, a test like `assert HarmonicLogic.get_bpm_range(0) == (0, 0)` documents that contract.
- A test that omits `range_percent` ensures the default remains 8%.

These will strengthen edge-case coverage for BPM handling.

Suggested implementation:

```python
def test_bpm_range_calculation():
    """Verify the BPM range logic (+/- 8%)."""
    # 100 BPM -> Range should be 92 to 108
    min_b, max_b = HarmonicLogic.get_bpm_range(100, 0.08)
    assert min_b == 92.0
    assert max_b == 108.0


def test_bpm_range_none_bpm():
    """bpm=None (or other falsy) should return (0, 0)."""
    assert HarmonicLogic.get_bpm_range(None) == (0, 0)


def test_bpm_range_zero_bpm():
    """bpm=0 should return (0, 0) to document the contract."""
    assert HarmonicLogic.get_bpm_range(0) == (0, 0)


def test_bpm_range_default_percent():
    """Omitting range_percent should use the default +/- 8%."""
    min_b, max_b = HarmonicLogic.get_bpm_range(100)
    assert min_b == 92.0
    assert max_b == 108.0

```

These tests assume `HarmonicLogic.get_bpm_range` currently implements:
- A falsy check like `if not bpm: return (0, 0)`.
- A default `range_percent` of `0.08`.

If the implementation differs (e.g., stricter type checks or a different default), you should align either the implementation or the expected values in these tests accordingly.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment on lines +45 to +50
def test_bpm_range_calculation():
"""Verify the BPM range logic (+/- 8%)."""
# 100 BPM -> Range should be 92 to 108
min_b, max_b = HarmonicLogic.get_bpm_range(100, 0.08)
assert min_b == 92.0
assert max_b == 108.0
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (testing): Add tests for get_bpm_range when bpm is 0 or None, and for the default range_percent.

test_bpm_range_calculation validates the formula with an explicit range_percent, but the function also has behaviors that should be locked in via tests:

  • bpm is None (or other falsy values) should return (0, 0), e.g. assert HarmonicLogic.get_bpm_range(None) == (0, 0).
  • If bpm being 0 is intentional, a test like assert HarmonicLogic.get_bpm_range(0) == (0, 0) documents that contract.
  • A test that omits range_percent ensures the default remains 8%.

These will strengthen edge-case coverage for BPM handling.

Suggested implementation:

def test_bpm_range_calculation():
    """Verify the BPM range logic (+/- 8%)."""
    # 100 BPM -> Range should be 92 to 108
    min_b, max_b = HarmonicLogic.get_bpm_range(100, 0.08)
    assert min_b == 92.0
    assert max_b == 108.0


def test_bpm_range_none_bpm():
    """bpm=None (or other falsy) should return (0, 0)."""
    assert HarmonicLogic.get_bpm_range(None) == (0, 0)


def test_bpm_range_zero_bpm():
    """bpm=0 should return (0, 0) to document the contract."""
    assert HarmonicLogic.get_bpm_range(0) == (0, 0)


def test_bpm_range_default_percent():
    """Omitting range_percent should use the default +/- 8%."""
    min_b, max_b = HarmonicLogic.get_bpm_range(100)
    assert min_b == 92.0
    assert max_b == 108.0

These tests assume HarmonicLogic.get_bpm_range currently implements:

  • A falsy check like if not bpm: return (0, 0).
  • A default range_percent of 0.08.

If the implementation differs (e.g., stricter type checks or a different default), you should align either the implementation or the expected values in these tests accordingly.

@codecov
Copy link

codecov bot commented Dec 26, 2025

Codecov Report

❌ Patch coverage is 43.63636% with 31 lines in your changes missing coverage. Please review.
✅ Project coverage is 68.18%. Comparing base (a62f4fb) to head (66aceab).
⚠️ Report is 61 commits behind head on master.
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
beetsplug/harmonicmix.py 43.63% 31 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master    #6241      +/-   ##
==========================================
- Coverage   68.26%   68.18%   -0.08%     
==========================================
  Files         138      139       +1     
  Lines       18791    18846      +55     
  Branches     3167     3177      +10     
==========================================
+ Hits        12827    12851      +24     
- Misses       5290     5321      +31     
  Partials      674      674              
Files with missing lines Coverage Δ
beetsplug/harmonicmix.py 43.63% <43.63%> (ø)
🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@Exafto
Copy link
Author

Exafto commented Dec 26, 2025

I think I'm finished with the code and docs, but the documentation linter keeps failing on the CI server even though it passes locally on my Windows machine. I made some attempts but I cannot pinpoint what's the problem. Could you help me spot the culprit?

@JOJ0
Copy link
Member

JOJ0 commented Dec 27, 2025

I think I'm finished with the code and docs, but the documentation linter keeps failing on the CI server even though it passes locally on my Windows machine. I made some attempts but I cannot pinpoint what's the problem. Could you help me spot the culprit?

You tried those poe commands?

https://beets.readthedocs.io/en/latest/contributing.html#style

@JOJ0
Copy link
Member

JOJ0 commented Dec 27, 2025

But first of all: Welcome! Thanks for the contribution. I've always wanted to add circle of fiths functionality to Beets but I was picturing it a little differently, the main point being to be able to use it as a general sorting mechanism so it's usable with the smartplaylist plugin too. I'll try to come up with a high level review during the next couple of days pointing out in which direction this could go and I spotted a view low level design improvement possibilities from a quick look already too. Also there is some minimal logic around the topic here already, which we should keep in mind: https://github.com/beetbox/beets/blob/master/beets/dbcore/types.py#L403 . Speak soon!

@Exafto
Copy link
Author

Exafto commented Dec 27, 2025

But first of all: Welcome! Thanks for the contribution. I've always wanted to add circle of fiths functionality to Beets but I was picturing it a little differently, the main point being to be able to use it as a general sorting mechanism so it's usable with the smartplaylist plugin too. I'll try to come up with a high level review during the next couple of days pointing out in which direction this could go and I spotted a view low level design improvement possibilities from a quick look already too. Also there is some minimal logic around the topic here already, which we should keep in mind: https://github.com/beetbox/beets/blob/master/beets/dbcore/types.py#L403 . Speak soon!

Hello! Thank you for replying so quickly and for welcoming me!

  • Firstly, I ran the poe commands mentioned in the style guide locally, and no changes took place. I could check for a Windows/Linux line-ending (CRLF) mismatch, but I will wait for the full high-level review.

  • Secondly, the idea of a sorting mechanism sounds great! I'd be happy to help to the best of my abilities. The existing logic in types.py will most likely replace my current logic, thanks for pointing it out.

To conclude, I'll wait for your full review and then start the refactor. Thanks again and happy holidays!

@JOJ0 JOJ0 added newplugin plugin Pull requests that are plugins related labels Jan 10, 2026
@JOJ0 JOJ0 self-assigned this Jan 11, 2026
@JOJ0
Copy link
Member

JOJ0 commented Jan 11, 2026

Hi Angelos,
finally my review and ideas for a way forward:

I see two use cases here:

  • Integrating sorting by musical key into Beets
  • The actual mix subcommand, which is a helper to find and print all "compatible tracks"

Sorting

  • The Beets query language could be extended with new special symbols for sorting by musical key, the tricky part is to find symbols that are possibly not used by shells like zsh, bash, Windows shells? For example using symbols like ~ and = would have to be escaped with single quotes (or \) which makes usage tedious. Let's brainstorm here...
  • Sorting by bpm is already possible (bpm+, bpm-), we don't need to do anything here
  • Or, and that might be easier, with a new config option in the plugin's config space, like eg. circle_of_fifths_sort: yes/no the regular + and - symbols would simply change the default sorting (alphabetically) to a clockwise or counterclockwise sort in the circle.

mix subcommand

Future ideas feedback

Integrate calls to autobpm and keyfinder to fill in missing metadata.

I would not recommend this. Integrating with specific plugins is very opinionated and creates dependencies not every user might want, and it sounds like overcomplicating things anyway

Make the BPM range configurable (currently fixed at +/- 8%).

This is easy to implement and I think would be good to include in the initial implementation. My personal preference for a default is +/- 6% since it works well on my Technics, but I'm fine with 8 if you prefer that :-)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

newplugin plugin Pull requests that are plugins related

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants