head_music 8.3.0 → 11.0.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 (138) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +9 -3
  3. data/CHANGELOG.md +71 -0
  4. data/CLAUDE.md +62 -25
  5. data/Gemfile +7 -1
  6. data/Gemfile.lock +91 -3
  7. data/MUSIC_THEORY.md +120 -0
  8. data/README.md +18 -0
  9. data/Rakefile +7 -2
  10. data/head_music.gemspec +1 -1
  11. data/lib/head_music/analysis/diatonic_interval.rb +29 -27
  12. data/lib/head_music/analysis/dyad.rb +229 -0
  13. data/lib/head_music/analysis/interval_consonance.rb +51 -0
  14. data/lib/head_music/analysis/melodic_interval.rb +1 -1
  15. data/lib/head_music/analysis/pitch_class_set.rb +111 -14
  16. data/lib/head_music/analysis/{pitch_set.rb → pitch_collection.rb} +11 -5
  17. data/lib/head_music/analysis/sonority.rb +50 -12
  18. data/lib/head_music/content/note.rb +1 -1
  19. data/lib/head_music/content/placement.rb +1 -1
  20. data/lib/head_music/content/position.rb +1 -1
  21. data/lib/head_music/content/voice.rb +1 -1
  22. data/lib/head_music/instruments/alternate_tuning.rb +102 -0
  23. data/lib/head_music/instruments/alternate_tunings.yml +78 -0
  24. data/lib/head_music/instruments/instrument.rb +231 -72
  25. data/lib/head_music/instruments/instrument_configuration.rb +66 -0
  26. data/lib/head_music/instruments/instrument_configuration_option.rb +38 -0
  27. data/lib/head_music/instruments/instrument_configurations.yml +288 -0
  28. data/lib/head_music/instruments/instrument_families.yml +77 -0
  29. data/lib/head_music/instruments/instrument_family.rb +15 -5
  30. data/lib/head_music/instruments/instruments.yml +795 -965
  31. data/lib/head_music/instruments/playing_technique.rb +75 -0
  32. data/lib/head_music/instruments/playing_techniques.yml +826 -0
  33. data/lib/head_music/instruments/score_order.rb +136 -0
  34. data/lib/head_music/instruments/score_orders.yml +130 -0
  35. data/lib/head_music/instruments/staff.rb +61 -1
  36. data/lib/head_music/instruments/staff_scheme.rb +6 -4
  37. data/lib/head_music/instruments/stringing.rb +115 -0
  38. data/lib/head_music/instruments/stringing_course.rb +58 -0
  39. data/lib/head_music/instruments/stringings.yml +168 -0
  40. data/lib/head_music/instruments/variant.rb +6 -1
  41. data/lib/head_music/locales/de.yml +29 -0
  42. data/lib/head_music/locales/en.yml +106 -0
  43. data/lib/head_music/locales/es.yml +29 -0
  44. data/lib/head_music/locales/fr.yml +29 -0
  45. data/lib/head_music/locales/it.yml +29 -0
  46. data/lib/head_music/locales/ru.yml +29 -0
  47. data/lib/head_music/{rudiment → notation}/musical_symbol.rb +3 -3
  48. data/lib/head_music/notation/staff_mapping.rb +70 -0
  49. data/lib/head_music/notation/staff_position.rb +62 -0
  50. data/lib/head_music/notation.rb +7 -0
  51. data/lib/head_music/rudiment/alteration.rb +34 -49
  52. data/lib/head_music/rudiment/alterations.yml +32 -0
  53. data/lib/head_music/rudiment/base.rb +9 -0
  54. data/lib/head_music/rudiment/chromatic_interval.rb +4 -7
  55. data/lib/head_music/rudiment/clef.rb +2 -2
  56. data/lib/head_music/rudiment/consonance.rb +39 -5
  57. data/lib/head_music/rudiment/diatonic_context.rb +25 -0
  58. data/lib/head_music/rudiment/key.rb +77 -0
  59. data/lib/head_music/rudiment/key_signature/enharmonic_equivalence.rb +1 -1
  60. data/lib/head_music/rudiment/key_signature.rb +21 -8
  61. data/lib/head_music/rudiment/letter_name.rb +3 -3
  62. data/lib/head_music/rudiment/meter.rb +19 -9
  63. data/lib/head_music/rudiment/mode.rb +92 -0
  64. data/lib/head_music/rudiment/note.rb +112 -0
  65. data/lib/head_music/rudiment/pitch/parser.rb +52 -0
  66. data/lib/head_music/rudiment/pitch.rb +5 -6
  67. data/lib/head_music/rudiment/pitch_class.rb +1 -1
  68. data/lib/head_music/rudiment/quality.rb +1 -1
  69. data/lib/head_music/rudiment/reference_pitch.rb +1 -1
  70. data/lib/head_music/rudiment/register.rb +4 -1
  71. data/lib/head_music/rudiment/rest.rb +36 -0
  72. data/lib/head_music/rudiment/rhythmic_element.rb +53 -0
  73. data/lib/head_music/rudiment/rhythmic_unit/parser.rb +86 -0
  74. data/lib/head_music/rudiment/rhythmic_unit.rb +13 -5
  75. data/lib/head_music/rudiment/rhythmic_units.yml +80 -0
  76. data/lib/head_music/rudiment/rhythmic_value/parser.rb +77 -0
  77. data/lib/head_music/{content → rudiment}/rhythmic_value.rb +23 -5
  78. data/lib/head_music/rudiment/scale.rb +4 -5
  79. data/lib/head_music/rudiment/scale_degree.rb +1 -1
  80. data/lib/head_music/rudiment/scale_type.rb +9 -3
  81. data/lib/head_music/rudiment/solmization.rb +1 -1
  82. data/lib/head_music/rudiment/spelling.rb +8 -4
  83. data/lib/head_music/rudiment/tempo.rb +85 -0
  84. data/lib/head_music/rudiment/tonal_context.rb +35 -0
  85. data/lib/head_music/rudiment/tuning/just_intonation.rb +0 -39
  86. data/lib/head_music/rudiment/tuning/meantone.rb +0 -39
  87. data/lib/head_music/rudiment/tuning/pythagorean.rb +0 -39
  88. data/lib/head_music/rudiment/tuning.rb +21 -1
  89. data/lib/head_music/rudiment/unpitched_note.rb +62 -0
  90. data/lib/head_music/style/guidelines/consonant_climax.rb +2 -2
  91. data/lib/head_music/style/medieval_tradition.rb +26 -0
  92. data/lib/head_music/style/modern_tradition.rb +31 -0
  93. data/lib/head_music/style/renaissance_tradition.rb +26 -0
  94. data/lib/head_music/style/tradition.rb +21 -0
  95. data/lib/head_music/time/clock_position.rb +84 -0
  96. data/lib/head_music/time/conductor.rb +264 -0
  97. data/lib/head_music/time/meter_event.rb +37 -0
  98. data/lib/head_music/time/meter_map.rb +173 -0
  99. data/lib/head_music/time/musical_position.rb +188 -0
  100. data/lib/head_music/time/smpte_timecode.rb +164 -0
  101. data/lib/head_music/time/tempo_event.rb +40 -0
  102. data/lib/head_music/time/tempo_map.rb +187 -0
  103. data/lib/head_music/time.rb +32 -0
  104. data/lib/head_music/utilities/case.rb +27 -0
  105. data/lib/head_music/utilities/hash_key.rb +34 -2
  106. data/lib/head_music/version.rb +1 -1
  107. data/lib/head_music.rb +71 -22
  108. data/user_stories/active/string-pitches.md +41 -0
  109. data/user_stories/backlog/notation-style.md +183 -0
  110. data/user_stories/backlog/organizing-content.md +80 -0
  111. data/user_stories/done/consonance-dissonance-classification.md +117 -0
  112. data/user_stories/{backlog → done}/dyad-analysis.md +6 -16
  113. data/user_stories/done/epic--score-order/PLAN.md +244 -0
  114. data/user_stories/done/expand-playing-techniques.md +38 -0
  115. data/user_stories/done/handle-time.md +7 -0
  116. data/user_stories/done/handle-time.rb +163 -0
  117. data/user_stories/done/instrument-architecture.md +238 -0
  118. data/user_stories/done/instrument-variant.md +65 -0
  119. data/user_stories/done/move-musical-symbol-to-notation.md +161 -0
  120. data/user_stories/done/move-staff-mapping-to-notation.md +158 -0
  121. data/user_stories/done/move-staff-position-to-notation.md +141 -0
  122. data/user_stories/done/notation-module-foundation.md +102 -0
  123. data/user_stories/done/percussion_set.md +260 -0
  124. data/user_stories/done/sonority-identification.md +37 -0
  125. data/user_stories/done/superclass-for-note.md +30 -0
  126. data/user_stories/epics/notation-module.md +135 -0
  127. data/user_stories/visioning/agentic-daw.md +2 -0
  128. metadata +84 -18
  129. data/TODO.md +0 -109
  130. data/check_instrument_consistency.rb +0 -0
  131. data/test_translations.rb +0 -15
  132. data/user_stories/backlog/consonance-dissonance-classification.md +0 -57
  133. data/user_stories/backlog/pitch-set-classification.md +0 -62
  134. data/user_stories/backlog/sonority-identification.md +0 -47
  135. /data/user_stories/{backlog → done/epic--score-order}/band-score-order.md +0 -0
  136. /data/user_stories/{backlog → done/epic--score-order}/chamber-ensemble-score-order.md +0 -0
  137. /data/user_stories/{backlog → done/epic--score-order}/orchestral-score-order.md +0 -0
  138. /data/user_stories/{backlog → done}/pitch-class-set-analysis.md +0 -0
