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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3b3c24cfdb7c1e1e6fe97157437f690995f59a9981b0771f42f1669af101a31a
4
- data.tar.gz: 03c846e8a0c9e19d6a6b9f8a6cb72edc426a1db17dcd4cfdbcaa556c643a1eb1
3
+ metadata.gz: 6a763e53a386112a5a2311cf50108a3a237ae7442d1d9c53b98fb02696315a12
4
+ data.tar.gz: c9972d69bad12cfd4111067bcc34e9ec646f26e80b97df44a1c5a7762daf8abf
5
5
  SHA512:
6
- metadata.gz: 7f3ce9462f43a8c1fd453df2eb1919fdf6c21ef5f2efa95aec8effdb737309801f1c94b3ec31353a766e448f7c14148208628f4f0f80c86e0b19079fef6f6ac0
7
- data.tar.gz: a51decb6dc314e43c134fa11b5f0332eddbde5616b2051bafa645861b386dc965f88f0d0725fc9a435d5d3206f1e8af8ad38d26b204eb49072b5e2a98daaa763
6
+ metadata.gz: 4964e829b8b3488f16e8a39857bfd178c624b94bb9739195d987d17cb20c57fb811e6d0a442dc6bf104ff7f18df658b40c038decbcc813a2d0f332e9d1384315
7
+ data.tar.gz: e96d2143e3667162200af22ffc97738cf36737cc0cd1a7f5ed2f3316b401544986d9a2a44841beb37e1e4059cf351986432a0783cbdfa1e29c9e0f3777b8e401
data/README.md CHANGED
@@ -27,36 +27,18 @@ Unsupported file types will be ignored when syncing.
27
27
 
28
28
  ```bash
29
29
  brew install ffmpeg
30
- brew install taglib
31
- ```
32
-
33
- 3. Install Wavesync
34
-
35
- ```bash
36
- gem install wavesync --pre
37
- ```
38
-
39
- 4. Set up the Python environment for BPM analysis
40
-
41
- ```bash
30
+ brew install libmtp
42
31
  brew install python@3.11
43
32
  python3.11 -m venv ~/.wavesync-venv
44
33
  ~/.wavesync-venv/bin/pip install essentia
45
34
  ```
46
35
 
47
- 5. Install libmtp (only required for syncing to TP-7)
36
+ 3. Install Wavesync
48
37
 
49
38
  ```bash
50
- brew install libmtp
39
+ gem install wavesync --pre
51
40
  ```
52
41
 
53
- Recent versions of field kit no longer expose the TP-7 as a filesystem path, so wavesync talks to the device over MTP directly.
54
-
55
- Before syncing the TP-7:
56
-
57
- - Quit field kit and any other app that may claim the TP-7. They cannot run at the same time as wavesync — only one process can hold the MTP session.
58
- - Put the TP-7 into MTP mode: hold down the Stop button while turning the TP-7 on, and keep holding until MTP mode is engaged.
59
-
60
42
  ## Configuration
61
43
 
62
44
  Wavesync is configured via a YAML file. By default it looks for `~/wavesync.yml`. You can also pass a path explicitly with the `-c` flag.
@@ -108,6 +90,12 @@ wavesync analyze
108
90
 
109
91
  # Overwrite existing BPM values
110
92
  wavesync analyze -f
93
+
94
+ # Analyze a single file instead of the whole library
95
+ wavesync analyze "/path/to/track.m4a"
96
+
97
+ # Analyze all files in a folder instead of the whole library
98
+ wavesync analyze "/path/to/folder"
111
99
  ```
112
100
 
113
101
  ### Sync
@@ -127,6 +115,11 @@ wavesync sync -p
127
115
 
128
116
  When a source file's sample rate isn't supported by the target device, Wavesync selects the closest supported rate. Example: If a 96kHz file is synced to an Octatrack (which only supports 44.1kHz), it will be downsampled to 44.1kHz.
129
117
 
118
+ Before syncing the TP-7:
119
+
120
+ - Quit field kit and any other app that may claim the TP-7. They cannot run at the same time as wavesync — only one process can hold the MTP session.
121
+ - Put the TP-7 into MTP mode: hold down the Stop button while turning the TP-7 on, and keep holding until MTP mode is engaged.
122
+
130
123
  ### Pull
131
124
 
132
125
  ```bash
data/config/devices.yml CHANGED
@@ -17,6 +17,7 @@ devices:
17
17
  unsupported_characters:
