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.
- checksums.yaml +4 -4
- data/README.md +95 -59
- data/config/devices.yml +20 -0
- data/lib/wavesync/acid_chunk.rb +45 -4
- data/lib/wavesync/analyzer.rb +17 -4
- data/lib/wavesync/audio.rb +153 -90
- data/lib/wavesync/audio_format.rb +9 -2
- data/lib/wavesync/bpm_detector.rb +14 -7
- data/lib/wavesync/cli.rb +3 -0
- data/lib/wavesync/commands/analyze.rb +2 -0
- data/lib/wavesync/commands/clear_cache.rb +75 -0
- data/lib/wavesync/commands/command.rb +4 -1
- data/lib/wavesync/commands/help.rb +6 -0
- data/lib/wavesync/commands/pull.rb +43 -0
- data/lib/wavesync/commands/setlist.rb +66 -0
- data/lib/wavesync/commands/sync.rb +19 -26
- data/lib/wavesync/commands.rb +52 -12
- data/lib/wavesync/config.rb +43 -3
- data/lib/wavesync/cue_chunk.rb +203 -0
- data/lib/wavesync/device.rb +32 -7
- data/lib/wavesync/essentia_bpm_detector.rb +38 -0
- data/lib/wavesync/ffmpeg/probe.rb +74 -0
- data/lib/wavesync/ffmpeg.rb +144 -0
- data/lib/wavesync/file_converter.rb +7 -2
- data/lib/wavesync/libmtp.rb +333 -0
- data/lib/wavesync/logger.rb +84 -0
- data/lib/wavesync/path_resolver.rb +32 -6
- data/lib/wavesync/percival_bpm_detector.rb +31 -0
- data/lib/wavesync/python_venv.rb +25 -0
- data/lib/wavesync/scanner.rb +143 -27
- data/lib/wavesync/{set.rb → setlist.rb} +28 -12
- data/lib/wavesync/setlist_editor.rb +556 -0
- data/lib/wavesync/track_padding.rb +15 -4
- data/lib/wavesync/transport/filesystem.rb +36 -0
- data/lib/wavesync/transport/mtp.rb +285 -0
- data/lib/wavesync/transport.rb +21 -0
- data/lib/wavesync/ui.rb +67 -12
- data/lib/wavesync/version.rb +2 -1
- data/lib/wavesync.rb +7 -2
- metadata +17 -32
- data/lib/wavesync/commands/set.rb +0 -63
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|