wavesync 1.0.0.alpha1 → 1.0.0.alpha3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5ba5ed4b274c7287903407029c902eeee5baeb3138f7f4fe19127a2ceca5329f
4
- data.tar.gz: c2653fe2efc113dafd67814a7c653bc2f7a9c068369800775028397ecc963793
3
+ metadata.gz: 460ae33ce4f3fb42e31cb8505f3ce403f558080d4f1a41f9ca84466c41d3cf27
4
+ data.tar.gz: '084dfb18be270a6d702295326eb70538bd38106ba6ddd853af834d46acf404e5'
5
5
  SHA512:
6
- metadata.gz: 846c29ec104723435dbb14bb4eb7902e17ed3276c322de9d82664ede303eb3878394bff44ee0769b934e3d39066ddc9d437656565f71c7ccf4ab8a956b42b2d0
7
- data.tar.gz: a3b5135a634bf8d088cc8886e4244d62ddfddd7428f974781d47037432ca80476e92bb736d5fa0f0467e5d64c9d07b8fd73c331c78b031d395c25ce432095483
6
+ metadata.gz: e20ff7f979e58e2602fe5b02452e2b6aa1a619cfbb27b303665fa986377942154f5f976f20da97ce23d7e9b3982d5b01eb08fbdcc84532704d2a287b6ed8de05
7
+ data.tar.gz: eb710a3fcc8b9e729bbd86f3e2f506fdfadcad64ce0c112f4c6480677e09bee9f61c1de69bf54f942429cb9c6708c5652b9b083893ab9b05e6795c304996aad9
data/README.md CHANGED
@@ -1,11 +1,10 @@
1
1
  # Wavesync
2
2
 
