wavesync 1.0.0.alpha2 → 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.
data/lib/wavesync/set.rb CHANGED
@@ -1,4 +1,5 @@
1
1
  # frozen_string_literal: true
2
+ # rbs_inline: enabled
2
3
 
3
4
  require 'yaml'
4
5
  require 'fileutils'
@@ -7,21 +8,27 @@ module Wavesync
7
8
  class Set
8
9
  SETS_FOLDER = '.sets'
9
10
 
10
- attr_reader :name, :tracks, :library_path
11
+ attr_reader :name #: String
12
+ attr_reader :tracks #: Array[String]
13
+ attr_reader :library_path #: String
11
14
 
15
+ #: (String library_path) -> String
12
16
  def self.sets_path(library_path)
13
17
  File.join(library_path, SETS_FOLDER)
14
18
  end
15
19
 
20
+ #: (String library_path, String name) -> String
16
21
  def self.set_path(library_path, name)
17
22
  File.join(sets_path(library_path), "#{name}.yml")
18
23
  end
19
24
 
25
+ #: (String library_path, String name) -> Set
20
26
  def self.load(library_path, name)
21
27
  data = YAML.load_file(set_path(library_path, name))
22
28
  new(library_path, data['name'], expand_tracks(library_path, data['tracks']))
23
29
  end
24
30
 
31
+ #: (String library_path) -> Array[Set]
25
32
  def self.all(library_path)
26
33
  path = sets_path(library_path)
27
34
  return [] unless Dir.exist?(path)
@@ -32,36 +39,43 @@ module Wavesync
32
39
  end.sort_by(&:name)
33
40
  end
34
41
 
42
+ #: (String library_path, String name) -> bool
35
43
  def self.exists?(library_path, name)
36
44
  File.exist?(set_path(library_path, name))
37
45
  end
38
46
 
47
+ #: (String library_path, String name, ?Array[String] tracks) -> void
39
48
  def initialize(library_path, name, tracks = [])
40
49
  @library_path = library_path
41
50
  @name = name
42
51
  @tracks = tracks.dup
43
52
  end
44
53
 
54
+ #: (String path) -> void
45
55
  def add_track(path)
46
56
  @tracks << path
47
57
  end
48
58
 
59
+ #: (Integer index) -> String?
49
60
  def remove_track(index)
50
61
  @tracks.delete_at(index)
51
62
  end
52
63
 
64
+ #: (Integer index) -> void
53
65
  def move_up(index)
54
66
  return if index <= 0
55
67
 
56
68
  @tracks[index], @tracks[index - 1] = @tracks[index - 1], @tracks[index]
57
69
  end
58
70
 
71
+ #: (Integer index) -> void
59
72
  def move_down(index)
60
73
  return if index >= @tracks.size - 1
61
74
 
62
75
  @tracks[index], @tracks[index + 1] = @tracks[index + 1], @tracks[index]
63
76
  end
64
77
 
78
+ #: () -> void
65
79
  def save
66
80
  FileUtils.mkdir_p(self.class.sets_path(@library_path))
67
81
  File.write(self.class.set_path(@library_path, @name), to_yaml)
@@ -69,11 +83,13 @@ module Wavesync
69
83
 
70
84
  private
71
85
 
86
+ #: (String library_path, Array[String]? tracks) -> Array[String]
72
87
  def self.expand_tracks(library_path, tracks)
73
88
  (tracks || []).map { |t| File.join(library_path, t) }
74
89
  end
75
90
  private_class_method :expand_tracks
76
91
 
92
+ #: () -> String
77
93
  def to_yaml
78
94
  relative_tracks = @tracks.map { |t| t.sub("#{@library_path}/", '') }
79
95
  { 'name' => @name, 'tracks' => relative_tracks }.to_yaml
@@ -1,7 +1,9 @@
1
1
  # frozen_string_literal: true
2
+ # rbs_inline: enabled
2
3
 
3
4
  require 'tty-prompt'
