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.
@@ -1,30 +1,28 @@
1
1
  # frozen_string_literal: true
2
+ # rbs_inline: enabled
2
3
 
3
4
  module Wavesync
4
5
  module Commands
5
- Option = Struct.new(:short, :long, :description, keyword_init: true)
6
- Subcommand = Struct.new(:usage, :description, keyword_init: true)
6
+ Option = Struct.new(:short, :long, :description)
7
+ Subcommand = Struct.new(:usage, :description)
7
8
 
8
9
  CONFIG_OPTION = Option.new(short: '-c', long: '--config PATH', description: 'Path to wavesync config YAML file')
9
10
  GLOBAL_OPTIONS = [CONFIG_OPTION].freeze
10
11
 
12
+ #: (String path) -> Config
11
13
  def self.load_config(path)
12
14
  Wavesync::Config.load(path)
13
15
  rescue Wavesync::ConfigError => e
14
16
  puts "Configuration error: #{e.message}"
15
17
  exit 1
16
18
  end
17
- end
18
- end
19
19
 
20
- require_relative 'commands/command'
21
- require_relative 'commands/sync'
22
- require_relative 'commands/analyze'
23
- require_relative 'commands/set'
24
- require_relative 'commands/help'
20
+ require_relative 'commands/command'
21
+ require_relative 'commands/sync'
22
+ require_relative 'commands/analyze'
23
+ require_relative 'commands/set'
24
+ require_relative 'commands/help'
25
25
 
26
- module Wavesync
27
- module Commands
28
26
  ALL = [Sync, Analyze, Set, Help].freeze
29
27
  end
30
28
  end
@@ -1,4 +1,5 @@
1
1
  # frozen_string_literal: true
2
+ # rbs_inline: enabled
2
3
 
3
4
  require 'yaml'
4
5
 
@@ -12,8 +13,10 @@ module Wavesync
12
13
  DEVICE_SUPPORTED_KEYS = %w[name model path].freeze
13
14
  DEVICE_REQUIRED_KEYS = %w[name model path].freeze
14
15
 
15
- attr_reader :library, :device_configs
16
+ attr_reader :library #: String
17
+ attr_reader :device_configs #: Array[{ name: String, model: String, path: String }]
16
18
 
19
+ #: (?String path) -> Config
17
20
  def self.load(path = DEFAULT_PATH)
18
21
  expanded = File.expand_path(path)
19
22
  begin
@@ -26,6 +29,7 @@ module Wavesync
26
29
  new(data)
27
30
  end
28
31
 
32
+ #: (untyped data) -> void
29
33
  def initialize(data)
30
34
  validate!(data)
31
35
  @library = File.expand_path(data['library'])
@@ -37,6 +41,7 @@ module Wavesync
37
41
 
38
42
  private
39
43
 
44
+ #: (untyped data) -> void
40
45
  def validate!(data)
41
46
  raise ConfigError, 'Config file must contain a YAML mapping' unless data.is_a?(Hash)
42
47
 
@@ -52,6 +57,7 @@ module Wavesync
52
57
  raise ConfigError, "'devices' must contain at least one device" if data['devices'].empty?
53
58
  end
54
59
 
60
+ #: (untyped device, Integer index) -> void
55
61
  def validate_device!(device, index)
56
62
  raise ConfigError, "Device #{index + 1} must be a YAML mapping" unless device.is_a?(Hash)
57
63
 
@@ -0,0 +1,179 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ module Wavesync
5
+ class CueChunk
6
+ RIFF_HEADER_SIZE = 12
7
+ CHUNK_ID_SIZE = 4
8
+ CHUNK_SIZE_FIELD_SIZE = 4
9
+ CUE_CHUNK_ID = 'cue '
10
+ LIST_CHUNK_ID = 'LIST'
11
+ ADTL_LIST_TYPE = 'adtl'
12
+ LABL_CHUNK_ID = 'labl'
13
+ DATA_CHUNK_ID = 'data'
14
+ CUE_HEADER_SIZE = 4
15
+ BYTES_PER_CUE_POINT = 24
16
+
17
+ UINT32_LE = 'V'
18
+
19
+ #: (String filepath) -> Array[{identifier: Integer, sample_offset: Integer, label: String?}]
20
+ def self.read(filepath)
21
+ cue_points = [] #: Array[{identifier: Integer, sample_offset: Integer, label: String?}]
22
+ labels = {} #: Hash[Integer, String]
23
+
24
+ File.open(filepath, 'rb') do |file|
25
+ file.seek(RIFF_HEADER_SIZE)
26
+
27
+ until file.eof?
28
+ chunk_id = file.read(CHUNK_ID_SIZE)
29
+ break if chunk_id.nil? || chunk_id.length < CHUNK_ID_SIZE
30
+
31
+ chunk_size_bytes = file.read(CHUNK_SIZE_FIELD_SIZE)
32
+ break if chunk_size_bytes.nil?
33
+
34
+ chunk_size = chunk_size_bytes.unpack1(UINT32_LE).to_i
35
+ chunk_data_start = file.tell
36
+
37
+ if chunk_id == CUE_CHUNK_ID
38
+ num_cues = file.read(4)&.unpack1(UINT32_LE).to_i
39
+ num_cues.times do
40
+ identifier = file.read(4)&.unpack1(UINT32_LE)&.to_i
41
+ file.read(16) # skip position, fcc_chunk, chunk_start, block_start
42
+ sample_offset = file.read(4)&.unpack1(UINT32_LE)&.to_i
43
+ cue_points << { identifier: identifier, sample_offset: sample_offset, label: nil } if identifier && sample_offset
44
+ end
45
+ elsif chunk_id == LIST_CHUNK_ID && chunk_size >= 4
46
+ list_type = file.read(4)
47
+ read_adtl_labels(file, chunk_data_start + chunk_size, labels) if list_type == ADTL_LIST_TYPE
48
+ end
49
+
50
+ chunk_padding = chunk_size.odd? ? 1 : 0
51
+ file.seek(chunk_data_start + chunk_size + chunk_padding)
52
+ end
53
+ end
54
+
55
+ cue_points.map { |cue_point| { identifier: cue_point[:identifier], sample_offset: cue_point[:sample_offset], label: labels[cue_point[:identifier]] } }
56
+ end
57
+
58
+ #: (String source_filepath, String output_filepath, Array[{identifier: Integer, sample_offset: Integer, label: String?}] cue_points) -> void
59
+ def self.write(source_filepath, output_filepath, cue_points)
60
+ File.open(source_filepath, 'rb') do |input|
61
+ File.open(output_filepath, 'wb') do |output|
62
+ output.write(input.read(RIFF_HEADER_SIZE))
63
+
64
+ until input.eof?
65
+ chunk_id = input.read(CHUNK_ID_SIZE)
66
+ break if chunk_id.nil? || chunk_id.length < CHUNK_ID_SIZE
67
+
68
+ chunk_size_bytes = input.read(CHUNK_SIZE_FIELD_SIZE)
69
+ break if chunk_size_bytes.nil?
70
+
71
+ chunk_size = chunk_size_bytes.unpack1(UINT32_LE).to_i
72
+ chunk_padding = chunk_size.odd? ? 1 : 0
73
+
74
+ if chunk_id == CUE_CHUNK_ID
75
+ input.read(chunk_size + chunk_padding)
76
+ elsif chunk_id == LIST_CHUNK_ID && chunk_size >= 4
77
+ list_type = input.read(4)
78
+ if list_type == ADTL_LIST_TYPE
79
+ input.read(chunk_size - 4 + chunk_padding)
80
+ else
81
+ output.write(chunk_id)
82
+ output.write(chunk_size_bytes)
83
+ output.write(list_type)
84
+ output.write(input.read(chunk_size - 4 + chunk_padding))
85
+ end
86
+ else
87
+ output.write(chunk_id)
88
+ output.write(chunk_size_bytes)
89
+ output.write(input.read(chunk_size + chunk_padding))
90
+ end
91
+ end
92
+
93
+ unless cue_points.empty?
94
+ write_cue_chunk(output, cue_points)
95
+ labeled_cue_points = cue_points.select { |cue_point| cue_point[:label] }
96
+ write_adtl_chunk(output, labeled_cue_points) if labeled_cue_points.any?
97
+ end
98
+ end
99
+ end
100
+
101
+ update_riff_size(output_filepath)
102
+ end
103
+
104
+ #: (untyped output, Array[{identifier: Integer, sample_offset: Integer, label: String?}] cue_points) -> void
105
+ def self.write_cue_chunk(output, cue_points)
106
+ chunk_size = CUE_HEADER_SIZE + (cue_points.size * BYTES_PER_CUE_POINT)
107
+ output.write(CUE_CHUNK_ID)
108
+ output.write([chunk_size].pack(UINT32_LE))
109
+ output.write([cue_points.size].pack(UINT32_LE))
110
+ cue_points.each_with_index do |cue_point, index|
111
+ identifier = cue_point[:identifier] || (index + 1)
112
+ output.write([identifier].pack(UINT32_LE))
113
+ output.write([0].pack(UINT32_LE))
114
+ output.write(DATA_CHUNK_ID)
115
+ output.write([0].pack(UINT32_LE))
116
+ output.write([0].pack(UINT32_LE))
117
+ output.write([cue_point[:sample_offset]].pack(UINT32_LE))
118
+ end
119
+ end
120
+
121
+ #: (untyped output, Array[{identifier: Integer, sample_offset: Integer, label: String?}] cue_points) -> void
122
+ def self.write_adtl_chunk(output, cue_points)
123
+ labl_entries = cue_points.map do |cue_point|
124
+ text = "#{cue_point[:label]}\x00"
125
+ data_size = 4 + text.length
126
+ pad = data_size.odd? ? 1 : 0
127
+ { identifier: cue_point[:identifier], text: text, data_size: data_size, pad: pad }
128
+ end #: Array[{identifier: Integer, text: String, data_size: Integer, pad: Integer}]
129
+
130
+ adtl_data_size = 4 + labl_entries.sum { |entry| 8 + entry[:data_size] + entry[:pad] }
131
+ output.write(LIST_CHUNK_ID)
132
+ output.write([adtl_data_size].pack(UINT32_LE))
133
+ output.write(ADTL_LIST_TYPE)
134
+
135
+ labl_entries.each do |entry|
136
+ output.write(LABL_CHUNK_ID)
137
+ output.write([entry[:data_size]].pack(UINT32_LE))
138
+ output.write([entry[:identifier]].pack(UINT32_LE))
139
+ output.write(entry[:text])
140
+ output.write("\x00") if entry[:pad] == 1
141
+ end
142
+ end
143
+
144
+ #: (untyped file, Integer list_end, Hash[Integer, String] labels) -> void
145
+ def self.read_adtl_labels(file, list_end, labels)
146
+ while file.tell < list_end - 8
147
+ sub_id = file.read(CHUNK_ID_SIZE)
148
+ break if sub_id.nil? || sub_id.length < CHUNK_ID_SIZE
149
+
150
+ sub_size_bytes = file.read(CHUNK_SIZE_FIELD_SIZE)
151
+ break if sub_size_bytes.nil?
152
+
153
+ sub_size = sub_size_bytes.unpack1(UINT32_LE).to_i
154
+ sub_data_start = file.tell
155
+
156
+ if sub_id == LABL_CHUNK_ID && sub_size >= 4
157
+ identifier = file.read(4)&.unpack1(UINT32_LE)&.to_i
158
+ text_size = sub_size - 4
159
+ text = text_size.positive? ? (file.read(text_size) || '') : ''
160
+ labels[identifier] = text.delete("\x00") if identifier
161
+ end
162
+
163
+ sub_padding = sub_size.odd? ? 1 : 0
164
+ file.seek(sub_data_start + sub_size + sub_padding)
165
+ end
166
+ end
167
+
168
+ #: (String filepath) -> void
169
+ def self.update_riff_size(filepath)
170
+ File.open(filepath, 'r+b') do |file|
171
+ file.seek(0, IO::SEEK_END)
172
+ file_size = file.tell
173
+ riff_size = file_size - 8
174
+ file.seek(4)
175
+ file.write([riff_size].pack(UINT32_LE))
176
+ end
177
+ end
178
+ end
179
+ end
@@ -1,10 +1,17 @@
1
1
  # frozen_string_literal: true
2
+ # rbs_inline: enabled
2
3
 
3
4
  require 'yaml'
4
5
  module Wavesync
5
6
  class Device
6
- attr_reader :name, :sample_rates, :bit_depths, :file_types, :bpm_source, :bar_multiple
7
+ attr_reader :name #: String
8
+ attr_reader :sample_rates #: Array[Integer]
9
+ attr_reader :bit_depths #: Array[Integer]
10
+ attr_reader :file_types #: Array[String]
11
+ attr_reader :bpm_source #: Symbol?
12
+ attr_reader :bar_multiple #: Integer?
7
13
 
14
+ #: (name: String, sample_rates: Array[Integer], bit_depths: Array[Integer], file_types: Array[String], ?bpm_source: Symbol?, ?bar_multiple: Integer?) -> void
8
15
  def initialize(name:, sample_rates:, bit_depths:, file_types:, bpm_source: nil, bar_multiple: nil)
9
16
  @name = name
10
17
  @sample_rates = sample_rates
@@ -14,18 +21,22 @@ module Wavesync
14
21
  @bar_multiple = bar_multiple
15
22
  end
16
23
 
24
+ #: () -> String
17
25
  def self.config_path
18
- File.expand_path('../../config/devices.yml', __dir__)
26
+ File.expand_path('../../config/devices.yml', __dir__ || '')
19
27
  end
20
28
 
29
+ #: () -> Array[Device]
21
30
  def self.all
22
31
  @all ||= load_from_yaml
23
32
  end
24
33
 
34
+ #: (name: String) -> Device?
25
35
  def self.find_by(name:)
26
36
  all.find { |device| device.name == name }
27
37
  end
28
38
 
39
+ #: () -> Array[Device]
29
40
  def self.load_from_yaml
30
41
  data = YAML.load_file(config_path)
31
42
  data.fetch('devices').map do |attrs|
@@ -40,6 +51,7 @@ module Wavesync
40
51
  end
41
52
  end
42
53
 
54
+ #: (AudioFormat source_format, String source_file_path) -> AudioFormat
43
55
  def target_format(source_format, source_file_path)
44
56
  AudioFormat.new(
45
57
  file_type: target_file_type(source_file_path),
@@ -48,19 +60,23 @@ module Wavesync
48
60
  )
49
61
  end
50
62
 
63
+ #: (String source_file_path) -> String?
51
64
  def target_file_type(source_file_path)
52
- file_extension = File.extname(source_file_path).downcase[1..]
65
+ file_extension = File.extname(source_file_path).downcase[1..] || ''
53
66
  return nil if file_types.include?(file_extension)
54
67
 
55
68
  file_types.first
56
69
  end
57
70
 
71
+ #: (Integer? source_sample_rate) -> Integer?
58
72
  def target_sample_rate(source_sample_rate)
73
+ return nil if source_sample_rate.nil?
59
74
  return nil if sample_rates.include?(source_sample_rate)
60
75
 
61
76
  sample_rates.min_by { |n| [(n - source_sample_rate).abs, -n] }
62
77
  end
63
78
 
79
+ #: (Integer? source_bit_depth) -> Integer?
64
80
  def target_bit_depth(source_bit_depth)
65
81
  return nil if source_bit_depth.nil? || bit_depths.include?(source_bit_depth)
66
82
 
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ require 'json'
5
+ require_relative 'python_venv'
6
+
7
+ module Wavesync
8
+ class EssentiaBpmDetector
9
+ PYTHON_SCRIPT = <<~PYTHON
10
+ import essentia, essentia.streaming as ess, json, sys
11
+ pool = essentia.Pool()
12
+ loader = ess.MonoLoader(filename=sys.argv[1], sampleRate=44100)
13
+ rhythm = ess.RhythmDescriptors()
14
+ loader.audio >> rhythm.signal
15
+ rhythm.bpm >> (pool, 'bpm')
16
+ rhythm.confidence >> (pool, 'confidence')
17
+ essentia.run(loader)
18
+ print(json.dumps({'bpm': round(float(pool['bpm'])), 'confidence': round(float(pool['confidence']), 2)}))
19
+ PYTHON
20
+
21
+ #: () -> bool?
22
+ def self.available?
23
+ PythonVenv.essentia_available?
24
+ end
25
+
26
+ #: (String file_path) -> {bpm: Integer, confidence: Float}?
27
+ def self.detect(file_path)
28
+ output = PythonVenv.run_script(PYTHON_SCRIPT, file_path)
29
+ data = JSON.parse(output.strip)
30
+ bpm = data['bpm'].to_f
31
+ bpm.positive? ? { bpm: bpm.round, confidence: data['confidence'].to_f } : nil
32
+ rescue StandardError
33
+ nil
34
+ end
35
+ end
36
+ end
@@ -1,9 +1,11 @@
1
1
  # frozen_string_literal: true
