wavesync 1.0.0.alpha2 → 1.0.0.alpha4

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.
Files changed (42) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +95 -59
  3. data/config/devices.yml +20 -0
  4. data/lib/wavesync/acid_chunk.rb +45 -4
  5. data/lib/wavesync/analyzer.rb +17 -4
  6. data/lib/wavesync/audio.rb +153 -90
  7. data/lib/wavesync/audio_format.rb +9 -2
  8. data/lib/wavesync/bpm_detector.rb +14 -7
  9. data/lib/wavesync/cli.rb +3 -0
  10. data/lib/wavesync/commands/analyze.rb +2 -0
  11. data/lib/wavesync/commands/clear_cache.rb +75 -0
  12. data/lib/wavesync/commands/command.rb +4 -1
  13. data/lib/wavesync/commands/help.rb +6 -0
  14. data/lib/wavesync/commands/pull.rb +43 -0
  15. data/lib/wavesync/commands/setlist.rb +66 -0
  16. data/lib/wavesync/commands/sync.rb +19 -26
  17. data/lib/wavesync/commands.rb +52 -12
  18. data/lib/wavesync/config.rb +43 -3
  19. data/lib/wavesync/cue_chunk.rb +203 -0
  20. data/lib/wavesync/device.rb +32 -7
  21. data/lib/wavesync/essentia_bpm_detector.rb +38 -0
  22. data/lib/wavesync/ffmpeg/probe.rb +74 -0
  23. data/lib/wavesync/ffmpeg.rb +144 -0
  24. data/lib/wavesync/file_converter.rb +7 -2
  25. data/lib/wavesync/libmtp.rb +333 -0
  26. data/lib/wavesync/logger.rb +84 -0
  27. data/lib/wavesync/path_resolver.rb +32 -6
  28. data/lib/wavesync/percival_bpm_detector.rb +31 -0
  29. data/lib/wavesync/python_venv.rb +25 -0
  30. data/lib/wavesync/scanner.rb +143 -27
  31. data/lib/wavesync/{set.rb → setlist.rb} +28 -12
  32. data/lib/wavesync/setlist_editor.rb +556 -0
  33. data/lib/wavesync/track_padding.rb +15 -4
  34. data/lib/wavesync/transport/filesystem.rb +36 -0
  35. data/lib/wavesync/transport/mtp.rb +285 -0
  36. data/lib/wavesync/transport.rb +21 -0
  37. data/lib/wavesync/ui.rb +67 -12
  38. data/lib/wavesync/version.rb +2 -1
  39. data/lib/wavesync.rb +7 -2
  40. metadata +17 -32
  41. data/lib/wavesync/commands/set.rb +0 -63
  42. data/lib/wavesync/set_editor.rb +0 -245
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
+ # rbs_inline: enabled
2
3
 
3
4
  require 'optparse'
5
+ require_relative '../transport'
4
6
 
5
7
  module Wavesync
6
8
  module Commands
@@ -12,40 +14,31 @@ module Wavesync
12
14
  self.description = 'Sync music library to a device'
13
15
  self.options = [DEVICE_OPTION, PAD_OPTION].freeze
14
16
 
17
+ #: () -> void
15
18
  def run
16
19
  options, config = parse_options(banner: 'Usage: wavesync sync [options]') do |opts, opts_hash|
17
20
  opts.on(*DEVICE_OPTION.to_a) { |value| opts_hash[:device] = value }
18
21
  opts.on(*PAD_OPTION.to_a) { opts_hash[:pad] = true }
19
22
  end
20
23
 
21
- device_configs = config.device_configs
22
- if options[:device]
23
- device_configs = device_configs.select { |device_config| device_config[:name] == options[:device] }
24
- if device_configs.empty?
25
- known = config.device_configs.map { |device_config| device_config[:name] }.join(', ')
26
- puts "Unknown device \"#{options[:device]}\". Devices in config: #{known}"
27
- exit 1
28
- end
29
- elsif device_configs.size > 1
30
- device_names = device_configs.map { |device_config| device_config[:name] }
31
- selected_name = Wavesync::UI.new.select('Select device', device_names)
32
- device_configs = device_configs.select { |device_config| device_config[:name] == selected_name }
33
- end
34
-
35
- device_pairs = device_configs.map do |device_config|
36
- device = Wavesync::Device.find_by(name: device_config[:model])
37
- unless device
38
- supported = Wavesync::Device.all.map(&:name).join(', ')
39
- puts "Unknown device model \"#{device_config[:model]}\" in config. Supported models: #{supported}"
40
- exit 1
41
- end
42
- [device_config, device]
43
- end
44
-
24
+ device_pairs = Commands.resolve_device_pairs(config, device_name: options[:device])
45
25
  scanner = Wavesync::Scanner.new(config.library)
46
26
 
47
- device_pairs.each do |device_config, device|
48
- scanner.sync(device_config[:path], device, pad: options[:pad] || false)
27
+ device_pairs.each do |pair|
28
+ device_config = pair[0] #: { name: String, model: String, path: String, transport: String, mp3_bitrate: Integer }
29
+ device = pair[1] #: Wavesync::Device
30
+ transport = Wavesync::Transport.for(device_config)
31
+ Commands.with_mtp_retry(transport, device_config[:name]) do
32
+ puts "Pushing to #{device_config[:name]} via MTP..." if transport.is_a?(Wavesync::Transport::Mtp)
33
+ transport.begin_push!
34
+ end
35
+ begin
36
+ scanner.sync(transport.working_directory, device, pad: options[:pad] || false, staged: transport.is_a?(Wavesync::Transport::Mtp), mp3_bitrate: device_config[:mp3_bitrate]) do |relative_path|
37
+ transport.push_file!(relative_path)
38
+ end
39
+ ensure
40
+ transport.finish_push!
41
+ end
49
42
  end
50
43
  end
51
44
  end
@@ -1,30 +1,70 @@
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
+ #: (Config config, ?device_name: String?) -> Array[untyped]
21
+ def self.resolve_device_pairs(config, device_name: nil)
22
+ device_configs = config.device_configs
23
+ if device_name
24
+ device_configs = device_configs.select { |device_config| device_config[:name] == device_name }
25
+ if device_configs.empty?
26
+ known = config.device_configs.map { |device_config| device_config[:name] }.join(', ')
27
+ puts "Unknown device \"#{device_name}\". Devices in config: #{known}"
28
+ exit 1
29
+ end
30
+ elsif device_configs.size > 1
31
+ device_names = device_configs.map { |device_config| device_config[:name] }
32
+ selected_name = Wavesync::UI.new.select('Select device', device_names)
33
+ device_configs = device_configs.select { |device_config| device_config[:name] == selected_name }
34
+ end
25
35
 
26
- module Wavesync
27
- module Commands
28
- ALL = [Sync, Analyze, Set, Help].freeze
36
+ device_configs.map do |device_config|
37
+ device = Wavesync::Device.find_by(name: device_config[:model])
38
+ unless device
39
+ supported = Wavesync::Device.all.map(&:name).join(', ')
40
+ puts "Unknown device model \"#{device_config[:model]}\" in config. Supported models: #{supported}"
41
+ exit 1
42
+ end
43
+ [device_config, device]
44
+ end
45
+ end
46
+
47
+ #: ((Wavesync::Transport::Filesystem | Wavesync::Transport::Mtp) transport, String device_name) { () -> void } -> void
48
+ def self.with_mtp_retry(transport, device_name, &block)
49
+ return block.call unless transport.is_a?(Wavesync::Transport::Mtp)
50
+
51
+ begin
52
+ block.call
53
+ rescue Wavesync::Libmtp::Error => e
54
+ puts "Could not reach #{device_name} over MTP (#{e.message}). Put the device in MTP mode, then press Enter to retry (Ctrl+C to abort)."
55
+ $stdin.gets
56
+ retry
57
+ end
58
+ end
59
+
60
+ require_relative 'commands/command'
61
+ require_relative 'commands/sync'
62
+ require_relative 'commands/pull'
63
+ require_relative 'commands/analyze'
64
+ require_relative 'commands/setlist'
65
+ require_relative 'commands/clear_cache'
66
+ require_relative 'commands/help'
67
+
68
+ ALL = [Sync, Pull, Analyze, Setlist, ClearCache, Help].freeze
29
69
  end
30
70
  end
@@ -1,4 +1,5 @@
1
1
  # frozen_string_literal: true
2
+ # rbs_inline: enabled
2
3
 
3
4
  require 'yaml'
4
5
 
@@ -9,11 +10,16 @@ module Wavesync
9
10
  DEFAULT_PATH = File.join(Dir.home, 'wavesync.yml')
10
11
 
11
12
  SUPPORTED_KEYS = %w[library devices].freeze
12
- DEVICE_SUPPORTED_KEYS = %w[name model path].freeze
13
+ DEVICE_SUPPORTED_KEYS = %w[name model path transport mp3_bitrate].freeze
13
14
  DEVICE_REQUIRED_KEYS = %w[name model path].freeze
15
+ SUPPORTED_TRANSPORTS = %w[filesystem mtp].freeze
16
+ SUPPORTED_MP3_BITRATES = [96, 128, 160, 192, 256, 320].freeze
17
+ DEFAULT_MP3_BITRATE = 192
14
18
 
15
- attr_reader :library, :device_configs
19
+ attr_reader :library #: String
20
+ attr_reader :device_configs #: Array[{ name: String, model: String, path: String, transport: String, mp3_bitrate: Integer }]
16
21
 
22
+ #: (?String path) -> Config
17
23
  def self.load(path = DEFAULT_PATH)
18
24
  expanded = File.expand_path(path)
19
25
  begin
@@ -26,17 +32,27 @@ module Wavesync
26
32
  new(data)
27
33
  end
28
34
 
35
+ #: (untyped data) -> void
29
36
  def initialize(data)
30
37
  validate!(data)
31
38
  @library = File.expand_path(data['library'])
32
39
  @device_configs = data['devices'].each_with_index.map do |device, i|
33
40
  validate_device!(device, i)
34
- { name: device['name'], model: device['model'], path: File.expand_path(device['path']) }
41
+ transport = device['transport'] || 'filesystem'
42
+ path = transport == 'filesystem' ? File.expand_path(device['path']) : device['path']
43
+ {
44
+ name: device['name'],
45
+ model: device['model'],
46
+ path: path,
47
+ transport: transport,
48
+ mp3_bitrate: device['mp3_bitrate'] || DEFAULT_MP3_BITRATE
49
+ }
35
50
  end
36
51
  end
37
52
 
38
53
  private
39
54
 
55
+ #: (untyped data) -> void
40
56
  def validate!(data)
41
57
  raise ConfigError, 'Config file must contain a YAML mapping' unless data.is_a?(Hash)
42
58
 
@@ -52,6 +68,7 @@ module Wavesync
52
68
  raise ConfigError, "'devices' must contain at least one device" if data['devices'].empty?
53
69
  end
54
70
 
71
+ #: (untyped device, Integer index) -> void
55
72
  def validate_device!(device, index)
56
73
  raise ConfigError, "Device #{index + 1} must be a YAML mapping" unless device.is_a?(Hash)
57
74
 
@@ -65,6 +82,29 @@ module Wavesync
65
82
  %w[name model path].each do |key|
66
83
  raise ConfigError, "Device #{index + 1} '#{key}' must be a string" unless device[key].is_a?(String)
67
84
  end
85
+
86
+ validate_device_transport!(device, index)
87
+ validate_device_mp3_bitrate!(device, index)
88
+ end
89
+
90
+ #: (Hash[String, untyped] device, Integer index) -> void
91
+ def validate_device_transport!(device, index)
92
+ return unless device.key?('transport')
93
+
94
+ raise ConfigError, "Device #{index + 1} 'transport' must be a string" unless device['transport'].is_a?(String)
95
+ return if SUPPORTED_TRANSPORTS.include?(device['transport'])
96
+
97
+ raise ConfigError, "Device #{index + 1} 'transport' must be one of: #{SUPPORTED_TRANSPORTS.join(', ')}"
98
+ end
99
+
100
+ #: (Hash[String, untyped] device, Integer index) -> void
101
+ def validate_device_mp3_bitrate!(device, index)
102
+ return unless device.key?('mp3_bitrate')
103
+
104
+ raise ConfigError, "Device #{index + 1} 'mp3_bitrate' must be an integer" unless device['mp3_bitrate'].is_a?(Integer)
105
+ return if SUPPORTED_MP3_BITRATES.include?(device['mp3_bitrate'])
106
+
107
+ raise ConfigError, "Device #{index + 1} 'mp3_bitrate' must be one of: #{SUPPORTED_MP3_BITRATES.join(', ')}"
68
108
  end
69
109
  end
70
110
  end
@@ -0,0 +1,203 @@
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
+ #: (Array[{identifier: Integer, sample_offset: Integer, label: String?}] cue_points_a, Array[{identifier: Integer, sample_offset: Integer, label: String?}] cue_points_b) -> bool
59
+ def self.same?(cue_points_a, cue_points_b)
60
+ to_comparable(cue_points_a) == to_comparable(cue_points_b)
61
+ end
62
+
63
+ #: (Array[{identifier: Integer, sample_offset: Integer, label: String?}] cue_points) -> Array[{sample_offset: Integer, label: String?}]
64
+ def self.to_comparable(cue_points)
65
+ mapped = cue_points.map { |cp| { sample_offset: cp[:sample_offset], label: cp[:label] } } #: Array[{sample_offset: Integer, label: String?}]
66
+ mapped.sort_by { |cp| cp[:sample_offset] }
67
+ end
68
+
69
+ #: (String filepath, Array[{identifier: Integer, sample_offset: Integer, label: String?}] cue_points) -> void
70
+ def self.append_to_file(filepath, cue_points)
71
+ return if cue_points.empty?
72
+
73
+ File.open(filepath, 'ab') do |file|
74
+ write_cue_chunk(file, cue_points)
75
+ labeled_cue_points = cue_points.select { |cue_point| cue_point[:label] }
76
+ write_adtl_chunk(file, labeled_cue_points) if labeled_cue_points.any?
77
+ end
78
+
79
+ update_riff_size(filepath)
80
+ end
81
+
82
+ #: (String source_filepath, String output_filepath, Array[{identifier: Integer, sample_offset: Integer, label: String?}] cue_points) -> void
83
+ def self.write(source_filepath, output_filepath, cue_points)
84
+ File.open(source_filepath, 'rb') do |input|
85
+ File.open(output_filepath, 'wb') do |output|
86
+ output.write(input.read(RIFF_HEADER_SIZE))
87
+
88
+ until input.eof?
89
+ chunk_id = input.read(CHUNK_ID_SIZE)
90
+ break if chunk_id.nil? || chunk_id.length < CHUNK_ID_SIZE
91
+
92
+ chunk_size_bytes = input.read(CHUNK_SIZE_FIELD_SIZE)
93
+ break if chunk_size_bytes.nil?
94
+
95
+ chunk_size = chunk_size_bytes.unpack1(UINT32_LE).to_i
96
+ chunk_padding = chunk_size.odd? ? 1 : 0
97
+
98
+ if chunk_id == CUE_CHUNK_ID
99
+ input.read(chunk_size + chunk_padding)
100
+ elsif chunk_id == LIST_CHUNK_ID && chunk_size >= 4
101
+ list_type = input.read(4)
102
+ if list_type == ADTL_LIST_TYPE
103
+ input.read(chunk_size - 4 + chunk_padding)
104
+ else
105
+ output.write(chunk_id)
106
+ output.write(chunk_size_bytes)
107
+ output.write(list_type)
108
+ output.write(input.read(chunk_size - 4 + chunk_padding))
109
+ end
110
+ else
111
+ output.write(chunk_id)
112
+ output.write(chunk_size_bytes)
113
+ output.write(input.read(chunk_size + chunk_padding))
114
+ end
115
+ end
116
+
117
+ unless cue_points.empty?
118
+ write_cue_chunk(output, cue_points)
119
+ labeled_cue_points = cue_points.select { |cue_point| cue_point[:label] }
120
+ write_adtl_chunk(output, labeled_cue_points) if labeled_cue_points.any?
121
+ end
122
+ end
123
+ end
124
+
125
+ update_riff_size(output_filepath)
126
+ end
127
+
128
+ #: (untyped output, Array[{identifier: Integer, sample_offset: Integer, label: String?}] cue_points) -> void
129
+ def self.write_cue_chunk(output, cue_points)
130
+ chunk_size = CUE_HEADER_SIZE + (cue_points.size * BYTES_PER_CUE_POINT)
131
+ output.write(CUE_CHUNK_ID)
132
+ output.write([chunk_size].pack(UINT32_LE))
133
+ output.write([cue_points.size].pack(UINT32_LE))
134
+ cue_points.each_with_index do |cue_point, index|
135
+ identifier = cue_point[:identifier] || (index + 1)
136
+ output.write([identifier].pack(UINT32_LE))
137
+ output.write([0].pack(UINT32_LE))
138
+ output.write(DATA_CHUNK_ID)
139
+ output.write([0].pack(UINT32_LE))
140
+ output.write([0].pack(UINT32_LE))
141
+ output.write([cue_point[:sample_offset]].pack(UINT32_LE))
142
+ end
143
+ end
144
+
145
+ #: (untyped output, Array[{identifier: Integer, sample_offset: Integer, label: String?}] cue_points) -> void
146
+ def self.write_adtl_chunk(output, cue_points)
147
+ labl_entries = cue_points.map do |cue_point|
148
+ text = "#{cue_point[:label]}\x00"
149
+ data_size = 4 + text.length
150
+ pad = data_size.odd? ? 1 : 0
151
+ { identifier: cue_point[:identifier], text: text, data_size: data_size, pad: pad }
152
+ end #: Array[{identifier: Integer, text: String, data_size: Integer, pad: Integer}]
153
+
154
+ adtl_data_size = 4 + labl_entries.sum { |entry| 8 + entry[:data_size] + entry[:pad] }
155
+ output.write(LIST_CHUNK_ID)
156
+ output.write([adtl_data_size].pack(UINT32_LE))
157
+ output.write(ADTL_LIST_TYPE)
158
+
159
+ labl_entries.each do |entry|
160
+ output.write(LABL_CHUNK_ID)
161
+ output.write([entry[:data_size]].pack(UINT32_LE))
162
+ output.write([entry[:identifier]].pack(UINT32_LE))
163
+ output.write(entry[:text])
164
+ output.write("\x00") if entry[:pad] == 1
165
+ end
166
+ end
167
+
168
+ #: (untyped file, Integer list_end, Hash[Integer, String] labels) -> void
169
+ def self.read_adtl_labels(file, list_end, labels)
170
+ while file.tell < list_end - 8
171
+ sub_id = file.read(CHUNK_ID_SIZE)
172
+ break if sub_id.nil? || sub_id.length < CHUNK_ID_SIZE
173
+
174
+ sub_size_bytes = file.read(CHUNK_SIZE_FIELD_SIZE)
175
+ break if sub_size_bytes.nil?
176
+
177
+ sub_size = sub_size_bytes.unpack1(UINT32_LE).to_i
178
+ sub_data_start = file.tell
179
+
180
+ if sub_id == LABL_CHUNK_ID && sub_size >= 4
181
+ identifier = file.read(4)&.unpack1(UINT32_LE)&.to_i
182
+ text_size = sub_size - 4
183
+ text = text_size.positive? ? (file.read(text_size) || '') : ''
184
+ labels[identifier] = text.delete("\x00") if identifier
185
+ end
186
+
187
+ sub_padding = sub_size.odd? ? 1 : 0
188
+ file.seek(sub_data_start + sub_size + sub_padding)
189
+ end
190
+ end
191
+
192
+ #: (String filepath) -> void
193
+ def self.update_riff_size(filepath)
194
+ File.open(filepath, 'r+b') do |file|
195
+ file.seek(0, IO::SEEK_END)
196
+ file_size = file.tell
197
+ riff_size = file_size - 8
198
+ file.seek(4)
199
+ file.write([riff_size].pack(UINT32_LE))
200
+ end
201
+ end
202
+ end
203
+ end
@@ -1,45 +1,66 @@
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?
13
+ attr_reader :unsupported_characters #: Array[String]
14
+ attr_reader :transliterate_metadata #: bool
15
+ attr_reader :uppercase_paths #: bool
7
16
 
8
- def initialize(name:, sample_rates:, bit_depths:, file_types:, bpm_source: nil, bar_multiple: nil)
17
+ #: (name: String, sample_rates: Array[Integer], file_types: Array[String], ?bit_depths: Array[Integer], ?bpm_source: Symbol?, ?bar_multiple: Integer?, ?unsupported_characters: Array[String], ?transliterate_metadata: bool, ?uppercase_paths: bool) -> void
18
+ def initialize(name:, sample_rates:, file_types:, bit_depths: [], bpm_source: nil, bar_multiple: nil, unsupported_characters: [], transliterate_metadata: false, uppercase_paths: false)
9
19
  @name = name
10
20
  @sample_rates = sample_rates
11
21
  @bit_depths = bit_depths
12
22
  @file_types = file_types
13
23
  @bpm_source = bpm_source
14
24
  @bar_multiple = bar_multiple
25
+ @unsupported_characters = unsupported_characters
26
+ @transliterate_metadata = transliterate_metadata
27
+ @uppercase_paths = uppercase_paths
15
28
  end
16
29
 
30
+ #: () -> String
17
31
  def self.config_path
18
- File.expand_path('../../config/devices.yml', __dir__)
32
+ File.expand_path('../../config/devices.yml', __dir__ || '')
19
33
  end
