wavesync 1.0.0.alpha3 → 1.0.0.beta1

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.
@@ -2,7 +2,9 @@
2
2
  # rbs_inline: enabled
3
3
 
4
4
  require 'fileutils'
5
- require 'streamio-ffmpeg'
5
+ require 'pathname'
6
+ require 'tempfile'
7
+ require_relative 'logger'
6
8
  require_relative 'file_converter'
7
9
 
8
10
  module Wavesync
@@ -10,14 +12,16 @@ module Wavesync
10
12
  #: (String source_library_path) -> void
11
13
  def initialize(source_library_path)
12
14
  @source_library_path = File.expand_path(source_library_path) #: String
15
+ Logger.configure(@source_library_path)
13
16
  @audio_files = find_audio_files #: Array[String]
14
17
  @ui = Wavesync::UI.new #: UI
15
18
  @converter = FileConverter.new #: FileConverter
16
- FFMPEG.logger = Logger.new(File::NULL)
17
19
  end
18
20
 
19
- #: (String target_library_path, Device device, ?pad: bool) -> void
20
- def sync(target_library_path, device, pad: false)
21
+ #: (String target_library_path, Device device, ?pad: bool, ?staged: bool, ?mp3_bitrate: Integer) ?{ (String) -> void } -> void
22
+ def sync(target_library_path, device, pad: false, staged: false, mp3_bitrate: 192, &on_file_synced)
23
+ start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
24
+ target_library_pathname = Pathname.new(target_library_path)
21
25
  path_resolver = PathResolver.new(@source_library_path, target_library_path, device)
22
26
  skipped_count = 0
23
27
  conversion_count = 0
@@ -25,73 +29,114 @@ module Wavesync
25
29
 
26
30
  @audio_files.each_with_index do |file, index|
27
31
  audio = Audio.new(file)
32
+ bpm = audio.bpm
28
33
 
29
34
  source_format = audio.format
30
35
  target_format = device.target_format(source_format, file)
36
+ target_format = target_format.with(sample_rate: nil, bit_depth: nil) if source_format.file_type == 'mp3' && target_format.file_type.nil?
31
37
 
32
38
  padding_seconds = nil #: Numeric?
33
39
  original_bars = nil #: Integer?
34
40
  target_bars = nil #: Integer?
35
41
  if pad && device.bar_multiple
36
- padding_seconds = TrackPadding.compute(audio.duration, audio.bpm, device.bar_multiple)
37
- original_bars, target_bars = TrackPadding.bar_counts(audio.duration, audio.bpm, device.bar_multiple) unless padding_seconds.zero?
42
+ padding_seconds = TrackPadding.compute(audio.duration, bpm, device.bar_multiple)
43
+ original_bars, target_bars = TrackPadding.bar_counts(audio.duration, bpm, device.bar_multiple) unless padding_seconds.zero?
38
44
  padding_seconds = nil if padding_seconds.zero?
39
45
  end
40
46
 
41
- @ui.bpm(audio.bpm, original_bars: original_bars, target_bars: target_bars)
47
+ @ui.bpm(bpm, original_bars: original_bars, target_bars: target_bars)
42
48
  @ui.file_progress(file)
43
49
 
44
- if source_format.file_type == 'wav'
45
- prospective_target_path = path_resolver.resolve(file, audio, target_file_type: target_format.file_type)
46
- if prospective_target_path.extname.downcase == '.wav' && prospective_target_path.exist?
47
- target_cue_points = CueChunk.read(prospective_target_path.to_s)
48
- if target_cue_points.any?
49
- source_cue_points = audio.cue_points
50
- audio.write_cue_points(target_cue_points) unless same_cue_points?(source_cue_points, target_cue_points)
51
- end
52
- end
53
- end
54
-
55
50
  if target_format.file_type || target_format.sample_rate || target_format.bit_depth || padding_seconds
