Last active
August 19, 2020 20:17
-
-
Save fizvlad/0992ee0dec832ac0de9e6da91ee51aaf to your computer and use it in GitHub Desktop.
Ruby script for converting MP3 files into DCA compressed format
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/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