head_music 17.3.0 → 17.5.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 (121) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +3 -3
  3. data/.github/workflows/release.yml +1 -1
  4. data/.github/workflows/security.yml +2 -1
  5. data/.rubocop.yml +6 -0
  6. data/CHANGELOG.md +15 -0
  7. data/Gemfile.lock +68 -68
  8. data/head_music.gemspec +1 -1
  9. data/lib/head_music/analysis/chord_analysis.rb +123 -0
  10. data/lib/head_music/analysis/circle.rb +11 -3
  11. data/lib/head_music/analysis/diatonic_interval.rb +14 -11
  12. data/lib/head_music/analysis/dyad/chord_implication.rb +101 -0
  13. data/lib/head_music/analysis/dyad.rb +24 -104
  14. data/lib/head_music/analysis/interval_cycle.rb +9 -3
  15. data/lib/head_music/analysis/pitch_collection.rb +35 -109
  16. data/lib/head_music/analysis/sonority.rb +11 -21
  17. data/lib/head_music/content/composition/hash_deserializer.rb +113 -0
  18. data/lib/head_music/content/composition/schema_values.rb +167 -0
  19. data/lib/head_music/content/composition.rb +0 -216
  20. data/lib/head_music/content/placement.rb +31 -42
  21. data/lib/head_music/content/sound_resolver.rb +54 -0
  22. data/lib/head_music/content/syllable.rb +38 -0
  23. data/lib/head_music/content/voice/melodic_line.rb +32 -0
  24. data/lib/head_music/content/voice/melodic_note_pair.rb +33 -0
  25. data/lib/head_music/content/voice.rb +8 -59
  26. data/lib/head_music/instruments/alternate_tuning.rb +4 -6
  27. data/lib/head_music/instruments/catalog_lookup.rb +18 -0
  28. data/lib/head_music/instruments/instrument.rb +17 -117
  29. data/lib/head_music/instruments/instrument_catalog.rb +48 -0
  30. data/lib/head_music/instruments/instrument_configuration.rb +4 -6
  31. data/lib/head_music/instruments/instrument_family.rb +3 -8
  32. data/lib/head_music/instruments/instrument_name.rb +49 -0
  33. data/lib/head_music/instruments/score_order.rb +52 -42
  34. data/lib/head_music/instruments/staff_profile.rb +71 -0
  35. data/lib/head_music/instruments/stringing.rb +4 -6
  36. data/lib/head_music/instruments/stringing_course.rb +4 -6
  37. data/lib/head_music/instruments/variant.rb +4 -6
  38. data/lib/head_music/named/locale.rb +32 -0
  39. data/lib/head_music/named/localized_name.rb +15 -0
  40. data/lib/head_music/named.rb +1 -45
  41. data/lib/head_music/notation/abc/body_lexer.rb +48 -23
  42. data/lib/head_music/notation/abc/duration_resolver.rb +11 -1
  43. data/lib/head_music/notation/abc/duration_writer.rb +1 -8
  44. data/lib/head_music/notation/abc/header.rb +27 -16
  45. data/lib/head_music/notation/abc/parser.rb +44 -183
  46. data/lib/head_music/notation/abc/preflight.rb +48 -0
  47. data/lib/head_music/notation/abc/voice_state.rb +161 -0
  48. data/lib/head_music/notation/abc/writer.rb +34 -17
  49. data/lib/head_music/notation/dotted_duration.rb +19 -0
  50. data/lib/head_music/notation/instrument_notation.rb +5 -7
  51. data/lib/head_music/notation/music_xml/beam_grouper.rb +2 -2
  52. data/lib/head_music/notation/music_xml/duration_writer.rb +4 -9
  53. data/lib/head_music/notation/music_xml/preflight.rb +90 -0
  54. data/lib/head_music/notation/music_xml/render_plan.rb +161 -0
  55. data/lib/head_music/notation/music_xml/writer.rb +66 -209
  56. data/lib/head_music/notation/placement_validation.rb +19 -0
  57. data/lib/head_music/rudiment/enharmonic_equivalence.rb +33 -0
  58. data/lib/head_music/rudiment/key.rb +8 -36
  59. data/lib/head_music/rudiment/key_signature/enharmonic_equivalence.rb +21 -15
  60. data/lib/head_music/rudiment/key_signature.rb +18 -19
  61. data/lib/head_music/rudiment/meter.rb +17 -13
  62. data/lib/head_music/rudiment/mode.rb +33 -65
  63. data/lib/head_music/rudiment/note.rb +18 -21
  64. data/lib/head_music/rudiment/pitch/enharmonic_equivalence.rb +4 -15
  65. data/lib/head_music/rudiment/pitch/natural_step.rb +36 -0
  66. data/lib/head_music/rudiment/pitch/parser.rb +16 -5
  67. data/lib/head_music/rudiment/pitch.rb +11 -33
  68. data/lib/head_music/rudiment/qualified_diatonic_context.rb +61 -0
  69. data/lib/head_music/rudiment/rhythmic_unit/parser.rb +16 -12
  70. data/lib/head_music/rudiment/rhythmic_unit.rb +21 -37
  71. data/lib/head_music/rudiment/rhythmic_value/parser.rb +53 -46
  72. data/lib/head_music/rudiment/spelling/enharmonic_equivalence.rb +13 -0
  73. data/lib/head_music/rudiment/spelling.rb +0 -25
  74. data/lib/head_music/rudiment/tuning/just_intonation.rb +2 -4
  75. data/lib/head_music/rudiment/tuning/meantone.rb +2 -4
  76. data/lib/head_music/rudiment/tuning/pythagorean.rb +2 -4
  77. data/lib/head_music/rudiment/tuning/tonal_centered.rb +10 -0
  78. data/lib/head_music/rudiment/unpitched_note.rb +3 -5
  79. data/lib/head_music/style/annotation/configured.rb +35 -0
  80. data/lib/head_music/style/annotation.rb +32 -44
  81. data/lib/head_music/style/guidelines/allowed_rhythmic_values_for_fifth_species.rb +0 -10
  82. data/lib/head_music/style/guidelines/contoured.rb +65 -53
  83. data/lib/head_music/style/guidelines/dissonance_figure_detection.rb +55 -16
  84. data/lib/head_music/style/guidelines/florid_dissonance_treatment.rb +0 -37
  85. data/lib/head_music/style/guidelines/large_leaps.rb +10 -2
  86. data/lib/head_music/style/guidelines/minimum_melodic_intervals.rb +3 -25
  87. data/lib/head_music/style/guidelines/minimum_notes.rb +3 -25
  88. data/lib/head_music/style/guidelines/minimum_threshold.rb +38 -0
  89. data/lib/head_music/style/guidelines/no_parallel_perfect.rb +31 -0
  90. data/lib/head_music/style/guidelines/no_parallel_perfect_on_downbeats.rb +3 -17
  91. data/lib/head_music/style/guidelines/no_parallel_perfect_with_syncopation.rb +3 -17
  92. data/lib/head_music/style/guidelines/notes_same_length.rb +1 -1
  93. data/lib/head_music/style/guidelines/prepare_octave_leaps.rb +10 -7
  94. data/lib/head_music/style/guidelines/singable_intervals.rb +22 -8
  95. data/lib/head_music/style/guidelines/suspension_treatment.rb +30 -12
  96. data/lib/head_music/style/guidelines/third_species_dissonance_treatment.rb +0 -7
  97. data/lib/head_music/style/guidelines/triple_meter_dissonance_treatment.rb +0 -12
  98. data/lib/head_music/style/guidelines/weak_beat_dissonance_treatment.rb +2 -42
  99. data/lib/head_music/style/guides/fifth_species_melody.rb +3 -13
  100. data/lib/head_music/style/guides/fourth_species_melody.rb +3 -13
  101. data/lib/head_music/style/guides/second_species_harmony.rb +2 -5
  102. data/lib/head_music/style/guides/second_species_melody.rb +2 -12
  103. data/lib/head_music/style/guides/species_harmony.rb +14 -0
  104. data/lib/head_music/style/guides/species_melody.rb +21 -0
  105. data/lib/head_music/style/guides/third_species_harmony.rb +2 -5
  106. data/lib/head_music/style/guides/third_species_melody.rb +3 -13
  107. data/lib/head_music/style/guides/third_species_triple_meter_harmony.rb +2 -5
  108. data/lib/head_music/style/guides/third_species_triple_meter_melody.rb +2 -12
  109. data/lib/head_music/time/conductor.rb +19 -125
  110. data/lib/head_music/time/musical_position.rb +14 -26
  111. data/lib/head_music/time/musical_time_converter.rb +104 -0
  112. data/lib/head_music/time/radix_carry.rb +22 -0
  113. data/lib/head_music/time/smpte_converter.rb +51 -0
  114. data/lib/head_music/time/smpte_timecode.rb +13 -23
  115. data/lib/head_music/time.rb +3 -0
  116. data/lib/head_music/value_equality.rb +37 -0
  117. data/lib/head_music/version.rb +1 -1
  118. data/lib/head_music.rb +26 -1
  119. data/user-stories/done/lyrics.md +162 -0
  120. data/user-stories/index.html +7 -3
  121. metadata +45 -6