18
18
  - "™"
19
19
  - '"'
20
+ transliterate_paths: true
20
21
  uppercase_paths: true
21
22
 
22
23
  - name: Octatrack
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
  # rbs_inline: enabled
3
3
 
4
+ require_relative 'timing'
5
+
4
6
  module Wavesync
5
7
  class AcidChunk
6
8
  RIFF_HEADER_SIZE = 12 # 'RIFF' (4) + size (4) + 'WAVE' (4)
@@ -30,107 +32,115 @@ module Wavesync
30
32
 
31
33
  #: (String filepath) -> Float?
32
34
  def self.read_bpm(filepath)
33
- File.open(filepath, 'rb') do |file|
34
- file.seek(RIFF_HEADER_SIZE)
35
- until file.eof?
36
- chunk_id = file.read(CHUNK_ID_SIZE)
37
- break if chunk_id.nil? || chunk_id.length < CHUNK_ID_SIZE
38
-
39
- chunk_size = ((file.read(CHUNK_SIZE_FIELD_SIZE) || '').unpack1(UINT32_LITTLE_ENDIAN) || 0).to_i
40
- if chunk_id == ACID_CHUNK_ID
41
- file.seek(ACID_TEMPO_OFFSET, IO::SEEK_CUR)
42
- return ((file.read(ACID_TEMPO_SIZE) || '').unpack1(FLOAT32_LITTLE_ENDIAN) || 0).to_f
43
- else
44
- padding = chunk_size.odd? ? 1 : 0
45
- file.seek(chunk_size + padding, IO::SEEK_CUR)
35
+ Timing.current.measure(:wav_chunks) do
36
+ result = nil #: Float?
37
+ File.open(filepath, 'rb') do |file|
38
+ file.seek(RIFF_HEADER_SIZE)
39
+ until file.eof?
40
+ chunk_id = file.read(CHUNK_ID_SIZE)
41
+ break if chunk_id.nil? || chunk_id.length < CHUNK_ID_SIZE
42
+
43
+ chunk_size = ((file.read(CHUNK_SIZE_FIELD_SIZE) || '').unpack1(UINT32_LITTLE_ENDIAN) || 0).to_i
44
+ if chunk_id == ACID_CHUNK_ID
45
+ file.seek(ACID_TEMPO_OFFSET, IO::SEEK_CUR)
46
+ result = ((file.read(ACID_TEMPO_SIZE) || '').unpack1(FLOAT32_LITTLE_ENDIAN) || 0).to_f
47
+ break
48
+ else
49
+ padding = chunk_size.odd? ? 1 : 0
50
+ file.seek(chunk_size + padding, IO::SEEK_CUR)
51
+ end
46
52
  end
47
53
  end
54
+ result
48
55
  end
49
- nil
50
56
  end
51
57
 
52
58
  #: (String filepath, Integer | Float | String new_bpm) -> void
53
59
  def self.write_bpm_in_place(filepath, new_bpm)
54
- bpm_bytes = [new_bpm.to_f].pack(FLOAT32_LITTLE_ENDIAN)
60
+ Timing.current.measure(:wav_chunks) do
61
+ bpm_bytes = [new_bpm.to_f].pack(FLOAT32_LITTLE_ENDIAN)
55
62
 
56
- File.open(filepath, 'r+b') do |file|
57
- file.seek(RIFF_HEADER_SIZE)
63
+ File.open(filepath, 'r+b') do |file|
64
+ file.seek(RIFF_HEADER_SIZE)
58
65
 
59
- acid_chunk_found = false
66
+ acid_chunk_found = false
60
67
 
61
- until file.eof?
62
- chunk_id = file.read(CHUNK_ID_SIZE)
63
- break if chunk_id.nil? || chunk_id.length < CHUNK_ID_SIZE
64
-
65
- chunk_size_bytes = file.read(CHUNK_SIZE_FIELD_SIZE) || ''
66
- chunk_size = (chunk_size_bytes.unpack1(UINT32_LITTLE_ENDIAN) || 0).to_i
67
-
68
- if chunk_id == ACID_CHUNK_ID
69
- file.seek(ACID_TEMPO_OFFSET, IO::SEEK_CUR)
70
- file.write(bpm_bytes)
71
- acid_chunk_found = true
72
- break
73
- else
74
- padding = chunk_size.odd? ? 1 : 0
75
- file.seek(chunk_size + padding, IO::SEEK_CUR)
68
+ until file.eof?
69
+ chunk_id = file.read(CHUNK_ID_SIZE)
70
+ break if chunk_id.nil? || chunk_id.length < CHUNK_ID_SIZE
71
+
72
+ chunk_size_bytes = file.read(CHUNK_SIZE_FIELD_SIZE) || ''
73
+ chunk_size = (chunk_size_bytes.unpack1(UINT32_LITTLE_ENDIAN) || 0).to_i
74
+
75
+ if chunk_id == ACID_CHUNK_ID
76
+ file.seek(ACID_TEMPO_OFFSET, IO::SEEK_CUR)
77
+ file.write(bpm_bytes)
78
+ acid_chunk_found = true
79
+ break
80
+ else
81
+ padding = chunk_size.odd? ? 1 : 0
82
+ file.seek(chunk_size + padding, IO::SEEK_CUR)
83
+ end
76
84
  end
