wavesync 1.0.0.alpha4 → 1.0.0.beta2
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 +14 -21
- data/config/devices.yml +1 -0
- data/lib/wavesync/acid_chunk.rb +78 -68
- data/lib/wavesync/analyzer.rb +29 -7
- data/lib/wavesync/audio.rb +17 -18
- data/lib/wavesync/commands/analyze.rb +4 -2
- data/lib/wavesync/commands/command.rb +2 -1
- data/lib/wavesync/commands/help.rb +3 -1
- data/lib/wavesync/commands/pull.rb +28 -11
- data/lib/wavesync/cue_chunk.rb +131 -86
- data/lib/wavesync/device.rb +5 -2
- data/lib/wavesync/essentia_bpm_detector.rb +10 -0
- data/lib/wavesync/ffmpeg/probe.rb +8 -4
- data/lib/wavesync/logger.rb +16 -2
- data/lib/wavesync/mp4_tmpo.rb +287 -0
- data/lib/wavesync/path_resolver.rb +11 -0
- data/lib/wavesync/scanner.rb +7 -6
- data/lib/wavesync/setlist_editor.rb +69 -30
- data/lib/wavesync/timing.rb +36 -0
- data/lib/wavesync/transliterator.rb +15 -0
- data/lib/wavesync/transport/mtp.rb +14 -8
- data/lib/wavesync/version.rb +1 -1
- data/lib/wavesync.rb +2 -0
- metadata +4 -1
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
require 'fileutils'
|
|
5
|
+
require_relative 'timing'
|
|
6
|
+
|
|
7
|
+
module Wavesync
|
|
8
|
+
# Reads and writes the iTunes `tmpo` atom (BPM) in MP4/M4A files.
|
|
9
|
+
#
|
|
10
|
+
# ffmpeg/ffprobe cannot read or write `tmpo`, so we parse the atom tree
|
|
11
|
+
# directly — the same hand-rolled approach used for WAV in `AcidChunk` and
|
|
12
|
+
# `CueChunk`. `tmpo` lives at moov → udta → meta → ilst → tmpo → data, where
|
|
13
|
+
# the value is a big-endian integer. This is the form Apple Music writes and
|
|
14
|
+
# the one `AVFoundation` (and thus audioplayer) reads.
|
|
15
|
+
class Mp4Tmpo
|
|
16
|
+
HEADER_SIZE = 8 # box size (4) + type (4)
|
|
17
|
+
TYPE_SIZE = 4
|
|
18
|
+
FULLBOX_FLAGS_SIZE = 4 # version (1) + flags (3)
|
|
19
|
+
LOCALE_SIZE = 4 # `data` atom locale field
|
|
20
|
+
DATA_TYPE_UTF8 = 1
|
|
21
|
+
DATA_TYPE_INTEGER = 21
|
|
22
|
+
|
|
23
|
+
UINT16 = 'n' # iTunes stores BPM as a 16-bit big-endian integer
|
|
24
|
+
UINT32 = 'N'
|
|
25
|
+
UINT64 = 'Q>'
|
|
26
|
+
ZERO_FLAGS = [0].pack(UINT32).freeze
|
|
27
|
+
|
|
28
|
+
#: (String filepath) -> Integer?
|
|
29
|
+
def self.read_bpm(filepath)
|
|
30
|
+
Timing.current.measure(:mp4_chunks) do
|
|
31
|
+
data = File.binread(filepath).b
|
|
32
|
+
ilst = locate_ilst(data)
|
|
33
|
+
next nil unless ilst
|
|
34
|
+
|
|
35
|
+
value = tmpo_value(data, ilst[0], ilst[1])
|
|
36
|
+
value&.positive? ? value : nil
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
#: (String filepath, Integer | Float | String bpm) -> void
|
|
41
|
+
def self.write_bpm(filepath, bpm)
|
|
42
|
+
Timing.current.measure(:mp4_chunks) do
|
|
43
|
+
data = File.binread(filepath).b
|
|
44
|
+
updated = set_tmpo(data, bpm.to_i)
|
|
45
|
+
next if updated.equal?(data)
|
|
46
|
+
|
|
47
|
+
temp_path = "#{filepath}.tmp"
|
|
48
|
+
File.binwrite(temp_path, updated)
|
|
49
|
+
FileUtils.mv(temp_path, filepath)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# --- reading -------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
#: (String data, Integer ilst_start, Integer ilst_finish) -> Integer?
|
|
56
|
+
def self.tmpo_value(data, ilst_start, ilst_finish)
|
|
57
|
+
tmpo = find_box(data, 'tmpo', ilst_start, ilst_finish)
|
|
58
|
+
return nil unless tmpo
|
|
59
|
+
|
|
60
|
+
t_off, t_size, t_hdr = tmpo
|
|
61
|
+
box = find_box(data, 'data', t_off + t_hdr, t_off + t_size)
|
|
62
|
+
return nil unless box
|
|
63
|
+
|
|
64
|
+
d_off, d_size, d_hdr = box
|
|
65
|
+
type_flag = u32(data, d_off + d_hdr) & 0xFFFFFF
|
|
66
|
+
value_start = d_off + d_hdr + FULLBOX_FLAGS_SIZE + LOCALE_SIZE
|
|
67
|
+
bytes = data[value_start, (d_off + d_size) - value_start] || ''
|
|
68
|
+
decode_value(bytes, type_flag)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
#: (String bytes, Integer type_flag) -> Integer?
|
|
72
|
+
def self.decode_value(bytes, type_flag)
|
|
73
|
+
return bytes.to_i if type_flag == DATA_TYPE_UTF8
|
|
74
|
+
|
|
75
|
+
bytes.bytes.reduce(0) { |acc, byte| (acc << 8) | byte }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Returns the byte range [start, finish) spanning the ilst children, or nil.
|
|
79
|
+
#: (String data) -> [Integer, Integer]?
|
|
80
|
+
def self.locate_ilst(data)
|
|
81
|
+
moov = find_box(data, 'moov', 0, data.bytesize)
|
|
82
|
+
return nil unless moov
|
|
83
|
+
|
|
84
|
+
udta = find_box(data, 'udta', moov[0] + moov[2], moov[0] + moov[1])
|
|
85
|
+
return nil unless udta
|
|
86
|
+
|
|
87
|
+
meta = find_box(data, 'meta', udta[0] + udta[2], udta[0] + udta[1])
|
|
88
|
+
return nil unless meta
|
|
89
|
+
|
|
90
|
+
meo, mes, meh = meta
|
|
91
|
+
children_start = meo + meh + meta_prefix_size(data[meo + meh, mes - meh] || '')
|
|
92
|
+
ilst = find_box(data, 'ilst', children_start, meo + mes)
|
|
93
|
+
return nil unless ilst
|
|
94
|
+
|
|
95
|
+
io, isz, ih = ilst
|
|
96
|
+
[io + ih, io + isz]
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# --- writing -------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
# Rebuilds the moov atom with `tmpo` set to `bpm`, fixing up chunk offsets
|
|
102
|
+
# if moov precedes mdat. Returns the same object unchanged when there is no
|
|
103
|
+
# moov atom (not an MP4 we understand).
|
|
104
|
+
#: (String data, Integer bpm) -> String
|
|
105
|
+
def self.set_tmpo(data, bpm)
|
|
106
|
+
moov = find_box(data, 'moov', 0, data.bytesize)
|
|
107
|
+
return data unless moov
|
|
108
|
+
|
|
109
|
+
mo, ms, = moov
|
|
110
|
+
new_moov = rebuild_moov(data[mo, ms] || '', build_tmpo(bpm))
|
|
111
|
+
delta = new_moov.bytesize - ms
|
|
112
|
+
|
|
113
|
+
mdat = find_box(data, 'mdat', 0, data.bytesize)
|
|
114
|
+
new_moov = patch_chunk_offsets(new_moov, delta) if delta != 0 && mdat && mo < mdat[0]
|
|
115
|
+
|
|
116
|
+
"#{data[0, mo]}#{new_moov}#{data[(mo + ms)..]}"
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
#: (String moov, String tmpo_box) -> String
|
|
120
|
+
def self.rebuild_moov(moov, tmpo_box)
|
|
121
|
+
hdr = box_header_size(moov, 0)
|
|
122
|
+
payload = moov[hdr..] || ''
|
|
123
|
+
|
|
124
|
+
new_payload = update_container(payload, 'udta') do |udta|
|
|
125
|
+
update_container(udta, 'meta') do |meta|
|
|
126
|
+
prefix = meta_prefix(meta)
|
|
127
|
+
body = meta[prefix.bytesize..] || ''
|
|
128
|
+
prefix + update_container(body, 'ilst') { |ilst| set_tmpo_box(ilst, tmpo_box) }
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
box('moov', new_payload)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Replaces the child of `type` in `payload` with the result of the block
|
|
136
|
+
# (called with the child's payload), or appends a new child if absent.
|
|
137
|
+
#: (String payload, String type) { (String) -> String } -> String
|
|
138
|
+
def self.update_container(payload, type)
|
|
139
|
+
found = find_box(payload, type, 0, payload.bytesize)
|
|
140
|
+
if found
|
|
141
|
+
off, size, hdr = found
|
|
142
|
+
new_child = yield(payload[off + hdr, size - hdr] || '')
|
|
143
|
+
"#{payload[0, off]}#{box(type, new_child)}#{payload[(off + size)..]}"
|
|
144
|
+
else
|
|
145
|
+
payload + box(type, yield(''))
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
#: (String ilst, String tmpo_box) -> String
|
|
150
|
+
def self.set_tmpo_box(ilst, tmpo_box)
|
|
151
|
+
found = find_box(ilst, 'tmpo', 0, ilst.bytesize)
|
|
152
|
+
return ilst + tmpo_box unless found
|
|
153
|
+
|
|
154
|
+
off, size, = found
|
|
155
|
+
"#{ilst[0, off]}#{tmpo_box}#{ilst[(off + size)..]}"
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
#: (Integer bpm) -> String
|
|
159
|
+
def self.build_tmpo(bpm)
|
|
160
|
+
value = [clamp_uint16(bpm)].pack(UINT16)
|
|
161
|
+
data_payload = [DATA_TYPE_INTEGER].pack(UINT32) + [0].pack(UINT32) + value
|
|
162
|
+
box('tmpo', box('data', data_payload))
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Adds `delta` to every stco/co64 chunk offset, so audio data references
|
|
166
|
+
# stay valid after moov grows ahead of mdat.
|
|
167
|
+
#: (String moov, Integer delta) -> String
|
|
168
|
+
def self.patch_chunk_offsets(moov, delta)
|
|
169
|
+
moov = moov.b
|
|
170
|
+
each_descendant(moov, 0, moov.bytesize, %w[moov trak mdia minf stbl]) do |type, off, _size, hdr|
|
|
171
|
+
next unless %w[stco co64].include?(type)
|
|
172
|
+
|
|
173
|
+
count = u32(moov, off + hdr + FULLBOX_FLAGS_SIZE)
|
|
174
|
+
base = off + hdr + FULLBOX_FLAGS_SIZE + 4
|
|
175
|
+
count.times do |i|
|
|
176
|
+
if type == 'stco'
|
|
177
|
+
pos = base + (i * 4)
|
|
178
|
+
moov[pos, 4] = [(u32(moov, pos) + delta) & 0xFFFFFFFF].pack(UINT32)
|
|
179
|
+
else
|
|
180
|
+
pos = base + (i * 8)
|
|
181
|
+
moov[pos, 8] = [u64(moov, pos) + delta].pack(UINT64)
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
moov
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# --- atom helpers --------------------------------------------------------
|
|
189
|
+
|
|
190
|
+
#: (String type, String payload) -> String
|
|
191
|
+
def self.box(type, payload)
|
|
192
|
+
[HEADER_SIZE + payload.bytesize].pack(UINT32) + type + payload
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# `meta` is a FullBox in MP4 (4-byte version/flags before its children) but
|
|
196
|
+
# a plain box in some QuickTime files. Detect which, and synthesize a valid
|
|
197
|
+
# FullBox header (with an `mdir` handler) when creating `meta` from scratch.
|
|
198
|
+
#: (String meta_payload) -> String
|
|
199
|
+
def self.meta_prefix(meta_payload)
|
|
200
|
+
return ZERO_FLAGS + hdlr_mdir_box if meta_payload.empty?
|
|
201
|
+
return '' if non_fullbox_meta?(meta_payload)
|
|
202
|
+
|
|
203
|
+
meta_payload[0, FULLBOX_FLAGS_SIZE] || ''
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
#: (String meta_payload) -> Integer
|
|
207
|
+
def self.meta_prefix_size(meta_payload)
|
|
208
|
+
meta_prefix(meta_payload).bytesize
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# A plain (non-FullBox) meta starts with a child box, so a known child type
|
|
212
|
+
# ('hdlr') appears at the type offset of the first box.
|
|
213
|
+
#: (String meta_payload) -> bool
|
|
214
|
+
def self.non_fullbox_meta?(meta_payload)
|
|
215
|
+
meta_payload[TYPE_SIZE, TYPE_SIZE] == 'hdlr'
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
#: () -> String
|
|
219
|
+
def self.hdlr_mdir_box
|
|
220
|
+
payload = "#{ZERO_FLAGS}#{[0].pack(UINT32)}mdirappl#{[0].pack(UINT32) * 2}\u0000" # empty name, null-terminated
|
|
221
|
+
box('hdlr', payload)
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# Returns [offset, size, header_size] of the first direct child of `type`
|
|
225
|
+
# within [start, finish), or nil.
|
|
226
|
+
#: (String data, String type, Integer start, Integer finish) -> [Integer, Integer, Integer]?
|
|
227
|
+
def self.find_box(data, type, start, finish)
|
|
228
|
+
result = nil #: [Integer, Integer, Integer]?
|
|
229
|
+
each_box(data, start, finish) do |t, off, size, hdr|
|
|
230
|
+
if t == type
|
|
231
|
+
result = [off, size, hdr]
|
|
232
|
+
break
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
result
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
#: (String data, Integer start, Integer finish) { (String, Integer, Integer, Integer) -> void } -> void
|
|
239
|
+
def self.each_box(data, start, finish)
|
|
240
|
+
pos = start
|
|
241
|
+
while pos + HEADER_SIZE <= finish
|
|
242
|
+
size = u32(data, pos)
|
|
243
|
+
hdr = HEADER_SIZE
|
|
244
|
+
if size == 1
|
|
245
|
+
size = u64(data, pos + HEADER_SIZE)
|
|
246
|
+
hdr = 16
|
|
247
|
+
elsif size.zero?
|
|
248
|
+
size = finish - pos
|
|
249
|
+
end
|
|
250
|
+
break if size < hdr || pos + size > finish
|
|
251
|
+
|
|
252
|
+
yield data[pos + 4, TYPE_SIZE] || '', pos, size, hdr
|
|
253
|
+
pos += size
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
#: (String data, Integer start, Integer finish, Array[String] containers) { (String, Integer, Integer, Integer) -> void } -> void
|
|
258
|
+
def self.each_descendant(data, start, finish, containers, &block)
|
|
259
|
+
each_box(data, start, finish) do |type, off, size, hdr|
|
|
260
|
+
block.call(type, off, size, hdr)
|
|
261
|
+
each_descendant(data, off + hdr, off + size, containers, &block) if containers.include?(type)
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
#: (String data, Integer offset) -> Integer
|
|
266
|
+
def self.box_header_size(data, offset)
|
|
267
|
+
u32(data, offset) == 1 ? 16 : HEADER_SIZE
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
#: (Integer value) -> Integer
|
|
271
|
+
def self.clamp_uint16(value)
|
|
272
|
+
value.clamp(0, 0xFFFF)
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
#: (String data, Integer offset) -> Integer
|
|
276
|
+
def self.u32(data, offset)
|
|
277
|
+
value = (data[offset, 4] || '').unpack1(UINT32)
|
|
278
|
+
value.is_a?(Integer) ? value : 0
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
#: (String data, Integer offset) -> Integer
|
|
282
|
+
def self.u64(data, offset)
|
|
283
|
+
value = (data[offset, 8] || '').unpack1(UINT64)
|
|
284
|
+
value.is_a?(Integer) ? value : 0
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
end
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
# rbs_inline: enabled
|
|
3
3
|
|
|
4
4
|
require 'pathname'
|
|
5
|
+
require_relative 'transliterator'
|
|
5
6
|
|
|
6
7
|
module Wavesync
|
|
7
8
|
class PathResolver
|
|
@@ -25,6 +26,7 @@ module Wavesync
|
|
|
25
26
|
target_path = add_bpm_to_filename(target_path, bpm) if @device.bpm_source == :filename && bpm
|
|
26
27
|
|
|
27
28
|
target_path = strip_unsupported_characters(target_path)
|
|
29
|
+
target_path = transliterate_relative_path(target_path)
|
|
28
30
|
uppercase_relative_path(target_path)
|
|
29
31
|
end
|
|
30
32
|
|
|
@@ -60,6 +62,15 @@ module Wavesync
|
|
|
60
62
|
Pathname(path.to_s.delete(@device.unsupported_characters.join))
|
|
61
63
|
end
|
|
62
64
|
|
|
65
|
+
#: (Pathname path) -> Pathname
|
|
66
|
+
def transliterate_relative_path(path)
|
|
67
|
+
return path unless @device.transliterate_paths
|
|
68
|
+
|
|
69
|
+
relative = path.relative_path_from(@target_library_path)
|
|
70
|
+
transliterated = relative.each_filename.map { |filename| Transliterator.transliterate(filename) }.join('/')
|
|
71
|
+
@target_library_path.join(transliterated)
|
|
72
|
+
end
|
|
73
|
+
|
|
63
74
|
#: (Pathname path) -> Pathname
|
|
64
75
|
def uppercase_relative_path(path)
|
|
65
76
|
return path unless @device.uppercase_paths
|
data/lib/wavesync/scanner.rb
CHANGED
|
@@ -20,6 +20,7 @@ module Wavesync
|
|
|
20
20
|
|
|
21
21
|
#: (String target_library_path, Device device, ?pad: bool, ?staged: bool, ?mp3_bitrate: Integer) ?{ (String) -> void } -> void
|
|
22
22
|
def sync(target_library_path, device, pad: false, staged: false, mp3_bitrate: 192, &on_file_synced)
|
|
23
|
+
Timing.reset
|
|
23
24
|
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
24
25
|
target_library_pathname = Pathname.new(target_library_path)
|
|
25
26
|
path_resolver = PathResolver.new(@source_library_path, target_library_path, device)
|
|
@@ -102,9 +103,9 @@ module Wavesync
|
|
|
102
103
|
end
|
|
103
104
|
|
|
104
105
|
puts
|
|
105
|
-
system('sync')
|
|
106
|
+
Timing.current.measure(:filesystem) { system('sync') }
|
|
106
107
|
elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
|
|
107
|
-
Logger.log_run_time(elapsed)
|
|
108
|
+
Logger.log_run_time(elapsed, timings: Timing.current.totals)
|
|
108
109
|
end
|
|
109
110
|
|
|
110
111
|
#: (String target_library_path, Device device) -> void
|
|
@@ -186,19 +187,19 @@ module Wavesync
|
|
|
186
187
|
CueChunk.append_to_file(local_temp_path, rescaled_cue_points)
|
|
187
188
|
end
|
|
188
189
|
|
|
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
|
+
#: (Array[{identifier: Integer, sample_offset: Integer, label: String?, note: String?}] cue_points, Integer? source_sample_rate, Integer? target_sample_rate) -> Array[{identifier: Integer, sample_offset: Integer, label: String?, note: String?}]
|
|
190
191
|
def rescale_cue_points(cue_points, source_sample_rate, target_sample_rate)
|
|
191
192
|
return cue_points if source_sample_rate == target_sample_rate || source_sample_rate.nil? || target_sample_rate.nil?
|
|
192
193
|
|
|
193
194
|
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
|
+
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?, note: String?}
|
|
195
196
|
end
|
|
196
197
|
end
|
|
197
198
|
|
|
198
199
|
#: (String source, Pathname target) -> void
|
|
199
200
|
def safe_copy(source, target)
|
|
200
|
-
FileUtils.install(source, target)
|
|
201
|
-
rescue
|
|
201
|
+
Timing.current.measure(:copy) { FileUtils.install(source, target) }
|
|
202
|
+
rescue SystemCallError => e
|
|
202
203
|
Logger.log_error(e, call_site: 'Scanner#safe_copy', arguments: { source:, target: })
|
|
203
204
|
end
|
|
204
205
|
|
|
@@ -5,6 +5,7 @@ require 'tty-prompt'
|
|
|
5
5
|
require 'io/console'
|
|
6
6
|
require 'stringio'
|
|
7
7
|
require_relative 'logger'
|
|
8
|
+
require_relative 'cue_chunk'
|
|
8
9
|
|
|
9
10
|
module Wavesync
|
|
10
11
|
class SetlistEditor
|
|
@@ -87,26 +88,26 @@ module Wavesync
|
|
|
87
88
|
end
|
|
88
89
|
end
|
|
89
90
|
|
|
91
|
+
EMPTY_MARKERS = { cues: [], loops: [] }.freeze # steep:ignore UnannotatedEmptyCollection
|
|
92
|
+
|
|
90
93
|
#: (String? path) -> Array[Float]
|
|
91
94
|
def track_cue_fractions(path)
|
|
92
|
-
|
|
95
|
+
track_markers(path)[:cues]
|
|
96
|
+
end
|
|
93
97
|
|
|
94
|
-
|
|
95
|
-
|
|
98
|
+
#: (String? path) -> Array[{start_fraction: Float, end_fraction: Float}]
|
|
99
|
+
def track_loop_fractions(path)
|
|
100
|
+
track_markers(path)[:loops]
|
|
101
|
+
end
|
|
96
102
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
end
|
|
106
|
-
rescue StandardError => e
|
|
107
|
-
Logger.log_error(e, call_site: 'SetlistEditor#track_cue_fractions', arguments: { path: })
|
|
108
|
-
[] #: Array[Float]
|
|
109
|
-
end
|
|
103
|
+
#: (String? path) -> {cues: Array[Float], loops: Array[{start_fraction: Float, end_fraction: Float}]}
|
|
104
|
+
def track_markers(path)
|
|
105
|
+
return EMPTY_MARKERS if path.nil?
|
|
106
|
+
|
|
107
|
+
@track_markers ||= {} #: Hash[String, {cues: Array[Float], loops: Array[{start_fraction: Float, end_fraction: Float}]}]
|
|
108
|
+
return @track_markers[path] if @track_markers.key?(path)
|
|
109
|
+
|
|
110
|
+
@track_markers[path] = compute_track_markers(path)
|
|
110
111
|
end
|
|
111
112
|
|
|
112
113
|
#: ((String | Integer)? source_bpm, (String | Integer)? target_bpm) -> Float?
|
|
@@ -128,6 +129,29 @@ module Wavesync
|
|
|
128
129
|
|
|
129
130
|
private
|
|
130
131
|
|
|
132
|
+
#: (String path) -> {cues: Array[Float], loops: Array[{start_fraction: Float, end_fraction: Float}]}
|
|
133
|
+
def compute_track_markers(path)
|
|
134
|
+
audio = Audio.new(path)
|
|
135
|
+
sample_rate = audio.sample_rate
|
|
136
|
+
duration = audio.duration
|
|
137
|
+
return EMPTY_MARKERS unless sample_rate && duration&.positive?
|
|
138
|
+
|
|
139
|
+
cue_points = audio.cue_points
|
|
140
|
+
divisor = sample_rate.to_f * duration
|
|
141
|
+
|
|
142
|
+
cues = cue_points.reject { |cue_point| CueChunk.loop_marker?(cue_point) }
|
|
143
|
+
.map { |cue_point| cue_point[:sample_offset].to_f / divisor }
|
|
144
|
+
|
|
145
|
+
loops = CueChunk.loops(cue_points).map do |loop_range|
|
|
146
|
+
loop_hash = { start_fraction: loop_range[:start_sample].to_f / divisor, end_fraction: loop_range[:end_sample].to_f / divisor }
|
|
147
|
+
loop_hash #: {start_fraction: Float, end_fraction: Float}
|
|
148
|
+
end
|
|
149
|
+
{ cues: cues, loops: loops }
|
|
150
|
+
rescue StandardError => e
|
|
151
|
+
Logger.log_error(e, call_site: 'SetlistEditor#track_cue_fractions', arguments: { path: })
|
|
152
|
+
EMPTY_MARKERS
|
|
153
|
+
end
|
|
154
|
+
|
|
131
155
|
#: () -> void
|
|
132
156
|
def enter_fullscreen
|
|
133
157
|
print "\e[?1049h" # enter alternate screen buffer
|
|
@@ -212,30 +236,44 @@ module Wavesync
|
|
|
212
236
|
string.gsub(/\e\[[0-9;]*[A-Za-z]/, '').length
|
|
213
237
|
end
|
|
214
238
|
|
|
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: [])
|
|
239
|
+
#: (Float elapsed, Float total_duration, Integer bar_width, ?cue_fractions: Array[Float], ?loop_fractions: Array[{start_fraction: Float, end_fraction: Float}]) -> String
|
|
240
|
+
def playback_bar(elapsed, total_duration, bar_width, cue_fractions: [], loop_fractions: [])
|
|
217
241
|
ratio = total_duration.positive? ? [elapsed / total_duration, 1.0].min : 0.0
|
|
218
242
|
filled = (ratio * bar_width).round
|
|
219
243
|
|
|
220
244
|
bar = Array.new(bar_width) { |i| i < filled ? '█' : '░' }
|
|
245
|
+
cell_colors = Array.new(bar_width, :surface) #: Array[Symbol]
|
|
246
|
+
marker_positions = {} #: Hash[Integer, bool]
|
|
247
|
+
|
|
248
|
+
loop_fractions.each do |loop_range|
|
|
249
|
+
loop_start_position = (loop_range[:start_fraction] * (bar_width - 1)).round.clamp(0, bar_width - 1)
|
|
250
|
+
loop_end_position = (loop_range[:end_fraction] * (bar_width - 1)).round.clamp(0, bar_width - 1)
|
|
251
|
+
next if loop_end_position <= loop_start_position
|
|
252
|
+
|
|
253
|
+
(loop_start_position..loop_end_position).each { |position| cell_colors[position] = :extra }
|
|
254
|
+
bar[loop_start_position] = '⟨'
|
|
255
|
+
bar[loop_end_position] = '⟩'
|
|
256
|
+
marker_positions[loop_start_position] = true
|
|
257
|
+
marker_positions[loop_end_position] = true
|
|
258
|
+
end
|
|
221
259
|
|
|
222
|
-
cue_position_colors = {} #: Hash[Integer, Symbol]
|
|
223
260
|
cue_fractions.each do |fraction|
|
|
224
261
|
position = (fraction * (bar_width - 1)).round.clamp(0, bar_width - 1)
|
|
225
|
-
|
|
262
|
+
bar[position] = '◆'
|
|
263
|
+
cell_colors[position] = position == filled ? :highlight : :surface
|
|
264
|
+
marker_positions[position] = true
|
|
226
265
|
end
|
|
227
|
-
cue_position_colors.each_key { |position| bar[position] = '◆' }
|
|
228
266
|
|
|
229
267
|
result = +''
|
|
230
268
|
run_start = 0
|
|
231
269
|
while run_start < bar_width
|
|
232
|
-
if
|
|
233
|
-
result << @ui.color(bar[run_start],
|
|
270
|
+
if marker_positions[run_start]
|
|
271
|
+
result << @ui.color(bar[run_start], cell_colors[run_start])
|
|
234
272
|
run_start += 1
|
|
235
273
|
else
|
|
236
|
-
run_end = run_start
|
|
237
|
-
run_end += 1 while run_end < bar_width && !
|
|
238
|
-
result << @ui.color((bar[run_start...run_end] || []).join,
|
|
274
|
+
run_end = run_start + 1
|
|
275
|
+
run_end += 1 while run_end < bar_width && !marker_positions[run_end] && cell_colors[run_end] == cell_colors[run_start]
|
|
276
|
+
result << @ui.color((bar[run_start...run_end] || []).join, cell_colors[run_start])
|
|
239
277
|
run_start = run_end
|
|
240
278
|
end
|
|
241
279
|
end
|
|
@@ -289,7 +327,8 @@ module Wavesync
|
|
|
289
327
|
pitch_shift = pitch_shift_semitones(current_bpm, track_bpm(@setlist.tracks[i + 1]))
|
|
290
328
|
render_track(i, relative_path(track), i == @selected, i == @player_index,
|
|
291
329
|
bpm: current_bpm, pitch_shift: pitch_shift, duration: current_duration,
|
|
292
|
-
duration_col_width: duration_col_width, cue_fractions: track_cue_fractions(track)
|
|
330
|
+
duration_col_width: duration_col_width, cue_fractions: track_cue_fractions(track),
|
|
331
|
+
loop_fractions: track_loop_fractions(track))
|
|
293
332
|
end
|
|
294
333
|
end
|
|
295
334
|
|
|
@@ -301,8 +340,8 @@ module Wavesync
|
|
|
301
340
|
flush_render(buffer)
|
|
302
341
|
end
|
|
303
342
|
|
|
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: [])
|
|
343
|
+
#: (Integer index, String relative, bool selected, bool playing, ?bpm: (String | Integer)?, ?pitch_shift: Float?, ?duration: Float?, ?duration_col_width: Integer, ?cue_fractions: Array[Float], ?loop_fractions: Array[{start_fraction: Float, end_fraction: Float}]) -> void
|
|
344
|
+
def render_track(index, relative, selected, playing, bpm: nil, pitch_shift: nil, duration: nil, duration_col_width: 0, cue_fractions: [], loop_fractions: [])
|
|
306
345
|
name = display_name(relative)
|
|
307
346
|
folder = File.dirname(relative)
|
|
308
347
|
icon = if playing
|
|
@@ -350,7 +389,7 @@ module Wavesync
|
|
|
350
389
|
|
|
351
390
|
if playing && @player_state != :stopped && duration
|
|
352
391
|
bar_width = [terminal_width - 5, 20].max
|
|
353
|
-
puts " #{playback_bar(playback_elapsed, duration, bar_width, cue_fractions: cue_fractions)}"
|
|
392
|
+
puts " #{playback_bar(playback_elapsed, duration, bar_width, cue_fractions: cue_fractions, loop_fractions: loop_fractions)}"
|
|
354
393
|
end
|
|
355
394
|
|
|
356
395
|
return unless pitch_shift
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
module Wavesync
|
|
5
|
+
class Timing
|
|
6
|
+
BUCKETS = %i[transcode probe ffmpeg_metadata copy wav_chunks mp4_chunks filesystem].freeze
|
|
7
|
+
|
|
8
|
+
#: () -> Timing
|
|
9
|
+
def self.current
|
|
10
|
+
@current ||= new
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
#: () -> void
|
|
14
|
+
def self.reset
|
|
15
|
+
@current = new
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
#: () -> void
|
|
19
|
+
def initialize
|
|
20
|
+
@totals = Hash.new(0.0) #: Hash[Symbol, Float]
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
#: (Symbol bucket) { () -> untyped } -> untyped
|
|
24
|
+
def measure(bucket)
|
|
25
|
+
start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
26
|
+
yield
|
|
27
|
+
ensure
|
|
28
|
+
@totals[bucket] += Process.clock_gettime(Process::CLOCK_MONOTONIC) - start
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
#: () -> Hash[Symbol, Float]
|
|
32
|
+
def totals
|
|
33
|
+
@totals.dup
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
module Wavesync
|
|
5
|
+
module Transliterator
|
|
6
|
+
COMBINING_MARKS = /\p{Mn}/
|
|
7
|
+
|
|
8
|
+
#: (String string) -> String
|
|
9
|
+
def self.transliterate(string)
|
|
10
|
+
string
|
|
11
|
+
.unicode_normalize(:nfd)
|
|
12
|
+
.gsub(COMBINING_MARKS, '')
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -40,8 +40,8 @@ module Wavesync
|
|
|
40
40
|
FileUtils.mkdir_p(@working_directory)
|
|
41
41
|
end
|
|
42
42
|
|
|
43
|
-
#: () ?{ (Integer, Integer, String) -> void } -> void
|
|
44
|
-
def prepare!(&progress)
|
|
43
|
+
#: (?stop_when: ^() -> bool) ?{ (Integer, Integer, String) -> void } -> void
|
|
44
|
+
def prepare!(stop_when: nil, &progress)
|
|
45
45
|
device_files = @libmtp.files
|
|
46
46
|
folder_paths = build_folder_paths(@libmtp.folders)
|
|
47
47
|
|
|
@@ -57,6 +57,8 @@ module Wavesync
|
|
|
57
57
|
|
|
58
58
|
Dir.mktmpdir('wavesync_mtp_pull') do |tmpdir|
|
|
59
59
|
candidates.each_with_index do |candidate, index|
|
|
60
|
+
break if stop_when&.call
|
|
61
|
+
|
|
60
62
|
progress&.call(index, candidates.size, candidate[:relative_path])
|
|
61
63
|
pull_if_cues_differ(candidate, tmpdir)
|
|
62
64
|
end
|
|
@@ -271,14 +273,18 @@ module Wavesync
|
|
|
271
273
|
local_path = candidate[:local_path]
|
|
272
274
|
tmp_path = File.join(tmpdir, "#{device_file.id}.wav")
|
|
273
275
|
|
|
274
|
-
|
|
276
|
+
begin
|
|
277
|
+
@libmtp.get_file(id: device_file.id, local_path: tmp_path)
|
|
275
278
|
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
+
device_cues = CueChunk.read(tmp_path)
|
|
280
|
+
local_cues = File.exist?(local_path) ? CueChunk.read(local_path) : [] #: Array[{identifier: Integer, sample_offset: Integer, label: String?, note: String?}]
|
|
281
|
+
return if CueChunk.same?(device_cues, local_cues)
|
|
279
282
|
|
|
280
|
-
|
|
281
|
-
|
|
283
|
+
FileUtils.mkdir_p(File.dirname(local_path))
|
|
284
|
+
FileUtils.mv(tmp_path, local_path)
|
|
285
|
+
ensure
|
|
286
|
+
FileUtils.rm_f(tmp_path)
|
|
287
|
+
end
|
|
282
288
|
end
|
|
283
289
|
end
|
|
284
290
|
end
|
data/lib/wavesync/version.rb
CHANGED
data/lib/wavesync.rb
CHANGED
|
@@ -4,9 +4,11 @@ module Wavesync
|
|
|
4
4
|
end
|
|
5
5
|
|
|
6
6
|
require 'wavesync/logger'
|
|
7
|
+
require 'wavesync/timing'
|
|
7
8
|
require 'wavesync/version'
|
|
8
9
|
require 'wavesync/acid_chunk'
|
|
9
10
|
require 'wavesync/cue_chunk'
|
|
11
|
+
require 'wavesync/mp4_tmpo'
|
|
10
12
|
require 'wavesync/audio_format'
|
|
11
13
|
require 'wavesync/ffmpeg'
|
|
12
14
|
require 'wavesync/audio'
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: wavesync
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.0.0.
|
|
4
|
+
version: 1.0.0.beta2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Andreas Zecher
|
|
@@ -98,13 +98,16 @@ files:
|
|
|
98
98
|
- lib/wavesync/file_converter.rb
|
|
99
99
|
- lib/wavesync/libmtp.rb
|
|
100
100
|
- lib/wavesync/logger.rb
|
|
101
|
+
- lib/wavesync/mp4_tmpo.rb
|
|
101
102
|
- lib/wavesync/path_resolver.rb
|
|
102
103
|
- lib/wavesync/percival_bpm_detector.rb
|
|
103
104
|
- lib/wavesync/python_venv.rb
|
|
104
105
|
- lib/wavesync/scanner.rb
|
|
105
106
|
- lib/wavesync/setlist.rb
|
|
106
107
|
- lib/wavesync/setlist_editor.rb
|
|
108
|
+
- lib/wavesync/timing.rb
|
|
107
109
|
- lib/wavesync/track_padding.rb
|
|
110
|
+
- lib/wavesync/transliterator.rb
|
|
108
111
|
- lib/wavesync/transport.rb
|
|
109
112
|
- lib/wavesync/transport/filesystem.rb
|
|
110
113
|
- lib/wavesync/transport/mtp.rb
|