Class: Wavesync::Audio

Inherits:
Object
  • Object
show all
Defined in:
lib/wavesync/audio.rb

Constant Summary collapse

SUPPORTED_FORMATS =
%w[.m4a .mp3 .wav .aif .aiff].freeze
ID3V2_FRAME_TITLE =
'TIT2'
ID3V2_FRAME_ARTIST =
'TPE1'
ID3V2_FRAME_ALBUM =
'TALB'
ID3V2_FRAME_ALBUM_ARTIST =
'TPE2'
ID3V2_FRAME_GENRE =
'TCON'
ID3V2_FRAME_COMPOSER =
'TCOM'
ID3V2_FRAME_ENCODED_BY =
'TENC'
ID3V2_FRAME_COMPILATION =
'TCMP'
FRAME_ID_TO_FFMPEG_KEY =
{
  ID3V2_FRAME_TITLE => 'title',
  ID3V2_FRAME_ARTIST => 'artist',
  ID3V2_FRAME_ALBUM => 'album',
  ID3V2_FRAME_ALBUM_ARTIST => 'album_artist',
  ID3V2_FRAME_GENRE => 'genre',
  ID3V2_FRAME_COMPOSER => 'composer',
  ID3V2_FRAME_ENCODED_BY => 'encoded_by',
  ID3V2_FRAME_COMPILATION => 'compilation'
}.freeze
FFMPEG_KEY_TO_FRAME_ID =
FRAME_ID_TO_FFMPEG_KEY.invert.freeze

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(file_path) ⇒ Audio

: (String file_path) -> void



24
25
26
27
28
# File 'lib/wavesync/audio.rb', line 24

def initialize(file_path)
  @file_path = file_path #: String
  @file_ext = File.extname(@file_path).downcase #: String
  @audio = Wavesync::FFMPEG::Probe.new(file_path) #: Wavesync::FFMPEG::Probe
end

Class Method Details

.find_all(library_path) ⇒ Object

: (String library_path) -> Array



17
18
19
20
21
# File 'lib/wavesync/audio.rb', line 17

def self.find_all(library_path)
  Dir.glob(File.join(library_path, '**', '*'))
     .select { |file| SUPPORTED_FORMATS.include?(File.extname(file).downcase) }
     .sort_by(&:downcase)
end

Instance Method Details

#bit_depthObject

: () -> Integer?



41
42
43
# File 'lib/wavesync/audio.rb', line 41

def bit_depth
  @bit_depth ||= @audio.bit_depth
end

#bitrateObject

: () -> Integer?



46
47
48
# File 'lib/wavesync/audio.rb', line 46

def bitrate
  @bitrate ||= @audio.bitrate
end

#bpmObject

: () -> (String | Integer)?



51
52
53
54
55
# File 'lib/wavesync/audio.rb', line 51

def bpm
  return @bpm if defined?(@bpm)

  @bpm = bpm_from_file
end

#cue_pointsObject

: () -> Array



68
69
70
71
72
# File 'lib/wavesync/audio.rb', line 68

def cue_points
  return [] unless @file_ext == '.wav'

  CueChunk.read(@file_path)
end

#durationObject

: () -> Float



31
32
33
# File 'lib/wavesync/audio.rb', line 31

def duration
  @audio.duration
end

#formatObject

: () -> AudioFormat



58
59
60
61
62
63
64
65
# File 'lib/wavesync/audio.rb', line 58

def format
  AudioFormat.new(
    file_type: @file_ext.delete_prefix('.'),
    sample_rate: sample_rate,
    bit_depth: bit_depth,
    bitrate: bitrate
  )
end

#loopsObject

: () -> Array



75
76
77
# File 'lib/wavesync/audio.rb', line 75

def loops
  CueChunk.loops(cue_points)
end

#sample_rateObject

: () -> Integer?



36
37
38
# File 'lib/wavesync/audio.rb', line 36

def sample_rate
  @sample_rate ||= @audio.sample_rate
end

#transcode(target_path, target_sample_rate: nil, target_file_type: nil, target_bit_depth: nil, padding_seconds: nil, metadata: {}, target_bitrate: 192) ⇒ Object

: (String target_path, ?target_sample_rate: Integer?, ?target_file_type: String?, ?target_bit_depth: Integer?, ?padding_seconds: Numeric?, ?metadata: Hash[String, String], ?target_bitrate: Integer) ?{ (String) -> void } -> bool



150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
# File 'lib/wavesync/audio.rb', line 150

def transcode(target_path, target_sample_rate: nil, target_file_type: nil, target_bit_depth: nil, padding_seconds: nil, metadata: {}, target_bitrate: 192)
  ext = target_file_type || @file_ext.delete_prefix('.')
  temp_path = File.join(Dir.tmpdir, "wavesync_transcode_#{SecureRandom.hex}.#{ext}")

  begin
    command = Wavesync::FFMPEG.new.input(@file_path).audio_codec(transcode_codec(ext, target_bit_depth))
    command.audio_bitrate("#{target_bitrate}k") if ext == 'mp3'
    command.sample_rate(target_sample_rate) if target_sample_rate
    if padding_seconds&.positive?
      total_duration = @audio.duration + padding_seconds
      command.audio_filter("apad=whole_dur=#{total_duration.round(6)}")
    end
    .each { |key, value| command.(key, value) }
    Timing.current.measure(:transcode) { command.run(temp_path) }
    yield temp_path if block_given?
    Timing.current.measure(:copy) { FileUtils.install(temp_path, target_path) }
    true
  rescue SystemCallError => e
    Logger.log_error(e, call_site: 'Audio#transcode', arguments: { target_path:, target_sample_rate:, target_file_type:, target_bit_depth:, padding_seconds:, target_bitrate: })
    false
  ensure
    FileUtils.rm_f(temp_path)
  end
end

#transliterate_tagsObject

: () -> void



125
126
127
128
129
130
131
132
# File 'lib/wavesync/audio.rb', line 125

def transliterate_tags
  return unless @file_ext == '.mp3'

  changes = transliterated_tag_changes
  return if changes.empty?

  (changes)
end

#transliterated_tag_changesObject

: () -> Hash[String, String]



109
110
111
112
113
114
115
116
117
118
119
120
121
122
# File 'lib/wavesync/audio.rb', line 109

def transliterated_tag_changes
  current_tags = @audio.tags
  changes = {} #: Hash[String, String]

  FRAME_ID_TO_FFMPEG_KEY.each_value do |ffmpeg_key|
    current_value = find_in_tags(current_tags, ffmpeg_key)
    next if current_value.nil?

    transliterated = transliterate(current_value)
    changes[ffmpeg_key] = transliterated if transliterated != current_value
  end

  changes
end

#write_bpm(bpm) ⇒ Object

: (String | Integer | Float bpm) -> void



135
136
137
138
139
140
141
142
143
144
145
146
147
# File 'lib/wavesync/audio.rb', line 135

def write_bpm(bpm)
  case @file_ext
  when '.m4a'
    write_bpm_to_m4a(bpm)
  when '.mp3'
    write_bpm_to_mp3(bpm)
  when '.wav'
    write_bpm_to_wav(bpm)
  when '.aif', '.aiff'
    write_bpm_to_aiff(bpm)
  end
  @bpm = bpm
end

#write_cue_points(cue_points) ⇒ Object

: (Array cue_points) -> void



80
81
82
83
84
# File 'lib/wavesync/audio.rb', line 80

def write_cue_points(cue_points)
  temp_path = "#{@file_path}.tmp"
  CueChunk.write(@file_path, temp_path, cue_points)
  FileUtils.mv(temp_path, @file_path)
end