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,144 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ require 'open3'
5
+ require_relative 'ffmpeg/probe'
6
+
7
+ module Wavesync
8
+ class FFMPEG
9
+ class Error < StandardError; end
10
+
11
+ #: () -> String
12
+ def self.binary
13
+ @binary ||= locate_binary('ffmpeg')
14
+ end
15
+
16
+ #: () -> String
17
+ def self.ffplay_binary
18
+ @ffplay_binary ||= binary.sub('ffmpeg', 'ffplay')
19
+ end
20
+
21
+ #: () -> void
22
+ def initialize
23
+ @inputs = [] #: Array[Hash[Symbol, String?]]
24
+ @options = {} #: Hash[Symbol, untyped]
25
+ @metadata_pairs = [] #: Array[[String, String]]
26
+ end
27
+
28
+ #: (String source, ?format: String?) -> self
29
+ def input(source, format: nil)
30
+ @inputs << { source: source, format: format }
31
+ self
32
+ end
33
+
34
+ #: (String codec) -> self
35
+ def audio_codec(codec)
36
+ @options[:audio_codec] = codec
37
+ self
38
+ end
39
+
40
+ #: (Integer rate) -> self
41
+ def sample_rate(rate)
42
+ @options[:sample_rate] = rate
43
+ self
44
+ end
45
+
46
+ #: (String bitrate) -> self
47
+ def audio_bitrate(bitrate)
48
+ @options[:audio_bitrate] = bitrate
49
+ self
50
+ end
51
+
52
+ #: (String filter) -> self
53
+ def audio_filter(filter)
54
+ @options[:audio_filter] = filter
55
+ self
56
+ end
57
+
58
+ #: (String graph) -> self
59
+ def filter_complex(graph)
60
+ @options[:filter_complex] = graph
61
+ self
62
+ end
63
+
64
+ #: (String format) -> self
65
+ def output_format(format)
66
+ @options[:output_format] = format
67
+ self
68
+ end
69
+
70
+ #: (Numeric seconds) -> self
71
+ def duration(seconds)
72
+ @options[:duration] = seconds
73
+ self
74
+ end
75
+
76
+ #: () -> self
77
+ def copy_streams
78
+ @options[:copy_streams] = true
79
+ self
80
+ end
81
+
82
+ #: (Integer source_index) -> self
83
+ def map_metadata(source_index)
84
+ @options[:map_metadata] = source_index
85
+ self
86
+ end
87
+
88
+ #: (String key, String value) -> self
89
+ def metadata(key, value)
90
+ @metadata_pairs << [key, value]
91
+ self
92
+ end
93
+
94
+ #: (String flags) -> self
95
+ def movflags(flags)
96
+ @options[:movflags] = flags
97
+ self
98
+ end
99
+
100
+ #: (Integer version) -> self
101
+ def write_id3v2(version)
102
+ @options[:write_id3v2] = version
103
+ self
104
+ end
105
+
106
+ #: (String output_path) -> void
107
+ def run(output_path)
108
+ args = ['-y']
109
+
110
+ @inputs.each do |input|
111
+ args += ['-f', input[:format]] if input[:format]
112
+ args += ['-i', input[:source]]
113
+ end
114
+
115
+ args += ['-loglevel', 'warning', '-nostats', '-hide_banner']
116
+ args += ['-c', 'copy'] if @options[:copy_streams]
117
+ args += ['-map_metadata', @options[:map_metadata].to_s] if @options.key?(:map_metadata)
118
+ @metadata_pairs.each { |key, value| args += ['-metadata', "#{key}=#{value}"] }
119
+ args += ['-filter_complex', @options[:filter_complex]] if @options[:filter_complex]
120
+ args += ['-af', @options[:audio_filter]] if @options[:audio_filter]
121
+ args += ['-acodec', @options[:audio_codec]] if @options[:audio_codec]
122
+ args += ['-b:a', @options[:audio_bitrate]] if @options[:audio_bitrate]
123
+ args += ['-ar', @options[:sample_rate].to_s] if @options[:sample_rate]
124
+ args += ['-t', @options[:duration].to_s] if @options[:duration]
125
+ args += ['-movflags', @options[:movflags]] if @options[:movflags]
126
+ args += ['-write_id3v2', @options[:write_id3v2].to_s] if @options[:write_id3v2]
127
+ args += ['-f', @options[:output_format]] if @options[:output_format]
128
+ args << output_path
129
+
130
+ _stdout, stderr, status = Open3.capture3(self.class.binary, *args)
131
+ raise Error, "ffmpeg failed: #{stderr}" unless status.success?
132
+ end
133
+
134
+ private
135
+
136
+ #: (String binary_name) -> String
137
+ def self.locate_binary(binary_name)
138
+ stdout, _stderr, _status = Open3.capture3('which', binary_name)
139
+ path = stdout.strip
140
+ path.empty? ? binary_name : path
141
+ end
142
+ private_class_method :locate_binary
143
+ end
144
+ end
@@ -1,10 +1,12 @@
1
1
  # frozen_string_literal: true
