head_music 17.2.0 → 17.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +15 -0
  3. data/Gemfile.lock +1 -1
  4. data/lib/head_music/analysis/chord_analysis.rb +123 -0
  5. data/lib/head_music/analysis/circle.rb +11 -3
  6. data/lib/head_music/analysis/diatonic_interval.rb +14 -11
  7. data/lib/head_music/analysis/dyad/chord_implication.rb +101 -0
  8. data/lib/head_music/analysis/dyad.rb +24 -104
  9. data/lib/head_music/analysis/interval_cycle.rb +9 -3
  10. data/lib/head_music/analysis/pitch_collection.rb +33 -107
  11. data/lib/head_music/analysis/sonority.rb +11 -21
  12. data/lib/head_music/content/composition/hash_deserializer.rb +110 -0
  13. data/lib/head_music/content/composition/schema_values.rb +127 -0
  14. data/lib/head_music/content/composition.rb +0 -215
  15. data/lib/head_music/content/placement.rb +11 -42
  16. data/lib/head_music/content/sound_resolver.rb +54 -0
  17. data/lib/head_music/content/voice/melodic_line.rb +32 -0
  18. data/lib/head_music/content/voice/melodic_note_pair.rb +33 -0
  19. data/lib/head_music/content/voice.rb +7 -58
  20. data/lib/head_music/instruments/alternate_tuning.rb +4 -6
  21. data/lib/head_music/instruments/catalog_lookup.rb +18 -0
  22. data/lib/head_music/instruments/instrument.rb +17 -117
  23. data/lib/head_music/instruments/instrument_catalog.rb +48 -0
  24. data/lib/head_music/instruments/instrument_configuration.rb +4 -6
  25. data/lib/head_music/instruments/instrument_family.rb +3 -8
  26. data/lib/head_music/instruments/instrument_name.rb +49 -0
  27. data/lib/head_music/instruments/score_order.rb +52 -42
  28. data/lib/head_music/instruments/staff_profile.rb +71 -0
  29. data/lib/head_music/instruments/stringing.rb +4 -6
  30. data/lib/head_music/instruments/stringing_course.rb +4 -6
  31. data/lib/head_music/instruments/variant.rb +4 -6
  32. data/lib/head_music/named/locale.rb +32 -0
  33. data/lib/head_music/named/localized_name.rb +15 -0
  34. data/lib/head_music/named.rb +1 -45
  35. data/lib/head_music/notation/abc/body_lexer.rb +65 -24
  36. data/lib/head_music/notation/abc/duration_resolver.rb +11 -1
  37. data/lib/head_music/notation/abc/duration_writer.rb +1 -8
  38. data/lib/head_music/notation/abc/header.rb +27 -16
  39. data/lib/head_music/notation/abc/parser.rb +48 -159
  40. data/lib/head_music/notation/abc/preflight.rb +48 -0
  41. data/lib/head_music/notation/abc/voice_state.rb +161 -0
  42. data/lib/head_music/notation/abc/writer.rb +47 -16
  43. data/lib/head_music/notation/dotted_duration.rb +19 -0
  44. data/lib/head_music/notation/instrument_notation.rb +5 -7
  45. data/lib/head_music/notation/music_xml/beam_grouper.rb +108 -0
  46. data/lib/head_music/notation/music_xml/duration_writer.rb +3 -8
  47. data/lib/head_music/notation/music_xml/preflight.rb +90 -0
  48. data/lib/head_music/notation/music_xml/render_plan.rb +161 -0
  49. data/lib/head_music/notation/music_xml/writer.rb +31 -146
  50. data/lib/head_music/notation/placement_validation.rb +19 -0
  51. data/lib/head_music/rudiment/enharmonic_equivalence.rb +33 -0
  52. data/lib/head_music/rudiment/key.rb +8 -36
  53. data/lib/head_music/rudiment/key_signature/enharmonic_equivalence.rb +21 -15
  54. data/lib/head_music/rudiment/key_signature.rb +18 -19
  55. data/lib/head_music/rudiment/meter.rb +39 -6
  56. data/lib/head_music/rudiment/mode.rb +33 -65
  57. data/lib/head_music/rudiment/note.rb +18 -21
  58. data/lib/head_music/rudiment/pitch/enharmonic_equivalence.rb +4 -15
  59. data/lib/head_music/rudiment/pitch/natural_step.rb +36 -0
  60. data/lib/head_music/rudiment/pitch/parser.rb +16 -5
  61. data/lib/head_music/rudiment/pitch.rb +11 -33
  62. data/lib/head_music/rudiment/qualified_diatonic_context.rb +61 -0
  63. data/lib/head_music/rudiment/rhythmic_unit/parser.rb +16 -12
  64. data/lib/head_music/rudiment/rhythmic_unit.rb +21 -37
  65. data/lib/head_music/rudiment/rhythmic_value/parser.rb +53 -46
  66. data/lib/head_music/rudiment/spelling/enharmonic_equivalence.rb +13 -0
  67. data/lib/head_music/rudiment/spelling.rb +0 -25
  68. data/lib/head_music/rudiment/tuning/just_intonation.rb +2 -4
  69. data/lib/head_music/rudiment/tuning/meantone.rb +2 -4
  70. data/lib/head_music/rudiment/tuning/pythagorean.rb +2 -4
  71. data/lib/head_music/rudiment/tuning/tonal_centered.rb +10 -0
  72. data/lib/head_music/rudiment/unpitched_note.rb +3 -5
  73. data/lib/head_music/style/annotation/configured.rb +35 -0
  74. data/lib/head_music/style/annotation.rb +32 -44
  75. data/lib/head_music/style/guidelines/allowed_rhythmic_values_for_fifth_species.rb +0 -10
  76. data/lib/head_music/style/guidelines/contoured.rb +65 -53
  77. data/lib/head_music/style/guidelines/dissonance_figure_detection.rb +55 -16
  78. data/lib/head_music/style/guidelines/florid_dissonance_treatment.rb +0 -37
  79. data/lib/head_music/style/guidelines/large_leaps.rb +10 -2
  80. data/lib/head_music/style/guidelines/minimum_melodic_intervals.rb +3 -25
  81. data/lib/head_music/style/guidelines/minimum_notes.rb +3 -25
  82. data/lib/head_music/style/guidelines/minimum_threshold.rb +38 -0
  83. data/lib/head_music/style/guidelines/no_parallel_perfect.rb +31 -0
  84. data/lib/head_music/style/guidelines/no_parallel_perfect_on_downbeats.rb +3 -17
  85. data/lib/head_music/style/guidelines/no_parallel_perfect_with_syncopation.rb +3 -17
  86. data/lib/head_music/style/guidelines/prepare_octave_leaps.rb +10 -7
  87. data/lib/head_music/style/guidelines/singable_intervals.rb +22 -8
  88. data/lib/head_music/style/guidelines/suspension_treatment.rb +30 -12
  89. data/lib/head_music/style/guidelines/third_species_dissonance_treatment.rb +0 -7
  90. data/lib/head_music/style/guidelines/triple_meter_dissonance_treatment.rb +0 -12
  91. data/lib/head_music/style/guidelines/weak_beat_dissonance_treatment.rb +2 -42
  92. data/lib/head_music/style/guides/fifth_species_melody.rb +3 -13
  93. data/lib/head_music/style/guides/fourth_species_melody.rb +3 -13
  94. data/lib/head_music/style/guides/second_species_harmony.rb +2 -5
  95. data/lib/head_music/style/guides/second_species_melody.rb +2 -12
  96. data/lib/head_music/style/guides/species_harmony.rb +14 -0
  97. data/lib/head_music/style/guides/species_melody.rb +21 -0
  98. data/lib/head_music/style/guides/third_species_harmony.rb +2 -5
  99. data/lib/head_music/style/guides/third_species_melody.rb +3 -13
  100. data/lib/head_music/style/guides/third_species_triple_meter_harmony.rb +2 -5
  101. data/lib/head_music/style/guides/third_species_triple_meter_melody.rb +2 -12
  102. data/lib/head_music/time/conductor.rb +19 -125
  103. data/lib/head_music/time/musical_position.rb +26 -26
  104. data/lib/head_music/time/musical_time_converter.rb +104 -0
  105. data/lib/head_music/time/smpte_converter.rb +51 -0
  106. data/lib/head_music/time/smpte_timecode.rb +25 -23
  107. data/lib/head_music/time.rb +2 -0
  108. data/lib/head_music/value_equality.rb +37 -0
  109. data/lib/head_music/version.rb +1 -1
  110. data/lib/head_music.rb +25 -1
  111. data/user-stories/done/abc-notation-beaming.md +235 -0
  112. data/user-stories/index.html +7 -3
  113. metadata +34 -2
