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
@@ -4,8 +4,8 @@ module HeadMusic::Analysis; end
4
4
  # A diatonic interval is the distance between two spelled pitches.
5
5
  class HeadMusic::Analysis::DiatonicInterval
6
6
  include Comparable
7
+ include HeadMusic::Named
7
8
 
8
- # TODO: include Named module
9
9
  NUMBER_NAMES = %w[
10
10
  unison second third fourth fifth sixth seventh octave
11
11
  ninth tenth eleventh twelfth thirteenth fourteenth fifteenth
@@ -43,7 +43,6 @@ class HeadMusic::Analysis::DiatonicInterval
43
43
 
44
44
  attr_reader :lower_pitch, :higher_pitch
45
45
 
46
- delegate :to_s, to: :name
47
46
  delegate :perfect?, :major?, :minor?, :diminished?, :augmented?, :doubly_diminished?, :doubly_augmented?, to: :quality
48
47
 
49
48
  delegate :step?, :skip?, :leap?, :large_leap?, to: :category
@@ -52,18 +51,41 @@ class HeadMusic::Analysis::DiatonicInterval
52
51
  to: :size
53
52
  )
54
53
  delegate(
55
- :simple_name, :quality_name, :simple_number_name, :number_name, :name, :shorthand,
54
+ :simple_name, :quality_name, :simple_number_name, :number_name, :shorthand,
56
55
  to: :naming
57
56
  )
58
57
 
59
58
  alias_method :to_i, :semitones
60
59
 
60
+ # Override Named module method to try I18n and fall back to computed name
61
+ def name(locale_code: nil)
62
+ if locale_code
63
+ name_key = HeadMusic::Utilities::HashKey.for(naming.name)
64
+ if I18n.backend.translations[locale_code]
65
+ locale_data = I18n.backend.translations[locale_code][:head_music] || {}
66
+ return locale_data[:diatonic_intervals][name_key] if locale_data.dig(:diatonic_intervals, name_key)
67
+ return locale_data[:chromatic_intervals][name_key] if locale_data.dig(:chromatic_intervals, name_key)
68
+ end
69
+ end
70
+ naming.name
71
+ end
72
+
73
+ def to_s
74
+ name
75
+ end
76
+
61
77
  # Accepts a name and returns the interval with middle c on the bottom
62
78
  def self.get(identifier)
63
- name = Parser.new(identifier)
64
- semitones = Semitones.new(name.degree_name.to_sym, name.quality_name).count
65
- higher_pitch = HeadMusic::Rudiment::Pitch.from_number_and_letter(HeadMusic::Rudiment::Pitch.middle_c + semitones, name.higher_letter)
66
- new(HeadMusic::Rudiment::Pitch.middle_c, higher_pitch)
79
+ if identifier.is_a?(String) || identifier.is_a?(Symbol)
80
+ name = Parser.new(identifier)
81
+ semitones = Semitones.new(name.degree_name.to_sym, name.quality_name).count
82
+ higher_pitch = HeadMusic::Rudiment::Pitch.from_number_and_letter(HeadMusic::Rudiment::Pitch.middle_c + semitones, name.higher_letter)
83
+ interval = new(HeadMusic::Rudiment::Pitch.middle_c, higher_pitch)
84
+ interval.ensure_localized_name(name: identifier.to_s)
85
+ interval
86
+ else
87
+ identifier
88
+ end
67
89
  end
68
90
 
69
91
  def initialize(first_pitch, second_pitch)
@@ -90,26 +112,39 @@ class HeadMusic::Analysis::DiatonicInterval
90
112
  alias_method :invert, :inversion
91
113
 
92
114
  def consonance(style = :standard_practice)
93
- consonance_for_perfect(style) ||
94
- consonance_for_major_and_minor ||
95
- HeadMusic::Rudiment::Consonance.get(:dissonant)
115
+ consonance_analysis(style).consonance
96
116
  end
97
117
 
98
118
  def consonance?(style = :standard_practice)