@@ -31,11 +31,7 @@ class HeadMusic::Analysis::Dyad
31
31
  end
32
32
 
33
33
  def possible_trichords
34
- @possible_trichords ||= begin
35
- trichords = generate_possible_trichords
36
- trichords = filter_by_key(trichords) if key
37
- sort_by_diatonic_agreement(trichords)
38
- end
34
+ chord_implication.trichords
39
35
  end
40
36
 
41
37
  def possible_triads
@@ -43,11 +39,7 @@ class HeadMusic::Analysis::Dyad
43
39
  end
44
40
 
45
41
  def possible_seventh_chords
46
- @possible_seventh_chords ||= begin
47
- seventh_chords = generate_possible_seventh_chords
48
- seventh_chords = filter_by_key(seventh_chords) if key
49
- sort_by_diatonic_agreement(seventh_chords)
50
- end
42
+ chord_implication.seventh_chords
51
43
  end
52
44
 
53
45
  def enharmonic_respellings
@@ -68,111 +60,39 @@ class HeadMusic::Analysis::Dyad
68
60
 
69
61
  private
70
62
 
71
- TRICHORD_INTERVALS = [
72
- %w[M3 P5], # major triad
73
- %w[m3 P5], # minor triad
74
- %w[m3 d5], # diminished triad
75
- %w[M3 A5], # augmented triad
76
- %w[P4 P5], # sus4 (not a triad)
77
- %w[M2 P5] # sus2 (not a triad)
78
- ].freeze
79
-
80
- SEVENTH_CHORD_INTERVALS = [
81
- %w[M3 P5 M7], # major seventh
82
- %w[M3 P5 m7], # dominant seventh (major-minor)
83
- %w[m3 P5 m7], # minor seventh
84
- %w[m3 P5 M7], # minor-major seventh
85
- %w[m3 d5 m7], # half-diminished seventh
86
- %w[m3 d5 d7], # diminished seventh
87
- %w[M2 M3 P5 m7], # dominant ninth
88
- %w[m2 M3 P5 m7], # dominant minor ninth
89
- %w[M2 m3 P5 m7], # minor ninth
90
- %w[M2 M3 P5 M7] # major ninth
91
- ].freeze
92
-
93
- def generate_possible_trichords
94
- generate_possible_chords(TRICHORD_INTERVALS)
95
- end
96
-
97
- def generate_possible_seventh_chords
98
- generate_possible_chords(SEVENTH_CHORD_INTERVALS)
99
- end
100
-
101
- def generate_possible_chords(interval_sets)
102
- dyad_pitch_classes = [lower_pitch.pitch_class, upper_pitch.pitch_class]
103
- chords = []
104
-
105
- HeadMusic::Rudiment::Spelling::CHROMATIC_SPELLINGS.each do |root_spelling|
106
- root_pitch = HeadMusic::Rudiment::Pitch.get("#{root_spelling}4")
107
-
108
- interval_sets.each do |intervals|
109
- chord_pitches = [root_pitch] + intervals.map { |name| HeadMusic::Analysis::DiatonicInterval.get(name).above(root_pitch) }
110
- pitch_collection = HeadMusic::Analysis::PitchCollection.new(chord_pitches)
111
-
112
- if dyad_pitch_classes.all? { |pc| pitch_collection.pitch_classes.include?(pc) }
113
- chords << pitch_collection
114
- end
115
- end
116
- end
117
-
118
- chords.uniq { |chord| chord.pitch_classes.sort.map(&:to_i) }
119
- end
120
-
121
- def filter_by_key(pitch_collections)
122
- return pitch_collections unless key
123
-
124
- pitch_collections.select do |pitch_collection|
125
- pitch_collection.pitches.all? { |pitch| diatonic_spellings.include?(pitch.spelling) }
126
- end
127
- end
128
-
129
- def sort_by_diatonic_agreement(pitch_collections)
130
- return pitch_collections unless key
131
-
132
- pitch_collections.sort_by do |pitch_collection|
133
- -pitch_collection.pitches.count { |pitch| diatonic_spellings.include?(pitch.spelling) }
134
- end
135
- end
136
-
137
- def diatonic_spellings
138
- @diatonic_spellings ||= key.scale.spellings
63
+ def chord_implication
64
+ @chord_implication ||= ChordImplication.new([lower_pitch.pitch_class, upper_pitch.pitch_class], key)
139
65
  end