@@ -2,37 +2,22 @@
2
2
  module HeadMusic::Rudiment; end
3
3
 
4
4
  # Represents a musical key (major or minor)
5
- class HeadMusic::Rudiment::Key < HeadMusic::Rudiment::DiatonicContext
6
- include HeadMusic::Named
7
-
5
+ class HeadMusic::Rudiment::Key < HeadMusic::Rudiment::QualifiedDiatonicContext
8
6
  QUALITIES = %i[major minor].freeze
9
7
 
10
- attr_reader :quality
11
-
12
- def self.get(identifier)
13
- return identifier if identifier.is_a?(HeadMusic::Rudiment::Key)
14
-
15
- @keys ||= {}
16
- tonic_spelling, quality_name = parse_identifier(identifier)
17
- hash_key = HeadMusic::Utilities::HashKey.for(identifier)
18
- @keys[hash_key] ||= new(tonic_spelling, quality_name)
8
+ def self.default_qualifier
9
+ :major
19
10
  end
20
11
 
21
- def self.parse_identifier(identifier)
22
- tonic_spelling, quality_name = identifier.to_s.strip.split(/\s+/)
23
- quality_name ||= "major"
24
- [tonic_spelling, quality_name]
12
+ def self.valid_qualifiers
13
+ QUALITIES
25
14
  end