77
- end
78
85
 
79
- unless acid_chunk_found
80
- file.seek(0, IO::SEEK_END)
81
- create_acid_chunk(file, new_bpm.to_f)
86
+ unless acid_chunk_found
87
+ file.seek(0, IO::SEEK_END)
88
+ create_acid_chunk(file, new_bpm.to_f)
89
+ end
82
90
  end
83
- end
84
91
 
85
- update_riff_size(filepath)
92
+ update_riff_size(filepath)
93
+ end
86
94
  end
87
95
 
88
96
  #: (String source_filepath, String output_filepath, Integer | Float | String new_bpm) -> void
89
97
  def self.write_bpm(source_filepath, output_filepath, new_bpm)
90
- bpm_bytes = [new_bpm.to_f].pack(FLOAT32_LITTLE_ENDIAN)
91
- acid_chunk_found = false
98
+ Timing.current.measure(:wav_chunks) do
99
+ bpm_bytes = [new_bpm.to_f].pack(FLOAT32_LITTLE_ENDIAN)
100
+ acid_chunk_found = false
92
101
 
93
- File.open(source_filepath, 'rb') do |input|
94
- File.open(output_filepath, 'wb') do |output|
95
- riff_header = input.read(RIFF_HEADER_SIZE)
96
- output.write(riff_header)
102
+ File.open(source_filepath, 'rb') do |input|
103
+ File.open(output_filepath, 'wb') do |output|
104
+ riff_header = input.read(RIFF_HEADER_SIZE)
105
+ output.write(riff_header)
97
106
 
98
- until input.eof?
99
- chunk_id = input.read(CHUNK_ID_SIZE)
100
- break if chunk_id.nil? || chunk_id.length < CHUNK_ID_SIZE
107
+ until input.eof?
108
+ chunk_id = input.read(CHUNK_ID_SIZE)
109
+ break if chunk_id.nil? || chunk_id.length < CHUNK_ID_SIZE
101
110
 
102
- chunk_size_bytes = input.read(CHUNK_SIZE_FIELD_SIZE) || ''
103
- chunk_size = (chunk_size_bytes.unpack1(UINT32_LITTLE_ENDIAN) || 0).to_i
111
+ chunk_size_bytes = input.read(CHUNK_SIZE_FIELD_SIZE) || ''
112
+ chunk_size = (chunk_size_bytes.unpack1(UINT32_LITTLE_ENDIAN) || 0).to_i
104
113
 
105
- output.write(chunk_id)
106
- output.write(chunk_size_bytes)
114
+ output.write(chunk_id)
115
+ output.write(chunk_size_bytes)
107
116
 
108
- if chunk_id == ACID_CHUNK_ID
109
- acid_chunk_found = true
117
+ if chunk_id == ACID_CHUNK_ID
118
+ acid_chunk_found = true
110
119
 
111
- output.write(input.read(ACID_TEMPO_OFFSET))
120
+ output.write(input.read(ACID_TEMPO_OFFSET))
112
121
 
113
- input.read(ACID_TEMPO_SIZE)
122
+ input.read(ACID_TEMPO_SIZE)
114
123
 
115
- output.write(bpm_bytes)
124
+ output.write(bpm_bytes)
116
125
 
117
- remaining = chunk_size - ACID_TEMPO_OFFSET - ACID_TEMPO_SIZE
118
- output.write(input.read(remaining)) if remaining.positive?
126
+ remaining = chunk_size - ACID_TEMPO_OFFSET - ACID_TEMPO_SIZE
127
+ output.write(input.read(remaining)) if remaining.positive?
119
128
 