@@ -1,86 +1,64 @@
1
- require "head_music/rudiment/musical_symbol"
2
-
3
1
  # A module for music rudiments
4
2
  module HeadMusic::Rudiment; end
5
3
 
6
4
  # An Alteration is a symbol that modifies pitch, such as a sharp, flat, or natural.
7
5
  # In French, sharps and flats in the key signature are called "altérations".
8
- class HeadMusic::Rudiment::Alteration
6
+ class HeadMusic::Rudiment::Alteration < HeadMusic::Rudiment::Base
9
7
  include Comparable
8
+ include HeadMusic::Named
10
9
 
11
- attr_reader :identifier, :cents, :musical_symbols
10
+ attr_reader :identifier, :semitones, :musical_symbols
12
11
 
13
12
  delegate :ascii, :unicode, :html_entity, to: :musical_symbol
14
13
 
15
- ALTERATION_RECORDS = [
16
- {
17
- identifier: :sharp, cents: 100,
18
- symbols: [{ascii: "#", unicode: "♯", html_entity: "&#9839;"}]
19
- },
20
- {
21
- identifier: :flat, cents: -100,
22
- symbols: [{ascii: "b", unicode: "♭", html_entity: "&#9837;"}]
23
- },
24
- {
25
- identifier: :natural, cents: 0,
26
- symbols: [{ascii: "", unicode: "♮", html_entity: "&#9838;"}]
27
- },
28
- {
29
- identifier: :double_sharp, cents: 200,
30
- symbols: [{ascii: "x", unicode: "𝄪", html_entity: "&#119082;"}]
31
- },
32
- {
33
- identifier: :double_flat, cents: -200,
34
- symbols: [{ascii: "bb", unicode: "𝄫", html_entity: "&#119083;"}]
35
- }
36
- ].freeze
37
-
38
- ALTERATION_IDENTIFIERS = ALTERATION_RECORDS.map { |attributes| attributes[:identifier] }.freeze
14
+ ALTERATION_RECORDS =
15
+ YAML.load_file(File.expand_path("alterations.yml", __dir__), symbolize_names: true)[:alterations].freeze
16
+
17
+ ALTERATION_IDENTIFIERS = ALTERATION_RECORDS.keys.freeze
18
+ SYMBOLS = ALTERATION_RECORDS.map { |key, attributes| attributes[:symbols].map { |symbol| [symbol[:unicode], symbol[:ascii]] } }.flatten.reject { |s| s.nil? || s.empty? }.freeze
19
+ PATTERN = Regexp.union(SYMBOLS)
20
+ MATCHER = PATTERN
39
21
 
