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,556 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
require 'tty-prompt'
|
|
5
|
+
require 'io/console'
|
|
6
|
+
require 'stringio'
|
|
7
|
+
require_relative 'logger'
|
|
8
|
+
|
|
9
|
+
module Wavesync
|
|
10
|
+
class SetlistEditor
|
|
11
|
+
KEY_MAP = {
|
|
12
|
+
'a' => :add,
|
|
13
|
+
'u' => :move_up,
|
|
14
|
+
'd' => :move_down,
|
|
15
|
+
'r' => :remove,
|
|
16
|
+
'q' => :quit,
|
|
17
|
+
' ' => :toggle_play,
|
|
18
|
+
'j' => :jump_to_next_cue,
|
|
19
|
+
"\e[A" => :cursor_up,
|
|
20
|
+
"\e[B" => :cursor_down
|
|
21
|
+
}.freeze
|
|
22
|
+
|
|
23
|
+
attr_accessor :player_state #: Symbol
|
|
24
|
+
attr_reader :selected, :setlist, :ui #: untyped
|
|
25
|
+
attr_writer :player_track, :player_index, :player_offset, :player_started_at, :player_pid
|
|
26
|
+
|
|
27
|
+
#: (Setlist setlist, String library_path) -> void
|
|
28
|
+
def initialize(setlist, library_path)
|
|
29
|
+
@setlist = setlist #: Setlist
|
|
30
|
+
@library_path = library_path #: String
|
|
31
|
+
Logger.configure(@library_path)
|
|
32
|
+
@prompt = TTY::Prompt.new(interrupt: :exit, active_color: :red) #: untyped
|
|
33
|
+
@ui = UI.new #: UI
|
|
34
|
+
@selected = @setlist.tracks.empty? ? nil : 0 #: Integer?
|
|
35
|
+
@player_pid = nil #: Integer?
|
|
36
|
+
@player_track = nil #: String?
|
|
37
|
+
@player_index = nil #: Integer?
|
|
38
|
+
@player_state = :stopped #: Symbol
|
|
39
|
+
@player_offset = 0 #: Numeric
|
|
40
|
+
@player_started_at = nil #: Time?
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
#: () -> void
|
|
44
|
+
def run
|
|
45
|
+
enter_fullscreen
|
|
46
|
+
loop do
|
|
47
|
+
check_player
|
|
48
|
+
render
|
|
49
|
+
action = KEY_MAP[read_key]
|
|
50
|
+
next unless action
|
|
51
|
+
|
|
52
|
+
result = handle_action(action)
|
|
53
|
+
break if result == :quit
|
|
54
|
+
end
|
|
55
|
+
ensure
|
|
56
|
+
exit_fullscreen
|
|
57
|
+
stop_playback
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
#: (String? path) -> (String | Integer)?
|
|
61
|
+
def track_bpm(path)
|
|
62
|
+
return nil if path.nil?
|
|
63
|
+
|
|
64
|
+
@track_bpms ||= {} #: Hash[String, (String | Integer)?]
|
|
65
|
+
return @track_bpms[path] if @track_bpms.key?(path)
|
|
66
|
+
|
|
67
|
+
@track_bpms[path] = begin
|
|
68
|
+
Audio.new(path).bpm
|
|
69
|
+
rescue StandardError => e
|
|
70
|
+
Logger.log_error(e, call_site: 'SetlistEditor#track_bpm', arguments: { path: })
|
|
71
|
+
nil
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
#: (String? path) -> Float?
|
|
76
|
+
def track_duration(path)
|
|
77
|
+
return nil if path.nil?
|
|
78
|
+
|
|
79
|
+
@track_durations ||= {} #: Hash[String, Float?]
|
|
80
|
+
return @track_durations[path] if @track_durations.key?(path)
|
|
81
|
+
|
|
82
|
+
@track_durations[path] = begin
|
|
83
|
+
Audio.new(path).duration
|
|
84
|
+
rescue StandardError => e
|
|
85
|
+
Logger.log_error(e, call_site: 'SetlistEditor#track_duration', arguments: { path: })
|
|
86
|
+
nil
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
#: (String? path) -> Array[Float]
|
|
91
|
+
def track_cue_fractions(path)
|
|
92
|
+
return [] if path.nil?
|
|
93
|
+
|
|
94
|
+
@track_cue_fractions ||= {} #: Hash[String, Array[Float]]
|
|
95
|
+
return @track_cue_fractions[path] if @track_cue_fractions.key?(path)
|
|
96
|
+
|
|
97
|
+
@track_cue_fractions[path] = begin
|
|
98
|
+
audio = Audio.new(path)
|
|
99
|
+
sample_rate = audio.sample_rate
|
|
100
|
+
duration = audio.duration
|
|
101
|
+
if sample_rate && duration&.positive?
|
|
102
|
+
audio.cue_points.map { |cue_point| cue_point[:sample_offset].to_f / sample_rate / duration }
|
|
103
|
+
else
|
|
104
|
+
[] #: Array[Float]
|
|
105
|
+
end
|
|
106
|
+
rescue StandardError => e
|
|
107
|
+
Logger.log_error(e, call_site: 'SetlistEditor#track_cue_fractions', arguments: { path: })
|
|
108
|
+
[] #: Array[Float]
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
#: ((String | Integer)? source_bpm, (String | Integer)? target_bpm) -> Float?
|
|
113
|
+
def pitch_shift_semitones(source_bpm, target_bpm)
|
|
114
|
+
return nil unless source_bpm && target_bpm
|
|
115
|
+
|
|
116
|
+
source = source_bpm.to_f
|
|
117
|
+
target = target_bpm.to_f
|
|
118
|
+
return nil if source.zero?
|
|
119
|
+
|
|
120
|
+
12.0 * Math.log2(target / source)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
#: (Float semitones) -> String
|
|
124
|
+
def format_pitch_shift(semitones)
|
|
125
|
+
sign = semitones >= 0 ? '+' : ''
|
|
126
|
+
"#{sign}#{semitones.round(2)}"
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
private
|
|
130
|
+
|
|
131
|
+
#: () -> void
|
|
132
|
+
def enter_fullscreen
|
|
133
|
+
print "\e[?1049h" # enter alternate screen buffer
|
|
134
|
+
print "\e[?25l" # hide cursor
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
#: () -> void
|
|
138
|
+
def exit_fullscreen
|
|
139
|
+
print "\e[?25h" # show cursor
|
|
140
|
+
print "\e[?1049l" # exit alternate screen buffer
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
#: (StringIO buffer) -> void
|
|
144
|
+
def flush_render(buffer)
|
|
145
|
+
$stdout.print "\e[H"
|
|
146
|
+
lines = buffer.string.lines
|
|
147
|
+
lines.each_with_index do |line, i|
|
|
148
|
+
terminator = i < lines.size - 1 ? "\n" : ''
|
|
149
|
+
$stdout.print "\e[K#{line.chomp}#{terminator}"
|
|
150
|
+
end
|
|
151
|
+
$stdout.print "\e[J"
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
#: () -> String?
|
|
155
|
+
def read_key
|
|
156
|
+
$stdin.raw do |io|
|
|
157
|
+
ready = io.wait_readable(0.5)
|
|
158
|
+
return nil unless ready
|
|
159
|
+
|
|
160
|
+
char = io.getc || ''
|
|
161
|
+
if char == "\e"
|
|
162
|
+
rest = begin
|
|
163
|
+
io.read_nonblock(3)
|
|
164
|
+
rescue StandardError
|
|
165
|
+
''
|
|
166
|
+
end
|
|
167
|
+
char + rest
|
|
168
|
+
else
|
|
169
|
+
char
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
#: (String absolute) -> String
|
|
175
|
+
def relative_path(absolute)
|
|
176
|
+
absolute.sub("#{@library_path}/", '')
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
#: (Float? seconds) -> String?
|
|
180
|
+
def format_duration(seconds)
|
|
181
|
+
return nil unless seconds
|
|
182
|
+
|
|
183
|
+
total_seconds = seconds.to_i
|
|
184
|
+
mins = total_seconds / 60
|
|
185
|
+
secs = total_seconds % 60
|
|
186
|
+
"#{mins}:#{secs.to_s.rjust(2, '0')}"
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
#: () -> Float
|
|
190
|
+
def playback_elapsed
|
|
191
|
+
case @player_state
|
|
192
|
+
when :playing
|
|
193
|
+
(@player_offset + (Time.now - @player_started_at)).to_f
|
|
194
|
+
when :paused
|
|
195
|
+
@player_offset.to_f
|
|
196
|
+
else
|
|
197
|
+
0.0
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
#: () -> Integer
|
|
202
|
+
def terminal_width
|
|
203
|
+
IO.console&.winsize&.last || 80
|
|
204
|
+
rescue StandardError
|
|
205
|
+
80
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
#: (String? string) -> Integer
|
|
209
|
+
def visible_length(string)
|
|
210
|
+
return 0 unless string
|
|
211
|
+
|
|
212
|
+
string.gsub(/\e\[[0-9;]*[A-Za-z]/, '').length
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
#: (Float elapsed, Float total_duration, Integer bar_width, ?cue_fractions: Array[Float]) -> String
|
|
216
|
+
def playback_bar(elapsed, total_duration, bar_width, cue_fractions: [])
|
|
217
|
+
ratio = total_duration.positive? ? [elapsed / total_duration, 1.0].min : 0.0
|
|
218
|
+
filled = (ratio * bar_width).round
|
|
219
|
+
|
|
220
|
+
bar = Array.new(bar_width) { |i| i < filled ? '█' : '░' }
|
|
221
|
+
|
|
222
|
+
cue_position_colors = {} #: Hash[Integer, Symbol]
|
|
223
|
+
cue_fractions.each do |fraction|
|
|
224
|
+
position = (fraction * (bar_width - 1)).round.clamp(0, bar_width - 1)
|
|
225
|
+
cue_position_colors[position] = position == filled ? :highlight : :surface
|
|
226
|
+
end
|
|
227
|
+
cue_position_colors.each_key { |position| bar[position] = '◆' }
|
|
228
|
+
|
|
229
|
+
result = +''
|
|
230
|
+
run_start = 0
|
|
231
|
+
while run_start < bar_width
|
|
232
|
+
if cue_position_colors.key?(run_start)
|
|
233
|
+
result << @ui.color(bar[run_start], cue_position_colors[run_start])
|
|
234
|
+
run_start += 1
|
|
235
|
+
else
|
|
236
|
+
run_end = run_start
|
|
237
|
+
run_end += 1 while run_end < bar_width && !cue_position_colors.key?(run_end)
|
|
238
|
+
result << @ui.color((bar[run_start...run_end] || []).join, :surface)
|
|
239
|
+
run_start = run_end
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
result
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
#: (Float elapsed, Float total_duration) -> String
|
|
246
|
+
def remaining_display(elapsed, total_duration)
|
|
247
|
+
remaining = [total_duration - elapsed, 0.0].max
|
|
248
|
+
"-#{format_duration(remaining) || '0:00'}"
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
#: (String relative) -> String
|
|
252
|
+
def display_name(relative)
|
|
253
|
+
File.basename(relative, '.*').sub(/\A\d+[\s.\-_]+/, '')
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
#: (?String title) -> void
|
|
257
|
+
def render(title = "wavesync setlist #{@setlist.name}")
|
|
258
|
+
buffer = StringIO.new
|
|
259
|
+
$stdout = buffer
|
|
260
|
+
|
|
261
|
+
header = @ui.color(title, :primary)
|
|
262
|
+
total_duration = @setlist.tracks.sum { |track_path| track_duration(track_path) || 0.0 }
|
|
263
|
+
|
|
264
|
+
duration_widths = @setlist.tracks.map { |track_path| format_duration(track_duration(track_path))&.length || 0 }
|
|
265
|
+
duration_widths << (format_duration(total_duration)&.length || 0)
|
|
266
|
+
player_duration = @player_index ? track_duration(@setlist.tracks[@player_index]) : nil
|
|
267
|
+
duration_widths << remaining_display(0.0, player_duration).length if player_duration
|
|
268
|
+
duration_col_width = duration_widths.max || 0
|
|
269
|
+
|
|
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)
|
|
273
|
+
duration_part = @ui.color(format_duration(total_duration).to_s.rjust(duration_col_width), :secondary)
|
|
274
|
+
summary = "#{track_count_part} #{duration_part}"
|
|
275
|
+
gap = [terminal_width - visible_length(header) - visible_length(summary), 2].max
|
|
276
|
+
puts "#{header}#{' ' * gap}#{summary}"
|
|
277
|
+
else
|
|
278
|
+
puts header
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
puts
|
|
282
|
+
|
|
283
|
+
if @setlist.tracks.empty?
|
|
284
|
+
puts @ui.color(' (no tracks)', :secondary)
|
|
285
|
+
else
|
|
286
|
+
@setlist.tracks.each_with_index do |track, i|
|
|
287
|
+
current_bpm = track_bpm(track)
|
|
288
|
+
current_duration = track_duration(track)
|
|
289
|
+
pitch_shift = pitch_shift_semitones(current_bpm, track_bpm(@setlist.tracks[i + 1]))
|
|
290
|
+
render_track(i, relative_path(track), i == @selected, i == @player_index,
|
|
291
|
+
bpm: current_bpm, pitch_shift: pitch_shift, duration: current_duration,
|
|
292
|
+
duration_col_width: duration_col_width, cue_fractions: track_cue_fractions(track))
|
|
293
|
+
end
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
puts
|
|
297
|
+
puts @ui.color('[↑↓] navigate [space] play/pause [j] next cue [a] add [u] up [d] down [r] remove [q] quit',
|
|
298
|
+
:secondary)
|
|
299
|
+
ensure
|
|
300
|
+
$stdout = STDOUT
|
|
301
|
+
flush_render(buffer)
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
#: (Integer index, String relative, bool selected, bool playing, ?bpm: (String | Integer)?, ?pitch_shift: Float?, ?duration: Float?, ?duration_col_width: Integer, ?cue_fractions: Array[Float]) -> void
|
|
305
|
+
def render_track(index, relative, selected, playing, bpm: nil, pitch_shift: nil, duration: nil, duration_col_width: 0, cue_fractions: [])
|
|
306
|
+
name = display_name(relative)
|
|
307
|
+
folder = File.dirname(relative)
|
|
308
|
+
icon = if playing
|
|
309
|
+
@player_state == :paused ? '⏸' : '▶'
|
|
310
|
+
else
|
|
311
|
+
' '
|
|
312
|
+
end
|
|
313
|
+
name_color = selected ? :highlight : :primary
|
|
314
|
+
index_color = selected ? :highlight : :extra
|
|
315
|
+
bpm_color = selected ? :highlight : :secondary
|
|
316
|
+
meta_color = selected ? :highlight : :tertiary
|
|
317
|
+
|
|
318
|
+
colored_icon = @ui.color(icon, :surface)
|
|
319
|
+
colored_index = @ui.color("#{index + 1}.", index_color)
|
|
320
|
+
colored_name = @ui.color(name, name_color)
|
|
321
|
+
bpm_label = bpm ? " · #{@ui.color("#{bpm} bpm", bpm_color)}" : ''
|
|
322
|
+
left_line = "#{colored_icon} #{colored_index} #{colored_name}#{bpm_label}"
|
|
323
|
+
|
|
324
|
+
folder_part = folder == '.' ? nil : @ui.color(folder, meta_color)
|
|
325
|
+
elapsed = playing && duration ? playback_elapsed : nil
|
|
326
|
+
duration_str = if elapsed && duration
|
|
327
|
+
@ui.color(remaining_display(elapsed, duration).rjust(duration_col_width), meta_color)
|
|
328
|
+
elsif !playing
|
|
329
|
+
format_duration(duration)&.then { @ui.color(_1.rjust(duration_col_width), meta_color) }
|
|
330
|
+
end
|
|
331
|
+
right_line = [folder_part, duration_str].compact.join(' ')
|
|
332
|
+
|
|
333
|
+
if right_line.empty?
|
|
334
|
+
puts left_line
|
|
335
|
+
else
|
|
336
|
+
gap = terminal_width - visible_length(left_line) - visible_length(right_line)
|
|
337
|
+
if gap >= 2
|
|
338
|
+
puts "#{left_line}#{' ' * gap}#{right_line}"
|
|
339
|
+
else
|
|
340
|
+
puts left_line
|
|
341
|
+
indent = ' '
|
|
342
|
+
if folder_part && duration_str
|
|
343
|
+
second_gap = [terminal_width - indent.length - visible_length(folder_part) - visible_length(duration_str), 2].max
|
|
344
|
+
puts "#{indent}#{folder_part}#{' ' * second_gap}#{duration_str}"
|
|
345
|
+
else
|
|
346
|
+
puts "#{indent}#{folder_part || duration_str}"
|
|
347
|
+
end
|
|
348
|
+
end
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
if playing && @player_state != :stopped && duration
|
|
352
|
+
bar_width = [terminal_width - 5, 20].max
|
|
353
|
+
puts " #{playback_bar(playback_elapsed, duration, bar_width, cue_fractions: cue_fractions)}"
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
return unless pitch_shift
|
|
357
|
+
|
|
358
|
+
puts " #{@ui.color("↓ #{format_pitch_shift(pitch_shift)} st", :secondary)}"
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
#: (Symbol action) -> Symbol?
|
|
362
|
+
def handle_action(action)
|
|
363
|
+
case action
|
|
364
|
+
when :cursor_up
|
|
365
|
+
@selected = [@selected - 1, 0].max unless @setlist.tracks.empty?
|
|
366
|
+
nil
|
|
367
|
+
when :cursor_down
|
|
368
|
+
@selected = [@selected + 1, @setlist.tracks.size - 1].min unless @setlist.tracks.empty?
|
|
369
|
+
nil
|
|
370
|
+
when :toggle_play
|
|
371
|
+
toggle_playback
|
|
372
|
+
nil
|
|
373
|
+
when :add
|
|
374
|
+
add_track
|
|
375
|
+
nil
|
|
376
|
+
when :remove
|
|
377
|
+
remove_track
|
|
378
|
+
nil
|
|
379
|
+
when :move_up
|
|
380
|
+
move_track(:up)
|
|
381
|
+
nil
|
|
382
|
+
when :move_down
|
|
383
|
+
move_track(:down)
|
|
384
|
+
nil
|
|
385
|
+
when :jump_to_next_cue
|
|
386
|
+
jump_to_next_cue
|
|
387
|
+
nil
|
|
388
|
+
when :quit
|
|
389
|
+
@setlist.save
|
|
390
|
+
:quit
|
|
391
|
+
end
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
#: () -> void
|
|
395
|
+
def toggle_playback
|
|
396
|
+
return if @selected.nil?
|
|
397
|
+
|
|
398
|
+
track = @setlist.tracks[@selected]
|
|
399
|
+
|
|
400
|
+
if @player_track == track
|
|
401
|
+
case @player_state
|
|
402
|
+
when :playing
|
|
403
|
+
@player_offset += Time.now - @player_started_at
|
|
404
|
+
kill_player
|
|
405
|
+
@player_state = :paused
|
|
406
|
+
when :paused
|
|
407
|
+
start_player(track, @player_offset)
|
|
408
|
+
end
|
|
409
|
+
else
|
|
410
|
+
stop_playback
|
|
411
|
+
start_player(track)
|
|
412
|
+
end
|
|
413
|
+
end
|
|
414
|
+
|
|
415
|
+
#: (String track, ?Numeric offset, ?player_index: Integer?) -> void
|
|
416
|
+
def start_player(track, offset = 0, player_index: @selected)
|
|
417
|
+
ffplay = Wavesync::FFMPEG.ffplay_binary
|
|
418
|
+
args = [ffplay, '-nodisp', '-autoexit', '-loglevel', 'quiet', '-probesize', '32', '-analyzeduration', '0']
|
|
419
|
+
args += ['-ss', offset.to_s] if offset.positive?
|
|
420
|
+
args << track
|
|
421
|
+
@player_pid = spawn(*args, out: File::NULL, err: File::NULL)
|
|
422
|
+
@player_track = track
|
|
423
|
+
@player_index = player_index
|
|
424
|
+
@player_offset = offset
|
|
425
|
+
@player_started_at = Time.now
|
|
426
|
+
@player_state = :playing
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
#: () -> void
|
|
430
|
+
def jump_to_next_cue
|
|
431
|
+
return unless @player_track
|
|
432
|
+
|
|
433
|
+
duration = track_duration(@player_track)
|
|
434
|
+
return unless duration&.positive?
|
|
435
|
+
|
|
436
|
+
fractions = track_cue_fractions(@player_track)
|
|
437
|
+
return if fractions.empty?
|
|
438
|
+
|
|
439
|
+
elapsed = playback_elapsed
|
|
440
|
+
sorted_fractions = fractions.sort
|
|
441
|
+
next_fraction = sorted_fractions.find { |fraction| fraction * duration > elapsed + 0.05 }
|
|
442
|
+
next_fraction ||= sorted_fractions.first
|
|
443
|
+
|
|
444
|
+
kill_player
|
|
445
|
+
start_player(@player_track, next_fraction * duration, player_index: @player_index)
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
#: () -> void
|
|
449
|
+
def kill_player
|
|
450
|
+
return unless @player_pid
|
|
451
|
+
|
|
452
|
+
player_pid = @player_pid
|
|
453
|
+
Process.kill('TERM', @player_pid)
|
|
454
|
+
@player_pid = nil
|
|
455
|
+
rescue Errno::ESRCH => e
|
|
456
|
+
Logger.log_error(e, call_site: 'SetlistEditor#kill_player', arguments: { player_pid: })
|
|
457
|
+
@player_pid = nil
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
#: () -> void
|
|
461
|
+
def stop_playback
|
|
462
|
+
kill_player
|
|
463
|
+
@player_track = nil
|
|
464
|
+
@player_index = nil
|
|
465
|
+
@player_state = :stopped
|
|
466
|
+
@player_offset = 0
|
|
467
|
+
@player_started_at = nil
|
|
468
|
+
end
|
|
469
|
+
|
|
470
|
+
#: () -> void
|
|
471
|
+
def check_player
|
|
472
|
+
return unless @player_pid
|
|
473
|
+
|
|
474
|
+
player_pid = @player_pid
|
|
475
|
+
result = Process.waitpid(@player_pid, Process::WNOHANG)
|
|
476
|
+
return unless result
|
|
477
|
+
|
|
478
|
+
@player_pid = nil
|
|
479
|
+
@player_track = nil
|
|
480
|
+
@player_index = nil
|
|
481
|
+
@player_state = :stopped
|
|
482
|
+
@player_offset = 0
|
|
483
|
+
advance_and_play
|
|
484
|
+
rescue Errno::ECHILD => e
|
|
485
|
+
Logger.log_error(e, call_site: 'SetlistEditor#check_player', arguments: { player_pid: })
|
|
486
|
+
@player_pid = nil
|
|
487
|
+
@player_track = nil
|
|
488
|
+
@player_index = nil
|
|
489
|
+
@player_state = :stopped
|
|
490
|
+
@player_offset = 0
|
|
491
|
+
advance_and_play
|
|
492
|
+
end
|
|
493
|
+
|
|
494
|
+
#: () -> void
|
|
495
|
+
def advance_and_play
|
|
496
|
+
return if @selected.nil? || @selected >= @setlist.tracks.size - 1
|
|
497
|
+
|
|
498
|
+
@selected += 1
|
|
499
|
+
start_player(@setlist.tracks[@selected])
|
|
500
|
+
end
|
|
501
|
+
|
|
502
|
+
#: () -> Array[String]
|
|
503
|
+
def audio_files
|
|
504
|
+
@audio_files ||= Audio.find_all(@library_path)
|
|
505
|
+
end
|
|
506
|
+
|
|
507
|
+
#: () -> void
|
|
508
|
+
def add_track
|
|
509
|
+
if audio_files.empty?
|
|
510
|
+
puts @ui.color('No audio files found in library.', :highlight)
|
|
511
|
+
sleep 1
|
|
512
|
+
return
|
|
513
|
+
end
|
|
514
|
+
|
|
515
|
+
choices = audio_files.map { |file| { name: relative_path(file), value: file } }
|
|
516
|
+
|
|
517
|
+
render("wavesync setlist #{@setlist.name} — add track")
|
|
518
|
+
puts
|
|
519
|
+
picked = @prompt.select('Select a track to add:', choices, cycle: true, filter: true, per_page: 20)
|
|
520
|
+
|
|
521
|
+
insert_at = @selected.nil? ? 0 : @selected + 1
|
|
522
|
+
@setlist.tracks.insert(insert_at, picked)
|
|
523
|
+
@selected = insert_at
|
|
524
|
+
end
|
|
525
|
+
|
|
526
|
+
#: () -> void
|
|
527
|
+
def remove_track
|
|
528
|
+
return if @setlist.tracks.empty? || @selected.nil?
|
|
529
|
+
|
|
530
|
+
stop_playback if @player_track == @setlist.tracks[@selected]
|
|
531
|
+
@setlist.remove_track(@selected)
|
|
532
|
+
@selected = if @setlist.tracks.empty?
|
|
533
|
+
nil
|
|
534
|
+
else
|
|
535
|
+
[@selected, @setlist.tracks.size - 1].min
|
|
536
|
+
end
|
|
537
|
+
end
|
|
538
|
+
|
|
539
|
+
#: (Symbol direction) -> void
|
|
540
|
+
def move_track(direction)
|
|
541
|
+
return if @setlist.tracks.size < 2 || @selected.nil?
|
|
542
|
+
|
|
543
|
+
if direction == :up
|
|
544
|
+
@setlist.move_up(@selected)
|
|
545
|
+
@selected = [@selected - 1, 0].max
|
|
546
|
+
else
|
|
547
|
+
@setlist.move_down(@selected)
|
|
548
|
+
@selected = [@selected + 1, @setlist.tracks.size - 1].min
|
|
549
|
+
end
|
|
550
|
+
end
|
|
551
|
+
|
|
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
|
|
555
|
+
end
|
|
556
|
+
end
|
|
@@ -1,27 +1,38 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
2
3
|
|
|
3
4
|
module Wavesync
|
|
4
5
|
class TrackPadding
|
|
5
6
|
BEATS_PER_BAR = 4
|
|
6
7
|
|
|
8
|
+
#: ((Float | Integer)? duration_seconds, (Integer | Float | String)? bpm, (Integer | Float)? bar_multiple) -> (Float | Integer)
|
|
7
9
|
def self.compute(duration_seconds, bpm, bar_multiple)
|
|
8
|
-
return 0 if bpm.nil? || bpm.to_f.zero? || duration_seconds.nil? || duration_seconds <= 0
|
|
10
|
+
return 0 if bpm.nil? || bpm.to_f.zero? || duration_seconds.nil? || duration_seconds <= 0 || bar_multiple.nil?
|
|
9
11
|
|
|
10
12
|
seconds_per_bar = BEATS_PER_BAR * 60.0 / bpm.to_f
|
|
11
13
|
track_bars = (duration_seconds / seconds_per_bar).round(6)
|
|
12
|
-
target_bars = (track_bars
|
|
14
|
+
target_bars = next_power_of_two_multiple(track_bars, bar_multiple)
|
|
13
15
|
|
|
14
16
|
padding = (target_bars * seconds_per_bar) - duration_seconds
|
|
15
17
|
padding < 0.001 ? 0 : padding
|
|
16
18
|
end
|
|
17
19
|
|
|
20
|
+
#: ((Float | Integer)? duration_seconds, (Integer | Float | String)? bpm, (Integer | Float)? bar_multiple) -> [Integer?, Integer?]
|
|
18
21
|
def self.bar_counts(duration_seconds, bpm, bar_multiple)
|
|
19
|
-
return [nil, nil] if bpm.nil? || bpm.to_f.zero? || duration_seconds.nil? || duration_seconds <= 0
|
|
22
|
+
return [nil, nil] if bpm.nil? || bpm.to_f.zero? || duration_seconds.nil? || duration_seconds <= 0 || bar_multiple.nil?
|
|
20
23
|
|
|
21
24
|
seconds_per_bar = BEATS_PER_BAR * 60.0 / bpm.to_f
|
|
22
25
|
original_bars = (duration_seconds / seconds_per_bar).round
|
|
23
|
-
target_bars = (
|
|
26
|
+
target_bars = next_power_of_two_multiple(original_bars, bar_multiple).to_i
|
|
24
27
|
[original_bars, target_bars]
|
|
25
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
|
|
26
37
|
end
|
|
27
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
|