2
+ # rbs_inline: enabled
2
3
 
3
4
  module Wavesync
4
5
  class FileConverter
5
6
  DURATION_TOLERANCE_SECONDS = 0.5
6
7
 
7
- def convert(audio, source_file_path, path_resolver, source_format, target_format, padding_seconds: nil, &before_transcode)
8
+ #: (Audio audio, String source_file_path, PathResolver path_resolver, AudioFormat source_format, AudioFormat target_format, ?padding_seconds: Numeric?, ?before_transcode: (^() -> void)?, ?metadata: Hash[String, String], ?mp3_bitrate: Integer) ?{ (String) -> void } -> bool
9
+ def convert(audio, source_file_path, path_resolver, source_format, target_format, padding_seconds: nil, before_transcode: nil, metadata: {}, mp3_bitrate: 192, &post_transcode)
8
10
  needs_format_conversion = target_format.file_type || target_format.sample_rate || target_format.bit_depth
9
11
  return false unless needs_format_conversion || padding_seconds&.positive?
10
12
 
@@ -32,7 +34,10 @@ module Wavesync
32
34
  audio.transcode(target_path.to_s, target_sample_rate: target_format.sample_rate,
33
35
  target_file_type: target_format.file_type,
34
36
  target_bit_depth: target_format.bit_depth || source_format.bit_depth,
35
- padding_seconds: padding_seconds)
37
+ padding_seconds: padding_seconds,
38
+ metadata: metadata,
39
+ target_bitrate: mp3_bitrate,
40
+ &post_transcode)
36
41
 
37
42
  true
38
43
  end
