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.
- checksums.yaml +4 -4
- data/README.md +46 -60
- data/config/devices.yml +3 -0
- data/lib/wavesync/acid_chunk.rb +9 -4
- data/lib/wavesync/analyzer.rb +9 -4
- data/lib/wavesync/audio.rb +43 -4
- data/lib/wavesync/audio_format.rb +1 -0
- data/lib/wavesync/bpm_detector.rb +14 -7
- data/lib/wavesync/cli.rb +2 -0
- data/lib/wavesync/commands/analyze.rb +2 -0
- data/lib/wavesync/commands/command.rb +2 -1
- data/lib/wavesync/commands/help.rb +6 -0
- data/lib/wavesync/commands/set.rb +3 -0
- data/lib/wavesync/commands/sync.rb +6 -2
- data/lib/wavesync/commands.rb +9 -11
- data/lib/wavesync/config.rb +7 -1
- data/lib/wavesync/cue_chunk.rb +179 -0
- data/lib/wavesync/device.rb +19 -3
- data/lib/wavesync/essentia_bpm_detector.rb +36 -0
- data/lib/wavesync/file_converter.rb +2 -0
- data/lib/wavesync/path_resolver.rb +14 -5
- data/lib/wavesync/percival_bpm_detector.rb +29 -0
- data/lib/wavesync/python_venv.rb +25 -0
- data/lib/wavesync/scanner.rb +58 -9
- data/lib/wavesync/set.rb +17 -1
- data/lib/wavesync/set_editor.rb +333 -31
- data/lib/wavesync/track_padding.rb +7 -4
- data/lib/wavesync/ui.rb +28 -4
- data/lib/wavesync/version.rb +2 -1
- data/lib/wavesync.rb +1 -0
- metadata +5 -1
data/lib/wavesync/commands.rb
CHANGED
|
@@ -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
|
|
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
|
-
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
|
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
|
|
|
@@ -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
|
|
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
|
data/lib/wavesync/device.rb
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
data/lib/wavesync/scanner.rb
CHANGED
|
@@ -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
|
-
|
|
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,
|
|
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
|