Convert mp3 files to a streaming-ready format

with Ruby SDK for AWS MediaConvert

In my yesterday's post, I described why I decided to take advantage of the HLS streaming in my new project which is a platform for audiobook streaming.

I explained why a file conversion is required. The simplest way, I discovered, to get this done, is using the AWS Elemental MediaConvert service. It was an obvious choice for me as I am a bit familiar with the AWS stack.

Are you even going to show me the code??

Sure! There will be a lot of code this time as kicking off a convert job requires passing extensive configuration hash. My implementation wraps the official MediaConvert SDK for Ruby. You can get it having aws-sdk-mediaconvert listed in your Gemfile.

class Converting::MediaConverterWrapper
  protected

  def client
    Aws::MediaConvert::Client.new(
      region: Rails.configuration.aws[:region],
      access_key_id: Rails.application.credentials.aws[:access_key_id],
      secret_access_key: Rails.application.credentials.aws[:secret_access_key],
      endpoint: Rails.configuration.aws[:endpoint]
    )
  end
end
class Converting::MediaConverterJobInitializer < Converting::MediaConverterWrapper

  def initialize source_s3_key
    @source_s3_key = source_s3_key
  end

  def call
    source_S3_bucket = Rails.configuration.aws[:source_S3_bucket]
    source_S3 = "s3://#{source_S3_bucket}/#{@source_s3_key}"
    destination_S3_bucket = "s3://#{Rails.configuration.aws[:destination_S3_bucket]}/"
    queue = Rails.configuration.aws[:queue]
    role = Rails.configuration.aws[:role]

    # Kicking off a job
    @response = client.create_job(create_job_request_body(source_S3, queue, role, destination_S3_bucket))
    nil
  end

  def job_id
    @response[:job][:id]
  end

  private

  def create_job_request_body source_S3, queue, role, destination_S3
    {
      :queue => queue,
      :user_metadata => {},
      role: role,
      :settings => {
        :timecode_config => {
          :source => 'ZEROBASED'
        },
        :output_groups => [
          {
            :name => 'Apple HLS',
            :outputs => [
              {
                :container_settings => {
                  :container => 'M3U8',
                  :m3u_8_settings => {
                    :audio_frames_per_pes => 4,
                    :pcr_control => 'PCR_EVERY_PES_PACKET',
                    :pmt_pid => 480,
                    :private_metadata_pid => 503,
                    :program_number => 1,
                    :pat_interval => 0,
                    :pmt_interval => 0,
                    :scte_35_source => 'NONE',
                    :nielsen_id_3 => 'NONE',
                    :timed_metadata => 'NONE',
                    :video_pid => 481,
                    :audio_pids => [482, 483, 484, 485, 486, 487, 488, 489, 490, 491, 492],
                    :audio_duration => 'DEFAULT_CODEC_DURATION' }
                },
                :audio_descriptions => [
                  {
                    :audio_type_control => 'FOLLOW_INPUT',
                    :audio_source_name => 'Audio Selector 1',
                    :codec_settings => {
                      :codec => 'AAC',
                      :aac_settings => {
                        :audio_description_broadcaster_mix => 'NORMAL',
                        :bitrate => 96_000,
                        :rate_control_mode => 'CBR',
                        :codec_profile => 'LC',
                        :coding_mode => 'CODING_MODE_2_0',
                        :raw_format => 'NONE',
                        :sample_rate => 48_000,
                        :specification => 'MPEG4'
                      }
                    },
                    :language_code_control => 'FOLLOW_INPUT'
                  }
                ],
                :output_settings => {
                  :hls_settings => {
                    :audio_group_id => 'program_audio',
                    :audio_only_container => 'AUTOMATIC',
                    :i_frame_only_manifest => 'EXCLUDE'
                  }
                },
                :name_modifier => '_converted'
              }
            ],
            :output_group_settings => {
              :type => 'HLS_GROUP_SETTINGS',
              :hls_group_settings => {
                :manifest_duration_format => 'INTEGER',
                :segment_length => 10,
                :timed_metadata_id_3_period => 10,
                :caption_language_setting => 'OMIT',
                :destination => destination_S3,
                :timed_metadata_id_3_frame => 'PRIV',
                :codec_specification => 'RFC_4281',
                :output_selection => 'MANIFESTS_AND_SEGMENTS',
                :program_date_time_period => 600,
                :min_segment_length => 0,
                :min_final_segment_length => 0,
                :directory_structure => 'SINGLE_DIRECTORY',
                :program_date_time => 'EXCLUDE',
                :segment_control => 'SEGMENTED_FILES',
                :manifest_compression => 'NONE',
                :client_cache => 'ENABLED',
                :audio_only_header => 'INCLUDE',
                :stream_inf_resolution => 'INCLUDE'
              }
            }
          }
        ],
        :ad_avail_offset => 0,
        :inputs => [
          {
            :audio_selectors => {
              'Audio Selector 1' => {
                :offset => 0,
                :default_selection => 'DEFAULT',
                :program_selection => 1
              }
            },
            :filter_enable => 'AUTO',
            :psi_control => 'USE_PSI',
            :filter_strength => 0,
            :deblock_filter => 'DISABLED',
            :denoise_filter => 'DISABLED',
            :input_scan_type => 'AUTO',
            :timecode_source => 'ZEROBASED',
            :file_input => source_S3
          }
        ]
      },
      :acceleration_settings => { :mode => 'DISABLED' },
      :status_update_interval => 'SECONDS_60',
      :priority => 0
    }
  end
end

Take note of several options for this advanced configuration:

  • queue passing this should be optional as there is such a thing as a "default queue"
  • role identifier of AWS IAM role with permissions to access both: source and destination, S3 buckets
  • bitrate specifies output .aac files bitrate. You can specify multiple AudioDescriptions with different bitrates to have an adaptive stream
  • segment_length duration in seconds of .aac segment files
  • destination identifier of S3 bucket for storing converted files
  • name_modifier specifies output file name addon
  • file_input S3 identifier of source file

What's next?

Remember to save obtained job_id, to be able to check the status of initialized convert job later. Of course, I also have a service class for this purpose.

class Converting::MediaConverterJobInspector < Converting::MediaConverterWrapper
  def initialize media_convert_job_id
    @media_convert_job_id = media_convert_job_id
  end

  def call
    @response = client.get_job({ id: @media_convert_job_id })
    nil
  end

  # returns job status
  def status
    @response[:job][:status]
  end
  # ...
end