20
34
 
35
+ #: () -> Array[Device]
21
36
  def self.all
22
37
  @all ||= load_from_yaml
23
38
  end
24
39
 
40
+ #: (name: String) -> Device?
25
41
  def self.find_by(name:)
26
42
  all.find { |device| device.name == name }
27
43
  end
28
44
 
45
+ #: () -> Array[Device]
29
46
  def self.load_from_yaml
30
47
  data = YAML.load_file(config_path)
31
48
  data.fetch('devices').map do |attrs|
32
49
  new(
33
50
  name: attrs['name'],
34
51
  sample_rates: attrs['sample_rates'],
35
- bit_depths: attrs['bit_depths'],
52
+ bit_depths: attrs['bit_depths'] || [],
36
53
  file_types: attrs['file_types'],
37
54
  bpm_source: attrs['bpm_source']&.to_sym,
38
- bar_multiple: attrs['bar_multiple']
55
+ bar_multiple: attrs['bar_multiple'],
56
+ unsupported_characters: attrs['unsupported_characters'] || [],
57
+ transliterate_metadata: attrs['transliterate_metadata'] || false,
58
+ uppercase_paths: attrs['uppercase_paths'] || false
39
59
  )
40
60
  end
41
61
  end
42
62
 
63
+ #: (AudioFormat source_format, String source_file_path) -> AudioFormat
43
64
  def target_format(source_format, source_file_path)
44
65
  AudioFormat.new(
45
66
  file_type: target_file_type(source_file_path),
@@ -48,21 +69,25 @@ module Wavesync
48
69
  )
49
70
  end
50
71
 
72
+ #: (String source_file_path) -> String?
51
73
  def target_file_type(source_file_path)
52
- file_extension = File.extname(source_file_path).downcase[1..]
74
+ file_extension = File.extname(source_file_path).downcase[1..] || ''
53
75
  return nil if file_types.include?(file_extension)
54
76
 
55
77
  file_types.first
56
78
  end
57
79
 
80
+ #: (Integer? source_sample_rate) -> Integer?
58
81
  def target_sample_rate(source_sample_rate)
82
+ return nil if source_sample_rate.nil?
59
83
  return nil if sample_rates.include?(source_sample_rate)
60
84
 
61
85
  sample_rates.min_by { |n| [(n - source_sample_rate).abs, -n] }
62
86
  end
63
87
 
88
+ #: (Integer? source_bit_depth) -> Integer?
64
89
  def target_bit_depth(source_bit_depth)
65
- return nil if source_bit_depth.nil? || bit_depths.include?(source_bit_depth)
90
+ return nil if source_bit_depth.nil? || bit_depths.empty? || bit_depths.include?(source_bit_depth)
66
91
 