140
66
 
141
67
  def generate_enharmonic_respellings
142
- respellings = []
143
-
144
- # Get enharmonic equivalents for each pitch
145
- pitch1_equivalents = enharmonic_equivalents_for(pitch1)
146
- pitch2_equivalents = enharmonic_equivalents_for(pitch2)
68
+ lower_equivalents = enharmonic_equivalents_for(pitch1)
69
+ upper_equivalents = enharmonic_equivalents_for(pitch2)
147
70
 
148
- # Generate all combinations
149
- pitch1_equivalents.each do |lower|
150
- pitch2_equivalents.each do |upper|
151
- next if lower.spelling == pitch1.spelling && upper.spelling == pitch2.spelling
152
-
153
- respellings << self.class.new(lower, upper, key: key)
154
- end
155
- end
71
+ lower_equivalents.product(upper_equivalents)
72
+ .reject { |lower, upper| original_spelling?(lower, upper) }
73
+ .map { |lower, upper| self.class.new(lower, upper, key: key) }
74
+ end
156
75
 
157
- respellings
76
+ def original_spelling?(lower, upper)
77
+ lower.spelling == pitch1.spelling && upper.spelling == pitch2.spelling
158
78
  end
159
79
 
160
80
  ALTERATION_SIGNS = {-2 => "bb", -1 => "b", 0 => "", 1 => "#", 2 => "##"}.freeze
161
81
 
162
82
  def enharmonic_equivalents_for(pitch)
163
- target_pitch_class = pitch.pitch_class
164
- equivalents = [pitch]
165
-
166
- HeadMusic::Rudiment::LetterName.all.each do |letter_name|
167
- ALTERATION_SIGNS.each_value do |sign|
168
- spelling = HeadMusic::Rudiment::Spelling.get("#{letter_name}#{sign}")
169
- next unless spelling && spelling.pitch_class == target_pitch_class
170
- next if equivalents.any? { |equiv| equiv.spelling == spelling }
171
-
172
- equivalents << HeadMusic::Rudiment::Pitch.fetch_or_create(spelling, pitch.register)
173
- end
83
+ equivalent_pitches = enharmonic_spellings_for(pitch.pitch_class).map do |spelling|
84
+ HeadMusic::Rudiment::Pitch.fetch_or_create(spelling, pitch.register)
174
85
  end
86
+ [pitch, *equivalent_pitches].uniq(&:spelling)
87
+ end
88
+
89
+ def enharmonic_spellings_for(target_pitch_class)
90
+ all_spellings.select { |spelling| spelling.pitch_class == target_pitch_class }
91
+ end
175
92
 
176
- equivalents
93
+ def all_spellings
94
+ HeadMusic::Rudiment::LetterName.all.flat_map do |letter_name|
95
+ ALTERATION_SIGNS.each_value.map { |sign| HeadMusic::Rudiment::Spelling.get("#{letter_name}#{sign}") }
96
+ end.compact
177
97
  end
178
98
  end
@@ -41,15 +41,21 @@ class HeadMusic::Analysis::IntervalCycle
41
41
  def pitches_up
42
42
  @pitches_up ||= [starting_pitch].tap do |list|
43
43
  loop do
44
- next_pitch = list.last + interval
45
- next_pitch -= octave while next_pitch - starting_pitch > 12
46
- break if next_pitch.pitch_class == list.first.pitch_class
44
+ next_pitch = folded_up(list.last)
45
+ break if next_pitch.pitch_class == starting_pitch.pitch_class
47
46
 
