Subversion Repositories mildred

Rev

Blame | Compare with Previous | Last modification | View Log | RSS feed

# 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