head_music 8.2.1 → 9.0.1

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 (99) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +1 -1
  3. data/.github/workflows/release.yml +1 -1
  4. data/CHANGELOG.md +53 -0
  5. data/CLAUDE.md +151 -0
  6. data/Gemfile.lock +25 -25
  7. data/MUSIC_THEORY.md +120 -0
  8. data/Rakefile +2 -2
  9. data/bin/check_instrument_consistency.rb +86 -0
  10. data/check_instrument_consistency.rb +0 -0
  11. data/head_music.gemspec +1 -1
  12. data/lib/head_music/analysis/diatonic_interval/naming.rb +1 -1
  13. data/lib/head_music/analysis/diatonic_interval.rb +50 -27
  14. data/lib/head_music/analysis/interval_consonance.rb +51 -0
  15. data/lib/head_music/content/note.rb +1 -1
  16. data/lib/head_music/content/placement.rb +1 -1
  17. data/lib/head_music/content/position.rb +1 -1
  18. data/lib/head_music/content/staff.rb +1 -1
  19. data/lib/head_music/instruments/instrument.rb +103 -113
  20. data/lib/head_music/instruments/instrument_families.yml +10 -9
  21. data/lib/head_music/instruments/instrument_family.rb +13 -2
  22. data/lib/head_music/instruments/instrument_type.rb +188 -0
  23. data/lib/head_music/instruments/instruments.yml +350 -368
  24. data/lib/head_music/instruments/score_order.rb +139 -0
  25. data/lib/head_music/instruments/score_orders.yml +130 -0
  26. data/lib/head_music/instruments/variant.rb +6 -0
  27. data/lib/head_music/locales/de.yml +6 -0
  28. data/lib/head_music/locales/en.yml +98 -0
  29. data/lib/head_music/locales/es.yml +6 -0
  30. data/lib/head_music/locales/fr.yml +6 -0
  31. data/lib/head_music/locales/it.yml +6 -0
  32. data/lib/head_music/locales/ru.yml +6 -0
  33. data/lib/head_music/rudiment/alteration.rb +23 -8
  34. data/lib/head_music/rudiment/base.rb +9 -0
  35. data/lib/head_music/rudiment/chromatic_interval.rb +3 -6
  36. data/lib/head_music/rudiment/clef.rb +1 -1
  37. data/lib/head_music/rudiment/consonance.rb +37 -4
  38. data/lib/head_music/rudiment/diatonic_context.rb +25 -0
  39. data/lib/head_music/rudiment/key.rb +77 -0
  40. data/lib/head_music/rudiment/key_signature/enharmonic_equivalence.rb +1 -1
  41. data/lib/head_music/rudiment/key_signature.rb +46 -7
  42. data/lib/head_music/rudiment/letter_name.rb +3 -3
  43. data/lib/head_music/rudiment/meter.rb +19 -9
  44. data/lib/head_music/rudiment/mode.rb +92 -0
  45. data/lib/head_music/rudiment/musical_symbol.rb +1 -1
  46. data/lib/head_music/rudiment/note.rb +112 -0
  47. data/lib/head_music/rudiment/pitch/parser.rb +52 -0
  48. data/lib/head_music/rudiment/pitch.rb +5 -6
  49. data/lib/head_music/rudiment/pitch_class.rb +1 -1
  50. data/lib/head_music/rudiment/quality.rb +1 -1
  51. data/lib/head_music/rudiment/reference_pitch.rb +1 -1
  52. data/lib/head_music/rudiment/register.rb +4 -1
  53. data/lib/head_music/rudiment/rest.rb +36 -0
  54. data/lib/head_music/rudiment/rhythmic_element.rb +53 -0
  55. data/lib/head_music/rudiment/rhythmic_unit/parser.rb +86 -0
  56. data/lib/head_music/rudiment/rhythmic_unit.rb +104 -29
  57. data/lib/head_music/rudiment/rhythmic_units.yml +80 -0
  58. data/lib/head_music/rudiment/rhythmic_value/parser.rb +77 -0
  59. data/lib/head_music/{content → rudiment}/rhythmic_value.rb +23 -5
  60. data/lib/head_music/rudiment/scale.rb +4 -5
  61. data/lib/head_music/rudiment/scale_degree.rb +9 -4
  62. data/lib/head_music/rudiment/scale_type.rb +9 -3
  63. data/lib/head_music/rudiment/solmization.rb +1 -1
  64. data/lib/head_music/rudiment/spelling.rb +5 -4
  65. data/lib/head_music/rudiment/tempo.rb +85 -0
  66. data/lib/head_music/rudiment/tonal_context.rb +35 -0
  67. data/lib/head_music/rudiment/tuning/just_intonation.rb +85 -0
  68. data/lib/head_music/rudiment/tuning/meantone.rb +87 -0
  69. data/lib/head_music/rudiment/tuning/pythagorean.rb +91 -0
  70. data/lib/head_music/rudiment/tuning.rb +18 -4
  71. data/lib/head_music/rudiment/unpitched_note.rb +62 -0
  72. data/lib/head_music/style/annotation.rb +4 -4
  73. data/lib/head_music/style/guidelines/notes_same_length.rb +16 -16
  74. data/lib/head_music/style/medieval_tradition.rb +26 -0
  75. data/lib/head_music/style/modern_tradition.rb +34 -0
  76. data/lib/head_music/style/renaissance_tradition.rb +26 -0
  77. data/lib/head_music/style/tradition.rb +21 -0
  78. data/lib/head_music/utilities/hash_key.rb +34 -2
  79. data/lib/head_music/version.rb +1 -1
  80. data/lib/head_music.rb +33 -9
  81. data/user_stories/active/handle-time.md +7 -0
  82. data/user_stories/active/handle-time.rb +177 -0
  83. data/user_stories/done/epic--score-order/PLAN.md +244 -0
  84. data/user_stories/done/epic--score-order/band-score-order.md +38 -0
  85. data/user_stories/done/epic--score-order/chamber-ensemble-score-order.md +33 -0
  86. data/user_stories/done/epic--score-order/orchestral-score-order.md +43 -0
  87. data/user_stories/done/instrument-variant.md +65 -0
  88. data/user_stories/done/superclass-for-note.md +30 -0
  89. data/user_stories/todo/agentic-daw.md +3 -0
  90. data/user_stories/todo/consonance-dissonance-classification.md +57 -0
  91. data/user_stories/todo/dyad-analysis.md +57 -0
  92. data/user_stories/todo/material-and-scores.md +10 -0
  93. data/user_stories/todo/organizing-content.md +72 -0
  94. data/user_stories/todo/percussion_set.md +1 -0
  95. data/user_stories/todo/pitch-class-set-analysis.md +79 -0
  96. data/user_stories/todo/pitch-set-classification.md +72 -0
  97. data/user_stories/todo/sonority-identification.md +67 -0
  98. metadata +51 -6
  99. data/TODO.md +0 -218