48
47
  list << next_pitch
49
48
  end
50
49
  end
51
50
  end
52
51
 
52
+ # The next pitch a cycle-interval above, folded down by octaves to stay within an octave of the start.
53
+ def folded_up(pitch)
54
+ next_pitch = pitch + interval
55
+ next_pitch -= octave while next_pitch - starting_pitch > 12
56
+ next_pitch
57
+ end
58
+
53
59
  def octave
54
60
  @octave ||= HeadMusic::Analysis::DiatonicInterval.get(:perfect_octave)
55
61
  end
@@ -6,15 +6,6 @@ module HeadMusic::Analysis; end
6
6
  # as distinct from a "pitch-class set" which abstracts away register and octave equivalence.
7
7
  # See also: PitchClassSet
8
8
  class HeadMusic::Analysis::PitchCollection
9
- TERTIAN_SONORITIES = {
10
- implied_triad: [3],
11
- triad: [3, 5],
12
- seventh_chord: [3, 5, 7],
13
- ninth_chord: [2, 3, 5, 7],
14
- eleventh_chord: [2, 3, 4, 5, 7],
15
- thirteenth_chord: [2, 3, 4, 5, 6, 7] # a.k.a. diatonic scale
16
- }.freeze
17
-
18
9
  attr_reader :pitches
19
10
 
20
11
  delegate :diatonic_intervals, to: :reduction, prefix: true
@@ -24,6 +15,17 @@ class HeadMusic::Analysis::PitchCollection
24
15
  delegate :heptachord?, :octachord?, :nonachord?, :decachord?, :undecachord?, :dodecachord?, to: :pitch_class_set
25
16
  delegate :size, to: :pitch_class_set, prefix: true
26
17
 
18
+ # Tertian chord classification (triad/seventh/extended, quality, inversion).
19
+ delegate(
20
+ :triad?, :consonant_triad?,
21
+ :major_triad?, :minor_triad?, :diminished_triad?, :augmented_triad?,
22
+ :root_position_triad?, :first_inversion_triad?, :second_inversion_triad?,
23
+ :seventh_chord?, :root_position_seventh_chord?, :first_inversion_seventh_chord?,
24
+ :second_inversion_seventh_chord?, :third_inversion_seventh_chord?,
25
+ :ninth_chord?, :eleventh_chord?, :thirteenth_chord?, :tertian?,
26
+ to: :chord_analysis
27
+ )
28
+
27
29
  def initialize(pitches)
28
30
  @pitches = pitches.map { |pitch| HeadMusic::Rudiment::Pitch.get(pitch) }.sort.uniq
29
31
  end
@@ -60,20 +62,17 @@ class HeadMusic::Analysis::PitchCollection
60
62
  # questions:
61
63
  # - should this be absolute? 0 = C?
62
64
  # - should there be both absolute and relative versions?
63
- @integer_notation ||= begin
64
- return [] if pitches.empty?
65
- diatonic_intervals_above_bass_pitch.map { |interval| interval.semitones % 12 }.flatten.sort.unshift(0).uniq
66
- end
65
+ @integer_notation ||= integer_notation_values
67
66
  end
68
67
 
69
68
  def invert
70
- inverted_pitch = pitches[0] + HeadMusic::Analysis::DiatonicInterval.get("perfect octave")
69
+ inverted_pitch = pitches.first + octave
71
70
  new_pitches = pitches.drop(1) + [inverted_pitch]
72
71
  HeadMusic::Analysis::PitchCollection.new(new_pitches)
73
72
  end
74
73
 
75
74
  def uninvert
76
- inverted_pitch = pitches[-1] - HeadMusic::Analysis::DiatonicInterval.get("perfect octave")
75
+ inverted_pitch = pitches.last - octave
77
76
  new_pitches = [inverted_pitch] + pitches[0..-2]
78
77
  HeadMusic::Analysis::PitchCollection.new(new_pitches)
79
78
  end
@@ -83,11 +82,11 @@ class HeadMusic::Analysis::PitchCollection
83
82
  end
84
83
 
85
84
  def inspect
86
- pitches.map(&:to_s).join(" ")
85
+ pitches.join(" ")
87
86
  end
