Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
11 changes: 9 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@ Unsupported file types will be ignored when syncing.
```bash
brew install ffmpeg
brew install taglib
brew install bpm-tools
```

3. Install Wavesync
Expand All @@ -37,7 +36,15 @@ brew install bpm-tools
gem install wavesync --pre
```

4. Install field kit (only required for syncing to TP-7)
4. Set up the Python environment for BPM analysis

```bash
brew install python@3.11
python3.11 -m venv ~/.wavesync-venv
~/.wavesync-venv/bin/pip install essentia
```

5. Install field kit (only required for syncing to TP-7)

https://teenage.engineering/guides/fieldkit

Expand Down
1 change: 1 addition & 0 deletions Steepfile
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ target :wavesync do
# Ignore files whose bodies Steep cannot check due to DSL patterns
ignore 'lib/wavesync/audio_format.rb'

library 'json'
library 'logger'
library 'yaml'
library 'psych'
Expand Down
3 changes: 2 additions & 1 deletion lib/wavesync/analyzer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
module Wavesync
class Analyzer
CONFIRM_MESSAGE = 'wavesync analyze will add bpm meta data to files in library. Continue? [y/N] '
SETUP_INSTRUCTIONS = 'brew install python@3.11 && python3.11 -m venv ~/.wavesync-venv && ~/.wavesync-venv/bin/pip install essentia'

#: (String library_path) -> void
def initialize(library_path)
Expand All @@ -15,7 +16,7 @@ def initialize(library_path)
#: (?overwrite: bool) -> void
def analyze(overwrite: false)
unless BpmDetector.available?
puts 'Error: bpm-tools or ffmpeg is not installed. Install with: brew install bpm-tools ffmpeg'
puts "Error: essentia is not installed. Set up the Python venv with:\n #{SETUP_INSTRUCTIONS}"
exit 1
end

Expand Down
18 changes: 11 additions & 7 deletions lib/wavesync/bpm_detector.rb
Original file line number Diff line number Diff line change
@@ -1,22 +1,26 @@
# frozen_string_literal: true
# rbs_inline: enabled

require 'shellwords'
require_relative 'essentia_bpm_detector'
require_relative 'percival_bpm_detector'

module Wavesync
class BpmDetector
CONFIDENCE_THRESHOLD = 2.0

#: () -> bool?
def self.available?
system('which bpm > /dev/null 2>&1') && system('which ffmpeg > /dev/null 2>&1')
EssentiaBpmDetector.available? || PercivalBpmDetector.available?
end

#: (String file_path) -> Integer?
def self.detect(file_path)
output = `ffmpeg -i #{Shellwords.escape(file_path)} -ac 1 -ar 44100 -f f32le - 2>/dev/null | bpm`
bpm = output.strip.to_f
bpm.positive? ? bpm.round : nil
rescue StandardError
nil
if EssentiaBpmDetector.available?
essentia_result = EssentiaBpmDetector.detect(file_path)
return essentia_result[:bpm] if essentia_result && essentia_result[:confidence] > CONFIDENCE_THRESHOLD
end

PercivalBpmDetector.detect(file_path) if PercivalBpmDetector.available?
end
end
end
36 changes: 36 additions & 0 deletions lib/wavesync/essentia_bpm_detector.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# frozen_string_literal: true
# rbs_inline: enabled

require 'json'
require_relative 'python_venv'

module Wavesync
class EssentiaBpmDetector
PYTHON_SCRIPT = <<~PYTHON
import essentia, essentia.streaming as ess, json, sys
pool = essentia.Pool()
loader = ess.MonoLoader(filename=sys.argv[1], sampleRate=44100)
rhythm = ess.RhythmDescriptors()
loader.audio >> rhythm.signal
rhythm.bpm >> (pool, 'bpm')
rhythm.confidence >> (pool, 'confidence')
essentia.run(loader)
print(json.dumps({'bpm': round(float(pool['bpm'])), 'confidence': round(float(pool['confidence']), 2)}))
PYTHON

#: () -> bool?
def self.available?
PythonVenv.essentia_available?
end

#: (String file_path) -> {bpm: Integer, confidence: Float}?
def self.detect(file_path)
output = PythonVenv.run_script(PYTHON_SCRIPT, file_path)
data = JSON.parse(output.strip)
bpm = data['bpm'].to_f
bpm.positive? ? { bpm: bpm.round, confidence: data['confidence'].to_f } : nil
rescue StandardError
nil
end
end
end
29 changes: 29 additions & 0 deletions lib/wavesync/percival_bpm_detector.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# frozen_string_literal: true
# rbs_inline: enabled

