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.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +9 -3
- data/CHANGELOG.md +71 -0
- data/CLAUDE.md +62 -25
- data/Gemfile +7 -1
- data/Gemfile.lock +91 -3
- data/MUSIC_THEORY.md +120 -0
- data/README.md +18 -0
- data/Rakefile +7 -2
- data/head_music.gemspec +1 -1
- data/lib/head_music/analysis/diatonic_interval.rb +29 -27
- data/lib/head_music/analysis/dyad.rb +229 -0
- data/lib/head_music/analysis/interval_consonance.rb +51 -0
- data/lib/head_music/analysis/melodic_interval.rb +1 -1
- data/lib/head_music/analysis/pitch_class_set.rb +111 -14
- data/lib/head_music/analysis/{pitch_set.rb → pitch_collection.rb} +11 -5
- data/lib/head_music/analysis/sonority.rb +50 -12
- data/lib/head_music/content/note.rb +1 -1
- data/lib/head_music/content/placement.rb +1 -1
- data/lib/head_music/content/position.rb +1 -1
- data/lib/head_music/content/voice.rb +1 -1
- data/lib/head_music/instruments/alternate_tuning.rb +102 -0
- data/lib/head_music/instruments/alternate_tunings.yml +78 -0
- data/lib/head_music/instruments/instrument.rb +231 -72
- data/lib/head_music/instruments/instrument_configuration.rb +66 -0
- data/lib/head_music/instruments/instrument_configuration_option.rb +38 -0
- data/lib/head_music/instruments/instrument_configurations.yml +288 -0
- data/lib/head_music/instruments/instrument_families.yml +77 -0
- data/lib/head_music/instruments/instrument_family.rb +15 -5
- data/lib/head_music/instruments/instruments.yml +795 -965
- data/lib/head_music/instruments/playing_technique.rb +75 -0
- data/lib/head_music/instruments/playing_techniques.yml +826 -0
- data/lib/head_music/instruments/score_order.rb +136 -0
- data/lib/head_music/instruments/score_orders.yml +130 -0
- data/lib/head_music/instruments/staff.rb +61 -1
- data/lib/head_music/instruments/staff_scheme.rb +6 -4
- data/lib/head_music/instruments/stringing.rb +115 -0
- data/lib/head_music/instruments/stringing_course.rb +58 -0
- data/lib/head_music/instruments/stringings.yml +168 -0
- data/lib/head_music/instruments/variant.rb +6 -1
- data/lib/head_music/locales/de.yml +29 -0
- data/lib/head_music/locales/en.yml +106 -0
- data/lib/head_music/locales/es.yml +29 -0
- data/lib/head_music/locales/fr.yml +29 -0
- data/lib/head_music/locales/it.yml +29 -0
- data/lib/head_music/locales/ru.yml +29 -0
- data/lib/head_music/{rudiment → notation}/musical_symbol.rb +3 -3
- data/lib/head_music/notation/staff_mapping.rb +70 -0
- data/lib/head_music/notation/staff_position.rb +62 -0
- data/lib/head_music/notation.rb +7 -0
- data/lib/head_music/rudiment/alteration.rb +34 -49
- data/lib/head_music/rudiment/alterations.yml +32 -0
- data/lib/head_music/rudiment/base.rb +9 -0
- data/lib/head_music/rudiment/chromatic_interval.rb +4 -7
- data/lib/head_music/rudiment/clef.rb +2 -2
- data/lib/head_music/rudiment/consonance.rb +39 -5
- data/lib/head_music/rudiment/diatonic_context.rb +25 -0
- data/lib/head_music/rudiment/key.rb +77 -0
- data/lib/head_music/rudiment/key_signature/enharmonic_equivalence.rb +1 -1
- data/lib/head_music/rudiment/key_signature.rb +21 -8
- data/lib/head_music/rudiment/letter_name.rb +3 -3
- data/lib/head_music/rudiment/meter.rb +19 -9
- data/lib/head_music/rudiment/mode.rb +92 -0
- data/lib/head_music/rudiment/note.rb +112 -0
- data/lib/head_music/rudiment/pitch/parser.rb +52 -0
- data/lib/head_music/rudiment/pitch.rb +5 -6
- data/lib/head_music/rudiment/pitch_class.rb +1 -1
- data/lib/head_music/rudiment/quality.rb +1 -1
- data/lib/head_music/rudiment/reference_pitch.rb +1 -1
- data/lib/head_music/rudiment/register.rb +4 -1
- data/lib/head_music/rudiment/rest.rb +36 -0
- data/lib/head_music/rudiment/rhythmic_element.rb +53 -0
- data/lib/head_music/rudiment/rhythmic_unit/parser.rb +86 -0
- data/lib/head_music/rudiment/rhythmic_unit.rb +13 -5
- data/lib/head_music/rudiment/rhythmic_units.yml +80 -0
- data/lib/head_music/rudiment/rhythmic_value/parser.rb +77 -0
- data/lib/head_music/{content → rudiment}/rhythmic_value.rb +23 -5
- data/lib/head_music/rudiment/scale.rb +4 -5
- data/lib/head_music/rudiment/scale_degree.rb +1 -1
- data/lib/head_music/rudiment/scale_type.rb +9 -3
- data/lib/head_music/rudiment/solmization.rb +1 -1
- data/lib/head_music/rudiment/spelling.rb +8 -4
- data/lib/head_music/rudiment/tempo.rb +85 -0
- data/lib/head_music/rudiment/tonal_context.rb +35 -0
- data/lib/head_music/rudiment/tuning/just_intonation.rb +0 -39
- data/lib/head_music/rudiment/tuning/meantone.rb +0 -39
- data/lib/head_music/rudiment/tuning/pythagorean.rb +0 -39
- data/lib/head_music/rudiment/tuning.rb +21 -1
- data/lib/head_music/rudiment/unpitched_note.rb +62 -0
- data/lib/head_music/style/guidelines/consonant_climax.rb +2 -2
- data/lib/head_music/style/medieval_tradition.rb +26 -0
- data/lib/head_music/style/modern_tradition.rb +31 -0
- data/lib/head_music/style/renaissance_tradition.rb +26 -0
- data/lib/head_music/style/tradition.rb +21 -0
- data/lib/head_music/time/clock_position.rb +84 -0
- data/lib/head_music/time/conductor.rb +264 -0
- data/lib/head_music/time/meter_event.rb +37 -0
- data/lib/head_music/time/meter_map.rb +173 -0
- data/lib/head_music/time/musical_position.rb +188 -0
- data/lib/head_music/time/smpte_timecode.rb +164 -0
- data/lib/head_music/time/tempo_event.rb +40 -0
- data/lib/head_music/time/tempo_map.rb +187 -0
- data/lib/head_music/time.rb +32 -0
- data/lib/head_music/utilities/case.rb +27 -0
- data/lib/head_music/utilities/hash_key.rb +34 -2
- data/lib/head_music/version.rb +1 -1
- data/lib/head_music.rb +71 -22
- data/user_stories/active/string-pitches.md +41 -0
- data/user_stories/backlog/notation-style.md +183 -0
- data/user_stories/backlog/organizing-content.md +80 -0
- data/user_stories/done/consonance-dissonance-classification.md +117 -0
- data/user_stories/{backlog → done}/dyad-analysis.md +6 -16
- data/user_stories/done/epic--score-order/PLAN.md +244 -0
- data/user_stories/done/expand-playing-techniques.md +38 -0
- data/user_stories/done/handle-time.md +7 -0
- data/user_stories/done/handle-time.rb +163 -0
- data/user_stories/done/instrument-architecture.md +238 -0
- data/user_stories/done/instrument-variant.md +65 -0
- data/user_stories/done/move-musical-symbol-to-notation.md +161 -0
- data/user_stories/done/move-staff-mapping-to-notation.md +158 -0
- data/user_stories/done/move-staff-position-to-notation.md +141 -0
- data/user_stories/done/notation-module-foundation.md +102 -0
- data/user_stories/done/percussion_set.md +260 -0
- data/user_stories/done/sonority-identification.md +37 -0
- data/user_stories/done/superclass-for-note.md +30 -0
- data/user_stories/epics/notation-module.md +135 -0
- data/user_stories/visioning/agentic-daw.md +2 -0
- metadata +84 -18
- data/TODO.md +0 -109
- data/check_instrument_consistency.rb +0 -0
- data/test_translations.rb +0 -15
- data/user_stories/backlog/consonance-dissonance-classification.md +0 -57
- data/user_stories/backlog/pitch-set-classification.md +0 -62
- data/user_stories/backlog/sonority-identification.md +0 -47
- /data/user_stories/{backlog → done/epic--score-order}/band-score-order.md +0 -0
- /data/user_stories/{backlog → done/epic--score-order}/chamber-ensemble-score-order.md +0 -0
- /data/user_stories/{backlog → done/epic--score-order}/orchestral-score-order.md +0 -0
- /data/user_stories/{backlog → done}/pitch-class-set-analysis.md +0 -0
|
@@ -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::
|
|
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::
|
|
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::
|
|
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
|
|
|
@@ -170,7 +170,7 @@ class HeadMusic::Content::Voice
|
|
|
170
170
|
combined_pitches = (pitches + other_note_pair.pitches).uniq
|
|
171
171
|
return false if combined_pitches.length < 3
|
|
172
172
|
|
|
173
|
-
HeadMusic::Analysis::
|
|
173
|
+
HeadMusic::Analysis::PitchCollection.new(combined_pitches).consonant_triad?
|
|
174
174
|
end
|
|
175
175
|
end
|
|
176
176
|
end
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
module HeadMusic::Instruments; end
|
|
2
|
+
|
|
3
|
+
# An alternate tuning for a stringed instrument.
|
|
4
|
+
#
|
|
5
|
+
# Tunings are defined as semitone adjustments from the standard tuning.
|
|
6
|
+
# For example, "Drop D" tuning lowers the low E string by 2 semitones.
|
|
7
|
+
#
|
|
8
|
+
# Examples:
|
|
9
|
+
# drop_d = HeadMusic::Instruments::AlternateTuning.get("guitar", "drop_d")
|
|
10
|
+
# drop_d.semitones # => [-2, 0, 0, 0, 0, 0]
|
|
11
|
+
#
|
|
12
|
+
# When applying a tuning:
|
|
13
|
+
# - First element applies to the lowest course
|
|
14
|
+
# - Missing elements are treated as 0 (no change)
|
|
15
|
+
# - Extra elements are ignored
|
|
16
|
+
class HeadMusic::Instruments::AlternateTuning
|
|
17
|
+
TUNINGS = YAML.load_file(File.expand_path("alternate_tunings.yml", __dir__)).freeze
|
|
18
|
+
|
|
19
|
+
attr_reader :instrument_key, :name_key, :semitones
|
|
20
|
+
|
|
21
|
+
class << self
|
|
22
|
+
# Get an alternate tuning by instrument and name
|
|
23
|
+
# @param instrument [HeadMusic::Instruments::Instrument, String, Symbol] The instrument
|
|
24
|
+
# @param name [String, Symbol] The tuning name (e.g., "drop_d")
|
|
25
|
+
# @return [AlternateTuning, nil]
|
|
26
|
+
def get(instrument, name)
|
|
27
|
+
instrument_key = normalize_instrument_key(instrument)
|
|
28
|
+
name_key = name.to_s
|
|
29
|
+
|
|
30
|
+
data = TUNINGS.dig(instrument_key, name_key)
|
|
31
|
+
return nil unless data
|
|
32
|
+
|
|
33
|
+
new(
|
|
34
|
+
instrument_key: instrument_key,
|
|
35
|
+
name_key: name_key,
|
|
36
|
+
semitones: data["semitones"] || []
|
|
37
|
+
)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Get all alternate tunings for an instrument
|
|
41
|
+
# @param instrument [HeadMusic::Instruments::Instrument, String, Symbol] The instrument
|
|
42
|
+
# @return [Array<AlternateTuning>]
|
|
43
|
+
def for_instrument(instrument)
|
|
44
|
+
instrument_key = normalize_instrument_key(instrument)
|
|
45
|
+
return [] unless TUNINGS.key?(instrument_key)
|
|
46
|
+
|
|
47
|
+
TUNINGS[instrument_key].map do |name_key, data|
|
|
48
|
+
new(
|
|
49
|
+
instrument_key: instrument_key,
|
|
50
|
+
name_key: name_key,
|
|
51
|
+
semitones: data["semitones"] || []
|
|
52
|
+
)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def normalize_instrument_key(instrument)
|
|
59
|
+
case instrument
|
|
60
|
+
when HeadMusic::Instruments::Instrument
|
|
61
|
+
instrument.name_key.to_s
|
|
62
|
+
else
|
|
63
|
+
instrument.to_s
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def initialize(instrument_key:, name_key:, semitones:)
|
|
69
|
+
@instrument_key = instrument_key.to_sym
|
|
70
|
+
@name_key = name_key.to_sym
|
|
71
|
+
@semitones = Array(semitones)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# The instrument this tuning applies to
|
|
75
|
+
# @return [HeadMusic::Instruments::Instrument]
|
|
76
|
+
def instrument
|
|
77
|
+
HeadMusic::Instruments::Instrument.get(instrument_key)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Human-readable name for the tuning
|
|
81
|
+
# @return [String]
|
|
82
|
+
def name
|
|
83
|
+
name_key.to_s.tr("_", " ").split.map(&:capitalize).join(" ")
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Apply this tuning to a stringing's standard pitches
|
|
87
|
+
# @param stringing [Stringing] The stringing to apply to
|
|
88
|
+
# @return [Array<HeadMusic::Rudiment::Pitch>]
|
|
89
|
+
def apply_to(stringing)
|
|
90
|
+
stringing.pitches_with_tuning(self)
|
|
91
|
+
end
|
|
92
|
+
|
|
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
|
+
def to_s
|
|
100
|
+
"#{name} (#{instrument_key})"
|
|
101
|
+
end
|
|
102
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# Alternate tunings for stringed instruments.
|
|
2
|
+
#
|
|
3
|
+
# Each tuning is defined as semitone adjustments from standard tuning.
|
|
4
|
+
# - First element = lowest course
|
|
5
|
+
# - Missing elements = 0 (no change)
|
|
6
|
+
# - Extra elements = ignored
|
|
7
|
+
|
|
8
|
+
guitar:
|
|
9
|
+
drop_d:
|
|
10
|
+
semitones: [-2, 0, 0, 0, 0, 0]
|
|
11
|
+
double_drop_d:
|
|
12
|
+
semitones: [-2, 0, 0, 0, 0, -2]
|
|
13
|
+
dadgad:
|
|
14
|
+
semitones: [-2, 0, 0, 0, -2, -2]
|
|
15
|
+
open_d:
|
|
16
|
+
semitones: [-2, 0, 0, -1, -2, -2]
|
|
17
|
+
open_g:
|
|
18
|
+
semitones: [-2, -2, 0, 0, 0, -2]
|
|
19
|
+
open_e:
|
|
20
|
+
semitones: [0, 2, 2, 1, 0, 0]
|
|
21
|
+
open_a:
|
|
22
|
+
semitones: [0, 0, 2, 2, 2, 0]
|
|
23
|
+
open_c:
|
|
24
|
+
semitones: [-4, -2, 0, 0, 1, 0]
|
|
25
|
+
half_step_down:
|
|
26
|
+
semitones: [-1, -1, -1, -1, -1, -1]
|
|
27
|
+
whole_step_down:
|
|
28
|
+
semitones: [-2, -2, -2, -2, -2, -2]
|
|
29
|
+
drop_c:
|
|
30
|
+
semitones: [-4, -2, -2, -2, -2, -2]
|
|
31
|
+
nashville:
|
|
32
|
+
semitones: [12, 12, 12, 12, 0, 0]
|
|
33
|
+
|
|
34
|
+
bass_guitar:
|
|
35
|
+
drop_d:
|
|
36
|
+
semitones: [-2, 0, 0, 0]
|
|
37
|
+
half_step_down:
|
|
38
|
+
semitones: [-1, -1, -1, -1]
|
|
39
|
+
whole_step_down:
|
|
40
|
+
semitones: [-2, -2, -2, -2]
|
|
41
|
+
drop_c:
|
|
42
|
+
semitones: [-4, -2, -2, -2]
|
|
43
|
+
|
|
44
|
+
five_string_bass:
|
|
45
|
+
standard:
|
|
46
|
+
semitones: [0, 0, 0, 0, 0]
|
|
47
|
+
drop_a:
|
|
48
|
+
semitones: [-2, 0, 0, 0, 0]
|
|
49
|
+
|
|
50
|
+
banjo:
|
|
51
|
+
open_g:
|
|
52
|
+
semitones: [0, 0, 0, 0, 0]
|
|
53
|
+
double_c:
|
|
54
|
+
semitones: [-2, -2, 0, -2, 0]
|
|
55
|
+
sawmill:
|
|
56
|
+
semitones: [-2, -2, 0, 0, -2]
|
|
57
|
+
open_d:
|
|
58
|
+
semitones: [-5, -2, -2, -1, -2]
|
|
59
|
+
|
|
60
|
+
ukulele:
|
|
61
|
+
low_g:
|
|
62
|
+
semitones: [-12, 0, 0, 0]
|
|
63
|
+
baritone:
|
|
64
|
+
semitones: [-5, -5, -5, -5]
|
|
65
|
+
slack_key:
|
|
66
|
+
semitones: [0, 0, -1, 0]
|
|
67
|
+
|
|
68
|
+
violin:
|
|
69
|
+
solo_tuning:
|
|
70
|
+
semitones: [1, 1, 1, 1]
|
|
71
|
+
cross_tuning:
|
|
72
|
+
semitones: [0, 0, -2, 0]
|
|
73
|
+
|
|
74
|
+
cello:
|
|
75
|
+
solo_tuning:
|
|
76
|
+
semitones: [1, 1, 1, 1]
|
|
77
|
+
drop_c:
|
|
78
|
+
semitones: [-2, 0, 0, 0]
|
|
@@ -1,51 +1,104 @@
|
|
|
1
|
-
# Namespace for instrument definitions, categorization, and configuration
|
|
2
1
|
module HeadMusic::Instruments; end
|
|
3
2
|
|
|
4
|
-
# A musical instrument.
|
|
5
|
-
#
|
|
3
|
+
# A musical instrument with parent-based inheritance.
|
|
4
|
+
#
|
|
5
|
+
# Instruments can inherit from parent instruments, allowing for a clean
|
|
6
|
+
# hierarchy where child instruments override specific attributes while
|
|
7
|
+
# inheriting others from their parents.
|
|
8
|
+
#
|
|
9
|
+
# Examples:
|
|
10
|
+
# trumpet = HeadMusic::Instruments::Instrument.get("trumpet")
|
|
11
|
+
# clarinet_in_a = HeadMusic::Instruments::Instrument.get("clarinet_in_a")
|
|
12
|
+
# clarinet_in_a.parent # => clarinet
|
|
13
|
+
# clarinet_in_a.pitch_key # => "a" (own attribute)
|
|
14
|
+
# clarinet_in_a.family_key # => "clarinet" (inherited from parent)
|
|
15
|
+
#
|
|
6
16
|
# Attributes:
|
|
7
|
-
# name_key: the
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
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")
|
|
17
|
+
# name_key: the primary identifier for the instrument
|
|
18
|
+
# parent_key: optional key referencing the parent instrument
|
|
19
|
+
# family_key: the instrument family (e.g., "clarinet", "trumpet")
|
|
20
|
+
# pitch_key: the pitch designation (e.g., "b_flat", "a", "c")
|
|
21
|
+
# alias_name_keys: alternative names for the instrument
|
|
22
|
+
# range_categories: size/range classifications
|
|
23
|
+
# staff_schemes: notation schemes (to be moved to NotationStyle later)
|
|
20
24
|
class HeadMusic::Instruments::Instrument
|
|
21
25
|
include HeadMusic::Named
|
|
22
26
|
|
|
23
27
|
INSTRUMENTS = YAML.load_file(File.expand_path("instruments.yml", __dir__)).freeze
|
|
24
28
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
29
|
+
attr_reader :name_key, :parent_key, :alias_name_keys, :range_categories, :staff_schemes_data
|
|
30
|
+
|
|
31
|
+
class << self
|
|
32
|
+
# Factory method to get an Instrument instance
|
|
33
|
+
# @param name [String, Symbol] instrument name (e.g., "clarinet", "clarinet_in_a")
|
|
34
|
+
# @param variant_key [String, Symbol, nil] DEPRECATED: variant key (for backward compatibility)
|
|
35
|
+
# @return [Instrument, nil] instrument instance or nil if not found
|
|
36
|
+
def get(name, variant_key = nil)
|
|
37
|
+
return name if name.is_a?(self)
|
|
38
|
+
|
|
39
|
+
# Handle two-argument form for backward compatibility
|
|
40
|
+
if variant_key
|
|
41
|
+
combined_name = "#{name}_#{variant_key}"
|
|
42
|
+
result = find_valid_instrument(combined_name) || find_valid_instrument(name.to_s)
|
|
43
|
+
else
|
|
44
|
+
result = find_valid_instrument(name.to_s) || find_valid_instrument(normalize_variant_name(name))
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
result
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def find_valid_instrument(name)
|
|
51
|
+
instrument = get_by_name(name)
|
|
52
|
+
instrument&.name_key ? instrument : nil
|
|
53
|
+
end
|
|
28
54
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
55
|
+
def all
|
|
56
|
+
HeadMusic::Instruments::InstrumentFamily.all # Ensure families are loaded first
|
|
57
|
+
INSTRUMENTS.map { |key, _data| get(key) }
|
|
58
|
+
@all ||= @instances.values.compact.sort_by { |instrument| instrument.name.downcase }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
# Convert shorthand variant names to full form
|
|
64
|
+
# e.g., "trumpet_in_eb" -> "trumpet_in_e_flat"
|
|
65
|
+
# e.g., "clarinet_in_bb" -> "clarinet_in_b_flat"
|
|
66
|
+
def normalize_variant_name(name)
|
|
67
|
+
name_str = name.to_s
|
|
68
|
+
|
|
69
|
+
# Match patterns like "_in_eb" or "_in_bb" at the end (flat)
|
|
70
|
+
flat_pattern = /^(.+)_in_([a-g])b$/i
|
|
71
|
+
sharp_pattern = %r{^(.+)_in_([a-g])\#$}i
|
|
72
|
+
|
|
73
|
+
if name_str =~ flat_pattern
|
|
74
|
+
instrument = Regexp.last_match(1)
|
|
75
|
+
note = Regexp.last_match(2).downcase
|
|
76
|
+
"#{instrument}_in_#{note}_flat"
|
|
77
|
+
elsif name_str =~ sharp_pattern
|
|
78
|
+
instrument = Regexp.last_match(1)
|
|
79
|
+
note = Regexp.last_match(2).downcase
|
|
80
|
+
"#{instrument}_in_#{note}_sharp"
|
|
81
|
+
else
|
|
82
|
+
name_str
|
|
83
|
+
end
|
|
84
|
+
end
|
|
33
85
|
end
|
|
34
86
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
:variants, :classification_keys
|
|
39
|
-
)
|
|
87
|
+
# Parent instrument (for inheritance)
|
|
88
|
+
def parent
|
|
89
|
+
return nil unless parent_key
|
|
40
90
|
|
|
41
|
-
|
|
42
|
-
to_s == other.to_s
|
|
91
|
+
@parent ||= self.class.get(parent_key)
|
|
43
92
|
end
|
|
44
93
|
|
|
45
|
-
|
|
46
|
-
return name unless name_key
|
|
94
|
+
# Attributes with parent chain resolution
|
|
47
95
|
|
|
48
|
-
|
|
96
|
+
def family_key
|
|
97
|
+
@family_key || parent&.family_key
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def pitch_key
|
|
101
|
+
@pitch_key || parent&.pitch_key
|
|
49
102
|
end
|
|
50
103
|
|
|
51
104
|
def family
|
|
@@ -54,14 +107,51 @@ class HeadMusic::Instruments::Instrument
|
|
|
54
107
|
HeadMusic::Instruments::InstrumentFamily.get(family_key)
|
|
55
108
|
end
|
|
56
109
|
|
|
57
|
-
|
|
110
|
+
def orchestra_section_key
|
|
111
|
+
family&.orchestra_section_key
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def classification_keys
|
|
115
|
+
family&.classification_keys || []
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Pitch designation as a Spelling object (for backward compatibility)
|
|
119
|
+
def pitch_designation
|
|
120
|
+
return nil unless pitch_key
|
|
121
|
+
|
|
122
|
+
@pitch_designation ||= HeadMusic::Rudiment::Spelling.get(pitch_key_to_designation)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Staff schemes (notation concern - kept for backward compatibility)
|
|
126
|
+
def staff_schemes
|
|
127
|
+
@staff_schemes ||= build_staff_schemes
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def default_staff_scheme
|
|
131
|
+
@default_staff_scheme ||=
|
|
132
|
+
staff_schemes.find(&:default?) || staff_schemes.first
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def default_staves
|
|
136
|
+
default_staff_scheme&.staves || []
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def default_clefs
|
|
140
|
+
default_staves&.map(&:clef) || []
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def sounding_transposition
|
|
144
|
+
default_staves&.first&.sounding_transposition || 0
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
alias_method :default_sounding_transposition, :sounding_transposition
|
|
148
|
+
|
|
58
149
|
def transposing?
|
|
59
|
-
|
|
150
|
+
sounding_transposition != 0
|
|
60
151
|
end
|
|
61
152
|
|
|
62
|
-
# Returns true if the instrument sounds at a different register than written.
|
|
63
153
|
def transposing_at_the_octave?
|
|
64
|
-
transposing? &&
|
|
154
|
+
transposing? && sounding_transposition % 12 == 0
|
|
65
155
|
end
|
|
66
156
|
|
|
67
157
|
def single_staff?
|
|
@@ -78,22 +168,47 @@ class HeadMusic::Instruments::Instrument
|
|
|
78
168
|
default_clefs.any?
|
|
79
169
|
end
|
|
80
170
|
|
|
81
|
-
def
|
|
82
|
-
|
|
171
|
+
def translation(locale = :en)
|
|
172
|
+
return name unless name_key
|
|
173
|
+
|
|
174
|
+
I18n.translate(name_key, scope: %i[head_music instruments], locale: locale, default: name)
|
|
83
175
|
end
|
|
84
176
|
|
|
85
|
-
|
|
177
|
+
def ==(other)
|
|
178
|
+
return false unless other.is_a?(self.class)
|
|
86
179
|
|
|
87
|
-
|
|
88
|
-
default_staff_scheme&.staves || []
|
|
180
|
+
name_key == other.name_key
|
|
89
181
|
end
|
|
90
182
|
|
|
91
|
-
def
|
|
92
|
-
|
|
183
|
+
def to_s
|
|
184
|
+
name
|
|
93
185
|
end
|
|
94
186
|
|
|
95
|
-
|
|
96
|
-
|
|
187
|
+
# For backward compatibility with code that expects variants
|
|
188
|
+
def variants
|
|
189
|
+
[]
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def default_variant
|
|
193
|
+
nil
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# Collect all instrument_configurations from self and ancestors
|
|
197
|
+
def instrument_configurations
|
|
198
|
+
own_configs = HeadMusic::Instruments::InstrumentConfiguration.for_instrument(name_key)
|
|
199
|
+
parent_configs = parent&.instrument_configurations || []
|
|
200
|
+
own_configs + parent_configs
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def stringing
|
|
204
|
+
@stringing ||= HeadMusic::Instruments::Stringing.for_instrument(self) || parent&.stringing
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def alternate_tunings
|
|
208
|
+
own_tunings = HeadMusic::Instruments::AlternateTuning.for_instrument(name_key)
|
|
209
|
+
return own_tunings if own_tunings.any?
|
|
210
|
+
|
|
211
|
+
parent&.alternate_tunings || []
|
|
97
212
|
end
|
|
98
213
|
|
|
99
214
|
private_class_method :new
|
|
@@ -105,13 +220,16 @@ class HeadMusic::Instruments::Instrument
|
|
|
105
220
|
if record
|
|
106
221
|
initialize_data_from_record(record)
|
|
107
222
|
else
|
|
223
|
+
# Mark as invalid - will be filtered out by get_by_name
|
|
224
|
+
@name_key = nil
|
|
108
225
|
self.name = name.to_s
|
|
109
226
|
end
|
|
110
227
|
end
|
|
111
228
|
|
|
112
229
|
def record_for_name(name)
|
|
113
230
|
record_for_key(HeadMusic::Utilities::HashKey.for(name)) ||
|
|
114
|
-
record_for_key(key_for_name(name))
|
|
231
|
+
record_for_key(key_for_name(name)) ||
|
|
232
|
+
record_for_alias(name)
|
|
115
233
|
end
|
|
116
234
|
|
|
117
235
|
def key_for_name(name)
|
|
@@ -126,46 +244,87 @@ class HeadMusic::Instruments::Instrument
|
|
|
126
244
|
|
|
127
245
|
def record_for_key(key)
|
|
128
246
|
INSTRUMENTS.each do |name_key, data|
|
|
129
|
-
return data.merge
|
|
247
|
+
return data.merge("name_key" => name_key) if name_key.to_s == key.to_s
|
|
130
248
|
end
|
|
131
249
|
nil
|
|
132
250
|
end
|
|
133
251
|
|
|
134
|
-
def
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
252
|
+
def record_for_alias(name)
|
|
253
|
+
normalized_name = HeadMusic::Utilities::HashKey.for(name).to_s
|
|
254
|
+
INSTRUMENTS.each do |name_key, data|
|
|
255
|
+
data["alias_name_keys"]&.each do |alias_key|
|
|
256
|
+
return data.merge("name_key" => name_key) if HeadMusic::Utilities::HashKey.for(alias_key).to_s == normalized_name
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
nil
|
|
139
260
|
end
|
|
140
261
|
|
|
141
|
-
def
|
|
262
|
+
def initialize_data_from_record(record)
|
|
263
|
+
@name_key = record["name_key"].to_sym
|
|
264
|
+
@parent_key = record["parent_key"]&.to_sym
|
|
142
265
|
@family_key = record["family_key"]
|
|
143
|
-
@
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
return unless family
|
|
266
|
+
@pitch_key = record["pitch_key"]
|
|
267
|
+
@alias_name_keys = record["alias_name_keys"] || []
|
|
268
|
+
@range_categories = record["range_categories"] || []
|
|
269
|
+
@staff_schemes_data = record["staff_schemes"] || {}
|
|
148
270
|
|
|
149
|
-
|
|
150
|
-
@classification_keys = family.classification_keys || []
|
|
271
|
+
initialize_name
|
|
151
272
|
end
|
|
152
273
|
|
|
153
|
-
def
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
274
|
+
def initialize_name
|
|
275
|
+
# Try to get a translation first
|
|
276
|
+
base_name = I18n.translate(name_key, scope: "head_music.instruments", locale: "en", default: nil)
|
|
277
|
+
|
|
278
|
+
if base_name
|
|
279
|
+
# Use the translation as-is
|
|
280
|
+
self.name = base_name
|
|
281
|
+
elsif parent_key && pitch_key
|
|
282
|
+
# Build name from parent + pitch for child instruments
|
|
283
|
+
pitch_name = format_pitch_name(pitch_key_to_designation)
|
|
284
|
+
self.name = "#{parent_translation} in #{pitch_name}"
|
|
285
|
+
else
|
|
286
|
+
# Fall back to inferred name
|
|
287
|
+
self.name = inferred_name
|
|
288
|
+
end
|
|
157
289
|
end
|
|
158
290
|
|
|
159
|
-
def
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
(record["variants"] || {}).map do |key, attributes|
|
|
164
|
-
HeadMusic::Instruments::Variant.new(key, attributes)
|
|
165
|
-
end
|
|
291
|
+
def parent_translation
|
|
292
|
+
return nil unless parent_key
|
|
293
|
+
|
|
294
|
+
I18n.translate(parent_key, scope: "head_music.instruments", locale: "en", default: parent_key.to_s.tr("_", " "))
|
|
166
295
|
end
|
|
167
296
|
|
|
168
297
|
def inferred_name
|
|
169
298
|
name_key.to_s.tr("_", " ")
|
|
170
299
|
end
|
|
300
|
+
|
|
301
|
+
def format_pitch_name(pitch_designation)
|
|
302
|
+
pitch_designation.to_s.tr("b", "♭").tr("#", "♯")
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
# Convert pitch_key (e.g., "b_flat") to designation format (e.g., "Bb")
|
|
306
|
+
def pitch_key_to_designation
|
|
307
|
+
return nil unless pitch_key
|
|
308
|
+
|
|
309
|
+
key = pitch_key.to_s
|
|
310
|
+
if key.end_with?("_flat")
|
|
311
|
+
"#{key[0].upcase}b"
|
|
312
|
+
elsif key.end_with?("_sharp")
|
|
313
|
+
"#{key[0].upcase}#"
|
|
314
|
+
else
|
|
315
|
+
key.upcase
|
|
316
|
+
end
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
def build_staff_schemes
|
|
320
|
+
return parent&.staff_schemes || [] if staff_schemes_data.empty?
|
|
321
|
+
|
|
322
|
+
staff_schemes_data.map do |key, list|
|
|
323
|
+
HeadMusic::Instruments::StaffScheme.new(
|
|
324
|
+
key: key,
|
|
325
|
+
instrument: self,
|
|
326
|
+
list: list
|
|
327
|
+
)
|
|
328
|
+
end
|
|
329
|
+
end
|
|
171
330
|
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
module HeadMusic::Instruments; end
|
|
2
|
+
|
|
3
|
+
# A configurable aspect of an instrument, such as a leadpipe, mute, or attachment.
|
|
4
|
+
#
|
|
5
|
+
# Examples:
|
|
6
|
+
# - Piccolo trumpet "leadpipe" configuration with options: b_flat (default), a
|
|
7
|
+
# - Trumpet "mute" configuration with options: open (default), straight, cup, harmon
|
|
8
|
+
# - Bass trombone "f_attachment" with options: disengaged (default), engaged
|
|
9
|
+
class HeadMusic::Instruments::InstrumentConfiguration
|
|
10
|
+
CONFIGURATIONS = YAML.load_file(File.expand_path("instrument_configurations.yml", __dir__)).freeze
|
|
11
|
+
|
|
12
|
+
attr_reader :name_key, :instrument_key, :options
|
|
13
|
+
|
|
14
|
+
class << self
|
|
15
|
+
def for_instrument(instrument_key)
|
|
16
|
+
instrument_key = instrument_key.to_s
|
|
17
|
+
return [] unless CONFIGURATIONS.key?(instrument_key)
|
|
18
|
+
|
|
19
|
+
CONFIGURATIONS[instrument_key].map do |config_name, config_data|
|
|
20
|
+
new(
|
|
21
|
+
name_key: config_name,
|
|
22
|
+
instrument_key: instrument_key,
|
|
23
|
+
options_data: config_data["options"] || {}
|
|
24
|
+
)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def initialize(name_key:, instrument_key:, options_data: {})
|
|
30
|
+
@name_key = name_key.to_sym
|
|
31
|
+
@instrument_key = instrument_key.to_sym
|
|
32
|
+
@options = build_options(options_data)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def default_option
|
|
36
|
+
@default_option ||= options.find(&:default?) || options.first
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def option(option_key)
|
|
40
|
+
options.find { |opt| opt.name_key == option_key.to_sym }
|
|
41
|
+
end
|
|
42
|
+
|
|
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
|
+
def to_s
|
|
50
|
+
name_key.to_s
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def build_options(options_data)
|
|
56
|
+
options_data.map do |option_name, option_attrs|
|
|
57
|
+
attrs = option_attrs || {}
|
|
58
|
+
HeadMusic::Instruments::InstrumentConfigurationOption.new(
|
|
59
|
+
name_key: option_name,
|
|
60
|
+
default: attrs["default"],
|
|
61
|
+
transposition_semitones: attrs["transposition_semitones"],
|
|
62
|
+
lowest_pitch_semitones: attrs["lowest_pitch_semitones"]
|
|
63
|
+
)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|