@@ -0,0 +1,333 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'ffi'
4
+
5
+ module Wavesync
6
+ class Libmtp
7
+ class Error < StandardError; end
8
+ class DeviceNotFound < Error; end
9
+
10
+ INSTALL_HINT = 'Install libmtp with `brew install libmtp`'
11
+
12
+ DeviceFile = Data.define(:id, :filename, :size, :parent_id, :storage_id)
13
+ DeviceFolder = Data.define(:folder_id, :name, :parent_id, :storage_id)
14
+
15
+ LIBRARY_PATHS = [
16
+ '/usr/local/opt/libmtp/lib/libmtp.dylib',
17
+ '/opt/homebrew/opt/libmtp/lib/libmtp.dylib',
18
+ 'libmtp.dylib',
19
+ 'libmtp.so.9',
20
+ 'libmtp.so'
21
+ ].freeze
22
+
23
+ module FFIBindings
24
+ extend FFI::Library
25
+
26
+ begin
27
+ ffi_lib LIBRARY_PATHS
28
+ rescue LoadError
29
+ # Library missing; instance methods raise via ensure_loaded! when used.
30
+ end
31
+
32
+ LIBMTP_STORAGE_SORTBY_NOTSORTED = 0
33
+
34
+ LIBMTP_FILETYPE_WAV = 1
35
+ LIBMTP_FILETYPE_MP3 = 2
36
+ LIBMTP_FILETYPE_OGG = 4
37
+ LIBMTP_FILETYPE_AAC = 30
38
+ LIBMTP_FILETYPE_FLAC = 32
39
+ LIBMTP_FILETYPE_M4A = 34
40
+ LIBMTP_FILETYPE_UNKNOWN = 44
41
+
42
+ class DeviceEntry < FFI::Struct
43
+ layout :vendor, :pointer,
44
+ :vendor_id, :uint16,
45
+ :product, :pointer,
46
+ :product_id, :uint16,
47
+ :device_flags, :uint32
48
+ end
49
+
50
+ class RawDevice < FFI::Struct
51
+ layout :device_entry, DeviceEntry,
52
+ :bus_location, :uint32,
53
+ :devnum, :uint8
54
+ end
55
+
56
+ class Folder < FFI::Struct
57
+ layout :folder_id, :uint32,
58
+ :parent_id, :uint32,
59
+ :storage_id, :uint32,
60
+ :name, :pointer,
61
+ :sibling, :pointer,
62
+ :child, :pointer
63
+ end
64
+
65
+ class File < FFI::Struct
66
+ layout :item_id, :uint32,
67
+ :parent_id, :uint32,
68
+ :storage_id, :uint32,
69
+ :filename, :pointer,
70
+ :filesize, :uint64,
71
+ :modificationdate, :long,
72
+ :filetype, :int,
73
+ :next, :pointer
74
+ end
75
+
76
+ class DeviceStorage < FFI::Struct
77
+ layout :id, :uint32,
78
+ :storage_type, :uint16,
79
+ :filesystem_type, :uint16,
80
+ :access_capability, :uint16,
81
+ :max_capacity, :uint64,
82
+ :free_space_in_bytes, :uint64,
83
+ :free_space_in_objects, :uint64,
84
+ :storage_description, :pointer,
85
+ :volume_identifier, :pointer,
86
+ :next, :pointer,
87
+ :prev, :pointer
88
+ end
89
+
90
+ class MtpDevice < FFI::Struct
91
+ layout :object_bitsize, :uint8,
92
+ :params, :pointer,
93
+ :usbinfo, :pointer,
94
+ :storage, :pointer
95
+ end
96
+
97
+ def self.bind!
98
+ return if @bound
99
+
100
+ attach_function :LIBMTP_Init, [], :void
101
+ attach_function :LIBMTP_Detect_Raw_Devices, %i[pointer pointer], :int
102
+ attach_function :LIBMTP_Open_Raw_Device, [:pointer], :pointer
103
+ attach_function :LIBMTP_Get_Storage, %i[pointer int], :int
104
+ attach_function :LIBMTP_Get_Folder_List_For_Storage, %i[pointer uint32], :pointer
105
+ attach_function :LIBMTP_Get_Filelisting_With_Callback, %i[pointer pointer pointer], :pointer
106
+ attach_function :LIBMTP_Send_File_From_File, %i[pointer string pointer pointer pointer], :int
107
+ attach_function :LIBMTP_Create_Folder, %i[pointer pointer uint32 uint32], :uint32
108
+ attach_function :LIBMTP_Delete_Object, %i[pointer uint32], :int
109
+ attach_function :LIBMTP_Get_File_To_File, %i[pointer uint32 string pointer pointer], :int
110
+ attach_function :LIBMTP_destroy_folder_t, [:pointer], :void
111
+ attach_function :LIBMTP_destroy_file_t, [:pointer], :void
112
+ attach_function :LIBMTP_Release_Device, [:pointer], :void
113
+ attach_function :LIBMTP_FreeMemory, [:pointer], :void
114
+ attach_function :LIBMTP_Dump_Errorstack, [:pointer], :void
115
+ attach_function :LIBMTP_Clear_Errorstack, [:pointer], :void
116
+ @bound = true
117
+ end
118
+ end
119
+
120
+ @library_initialized = false
121
+
122
+ class << self
123
+ #: () -> void
124
+ def init_library!
125
+ return if @library_initialized
126
+
127
+ FFIBindings.bind!
128
+ FFIBindings.LIBMTP_Init
129
+ @library_initialized = true
130
+ end
131
+ end
132
+
133
+ #: () -> void
134
+ def initialize
135
+ @device_handle = nil #: FFI::Pointer?
136
+ @raw_devices_pointer = nil #: FFI::Pointer?
137
+ end
138
+
139
+ #: () -> bool
140
+ def detected?
141
+ ensure_library_loaded!
142
+ raw_devices_pointer_pointer = FFI::MemoryPointer.new(:pointer)
143
+ count_pointer = FFI::MemoryPointer.new(:int)
144
+ result = FFIBindings.LIBMTP_Detect_Raw_Devices(raw_devices_pointer_pointer, count_pointer)
145
+ count = count_pointer.read_int
146
+ raw_devices_address = raw_devices_pointer_pointer.read_pointer
147
+ FFIBindings.LIBMTP_FreeMemory(raw_devices_address) unless raw_devices_address.null?
148
+ result.zero? && count.positive?
149
+ rescue Error
150
+ false
151
+ end
152
+
153
+ #: () -> Array[DeviceFile]
154
+ def files
155
+ ensure_open!
156
+ list_head = FFIBindings.LIBMTP_Get_Filelisting_With_Callback(@device_handle, nil, nil)
157
+ result = walk_files(list_head)
158
+ FFIBindings.LIBMTP_destroy_file_t(list_head) unless list_head.null?
159
+ result
160
+ end
161
+
162
+ #: () -> Array[DeviceFolder]
163
+ def folders
164
+ ensure_open!
165
+ mtp_device = FFIBindings::MtpDevice.new(@device_handle)
166
+ result = [] #: Array[DeviceFolder]
167
+ storage_node = mtp_device[:storage]
168
+ until storage_node.null?
169
+ storage = FFIBindings::DeviceStorage.new(storage_node)
170
+ storage_id = storage[:id]
171
+ folder_root = FFIBindings.LIBMTP_Get_Folder_List_For_Storage(@device_handle, storage_id)
172
+ walk_folders(folder_root, storage_id, result) unless folder_root.null?
173
+ FFIBindings.LIBMTP_destroy_folder_t(folder_root) unless folder_root.null?
174
+ storage_node = storage[:next]
175
+ end
176
+ result
177
+ end
178
+
179
+ #: (local_path: String, remote_filename: String, parent_id: Integer, storage_id: Integer) -> void
180
+ def send_file(local_path:, remote_filename:, parent_id:, storage_id:)
181
+ ensure_open!
182
+ filesize = ::File.size(local_path)
183
+ filename_pointer = FFI::MemoryPointer.from_string(remote_filename)
184
+ file_metadata = FFIBindings::File.new
185
+ file_metadata[:item_id] = 0
186
+ file_metadata[:parent_id] = parent_id
187
+ file_metadata[:storage_id] = storage_id
188
+ file_metadata[:filename] = filename_pointer
189
+ file_metadata[:filesize] = filesize
190
+ file_metadata[:modificationdate] = 0
191
+ file_metadata[:filetype] = filetype_for(remote_filename)
192
+ result = FFIBindings.LIBMTP_Send_File_From_File(@device_handle, local_path, file_metadata.to_ptr, nil, nil)
193
+ raise_on_error!(result, "send_file failed for #{local_path}")
194
+ end
195
+
196
+ #: (name: String, parent_id: Integer, storage_id: Integer) -> Integer
197
+ def create_folder(name:, parent_id:, storage_id:)
198
+ ensure_open!
199
+ name_pointer = FFI::MemoryPointer.from_string(name)
200
+ new_id = FFIBindings.LIBMTP_Create_Folder(@device_handle, name_pointer, parent_id, storage_id)
201
+ raise Error, "create_folder failed for #{name.inspect}" if new_id.zero?
202
+
203
+ new_id
204
+ end
205
+
206
+ #: (id: Integer) -> void
207
+ def delete_file(id:)
208
+ ensure_open!
209
+ result = FFIBindings.LIBMTP_Delete_Object(@device_handle, id)
210
+ raise_on_error!(result, "delete_file failed for id #{id}")
211
+ end
212
+
213
+ #: (id: Integer, local_path: String) -> void
214
+ def get_file(id:, local_path:)
215
+ ensure_open!
216
+ result = FFIBindings.LIBMTP_Get_File_To_File(@device_handle, id, local_path, nil, nil)
217
+ raise_on_error!(result, "get_file failed for id #{id}")
218
+ end
219
+
220
+ #: () -> void
221
+ def close!
222
+ device_handle = @device_handle
223
+ raw_devices_pointer = @raw_devices_pointer
224
+ FFIBindings.LIBMTP_Release_Device(device_handle) if device_handle
225
+ FFIBindings.LIBMTP_FreeMemory(raw_devices_pointer) if raw_devices_pointer && !raw_devices_pointer.null?
226
+ @device_handle = nil
227
+ @raw_devices_pointer = nil
228
+ end
229
+
230
+ private
231
+
232
+ #: () -> void
233
+ def ensure_library_loaded!
234
+ self.class.init_library!
235
+ rescue LoadError, FFI::NotFoundError => e
236
+ raise Error, "#{e.message}. #{INSTALL_HINT}"
237
+ end
238
+
239
+ #: () -> void
240
+ def ensure_open!
241
+ return if @device_handle
242
+
243
+ ensure_library_loaded!
244
+ raw_devices_pointer_pointer = FFI::MemoryPointer.new(:pointer)
245
+ count_pointer = FFI::MemoryPointer.new(:int)
246
+ result = FFIBindings.LIBMTP_Detect_Raw_Devices(raw_devices_pointer_pointer, count_pointer)
247
+ device_count = count_pointer.read_int
248
+ raise DeviceNotFound, 'No MTP device detected. Connect the device and unmount it from any other app.' unless result.zero? && device_count.positive?
249
+
250
+ raw_devices_pointer = raw_devices_pointer_pointer.read_pointer
251
+ first_raw_device = FFIBindings::RawDevice.new(raw_devices_pointer)
252
+ device_handle = FFIBindings.LIBMTP_Open_Raw_Device(first_raw_device.to_ptr)
253
+ if device_handle.null?
254
+ FFIBindings.LIBMTP_FreeMemory(raw_devices_pointer)
255
+ raise Error, 'Could not open MTP device'
256
+ end
257
+
258
+ FFIBindings.LIBMTP_Clear_Errorstack(device_handle)
259
+ get_storage_result = FFIBindings.LIBMTP_Get_Storage(device_handle, FFIBindings::LIBMTP_STORAGE_SORTBY_NOTSORTED)
260
+ unless get_storage_result.zero?
261
+ FFIBindings.LIBMTP_Dump_Errorstack(device_handle)
262
+ FFIBindings.LIBMTP_Release_Device(device_handle)
263
+ FFIBindings.LIBMTP_FreeMemory(raw_devices_pointer)
264
+ raise Error, 'LIBMTP_Get_Storage failed'
265
+ end
266
+
267
+ @device_handle = device_handle
268
+ @raw_devices_pointer = raw_devices_pointer
269
+ end
270
+
271
+ #: (FFI::Pointer list_head) -> Array[DeviceFile]
272
+ def walk_files(list_head)
273
+ result = [] #: Array[DeviceFile]
274
+ node = list_head
275
+ until node.null?
276
+ file = FFIBindings::File.new(node)
277
+ filename_pointer = file[:filename]
278
+ filename = filename_pointer.null? ? '' : filename_pointer.read_string.force_encoding(Encoding::UTF_8)
279
+ result << DeviceFile.new(
280
+ id: file[:item_id],
281
+ filename: filename,
282
+ size: file[:filesize],
283
+ parent_id: file[:parent_id],
284
+ storage_id: file[:storage_id]
285
+ )
286
+ node = file[:next]
287
+ end
288
+ result
289
+ end
290
+
291
+ #: (FFI::Pointer node, Integer storage_id, Array[DeviceFolder] accumulator) -> void
292
+ def walk_folders(node, storage_id, accumulator)
293
+ return if node.null?
294
+
295
+ folder = FFIBindings::Folder.new(node)
296
+ name_pointer = folder[:name]
297
+ name = name_pointer.null? ? '' : name_pointer.read_string.force_encoding(Encoding::UTF_8)
298
+ accumulator << DeviceFolder.new(
299
+ folder_id: folder[:folder_id],
300
+ name: name,
301
+ parent_id: folder[:parent_id],
302
+ storage_id: storage_id
303
+ )
304
+ walk_folders(folder[:child], storage_id, accumulator)
305
+ walk_folders(folder[:sibling], storage_id, accumulator)
306
+ end
307
+
308
+ #: (String filename) -> Integer
309
+ def filetype_for(filename)
310
+ case ::File.extname(filename).downcase
311
+ when '.wav' then FFIBindings::LIBMTP_FILETYPE_WAV
312
+ when '.mp3' then FFIBindings::LIBMTP_FILETYPE_MP3
313
+ when '.flac' then FFIBindings::LIBMTP_FILETYPE_FLAC
314
+ when '.m4a' then FFIBindings::LIBMTP_FILETYPE_M4A
315
+ when '.aac' then FFIBindings::LIBMTP_FILETYPE_AAC
316
+ when '.ogg' then FFIBindings::LIBMTP_FILETYPE_OGG
317
+ else FFIBindings::LIBMTP_FILETYPE_UNKNOWN
318
+ end
319
+ end
320
+
321
+ #: (Integer result, String context) -> void
322
+ def raise_on_error!(result, context)
323
+ return if result.zero?
324
+
325
+ device_handle = @device_handle
326
+ if device_handle
327
+ FFIBindings.LIBMTP_Dump_Errorstack(device_handle)
328
+ FFIBindings.LIBMTP_Clear_Errorstack(device_handle)
329
+ end
330
+ raise Error, context
331
+ end
332
+ end
333
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ module Wavesync
5
+ module Logger
6
+ #: (String? library_path) -> void
7
+ def self.configure(library_path)
8
+ @log_path = library_path ? File.join(library_path, 'wavesync.log') : nil
9
+ @invocation_args = nil unless library_path
10
+ end
11
+
12
+ #: () -> String?
13
+ def self.log_path
14
+ @log_path
15
+ end
16
+
17
+ #: (Array[String] args) -> void
18
+ def self.capture_invocation(args)
19
+ @invocation_args = args
20
+ end
21
+
22
+ #: () -> void
23
+ def self.log_invocation
24
+ path = log_path
25
+ return unless path && @invocation_args
26
+
27
+ invocation = (['wavesync'] + @invocation_args).join(' ')
28
+ entry = "---\n[#{timestamp}] #{invocation}\n"
29
+ File.open(path, 'a') { |file| file.write(entry) }
30
+ @invocation_args = nil
31
+ end
32
+
33
+ #: (Exception error, call_site: String, arguments: Hash[Symbol, untyped]) -> void
34
+ def self.log_error(error, call_site:, arguments: {})
35
+ path = log_path
36
+ return unless path
37
+
38
+ args_str = arguments.map { |key, value| "#{key}: #{value.inspect}" }.join(', ')
39
+ entry = "[#{timestamp}] #{call_site}(#{args_str}) raised #{error.class}: #{error.message}\n"
40
+ File.open(path, 'a') { |file| file.write(entry) }
41
+ end
42
+
43
+ #: (String message) -> void
44
+ def self.log_event(message)
45
+ path = log_path
46
+ return unless path
47
+
48
+ entry = "[#{timestamp}] #{message}\n"
49
+ File.open(path, 'a') { |file| file.write(entry) }
50
+ end
51
+
52
+ #: (Float seconds) -> void
53
+ def self.log_run_time(seconds)
54
+ path = log_path
55
+ return unless path
56
+
57
+ entry = "[#{timestamp}] Run time: #{format_duration(seconds)}\n"
58
+ File.open(path, 'a') { |file| file.write(entry) }
59
+ end
60
+
61
+ #: () -> String
62
+ def self.timestamp
63
+ Time.now.strftime('%Y-%m-%d %H:%M:%S')
64
+ end
65
+ private_class_method :timestamp
66
+
67
+ #: (Float seconds) -> String
68
+ def self.format_duration(seconds)
69
+ total_seconds = seconds.to_i
70
+ hours = total_seconds / 3600
71
+ minutes = (total_seconds % 3600) / 60
72
+ secs = total_seconds % 60
73
+
74
+ if hours.positive?
75
+ "#{hours}h #{minutes}m #{secs}s"
76
+ elsif minutes.positive?
77
+ "#{minutes}m #{secs}s"
78
+ else
79
+ "#{secs}s"
80
+ end
81
+ end
82
+ private_class_method :format_duration
83
+ end
84
+ end
@@ -1,26 +1,34 @@
1
1
  # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ require 'pathname'