26
15
 
27
- def initialize(tonic_spelling, quality = :major)
28
- super(tonic_spelling)
29
- @quality = quality.to_s.downcase.to_sym
30
- raise ArgumentError, "Quality must be :major or :minor" unless QUALITIES.include?(@quality)
16
+ def self.invalid_qualifier_message
17
+ "Quality must be :major or :minor"
31
18
  end
32
19
 
33
- def scale_type
34
- @scale_type ||= HeadMusic::Rudiment::ScaleType.get(quality)
35
- end
20
+ alias_method :quality, :qualifier
36
21
 
37
22
  def major?
38
23
  quality == :major
@@ -61,17 +46,4 @@ class HeadMusic::Rudiment::Key < HeadMusic::Rudiment::DiatonicContext
61
46
  self.class.get("#{tonic_spelling} major")
62
47
  end
63
48
  end
64
-
65
- def name
66
- "#{tonic_spelling} #{quality}"
67
- end
68
-
69
- def to_s
70
- name
71
- end
72
-
73
- def ==(other)
74
- other = self.class.get(other)
75
- tonic_spelling == other.tonic_spelling && quality == other.quality
76
- end
77
49
  end
@@ -1,26 +1,32 @@
1
1
  # Key signatures are enharmonic when they represent the same set of altered pitch classes but with different spellings.
2
- class HeadMusic::Rudiment::KeySignature::EnharmonicEquivalence
3
- attr_reader :key_signature
4
-
5
- def self.get(key_signature)
6
- key_signature = HeadMusic::Rudiment::KeySignature.get(key_signature)
7
- @enharmonic_equivalences ||= {}
8
- @enharmonic_equivalences[key_signature.to_s] ||= new(key_signature)
2
+ class HeadMusic::Rudiment::KeySignature::EnharmonicEquivalence < HeadMusic::Rudiment::EnharmonicEquivalence
3
+ def self.subject_class
4
+ HeadMusic::Rudiment::KeySignature
9
5
  end
10
6
 
11
- def initialize(key_signature)
12
- @key_signature = HeadMusic::Rudiment::KeySignature.get(key_signature)
13
- end
7
+ alias_method :key_signature, :subject
14
8
 
15
9
  def enharmonic_equivalent?(other)
16
10
  other = HeadMusic::Rudiment::KeySignature.get(other)
17
11
 
18
- key_signature.pitch_classes.map(&:to_i).sort == other.pitch_classes.map(&:to_i).sort &&
19
- key_signature.alterations.map(&:to_s).sort != other.alterations.map(&:to_s).sort
12
+ same_pitch_classes?(other) && different_spelling?(other)
20
13
  end
21
14
 
22
- alias_method :enharmonic?, :enharmonic_equivalent?
23
- alias_method :equivalent?, :enharmonic_equivalent?
15
+ private
24
16
 
25
- private_class_method :new
17
+ def same_pitch_classes?(other)
18
+ sorted_pitch_class_numbers(key_signature) == sorted_pitch_class_numbers(other)
19
+ end
20
+
21
+ def different_spelling?(other)
22
+ sorted_alteration_names(key_signature) != sorted_alteration_names(other)
23
+ end
24
+
25
+ def sorted_pitch_class_numbers(key_signature)
26
+ key_signature.pitch_classes.map(&:to_i).sort
27
+ end
28
+
29
+ def sorted_alteration_names(key_signature)
30
+ key_signature.alterations.map(&:to_s).sort
31
+ end
26
32
  end
@@ -51,27 +51,19 @@ class HeadMusic::Rudiment::KeySignature < HeadMusic::Rudiment::Base
51
51
  end
52
52
 
53
53
  def sharps
54
- spellings.select(&:sharp?).sort_by do |spelling|
55
- ORDERED_LETTER_NAMES_OF_SHARPS.index(spelling.letter_name.to_s)
56
- end
54
+ altered_spellings(:sharp?, ORDERED_LETTER_NAMES_OF_SHARPS)
57
55
  end
58
56
 
59
57
  def double_sharps
60
- spellings.select(&:double_sharp?).sort_by do |spelling|
61
- ORDERED_LETTER_NAMES_OF_SHARPS.index(spelling.letter_name.to_s)
62
- end
58
+ altered_spellings(:double_sharp?, ORDERED_LETTER_NAMES_OF_SHARPS)
63
59
  end
