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,77 @@
1
+ # A module for music rudiments
2
+ module HeadMusic::Rudiment; end
3
+
4
+ # Represents a musical key (major or minor)
5
+ class HeadMusic::Rudiment::Key < HeadMusic::Rudiment::DiatonicContext
6
+ include HeadMusic::Named
7
+
8
+ QUALITIES = %i[major minor].freeze
9
+
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)
19
+ end
20
+
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]
25
+ end
26
+
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)
31
+ end
32
+
33
+ def scale_type
34
+ @scale_type ||= HeadMusic::Rudiment::ScaleType.get(quality)
35
+ end
36
+
37
+ def major?
38
+ quality == :major
39
+ end
40
+
41
+ def minor?
42
+ quality == :minor
43
+ end
44
+
45
+ def relative
46
+ if major?
47
+ # Major to relative minor: down a minor third (3 semitones)
48
+ relative_pitch = tonic_pitch + -3
49
+ self.class.get("#{relative_pitch.spelling} minor")
50
+ else
51
+ # Minor to relative major: up a minor third (3 semitones)
52
+ relative_pitch = tonic_pitch + 3
53
+ self.class.get("#{relative_pitch.spelling} major")
54
+ end
55
+ end
56
+
57
+ def parallel
58
+ if major?
59
+ self.class.get("#{tonic_spelling} minor")
60
+ else
61
+ self.class.get("#{tonic_spelling} major")
62
+ end
63
+ 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
+ end
@@ -1,4 +1,4 @@
1
- # Key signatures are enharmonic when all pitch classes in one are respellings of the pitch classes in the other.
1
+ # Key signatures are enharmonic when they represent the same set of altered pitch classes but with different spellings.
2
2
  class HeadMusic::Rudiment::KeySignature::EnharmonicEquivalence
3
3
  attr_reader :key_signature
4
4
 
@@ -1,9 +1,10 @@
1
1
  # A module for music rudiments
2
2
  module HeadMusic::Rudiment; end
3
3
 
4
- # Represents a key signature.
5
- class HeadMusic::Rudiment::KeySignature
6
- attr_reader :tonic_spelling, :scale_type, :scale
4
+ # Represents a key signature (traditionally associated with a key)
5
+ # This class maintains backward compatibility while delegating to Key/Mode internally
6
+ class HeadMusic::Rudiment::KeySignature < HeadMusic::Rudiment::Base
7
+ attr_reader :tonal_context
7
8
 
8
9
  ORDERED_LETTER_NAMES_OF_SHARPS = %w[F C G D A E B].freeze
9
10
  ORDERED_LETTER_NAMES_OF_FLATS = ORDERED_LETTER_NAMES_OF_SHARPS.reverse.freeze
@@ -16,14 +17,32 @@ class HeadMusic::Rudiment::KeySignature
16
17
  return identifier if identifier.is_a?(HeadMusic::Rudiment::KeySignature)
17
18
 
18
19
  @key_signatures ||= {}
