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.
@@ -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
@@ -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