wavesync 1.0.0.alpha1
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 +7 -0
- data/LICENSE +21 -0
- data/README.md +135 -0
- data/bin/wavesync +8 -0
- data/config/devices.yml +27 -0
- data/lib/wavesync/acid_chunk.rb +140 -0
- data/lib/wavesync/analyzer.rb +39 -0
- data/lib/wavesync/audio.rb +212 -0
- data/lib/wavesync/audio_format.rb +13 -0
- data/lib/wavesync/bpm_detector.rb +19 -0
- data/lib/wavesync/cli.rb +159 -0
- data/lib/wavesync/config.rb +70 -0
- data/lib/wavesync/device.rb +70 -0
- data/lib/wavesync/file_converter.rb +40 -0
- data/lib/wavesync/path_resolver.rb +55 -0
- data/lib/wavesync/scanner.rb +97 -0
- data/lib/wavesync/set.rb +82 -0
- data/lib/wavesync/set_editor.rb +245 -0
- data/lib/wavesync/track_padding.rb +27 -0
- data/lib/wavesync/ui.rb +139 -0
- data/lib/wavesync/version.rb +5 -0
- data/lib/wavesync.rb +21 -0
- metadata +144 -0
data/lib/wavesync/cli.rb
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'optparse'
|
|
4
|
+
|
|
5
|
+
module Wavesync
|
|
6
|
+
class CLI
|
|
7
|
+
def self.start
|
|
8
|
+
command = ARGV.first && !ARGV.first.start_with?('-') ? ARGV.shift : 'sync'
|
|
9
|
+
|
|
10
|
+
case command
|
|
11
|
+
when 'sync'
|
|
12
|
+
start_sync
|
|
13
|
+
when 'analyze'
|
|
14
|
+
start_analyze
|
|
15
|
+
when 'set'
|
|
16
|
+
start_set
|
|
17
|
+
else
|
|
18
|
+
puts "Unknown command: #{command}"
|
|
19
|
+
puts 'Available commands: sync, analyze, set'
|
|
20
|
+
exit 1
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def self.start_sync
|
|
25
|
+
options = {}
|
|
26
|
+
parser = OptionParser.new do |opts|
|
|
27
|
+
opts.banner = 'Usage: wavesync sync [options]'
|
|
28
|
+
|
|
29
|
+
opts.on('-d', '--device NAME', 'Name of device to sync (as defined in config)') do |value|
|
|
30
|
+
options[:device] = value
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
opts.on('-c', '--config PATH', 'Path to wavesync config YAML file') do |value|
|
|
34
|
+
options[:config] = value
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
opts.on('-p', '--pad', 'Pad tracks with silence so total length is a multiple of 64 bars (Octatrack only)') do
|
|
38
|
+
options[:pad] = true
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
parser.parse!
|
|
43
|
+
|
|
44
|
+
config_path = options[:config] || Wavesync::Config::DEFAULT_PATH
|
|
45
|
+
config = load_config(config_path)
|
|
46
|
+
|
|
47
|
+
device_configs = config.device_configs
|
|
48
|
+
if options[:device]
|
|
49
|
+
device_configs = device_configs.select { |device_config| device_config[:name] == options[:device] }
|
|
50
|
+
if device_configs.empty?
|
|
51
|
+
known = config.device_configs.map { |device_config| device_config[:name] }.join(', ')
|
|
52
|
+
puts "Unknown device \"#{options[:device]}\". Devices in config: #{known}"
|
|
53
|
+
exit 1
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
device_configs.each do |device_config|
|
|
58
|
+
next if Wavesync::Device.find_by(name: device_config[:model])
|
|
59
|
+
|
|
60
|
+
supported = Wavesync::Device.all.map(&:name).join(', ')
|
|
61
|
+
puts "Unknown device model \"#{device_config[:model]}\" in config. Supported models: #{supported}"
|
|
62
|
+
exit 1
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
scanner = Wavesync::Scanner.new(config.library)
|
|
66
|
+
|
|
67
|
+
device_configs.each do |device_config|
|
|
68
|
+
scanner.sync(device_config[:path], Wavesync::Device.find_by(name: device_config[:model]),
|
|
69
|
+
pad: options[:pad] || false)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def self.start_set
|
|
74
|
+
subcommand = ARGV.shift
|
|
75
|
+
|
|
76
|
+
options = {}
|
|
77
|
+
parser = OptionParser.new do |opts|
|
|
78
|
+
opts.banner = 'Usage: wavesync set <subcommand> [options]'
|
|
79
|
+
|
|
80
|
+
opts.on('-c', '--config PATH', 'Path to wavesync config YAML file') do |value|
|
|
81
|
+
options[:config] = value
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
parser.parse!
|
|
86
|
+
|
|
87
|
+
config_path = options[:config] || Wavesync::Config::DEFAULT_PATH
|
|
88
|
+
config = load_config(config_path)
|
|
89
|
+
|
|
90
|
+
case subcommand
|
|
91
|
+
when 'create'
|
|
92
|
+
name = require_set_name('create')
|
|
93
|
+
if Wavesync::Set.exists?(config.library, name)
|
|
94
|
+
puts "Set '#{name}' already exists. Use 'wavesync set edit #{name}' to edit it."
|
|
95
|
+
exit 1
|
|
96
|
+
end
|
|
97
|
+
set = Wavesync::Set.new(config.library, name)
|
|
98
|
+
Wavesync::SetEditor.new(set, config.library).run
|
|
99
|
+
when 'edit'
|
|
100
|
+
name = require_set_name('edit')
|
|
101
|
+
unless Wavesync::Set.exists?(config.library, name)
|
|
102
|
+
puts "Set '#{name}' not found. Use 'wavesync set create #{name}' to create it."
|
|
103
|
+
exit 1
|
|
104
|
+
end
|
|
105
|
+
set = Wavesync::Set.load(config.library, name)
|
|
106
|
+
Wavesync::SetEditor.new(set, config.library).run
|
|
107
|
+
when 'list'
|
|
108
|
+
sets = Wavesync::Set.all(config.library)
|
|
109
|
+
if sets.empty?
|
|
110
|
+
puts 'No sets found.'
|
|
111
|
+
else
|
|
112
|
+
sets.each { |s| puts "#{s.name} (#{s.tracks.size} tracks)" }
|
|
113
|
+
end
|
|
114
|
+
else
|
|
115
|
+
puts "Unknown subcommand: #{subcommand || '(none)'}"
|
|
116
|
+
puts 'Available subcommands: create, edit, list'
|
|
117
|
+
exit 1
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def self.load_config(path)
|
|
122
|
+
Wavesync::Config.load(path)
|
|
123
|
+
rescue Wavesync::ConfigError => e
|
|
124
|
+
puts "Configuration error: #{e.message}"
|
|
125
|
+
exit 1
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def self.require_set_name(subcommand)
|
|
129
|
+
name = ARGV.shift
|
|
130
|
+
unless name
|
|
131
|
+
puts "Usage: wavesync set #{subcommand} <name>"
|
|
132
|
+
exit 1
|
|
133
|
+
end
|
|
134
|
+
name
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def self.start_analyze
|
|
138
|
+
options = {}
|
|
139
|
+
parser = OptionParser.new do |opts|
|
|
140
|
+
opts.banner = 'Usage: wavesync analyze [options]'
|
|
141
|
+
|
|
142
|
+
opts.on('-c', '--config PATH', 'Path to wavesync config YAML file') do |value|
|
|
143
|
+
options[:config] = value
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
opts.on('-f', '--force', 'Overwrite existing BPM values') do
|
|
147
|
+
options[:overwrite] = true
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
parser.parse!
|
|
152
|
+
|
|
153
|
+
config_path = options[:config] || Wavesync::Config::DEFAULT_PATH
|
|
154
|
+
config = load_config(config_path)
|
|
155
|
+
|
|
156
|
+
Wavesync::Analyzer.new(config.library).analyze(overwrite: options[:overwrite] || false)
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'yaml'
|
|
4
|
+
|
|
5
|
+
module Wavesync
|
|
6
|
+
class ConfigError < StandardError; end
|
|
7
|
+
|
|
8
|
+
class Config
|
|
9
|
+
DEFAULT_PATH = File.join(Dir.home, 'wavesync.yml')
|
|
10
|
+
|
|
11
|
+
SUPPORTED_KEYS = %w[library devices].freeze
|
|
12
|
+
DEVICE_SUPPORTED_KEYS = %w[name model path].freeze
|
|
13
|
+
DEVICE_REQUIRED_KEYS = %w[name model path].freeze
|
|
14
|
+
|
|
15
|
+
attr_reader :library, :device_configs
|
|
16
|
+
|
|
17
|
+
def self.load(path = DEFAULT_PATH)
|
|
18
|
+
expanded = File.expand_path(path)
|
|
19
|
+
begin
|
|
20
|
+
data = YAML.load_file(expanded)
|
|
21
|
+
rescue Errno::ENOENT
|
|
22
|
+
raise ConfigError, "Config file not found: #{path}"
|
|
23
|
+
rescue Psych::SyntaxError => e
|
|
24
|
+
raise ConfigError, "Invalid YAML in config file: #{e.message}"
|
|
25
|
+
end
|
|
26
|
+
new(data)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def initialize(data)
|
|
30
|
+
validate!(data)
|
|
31
|
+
@library = File.expand_path(data['library'])
|
|
32
|
+
@device_configs = data['devices'].each_with_index.map do |device, i|
|
|
33
|
+
validate_device!(device, i)
|
|
34
|
+
{ name: device['name'], model: device['model'], path: File.expand_path(device['path']) }
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def validate!(data)
|
|
41
|
+
raise ConfigError, 'Config file must contain a YAML mapping' unless data.is_a?(Hash)
|
|
42
|
+
|
|
43
|
+
unsupported = data.keys - SUPPORTED_KEYS
|
|
44
|
+
raise ConfigError, "Unsupported config keys: #{unsupported.join(', ')}" if unsupported.any?
|
|
45
|
+
|
|
46
|
+
%w[library devices].each do |key|
|
|
47
|
+
raise ConfigError, "Missing required config key: '#{key}'" unless data.key?(key)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
raise ConfigError, "'library' must be a string" unless data['library'].is_a?(String)
|
|
51
|
+
raise ConfigError, "'devices' must be a list" unless data['devices'].is_a?(Array)
|
|
52
|
+
raise ConfigError, "'devices' must contain at least one device" if data['devices'].empty?
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def validate_device!(device, index)
|
|
56
|
+
raise ConfigError, "Device #{index + 1} must be a YAML mapping" unless device.is_a?(Hash)
|
|
57
|
+
|
|
58
|
+
unsupported = device.keys - DEVICE_SUPPORTED_KEYS
|
|
59
|
+
raise ConfigError, "Unsupported keys in device #{index + 1}: #{unsupported.join(', ')}" if unsupported.any?
|
|
60
|
+
|
|
61
|
+
DEVICE_REQUIRED_KEYS.each do |key|
|
|
62
|
+
raise ConfigError, "Missing required key '#{key}' in device #{index + 1}" unless device.key?(key)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
%w[name model path].each do |key|
|
|
66
|
+
raise ConfigError, "Device #{index + 1} '#{key}' must be a string" unless device[key].is_a?(String)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'yaml'
|
|
4
|
+
module Wavesync
|
|
5
|
+
class Device
|
|
6
|
+
attr_reader :name, :sample_rates, :bit_depths, :file_types, :bpm_source, :bar_multiple
|
|
7
|
+
|
|
8
|
+
def initialize(name:, sample_rates:, bit_depths:, file_types:, bpm_source: nil, bar_multiple: nil)
|
|
9
|
+
@name = name
|
|
10
|
+
@sample_rates = sample_rates
|
|
11
|
+
@bit_depths = bit_depths
|
|
12
|
+
@file_types = file_types
|
|
13
|
+
@bpm_source = bpm_source
|
|
14
|
+
@bar_multiple = bar_multiple
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def self.config_path
|
|
18
|
+
File.expand_path('../../config/devices.yml', __dir__)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def self.all
|
|
22
|
+
@all ||= load_from_yaml
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.find_by(name:)
|
|
26
|
+
all.find { |device| device.name == name }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def self.load_from_yaml
|
|
30
|
+
data = YAML.load_file(config_path)
|
|
31
|
+
data.fetch('devices').map do |attrs|
|
|
32
|
+
new(
|
|
33
|
+
name: attrs['name'],
|
|
34
|
+
sample_rates: attrs['sample_rates'],
|
|
35
|
+
bit_depths: attrs['bit_depths'],
|
|
36
|
+
file_types: attrs['file_types'],
|
|
37
|
+
bpm_source: attrs['bpm_source']&.to_sym,
|
|
38
|
+
bar_multiple: attrs['bar_multiple']
|
|
39
|
+
)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def target_format(source_format, source_file_path)
|
|
44
|
+
AudioFormat.new(
|
|
45
|
+
file_type: target_file_type(source_file_path),
|
|
46
|
+
sample_rate: target_sample_rate(source_format.sample_rate),
|
|
47
|
+
bit_depth: target_bit_depth(source_format.bit_depth)
|
|
48
|
+
)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def target_file_type(source_file_path)
|
|
52
|
+
file_extension = File.extname(source_file_path).downcase[1..]
|
|
53
|
+
return nil if file_types.include?(file_extension)
|
|
54
|
+
|
|
55
|
+
file_types.first
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def target_sample_rate(source_sample_rate)
|
|
59
|
+
return nil if sample_rates.include?(source_sample_rate)
|
|
60
|
+
|
|
61
|
+
sample_rates.min_by { |n| [(n - source_sample_rate).abs, -n] }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def target_bit_depth(source_bit_depth)
|
|
65
|
+
return nil if source_bit_depth.nil? || bit_depths.include?(source_bit_depth)
|
|
66
|
+
|
|
67
|
+
bit_depths.min_by { |n| [(n - source_bit_depth).abs, -n] }
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Wavesync
|
|
4
|
+
class FileConverter
|
|
5
|
+
DURATION_TOLERANCE_SECONDS = 0.5
|
|
6
|
+
|
|
7
|
+
def convert(audio, source_file_path, path_resolver, source_format, target_format, padding_seconds: nil, &before_transcode)
|
|
8
|
+
needs_format_conversion = target_format.file_type || target_format.sample_rate || target_format.bit_depth
|
|
9
|
+
return false unless needs_format_conversion || padding_seconds&.positive?
|
|
10
|
+
|
|
11
|
+
target_path = path_resolver.resolve(source_file_path, audio, target_file_type: target_format.file_type)
|
|
12
|
+
|
|
13
|
+
files_to_cleanup = path_resolver.find_files_to_cleanup(target_path, audio)
|
|
14
|
+
files_to_cleanup.each { |file| FileUtils.rm_f(file) }
|
|
15
|
+
|
|
16
|
+
if target_format.file_type
|
|
17
|
+
source_converted_path = Pathname(source_file_path).sub_ext(".#{target_format.file_type}")
|
|
18
|
+
return false if source_converted_path.exist?
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
if target_path.exist?
|
|
22
|
+
existing_duration = Audio.new(target_path.to_s).duration
|
|
23
|
+
expected_duration = audio.duration + (padding_seconds || 0)
|
|
24
|
+
return false if (existing_duration - expected_duration).abs < DURATION_TOLERANCE_SECONDS
|
|
25
|
+
|
|
26
|
+
target_path.delete
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
target_path.dirname.mkpath
|
|
30
|
+
before_transcode&.call
|
|
31
|
+
|
|
32
|
+
audio.transcode(target_path.to_s, target_sample_rate: target_format.sample_rate,
|
|
33
|
+
target_file_type: target_format.file_type,
|
|
34
|
+
target_bit_depth: target_format.bit_depth || source_format.bit_depth,
|
|
35
|
+
padding_seconds: padding_seconds)
|
|
36
|
+
|
|
37
|
+
true
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Wavesync
|
|
4
|
+
class PathResolver
|
|
5
|
+
BPM_PATTERN = / \d+ bpm/
|
|
6
|
+
|
|
7
|
+
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
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def resolve(source_file_path, audio, target_file_type: nil)
|
|
14
|
+
relative_path = Pathname(source_file_path).relative_path_from(@source_library_path)
|
|
15
|
+
target_path = @target_library_path.join(relative_path)
|
|
16
|
+
|
|
17
|
+
target_path = target_path.sub_ext(".#{target_file_type}") if target_file_type
|
|
18
|
+
|
|
19
|
+
target_path = add_bpm_to_filename(target_path, audio.bpm) if @device.bpm_source == :filename && audio.bpm
|
|
20
|
+
|
|
21
|
+
target_path
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def find_files_to_cleanup(target_path, audio)
|
|
25
|
+
return [] unless @device.bpm_source == :filename && audio.bpm
|
|
26
|
+
|
|
27
|
+
ext = target_path.extname
|
|
28
|
+
basename = target_path.basename(ext).to_s.gsub(BPM_PATTERN, '')
|
|
29
|
+
|
|
30
|
+
pattern = target_path.dirname.join("#{basename}{, * bpm}#{ext}")
|
|
31
|
+
Dir.glob(pattern.to_s).map { |f| Pathname(f) }
|
|
32
|
+
.reject { |path| path == target_path }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def add_bpm_to_filename(path, bpm)
|
|
38
|
+
ext = path.extname
|
|
39
|
+
basename = path.basename(ext).to_s
|
|
40
|
+
|
|
41
|
+
basename = basename.gsub(BPM_PATTERN, '')
|
|
42
|
+
|
|
43
|
+
new_basename = "#{basename} #{bpm} bpm#{ext}"
|
|
44
|
+
path.dirname.join(new_basename)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def remove_bpm_from_filename(path)
|
|
48
|
+
ext = path.extname
|
|
49
|
+
basename = path.basename(ext).to_s
|
|
50
|
+
basename_without_bpm = basename.gsub(BPM_PATTERN, '')
|
|
51
|
+
|
|
52
|
+
path.dirname.join("#{basename_without_bpm}#{ext}")
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
require 'streamio-ffmpeg'
|
|
5
|
+
require_relative 'file_converter'
|
|
6
|
+
|
|
7
|
+
module Wavesync
|
|
8
|
+
class Scanner
|
|
9
|
+
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
|
|
14
|
+
FFMPEG.logger = Logger.new(File::NULL)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def sync(target_library_path, device, pad: false)
|
|
18
|
+
path_resolver = PathResolver.new(@source_library_path, target_library_path, device)
|
|
19
|
+
skipped_count = 0
|
|
20
|
+
conversion_count = 0
|
|
21
|
+
@ui.sync_progress(0, @audio_files.size, device)
|
|
22
|
+
|
|
23
|
+
@audio_files.each_with_index do |file, index|
|
|
24
|
+
audio = Audio.new(file)
|
|
25
|
+
|
|
26
|
+
source_format = audio.format
|
|
27
|
+
target_format = device.target_format(source_format, file)
|
|
28
|
+
|
|
29
|
+
padding_seconds = nil
|
|
30
|
+
original_bars = nil
|
|
31
|
+
target_bars = nil
|
|
32
|
+
if pad && device.bar_multiple
|
|
33
|
+
padding_seconds = TrackPadding.compute(audio.duration, audio.bpm, device.bar_multiple)
|
|
34
|
+
original_bars, target_bars = TrackPadding.bar_counts(audio.duration, audio.bpm, device.bar_multiple) unless padding_seconds.zero?
|
|
35
|
+
padding_seconds = nil if padding_seconds.zero?
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
@ui.bpm(audio.bpm, original_bars: original_bars, target_bars: target_bars)
|
|
39
|
+
@ui.file_progress(file)
|
|
40
|
+
|
|
41
|
+
if target_format.file_type || target_format.sample_rate || target_format.bit_depth || padding_seconds
|
|
42
|
+
converted = @converter.convert(audio, file, path_resolver, source_format, target_format,
|
|
43
|
+
padding_seconds: padding_seconds) do
|
|
44
|
+
@ui.conversion_progress(source_format, target_format)
|
|
45
|
+
end
|
|
46
|
+
target_path = path_resolver.resolve(file, audio, target_file_type: target_format.file_type)
|
|
47
|
+
else
|
|
48
|
+
copied = copy_file(audio, file, path_resolver)
|
|
49
|
+
@ui.copy(source_format)
|
|
50
|
+
target_path = path_resolver.resolve(file, audio)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
if (copied || converted) && device.bpm_source == :acid_chunk && audio.bpm && target_path.extname.downcase == '.wav'
|
|
54
|
+
temp_path = "#{target_path}.tmp"
|
|
55
|
+
AcidChunk.write_bpm(target_path.to_s, temp_path, audio.bpm)
|
|
56
|
+
FileUtils.mv(temp_path, target_path.to_s)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
if !copied && !converted
|
|
60
|
+
skipped_count += 1
|
|
61
|
+
@ui.skip
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
conversion_count += 1 if converted
|
|
65
|
+
@ui.sync_progress(index, @audio_files.size, device)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
puts
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
def find_audio_files
|
|
74
|
+
Audio.find_all(@source_library_path)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def copy_file(audio, source_file_path, path_resolver)
|
|
78
|
+
target_path = path_resolver.resolve(source_file_path, audio)
|
|
79
|
+
|
|
80
|
+
files_to_cleanup = path_resolver.find_files_to_cleanup(target_path, audio)
|
|
81
|
+
files_to_cleanup.each { |file| FileUtils.rm_f(file) }
|
|
82
|
+
|
|
83
|
+
if target_path.exist?
|
|
84
|
+
false
|
|
85
|
+
else
|
|
86
|
+
safe_copy(source_file_path, target_path)
|
|
87
|
+
true
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def safe_copy(source, target)
|
|
92
|
+
FileUtils.install(source, target)
|
|
93
|
+
rescue Errno::ENOENT
|
|
94
|
+
puts 'Errno::ENOENT'
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
data/lib/wavesync/set.rb
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'yaml'
|
|
4
|
+
require 'fileutils'
|
|
5
|
+
|
|
6
|
+
module Wavesync
|
|
7
|
+
class Set
|
|
8
|
+
SETS_FOLDER = '.sets'
|
|
9
|
+
|
|
10
|
+
attr_reader :name, :tracks, :library_path
|
|
11
|
+
|
|
12
|
+
def self.sets_path(library_path)
|
|
13
|
+
File.join(library_path, SETS_FOLDER)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def self.set_path(library_path, name)
|
|
17
|
+
File.join(sets_path(library_path), "#{name}.yml")
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def self.load(library_path, name)
|
|
21
|
+
data = YAML.load_file(set_path(library_path, name))
|
|
22
|
+
new(library_path, data['name'], expand_tracks(library_path, data['tracks']))
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.all(library_path)
|
|
26
|
+
path = sets_path(library_path)
|
|
27
|
+
return [] unless Dir.exist?(path)
|
|
28
|
+
|
|
29
|
+
Dir.glob(File.join(path, '*.yml')).map do |file|
|
|
30
|
+
data = YAML.load_file(file)
|
|
31
|
+
new(library_path, data['name'], expand_tracks(library_path, data['tracks']))
|
|
32
|
+
end.sort_by(&:name)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def self.exists?(library_path, name)
|
|
36
|
+
File.exist?(set_path(library_path, name))
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def initialize(library_path, name, tracks = [])
|
|
40
|
+
@library_path = library_path
|
|
41
|
+
@name = name
|
|
42
|
+
@tracks = tracks.dup
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def add_track(path)
|
|
46
|
+
@tracks << path
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def remove_track(index)
|
|
50
|
+
@tracks.delete_at(index)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def move_up(index)
|
|
54
|
+
return if index <= 0
|
|
55
|
+
|
|
56
|
+
@tracks[index], @tracks[index - 1] = @tracks[index - 1], @tracks[index]
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def move_down(index)
|
|
60
|
+
return if index >= @tracks.size - 1
|
|
61
|
+
|
|
62
|
+
@tracks[index], @tracks[index + 1] = @tracks[index + 1], @tracks[index]
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def save
|
|
66
|
+
FileUtils.mkdir_p(self.class.sets_path(@library_path))
|
|
67
|
+
File.write(self.class.set_path(@library_path, @name), to_yaml)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
def self.expand_tracks(library_path, tracks)
|
|
73
|
+
(tracks || []).map { |t| File.join(library_path, t) }
|
|
74
|
+
end
|
|
75
|
+
private_class_method :expand_tracks
|
|
76
|
+
|
|
77
|
+
def to_yaml
|
|
78
|
+
relative_tracks = @tracks.map { |t| t.sub("#{@library_path}/", '') }
|
|
79
|
+
{ 'name' => @name, 'tracks' => relative_tracks }.to_yaml
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|