51
+ target_ext = target_format.file_type || source_format.file_type
52
+ transliterated_metadata = {} #: Hash[String, String]
53
+ transliterated_metadata = audio.transliterated_tag_changes if device.transliterate_metadata && target_ext == 'mp3'
56
54
  converted = @converter.convert(audio, file, path_resolver, source_format, target_format,
57
- padding_seconds: padding_seconds) do
58
- @ui.conversion_progress(source_format, target_format)
55
+ padding_seconds: padding_seconds,
56
+ metadata: transliterated_metadata,
57
+ mp3_bitrate: mp3_bitrate,
58
+ before_transcode: -> { @ui.conversion_progress(source_format, target_format, mp3_bitrate) }) do |local_temp_path|
59
+ inject_acid_bpm(local_temp_path, bpm, device)
60
+ inject_cue_points(local_temp_path, audio, source_format, target_format)
59
61
  end
60
- target_path = path_resolver.resolve(file, audio, target_file_type: target_format.file_type)
62
+ converted_target_path = path_resolver.resolve(file, audio, target_file_type: target_format.file_type)
63
+ verify_written(converted_target_path, source: file) if converted
64
+ final_target_path = converted_target_path
61
65
  else
62
- copied = copy_file(audio, file, path_resolver)
63
- @ui.copy(source_format)
64
- target_path = path_resolver.resolve(file, audio)
65
- end
66
-
67
- bpm = audio.bpm
68
- if (copied || converted) && device.bpm_source == :acid_chunk && bpm && target_path.extname.downcase == '.wav'
69
- temp_path = "#{target_path}.tmp"
70
- AcidChunk.write_bpm(target_path.to_s, temp_path, bpm)
71
- FileUtils.mv(temp_path, target_path.to_s)
72
- end
73
-
74
- if converted && source_format.file_type == 'wav' && target_path.extname.downcase == '.wav'
75
- source_cue_points = audio.cue_points
76
- if source_cue_points.any?
77
- rescaled_cue_points = rescale_cue_points(source_cue_points, audio.sample_rate, target_format.sample_rate || audio.sample_rate)
78
- temp_path = "#{target_path}.tmp"
79
- CueChunk.write(target_path.to_s, temp_path, rescaled_cue_points)
80
- FileUtils.mv(temp_path, target_path.to_s)
66
+ if device.bpm_source == :acid_chunk && bpm && File.extname(file).downcase == '.wav'
67
+ target_path = path_resolver.resolve(file, audio)
68
+ files_to_cleanup = path_resolver.find_files_to_cleanup(target_path, audio)
69
+ files_to_cleanup.each { |cleanup_file| FileUtils.rm_f(cleanup_file) }
70
+ if target_path.exist?
71
+ copied = false
72
+ else
73
+ target_path.dirname.mkpath
74
+ AcidChunk.write_bpm(file, target_path.to_s, bpm)
75
+ verify_written(target_path, source: file)
76
+ copied = true
77
+ end
78
+ else
79
+ copied = copy_file(audio, file, path_resolver)
80
+ target_path = path_resolver.resolve(file, audio)
81
+ if copied
82
+ verify_written(target_path, source: file)
83
+ inject_transliterated_metadata(target_path.to_s, device)
84
+ end
81
85
  end
86
+ final_target_path = target_path
87
+ @ui.copy(source_format)
82
88
  end
83
89
 
84
90
  if !copied && !converted
85
91
  skipped_count += 1
86
- @ui.skip
92
+ @ui.skip(staged: staged)
87
93
  end
88
94
 
89
95
  conversion_count += 1 if converted
90
96
  @ui.sync_progress(index, @audio_files.size, device)
