bpm-finder-cli 0.1.0
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 +7 -0
- data/CHANGELOG.md +8 -0
- data/PUBLISHING.md +70 -0
- data/README.md +119 -0
- data/bin/bpm-finder +5 -0
- data/lib/bpm/finder/audio_file_analyzer.rb +291 -0
- data/lib/bpm/finder/cli/version.rb +7 -0
- data/lib/bpm/finder/cli.rb +219 -0
- data/lib/bpm/finder/validation_error.rb +5 -0
- data/test/bpm_finder_cli_test.rb +152 -0
- data/test/test_helper.rb +6 -0
- metadata +66 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 39c9722853ac89903e1927c0eabdd09252237a6f17010e45ccdae47bfda7fdd5
|
|
4
|
+
data.tar.gz: 39b96690e5eb91a16c35abb049cc7208002f2481f8d80e779c5c0b9546db9c3b
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 99f5625895b8465b3f92f3ceb0d497a52e31825ad6306fb359185e150f5e473a8a80153a3ed770dc6c03ed20a6f87fbd7fc12e4f4a838d786e417bd2d56d2136
|
|
7
|
+
data.tar.gz: 257ef4312e52916925d755559a2f98f4924abf7f7bc6e1c945660f06d9e32bc856bc6bd54f428604a48333df04ad9dae711212145aa871771094f19633e88a02
|
data/CHANGELOG.md
ADDED
data/PUBLISHING.md
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# Publishing bpm-finder-cli
|
|
2
|
+
|
|
3
|
+
This document describes the exact release flow for publishing the gem on
|
|
4
|
+
RubyGems and using the package page as a clean backlink to the BPM Finder
|
|
5
|
+
homepage.
|
|
6
|
+
|
|
7
|
+
The current release includes real audio BPM detection for local WAV files, so
|
|
8
|
+
the package page represents a functional utility instead of a metadata-only
|
|
9
|
+
listing.
|
|
10
|
+
|
|
11
|
+
## 1. Build the gem locally
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
cd backlinks/rubygems/bpm-finder-cli
|
|
15
|
+
gem build bpm-finder-cli.gemspec
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
This should create:
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
bpm-finder-cli-0.1.0.gem
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## 2. Sign in to RubyGems
|
|
25
|
+
|
|
26
|
+
If this is the first release on the current machine:
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
gem signin
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
RubyGems stores credentials in `~/.gem/credentials`.
|
|
33
|
+
|
|
34
|
+
## 3. Publish the gem
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
gem push bpm-finder-cli-0.1.0.gem
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## 4. Verify the backlink
|
|
41
|
+
|
|
42
|
+
After the package is live on RubyGems:
|
|
43
|
+
|
|
44
|
+
- Open the package page
|
|
45
|
+
- Confirm the homepage field links to `https://bpm-finder.net/`
|
|
46
|
+
- Confirm the README renders correctly
|
|
47
|
+
- Confirm the project metadata points to the GitHub source and changelog URLs
|
|
48
|
+
|
|
49
|
+
## 5. Release a new version
|
|
50
|
+
|
|
51
|
+
1. Update `lib/bpm/finder/cli/version.rb`
|
|
52
|
+
2. Update `CHANGELOG.md`
|
|
53
|
+
3. Rebuild the gem
|
|
54
|
+
4. Push the new `.gem` file
|
|
55
|
+
|
|
56
|
+
Commands:
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
gem build bpm-finder-cli.gemspec
|
|
60
|
+
gem push bpm-finder-cli-X.Y.Z.gem
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## 6. If the gem name is unavailable
|
|
64
|
+
|
|
65
|
+
If `bpm-finder-cli` is already taken at publish time:
|
|
66
|
+
|
|
67
|
+
- Rename the gem to `bpm-finder-tools`
|
|
68
|
+
- Update the gemspec name
|
|
69
|
+
- Rename the built artifact accordingly
|
|
70
|
+
- Keep the same homepage and metadata backlink strategy
|
data/README.md
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# bpm-finder-cli
|
|
2
|
+
|
|
3
|
+
`bpm-finder-cli` is a lightweight Ruby gem for quick BPM math in the terminal.
|
|
4
|
+
It helps you convert tempo into practical delay times, turn milliseconds back
|
|
5
|
+
into BPM, estimate tempo from tap intervals, and analyze local WAV files
|
|
6
|
+
without leaving the command line.
|
|
7
|
+
|
|
8
|
+
## What it does
|
|
9
|
+
|
|
10
|
+
- Converts BPM into common note divisions in milliseconds
|
|
11
|
+
- Converts milliseconds back into exact and rounded BPM values
|
|
12
|
+
- Estimates BPM from a series of tap intervals
|
|
13
|
+
- Analyzes local `.wav` audio files and estimates BPM from rhythmic content
|
|
14
|
+
- Runs with plain Ruby and no external service dependencies
|
|
15
|
+
|
|
16
|
+
## Installation
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
gem install bpm-finder-cli
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Or install from a local build:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
gem build bpm-finder-cli.gemspec
|
|
26
|
+
gem install ./bpm-finder-cli-0.1.0.gem
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Quick examples
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
bpm-finder ms 128
|
|
33
|
+
bpm-finder bpm 468.75
|
|
34
|
+
bpm-finder tap 500 480 495 505
|
|
35
|
+
bpm-finder analyze ./loop.wav --min 70 --max 150
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Command reference
|
|
39
|
+
|
|
40
|
+
### `bpm-finder ms BPM`
|
|
41
|
+
|
|
42
|
+
Converts a BPM value into common note division delay times.
|
|
43
|
+
|
|
44
|
+
Example:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
$ bpm-finder ms 128
|
|
48
|
+
BPM: 128
|
|
49
|
+
Delay times (ms):
|
|
50
|
+
whole 1875
|
|
51
|
+
half 937.5
|
|
52
|
+
quarter 468.75
|
|
53
|
+
eighth 234.375
|
|
54
|
+
sixteenth 117.188
|
|
55
|
+
thirty-second 58.594
|
|
56
|
+
dotted quarter 703.125
|
|
57
|
+
dotted eighth 351.563
|
|
58
|
+
quarter triplet 312.5
|
|
59
|
+
eighth triplet 156.25
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
### `bpm-finder bpm MILLISECONDS`
|
|
63
|
+
|
|
64
|
+
Converts a delay time in milliseconds back into BPM.
|
|
65
|
+
|
|
66
|
+
Example:
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
$ bpm-finder bpm 500
|
|
70
|
+
Milliseconds: 500
|
|
71
|
+
Exact BPM: 120
|
|
72
|
+
Rounded BPM: 120
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
### `bpm-finder tap INTERVAL_MS [INTERVAL_MS ...]`
|
|
76
|
+
|
|
77
|
+
Estimates BPM from a series of tap intervals.
|
|
78
|
+
|
|
79
|
+
Example:
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
$ bpm-finder tap 500 480 495 505
|
|
83
|
+
Tap intervals: 500, 480, 495, 505 ms
|
|
84
|
+
Average interval: 495 ms
|
|
85
|
+
Exact BPM: 121.212
|
|
86
|
+
Rounded BPM: 121
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
### `bpm-finder analyze FILE.wav [--min BPM] [--max BPM]`
|
|
90
|
+
|
|
91
|
+
Estimates BPM from a local WAV audio file. The current audio parser supports
|
|
92
|
+
PCM and IEEE float WAV files. This is the command to use when you want actual
|
|
93
|
+
audio-file BPM detection rather than simple tempo math.
|
|
94
|
+
|
|
95
|
+
Example:
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
$ bpm-finder analyze ./loop.wav --min 70 --max 150
|
|
99
|
+
File: /absolute/path/to/loop.wav
|
|
100
|
+
Format: WAV
|
|
101
|
+
Sample rate: 44100 Hz
|
|
102
|
+
Channels: 1
|
|
103
|
+
Duration analyzed: 12
|
|
104
|
+
Exact BPM: 120.088
|
|
105
|
+
Rounded BPM: 120
|
|
106
|
+
Confidence: 81.3%
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## Why this tool exists
|
|
110
|
+
|
|
111
|
+
Tempo math is still a frequent part of DJ prep, delay setup, content editing,
|
|
112
|
+
and small scripting tasks. This gem is intentionally narrow: it solves the
|
|
113
|
+
quick numeric cases that are awkward to do by hand, while still giving you a
|
|
114
|
+
direct way to estimate BPM from a real WAV file when you need actual analysis.
|
|
115
|
+
|
|
116
|
+
## Related browser-based BPM analysis
|
|
117
|
+
|
|
118
|
+
For full browser-based BPM analysis, batch workflows, and privacy-first audio
|
|
119
|
+
processing, see [BPM Finder](https://bpm-finder.net/).
|
data/bin/bpm-finder
ADDED
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
module Bpm
|
|
2
|
+
module Finder
|
|
3
|
+
class AudioFileAnalyzer
|
|
4
|
+
DEFAULT_MIN_BPM = 60.0
|
|
5
|
+
DEFAULT_MAX_BPM = 200.0
|
|
6
|
+
DEFAULT_HOP_SIZE = 512
|
|
7
|
+
DEFAULT_MAX_ANALYSIS_SECONDS = 120.0
|
|
8
|
+
|
|
9
|
+
def self.analyze_file(path, min_bpm: DEFAULT_MIN_BPM, max_bpm: DEFAULT_MAX_BPM)
|
|
10
|
+
new(path, min_bpm: min_bpm, max_bpm: max_bpm).analyze
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def initialize(path, min_bpm:, max_bpm:)
|
|
14
|
+
@path = path
|
|
15
|
+
@min_bpm = Cli.positive_number!(min_bpm, "Minimum BPM")
|
|
16
|
+
@max_bpm = Cli.positive_number!(max_bpm, "Maximum BPM")
|
|
17
|
+
raise ValidationError, "Maximum BPM must be greater than minimum BPM." unless @max_bpm > @min_bpm
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def analyze
|
|
21
|
+
raise ValidationError, "Audio file not found: #{@path}" unless File.file?(@path)
|
|
22
|
+
|
|
23
|
+
ext = File.extname(@path).downcase
|
|
24
|
+
raise ValidationError, "Only .wav files are supported for audio analysis right now." unless ext == ".wav"
|
|
25
|
+
|
|
26
|
+
wav = parse_wav(File.binread(@path))
|
|
27
|
+
mono_samples = decode_mono_samples(wav)
|
|
28
|
+
raise ValidationError, "Audio file is too short to analyze." if mono_samples.length < wav[:sample_rate]
|
|
29
|
+
|
|
30
|
+
max_frames = (wav[:sample_rate] * DEFAULT_MAX_ANALYSIS_SECONDS).to_i
|
|
31
|
+
mono_samples = mono_samples.first(max_frames)
|
|
32
|
+
|
|
33
|
+
analysis = estimate_bpm(mono_samples, wav[:sample_rate])
|
|
34
|
+
|
|
35
|
+
{
|
|
36
|
+
file_path: File.expand_path(@path),
|
|
37
|
+
format: "wav",
|
|
38
|
+
sample_rate: wav[:sample_rate],
|
|
39
|
+
channels: wav[:channels],
|
|
40
|
+
duration_seconds: mono_samples.length / wav[:sample_rate].to_f,
|
|
41
|
+
exact_bpm: analysis[:exact_bpm],
|
|
42
|
+
rounded_bpm: analysis[:exact_bpm].round,
|
|
43
|
+
confidence: analysis[:confidence]
|
|
44
|
+
}
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def parse_wav(binary)
|
|
50
|
+
raise ValidationError, "Invalid WAV file header." unless binary.bytesize >= 12
|
|
51
|
+
raise ValidationError, "Invalid WAV file header." unless binary.start_with?("RIFF") && binary[8, 4] == "WAVE"
|
|
52
|
+
|
|
53
|
+
offset = 12
|
|
54
|
+
fmt = nil
|
|
55
|
+
data = nil
|
|
56
|
+
|
|
57
|
+
while offset + 8 <= binary.bytesize
|
|
58
|
+
chunk_id = binary[offset, 4]
|
|
59
|
+
chunk_size = binary[offset + 4, 4].unpack1("V")
|
|
60
|
+
chunk_data_offset = offset + 8
|
|
61
|
+
chunk = binary.byteslice(chunk_data_offset, chunk_size)
|
|
62
|
+
|
|
63
|
+
case chunk_id
|
|
64
|
+
when "fmt "
|
|
65
|
+
fmt = parse_fmt_chunk(chunk)
|
|
66
|
+
when "data"
|
|
67
|
+
data = chunk
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
offset = chunk_data_offset + chunk_size
|
|
71
|
+
offset += 1 if chunk_size.odd?
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
raise ValidationError, "WAV fmt chunk is missing." unless fmt
|
|
75
|
+
raise ValidationError, "WAV data chunk is missing." unless data
|
|
76
|
+
|
|
77
|
+
fmt.merge(data: data)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def parse_fmt_chunk(chunk)
|
|
81
|
+
raise ValidationError, "Invalid WAV fmt chunk." unless chunk && chunk.bytesize >= 16
|
|
82
|
+
|
|
83
|
+
audio_format, channels, sample_rate, _byte_rate, block_align, bits_per_sample = chunk.unpack("v v V V v v")
|
|
84
|
+
|
|
85
|
+
unless [1, 3].include?(audio_format)
|
|
86
|
+
raise ValidationError, "Unsupported WAV encoding. Use PCM or IEEE float WAV files."
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
unless channels.positive? && sample_rate.positive? && block_align.positive? && bits_per_sample.positive?
|
|
90
|
+
raise ValidationError, "Invalid WAV format information."
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
{
|
|
94
|
+
audio_format: audio_format,
|
|
95
|
+
channels: channels,
|
|
96
|
+
sample_rate: sample_rate,
|
|
97
|
+
block_align: block_align,
|
|
98
|
+
bits_per_sample: bits_per_sample
|
|
99
|
+
}
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def decode_mono_samples(wav)
|
|
103
|
+
data = wav[:data]
|
|
104
|
+
frame_count = data.bytesize / wav[:block_align]
|
|
105
|
+
bytes_per_sample = wav[:bits_per_sample] / 8
|
|
106
|
+
raise ValidationError, "Unsupported WAV bit depth." unless [1, 2, 3, 4, 8].include?(bytes_per_sample)
|
|
107
|
+
|
|
108
|
+
samples = Array.new(frame_count, 0.0)
|
|
109
|
+
|
|
110
|
+
frame_count.times do |frame_index|
|
|
111
|
+
frame_offset = frame_index * wav[:block_align]
|
|
112
|
+
total = 0.0
|
|
113
|
+
|
|
114
|
+
wav[:channels].times do |channel_index|
|
|
115
|
+
sample_offset = frame_offset + channel_index * bytes_per_sample
|
|
116
|
+
sample_bytes = data.byteslice(sample_offset, bytes_per_sample)
|
|
117
|
+
total += decode_sample(sample_bytes, wav[:audio_format], wav[:bits_per_sample])
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
samples[frame_index] = total / wav[:channels].to_f
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
samples
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def decode_sample(bytes, audio_format, bits_per_sample)
|
|
127
|
+
case audio_format
|
|
128
|
+
when 1
|
|
129
|
+
decode_pcm_sample(bytes, bits_per_sample)
|
|
130
|
+
when 3
|
|
131
|
+
decode_float_sample(bytes, bits_per_sample)
|
|
132
|
+
else
|
|
133
|
+
raise ValidationError, "Unsupported WAV encoding."
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def decode_pcm_sample(bytes, bits_per_sample)
|
|
138
|
+
case bits_per_sample
|
|
139
|
+
when 8
|
|
140
|
+
(bytes.unpack1("C") - 128) / 128.0
|
|
141
|
+
when 16
|
|
142
|
+
bytes.unpack1("s<") / 32_768.0
|
|
143
|
+
when 24
|
|
144
|
+
unpack_int24_le(bytes) / 8_388_608.0
|
|
145
|
+
when 32
|
|
146
|
+
bytes.unpack1("l<") / 2_147_483_648.0
|
|
147
|
+
else
|
|
148
|
+
raise ValidationError, "Unsupported PCM bit depth: #{bits_per_sample}."
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def decode_float_sample(bytes, bits_per_sample)
|
|
153
|
+
case bits_per_sample
|
|
154
|
+
when 32
|
|
155
|
+
bytes.unpack1("e")
|
|
156
|
+
when 64
|
|
157
|
+
bytes.unpack1("E")
|
|
158
|
+
else
|
|
159
|
+
raise ValidationError, "Unsupported IEEE float bit depth: #{bits_per_sample}."
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def unpack_int24_le(bytes)
|
|
164
|
+
value = bytes.unpack("C3")
|
|
165
|
+
integer = value[0] | (value[1] << 8) | (value[2] << 16)
|
|
166
|
+
integer -= 1 << 24 if integer >= (1 << 23)
|
|
167
|
+
integer
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# A lightweight onset-envelope + autocorrelation tempo estimator for WAV files.
|
|
171
|
+
def estimate_bpm(samples, sample_rate)
|
|
172
|
+
hop_size = [DEFAULT_HOP_SIZE, (sample_rate / 50.0).round].max
|
|
173
|
+
frame_size = hop_size * 2
|
|
174
|
+
|
|
175
|
+
raise ValidationError, "Audio file is too short to analyze." if samples.length < frame_size * 4
|
|
176
|
+
|
|
177
|
+
energies = []
|
|
178
|
+
index = 0
|
|
179
|
+
while index + frame_size <= samples.length
|
|
180
|
+
sum = 0.0
|
|
181
|
+
frame = samples[index, frame_size]
|
|
182
|
+
frame.each { |sample| sum += sample.abs }
|
|
183
|
+
energies << (sum / frame_size.to_f)
|
|
184
|
+
index += hop_size
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
onset = []
|
|
188
|
+
previous = energies.first || 0.0
|
|
189
|
+
energies.each do |value|
|
|
190
|
+
delta = value - previous
|
|
191
|
+
onset << (delta.positive? ? delta : 0.0)
|
|
192
|
+
previous = (previous * 0.6) + (value * 0.4)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
normalize_series!(onset)
|
|
196
|
+
smooth_series!(onset)
|
|
197
|
+
|
|
198
|
+
frame_duration = hop_size / sample_rate.to_f
|
|
199
|
+
min_lag = [(60.0 / @max_bpm / frame_duration).floor, 1].max
|
|
200
|
+
max_lag = [(60.0 / @min_bpm / frame_duration).ceil, onset.length - 2].min
|
|
201
|
+
raise ValidationError, "Audio file does not contain enough rhythmic information." if max_lag <= min_lag
|
|
202
|
+
|
|
203
|
+
scores = {}
|
|
204
|
+
(min_lag..max_lag).each do |lag|
|
|
205
|
+
base = autocorrelation(onset, lag)
|
|
206
|
+
harmonic = harmonic_support(onset, lag, max_lag)
|
|
207
|
+
score = base + harmonic
|
|
208
|
+
bpm = 60.0 / (lag * frame_duration)
|
|
209
|
+
next unless bpm.finite? && bpm >= @min_bpm && bpm <= @max_bpm
|
|
210
|
+
|
|
211
|
+
scores[lag] = score
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
raise ValidationError, "Could not estimate BPM from this audio file." if scores.empty?
|
|
215
|
+
|
|
216
|
+
ranked = scores.sort_by { |(_lag, score)| -score }
|
|
217
|
+
best_lag, best_score = ranked.first
|
|
218
|
+
second_score = ranked[1] ? ranked[1][1] : 0.0
|
|
219
|
+
best_lag_value = refine_peak_lag(best_lag, scores)
|
|
220
|
+
exact_bpm = 60.0 / (best_lag_value * frame_duration)
|
|
221
|
+
|
|
222
|
+
confidence = if best_score <= 0.0
|
|
223
|
+
0.0
|
|
224
|
+
else
|
|
225
|
+
((best_score - second_score) / best_score.to_f).clamp(0.0, 1.0)
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
{
|
|
229
|
+
exact_bpm: exact_bpm,
|
|
230
|
+
confidence: confidence
|
|
231
|
+
}
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def autocorrelation(series, lag)
|
|
235
|
+
sum = 0.0
|
|
236
|
+
count = 0
|
|
237
|
+
|
|
238
|
+
(lag...series.length).each do |index|
|
|
239
|
+
sum += series[index] * series[index - lag]
|
|
240
|
+
count += 1
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
count.zero? ? 0.0 : sum / count.to_f
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def harmonic_support(series, lag, max_lag)
|
|
247
|
+
support = 0.0
|
|
248
|
+
[[2, 0.5], [3, 0.25]].each do |multiplier, weight|
|
|
249
|
+
harmonic_lag = lag * multiplier
|
|
250
|
+
next if harmonic_lag > max_lag
|
|
251
|
+
|
|
252
|
+
support += autocorrelation(series, harmonic_lag) * weight
|
|
253
|
+
end
|
|
254
|
+
support
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def refine_peak_lag(best_lag, scores)
|
|
258
|
+
left = scores[best_lag - 1]
|
|
259
|
+
center = scores[best_lag]
|
|
260
|
+
right = scores[best_lag + 1]
|
|
261
|
+
return best_lag.to_f unless left && center && right
|
|
262
|
+
|
|
263
|
+
denominator = left - (2.0 * center) + right
|
|
264
|
+
return best_lag.to_f if denominator.abs < 1e-9
|
|
265
|
+
|
|
266
|
+
offset = 0.5 * (left - right) / denominator
|
|
267
|
+
best_lag + offset.clamp(-0.5, 0.5)
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def normalize_series!(series)
|
|
271
|
+
max_value = series.max.to_f
|
|
272
|
+
return if max_value <= 0.0
|
|
273
|
+
|
|
274
|
+
series.map! { |value| value / max_value }
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def smooth_series!(series)
|
|
278
|
+
return if series.length < 3
|
|
279
|
+
|
|
280
|
+
smoothed = Array.new(series.length, 0.0)
|
|
281
|
+
series.each_index do |index|
|
|
282
|
+
left = index.zero? ? series[index] : series[index - 1]
|
|
283
|
+
center = series[index]
|
|
284
|
+
right = index == series.length - 1 ? series[index] : series[index + 1]
|
|
285
|
+
smoothed[index] = (left + center + right) / 3.0
|
|
286
|
+
end
|
|
287
|
+
series.replace(smoothed)
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
end
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
require_relative "cli/version"
|
|
2
|
+
require_relative "validation_error"
|
|
3
|
+
require_relative "audio_file_analyzer"
|
|
4
|
+
|
|
5
|
+
module Bpm
|
|
6
|
+
module Finder
|
|
7
|
+
class Cli
|
|
8
|
+
ValidationError = Bpm::Finder::ValidationError
|
|
9
|
+
|
|
10
|
+
NOTE_DIVISIONS = [
|
|
11
|
+
["whole", 4.0],
|
|
12
|
+
["half", 2.0],
|
|
13
|
+
["quarter", 1.0],
|
|
14
|
+
["eighth", 0.5],
|
|
15
|
+
["sixteenth", 0.25],
|
|
16
|
+
["thirty-second", 0.125],
|
|
17
|
+
["dotted quarter", 1.5],
|
|
18
|
+
["dotted eighth", 0.75],
|
|
19
|
+
["quarter triplet", 2.0 / 3.0],
|
|
20
|
+
["eighth triplet", 1.0 / 3.0]
|
|
21
|
+
].freeze
|
|
22
|
+
|
|
23
|
+
def self.start(argv = ARGV, out: $stdout, err: $stderr)
|
|
24
|
+
new(out: out, err: err).start(argv)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def self.delay_table_for_bpm(bpm)
|
|
28
|
+
bpm_value = positive_number!(bpm, "BPM")
|
|
29
|
+
quarter_ms = 60_000.0 / bpm_value
|
|
30
|
+
|
|
31
|
+
NOTE_DIVISIONS.map do |name, multiplier|
|
|
32
|
+
[name, quarter_ms * multiplier]
|
|
33
|
+
end.to_h
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def self.bpm_from_milliseconds(milliseconds)
|
|
37
|
+
ms_value = positive_number!(milliseconds, "Milliseconds")
|
|
38
|
+
60_000.0 / ms_value
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def self.tap_bpm(intervals)
|
|
42
|
+
raise ValidationError, "Provide at least two tap intervals." if intervals.nil? || intervals.empty?
|
|
43
|
+
|
|
44
|
+
cleaned = intervals.map.with_index do |value, index|
|
|
45
|
+
positive_number!(value, "Tap interval ##{index + 1}")
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
average_interval = cleaned.sum / cleaned.length.to_f
|
|
49
|
+
exact_bpm = bpm_from_milliseconds(average_interval)
|
|
50
|
+
|
|
51
|
+
{
|
|
52
|
+
average_interval_ms: average_interval,
|
|
53
|
+
exact_bpm: exact_bpm,
|
|
54
|
+
rounded_bpm: exact_bpm.round
|
|
55
|
+
}
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def self.positive_number!(value, label)
|
|
59
|
+
numeric = Float(value)
|
|
60
|
+
raise ValidationError, "#{label} must be greater than zero." unless numeric.positive?
|
|
61
|
+
|
|
62
|
+
numeric
|
|
63
|
+
rescue ArgumentError, TypeError
|
|
64
|
+
raise ValidationError, "#{label} must be a valid number."
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def initialize(out:, err:)
|
|
68
|
+
@out = out
|
|
69
|
+
@err = err
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def start(argv)
|
|
73
|
+
args = Array(argv).dup
|
|
74
|
+
command = args.shift
|
|
75
|
+
|
|
76
|
+
case command
|
|
77
|
+
when nil, "help", "--help", "-h"
|
|
78
|
+
@out.puts help_text
|
|
79
|
+
0
|
|
80
|
+
when "ms"
|
|
81
|
+
run_ms(args)
|
|
82
|
+
when "bpm"
|
|
83
|
+
run_bpm(args)
|
|
84
|
+
when "tap"
|
|
85
|
+
run_tap(args)
|
|
86
|
+
when "analyze"
|
|
87
|
+
run_analyze(args)
|
|
88
|
+
else
|
|
89
|
+
raise ValidationError, "Unknown command: #{command}"
|
|
90
|
+
end
|
|
91
|
+
rescue ValidationError => e
|
|
92
|
+
@err.puts "Error: #{e.message}"
|
|
93
|
+
@err.puts
|
|
94
|
+
@err.puts help_text
|
|
95
|
+
1
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
private
|
|
99
|
+
|
|
100
|
+
def run_ms(args)
|
|
101
|
+
raise ValidationError, "The ms command expects exactly one BPM value." unless args.length == 1
|
|
102
|
+
|
|
103
|
+
bpm_value = self.class.positive_number!(args.first, "BPM")
|
|
104
|
+
table = self.class.delay_table_for_bpm(bpm_value)
|
|
105
|
+
|
|
106
|
+
@out.puts "BPM: #{format_number(bpm_value)}"
|
|
107
|
+
@out.puts "Delay times (ms):"
|
|
108
|
+
table.each do |name, value|
|
|
109
|
+
@out.puts format("%-16s %10s", name, format_number(value))
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
0
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def run_bpm(args)
|
|
116
|
+
raise ValidationError, "The bpm command expects exactly one millisecond value." unless args.length == 1
|
|
117
|
+
|
|
118
|
+
milliseconds = self.class.positive_number!(args.first, "Milliseconds")
|
|
119
|
+
bpm_value = self.class.bpm_from_milliseconds(milliseconds)
|
|
120
|
+
|
|
121
|
+
@out.puts "Milliseconds: #{format_number(milliseconds)}"
|
|
122
|
+
@out.puts "Exact BPM: #{format_number(bpm_value)}"
|
|
123
|
+
@out.puts "Rounded BPM: #{bpm_value.round}"
|
|
124
|
+
0
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def run_tap(args)
|
|
128
|
+
raise ValidationError, "The tap command expects one or more tap intervals in milliseconds." if args.empty?
|
|
129
|
+
|
|
130
|
+
result = self.class.tap_bpm(args)
|
|
131
|
+
|
|
132
|
+
@out.puts "Tap intervals: #{args.join(', ')} ms"
|
|
133
|
+
@out.puts "Average interval: #{format_number(result[:average_interval_ms])} ms"
|
|
134
|
+
@out.puts "Exact BPM: #{format_number(result[:exact_bpm])}"
|
|
135
|
+
@out.puts "Rounded BPM: #{result[:rounded_bpm]}"
|
|
136
|
+
0
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def run_analyze(args)
|
|
140
|
+
path, options = parse_analyze_args(args)
|
|
141
|
+
result = AudioFileAnalyzer.analyze_file(path, min_bpm: options[:min_bpm], max_bpm: options[:max_bpm])
|
|
142
|
+
|
|
143
|
+
@out.puts "File: #{result[:file_path]}"
|
|
144
|
+
@out.puts "Format: #{result[:format].upcase}"
|
|
145
|
+
@out.puts "Sample rate: #{result[:sample_rate]} Hz"
|
|
146
|
+
@out.puts "Channels: #{result[:channels]}"
|
|
147
|
+
@out.puts "Duration analyzed: #{format_number(result[:duration_seconds])} s"
|
|
148
|
+
@out.puts "Exact BPM: #{format_number(result[:exact_bpm])}"
|
|
149
|
+
@out.puts "Rounded BPM: #{result[:rounded_bpm]}"
|
|
150
|
+
@out.puts "Confidence: #{format_number(result[:confidence] * 100)}%"
|
|
151
|
+
0
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def parse_analyze_args(args)
|
|
155
|
+
raise ValidationError, "The analyze command expects a WAV file path." if args.empty?
|
|
156
|
+
|
|
157
|
+
options = {
|
|
158
|
+
min_bpm: AudioFileAnalyzer::DEFAULT_MIN_BPM,
|
|
159
|
+
max_bpm: AudioFileAnalyzer::DEFAULT_MAX_BPM
|
|
160
|
+
}
|
|
161
|
+
path = nil
|
|
162
|
+
remaining = args.dup
|
|
163
|
+
|
|
164
|
+
until remaining.empty?
|
|
165
|
+
token = remaining.shift
|
|
166
|
+
|
|
167
|
+
case token
|
|
168
|
+
when "--min"
|
|
169
|
+
raise ValidationError, "Missing value for --min." if remaining.empty?
|
|
170
|
+
options[:min_bpm] = self.class.positive_number!(remaining.shift, "Minimum BPM")
|
|
171
|
+
when "--max"
|
|
172
|
+
raise ValidationError, "Missing value for --max." if remaining.empty?
|
|
173
|
+
options[:max_bpm] = self.class.positive_number!(remaining.shift, "Maximum BPM")
|
|
174
|
+
when /\A--/
|
|
175
|
+
raise ValidationError, "Unknown analyze option: #{token}"
|
|
176
|
+
else
|
|
177
|
+
raise ValidationError, "Provide exactly one WAV file path." if path
|
|
178
|
+
path = token
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
raise ValidationError, "The analyze command expects a WAV file path." unless path
|
|
183
|
+
|
|
184
|
+
[path, options]
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def format_number(number)
|
|
188
|
+
rounded = number.round(3)
|
|
189
|
+
if (rounded % 1).zero?
|
|
190
|
+
rounded.to_i.to_s
|
|
191
|
+
else
|
|
192
|
+
format("%.3f", rounded).sub(/\.?0+$/, "")
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def help_text
|
|
197
|
+
<<~TEXT
|
|
198
|
+
Usage:
|
|
199
|
+
bpm-finder ms BPM
|
|
200
|
+
bpm-finder bpm MILLISECONDS
|
|
201
|
+
bpm-finder tap INTERVAL_MS [INTERVAL_MS ...]
|
|
202
|
+
bpm-finder analyze FILE.wav [--min BPM] [--max BPM]
|
|
203
|
+
|
|
204
|
+
Commands:
|
|
205
|
+
ms Convert a BPM value into common note division delay times.
|
|
206
|
+
bpm Convert milliseconds back into BPM.
|
|
207
|
+
tap Estimate BPM from one or more tap intervals in milliseconds.
|
|
208
|
+
analyze Estimate BPM from a local WAV audio file.
|
|
209
|
+
|
|
210
|
+
Examples:
|
|
211
|
+
bpm-finder ms 128
|
|
212
|
+
bpm-finder bpm 468.75
|
|
213
|
+
bpm-finder tap 500 480 495 505
|
|
214
|
+
bpm-finder analyze ./loop.wav --min 70 --max 150
|
|
215
|
+
TEXT
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
end
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
require_relative "test_helper"
|
|
2
|
+
|
|
3
|
+
class BpmFinderCliTest < Minitest::Test
|
|
4
|
+
SAMPLE_RATE = 44_100
|
|
5
|
+
|
|
6
|
+
def test_120_bpm_to_500_ms_quarter_note
|
|
7
|
+
table = Bpm::Finder::Cli.delay_table_for_bpm(120)
|
|
8
|
+
|
|
9
|
+
assert_in_delta 500.0, table["quarter"], 0.001
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def test_128_bpm_common_note_divisions
|
|
13
|
+
table = Bpm::Finder::Cli.delay_table_for_bpm(128)
|
|
14
|
+
|
|
15
|
+
assert_in_delta 468.75, table["quarter"], 0.001
|
|
16
|
+
assert_in_delta 234.375, table["eighth"], 0.001
|
|
17
|
+
assert_in_delta 117.1875, table["sixteenth"], 0.001
|
|
18
|
+
assert_in_delta 703.125, table["dotted quarter"], 0.001
|
|
19
|
+
assert_in_delta 312.5, table["quarter triplet"], 0.001
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def test_500_ms_to_120_bpm
|
|
23
|
+
assert_in_delta 120.0, Bpm::Finder::Cli.bpm_from_milliseconds(500), 0.001
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def test_tap_intervals_average_and_rounding
|
|
27
|
+
result = Bpm::Finder::Cli.tap_bpm([500, 480, 495, 505])
|
|
28
|
+
|
|
29
|
+
assert_in_delta 495.0, result[:average_interval_ms], 0.001
|
|
30
|
+
assert_in_delta 121.212, result[:exact_bpm], 0.001
|
|
31
|
+
assert_equal 121, result[:rounded_bpm]
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def test_invalid_numbers_raise_errors
|
|
35
|
+
assert_raises(Bpm::Finder::Cli::ValidationError) do
|
|
36
|
+
Bpm::Finder::Cli.delay_table_for_bpm(0)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
assert_raises(Bpm::Finder::Cli::ValidationError) do
|
|
40
|
+
Bpm::Finder::Cli.bpm_from_milliseconds(-20)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
assert_raises(Bpm::Finder::Cli::ValidationError) do
|
|
44
|
+
Bpm::Finder::Cli.tap_bpm(["abc"])
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def test_analyze_wav_file_detects_bpm
|
|
49
|
+
path = build_click_track_wav(120)
|
|
50
|
+
result = Bpm::Finder::AudioFileAnalyzer.analyze_file(path, min_bpm: 80, max_bpm: 160)
|
|
51
|
+
|
|
52
|
+
assert_in_delta 120.0, result[:exact_bpm], 1.0
|
|
53
|
+
assert_equal 120, result[:rounded_bpm]
|
|
54
|
+
assert_operator result[:confidence], :>, 0.0
|
|
55
|
+
assert_equal "wav", result[:format]
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def test_analyze_command_outputs_audio_analysis
|
|
59
|
+
path = build_click_track_wav(120)
|
|
60
|
+
out = StringIO.new
|
|
61
|
+
err = StringIO.new
|
|
62
|
+
|
|
63
|
+
status = Bpm::Finder::Cli.start(["analyze", path, "--min", "80", "--max", "160"], out: out, err: err)
|
|
64
|
+
|
|
65
|
+
assert_equal 0, status
|
|
66
|
+
assert_includes out.string, "Format: WAV"
|
|
67
|
+
assert_includes out.string, "Rounded BPM: 120"
|
|
68
|
+
assert_empty err.string
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def test_analyze_rejects_non_wav_files
|
|
72
|
+
dir = Dir.mktmpdir("bpm-finder-cli-test")
|
|
73
|
+
path = File.join(dir, "fake.mp3")
|
|
74
|
+
File.write(path, "not really an mp3")
|
|
75
|
+
out = StringIO.new
|
|
76
|
+
err = StringIO.new
|
|
77
|
+
|
|
78
|
+
status = Bpm::Finder::Cli.start(["analyze", path], out: out, err: err)
|
|
79
|
+
|
|
80
|
+
assert_equal 1, status
|
|
81
|
+
assert_includes err.string, "Only .wav files are supported"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def test_help_text_is_available
|
|
85
|
+
out = StringIO.new
|
|
86
|
+
err = StringIO.new
|
|
87
|
+
|
|
88
|
+
status = Bpm::Finder::Cli.start(["--help"], out: out, err: err)
|
|
89
|
+
|
|
90
|
+
assert_equal 0, status
|
|
91
|
+
assert_includes out.string, "Usage:"
|
|
92
|
+
assert_includes out.string, "analyze FILE.wav"
|
|
93
|
+
assert_empty err.string
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def test_invalid_command_returns_error
|
|
97
|
+
out = StringIO.new
|
|
98
|
+
err = StringIO.new
|
|
99
|
+
|
|
100
|
+
status = Bpm::Finder::Cli.start(["wat"], out: out, err: err)
|
|
101
|
+
|
|
102
|
+
assert_equal 1, status
|
|
103
|
+
assert_includes err.string, "Unknown command"
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
private
|
|
107
|
+
|
|
108
|
+
def build_click_track_wav(bpm, duration_seconds: 12, click_duration_seconds: 0.03)
|
|
109
|
+
dir = Dir.mktmpdir("bpm-finder-cli-test")
|
|
110
|
+
path = File.join(dir, "click-#{bpm}.wav")
|
|
111
|
+
total_samples = (SAMPLE_RATE * duration_seconds).to_i
|
|
112
|
+
samples = Array.new(total_samples, 0)
|
|
113
|
+
interval = (60.0 / bpm * SAMPLE_RATE).round
|
|
114
|
+
click_length = (click_duration_seconds * SAMPLE_RATE).round
|
|
115
|
+
|
|
116
|
+
sample_index = 0
|
|
117
|
+
while sample_index < total_samples
|
|
118
|
+
click_length.times do |offset|
|
|
119
|
+
break if sample_index + offset >= total_samples
|
|
120
|
+
|
|
121
|
+
decay = 1.0 - (offset / click_length.to_f)
|
|
122
|
+
samples[sample_index + offset] = (decay * 26_000).round
|
|
123
|
+
end
|
|
124
|
+
sample_index += interval
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
write_wav(path, samples, SAMPLE_RATE)
|
|
128
|
+
path
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def write_wav(path, samples, sample_rate)
|
|
132
|
+
data = samples.pack("s<*")
|
|
133
|
+
channels = 1
|
|
134
|
+
bits_per_sample = 16
|
|
135
|
+
block_align = channels * (bits_per_sample / 8)
|
|
136
|
+
byte_rate = sample_rate * block_align
|
|
137
|
+
fmt_chunk = [1, channels, sample_rate, byte_rate, block_align, bits_per_sample].pack("v v V V v v")
|
|
138
|
+
|
|
139
|
+
File.binwrite(
|
|
140
|
+
path,
|
|
141
|
+
+"RIFF" +
|
|
142
|
+
[36 + data.bytesize].pack("V") +
|
|
143
|
+
"WAVE" +
|
|
144
|
+
"fmt " +
|
|
145
|
+
[fmt_chunk.bytesize].pack("V") +
|
|
146
|
+
fmt_chunk +
|
|
147
|
+
"data" +
|
|
148
|
+
[data.bytesize].pack("V") +
|
|
149
|
+
data
|
|
150
|
+
)
|
|
151
|
+
end
|
|
152
|
+
end
|
data/test/test_helper.rb
ADDED
metadata
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: bpm-finder-cli
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Andy Chen
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-03-07 00:00:00.000000000 Z
|
|
12
|
+
dependencies: []
|
|
13
|
+
description: |
|
|
14
|
+
bpm-finder-cli is a small Ruby command-line tool for converting BPM values
|
|
15
|
+
into practical delay times, converting milliseconds back into tempo,
|
|
16
|
+
estimating tempo from tap intervals, and analyzing local WAV audio files
|
|
17
|
+
for BPM.
|
|
18
|
+
|
|
19
|
+
It is designed for DJs, producers, and developers who need quick tempo math
|
|
20
|
+
in scripts or terminals. For a full browser-based BPM workflow, visit
|
|
21
|
+
https://bpm-finder.net/
|
|
22
|
+
email:
|
|
23
|
+
- support@bpm-finder.net
|
|
24
|
+
executables:
|
|
25
|
+
- bpm-finder
|
|
26
|
+
extensions: []
|
|
27
|
+
extra_rdoc_files: []
|
|
28
|
+
files:
|
|
29
|
+
- CHANGELOG.md
|
|
30
|
+
- PUBLISHING.md
|
|
31
|
+
- README.md
|
|
32
|
+
- bin/bpm-finder
|
|
33
|
+
- lib/bpm/finder/audio_file_analyzer.rb
|
|
34
|
+
- lib/bpm/finder/cli.rb
|
|
35
|
+
- lib/bpm/finder/cli/version.rb
|
|
36
|
+
- lib/bpm/finder/validation_error.rb
|
|
37
|
+
- test/bpm_finder_cli_test.rb
|
|
38
|
+
- test/test_helper.rb
|
|
39
|
+
homepage: https://bpm-finder.net/
|
|
40
|
+
licenses:
|
|
41
|
+
- MIT
|
|
42
|
+
metadata:
|
|
43
|
+
homepage_uri: https://bpm-finder.net/
|
|
44
|
+
source_code_uri: https://github.com/bpmfinder/bpm-finder/tree/main/backlinks/rubygems/bpm-finder-cli
|
|
45
|
+
documentation_uri: https://github.com/bpmfinder/bpm-finder/blob/main/backlinks/rubygems/bpm-finder-cli/README.md
|
|
46
|
+
changelog_uri: https://github.com/bpmfinder/bpm-finder/blob/main/backlinks/rubygems/bpm-finder-cli/CHANGELOG.md
|
|
47
|
+
post_install_message:
|
|
48
|
+
rdoc_options: []
|
|
49
|
+
require_paths:
|
|
50
|
+
- lib
|
|
51
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
52
|
+
requirements:
|
|
53
|
+
- - ">="
|
|
54
|
+
- !ruby/object:Gem::Version
|
|
55
|
+
version: 2.6.0
|
|
56
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
57
|
+
requirements:
|
|
58
|
+
- - ">="
|
|
59
|
+
- !ruby/object:Gem::Version
|
|
60
|
+
version: '0'
|
|
61
|
+
requirements: []
|
|
62
|
+
rubygems_version: 3.0.3.1
|
|
63
|
+
signing_key:
|
|
64
|
+
specification_version: 4
|
|
65
|
+
summary: CLI utilities for BPM analysis and delay-time conversions.
|
|
66
|
+
test_files: []
|