4
5
  require 'io/console'
6
+ require 'stringio'
5
7
 
6
8
  module Wavesync
7
9
  class SetEditor
@@ -10,27 +12,35 @@ module Wavesync
10
12
  'u' => :move_up,
11
13
  'd' => :move_down,
12
14
  'r' => :remove,
13
- 's' => :save,
14
- 'c' => :quit,
15
+ 'q' => :quit,
15
16
  ' ' => :toggle_play,
17
+ 'j' => :jump_to_next_cue,
16
18
  "\e[A" => :cursor_up,
17
19
  "\e[B" => :cursor_down
18
20
  }.freeze
19
21
 
22
+ attr_accessor :player_state #: Symbol
23
+ attr_reader :selected, :set, :ui #: untyped
24
+ attr_writer :player_track, :player_index, :player_offset, :player_started_at
25
+
26
+ #: (Set set, String library_path) -> void
20
27
  def initialize(set, library_path)
21
- @set = set
22
- @library_path = library_path
23
- @prompt = TTY::Prompt.new(interrupt: :exit, active_color: :red)
24
- @ui = UI.new
25
- @selected = @set.tracks.empty? ? nil : 0
26
- @player_pid = nil
27
- @player_track = nil
28
- @player_state = :stopped
29
- @player_offset = 0
30
- @player_started_at = nil
28
+ @set = set #: Set
29
+ @library_path = library_path #: String
30
+ @prompt = TTY::Prompt.new(interrupt: :exit, active_color: :red) #: untyped
31
+ @ui = UI.new #: UI
32
+ @selected = @set.tracks.empty? ? nil : 0 #: Integer?
33
+ @player_pid = nil #: Integer?
34
+ @player_track = nil #: String?
35
+ @player_index = nil #: Integer?
36
+ @player_state = :stopped #: Symbol
37
+ @player_offset = 0 #: Numeric
38
+ @player_started_at = nil #: Time?
31
39
  end
32
40
 
41
+ #: () -> void
33
42
  def run
43
+ enter_fullscreen
34
44
  loop do
35
45
  check_player
36
46
  render
@@ -41,14 +51,108 @@ module Wavesync
41
51
  break if result == :quit
42
52
  end
43
53
  ensure
54
+ exit_fullscreen
44
55
  stop_playback
45
56
  end
46
57
 
58
+ #: (String? path) -> (String | Integer)?
59
+ def track_bpm(path)
60
+ return nil if path.nil?
61
+
62
+ @track_bpms ||= {} #: Hash[String, (String | Integer)?]
63
+ return @track_bpms[path] if @track_bpms.key?(path)
64
+
65
+ @track_bpms[path] = begin
66
+ Audio.new(path).bpm
67
+ rescue StandardError
68
+ nil
69
+ end
70
+ end
71
+
72
+ #: (String? path) -> Float?
73
+ def track_duration(path)
74
+ return nil if path.nil?
75
+
76
+ @track_durations ||= {} #: Hash[String, Float?]
77
+ return @track_durations[path] if @track_durations.key?(path)
78
+
79
+ @track_durations[path] = begin
80
+ Audio.new(path).duration
81
+ rescue StandardError
82
+ nil
83
+ end
84
+ end
85
+
86
+ #: (String? path) -> Array[Float]
87
+ def track_cue_fractions(path)
88
+ return [] if path.nil?
89
+
90
+ @track_cue_fractions ||= {} #: Hash[String, Array[Float]]
91
+ return @track_cue_fractions[path] if @track_cue_fractions.key?(path)
92
+
93
+ @track_cue_fractions[path] = begin
94
+ audio = Audio.new(path)
95
+ sample_rate = audio.sample_rate
96
+ duration = audio.duration
97
+ if sample_rate && duration&.positive?
98
+ audio.cue_points.map { |cue_point| cue_point[:sample_offset].to_f / sample_rate / duration }
99
+ else
100
+ [] #: Array[Float]
101
+ end
102
+ rescue StandardError
103
+ [] #: Array[Float]
104
+ end
105
+ end
106
+
107
+ #: ((String | Integer)? source_bpm, (String | Integer)? target_bpm) -> Float?
108
+ def pitch_shift_semitones(source_bpm, target_bpm)
109
+ return nil unless source_bpm && target_bpm
110
+
111
+ source = source_bpm.to_f
112
+ target = target_bpm.to_f
113
+ return nil if source.zero?
114
+
115
+ 12.0 * Math.log2(target / source)
116
+ end
117
+
118
+ #: (Float semitones) -> String
119
+ def format_pitch_shift(semitones)
120
+ sign = semitones >= 0 ? '+' : ''
121
+ "#{sign}#{semitones.round(2)}"
122
+ end
123
+
47
124
  private