97
+
98
+ if on_file_synced && final_target_path
99
+ relative_path = final_target_path.relative_path_from(target_library_pathname).to_s
100
+ on_file_synced.call(relative_path)
101
+ end
102
+ end
103
+
104
+ puts
105
+ system('sync')
106
+ elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
107
+ Logger.log_run_time(elapsed)
108
+ end
109
+
110
+ #: (String target_library_path, Device device) -> void
111
+ def pull_cue_points(target_library_path, device)
112
+ start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
113
+ path_resolver = PathResolver.new(@source_library_path, target_library_path, device)
114
+ @ui.pull_progress(0, @audio_files.size, device)
115
+
116
+ @audio_files.each_with_index do |file, index|
117
+ audio = Audio.new(file)
118
+ source_format = audio.format
119
+ @ui.file_progress(file)
120
+
121
+ if source_format.file_type == 'wav'
122
+ target_format = device.target_format(source_format, file)
123
+ target_path = path_resolver.resolve(file, audio, target_file_type: target_format.file_type)
124
+ if target_path.extname.downcase == '.wav' && target_path.exist?
125
+ target_cue_points = CueChunk.read(target_path.to_s)
126
+ if target_cue_points.any?
127
+ source_cue_points = audio.cue_points
128
+ audio.write_cue_points(target_cue_points) unless CueChunk.same?(source_cue_points, target_cue_points)
129
+ end
130
+ end
131
+ end
132
+
133
+ @ui.pull_progress(index, @audio_files.size, device)
91
134
  end
92
135
 
93
136
  puts
94
137
  system('sync')
138
+ elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
139
+ Logger.log_run_time(elapsed)
95
140
  end
96
141
 
97
142
  private
@@ -116,15 +161,29 @@ module Wavesync
116
161
  end
117
162
  end
118
163
 
119
- #: (Array[{identifier: Integer, sample_offset: Integer, label: String?}] cue_points_a, Array[{identifier: Integer, sample_offset: Integer, label: String?}] cue_points_b) -> bool
120
- def same_cue_points?(cue_points_a, cue_points_b)
121
- comparable_cue_points(cue_points_a) == comparable_cue_points(cue_points_b)
164
+ #: (String path, Device device) -> void
165
+ def inject_transliterated_metadata(path, device)
166
+ return unless device.transliterate_metadata
167
+
168
+ Audio.new(path).transliterate_tags
169
+ end
170
+
171
+ #: (String local_temp_path, (Integer | Float | String)? bpm, Device device) -> void
172
+ def inject_acid_bpm(local_temp_path, bpm, device)
173
+ return unless device.bpm_source == :acid_chunk && bpm && File.extname(local_temp_path).downcase == '.wav'
174
+
175
+ AcidChunk.write_bpm_in_place(local_temp_path, bpm)
122
176
  end
123
177
 
124
- #: (Array[{identifier: Integer, sample_offset: Integer, label: String?}] cue_points) -> Array[{sample_offset: Integer, label: String?}]
125
- def comparable_cue_points(cue_points)
126
- mapped = cue_points.map { |cp| { sample_offset: cp[:sample_offset], label: cp[:label] } } #: Array[{sample_offset: Integer, label: String?}]
127
- mapped.sort_by { |cp| cp[:sample_offset] }
178
+ #: (String local_temp_path, Audio audio, AudioFormat source_format, AudioFormat target_format) -> void
179
+ def inject_cue_points(local_temp_path, audio, source_format, target_format)
180
+ return unless source_format.file_type == 'wav' && File.extname(local_temp_path).downcase == '.wav'
181
+
182
+ source_cue_points = audio.cue_points
183
+ return unless source_cue_points.any?
184
+
185
+ rescaled_cue_points = rescale_cue_points(source_cue_points, audio.sample_rate, target_format.sample_rate || audio.sample_rate)
186
+ CueChunk.append_to_file(local_temp_path, rescaled_cue_points)
128
187
  end
129
188
 
130
189
  #: (Array[{identifier: Integer, sample_offset: Integer, label: String?}] cue_points, Integer? source_sample_rate, Integer? target_sample_rate) -> Array[{identifier: Integer, sample_offset: Integer, label: String?}]
@@ -139,8 +198,16 @@ module Wavesync
139
198
  #: (String source, Pathname target) -> void
140
199
  def safe_copy(source, target)
141
200
  FileUtils.install(source, target)