64
60
 
65
61
  def flats
66
- spellings.select(&:flat?).sort_by do |spelling|
67
- ORDERED_LETTER_NAMES_OF_FLATS.index(spelling.letter_name.to_s)
68
- end
62
+ altered_spellings(:flat?, ORDERED_LETTER_NAMES_OF_FLATS)
69
63
  end
70
64
 
71
65
  def double_flats
72
- spellings.select(&:double_flat?).sort_by do |spelling|
73
- ORDERED_LETTER_NAMES_OF_FLATS.index(spelling.letter_name.to_s)
74
- end
66
+ altered_spellings(:double_flat?, ORDERED_LETTER_NAMES_OF_FLATS)
75
67
  end
76
68
 
77
69
  def num_sharps
@@ -102,13 +94,10 @@ class HeadMusic::Rudiment::KeySignature < HeadMusic::Rudiment::Base
102
94
  end
103
95
 
104
96
  def to_s
105
- if sharps.any?
106
- (sharps.length == 1) ? "1 sharp" : "#{sharps.length} sharps"
107
- elsif flats.any?
108
- (flats.length == 1) ? "1 flat" : "#{flats.length} flats"
109
- else
110
- "no sharps or flats"
111
- end
97
+ return pluralize(sharps.length, "sharp") if sharps.any?
98
+ return pluralize(flats.length, "flat") if flats.any?
99
+
100
+ "no sharps or flats"
112
101
  end
113
102
 
114
103
  def enharmonic_equivalent?(other)
@@ -117,6 +106,16 @@ class HeadMusic::Rudiment::KeySignature < HeadMusic::Rudiment::Base
117
106
 
118
107
  private
119
108
 
109
+ def altered_spellings(predicate, letter_name_order)
110
+ spellings.select(&predicate).sort_by do |spelling|
111
+ letter_name_order.index(spelling.letter_name.to_s)
112
+ end
113
+ end
114
+
115
+ def pluralize(count, noun)
116
+ (count == 1) ? "1 #{noun}" : "#{count} #{noun}s"
117
+ end
118
+
120
119
  def enharmonic_equivalence
121
120
  @enharmonic_equivalence ||= HeadMusic::Rudiment::KeySignature::EnharmonicEquivalence.get(self)
122
121
  end
@@ -88,17 +88,26 @@ class HeadMusic::Rudiment::Meter < HeadMusic::Rudiment::Base
88
88
  end
89
89
 
90
90
  def beat_value
91
- @beat_value ||=
92
- if compound?
93
- HeadMusic::Rudiment::RhythmicValue.new(HeadMusic::Rudiment::RhythmicUnit.for_denominator_value(bottom_number / 2), dots: 1)
94
- else
95
- HeadMusic::Rudiment::RhythmicValue.new(count_unit)
96
- end
91
+ @beat_value ||= compound? ? dotted_denominator_value(bottom_number / 2) : count_unit_value
97
92
  end
98
93
 
99
94
  # for consistency with conversational usage
100
95
  alias_method :beat_unit, :beat_value
101
96
 
97
+ # The span of one default beam group for this meter, as a RhythmicValue.
98
+ # This is a distinct concern from #beat_value: it is the boundary unit used
99
+ # to decide where beams break.
100
+ def beam_group_unit
101
+ @beam_group_unit ||=
102
+ if compound?
103
+ beat_value
104
+ elsif bottom_number >= 8
105
+ whole_bar_beam_group_unit
106
+ else
107
+ count_unit_value
108
+ end
109
+ end
110
+
102
111
  def to_s
103
112
  [top_number, bottom_number].join("/")
104
113
  end
@@ -119,6 +128,30 @@ class HeadMusic::Rudiment::Meter < HeadMusic::Rudiment::Base
119
128
 
120
129
  private
121
130
 
131
+ # For simple meters with an eighth-or-shorter denominator (e.g. 2/8, 3/8),
132
+ # the whole bar is a single beam group. A triple grouping (3/8) is a dotted
133
+ # value of the half-denominator unit; a duple grouping (2/8) spans the bar.
134
+ # Asymmetric meters (5/8, 7/16, ...) are out of scope: their bar has no
135
+ # single power-of-two unit, so the group unit falls back to the count unit
136
+ # (each count its own group) rather than raising on a nil unit.
137
+ def whole_bar_beam_group_unit
138
+ return dotted_denominator_value(bottom_number / 2) if (top_number % 3).zero?
139
+
140
+ unit = HeadMusic::Rudiment::RhythmicUnit.for_denominator_value(bottom_number / top_number)
141
+ HeadMusic::Rudiment::RhythmicValue.new(unit || count_unit)
142
+ end
143
+
144
+ def count_unit_value
145
+ HeadMusic::Rudiment::RhythmicValue.new(count_unit)
146
+ end
147
+
148
+ # A dotted value of the rhythmic unit for the given denominator (e.g. the
149
+ # dotted-quarter beat of a 6/8 meter for denominator 4).
150
+ def dotted_denominator_value(denominator)
151
+ unit = HeadMusic::Rudiment::RhythmicUnit.for_denominator_value(denominator)
152
+ HeadMusic::Rudiment::RhythmicValue.new(unit, dots: 1)
153
+ end
154
+
122
155
  def downbeat?(count, tick = 0)