48
125
 
126
+ #: () -> void
127
+ def enter_fullscreen
128
+ print "\e[?1049h" # enter alternate screen buffer
129
+ print "\e[?25l" # hide cursor
130
+ end
131
+
132
+ #: () -> void
133
+ def exit_fullscreen
134
+ print "\e[?25h" # show cursor
135
+ print "\e[?1049l" # exit alternate screen buffer
136
+ end
137
+
138
+ #: (StringIO buffer) -> void
139
+ def flush_render(buffer)
140
+ $stdout.print "\e[H"
141
+ lines = buffer.string.lines
142
+ lines.each_with_index do |line, i|
143
+ terminator = i < lines.size - 1 ? "\n" : ''
144
+ $stdout.print "\e[K#{line.chomp}#{terminator}"
145
+ end
146
+ $stdout.print "\e[J"
147
+ end
148
+
149
+ #: () -> String?
49
150
  def read_key
50
151
  $stdin.raw do |io|
51
- char = io.getc
152
+ ready = io.wait_readable(0.5)
153
+ return nil unless ready
154
+
155
+ char = io.getc || ''
52
156
  if char == "\e"
53
157
  rest = begin
54
158
  io.read_nonblock(3)
@@ -62,33 +166,138 @@ module Wavesync
62
166
  end
63
167
  end
64
168
 
169
+ #: (String absolute) -> String
65
170
  def relative_path(absolute)
66
171
  absolute.sub("#{@library_path}/", '')
67
172
  end
68
173
 