40
22
  def self.all
41
- ALTERATION_RECORDS.map { |attributes| new(attributes) }
23
+ @all ||= ALTERATION_RECORDS.map { |key, attributes| new(key, attributes) }
42
24
  end
43
25
 
44
26
  def self.symbols
45
27
  @symbols ||= all.map { |alteration| [alteration.ascii, alteration.unicode] }.flatten.reject { |s| s.nil? || s.empty? }
46
28
  end
47
29
 
48
- def self.matcher
49
- @matcher ||= Regexp.new symbols.join("|")
50
- end
51
-
52
30
  def self.symbol?(candidate)
53
- candidate =~ /^(#{matcher})$/
31
+ SYMBOLS.include?(candidate)
54
32
  end
55
33
 
56
34
  def self.get(identifier)
57
35
  return identifier if identifier.is_a?(HeadMusic::Rudiment::Alteration)
58
36
 
59
37
  all.detect do |alteration|
60
- alteration.representions.include?(identifier)
38
+ alteration.representations.include?(identifier)
61
39
  end
62
40
  end
63
41
 
64
42
  def self.by(key, value)
65
43
  all.detect do |alteration|
66
- alteration.send(key) == value if %i[cents semitones].include?(key.to_sym)
44
+ alteration.send(key) == value if %i[semitones].include?(key.to_sym)
67
45
  end
68
46
  end
69
47
 
70
- def name
71
- identifier.to_s.tr("_", " ")
48
+ def self.get_by_name(name)
49
+ all.detect { |alteration| alteration.name == name.to_s }
72
50
  end
73
51
 
74
- def representions
75
- [identifier, identifier.to_s, name, ascii, unicode, html_entity]
76
- .reject { |representation| representation.to_s.strip == "" }
52
+ def name(locale_code: I18n.locale)
53
+ super || identifier.to_s.tr("_", " ")
77
54
  end
78
55
 
79
- def semitones
80
- cents / 100.0
56
+ def representations
57
+ [identifier, identifier.to_s, name, ascii, unicode, html_entity]
58
+ .reject { |representation| representation.to_s.strip == "" }
81
59
  end
82
60
 
83
- ALTERATION_IDENTIFIERS.each do |key|
61
+ ALTERATION_RECORDS.keys.each do |key|
84
62
  define_method(:"#{key}?") { identifier == key }
85
63
  end
86
64
 
@@ -90,7 +68,7 @@ class HeadMusic::Rudiment::Alteration
90
68
 
91
69
  def <=>(other)
92
70
  other = HeadMusic::Rudiment::Alteration.get(other)
93
- cents <=> other.cents
71
+ semitones <=> other.semitones
94
72
  end
95
73
 
96
74
  def musical_symbol
@@ -99,15 +77,22 @@ class HeadMusic::Rudiment::Alteration
99
77
 
100
78
  private
101
79
 
102
- def initialize(attributes)
103
- @identifier = attributes[:identifier]
104
- @cents = attributes[:cents]
80
+ def initialize(key, attributes)
81
+ @identifier = key
82
+ @semitones = attributes[:semitones]
105
83
  initialize_musical_symbols(attributes[:symbols])
84
+ initialize_localized_names
85
+ end
86
+
87
+ def initialize_localized_names
88
+ # Initialize default English names
89
+ ensure_localized_name(name: identifier.to_s.tr("_", " "), locale_code: :en)
90
+ # Additional localized names will be loaded from locale files
106
91
  end
107
92
 
108
93
  def initialize_musical_symbols(list)
109
94
  @musical_symbols = (list || []).map do |record|
110
- HeadMusic::Rudiment::MusicalSymbol.new(
95
+ HeadMusic::Notation::MusicalSymbol.new(
111
96
  unicode: record[:unicode],
112
97
  ascii: record[:ascii],
113
98
  html_entity: record[:html_entity]
@@ -0,0 +1,32 @@
1
+ alterations:
2
+ double_flat:
3
+ semitones: -2
4
+ symbols:
5
+ - ascii: "bb"
6
+ unicode: "𝄫"
7
+ html_entity: "&#119083;"
8
+ flat:
9
+ semitones: -1
10
+ symbols:
11
+ - ascii: "b"
12
+ unicode: "♭"
13
+ html_entity: "&#9837;"
14
+ natural:
15
+ cents: 0
16
+ semitones: 0
17
+ symbols:
18
+ - ascii: ""
19
+ unicode: "♮"
20
+ html_entity: "&#9838;"
21
+ sharp:
22
+ semitones: 1
23
+ symbols:
24
+ - ascii: "#"
25
+ unicode: "♯"
26
+ html_entity: "&#9839;"
27
+ double_sharp:
28
+ semitones: 2
29
+ symbols:
30
+ - ascii: "x"
31
+ unicode: "𝄪"
32
+ html_entity: "&#119082;"
@@ -0,0 +1,9 @@
1
+ module HeadMusic::Rudiment; end
2
+
3
+ class HeadMusic::Rudiment::Base
4
+ private
5
+
6
+ def initialize
7
+ raise NotImplementedError, "Cannot instantiate abstract rudiment base class"
8
+ end
9
+ end
@@ -2,7 +2,7 @@
2
2
  module HeadMusic::Rudiment; end
3
3
 
4
4
  # A chromatic interval is the distance between two pitches measured in half-steps.
5
- class HeadMusic::Rudiment::ChromaticInterval
5
+ class HeadMusic::Rudiment::ChromaticInterval < HeadMusic::Rudiment::Base
6
6
  include Comparable
7
7
  include HeadMusic::Named
8
8
 
@@ -17,17 +17,14 @@ class HeadMusic::Rudiment::ChromaticInterval
17
17
 
18
18
  def self.get(identifier)
19
19
  @intervals ||= {}
20
- candidate = identifier.to_s.downcase.gsub(/\W+/, "_")
20
+ candidate = HeadMusic::Utilities::Case.to_snake_case(identifier)
21
21
  semitones = NAMES.index(candidate) || identifier.to_i
22
22
  @intervals[semitones] ||= new(semitones.to_i)
23
23
  end
24
24
 
25
25
  def initialize(identifier)
26
- if /^\D/i.match?(identifier.to_s.strip)
27
- candidate = identifier.to_s.downcase.gsub(/\W+/, "_")
28
- semitones = NAMES.index(candidate) || identifier.to_i
29
- end
30
- @semitones = semitones || identifier.to_i
26
+ candidate = HeadMusic::Utilities::HashKey.for(identifier).to_s
27
+ @semitones = NAMES.index(candidate) || identifier.to_i
31
28
  set_name
32
29
  end
33
30
 
@@ -4,7 +4,7 @@ require "yaml"
4
4
  module HeadMusic::Rudiment; end
5
5
 
6
6
  # A clef assigns pitches to the lines and spaces of a staff.
7
- class HeadMusic::Rudiment::Clef
7
+ class HeadMusic::Rudiment::Clef < HeadMusic::Rudiment::Base
8
8
  include HeadMusic::Named
9
9
 
10
10
  RECORDS = YAML.load_file(File.expand_path("clefs.yml", __dir__)).freeze
@@ -98,7 +98,7 @@ class HeadMusic::Rudiment::Clef
98
98
 
99
99
  def initialize_musical_symbols(list)
100
100
  @musical_symbols = (list || []).map do |symbol_data|
101
- HeadMusic::Rudiment::MusicalSymbol.new(**symbol_data.slice(:ascii, :html_entity, :unicode))
101
+ HeadMusic::Notation::MusicalSymbol.new(**symbol_data.slice(:ascii, :html_entity, :unicode))
102
102
  end
103
103
  end
104
104
  end
@@ -1,13 +1,31 @@
1
1
  # A module for music rudiments
2
2
  module HeadMusic::Rudiment; end
3
3
 
4
- # Consonance describes a category or degree of harmonic pleasantness: perfect, imperfect, or dissonant
5
- class HeadMusic::Rudiment::Consonance
6
- LEVELS = %w[perfect imperfect dissonant].freeze
4
+ # Consonance describes a category or degree of harmonic pleasantness
5
+ class HeadMusic::Rudiment::Consonance < HeadMusic::Rudiment::Base
6
+ PERFECT_CONSONANCE = :perfect_consonance
7
+ IMPERFECT_CONSONANCE = :imperfect_consonance
8
+ CONTEXTUAL = :contextual
9
+ MILD_DISSONANCE = :mild_dissonance
10
+ HARSH_DISSONANCE = :harsh_dissonance
11
+ DISSONANCE = :dissonance
12
+
13
+ LEVELS = [
14
+ PERFECT_CONSONANCE,
15
+ IMPERFECT_CONSONANCE,
16
+ CONTEXTUAL,
17
+ MILD_DISSONANCE,
18
+ HARSH_DISSONANCE,
19
+ DISSONANCE
20
+ ].freeze
7
21
 
8
22
  def self.get(name)
23
+ return name if name.is_a?(self)
24
+ return nil if name.nil?
25
+
9
26
  @consonances ||= {}
10
- @consonances[name.to_sym] ||= new(name) if LEVELS.include?(name.to_s)
27
+ name_sym = name.to_sym
28
+ @consonances[name_sym] ||= new(name) if LEVELS.include?(name_sym)
11
29
  end
12
30
 
13
31
  attr_reader :name
@@ -22,7 +40,23 @@ class HeadMusic::Rudiment::Consonance
22
40
  to_s == other.to_s
23
41
  end
24
42
 
43
+ # Check if this represents a consonance (perfect or imperfect)
44
+ def consonant?
45
+ [PERFECT_CONSONANCE, IMPERFECT_CONSONANCE].include?(name)
46
+ end
47
+
48
+ # Check if this represents any form of dissonance
49
+ def dissonant?
50
+ [MILD_DISSONANCE, HARSH_DISSONANCE, DISSONANCE].include?(name)
51
+ end
52
+
53
+ # Contextual is special - neither strictly consonant nor dissonant
54
+ def contextual?
55
+ name == CONTEXTUAL
56
+ end
57
+
58
+ # Predicate methods for each level
25
59
  LEVELS.each do |method_name|
26
- define_method(:"#{method_name}?") { to_s == method_name }
60
+ define_method(:"#{method_name}?") { to_s == method_name.to_s }
27
61
  end
28
62
  end
@@ -0,0 +1,25 @@
1
+ # A module for music rudiments
2
+ module HeadMusic::Rudiment; end
3
+
4
+ # Abstract class representing a diatonic tonal context (7-note scale system)
5
+ class HeadMusic::Rudiment::DiatonicContext < HeadMusic::Rudiment::TonalContext
6
+ def scale_type
7
+ raise NotImplementedError, "Subclasses must implement #scale_type"
8
+ end
9
+
10
+ def scale
11
+ @scale ||= HeadMusic::Rudiment::Scale.get(tonic_spelling, scale_type)
12
+ end
13
+
14
+ def key_signature
15
+ @key_signature ||= HeadMusic::Rudiment::KeySignature.from_scale(scale)
16
+ end
17
+
18
+ def relative
19
+ raise NotImplementedError, "Subclasses must implement #relative"
20
+ end
21
+
22
+ def parallel
23
+ raise NotImplementedError, "Subclasses must implement #parallel"
24
+ end
25
+ end
@@ -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,10 +1,9 @@
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
7
-
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
8
7
  ORDERED_LETTER_NAMES_OF_SHARPS = %w[F C G D A E B].freeze
9
8
  ORDERED_LETTER_NAMES_OF_FLATS = ORDERED_LETTER_NAMES_OF_SHARPS.reverse.freeze
10
9
 
@@ -16,14 +15,28 @@ class HeadMusic::Rudiment::KeySignature
16
15
  return identifier if identifier.is_a?(HeadMusic::Rudiment::KeySignature)
17
16
 
18
17
  @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)
18
+
19
+ if identifier.is_a?(String)
20
+ tonic_spelling, scale_type_name = identifier.strip.split(/\s/)
21
+ hash_key = HeadMusic::Utilities::HashKey.for(identifier.gsub(/#|♯/, " sharp").gsub(/(\w)[b♭]/, '\\1 flat'))
22
+ @key_signatures[hash_key] ||= new(tonic_spelling, scale_type_name)
23
+ elsif identifier.is_a?(HeadMusic::Rudiment::DiatonicContext)
24
+ identifier.key_signature
25
+ end
26
+ end
27
+
28
+ def self.from_scale(scale)
29
+ # Find a key or mode that uses this scale
30
+ tonic = scale.root_pitch.spelling
31
+ scale_type = scale.scale_type
32
+ new(tonic, scale_type)
22
33
  end
23
34
 
35
+ attr_reader :tonic_spelling, :scale_type, :scale
36
+
24
37
  delegate :pitch_class, to: :tonic_spelling, prefix: :tonic
25
- delegate :to_s, to: :name
26
38
  delegate :pitches, :pitch_classes, to: :scale
39
+ delegate :to_s, to: :name
27
40
 
28
41
  def initialize(tonic_spelling, scale_type = nil)
29
42
  @tonic_spelling = HeadMusic::Rudiment::Spelling.get(tonic_spelling)
@@ -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