head_music 17.2.0 → 17.4.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 (113) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +15 -0
  3. data/Gemfile.lock +1 -1
  4. data/lib/head_music/analysis/chord_analysis.rb +123 -0
  5. data/lib/head_music/analysis/circle.rb +11 -3
  6. data/lib/head_music/analysis/diatonic_interval.rb +14 -11
  7. data/lib/head_music/analysis/dyad/chord_implication.rb +101 -0
  8. data/lib/head_music/analysis/dyad.rb +24 -104
  9. data/lib/head_music/analysis/interval_cycle.rb +9 -3
  10. data/lib/head_music/analysis/pitch_collection.rb +33 -107
  11. data/lib/head_music/analysis/sonority.rb +11 -21
  12. data/lib/head_music/content/composition/hash_deserializer.rb +110 -0
  13. data/lib/head_music/content/composition/schema_values.rb +127 -0
  14. data/lib/head_music/content/composition.rb +0 -215
  15. data/lib/head_music/content/placement.rb +11 -42
  16. data/lib/head_music/content/sound_resolver.rb +54 -0
  17. data/lib/head_music/content/voice/melodic_line.rb +32 -0
  18. data/lib/head_music/content/voice/melodic_note_pair.rb +33 -0
  19. data/lib/head_music/content/voice.rb +7 -58
  20. data/lib/head_music/instruments/alternate_tuning.rb +4 -6
  21. data/lib/head_music/instruments/catalog_lookup.rb +18 -0
  22. data/lib/head_music/instruments/instrument.rb +17 -117
  23. data/lib/head_music/instruments/instrument_catalog.rb +48 -0
  24. data/lib/head_music/instruments/instrument_configuration.rb +4 -6
  25. data/lib/head_music/instruments/instrument_family.rb +3 -8
  26. data/lib/head_music/instruments/instrument_name.rb +49 -0
  27. data/lib/head_music/instruments/score_order.rb +52 -42
  28. data/lib/head_music/instruments/staff_profile.rb +71 -0
  29. data/lib/head_music/instruments/stringing.rb +4 -6
  30. data/lib/head_music/instruments/stringing_course.rb +4 -6
  31. data/lib/head_music/instruments/variant.rb +4 -6
  32. data/lib/head_music/named/locale.rb +32 -0
  33. data/lib/head_music/named/localized_name.rb +15 -0
  34. data/lib/head_music/named.rb +1 -45
  35. data/lib/head_music/notation/abc/body_lexer.rb +65 -24
  36. data/lib/head_music/notation/abc/duration_resolver.rb +11 -1
  37. data/lib/head_music/notation/abc/duration_writer.rb +1 -8
  38. data/lib/head_music/notation/abc/header.rb +27 -16
  39. data/lib/head_music/notation/abc/parser.rb +48 -159
  40. data/lib/head_music/notation/abc/preflight.rb +48 -0
  41. data/lib/head_music/notation/abc/voice_state.rb +161 -0
  42. data/lib/head_music/notation/abc/writer.rb +47 -16
  43. data/lib/head_music/notation/dotted_duration.rb +19 -0
  44. data/lib/head_music/notation/instrument_notation.rb +5 -7
  45. data/lib/head_music/notation/music_xml/beam_grouper.rb +108 -0
  46. data/lib/head_music/notation/music_xml/duration_writer.rb +3 -8
  47. data/lib/head_music/notation/music_xml/preflight.rb +90 -0
  48. data/lib/head_music/notation/music_xml/render_plan.rb +161 -0
  49. data/lib/head_music/notation/music_xml/writer.rb +31 -146
  50. data/lib/head_music/notation/placement_validation.rb +19 -0
  51. data/lib/head_music/rudiment/enharmonic_equivalence.rb +33 -0
  52. data/lib/head_music/rudiment/key.rb +8 -36
  53. data/lib/head_music/rudiment/key_signature/enharmonic_equivalence.rb +21 -15
  54. data/lib/head_music/rudiment/key_signature.rb +18 -19
  55. data/lib/head_music/rudiment/meter.rb +39 -6
  56. data/lib/head_music/rudiment/mode.rb +33 -65
  57. data/lib/head_music/rudiment/note.rb +18 -21
  58. data/lib/head_music/rudiment/pitch/enharmonic_equivalence.rb +4 -15
  59. data/lib/head_music/rudiment/pitch/natural_step.rb +36 -0
  60. data/lib/head_music/rudiment/pitch/parser.rb +16 -5
  61. data/lib/head_music/rudiment/pitch.rb +11 -33
  62. data/lib/head_music/rudiment/qualified_diatonic_context.rb +61 -0
  63. data/lib/head_music/rudiment/rhythmic_unit/parser.rb +16 -12
  64. data/lib/head_music/rudiment/rhythmic_unit.rb +21 -37
  65. data/lib/head_music/rudiment/rhythmic_value/parser.rb +53 -46
  66. data/lib/head_music/rudiment/spelling/enharmonic_equivalence.rb +13 -0
  67. data/lib/head_music/rudiment/spelling.rb +0 -25
  68. data/lib/head_music/rudiment/tuning/just_intonation.rb +2 -4
  69. data/lib/head_music/rudiment/tuning/meantone.rb +2 -4
  70. data/lib/head_music/rudiment/tuning/pythagorean.rb +2 -4
  71. data/lib/head_music/rudiment/tuning/tonal_centered.rb +10 -0
  72. data/lib/head_music/rudiment/unpitched_note.rb +3 -5
  73. data/lib/head_music/style/annotation/configured.rb +35 -0
  74. data/lib/head_music/style/annotation.rb +32 -44
  75. data/lib/head_music/style/guidelines/allowed_rhythmic_values_for_fifth_species.rb +0 -10
  76. data/lib/head_music/style/guidelines/contoured.rb +65 -53
  77. data/lib/head_music/style/guidelines/dissonance_figure_detection.rb +55 -16
  78. data/lib/head_music/style/guidelines/florid_dissonance_treatment.rb +0 -37
  79. data/lib/head_music/style/guidelines/large_leaps.rb +10 -2
  80. data/lib/head_music/style/guidelines/minimum_melodic_intervals.rb +3 -25
  81. data/lib/head_music/style/guidelines/minimum_notes.rb +3 -25
  82. data/lib/head_music/style/guidelines/minimum_threshold.rb +38 -0
  83. data/lib/head_music/style/guidelines/no_parallel_perfect.rb +31 -0
  84. data/lib/head_music/style/guidelines/no_parallel_perfect_on_downbeats.rb +3 -17
  85. data/lib/head_music/style/guidelines/no_parallel_perfect_with_syncopation.rb +3 -17
  86. data/lib/head_music/style/guidelines/prepare_octave_leaps.rb +10 -7
  87. data/lib/head_music/style/guidelines/singable_intervals.rb +22 -8
  88. data/lib/head_music/style/guidelines/suspension_treatment.rb +30 -12
  89. data/lib/head_music/style/guidelines/third_species_dissonance_treatment.rb +0 -7
  90. data/lib/head_music/style/guidelines/triple_meter_dissonance_treatment.rb +0 -12
  91. data/lib/head_music/style/guidelines/weak_beat_dissonance_treatment.rb +2 -42
  92. data/lib/head_music/style/guides/fifth_species_melody.rb +3 -13
  93. data/lib/head_music/style/guides/fourth_species_melody.rb +3 -13
  94. data/lib/head_music/style/guides/second_species_harmony.rb +2 -5
  95. data/lib/head_music/style/guides/second_species_melody.rb +2 -12
  96. data/lib/head_music/style/guides/species_harmony.rb +14 -0
  97. data/lib/head_music/style/guides/species_melody.rb +21 -0
  98. data/lib/head_music/style/guides/third_species_harmony.rb +2 -5
  99. data/lib/head_music/style/guides/third_species_melody.rb +3 -13
  100. data/lib/head_music/style/guides/third_species_triple_meter_harmony.rb +2 -5
  101. data/lib/head_music/style/guides/third_species_triple_meter_melody.rb +2 -12
  102. data/lib/head_music/time/conductor.rb +19 -125
  103. data/lib/head_music/time/musical_position.rb +26 -26
  104. data/lib/head_music/time/musical_time_converter.rb +104 -0
  105. data/lib/head_music/time/smpte_converter.rb +51 -0
  106. data/lib/head_music/time/smpte_timecode.rb +25 -23
  107. data/lib/head_music/time.rb +2 -0
  108. data/lib/head_music/value_equality.rb +37 -0
  109. data/lib/head_music/version.rb +1 -1
  110. data/lib/head_music.rb +25 -1
  111. data/user-stories/done/abc-notation-beaming.md +235 -0
  112. data/user-stories/index.html +7 -3
  113. metadata +34 -2
@@ -7,6 +7,13 @@ class HeadMusic::Content::Placement
7
7
 
8
8
  attr_reader :voice, :position, :rhythmic_value, :sounds
9
9
 
10
+ # Authored beam grouping relative to the previous placement, set after
11
+ # construction (the Bar-style side-metadata pattern). Tri-state: nil = use
12
+ # the meter-derived default, true = force a beam break before this placement,
13
+ # false = force a beam join to the previous placement. Consumed by the
14
+ # MusicXML writer, which prefers it over the default grouping.
15
+ attr_accessor :beam_break_before
16
+
10
17
  delegate :composition, to: :voice
11
18
  delegate :spelling, to: :pitch, allow_nil: true
12
19
 
@@ -85,11 +92,13 @@ class HeadMusic::Content::Placement
85
92
  end
86
93
 
87
94
  def to_h
88
- {
95
+ hash = {
89
96
  "position" => position.to_s,
90
97
  "rhythmic_value" => rhythmic_value.to_s,
91
98
  "sounds" => sounds.map { |sound| sound_datum(sound) }
92
99
  }
100
+ hash["beam_break_before"] = beam_break_before unless beam_break_before.nil?
101
+ hash
93
102
  end
94
103
 
95
104
  private
@@ -120,47 +129,7 @@ class HeadMusic::Content::Placement
120
129
  @voice = voice
121
130
  ensure_position(position)
122
131
  @rhythmic_value = HeadMusic::Rudiment::RhythmicValue.get(rhythmic_value)
123
- @sounds = ensure_sounds(sound_or_sounds)
124
- end
125
-
126
- def ensure_sounds(sound_or_sounds)
127
- return [].freeze if sound_or_sounds.nil?
128
-
129
- values = sound_or_sounds.is_a?(Array) ? sound_or_sounds : [sound_or_sounds]
130
- values.map { |value| resolve_sound(value) }.uniq.freeze
131
- end
132
-
133
- def resolve_sound(value)
134
- return value if value.is_a?(HeadMusic::Rudiment::UnpitchedSound)
135
- return HeadMusic::Rudiment::UnpitchedSound.get(value) if value.is_a?(HeadMusic::Instruments::Instrument)
136
-
137
- pitch = HeadMusic::Rudiment::Pitch.get(value)
138
- return pitch if pitch
139
-
140
- unpitched_sound(value) || raise(ArgumentError, unknown_sound_message(value))
141
- end
142
-
143
- # A bare name resolves to a percussive hit only on an unpitched instrument;
144
- # naming a pitched instrument is ambiguous, so it raises instead.
145
- # UnpitchedSound.get(nil) is the generic sound, so nil is excluded here to
146
- # preserve nil-as-rest at the argument level and nil-raises inside arrays.
147
- def unpitched_sound(value)
148
- return nil if value.nil?
149
-
150
- sound = HeadMusic::Rudiment::UnpitchedSound.get(value)
151
- return nil unless sound&.instrument
152
- return nil if sound.instrument.pitched?
153
-
154
- sound
155
- end
156
-
157
- def unknown_sound_message(value)
158
- if HeadMusic::Instruments::Instrument.get(value)&.pitched?
159
- "#{value.inspect} is a pitched instrument; place a pitch such as \"D4\", " \
160
- "or pass HeadMusic::Rudiment::UnpitchedSound.get(#{value.inspect}) for a percussive hit"
161
- else
162
- "unknown sound: #{value.inspect}"
163
- end
132
+ @sounds = HeadMusic::Content::SoundResolver.resolve(sound_or_sounds)
164
133
  end
165
134
 
166
135
  def ensure_position(position)
@@ -0,0 +1,54 @@
1
+ # A module for musical content
2
+ module HeadMusic::Content; end
3
+
4
+ # Resolves the raw sound argument(s) passed to Placement.new into a frozen,
5
+ # de-duplicated array of sound objects. Each value may be a Pitch, an
6
+ # UnpitchedSound, an Instrument (resolved to its percussive hit), or a name
7
+ # resolvable to one of those; an unresolvable name raises with guidance.
8
+ class HeadMusic::Content::SoundResolver
9
+ def self.resolve(sound_or_sounds)
10
+ new.resolve(sound_or_sounds)
11
+ end
12
+
13
+ def resolve(sound_or_sounds)
14
+ return [].freeze if sound_or_sounds.nil?
15
+
16
+ values = sound_or_sounds.is_a?(Array) ? sound_or_sounds : [sound_or_sounds]
17
+ values.map { |value| resolve_sound(value) }.uniq.freeze
18
+ end
19
+
20
+ private
21
+
22
+ def resolve_sound(value)
23
+ return value if value.is_a?(HeadMusic::Rudiment::UnpitchedSound)
24
+ return HeadMusic::Rudiment::UnpitchedSound.get(value) if value.is_a?(HeadMusic::Instruments::Instrument)
25
+
26
+ pitch = HeadMusic::Rudiment::Pitch.get(value)
27
+ return pitch if pitch
28
+
29
+ unpitched_sound(value) || raise(ArgumentError, unknown_sound_message(value))
30
+ end
31
+
32
+ # A bare name resolves to a percussive hit only on an unpitched instrument;
33
+ # naming a pitched instrument is ambiguous, so it raises instead.
34
+ # UnpitchedSound.get(nil) is the generic sound, so nil is excluded here to
35
+ # preserve nil-as-rest at the argument level and nil-raises inside arrays.
36
+ def unpitched_sound(value)
37
+ return nil if value.nil?
38
+
39
+ sound = HeadMusic::Rudiment::UnpitchedSound.get(value)
40
+ return nil unless sound&.instrument
41
+ return nil if sound.instrument.pitched?
42
+
43
+ sound
44
+ end
45
+
46
+ def unknown_sound_message(value)
47
+ if HeadMusic::Instruments::Instrument.get(value)&.pitched?
48
+ "#{value.inspect} is a pitched instrument; place a pitch such as \"D4\", " \
49
+ "or pass HeadMusic::Rudiment::UnpitchedSound.get(#{value.inspect}) for a percussive hit"
50
+ else
51
+ "unknown sound: #{value.inspect}"
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,32 @@
1
+ class HeadMusic::Content::Voice
2
+ # The melodic view of a voice: its notes read as the sequence of consecutive
3
+ # pairs, the interval spanned by each pair, and the leaps among them. Built
4
+ # from an ordered list of notes and memoizes the pairs, so callers sharing a
5
+ # MelodicLine see the same pair objects.
6
+ class MelodicLine
7
+ attr_reader :notes
8
+
9
+ def initialize(notes)
10
+ @notes = notes
11
+ end
12
+
13
+ def melodic_note_pairs
14
+ @melodic_note_pairs ||= notes.each_cons(2).map do |first_note, second_note|
15
+ MelodicNotePair.new(first_note, second_note)
16
+ end
17
+ end
18
+
19
+ def melodic_intervals
20
+ @melodic_intervals ||=
21
+ melodic_note_pairs.map { |note_pair| HeadMusic::Analysis::MelodicInterval.new(*note_pair.notes) }
22
+ end
23
+
24
+ def leaps
25
+ melodic_note_pairs.select(&:leap?)
26
+ end
27
+
28
+ def large_leaps
29
+ melodic_note_pairs.select(&:large_leap?)
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,33 @@
1
+ class HeadMusic::Content::Voice
2
+ # A pair of consecutive notes in a melodic line, used to analyze intervals and leaps.
3
+ class MelodicNotePair
4
+ attr_reader :first_note, :second_note
5
+
6
+ delegate(
7
+ :octave?, :unison?,
8
+ :perfect?,
9
+ :step?, :leap?, :large_leap?,
10
+ :ascending?, :descending?, :repetition?,
11
+ :spans?,
12
+ :spells_consonant_triad_with?,
13
+ to: :melodic_interval
14
+ )
15
+
16
+ def initialize(first_note, second_note)
17
+ @first_note = first_note
18
+ @second_note = second_note
19
+ end
20
+
21
+ def notes
22
+ @notes ||= [first_note, second_note]
23
+ end
24
+
25
+ def pitches
26
+ @pitches ||= notes.map(&:pitch)
27
+ end
28
+
29
+ def melodic_interval
30
+ @melodic_interval ||= HeadMusic::Analysis::MelodicInterval.new(*notes)
31
+ end
32
+ end
33
+ end
@@ -62,24 +62,11 @@ class HeadMusic::Content::Voice
62
62
  HeadMusic::Analysis::DiatonicInterval.new(lowest_pitch, highest_pitch)
63
63
  end
64
64
 
65
- def melodic_note_pairs
66
- @melodic_note_pairs ||= notes.each_cons(2).map do |first_note, second_note|
67
- HeadMusic::Content::Voice::MelodicNotePair.new(first_note, second_note)
68
- end
69
- end
70
-
71
- def melodic_intervals
72
- @melodic_intervals ||=
73
- melodic_note_pairs.map { |note_pair| HeadMusic::Analysis::MelodicInterval.new(*note_pair.notes) }
65
+ def melodic_line
66
+ @melodic_line ||= MelodicLine.new(notes)
74
67
  end
75
68
 
76
- def leaps
77
- melodic_note_pairs.select(&:leap?)
78
- end
79
-
80
- def large_leaps
81
- melodic_note_pairs.select(&:large_leap?)
82
- end
69
+ delegate :melodic_note_pairs, :melodic_intervals, :leaps, :large_leaps, to: :melodic_line
83
70
 
84
71
  def cantus_firmus?
85
72
  role.to_s =~ /cantus.?firmus/i
@@ -129,10 +116,12 @@ class HeadMusic::Content::Voice
129
116
  first = placements.first
130
117
  return unless first
131
118
 
132
- return [bar_start_position(first), first] unless first.position.count == 1 && first.position.tick.zero?
119
+ first_position = first.position
120
+ return [bar_start_position(first), first] unless first_position.count == 1 && first_position.tick.zero?
133
121
 
134
122
  placements.each_cons(2) do |previous, current|
135
- return [previous.next_position, current] unless current.position == previous.next_position
123
+ expected_position = previous.next_position
124
+ return [expected_position, current] unless current.position == expected_position
136
125
  end
137
126
  nil
138
127
  end
@@ -170,44 +159,4 @@ class HeadMusic::Content::Voice
170
159
  def pitches_string
171
160
  pitches.first(10).map(&:to_s).join(" ")
172
161
  end
173
-
174
- # A pair of consecutive notes in a melodic line, used to analyze intervals and leaps.
175
- class MelodicNotePair
176
- attr_reader :first_note, :second_note
177
-
178
- delegate(
179
- :octave?, :unison?,
180
- :perfect?,
181
- :step?, :leap?, :large_leap?,
182
- :ascending?, :descending?, :repetition?,
183
- :spans?,
184
- to: :melodic_interval
185
- )
186
-
187
- def initialize(first_note, second_note)
188
- @first_note = first_note
189
- @second_note = second_note
190
- end
191
-
192
- def notes
193
- @notes ||= [first_note, second_note]
194
- end
195
-
196
- def pitches
197
- @pitches ||= notes.map(&:pitch)
198
- end
199
-
200
- def melodic_interval
201
- @melodic_interval ||= HeadMusic::Analysis::MelodicInterval.new(*notes)
202
- end
203
-
204
- def spells_consonant_triad_with?(other_note_pair)
205
- return false if step? || other_note_pair.step?
206
-
207
- combined_pitches = (pitches + other_note_pair.pitches).uniq
208
- return false if combined_pitches.length < 3
209
-
210
- HeadMusic::Analysis::PitchCollection.new(combined_pitches).consonant_triad?
211
- end
212
- end
213
162
  end
@@ -14,10 +14,14 @@ module HeadMusic::Instruments; end
14
14
  # - Missing elements are treated as 0 (no change)
15
15
  # - Extra elements are ignored
16
16
  class HeadMusic::Instruments::AlternateTuning
17
+ include HeadMusic::ValueEquality
18
+
17
19
  TUNINGS = YAML.load_file(File.expand_path("alternate_tunings.yml", __dir__)).freeze
18
20
 
19
21
  attr_reader :instrument_key, :name_key, :semitones
20
22
 
23
+ value_equality :instrument_key, :name_key
24
+
21
25
  class << self
22
26
  # Get an alternate tuning by instrument and name
23
27
  # @param instrument [HeadMusic::Instruments::Instrument, String, Symbol] The instrument
@@ -90,12 +94,6 @@ class HeadMusic::Instruments::AlternateTuning
90
94
  stringing.pitches_with_tuning(self)
91
95
  end
92
96
 
93
- def ==(other)
94
- return false unless other.is_a?(self.class)
95
-
96
- instrument_key == other.instrument_key && name_key == other.name_key
97
- end
98
-
99
97
  def to_s
100
98
  "#{name} (#{instrument_key})"
101
99
  end
@@ -0,0 +1,18 @@
1
+ module HeadMusic::Instruments; end
2
+
3
+ # Shared lookup of a catalog entry's key by a localized (translated) name.
4
+ # Including classes provide #catalog, a Hash of name_key => record whose keys
5
+ # match the I18n keys under "head_music.instruments".
6
+ module HeadMusic::Instruments::CatalogLookup
7
+ private
8
+
9
+ def key_for_name(name)
10
+ catalog.each do |key, _data|
11
+ I18n.config.available_locales.each do |locale|
12
+ translation = I18n.t("head_music.instruments.#{key}", locale: locale)
13
+ return key if translation.downcase == name.downcase
14
+ end
15
+ end
16
+ nil
17
+ end
18
+ end
@@ -114,62 +114,27 @@ class HeadMusic::Instruments::Instrument
114
114
  HeadMusic::Notation::NotationStyle.get(style).notation_for(self)
115
115
  end
116
116
 
117
- # Staff schemes are a notation concern; they now live in NotationStyle.
118
- # These methods remain for backward compatibility and delegate to the
119
- # default style. Referenced only inside method bodies, because the Notation
120
- # module loads after Instruments (see head_music.rb load order).
121
- def staff_schemes
122
- [default_staff_scheme]
123
- end
124
-
125
- def default_staff_scheme
126
- @default_staff_scheme ||= HeadMusic::Instruments::StaffScheme.new(
127
- key: "default",
128
- instrument: self,
129
- list: default_notation_staves_data
130
- )
131
- end
132
-
133
- def default_staves
134
- default_staff_scheme.staves
135
- end
136
-
137
- def default_clefs
138
- default_staves&.map(&:clef) || []
139
- end
140
-
141
- def sounding_transposition
142
- default_staves&.first&.sounding_transposition || 0
143
- end
117
+ # Staff schemes are a notation concern; StaffProfile projects the default
118
+ # NotationStyle onto the staff-derived properties below. These delegators
119
+ # remain because callers ask an instrument directly whether it transposes,
120
+ # how many staves it uses, and so on.
121
+ delegate(
122
+ :staff_schemes, :default_staff_scheme, :default_staves, :default_clefs,
123
+ :sounding_transposition, :transposing?, :transposing_at_the_octave?,
124
+ :single_staff?, :multiple_staves?, :pitched?,
125
+ to: :staff_profile
126
+ )
144
127
 
145
128
  alias_method :default_sounding_transposition, :sounding_transposition
146
129
 
147
- def transposing?
148
- sounding_transposition != 0
149
- end
150
-
151
- def transposing_at_the_octave?
152
- transposing? && sounding_transposition % 12 == 0
153
- end
154
-
155
- def single_staff?
156
- default_staves.length == 1
157
- end
158
-
159
- def multiple_staves?
160
- default_staves.length > 1
161
- end
162
-
163
- def pitched?
164
- return false if default_clefs.compact.uniq == [HeadMusic::Rudiment::Clef.get("neutral_clef")]
165
-
166
- default_clefs.any?
130
+ def staff_profile
131
+ @staff_profile ||= HeadMusic::Instruments::StaffProfile.new(self)
167
132
  end
168
133
 
169
134
  def translation(locale = :en)
170
135
  return name unless name_key
171
136
 
172
- I18n.translate(name_key, scope: %i[head_music instruments], locale: locale, default: name)
137
+ HeadMusic::Instruments::InstrumentName.translate(name_key, locale: locale, default: name)
173
138
  end
174
139
 
175
140
  def ==(other)
@@ -214,7 +179,7 @@ class HeadMusic::Instruments::Instrument
214
179
  private
215
180
 
216
181
  def initialize(name)
217
- record = record_for_name(name)
182
+ record = HeadMusic::Instruments::InstrumentCatalog.new(INSTRUMENTS).record_for(name)
218
183
  if record
219
184
  initialize_data_from_record(record)
220
185
  else
@@ -224,39 +189,6 @@ class HeadMusic::Instruments::Instrument
224
189
  end
225
190
  end
226
191
 
227
- def record_for_name(name)
228
- record_for_key(HeadMusic::Utilities::HashKey.for(name)) ||
229
- record_for_key(key_for_name(name)) ||
230
- record_for_alias(name)
231
- end
232
-
233
- def key_for_name(name)
234
- INSTRUMENTS.each do |key, _data|
235
- I18n.config.available_locales.each do |locale|
236
- translation = I18n.t("head_music.instruments.#{key}", locale: locale)
237
- return key if translation.downcase == name.downcase
238
- end
239
- end
240
- nil
241
- end
242
-
243
- def record_for_key(key)
244
- INSTRUMENTS.each do |name_key, data|
245
- return data.merge("name_key" => name_key) if name_key.to_s == key.to_s
246
- end
247
- nil
248
- end
249
-
250
- def record_for_alias(name)
251
- normalized_name = HeadMusic::Utilities::HashKey.for(name).to_s
252
- INSTRUMENTS.each do |name_key, data|
253
- data["alias_name_keys"]&.each do |alias_key|
254
- return data.merge("name_key" => name_key) if HeadMusic::Utilities::HashKey.for(alias_key).to_s == normalized_name
255
- end
256
- end
257
- nil
258
- end
259
-
260
192
  def initialize_data_from_record(record)
261
193
  @name_key = record["name_key"].to_sym
262
194
  @parent_key = record["parent_key"]&.to_sym
@@ -269,34 +201,9 @@ class HeadMusic::Instruments::Instrument
269
201
  end
270
202
 
271
203
  def initialize_name
272
- # Try to get a translation first
273
- base_name = I18n.translate(name_key, scope: "head_music.instruments", locale: "en", default: nil)
274
-
275
- if base_name
276
- # Use the translation as-is
277
- self.name = base_name
278
- elsif parent_key && pitch_key
279
- # Build name from parent + pitch for child instruments
280
- pitch_name = format_pitch_name(pitch_key_to_designation)
281
- self.name = "#{parent_translation} in #{pitch_name}"
282
- else
283
- # Fall back to inferred name
284
- self.name = inferred_name
285
- end
286
- end
287
-
288
- def parent_translation
289
- return nil unless parent_key
290
-
291
- I18n.translate(parent_key, scope: "head_music.instruments", locale: "en", default: parent_key.to_s.tr("_", " "))
292
- end
293
-
294
- def inferred_name
295
- name_key.to_s.tr("_", " ")
296
- end
297
-
298
- def format_pitch_name(pitch_designation)
299
- pitch_designation.to_s.tr("b", "♭").tr("#", "♯")
204
+ self.name = HeadMusic::Instruments::InstrumentName.new(
205
+ name_key: name_key, parent_key: parent_key, pitch_designation: pitch_key_to_designation
206
+ ).to_s
300
207
  end
301
208
 
302
209
  # Convert pitch_key (e.g., "b_flat") to designation format (e.g., "Bb")
@@ -313,11 +220,4 @@ class HeadMusic::Instruments::Instrument
313
220
  pitch_key_str.upcase
314
221
  end
315
222
  end
316
-
317
- # The raw staff-attribute list for this instrument's default notation,
318
- # resolved from the default NotationStyle.
319
- def default_notation_staves_data
320
- notation = HeadMusic::Notation::NotationStyle.default.notation_for(self)
321
- (notation&.staves || []).map(&:attributes)
322
- end
323
223
  end
@@ -0,0 +1,48 @@
1
+ # A module for musical instruments and their properties
2
+ module HeadMusic::Instruments; end
3
+
4
+ # Finds an instrument's catalog record by key, localized name, or alias.
5
+ # Wraps the instruments.yml data (a Hash of name_key => record) so an
6
+ # Instrument need not know how a raw record is located, only how to build
7
+ # itself from one.
8
+ class HeadMusic::Instruments::InstrumentCatalog
9
+ include HeadMusic::Instruments::CatalogLookup
10
+
11
+ def initialize(records)
12
+ @records = records
13
+ end
14
+
15
+ # The record matching a name given as a key, a localized (translated) name,
16
+ # or an alias, with its own name_key merged in; nil when nothing matches.
17
+ def record_for(name)
18
+ record_for_key(HeadMusic::Utilities::HashKey.for(name)) ||
19
+ record_for_key(key_for_name(name)) ||
20
+ record_for_alias(name)
21
+ end
22
+
23
+ private
24
+
25
+ attr_reader :records
26
+
27
+ # CatalogLookup hook: the hash its #key_for_name searches by translation.
28
+ def catalog
29
+ records
30
+ end
31
+
32
+ def record_for_key(key)
33
+ records.each do |name_key, data|
34
+ return data.merge("name_key" => name_key) if name_key.to_s == key.to_s
35
+ end
36
+ nil
37
+ end
38
+
39
+ def record_for_alias(name)
40
+ normalized_name = HeadMusic::Utilities::HashKey.for(name).to_s
41
+ records.each do |name_key, data|
42
+ data["alias_name_keys"]&.each do |alias_key|
43
+ return data.merge("name_key" => name_key) if HeadMusic::Utilities::HashKey.for(alias_key).to_s == normalized_name
44
+ end
45
+ end
46
+ nil
47
+ end
48
+ end
@@ -7,10 +7,14 @@ module HeadMusic::Instruments; end
7
7
  # - Trumpet "mute" configuration with options: open (default), straight, cup, harmon
8
8
  # - Bass trombone "f_attachment" with options: disengaged (default), engaged
9
9
  class HeadMusic::Instruments::InstrumentConfiguration
10
+ include HeadMusic::ValueEquality
11
+
10
12
  CONFIGURATIONS = YAML.load_file(File.expand_path("instrument_configurations.yml", __dir__)).freeze
11
13
 
12
14
  attr_reader :name_key, :instrument_key, :options
13
15
 
16
+ value_equality :name_key, :instrument_key
17
+
14
18
  class << self
15
19
  def for_instrument(instrument_key)
16
20
  instrument_key = instrument_key.to_s
@@ -40,12 +44,6 @@ class HeadMusic::Instruments::InstrumentConfiguration
40
44
  options.find { |opt| opt.name_key == option_key.to_sym }
41
45
  end
42
46
 
43
- def ==(other)
44
- return false unless other.is_a?(self.class)
45
-
46
- name_key == other.name_key && instrument_key == other.instrument_key
47
- end
48
-
49
47
  def to_s
50
48
  name_key.to_s
51
49
  end
@@ -17,6 +17,7 @@ module HeadMusic::Instruments; end
17
17
  # @see HeadMusic::Instruments::Instrument
18
18
  class HeadMusic::Instruments::InstrumentFamily
19
19
  include HeadMusic::Named
20
+ include HeadMusic::Instruments::CatalogLookup
20
21
 
21
22
  INSTRUMENT_FAMILIES =
22
23
  YAML.load_file(File.expand_path("instrument_families.yml", __dir__)).freeze
@@ -52,14 +53,8 @@ class HeadMusic::Instruments::InstrumentFamily
52
53
  record_for_key(key_for_name(name))
53
54
  end
54
55
 
55
- def key_for_name(name)
56
- INSTRUMENT_FAMILIES.each do |key, _data|
57
- I18n.config.available_locales.each do |locale|
58
- translation = I18n.t("head_music.instruments.#{key}", locale: locale)
59
- return key if translation.downcase == name.downcase
60
- end
61
- end
62
- nil
56
+ def catalog
57
+ INSTRUMENT_FAMILIES
63
58
  end
64
59
 
65
60
  def record_for_key(key)
@@ -0,0 +1,49 @@
1
+ # A module for musical instruments and their properties
2
+ module HeadMusic::Instruments; end
3
+
4
+ # Builds an instrument's localized display name from its catalog keys.
5
+ #
6
+ # The name is, in order of preference:
7
+ # 1. an explicit translation of the instrument's own key,
8
+ # 2. a name composed from the parent instrument and pitch for a child
9
+ # instrument (e.g. "Clarinet in B♭"), or
10
+ # 3. a plain inference from the key ("bass_clarinet" -> "bass clarinet").
11
+ class HeadMusic::Instruments::InstrumentName
12
+ # Localized name for an instrument key under the head_music.instruments scope.
13
+ def self.translate(key, locale: "en", default: nil)
14
+ I18n.translate(key, scope: %i[head_music instruments], locale: locale, default: default)
15
+ end
16
+
17
+ def initialize(name_key:, parent_key:, pitch_designation:)
18
+ @name_key = name_key
19
+ @parent_key = parent_key
20
+ @pitch_designation = pitch_designation
21
+ end
22
+
23
+ def to_s
24
+ self.class.translate(name_key) || child_instrument_name || inferred_name
25
+ end
26
+
27
+ private
28
+
29
+ attr_reader :name_key, :parent_key, :pitch_designation
30
+
31
+ # Name built from parent + pitch for child instruments, e.g. "Clarinet in B♭".
32
+ def child_instrument_name
33
+ return nil unless parent_key && pitch_designation
34
+
35
+ "#{parent_translation} in #{format_pitch_name(pitch_designation)}"
36
+ end
37
+
38
+ def parent_translation
39
+ self.class.translate(parent_key, default: parent_key.to_s.tr("_", " "))
40
+ end
41
+
42
+ def inferred_name
43
+ name_key.to_s.tr("_", " ")
44
+ end
45
+
46
+ def format_pitch_name(pitch_designation)
47
+ pitch_designation.to_s.tr("b", "♭").tr("#", "♯")
48
+ end
49
+ end