174
+ #: (Float? seconds) -> String?
175
+ def format_duration(seconds)
176
+ return nil unless seconds
177
+
178
+ total_seconds = seconds.to_i
179
+ mins = total_seconds / 60
180
+ secs = total_seconds % 60
181
+ "#{mins}:#{secs.to_s.rjust(2, '0')}"
182
+ end
183
+
184
+ #: () -> Float
185
+ def playback_elapsed
186
+ case @player_state
187
+ when :playing
188
+ (@player_offset + (Time.now - @player_started_at)).to_f
189
+ when :paused
190
+ @player_offset.to_f
191
+ else
192
+ 0.0
193
+ end
194
+ end
195
+
196
+ #: () -> Integer
197
+ def terminal_width
198
+ IO.console&.winsize&.last || 80
199
+ rescue StandardError
200
+ 80
201
+ end
202
+
203
+ #: (String? string) -> Integer
204
+ def visible_length(string)
205
+ return 0 unless string
206
+
207
+ string.gsub(/\e\[[0-9;]*[A-Za-z]/, '').length
208
+ end
209
+
210
+ #: (Float elapsed, Float total_duration, Integer bar_width, ?cue_fractions: Array[Float]) -> String
211
+ def playback_bar(elapsed, total_duration, bar_width, cue_fractions: [])
212
+ ratio = total_duration.positive? ? [elapsed / total_duration, 1.0].min : 0.0
213
+ filled = (ratio * bar_width).round
214
+
215
+ bar = Array.new(bar_width) { |i| i < filled ? '█' : '░' }
216
+
217
+ cue_position_colors = {} #: Hash[Integer, Symbol]
218
+ cue_fractions.each do |fraction|
219
+ position = (fraction * (bar_width - 1)).round.clamp(0, bar_width - 1)
220
+ cue_position_colors[position] = position == filled ? :highlight : :surface
221
+ end
222
+ cue_position_colors.each_key { |position| bar[position] = '◆' }
223
+
224
+ result = +''
225
+ run_start = 0
226
+ while run_start < bar_width
227
+ if cue_position_colors.key?(run_start)
228
+ result << @ui.color(bar[run_start], cue_position_colors[run_start])
229
+ run_start += 1
230
+ else
231
+ run_end = run_start
232
+ run_end += 1 while run_end < bar_width && !cue_position_colors.key?(run_end)
233
+ result << @ui.color((bar[run_start...run_end] || []).join, :surface)
234
+ run_start = run_end
235
+ end
236
+ end
237
+ result
238
+ end
239
+
240
+ #: (Float elapsed, Float total_duration) -> String
241
+ def remaining_display(elapsed, total_duration)
242
+ remaining = [total_duration - elapsed, 0.0].max
243
+ "-#{format_duration(remaining) || '0:00'}"
244
+ end
245
+
246
+ #: (String relative) -> String
69
247
  def display_name(relative)
70
248
  File.basename(relative, '.*').sub(/\A\d+[\s.\-_]+/, '')
71
249
  end
72
250
 
251
+ #: (?String title) -> void
73
252
  def render(title = "wavesync set #{@set.name}")
74
- @ui.clear
75
- puts @ui.color(title, :primary)
253
+ buffer = StringIO.new
254
+ $stdout = buffer
255
+
256
+ header = @ui.color(title, :primary)
257
+ total_duration = @set.tracks.sum { |track_path| track_duration(track_path) || 0.0 }
258
+
259
+ duration_widths = @set.tracks.map { |track_path| format_duration(track_duration(track_path))&.length || 0 }
260
+ duration_widths << (format_duration(total_duration)&.length || 0)
261
+ player_duration = @player_index ? track_duration(@set.tracks[@player_index]) : nil
262
+ duration_widths << remaining_display(0.0, player_duration).length if player_duration
263
+ duration_col_width = duration_widths.max || 0
264
+
265
+ if @set.tracks.any? && total_duration.positive?
266
+ track_label = @set.tracks.size == 1 ? 'track' : 'tracks'
267
+ track_count_part = @ui.color("#{@set.tracks.size} #{track_label}", :secondary)
268
+ duration_part = @ui.color(format_duration(total_duration).to_s.rjust(duration_col_width), :secondary)
269
+ summary = "#{track_count_part} #{duration_part}"
270
+ gap = [terminal_width - visible_length(header) - visible_length(summary), 2].max
271
+ puts "#{header}#{' ' * gap}#{summary}"
272
+ else
273
+ puts header
274
+ end
275
+
76
276
  puts
77
277
 
78
278
  if @set.tracks.empty?
79
279
  puts @ui.color(' (no tracks)', :secondary)
80
280
  else
81
281
  @set.tracks.each_with_index do |track, i|
82
- render_track(i, relative_path(track), i == @selected, track == @player_track)
282
+ current_bpm = track_bpm(track)
283
+ current_duration = track_duration(track)
284
+ pitch_shift = pitch_shift_semitones(current_bpm, track_bpm(@set.tracks[i + 1]))
285
+ render_track(i, relative_path(track), i == @selected, i == @player_index,
286
+ bpm: current_bpm, pitch_shift: pitch_shift, duration: current_duration,
287
+ duration_col_width: duration_col_width, cue_fractions: track_cue_fractions(track))
83
288
  end
84
289
  end
85
290
 
86
291
  puts
87
- puts @ui.color('[↑↓] navigate [space] play/pause [a] add [u] up [d] down [r] remove [s] save [c] cancel',
292
+ puts @ui.color('[↑↓] navigate [space] play/pause [j] next cue [a] add [u] up [d] down [r] remove [q] quit',
88
293
  :secondary)
294
+ ensure
295
+ $stdout = STDOUT
296
+ flush_render(buffer)
89
297
  end
90
298
 
91
- def render_track(index, relative, selected, playing)
299
+ #: (Integer index, String relative, bool selected, bool playing, ?bpm: (String | Integer)?, ?pitch_shift: Float?, ?duration: Float?, ?duration_col_width: Integer, ?cue_fractions: Array[Float]) -> void
300
+ def render_track(index, relative, selected, playing, bpm: nil, pitch_shift: nil, duration: nil, duration_col_width: 0, cue_fractions: [])
92
301
  name = display_name(relative)
93
302
  folder = File.dirname(relative)
94
303
  icon = if playing
@@ -96,31 +305,88 @@ module Wavesync
96
305
  else
97
306
  ' '
98
307
  end
99
- puts "#{@ui.color(icon, :surface)} #{@ui.color("#{index + 1}.", selected ? :highlight : :extra)} #{@ui.color(name, selected ? :highlight : :primary)}"
100
- puts @ui.color(" #{folder}", selected ? :highlight : :tertiary) unless folder == '.'
308
+ name_color = selected ? :highlight : :primary
309
+ index_color = selected ? :highlight : :extra
310
+ bpm_color = selected ? :highlight : :secondary
311
+ meta_color = selected ? :highlight : :tertiary
312
+
313
+ colored_icon = @ui.color(icon, :surface)
314
+ colored_index = @ui.color("#{index + 1}.", index_color)
315
+ colored_name = @ui.color(name, name_color)
316
+ bpm_label = bpm ? " · #{@ui.color("#{bpm} bpm", bpm_color)}" : ''
317
+ left_line = "#{colored_icon} #{colored_index} #{colored_name}#{bpm_label}"
318
+
319
+ folder_part = folder == '.' ? nil : @ui.color(folder, meta_color)
320
+ elapsed = playing && duration ? playback_elapsed : nil
321
+ duration_str = if elapsed && duration
322
+ @ui.color(remaining_display(elapsed, duration).rjust(duration_col_width), meta_color)
323
+ elsif !playing
324
+ format_duration(duration)&.then { @ui.color(_1.rjust(duration_col_width), meta_color) }
325
+ end
326
+ right_line = [folder_part, duration_str].compact.join(' ')
327
+
328
+ if right_line.empty?
329
+ puts left_line
330
+ else
331
+ gap = terminal_width - visible_length(left_line) - visible_length(right_line)
332
+ if gap >= 2
333
+ puts "#{left_line}#{' ' * gap}#{right_line}"
334
+ else
335
+ puts left_line
336
+ indent = ' '
337
+ if folder_part && duration_str
338
+ second_gap = [terminal_width - indent.length - visible_length(folder_part) - visible_length(duration_str), 2].max
339
+ puts "#{indent}#{folder_part}#{' ' * second_gap}#{duration_str}"
340
+ else
341
+ puts "#{indent}#{folder_part || duration_str}"
342
+ end
343
+ end
344
+ end
345
+
346
+ if playing && @player_state != :stopped && duration
347
+ bar_width = [terminal_width - 5, 20].max
348
+ puts " #{playback_bar(playback_elapsed, duration, bar_width, cue_fractions: cue_fractions)}"
349
+ end
350
+
351
+ return unless pitch_shift
352
+
353
+ puts " #{@ui.color("↓ #{format_pitch_shift(pitch_shift)} st", :secondary)}"
101
354
  end
102
355
 
356
+ #: (Symbol action) -> Symbol?
103
357
  def handle_action(action)
104
358
  case action
105
359
  when :cursor_up
106
360
  @selected = [@selected - 1, 0].max unless @set.tracks.empty?
361
+ nil
107
362
  when :cursor_down
108
363
  @selected = [@selected + 1, @set.tracks.size - 1].min unless @set.tracks.empty?
109
- when :toggle_play then toggle_playback
110
- when :add then add_track
111
- when :remove then remove_track
112
- when :move_up then move_track(:up)
113
- when :move_down then move_track(:down)
114
- when :save
115
- @set.save
116
- path = Set.set_path(@library_path, @set.name)
117
- puts @ui.color("Saved '#{@set.name}' to #{path}.", :primary)
118
- :quit
364
+ nil
365
+ when :toggle_play
366
+ toggle_playback
367
+ nil
368
+ when :add
369
+ add_track
370
+ nil
371
+ when :remove
372
+ remove_track
373
+ nil
374
+ when :move_up
375
+ move_track(:up)
376
+ nil
377
+ when :move_down
378
+ move_track(:down)
379
+ nil
380
+ when :jump_to_next_cue
381
+ jump_to_next_cue
382
+ nil
119
383
  when :quit
384
+ @set.save
120
385
  :quit
121
386
  end
122
387
  end
123
388
 
389
+ #: () -> void
124
390
  def toggle_playback
125
391
  return if @selected.nil?
126
392
 
@@ -141,18 +407,40 @@ module Wavesync
141
407
  end
142
408
  end
143
409
 
144
- def start_player(track, offset = 0)
410
+ #: (String track, ?Numeric offset, ?player_index: Integer?) -> void
411
+ def start_player(track, offset = 0, player_index: @selected)
145
412
  ffplay = FFMPEG.ffmpeg_binary.sub('ffmpeg', 'ffplay')
146
413
  args = [ffplay, '-nodisp', '-autoexit', '-loglevel', 'quiet', '-probesize', '32', '-analyzeduration', '0']
147
414
  args += ['-ss', offset.to_s] if offset.positive?
148
415
  args << track
149
416
  @player_pid = spawn(*args, out: File::NULL, err: File::NULL)
150
417
  @player_track = track
418
+ @player_index = player_index
151
419
  @player_offset = offset
152
420
  @player_started_at = Time.now
153
421
  @player_state = :playing
154
422
  end
155
423
 
424
+ #: () -> void
425
+ def jump_to_next_cue
426
+ return unless @player_track
427
+
428
+ duration = track_duration(@player_track)
429
+ return unless duration&.positive?
430
+
431
+ fractions = track_cue_fractions(@player_track)
432
+ return if fractions.empty?
433
+
434
+ elapsed = playback_elapsed
435
+ sorted_fractions = fractions.sort
436
+ next_fraction = sorted_fractions.find { |fraction| fraction * duration > elapsed + 0.05 }
437
+ next_fraction ||= sorted_fractions.first
438
+
439
+ kill_player
440
+ start_player(@player_track, next_fraction * duration, player_index: @player_index)
441
+ end
442
+
443
+ #: () -> void
156
444
  def kill_player
157
445
  return unless @player_pid
158
446
 
@@ -162,14 +450,17 @@ module Wavesync
162
450
  @player_pid = nil
163
451
  end
164
452
 
453
+ #: () -> void
165
454
  def stop_playback
166
455
  kill_player
167
456
  @player_track = nil
457
+ @player_index = nil
168
458
  @player_state = :stopped
169
459
  @player_offset = 0
170
460
  @player_started_at = nil
171
461
  end
172
462
 
463
+ #: () -> void
173
464
  def check_player
174
465
  return unless @player_pid
175
466
 
@@ -178,17 +469,20 @@ module Wavesync
178
469
 
179
470
  @player_pid = nil
180
471
  @player_track = nil
472
+ @player_index = nil
181
473
  @player_state = :stopped
182
474
  @player_offset = 0
183
475
  advance_and_play
184
476
  rescue Errno::ECHILD
185
477
  @player_pid = nil
186
478
  @player_track = nil
479
+ @player_index = nil
187
480
  @player_state = :stopped
188
481
  @player_offset = 0
189
482
  advance_and_play
190
483
  end
191
484
 
485
+ #: () -> void
192
486
  def advance_and_play
193
487
  return if @selected.nil? || @selected >= @set.tracks.size - 1
194
488
 
@@ -196,10 +490,12 @@ module Wavesync
196
490
  start_player(@set.tracks[@selected])
197
491
  end
198
492
 
493
+ #: () -> Array[String]
199
494
  def audio_files
200
495
  @audio_files ||= Audio.find_all(@library_path)
201
496
  end
202
497
 
498
+ #: () -> void
203
499
  def add_track
204
500
  if audio_files.empty?
205
501
  puts @ui.color('No audio files found in library.', :highlight)
@@ -218,6 +514,7 @@ module Wavesync
218
514
  @selected = insert_at
219
515
  end
220
516
 
517
+ #: () -> void
221
518
  def remove_track
222
519
  return if @set.tracks.empty? || @selected.nil?
223
520
 
@@ -230,6 +527,7 @@ module Wavesync
230
527
  end
231
528
  end
232
529
 
530
+ #: (Symbol direction) -> void
233
531
  def move_track(direction)
234
532
  return if @set.tracks.size < 2 || @selected.nil?
235
533
 
@@ -241,5 +539,9 @@ module Wavesync
241
539
  @selected = [@selected + 1, @set.tracks.size - 1].min
242
540
  end
243
541
  end
542
+
543
+ public :handle_action, :advance_and_play, :display_name, :relative_path,
544
+ :format_duration, :playback_elapsed, :visible_length, :playback_bar,
545
+ :selected, :set, :ui
244
546
  end
245
547
  end
@@ -1,26 +1,29 @@
1
1
  # frozen_string_literal: true
2
+ # rbs_inline: enabled
2
3
 
3
4
  module Wavesync
4
5
  class TrackPadding
5
6
  BEATS_PER_BAR = 4
6
7
 
8
+ #: ((Float | Integer)? duration_seconds, (Integer | Float | String)? bpm, (Integer | Float)? bar_multiple) -> (Float | Integer)
7
9
  def self.compute(duration_seconds, bpm, bar_multiple)
8
- return 0 if bpm.nil? || bpm.to_f.zero? || duration_seconds.nil? || duration_seconds <= 0
10
+ return 0 if bpm.nil? || bpm.to_f.zero? || duration_seconds.nil? || duration_seconds <= 0 || bar_multiple.nil?
9
11
 
10
12
  seconds_per_bar = BEATS_PER_BAR * 60.0 / bpm.to_f
11
13
  track_bars = (duration_seconds / seconds_per_bar).round(6)
12
- target_bars = (track_bars / bar_multiple.to_f).ceil * bar_multiple
14
+ target_bars = (track_bars / bar_multiple.to_f).ceil * bar_multiple.to_f
13
15
 
14
16
  padding = (target_bars * seconds_per_bar) - duration_seconds
15
17
  padding < 0.001 ? 0 : padding
16
18
  end
17
19
 
20
+ #: ((Float | Integer)? duration_seconds, (Integer | Float | String)? bpm, (Integer | Float)? bar_multiple) -> [Integer?, Integer?]
18
21
  def self.bar_counts(duration_seconds, bpm, bar_multiple)
19
- return [nil, nil] if bpm.nil? || bpm.to_f.zero? || duration_seconds.nil? || duration_seconds <= 0
22
+ return [nil, nil] if bpm.nil? || bpm.to_f.zero? || duration_seconds.nil? || duration_seconds <= 0 || bar_multiple.nil?
20
23
 
21
24
  seconds_per_bar = BEATS_PER_BAR * 60.0 / bpm.to_f
22
25
  original_bars = (duration_seconds / seconds_per_bar).round
23
- target_bars = ((original_bars.to_f / bar_multiple).ceil * bar_multiple).to_i
26
+ target_bars = ((original_bars.to_f / bar_multiple).ceil * bar_multiple.to_f).to_i
24
27
  [original_bars, target_bars]
25
28
  end
26
29
  end