2
5
 
3
6
  module Wavesync
4
7
  class PathResolver
5
8
  BPM_PATTERN = / \d+ bpm/
6
9
 
10
+ #: (String source_library_path, String target_library_path, Device device) -> void
7
11
  def initialize(source_library_path, target_library_path, device)
8
- @source_library_path = Pathname(File.expand_path(source_library_path))
9
- @target_library_path = Pathname(File.expand_path(target_library_path))
10
- @device = device
12
+ @source_library_path = Pathname(File.expand_path(source_library_path)) #: Pathname
13
+ @target_library_path = Pathname(File.expand_path(target_library_path)) #: Pathname
14
+ @device = device #: Device
11
15
  end
12
16
 
17
+ #: (String source_file_path, Audio audio, ?target_file_type: String?) -> Pathname
13
18
  def resolve(source_file_path, audio, target_file_type: nil)
14
19
  relative_path = Pathname(source_file_path).relative_path_from(@source_library_path)
15
20
  target_path = @target_library_path.join(relative_path)
16
21
 
17
22
  target_path = target_path.sub_ext(".#{target_file_type}") if target_file_type
18
23
 
19
- target_path = add_bpm_to_filename(target_path, audio.bpm) if @device.bpm_source == :filename && audio.bpm
24
+ bpm = audio.bpm
25
+ target_path = add_bpm_to_filename(target_path, bpm) if @device.bpm_source == :filename && bpm
20
26
 
