Repository: beetbox/beets

Issue:
Implement an automatic bmp computation
### Use case

**beets** already comes with a plugin for computing [bpm](https://beets.readthedocs.io/en/stable/plugins/bpm.html), based on _manual_ tapping (!).
However alternatives exist for _automatically_ computing the **bpm**:
* [librosa](https://github.com/librosa/librosa)
* [aubio](https://github.com/aubio/aubio)
* ...

### Solution

I quickly test both. Both projects are quite active. **audio** was a bit simpler to install and to test. It could be installed as a standalone command as well, not only as **Python** dependency.

Other criteria to consider if I want to write a plugin around it? What about the name? Or are they already plugins in **beets** which might compute the `bmp`?


Discussion:
> I quickly test both. Both projects are quite active. **audio** was a bit simpler to install and to test. It could be installed as a standalone command as well, not only as **Python** dependency.
> 
> Other criteria to consider if I want to write a plugin around it? What about the name? Or are they already plugins in **beets** which might compute the `bmp`?

In fact, I would say it should become part of the `bpm` plugin: You could add a `method` configuration option. It would need to take a value like `manual` corresponding to the current functionality. In addition, you could pick your preferred backend out of `librosa` and `aubio` and implement that (or both, or leave the other to whoever is interested). This would be similar to other plugins (`lyrics`, `fetchart`) that provide some functionality from a number of user-selectable backends.

Thanks a lot. I will have a look.
Sounds reasonable. FWIW, you can also try AcousticBrainz, which gets not only `bpm` but also many other acoustic measurements:
https://beets.readthedocs.io/en/stable/plugins/acousticbrainz.html
> Sounds reasonable. FWIW, you can also try AcousticBrainz, which gets not only `bpm` but also many other acoustic measurements:
> https://beets.readthedocs.io/en/stable/plugins/acousticbrainz.html

True. But then this information will be triggered from the **AcousticBrainz** API. If the track you are trying to import is not part of the project, then you have nothing or, better said, you will need an _offline_ solution.

This is where the current `bpm` plugin makes senses.

Does it make sense?
Yes, but there is also `absubmit`, which actually does the analysis:
https://beets.readthedocs.io/en/stable/plugins/absubmit.html
Having a deeper look at it... If we go the way @wisp3rwind is suggesting, then we end up with an _unclean_ interface IMHO: `max_strokes` makes only sense in **manual** mode. One possibility to overcome this flaw would be to have a deeper **YAML** structure:
```yml
method:
  manual:
    max_strokes: ...
```
What do you think?
Sorry @ribeaud for not replying for such a long time here!

> Having a deeper look at it... If we go the way @wisp3rwind is suggesting, then we end up with an _unclean_ interface IMHO: `max_strokes` makes only sense in **manual** mode. One possibility to overcome this flaw would be to have a deeper **YAML** structure:
> 
> ```yaml
> method:
>   manual:
>     max_strokes: ...
> ```
> 
> What do you think?

Sounds good! I guess beets doesn't really have a very clean config structure in that sense overall, and we don't validate the configuration apart from checking types for the values that are actually being accessed anyway.

Currently, the plugin can be configured as
```yaml
bpm:
    max_strokes: 3,
    overwrite: yes,
```

So the full config section for the plugin might look like this I guess?
```yaml
bpm:
    max_strokes: 3,  # Still support this for the time being, but issue a deprecation warning
    overwrite: yes,
    method: "manual"|"aubio",   # Default to "manual", which doesn't have any external dependency and matches the old behaviour
    manual:
        max_strokes: 3,
    aubio:
        some_setting: "value",
```

One could also consider adding an `auto` option to enable analysis on import, and it could be worthwhile to add a `--method` CLI option to the `bpm` command which overrides the config value (neither of these features would of course be required for an initial implementation of these new backends, just collecting some ideas here).
All this sounds totally reasonable, including the separate subsections in the config! I just also wanted to mention that it could even make sense to establish a second plugin, called something like `autobpm` or whatever, to support methods like aubio. In particular, the manual space-bar-pressing method is _so different_ from any kind of audio analysis that they might have very little in common in terms of implementation, configuration, or interested audience.

Anyway, no strong preference in favor of separation, but just thought I'd mention it as a possibility!

Resolving patch:
diff --git a/beetsplug/autobpm.py b/beetsplug/autobpm.py
new file mode 100644
--- /dev/null
+++ b/beetsplug/autobpm.py
@@ -0,0 +1,80 @@
+# This file is part of beets.
+#
+# 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.
+
+"""Uses Librosa to calculate the `bpm` field.
+"""
+
+
+from beets import ui
+from beets import util
+from beets.plugins import BeetsPlugin
+
+from librosa import load, beat
+from soundfile import LibsndfileError
+
+
+class AutoBPMPlugin(BeetsPlugin):
+
+    def __init__(self):
+        super().__init__()
+        self.config.add({
+            'auto': True,
+            'overwrite': False,
+        })
+
+        if self.config['auto'].get(bool):
+            self.import_stages = [self.imported]
+
+    def commands(self):
+        cmd = ui.Subcommand('autobpm',
+                            help='detect and add bpm from audio using Librosa')
+        cmd.func = self.command
+        return [cmd]
+
+    def command(self, lib, opts, args):
+        self.calculate_bpm(lib.items(ui.decargs(args)),
+                           write=ui.should_write())
+
+    def imported(self, session, task):
+        self.calculate_bpm(task.imported_items())
+
+    def calculate_bpm(self, items, write=False):
+        overwrite = self.config['overwrite'].get(bool)
+
+        for item in items:
+            if item['bpm']:
+                self._log.info('found bpm {0} for {1}',
+                               item['bpm'], util.displayable_path(item.path))
+                if not overwrite:
+                    continue
+
+            try:
+                y, sr = load(util.syspath(item.path), res_type='kaiser_fast')
+            except LibsndfileError as exc:
+                self._log.error('LibsndfileError: failed to load {0} {1}',
+                                util.displayable_path(item.path), exc)
+                continue
+            except ValueError as exc:
+                self._log.error('ValueError: failed to load {0} {1}',
+                                util.displayable_path(item.path), exc)
+                continue
+
+            tempo, _ = beat.beat_track(y=y, sr=sr)
+            bpm = round(tempo)
+            item['bpm'] = bpm
+            self._log.info('added computed bpm {0} for {1}',
+                           bpm, util.displayable_path(item.path))
+
+            if write:
+                item.try_write()
+            item.store()


Test patch: