radiorabe/raar

View on GitHub
app/services/import/recording/composer.rb

Summary

Maintainability
B
4 hrs
Test Coverage
# frozen_string_literal: true

module Import
  module Recording
    # From a list of recordings, split or join them to correspond to a single broadcast.
    # The recordings must overlap or correspond to the broadcast duration but must not be shorter.
    #
    # If the audio duration of the given recordings is longer than declared, the files are trimmed
    # at the end. If the audio duration is shorter, the recordings are used from the declared
    # start position as long as available.
    #
    # In the case that recordings overlap each other, they are trimmed to build an adjacent stream.
    class Composer

      include Loggable

      COMMON_FLAC_FRAME_SIZE = 1024
      FLAC_FRAME_SIZE_RANGE = 256
      MAX_TRANSCODE_RETRIES = 5

      attr_reader :mapping, :recordings

      def initialize(mapping, recordings)
        @mapping = mapping
        @recordings = recordings.sort_by(&:started_at)
        check_arguments
      end

      # Compose the recordings and return the resulting file.
      def compose
        if first_equal?
          file_with_considered_duration(first)
        elsif first_earlier_and_longer?
          trim_start_and_end
        else
          concat_list
        end
      end

      private

      def check_arguments
        unless mapping.complete?(Importer::INCOMPLETE_MAPPING_TOLERANCE)
          raise(ArgumentError, 'broadcast mapping must be complete')
        end
        if (recordings - mapping.recordings).present?
          raise(ArgumentError, 'recordings must be part of the broadcast mapping')
        end
      end

      def first
        recordings.first
      end

      def last
        recordings.last
      end

      def first_equal?
        first.started_at == mapping.started_at &&
          first.audio_finished_at == mapping.finished_at
      end

      def first_earlier_and_longer?
        first.started_at <= mapping.started_at &&
          first.audio_finished_at >= mapping.finished_at
      end

      def first_earlier?
        first.started_at < mapping.started_at
      end

      def last_longer?(current)
        current == last && current.audio_finished_at > mapping.finished_at
      end

      def trim_start_and_end
        start = mapping.started_at - first.started_at
        duration = mapping.duration
        trim_available(first, start, duration)
      end

      def concat_list
        list = []
        @previous_finished_at = mapping.started_at
        recordings.each_with_index do |r, i|
          list << trim_list_recording(r, i)
        end

        concat(list.compact)
      ensure
        list.each { |file| file.close! if file.respond_to?(:close!) }
      end

      def trim_list_recording(current, index)
        if previous_overlapping_current?(current)
          trim_overlapped(current) if previous_not_overlapping_next?(current, index)
        elsif last_longer?(current)
          trim_end
        else
          trim_to_considered_duration(current)
        end
      end

      def previous_overlapping_current?(current)
        @previous_finished_at > current.started_at + DURATION_TOLERANCE
      end

      def previous_not_overlapping_next?(current, index)
        @previous_finished_at < next_started_at(current, index) - DURATION_TOLERANCE
      end

      def next_started_at(current, index)
        current == last ? mapping.finished_at : recordings[index + 1].started_at
      end

      def trim_overlapped(current)
        start = @previous_finished_at - current.started_at
        duration = [current.considered_finished_at, mapping.finished_at].min - @previous_finished_at
        @previous_finished_at += duration.seconds
        trim_available(current, start, duration)
      end

      def trim_to_considered_duration(current)
        @previous_finished_at = current.considered_finished_at
        file_with_considered_duration(current)
      end

      def trim_end
        duration = mapping.finished_at - last.started_at
        trim_available(last, 0, duration)
      end

      def file_with_considered_duration(recording)
        if recording.audio_duration_too_long?
          trim_available(recording, 0, recording.specified_duration)
        else
          recording
        end
      end

      def trim_available(recording, start, duration)
        if start < recording.audio_duration
          duration = [duration, recording.audio_duration - start].min
          trim(recording.path, start, duration)
        end
      end

      def trim(file, start, duration)
        inform("Trimming #{file} from #{start.round}s to #{(start + duration).round}s")
        new_tempfile(::File.extname(file)) do |target_file|
          proc = AudioProcessor.new(file)
          proc.trim(target_file.path, start, duration)
        end
      end

      def concat(list)
        return list.first if list.size <= 1

        with_same_format(list) do |unified|
          new_tempfile(::File.extname(unified[0])) do |target_file|
            proc = AudioProcessor.new(unified[0])
            proc.concat(target_file.path, unified[1..])
          end
        end
      end

      def with_same_format(list)
        unified = convert_all_to_same_format(list)
        yield unified.map(&:path)
      ensure
        close_files(unified) if unified
      end

      def convert_all_to_same_format(list)
        format = AudioProcessor.new(list.first.path).audio_format
        if format.codec == 'flac'
          # always convert flacs so they have the same frame size
          convert_list_to_flac(list, format)
        else
          convert_list_to_format(list, format)
        end
      end

      # When converting a list of flacs, they must all have the same
      # frame size. This transcoding sometimes fails for certain files
      # reproducibly in ffmpeg, based on the given frame size. With an
      # adjusted frame size, transcoding may succeed. Hence retry a few
      # times before raising the exception.
      def convert_list_to_flac(list, format)
        retries ||= 0
        frame_size ||= COMMON_FLAC_FRAME_SIZE
        convert_file_list(list) { |file| convert_to_flac(file, format, frame_size) }
      rescue AudioProcessor::FailingFrameSizeError
        retries += 1
        frame_size = COMMON_FLAC_FRAME_SIZE + rand(FLAC_FRAME_SIZE_RANGE) + 1
        retries <= MAX_TRANSCODE_RETRIES ? retry : raise
      end

      def convert_list_to_format(list, format)
        convert_file_list(list) do |file|
          if ::File.extname(file.path) == ".#{format.file_extension}"
            file
          else
            convert_to_format(file, format)
          end
        end
      end

      def convert_to_format(file, format)
        processor = AudioProcessor.new(file.path)
        new_tempfile(".#{format.file_extension}") do |target_file|
          processor.transcode(target_file.path, format)
        end
      end

      def convert_to_flac(file, format, frame_size)
        processor = AudioProcessor.new(file.path)
        new_tempfile(".#{format.file_extension}") do |target_file|
          processor.transcode_flac(target_file.path, format, frame_size)
        end
      end

      def convert_file_list(list)
        converted = []
        # use `each` instead of `map` to be able to close previously converted files
        # if an error is raised in the middle of the list.
        list.each { |file| converted << yield(file) }
        converted
      rescue StandardError
        close_files(converted)
        raise
      end

      def close_files(list)
        list.each { |file| file.close! if file.respond_to?(:close!) }
      end

      # Create a new tempfile, generate its content and then return the file.
      # If generating content fails, remove the tempfile and raise the original error.
      def new_tempfile(extension)
        file = Tempfile.new(['master', extension])
        yield file # generate content
        file
      rescue StandardError
        file&.close!
        raise
      end

    end
  end
end