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