120
- output.write(input.read(1)) if chunk_size.odd?
121
- else
122
- padding = chunk_size.odd? ? 1 : 0
123
- output.write(input.read(chunk_size + padding))
129
+ output.write(input.read(1)) if chunk_size.odd?
130
+ else
131
+ padding = chunk_size.odd? ? 1 : 0
132
+ output.write(input.read(chunk_size + padding))
133
+ end
124
134
  end
125
- end
126
135
 
127
- create_acid_chunk(output, new_bpm.to_f) unless acid_chunk_found
136
+ create_acid_chunk(output, new_bpm.to_f) unless acid_chunk_found
137
+ end
128
138
  end
129
- end
130
139
 
131
- return if acid_chunk_found
140
+ next if acid_chunk_found
132
141
 
133
- update_riff_size(output_filepath)
142
+ update_riff_size(output_filepath)
143
+ end
134
144
  end
135
145
 
136
146
  #: (untyped output, Float bpm) -> void
@@ -5,7 +5,6 @@ require_relative 'logger'
5
5
 
6
6
  module Wavesync
7
7
  class Analyzer
8
- CONFIRM_MESSAGE = 'wavesync analyze will add bpm meta data to files in library. Continue? [y/N] '
9
8
  SETUP_INSTRUCTIONS = 'brew install python@3.11 && python3.11 -m venv ~/.wavesync-venv && ~/.wavesync-venv/bin/pip install essentia'
10
9
 
11
10
  #: (String library_path) -> void
@@ -16,29 +15,30 @@ module Wavesync
16
15
  @ui = UI.new #: UI
17
16
  end
18
17
 
19
- #: (?overwrite: bool) -> void
20
- def analyze(overwrite: false)
18
+ #: (?overwrite: bool, ?path: String?) -> void
19
+ def analyze(overwrite: false, path: nil)
21
20
  unless BpmDetector.available?
22
21
  puts "Error: essentia is not installed. Set up the Python venv with:\n #{SETUP_INSTRUCTIONS}"
23
22
  exit 1
24
23
  end
25
24
 
26
- return unless @ui.confirm(CONFIRM_MESSAGE)
25
+ files = files_for(path)
26
+ return unless @ui.confirm(confirm_message(path))
27
27
 
28
28
  start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
29
29
 
30
- @audio_files.each_with_index do |file, index|
30
+ files.each_with_index do |file, index|
31
31
  audio = Audio.new(file)
32
32
 
33
33
  if audio.bpm && !overwrite
34
- @ui.analyze_progress(index, @audio_files.size)
34
+ @ui.analyze_progress(index, files.size)
35
35
  @ui.analyze_skip(file, audio.bpm)
36
36
  next
37
37
  end
38
38
 
39
39
  bpm = BpmDetector.detect(file)
40
40
  audio.write_bpm(bpm) if bpm
41
- @ui.analyze_progress(index, @audio_files.size)
41
+ @ui.analyze_progress(index, files.size)
42
42
  @ui.analyze_result(file, bpm)
43
43
  end
44
44
 
@@ -48,6 +48,28 @@ module Wavesync
48
48
 
49
49
  private
50
50
 
51
+ #: (String? path) -> String
52
+ def confirm_message(path)
53
+ target =
54
+ if path.nil?
55
+ 'files in library'
56
+ elsif File.directory?(File.expand_path(path))
57
+ 'files in folder'
58
+ else
59
+ 'file'
60
+ end
61
+
62
+ "wavesync analyze will add bpm meta data to #{target}. Continue? [y/N] "
63
+ end
64
+
65
+ #: (String? path) -> Array[String]
66
+ def files_for(path)
67
+ return @audio_files unless path
68
+
69
+ expanded_path = File.expand_path(path)
70
+ File.directory?(expanded_path) ? Audio.find_all(expanded_path) : [expanded_path]
71
+ end
72
+
51
73
  #: () -> Array[String]
52
74
  def find_audio_files
53
75
  Audio.find_all(@library_path).sort
@@ -5,6 +5,9 @@ require 'securerandom'
5
5
  require 'tmpdir'
6
6
  require 'fileutils'
7
7
  require_relative 'logger'
8
+ require_relative 'timing'
9
+ require_relative 'transliterator'
10
+ require_relative 'mp4_tmpo'
8
11
 
9
12
  module Wavesync
10
13
  class Audio
@@ -61,14 +64,19 @@ module Wavesync
61
64
  )
