Skip to content

Instantly share code, notes, and snippets.

@fizvlad
Last active August 19, 2020 20:17
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save fizvlad/0992ee0dec832ac0de9e6da91ee51aaf to your computer and use it in GitHub Desktop.
Save fizvlad/0992ee0dec832ac0de9e6da91ee51aaf to your computer and use it in GitHub Desktop.
Ruby script for converting MP3 files into DCA compressed format
#!/usr/bin/env ruby
require 'slop'
VERSION = '1.0.1'
OPT = Slop.parse do |o|
o.separator 'Data streams:'
o.string '-i', '--input', 'path to input file. If "pipe:0" is specified, STDIN will be used', required: false
o.string '-o', '--output', 'path to output file. If "pipe:1" is specified STDOUT will be used', required: false
o.separator ''
o.separator 'Options:'
o.float '-v', '--volume', 'volume multiplier', required: false, default: 1.0
o.integer '-ar', '--sample-rate', 'sample rate', required: false, default: 48000
o.integer '-ac', '--channels', 'amount of channels', required: false, default: 2
o.separator ''
o.separator 'Other:'
o.bool '--quiet', 'no log messages'
o.on '--version', 'show app version' do
puts VERSION
exit
end
o.on '--help', 'show help message' do
puts o
exit
end
end
require 'json'
require 'opus-ruby'
# Part of `active_support` gem
class Hash
def deep_merge(other_hash, &block)
dup.deep_merge!(other_hash, &block)
end
def deep_merge!(other_hash, &block)
merge!(other_hash) do |key, this_val, other_val|
if this_val.is_a?(Hash) && other_val.is_a?(Hash)
this_val.deep_merge(other_val, &block)
elsif block_given?
block.call(key, this_val, other_val)
else
other_val
end
end
end
end
# Small module to turn MP3 files to DCA.
module DCA
# Default metadata in DCA file header.
DEFAULT_METADATA = {
# [REQUIRED] General information about this particular DCA file
dca: {
# [REQUIRED] The version of the metadata and audio format.
# Changes in this version will always be backwards-compatible.
version: 1,
# [REQUIRED] Information about the tool used to encode the file
tool: {
# [REQUIRED] Name of the tool, can be any string
name: 'dca-rb',
# [REQUIRED] The version of the tool used
version: '1.0.0',
# URL where to find the tool at
url: '',
# Author of the tool
author: 'fizvlad'
}
},
# [REQUIRED] Information about the parameters the audio packets are encoded with
opus: {
# [REQUIRED] The opus mode, also called application - 'voip', 'music', or 'lowdelay'
mode: 'voip',
# [REQUIRED] The sample rate in Hz.
sample_rate: 48000,
# [REQUIRED] The frame size in bytes.
frame_size: 960,
# [REQUIRED] The resulting audio bitrate in bits per second, or null if the default has not been changed.
abr: nil,
# [REQUIRED] Whether variable bitrate encoding has been used (true/false).
vbr: true,
# [REQUIRED] The resulting number of audio channels.
channels: 2
},
# Information about the audio track.
# This attribute is optional but it is highly recommended to add whenever possible.
info: {
# Title of the track
title: '',
# Artist who made the track
artist: '',
# Album the track is released in
album: '',
# Genre the track is classified under
genre: '',
# Any comments about the track
comments: '',
# The cover image of the album/track. See footnote [1] for information about this
cover: nil
},
# Information about where the audio data came from
origin: {
# The type of source that was converted to DCA. See footnote [2] for information about this
source: 'file',
# Source bitrate in bits per second
abr: nil,
# Number of channels in the source data
channels: 2,
# Source encoding
encoding: nil,
# The URL the source can be found at, or omitted if it wasn't downloaded from the network.
# Do not put a file path in here, it should be reserved for remote URLs only.
url: ''
},
# [REQUIRED] A field to put other arbitrary data into. It can be assumed
# that it always exists, but may be empty. DCA will never use this field internally.
extra: {}
}.freeze
# Size of opus_int_16 type.
OPUS_INT16_SIZE = 2
# Write DCA data into provided IO output.
# @param path [String] path to audio.
# @param out [IO, #write] output IO.
# @param metadata [Hash] hash with metadata.
# @see DEFAULT_METADATA
# @return [IO, #write] +out+ parameter.
def self.encode(input, output, volume = 1.0, **metadata)
t_start = Time.now
log 'Started encoding'
output.write 'DCA1'
metadata = DEFAULT_METADATA.deep_merge metadata
metadata_str = JSON.generate metadata
output.write [metadata_str.size].pack('l<')
output.write metadata_str
sample_rate = metadata[:opus][:sample_rate]
frame_size = metadata[:opus][:frame_size]
#vbr = metadata[:opus][:vbr] ? 'on' : 'off'
channels = metadata[:opus][:channels]
#application = metadata[:opus][:mode]
data_length = frame_size * channels * OPUS_INT16_SIZE # Amount of bytes to load to handle single frame
ffmpeg = "ffmpeg -loglevel 0 -i pipe:0 -f s16le -ar #{sample_rate} -ac #{channels} pipe:1"
encoded_io, writer = IO.pipe
ffmpeg_pid = spawn(ffmpeg, in: input, out: writer)
log "Executing #{ffmpeg}. PID: #{ffmpeg_pid}"
proc_await = Thread.new do
Process.wait ffmpeg_pid
log "FFMPEG finished"
writer.close # Closing data stream to ffmpeg
end
log 'Starting to read s16le data'
opus = Opus::Encoder.new(sample_rate, frame_size, channels)
data_size = 0
counter = 0
log_every = 1000
loop do
counter += 1
log "Reading buf##{counter}" if ((counter - 1) % log_every).zero?
buf = begin
encoded_io.readpartial(data_length)
rescue EOFError
nil
end
if buf.nil? || buf.size == 0
log 'Stopping encoding loop'
break
end
if buf.size != data_length
log "Skipping bad buf, size: #{buf.size}/#{data_length} (counter=#{counter})"
next
end
# TODO: Adjust volume
log "Encoding buf##{counter}" if ((counter - 1) % log_every).zero?
encoded = opus.encode(buf, buf.size / OPUS_INT16_SIZE)
output.write [encoded.size].pack('s<')
log "Writing encoded buf##{counter} (data size: #{data_size})" if ((counter - 1) % log_every).zero?
output.write encoded
data_size += encoded.size
end
output.flush
t_end = Time.now
log "Finished encoding. Amount of buffers: #{counter}. " \
"Runtime: #{t_end - t_start}s. Size of encoded audio " \
"(not counting metadata): #{data_size}"
ensure
encoded_io.close
proc_await.join
opus.destroy
end
def self.log(str)
LOGGER.info str
end
end
require 'logger'
LOGGER = Logger.new(STDERR)
LOGGER.level = Logger::FATAL if OPT.quiet?
LOGGER.info "Version: #{VERSION}"
input = if OPT[:input].nil?
puts 'Can not open input file. Please check arguments'
exit
elsif OPT[:input] == 'pipe:0'
STDIN.binmode
STDIN
else
File.open(OPT[:input], 'rb')
end
output = if OPT[:output].nil?
puts 'Can not open output file. Please check arguments'
exit
elsif OPT[:output] == 'pipe:1'
STDOUT.binmode
STDOUT
else
File.open(OPT[:output], 'wb')
end
begin
DCA.encode(input,
output,
OPT[:volume],
opus: {
sample_rate: OPT[:'sample-rate'],
channels: OPT[:channels]
})
ensure
LOGGER.close
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment