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
COMBINING_MARKS =
/\p{Mn}/

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(file_path) ⇒ Audio

: (String file_path) -> void



21
22
23
24
25
# File 'lib/wavesync/audio.rb', line 21

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



14
15
16
17
18
# File 'lib/wavesync/audio.rb', line 14

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?



38
39
40
# File 'lib/wavesync/audio.rb', line 38

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

#bitrateObject

: () -> Integer?



43
44
45
# File 'lib/wavesync/audio.rb', line 43

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

#bpmObject

: () -> (String | Integer)?



48
49
50
51
52
# File 'lib/wavesync/audio.rb', line 48

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

  @bpm = bpm_from_file
end

#cue_pointsObject

: () -> Array[Integer, sample_offset: Integer, label: String?]



65
66
67
68
69
# File 'lib/wavesync/audio.rb', line 65

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

  CueChunk.read(@file_path)
end

#durationObject

: () -> Float



28
29
30
# File 'lib/wavesync/audio.rb', line 28

def duration
  @audio.duration
end

#formatObject

: () -> AudioFormat



55
56
57
58
59
60
61
62
# File 'lib/wavesync/audio.rb', line 55

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

#sample_rateObject

: () -> Integer?



33
34
35
# File 'lib/wavesync/audio.rb', line 33

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



144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
# File 'lib/wavesync/audio.rb', line 144

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) }
    command.run(temp_path)
    yield temp_path if block_given?
    FileUtils.install(temp_path, target_path)
    true
  rescue Errno::ENOENT => 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



119
120
121
122
123
124
125
126
# File 'lib/wavesync/audio.rb', line 119

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

  changes = transliterated_tag_changes
  return if changes.empty?

  (changes)
end

#transliterated_tag_changesObject

: () -> Hash[String, String]



103
104
105
106
107
108
109
110
111
112
113
114
115
116
# File 'lib/wavesync/audio.rb', line 103

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



129
130
131
132
133
134
135
136
137
138
139
140
141
# File 'lib/wavesync/audio.rb', line 129

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[Integer, sample_offset: Integer, label: String?] cue_points) -> void



72
73
74
75
76
# File 'lib/wavesync/audio.rb', line 72

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