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.
- checksums.yaml +4 -4
- data/README.md +64 -14
- data/config/devices.yml +17 -0
- data/lib/wavesync/acid_chunk.rb +36 -0
- data/lib/wavesync/analyzer.rb +8 -0
- data/lib/wavesync/audio.rb +118 -94
- data/lib/wavesync/audio_format.rb +8 -2
- data/lib/wavesync/cli.rb +1 -0
- data/lib/wavesync/commands/clear_cache.rb +75 -0
- data/lib/wavesync/commands/command.rb +2 -0
- data/lib/wavesync/commands/pull.rb +43 -0
- data/lib/wavesync/commands/setlist.rb +66 -0
- data/lib/wavesync/commands/sync.rb +15 -26
- data/lib/wavesync/commands.rb +44 -2
- data/lib/wavesync/config.rb +37 -3
- data/lib/wavesync/cue_chunk.rb +24 -0
- data/lib/wavesync/device.rb +14 -5
- data/lib/wavesync/essentia_bpm_detector.rb +3 -1
- data/lib/wavesync/ffmpeg/probe.rb +74 -0
- data/lib/wavesync/ffmpeg.rb +144 -0
- data/lib/wavesync/file_converter.rb +6 -3
- data/lib/wavesync/libmtp.rb +333 -0
- data/lib/wavesync/logger.rb +84 -0
- data/lib/wavesync/path_resolver.rb +19 -2
- data/lib/wavesync/percival_bpm_detector.rb +3 -1
- data/lib/wavesync/scanner.rb +117 -50
- data/lib/wavesync/{set.rb → setlist.rb} +13 -13
- data/lib/wavesync/{set_editor.rb → setlist_editor.rb} +52 -43
- data/lib/wavesync/track_padding.rb +10 -2
- 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 +43 -12
- data/lib/wavesync/version.rb +1 -1
- data/lib/wavesync.rb +6 -2
- metadata +13 -32
- data/lib/wavesync/commands/set.rb +0 -66
data/lib/wavesync/scanner.rb
CHANGED
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
# rbs_inline: enabled
|
|
3
3
|
|
|
4
4
|
require 'fileutils'
|
|
5
|
-
require '
|
|
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,
|
|
37
|
-
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?
|
|
38
44
|
padding_seconds = nil if padding_seconds.zero?
|
|
39
45
|
end
|
|
40
46
|
|
|
41
|
-
@ui.bpm(
|
|
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
|
|
58
|
-
|
|
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
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
#: (
|
|
120
|
-
def
|
|
121
|
-
|
|
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
|
-
#: (
|
|
125
|
-
def
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
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
|
|
9
|
-
|
|
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.
|
|
17
|
-
File.join(library_path,
|
|
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.
|
|
22
|
-
File.join(
|
|
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) ->
|
|
25
|
+
#: (String library_path, String name) -> Setlist
|
|
26
26
|
def self.load(library_path, name)
|
|
27
|
-
data = YAML.load_file(
|
|
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[
|
|
31
|
+
#: (String library_path) -> Array[Setlist]
|
|
32
32
|
def self.all(library_path)
|
|
33
|
-
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?(
|
|
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.
|
|
81
|
-
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)
|
|
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
|
|
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, :
|
|
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
|
-
#: (
|
|
27
|
-
def initialize(
|
|
28
|
-
@
|
|
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 = @
|
|
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
|
|
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 = @
|
|
262
|
+
total_duration = @setlist.tracks.sum { |track_path| track_duration(track_path) || 0.0 }
|
|
258
263
|
|
|
259
|
-
duration_widths = @
|
|
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(@
|
|
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 @
|
|
266
|
-
track_label = @
|
|
267
|
-
track_count_part = @ui.color("#{@
|
|
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 @
|
|
283
|
+
if @setlist.tracks.empty?
|
|
279
284
|
puts @ui.color(' (no tracks)', :secondary)
|
|
280
285
|
else
|
|
281
|
-
@
|
|
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(@
|
|
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 @
|
|
365
|
+
@selected = [@selected - 1, 0].max unless @setlist.tracks.empty?
|
|
361
366
|
nil
|
|
362
367
|
when :cursor_down
|
|
363
|
-
@selected = [@selected + 1, @
|
|
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
|
-
@
|
|
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 = @
|
|
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.
|
|
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 >= @
|
|
496
|
+
return if @selected.nil? || @selected >= @setlist.tracks.size - 1
|
|
488
497
|
|
|
489
498
|
@selected += 1
|
|
490
|
-
start_player(@
|
|
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
|
|
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
|
-
@
|
|
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 @
|
|
528
|
+
return if @setlist.tracks.empty? || @selected.nil?
|
|
520
529
|
|
|
521
|
-
stop_playback if @player_track == @
|
|
522
|
-
@
|
|
523
|
-
@selected = if @
|
|
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, @
|
|
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 @
|
|
541
|
+
return if @setlist.tracks.size < 2 || @selected.nil?
|
|
533
542
|
|
|
534
543
|
if direction == :up
|
|
535
|
-
@
|
|
544
|
+
@setlist.move_up(@selected)
|
|
536
545
|
@selected = [@selected - 1, 0].max
|
|
537
546
|
else
|
|
538
|
-
@
|
|
539
|
-
@selected = [@selected + 1, @
|
|
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, :
|
|
544
|
-
:
|
|
545
|
-
:selected, :
|
|
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
|
|
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 = (
|
|
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
|