wavesync 1.0.0.beta1 → 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.
@@ -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
@@ -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 Errno::ENOENT => e
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
- return [] if path.nil?
95
+ track_markers(path)[:cues]
96
+ end
93
97
 
94
- @track_cue_fractions ||= {} #: Hash[String, Array[Float]]
95
- return @track_cue_fractions[path] if @track_cue_fractions.key?(path)
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
- @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
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
- cue_position_colors[position] = position == filled ? :highlight : :surface
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 cue_position_colors.key?(run_start)
233
- result << @ui.color(bar[run_start], cue_position_colors[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 && !cue_position_colors.key?(run_end)
238
- result << @ui.color((bar[run_start...run_end] || []).join, :surface)
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
- @libmtp.get_file(id: device_file.id, local_path: tmp_path)
276
+ begin
277
+ @libmtp.get_file(id: device_file.id, local_path: tmp_path)
275
278
 
276
- device_cues = CueChunk.read(tmp_path)
277
- local_cues = File.exist?(local_path) ? CueChunk.read(local_path) : [] #: Array[{identifier: Integer, sample_offset: Integer, label: String?}]
278
- return if CueChunk.same?(device_cues, local_cues)
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
- FileUtils.mkdir_p(File.dirname(local_path))
281
- FileUtils.mv(tmp_path, local_path)
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
@@ -2,5 +2,5 @@
2
2
  # rbs_inline: enabled
3
3
 
4
4
  module Wavesync
5
- VERSION = '1.0.0.beta1'
5
+ VERSION = '1.0.0.beta2'
6
6
  end
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.beta1
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