# This is my normal "weighted" selector.
class WeightedSelector < Selector
# Determines the membership of the chunk. All tracks whose weights are
# within CHUNK_RANGE of the highest weighted track's weight will be included
# in the chunk. The track that is ultimately selected is chosen randomly
# from the chunk.
CHUNK_RANGE = 0.2
# Weight modifier for tracks whose album are in the New mood. This is
# applied such that new_weight = weight + (weight * NEW_MOOD_MULTIPLIER).abs
NEW_MOOD_MULTIPLIER = 0.1
# Rating modifier to emphasize last_queued over rating.
RATING_MODIFIER = 0.25
# used for testing
attr_reader :track_weights, :album_weights, :artist_weights
def select(time=Time.now)
@last_track ||= nil
tracks = load_tracks(time)
check_pblock(time) # needing to call this for select_type_from_track has a
# certain code smell to it
track_stats = collect_stats(tracks)
ordered = calc_weights(tracks, track_stats)
chunk = ordered.find_all {|o| o[1] + CHUNK_RANGE >= ordered[0][1]}
tracks = []
chunk.each do |c|
track = Track.find(c[0])
tracks << track
end
track = tracks[Kernel::rand(tracks.size)]
if ["development",].include?(RAILS_ENV) # these take significant time
debug_last_track
debug_current_track(track)
debug_tracer_tracks
debug_avg_track
debug_min_max_tracks(ordered)
end
@last_track = track
select_type_from_track(track)
end
private
def calc_weights(tracks, track_stats)
track_weights = calc_normalized_weights(tracks, track_stats)
t2 = track_weights.sort {|a,b| b[1] <=> a[1]}
album_ids = tracks.map {|t| t.medium.album_id}
albums = Album.find(album_ids)
artist_ids = tracks.map {|t| t.artist_id ? t.artist_id : t.album.artist_id}
artists = Artist.find(artist_ids)
album_stats = collect_stats(albums)
artist_stats = collect_stats(artists)
weights = []
logger.debug(track_stats.debug)
logger.debug(album_stats.debug)
logger.debug(artist_stats.debug)
album_weights = calc_normalized_weights(albums, album_stats)
artist_weights = calc_normalized_weights(artists, artist_stats)
new_mood = Mood.find_by_name("New")
tracks.each do |track|
weight = track_weights[track.id]
if track.album.moods.include?(new_mood)
weight += (weight * NEW_MOOD_MULTIPLIER).abs
end
if track.last_queued > RECENTLY_QUEUED_DURATION
weight -= RECENTLY_QUEUED_PENALTY
end
track_weights[track.id] = weight
weights << [track.id, weight + album_weights[track.album.id] +
artist_weights[track.artist.id]]
end
weights = weights.sort {|a,b| b[1] <=> a[1]}
@artist_weights = artist_weights
@album_weights = album_weights
@track_weights = track_weights
weights
end
def calc_normalized_weights(objects, stats)
weights = {}
weights.default = -100.0
objects.each do |object|
norm_rating = normalize_rating(object.alt_rating, stats.min_r,
stats.max_r, stats.inv_range_r)
norm_lq = normalize_last_queued(object.last_queued, stats.min_lq,
stats.max_lq, stats.inv_range_lq)
#adj = norm_rating - 0.5 # weights more towards rating, than last queued
# if we simply add rating & lq, rating easily dominates. Playing a
# track from the same album barely even dents the album's weight. It's
# too strong.
# if object.id == 640
# logger.debug(stats.debug)
# end
weights[object.id] = Weight.new(:rating => norm_rating *
RATING_MODIFIER,
:lq => norm_lq)
end
weights
end
def collect_stats(objects)
stats = Stats.new
objects.each do |obj|
stats.min_r = [obj.alt_rating, stats.min_r].min
stats.max_r = [obj.alt_rating, stats.max_r].max
stats.min_lq = [obj.last_queued, stats.min_lq].min
stats.max_lq = [obj.last_queued, stats.max_lq].max
end
stats.process
stats
end
def debug_min_max_tracks(ordered)
maximum = Track.find(ordered.first[0])
minimum = Track.find(ordered.last[0])
s_max = @track_weights[maximum.id] + @album_weights[maximum.album.id] +
@artist_weights[maximum.artist.id]
s_min = @track_weights[minimum.id] + @album_weights[minimum.album.id] +
@artist_weights[minimum.artist.id]
msg = sprintf("DT %d: \"%s\" %0.4f %0.4f (%0.2f; %0.2f; %0.2f)", 0000,
"000 Max Track",
Track.find(maximum.id).rating, s_max,
@track_weights[maximum.id],
@album_weights[maximum.album.id],
@artist_weights[maximum.artist.id])
logger.debug(msg)
msg = sprintf("DT %d: \"%s\" %0.4f %0.4f (%0.2f; %0.2f; %0.2f)", 0000,
"000 Min Track",
Track.find(minimum.id).rating, s_min,
@track_weights[minimum.id],
@album_weights[minimum.album.id],
@artist_weights[minimum.artist.id])
#logger.debug(msg) # we're currently not interested in the min track
end
def debug_current_track(track)
total = @artist_weights[track.artist.id] +
@album_weights[track.album.id] +
@track_weights[track.id]
logger.debug("#{track.title.inspect} by #{track.artist.name} breakdown:\n" +
"\tArtist: #{@artist_weights[track.artist.id]}\n" +
"\t Album: #{@album_weights[track.album.id]}\n" +
"\t Track: #{@track_weights[track.id]}\n" +
"\t Total: #{total}")
end
def debug_last_track
if @last_track && # the following protects us during schedule changes
@artist_weights.include?(@last_track.artist.id) &&
@album_weights.include?(@last_track.album.id) &&
@track_weights.include?([@last_track.id])
total = @artist_weights[@last_track.artist.id] +
@album_weights[@last_track.album.id] +
@track_weights[@last_track.id]
logger.debug("Last track #{@last_track.title.inspect} " +
"by #{@last_track.artist.name} breakdown:\n" +
"\tArtist: #{@artist_weights[@last_track.artist.id]}\n" +
"\t Album: #{@album_weights[@last_track.album.id]}\n" +
"\t Track: #{@track_weights[@last_track.id]}\n" +
"\t Total: #{total}")
end
end
def debug_tracer_tracks
tracks = [
Track.find(2400), # Shop (W.A.C.I. Remix), rating 2
Track.find(3335), # Crack, rating 3
Track.find(3598), # Welcome To The Cyberspace, rating 4
Track.find(7703), # Adrenalin Rush 2006, rating 5
Track.find(588), # Tiny Voices, rating 6
Track.find(7695), # Once Upon A Time, raitng 7
Track.find(2940), # Born In A Burial Gown, rating 8
Track.find(6820), # Alpha Omega, rating 9
Track.find(5389), # World Window, rating 10
Track.find(555), # Fire It Up, WHY OH WHY?
]
tracks.each {|t| debug_track(t)}
end
def debug_avg_track
ar_avg = (@artist_weights.values.inject(0) {|m,v| m += v} /
@artist_weights.size)
al_avg = (@album_weights.values.inject(0) {|m,v| m += v} /
@album_weights.size)
# ignore tracks with a negative weight, as they don't help understand the
# average... They're outliers that are not interesting
tr_avg = (@track_weights.values.inject(0) {|m,v| v > 0 ? m += v : m} /
@track_weights.size)
r_avg = (@track_weights.keys.inject(0.0) {|m,v| m += Track.find(v).rating} /
@track_weights.size)
avg = ar_avg + al_avg + tr_avg
msg = sprintf("DT %d: \"%s\" %0.4f %0.4f (%0.2f; %0.2f; %0.2f)", 0000,
"000 Average Track", r_avg, avg, tr_avg, al_avg, ar_avg)
logger.debug(msg)
logger.debug("DT #{@track_weights.size} tracks, " +
"#{@album_weights.size} albums, " +
"#{@artist_weights.size} artists.")
end
def debug_track(track)
artist_weight = @artist_weights[track.artist.id]
album_weight = @album_weights[track.album.id]
track_weight = @track_weights[track.id]
weight = artist_weight + album_weight + track_weight
msg = sprintf("DT %d: \"%s\" %d %0.4f (%0.2f; %0.2f; %0.2f)",
track.id, track.title, track.rating.to_i, weight,
track_weight, album_weight, artist_weight)
logger.debug(msg)
end
end
# just a data structure
class Stats
attr_accessor :min_r, :max_r, :min_lq, :max_lq, :inv_range_lq, :inv_range_r
def initialize
@min_r = 11
@max_r = -1
@min_lq = Time.now
@max_lq = Time.at(0)
end
def process
if @max_r - @min_r == 0
@inv_range_r = 0.0
else
@inv_range_r = 1.0 / (@max_r - @min_r)
end
# float comparison are too sloppy
if @max_lq.to_i - @min_lq.to_i == 0
@inv_range_lq = 0.0
else
@inv_range_lq = 1.0 / (@max_lq - @min_lq)
end
end
def debug
sprintf("r: [%0.1f, %0.1f] lq: [%s, %s] inv_r: %0.1f inv_lq: %0.1f",
@min_r, @max_r, @min_lq, @max_lq, @inv_range_r, @inv_range_lq)
end
end
class Weight
def <=>(other)
to_f <=> other.to_f
end
def initialize(factors={})
@factors = factors
end
def coerce(other)
[Weight.new(:coerced => other), self]
end
def +(other)
to_f + other.to_f
end
def *(other)
to_f * other.to_f
end
def -(other)
to_f - other.to_f
end
def /(other)
to_f / other.to_f
end
def >(other)
(self <=> other) == 1
end
def <(other)
(self <=> other) == -1
end
def ==(other)
(self <=> other) == 0
end
def inspect
to_s
end
def to_i
to_f.to_i
end
def to_f
weight
end
def to_s
debug
end
def add_factor(name, value)
@factors[name] = value
end
def debug
template = ["%s %0.4f"]
template *= @factors.size
factors = sprintf(template.join(", ").strip, *@factors.to_a.flatten)
sprintf("%0.4f (%s)", weight, factors)
end
def weight
x = @factors.values.inject {|m,o| m += o}.to_f
#raise "BAD WEIGHT (#{x})" if x < -5 || x > 20 # random magic numbers
#x
end
end