62
65
  end
63
66
 
64
- #: () -> Array[{identifier: Integer, sample_offset: Integer, label: String?}]
67
+ #: () -> Array[{identifier: Integer, sample_offset: Integer, label: String?, note: String?}]
65
68
  def cue_points
66
69
  return [] unless @file_ext == '.wav'
67
70
 
68
71
  CueChunk.read(@file_path)
69
72
  end
70
73
 
71
- #: (Array[{identifier: Integer, sample_offset: Integer, label: String?}] cue_points) -> void
74
+ #: () -> Array[{start_sample: Integer, end_sample: Integer}]
75
+ def loops
76
+ CueChunk.loops(cue_points)
77
+ end
78
+
79
+ #: (Array[{identifier: Integer, sample_offset: Integer, label: String?, note: String?}] cue_points) -> void
72
80
  def write_cue_points(cue_points)
73
81
  temp_path = "#{@file_path}.tmp"
74
82
  CueChunk.write(@file_path, temp_path, cue_points)
@@ -97,8 +105,6 @@ module Wavesync
97
105
 
98
106
  FFMPEG_KEY_TO_FRAME_ID = FRAME_ID_TO_FFMPEG_KEY.invert.freeze
99
107
 
100
- COMBINING_MARKS = /\p{Mn}/
101
-
102
108
  #: () -> Hash[String, String]
103
109
  def transliterated_tag_changes
104
110
  current_tags = @audio.tags
@@ -154,11 +160,11 @@ module Wavesync
154
160
  command.audio_filter("apad=whole_dur=#{total_duration.round(6)}")
155
161
  end
156
162
  metadata.each { |key, value| command.metadata(key, value) }
157
- command.run(temp_path)
163
+ Timing.current.measure(:transcode) { command.run(temp_path) }
158
164
  yield temp_path if block_given?
159
- FileUtils.install(temp_path, target_path)
165
+ Timing.current.measure(:copy) { FileUtils.install(temp_path, target_path) }
160
166
  true
161
- rescue Errno::ENOENT => e
167
+ rescue SystemCallError => e
162
168
  Logger.log_error(e, call_site: 'Audio#transcode', arguments: { target_path:, target_sample_rate:, target_file_type:, target_bit_depth:, padding_seconds:, target_bitrate: })
163
169
  false
164
170
  ensure
@@ -191,11 +197,7 @@ module Wavesync
191
197
 
192
198
  #: () -> Integer?
193
199
  def bpm_from_m4a
194
- value = find_in_tags(@audio.tags, 'BPM')
195
- return nil if value.nil?
196
-
197
- int_value = value.to_i
198
- int_value.zero? ? nil : int_value
200
+ Mp4Tmpo.read_bpm(@file_path)
199
201
  end
200
202
 
201
203
  #: () -> String?
@@ -243,7 +245,7 @@ module Wavesync
243
245
 
244
246
  #: (String | Integer | Float bpm) -> void
245
247
  def write_bpm_to_m4a(bpm)
246
- write_file_metadata('BPM' => bpm.to_i.to_s)
248
+ Mp4Tmpo.write_bpm(@file_path, bpm)
247
249
  end
248
250
 
249
251
  #: (String | Integer | Float bpm) -> void
@@ -256,10 +258,9 @@ module Wavesync
256
258
  ext = File.extname(@file_path)
257
259
  temp_path = File.join(Dir.tmpdir, "wavesync_meta_#{SecureRandom.hex}#{ext}")
258
260
  command = FFMPEG.new.input(@file_path).copy_streams.map_metadata(0)
259
- command.movflags('+use_metadata_tags') if ext == '.m4a'
260
261
  command.write_id3v2(1) if %w[.aif .aiff].include?(ext)
261
262
  metadata_hash.each { |key, value| command.metadata(key, value) }
262
- command.run(temp_path)
263
+ Timing.current.measure(:ffmpeg_metadata) { command.run(temp_path) }
263
264
  FileUtils.mv(temp_path, @file_path)
264
265
  ensure
265
266
  FileUtils.rm_f(temp_path)
@@ -267,9 +268,7 @@ module Wavesync
267
268
 
268
269
  #: (String string) -> String
269
270
  def transliterate(string)
270
- string
271
- .unicode_normalize(:nfd)
272
- .gsub(COMBINING_MARKS, '')
271
+ Transliterator.transliterate(string)
273
272
  end
274
273
  end
275
274
  end