88
87
 
89
88
  def to_s
90
- pitches.map(&:to_s).join(" ")
89
+ pitches.join(" ")
91
90
  end
92
91
 
93
92
  def ==(other)
@@ -102,92 +101,6 @@ class HeadMusic::Analysis::PitchCollection
102
101
  pitches.length
103
102
  end
104
103
 
105
- def triad?
106
- trichord? && tertian?
107
- end
108
-
109
- def consonant_triad?
110
- major_triad? || minor_triad?
111
- end
112
-
113
- TRIAD_PATTERNS = {
114
- major: [%w[M3 m3], %w[m3 P4], %w[P4 M3]],
115
- minor: [%w[m3 M3], %w[M3 P4], %w[P4 m3]],
116
- diminished: [%w[m3 m3], %w[m3 A4], %w[A4 m3]],
117
- augmented: [%w[M3 M3], %w[M3 d4], %w[d4 M3]]
118
- }.freeze
119
-
120
- def major_triad?
121
- triad_type?(:major)
122
- end
123
-
124
- def minor_triad?
125
- triad_type?(:minor)
126
- end
127
-
128
- def diminished_triad?
129
- triad_type?(:diminished)
130
- end
131
-
132
- def augmented_triad?
133
- triad_type?(:augmented)
134
- end
135
-
136
- def root_position_triad?
137
- trichord? && reduction_diatonic_intervals.all?(&:third?)
138
- end
139
-
140
- def first_inversion_triad?
141
- trichord? && reduction.uninvert.diatonic_intervals.all?(&:third?)
142
- end
143
-
144
- def second_inversion_triad?
145
- trichord? && reduction.invert.diatonic_intervals.all?(&:third?)
146
- end
147
-
148
- def seventh_chord?
149
- tetrachord? && tertian?
150
- end
151
-
152
- def root_position_seventh_chord?
153
- tetrachord? && reduction_diatonic_intervals.all?(&:third?)
154
- end
155
-
156
- def first_inversion_seventh_chord?
157
- tetrachord? && reduction.uninvert.diatonic_intervals.all?(&:third?)
158
- end
159
-
160
- def second_inversion_seventh_chord?
161
- tetrachord? && reduction.uninvert.uninvert.diatonic_intervals.all?(&:third?)
162
- end
163
-
164
- def third_inversion_seventh_chord?
165
- tetrachord? && reduction.invert.diatonic_intervals.all?(&:third?)
166
- end
167
-
168
- def ninth_chord?
169
- pentachord? && tertian?
170
- end
171
-
172
- def eleventh_chord?
173
- hexachord? && tertian?
174
- end
175
-
176
- def thirteenth_chord?
177
- heptachord? && tertian?
178
- end
179
-
180
- def tertian?
181
- return false unless diatonic_intervals.any?
182
-
183
- inversion = reduction
184
- pitches.length.times do
185
- return true if TERTIAN_SONORITIES.value?(inversion.scale_degrees_above_bass_pitch)
186
- inversion = inversion.invert
187
- end
188
- false
189
- end
190
-
191
104
  def scale_degrees
192
105
  @scale_degrees ||= pitches.empty? ? [] : scale_degrees_above_bass_pitch.unshift(1)
193
106
  end
@@ -200,16 +113,29 @@ class HeadMusic::Analysis::PitchCollection
200
113
  @sonority ||= HeadMusic::Analysis::Sonority.new(self)
201
114
  end
202
115
 
116
+ def chord_analysis
117
+ @chord_analysis ||= HeadMusic::Analysis::ChordAnalysis.new(self)
118
+ end
119
+
203
120
  private
204
121
 
205
- def triad_type?(type)
206
- TRIAD_PATTERNS[type].include?(reduction_diatonic_intervals.map(&:shorthand))
122
+ def octave
123
+ @octave ||= HeadMusic::Analysis::DiatonicInterval.get("perfect octave")
124
+ end
125
+
126
+ def integer_notation_values
127
+ return [] if pitches.empty?
128
+
129
+ diatonic_intervals_above_bass_pitch.map { |interval| interval.semitones % 12 }.sort.unshift(0).uniq
207
130
  end
208
131
 
209
132
  def reduction_pitches
