Class: HeadMusic::Style::Guidelines::Contoured

Inherits:
Annotation
  • Object
show all
Defined in:
lib/head_music/style/guidelines/contoured.rb

Overview

Flags a melody without the configured contour Configure the contour with the factory, e.g. Contoured.with(:arch).

Defined Under Namespace

Classes: TrendWalk

Constant Summary collapse

CONTOURS =
%i[ascending descending arch valley wave static].freeze
TREND_REVERSAL_SEMITONES =

a trend reversal must exceed a whole step

3
DEFAULT_WEIGHT =
HeadMusic::GOLDEN_RATIO_INVERSE

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

This class inherits a constructor from HeadMusic::Style::Annotation

Class Method Details

.default_weightObject



29
30
31
# File 'lib/head_music/style/guidelines/contoured.rb', line 29

def self.default_weight
  DEFAULT_WEIGHT
end

.normalized_contour(contour_key) ⇒ Object



20
21
22
23
24
25
26
27
# File 'lib/head_music/style/guidelines/contoured.rb', line 20

def self.normalized_contour(contour_key)
  contour = contour_key.to_s.downcase.to_sym
  unless CONTOURS.include?(contour)
    raise ArgumentError, "Contour must be one of: #{CONTOURS.join(", ")} (got #{contour_key.inspect})"
  end

  contour
end

.with(contour_key, **options) ⇒ Object



16
17
18
# File 'lib/head_music/style/guidelines/contoured.rb', line 16

def self.with(contour_key, **options)
  super(contour: normalized_contour(contour_key), **options)
end

Instance Method Details

#advance_trend(walk, number) ⇒ Object (private)



110
111
112
113
114
115
116
# File 'lib/head_music/style/guidelines/contoured.rb', line 110

def advance_trend(walk, number)
  if walk.direction.nil?
    seek_trend(walk, number)
  else
    continue_trend(walk, number)
  end
end

#arch?Boolean (private)

The climax is by definition the maximum, so “net rise before, net fall after” is equivalent to both endpoints sitting below the climax pitch. Climax uniqueness and consonance remain ConsonantClimax’s job.

Returns:

  • (Boolean)


65
66
67
# File 'lib/head_music/style/guidelines/contoured.rb', line 65

def arch?
  endpoints_interior_to?(highest_pitch)
end

#ascending?Boolean (private)

Returns:

  • (Boolean)


54
55
56
# File 'lib/head_music/style/guidelines/contoured.rb', line 54

def ascending?
  first_note.pitch == lowest_pitch && last_note.pitch == highest_pitch
end

#continue_trend(walk, number) ⇒ Object (private)

Within a trend: extend the extreme while the melody keeps going, or confirm a reversal once it retraces from that extreme by at least the threshold.



133
134
135
136
137
138
139
140
141
# File 'lib/head_music/style/guidelines/contoured.rb', line 133

def continue_trend(walk, number)
  sign = (walk.direction == :ascending) ? 1 : -1
  delta = number - walk.extreme
  if sign * delta > 0
    walk.extreme = number
  elsif -sign * delta >= TREND_REVERSAL_SEMITONES
    start_trend(walk, opposite_direction(walk.direction), number)
  end
end

#contourObject (private)

Validated again here because Contoured.new(voice, contour: :bogus) bypasses .with.



46
47
48
# File 'lib/head_music/style/guidelines/contoured.rb', line 46

def contour
  @contour ||= self.class.normalized_contour(options.fetch(:contour))
end

#descending?Boolean (private)

Returns:

  • (Boolean)


58
59
60
# File 'lib/head_music/style/guidelines/contoured.rb', line 58

def descending?
  first_note.pitch == highest_pitch && last_note.pitch == lowest_pitch
end

#directional_endpoints?Boolean (private)

The highest_pitch > lowest_pitch guard is load-bearing: without it, an all-same-pitch melody (first == lowest and last == highest simultaneously) would absurdly fail static.

Returns:

  • (Boolean)


90
91
92
# File 'lib/head_music/style/guidelines/contoured.rb', line 90