@@ -0,0 +1,53 @@
1
+ # A module for music rudiments
2
+ module HeadMusic::Rudiment; end
3
+
4
+ # Abstract base class for rhythmic elements that have a rhythmic value.
5
+ # This includes notes (pitched), rests (silence), and unpitched notes (percussion).
6
+ class HeadMusic::Rudiment::RhythmicElement < HeadMusic::Rudiment::Base
7
+ include Comparable
8
+
9
+ LetterName = HeadMusic::Rudiment::LetterName
10
+ Alteration = HeadMusic::Rudiment::Alteration
11
+ Register = HeadMusic::Rudiment::Register
12
+ RhythmicValue = HeadMusic::Rudiment::RhythmicValue
13
+
14
+ attr_reader :rhythmic_value
15
+
16
+ delegate :unit, :dots, :tied_value, :ticks, to: :rhythmic_value
17
+
18
+ # Make new private to prevent direct instantiation of abstract class
19
+ private_class_method :new
20
+
21
+ def initialize(rhythmic_value)
22
+ @rhythmic_value = rhythmic_value
23
+ end
24
+
25
+ # Create a new instance with a different rhythmic value
26
+ def with_rhythmic_value(new_rhythmic_value)
27
+ self.class.get(new_rhythmic_value)
28
+ end
29
+
30
+ def ==(other)
31
+ return false unless other.is_a?(self.class)
32
+ rhythmic_value == other.rhythmic_value
33
+ end
34
+
35
+ def <=>(other)
36
+ return nil unless other.is_a?(HeadMusic::Rudiment::RhythmicElement)
37
+ rhythmic_value <=> other.rhythmic_value
38
+ end
39
+
40
+ def to_s
41
+ name
42
+ end
43
+
44
+ # Abstract method - must be implemented by subclasses
45
+ def name
46
+ raise NotImplementedError, "Subclasses must implement the name method"
47
+ end
48
+
49
+ # Abstract method - must be implemented by subclasses
50
+ def sounded?
51
+ raise NotImplementedError, "Subclasses must implement the sounded? method"
52
+ end
53
+ end
@@ -0,0 +1,86 @@
1
+ class HeadMusic::Rudiment::RhythmicUnit::Parser
2
+ attr_reader :rhythmic_unit, :identifier
3
+
4
+ RHYTHMIC_UNITS_DATA = HeadMusic::Rudiment::RhythmicUnit::RHYTHMIC_UNITS_DATA
5
+
6
+ TEMPO_SHORTHAND_PATTERN = RHYTHMIC_UNITS_DATA.map { |unit| unit["tempo_shorthand"] }.compact.uniq.sort_by { |s| -s.length }.join("|")
7
+
8
+ def self.parse(identifier)
9
+ return nil if identifier.nil? || identifier.to_s.strip.empty?
10
+ new(identifier).parsed_name
11
+ end
12
+
13
+ def initialize(identifier)
14
+ @identifier = identifier.to_s.strip
15
+ parse
16
+ end
17
+
18
+ def parse
19
+ @unit_data = from_american_name || from_british_name || from_tempo_shorthand || from_duration
20
+ @rhythmic_unit = @unit_data ? HeadMusic::Rudiment::RhythmicUnit.get_by_name(@unit_data["american_name"]) : nil
21
+ end
22
+
23
+ def parsed_name
24
+ # Return the name format that was used in input
25
+ return nil unless @unit_data
26
+
27
+ # Check which type matched
28
+ if from_british_name == @unit_data && @unit_data["british_name"]
29
+ @unit_data["british_name"]
30
+ else
31
+ @unit_data["american_name"]
32
+ end
33
+ end
34
+
35
+ def american_name
36
+ # Always return the American name if a unit was found
37
+ @unit_data&.fetch("american_name", nil)
38
+ end
39
+
40
+ def normalized_identifier
41
+ @normalized_identifier ||= identifier.downcase.strip.gsub(/[^a-z0-9]/, "_").gsub(/_+/, "_").gsub(/^_|_$/, "")
42
+ end
43
+
44
+ def from_american_name
45
+ RHYTHMIC_UNITS_DATA.find do |unit|
46
+ normalize_name(unit["american_name"]) == normalized_identifier
47
+ end
48
+ end
49
+
50
+ def from_british_name
51
+ RHYTHMIC_UNITS_DATA.find do |unit|
52
+ normalize_name(unit["british_name"]) == normalized_identifier
53
+ end
54
+ end
55
+
56
+ def from_tempo_shorthand
57
+ # Handle shorthand with dots (e.g., "q." should match "q")
58
+ clean_identifier = identifier.downcase.strip.gsub(/\.*$/, "")
59
+ RHYTHMIC_UNITS_DATA.find do |unit|
60
+ unit["tempo_shorthand"] && unit["tempo_shorthand"].downcase == clean_identifier
61
+ end
62
+ end
63
+
64
+ def from_duration
65
+ RHYTHMIC_UNITS_DATA.find do |unit|
66
+ # Match decimal duration (e.g., "0.25")
67
+ return unit if unit["duration"].to_s == identifier.strip
68
+
69
+ # Match fraction notation (e.g., "1/4" = 0.25)
70
+ if identifier.match?(%r{^\d+/\d+$})
71
+ numerator, denominator = identifier.split("/").map(&:to_f)
72
+ calculated_duration = numerator / denominator
73
+ return unit if (calculated_duration - unit["duration"]).abs < 0.0001
74
+ end
75
+
76
+ false
77
+ end
78
+ end
79
+
80
+ private
81
+
82
+ def normalize_name(name)
83
+ return nil if name.nil?
84
+ name.to_s.downcase.strip.gsub(/[^a-z0-9]/, "_").gsub(/_+/, "_").gsub(/^_|_$/, "")
85
+ end
86
+ end
@@ -2,32 +2,85 @@
2
2
  module HeadMusic::Rudiment; end
