wavesync 1.0.0.alpha3 → 1.0.0.beta1

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,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
- 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
-
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
- scanner.sync(device_config[:path], device, pad: options[:pad] || false)
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
@@ -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/set'
64
+ require_relative 'commands/setlist'
65
+ require_relative 'commands/clear_cache'
24
66
  require_relative 'commands/help'
25
67
 
26
- ALL = [Sync, Analyze, Set, Help].freeze
68
+ ALL = [Sync, Pull, Analyze, Setlist, ClearCache, Help].freeze
27
69
  end
28
70
  end
@@ -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
- { name: device['name'], model: device['model'], path: File.expand_path(device['path']) }
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
@@ -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|
@@ -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], bit_depths: Array[Integer], file_types: Array[String], ?bpm_source: Symbol?, ?bar_multiple: Integer?) -> void
15
- def initialize(name:, sample_rates:, bit_depths:, file_types:, bpm_source: nil, bar_multiple: nil)
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, &before_transcode)
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