def directional_endpoints?
  highest_pitch > lowest_pitch && (ascending? || descending?)
end

#endpoints_interior_to?(extreme_pitch) ⇒ Boolean (private)

Because the given pitch is a running extreme (the highest or lowest), no endpoint can pass it, so “interior” simply means neither endpoint touches it.

Returns:

  • (Boolean)


75
76
77
# File 'lib/head_music/style/guidelines/contoured.rb', line 75

def endpoints_interior_to?(extreme_pitch)
  notes.length >= 3 && first_note.pitch != extreme_pitch && last_note.pitch != extreme_pitch
end

#marksObject



33
34
35
36
37
# File 'lib/head_music/style/guidelines/contoured.rb', line 33

def marks
  return if notes.empty? || matches_contour?

  HeadMusic::Style::Mark.for_all(notes, fitness: HeadMusic::GOLDEN_RATIO_INVERSE**2)
end

#matches_contour?Boolean (private)

Returns:

  • (Boolean)


50
51
52
# File 'lib/head_music/style/guidelines/contoured.rb', line 50

def matches_contour?
  send("#{contour}?")
end

#messageObject



39
40
41
# File 'lib/head_music/style/guidelines/contoured.rb', line 39

def message
  "Write a melody with the #{contour} contour."
end

#opposite_direction(direction) ⇒ Object (private)



149
150
151
# File 'lib/head_music/style/guidelines/contoured.rb', line 149

def opposite_direction(direction)
  (direction == :ascending) ? :descending : :ascending
end

#pitch_numbersObject (private)



94
95
96
# File 'lib/head_music/style/guidelines/contoured.rb', line 94

def pitch_numbers
  @pitch_numbers ||= notes.map { |note| note.pitch.midi_note_number }
end

#seek_trend(walk, number) ⇒ Object (private)

No trend confirmed yet: widen the running range until the melody breaks out of it by at least the reversal threshold, which sets the first direction.



120
121
122
123
124
125
126
127
128
129
# File 'lib/head_music/style/guidelines/contoured.rb', line 120

def seek_trend(walk, number)
  if number - walk.low >= TREND_REVERSAL_SEMITONES
    start_trend(walk, :ascending, number)
  elsif walk.high - number >= TREND_REVERSAL_SEMITONES
    start_trend(walk, :descending, number)
  else
    walk.high = [walk.high, number].max
    walk.low = [walk.low, number].min
  end
end

#start_trend(walk, direction, number) ⇒ Object (private)



143
144
145
146
147
# File 'lib/head_music/style/guidelines/contoured.rb', line 143

def start_trend(walk, direction, number)
  walk.direction = direction
  walk.extreme = number
  walk.directions << direction
end

#static?Boolean (private)

Returns:

  • (Boolean)


83
84
85
# File 'lib/head_music/style/guidelines/contoured.rb', line 83

def static?
  range <= HeadMusic::Analysis::DiatonicInterval.get(:major_third) && !directional_endpoints?
end

#trend_directionsObject (private)

Zigzag walk: a trend reversal is confirmed only when the melody retraces at least TREND_REVERSAL_SEMITONES from the running extreme of the current trend, so stepwise neighbor-note undulation never registers as a trend change.



101
102
103
104
105
106
107
108
# File 'lib/head_music/style/guidelines/contoured.rb', line 101

def trend_directions
  @trend_directions ||= begin
    first = pitch_numbers.first
    walk = TrendWalk.new([], nil, first, first, nil)
    pitch_numbers.drop(1).each { |number| advance_trend(walk, number) }
    walk.directions
  end
end

#valley?Boolean (private)

Returns:

  • (Boolean)


69
70
71
# File 'lib/head_music/style/guidelines/contoured.rb', line 69

def valley?
  endpoints_interior_to?(lowest_pitch)
end

#wave?Boolean (private)

Returns:

  • (Boolean)


79
80
81
# File 'lib/head_music/style/guidelines/contoured.rb', line 79

def wave?
  trend_directions.length >= 3
end