123
156
  beat?(tick) && count == 1
124
157
  end
@@ -2,59 +2,38 @@
2
2
  module HeadMusic::Rudiment; end
3
3
 
4
4
  # Represents a musical mode (church modes)
5
- class HeadMusic::Rudiment::Mode < HeadMusic::Rudiment::DiatonicContext
6
- include HeadMusic::Named
7
-
5
+ class HeadMusic::Rudiment::Mode < HeadMusic::Rudiment::QualifiedDiatonicContext
8
6
  MODES = %i[ionian dorian phrygian lydian mixolydian aeolian locrian].freeze
9
7
 
10
- attr_reader :mode_name
11
-
12
- def self.get(identifier)
13
- return identifier if identifier.is_a?(HeadMusic::Rudiment::Mode)
14
-
15
- @modes ||= {}
16
- tonic_spelling, mode_name = parse_identifier(identifier)
17
- hash_key = HeadMusic::Utilities::HashKey.for(identifier)
18
- @modes[hash_key] ||= new(tonic_spelling, mode_name)
8
+ def self.default_qualifier
9
+ :ionian
19
10
  end
20
11
 
21
- def self.parse_identifier(identifier)
22
- identifier = identifier.to_s.strip
23
- parts = identifier.split(/\s+/)
24
- tonic_spelling = parts[0]
25
- mode_name = parts[1] || "ionian"
26
- [tonic_spelling, mode_name]
12
+ def self.valid_qualifiers
13
+ MODES
27
14
  end
28
15
 
29
- def initialize(tonic_spelling, mode_name = :ionian)
30
- super(tonic_spelling)
31
- @mode_name = mode_name.to_s.downcase.to_sym
32
- raise ArgumentError, "Mode must be one of: #{MODES.join(", ")}" unless MODES.include?(@mode_name)
16
+ def self.invalid_qualifier_message
17
+ "Mode must be one of: #{MODES.join(", ")}"
33
18
  end
34
19
 
35
- def scale_type
36
- @scale_type ||= HeadMusic::Rudiment::ScaleType.get(mode_name)
37
- end
20
+ alias_method :mode_name, :qualifier
38
21
 
39
- def relative_major
40
- case mode_name
41
- when :ionian
42
- return HeadMusic::Rudiment::Key.get("#{tonic_spelling} major")
43
- when :dorian
44
- relative_pitch = tonic_pitch + -2
45
- when :phrygian
46
- relative_pitch = tonic_pitch + -4
47
- when :lydian
48
- relative_pitch = tonic_pitch + -5
49
- when :mixolydian
50
- relative_pitch = tonic_pitch + -7
51
- when :aeolian
52
- relative_pitch = tonic_pitch + -9
53
- when :locrian
54
- relative_pitch = tonic_pitch + -11
55
- end
22
+ # Semitones from a mode's tonic down to its relative major tonic.
23
+ # Ionian is omitted because its own tonic spelling is the relative major.
24
+ RELATIVE_MAJOR_SEMITONES_BELOW_TONIC = {
25
+ dorian: -2, phrygian: -4, lydian: -5,
26
+ mixolydian: -7, aeolian: -9, locrian: -11
27
+ }.freeze
56
28
 
57
- HeadMusic::Rudiment::Key.get("#{relative_pitch.spelling} major")
29
+ # The major or minor quality of each mode's parallel key.
30
+ PARALLEL_QUALITIES = {
31
+ ionian: :major, dorian: :minor, phrygian: :minor, lydian: :major,
32
+ mixolydian: :major, aeolian: :minor, locrian: :minor
33
+ }.freeze
34
+
35
+ def relative_major
36
+ HeadMusic::Rudiment::Key.get("#{relative_major_tonic_spelling} major")
58
37
  end
59
38
 
60
39
  def relative
@@ -62,31 +41,20 @@ class HeadMusic::Rudiment::Mode < HeadMusic::Rudiment::DiatonicContext
62
41
  end
63
42
 
64
43
  def parallel
