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/cli.rb
CHANGED
|
@@ -1,159 +1,22 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
2
3
|
|
|
3
|
-
|
|
4
|
+
require_relative 'commands'
|
|
4
5
|
|
|
5
6
|
module Wavesync
|
|
6
7
|
class CLI
|
|
8
|
+
#: () -> void
|
|
7
9
|
def self.start
|
|
8
|
-
|
|
10
|
+
command_name = ARGV.first && !ARGV.first.start_with?('-') ? ARGV.shift : 'sync'
|
|
11
|
+
command_class = Commands::ALL.find { |cmd| command_name == cmd.name }
|
|
9
12
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
start_sync
|
|
13
|
-
when 'analyze'
|
|
14
|
-
start_analyze
|
|
15
|
-
when 'set'
|
|
16
|
-
start_set
|
|
13
|
+
if command_class
|
|
14
|
+
command_class.new.run
|
|
17
15
|
else
|
|
18
|
-
puts "Unknown command: #{
|
|
19
|
-
puts
|
|
16
|
+
puts "Unknown command: #{command_name}"
|
|
17
|
+
puts "Available commands: #{Commands::ALL.map(&:name).join(', ')}"
|
|
20
18
|
exit 1
|
|
21
19
|
end
|
|
22
20
|
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
21
|
end
|
|
159
22
|
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
require 'optparse'
|
|
5
|
+
|
|
6
|
+
module Wavesync
|
|
7
|
+
module Commands
|
|
8
|
+
class Analyze < Command
|
|
9
|
+
FORCE_OPTION = Option.new(short: '-f', long: '--force', description: 'Overwrite existing BPM values')
|
|
10
|
+
|
|
11
|
+
self.name = 'analyze'
|
|
12
|
+
self.description = 'Detect and write BPM metadata to library tracks'
|
|
13
|
+
self.options = [FORCE_OPTION].freeze
|
|
14
|
+
|
|
15
|
+
#: () -> void
|
|
16
|
+
def run
|
|
17
|
+
options, config = parse_options(banner: 'Usage: wavesync analyze [options]') do |opts, opts_hash|
|
|
18
|
+
opts.on(*FORCE_OPTION.to_a) { opts_hash[:overwrite] = true }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
Wavesync::Analyzer.new(config.library).analyze(overwrite: options[:overwrite] || false)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'optparse'
|
|
4
|
+
|
|
5
|
+
module Wavesync
|
|
6
|
+
module Commands
|
|
7
|
+
class Command
|
|
8
|
+
class << self
|
|
9
|
+
attr_accessor :name, :description
|
|
10
|
+
attr_writer :options, :subcommands
|
|
11
|
+
|
|
12
|
+
def options = @options || []
|
|
13
|
+
def subcommands = @subcommands || []
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
#: (banner: String) ?{ (OptionParser, Hash[Symbol, untyped]) -> void } -> [Hash[Symbol, untyped], Config]
|
|
17
|
+
def parse_options(banner:)
|
|
18
|
+
options = {} #: Hash[Symbol, untyped]
|
|
19
|
+
OptionParser.new do |opts|
|
|
20
|
+
opts.banner = banner
|
|
21
|
+
opts.on(*CONFIG_OPTION.to_a) { |value| options[:config] = value }
|
|
22
|
+
yield opts, options if block_given?
|
|
23
|
+
end.parse!
|
|
24
|
+
config_path = options[:config] || Wavesync::Config::DEFAULT_PATH
|
|
25
|
+
config = Commands.load_config(config_path)
|
|
26
|
+
[options, config]
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
require 'optparse'
|
|
5
|
+
require 'rainbow'
|
|
6
|
+
|
|
7
|
+
module Wavesync
|
|
8
|
+
module Commands
|
|
9
|
+
class Help < Command
|
|
10
|
+
self.name = 'help'
|
|
11
|
+
self.description = 'Show this help message'
|
|
12
|
+
|
|
13
|
+
DESCRIPTION_COLUMN = 23
|
|
14
|
+
|
|
15
|
+
#: () -> void
|
|
16
|
+
def run
|
|
17
|
+
subcommand_name = ARGV.shift
|
|
18
|
+
|
|
19
|
+
if subcommand_name
|
|
20
|
+
command = ALL.find { |cmd| subcommand_name == cmd.name }
|
|
21
|
+
if command
|
|
22
|
+
show_command_help(command)
|
|
23
|
+
else
|
|
24
|
+
puts "Unknown command: #{subcommand_name}"
|
|
25
|
+
puts "Available commands: #{ALL.map(&:name).reject { |cmd_name| cmd_name == self.class.name }.join(', ')}"
|
|
26
|
+
exit 1
|
|
27
|
+
end
|
|
28
|
+
else
|
|
29
|
+
show_general_help
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
#: () -> void
|
|
36
|
+
def show_general_help
|
|
37
|
+
puts 'Usage: wavesync [command] [options]'
|
|
38
|
+
puts ''
|
|
39
|
+
puts 'Commands:'
|
|
40
|
+
ALL.each do |command|
|
|
41
|
+
if command.subcommands.any?
|
|
42
|
+
command.subcommands.each { |subcommand| puts format_command_line(subcommand.usage, subcommand.description) }
|
|
43
|
+
else
|
|
44
|
+
puts format_command_line(command.name, command.description)
|
|
45
|
+
command.options.each { |option| puts format_option_line(option, indent: 4) }
|
|
46
|
+
end
|
|
47
|
+
puts ''
|
|
48
|
+
end
|
|
49
|
+
puts 'Options:'
|
|
50
|
+
GLOBAL_OPTIONS.each { |option| puts format_option_line(option, indent: 2) }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
#: (untyped command) -> void
|
|
54
|
+
def show_command_help(command)
|
|
55
|
+
if command.subcommands.any?
|
|
56
|
+
puts "Usage: wavesync #{command.name} <subcommand> [options]"
|
|
57
|
+
puts ''
|
|
58
|
+
puts 'Subcommands:'
|
|
59
|
+
command.subcommands.each do |subcommand|
|
|
60
|
+
subcommand_key = subcommand.usage.delete_prefix("#{command.name} ")
|
|
61
|
+
puts " #{subcommand_key.ljust(DESCRIPTION_COLUMN - 2)}#{subcommand.description}"
|
|
62
|
+
end
|
|
63
|
+
puts ''
|
|
64
|
+
puts 'Options:'
|
|
65
|
+
GLOBAL_OPTIONS.each { |option| puts " #{option.short}, #{option.long} #{option.description}" }
|
|
66
|
+
else
|
|
67
|
+
OptionParser.new do |opts|
|
|
68
|
+
opts.banner = "Usage: wavesync #{command.name} [options]"
|
|
69
|
+
(command.options + GLOBAL_OPTIONS).each { |option| opts.on(*option.to_a) }
|
|
70
|
+
puts opts
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
#: (String name, String description) -> String
|
|
76
|
+
def format_command_line(name, description)
|
|
77
|
+
" #{name.ljust(DESCRIPTION_COLUMN - 2)}#{description}"
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
#: (untyped option, indent: Integer) -> String
|
|
81
|
+
def format_option_line(option, indent:)
|
|
82
|
+
key = "#{option.short}, #{option.long}"
|
|
83
|
+
Rainbow("#{' ' * indent}#{key.ljust(DESCRIPTION_COLUMN - indent)}#{option.description}").darkgray
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
require 'optparse'
|
|
5
|
+
|
|
6
|
+
module Wavesync
|
|
7
|
+
module Commands
|
|
8
|
+
class Set < Command
|
|
9
|
+
self.name = 'set'
|
|
10
|
+
self.subcommands = [
|
|
11
|
+
Subcommand.new(usage: 'set create NAME', description: 'Create a new track set'),
|
|
12
|
+
Subcommand.new(usage: 'set edit NAME', description: 'Edit an existing track set'),
|
|
13
|
+
Subcommand.new(usage: 'set list', description: 'List all track sets')
|
|
14
|
+
].freeze
|
|
15
|
+
|
|
16
|
+
#: () -> void
|
|
17
|
+
def run
|
|
18
|
+
subcommand = ARGV.shift
|
|
19
|
+
|
|
20
|
+
_options, config = parse_options(banner: 'Usage: wavesync set <subcommand> [options]')
|
|
21
|
+
|
|
22
|
+
case subcommand
|
|
23
|
+
when 'create'
|
|
24
|
+
name = require_name('create')
|
|
25
|
+
if Wavesync::Set.exists?(config.library, name)
|
|
26
|
+
puts "Set '#{name}' already exists. Use 'wavesync set edit #{name}' to edit it."
|
|
27
|
+
exit 1
|
|
28
|
+
end
|
|
29
|
+
set = Wavesync::Set.new(config.library, name)
|
|
30
|
+
Wavesync::SetEditor.new(set, config.library).run
|
|
31
|
+
when 'edit'
|
|
32
|
+
name = require_name('edit')
|
|
33
|
+
unless Wavesync::Set.exists?(config.library, name)
|
|
34
|
+
puts "Set '#{name}' not found. Use 'wavesync set create #{name}' to create it."
|
|
35
|
+
exit 1
|
|
36
|
+
end
|
|
37
|
+
set = Wavesync::Set.load(config.library, name)
|
|
38
|
+
Wavesync::SetEditor.new(set, config.library).run
|
|
39
|
+
when 'list'
|
|
40
|
+
sets = Wavesync::Set.all(config.library)
|
|
41
|
+
if sets.empty?
|
|
42
|
+
puts 'No sets found.'
|
|
43
|
+
else
|
|
44
|
+
sets.each { |set| puts "#{set.name} (#{set.tracks.size} tracks)" }
|
|
45
|
+
end
|
|
46
|
+
else
|
|
47
|
+
puts "Unknown subcommand: #{subcommand || '(none)'}"
|
|
48
|
+
puts 'Available subcommands: create, edit, list'
|
|
49
|
+
exit 1
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
#: (String subcommand) -> String
|
|
56
|
+
def require_name(subcommand)
|
|
57
|
+
name = ARGV.shift
|
|
58
|
+
unless name
|
|
59
|
+
puts "Usage: wavesync set #{subcommand} <name>"
|
|
60
|
+
exit 1
|
|
61
|
+
end
|
|
62
|
+
name
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
require 'optparse'
|
|
5
|
+
|
|
6
|
+
module Wavesync
|
|
7
|
+
module Commands
|
|
8
|
+
class Sync < Command
|
|
9
|
+
DEVICE_OPTION = Option.new(short: '-d', long: '--device NAME', description: 'Name of device to sync (as defined in config)')
|
|
10
|
+
PAD_OPTION = Option.new(short: '-p', long: '--pad', description: 'Pad tracks with silence so total length is a multiple of 64 bars (Octatrack only)')
|
|
11
|
+
|
|
12
|
+
self.name = 'sync'
|
|
13
|
+
self.description = 'Sync music library to a device'
|
|
14
|
+
self.options = [DEVICE_OPTION, PAD_OPTION].freeze
|
|
15
|
+
|
|
16
|
+
#: () -> void
|
|
17
|
+
def run
|
|
18
|
+
options, config = parse_options(banner: 'Usage: wavesync sync [options]') do |opts, opts_hash|
|
|
19
|
+
opts.on(*DEVICE_OPTION.to_a) { |value| opts_hash[:device] = value }
|
|
20
|
+
opts.on(*PAD_OPTION.to_a) { opts_hash[:pad] = true }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
device_configs = config.device_configs
|
|
24
|
+
if options[:device]
|
|
25
|
+
device_configs = device_configs.select { |device_config| device_config[:name] == options[:device] }
|
|
26
|
+
if device_configs.empty?
|
|
27
|
+
known = config.device_configs.map { |device_config| device_config[:name] }.join(', ')
|
|
28
|
+
puts "Unknown device \"#{options[:device]}\". Devices in config: #{known}"
|
|
29
|
+
exit 1
|
|
30
|
+
end
|
|
31
|
+
elsif device_configs.size > 1
|
|
32
|
+
device_names = device_configs.map { |device_config| device_config[:name] }
|
|
33
|
+
selected_name = Wavesync::UI.new.select('Select device', device_names)
|
|
34
|
+
device_configs = device_configs.select { |device_config| device_config[:name] == selected_name }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
device_pairs = device_configs.map do |device_config|
|
|
38
|
+
device = Wavesync::Device.find_by(name: device_config[:model])
|
|
39
|
+
unless device
|
|
40
|
+
supported = Wavesync::Device.all.map(&:name).join(', ')
|
|
41
|
+
puts "Unknown device model \"#{device_config[:model]}\" in config. Supported models: #{supported}"
|
|
42
|
+
exit 1
|
|
43
|
+
end
|
|
44
|
+
[device_config, device]
|
|
45
|
+
end #: Array[untyped]
|
|
46
|
+
|
|
47
|
+
scanner = Wavesync::Scanner.new(config.library)
|
|
48
|
+
|
|
49
|
+
device_pairs.each do |pair|
|
|
50
|
+
device_config = pair[0] #: { name: String, model: String, path: String }
|
|
51
|
+
device = pair[1] #: Wavesync::Device
|
|
52
|
+
scanner.sync(device_config[:path], device, pad: options[:pad] || false)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
module Wavesync
|
|
5
|
+
module Commands
|
|
6
|
+
Option = Struct.new(:short, :long, :description)
|
|
7
|
+
Subcommand = Struct.new(:usage, :description)
|
|
8
|
+
|
|
9
|
+
CONFIG_OPTION = Option.new(short: '-c', long: '--config PATH', description: 'Path to wavesync config YAML file')
|
|
10
|
+
GLOBAL_OPTIONS = [CONFIG_OPTION].freeze
|
|
11
|
+
|
|
12
|
+
#: (String path) -> Config
|
|
13
|
+
def self.load_config(path)
|
|
14
|
+
Wavesync::Config.load(path)
|
|
15
|
+
rescue Wavesync::ConfigError => e
|
|
16
|
+
puts "Configuration error: #{e.message}"
|
|
17
|
+
exit 1
|
|
18
|
+
end
|
|
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'
|
|
25
|
+
|
|
26
|
+
ALL = [Sync, Analyze, Set, Help].freeze
|
|
27
|
+
end
|
|
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
|