Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
156 changes: 156 additions & 0 deletions beetsplug/harmonicmix.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
# This file is part of beets.
# Copyright 2025, Angelos Exaftopoulos.
#
# 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.

"""Finds songs that are harmonically compatible with a chosen track.
The results also have a matching BPM (+/- 8% from the source track).
"""

from beets.plugins import BeetsPlugin
from beets import ui

Check failure on line 20 in beetsplug/harmonicmix.py

View workflow job for this annotation

GitHub Actions / Check linting

Ruff (I001)

beetsplug/harmonicmix.py:19:1: I001 Import block is un-sorted or un-formatted


class HarmonicLogic:
"""Pure music theory logic.
Separated from the plugin for test coverage.
"""

# Mapping normalized to standard musical notation
# Also covers enharmonics (i.e. F#=Gb and so on)
CIRCLE_OF_FIFTHS = {
# Major Keys
'C': ['C', 'B#', 'G', 'F', 'E#', 'Am'],
'G': ['G', 'D', 'C', 'B#', 'Em', 'Fbm'],
'D': ['D', 'A', 'G', 'Bm', 'Cbm'],
'A': ['A', 'E', 'Fb', 'D', 'F#m', 'Gbm'],
'E': ['E', 'Fb', 'B', 'Cb', 'A', 'C#m', 'Dbm'],
'B': ['B', 'Cb', 'F#', 'Gb', 'E', 'Fb', 'G#m', 'Abm'],
'Gb': ['Gb', 'F#', 'Db', 'C#', 'Cb', 'B', 'Ebm', 'D#m'],
'F#': ['F#', 'Gb', 'C#', 'Db', 'B', 'Cb', 'D#m', 'Ebm'],
'Db': ['Db', 'C#', 'Ab', 'G#', 'Gb', 'F#', 'Bbm', 'A#m'],
'C#': ['C#', 'Db', 'G#', 'Ab', 'F#', 'Gb', 'A#m', 'Bbm'],
'Ab': ['Ab', 'G#', 'Eb', 'D#', 'Db', 'C#', 'Fm', 'E#m'],
'Eb': ['Eb', 'D#', 'Bb', 'A#', 'Ab', 'G#', 'Cm', 'B#m'],
'Bb': ['Bb', 'A#', 'F', 'E#', 'Eb', 'D#', 'Gm'],
'F': ['F', 'E#', 'C', 'B#', 'Bb', 'A#', 'Dm'],

# Major Enharmonics
'B#': ['C', 'B#', 'G', 'F', 'E#', 'Am'],
'E#': ['F', 'E#', 'C', 'B#', 'Bb', 'A#', 'Dm'],
'Cb': ['B', 'Cb', 'F#', 'Gb', 'E', 'Fb', 'G#m', 'Abm'],
'Fb': ['E', 'Fb', 'B', 'Cb', 'A', 'C#m', 'Dbm'],

# Minor Keys
'Am': ['Am', 'Em', 'Fbm', 'Dm', 'C', 'B#'],
'Em': ['Em', 'Fbm', 'Bm', 'Cbm', 'Am', 'G'],
'Bm': ['Bm', 'Cbm', 'F#m', 'Gbm', 'Em', 'Fbm', 'D'],
'F#m': ['F#m', 'Gbm', 'C#m', 'Dbm', 'Bm', 'Cbm', 'A'],
'C#m': ['C#m', 'Dbm', 'G#m', 'Abm', 'F#m', 'Gbm', 'E', 'Fb'],
'G#m': ['G#m', 'Abm', 'D#m', 'Ebm', 'C#m', 'Dbm', 'B', 'Cb'],
'Ebm': ['Ebm', 'D#m', 'Bbm', 'A#m', 'G#m', 'Abm', 'Gb', 'F#'],
'D#m': ['D#m', 'Ebm', 'A#m', 'Bbm', 'G#m', 'Abm', 'F#', 'Gb'],
'Bbm': ['Bbm', 'A#m', 'Fm', 'E#m', 'Ebm', 'D#m', 'Db', 'C#'],
'Fm': ['Fm', 'E#m', 'Cm', 'B#m', 'Bbm', 'A#m', 'Ab', 'G#'],
'Cm': ['Cm', 'B#m', 'Gm', 'Fm', 'E#m', 'Eb', 'D#'],
'Gm': ['Gm', 'Dm', 'Cm', 'B#m', 'Bb', 'A#'],
'Dm': ['Dm', 'Am', 'Gm', 'F', 'E#'],

# Minor Enharmonics
'E#m': ['Fm', 'E#m', 'Cm', 'B#m', 'Bbm', 'A#m', 'Ab', 'G#'],
'B#m': ['Cm', 'B#m', 'Gm', 'Fm', 'E#m', 'Eb', 'D#'],
'Cbm': ['Bm', 'Cbm', 'F#m', 'Gbm', 'Em', 'Fbm', 'D'],
'Fbm': ['Em', 'Fbm', 'Bm', 'Cbm', 'Am', 'G'],
}

