422 lines
12 KiB
Ruby
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
|