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,25 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ require 'shellwords'
5
+
6
+ module Wavesync
7
+ module PythonVenv
8
+ PYTHON_PATH = File.expand_path('~/.wavesync-venv/bin/python3').freeze
9
+
10
+ #: () -> bool
11
+ def self.available?
12
+ File.executable?(PYTHON_PATH)
13
+ end
14
+
15
+ #: () -> bool?
16
+ def self.essentia_available?
17
+ available? && system("#{PYTHON_PATH} -c 'import essentia' > /dev/null 2>&1")
18
+ end
19
+
20
+ #: (String script, String file_path) -> String
21
+ def self.run_script(script, file_path)
22
+ `#{PYTHON_PATH} -c #{Shellwords.escape(script)} #{Shellwords.escape(file_path)} 2>/dev/null`
23
+ end
24
+ end
25
+ end
@@ -1,20 +1,27 @@
1
1
  # frozen_string_literal: true
2
+ # rbs_inline: enabled
2
3
 
3
4
  require 'fileutils'
4
- require 'streamio-ffmpeg'
5
+ require 'pathname'
6
+ require 'tempfile'
7
+ require_relative 'logger'
5
8
  require_relative 'file_converter'
6
9
 
7
10
  module Wavesync
8
11
  class Scanner
12
+ #: (String source_library_path) -> void
9
13
  def initialize(source_library_path)
10
- @source_library_path = File.expand_path(source_library_path)
11
- @audio_files = find_audio_files
12
- @ui = Wavesync::UI.new
13
- @converter = FileConverter.new
14
- FFMPEG.logger = Logger.new(File::NULL)
14
+ @source_library_path = File.expand_path(source_library_path) #: String
15
+ Logger.configure(@source_library_path)
16
+ @audio_files = find_audio_files #: Array[String]
17
+ @ui = Wavesync::UI.new #: UI
18
+ @converter = FileConverter.new #: FileConverter
15
19
  end
16
20
 
17
- 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)
18
25
  path_resolver = PathResolver.new(@source_library_path, target_library_path, device)
19
26
  skipped_count = 0
20
27
  conversion_count = 0
@@ -22,58 +29,124 @@ module Wavesync
22
29
 
23
30
  @audio_files.each_with_index do |file, index|
24
31
  audio = Audio.new(file)
32
+ bpm = audio.bpm
25
33
 
26
34
  source_format = audio.format
27
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?
28
37
 
29
- padding_seconds = nil
30
- original_bars = nil
31
- target_bars = nil
38
+ padding_seconds = nil #: Numeric?
39
+ original_bars = nil #: Integer?
40
+ target_bars = nil #: Integer?
32
41
  if pad && device.bar_multiple
33
- padding_seconds = TrackPadding.compute(audio.duration, audio.bpm, device.bar_multiple)
34
- 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?
35
44
  padding_seconds = nil if padding_seconds.zero?
36
45
  end
37
46
 
38
- @ui.bpm(audio.bpm, original_bars: original_bars, target_bars: target_bars)
47
+ @ui.bpm(bpm, original_bars: original_bars, target_bars: target_bars)
39
48
  @ui.file_progress(file)
40
49
 
41
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'
42
54
  converted = @converter.convert(audio, file, path_resolver, source_format, target_format,
43
- padding_seconds: padding_seconds) do
44
- @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)
45
61
  end
46
- 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
47
65
  else
48
- copied = copy_file(audio, file, path_resolver)
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
85
+ end
86
+ final_target_path = target_path
49
87
  @ui.copy(source_format)
50
- target_path = path_resolver.resolve(file, audio)
51
- end
52
-
53
- if (copied || converted) && device.bpm_source == :acid_chunk && audio.bpm && target_path.extname.downcase == '.wav'
54
- temp_path = "#{target_path}.tmp"
55
- AcidChunk.write_bpm(target_path.to_s, temp_path, audio.bpm)
56
- FileUtils.mv(temp_path, target_path.to_s)
57
88
  end
58
89
 
59
90
  if !copied && !converted
60
91
  skipped_count += 1
61
- @ui.skip
92
+ @ui.skip(staged: staged)
62
93
  end
63
94
 
64
95
  conversion_count += 1 if converted
65
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
66
102
  end
67
103
 
68
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)
134
+ end
135
+
136
+ puts
137
+ system('sync')
138
+ elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
139
+ Logger.log_run_time(elapsed)
69
140
  end
70
141
 
71
142
  private
72
143
 
144
+ #: () -> Array[String]
73
145
  def find_audio_files
74
146
  Audio.find_all(@source_library_path)
75
147
  end
76
148
 
149
+ #: (Audio audio, String source_file_path, PathResolver path_resolver) -> bool
77
150
  def copy_file(audio, source_file_path, path_resolver)
78
151
  target_path = path_resolver.resolve(source_file_path, audio)
79
152
 
@@ -88,10 +161,53 @@ module Wavesync
88
161
  end
89
162
  end
90
163
 
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)
176
+ end
177
+
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)
187
+ end
188
+
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?}]
190
+ def rescale_cue_points(cue_points, source_sample_rate, target_sample_rate)
191
+ return cue_points if source_sample_rate == target_sample_rate || source_sample_rate.nil? || target_sample_rate.nil?
192
+
193
+ cue_points.map do |cue_point|
194
+ cue_point.merge(sample_offset: (cue_point[:sample_offset] * target_sample_rate / source_sample_rate.to_f).round) #: {identifier: Integer, sample_offset: Integer, label: String?}
195
+ end
196
+ end
197
+
198
+ #: (String source, Pathname target) -> void
91
199
  def safe_copy(source, target)
92
200
  FileUtils.install(source, target)
93
- rescue Errno::ENOENT
94
- 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 })
95
211
  end
96
212
  end
97
213
  end
@@ -1,29 +1,36 @@
1
1
  # frozen_string_literal: true
2
+ # rbs_inline: enabled
2
3
 
3
4
  require 'yaml'
4
5
  require 'fileutils'
5
6
 
6
7
  module Wavesync
7
- class Set
8
- SETS_FOLDER = '.sets'
8
+ class Setlist
9
+ SETLISTS_FOLDER = '.setlists'
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
 
12
- def self.sets_path(library_path)
13
- File.join(library_path, SETS_FOLDER)
15
+ #: (String library_path) -> String
16
+ def self.setlists_path(library_path)
17
+ File.join(library_path, SETLISTS_FOLDER)
14
18
  end
15
19
 
16
- def self.set_path(library_path, name)
17
- File.join(sets_path(library_path), "#{name}.yml")
20
+ #: (String library_path, String name) -> String
21
+ def self.setlist_path(library_path, name)
22
+ File.join(setlists_path(library_path), "#{name}.yml")
18
23
  end
19
24
 
25
+ #: (String library_path, String name) -> Setlist
20
26
  def self.load(library_path, name)
21
- data = YAML.load_file(set_path(library_path, name))
27
+ data = YAML.load_file(setlist_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[Setlist]
25
32
  def self.all(library_path)
26
- path = sets_path(library_path)
33
+ path = setlists_path(library_path)
27
34
  return [] unless Dir.exist?(path)
28
35
 
29
36
  Dir.glob(File.join(path, '*.yml')).map do |file|
@@ -32,48 +39,57 @@ 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
- File.exist?(set_path(library_path, name))
44
+ File.exist?(setlist_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
- FileUtils.mkdir_p(self.class.sets_path(@library_path))
67
- 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)
68
82
  end
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