diff --git a/BEETSDIR/config.yaml b/BEETSDIR/config.yaml index e5302bf..05a8b0d 100644 --- a/BEETSDIR/config.yaml +++ b/BEETSDIR/config.yaml @@ -28,17 +28,17 @@ xtractor: force: no quiet: no items_per_run: 0 - keep_output: no + keep_output: yes keep_profiles: no output_path: /Users/jackisback/Documents/Projects/Python/BeetsPluginXtractor/BEETSDIR/xtraction low_level_extractor: /Users/jackisback/Documents/Projects/Other/extractors/beta5/essentia_streaming_extractor_music high_level_extractor: /Users/jackisback/Documents/Projects/Other/extractors/beta5/essentia_streaming_extractor_music_svm low_level_profile: - outputFormat: yaml - outputFrames: 0 + outputFormat: yaml + outputFrames: 0 high_level_profile: - outputFormat: json - highlevel: + outputFormat: json + highlevel: compute: 1 svm_models: - /Users/jackisback/Documents/Projects/Other/extractors/svm_models_beta5/danceability.history @@ -52,6 +52,6 @@ xtractor: - /Users/jackisback/Documents/Projects/Other/extractors/svm_models_beta5/mood_relaxed.history - /Users/jackisback/Documents/Projects/Other/extractors/svm_models_beta5/mood_sad.history - /Users/jackisback/Documents/Projects/Other/extractors/svm_models_beta5/voice_instrumental.history - chromaprint: - compute: 0 + chromaprint: + compute: 0 diff --git a/MANIFEST.in b/MANIFEST.in index e79dc2a..8b46fe1 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,3 +1,5 @@ prune test include LICENSE.txt include README.md +include beetsplug/xtractor/version.py +include beetsplug/xtractor/config_default.yml \ No newline at end of file diff --git a/README.md b/README.md index 9c1faea..e69bb3e 100644 --- a/README.md +++ b/README.md @@ -5,19 +5,21 @@ # Xtractor (beets plugin) -*A [beets](https://github.com/beetbox/beets) plugin for insane obsessive-compulsive music geeks.* +The *beets-xtractor* plugin lets you use the extractors of the [Essentia](https://essentia.upf.edu/index.html) project developed by the Music Technology Group. -The *beets-xtractor* plugin lets you use the extractors of the Essentia project developed by (credits here) to... + +*NOTE: This plugin is highly unstable and not at all documented! Use it at your own risk* ## Installation The plugin can be installed via: ```shell script -$ pip install beets-xtractor (*not just yet!*) +$ pip install beets-xtractor ``` +## References [Essentia](https://essentia.upf.edu/index.html) [SVM Models](https://essentia.upf.edu/svm_models/) @@ -28,4 +30,3 @@ $ pip install beets-xtractor (*not just yet!*) [Acousticbrainz Downloads](https://acousticbrainz.org/download) - diff --git a/beetsplug/xtractor/__init__.py b/beetsplug/xtractor/__init__.py index 9e0e8fc..767e49f 100644 --- a/beetsplug/xtractor/__init__.py +++ b/beetsplug/xtractor/__init__.py @@ -4,23 +4,22 @@ # Created: 3/13/20, 12:17 AM # License: See LICENSE.txt +import os + from beets.plugins import BeetsPlugin -from beets.util import cpu_count +from beets.util.confit import ConfigSource, load_yaml from beetsplug.xtractor.command import XtractorCommand class XtractorPlugin(BeetsPlugin): + _default_plugin_config_file_name_ = 'config_default.yml' + def __init__(self): super(XtractorPlugin, self).__init__() - self.config.add({ - 'auto': False, - 'dry-run': False, - 'write': True, - 'threads': cpu_count(), - 'force': False, - 'quiet': False - }) + config_file_path = os.path.join(os.path.dirname(__file__), self._default_plugin_config_file_name_) + source = ConfigSource(load_yaml(config_file_path) or {}, config_file_path) + self.config.add(source) def commands(self): return [XtractorCommand(self.config)] diff --git a/beetsplug/xtractor/command.py b/beetsplug/xtractor/command.py index 125afd2..df9b2bf 100644 --- a/beetsplug/xtractor/command.py +++ b/beetsplug/xtractor/command.py @@ -142,13 +142,32 @@ def xtract(self): # Set up the query for unprocessed items unprocessed_items_query = dbcore.query.OrQuery( [ + # LOW + # dbcore.query.NoneQuery(u'average_loudness', fast=False), + dbcore.query.MatchQuery(u'average_loudness', None, fast=False), dbcore.query.NumericQuery(u'bpm', u'0'), - dbcore.query.MatchQuery(u'gender', u'', fast=False), + dbcore.query.MatchQuery(u'danceability', None, fast=False), + dbcore.query.MatchQuery(u'beats_count', None, fast=False), + + # HIGH + dbcore.query.MatchQuery(u'danceable', None, fast=False), dbcore.query.MatchQuery(u'gender', None, fast=False), + dbcore.query.MatchQuery(u'genre_rosamerica', None, fast=False), + dbcore.query.MatchQuery(u'voice_instrumental', None, fast=False), + + dbcore.query.MatchQuery(u'mood_acoustic', None, fast=False), + dbcore.query.MatchQuery(u'mood_aggressive', None, fast=False), + dbcore.query.MatchQuery(u'mood_electronic', None, fast=False), + dbcore.query.MatchQuery(u'mood_happy', None, fast=False), + dbcore.query.MatchQuery(u'mood_party', None, fast=False), + dbcore.query.MatchQuery(u'mood_relaxed', None, fast=False), + dbcore.query.MatchQuery(u'mood_sad', None, fast=False), ] ) combined_query = dbcore.query.AndQuery([parsed_query, unprocessed_items_query]) + log.debug("Combined query: {}".format(combined_query)) + # Get the library items library_items = self.lib.items(combined_query, parsed_sort) if len(library_items) == 0: @@ -213,9 +232,9 @@ def _run_analysis_high_level(self, item): self._say("Running high-level analysis: {0}".format(input_path)) self._run_essentia_extractor(extractor_path, input_path, output_path, profile_path) - # todo: allow failing individual attributes try: - audiodata = bpmHelper.extract_high_level_data(output_path) + target_map = self.config["high_level_targets"] + audiodata = bpmHelper.extract_from_output(output_path, target_map) except FileNotFoundError as e: self._say("File not found: {0}".format(e)) return @@ -223,13 +242,12 @@ def _run_analysis_high_level(self, item): self._say("Attribute not present: {0}".format(e)) return + print(audiodata) + if not self.cfg_dry_run: - for attr in [ - "danceable", "gender", "genre_rosamerica", "voice_instrumental", - "mood_acoustic", "mood_aggressive", "mood_electronic", - "mood_happy", "mood_party", "mood_relaxed", "mood_sad" - ]: - setattr(item, attr, audiodata.get(attr)) + for attr in audiodata.keys(): + if audiodata.get(attr): + setattr(item, attr, audiodata.get(attr)) item.store() def _run_analysis_low_level(self, item): @@ -252,7 +270,9 @@ def _run_analysis_low_level(self, item): self._run_essentia_extractor(extractor_path, input_path, output_path, profile_path) try: - audiodata = bpmHelper.extract_low_level_data(output_path) + target_map = self.config["low_level_targets"] + audiodata = bpmHelper.extract_from_output(output_path, target_map) + except FileNotFoundError as e: self._say("File not found: {0}".format(e)) return @@ -261,8 +281,9 @@ def _run_analysis_low_level(self, item): return if not self.cfg_dry_run: - for attr in ["bpm"]: - setattr(item, attr, audiodata.get(attr)) + for attr in audiodata.keys(): + if audiodata.get(attr): + setattr(item, attr, audiodata.get(attr)) item.store() def _run_essentia_extractor(self, extractor_path, input_path, output_path, profile_path): diff --git a/beetsplug/xtractor/config_default.yml b/beetsplug/xtractor/config_default.yml new file mode 100644 index 0000000..5687f13 --- /dev/null +++ b/beetsplug/xtractor/config_default.yml @@ -0,0 +1,65 @@ +auto: no +dry-run: no +write: yes +threads: 1 +force: no +quiet: no +low_level_targets: + average_loudness: + path: "lowlevel.average_loudness" + type: float + bpm: + path: "rhythm.bpm" + type: integer + danceability: + path: "rhythm.danceability" + type: float + beats_count: + path: "rhythm.beats_count" + type: integer +high_level_targets: + danceable: + path: "highlevel.danceability.all.danceable" + type: float + gender: + path: "highlevel.gender.value" + type: string + is_male: + path: "highlevel.gender.all.male" + type: float + is_female: + path: "highlevel.gender.all.female" + type: float + genre_rosamerica: + path: "highlevel.genre_rosamerica.value" + type: string + voice_instrumental: + path: "highlevel.voice_instrumental.value" + type: string + is_voice: + path: "highlevel.voice_instrumental.all.voice" + type: float + is_instrumental: + path: "highlevel.voice_instrumental.all.instrumental" + type: float + mood_acoustic: + path: "highlevel.mood_acoustic.all.acoustic" + type: float + mood_aggressive: + path: "highlevel.mood_aggressive.all.aggressive" + type: float + mood_electronic: + path: "highlevel.mood_electronic.all.electronic" + type: float + mood_happy: + path: "highlevel.mood_happy.all.happy" + type: float + mood_party: + path: "highlevel.mood_party.all.party" + type: float + mood_relaxed: + path: "highlevel.mood_relaxed.all.relaxed" + type: float + mood_sad: + path: "highlevel.mood_sad.all.sad" + type: float diff --git a/beetsplug/xtractor/helper.py b/beetsplug/xtractor/helper.py index 56be564..12f883a 100644 --- a/beetsplug/xtractor/helper.py +++ b/beetsplug/xtractor/helper.py @@ -7,20 +7,26 @@ import json import os +from confuse import Subview + _module_path = os.path.dirname(__file__) """Checklist from Acousticbrainz plugin: - -average_loudness X +ITEM bpm OK +initial_key X ??? + +ATTRIBUTE: +average_loudness OK +beets_count OK (extra) chords_changes_rate X chords_key X chords_number_rate X chords_scale X danceable OK +danceability OK (extra) gender OK (!!!) genre_rosamerica OK -initial_key X key_strength X mood_acoustic OK mood_aggressive OK @@ -31,98 +37,47 @@ mood_sad OK rhythm X tonal X -voice_instrumental X +voice_instrumental OK """ -def extract_high_level_data(output_path): +def extract_from_output(output_path, target_map: Subview): + """extracts data from the low level json file as mapped out in the `low_level_targets` configuration key + """ data = {} if os.path.isfile(output_path): with open(output_path, "r") as json_file: audiodata = json.load(json_file) - if "highlevel" in audiodata: - highlevel = audiodata["highlevel"] - - if "danceability" in highlevel: - data['danceable'] = float(highlevel["danceability"]["all"]["danceable"]) - else: - raise KeyError("No 'danceability' data for: {}".format(output_path)) - - if "gender" in highlevel: - data['gender'] = highlevel["gender"]["value"] - data['is_male'] = float(highlevel["gender"]["all"]["male"]) - data['is_female'] = float(highlevel["gender"]["all"]["female"]) - else: - raise KeyError("No 'gender' data for: {}".format(output_path)) - - if "genre_rosamerica" in highlevel: - data['genre_rosamerica'] = highlevel["genre_rosamerica"]["value"] - else: - raise KeyError("No 'genre_rosamerica' data for: {}".format(output_path)) - - if "voice_instrumental" in highlevel: - data['voice_instrumental'] = highlevel["voice_instrumental"]["value"] - data['is_voice'] = float(highlevel["voice_instrumental"]["all"]["voice"]) - data['is_instrumental'] = float(highlevel["voice_instrumental"]["all"]["instrumental"]) - else: - raise KeyError("No 'voice_instrumental' data for: {}".format(output_path)) - - if "mood_acoustic" in highlevel: - data['mood_acoustic'] = float(highlevel["mood_acoustic"]["all"]["acoustic"]) - else: - raise KeyError("No 'mood_acoustic' data for: {}".format(output_path)) - - if "mood_aggressive" in highlevel: - data['mood_aggressive'] = float(highlevel["mood_aggressive"]["all"]["aggressive"]) - else: - raise KeyError("No 'mood_aggressive' data for: {}".format(output_path)) - - if "mood_electronic" in highlevel: - data['mood_electronic'] = float(highlevel["mood_electronic"]["all"]["electronic"]) - else: - raise KeyError("No 'mood_electronic' data for: {}".format(output_path)) - - if "mood_happy" in highlevel: - data['mood_happy'] = float(highlevel["mood_happy"]["all"]["happy"]) - else: - raise KeyError("No 'mood_happy' data for: {}".format(output_path)) + for key in target_map.keys(): + try: + val = extract_value_from_audiodata(audiodata, target_map[key]) + except AttributeError: + val = None - if "mood_party" in highlevel: - data['mood_party'] = float(highlevel["mood_party"]["all"]["party"]) - else: - raise KeyError("No 'mood_party' data for: {}".format(output_path)) - - if "mood_relaxed" in highlevel: - data['mood_relaxed'] = float(highlevel["mood_relaxed"]["all"]["relaxed"]) - else: - raise KeyError("No 'mood_relaxed' data for: {}".format(output_path)) - - if "mood_sad" in highlevel: - data['mood_sad'] = float(highlevel["mood_sad"]["all"]["sad"]) - else: - raise KeyError("No 'mood_sad' data for: {}".format(output_path)) - - else: - raise KeyError("No high level data for: {}".format(output_path)) + data[key] = val else: raise FileNotFoundError("Output file({}) not found!".format(output_path)) return data -def extract_low_level_data(output_path): - data = {} - - if os.path.isfile(output_path): - with open(output_path, "r") as json_file: - audiodata = json.load(json_file) - if "rhythm" in audiodata and "bpm" in audiodata["rhythm"]: - bpm = round(float(audiodata["rhythm"]["bpm"])) - data['bpm'] = bpm - else: - raise AttributeError("No Bpm data for: {}".format(output_path)) +def extract_value_from_audiodata(audiodata, target_map_item: Subview): + path: str = target_map_item["path"].as_str() + value_type = target_map_item["type"].as_str() + path_parts = path.split(".") + for part in path_parts: + if not part in audiodata: + raise AttributeError("No path '{}' found in audiodata".format(path)) + audiodata = audiodata[part] + + if value_type == "string": + value = str(audiodata) + elif value_type == "float": + value = float(audiodata) + elif value_type == "integer": + value = int(round(float(audiodata))) else: - raise FileNotFoundError("Output file({}) not found!".format(output_path)) + value = audiodata - return data + return value diff --git a/beetsplug/xtractor/version.py b/beetsplug/xtractor/version.py index 6e3531e..12381f8 100644 --- a/beetsplug/xtractor/version.py +++ b/beetsplug/xtractor/version.py @@ -4,4 +4,4 @@ # Created: 3/13/20, 12:17 AM # License: See LICENSE.txt -__version__ = '0.1.2' +__version__ = '0.2.0'