99
- consonance(style).perfect? || consonance(style).imperfect?
119
+ consonance(style).consonant?
120
+ end
121
+
122
+ def consonant?(style = :standard_practice)
123
+ consonance_analysis(style).consonant?
100
124
  end
101
- alias_method :consonant?, :consonance?
102
125
 
103
126
  def perfect_consonance?(style = :standard_practice)
104
- consonance(style).perfect?
127
+ consonance_analysis(style).perfect_consonance?
105
128
  end
106
129
 
107
130
  def imperfect_consonance?(style = :standard_practice)
108
- consonance(style).imperfect?
131
+ consonance_analysis(style).imperfect_consonance?
109
132
  end
110
133
 
111
134
  def dissonance?(style = :standard_practice)
112
- consonance(style).dissonant?
135
+ consonance_analysis(style).dissonant?
136
+ end
137
+
138
+ def dissonant?(style = :standard_practice)
139
+ consonance_analysis(style).dissonant?
140
+ end
141
+
142
+ def consonance_analysis(style = :standard_practice)
143
+ HeadMusic::Analysis::IntervalConsonance.new(self, style)
144
+ end
145
+
146
+ def consonance_classification(style: :standard_practice)
147
+ consonance_analysis(style).classification
113
148
  end
114
149
 
115
150
  def above(pitch)
@@ -160,16 +195,4 @@ class HeadMusic::Analysis::DiatonicInterval
160
195
  def naming
161
196
  @naming ||= Naming.new(number: number, semitones: semitones)
162
197
  end
163
-
164
- def consonance_for_perfect(style = :standard_practice)
165
- HeadMusic::Rudiment::Consonance.get(dissonant_fourth?(style) ? :dissonant : :perfect) if perfect?
166
- end
167
-
168
- def consonance_for_major_and_minor
169
- HeadMusic::Rudiment::Consonance.get((third_or_compound? || sixth_or_compound?) ? :imperfect : :dissonant) if major? || minor?
170
- end
171
-
172
- def dissonant_fourth?(style = :standard_practice)
173
- fourth_or_compound? && style == :two_part_harmony
174
- end
175
198
  end
@@ -0,0 +1,51 @@
1
+ # Analysis class that combines an interval with a style tradition to determine consonance
2
+ class HeadMusic::Analysis::IntervalConsonance
3
+ attr_reader :interval, :style_tradition
4
+
5
+ def initialize(interval, style_tradition = HeadMusic::Style::ModernTradition.new)
6
+ @interval = interval
7
+ @style_tradition = style_tradition.is_a?(HeadMusic::Style::Tradition) ?
8
+ style_tradition :
9
+ HeadMusic::Style::Tradition.get(style_tradition)
10
+ end
11
+
12
+ def classification
13
+ @classification ||= style_tradition.consonance_classification(interval)
14
+ end
15
+
16
+ def consonance
17
+ @consonance ||= HeadMusic::Rudiment::Consonance.get(classification)
18
+ end
19
+
20
+ def consonant?
21
+ [HeadMusic::Rudiment::Consonance::PERFECT_CONSONANCE, HeadMusic::Rudiment::Consonance::IMPERFECT_CONSONANCE].include?(classification)
22
+ end
23
+
24
+ def dissonant?
25
+ [HeadMusic::Rudiment::Consonance::MILD_DISSONANCE, HeadMusic::Rudiment::Consonance::HARSH_DISSONANCE, HeadMusic::Rudiment::Consonance::DISSONANCE].include?(classification)
26
+ end
27
+
28
+ def contextual?
29
+ classification == HeadMusic::Rudiment::Consonance::CONTEXTUAL
30
+ end
31
+
32
+ def perfect_consonance?
33
+ classification == HeadMusic::Rudiment::Consonance::PERFECT_CONSONANCE
34
+ end
35
+
36
+ def imperfect_consonance?
37
+ classification == HeadMusic::Rudiment::Consonance::IMPERFECT_CONSONANCE
38
+ end
39
+
40
+ def mild_dissonance?
41
+ classification == HeadMusic::Rudiment::Consonance::MILD_DISSONANCE
42
+ end
43
+
44
+ def harsh_dissonance?
45
+ classification == HeadMusic::Rudiment::Consonance::HARSH_DISSONANCE
46
+ end
47
+
48
+ def dissonance?
49
+ classification == HeadMusic::Rudiment::Consonance::DISSONANCE
50
+ end
51
+ end
@@ -11,7 +11,7 @@ class HeadMusic::Content::Note
11
11
 
