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.
- checksums.yaml +4 -4
- data/README.md +95 -59
- data/config/devices.yml +20 -0
- data/lib/wavesync/acid_chunk.rb +45 -4
- data/lib/wavesync/analyzer.rb +17 -4
- data/lib/wavesync/audio.rb +153 -90
- data/lib/wavesync/audio_format.rb +9 -2
- data/lib/wavesync/bpm_detector.rb +14 -7
- data/lib/wavesync/cli.rb +3 -0
- data/lib/wavesync/commands/analyze.rb +2 -0
- data/lib/wavesync/commands/clear_cache.rb +75 -0
- data/lib/wavesync/commands/command.rb +4 -1
- data/lib/wavesync/commands/help.rb +6 -0
- data/lib/wavesync/commands/pull.rb +43 -0
- data/lib/wavesync/commands/setlist.rb +66 -0
- data/lib/wavesync/commands/sync.rb +19 -26
- data/lib/wavesync/commands.rb +52 -12
- data/lib/wavesync/config.rb +43 -3
- data/lib/wavesync/cue_chunk.rb +203 -0
- data/lib/wavesync/device.rb +32 -7
- data/lib/wavesync/essentia_bpm_detector.rb +38 -0
- data/lib/wavesync/ffmpeg/probe.rb +74 -0
- data/lib/wavesync/ffmpeg.rb +144 -0
- data/lib/wavesync/file_converter.rb +7 -2
- data/lib/wavesync/libmtp.rb +333 -0
- data/lib/wavesync/logger.rb +84 -0
- data/lib/wavesync/path_resolver.rb +32 -6
- data/lib/wavesync/percival_bpm_detector.rb +31 -0
- data/lib/wavesync/python_venv.rb +25 -0
- data/lib/wavesync/scanner.rb +143 -27
- data/lib/wavesync/{set.rb → setlist.rb} +28 -12
- data/lib/wavesync/setlist_editor.rb +556 -0
- data/lib/wavesync/track_padding.rb +15 -4
- data/lib/wavesync/transport/filesystem.rb +36 -0
- data/lib/wavesync/transport/mtp.rb +285 -0
- data/lib/wavesync/transport.rb +21 -0
- data/lib/wavesync/ui.rb +67 -12
- data/lib/wavesync/version.rb +2 -1
- data/lib/wavesync.rb +7 -2
- metadata +17 -32
- data/lib/wavesync/commands/set.rb +0 -63
- 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
|
-
|
|
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 |
|
|
48
|
-
|
|
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
|
data/lib/wavesync/commands.rb
CHANGED
|
@@ -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
|
|
6
|
-
Subcommand = Struct.new(:usage, :description
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
data/lib/wavesync/config.rb
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
data/lib/wavesync/device.rb
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|