@staticmethod
def get_compatible_keys(key):
"""Returns a list of compatible keys for a given input key."""
# We assume the DB uses standard short notation, i.e. C instead of C major
if not key:
return []

# Strip whitespace
key = key.strip()
return HarmonicLogic.CIRCLE_OF_FIFTHS.get(key, [])

@staticmethod
def get_bpm_range(bpm, range_percent=0.08):
"""Returns a tuple (min_bpm, max_bpm)"""
if not bpm:
return (0, 0)
return (bpm * (1 - range_percent), bpm * (1 + range_percent))


class HarmonicMixPlugin(BeetsPlugin):
"""The Beets plugin wrapper."""

def __init__(self):
super().__init__()

def commands(self):
cmd = ui.Subcommand('mix', help='find harmonically compatible songs')
cmd.func = self.command
return [cmd]

def command(self, lib, opts, args):
query = args
items = lib.items(query)

if not items:
self._log.warning("Song not found!")
return

source_song = items[0]
# Use .get() to avoid crashing if tags are missing
source_key = source_song.get('key')
source_bpm = source_song.get('bpm')

if not source_key:
self._log.warning(f"No key found for {source_song.title}")
return

self._log.info(
f"Source: {source_song.title} | Key: {source_key} | BPM: {source_bpm}"
)

# 1. Get Logic from Helper Class
compatible_keys = HarmonicLogic.get_compatible_keys(source_key)

# 2. BPM Range
# Only use BPM logic if the song actually has a BPM
bpm_query_part = ""
if source_bpm:
min_b, max_b = HarmonicLogic.get_bpm_range(source_bpm)
# Create a range query string for Beets
bpm_query_part = f" bpm:{min_b}..{max_b}"

# 3. Construct Query
# We want: (Key:A OR Key:B...) AND BPM range
# Beets query syntax for OR is a bit tricky, so we filter manually
# for safety and simplicity, using only the BPM filter in the query.
candidates = lib.items(bpm_query_part.strip())

found_count = 0
for song in candidates:
# skip source
if song.id == source_song.id:
continue

if song.get('key') in compatible_keys:
self._log.info(
f"MATCH: {song.title} ({song.get('key')}, {song.get('bpm')} BPM)"
)
found_count += 1

if found_count == 0:
self._log.info("No mixable songs found.")
1 change: 1 addition & 0 deletions docs/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ New features:
resolve differences in metadata source styles.
- :doc:`plugins/spotify`: Added support for multi-artist albums and tracks,
saving all contributing artists to the respective fields.
- :doc:`/plugins/harmonicmix`: Suggests harmonically compatible tracks for DJ mixing based on key and BPM.

Bug fixes:

Expand Down
33 changes: 33 additions & 0 deletions docs/plugins/harmonicmix.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
Harmonic Mix Plugin
===================

The ``harmonicmix`` plugin is designed to help DJs and playlist curators find songs that mix well together based on the Circle of Fifths. It checks for:

1. **Harmonic Compatibility:** Finds songs in the same key, the dominant, subdominant, or relative major/minor. It also handles common enharmonics (e.g., F# = Gb).
2. **BPM Matching:** Filters results to show tracks within a mixable BPM range (+/- 8% of the source track).

Configuration
-------------

To use the plugin, enable it in your configuration file (``config.yaml``)::

plugins: harmonicmix

Usage
-----

To find songs compatible with a specific track, use the ``mix`` command followed by a query::

$ beet mix "Billie Jean"

The plugin will list tracks that match the harmonic criteria. For example::

Source: Billie Jean | Key: F#m | BPM: 117
----------------------------------------
MATCH: Stayin' Alive (F#m, 104 BPM)
MATCH: Another One Bites the Dust (Em, 110 BPM)

Note that if the source song does not have a ``key`` tag, the plugin cannot find matches. In addition, if a song does not have a ``bpm`` tag, then the matching process only considers the key.
Tags must be in the format: "C" instead of "C major".

This plugin could also be paired with the ``xtractor`` or ``keyfinder`` plugins for better results.
56 changes: 56 additions & 0 deletions test/test_harmonicmix.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# This file is part of beets.
# Copyright 2025, Angelos Exaftopoulos.
#
# 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.

"""Tests for harmonicmix plugin. Test only cover the logic class"""

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


def test_custom_enharmonics():
"""Verify advanced enharmonics such as E#==Fb"""
# Case: Fbm should be compatible with Am (because Fbm == Em)
keys_am = HarmonicLogic.get_compatible_keys("Am")
assert "Fbm" in keys_am

# Case: E# should be treated like F
# F is compatible with C, so E# should be in C's list
keys_c = HarmonicLogic.get_compatible_keys("C")
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
Comment on lines +58 to +63
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.



def test_empty_input():
"""Ensure it doesn't crash on empty keys."""
assert HarmonicLogic.get_compatible_keys(None) == []
assert HarmonicLogic.get_compatible_keys("") == []
Loading