module JamRuby class Mix < ActiveRecord::Base include S3ManagerMixin MAX_MIX_TIME = 7200 # 2 hours @@log = Logging.logger[Mix] before_destroy :delete_s3_files self.primary_key = 'id' attr_accessible :ogg_url, :should_retry, as: :admin attr_accessor :is_skip_mount_uploader attr_writer :current_user belongs_to :recording, :class_name => "JamRuby::Recording", :inverse_of => :mixes, :foreign_key => 'recording_id' validates :download_count, presence: true validate :verify_download_count skip_callback :save, :before, :store_picture!, if: :is_skip_mount_uploader mount_uploader :ogg_url, MixUploader def verify_download_count if (self.download_count < 0 || self.download_count > APP_CONFIG.max_audio_downloads) && !@current_user.admin errors.add(:download_count, "must be less than or equal to 100") end end before_validation do # this should be an activeadmin only path, because it's using the mount_uploader (whereas the client does something completely different) if !is_skip_mount_uploader && ogg_url.present? && ogg_url.respond_to?(:file) && ogg_url_changed? self.ogg_length = ogg_url.file.size self.ogg_md5 = ogg_url.md5 self.completed = true self.started_at = Time.now self.completed_at = Time.now # do not set marking_complete = true; use of marking_complete is a client-centric design, # and setting to true causes client-centric validations end end def self.schedule(recording) raise if recording.nil? mix = Mix.new mix.is_skip_mount_uploader = true mix.recording = recording mix.save mix[:ogg_url] = construct_filename(recording.created_at, recording.id, mix.id, type='ogg') mix[:mp3_url] = construct_filename(recording.created_at, recording.id, mix.id, type='mp3') if mix.save mix.enqueue end mix.is_skip_mount_uploader = false mix end def enqueue begin Resque.enqueue(AudioMixer, self.id, self.sign_put(3600 * 24, 'ogg'), self.sign_put(3600 * 24, 'mp3')) rescue # implies redis is down. we don't update started_at false end # avoid db validations Mix.where(:id => self.id).update_all(:started_at => Time.now, :should_retry => false) true end def can_download?(some_user) return false if some_user.nil? claimed_recording = ClaimedRecording.find_by_user_id_and_recording_id(some_user.id, recording.id) if claimed_recording !claimed_recording.discarded else false end end def mix_timeout? Time.now - started_at > 60 * 30 # 30 minutes to mix is more than enough end def state return 'mixed' if completed return 'stream-mix' if recording.has_stream_mix return 'waiting-to-mix' if started_at.nil? return 'error' if error_count > 0 || mix_timeout? return 'mixing' end def error return nil if state != 'error' return {error_count: error_count, error_reason: error_reason, error_detail: error_detail} if error_count > 0 return {error_count: 1, error_reason: 'mix-timeout', error_detail: started_at} if mix_timeout? return {error_count: 1, error_reason: 'unknown', error_detail: 'unknown'} end def too_many_downloads? (self.download_count < 0 || self.download_count > APP_CONFIG.max_audio_downloads) && !@current_user.admin end def errored(reason, detail) self.started_at = nil self.error_reason = reason self.error_detail = detail self.error_count = self.error_count + 1 if self.error_count <= 3 self.should_retry = true end save end def finish(ogg_length, ogg_md5, mp3_length, mp3_md5) self.completed_at = Time.now self.ogg_length = ogg_length self.ogg_md5 = ogg_md5 self.mp3_length = mp3_length self.mp3_md5 = mp3_md5 self.completed = true if save Notification.send_recording_master_mix_complete(recording) end Recording.where(:id => self.recording.id).update_all(:has_final_mix => true) end # valid for 1 day; because the s3 urls eventually expire def manifest one_day = 60 * 60 * 24 jam_track_offset = 0 jam_track_seek = 0 was_jamtrack_played = false if recording.timeline recording_timeline_data = recording.timeline # did the jam track play at all? jam_track_isplaying = recording_timeline_data["jam_track_isplaying"] recording_start_time = recording_timeline_data["recording_start_time"] jam_track_play_start_time = recording_timeline_data["jam_track_play_start_time"] jam_track_recording_start_play_offset = recording_timeline_data["jam_track_recording_start_play_offset"] if jam_track_play_start_time != 0 was_jamtrack_played = true # how long did the JamTrack play? not needed because we limit on the input tracks, which represents how long the recording is, too jam_track_play_time = recording_timeline_data["jam_track_play_time"] offset = jam_track_play_start_time - recording_start_time @@log.debug("base offset = #{offset}") if offset >= 0 # jamtrack started after recording, so buffer with silence as necessary\ if jam_track_recording_start_play_offset < 0 @@log.info("prelude captured. offsetting further by #{-jam_track_recording_start_play_offset}") # a negative jam_track_recording_start_play_offset indicates prelude, i.e., silence # so add it to the offset to add more silence as necessary offset = offset + -jam_track_recording_start_play_offset jam_track_offset = offset else @@log.info("positive jamtrack offset; seeking into jamtrack by #{jam_track_recording_start_play_offset}") # a positive jam_track_recording_start_play_offset means we need to cut into the jamtrack jam_track_seek = jam_track_recording_start_play_offset jam_track_offset = offset end else # jamtrack started before recording, so we can seek into it to make up for the missing parts if jam_track_recording_start_play_offset < 0 @@log.info("partial prelude captured. offset becomes jamtrack offset#{-jam_track_recording_start_play_offset}") # a negative jam_track_recording_start_play_offset indicates prelude, i.e., silence # so add it to the offset to add more silence as necessary jam_track_offset = -jam_track_recording_start_play_offset else @@log.info("no prelude captured. offset becomes jamtrack offset=#{jam_track_recording_start_play_offset}") jam_track_offset = 0 jam_track_seek = jam_track_recording_start_play_offset end # also, ignore jam_track_recording_start_play_offset - it simply matches the offset in this case end @@log.info("computed values. jam_track_offset=#{jam_track_offset} jam_track_seek=#{jam_track_seek}") end end manifest = { "files" => [], "timeline" => [] } mix_params = [] # this 'pick limiter' logic will ensure that we set a limiter on the 1st recorded_track we come across. pick_limiter = false if was_jamtrack_played # we only use the limiter feature if this is a JamTrack recording # by setting this to true, the 1st recorded_track in the database will be the limiter pick_limiter = true end recording.recorded_tracks.each do |recorded_track| manifest["files"] << { "filename" => recorded_track.sign_url(one_day), "codec" => "vorbis", "offset" => 0, limiter:pick_limiter } pick_limiter = false mix_params << { "level" => 1.0, "balance" => 0 } end recording.recorded_backing_tracks.each do |recorded_backing_track| manifest["files"] << { "filename" => recorded_backing_track.sign_url(one_day), "codec" => "vorbis", "offset" => 0 } mix_params << { "level" => 1.0, "balance" => 0 } end if was_jamtrack_played recording.recorded_jam_track_tracks.each do |recorded_jam_track_track| manifest["files"] << { "filename" => recorded_jam_track_track.jam_track_track.sign_url(one_day, sample_rate=44), "codec" => "vorbis", "offset" => jam_track_offset, "seek" => jam_track_seek } # let's look for level info from the client level = 1.0 # default value - means no effect if recorded_jam_track_track.timeline timeline_data = recorded_jam_track_track.timeline # always take the 1st entry for now first = timeline_data[0] if first["mute"] # mute equates to no noise level = 0.0 else # otherwise grab the left channel... level = first["vol_l"] end end mix_params << { "level" => level, "balance" => 0 } end end manifest["timeline"] << { "timestamp" => 0, "mix" => mix_params } manifest["output"] = { "codec" => "vorbis" } manifest["recording_id"] = self.recording.id manifest end def local_manifest remote_manifest = self.manifest remote_manifest["files"].each do |file| filename = file["filename"] basename = File.basename(filename) basename = basename[0..(basename.index('?') - 1)] file["filename"] = basename end # update manifest so that audiomixer writes here remote_manifest["output"]["filename"] = 'out.ogg' # update manifest so that audiomixer writes here remote_manifest["error_out"] = 'error.out' remote_manifest["mix_id"] = self.id remote_manifest end def download_script out = '' remote_manifest = manifest remote_manifest["files"].each do |file| filename = file["filename"] basename = File.basename(filename) basename = basename[0..(basename.index('?') - 1)] out << "curl -o \"#{basename}\" \"#{filename}\"\r\n\r\n" end out << "\r\n\r\n" out end def s3_url(type='ogg') if type == 'ogg' s3_manager.s3_url(self[:ogg_url]) else s3_manager.s3_url(self[:mp3_url]) end end def is_completed completed end # if the url starts with http, just return it because it's in some other store. Otherwise it's a relative path in s3 and needs be signed def resolve_url(url_field, mime_type, expiration_time) self[url_field].start_with?('http') ? self[url_field] : s3_manager.sign_url(self[url_field], {:expires => expiration_time, :response_content_type => mime_type, :secure => true}) end def sign_url(expiration_time = 120, type='ogg') type ||= 'ogg' # expire link in 1 minute--the expectation is that a client is immediately following this link if type == 'ogg' resolve_url(:ogg_url, 'audio/ogg', expiration_time) else resolve_url(:mp3_url, 'audio/mpeg', expiration_time) end end def sign_put(expiration_time = 3600 * 24, type='ogg') type ||= 'ogg' if type == 'ogg' s3_manager.sign_url(self[:ogg_url], {:expires => expiration_time, :content_type => 'audio/ogg', :secure => false}, :put) else s3_manager.sign_url(self[:mp3_url], {:expires => expiration_time, :content_type => 'audio/mpeg', :secure => false}, :put) end end def filename(type='ogg') # construct a path for s3 Mix.construct_filename(recording.created_at, self.recording_id, self.id, type) end def update_download_count(count=1) self.download_count = self.download_count + count self.last_downloaded_at = Time.now end def delete_s3_files s3_manager.delete(filename(type='ogg')) if self[:ogg_url] && s3_manager.exists?(filename(type='ogg')) s3_manager.delete(filename(type='mp3')) if self[:mp3_url] && s3_manager.exists?(filename(type='mp3')) end private def self.construct_filename(created_at, recording_id, id, type='ogg') raise "unknown ID" unless id "recordings/#{created_at.strftime('%m-%d-%Y')}/#{recording_id}/mix-#{id}.#{type}" end end end