-
Notifications
You must be signed in to change notification settings - Fork 2k
Harmonic mix plugin #6241
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
Exafto
wants to merge
5
commits into
beetbox:master
Choose a base branch
from
Exafto:harmonic-mix-plugin
base: master
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+489
−132
Open
Harmonic mix plugin #6241
Changes from 2 commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
a883827
Add harmonicmix plugin for DJ mixing suggestions
Exafto 08e434f
Update copyright
Exafto e1dbe89
Fix linting, formatting, and extra tests
Exafto 81ae6f7
Fix documentation formatting
Exafto 66aceab
Fix RST header formatting and changelog alignment
Exafto File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
|
|
||
|
|
||
| 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.") | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
|
|
||
|
|
||
| def test_empty_input(): | ||
| """Ensure it doesn't crash on empty keys.""" | ||
| assert HarmonicLogic.get_compatible_keys(None) == [] | ||
| assert HarmonicLogic.get_compatible_keys("") == [] | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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_rangewhenbpmis 0 orNone, and for the default range_percent.test_bpm_range_calculationvalidates the formula with an explicitrange_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).bpmbeing0is intentional, a test likeassert HarmonicLogic.get_bpm_range(0) == (0, 0)documents that contract.range_percentensures the default remains 8%.These will strengthen edge-case coverage for BPM handling.
Suggested implementation:
These tests assume
HarmonicLogic.get_bpm_rangecurrently implements:if not bpm: return (0, 0).range_percentof0.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.