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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +15 -0
- data/Gemfile.lock +1 -1
- data/lib/head_music/analysis/chord_analysis.rb +123 -0
- data/lib/head_music/analysis/circle.rb +11 -3
- data/lib/head_music/analysis/diatonic_interval.rb +14 -11
- data/lib/head_music/analysis/dyad/chord_implication.rb +101 -0
- data/lib/head_music/analysis/dyad.rb +24 -104
- data/lib/head_music/analysis/interval_cycle.rb +9 -3
- data/lib/head_music/analysis/pitch_collection.rb +33 -107
- data/lib/head_music/analysis/sonority.rb +11 -21
- data/lib/head_music/content/composition/hash_deserializer.rb +110 -0
- data/lib/head_music/content/composition/schema_values.rb +127 -0
- data/lib/head_music/content/composition.rb +0 -215
- data/lib/head_music/content/placement.rb +11 -42
- data/lib/head_music/content/sound_resolver.rb +54 -0
- data/lib/head_music/content/voice/melodic_line.rb +32 -0
- data/lib/head_music/content/voice/melodic_note_pair.rb +33 -0
- data/lib/head_music/content/voice.rb +7 -58
- data/lib/head_music/instruments/alternate_tuning.rb +4 -6
- data/lib/head_music/instruments/catalog_lookup.rb +18 -0
- data/lib/head_music/instruments/instrument.rb +17 -117
- data/lib/head_music/instruments/instrument_catalog.rb +48 -0
- data/lib/head_music/instruments/instrument_configuration.rb +4 -6
- data/lib/head_music/instruments/instrument_family.rb +3 -8
- data/lib/head_music/instruments/instrument_name.rb +49 -0
- data/lib/head_music/instruments/score_order.rb +52 -42
- data/lib/head_music/instruments/staff_profile.rb +71 -0
- data/lib/head_music/instruments/stringing.rb +4 -6
- data/lib/head_music/instruments/stringing_course.rb +4 -6
- data/lib/head_music/instruments/variant.rb +4 -6
- data/lib/head_music/named/locale.rb +32 -0
- data/lib/head_music/named/localized_name.rb +15 -0
- data/lib/head_music/named.rb +1 -45
- data/lib/head_music/notation/abc/body_lexer.rb +65 -24
- data/lib/head_music/notation/abc/duration_resolver.rb +11 -1
- data/lib/head_music/notation/abc/duration_writer.rb +1 -8
- data/lib/head_music/notation/abc/header.rb +27 -16
- data/lib/head_music/notation/abc/parser.rb +48 -159
- data/lib/head_music/notation/abc/preflight.rb +48 -0
- data/lib/head_music/notation/abc/voice_state.rb +161 -0
- data/lib/head_music/notation/abc/writer.rb +47 -16
- data/lib/head_music/notation/dotted_duration.rb +19 -0
- data/lib/head_music/notation/instrument_notation.rb +5 -7
- data/lib/head_music/notation/music_xml/beam_grouper.rb +108 -0
- data/lib/head_music/notation/music_xml/duration_writer.rb +3 -8
- data/lib/head_music/notation/music_xml/preflight.rb +90 -0
- data/lib/head_music/notation/music_xml/render_plan.rb +161 -0
- data/lib/head_music/notation/music_xml/writer.rb +31 -146
- data/lib/head_music/notation/placement_validation.rb +19 -0
- data/lib/head_music/rudiment/enharmonic_equivalence.rb +33 -0
- data/lib/head_music/rudiment/key.rb +8 -36
- data/lib/head_music/rudiment/key_signature/enharmonic_equivalence.rb +21 -15
- data/lib/head_music/rudiment/key_signature.rb +18 -19
- data/lib/head_music/rudiment/meter.rb +39 -6
- data/lib/head_music/rudiment/mode.rb +33 -65
- data/lib/head_music/rudiment/note.rb +18 -21
- data/lib/head_music/rudiment/pitch/enharmonic_equivalence.rb +4 -15
- data/lib/head_music/rudiment/pitch/natural_step.rb +36 -0
- data/lib/head_music/rudiment/pitch/parser.rb +16 -5
- data/lib/head_music/rudiment/pitch.rb +11 -33
- data/lib/head_music/rudiment/qualified_diatonic_context.rb +61 -0
- data/lib/head_music/rudiment/rhythmic_unit/parser.rb +16 -12
- data/lib/head_music/rudiment/rhythmic_unit.rb +21 -37
- data/lib/head_music/rudiment/rhythmic_value/parser.rb +53 -46
- data/lib/head_music/rudiment/spelling/enharmonic_equivalence.rb +13 -0
- data/lib/head_music/rudiment/spelling.rb +0 -25
- data/lib/head_music/rudiment/tuning/just_intonation.rb +2 -4
- data/lib/head_music/rudiment/tuning/meantone.rb +2 -4
- data/lib/head_music/rudiment/tuning/pythagorean.rb +2 -4
- data/lib/head_music/rudiment/tuning/tonal_centered.rb +10 -0
- data/lib/head_music/rudiment/unpitched_note.rb +3 -5
- data/lib/head_music/style/annotation/configured.rb +35 -0
- data/lib/head_music/style/annotation.rb +32 -44
- data/lib/head_music/style/guidelines/allowed_rhythmic_values_for_fifth_species.rb +0 -10
- data/lib/head_music/style/guidelines/contoured.rb +65 -53
- data/lib/head_music/style/guidelines/dissonance_figure_detection.rb +55 -16
- data/lib/head_music/style/guidelines/florid_dissonance_treatment.rb +0 -37
- data/lib/head_music/style/guidelines/large_leaps.rb +10 -2
- data/lib/head_music/style/guidelines/minimum_melodic_intervals.rb +3 -25
- data/lib/head_music/style/guidelines/minimum_notes.rb +3 -25
- data/lib/head_music/style/guidelines/minimum_threshold.rb +38 -0
- data/lib/head_music/style/guidelines/no_parallel_perfect.rb +31 -0
- data/lib/head_music/style/guidelines/no_parallel_perfect_on_downbeats.rb +3 -17
- data/lib/head_music/style/guidelines/no_parallel_perfect_with_syncopation.rb +3 -17
- data/lib/head_music/style/guidelines/prepare_octave_leaps.rb +10 -7
- data/lib/head_music/style/guidelines/singable_intervals.rb +22 -8
- data/lib/head_music/style/guidelines/suspension_treatment.rb +30 -12
- data/lib/head_music/style/guidelines/third_species_dissonance_treatment.rb +0 -7
- data/lib/head_music/style/guidelines/triple_meter_dissonance_treatment.rb +0 -12
- data/lib/head_music/style/guidelines/weak_beat_dissonance_treatment.rb +2 -42
- data/lib/head_music/style/guides/fifth_species_melody.rb +3 -13
- data/lib/head_music/style/guides/fourth_species_melody.rb +3 -13
- data/lib/head_music/style/guides/second_species_harmony.rb +2 -5
- data/lib/head_music/style/guides/second_species_melody.rb +2 -12
- data/lib/head_music/style/guides/species_harmony.rb +14 -0
- data/lib/head_music/style/guides/species_melody.rb +21 -0
- data/lib/head_music/style/guides/third_species_harmony.rb +2 -5
- data/lib/head_music/style/guides/third_species_melody.rb +3 -13
- data/lib/head_music/style/guides/third_species_triple_meter_harmony.rb +2 -5
- data/lib/head_music/style/guides/third_species_triple_meter_melody.rb +2 -12
- data/lib/head_music/time/conductor.rb +19 -125
- data/lib/head_music/time/musical_position.rb +26 -26
- data/lib/head_music/time/musical_time_converter.rb +104 -0
- data/lib/head_music/time/smpte_converter.rb +51 -0
- data/lib/head_music/time/smpte_timecode.rb +25 -23
- data/lib/head_music/time.rb +2 -0
- data/lib/head_music/value_equality.rb +37 -0
- data/lib/head_music/version.rb +1 -1
- data/lib/head_music.rb +25 -1
- data/user-stories/done/abc-notation-beaming.md +235 -0
- data/user-stories/index.html +7 -3
- 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 =
|
|
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
|
|
66
|
-
@
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
|
118
|
-
#
|
|
119
|
-
#
|
|
120
|
-
#
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
|
148
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
273
|
-
|
|
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
|
|
56
|
-
INSTRUMENT_FAMILIES
|
|
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
|