require_relative 'python_venv'

module Wavesync
class PercivalBpmDetector
PYTHON_SCRIPT = <<~PYTHON
import essentia.standard as es, sys
audio = es.MonoLoader(filename=sys.argv[1], sampleRate=44100)()
bpm = es.PercivalBpmEstimator()(audio)
print(round(float(bpm)))
PYTHON

#: () -> bool?
def self.available?
PythonVenv.essentia_available?
end

#: (String file_path) -> Integer?
def self.detect(file_path)
output = PythonVenv.run_script(PYTHON_SCRIPT, file_path)
bpm = output.strip.to_f
bpm.positive? ? bpm.round : nil
rescue StandardError
nil
end
end
end
25 changes: 25 additions & 0 deletions lib/wavesync/python_venv.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# frozen_string_literal: true
# rbs_inline: enabled

require 'shellwords'

module Wavesync
module PythonVenv
PYTHON_PATH = File.expand_path('~/.wavesync-venv/bin/python3').freeze

#: () -> bool
def self.available?
File.executable?(PYTHON_PATH)
end

#: () -> bool?
def self.essentia_available?
available? && system("#{PYTHON_PATH} -c 'import essentia' > /dev/null 2>&1")
end

#: (String script, String file_path) -> String
def self.run_script(script, file_path)
`#{PYTHON_PATH} -c #{Shellwords.escape(script)} #{Shellwords.escape(file_path)} 2>/dev/null`
end
end
end
2 changes: 2 additions & 0 deletions sig/generated/wavesync/analyzer.rbs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ module Wavesync
class Analyzer
CONFIRM_MESSAGE: ::String

SETUP_INSTRUCTIONS: ::String

# : (String library_path) -> void
def initialize: (String library_path) -> void

Expand Down
2 changes: 2 additions & 0 deletions sig/generated/wavesync/bpm_detector.rbs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

module Wavesync
class BpmDetector
CONFIDENCE_THRESHOLD: ::Float

# : () -> bool?
def self.available?: () -> bool?

Expand Down
13 changes: 13 additions & 0 deletions sig/generated/wavesync/essentia_bpm_detector.rbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Generated from lib/wavesync/essentia_bpm_detector.rb with RBS::Inline

module Wavesync
class EssentiaBpmDetector
PYTHON_SCRIPT: ::String

# : () -> bool?
def self.available?: () -> bool?

# : (String file_path) -> {bpm: Integer, confidence: Float}?
def self.detect: (String file_path) -> { bpm: Integer, confidence: Float }?
end
end
13 changes: 13 additions & 0 deletions sig/generated/wavesync/percival_bpm_detector.rbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Generated from lib/wavesync/percival_bpm_detector.rb with RBS::Inline

module Wavesync
class PercivalBpmDetector
PYTHON_SCRIPT: ::String

# : () -> bool?
def self.available?: () -> bool?

# : (String file_path) -> Integer?
def self.detect: (String file_path) -> Integer?
end
end
16 changes: 16 additions & 0 deletions sig/generated/wavesync/python_venv.rbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# Generated from lib/wavesync/python_venv.rb with RBS::Inline

module Wavesync
module PythonVenv
PYTHON_PATH: ::String

# : () -> bool
def self.available?: () -> bool

# : () -> bool?
def self.essentia_available?: () -> bool?

# : (String script, String file_path) -> String
def self.run_script: (String script, String file_path) -> String
end
end
52 changes: 42 additions & 10 deletions test/wavesync/bpm_detector_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,24 +5,56 @@