19
- tonic_spelling, scale_type_name = identifier.strip.split(/\s/)
20
- hash_key = HeadMusic::Utilities::HashKey.for(identifier.gsub(/#|♯/, " sharp").gsub(/(\w)[b♭]/, '\\1 flat'))
21
- @key_signatures[hash_key] ||= new(tonic_spelling, scale_type_name)
20
+
21
+ if identifier.is_a?(String)
22
+ tonic_spelling, scale_type_name = identifier.strip.split(/\s/)
23
+ hash_key = HeadMusic::Utilities::HashKey.for(identifier.gsub(/#|♯/, " sharp").gsub(/(\w)[b♭]/, '\\1 flat'))
24
+ @key_signatures[hash_key] ||= new(tonic_spelling, scale_type_name)
25
+ elsif identifier.is_a?(HeadMusic::Rudiment::DiatonicContext)
26
+ identifier.key_signature
27
+ end
28
+ end
29
+
30
+ def self.from_tonal_context(tonal_context)
31
+ new_from_context(tonal_context)
22
32
  end
23
33
 
34
+ def self.from_scale(scale)
35
+ # Find a key or mode that uses this scale
36
+ tonic = scale.root_pitch.spelling
37
+ scale_type = scale.scale_type
38
+ new(tonic, scale_type)
39
+ end
40
+
41
+ attr_reader :tonic_spelling, :scale_type, :scale
42
+
24
43
  delegate :pitch_class, to: :tonic_spelling, prefix: :tonic
25
- delegate :to_s, to: :name
26
44
  delegate :pitches, :pitch_classes, to: :scale
45
+ delegate :to_s, to: :name
27
46
 
28
47
  def initialize(tonic_spelling, scale_type = nil)
29
48
  @tonic_spelling = HeadMusic::Rudiment::Spelling.get(tonic_spelling)
@@ -31,6 +50,26 @@ class HeadMusic::Rudiment::KeySignature
31
50
  @scale_type ||= HeadMusic::Rudiment::ScaleType.default
32
51
  @scale_type = @scale_type.parent || @scale_type
33
52
  @scale = HeadMusic::Rudiment::Scale.get(@tonic_spelling, @scale_type)
53
+
54
+ # Create appropriate tonal context
55
+ scale_type_str = scale_type.to_s.downcase if scale_type
56
+
57
+ @tonal_context = if %w[major minor].include?(scale_type_str)
58
+ HeadMusic::Rudiment::Key.get("#{tonic_spelling} #{scale_type}")
59
+ elsif scale_type
60
+ HeadMusic::Rudiment::Mode.get("#{tonic_spelling} #{scale_type}")
61
+ else
62
+ HeadMusic::Rudiment::Key.get("#{tonic_spelling} major")
63
+ end
64
+ rescue ArgumentError
65
+ # Fall back to creating a major key if mode is not recognized
66
+ @tonal_context = HeadMusic::Rudiment::Key.get("#{tonic_spelling} major")
67
+ end
68
+
69
+ def self.new_from_context(context)
70
+ instance = allocate
71
+ instance.instance_variable_set(:@tonal_context, context)
72
+ instance
34
73
  end
35
74
 
36
75
  def spellings
@@ -2,8 +2,10 @@
2
2
  module HeadMusic::Rudiment; end
3
3
 
4
4
  # Music has seven lette names that are used to identify pitches and pitch classes.
5
- class HeadMusic::Rudiment::LetterName
5
+ class HeadMusic::Rudiment::LetterName < HeadMusic::Rudiment::Base
6
6
  NAMES = %w[C D E F G A B].freeze
7
+ PATTERN = /[A-Ga-g]/
8
+ MATCHER = /^#{PATTERN}$/
7
9
 
8
10
  NATURAL_PITCH_CLASS_NUMBERS = {
9
11
  "C" => 0,
@@ -96,6 +98,4 @@ class HeadMusic::Rudiment::LetterName
96
98
  series
97
99
  end
98
100
  end
99
-
100
- private_class_method :new
101
101
  end
@@ -2,7 +2,7 @@
2
2
  module HeadMusic::Rudiment; end
3
3
 
4
4
  # Meter is the rhythmic size of a measure, such as 4/4 or 6/8
5
- class HeadMusic::Rudiment::Meter
5
+ class HeadMusic::Rudiment::Meter < HeadMusic::Rudiment::Base
6
6
  attr_reader :top_number, :bottom_number
7
7
 
8
8
  NAMED = {
@@ -15,7 +15,7 @@ class HeadMusic::Rudiment::Meter
15
15
  hash_key = HeadMusic::Utilities::HashKey.for(identifier)
16
16
  time_signature_string = NAMED[hash_key] || identifier
17
17
  @meters ||= {}
18
- @meters[hash_key] ||= new(*time_signature_string.split("/").map(&:to_i))
18
+ @meters[hash_key] ||= new(*time_signature_string.split("/"))
19
19
  end
20
20
 
21
21
  def self.default
@@ -31,8 +31,8 @@ class HeadMusic::Rudiment::Meter
31
31
  end
32
32
 
33
33
  def initialize(top_number, bottom_number)
34
- @top_number = top_number
35
- @bottom_number = bottom_number
34
+ @top_number = top_number.to_i
35
+ @bottom_number = bottom_number.to_i
36
36
  end
37
37
 
38
38
  def simple?
@@ -63,6 +63,10 @@ class HeadMusic::Rudiment::Meter
63
63
  top_number
64
64
  end
65
65
 
66
+ def counts_per_quarter_note
67
+ 0.25 / count_unit.relative_value
68
+ end
69
+
66
70
  def beat_strength(count, tick: 0)
67
71
  return 100 if downbeat?(count, tick)
68
72
  return 80 if strong_beat?(count, tick)
@@ -76,19 +80,25 @@ class HeadMusic::Rudiment::Meter
76
80
  @ticks_per_count ||= count_unit.ticks
77
81
  end
78
82
 
83
+ # The rhythmic unit for the count (bottom number).
84
+ # This unit is also used as "beats" in a sequencer context
85
+ # For example, "1:3:000"
79
86
  def count_unit
80
87
  HeadMusic::Rudiment::RhythmicUnit.for_denominator_value(bottom_number)
81
88
  end
82
89
 
83
- def beat_unit
84
- @beat_unit ||=
90
+ def beat_value
91
+ @beat_value ||=
85
92
  if compound?
86
- HeadMusic::Content::RhythmicValue.new(HeadMusic::Rudiment::RhythmicUnit.for_denominator_value(bottom_number / 2), dots: 1)
93
+ HeadMusic::Rudiment::RhythmicValue.new(HeadMusic::Rudiment::RhythmicUnit.for_denominator_value(bottom_number / 2), dots: 1)
87
94
  else
88
- HeadMusic::Content::RhythmicValue.new(count_unit)
95
+ HeadMusic::Rudiment::RhythmicValue.new(count_unit)
89
96
  end
90
97
  end
91
98
 
99
+ # for consistency with conversational usage
100
+ alias_method :beat_unit, :beat_value
101
+
92
102
  def to_s
93
103
  [top_number, bottom_number].join("/")
94
104
  end
@@ -133,6 +143,6 @@ class HeadMusic::Rudiment::Meter
133
143
  end
134
144
 
135
145
  def beat?(tick)
136
- tick.zero?
146
+ tick.to_i.zero?
137
147
  end
138
148
  end
@@ -0,0 +1,92 @@
1
+ # A module for music rudiments
2
+ module HeadMusic::Rudiment; end
3
+
4
+ # Represents a musical mode (church modes)
5
+ class HeadMusic::Rudiment::Mode < HeadMusic::Rudiment::DiatonicContext
6
+ include HeadMusic::Named
7
+
8
+ MODES = %i[ionian dorian phrygian lydian mixolydian aeolian locrian].freeze
9
+
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)
19
+ end
20
+
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]
27
+ end
28
+
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)
33
+ end
34
+
35
+ def scale_type
36
+ @scale_type ||= HeadMusic::Rudiment::ScaleType.get(mode_name)
37
+ end
38
+
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
56
+
57
+ HeadMusic::Rudiment::Key.get("#{relative_pitch.spelling} major")
58
+ end
59
+
60
+ def relative
61
+ relative_major
62
+ end
63
+
64
+ 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
79
+
80
+ def name
81
+ "#{tonic_spelling} #{mode_name}"
82
+ end
83
+
84
+ def to_s
85
+ name
86
+ end
87
+
88
+ def ==(other)
89
+ other = self.class.get(other)
90
+ tonic_spelling == other.tonic_spelling && mode_name == other.mode_name
91
+ end
92
+ end
@@ -2,7 +2,7 @@
2
2
  module HeadMusic::Rudiment; end
3
3
 
4
4
  # A symbol is a mark or sign that signifies a particular rudiment of music
5
- class HeadMusic::Rudiment::MusicalSymbol
5
+ class HeadMusic::Rudiment::MusicalSymbol < HeadMusic::Rudiment::Base
6
6
  attr_reader :ascii, :unicode, :html_entity
7
7
 
8
8
  def initialize(ascii: nil, unicode: nil, html_entity: nil)
@@ -0,0 +1,112 @@
1
+ # A module for music rudiments
2
+ module HeadMusic::Rudiment; end
3
+
4
+ # A Note is a fundamental musical element consisting of a pitch and a duration.
5
+ # This is the rudiment version, representing the abstract concept of a note
6
+ # independent of its placement in a composition.
7
+ #
8
+ # For notes placed within a composition context, see HeadMusic::Content::Note
9
+ class HeadMusic::Rudiment::Note < HeadMusic::Rudiment::RhythmicElement
10
+ include HeadMusic::Named
11
+
12
+ attr_reader :pitch
13
+
14
+ delegate :spelling, :register, :letter_name, :alteration, to: :pitch
15
+ delegate :sharp?, :flat?, :natural?, to: :pitch
16
+ delegate :pitch_class, :midi_note_number, :frequency, to: :pitch
17
+
18
+ # Regex pattern for parsing note strings like "C#4 quarter" or "Eb3 dotted half"
19
+ # Extract the core pattern from Spelling::MATCHER without anchors
20
+ PITCH_PATTERN = /([A-G])(#{HeadMusic::Rudiment::Alteration::PATTERN.source}?)(-?\d+)?/i
21
+ MATCHER = /^\s*(#{PITCH_PATTERN.source})\s+(.+)$/i
22
+
23
+ def self.get(pitch, rhythmic_value = nil)
24
+ return pitch if pitch.is_a?(HeadMusic::Rudiment::Note)
25
+
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)
35
+
36
+ return fetch_or_create(pitch_obj, rhythmic_value_obj) if pitch_obj && rhythmic_value_obj
37
+ end
38
+
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
+
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
49
+ end
50
+
51
+ def self.fetch_or_create(pitch, rhythmic_value)
52
+ @notes ||= {}
53
+ hash_key = [pitch.to_s, rhythmic_value.to_s].join("_")
54
+ @notes[hash_key] ||= new(pitch, rhythmic_value)
55
+ end
56
+
57
+ def initialize(pitch, rhythmic_value)
58
+ super(rhythmic_value)
59
+ @pitch = pitch
60
+ end
61
+
62
+ # Make new public for this concrete class
63
+ public_class_method :new
64
+
65
+ def name
66
+ "#{pitch} #{rhythmic_value}"
67
+ end
68
+
69
+ def to_s
70
+ name
71
+ end
72
+
73
+ def ==(other)
74
+ other = HeadMusic::Rudiment::Note.get(other)
75
+ super && pitch == other.pitch
76
+ end
77
+
78
+ def <=>(other)
79
+ return nil unless other.is_a?(HeadMusic::Rudiment::RhythmicElement)
80
+ return super unless other.is_a?(self.class)
81
+
82
+ [rhythmic_value, pitch] <=> [other.rhythmic_value, other.pitch]
83
+ end
84
+
85
+ # Transpose the note up by an interval or semitones
86
+ def +(other)
87
+ new_pitch = pitch + other
88
+ self.class.get(new_pitch, rhythmic_value)
89
+ end
90
+
91
+ # Transpose the note down by an interval or semitones
92
+ def -(other)
93
+ new_pitch = pitch - other
94
+ self.class.get(new_pitch, rhythmic_value)
95
+ end
96
+
97
+ # Override to maintain pitch when changing rhythmic value
98
+ def with_rhythmic_value(new_rhythmic_value)
99
+ self.class.get(pitch, new_rhythmic_value)
100
+ end
101
+
102
+ # Change the pitch while keeping the same rhythmic value
103
+ def with_pitch(new_pitch)
104
+ self.class.get(new_pitch, rhythmic_value)
105
+ end
106
+
107
+ def sounded?
108
+ true
109
+ end
110
+
111
+ private_class_method :fetch_or_create
112
+ end
@@ -0,0 +1,52 @@
1
+ class HeadMusic::Rudiment::Pitch::Parser
2
+ attr_reader :identifier, :letter_name, :alteration, :register
3
+
4
+ LetterName = HeadMusic::Rudiment::LetterName
5
+ Alteration = HeadMusic::Rudiment::Alteration
6
+ Spelling = HeadMusic::Rudiment::Spelling
7
+ Register = HeadMusic::Rudiment::Register
8
+ Pitch = HeadMusic::Rudiment::Pitch
9
+
10
+ # Pattern that handles negative registers (e.g., -1) and positive registers
11
+ # Anchored to match complete pitch strings only
12
+ PATTERN = /\A(#{LetterName::PATTERN})?(#{Alteration::PATTERN.source})?(-?\d+)?\z/
13
+
14
+ # Parse a pitch identifier and return a Pitch object
15
+ # Returns nil if the identifier cannot be parsed into a valid pitch
16
+ def self.parse(identifier)
17
+ return nil if identifier.nil?
18
+ new(identifier).pitch
19
+ end
20
+
21
+ def initialize(identifier)
22
+ @identifier = identifier.to_s.strip
23
+ parse_components
24
+ end
25
+
26
+ def pitch
27
+ return unless spelling
28
+ # Default to register 4 if not provided (matching old behavior)
29
+ # Convert Register object to integer for fetch_or_create
30
+ reg = register ? register.to_i : Register::DEFAULT
31
+
32
+ @pitch ||= Pitch.fetch_or_create(spelling, reg)
33
+ end
34
+
35
+ def spelling
36
+ return unless letter_name
37
+
38
+ @spelling ||= Spelling.new(letter_name, alteration)
39
+ end
40
+
41
+ private
42
+
43
+ def parse_components
44
+ match = identifier.match(PATTERN)
45
+
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
51
+ end
52
+ end
@@ -1,8 +1,7 @@
1
- # A module for music rudiments
2
1
  module HeadMusic::Rudiment; end
3
2
 
4
3
  # A pitch is a named frequency represented by a spelling and a register.
5
- class HeadMusic::Rudiment::Pitch
4
+ class HeadMusic::Rudiment::Pitch < HeadMusic::Rudiment::Base
6
5
  include Comparable
7
6
 
8
7
  attr_reader :spelling, :register
@@ -29,6 +28,8 @@ class HeadMusic::Rudiment::Pitch
29
28
  # - a name string, such as 'Ab4'
30
29
  # - a number corresponding to the midi note number
31
30
  def self.get(value)
31
+ return value if value.is_a?(HeadMusic::Rudiment::Pitch)
32
+
32
33
  from_pitch_class(value) ||
33
34
  from_name(value) ||
34
35
  from_number(value)
@@ -51,7 +52,7 @@ class HeadMusic::Rudiment::Pitch
51
52
  def self.from_name(name)
52
53
  return nil unless name == name.to_s
53
54
 
54
- fetch_or_create(HeadMusic::Rudiment::Spelling.get(name), HeadMusic::Rudiment::Register.get(name).to_i)
55
+ Parser.parse(name)
55
56
  end
56
57
 
57
58
  def self.from_number(number)
@@ -114,7 +115,7 @@ class HeadMusic::Rudiment::Pitch
114
115
  end
115
116
 
116
117
  def natural
117
- HeadMusic::Rudiment::Pitch.get(to_s.gsub(HeadMusic::Rudiment::Alteration.matcher, ""))
118
+ HeadMusic::Rudiment::Pitch.get(to_s.gsub(HeadMusic::Rudiment::Alteration::PATTERN, ""))
118
119
  end
119
120
 
120
121
  def +(other)
@@ -167,8 +168,6 @@ class HeadMusic::Rudiment::Pitch
167
168
  letter_name_steps_to(other) + 7 * octave_changes_to(other)
168
169
  end
169
170
 
170
- private_class_method :new
171
-
172
171
  private
173
172
 
174
173
  def octave_changes_to(other)
@@ -2,7 +2,7 @@
2
2
  module HeadMusic::Rudiment; end
3
3
 
4
4
  # A pitch class is a set of pitches separated by octaves.
5
- class HeadMusic::Rudiment::PitchClass
5
+ class HeadMusic::Rudiment::PitchClass < HeadMusic::Rudiment::Base
6
6
  include Comparable
7
7
 
8
8
  attr_reader :number, :spelling
@@ -2,7 +2,7 @@
2
2
  module HeadMusic::Rudiment; end
3
3
 
4
4
  # A quality is a categorization of an interval.
5
- class HeadMusic::Rudiment::Quality
5
+ class HeadMusic::Rudiment::Quality < HeadMusic::Rudiment::Base
6
6
  SHORTHAND = {
7
7
  perfect: "P",
8
8
  major: "M",
@@ -3,7 +3,7 @@ module HeadMusic::Rudiment; end
3
3
 
4
4
  # A reference pitch has a pitch and a frequency
5
5
  # With no arguments, it assumes that A4 = 440.0 Hz
6
- class HeadMusic::Rudiment::ReferencePitch
6
+ class HeadMusic::Rudiment::ReferencePitch < HeadMusic::Rudiment::Base
7
7
  include HeadMusic::Named
8
8
 
9
9
  DEFAULT_PITCH_NAME = "A4"
@@ -6,9 +6,12 @@ module HeadMusic::Rudiment; end
6
6
  # A pitch is a spelling plus a register. For example, C4 is middle C and C5 is the C one octave higher.
7
7
  # The number changes between the letter names B and C regardless of sharps and flats,
8
8
  # so as an extreme example, Cb5 is actually a semitone below B#4.
9
- class HeadMusic::Rudiment::Register
9
+ class HeadMusic::Rudiment::Register < HeadMusic::Rudiment::Base
10
10
  include Comparable
11
11
 
12
+ AUDIBLE_REGISTERS = (0..10).map.freeze
13
+ PATTERN = Regexp.union(AUDIBLE_REGISTERS.map(&:to_s))
14
+
12
15
  DEFAULT = 4
13
16
 
14
17
  def self.get(identifier)
@@ -0,0 +1,36 @@
1
+ # A module for music rudiments
2
+ module HeadMusic::Rudiment; end
3
+
4
+ # A Rest represents a period of silence with a specific rhythmic value.
5
+ # It inherits from RhythmicElement and has a duration but no pitch.
6
+ class HeadMusic::Rudiment::Rest < HeadMusic::Rudiment::RhythmicElement
7
+ include HeadMusic::Named
8
+
9
+ # Make new public for this concrete class
10
+ public_class_method :new
11
+
12
+ def self.get(rhythmic_value)
13
+ return rhythmic_value if rhythmic_value.is_a?(HeadMusic::Rudiment::Rest)
14
+
15
+ rhythmic_value = HeadMusic::Rudiment::RhythmicValue.get(rhythmic_value)
16
+ return nil unless rhythmic_value
17
+
18
+ fetch_or_create(rhythmic_value)
19
+ end
20
+
21
+ def self.fetch_or_create(rhythmic_value)
22
+ @rests ||= {}
23
+ hash_key = rhythmic_value.to_s
24
+ @rests[hash_key] ||= new(rhythmic_value)
25
+ end
26
+
27
+ def name
28
+ "#{rhythmic_value} rest"
29
+ end
30
+
31
+ def sounded?
32
+ false
33
+ end
34
+
35
+ private_class_method :fetch_or_create
36
+ end