142
- rescue Errno::ENOENT
143
- puts 'Errno::ENOENT'
201
+ rescue Errno::ENOENT => e
202
+ Logger.log_error(e, call_site: 'Scanner#safe_copy', arguments: { source:, target: })
203
+ end
204
+
205
+ #: (Pathname target_path, source: String) -> void
206
+ def verify_written(target_path, source:)
207
+ return if target_path.exist?
208
+
209
+ error = RuntimeError.new('target file missing after write')
210
+ Logger.log_error(error, call_site: 'Scanner#verify_written', arguments: { source:, target: target_path.to_s })
144
211
  end
145
212
  end
146
213
  end
@@ -5,32 +5,32 @@ require 'yaml'
5
5
  require 'fileutils'
6
6
 
7
7
  module Wavesync
8
- class Set
9
- SETS_FOLDER = '.sets'
8
+ class Setlist
9
+ SETLISTS_FOLDER = '.setlists'
10
10
 
11
11
  attr_reader :name #: String
12
12
  attr_reader :tracks #: Array[String]
13
13
  attr_reader :library_path #: String
14
14
 
15
15
  #: (String library_path) -> String
16
- def self.sets_path(library_path)
17
- File.join(library_path, SETS_FOLDER)
16
+ def self.setlists_path(library_path)
17
+ File.join(library_path, SETLISTS_FOLDER)
18
18
  end
19
19
 
20
20
  #: (String library_path, String name) -> String
21
- def self.set_path(library_path, name)
22
- File.join(sets_path(library_path), "#{name}.yml")
21
+ def self.setlist_path(library_path, name)
22
+ File.join(setlists_path(library_path), "#{name}.yml")
23
23
  end
24
24
 
25
- #: (String library_path, String name) -> Set
25
+ #: (String library_path, String name) -> Setlist
26
26
  def self.load(library_path, name)
27
- data = YAML.load_file(set_path(library_path, name))
27
+ data = YAML.load_file(setlist_path(library_path, name))
28
28
  new(library_path, data['name'], expand_tracks(library_path, data['tracks']))
29
29
  end
30
30
 
31
- #: (String library_path) -> Array[Set]
31
+ #: (String library_path) -> Array[Setlist]
32
32
  def self.all(library_path)
33
- path = sets_path(library_path)
33
+ path = setlists_path(library_path)
34
34
  return [] unless Dir.exist?(path)
35
35
 
36
36
  Dir.glob(File.join(path, '*.yml')).map do |file|
@@ -41,7 +41,7 @@ module Wavesync
41
41
 
42
42
  #: (String library_path, String name) -> bool
43
43
  def self.exists?(library_path, name)
44
- File.exist?(set_path(library_path, name))
44
+ File.exist?(setlist_path(library_path, name))
45
45
  end
46
46
 
47
47
  #: (String library_path, String name, ?Array[String] tracks) -> void
@@ -77,8 +77,8 @@ module Wavesync
77
77
 
78
78
  #: () -> void
79
79
  def save
80
- FileUtils.mkdir_p(self.class.sets_path(@library_path))
81
- File.write(self.class.set_path(@library_path, @name), to_yaml)
80
+ FileUtils.mkdir_p(self.class.setlists_path(@library_path))
81
+ File.write(self.class.setlist_path(@library_path, @name), to_yaml)
82
82
  end
83
83
 
84
84
  private
@@ -4,9 +4,10 @@
4
4
  require 'tty-prompt'
5
5
  require 'io/console'
6
6
  require 'stringio'
7
+ require_relative 'logger'
7
8
 
8
9
  module Wavesync
9
- class SetEditor
10
+ class SetlistEditor
10
11
  KEY_MAP = {
11
12
  'a' => :add,
12
13
  'u' => :move_up,
@@ -20,16 +21,17 @@ module Wavesync
20
21
  }.freeze
21
22
 
22
23
  attr_accessor :player_state #: Symbol
