wavesync 1.0.0.alpha2 → 1.0.0.alpha4

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