head_music 8.3.0 → 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 (85) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +53 -0
  3. data/CLAUDE.md +32 -15
  4. data/Gemfile.lock +1 -1
  5. data/MUSIC_THEORY.md +120 -0
  6. data/lib/head_music/analysis/diatonic_interval.rb +29 -27
  7. data/lib/head_music/analysis/interval_consonance.rb +51 -0
  8. data/lib/head_music/content/note.rb +1 -1
  9. data/lib/head_music/content/placement.rb +1 -1
  10. data/lib/head_music/content/position.rb +1 -1
  11. data/lib/head_music/content/staff.rb +1 -1
  12. data/lib/head_music/instruments/instrument.rb +103 -113
  13. data/lib/head_music/instruments/instrument_family.rb +13 -2
  14. data/lib/head_music/instruments/instrument_type.rb +188 -0
  15. data/lib/head_music/instruments/score_order.rb +139 -0
  16. data/lib/head_music/instruments/score_orders.yml +130 -0
  17. data/lib/head_music/instruments/variant.rb +6 -0
  18. data/lib/head_music/locales/de.yml +6 -0
  19. data/lib/head_music/locales/en.yml +6 -0
  20. data/lib/head_music/locales/es.yml +6 -0
  21. data/lib/head_music/locales/fr.yml +6 -0
  22. data/lib/head_music/locales/it.yml +6 -0
  23. data/lib/head_music/locales/ru.yml +6 -0
  24. data/lib/head_music/rudiment/alteration.rb +23 -8
  25. data/lib/head_music/rudiment/base.rb +9 -0
  26. data/lib/head_music/rudiment/chromatic_interval.rb +3 -6
  27. data/lib/head_music/rudiment/clef.rb +1 -1
  28. data/lib/head_music/rudiment/consonance.rb +37 -4
  29. data/lib/head_music/rudiment/diatonic_context.rb +25 -0
  30. data/lib/head_music/rudiment/key.rb +77 -0
  31. data/lib/head_music/rudiment/key_signature/enharmonic_equivalence.rb +1 -1
  32. data/lib/head_music/rudiment/key_signature.rb +46 -7
  33. data/lib/head_music/rudiment/letter_name.rb +3 -3
  34. data/lib/head_music/rudiment/meter.rb +19 -9
  35. data/lib/head_music/rudiment/mode.rb +92 -0
  36. data/lib/head_music/rudiment/musical_symbol.rb +1 -1
  37. data/lib/head_music/rudiment/note.rb +112 -0
  38. data/lib/head_music/rudiment/pitch/parser.rb +52 -0
  39. data/lib/head_music/rudiment/pitch.rb +5 -6
  40. data/lib/head_music/rudiment/pitch_class.rb +1 -1
  41. data/lib/head_music/rudiment/quality.rb +1 -1
  42. data/lib/head_music/rudiment/reference_pitch.rb +1 -1
  43. data/lib/head_music/rudiment/register.rb +4 -1
  44. data/lib/head_music/rudiment/rest.rb +36 -0
  45. data/lib/head_music/rudiment/rhythmic_element.rb +53 -0
  46. data/lib/head_music/rudiment/rhythmic_unit/parser.rb +86 -0
  47. data/lib/head_music/rudiment/rhythmic_unit.rb +13 -5
  48. data/lib/head_music/rudiment/rhythmic_units.yml +80 -0
  49. data/lib/head_music/rudiment/rhythmic_value/parser.rb +77 -0
  50. data/lib/head_music/{content → rudiment}/rhythmic_value.rb +23 -5
  51. data/lib/head_music/rudiment/scale.rb +4 -5
  52. data/lib/head_music/rudiment/scale_degree.rb +1 -1
  53. data/lib/head_music/rudiment/scale_type.rb +9 -3
  54. data/lib/head_music/rudiment/solmization.rb +1 -1
  55. data/lib/head_music/rudiment/spelling.rb +5 -4
  56. data/lib/head_music/rudiment/tempo.rb +85 -0
  57. data/lib/head_music/rudiment/tonal_context.rb +35 -0
  58. data/lib/head_music/rudiment/tuning.rb +1 -1
  59. data/lib/head_music/rudiment/unpitched_note.rb +62 -0
  60. data/lib/head_music/style/medieval_tradition.rb +26 -0
  61. data/lib/head_music/style/modern_tradition.rb +34 -0
  62. data/lib/head_music/style/renaissance_tradition.rb +26 -0
  63. data/lib/head_music/style/tradition.rb +21 -0
  64. data/lib/head_music/utilities/hash_key.rb +34 -2
  65. data/lib/head_music/version.rb +1 -1
  66. data/lib/head_music.rb +31 -10
  67. data/user_stories/active/handle-time.md +7 -0
  68. data/user_stories/active/handle-time.rb +177 -0
  69. data/user_stories/done/epic--score-order/PLAN.md +244 -0
  70. data/user_stories/done/instrument-variant.md +65 -0
  71. data/user_stories/done/superclass-for-note.md +30 -0
  72. data/user_stories/todo/agentic-daw.md +3 -0
  73. data/user_stories/{backlog → todo}/dyad-analysis.md +2 -10
  74. data/user_stories/todo/material-and-scores.md +10 -0
  75. data/user_stories/todo/organizing-content.md +72 -0
  76. data/user_stories/todo/percussion_set.md +1 -0
  77. data/user_stories/{backlog → todo}/pitch-class-set-analysis.md +40 -0
  78. data/user_stories/{backlog → todo}/pitch-set-classification.md +10 -0
  79. data/user_stories/{backlog → todo}/sonority-identification.md +20 -0
  80. metadata +43 -12
  81. data/TODO.md +0 -109
  82. /data/user_stories/{backlog → done/epic--score-order}/band-score-order.md +0 -0
  83. /data/user_stories/{backlog → done/epic--score-order}/chamber-ensemble-score-order.md +0 -0
  84. /data/user_stories/{backlog → done/epic--score-order}/orchestral-score-order.md +0 -0
  85. /data/user_stories/{backlog → todo}/consonance-dissonance-classification.md +0 -0