21
- target_path
27
+ target_path = strip_unsupported_characters(target_path)
28
+ uppercase_relative_path(target_path)
22
29
  end
23
30
 
31
+ #: (Pathname target_path, Audio audio) -> Array[Pathname]
24
32
  def find_files_to_cleanup(target_path, audio)
25
33
  return [] unless @device.bpm_source == :filename && audio.bpm
26
34
 
@@ -29,11 +37,12 @@ module Wavesync
29
37
 
30
38
  pattern = target_path.dirname.join("#{basename}{, * bpm}#{ext}")
31
39
  Dir.glob(pattern.to_s).map { |f| Pathname(f) }
32
- .reject { |path| path == target_path }
40
+ .reject { |path| File.identical?(path.to_s, target_path.to_s) }
33
41
  end
34
42
 
35
43
  private
36
44
 
45
+ #: (Pathname path, String | Integer bpm) -> Pathname
37
46
  def add_bpm_to_filename(path, bpm)
38
47
  ext = path.extname
39
48
  basename = path.basename(ext).to_s
@@ -44,6 +53,23 @@ module Wavesync
44
53
  path.dirname.join(new_basename)
45
54
  end
46
55
 
56
+ #: (Pathname path) -> Pathname
57
+ def strip_unsupported_characters(path)
58
+ return path if @device.unsupported_characters.empty?
59
+
60
+ Pathname(path.to_s.delete(@device.unsupported_characters.join))
61
+ end
62
+
63
+ #: (Pathname path) -> Pathname
64
+ def uppercase_relative_path(path)
65
+ return path unless @device.uppercase_paths
66
+
67
+ relative = path.relative_path_from(@target_library_path)
68
+ uppercased = relative.each_filename.map(&:upcase).join('/')
69
+ @target_library_path.join(uppercased)
70
+ end
71
+
72
+ #: (Pathname path) -> Pathname
47
73
  def remove_bpm_from_filename(path)
48
74
  ext = path.extname
49
75
  basename = path.basename(ext).to_s
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ require_relative 'logger'
5
+ require_relative 'python_venv'
6
+
7
+ module Wavesync
8
+ class PercivalBpmDetector
9
+ PYTHON_SCRIPT = <<~PYTHON
10
+ import essentia.standard as es, sys
11
+ audio = es.MonoLoader(filename=sys.argv[1], sampleRate=44100)()
12
+ bpm = es.PercivalBpmEstimator()(audio)
13
+ print(round(float(bpm)))
14
+ PYTHON
15
+
16
+ #: () -> bool?
17
+ def self.available?
18
+ PythonVenv.essentia_available?
19
+ end
20
+
21
+ #: (String file_path) -> Integer?
22
+ def self.detect(file_path)
23
+ output = PythonVenv.run_script(PYTHON_SCRIPT, file_path)
24
+ bpm = output.strip.to_f
25
+ bpm.positive? ? bpm.round : nil
26
+ rescue StandardError => e
27
+ Logger.log_error(e, call_site: 'PercivalBpmDetector.detect', arguments: { file_path: })
28
+ nil
29
+ end
30
+ end
31
+ end