2
+ # rbs_inline: enabled
2
3
 
3
4
  module Wavesync
4
5
  class FileConverter
5
6
  DURATION_TOLERANCE_SECONDS = 0.5
6
7
 
8
+ #: (Audio audio, String source_file_path, PathResolver path_resolver, AudioFormat source_format, AudioFormat target_format, ?padding_seconds: Numeric?) ?{ () -> void } -> bool
7
9
  def convert(audio, source_file_path, path_resolver, source_format, target_format, padding_seconds: nil, &before_transcode)
8
10
  needs_format_conversion = target_format.file_type || target_format.sample_rate || target_format.bit_depth
9
11
  return false unless needs_format_conversion || padding_seconds&.positive?
@@ -1,26 +1,33 @@
1
1
  # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ require 'pathname'
2
5
 
3
6
  module Wavesync
4
7
  class PathResolver
5
8
  BPM_PATTERN = / \d+ bpm/
6
9
 
10
+ #: (String source_library_path, String target_library_path, Device device) -> void
7
11
  def initialize(source_library_path, target_library_path, device)
8
- @source_library_path = Pathname(File.expand_path(source_library_path))
9
- @target_library_path = Pathname(File.expand_path(target_library_path))
10
- @device = device
12
+ @source_library_path = Pathname(File.expand_path(source_library_path)) #: Pathname
13
+ @target_library_path = Pathname(File.expand_path(target_library_path)) #: Pathname
14
+ @device = device #: Device
11
15
  end
12
16
 
17
+ #: (String source_file_path, Audio audio, ?target_file_type: String?) -> Pathname
13
18
  def resolve(source_file_path, audio, target_file_type: nil)
14
19
  relative_path = Pathname(source_file_path).relative_path_from(@source_library_path)
15
20
  target_path = @target_library_path.join(relative_path)
16
21
 
17
22
  target_path = target_path.sub_ext(".#{target_file_type}") if target_file_type
18
23
 
19
- target_path = add_bpm_to_filename(target_path, audio.bpm) if @device.bpm_source == :filename && audio.bpm
24
+ bpm = audio.bpm
25
+ target_path = add_bpm_to_filename(target_path, bpm) if @device.bpm_source == :filename && bpm
20
26
 
21
27
  target_path
22
28
  end
23
29
 
30
+ #: (Pathname target_path, Audio audio) -> Array[Pathname]
24
31
  def find_files_to_cleanup(target_path, audio)
25
32
  return [] unless @device.bpm_source == :filename && audio.bpm
26
33
 
@@ -29,11 +36,12 @@ module Wavesync
29
36
 
30
37
  pattern = target_path.dirname.join("#{basename}{, * bpm}#{ext}")
31
38
  Dir.glob(pattern.to_s).map { |f| Pathname(f) }
32
- .reject { |path| path == target_path }
39
+ .reject { |path| path == target_path }
33
40
  end
34
41
 
35
42
  private
36
43
 
44
+ #: (Pathname path, String | Integer bpm) -> Pathname
37
45
  def add_bpm_to_filename(path, bpm)
38
46
  ext = path.extname
39
47
  basename = path.basename(ext).to_s
@@ -44,6 +52,7 @@ module Wavesync
44
52
  path.dirname.join(new_basename)
45
53
  end
46
54
 
55
+ #: (Pathname path) -> Pathname
47
56
  def remove_bpm_from_filename(path)
48
57
  ext = path.extname