23
- attr_reader :selected, :set, :ui #: untyped
24
- attr_writer :player_track, :player_index, :player_offset, :player_started_at
24
+ attr_reader :selected, :setlist, :ui #: untyped
25
+ attr_writer :player_track, :player_index, :player_offset, :player_started_at, :player_pid
25
26
 
26
- #: (Set set, String library_path) -> void
27
- def initialize(set, library_path)
28
- @set = set #: Set
27
+ #: (Setlist setlist, String library_path) -> void
28
+ def initialize(setlist, library_path)
29
+ @setlist = setlist #: Setlist
29
30
  @library_path = library_path #: String
31
+ Logger.configure(@library_path)
30
32
  @prompt = TTY::Prompt.new(interrupt: :exit, active_color: :red) #: untyped
31
33
  @ui = UI.new #: UI
32
- @selected = @set.tracks.empty? ? nil : 0 #: Integer?
34
+ @selected = @setlist.tracks.empty? ? nil : 0 #: Integer?
33
35
  @player_pid = nil #: Integer?
34
36
  @player_track = nil #: String?
35
37
  @player_index = nil #: Integer?
@@ -64,7 +66,8 @@ module Wavesync
64
66
 
65
67
  @track_bpms[path] = begin
66
68
  Audio.new(path).bpm
67
- rescue StandardError
69
+ rescue StandardError => e
70
+ Logger.log_error(e, call_site: 'SetlistEditor#track_bpm', arguments: { path: })
68
71
  nil
69
72
  end
70
73
  end
@@ -78,7 +81,8 @@ module Wavesync
78
81
 
79
82
  @track_durations[path] = begin
80
83
  Audio.new(path).duration
81
- rescue StandardError
84
+ rescue StandardError => e
85
+ Logger.log_error(e, call_site: 'SetlistEditor#track_duration', arguments: { path: })
82
86
  nil
83
87
  end
84
88
  end
@@ -99,7 +103,8 @@ module Wavesync
99
103
  else
100
104
  [] #: Array[Float]
101
105
  end
102
- rescue StandardError
106
+ rescue StandardError => e
107
+ Logger.log_error(e, call_site: 'SetlistEditor#track_cue_fractions', arguments: { path: })
103
108
  [] #: Array[Float]
104
109
  end
105
110
  end
@@ -249,22 +254,22 @@ module Wavesync
249
254
  end
250
255
 
251
256
  #: (?String title) -> void
252
- def render(title = "wavesync set #{@set.name}")
257
+ def render(title = "wavesync setlist #{@setlist.name}")
253
258
  buffer = StringIO.new
254
259
  $stdout = buffer
255
260
 
256
261
  header = @ui.color(title, :primary)
257
- total_duration = @set.tracks.sum { |track_path| track_duration(track_path) || 0.0 }
262
+ total_duration = @setlist.tracks.sum { |track_path| track_duration(track_path) || 0.0 }
258
263
 
259
- duration_widths = @set.tracks.map { |track_path| format_duration(track_duration(track_path))&.length || 0 }
264
+ duration_widths = @setlist.tracks.map { |track_path| format_duration(track_duration(track_path))&.length || 0 }
260
265
  duration_widths << (format_duration(total_duration)&.length || 0)
261
- player_duration = @player_index ? track_duration(@set.tracks[@player_index]) : nil
266
+ player_duration = @player_index ? track_duration(@setlist.tracks[@player_index]) : nil
262
267
  duration_widths << remaining_display(0.0, player_duration).length if player_duration
263
268
  duration_col_width = duration_widths.max || 0
264
269
 
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)
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)
268
273
  duration_part = @ui.color(format_duration(total_duration).to_s.rjust(duration_col_width), :secondary)
269
274
  summary = "#{track_count_part} #{duration_part}"
270
275
  gap = [terminal_width - visible_length(header) - visible_length(summary), 2].max
@@ -275,13 +280,13 @@ module Wavesync
275
280
 
276
281
  puts
277
282
 
278
- if @set.tracks.empty?
283
+ if @setlist.tracks.empty?
279
284
  puts @ui.color(' (no tracks)', :secondary)