12
12
  def initialize(pitch, rhythmic_value, voice = nil, position = nil)
13
13
  @pitch = HeadMusic::Rudiment::Pitch.get(pitch)
14
- @rhythmic_value = HeadMusic::Content::RhythmicValue.get(rhythmic_value)
14
+ @rhythmic_value = HeadMusic::Rudiment::RhythmicValue.get(rhythmic_value)
15
15
  @voice = voice || HeadMusic::Content::Voice.new
16
16
  @position = position || HeadMusic::Content::Position.new(@voice.composition, "1:1")
17
17
  end
@@ -55,7 +55,7 @@ class HeadMusic::Content::Placement
55
55
  def ensure_attributes(voice, position, rhythmic_value, pitch)
56
56
  @voice = voice
57
57
  ensure_position(position)
58
- @rhythmic_value = HeadMusic::Content::RhythmicValue.get(rhythmic_value)
58
+ @rhythmic_value = HeadMusic::Rudiment::RhythmicValue.get(rhythmic_value)
59
59
  @pitch = HeadMusic::Rudiment::Pitch.get(pitch)
60
60
  end
61
61
 
@@ -53,7 +53,7 @@ class HeadMusic::Content::Position
53
53
  end
54
54
 
55
55
  def +(other)
56
- other = HeadMusic::Content::RhythmicValue.new(other) if [HeadMusic::Rudiment::RhythmicUnit, Symbol, String].include?(other.class)
56
+ other = HeadMusic::Rudiment::RhythmicValue.new(other) if [HeadMusic::Rudiment::RhythmicUnit, Symbol, String].include?(other.class)
57
57
  self.class.new(composition, bar_number, count, tick + other.ticks)
58
58
  end
59
59
 
@@ -8,7 +8,7 @@ class HeadMusic::Content::Staff
8
8
  attr_reader :default_clef, :line_count, :instrument
9
9
 
10
10
  def initialize(default_clef_key, instrument: nil, line_count: nil)
11
- @instrument = HeadMusic::Instruments::Instrument.get(instrument) if instrument
11
+ @instrument = HeadMusic::Instruments::InstrumentType.get(instrument) if instrument
12
12
  begin
13
13
  @default_clef = HeadMusic::Rudiment::Clef.get(default_clef_key)
14
14
  rescue KeyError, NoMethodError
@@ -1,67 +1,74 @@
1
1
  # Namespace for instrument definitions, categorization, and configuration
2
2
  module HeadMusic::Instruments; end
3
3
 
4
- # A musical instrument.
5
- # An instrument object can be assigned to a staff object.
6
- # Attributes:
7
- # name_key: the name of the instrument
8
- # alias_name_keys: an array of alternative names for the instrument
9
- # orchestra_section_key: the section of the orchestra (e.g. "strings")
10
- # family_key: the key for the family of the instrument (e.g. "saxophone")
11
- # classification_keys: an array of classification_keys
12
- # default_clefs: the default clef or system of clefs for the instrument
13
- # - [treble] for instruments that use the treble clef
14
- # - [treble, bass] for instruments that use the grand staff
15
- # variants:
16
- # a hash of default and alternative pitch designations
17
- # Associations:
18
- # family: the family of the instrument (e.g. "saxophone")
19
- # orchestra_section: the section of the orchestra (e.g. "strings")
4
+ # A specific musical instrument instance with a selected variant.
5
+ # Represents an actual playable instrument with its transposition and configuration.
6
+ #
7
+ # Examples:
8
+ # trumpet_in_c = HeadMusic::Instruments::Instrument.get("trumpet_in_c")
9
+ # trumpet_in_c = HeadMusic::Instruments::Instrument.get("trumpet", "in_c")
10
+ # clarinet = HeadMusic::Instruments::Instrument.get("clarinet") # uses default Bb variant
11
+ #
12
+ # Attributes accessible via delegation to instrument_type and variant:
13
+ # name: display name including variant (e.g. "Trumpet in C")
14
+ # transposition: sounding transposition in semitones
15
+ # clefs: array of clefs for this instrument
16
+ # pitch_designation: the pitch designation for transposing instruments
20
17
  class HeadMusic::Instruments::Instrument