67
92
  bit_depths.min_by { |n| [(n - source_bit_depth).abs, -n] }
68
93
  end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ require 'json'
5
+ require_relative 'logger'
6
+ require_relative 'python_venv'
7
+
8
+ module Wavesync
9
+ class EssentiaBpmDetector
10
+ PYTHON_SCRIPT = <<~PYTHON
11
+ import essentia, essentia.streaming as ess, json, sys
12
+ pool = essentia.Pool()
13
+ loader = ess.MonoLoader(filename=sys.argv[1], sampleRate=44100)
14
+ rhythm = ess.RhythmDescriptors()
15
+ loader.audio >> rhythm.signal
16
+ rhythm.bpm >> (pool, 'bpm')
17
+ rhythm.confidence >> (pool, 'confidence')
18
+ essentia.run(loader)
19
+ print(json.dumps({'bpm': round(float(pool['bpm'])), 'confidence': round(float(pool['confidence']), 2)}))
20
+ PYTHON
21
+
22
+ #: () -> bool?
23
+ def self.available?
24
+ PythonVenv.essentia_available?
25
+ end
26
+
27
+ #: (String file_path) -> {bpm: Integer, confidence: Float}?
28
+ def self.detect(file_path)
29
+ output = PythonVenv.run_script(PYTHON_SCRIPT, file_path)
30
+ data = JSON.parse(output.strip)
31
+ bpm = data['bpm'].to_f
32
+ bpm.positive? ? { bpm: bpm.round, confidence: data['confidence'].to_f } : nil
33
+ rescue StandardError => e
34
+ Logger.log_error(e, call_site: 'EssentiaBpmDetector.detect', arguments: { file_path: })
35
+ nil
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ require 'open3'
5
+ require 'json'
6
+
7
+ module Wavesync
8
+ class FFMPEG
9
+ class Probe
10
+ #: (String file_path) -> void
11
+ def initialize(file_path)
12
+ @file_path = file_path #: String
13
+ end
14
+
15
+ #: () -> Float
16
+ def duration
17
+ format_data.fetch('duration', '0').to_f
18
+ end
19
+
20
+ #: () -> Integer?
21
+ def sample_rate
22
+ rate = audio_stream&.fetch('sample_rate', nil)
23
+ rate&.to_i
24
+ end
25
+
26
+ #: () -> Integer?
27
+ def bit_depth
28
+ bits = audio_stream&.fetch('bits_per_sample', nil)&.to_i
29
+ bits&.positive? ? bits : nil
30
+ end
31
+
32
+ #: () -> Integer?
33
+ def bitrate
34
+ bits_per_second = audio_stream&.fetch('bit_rate', nil)&.to_i
35
+ return nil unless bits_per_second&.positive?
36
+
37
+ (bits_per_second / 1000.0).round
38
+ end
39
+
40
+ #: () -> Hash[String, String]
41
+ def tags
42
+ format_data['tags'] || {}
43
+ end
44
+
45
+ private
46
+
47
+ #: () -> Hash[String, untyped]
48
+ def probe_data
49
+ @probe_data ||= run_probe
50
+ end
51
+
52
+ #: () -> Hash[String, untyped]
53
+ def run_probe
54
+ ffprobe = FFMPEG.binary.sub('ffmpeg', 'ffprobe')
55
+ stdout, _stderr, _status = Open3.capture3(
56
+ ffprobe, '-v', 'quiet', '-print_format', 'json',
57
+ '-show_streams', '-show_format', @file_path
58
+ )
59
+ JSON.parse(stdout)
60
+ end
61
+
62
+ #: () -> Hash[String, untyped]
63
+ def format_data
64
+ probe_data['format'] || {} #: Hash[String, untyped]
65
+ end
66
+
67
+ #: () -> Hash[String, untyped]?
68
+ def audio_stream
69
+ streams = probe_data['streams'] || [] #: Array[Hash[String, untyped]]
70
+ @audio_stream ||= streams.find { |stream| stream['codec_type'] == 'audio' }
71
+ end
72
+ end
73
+ end
74
+ end