280
285
  else
281
- @set.tracks.each_with_index do |track, i|
286
+ @setlist.tracks.each_with_index do |track, i|
282
287
  current_bpm = track_bpm(track)
283
288
  current_duration = track_duration(track)
284
- pitch_shift = pitch_shift_semitones(current_bpm, track_bpm(@set.tracks[i + 1]))
289
+ pitch_shift = pitch_shift_semitones(current_bpm, track_bpm(@setlist.tracks[i + 1]))
285
290
  render_track(i, relative_path(track), i == @selected, i == @player_index,
286
291
  bpm: current_bpm, pitch_shift: pitch_shift, duration: current_duration,
287
292
  duration_col_width: duration_col_width, cue_fractions: track_cue_fractions(track))
@@ -357,10 +362,10 @@ module Wavesync
357
362
  def handle_action(action)
358
363
  case action
359
364
  when :cursor_up
360
- @selected = [@selected - 1, 0].max unless @set.tracks.empty?
365
+ @selected = [@selected - 1, 0].max unless @setlist.tracks.empty?
361
366
  nil
362
367
  when :cursor_down
363
- @selected = [@selected + 1, @set.tracks.size - 1].min unless @set.tracks.empty?
368
+ @selected = [@selected + 1, @setlist.tracks.size - 1].min unless @setlist.tracks.empty?
364
369
  nil
365
370
  when :toggle_play
366
371
  toggle_playback
@@ -381,7 +386,7 @@ module Wavesync
381
386
  jump_to_next_cue
382
387
  nil
383
388
  when :quit
384
- @set.save
389
+ @setlist.save
385
390
  :quit
386
391
  end
387
392
  end
@@ -390,7 +395,7 @@ module Wavesync
390
395
  def toggle_playback
391
396
  return if @selected.nil?
392
397
 
393
- track = @set.tracks[@selected]
398
+ track = @setlist.tracks[@selected]
394
399
 
395
400
  if @player_track == track
396
401
  case @player_state
@@ -409,7 +414,7 @@ module Wavesync
409
414
 
410
415
  #: (String track, ?Numeric offset, ?player_index: Integer?) -> void
411
416
  def start_player(track, offset = 0, player_index: @selected)
412
- ffplay = FFMPEG.ffmpeg_binary.sub('ffmpeg', 'ffplay')
417
+ ffplay = Wavesync::FFMPEG.ffplay_binary
413
418
  args = [ffplay, '-nodisp', '-autoexit', '-loglevel', 'quiet', '-probesize', '32', '-analyzeduration', '0']
414
419
  args += ['-ss', offset.to_s] if offset.positive?
415
420
  args << track
@@ -444,9 +449,11 @@ module Wavesync
444
449
  def kill_player
445
450
  return unless @player_pid
446
451
 
452
+ player_pid = @player_pid
447
453
  Process.kill('TERM', @player_pid)
448
454
  @player_pid = nil
449
- rescue Errno::ESRCH
455
+ rescue Errno::ESRCH => e
456
+ Logger.log_error(e, call_site: 'SetlistEditor#kill_player', arguments: { player_pid: })
450
457
  @player_pid = nil
451
458
  end
452
459
 
@@ -464,6 +471,7 @@ module Wavesync
464
471
  def check_player
465
472
  return unless @player_pid
466
473
 
474
+ player_pid = @player_pid
467
475
  result = Process.waitpid(@player_pid, Process::WNOHANG)
468
476
  return unless result
469
477
 
@@ -473,7 +481,8 @@ module Wavesync
473
481
  @player_state = :stopped
474
482
  @player_offset = 0
475
483
  advance_and_play
476
- rescue Errno::ECHILD
484
+ rescue Errno::ECHILD => e
485
+ Logger.log_error(e, call_site: 'SetlistEditor#check_player', arguments: { player_pid: })
477
486
  @player_pid = nil
478
487
  @player_track = nil
479
488
  @player_index = nil
@@ -484,10 +493,10 @@ module Wavesync
484
493
 