65
- # Return the parallel major or minor key
66
- case mode_name
67
- when :ionian
68
- HeadMusic::Rudiment::Key.get("#{tonic_spelling} major")
69
- when :aeolian
70
- HeadMusic::Rudiment::Key.get("#{tonic_spelling} minor")
71
- when :dorian, :phrygian
72
- HeadMusic::Rudiment::Key.get("#{tonic_spelling} minor")
73
- when :lydian, :mixolydian
74
- HeadMusic::Rudiment::Key.get("#{tonic_spelling} major")
75
- when :locrian
76
- HeadMusic::Rudiment::Key.get("#{tonic_spelling} minor")
77
- end
78
- end
44
+ quality = PARALLEL_QUALITIES[mode_name]
45
+ return unless quality
79
46
 
80
- def name
81
- "#{tonic_spelling} #{mode_name}"
47
+ HeadMusic::Rudiment::Key.get("#{tonic_spelling} #{quality}")
82
48
  end
83
49
 
84
- def to_s
85
- name
86
- end
50
+ private
51
+
52
+ def relative_major_tonic_spelling
53
+ return tonic_spelling if mode_name == :ionian
87
54
 
88
- def ==(other)
89
- other = self.class.get(other)
90
- tonic_spelling == other.tonic_spelling && mode_name == other.mode_name
55
+ offset = RELATIVE_MAJOR_SEMITONES_BELOW_TONIC[mode_name]
56
+ relative_pitch = tonic_pitch + offset if offset
57
+ # An unrecognized mode leaves relative_pitch nil, raising NoMethodError (preserved behavior).
58
+ relative_pitch.spelling
91
59
  end
92
60
  end
@@ -22,30 +22,27 @@ class HeadMusic::Rudiment::Note < HeadMusic::Rudiment::RhythmicElement
22
22
 
23
23
  def self.get(pitch, rhythmic_value = nil)
24
24
  return pitch if pitch.is_a?(HeadMusic::Rudiment::Note)
25
+ return get_from_string(pitch) if rhythmic_value.nil? && pitch.is_a?(String)
25
26
 
26
- if rhythmic_value.nil? && pitch.is_a?(String)
27
- # Try to parse as "pitch rhythmic_value" format (e.g., "F#4 dotted-quarter")
28
- match = pitch.match(MATCHER)
29
- if match
30
- pitch_str = match[1] # The full pitch part
31
- rhythmic_value_str = match[5] # The rhythmic value part
32
-
33
- pitch_obj = HeadMusic::Rudiment::Pitch.get(pitch_str)
34
- rhythmic_value_obj = HeadMusic::Rudiment::RhythmicValue.get(rhythmic_value_str)
27
+ pitch = HeadMusic::Rudiment::Pitch.get(pitch)
28
+ rhythmic_value = HeadMusic::Rudiment::RhythmicValue.get(rhythmic_value || :quarter)
29
+ fetch_or_create(pitch, rhythmic_value)
30
+ end
35
31
 
36
- return fetch_or_create(pitch_obj, rhythmic_value_obj) if pitch_obj && rhythmic_value_obj
37
- end
32
+ # Parse a lone string as "pitch rhythmic_value" (e.g., "F#4 dotted-quarter"),
33
+ # falling back to just a pitch with a default quarter note.
34
+ def self.get_from_string(string)
35
+ match = string.match(MATCHER)
36
+ if match
37
+ pitch_obj = HeadMusic::Rudiment::Pitch.get(match[1])
38
+ rhythmic_value_obj = HeadMusic::Rudiment::RhythmicValue.get(match[5])
39
+ return fetch_or_create(pitch_obj, rhythmic_value_obj) if pitch_obj && rhythmic_value_obj
40
+ end
38
41
 
39
- # If parsing fails, treat it as just a pitch with default quarter note
40
- pitch_obj = HeadMusic::Rudiment::Pitch.get(pitch)
41
- return fetch_or_create(pitch_obj, HeadMusic::Rudiment::RhythmicValue.get(:quarter)) if pitch_obj
42
+ pitch_obj = HeadMusic::Rudiment::Pitch.get(string)
43
+ return fetch_or_create(pitch_obj, HeadMusic::Rudiment::RhythmicValue.get(:quarter)) if pitch_obj
42
44
 
43
- nil
44
- else
45
- pitch = HeadMusic::Rudiment::Pitch.get(pitch)
46
- rhythmic_value = HeadMusic::Rudiment::RhythmicValue.get(rhythmic_value || :quarter)
47
- fetch_or_create(pitch, rhythmic_value)
48
- end
45
+ nil
49
46
  end
50
47
 
51
48
  def self.fetch_or_create(pitch, rhythmic_value)
@@ -108,5 +105,5 @@ class HeadMusic::Rudiment::Note < HeadMusic::Rudiment::RhythmicElement
108
105
  true
109
106
  end
110
107
 
111
- private_class_method :fetch_or_create
108
+ private_class_method :fetch_or_create, :get_from_string
112
109
  end
@@ -1,26 +1,15 @@
1
1
  # An enharmonic equivalent pitch is the same frequency spelled differently, such as D# and Eb.