3
- Wavesync is a Ruby-based CLI tool that scans your music library and automatically converts audio files to match the specifications of specific hardware music devices like the teenage engineering TP-7 and Elektron Octatrack, adjusting sample rate, bit depth and file format as needed while preserving your original library structure and only converting files that don't already meet the device requirements. It also reads BPM information from the original file and converts it so that the target device can read it.
3
+ Wavesync is a Ruby-based CLI tool that scans your music library and automatically converts audio files to match the specifications of the [teenage engineering TP-7](https://teenage.engineering/products/tp-7) and the [Elektron Octatrack MKII](https://www.elektron.se/explore/octatrack-mkii), adjusting sample rate, bit depth and file format as needed while preserving your original library structure and only converting files that don't already meet the device requirements.
4
4
 
5
- ## Supported devices
5
+ It can also analyse the BPM of the tracks in your library and store the information as metadata in your files, as well as convert the metadata so that the target device can read the BPM. When syncing to the Octatrack, you can choose to add padding to each track, so that it's dead-simple to auto-slice your tracks to a multiple of full bars which allows precise looping, and seamlessly jumping to different parts of a track with Octatrack's sequencer.
6
6
 
7
- - teenage engineering TP-7
8
- - Elektron Octatrack MKII
7
+ ![Wavesync syncing to Octatrack](assets/screenshot.png)
9
8
 
10
9
  ## Supported file types
11
10
 
@@ -20,21 +19,32 @@ Unsupported file types will be ignored when syncing.
20
19
 
21
20
  ## Installation
22
21
 
23
- 1. Install dependencies
22
+ 1. Install Ruby
23
+
24
+ https://www.ruby-lang.org/en/documentation/installation/
25
+
26
+ 2. Install dependencies
24
27
 
25
28
  ```bash
26
- brew install ffmpeg # required for all commands
27
- brew install taglib # required for all commands
28
- brew install bpm-tools # required for analyze command
29
+ brew install ffmpeg
30
+ brew install taglib
29
31
  ```
30
32
 
31
- 2. Install Wavesync
33
+ 3. Install Wavesync
32
34
 
33
35
  ```bash
34
36
  gem install wavesync --pre
35
37
  ```
36
38
 
37
- 3. Install field kit (only required for syncing to TP-7)
39
+ 4. Set up the Python environment for BPM analysis
40
+
41
+ ```bash
42
+ brew install python@3.11
43
+ python3.11 -m venv ~/.wavesync-venv
44
+ ~/.wavesync-venv/bin/pip install essentia
45
+ ```
46
+
47
+ 5. Install field kit (only required for syncing to TP-7)
38
48
 
39
49
  https://teenage.engineering/guides/fieldkit
40
50
 
@@ -61,75 +71,72 @@ devices:
61
71
  - `model`: device model (`TP-7` or `Octatrack`)
62
72
  - `path`: path to the device's library directory
63
73
 
64
- Wavesync will exit with an error if a device model in the config is not supported.
65
-
66
74
  ## Usage
67
75
 
68
- ```bash
69
- # Sync library to all devices (uses default config at ~/wavesync.yml)
70
- wavesync sync
71
-
72
- # Use a config at a specific path
73
- wavesync sync -c /path/to/wavesync.yml
76
+ ### Help
74
77
 
75
- # Sync to a specific device only (by name as defined in config)
76
- wavesync sync -d Octatrack
78
+ ```bash
79
+ # List all available commands
80
+ wavesync help
81
+ ```
77
82
 
78
- # Pad each track with silence so its total length aligns to a multiple of 64 bars
79
- # (Octatrack only — requires BPM metadata on each track)
80
- wavesync sync -p
83
+ ### Analyze
81
84
 
85
+ ```bash
82
86
  # Analyze library files for BPM and write results to file metadata
83
87
  # Files that already have BPM set are skipped
84
88
  wavesync analyze
85
89
 
86
90
  # Overwrite existing BPM values
87
- wavesync analyze --force
91
+ wavesync analyze -f
92
+ ```
88
93
 
89
- # Analyze with a specific config path
90
- wavesync analyze -c /path/to/wavesync.yml
94
+ ### Sync
95
+
96
+ ```bash
97
+ # Sync library
98
+ # If multiple devices are configured, you will be prompted to select one
99
+ wavesync sync
100
+
101
+ # Sync to a specific device (by name as defined in config)
102
+ wavesync sync -d Octatrack
103
+
104
+ # Pad each track with silence so its total length aligns to a multiple of 64 bars
105
+ # (Octatrack only — requires BPM metadata on each track)
106
+ wavesync sync -p
91
107
  ```
92
108
 
93
- ## Sets
109
+ When a source file's sample rate isn't supported by the target device, Wavesync selects the closest supported rate. Example: If a 96kHz file is synced to an Octatrack (which only supports 44.1kHz), it will be downsampled to 44.1kHz.
94
110
 
95
- A set is a named, ordered selection of tracks from your library. Sets are stored as YAML files inside a `.sets` folder within the library directory.
111
+ ### Sets (experimental)
112
+
113
+ A set is a named, ordered selection of tracks from your library. Sets are stored as YAML files inside a `.sets` folder within the library directory. Syncing sets to devices is not yet implemented.
96
114
 
97
115
  ```bash
98
116
  # Create a new set and open the interactive editor
99
- wavesync set create <name>
117
+ wavesync set create NAME
100
118
 
101
119
  # Edit an existing set
102
- wavesync set edit <name>
120
+ wavesync set edit NAME
103
121
 
104
122
  # List all sets
105
123
  wavesync set list
106
124
  ```
107
125
 
108
- The set editor is a keyboard-driven interactive UI:
109
-
110
- | Key | Action |
111
- |-----|--------|
112
- | `↑` / `↓` | Navigate tracks |
113
- | `space` | Play / pause selected track |
114
- | `a` | Add track after selection |
115
- | `u` | Move selected track up |
116
- | `d` | Move selected track down |
117
- | `r` | Remove selected track |
118
- | `s` | Save and exit |
119
- | `c` | Cancel without saving |
120
-
121
- The playing track is indicated by `▶` (playing) or `⏸` (paused) before the track number. When a track finishes, the next track plays automatically. Changes are only written to disk when you press `s`.
122
-
123
- ## Sample Rate Selection
126
+ ## Development
124
127
 
125
- When a source file's sample rate isn't supported by the target device, Wavesync selects the closest supported rate. For files with equal distance to two rates, it chooses the higher rate to minimize quality loss.
128
+ ### Running Tests, RuboCop, and Type Checks
126
129
 
127
- Example: If a 96kHz file is synced to an Octatrack (which only supports 44.1kHz), it will be downsampled to 44.1kHz.
130
+ ```bash
131
+ rake
132
+ ```
128
133
 
129
- ## Development
134
+ This runs RuboCop (with auto-fix), Steep type checks, and the test suite in sequence.
130
135
 
131
- ### Running Tests
136
+ ### Releasing
132
137
 
133
138
  ```bash
134
- rake test
139
+ rake release:publish
135
140
  ```
141
+
142
+ This tags the current version, pushes the tag to origin, builds the gem, and publishes it to RubyGems.
data/config/devices.yml CHANGED
@@ -1,11 +1,13 @@
1
1
  devices:
2
2
  - name: TP-7
3
3
  sample_rates:
4
+ - 22050
4
5
  - 44100
5
6
  - 48000
6
7
  - 88200
7
8
  - 96000
8
9
  bit_depths:
10
+ - 8
9
11
  - 16
10
12
  - 24
11
13
  file_types:
@@ -17,6 +19,7 @@ devices:
17
19
  sample_rates:
18
20
  - 44100
19
21
  bit_depths:
22
+ - 8
20
23
  - 16
21
24
  - 24
22
25
  file_types:
@@ -1,4 +1,5 @@
1
1
  # frozen_string_literal: true
2
+ # rbs_inline: enabled
2
3
 
3
4
  module Wavesync
4
5
  class AcidChunk
@@ -27,6 +28,7 @@ module Wavesync
27
28
  UINT32_LITTLE_ENDIAN = 'V'
28
29
  FLOAT32_LITTLE_ENDIAN = 'e'
29
30
 
31
+ #: (String filepath) -> Float?
30
32
  def self.read_bpm(filepath)
31
33
  File.open(filepath, 'rb') do |file|
32
34
  file.seek(RIFF_HEADER_SIZE)
@@ -34,10 +36,10 @@ module Wavesync
34
36
  chunk_id = file.read(CHUNK_ID_SIZE)
35
37
  break if chunk_id.nil? || chunk_id.length < CHUNK_ID_SIZE
36
38
 
37
- chunk_size = file.read(CHUNK_SIZE_FIELD_SIZE).unpack1(UINT32_LITTLE_ENDIAN)
39
+ chunk_size = ((file.read(CHUNK_SIZE_FIELD_SIZE) || '').unpack1(UINT32_LITTLE_ENDIAN) || 0).to_i
38
40
  if chunk_id == ACID_CHUNK_ID
39
41
  file.seek(ACID_TEMPO_OFFSET, IO::SEEK_CUR)
40
- return file.read(ACID_TEMPO_SIZE).unpack1(FLOAT32_LITTLE_ENDIAN)
42
+ return ((file.read(ACID_TEMPO_SIZE) || '').unpack1(FLOAT32_LITTLE_ENDIAN) || 0).to_f
41
43
  else
42
44
  padding = chunk_size.odd? ? 1 : 0
43
45
  file.seek(chunk_size + padding, IO::SEEK_CUR)
@@ -47,6 +49,7 @@ module Wavesync
47
49
  nil
48
50
  end
49
51
 
52
+ #: (String source_filepath, String output_filepath, Integer | Float | String new_bpm) -> void
50
53
  def self.write_bpm(source_filepath, output_filepath, new_bpm)
51
54
  bpm_bytes = [new_bpm.to_f].pack(FLOAT32_LITTLE_ENDIAN)
52
55
  acid_chunk_found = false
@@ -60,8 +63,8 @@ module Wavesync
60
63
  chunk_id = input.read(CHUNK_ID_SIZE)
61
64
  break if chunk_id.nil? || chunk_id.length < CHUNK_ID_SIZE
62
65
 
63
- chunk_size_bytes = input.read(CHUNK_SIZE_FIELD_SIZE)
64
- chunk_size = chunk_size_bytes.unpack1(UINT32_LITTLE_ENDIAN)
66
+ chunk_size_bytes = input.read(CHUNK_SIZE_FIELD_SIZE) || ''
67
+ chunk_size = (chunk_size_bytes.unpack1(UINT32_LITTLE_ENDIAN) || 0).to_i
65
68
 
66
69
  output.write(chunk_id)
67
70
  output.write(chunk_size_bytes)
@@ -94,6 +97,7 @@ module Wavesync
94
97
  update_riff_size(output_filepath)
95
98
  end
96
99
 
100
+ #: (untyped output, Float bpm) -> void
97
101
  def self.create_acid_chunk(output, bpm)
98
102
  # Write ACID chunk ID
99
103
  output.write(ACID_CHUNK_ID)
@@ -125,6 +129,7 @@ module Wavesync
125
129
  output.write([bpm].pack(FLOAT32_LITTLE_ENDIAN))
126
130
  end
127
131
 
132
+ #: (String filepath) -> void
128
133
  def self.update_riff_size(filepath)
129
134
  File.open(filepath, 'r+b') do |file|
130
135
  file.seek(0, IO::SEEK_END)
@@ -1,19 +1,27 @@
1
1
  # frozen_string_literal: true
2
+ # rbs_inline: enabled
2
3
 
3
4
  module Wavesync
4
5
  class Analyzer
6
+ CONFIRM_MESSAGE = 'wavesync analyze will add bpm meta data to files in library. Continue? [y/N] '
7
+ SETUP_INSTRUCTIONS = 'brew install python@3.11 && python3.11 -m venv ~/.wavesync-venv && ~/.wavesync-venv/bin/pip install essentia'
8
+
9
+ #: (String library_path) -> void
5
10
  def initialize(library_path)
6
- @library_path = File.expand_path(library_path)
7
- @audio_files = find_audio_files
8
- @ui = UI.new
11
+ @library_path = File.expand_path(library_path) #: String
12
+ @audio_files = find_audio_files #: Array[String]
13
+ @ui = UI.new #: UI
9
14
  end
10
15
 
16
+ #: (?overwrite: bool) -> void
11
17
  def analyze(overwrite: false)
12
18
  unless BpmDetector.available?
13
- puts 'Error: bpm-tools or ffmpeg is not installed. Install with: brew install bpm-tools ffmpeg'
19
+ puts "Error: essentia is not installed. Set up the Python venv with:\n #{SETUP_INSTRUCTIONS}"
14
20
  exit 1
15
21
  end
16
22
 
23
+ return unless @ui.confirm(CONFIRM_MESSAGE)
24
+
17
25
  @audio_files.each_with_index do |file, index|
18
26
  audio = Audio.new(file)
19
27
 
@@ -32,6 +40,7 @@ module Wavesync
32
40
 
33
41
  private
34
42
 
43
+ #: () -> Array[String]
35
44
  def find_audio_files
36
45
  Audio.find_all(@library_path).sort
37
46
  end
@@ -1,4 +1,5 @@
1
1
  # frozen_string_literal: true
2
+ # rbs_inline: enabled
2
3
 
3
4
  require 'streamio-ffmpeg'
4
5
  require 'securerandom'
@@ -10,35 +11,42 @@ module Wavesync
10
11
  class Audio
11
12
  SUPPORTED_FORMATS = %w[.m4a .mp3 .wav .aif .aiff].freeze
12
13
 
14
+ #: (String library_path) -> Array[String]
13
15
  def self.find_all(library_path)
14
16
  Dir.glob(File.join(library_path, '**', '*'))
15
17
  .select { |f| SUPPORTED_FORMATS.include?(File.extname(f).downcase) }
16
18
  end
17
19
 
20
+ #: (String file_path) -> void
18
21
  def initialize(file_path)
19
- @file_path = file_path
20
- @file_ext = File.extname(@file_path).downcase
21
- @audio = FFMPEG::Movie.new(file_path)
22
+ @file_path = file_path #: String
23
+ @file_ext = File.extname(@file_path).downcase #: String
24
+ @audio = FFMPEG::Movie.new(file_path) #: untyped
22
25
  end
23
26
 
27
+ #: () -> Float
24
28
  def duration
25
29
  @audio.duration
26
30
  end
27
31
 
32
+ #: () -> Integer?
28
33
  def sample_rate
29
34
  @sample_rate ||= @audio.audio_sample_rate
30
35
  end
31
36
 
37
+ #: () -> Integer?
32
38
  def bit_depth
33
39
  @bit_depth ||= calculate_bit_depth
34
40
  end
35
41
 
42
+ #: () -> (String | Integer)?
36
43
  def bpm
37
44
  return @bpm if defined?(@bpm)
38
45
 
39
46
  @bpm = bpm_from_file
40
47
  end
41
48
 
49
+ #: () -> AudioFormat
42
50
  def format
43
51
  AudioFormat.new(
44
52
  file_type: @file_ext.delete_prefix('.'),
@@ -47,6 +55,21 @@ module Wavesync
47
55
  )
48
56
  end
49
57
 
58
+ #: () -> Array[{identifier: Integer, sample_offset: Integer, label: String?}]
59
+ def cue_points
60
+ return [] unless @file_ext == '.wav'
61
+
62
+ CueChunk.read(@file_path)
63
+ end
64
+
65
+ #: (Array[{identifier: Integer, sample_offset: Integer, label: String?}] cue_points) -> void
66
+ def write_cue_points(cue_points)
67
+ temp_path = "#{@file_path}.tmp"
68
+ CueChunk.write(@file_path, temp_path, cue_points)
69
+ FileUtils.mv(temp_path, @file_path)
70
+ end
71
+
72
+ #: (String | Integer | Float bpm) -> void
50
73
  def write_bpm(bpm)
51
74
  case @file_ext
52
75
  when '.m4a'
@@ -61,6 +84,7 @@ module Wavesync
61
84
  @bpm = bpm
62
85
  end
63
86
 
87
+ #: (String target_path, ?target_sample_rate: Integer?, ?target_file_type: String?, ?target_bit_depth: Integer?, ?padding_seconds: Numeric?) -> bool
64
88
  def transcode(target_path, target_sample_rate: nil, target_file_type: nil, target_bit_depth: nil, padding_seconds: nil)
65
89
  options = build_transcode_options(target_sample_rate, target_bit_depth, padding_seconds)
66
90
  ext = target_file_type || @file_ext.delete_prefix('.')
@@ -83,6 +107,7 @@ module Wavesync
83
107
 
84
108
  private
85
109
 
110
+ #: () -> Integer?
86
111
  def calculate_bit_depth
87
112
  data = @audio.metadata
88
113
  return nil unless data && data[:streams]
@@ -96,8 +121,9 @@ module Wavesync
96
121
  nil
97
122
  end
98
123
 
124
+ #: (Integer? target_sample_rate, Integer? target_bit_depth, ?Numeric? padding_seconds) -> Hash[Symbol, untyped]
99
125
  def build_transcode_options(target_sample_rate, target_bit_depth, padding_seconds = nil)
100
- options = { custom: %w[-loglevel warning -nostats -hide_banner] }
126
+ options = { custom: %w[-loglevel warning -nostats -hide_banner] } #: Hash[Symbol, untyped]
101
127
 
102
128
  if target_bit_depth == 24
103
129
  options[:audio_codec] = 'pcm_s24le'
@@ -116,6 +142,7 @@ module Wavesync
116
142
  options
117
143
  end
118
144
 
145
+ #: () -> (String | Integer)?
119
146
  def bpm_from_file
120
147
  case @file_ext
121
148
  when '.m4a'
@@ -129,6 +156,7 @@ module Wavesync
129
156
  end
130
157
  end
131
158
 
159
+ #: () -> Integer?
132
160
  def bpm_from_m4a
133
161
  TagLib::MP4::File.open(@file_path) do |file|
134
162
  tag = file.tag
@@ -136,6 +164,7 @@ module Wavesync
136
164
  end
137
165
  end
138
166
 
167
+ #: () -> String?
139
168
  def bpm_from_mp3
140
169
  TagLib::MPEG::File.open(@file_path) do |file|
141
170
  tag = file.id3v2_tag
@@ -143,6 +172,7 @@ module Wavesync
143
172
  end
144
173
  end
145
174
 
175
+ #: () -> (String | Integer)?
146
176
  def bpm_from_wav
147
177
  TagLib::RIFF::WAV::File.open(@file_path) do |file|
148
178
  tag = file.id3v2_tag
@@ -153,6 +183,7 @@ module Wavesync
153
183
  bpm_from_acid_chunk
154
184
  end
155
185
 
186
+ #: () -> String?
156
187
  def bpm_from_aiff
157
188
  TagLib::RIFF::AIFF::File.open(@file_path) do |file|
158
189
  tag = file.tag
@@ -160,26 +191,31 @@ module Wavesync
160
191
  end
161
192
  end
162
193
 
194
+ #: (untyped tag) -> Integer?
163
195
  def bpm_from_item_map(tag)
164
196
  tmpo = tag.item_map['tmpo']&.to_int
165
197
  tmpo&.zero? ? nil : tmpo
166
198
  end
167
199
 
200
+ #: (untyped tag) -> String?
168
201
  def bpm_from_frame_list(tag)
169
202
  tag.frame_list('TBPM').first&.to_s
170
203
  end
171
204
 
205
+ #: () -> Integer?
172
206
  def bpm_from_acid_chunk
173
207
  tmpo = Wavesync::AcidChunk.read_bpm(@file_path).to_i
174
208
  tmpo&.zero? ? nil : tmpo
175
209
  end
176
210
 
211
+ #: (String | Integer | Float bpm) -> void
177
212
  def write_bpm_to_wav(bpm)
178
213
  temp_path = "#{@file_path}.tmp"
179
214
  AcidChunk.write_bpm(@file_path, temp_path, bpm)
180
215
  FileUtils.mv(temp_path, @file_path)
181
216
  end
182
217
 
218
+ #: (String | Integer | Float bpm) -> void
183
219
  def write_bpm_to_mp3(bpm)
184
220
  TagLib::MPEG::File.open(@file_path) do |file|
185
221
  write_id3v2_bpm(file.id3v2_tag(true), bpm)
@@ -187,6 +223,7 @@ module Wavesync
187
223
  end
188
224
  end
189
225
 
226
+ #: (String | Integer | Float bpm) -> void
190
227
  def write_bpm_to_m4a(bpm)
191
228
  TagLib::MP4::File.open(@file_path) do |file|
192
229
  tag = file.tag
@@ -195,6 +232,7 @@ module Wavesync
195
232
  end
196
233
  end
197
234
 
235
+ #: (String | Integer | Float bpm) -> void
198
236
  def write_bpm_to_aiff(bpm)
199
237
  TagLib::RIFF::AIFF::File.open(@file_path) do |file|
200
238
  write_id3v2_bpm(file.tag, bpm)
@@ -202,6 +240,7 @@ module Wavesync
202
240
  end
203
241
  end
204
242
 
243
+ #: (untyped tag, String | Integer | Float bpm) -> void
205
244
  def write_id3v2_bpm(tag, bpm)
206
245
  tag.remove_frames('TBPM')
207
246
  frame = TagLib::ID3v2::TextIdentificationFrame.new('TBPM', TagLib::String::UTF8)
@@ -2,6 +2,7 @@
2
2
 
3
3
  module Wavesync
4
4
  AudioFormat = Data.define(:file_type, :sample_rate, :bit_depth) do
5
+ #: (AudioFormat other) -> AudioFormat
5
6
  def merge(other)
6
7
  with(
7
8
  file_type: other.file_type || file_type,
@@ -1,19 +1,26 @@
1
1
  # frozen_string_literal: true
2
+ # rbs_inline: enabled
2
3
 
3
- require 'shellwords'
4
+ require_relative 'essentia_bpm_detector'
5
+ require_relative 'percival_bpm_detector'
4
6
 
5
7
  module Wavesync
6
8
  class BpmDetector
9
+ CONFIDENCE_THRESHOLD = 2.0
10
+
11
+ #: () -> bool?
7
12
  def self.available?
8
- system('which bpm > /dev/null 2>&1') && system('which ffmpeg > /dev/null 2>&1')
13
+ EssentiaBpmDetector.available? || PercivalBpmDetector.available?
9
14
  end
10
15
 
16
+ #: (String file_path) -> Integer?
11
17
  def self.detect(file_path)
12
- output = `ffmpeg -i #{Shellwords.escape(file_path)} -ac 1 -ar 44100 -f f32le - 2>/dev/null | bpm`
13
- bpm = output.strip.to_f
14
- bpm.positive? ? bpm.round : nil
15
- rescue StandardError
16
- nil
18
+ if EssentiaBpmDetector.available?
19
+ essentia_result = EssentiaBpmDetector.detect(file_path)
20
+ return essentia_result[:bpm] if essentia_result && essentia_result[:confidence] > CONFIDENCE_THRESHOLD
21
+ end
22
+
23
+ PercivalBpmDetector.detect(file_path) if PercivalBpmDetector.available?
17
24
  end
18
25
  end
19
26
  end