Class: HeadMusic::Style::Guides::Base

Inherits:
Object
  • Object
show all
Extended by:
TieredItems
Defined in:
lib/head_music/style/guides/base.rb

Overview

Base class for style guides: declares guidelines in three tiers and assesses a voice against them.

Tier is the list an item is declared in rather than a property of the item, because the cores are shared objects -- ContourMelody treats as background exactly what DiatonicMelody teaches, and one frozen item cannot carry both.

Direct Known Subclasses

SpeciesHarmony, SpeciesMelody

Constant Summary collapse

TIERS =
%i[gate primary secondary].freeze

Class Method Summary collapse

Class Method Details

.assess(voice) ⇒ Object



32
33
34
# File 'lib/head_music/style/guides/base.rb', line 32

def assess(voice)
  HeadMusic::Style::GuideAssessment.new(self, voice)
end

.assess_items(voice) ⇒ Object

The material GuideAssessment grades, stopping at a failed gate.



37
38
39
# File 'lib/head_music/style/guides/base.rb', line 37

def assess_items(voice)
  HeadMusic::Style::Guides::Assessment.assess_items(voice, items_by_tier)
end

.categories ⇒ Object

The guide-side twin of GuideAssessment#assessments: one category, so a registry sweep can ask every entry the same question.



66
67
68
# File 'lib/head_music/style/guides/base.rb', line 66

def categories
  [category].compact
end

.category ⇒ Object

An open enum: :melody or :harmony today, declared on the marker bases.



56
57
58
# File 'lib/head_music/style/guides/base.rb', line 56

def category
  nil
end

.composite? ⇒ Boolean

Returns:

  • (Boolean)


60
61
62
# File 'lib/head_music/style/guides/base.rb', line 60

def composite?
  false
end

.declarations ⇒ Object (private)

Never inherited: a subclass that omits a list gets an empty one.



119
120
121
# File 'lib/head_music/style/guides/base.rb', line 119

def declarations
  @declarations ||= {}
end

.display_name ⇒ Object



70
71
72
# File 'lib/head_music/style/guides/base.rb', line 70

def display_name
  HeadMusic::Style::Guide.display_name_for(key)
end

.gate_items(*entries, except: nil) ⇒ Object

Preconditions: is this voice assessable at all?



17
# File 'lib/head_music/style/guides/base.rb', line 17

def gate_items(*entries, except: nil) = tier_items(:gate, entries, except)

.guide_items ⇒ Object Originally defined in module TieredItems

.instruction ⇒ Object



74
75
76
# File 'lib/head_music/style/guides/base.rb', line 74

def instruction
  HeadMusic::Style::Guide.instruction_for(key)
end

.items_by_tier ⇒ Object

A guide whose lists depend on configuration overrides this with a keyword signature, so an unconfigured use raises rather than grading a voice against nothing at a plausible 1.0.



28
29
30
# File 'lib/head_music/style/guides/base.rb', line 28

def items_by_tier
  @items_by_tier ||= normalize(declarations)
end

.key ⇒ Object



51
52
53
# File 'lib/head_music/style/guides/base.rb', line 51

def key
  HeadMusic::Utilities::Case.to_snake_case(name.split("::").last)
end

.normalize(tiers) ⇒ Object (protected)



80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
# File 'lib/head_music/style/guides/base.rb', line 80

def normalize(tiers)
  resolved = TIERS.to_h { |tier| [tier, wrap_list(tiers[tier])] }.freeze
  # ArgumentError, not NotImplementedError: the latter is a ScriptError and
  # so escapes an ordinary rescue.
  if resolved.values.all?(&:empty?)
    raise ArgumentError, "#{name} declares no guide items"
  end

  # A guide that is all background has no subject, and grading it 1.0 in
  # silence is the "nothing to find fault in" confusion.
  #
  # A gate-only guide is caught by the same check, deliberately and with no
  # exemption: a guide that only decides whether a voice is assessable has
  # nothing to grade it against, and an exemption would leave two spellings
  # of "this guide teaches nothing" -- one that raises and one that quietly
  # returns 1.0.
  #
  # The declared items are named because the class name is not enough: the
  # six contour guides are one class configured six ways.
  if resolved[:primary].empty?
    raise ArgumentError,
      "#{name} declares no primary guide items, so it teaches nothing: " \
      "#{resolved.reject { |_tier, items| items.empty? }.transform_values { |items| items.map(&:inspect) }.inspect}"
  end

  reject_duplicates(resolved)
  resolved
end

.primary_items(*entries, except: nil) ⇒ Object

What this guide is about.



20
# File 'lib/head_music/style/guides/base.rb', line 20

def primary_items(*entries, except: nil) = tier_items(:primary, entries, except)

.reject_duplicates(resolved) ⇒ Object (private)

MinimumNotes as a gate and again as a stylistic minimum asks two questions; the same configuration in two tiers is double-counting.

Raises:

  • (ArgumentError)


134
135
136
137
138
139
140
141
# File 'lib/head_music/style/guides/base.rb', line 134

def reject_duplicates(resolved)
  duplicated = resolved.values.flatten.tally.select { |_item, count| count > 1 }.keys
  return if duplicated.empty?

  raise ArgumentError,
    "#{name} declares the same guideline and configuration in more than one tier: " \
    "#{duplicated.map(&:inspect).join(", ")}"
end

.secondary_items(*entries, except: nil) ⇒ Object

Background craft this guide inherits rather than teaches.



23
# File 'lib/head_music/style/guides/base.rb', line 23

def secondary_items(*entries, except: nil) = tier_items(:secondary, entries, except)

.tier_items(tier, entries, except) ⇒ Object (private)



111
112
113
114
115
116
# File 'lib/head_music/style/guides/base.rb', line 111

def tier_items(tier, entries, except)
  return items_by_tier[tier] if entries.empty? && except.nil?

  declarations[tier] = [*declarations[tier], *wrap_list(entries, except)]
  nil
end

.with(**options) ⇒ Object

Rejected here rather than at the first assessment, where Ruby's bare "wrong number of arguments" would name neither the guide nor the option.



43
44
45
46
47
48
49
# File 'lib/head_music/style/guides/base.rb', line 43

def with(**options)
  if options.any? && method(:items_by_tier).parameters.empty?
    raise ArgumentError, "#{name} takes no configuration, so it cannot be given: #{options.keys.join(", ")}"
  end

  HeadMusic::Style::Guides::Configured.new(self, options)
end

.wrap_list(entries, excluded = nil) ⇒ Object (private)

except: applies to the entries of the call carrying it, not the tier: DiatonicMelody drops a core item and then declares its own configured one.



125
126
127
128
129
130
# File 'lib/head_music/style/guides/base.rb', line 125

def wrap_list(entries, excluded = nil)
  items = Array(entries).compact.map { |entry| HeadMusic::Style::GuideItem.wrap(entry) }
  return items.freeze if excluded.nil?

  items.reject { |item| Array(excluded).include?(item.guideline) }.freeze
end