module Wavesync
class BpmDetectorTest < Wavesync::TestCase
test 'detect returns rounded BPM from bpm-tools output' do
BpmDetector.stubs(:`).returns("120.4\n")
def setup
EssentiaBpmDetector.stubs(:available?).returns(true)
PercivalBpmDetector.stubs(:available?).returns(true)
end

test 'uses essentia BPM when confidence exceeds threshold' do
EssentiaBpmDetector.stubs(:detect).returns({ bpm: 128, confidence: 2.5 })
assert_equal 128, BpmDetector.detect('/fake/file.wav')
end

test 'falls back to percival when essentia confidence is below threshold' do
EssentiaBpmDetector.stubs(:detect).returns({ bpm: 162, confidence: 1.4 })
PercivalBpmDetector.stubs(:detect).returns(80)
assert_equal 80, BpmDetector.detect('/fake/file.wav')
end

test 'falls back to percival when essentia returns nil' do
EssentiaBpmDetector.stubs(:detect).returns(nil)
PercivalBpmDetector.stubs(:detect).returns(120)
assert_equal 120, BpmDetector.detect('/fake/file.wav')
end

test 'detect rounds up correctly' do
BpmDetector.stubs(:`).returns("120.6\n")
assert_equal 121, BpmDetector.detect('/fake/file.wav')
test 'uses percival when essentia is unavailable' do
EssentiaBpmDetector.stubs(:available?).returns(false)
PercivalBpmDetector.stubs(:detect).returns(120)
assert_equal 120, BpmDetector.detect('/fake/file.wav')
end

test 'detect returns nil when output is empty' do
BpmDetector.stubs(:`).returns('')
test 'returns nil when both detectors are unavailable' do
EssentiaBpmDetector.stubs(:available?).returns(false)
PercivalBpmDetector.stubs(:available?).returns(false)
assert_nil BpmDetector.detect('/fake/file.wav')
end

test 'detect returns nil when BPM is zero' do
BpmDetector.stubs(:`).returns("0.0\n")
assert_nil BpmDetector.detect('/fake/file.wav')
test 'available? returns true when essentia is available' do
EssentiaBpmDetector.stubs(:available?).returns(true)
PercivalBpmDetector.stubs(:available?).returns(false)
assert BpmDetector.available?
end

test 'available? returns true when percival is available' do
EssentiaBpmDetector.stubs(:available?).returns(false)
PercivalBpmDetector.stubs(:available?).returns(true)
assert BpmDetector.available?
end

test 'available? returns false when neither detector is available' do
EssentiaBpmDetector.stubs(:available?).returns(false)
PercivalBpmDetector.stubs(:available?).returns(false)
refute BpmDetector.available?
end
end
end
31 changes: 31 additions & 0 deletions test/wavesync/essentia_bpm_detector_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# frozen_string_literal: true

require_relative 'test_case'
require_relative '../../lib/wavesync/essentia_bpm_detector'

module Wavesync
class EssentiaBpmDetectorTest < Wavesync::TestCase
test 'detect returns rounded BPM and confidence from essentia output' do
PythonVenv.stubs(:run_script).returns('{"bpm": 120, "confidence": 0.87}')
result = EssentiaBpmDetector.detect('/fake/file.wav')
assert_equal 120, result[:bpm]
assert_equal 0.87, result[:confidence]
end

test 'detect rounds BPM up correctly' do
PythonVenv.stubs(:run_script).returns('{"bpm": 121, "confidence": 0.75}')
result = EssentiaBpmDetector.detect('/fake/file.wav')
assert_equal 121, result[:bpm]
end

test 'detect returns nil when output is empty' do
PythonVenv.stubs(:run_script).returns('')
assert_nil EssentiaBpmDetector.detect('/fake/file.wav')
end

test 'detect returns nil when BPM is zero' do
PythonVenv.stubs(:run_script).returns('{"bpm": 0, "confidence": 0.0}')
assert_nil EssentiaBpmDetector.detect('/fake/file.wav')
end
end
end
28 changes: 28 additions & 0 deletions test/wavesync/percival_bpm_detector_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# frozen_string_literal: true

require_relative 'test_case'
require_relative '../../lib/wavesync/percival_bpm_detector'

module Wavesync
class PercivalBpmDetectorTest < Wavesync::TestCase
test 'detect returns rounded BPM from percival output' do
PythonVenv.stubs(:run_script).returns("120.4\n")
assert_equal 120, PercivalBpmDetector.detect('/fake/file.wav')
end

test 'detect rounds up correctly' do
PythonVenv.stubs(:run_script).returns("120.6\n")
assert_equal 121, PercivalBpmDetector.detect('/fake/file.wav')
end

test 'detect returns nil when output is empty' do
PythonVenv.stubs(:run_script).returns('')
assert_nil PercivalBpmDetector.detect('/fake/file.wav')
end

test 'detect returns nil when BPM is zero' do
PythonVenv.stubs(:run_script).returns("0.0\n")
assert_nil PercivalBpmDetector.detect('/fake/file.wav')
end
end
end
Loading