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.
@@ -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, :sample_rates, :bit_depths, :file_types, :bpm_source, :bar_multiple
7
+ attr_reader :name #: String
8
+ attr_reader :sample_rates #: Array[Integer]
9
+ attr_reader :bit_depths #: Array[Integer]
10
+ attr_reader :file_types #: Array[String]
11
+ attr_reader :bpm_source #: Symbol?
12
+ attr_reader :bar_multiple #: Integer?
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
- target_path = add_bpm_to_filename(target_path, audio.bpm) if @device.bpm_source == :filename && audio.bpm
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
- .reject { |path| path == target_path }
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
@@ -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
- if (copied || converted) && device.bpm_source == :acid_chunk && audio.bpm && target_path.extname.downcase == '.wav'
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, audio.bpm)
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, :tracks, :library_path
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