3
3
 
4
4
  # A rhythmic unit is a rudiment of duration consisting of doublings and divisions of a whole note.
5
- class HeadMusic::Rudiment::RhythmicUnit
5
+ class HeadMusic::Rudiment::RhythmicUnit < HeadMusic::Rudiment::Base
6
6
  include HeadMusic::Named
7
+ include Comparable
7
8
 
8
- MULTIPLES = ["whole", "double whole", "longa", "maxima"].freeze
9
- FRACTIONS = [
9
+ RHYTHMIC_UNITS_DATA = YAML.load_file(File.expand_path("rhythmic_units.yml", __dir__)).freeze
10
+
11
+ AMERICAN_MULTIPLES_NAMES = [
12
+ "whole", "double whole", "longa", "maxima"
13
+ ].freeze
14
+
15
+ AMERICAN_DIVISIONS_NAMES = [
10
16
  "whole", "half", "quarter", "eighth", "sixteenth", "thirty-second",
11
17
  "sixty-fourth", "hundred twenty-eighth", "two hundred fifty-sixth"
12
18
  ].freeze
13
19
 
14
- BRITISH_MULTIPLE_NAMES = %w[semibreve breve longa maxima].freeze
15
- BRITISH_DIVISION_NAMES = %w[
20
+ AMERICAN_DURATIONS = (AMERICAN_MULTIPLES_NAMES + AMERICAN_DIVISIONS_NAMES).freeze
21
+
22
+ PATTERN = /#{Regexp.union(AMERICAN_DURATIONS)}/i
23
+
24
+ # British terminology for note values longer than a whole note
25
+ BRITISH_MULTIPLES_NAMES = %w[semibreve breve longa maxima].freeze
26
+
27
+ # British terminology for standard note divisions
28
+ BRITISH_DIVISIONS_NAMES = %w[
16
29
  semibreve minim crotchet quaver semiquaver demisemiquaver
17
30
  hemidemisemiquaver semihemidemisemiquaver demisemihemidemisemiquaver
18
31
  ].freeze
19
32
 
33
+ # Notehead symbols used for different note values
34
+ NOTEHEADS = {
35
+ maxima: 8.0,
36
+ longa: 4.0,
37
+ breve: 2.0,
38
+ open: [0.5, 1.0],
39
+ closed: :default
40
+ }.freeze
41
+
20
42
  def self.for_denominator_value(denominator)
21
- get(FRACTIONS[Math.log2(denominator).to_i])
43
+ return nil unless denominator.is_a?(Numeric) && denominator > 0
44
+ return nil unless (denominator & (denominator - 1)) == 0 # Check if power of 2
45
+
46
+ index = Math.log2(denominator).to_i
47
+ return nil if index >= AMERICAN_DIVISIONS_NAMES.length
48
+
49
+ get(AMERICAN_DIVISIONS_NAMES[index])
22
50
  end
23
51
 
24
52
  attr_reader :numerator, :denominator
25
53
 
26
54
  def self.get(name)
27
- get_by_name(name)
55
+ # Use the parser to handle tempo shorthand and other formats
56
+ parsed_name = HeadMusic::Rudiment::RhythmicUnit::Parser.parse(name)
57
+ return nil unless parsed_name
58
+
59
+ get_by_name(parsed_name)
60
+ end
61
+
62
+ def self.all
63
+ @all ||= (AMERICAN_MULTIPLES_NAMES.reverse + AMERICAN_DIVISIONS_NAMES).uniq.map { |name| get(name) }.compact
64
+ end
65
+
66
+ # Check if a name represents a valid rhythmic unit
67
+ def self.valid_name?(name)
68
+ normalized = normalize_name(name)
69
+ all_normalized_names.include?(normalized)
70
+ end
71
+
72
+ def self.all_normalized_names
73
+ @all_normalized_names ||= (
74
+ AMERICAN_MULTIPLES_NAMES.map { |n| normalize_name(n) } +
75
+ AMERICAN_DIVISIONS_NAMES.map { |n| normalize_name(n) } +
76
+ BRITISH_MULTIPLES_NAMES.map { |n| normalize_name(n) } +
77
+ BRITISH_DIVISIONS_NAMES.map { |n| normalize_name(n) }
78
+ ).uniq
28
79
  end
29
80
 
30
81
  def initialize(canonical_name)
82
+ raise ArgumentError, "Name cannot be nil or empty" if canonical_name.to_s.strip.empty?
83
+
31
84
  self.name = canonical_name
32
85
  @numerator = 2**numerator_exponent
33
86
  @denominator = 2**denominator_exponent
@@ -38,69 +91,91 @@ class HeadMusic::Rudiment::RhythmicUnit
38
91
  end
39
92
 
40
93
  def ticks
41
- HeadMusic::Rudiment::Rhythm::PPQN * 4 * relative_value
94
+ (HeadMusic::Rudiment::Rhythm::PPQN * 4 * relative_value).to_i
42
95
  end
43
96
 
44
97
  def notehead
45
- return :maxima if relative_value == 8
46
- return :longa if relative_value == 4
47
- return :breve if relative_value == 2
48
- return :open if [0.5, 1].include? relative_value
98
+ value = relative_value
99
+ return :maxima if value == NOTEHEADS[:maxima]
100
+ return :longa if value == NOTEHEADS[:longa]
101
+ return :breve if value == NOTEHEADS[:breve]
102
+ return :open if NOTEHEADS[:open].include?(value)
49
103
 
50
104
  :closed
51
105
  end
52
106
 
53
107
  def flags
54
- FRACTIONS.include?(name) ? [FRACTIONS.index(name) - 2, 0].max : 0
108
+ AMERICAN_DIVISIONS_NAMES.include?(name) ? [AMERICAN_DIVISIONS_NAMES.index(name) - 2, 0].max : 0
55
109
  end
56
110
 
57
111
  def stemmed?
58
112
  relative_value < 1
59
113
  end
60
114
 
115
+ # Returns true if this note value is commonly used in modern notation
116
+ def common?
117
+ AMERICAN_DIVISIONS_NAMES[0..6].include?(name) || BRITISH_DIVISIONS_NAMES[0..6].include?(name)
118
+ end
119
+
120
+ def <=>(other)
121
+ return nil unless other.is_a?(self.class)
122
+
123
+ relative_value <=> other.relative_value
124
+ end
125
+
61
126
  def british_name
62
- if multiple?
63
- BRITISH_MULTIPLE_NAMES[MULTIPLES.index(name)]
64
- elsif fraction?
65
- BRITISH_DIVISION_NAMES[FRACTIONS.index(name)]
66
- elsif BRITISH_MULTIPLE_NAMES.include?(name) || BRITISH_DIVISION_NAMES.include?(name)
67
- name
127
+ if has_american_multiple_name?
128
+ index = AMERICAN_MULTIPLES_NAMES.index(name)
129
+ return BRITISH_MULTIPLES_NAMES[index] unless index.nil?
130
+ elsif has_american_division_name?
131
+ index = AMERICAN_DIVISIONS_NAMES.index(name)
132
+ return BRITISH_DIVISIONS_NAMES[index] unless index.nil?
133
+ elsif BRITISH_MULTIPLES_NAMES.include?(name) || BRITISH_DIVISIONS_NAMES.include?(name)
134
+ return name
68
135
  end
136
+
137
+ nil # Return nil if no British equivalent found
69
138
  end
70
139
 
71
140
  private_class_method :new
72
141
 
142
+ def self.normalize_name(name)
143
+ name.to_s.gsub(/\W+/, "_")
144
+ end
145
+
73
146
  private
74
147
 
75
- def multiple?
76
- MULTIPLES.include?(name)
148
+ def has_american_multiple_name?
149
+ AMERICAN_MULTIPLES_NAMES.include?(name)
77
150
  end
78
151
 
79
- def fraction?
80
- FRACTIONS.include?(name)
152
+ def has_american_division_name?
153
+ AMERICAN_DIVISIONS_NAMES.include?(name)
81
154
  end
82
155
 
83
156
  def numerator_exponent
84
- multiples_keys.index(name.gsub(/\W+/, "_")) || british_multiples_keys.index(name.gsub(/\W+/, "_")) || 0
157
+ normalized_name = self.class.normalize_name(name)
158
+ multiples_keys.index(normalized_name) || british_multiples_keys.index(normalized_name) || 0
85
159
  end
86
160
 
87
161
  def multiples_keys
88
- MULTIPLES.map { |multiple| multiple.gsub(/\W+/, "_") }
162
+ AMERICAN_MULTIPLES_NAMES.map { |multiple| self.class.normalize_name(multiple) }
89
163
  end
90
164
 
91
165
  def british_multiples_keys
92
- BRITISH_MULTIPLE_NAMES.map { |multiple| multiple.gsub(/\W+/, "_") }
166
+ BRITISH_MULTIPLES_NAMES.map { |multiple| self.class.normalize_name(multiple) }
93
167
  end
94
168
 
95
169
  def denominator_exponent
96
- fractions_keys.index(name.gsub(/\W+/, "_")) || british_fractions_keys.index(name.gsub(/\W+/, "_")) || 0
170
+ normalized_name = self.class.normalize_name(name)
171
+ fractions_keys.index(normalized_name) || british_fractions_keys.index(normalized_name) || 0
97
172
  end
98
173
 
99
174
  def fractions_keys
100
- FRACTIONS.map { |fraction| fraction.gsub(/\W+/, "_") }
175
+ AMERICAN_DIVISIONS_NAMES.map { |fraction| self.class.normalize_name(fraction) }
101
176
  end
102
177
 
103
178
  def british_fractions_keys
104
- BRITISH_DIVISION_NAMES.map { |fraction| fraction.gsub(/\W+/, "_") }
179
+ BRITISH_DIVISIONS_NAMES.map { |fraction| self.class.normalize_name(fraction) }
105
180
  end
106
181
  end
@@ -0,0 +1,80 @@
1
+ - american_name: maxima
2
+ british_name: maxima
3
+ duration: 8.0
4
+ stem: false
5
+ flags: 0
6
+ notehead: maxima
7
+ - american_name: longa
8
+ british_name: longa
9
+ duration: 4.0
10
+ stem: false
11
+ flags: 0
12
+ notehead: longa
13
+ - american_name: double whole
14
+ british_name: breve
15
+ duration: 2.0
16
+ stem: false
17
+ flags: 0
18
+ notehead: breve
19
+ - american_name: whole
20
+ british_name: semibreve
21
+ duration: 1.0
22
+ stem: false
23
+ flags: 0
24
+ notehead: open
25
+ tempo_shorthand: w
26
+ - american_name: half
27
+ british_name: minim
28
+ duration: 0.5
29
+ stem: true
30
+ flags: 0
31
+ notehead: open
32
+ tempo_shorthand: h
33
+ - american_name: quarter
34
+ british_name: crotchet
35
+ duration: 0.25
36
+ stem: true
37
+ flags: 0
38
+ notehead: closed
39
+ tempo_shorthand: q
40
+ - american_name: eighth
41
+ british_name: quaver
42
+ duration: 0.125
43
+ stem: true
44
+ flags: 1
45
+ notehead: closed
46
+ tempo_shorthand: e
47
+ - american_name: sixteenth
48
+ british_name: semiquaver
49
+ duration: 0.0625
50
+ stem: true
51
+ flags: 2
52
+ notehead: closed
53
+ tempo_shorthand: s
54
+ - american_name: thirty-second
55
+ british_name: demisemiquaver
56
+ duration: 0.03125
57
+ stem: true
58
+ flags: 3
59
+ notehead: closed
60
+ tempo_shorthand: t
61
+ - american_name: sixty-fourth
62
+ british_name: hemidemisemiquaver
63
+ duration: 0.015625
64
+ stem: true
65
+ flags: 4
66
+ notehead: closed
67
+ tempo_shorthand: x
68
+ - american_name: hundred twenty-eighth
69
+ british_name: semihemidemisemiquaver
70
+ duration: 0.0078125
71
+ stem: true
72
+ flags: 5
73
+ notehead: closed
74
+ tempo_shorthand: o
75
+ - american_name: two hundred fifty-sixth
76
+ british_name: demisemihemidemisemiquaver
77
+ duration: 0.00390625
78
+ stem: true
79
+ flags: 6
80
+ notehead: closed
@@ -0,0 +1,77 @@
1
+ class HeadMusic::Rudiment::RhythmicValue::Parser
2
+ attr_reader :identifier, :rhythmic_value
3
+
4
+ RhythmicUnit = HeadMusic::Rudiment::RhythmicUnit
5
+ RhythmicValue = HeadMusic::Rudiment::RhythmicValue
6
+
7
+ PATTERN = /((double|triple)\W?)?(dotted)?.?(#{HeadMusic::Rudiment::RhythmicUnit::PATTERN})/
8
+
9
+ # For stuff like the "q." in "q. = 108"
10
+ SHORTHAND_PATTERN = /\A(#{HeadMusic::Rudiment::RhythmicUnit::Parser::TEMPO_SHORTHAND_PATTERN})(\.*)?\z/i
11
+
12
+ # Parse a rhythmic value identifier and return a RhythmicValue object
13
+ # Returns nil if the identifier cannot be parsed
14
+ def self.parse(identifier)
15
+ return nil if identifier.nil?
16
+ new(identifier).rhythmic_value
17
+ end
18
+
19
+ def initialize(identifier)
20
+ @identifier = identifier.to_s.strip
21
+ parse_components
22
+ end
23
+
24
+ private
25
+
26
+ def parse_components
27
+ # First check for shorthand patterns like "q." to avoid infinite recursion
28
+ match = identifier.match(SHORTHAND_PATTERN)
29
+ if match && match[1]
30
+ unit_name = RhythmicUnit::Parser.parse(match[1].to_s.strip)
31
+ dots = match[2] ? match[2].strip.length : 0
32
+ @rhythmic_value = RhythmicValue.new(unit_name, dots: dots) if unit_name
33
+ return
34
+ end
35
+
36
+ # Try RhythmicUnit::Parser directly first (handles fractions, decimals, British names, etc.)
37
+ parser = RhythmicUnit::Parser.new(identifier)
38
+ if parser.american_name
39
+ @rhythmic_value = RhythmicValue.new(parser.american_name, dots: 0)
40
+ return
41
+ end
42
+
43
+ # Then try to parse with dots extracted for formats like "1/4."
44
+ # Count and strip dots (e.g., "1/4." -> "1/4" with 1 dot)
45
+ # But skip this if identifier looks like a decimal number
46
+ unless identifier.match?(/^\d+\.\d+$/)
47
+ dots = identifier.scan(".").count
48
+ base_identifier = identifier.gsub(".", "").strip
49
+
50
+ # Try RhythmicUnit::Parser on the base identifier
51
+ parser = RhythmicUnit::Parser.new(base_identifier)
52
+ if parser.american_name
53
+ @rhythmic_value = RhythmicValue.new(parser.american_name, dots: dots)
54
+ return
55
+ end
56
+ end
57
+
58
+ # Finally check the word pattern for things like "dotted quarter"
59
+ match = identifier.match(PATTERN)
60
+ if match
61
+ matched_string = match[0].to_s.strip
62
+ # Extract unit and dots from the matched string
63
+ unit_part = matched_string.gsub(/^\W*(double|triple)?\W*(dotted)?\W*/, "")
64
+ unit = RhythmicUnit.get(unit_part)
65
+ if unit
66
+ dots = if matched_string.include?("triple")
67
+ 3
68
+ elsif matched_string.include?("double")
69
+ 2
70
+ else
71
+ matched_string.include?("dotted") ? 1 : 0
72
+ end
73
+ @rhythmic_value = RhythmicValue.new(unit, dots: dots)
74
+ end
75
+ end
76
+ end
77
+ end
@@ -1,8 +1,12 @@
1
1
  # A module for musical content
2
- module HeadMusic::Content; end
2
+ module HeadMusic::Rudiment; end
3
3
 
4
4
  # A rhythmic value is a duration composed of a rhythmic unit, any number of dots, and a tied value.
5
- class HeadMusic::Content::RhythmicValue
5
+ class HeadMusic::Rudiment::RhythmicValue
6
+ include Comparable
7
+
8
+ RhythmicUnit = HeadMusic::Rudiment::RhythmicUnit
9
+
6
10
  attr_reader :unit, :dots, :tied_value
7
11
 
8
12
  delegate :name, to: :unit, prefix: true
@@ -10,13 +14,23 @@ class HeadMusic::Content::RhythmicValue
10
14
 
11
15
  def self.get(identifier)
12
16
  case identifier
13
- when HeadMusic::Content::RhythmicValue
17
+ when HeadMusic::Rudiment::RhythmicValue
14
18
  identifier
15
19
  when HeadMusic::Rudiment::RhythmicUnit
16
20
  new(identifier)
17
21
  when Symbol, String
18
- identifier = identifier.to_s.downcase.strip.gsub(/\W+/, "_")
19
- from_words(identifier)
22
+ original_identifier = identifier.to_s.strip
23
+ # First try the new parser which handles all formats
24
+ parsed = Parser.parse(original_identifier)
25
+ return parsed if parsed
26
+
27
+ # Then try the word-based approach as fallback
28
+ identifier = original_identifier.downcase.gsub(/\W+/, "_")
29
+ begin
30
+ from_words(identifier)
31
+ rescue
32
+ nil
33
+ end
20
34
  end
21
35
  end
22
36
 
@@ -102,4 +116,8 @@ class HeadMusic::Content::RhythmicValue
102
116
  def to_s
103
117
  name.tr("_", "-")
104
118
  end
119
+
120
+ def <=>(other)
121
+ total_value <=> other.total_value
122
+ end
105
123
  end
@@ -2,13 +2,12 @@
2
2
  module HeadMusic::Rudiment; end
3
3
 
4
4
  # A scale contains ordered pitches starting at a tonal center.
5
- class HeadMusic::Rudiment::Scale
5
+ class HeadMusic::Rudiment::Scale < HeadMusic::Rudiment::Base
6
6
  SCALE_REGEX = /^[A-G][#b]?\s+\w+$/
7
7
 
8
8
  def self.get(root_pitch, scale_type = nil)
9
- root_pitch, scale_type = root_pitch.split(/\s+/) if root_pitch.is_a?(String) && scale_type =~ SCALE_REGEX
10
9
  root_pitch = HeadMusic::Rudiment::Pitch.get(root_pitch)
11
- scale_type = HeadMusic::Rudiment::ScaleType.get(scale_type || :major)
10
+ scale_type = HeadMusic::Rudiment::ScaleType.get(scale_type || HeadMusic::Rudiment::ScaleType::DEFAULT)
12
11
  @scales ||= {}
13
12
  hash_key = HeadMusic::Utilities::HashKey.for(
14
13
  [root_pitch, scale_type].join(" ").gsub(/#|♯/, "sharp").gsub(/(\w)[b♭]/, '\\1flat')
@@ -36,7 +35,7 @@ class HeadMusic::Rudiment::Scale
36
35
  end
37
36
 
38
37
  def spellings(direction: :ascending, octaves: 1)
39
- pitches(direction: direction, octaves: octaves).map(&:spelling).map(&:to_s)
38
+ pitches(direction: direction, octaves: octaves).map(&:spelling)
40
39
  end
41
40
 
42
41
  def pitch_names(direction: :ascending, octaves: 1)
@@ -83,7 +82,7 @@ class HeadMusic::Rudiment::Scale
83
82
  end
84
83
 
85
84
  def parent_scale_pitches
86
- HeadMusic::Rudiment::Scale.get(root_pitch, scale_type.parent_name).pitches if scale_type.parent
85
+ HeadMusic::Rudiment::Scale.get(root_pitch, scale_type.parent_name).pitches
87
86
  end
88
87
 
89
88
  def parent_scale_pitch_for(semitones_from_root)
@@ -3,7 +3,7 @@ module HeadMusic::Rudiment; end
3
3
 
4
4
  # A scale degree is a number indicating the ordinality of the spelling in the key.
5
5
  # TODO: Rewrite to accept a tonal_center and a scale type.
6
- class HeadMusic::Rudiment::ScaleDegree
6
+ class HeadMusic::Rudiment::ScaleDegree < HeadMusic::Rudiment::Base
7
7
  include Comparable
8
8
 
9
9
  NAME_FOR_DIATONIC_DEGREE = [nil, "tonic", "supertonic", "mediant", "subdominant", "dominant", "submediant"].freeze
@@ -38,11 +38,16 @@ class HeadMusic::Rudiment::ScaleDegree
38
38
  end
39
39
 
40
40
  def <=>(other)
41
- if other.is_a?(HeadMusic::Rudiment::ScaleDegree)
41
+ case other
42
+ when HeadMusic::Rudiment::ScaleDegree
42
43
  [degree, alteration_semitones] <=> [other.degree, other.alteration_semitones]
43
- else
44
- # TODO: Improve this
44
+ when Numeric
45
+ degree <=> other
46
+ when String
45
47
  to_s <=> other.to_s
48
+ else
49
+ # If we can't meaningfully compare, return nil (Ruby standard)
50
+ nil
46
51
  end
47
52
  end
48
53
 
@@ -2,9 +2,13 @@
2
2
  module HeadMusic::Rudiment; end
3
3
 
4
4
  # A ScaleType represents a particular scale pattern, such as major, lydian, or minor pentatonic.
5
- class HeadMusic::Rudiment::ScaleType
6
- H = 1 # whole step
7
- W = 2 # half step
5
+ class HeadMusic::Rudiment::ScaleType < HeadMusic::Rudiment::Base
6
+ # TODO: load scale types from yaml configuration file
7
+ # Include a system of aliasing, e.g. :natural_minor, :aeolian
8
+ # Include ascending and descending intervals for scales that differ
9
+
10
+ H = 1 # half step
11
+ W = 2 # whole step
8
12
 
9
13
  # Modal
10
14
  I = [W, W, H, W, W, W, H].freeze
@@ -33,6 +37,8 @@ class HeadMusic::Rudiment::ScaleType
33
37
 
34
38
  MINOR_PENTATONIC = [3, 2, 2, 3, 2].freeze
35
39
 
40
+ DEFAULT = :major
41
+
36
42
  def self._modes
37
43
  {}.tap do |modes|
38
44
  MODE_NAMES.each do |roman_numeral, aliases|
@@ -2,7 +2,7 @@
2
2
  module HeadMusic::Rudiment; end
3
3
 
4
4
  # A solmization is the rendering of scale degrees as syllables.
5
- class HeadMusic::Rudiment::Solmization
5
+ class HeadMusic::Rudiment::Solmization < HeadMusic::Rudiment::Base
6
6
  include HeadMusic::Named
7
7
 
8
8
  DEFAULT_SOLMIZATION = "solfège"
@@ -4,8 +4,11 @@ module HeadMusic::Rudiment; end
4
4
  # Represents the spelling of a pitch, such as C# or Db.
5
5
  # Composite of a LetterName and an optional Alteration.
6
6
  # Does not include the octave. See Pitch for that.
7
- class HeadMusic::Rudiment::Spelling
8
- MATCHER = /^\s*([A-G])(#{HeadMusic::Rudiment::Alteration.matcher}?)(-?\d+)?\s*$/i
7
+ class HeadMusic::Rudiment::Spelling < HeadMusic::Rudiment::Base
8
+ LetterName = HeadMusic::Rudiment::LetterName
9
+ Alteration = HeadMusic::Rudiment::Alteration
10
+
11
+ MATCHER = /^\s*(#{LetterName::PATTERN})(#{Alteration::PATTERN})?(-?\d+)?\s*$/i
9
12
 
10
13
  attr_reader :pitch_class, :letter_name, :alteration
11
14
 
@@ -86,8 +89,6 @@ class HeadMusic::Rudiment::Spelling
86
89
  !alteration || alteration.natural?
87
90
  end
88
91
 
89
- private_class_method :new
90
-
91
92
  private
92
93
 
93
94
  def enharmonic_equivalence