21
18
  include HeadMusic::Named
22
19
 
23
- INSTRUMENTS = YAML.load_file(File.expand_path("instruments.yml", __dir__)).freeze
20
+ attr_reader :instrument_type, :variant
24
21
 
25
- def self.get(name)
26
- get_by_name(name)
27
- end
22
+ # Factory method to get an Instrument instance
23
+ # @param type_or_name [String, Symbol] instrument type name or full name with variant
24
+ # @param variant_key [String, Symbol, nil] optional variant key if not included in name
25
+ # @return [Instrument] instrument instance with specified or default variant
26
+ def self.get(type_or_name, variant_key = nil)
27
+ return type_or_name if type_or_name.is_a?(self)
28
28
 
29
- def self.all
30
- HeadMusic::Instruments::InstrumentFamily.all
31
- @all ||=
32
- INSTRUMENTS.map { |key, _data| get(key) }.sort_by { |instrument| instrument.name.downcase }
33
- end
29
+ type_name, parsed_variant_key = parse_instrument_name(type_or_name)
30
+ variant_key ||= parsed_variant_key
34
31
 
35
- attr_reader(
36
- :name_key, :alias_name_keys,
37
- :family_key, :orchestra_section_key,
38
- :variants, :classification_keys
39
- )
32
+ instrument_type = HeadMusic::Instruments::InstrumentType.get(type_name)
33
+ return nil unless instrument_type&.name_key
40
34
 
41
- def ==(other)
42
- to_s == other.to_s
35
+ variant = find_variant(instrument_type, variant_key)
36
+ new(instrument_type, variant)
37
+ end
38
+
39
+ def initialize(instrument_type, variant)
40
+ @instrument_type = instrument_type
41
+ @variant = variant
42
+ initialize_name
43
43
  end
44
44
 
45
- def translation(locale = :en)
46
- return name unless name_key
45
+ # Delegations to instrument_type
46
+ delegate :name_key, :family_key, :family, :orchestra_section_key, :classification_keys,
47
+ :alias_name_keys, :variants, :translation, to: :instrument_type
47
48
 
48
- I18n.translate(name_key, scope: %i[head_music instruments], locale: locale, default: name)
49
+ # Delegations to variant
50
+ delegate :pitch_designation, :staff_schemes, :default_staff_scheme, to: :variant
51
+
52
+ def default_staves
53
+ default_staff_scheme&.staves || []
49
54
  end
50
55
 
51
- def family
52
- return unless family_key
56
+ def default_clefs
57
+ default_staves&.map(&:clef) || []
58
+ end
53
59
 
54
- HeadMusic::Instruments::InstrumentFamily.get(family_key)
60
+ def sounding_transposition
61
+ default_staves&.first&.sounding_transposition || 0
55
62
  end
56
63
 
57
- # Returns true if the instrument sounds at a different pitch than written.
64
+ alias_method :default_sounding_transposition, :sounding_transposition
65
+
58
66
  def transposing?
59
- default_sounding_transposition != 0
67
+ sounding_transposition != 0
60
68
  end
61
69
 
62
- # Returns true if the instrument sounds at a different register than written.
63
70
  def transposing_at_the_octave?
64
- transposing? && default_sounding_transposition % 12 == 0
71
+ transposing? && sounding_transposition % 12 == 0
65
72
  end
66
73
 
67
74
  def single_staff?
@@ -78,94 +85,77 @@ class HeadMusic::Instruments::Instrument
78
85
  default_clefs.any?
79
86
  end
80
87
 