485
494
  #: () -> void
486
495
  def advance_and_play
487
- return if @selected.nil? || @selected >= @set.tracks.size - 1
496
+ return if @selected.nil? || @selected >= @setlist.tracks.size - 1
488
497
 
489
498
  @selected += 1
490
- start_player(@set.tracks[@selected])
499
+ start_player(@setlist.tracks[@selected])
491
500
  end
492
501
 
493
502
  #: () -> Array[String]
@@ -505,43 +514,43 @@ module Wavesync
505
514
 
506
515
  choices = audio_files.map { |file| { name: relative_path(file), value: file } }
507
516
 
508
- render("wavesync set #{@set.name} — add track")
517
+ render("wavesync setlist #{@setlist.name} — add track")
509
518
  puts
510
519
  picked = @prompt.select('Select a track to add:', choices, cycle: true, filter: true, per_page: 20)
511
520
 
512
521
  insert_at = @selected.nil? ? 0 : @selected + 1
513
- @set.tracks.insert(insert_at, picked)
522
+ @setlist.tracks.insert(insert_at, picked)
514
523
  @selected = insert_at
515
524
  end
516
525
 
517
526
  #: () -> void
518
527
  def remove_track
519
- return if @set.tracks.empty? || @selected.nil?
528
+ return if @setlist.tracks.empty? || @selected.nil?
520
529
 
521
- stop_playback if @player_track == @set.tracks[@selected]
522
- @set.remove_track(@selected)
523
- @selected = if @set.tracks.empty?
530
+ stop_playback if @player_track == @setlist.tracks[@selected]
531
+ @setlist.remove_track(@selected)
532
+ @selected = if @setlist.tracks.empty?
524
533
  nil
525
534
  else
526
- [@selected, @set.tracks.size - 1].min
535
+ [@selected, @setlist.tracks.size - 1].min
527
536
  end
528
537
  end
529
538
 
530
539
  #: (Symbol direction) -> void
531
540
  def move_track(direction)
532
- return if @set.tracks.size < 2 || @selected.nil?
541
+ return if @setlist.tracks.size < 2 || @selected.nil?
533
542
 
534
543
  if direction == :up
535
- @set.move_up(@selected)
544
+ @setlist.move_up(@selected)
536
545
  @selected = [@selected - 1, 0].max
537
546
  else
538
- @set.move_down(@selected)
539
- @selected = [@selected + 1, @set.tracks.size - 1].min
547
+ @setlist.move_down(@selected)
548
+ @selected = [@selected + 1, @setlist.tracks.size - 1].min
540
549
  end
541
550
  end
542
551
 
543
- public :handle_action, :advance_and_play, :display_name, :relative_path,
544
- :format_duration, :playback_elapsed, :visible_length, :playback_bar,
545
- :selected, :set, :ui
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
546
555
  end
547
556
  end
@@ -11,7 +11,7 @@ module Wavesync
11
11
 
12
12
  seconds_per_bar = BEATS_PER_BAR * 60.0 / bpm.to_f
13
13
  track_bars = (duration_seconds / seconds_per_bar).round(6)
14
- target_bars = (track_bars / bar_multiple.to_f).ceil * bar_multiple.to_f
14
+ target_bars = next_power_of_two_multiple(track_bars, bar_multiple)
15
15
 
16
16
  padding = (target_bars * seconds_per_bar) - duration_seconds
17
17
  padding < 0.001 ? 0 : padding
@@ -23,8 +23,16 @@ module Wavesync
23
23
 
24
24
  seconds_per_bar = BEATS_PER_BAR * 60.0 / bpm.to_f
25
25
  original_bars = (duration_seconds / seconds_per_bar).round
26
- target_bars = ((original_bars.to_f / bar_multiple).ceil * bar_multiple.to_f).to_i
26
+ target_bars = next_power_of_two_multiple(original_bars, bar_multiple).to_i
27
27
  [original_bars, target_bars]
28
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
29
37
  end
30
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