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

770 lines
22 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
TAP_IN_PADDING = 2
MAX_PAN = 90
MIN_PAN = -90
KNOCK_SECONDS = 0.035
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)
process_jmep
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 = last_step_at
@mixdown_package.queued = false
@mixdown_package.save
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)
#SubscriptionMessage.mixdown_signing_job_change(@mixdown_package)
# 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
def process_jmep
@start_points = []
@initial_padding = 0.0
speed = @settings['speed'] || 0
@speed_factor = 1.0 + (-speed.to_f / 100.0)
@inverse_speed_factor = 1 - (-speed.to_f / 100)
log.info("speed factor #{@speed_factor}")
jmep = @mixdown.jam_track.jmep_json
if jmep
jmep = JSON.parse(jmep)
end
if jmep.nil?
log.debug("no jmep")
return
end
events = jmep["Events"]
return if events.nil? || events.length == 0
metronome = nil
events.each do |event|
if event.has_key?("metronome")
metronome = event["metronome"]
break
end
end
if metronome.nil? || metronome.length == 0
log.debug("no metronome events for jmep", jmep)
return
end
@start_points = metronome.select { |x| puts x.inspect; x["action"] == "start" }
log.debug("found #{@start_points.length} metronome start points")
start_point = @start_points[0]
if start_point
start_time = parse_time(start_point["ts"])
if start_time < 2.0
padding = start_time - 2.0
@initial_padding = padding.abs
@initial_tap_in = start_time
end
end
if @speed_factor != 1.0
metronome.length.times do |count|
# we expect to find metronome start/stop grouped
if count % 2 == 0
start = metronome[count]
stop = metronome[count + 1]
if start["action"] != "start" || stop["action"] != "stop"
# bail out
log.error("found de-coupled metronome events #{start.to_json} | #{stop.to_json}")
next
end
bpm = start["bpm"].to_f
stop_time = parse_time(stop['ts'])
ticks = stop['ticks'].to_i
new_bpm = bpm * @inverse_speed_factor
new_stop_time = stop_time * @speed_factor
new_start_time = new_stop_time - (60.0/new_bpm * ticks)
log.info("original bpm:#{bpm} start: #{parse_time(start["ts"])} stop: #{stop_time}")
log.info("updated bpm:#{new_bpm} start: #{new_start_time} stop: #{new_stop_time}")
stop["ts"] = new_stop_time
start["ts"] = new_start_time
start["bpm"] = new_bpm
stop["bpm"] = new_bpm
@tap_in_initial_silence = (@initial_tap_in + @initial_padding) * @speed_factor
end
end
end
@start_points = metronome.select { |x| puts x.inspect; x["action"] == "start" }
end
# format like: "-0:00:02:820"
def parse_time(ts)
if ts.is_a?(Float)
return ts
end
time = 0.0
negative = false
if ts.start_with?('-')
negative = true
end
# parse time_format
bits = ts.split(':').reverse
bit_position = 0
bits.each do |bit|
if bit_position == 0
# milliseconds
milliseconds = bit.to_f
time += milliseconds/1000
elsif bit_position == 1
# seconds
time += bit.to_f
elsif bit_position == 2
# minutes
time += 60 * bit.to_f
elsif bit_position == 3
# hours
# not bothering
end
bit_position += 1
end
if negative
time = 0.0 - time
end
time
end
def path_to_resources
File.join(File.dirname(File.expand_path(__FILE__)), '../../../lib/jam_ruby/app/assets/sounds')
end
def knock_file
if long_sample_rate == 44100
knock = File.join(path_to_resources, 'knock44.wav')
else
knock = File.join(path_to_resources, 'knock48.wav')
end
log.debug("knock file path: " + knock)
knock
end
def create_silence(tmp_dir, segment_count, duration)
file = File.join(tmp_dir, "#{segment_count}.wav")
# -c 2 means stereo
cmd("sox -n -r #{long_sample_rate} -c 2 #{file} trim 0.0 #{duration}", "silence")
file
end
def create_tapin_track(tmp_dir)
return nil if @start_points.length == 0
segment_count = 0
#initial_silence = @initial_tap_in + @initial_padding
initial_silence = @tap_in_initial_silence
#log.info("tapin data: initial_tap_in: #{@initial_tap_in}, initial_padding: #{@initial_padding}, initial_silence: #{initial_silence}")
time_points = []
files = []
if initial_silence > 0
files << create_silence(tmp_dir, segment_count, initial_silence)
time_points << {type: :silence, ts: initial_silence}
segment_count += 1
end
time_cursor = nil
@start_points.each do |start_point|
tap_time = parse_time(start_point["ts"])
if !time_cursor.nil?
between_silence = tap_time - time_cursor
files << create_silence(tmp_dir, segment_count, between_silence)
time_points << {type: :silence, ts: between_silence}
end
time_cursor = tap_time
bpm = start_point["bpm"].to_f
tick_silence = 60.0/bpm - KNOCK_SECONDS
ticks = start_point["ticks"].to_i
ticks.times do |tick|
files << knock_file
files << create_silence(tmp_dir, segment_count, tick_silence)
time_points << {type: :knock, ts: KNOCK_SECONDS}
time_points << {type: :silence, ts: tick_silence}
time_cursor + 60.0/bpm
segment_count += 1
end
end
log.info("time points for tap-in: #{time_points.inspect}")
# do we need to pad with time? not sure
sequence_cmd = "sox "
files.each do |file|
sequence_cmd << "\"#{file}\" "
end
count_in = File.join(tmp_dir, "count-in.wav")
sequence_cmd << "\"#{count_in}\""
cmd(sequence_cmd, "count_in")
@count_in_file = count_in
count_in
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
@include_count_in = @settings["count-in"] && @start_points.length > 0 && @mixdown_package.encrypt_type.nil?
# temp
# @include_count_in = true
if @include_count_in
@track_count += 1
end
stems.each do |stem|
vol = 1.0
pan = 0
match = false
skipped = 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 #{alteration.inspect}")
skipped = true
next
else
vol = alteration["vol"] || vol
pan = alteration["pan"] || pan
end
@track_settings << {stem: stem, vol: vol, pan: pan}
match = true
break
end
end
# if we didn't deliberately skip this one, and if there was no 'match' (meaning user did not specify), then we leave this in unchanged
if !skipped && !match
@track_settings << {stem: stem, vol: vol, pan: pan}
end
end
if @include_count_in
@track_settings << {count_in: true, vol: 1.0, pan: 0}
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 = ((pan * (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
log.info("Settings: #{@settings.to_json}")
Dir.mktmpdir do |tmp_dir|
# download all files
@track_settings.each do |track|
if track[:count_in]
file = create_tapin_track(tmp_dir)
bump_step(@mixdown_package)
else
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)
end
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
create_silence_padding 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]
count_in = track[:count_in]
file = track[:file]
unless should_alter_volume? track
track[:volumed_file] = file
else
pan_l, pan_r = slider_to_pan(track[:pan])
vol = track[:vol]
# 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
if count_in
volumed_file = File.join(tmp_dir, 'count-in' + '-volumed.ogg')
else
volumed_file = File.join(tmp_dir, jam_track_track.id + '-volumed.ogg')
end
cmd("sox \"#{file}\" \"#{volumed_file}\" remix 1v#{channel_r} 2v#{channel_l}", 'vol_pan')
track[:volumed_file] = volumed_file
end
end
end
def create_silence_padding(tmp_dir)
if @initial_padding > 0 && @include_count_in
@padding_file = File.join(tmp_dir, "initial_padding.ogg")
# -c 2 means stereo
cmd("sox -n -r #{long_sample_rate} -c 2 #{@padding_file} trim 0.0 #{@initial_padding}", "initial_padding")
@track_settings.each do |track|
next if track[:count_in]
input = track[:volumed_file]
output = input[0..-5] + '-padded.ogg'
padd_cmd = "sox '#{@padding_file}' '#{input}' '#{output}'"
cmd(padd_cmd, "pad_track_with_silence")
track[:volumed_file] = output
end
end
end
# output is @mix_file
def mix(tmp_dir)
bump_step(@mixdown_package)
@mix_file = File.join(tmp_dir, "mix.ogg")
pitch = @settings['pitch'] || 0
speed = @settings['speed'] || 0
# if there is only one track to mix, we need to skip mixing (sox will barf if you try to mix one file), but still divide by number of tracks
if @track_settings.count == 1
mix_divide = 1.0/@track_count
cmd = "sox -v #{mix_divide} \"#{@track_settings[0][:volumed_file]}\" \"#{@mix_file}\""
cmd(cmd, 'volume_adjust')
else
# 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|
# if pitch/shifted, we lay the tap-in after pitch/speed shift
# next if (pitch != 0 || speed != 0) && track[:count_in]
next if track[:count_in]
volumed_file = track[:volumed_file]
cmd << " -v #{mix_divide} \"#{volumed_file}\""
end
cmd << " \"#{@mix_file}\""
cmd(cmd, 'mix_adjust')
end
end
def long_sample_rate
sample_rate = 48000
if @mixdown_package.sample_rate != 48
sample_rate = 44100
end
sample_rate
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 = long_sample_rate
# 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}", 'speed_pitch_shift')
end
if @include_count_in
# lay the tap-ins over the recording
layered = File.join(tmp_dir, "layered_speed_mix.ogg")
cmd("sox -m '#{@count_in_file}' '#{@speed_mix_file}' '#{layered}'", "layer_tap_in")
@speed_mix_file = layered
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}")
cmd("#{APP_CONFIG.normalize_ogg_path} --bitrate 192 \"#{@speed_mix_file}\"", 'normalize')
if @mixdown_package.file_type == JamTrackMixdownPackage::FILE_TYPE_AAC
cmd("ffmpeg -i \"#{@speed_mix_file}\" -c:a libfdk_aac -b:a 128k \"#{output}\"", 'convert_aac')
elsif @mixdown_package.file_type == JamTrackMixdownPackage::FILE_TYPE_MP3
cmd("ffmpeg -i \"#{@speed_mix_file}\" -ab 192k \"#{output}\"", 'convert_mp3')
else
raise 'unknown file_type'
end
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
right = @mixdown.jam_track.right_for_user(@mixdown.user)
if @mixdown_package.sample_rate == 48
private_key = right.private_key_48
else
private_key = right.private_key_44
end
unless private_key
@error_reason = 'no_private_key'
@error_detail = 'user needs to generate JamTrack for given sample rate'
raise @error_reason
end
private_key_file = File.join(tmp_dir, 'skey.pem')
File.open(private_key_file, 'w') { |f| f.write(private_key) }
log.debug("PRIVATE KEY")
log.debug(private_key)
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(private_key_file)
end
return output, private_key
end
def cmd(cmd, type)
log.debug("executing #{cmd}")
output = `#{cmd}`
result_code = $?.to_i
if result_code == 0
output
else
@error_reason = type + "_fail"
@error_detail = "#{cmd}, #{output}"
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 = last_step_at
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