210
- pitches.map do |pitch|
211
- pitch = HeadMusic::Rudiment::Pitch.fetch_or_create(pitch.spelling, pitch.register - 1) while pitch > bass_pitch + 12
212
- pitch
213
- end.sort
133
+ pitches.map { |pitch| folded_into_reduction(pitch) }.sort
134
+ end
135
+
136
+ def folded_into_reduction(pitch)
137
+ ceiling = bass_pitch + 12
138
+ pitch = HeadMusic::Rudiment::Pitch.fetch_or_create(pitch.spelling, pitch.register - 1) while pitch > ceiling
139
+ pitch
214
140
  end
215
141
  end
@@ -73,6 +73,7 @@ class HeadMusic::Analysis::Sonority
73
73
  delegate :heptachord?, :octachord?, :nonachord?, :decachord?, :undecachord?, :dodecachord?, to: :pitch_collection
74
74
  delegate :pitch_class_set, :pitch_class_set_size, to: :pitch_collection
75
75
  delegate :scale_degrees_above_bass_pitch, to: :pitch_collection
76
+ delegate :triad?, :seventh_chord?, :tertian?, to: :pitch_collection
76
77
 
77
78
  def initialize(pitch_collection)
78
79
  @pitch_collection = pitch_collection
@@ -117,32 +118,13 @@ class HeadMusic::Analysis::Sonority
117
118
  root_position.diatonic_intervals_above_bass_pitch.all?(&:consonant?)
118
119
  end
119
120
 
120
- def triad?
121
- @triad ||= trichord? && tertian?
122
- end
123
-
124
- def seventh_chord?
125
- @seventh_chord ||= tetrachord? && tertian?
126
- end
127
-
128
- def tertian?
129
- @tertian ||= inversions.detect do |inversion|
130
- inversion.diatonic_intervals.count(&:third?).to_f / inversion.diatonic_intervals.length > 0.5 ||
131
- (scale_degrees_above_bass_pitch && [3, 5, 7]).length == 3
132
- end
133
- end
134
-
135
121
  def secundal?
136
- @secundal ||= inversions.detect do |inversion|
137
- inversion.diatonic_intervals.count(&:second?).to_f / inversion.diatonic_intervals.length > 0.5
138
- end
122
+ @secundal ||= inversions.detect { |inversion| interval_ratio(inversion, &:second?) > 0.5 }
139
123
  end
140
124
 
141
125
  def quartal?
142
126
  @quartal ||= inversions.detect do |inversion|
143
- inversion.diatonic_intervals.count do |interval|
144
- interval.fourth? || interval.fifth?
145
- end.to_f / inversion.diatonic_intervals.length > 0.5
127
+ interval_ratio(inversion) { |interval| interval.fourth? || interval.fifth? } > 0.5
146
128
  end
147
129
  end
148
130
  alias_method :quintal?, :quartal?
@@ -159,4 +141,12 @@ class HeadMusic::Analysis::Sonority
159
141
  other = self.class.new(other) if other.is_a?(HeadMusic::Analysis::PitchCollection)
160
142
  identifier == other.identifier
161
143
  end
144
+
145
+ private
146
+
147
+ # Fraction of an inversion's diatonic intervals that satisfy the given predicate.
148
+ def interval_ratio(inversion, &predicate)
149
+ intervals = inversion.diatonic_intervals
150
+ intervals.count(&predicate).to_f / intervals.length
151
+ end
162
152
  end
@@ -0,0 +1,113 @@
1
+ class HeadMusic::Content::Composition
2
+ # Rebuilds a composition from a schema v3 hash by replaying the public
3
+ # builder API in dependency order: meter and key changes first (position
4
+ # strings roll counts and ticks over via the meter map), then placements,
5
+ # then repeat flags (a pickup-bar flag needs its bar allocated), then
6
+ # comments. Raw values are validated at the boundary by SchemaValues so
7
+ # corrupted input raises ArgumentError with path context instead of
8
+ # silently deserializing wrong.
9
+ class HashDeserializer
10
+ def initialize(hash)
11
+ raise ArgumentError, "expected a Hash, got #{hash.class}" unless hash.is_a?(Hash)
12
+
13
+ @hash = hash.deep_transform_keys(&:to_s)
14
+ validate_schema_version
15
+ end
16
+
17
+ def composition
18
+ @composition ||= build_base_composition.tap do |composition|
19
+ apply_bar_changes(composition)
20
+ build_voices(composition)
21
+ apply_repeat_flags(composition)
22
+ add_comments(composition)
23
+ end
24
+ end
25
+
26
+ private
27
+
28
+ attr_reader :hash
29
+
30
+ def values
31
+ @values ||= SchemaValues.new
32
+ end
33
+
34
+ def validate_schema_version
35
+ version = hash["schema_version"]
36
+ return if version.is_a?(Integer) && version == SCHEMA_VERSION
37
+
38
+ message = "unsupported schema_version: #{version.inspect} (supported: #{SCHEMA_VERSION})"
39
+ if version == 2
40
+ message += "; migrate v2 \"pitches\" arrays to v3 \"sounds\" arrays " \
41
+ "(pitch strings unchanged; unpitched sounds are {\"unpitched\" => name_key} objects)"
42
+ end
43
+ raise ArgumentError, message
44
+ end
45
+
46
+ def build_base_composition
47
+ HeadMusic::Content::Composition.new(
48
+ name: hash["name"],
49
+ key_signature: values.key_signature(hash["key_signature"], "key_signature"),
50
+ meter: values.meter(hash["meter"], "meter"),
51
+ composer: hash["composer"],
52
+ origin: hash["origin"]
53
+ )
54
+ end
55
+
56
+ def bar_hashes
57
+ @bar_hashes ||= Array(hash["bars"])
58
+ end
59
+
60
+ def apply_bar_changes(composition)
61
+ bar_hashes.each_with_index do |bar_hash, index|
62
+ number = values.bar_number(bar_hash, index)
63
+ path = "bars[#{index}]"
64
+ key_signature = values.key_signature(bar_hash["key_signature"], path)
65
+ meter = values.meter(bar_hash["meter"], path)
66
+ composition.change_key_signature(number, key_signature) if key_signature
67
+ composition.change_meter(number, meter) if meter
68
+ end
69
+ end
70
+
71
+ def build_voices(composition)
72
+ Array(hash["voices"]).each_with_index do |voice_hash, voice_index|
73
+ voice = composition.add_voice(role: voice_hash["role"])
74
+ Array(voice_hash["placements"]).each_with_index do |placement_hash, placement_index|
75
+ path = "voices[#{voice_index}].placements[#{placement_index}]"
76
+ position = values.position(placement_hash["position"], path)
77
+ rhythmic_value = values.rhythmic_value(placement_hash["rhythmic_value"], path)
78
+ sounds = values.placement_sounds(placement_hash, path)
79
+ placement = voice.place(position, rhythmic_value, sounds)
80
+ placement.beam_break_before = placement_hash["beam_break_before"] if placement_hash.key?("beam_break_before")
81
+ values.placement_syllables(placement_hash, path).each do |syllable|
82
+ placement.sing(syllable.text, verse: syllable.verse, hyphen_after: syllable.hyphen_after)
83
+ end
84
+ end
85
+ end
86
+ end
87
+
88
+ def apply_repeat_flags(composition)
89
+ bar_hashes.each_with_index do |bar_hash, index|
90
+ next unless repeat_state?(bar_hash)
91
+
92
+ bar = composition.bars(values.bar_number(bar_hash, index)).last
93
+ bar.starts_repeat = true if bar_hash["starts_repeat"]
94
+ ends_repeat = bar_hash["ends_repeat_after_num_plays"]
95
+ bar.ends_repeat_after_num_plays = ends_repeat if ends_repeat
96
+ plays_on_passes = bar_hash["plays_on_passes"]
97
+ bar.plays_on_passes = plays_on_passes if plays_on_passes
98
+ end
99
+ end
100
+
101
+ def add_comments(composition)
102
+ Array(hash["comments"]).each_with_index do |comment_hash, index|
103
+ raw_position = comment_hash["position"]
104
+ position = values.position(raw_position, "comments[#{index}]") if raw_position
105
+ composition.add_comment(comment_hash["text"], position)
106
+ end
107
+ end
108
+
109
+ def repeat_state?(bar_hash)
110
+ bar_hash["starts_repeat"] || bar_hash["ends_repeat_after_num_plays"] || bar_hash["plays_on_passes"]
111
+ end
112
+ end
113
+ end