49
58
  basename = path.basename(ext).to_s
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ require_relative 'python_venv'
5
+
6
+ module Wavesync
7
+ class PercivalBpmDetector
8
+ PYTHON_SCRIPT = <<~PYTHON
9
+ import essentia.standard as es, sys
10
+ audio = es.MonoLoader(filename=sys.argv[1], sampleRate=44100)()
11
+ bpm = es.PercivalBpmEstimator()(audio)
12
+ print(round(float(bpm)))
13
+ PYTHON
14
+
15
+ #: () -> bool?
16
+ def self.available?
17
+ PythonVenv.essentia_available?
18
+ end
19
+
20
+ #: (String file_path) -> Integer?
21
+ def self.detect(file_path)
22
+ output = PythonVenv.run_script(PYTHON_SCRIPT, file_path)
23
+ bpm = output.strip.to_f
24
+ bpm.positive? ? bpm.round : nil
25
+ rescue StandardError
26
+ nil
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ require 'shellwords'
5
+
6
+ module Wavesync
7
+ module PythonVenv
8
+ PYTHON_PATH = File.expand_path('~/.wavesync-venv/bin/python3').freeze
9
+
10
+ #: () -> bool
11
+ def self.available?
12
+ File.executable?(PYTHON_PATH)
13
+ end
14
+
15
+ #: () -> bool?
16
+ def self.essentia_available?
17
+ available? && system("#{PYTHON_PATH} -c 'import essentia' > /dev/null 2>&1")
18
+ end
19
+
20
+ #: (String script, String file_path) -> String
21
+ def self.run_script(script, file_path)
22
+ `#{PYTHON_PATH} -c #{Shellwords.escape(script)} #{Shellwords.escape(file_path)} 2>/dev/null`
23
+ end
24
+ end
25
+ end
@@ -1,4 +1,5 @@
1
1
  # frozen_string_literal: true
2
+ # rbs_inline: enabled
2
3
 
3
4
  require 'fileutils'
4
5
  require 'streamio-ffmpeg'
@@ -6,14 +7,16 @@ require_relative 'file_converter'
6
7
 
7
8
  module Wavesync
8
9
  class Scanner
10
+ #: (String source_library_path) -> void
9
11
  def initialize(source_library_path)
10
- @source_library_path = File.expand_path(source_library_path)
11
- @audio_files = find_audio_files
12
- @ui = Wavesync::UI.new
13
- @converter = FileConverter.new
12
+ @source_library_path = File.expand_path(source_library_path) #: String
13
+ @audio_files = find_audio_files #: Array[String]
14
+ @ui = Wavesync::UI.new #: UI
15
+ @converter = FileConverter.new #: FileConverter
14
16
  FFMPEG.logger = Logger.new(File::NULL)
15
17
  end
16
18
 
19
+ #: (String target_library_path, Device device, ?pad: bool) -> void
17
20
  def sync(target_library_path, device, pad: false)
18
21
  path_resolver = PathResolver.new(@source_library_path, target_library_path, device)
19
22
  skipped_count = 0
@@ -26,9 +29,9 @@ module Wavesync
26
29
  source_format = audio.format
27
30
  target_format = device.target_format(source_format, file)
28
31
 
29
- padding_seconds = nil
30
- original_bars = nil
31
- target_bars = nil
32
+ padding_seconds = nil #: Numeric?
33
+ original_bars = nil #: Integer?
34
+ target_bars = nil #: Integer?
32
35
  if pad && device.bar_multiple
33
36
  padding_seconds = TrackPadding.compute(audio.duration, audio.bpm, device.bar_multiple)
34
37
  original_bars, target_bars = TrackPadding.bar_counts(audio.duration, audio.bpm, device.bar_multiple) unless padding_seconds.zero?
@@ -38,6 +41,17 @@ module Wavesync
38
41
  @ui.bpm(audio.bpm, original_bars: original_bars, target_bars: target_bars)
39
42
  @ui.file_progress(file)
40
43
 
