wavesync 1.0.0.alpha3 → 1.0.0.alpha4
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 +64 -14
- data/config/devices.yml +17 -0
- data/lib/wavesync/acid_chunk.rb +36 -0
- data/lib/wavesync/analyzer.rb +8 -0
- data/lib/wavesync/audio.rb +118 -94
- data/lib/wavesync/audio_format.rb +8 -2
- data/lib/wavesync/cli.rb +1 -0
- data/lib/wavesync/commands/clear_cache.rb +75 -0
- data/lib/wavesync/commands/command.rb +2 -0
- data/lib/wavesync/commands/pull.rb +43 -0
- data/lib/wavesync/commands/setlist.rb +66 -0
- data/lib/wavesync/commands/sync.rb +15 -26
- data/lib/wavesync/commands.rb +44 -2
- data/lib/wavesync/config.rb +37 -3
- data/lib/wavesync/cue_chunk.rb +24 -0
- data/lib/wavesync/device.rb +14 -5
- data/lib/wavesync/essentia_bpm_detector.rb +3 -1
- data/lib/wavesync/ffmpeg/probe.rb +74 -0
- data/lib/wavesync/ffmpeg.rb +144 -0
- data/lib/wavesync/file_converter.rb +6 -3
- data/lib/wavesync/libmtp.rb +333 -0
- data/lib/wavesync/logger.rb +84 -0
- data/lib/wavesync/path_resolver.rb +19 -2
- data/lib/wavesync/percival_bpm_detector.rb +3 -1
- data/lib/wavesync/scanner.rb +117 -50
- data/lib/wavesync/{set.rb → setlist.rb} +13 -13
- data/lib/wavesync/{set_editor.rb → setlist_editor.rb} +52 -43
- data/lib/wavesync/track_padding.rb +10 -2
- data/lib/wavesync/transport/filesystem.rb +36 -0
- data/lib/wavesync/transport/mtp.rb +285 -0
- data/lib/wavesync/transport.rb +21 -0
- data/lib/wavesync/ui.rb +43 -12
- data/lib/wavesync/version.rb +1 -1
- data/lib/wavesync.rb +6 -2
- metadata +13 -32
- data/lib/wavesync/commands/set.rb +0 -66
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
require 'optparse'
|
|
5
|
+
require_relative '../transport'
|
|
6
|
+
|
|
7
|
+
module Wavesync
|
|
8
|
+
module Commands
|
|
9
|
+
class Pull < Command
|
|
10
|
+
DEVICE_OPTION = Option.new(short: '-d', long: '--device NAME', description: 'Name of device to pull from (as defined in config)')
|
|
11
|
+
|
|
12
|
+
self.name = 'pull'
|
|
13
|
+
self.description = 'Read cue points from device files and write them back into the source library'
|
|
14
|
+
self.options = [DEVICE_OPTION].freeze
|
|
15
|
+
|
|
16
|
+
#: () -> void
|
|
17
|
+
def run
|
|
18
|
+
options, config = parse_options(banner: 'Usage: wavesync pull [options]') do |opts, opts_hash|
|
|
19
|
+
opts.on(*DEVICE_OPTION.to_a) { |value| opts_hash[:device] = value }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
device_pairs = Commands.resolve_device_pairs(config, device_name: options[:device])
|
|
23
|
+
scanner = Wavesync::Scanner.new(config.library)
|
|
24
|
+
ui = Wavesync::UI.new
|
|
25
|
+
|
|
26
|
+
device_pairs.each do |pair|
|
|
27
|
+
device_config = pair[0] #: { name: String, model: String, path: String, transport: String, mp3_bitrate: Integer }
|
|
28
|
+
device = pair[1] #: Wavesync::Device
|
|
29
|
+
transport = Wavesync::Transport.for(device_config)
|
|
30
|
+
Commands.with_mtp_retry(transport, device_config[:name]) do
|
|
31
|
+
next unless transport.is_a?(Wavesync::Transport::Mtp)
|
|
32
|
+
|
|
33
|
+
transport.prepare! do |index, total, relative_path|
|
|
34
|
+
ui.pull_staging_progress(index, total, device)
|
|
35
|
+
ui.file_progress(relative_path)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
scanner.pull_cue_points(transport.working_directory, device)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
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 Setlist < Command
|
|
9
|
+
self.name = 'setlist'
|
|
10
|
+
self.subcommands = [
|
|
11
|
+
Subcommand.new(usage: 'setlist create NAME', description: 'Create a new setlist'),
|
|
12
|
+
Subcommand.new(usage: 'setlist edit NAME', description: 'Edit an existing setlist'),
|
|
13
|
+
Subcommand.new(usage: 'setlist list', description: 'List all setlists')
|
|
14
|
+
].freeze
|
|
15
|
+
|
|
16
|
+
#: () -> void
|
|
17
|
+
def run
|
|
18
|
+
subcommand = ARGV.shift
|
|
19
|
+
|
|
20
|
+
_options, config = parse_options(banner: 'Usage: wavesync setlist <subcommand> [options]')
|
|
21
|
+
|
|
22
|
+
case subcommand
|
|
23
|
+
when 'create'
|
|
24
|
+
name = require_name('create')
|
|
25
|
+
if Wavesync::Setlist.exists?(config.library, name)
|
|
26
|
+
puts "Setlist '#{name}' already exists. Use 'wavesync setlist edit #{name}' to edit it."
|
|
27
|
+
exit 1
|
|
28
|
+
end
|
|
29
|
+
setlist = Wavesync::Setlist.new(config.library, name)
|
|
30
|
+
Wavesync::SetlistEditor.new(setlist, config.library).run
|
|
31
|
+
when 'edit'
|
|
32
|
+
name = require_name('edit')
|
|
33
|
+
unless Wavesync::Setlist.exists?(config.library, name)
|
|
34
|
+
puts "Setlist '#{name}' not found. Use 'wavesync setlist create #{name}' to create it."
|
|
35
|
+
exit 1
|
|
36
|
+
end
|
|
37
|
+
setlist = Wavesync::Setlist.load(config.library, name)
|
|
38
|
+
Wavesync::SetlistEditor.new(setlist, config.library).run
|
|
39
|
+
when 'list'
|
|
40
|
+
setlists = Wavesync::Setlist.all(config.library)
|
|
41
|
+
if setlists.empty?
|
|
42
|
+
puts 'No setlists found.'
|
|
43
|
+
else
|
|
44
|
+
setlists.each { |setlist| puts "#{setlist.name} (#{setlist.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 setlist #{subcommand} <name>"
|
|
60
|
+
exit 1
|
|
61
|
+
end
|
|
62
|
+
name
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
# rbs_inline: enabled
|
|
3
3
|
|
|
4
4
|
require 'optparse'
|
|
5
|
+
require_relative '../transport'
|
|
5
6
|
|
|
6
7
|
module Wavesync
|
|
7
8
|
module Commands
|
|
@@ -20,36 +21,24 @@ module Wavesync
|
|
|
20
21
|
opts.on(*PAD_OPTION.to_a) { opts_hash[:pad] = true }
|
|
21
22
|
end
|
|
22
23
|
|
|
23
|
-
|
|
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
|
-
|
|
24
|
+
device_pairs = Commands.resolve_device_pairs(config, device_name: options[:device])
|
|
47
25
|
scanner = Wavesync::Scanner.new(config.library)
|
|
48
26
|
|
|
49
27
|
device_pairs.each do |pair|
|
|
50
|
-
device_config = pair[0] #: { name: String, model: String, path: String }
|
|
28
|
+
device_config = pair[0] #: { name: String, model: String, path: String, transport: String, mp3_bitrate: Integer }
|
|
51
29
|
device = pair[1] #: Wavesync::Device
|
|
52
|
-
|
|
30
|
+
transport = Wavesync::Transport.for(device_config)
|
|
31
|
+
Commands.with_mtp_retry(transport, device_config[:name]) do
|
|
32
|
+
puts "Pushing to #{device_config[:name]} via MTP..." if transport.is_a?(Wavesync::Transport::Mtp)
|
|
33
|
+
transport.begin_push!
|
|
34
|
+
end
|
|
35
|
+
begin
|
|
36
|
+
scanner.sync(transport.working_directory, device, pad: options[:pad] || false, staged: transport.is_a?(Wavesync::Transport::Mtp), mp3_bitrate: device_config[:mp3_bitrate]) do |relative_path|
|
|
37
|
+
transport.push_file!(relative_path)
|
|
38
|
+
end
|
|
39
|
+
ensure
|
|
40
|
+
transport.finish_push!
|
|
41
|
+
end
|
|
53
42
|
end
|
|
54
43
|
end
|
|
55
44
|
end
|
data/lib/wavesync/commands.rb
CHANGED
|
@@ -17,12 +17,54 @@ module Wavesync
|
|
|
17
17
|
exit 1
|
|
18
18
|
end
|
|
19
19
|
|
|
20
|
+
#: (Config config, ?device_name: String?) -> Array[untyped]
|
|
21
|
+
def self.resolve_device_pairs(config, device_name: nil)
|
|
22
|
+
device_configs = config.device_configs
|
|
23
|
+
if device_name
|
|
24
|
+
device_configs = device_configs.select { |device_config| device_config[:name] == device_name }
|
|
25
|
+
if device_configs.empty?
|
|
26
|
+
known = config.device_configs.map { |device_config| device_config[:name] }.join(', ')
|
|
27
|
+
puts "Unknown device \"#{device_name}\". Devices in config: #{known}"
|
|
28
|
+
exit 1
|
|
29
|
+
end
|
|
30
|
+
elsif device_configs.size > 1
|
|
31
|
+
device_names = device_configs.map { |device_config| device_config[:name] }
|
|
32
|
+
selected_name = Wavesync::UI.new.select('Select device', device_names)
|
|
33
|
+
device_configs = device_configs.select { |device_config| device_config[:name] == selected_name }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
device_configs.map do |device_config|
|
|
37
|
+
device = Wavesync::Device.find_by(name: device_config[:model])
|
|
38
|
+
unless device
|
|
39
|
+
supported = Wavesync::Device.all.map(&:name).join(', ')
|
|
40
|
+
puts "Unknown device model \"#{device_config[:model]}\" in config. Supported models: #{supported}"
|
|
41
|
+
exit 1
|
|
42
|
+
end
|
|
43
|
+
[device_config, device]
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
#: ((Wavesync::Transport::Filesystem | Wavesync::Transport::Mtp) transport, String device_name) { () -> void } -> void
|
|
48
|
+
def self.with_mtp_retry(transport, device_name, &block)
|
|
49
|
+
return block.call unless transport.is_a?(Wavesync::Transport::Mtp)
|
|
50
|
+
|
|
51
|
+
begin
|
|
52
|
+
block.call
|
|
53
|
+
rescue Wavesync::Libmtp::Error => e
|
|
54
|
+
puts "Could not reach #{device_name} over MTP (#{e.message}). Put the device in MTP mode, then press Enter to retry (Ctrl+C to abort)."
|
|
55
|
+
$stdin.gets
|
|
56
|
+
retry
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
20
60
|
require_relative 'commands/command'
|
|
21
61
|
require_relative 'commands/sync'
|
|
62
|
+
require_relative 'commands/pull'
|
|
22
63
|
require_relative 'commands/analyze'
|
|
23
|
-
require_relative 'commands/
|
|
64
|
+
require_relative 'commands/setlist'
|
|
65
|
+
require_relative 'commands/clear_cache'
|
|
24
66
|
require_relative 'commands/help'
|
|
25
67
|
|
|
26
|
-
ALL = [Sync, Analyze,
|
|
68
|
+
ALL = [Sync, Pull, Analyze, Setlist, ClearCache, Help].freeze
|
|
27
69
|
end
|
|
28
70
|
end
|
data/lib/wavesync/config.rb
CHANGED
|
@@ -10,11 +10,14 @@ module Wavesync
|
|
|
10
10
|
DEFAULT_PATH = File.join(Dir.home, 'wavesync.yml')
|
|
11
11
|
|
|
12
12
|
SUPPORTED_KEYS = %w[library devices].freeze
|
|
13
|
-
DEVICE_SUPPORTED_KEYS = %w[name model path].freeze
|
|
13
|
+
DEVICE_SUPPORTED_KEYS = %w[name model path transport mp3_bitrate].freeze
|
|
14
14
|
DEVICE_REQUIRED_KEYS = %w[name model path].freeze
|
|
15
|
+
SUPPORTED_TRANSPORTS = %w[filesystem mtp].freeze
|
|
16
|
+
SUPPORTED_MP3_BITRATES = [96, 128, 160, 192, 256, 320].freeze
|
|
17
|
+
DEFAULT_MP3_BITRATE = 192
|
|
15
18
|
|
|
16
19
|
attr_reader :library #: String
|
|
17
|
-
attr_reader :device_configs #: Array[{ name: String, model: String, path: String }]
|
|
20
|
+
attr_reader :device_configs #: Array[{ name: String, model: String, path: String, transport: String, mp3_bitrate: Integer }]
|
|
18
21
|
|
|
19
22
|
#: (?String path) -> Config
|
|
20
23
|
def self.load(path = DEFAULT_PATH)
|
|
@@ -35,7 +38,15 @@ module Wavesync
|
|
|
35
38
|
@library = File.expand_path(data['library'])
|
|
36
39
|
@device_configs = data['devices'].each_with_index.map do |device, i|
|
|
37
40
|
validate_device!(device, i)
|
|
38
|
-
|
|
41
|
+
transport = device['transport'] || 'filesystem'
|
|
42
|
+
path = transport == 'filesystem' ? File.expand_path(device['path']) : device['path']
|
|
43
|
+
{
|
|
44
|
+
name: device['name'],
|
|
45
|
+
model: device['model'],
|
|
46
|
+
path: path,
|
|
47
|
+
transport: transport,
|
|
48
|
+
mp3_bitrate: device['mp3_bitrate'] || DEFAULT_MP3_BITRATE
|
|
49
|
+
}
|
|
39
50
|
end
|
|
40
51
|
end
|
|
41
52
|
|
|
@@ -71,6 +82,29 @@ module Wavesync
|
|
|
71
82
|
%w[name model path].each do |key|
|
|
72
83
|
raise ConfigError, "Device #{index + 1} '#{key}' must be a string" unless device[key].is_a?(String)
|
|
73
84
|
end
|
|
85
|
+
|
|
86
|
+
validate_device_transport!(device, index)
|
|
87
|
+
validate_device_mp3_bitrate!(device, index)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
#: (Hash[String, untyped] device, Integer index) -> void
|
|
91
|
+
def validate_device_transport!(device, index)
|
|
92
|
+
return unless device.key?('transport')
|
|
93
|
+
|
|
94
|
+
raise ConfigError, "Device #{index + 1} 'transport' must be a string" unless device['transport'].is_a?(String)
|
|
95
|
+
return if SUPPORTED_TRANSPORTS.include?(device['transport'])
|
|
96
|
+
|
|
97
|
+
raise ConfigError, "Device #{index + 1} 'transport' must be one of: #{SUPPORTED_TRANSPORTS.join(', ')}"
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
#: (Hash[String, untyped] device, Integer index) -> void
|
|
101
|
+
def validate_device_mp3_bitrate!(device, index)
|
|
102
|
+
return unless device.key?('mp3_bitrate')
|
|
103
|
+
|
|
104
|
+
raise ConfigError, "Device #{index + 1} 'mp3_bitrate' must be an integer" unless device['mp3_bitrate'].is_a?(Integer)
|
|
105
|
+
return if SUPPORTED_MP3_BITRATES.include?(device['mp3_bitrate'])
|
|
106
|
+
|
|
107
|
+
raise ConfigError, "Device #{index + 1} 'mp3_bitrate' must be one of: #{SUPPORTED_MP3_BITRATES.join(', ')}"
|
|
74
108
|
end
|
|
75
109
|
end
|
|
76
110
|
end
|
data/lib/wavesync/cue_chunk.rb
CHANGED
|
@@ -55,6 +55,30 @@ module Wavesync
|
|
|
55
55
|
cue_points.map { |cue_point| { identifier: cue_point[:identifier], sample_offset: cue_point[:sample_offset], label: labels[cue_point[:identifier]] } }
|
|
56
56
|
end
|
|
57
57
|
|
|
58
|
+
#: (Array[{identifier: Integer, sample_offset: Integer, label: String?}] cue_points_a, Array[{identifier: Integer, sample_offset: Integer, label: String?}] cue_points_b) -> bool
|
|
59
|
+
def self.same?(cue_points_a, cue_points_b)
|
|
60
|
+
to_comparable(cue_points_a) == to_comparable(cue_points_b)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
#: (Array[{identifier: Integer, sample_offset: Integer, label: String?}] cue_points) -> Array[{sample_offset: Integer, label: String?}]
|
|
64
|
+
def self.to_comparable(cue_points)
|
|
65
|
+
mapped = cue_points.map { |cp| { sample_offset: cp[:sample_offset], label: cp[:label] } } #: Array[{sample_offset: Integer, label: String?}]
|
|
66
|
+
mapped.sort_by { |cp| cp[:sample_offset] }
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
#: (String filepath, Array[{identifier: Integer, sample_offset: Integer, label: String?}] cue_points) -> void
|
|
70
|
+
def self.append_to_file(filepath, cue_points)
|
|
71
|
+
return if cue_points.empty?
|
|
72
|
+
|
|
73
|
+
File.open(filepath, 'ab') do |file|
|
|
74
|
+
write_cue_chunk(file, cue_points)
|
|
75
|
+
labeled_cue_points = cue_points.select { |cue_point| cue_point[:label] }
|
|
76
|
+
write_adtl_chunk(file, labeled_cue_points) if labeled_cue_points.any?
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
update_riff_size(filepath)
|
|
80
|
+
end
|
|
81
|
+
|
|
58
82
|
#: (String source_filepath, String output_filepath, Array[{identifier: Integer, sample_offset: Integer, label: String?}] cue_points) -> void
|
|
59
83
|
def self.write(source_filepath, output_filepath, cue_points)
|
|
60
84
|
File.open(source_filepath, 'rb') do |input|
|
data/lib/wavesync/device.rb
CHANGED
|
@@ -10,15 +10,21 @@ module Wavesync
|
|
|
10
10
|
attr_reader :file_types #: Array[String]
|
|
11
11
|
attr_reader :bpm_source #: Symbol?
|
|
12
12
|
attr_reader :bar_multiple #: Integer?
|
|
13
|
+
attr_reader :unsupported_characters #: Array[String]
|
|
14
|
+
attr_reader :transliterate_metadata #: bool
|
|
15
|
+
attr_reader :uppercase_paths #: bool
|
|
13
16
|
|
|
14
|
-
#: (name: String, sample_rates: Array[Integer],
|
|
15
|
-
def initialize(name:, sample_rates:,
|
|
17
|
+
#: (name: String, sample_rates: Array[Integer], file_types: Array[String], ?bit_depths: Array[Integer], ?bpm_source: Symbol?, ?bar_multiple: Integer?, ?unsupported_characters: Array[String], ?transliterate_metadata: bool, ?uppercase_paths: bool) -> void
|
|
18
|
+
def initialize(name:, sample_rates:, file_types:, bit_depths: [], bpm_source: nil, bar_multiple: nil, unsupported_characters: [], transliterate_metadata: false, uppercase_paths: false)
|
|
16
19
|
@name = name
|
|
17
20
|
@sample_rates = sample_rates
|
|
18
21
|
@bit_depths = bit_depths
|
|
19
22
|
@file_types = file_types
|
|
20
23
|
@bpm_source = bpm_source
|
|
21
24
|
@bar_multiple = bar_multiple
|
|
25
|
+
@unsupported_characters = unsupported_characters
|
|
26
|
+
@transliterate_metadata = transliterate_metadata
|
|
27
|
+
@uppercase_paths = uppercase_paths
|
|
22
28
|
end
|
|
23
29
|
|
|
24
30
|
#: () -> String
|
|
@@ -43,10 +49,13 @@ module Wavesync
|
|
|
43
49
|
new(
|
|
44
50
|
name: attrs['name'],
|
|
45
51
|
sample_rates: attrs['sample_rates'],
|
|
46
|
-
bit_depths: attrs['bit_depths'],
|
|
52
|
+
bit_depths: attrs['bit_depths'] || [],
|
|
47
53
|
file_types: attrs['file_types'],
|
|
48
54
|
bpm_source: attrs['bpm_source']&.to_sym,
|
|
49
|
-
bar_multiple: attrs['bar_multiple']
|
|
55
|
+
bar_multiple: attrs['bar_multiple'],
|
|
56
|
+
unsupported_characters: attrs['unsupported_characters'] || [],
|
|
57
|
+
transliterate_metadata: attrs['transliterate_metadata'] || false,
|
|
58
|
+
uppercase_paths: attrs['uppercase_paths'] || false
|
|
50
59
|
)
|
|
51
60
|
end
|
|
52
61
|
end
|
|
@@ -78,7 +87,7 @@ module Wavesync
|
|
|
78
87
|
|
|
79
88
|
#: (Integer? source_bit_depth) -> Integer?
|
|
80
89
|
def target_bit_depth(source_bit_depth)
|
|
81
|
-
return nil if source_bit_depth.nil? || bit_depths.include?(source_bit_depth)
|
|
90
|
+
return nil if source_bit_depth.nil? || bit_depths.empty? || bit_depths.include?(source_bit_depth)
|
|
82
91
|
|
|
83
92
|
bit_depths.min_by { |n| [(n - source_bit_depth).abs, -n] }
|
|
84
93
|
end
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
# rbs_inline: enabled
|
|
3
3
|
|
|
4
4
|
require 'json'
|
|
5
|
+
require_relative 'logger'
|
|
5
6
|
require_relative 'python_venv'
|
|
6
7
|
|
|
7
8
|
module Wavesync
|
|
@@ -29,7 +30,8 @@ module Wavesync
|
|
|
29
30
|
data = JSON.parse(output.strip)
|
|
30
31
|
bpm = data['bpm'].to_f
|
|
31
32
|
bpm.positive? ? { bpm: bpm.round, confidence: data['confidence'].to_f } : nil
|
|
32
|
-
rescue StandardError
|
|
33
|
+
rescue StandardError => e
|
|
34
|
+
Logger.log_error(e, call_site: 'EssentiaBpmDetector.detect', arguments: { file_path: })
|
|
33
35
|
nil
|
|
34
36
|
end
|
|
35
37
|
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
require 'open3'
|
|
5
|
+
require 'json'
|
|
6
|
+
|
|
7
|
+
module Wavesync
|
|
8
|
+
class FFMPEG
|
|
9
|
+
class Probe
|
|
10
|
+
#: (String file_path) -> void
|
|
11
|
+
def initialize(file_path)
|
|
12
|
+
@file_path = file_path #: String
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
#: () -> Float
|
|
16
|
+
def duration
|
|
17
|
+
format_data.fetch('duration', '0').to_f
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
#: () -> Integer?
|
|
21
|
+
def sample_rate
|
|
22
|
+
rate = audio_stream&.fetch('sample_rate', nil)
|
|
23
|
+
rate&.to_i
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
#: () -> Integer?
|
|
27
|
+
def bit_depth
|
|
28
|
+
bits = audio_stream&.fetch('bits_per_sample', nil)&.to_i
|
|
29
|
+
bits&.positive? ? bits : nil
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
#: () -> Integer?
|
|
33
|
+
def bitrate
|
|
34
|
+
bits_per_second = audio_stream&.fetch('bit_rate', nil)&.to_i
|
|
35
|
+
return nil unless bits_per_second&.positive?
|
|
36
|
+
|
|
37
|
+
(bits_per_second / 1000.0).round
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
#: () -> Hash[String, String]
|
|
41
|
+
def tags
|
|
42
|
+
format_data['tags'] || {}
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
#: () -> Hash[String, untyped]
|
|
48
|
+
def probe_data
|
|
49
|
+
@probe_data ||= run_probe
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
#: () -> Hash[String, untyped]
|
|
53
|
+
def run_probe
|
|
54
|
+
ffprobe = FFMPEG.binary.sub('ffmpeg', 'ffprobe')
|
|
55
|
+
stdout, _stderr, _status = Open3.capture3(
|
|
56
|
+
ffprobe, '-v', 'quiet', '-print_format', 'json',
|
|
57
|
+
'-show_streams', '-show_format', @file_path
|
|
58
|
+
)
|
|
59
|
+
JSON.parse(stdout)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
#: () -> Hash[String, untyped]
|
|
63
|
+
def format_data
|
|
64
|
+
probe_data['format'] || {} #: Hash[String, untyped]
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
#: () -> Hash[String, untyped]?
|
|
68
|
+
def audio_stream
|
|
69
|
+
streams = probe_data['streams'] || [] #: Array[Hash[String, untyped]]
|
|
70
|
+
@audio_stream ||= streams.find { |stream| stream['codec_type'] == 'audio' }
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
require 'open3'
|
|
5
|
+
require_relative 'ffmpeg/probe'
|
|
6
|
+
|
|
7
|
+
module Wavesync
|
|
8
|
+
class FFMPEG
|
|
9
|
+
class Error < StandardError; end
|
|
10
|
+
|
|
11
|
+
#: () -> String
|
|
12
|
+
def self.binary
|
|
13
|
+
@binary ||= locate_binary('ffmpeg')
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
#: () -> String
|
|
17
|
+
def self.ffplay_binary
|
|
18
|
+
@ffplay_binary ||= binary.sub('ffmpeg', 'ffplay')
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
#: () -> void
|
|
22
|
+
def initialize
|
|
23
|
+
@inputs = [] #: Array[Hash[Symbol, String?]]
|
|
24
|
+
@options = {} #: Hash[Symbol, untyped]
|
|
25
|
+
@metadata_pairs = [] #: Array[[String, String]]
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
#: (String source, ?format: String?) -> self
|
|
29
|
+
def input(source, format: nil)
|
|
30
|
+
@inputs << { source: source, format: format }
|
|
31
|
+
self
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
#: (String codec) -> self
|
|
35
|
+
def audio_codec(codec)
|
|
36
|
+
@options[:audio_codec] = codec
|
|
37
|
+
self
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
#: (Integer rate) -> self
|
|
41
|
+
def sample_rate(rate)
|
|
42
|
+
@options[:sample_rate] = rate
|
|
43
|
+
self
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
#: (String bitrate) -> self
|
|
47
|
+
def audio_bitrate(bitrate)
|
|
48
|
+
@options[:audio_bitrate] = bitrate
|
|
49
|
+
self
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
#: (String filter) -> self
|
|
53
|
+
def audio_filter(filter)
|
|
54
|
+
@options[:audio_filter] = filter
|
|
55
|
+
self
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
#: (String graph) -> self
|
|
59
|
+
def filter_complex(graph)
|
|
60
|
+
@options[:filter_complex] = graph
|
|
61
|
+
self
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
#: (String format) -> self
|
|
65
|
+
def output_format(format)
|
|
66
|
+
@options[:output_format] = format
|
|
67
|
+
self
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
#: (Numeric seconds) -> self
|
|
71
|
+
def duration(seconds)
|
|
72
|
+
@options[:duration] = seconds
|
|
73
|
+
self
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
#: () -> self
|
|
77
|
+
def copy_streams
|
|
78
|
+
@options[:copy_streams] = true
|
|
79
|
+
self
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
#: (Integer source_index) -> self
|
|
83
|
+
def map_metadata(source_index)
|
|
84
|
+
@options[:map_metadata] = source_index
|
|
85
|
+
self
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
#: (String key, String value) -> self
|
|
89
|
+
def metadata(key, value)
|
|
90
|
+
@metadata_pairs << [key, value]
|
|
91
|
+
self
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
#: (String flags) -> self
|
|
95
|
+
def movflags(flags)
|
|
96
|
+
@options[:movflags] = flags
|
|
97
|
+
self
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
#: (Integer version) -> self
|
|
101
|
+
def write_id3v2(version)
|
|
102
|
+
@options[:write_id3v2] = version
|
|
103
|
+
self
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
#: (String output_path) -> void
|
|
107
|
+
def run(output_path)
|
|
108
|
+
args = ['-y']
|
|
109
|
+
|
|
110
|
+
@inputs.each do |input|
|
|
111
|
+
args += ['-f', input[:format]] if input[:format]
|
|
112
|
+
args += ['-i', input[:source]]
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
args += ['-loglevel', 'warning', '-nostats', '-hide_banner']
|
|
116
|
+
args += ['-c', 'copy'] if @options[:copy_streams]
|
|
117
|
+
args += ['-map_metadata', @options[:map_metadata].to_s] if @options.key?(:map_metadata)
|
|
118
|
+
@metadata_pairs.each { |key, value| args += ['-metadata', "#{key}=#{value}"] }
|
|
119
|
+
args += ['-filter_complex', @options[:filter_complex]] if @options[:filter_complex]
|
|
120
|
+
args += ['-af', @options[:audio_filter]] if @options[:audio_filter]
|
|
121
|
+
args += ['-acodec', @options[:audio_codec]] if @options[:audio_codec]
|
|
122
|
+
args += ['-b:a', @options[:audio_bitrate]] if @options[:audio_bitrate]
|
|
123
|
+
args += ['-ar', @options[:sample_rate].to_s] if @options[:sample_rate]
|
|
124
|
+
args += ['-t', @options[:duration].to_s] if @options[:duration]
|
|
125
|
+
args += ['-movflags', @options[:movflags]] if @options[:movflags]
|
|
126
|
+
args += ['-write_id3v2', @options[:write_id3v2].to_s] if @options[:write_id3v2]
|
|
127
|
+
args += ['-f', @options[:output_format]] if @options[:output_format]
|
|
128
|
+
args << output_path
|
|
129
|
+
|
|
130
|
+
_stdout, stderr, status = Open3.capture3(self.class.binary, *args)
|
|
131
|
+
raise Error, "ffmpeg failed: #{stderr}" unless status.success?
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
private
|
|
135
|
+
|
|
136
|
+
#: (String binary_name) -> String
|
|
137
|
+
def self.locate_binary(binary_name)
|
|
138
|
+
stdout, _stderr, _status = Open3.capture3('which', binary_name)
|
|
139
|
+
path = stdout.strip
|
|
140
|
+
path.empty? ? binary_name : path
|
|
141
|
+
end
|
|
142
|
+
private_class_method :locate_binary
|
|
143
|
+
end
|
|
144
|
+
end
|
|
@@ -5,8 +5,8 @@ module Wavesync
|
|
|
5
5
|
class FileConverter
|
|
6
6
|
DURATION_TOLERANCE_SECONDS = 0.5
|
|
7
7
|
|
|
8
|
-
#: (Audio audio, String source_file_path, PathResolver path_resolver, AudioFormat source_format, AudioFormat target_format, ?padding_seconds: Numeric?) ?{ () -> void } -> bool
|
|
9
|
-
def convert(audio, source_file_path, path_resolver, source_format, target_format, padding_seconds: nil, &
|
|
8
|
+
#: (Audio audio, String source_file_path, PathResolver path_resolver, AudioFormat source_format, AudioFormat target_format, ?padding_seconds: Numeric?, ?before_transcode: (^() -> void)?, ?metadata: Hash[String, String], ?mp3_bitrate: Integer) ?{ (String) -> void } -> bool
|
|
9
|
+
def convert(audio, source_file_path, path_resolver, source_format, target_format, padding_seconds: nil, before_transcode: nil, metadata: {}, mp3_bitrate: 192, &post_transcode)
|
|
10
10
|
needs_format_conversion = target_format.file_type || target_format.sample_rate || target_format.bit_depth
|
|
11
11
|
return false unless needs_format_conversion || padding_seconds&.positive?
|
|
12
12
|
|
|
@@ -34,7 +34,10 @@ module Wavesync
|
|
|
34
34
|
audio.transcode(target_path.to_s, target_sample_rate: target_format.sample_rate,
|
|
35
35
|
target_file_type: target_format.file_type,
|
|
36
36
|
target_bit_depth: target_format.bit_depth || source_format.bit_depth,
|
|
37
|
-
padding_seconds: padding_seconds
|
|
37
|
+
padding_seconds: padding_seconds,
|
|
38
|
+
metadata: metadata,
|
|
39
|
+
target_bitrate: mp3_bitrate,
|
|
40
|
+
&post_transcode)
|
|
38
41
|
|
|
39
42
|
true
|
|
40
43
|
end
|