wavesync 1.0.0.alpha1 → 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 +58 -51
- data/config/devices.yml +3 -0
- data/lib/wavesync/acid_chunk.rb +9 -4
- data/lib/wavesync/analyzer.rb +13 -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 +9 -146
- data/lib/wavesync/commands/analyze.rb +25 -0
- data/lib/wavesync/commands/command.rb +30 -0
- data/lib/wavesync/commands/help.rb +87 -0
- data/lib/wavesync/commands/set.rb +66 -0
- data/lib/wavesync/commands/sync.rb +57 -0
- data/lib/wavesync/commands.rb +28 -0
- 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 +39 -3
- data/lib/wavesync/version.rb +2 -1
- data/lib/wavesync.rb +2 -0
- metadata +15 -3
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
|
data/lib/wavesync/set.rb
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
2
3
|
|
|
3
4
|
require 'yaml'
|
|
4
5
|
require 'fileutils'
|
|
@@ -7,21 +8,27 @@ module Wavesync
|
|
|
7
8
|
class Set
|
|
8
9
|
SETS_FOLDER = '.sets'
|
|
9
10
|
|
|
10
|
-
attr_reader :name
|
|
11
|
+
attr_reader :name #: String
|
|
12
|
+
attr_reader :tracks #: Array[String]
|
|
13
|
+
attr_reader :library_path #: String
|
|
11
14
|
|
|
15
|
+
#: (String library_path) -> String
|
|
12
16
|
def self.sets_path(library_path)
|
|
13
17
|
File.join(library_path, SETS_FOLDER)
|
|
14
18
|
end
|
|
15
19
|
|
|
20
|
+
#: (String library_path, String name) -> String
|
|
16
21
|
def self.set_path(library_path, name)
|
|
17
22
|
File.join(sets_path(library_path), "#{name}.yml")
|
|
18
23
|
end
|
|
19
24
|
|
|
25
|
+
#: (String library_path, String name) -> Set
|
|
20
26
|
def self.load(library_path, name)
|
|
21
27
|
data = YAML.load_file(set_path(library_path, name))
|
|
22
28
|
new(library_path, data['name'], expand_tracks(library_path, data['tracks']))
|
|
23
29
|
end
|
|
24
30
|
|
|
31
|
+
#: (String library_path) -> Array[Set]
|
|
25
32
|
def self.all(library_path)
|
|
26
33
|
path = sets_path(library_path)
|
|
27
34
|
return [] unless Dir.exist?(path)
|
|
@@ -32,36 +39,43 @@ module Wavesync
|
|
|
32
39
|
end.sort_by(&:name)
|
|
33
40
|
end
|
|
34
41
|
|
|
42
|
+
#: (String library_path, String name) -> bool
|
|
35
43
|
def self.exists?(library_path, name)
|
|
36
44
|
File.exist?(set_path(library_path, name))
|
|
37
45
|
end
|
|
38
46
|
|
|
47
|
+
#: (String library_path, String name, ?Array[String] tracks) -> void
|
|
39
48
|
def initialize(library_path, name, tracks = [])
|
|
40
49
|
@library_path = library_path
|
|
41
50
|
@name = name
|
|
42
51
|
@tracks = tracks.dup
|
|
43
52
|
end
|
|
44
53
|
|
|
54
|
+
#: (String path) -> void
|
|
45
55
|
def add_track(path)
|
|
46
56
|
@tracks << path
|
|
47
57
|
end
|
|
48
58
|
|
|
59
|
+
#: (Integer index) -> String?
|
|
49
60
|
def remove_track(index)
|
|
50
61
|
@tracks.delete_at(index)
|
|
51
62
|
end
|
|
52
63
|
|
|
64
|
+
#: (Integer index) -> void
|
|
53
65
|
def move_up(index)
|
|
54
66
|
return if index <= 0
|
|
55
67
|
|
|
56
68
|
@tracks[index], @tracks[index - 1] = @tracks[index - 1], @tracks[index]
|
|
57
69
|
end
|
|
58
70
|
|
|
71
|
+
#: (Integer index) -> void
|
|
59
72
|
def move_down(index)
|
|
60
73
|
return if index >= @tracks.size - 1
|
|
61
74
|
|
|
62
75
|
@tracks[index], @tracks[index + 1] = @tracks[index + 1], @tracks[index]
|
|
63
76
|
end
|
|
64
77
|
|
|
78
|
+
#: () -> void
|
|
65
79
|
def save
|
|
66
80
|
FileUtils.mkdir_p(self.class.sets_path(@library_path))
|
|
67
81
|
File.write(self.class.set_path(@library_path, @name), to_yaml)
|
|
@@ -69,11 +83,13 @@ module Wavesync
|
|
|
69
83
|
|
|
70
84
|
private
|
|
71
85
|
|
|
86
|
+
#: (String library_path, Array[String]? tracks) -> Array[String]
|
|
72
87
|
def self.expand_tracks(library_path, tracks)
|
|
73
88
|
(tracks || []).map { |t| File.join(library_path, t) }
|
|
74
89
|
end
|
|
75
90
|
private_class_method :expand_tracks
|
|
76
91
|
|
|
92
|
+
#: () -> String
|
|
77
93
|
def to_yaml
|
|
78
94
|
relative_tracks = @tracks.map { |t| t.sub("#{@library_path}/", '') }
|
|
79
95
|
{ 'name' => @name, 'tracks' => relative_tracks }.to_yaml
|