jam-cloud/ruby/lib/jam_ruby/resque/jam_track_mixdown_packager.rb

422 lines
12 KiB
Ruby

require 'json'
require 'resque'
require 'resque-retry'
require 'net/http'
require 'digest/md5'
module JamRuby
class JamTrackMixdownPackager
extend JamRuby::ResqueStats
include JamRuby::S3ManagerMixin
MAX_PAN = 90
MIN_PAN = -90
attr_accessor :mixdown_package_id, :settings, :mixdown_package, :mixdown, :step
@queue = :jam_track_mixdown_packager
def log
@log || Logging.logger[JamTrackMixdownPackager]
end
def self.perform(mixdown_package_id, bitrate=48)
jam_track_builder = JamTrackMixdownPackager.new()
jam_track_builder.mixdown_package_id = mixdown_package_id
jam_track_builder.run
end
def compute_steps
@step = 0
number_downloads = @track_settings.length
number_volume_adjustments = (@track_settings.select { |track| should_alter_volume? track }).length
pitch_shift_steps = @mixdown.will_pitch_shift? ? 1 : 0
mix_steps = 1
package_steps = 1
number_downloads + number_volume_adjustments + pitch_shift_steps + mix_steps + package_steps
end
def run
begin
log.info("Mixdown job starting. mixdown_packager_id #{mixdown_package_id}")
begin
@mixdown_package = JamTrackMixdownPackage.find(mixdown_package_id)
# bailout check
if @mixdown_package.signed?
log.debug("package is already signed. bailing")
return
end
@mixdown = @mixdown_package.jam_track_mixdown
@settings = JSON.parse(@mixdown.settings)
track_settings
# compute the step count
total_steps = compute_steps
# track that it's started ( and avoid db validations )
signing_started_at = Time.now
last_step_at = Time.now
JamTrackMixdownPackage.where(:id => @mixdown_package.id).update_all(:signing_started_at => signing_started_at, :should_retry => false, packaging_steps: total_steps, current_packaging_step: 0, last_step_at: last_step_at, :signing => true)
# because we are skipping 'after_save', we have to keep the model current for the notification. A bit ugly...
@mixdown_package.current_packaging_step = 0
@mixdown_package.packaging_steps = total_steps
@mixdown_package.signing_started_at = signing_started_at
@mixdown_package.signing = true
@mixdown_package.should_retry = false
@mixdown_package.last_step_at = Time.now
SubscriptionMessage.mixdown_signing_job_change(@mixdown_package)
package
log.info "Signed mixdown package to #{@mixdown_package[:url]}"
rescue Exception => e
# record the error in the database
post_error(e)
# and let the job fail, alerting ops too
raise
end
end
end
def should_alter_volume? track
# short cut is possible if vol = 1.0 and pan = 0
vol = track[:vol]
pan = track[:pan]
vol != 1.0 || pan != 0
end
# creates a list of tracks to actually mix
def track_settings
altered_tracks = @settings["tracks"] || []
@track_settings = []
#void slider2Pan(int i, float *f);
stems = @mixdown.jam_track.stem_tracks
@track_count = stems.length
stems.each do |stem|
vol = 1.0
pan = 0
match = false
# is this stem in the altered_tracks list?
altered_tracks.each do |alteration|
if alteration["id"] == stem.id
if alteration["mute"] || alteration["vol"] == 0
log.debug("leaving out track because muted or 0 volume")
next
else
vol = alteration["vol"] || vol
pan = alteration["pan"] || pan
end
@track_settings << {stem: stem, vol: vol, pan: pan}
match = true
break
end
end
unless match
@track_settings << {stem:stem, vol:vol, pan:pan}
end
end
@track_settings
end
def slider_to_pan(pan)
# transpose MIN_PAN to MAX_PAN to
# 0-1.0 range
#assumes abs(MIN_PAN) == abs(MAX_PAN)
# k = f(i) = (i)/(2*MAX_PAN) + 0.5
# so f(MIN_PAN) = -0.5 + 0.5 = 0
k = ((i * (1.0))/ (2.0 * MAX_PAN )) + 0.5
l, r = 0
if k == 0
l = 0.0
r = 1.0
else
l = Math.sqrt(k)
r = Math.sqrt(1-k)
end
[l, r]
end
def package
puts @settings.inspect
puts @track_count
puts @track_settings
Dir.mktmpdir do |tmp_dir|
# download all files
@track_settings.each do |track|
jam_track_track = track[:stem]
file = File.join(tmp_dir, jam_track_track.id + '.ogg')
bump_step(@mixdown_package)
# download each track needed
s3_manager.download(jam_track_track.url_by_sample_rate(@mixdown_package.sample_rate), file)
track[:file] = file
end
audio_process tmp_dir
end
end
def audio_process(tmp_dir)
# use sox remix to apply mute, volume, pan settings
# step 1: apply pan and volume per track. mute and vol of 0 has already been handled, by virtue of those tracks not being present in @track_settings
# step 2: mix all tracks into single track, dividing by constant number of jam tracks, which is same as done by client backend
# step 3: apply pitch and speed (if applicable)
# step 4: encrypt with jkz (if applicable)
apply_vol_and_pan tmp_dir
mix tmp_dir
pitch_speed tmp_dir
final_packaging tmp_dir
end
# output is :volumed_file in each track in @track_settings
def apply_vol_and_pan(tmp_dir)
@track_settings.each do |track|
jam_track_track = track[:stem]
file = track[:file]
unless should_alter_volume? track
track[:volumed_file] = file
else
pan_l, pan_r = slider_to_pan(track[:pan])
# short
channel_l = pan_l * vol
channel_r = pan_r * vol
bump_step(@mixdown_package)
# sox claps.wav claps-remixed.wav remix 1v1.0 2v1.0
volumed_file = File.join(tmp_dir, jam_track_track.id + '-volumed.ogg')
cmd("sox \"#{file}\" \"#{volumed_file}\" remix 1v#{channel_l} 2v#{channel_r}")
track[:volumed_file] = volumed_file
end
end
end
# output is @mix_file
def mix(tmp_dir)
bump_step(@mixdown_package)
# sox -m will divide by number of inputs by default. But we purposefully leave out tracks that are mute/no volume (to save downloading/processing time in this job)
# so we need to tell sox to divide by how many tracks there are as a constant, because this is how the client works today
#sox -m -v 1/n file1 -v 1/n file2 out
cmd = "sox -m"
mix_divide = 1.0/@track_count
@track_settings.each do |track|
volumed_file = track[:volumed_file]
cmd << " -v #{mix_divide} \"#{volumed_file}\""
end
@mix_file = File.join(tmp_dir, "mix.ogg")
cmd << " \"#{@mix_file}\""
cmd(cmd)
end
# output is @speed_mix_file
def pitch_speed tmp_dir
# # usage
# This app will take an ogg, wav, or mp3 file (for the uploads) as its input and output an ogg file.
# Usage:
# sbsms path-to-input.ogg path-to-output.ogg TimeStrech PitchShift
# input is @mix_file, created by mix()
# output is @speed_mix_file
pitch = @settings['pitch'] || 0
speed = @settings['speed'] || 0
# if pitch and speed are 0, we do nothing here
if pitch == 0 && speed == 0
@speed_mix_file = @mix_file
else
bump_step(@mixdown_package)
@speed_mix_file = File.join(tmp_dir, "speed_mix_file.ogg")
# usage: sbsms infile<.wav|.aif|.mp3|.ogg> outfile<.ogg> rate[0.01:100] halfsteps[-48:48] outSampleRateInHz
sample_rate = 48000
if @mixdown_package.sample_rate != 48
sample_rate = 44100
end
# rate comes in as a percent (like 5, -5 for 5%, -5%). We need to change that to 1.05/
sbsms_speed = speed/100.0
sbsms_speed = 1.0 + sbsms_speed
sbsms_pitch = pitch
cmd "sbsms \"#{@mix_file}\" \"#{@speed_mix_file}\" #{sbsms_speed} #{sbsms_pitch} #{sample_rate}"
end
end
def final_packaging tmp_dir
bump_step(@mixdown_package)
url = nil
private_key = nil
md5 = nil
length = 0
output = nil
if @mixdown_package.encrypt_type
output, private_key = encrypt_jkz tmp_dir
else
# create output file to correct output format
output = convert tmp_dir
end
# upload output to S3
s3_url = "#{@mixdown_package.store_dir}/#{@mixdown_package.filename}"
s3_manager.upload(s3_url, output)
length = File.size(output)
computed_md5 = Digest::MD5.new
File.open(output, 'rb').each {|line| computed_md5.update(line)}
md5 = computed_md5.to_s
@mixdown_package.finish_sign(s3_url, private_key, length, md5.to_s)
end
# returns output destination, converting if necessary
def convert(tmp_dir)
# if the file already ends with the desired file type, call it a win
if @speed_mix_file.end_with?(@mixdown_package.file_type)
@speed_mix_file
else
# otherwise we need to convert from lastly created file to correct
output = File.join(tmp_dir, "output.#{@mixdown_package.file_type}")
raise 'unknown file_type' if @mixdown_package.file_type != JamTrackMixdownPackage::FILE_TYPE_AAC
cmd("ffmpeg -i \"#{@speed_mix_file}\" -c:a libfdk_aac -b:a 128k \"#{output}\"")
output
end
end
def encrypt_jkz(tmp_dir)
py_root = APP_CONFIG.jamtracks_dir
step = 0
private_key = nil
# we need to make the id of the custom mix be the name of the file (ID.ogg)
custom_mix_name = File.join(tmp_dir, "#{@mixdown.id}.ogg")
FileUtils.mv(@speed_mix_file, custom_mix_name)
jam_file_opts = ""
jam_file_opts << " -i #{Shellwords.escape("#{custom_mix_name}+mixdown")}"
sku = @mixdown_package.id
title = @mixdown.name
output = File.join(tmp_dir, "#{title.parameterize}.jkz")
py_file = File.join(py_root, "jkcreate.py")
version = @mixdown_package.version
log.info "Executing python source in #{py_file}, outputting to #{tmp_dir} (#{output})"
cli = "python #{py_file} -D -k #{sku} -p #{Shellwords.escape(tmp_dir)}/pkey.pem -s #{Shellwords.escape(tmp_dir)}/skey.pem #{jam_file_opts} -o #{Shellwords.escape(output)} -t #{Shellwords.escape(title)} -V #{Shellwords.escape(version)}"
Open3.popen3(cli) do |stdin, stdout, stderr, wait_thr|
pid = wait_thr.pid
exit_status = wait_thr.value
err = stderr.read(1000)
out = stdout.read(1000)
#puts "stdout: #{out}, stderr: #{err}"
raise ArgumentError, "Error calling python script: #{err}" if err.present?
raise ArgumentError, "Error calling python script: #{out}" if out && (out.index("No track files specified") || out.index("Cannot find file"))
private_key = File.read("#{tmp_dir}/skey.pem")
end
return output, private_key
end
def cmd(cmd)
log.debug("executing #{cmd}")
output = `#{cmd}`
result_code = $?.to_i
if result_code == 0
output
else
raise "command `#{cmd}` failed."
end
end
# increment the step, which causes a notification to be sent to the client so it can keep the UI fresh as the packaging step goes on
def bump_step(mixdown_package)
step = @step
last_step_at = Time.now
mixdown_package.current_packaging_step = step
mixdown_package.last_step_at = Time.now
JamTrackMixdownPackage.where(:id => mixdown_package.id).update_all(last_step_at: last_step_at, current_packaging_step: step)
SubscriptionMessage.mixdown_signing_job_change(mixdown_package)
@step = step + 1
end
# set @error_reason before you raise an exception, and it will be sent back as the error reason
# otherwise, the error_reason will be unhandled-job-exception
def post_error(e)
#begin
# if error_reason is null, assume this is an unhandled error
unless @error_reason
@error_reason = "unhandled-job-exception"
@error_detail = e.to_s
end
@mixdown_package.finish_errored(@error_reason, @error_detail)
#rescue Exception => e
# log.error "unable to post back to the database the error #{e}"
#end
end
end
end