81
- def default_variant
82
- variants.find(&:default?) || variants.first
83
- end
84
-
85
- delegate :default_staff_scheme, to: :default_variant
88
+ def ==(other)
89
+ return false unless other.is_a?(self.class)
86
90
 
87
- def default_staves
88
- default_staff_scheme&.staves || []
91
+ instrument_type == other.instrument_type && variant == other.variant
89
92
  end
90
93
 
91
- def default_clefs
92
- default_staves&.map(&:clef) || []
94
+ def to_s
95
+ name
93
96
  end
94
97
 
95
- def default_sounding_transposition
96
- default_staves&.first&.sounding_transposition || 0
97
- end
98
-
99
- private_class_method :new
100
-
101
98
  private
102
99
 
103
- def initialize(name)
104
- record = record_for_name(name)
105
- if record
106
- initialize_data_from_record(record)
100
+ def initialize_name
101
+ if variant.default? || !pitch_designation
102
+ self.name = instrument_type.name
103
+ elsif pitch_designation
104
+ pitch_name = format_pitch_name(pitch_designation)
105
+ self.name = "#{instrument_type.name} in #{pitch_name}"
107
106
  else
108
- self.name = name.to_s
107
+ variant_name = variant.key.to_s.tr("_", " ")
108
+ self.name = "#{instrument_type.name} (#{variant_name})"
109
109
  end
110
110
  end
111
111
 
