QutBioacoustics/baw-server

View on GitHub
app/modules/api/media_metadata.rb

Summary

Maintainability
C
1 day
Test Coverage
# frozen_string_literal: true

# Provides support for creating metadata for media responses.
module Api
  class MediaMetadata
    # accepts '111', '11.123', not '.11'
    OFFSET_REGEXP = /^-?(\d+\.)?\d+$/

    # Create a new Api::Media instance.
    # @param [BawAudioTools::AudioBase] audio
    # @param [Hash] default_audio
    # @param [Hash] default_spectrogram
    def initialize(audio, default_audio, default_spectrogram)
      @audio = audio
      @default_audio = default_audio
      @default_spectrogram = default_spectrogram
    end

    def audio_recording_details(audio_recording)
      if audio_recording.original_file_name.blank?
        stored_media_type = audio_recording.media_type
        stored_extension = Mime::Type.lookup(audio_recording.media_type).to_sym.to_s
      else
        stored_extension = File.extname(audio_recording.original_file_name)
        stored_media_type = Mime::Type.lookup_by_extension(stored_extension.trim('.', ''))
      end

      {
        id: audio_recording.id,
        uuid: audio_recording.uuid,
        recorded_date: audio_recording.recorded_date,
        site_id: audio_recording.site_id,
        site_name: audio_recording.site.name,
        duration_seconds: audio_recording.duration_seconds.to_f,
        sample_rate_hertz: audio_recording.sample_rate_hertz,
        channel_count: audio_recording.channels,
        bit_rate_bps: audio_recording.bit_rate_bps,
        media_type: stored_media_type,
        extension: stored_extension,
        data_length_bytes: audio_recording.data_length_bytes,
        file_hash: audio_recording.file_hash,
        status: audio_recording.status,
        uploaded_date: audio_recording.created_at
      }
    end

    def current_request_details(audio_recording, media_info, request_params)
      modified_params = {}

      start_offset = get_param_value(request_params, modified_params, :start_offset, 0)
      end_offset = get_param_value(request_params, modified_params, :end_offset, audio_recording.duration_seconds)
      audio_event_id = get_param_value(request_params, modified_params, :audio_event_id, nil)
      channel = get_param_value(request_params, modified_params, :channel, 0)
      sample_rate = get_param_value(request_params, modified_params, :sample_rate, audio_recording.sample_rate_hertz)
      window_size = get_param_value(request_params, modified_params, :window_size, @default_spectrogram.window)
      window_function = get_param_value(request_params, modified_params, :window_function,
                                        @default_spectrogram.window_function)
      colour = normalize_color(get_param_value(request_params, modified_params, :colour, @default_spectrogram.colour))

      current_details = {
        start_offset: start_offset.to_f,
        end_offset: end_offset.to_f,
        audio_event_id: audio_event_id,
        channel: channel,
        sample_rate: sample_rate,
        window_size: window_size,
        window_function: window_function,
        colour: colour,
        media_type: media_info[:media_type],
        extension: media_info[:format],
        ppms: (sample_rate.to_f / window_size) / 1000.0
      }

      [current_details, modified_params]
    end

    def valid_options(audio_recording, available_formats)
      {
        # all formats, even wav, must adhere to this list
        channels: [*0..audio_recording.channels],
        #statuses: AudioRecording::AVAILABLE_STATUSES,
        audio: {
          duration_max: @default_audio.max_duration_seconds,
          duration_min: @default_audio.min_duration_seconds,
          formats: available_formats.audio.map do |format|
                     {
                       name: format,
                       valid_sample_rates: BawAudioTools::AudioBase.valid_sample_rates(format,
                                                                                       audio_recording[:sample_rate_hertz])
                     }
                   end
        },
        image: {
          spectrogram: {
            duration_max: @default_spectrogram.max_duration_seconds,
            duration_min: @default_spectrogram.min_duration_seconds,
            formats: available_formats.image,
            window_sizes: window_options,
            window_functions: window_function_options,
            colours: BawAudioTools::AudioSox.colour_options.merge({ w: :waveform }),
            valid_sample_rates: BawAudioTools::AudioBase.valid_sample_rates(nil, audio_recording[:sample_rate_hertz])
          }
        },
        text: {
          formats: available_formats.text
        }
      }
    end

    def available_request_details(audio_recording, current, modified_params, available_formats)
      audio_keys = [] #[:start_offset, :end_offset, :audio_event_id, :channel, :sample_rate]
      image_keys = #[:start_offset, :end_offset, :audio_event_id, :channel, :sample_rate,
        [:window_size, :window_function, :colour, :ppms]
      text_keys = []

      {
        audio: create_available_details(audio_recording, current, modified_params, available_formats.audio, audio_keys),
        image: create_available_details(audio_recording, current, modified_params, available_formats.image, image_keys),
        text: create_available_details(audio_recording, current, modified_params, available_formats.text, text_keys)
      }
    end

    def generation_request(audio_recording_info, current_request_info)
      {
        uuid: audio_recording_info[:uuid],
        format: current_request_info[:extension],
        media_type: current_request_info[:media_type],
        start_offset: current_request_info[:start_offset],
        end_offset: current_request_info[:end_offset],
        channel: current_request_info[:channel],
        sample_rate: current_request_info[:sample_rate],
        datetime_with_offset: audio_recording_info[:recorded_date],
        original_format: audio_recording_info[:extension],
        original_sample_rate: audio_recording_info[:sample_rate_hertz],
        window: current_request_info[:window_size],
        window_function: current_request_info[:window_function],
        colour: current_request_info[:colour]
      }
    end

    # Create a Hash compatible with the expected API Response.
    # @param [AudioRecording] audio_recording
    # @param [Hash] original
    # @param [Hash] current
    # @param [Hash] modified_params
    def api_response(audio_recording, original, current, modified_params)
      # remove available formats for which the specified sample rate is not valid
      # if no sample rate is specified, remove formats that don't support the native sample rate
      filtered_available_formats = Settings.available_formats.dup
      filter_by_sample_rate = modified_params.key?(:sample_rate) ? modified_params[:sample_rate].to_i : audio_recording[:sample_rate_hertz].to_i
      filtered_available_formats['audio'] = filtered_available_formats['audio'].select { |format|
        BawAudioTools::AudioBase.valid_sample_rates(format, audio_recording[:sample_rate_hertz]).include?(filter_by_sample_rate)
      }

      available = available_request_details(audio_recording, current, modified_params, filtered_available_formats)
      details = { recording: original, common_parameters: current, available: available }

      if modified_params.empty?
        # give options for all available formats, not just those that support the native sample rate
        details[:options] = valid_options(audio_recording, Settings.available_formats)
      end

      # ensure media_type for original is a string
      details[:recording][:media_type] = details[:recording][:media_type].to_s

      # keep only required entries
      details[:recording].slice!(
        :id, :uuid, :recorded_date, :duration_seconds, :sample_rate_hertz,
        :channel_count, :media_type
      )

      details[:common_parameters].slice!(
        :start_offset, :end_offset, :audio_event_id, :channel, :sample_rate
      )

      details
    end

    # Check offset parameters against min and max duration defaults.
    # @param [AduioRecording] audio_recording
    # @param [Hash] current
    # @param [Hash] request_defaults
    def check_duration_defaults(audio_recording, current, request_defaults)
      start_offset = current[:start_offset].to_f
      end_offset = current[:end_offset].to_f
      requested_duration = end_offset - start_offset
      original_duration = audio_recording.duration_seconds
      max_duration = request_defaults.max_duration_seconds.to_f
      min_duration = request_defaults.min_duration_seconds.to_f

      # now check bounds
      if requested_duration > max_duration
        msg = "Requested duration #{requested_duration} (#{start_offset} to #{end_offset}) is greater than maximum (#{max_duration})."
        raise CustomErrors::RequestedMediaDurationInvalid, msg
      end

      if requested_duration < min_duration
        msg = "Requested duration #{requested_duration} (#{start_offset} to #{end_offset}) is less than minimum (#{min_duration})."
        raise CustomErrors::RequestedMediaDurationInvalid, msg
      end
    end

    # Check common request parameters.
    # @param [AudioRecording] audio_recording
    # @param [ActionDispatch::Request] request_params
    def check_request_parameters(audio_recording, request_params)
      original_duration = audio_recording.duration_seconds

      # offsets
      start_offset = nil
      end_offset = nil
      if request_params.include?(:start_offset)
        start_offset_s = request_params[:start_offset].to_s
        start_offset = start_offset_s.to_f

        unless OFFSET_REGEXP === start_offset_s
          msg = "start_offset parameter must be a decimal number indicating seconds (maximum precision milliseconds, e.g., 1.234) (#{start_offset_s})"
          raise CustomErrors::UnprocessableEntityError, msg
        end

        if start_offset >= original_duration
          msg = "start_offset parameter (#{start_offset}) must be smaller than the duration of the audio recording (#{original_duration})."
          raise CustomErrors::UnprocessableEntityError, msg
        end

        if start_offset.negative?
          msg = "start_offset parameter (#{start_offset}) must be greater than or equal to 0."
          raise CustomErrors::UnprocessableEntityError, msg
        end
      end

      if request_params.include?(:end_offset)
        end_offset_s = request_params[:end_offset].to_s
        end_offset = end_offset_s.to_f

        unless OFFSET_REGEXP === end_offset_s
          msg = "end_offset parameter must be a decimal number indicating seconds (maximum precision milliseconds, e.g., 1.234) (#{end_offset_s})"
          raise CustomErrors::UnprocessableEntityError, msg
        end

        if end_offset > original_duration
          msg = "end_offset parameter (#{end_offset}) must be smaller than or equal to the duration of the audio recording (#{original_duration})."
          raise CustomErrors::UnprocessableEntityError, msg
        end

        if end_offset <= 0
          msg = "end_offset parameter (#{end_offset}) must be greater than 0."
          raise CustomErrors::UnprocessableEntityError, msg
        end
      end

      if request_params.include?(:start_offset) && request_params.include?(:end_offset) && (start_offset >= end_offset)
        msg = "start_offset parameter (#{start_offset}) must be smaller than end_offset (#{end_offset})."
        raise CustomErrors::UnprocessableEntityError, msg
      end

      # don't need to check overall duration - one of the start/end offset checks will pick it up?
      # if requested_duration > original_duration
      #   msg = "Requested duration #{requested_duration} (#{start_offset} to #{end_offset}) is greater than audio recording duration (#{original_duration})."
      #   fail BawAudioTools::Exceptions::SegmentRequestTooLong, msg
      # end

      # check window size
      if request_params.include?(:window_size) && (!window_options.include?(request_params[:window_size].to_i) ||
              (request_params[:window_size].to_i.to_s != request_params[:window_size].to_s))
        msg = "window_size parameter (#{request_params[:window_size]}) must be valid (#{window_options})."
        raise CustomErrors::UnprocessableEntityError, msg
      end

      # check window function
      if request_params.include?(:window_function) && !window_function_options.include?(request_params[:window_function])
        msg = "window_function parameter (#{request_params[:window_function]}) must be valid (#{window_function_options})."
        raise CustomErrors::UnprocessableEntityError, msg
      end

      # check sample rate
      if request_params.include?(:sample_rate)
        unless valid_sample_rates(request_params[:format],
                                  audio_recording.sample_rate_hertz.to_i).include?(request_params[:sample_rate].to_i)
          msg = "sample_rate parameter (#{request_params[:sample_rate]}) must be valid (#{valid_sample_rates})."
          raise CustomErrors::UnprocessableEntityError, msg
        end
        if request_params[:sample_rate].to_i.to_s != request_params[:sample_rate].to_s
          msg = "sample_rate parameter (#{request_params[:sample_rate]}) must be an integer)."
          raise CustomErrors::UnprocessableEntityError, msg
        end
      end

      # check channel
      valid_channels = [*0..audio_recording.channels]
      if request_params.include?(:channel) && (!valid_channels.include?(request_params[:channel].to_i) ||
          (request_params[:channel].to_i.to_s != request_params[:channel].to_s))
        msg = "channel parameter (#{request_params[:channel]}) must be valid (#{valid_channels})."
        raise CustomErrors::UnprocessableEntityError, msg
      end

      # check colour
      if request_params.include?(:colour) && !colour_options.include?(request_params[:colour].to_sym)
        msg = "colour parameter (#{request_params[:colour]}) must be valid (#{colour_options})."
        raise CustomErrors::UnprocessableEntityError, msg
      end
    end

    private

    # Create a Hash representing the available formats for the current request.
    # @param [AudioRecording] audio_recording
    # @param [Hash] current
    # @param [Hash] modified_params
    # @param [Array<String>] formats
    # @param [Hash] relevant_keys
    def create_available_details(audio_recording, current, modified_params, formats, relevant_keys)
      result = {}
      formats.each do |format|
        # include all settings in object properties
        result[format] = current.slice(*relevant_keys)
        result[format][:media_type] = Mime::Type.lookup_by_extension(format.to_s.downcase).to_s
        result[format][:extension] = format
        # only include modified settings in url
        modified_keys = modified_params.merge(format: format)
        result[format][:url] = Api::UrlHelpers.audio_recording_media_path(audio_recording, modified_keys)
      end
      result
    end

    def valid_sample_rates(format = nil, original_sample_rate = nil)
      BawAudioTools::AudioBase.valid_sample_rates format, original_sample_rate
    end

    def window_function_options
      BawAudioTools::AudioSox.window_function_options
    end

    def window_options
      BawAudioTools::AudioSox.window_options
    end

    def colour_options
      @colour_options ||= BawAudioTools::AudioSox.colour_options.to_a.flatten + [:w, :waveform]
    end

    def normalize_color(color)
      color = color&.to_sym
      return :w if [:w, :waveform].include?(color)
      return color if BawAudioTools::AudioSox.colour_options.key?(color)

      return BawAudioTools::AudioSox.colour_options.key(color) if BawAudioTools::AudioSox.colour_options.value?(color)

      raise "unhandled color: #{color}"
    end

    # Get param value if available, otherwise a default value.
    # @param [ActiveSupport::HashWithIndifferentAccess] request_params
    # @param [Hash] modified_params
    # @param [String] param_name
    # @param [Object] default_value
    def get_param_value(request_params, modified_params, param_name, default_value)
      if request_params.include?(param_name)
        param_value = request_params[param_name]
        modified_params[param_name] = param_value
      else
        param_value = default_value
      end
      param_value
    end
  end
end