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

Inherits:
Object
  • Object
show all
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



34
35
36
# File 'lib/head_music/style/guides/base.rb', line 34

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

.assess_items(voice) ⇒ Object

The material GuideAssessment grades, stopping at a failed gate.



39
40
41
# File 'lib/head_music/style/guides/base.rb', line 39

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

.categoriesObject

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



68
69
70
# File 'lib/head_music/style/guides/base.rb', line 68

def categories
  [category].compact
end

.categoryObject

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



58
59
60
# File 'lib/head_music/style/guides/base.rb', line 58

def category
  nil
end

.composite?Boolean

Returns:

  • (Boolean)


62
63
64
# File 'lib/head_music/style/guides/base.rb', line 62

def composite?
  false
end

.declarationsObject (private)

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



121
122
123
# File 'lib/head_music/style/guides/base.rb', line 121

def declarations
  @declarations ||= {}
end

.display_nameObject



72
73
74
# File 'lib/head_music/style/guides/base.rb', line 72

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

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

Preconditions: is this voice assessable at all?



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

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

.guide_itemsObject



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

def guide_items
  @guide_items ||= TIERS.flat_map { |tier| items_by_tier[tier] }.freeze
end

.instructionObject



76
77
78
# File 'lib/head_music/style/guides/base.rb', line 76

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

.items_by_tierObject

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.



30
31
32
# File 'lib/head_music/style/guides/base.rb', line 30

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

.keyObject



53
54
55
# File 'lib/head_music/style/guides/base.rb', line 53

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

.normalize(tiers) ⇒ Object (protected)



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
108
109
# File 'lib/head_music/style/guides/base.rb', line 82

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.



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

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)


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

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.



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

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

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



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

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.



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

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.



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

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