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 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
@@ -0,0 +1,8 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0
4
+
5
+ - Initial release
6
+ - Added `ms`, `bpm`, `tap`, and `analyze` CLI commands
7
+ - Added local WAV audio BPM analysis
8
+ - Added RubyGems metadata linking back to BPM Finder
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,5 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require_relative "../lib/bpm/finder/cli"
4
+
5
+ exit(Bpm::Finder::Cli.start(ARGV))
@@ -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,7 @@
1
+ module Bpm
2
+ module Finder
3
+ class Cli
4
+ VERSION = "0.1.0".freeze
5
+ end
6
+ end
7
+ 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,5 @@
1
+ module Bpm
2
+ module Finder
3
+ class ValidationError < StandardError; end
4
+ end
5
+ 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
@@ -0,0 +1,6 @@
1
+ $LOAD_PATH.unshift File.expand_path("../lib", __dir__)
2
+
3
+ require "minitest/autorun"
4
+ require "stringio"
5
+ require "tmpdir"
6
+ require "bpm/finder/cli"
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: []