2
- class HeadMusic::Rudiment::Pitch::EnharmonicEquivalence
3
- def self.get(pitch)
4
- pitch = HeadMusic::Rudiment::Pitch.get(pitch)
5
- @enharmonic_equivalences ||= {}
6
- @enharmonic_equivalences[pitch.to_s] ||= new(pitch)
2
+ class HeadMusic::Rudiment::Pitch::EnharmonicEquivalence < HeadMusic::Rudiment::EnharmonicEquivalence
3
+ def self.subject_class
4
+ HeadMusic::Rudiment::Pitch
7
5
  end
8
6
 
9
- attr_reader :pitch
7
+ alias_method :pitch, :subject
10
8
 
11
9
  delegate :pitch_class, to: :pitch
12
10
 
13
- def initialize(pitch)
14
- @pitch = HeadMusic::Rudiment::Pitch.get(pitch)
15
- end
16
-
17
11
  def enharmonic_equivalent?(other)
18
12
  other = HeadMusic::Rudiment::Pitch.get(other)
19
13
  pitch.midi_note_number == other.midi_note_number && pitch.spelling != other.spelling
20
14
  end
21
-
22
- alias_method :enharmonic?, :enharmonic_equivalent?
23
- alias_method :equivalent?, :enharmonic_equivalent?
24
-
25
- private_class_method :new
26
15
  end
@@ -0,0 +1,36 @@
1
+ class HeadMusic::Rudiment::Pitch
2
+ # Computes where a signed number of diatonic (letter-name) steps lands
3
+ # relative to a starting letter name: the destination letter and how many
4
+ # octaves the move crosses. Register-agnostic — it works purely from the
5
+ # letter name's position in the scale, so the Pitch supplies the register.
6
+ class NaturalStep
7
+ attr_reader :letter_name, :num_steps
8
+
9
+ def initialize(letter_name, num_steps)
10
+ @letter_name = letter_name
11
+ @num_steps = num_steps
12
+ end
13
+
14
+ def target_letter_name
15
+ @target_letter_name ||= letter_name.steps_up(num_steps)
16
+ end
17
+
18
+ def octaves_delta
19
+ whole_octaves = (num_steps.abs / 7) * (num_steps.negative? ? -1 : 1)
20
+ return whole_octaves - 1 if wrapped_down?
21
+ return whole_octaves + 1 if wrapped_up?
22
+
23
+ whole_octaves
24
+ end
25
+
26
+ private
27
+
28
+ def wrapped_down?
29
+ num_steps.negative? && target_letter_name.position > letter_name.position
30
+ end
31
+
32
+ def wrapped_up?
33
+ num_steps.positive? && target_letter_name.position < letter_name.position
34
+ end
35
+ end
36
+ end
@@ -42,11 +42,22 @@ class HeadMusic::Rudiment::Pitch::Parser
42
42
 
43
43
  def parse_components
44
44
  match = identifier.match(PATTERN)
45
+ return unless match
45
46
 
46
- if match
47
- @letter_name = LetterName.get(match[1].upcase) unless match[1].to_s.empty?
48
- @alteration = Alteration.get(match[2] || "") unless match[2].to_s.empty?
49
- @register = Register.get(match[3]&.to_i) unless match[3].to_s.empty?
50
- end
47
+ @letter_name = parse_letter_name(match[1])
48
+ @alteration = parse_alteration(match[2])
49
+ @register = parse_register(match[3])
50
+ end
51
+
52
+ def parse_letter_name(token)
53
+ LetterName.get(token.upcase) unless token.to_s.empty?
54
+ end
55
+
56
+ def parse_alteration(token)
57
+ Alteration.get(token || "") unless token.to_s.empty?
58
+ end
59
+
60
+ def parse_register(token)
61
+ Register.get(token.to_i) unless token.to_s.empty?
51
62
  end
52
63
  end
@@ -137,16 +137,16 @@ class HeadMusic::Rudiment::Pitch < HeadMusic::Rudiment::Base
137
137
  end
138
138
 
139
139
  def -(other)
140
- case other
141
- when HeadMusic::Analysis::DiatonicInterval
142
- # return a pitch
143
- other.below(self)
144
- when HeadMusic::Rudiment::Pitch
145
- # return an interval
146
- HeadMusic::Rudiment::ChromaticInterval.get(to_i - other.to_i)
140
+ # a diatonic interval below this pitch resolves to another pitch
141
+ return other.below(self) if other.is_a?(HeadMusic::Analysis::DiatonicInterval)
142
+
143
+ difference = to_i - other.to_i
144
+ if other.is_a?(HeadMusic::Rudiment::Pitch)
145
+ # the distance between two pitches is an interval
146
+ HeadMusic::Rudiment::ChromaticInterval.get(difference)
147
147
  else