44
+ if source_format.file_type == 'wav'
45
+ prospective_target_path = path_resolver.resolve(file, audio, target_file_type: target_format.file_type)
46
+ if prospective_target_path.extname.downcase == '.wav' && prospective_target_path.exist?
47
+ target_cue_points = CueChunk.read(prospective_target_path.to_s)
48
+ if target_cue_points.any?
49
+ source_cue_points = audio.cue_points
50
+ audio.write_cue_points(target_cue_points) unless same_cue_points?(source_cue_points, target_cue_points)
51
+ end
52
+ end
53
+ end
54
+
41
55
  if target_format.file_type || target_format.sample_rate || target_format.bit_depth || padding_seconds
42
56
  converted = @converter.convert(audio, file, path_resolver, source_format, target_format,
43
57
  padding_seconds: padding_seconds) do
@@ -50,12 +64,23 @@ module Wavesync
50
64
  target_path = path_resolver.resolve(file, audio)
51
65
  end
52
66
 
53
- if (copied || converted) && device.bpm_source == :acid_chunk && audio.bpm && target_path.extname.downcase == '.wav'
67
+ bpm = audio.bpm
68
+ if (copied || converted) && device.bpm_source == :acid_chunk && bpm && target_path.extname.downcase == '.wav'
54
69
  temp_path = "#{target_path}.tmp"
55
- AcidChunk.write_bpm(target_path.to_s, temp_path, audio.bpm)
70
+ AcidChunk.write_bpm(target_path.to_s, temp_path, bpm)
56
71
  FileUtils.mv(temp_path, target_path.to_s)
57
72
  end
58
73
 
74
+ if converted && source_format.file_type == 'wav' && target_path.extname.downcase == '.wav'
75
+ source_cue_points = audio.cue_points
76
+ if source_cue_points.any?
77
+ rescaled_cue_points = rescale_cue_points(source_cue_points, audio.sample_rate, target_format.sample_rate || audio.sample_rate)
78
+ temp_path = "#{target_path}.tmp"
79
+ CueChunk.write(target_path.to_s, temp_path, rescaled_cue_points)
80
+ FileUtils.mv(temp_path, target_path.to_s)
81
+ end
82
+ end
83
+
59
84
  if !copied && !converted
60
85
  skipped_count += 1
61
86
  @ui.skip
@@ -66,14 +91,17 @@ module Wavesync
66
91
  end
67
92
 
68
93
  puts
94
+ system('sync')
69
95
  end
70
96
 
71
97
  private
72
98
 
99
+ #: () -> Array[String]
73
100
  def find_audio_files
74
101
  Audio.find_all(@source_library_path)
75
102
  end
76
103
 
104
+ #: (Audio audio, String source_file_path, PathResolver path_resolver) -> bool
77
105
  def copy_file(audio, source_file_path, path_resolver)
78
106
  target_path = path_resolver.resolve(source_file_path, audio)
79
107
 
@@ -88,6 +116,27 @@ module Wavesync
88
116
  end
89
117
  end
90
118
 
119
+ #: (Array[{identifier: Integer, sample_offset: Integer, label: String?}] cue_points_a, Array[{identifier: Integer, sample_offset: Integer, label: String?}] cue_points_b) -> bool
120
+ def same_cue_points?(cue_points_a, cue_points_b)
121
+ comparable_cue_points(cue_points_a) == comparable_cue_points(cue_points_b)
122
+ end
123
+
124
+ #: (Array[{identifier: Integer, sample_offset: Integer, label: String?}] cue_points) -> Array[{sample_offset: Integer, label: String?}]
125
+ def comparable_cue_points(cue_points)
126
+ mapped = cue_points.map { |cp| { sample_offset: cp[:sample_offset], label: cp[:label] } } #: Array[{sample_offset: Integer, label: String?}]
127
+ mapped.sort_by { |cp| cp[:sample_offset] }
128
+ end
129
+
130
+ #: (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?}]
131
+ def rescale_cue_points(cue_points, source_sample_rate, target_sample_rate)
132
+ return cue_points if source_sample_rate == target_sample_rate || source_sample_rate.nil? || target_sample_rate.nil?
133
+
134
+ cue_points.map do |cue_point|
135
+ 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?}
136
+ end
137
+ end
138
+
139
+ #: (String source, Pathname target) -> void
91
140
  def safe_copy(source, target)
92
141
  FileUtils.install(source, target)
93
142
  rescue Errno::ENOENT