@@ -11,14 +11,16 @@ module Wavesync
11
11
  self.name = 'analyze'
12
12
  self.description = 'Detect and write BPM metadata to library tracks'
13
13
  self.options = [FORCE_OPTION].freeze
14
+ self.positional_args = '[file_or_folder]'
14
15
 
15
16
  #: () -> void
16
17
  def run
17
- options, config = parse_options(banner: 'Usage: wavesync analyze [options]') do |opts, opts_hash|
18
+ options, config = parse_options(banner: 'Usage: wavesync analyze [options] [file_or_folder]') do |opts, opts_hash|
18
19
  opts.on(*FORCE_OPTION.to_a) { opts_hash[:overwrite] = true }
19
20
  end
20
21
 
21
- Wavesync::Analyzer.new(config.library).analyze(overwrite: options[:overwrite] || false)
22
+ path = ARGV.shift
23
+ Wavesync::Analyzer.new(config.library).analyze(overwrite: options[:overwrite] || false, path: path)
22
24
  end
23
25
  end
24
26
  end
@@ -7,10 +7,11 @@ module Wavesync
7
7
  class Command
8
8
  class << self
9
9
  attr_accessor :name, :description
10
- attr_writer :options, :subcommands
10
+ attr_writer :options, :subcommands, :positional_args
11
11
 
12
12
  def options = @options || []
13
13
  def subcommands = @subcommands || []
14
+ def positional_args = @positional_args || ''
14
15
  end
15
16
 
16
17
  #: (banner: String) ?{ (OptionParser, Hash[Symbol, untyped]) -> void } -> [Hash[Symbol, untyped], Config]
@@ -65,7 +65,9 @@ module Wavesync
65
65
  GLOBAL_OPTIONS.each { |option| puts " #{option.short}, #{option.long} #{option.description}" }
66
66
  else
67
67
  OptionParser.new do |opts|
68
- opts.banner = "Usage: wavesync #{command.name} [options]"
68
+ banner = "Usage: wavesync #{command.name} [options]"
69
+ banner += " #{command.positional_args}" unless command.positional_args.empty?
70
+ opts.banner = banner
69
71
  (command.options + GLOBAL_OPTIONS).each { |option| opts.on(*option.to_a) }
70
72
  puts opts
71
73
  end
@@ -23,19 +23,36 @@ module Wavesync
23
23
  scanner = Wavesync::Scanner.new(config.library)
24
24
  ui = Wavesync::UI.new
25
25
 
26
- device_pairs.each do |pair|
27
- device_config = pair[0] #: { name: String, model: String, path: String, transport: String, mp3_bitrate: Integer }
28
- device = pair[1] #: Wavesync::Device
29
- transport = Wavesync::Transport.for(device_config)
30
- Commands.with_mtp_retry(transport, device_config[:name]) do
31
- next unless transport.is_a?(Wavesync::Transport::Mtp)
32
-
33
- transport.prepare! do |index, total, relative_path|
34
- ui.pull_staging_progress(index, total, device)
35
- ui.file_progress(relative_path)
26
+ stop_requested = false
27
+ original_handler = Signal.trap('INT') do
28
+ if stop_requested
29
+ Signal.trap('INT', 'DEFAULT')
30
+ warn "\nAborting."
31
+ Process.kill('INT', Process.pid)
32
+ else
33
+ stop_requested = true
34
+ warn "\nFinishing current file then stopping. Press Ctrl+C again to abort immediately."
35
+ end
36
+ end
37
+
38
+ begin
39
+ device_pairs.each do |pair|
40
+ device_config = pair[0] #: { name: String, model: String, path: String, transport: String, mp3_bitrate: Integer }
41
+ device = pair[1] #: Wavesync::Device
42
+ transport = Wavesync::Transport.for(device_config)
43
+ Commands.with_mtp_retry(transport, device_config[:name]) do
44
+ next unless transport.is_a?(Wavesync::Transport::Mtp)
45
+
46
+ transport.prepare!(stop_when: -> { stop_requested }) do |index, total, relative_path|
47
+ ui.pull_staging_progress(index, total, device)
48
+ ui.file_progress(relative_path)
49
+ end
36
50
  end
51
+ scanner.pull_cue_points(transport.working_directory, device)
52
+ break if stop_requested
37
53
  end
38
- scanner.pull_cue_points(transport.working_directory, device)
54
+ ensure
55
+ Signal.trap('INT', original_handler)
39
56
  end
40
57
  end
41
58
  end