@@ -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
@@ -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,21 +2,25 @@
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
7
  include Comparable
8
8
 
9
- # Note values longer than a whole note
9
+ RHYTHMIC_UNITS_DATA = YAML.load_file(File.expand_path("rhythmic_units.yml", __dir__)).freeze
10
+
10
11
  AMERICAN_MULTIPLES_NAMES = [
11
12
  "whole", "double whole", "longa", "maxima"
12
13
  ].freeze
13
14
 
14
- # Note values from whole note down to very short subdivisions
15
15
  AMERICAN_DIVISIONS_NAMES = [
16
16
  "whole", "half", "quarter", "eighth", "sixteenth", "thirty-second",
17
17
  "sixty-fourth", "hundred twenty-eighth", "two hundred fifty-sixth"
18
18
  ].freeze
19
19
 
20
+ AMERICAN_DURATIONS = (AMERICAN_MULTIPLES_NAMES + AMERICAN_DIVISIONS_NAMES).freeze
21
+
22
+ PATTERN = /#{Regexp.union(AMERICAN_DURATIONS)}/i
23
+
20
24
  # British terminology for note values longer than a whole note
21
25
  BRITISH_MULTIPLES_NAMES = %w[semibreve breve longa maxima].freeze
22
26
 
@@ -48,7 +52,11 @@ class HeadMusic::Rudiment::RhythmicUnit
48
52
  attr_reader :numerator, :denominator
49
53
 
50
54
  def self.get(name)
51
- 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)
52
60
  end
53
61
 
54
62
  def self.all
@@ -83,7 +91,7 @@ class HeadMusic::Rudiment::RhythmicUnit
83
91
  end
84
92
 
85
93
  def ticks
86
- HeadMusic::Rudiment::Rhythm::PPQN * 4 * relative_value
94
+ (HeadMusic::Rudiment::Rhythm::PPQN * 4 * relative_value).to_i
87
95
  end
88
96
 
89
97
  def notehead
@@ -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