112
- def record_for_name(name)
113
- record_for_key(HeadMusic::Utilities::HashKey.for(name)) ||
114
- record_for_key(key_for_name(name))
115
- end
116
-
117
- def key_for_name(name)
118
- INSTRUMENTS.each do |key, _data|
119
- I18n.config.available_locales.each do |locale|
120
- translation = I18n.t("head_music.instruments.#{key}", locale: locale)
121
- return key if translation.downcase == name.downcase
112
+ def format_pitch_name(pitch_designation)
113
+ # Format the pitch designation for display
114
+ # e.g. "Bb" -> "B♭", "C" -> "C", "Eb" -> "E♭"
115
+ pitch_designation.to_s.gsub("b", "♭").gsub("#", "♯")
116
+ end
117
+
118
+ def self.parse_instrument_name(name)
119
+ name_str = name.to_s
120
+
121
+ # Check for variant patterns like "trumpet_in_e_flat"
122
+ if name_str =~ /(.+)_in_([a-g])_(flat|sharp)$/i
123
+ type_name = Regexp.last_match(1)
124
+ note = Regexp.last_match(2).downcase
125
+ accidental = Regexp.last_match(3)
126
+ variant_key = :"in_#{note}_#{accidental}"
127
+ [type_name, variant_key]
128
+ # Check for variant patterns like "trumpet_in_c" or "clarinet_in_a" or "trumpet_in_eb"
129
+ elsif name_str =~ /(.+)_in_([a-g][b#]?)$/i
130
+ type_name = Regexp.last_match(1)
131
+ variant_note = Regexp.last_match(2).downcase
132
+ # Convert "eb" to "e_flat", "bb" to "b_flat", etc.
133
+ if variant_note.end_with?("b") && variant_note.length == 2
134
+ note_letter = variant_note[0]
135
+ variant_key = :"in_#{note_letter}_flat"
136
+ elsif variant_note.end_with?("#") && variant_note.length == 2
137
+ note_letter = variant_note[0]
138
+ variant_key = :"in_#{note_letter}_sharp"
139
+ else
140
+ variant_key = :"in_#{variant_note}"
122
141
  end
142
+ [type_name, variant_key]
143
+ else
144
+ [name_str, nil]
123
145
  end
124
- nil
125
146
  end
126
147
 
127
- def record_for_key(key)
128
- INSTRUMENTS.each do |name_key, data|
129
- return data.merge!("name_key" => name_key) if name_key.to_s == key.to_s
130
- end
131
- nil
132
- end
148
+ def self.find_variant(instrument_type, variant_key)
149
+ return instrument_type.default_variant unless variant_key
133
150
 
134
- def initialize_data_from_record(record)
135
- initialize_family(record)
136
- inherit_family_attributes(record)
137
- initialize_names(record)
138
- initialize_attributes(record)
139
- end
151
+ # Convert to symbol for comparison
152
+ variant_sym = variant_key.to_sym
140
153
 
141
- def initialize_family(record)
142
- @family_key = record["family_key"]
143
- @family = HeadMusic::Instruments::InstrumentFamily.get(family_key)
154
+ # Find the variant by key
155
+ variants = instrument_type.variants || []
156
+ variant = variants.find { |v| v.key == variant_sym }
157
+ variant || instrument_type.default_variant
144
158
  end
145
159
 
146
- def inherit_family_attributes(record)
147
- return unless family
148
-
149
- @orchestra_section_key = family.orchestra_section_key
150
- @classification_keys = family.classification_keys || []
151
- end
152
-
153
- def initialize_names(record)
154
- @name_key = record["name_key"].to_sym
155
- self.name = I18n.translate(name_key, scope: "head_music.instruments", locale: "en", default: inferred_name)
156
- @alias_name_keys = record["alias_name_keys"] || []
157
- end
158
-
159
- def initialize_attributes(record)
160
- @orchestra_section_key ||= record["orchestra_section_key"]
161
- @classification_keys = [@classification_keys, record["classification_keys"]].flatten.compact.uniq
162
- @variants =
163
- (record["variants"] || {}).map do |key, attributes|
164
- HeadMusic::Instruments::Variant.new(key, attributes)
165
- end
166
- end
167
-
168
- def inferred_name
169
- name_key.to_s.tr("_", " ")
170
- end
160
+ private_class_method :parse_instrument_name, :find_variant
171
161
  end
@@ -28,6 +28,15 @@ bassoon:
28
28
  - wind
29
29
  - woodwind
30
30
  orchestra_section_key: woodwind
31
+ celesta:
32
+ classification_keys:
33
+ - idiophone
34
+ - keyboard
35
+ - percussion
36
+ - struck
37
+ - metal
38
+ - pitched
39
+ orchestra_section_key: keyboard
31
40
  clarinet:
32
41
  classification_keys:
33
42
  - aerophone
@@ -42,15 +51,6 @@ clavichord:
42
51
  - keyboard
43
52
  - string
44
53
  orchestra_section_key: keyboard
45
- celesta:
46
- classification_keys:
47
- - idiophone
48
- - keyboard
49
- - percussion
50
- - struck
51
- - metal
52
- - pitched
53
- orchestra_section_key: percussion
54
54
  cornet:
55
55
  classification_keys:
56
56
  - aerophone
@@ -239,6 +239,7 @@ tambourine:
239
239
  classification_keys:
240
240
  - percussion
241
241
  - membranophone
242
+ - shaken
242
243
  - idiophone
243
244
  - unpitched
244
245
  orchestra_section_key: percussion
@@ -3,8 +3,19 @@ module HeadMusic::Instruments; end
3
3
 
4
4
  # An *InstrumentFamily* is a species of instrument
5
5
  # that may exist in a variety of keys or other variations.
6
- # For example, _saxophone_ is an instrument family, while
7
- # _alto saxophone_ and _baritone saxophone_ are specific instruments.
6
+ # For example:
7
+ # - _saxophone_ is an instrument family, while
8
+ # _alto saxophone_ and _baritone saxophone_ are specific instruments.
9
+ # - _oboe_ is an instrument family, while
10
+ # _oboe d'amore_ and _English horn_ are specific instruments.
11
+ #
12
+ # Instrument families are categorized by:
13
+ # - orchestra section (e.g. woodwind, brass, percussion, strings)
14
+ # - classification (e.g. bowed string, plucked string, double reed, single reed, brass, keyboard, electronic, percussion)
15
+ #
16
+ # Instrument families are defined in `lib/head_music/instruments/instrument_families.yml`.
17
+ #
18
+ # @see HeadMusic::Instruments::InstrumentType
8
19
  class HeadMusic::Instruments::InstrumentFamily
9
20
  include HeadMusic::Named
10
21