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,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
|
data/lib/wavesync/scanner.rb
CHANGED
|
@@ -1,20 +1,27 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
2
3
|
|
|
3
4
|
require 'fileutils'
|
|
4
|
-
require '
|
|
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
|
-
@
|
|
12
|
-
@
|
|
13
|
-
@
|
|
14
|
-
|
|
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
|
-
|
|
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,
|
|
34
|
-
original_bars, target_bars = TrackPadding.bar_counts(audio.duration,
|
|
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(
|
|
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
|
|
44
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
8
|
-
|
|
8
|
+
class Setlist
|
|
9
|
+
SETLISTS_FOLDER = '.setlists'
|
|
9
10
|
|
|
10
|
-
attr_reader :name
|
|
11
|
+
attr_reader :name #: String
|
|
12
|
+
attr_reader :tracks #: Array[String]
|
|
13
|
+
attr_reader :library_path #: String
|
|
11
14
|
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
|
|
17
|
-
|
|
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(
|
|
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 =
|
|
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?(
|
|
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.
|
|
67
|
-
File.write(self.class.
|
|
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
|