148
- # assume value represents an interval in semitones and return another pitch
149
- HeadMusic::Rudiment::Pitch.get(to_i - other.to_i)
148
+ # otherwise treat the value as a number of semitones and return another pitch
149
+ HeadMusic::Rudiment::Pitch.get(difference)
150
150
  end
151
151
  end
152
152
 
@@ -164,7 +164,8 @@ class HeadMusic::Rudiment::Pitch < HeadMusic::Rudiment::Base
164
164
  end
165
165
 
166
166
  def natural_steps(num_steps)
167
- HeadMusic::Rudiment::Pitch.get([target_letter_name(num_steps), register + octaves_delta(num_steps)].join)
167
+ step = NaturalStep.new(letter_name, num_steps)
168
+ HeadMusic::Rudiment::Pitch.get([step.target_letter_name, register + step.octaves_delta].join)
168
169
  end
169
170
 
170
171
  def frequency
@@ -202,29 +203,6 @@ class HeadMusic::Rudiment::Pitch < HeadMusic::Rudiment::Base
202
203
  @tuning ||= HeadMusic::Rudiment::Tuning.new
203
204
  end
204
205
 
205
- def octaves_delta(num_steps)
206
- octaves_delta = (num_steps.abs / 7) * ((num_steps >= 0) ? 1 : -1)
207
- if wrapped_down?(num_steps)
208
- octaves_delta -= 1
209
- elsif wrapped_up?(num_steps)
210
- octaves_delta += 1
211
- end
212
- octaves_delta
213
- end
214
-
215
- def wrapped_down?(num_steps)
216
- num_steps.negative? && target_letter_name(num_steps).position > letter_name.position
217
- end
218
-
219
- def wrapped_up?(num_steps)
220
- num_steps.positive? && target_letter_name(num_steps).position < letter_name.position
221
- end
222
-
223
- def target_letter_name(num_steps)
224
- @target_letter_name ||= {}
225
- @target_letter_name[num_steps] ||= letter_name.steps_up(num_steps)
226
- end
227
-
228
206
  def helmholtz_letter_name
229
207
  spelling_str = spelling.to_s
230
208
  return spelling_str.downcase if HeadMusic::Rudiment::Register.get(register).helmholtz_case == :lower
@@ -0,0 +1,61 @@
1
+ # A module for music rudiments
2
+ module HeadMusic::Rudiment; end
3
+
4
+ # Shared behavior for diatonic contexts identified by a tonic spelling and a
5
+ # named qualifier -- a key's quality (major/minor) or a mode's name.
6
+ # Subclasses declare their valid qualifiers, default qualifier, and the
7
+ # message raised for an invalid qualifier, and implement #relative and #parallel.
8
+ class HeadMusic::Rudiment::QualifiedDiatonicContext < HeadMusic::Rudiment::DiatonicContext
9
+ include HeadMusic::Named
10
+
11
+ def self.get(identifier)
12
+ return identifier if identifier.is_a?(self)
13
+
14
+ @cache ||= {}
15
+ tonic_spelling, qualifier = parse_identifier(identifier)
16
+ hash_key = HeadMusic::Utilities::HashKey.for(identifier)
17
+ @cache[hash_key] ||= new(tonic_spelling, qualifier)
18
+ end
19
+
20
+ def self.parse_identifier(identifier)
21
+ tonic_spelling, qualifier = identifier.to_s.strip.split(/\s+/)
22
+ [tonic_spelling, qualifier || default_qualifier.to_s]
23
+ end
24
+
25
+ def self.default_qualifier
26
+ raise NotImplementedError, "Subclasses must implement .default_qualifier"
27
+ end
28
+
29
+ def self.valid_qualifiers
30
+ raise NotImplementedError, "Subclasses must implement .valid_qualifiers"
31
+ end
32
+
33
+ def self.invalid_qualifier_message
34
+ raise NotImplementedError, "Subclasses must implement .invalid_qualifier_message"
35
+ end
36
+
37
+ attr_reader :qualifier
38
+
39
+ def initialize(tonic_spelling, qualifier = nil)
40
+ super(tonic_spelling)
41
+ @qualifier = (qualifier || self.class.default_qualifier).to_s.downcase.to_sym
42
+ raise ArgumentError, self.class.invalid_qualifier_message unless self.class.valid_qualifiers.include?(@qualifier)
43
+ end
44
+
45
+ def scale_type
46
+ @scale_type ||= HeadMusic::Rudiment::ScaleType.get(qualifier)
47
+ end
48
+
49
+ def name
50
+ "#{tonic_spelling} #{qualifier}"
51
+ end
52
+
53
+ def to_s
54
+ name
55
+ end
56
+
57
+ def ==(other)
58
+ other = self.class.get(other)
59
+ tonic_spelling == other.tonic_spelling && qualifier == other.qualifier
60
+ end
61
+ end