head_music 17.3.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 (110) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +8 -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 -216
  15. data/lib/head_music/content/placement.rb +1 -41
  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 +48 -23
  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 +44 -183
  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 +34 -17
  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/duration_writer.rb +3 -8
  46. data/lib/head_music/notation/music_xml/preflight.rb +90 -0
  47. data/lib/head_music/notation/music_xml/render_plan.rb +161 -0
  48. data/lib/head_music/notation/music_xml/writer.rb +20 -209
  49. data/lib/head_music/notation/placement_validation.rb +19 -0
  50. data/lib/head_music/rudiment/enharmonic_equivalence.rb +33 -0
  51. data/lib/head_music/rudiment/key.rb +8 -36
  52. data/lib/head_music/rudiment/key_signature/enharmonic_equivalence.rb +21 -15
  53. data/lib/head_music/rudiment/key_signature.rb +18 -19
  54. data/lib/head_music/rudiment/meter.rb +17 -13
  55. data/lib/head_music/rudiment/mode.rb +33 -65
  56. data/lib/head_music/rudiment/note.rb +18 -21
  57. data/lib/head_music/rudiment/pitch/enharmonic_equivalence.rb +4 -15
  58. data/lib/head_music/rudiment/pitch/natural_step.rb +36 -0
  59. data/lib/head_music/rudiment/pitch/parser.rb +16 -5
  60. data/lib/head_music/rudiment/pitch.rb +11 -33
  61. data/lib/head_music/rudiment/qualified_diatonic_context.rb +61 -0
  62. data/lib/head_music/rudiment/rhythmic_unit/parser.rb +16 -12
  63. data/lib/head_music/rudiment/rhythmic_unit.rb +21 -37
  64. data/lib/head_music/rudiment/rhythmic_value/parser.rb +53 -46
  65. data/lib/head_music/rudiment/spelling/enharmonic_equivalence.rb +13 -0
  66. data/lib/head_music/rudiment/spelling.rb +0 -25
  67. data/lib/head_music/rudiment/tuning/just_intonation.rb +2 -4
  68. data/lib/head_music/rudiment/tuning/meantone.rb +2 -4
  69. data/lib/head_music/rudiment/tuning/pythagorean.rb +2 -4
  70. data/lib/head_music/rudiment/tuning/tonal_centered.rb +10 -0
  71. data/lib/head_music/rudiment/unpitched_note.rb +3 -5
  72. data/lib/head_music/style/annotation/configured.rb +35 -0
  73. data/lib/head_music/style/annotation.rb +32 -44
  74. data/lib/head_music/style/guidelines/allowed_rhythmic_values_for_fifth_species.rb +0 -10
  75. data/lib/head_music/style/guidelines/contoured.rb +65 -53
  76. data/lib/head_music/style/guidelines/dissonance_figure_detection.rb +55 -16
  77. data/lib/head_music/style/guidelines/florid_dissonance_treatment.rb +0 -37
  78. data/lib/head_music/style/guidelines/large_leaps.rb +10 -2
  79. data/lib/head_music/style/guidelines/minimum_melodic_intervals.rb +3 -25
  80. data/lib/head_music/style/guidelines/minimum_notes.rb +3 -25
  81. data/lib/head_music/style/guidelines/minimum_threshold.rb +38 -0
  82. data/lib/head_music/style/guidelines/no_parallel_perfect.rb +31 -0
  83. data/lib/head_music/style/guidelines/no_parallel_perfect_on_downbeats.rb +3 -17
  84. data/lib/head_music/style/guidelines/no_parallel_perfect_with_syncopation.rb +3 -17
  85. data/lib/head_music/style/guidelines/prepare_octave_leaps.rb +10 -7
  86. data/lib/head_music/style/guidelines/singable_intervals.rb +22 -8
  87. data/lib/head_music/style/guidelines/suspension_treatment.rb +30 -12
  88. data/lib/head_music/style/guidelines/third_species_dissonance_treatment.rb +0 -7
  89. data/lib/head_music/style/guidelines/triple_meter_dissonance_treatment.rb +0 -12
  90. data/lib/head_music/style/guidelines/weak_beat_dissonance_treatment.rb +2 -42
  91. data/lib/head_music/style/guides/fifth_species_melody.rb +3 -13
  92. data/lib/head_music/style/guides/fourth_species_melody.rb +3 -13
  93. data/lib/head_music/style/guides/second_species_harmony.rb +2 -5
  94. data/lib/head_music/style/guides/second_species_melody.rb +2 -12
  95. data/lib/head_music/style/guides/species_harmony.rb +14 -0
  96. data/lib/head_music/style/guides/species_melody.rb +21 -0
  97. data/lib/head_music/style/guides/third_species_harmony.rb +2 -5
  98. data/lib/head_music/style/guides/third_species_melody.rb +3 -13
  99. data/lib/head_music/style/guides/third_species_triple_meter_harmony.rb +2 -5
  100. data/lib/head_music/style/guides/third_species_triple_meter_melody.rb +2 -12
  101. data/lib/head_music/time/conductor.rb +19 -125
  102. data/lib/head_music/time/musical_position.rb +26 -26
  103. data/lib/head_music/time/musical_time_converter.rb +104 -0
  104. data/lib/head_music/time/smpte_converter.rb +51 -0
  105. data/lib/head_music/time/smpte_timecode.rb +25 -23
  106. data/lib/head_music/time.rb +2 -0
  107. data/lib/head_music/value_equality.rb +37 -0
  108. data/lib/head_music/version.rb +1 -1
  109. data/lib/head_music.rb +25 -1
  110. metadata +31 -1
@@ -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
@@ -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,110 @@
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
+ end
82
+ end
83
+ end
84
+
85
+ def apply_repeat_flags(composition)
86
+ bar_hashes.each_with_index do |bar_hash, index|
87
+ next unless repeat_state?(bar_hash)
88
+
89
+ bar = composition.bars(values.bar_number(bar_hash, index)).last
90
+ bar.starts_repeat = true if bar_hash["starts_repeat"]
91
+ ends_repeat = bar_hash["ends_repeat_after_num_plays"]
92
+ bar.ends_repeat_after_num_plays = ends_repeat if ends_repeat
93
+ plays_on_passes = bar_hash["plays_on_passes"]
94
+ bar.plays_on_passes = plays_on_passes if plays_on_passes
95
+ end
96
+ end
97
+
98
+ def add_comments(composition)
99
+ Array(hash["comments"]).each_with_index do |comment_hash, index|
100
+ raw_position = comment_hash["position"]
101
+ position = values.position(raw_position, "comments[#{index}]") if raw_position
102
+ composition.add_comment(comment_hash["text"], position)
103
+ end
104
+ end
105
+
106
+ def repeat_state?(bar_hash)
107
+ bar_hash["starts_repeat"] || bar_hash["ends_repeat_after_num_plays"] || bar_hash["plays_on_passes"]
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,127 @@
1
+ class HeadMusic::Content::Composition
2
+ # Validates and coerces raw schema values into domain objects for the
3
+ # HashDeserializer. Every method takes the raw value and the path it came
4
+ # from, returning a validated object or raising ArgumentError with that path
5
+ # context. Stateless: it holds no reference to the source hash, so the
6
+ # deserializer stays responsible for *where* values come from and this class
7
+ # for *what* a value is allowed to be.
8
+ class SchemaValues
9
+ # Position silently coerces garbage strings to "0:1:000", which would
10
+ # mislocate content with no error, so the shape is validated up front.
11
+ # Accepts "bar", "bar:count", or "bar:count:tick" with non-negative parts.
12
+ def position(value, path)
13
+ return nil if value.nil?
14
+
15
+ unless value.is_a?(String) && value.match?(/\A\d+(:\d+){0,2}\z/)
16
+ raise ArgumentError, "#{path}: unknown position #{value.inspect}"
17
+ end
18
+ value
19
+ end
20
+
21
+ # KeySignature.get returns a hollow object (nil tonic_spelling) for
22
+ # garbage rather than nil, so presence of the tonic is the real check.
23
+ def key_signature(value, path)
24
+ return nil if value.nil?
25
+
26
+ key_signature = begin
27
+ HeadMusic::Rudiment::KeySignature.get(value)
28
+ rescue
29
+ nil
30
+ end
31
+ unless key_signature&.tonic_spelling
32
+ raise ArgumentError, "#{path}: unknown key signature #{value.inspect}"
33
+ end
34
+ key_signature
35
+ end
36
+
37
+ def meter(value, path)
38
+ return nil if value.nil?
39
+
40
+ meter = begin
41
+ HeadMusic::Rudiment::Meter.get(value)
42
+ rescue
43
+ nil
44
+ end
45
+ unless meter&.top_number&.positive? && meter.bottom_number.positive?
46
+ raise ArgumentError, "#{path}: unknown meter #{value.inspect}"
47
+ end
48
+ meter
49
+ end
50
+
51
+ def rhythmic_value(value, path)
52
+ rhythmic_value = HeadMusic::Rudiment::RhythmicValue.get(value)
53
+ unless valid_rhythmic_value?(rhythmic_value)
54
+ raise ArgumentError, "#{path}: unknown rhythmic value #{value.inspect}"
55
+ end
56
+ rhythmic_value
57
+ end
58
+
59
+ # "sounds" is an array of sound data, empty for a rest. A pitched sound
60
+ # is a pitch string; an unpitched sound is a one-key
61
+ # {"unpitched" => name_key} hash. A nil element is never a rest, so it
62
+ # fails like any other unknown sound.
63
+ def placement_sounds(placement_hash, path)
64
+ values = placement_hash["sounds"]
65
+ unless values.is_a?(Array)
66
+ raise ArgumentError, "#{path}: sounds must be an Array, got #{values.inspect}"
67
+ end
68
+
69
+ values.each_with_index.map do |value, index|
70
+ sound(value, "#{path}.sounds[#{index}]")
71
+ end
72
+ end
73
+
74
+ def bar_number(bar_hash, index)
75
+ number = bar_hash["number"]
76
+ unless number.is_a?(Integer) && number >= 0
77
+ raise ArgumentError, "bars[#{index}]: bar number must be an Integer of at least 0, got #{number.inspect}"
78
+ end
79
+ number
80
+ end
81
+
82
+ private
83
+
84
+ # RhythmicValue.get returns a hollow object (nil unit) for garbage rather
85
+ # than nil, and a tied tail can be hollow while the head parses, so the
86
+ # whole tie chain is checked.
87
+ def valid_rhythmic_value?(rhythmic_value)
88
+ return false unless rhythmic_value.is_a?(HeadMusic::Rudiment::RhythmicValue)
89
+ return false unless rhythmic_value.unit
90
+
91
+ tied_value = rhythmic_value.tied_value
92
+ tied_value.nil? || valid_rhythmic_value?(tied_value)
93
+ end
94
+
95
+ # A value that fails to parse would otherwise silently deserialize as
96
+ # a rest.
97
+ def pitch(value, path)
98
+ pitch = HeadMusic::Rudiment::Pitch.get(value)
99
+ raise ArgumentError, "#{path}: unknown pitch #{value.inspect}" unless pitch
100
+
101
+ pitch
102
+ end
103
+
104
+ def sound(value, path)
105
+ return pitch(value, path) if value.is_a?(String)
106
+ return unpitched_sound(value, path) if value.is_a?(Hash)
107
+
108
+ raise ArgumentError, "#{path}: unknown sound #{value.inspect}"
109
+ end
110
+
111
+ # A nil name_key is the generic unpitched sound. A pitched instrument is
112
+ # a valid hit surface (a knock on a violin body is unpitched), so any
113
+ # catalog name or alias resolves.
114
+ def unpitched_sound(value, path)
115
+ unless value.keys == ["unpitched"]
116
+ raise ArgumentError, "#{path}: unknown sound #{value.inspect}"
117
+ end
118
+
119
+ name = value["unpitched"]
120
+ valid_name = name.nil? || (name.is_a?(String) && !name.empty?)
121
+ sound = HeadMusic::Rudiment::UnpitchedSound.get(name) if valid_name
122
+ raise ArgumentError, "#{path}: unknown instrument #{name.inspect}" unless sound
123
+
124
+ sound
125
+ end
126
+ end
127
+ end
@@ -142,220 +142,4 @@ class HeadMusic::Content::Composition
142
142
  {"number" => number}.merge(bar_hash)
143
143
  end
144
144
  end
145
-
146
- # Rebuilds a composition from a schema v3 hash by replaying the public
147
- # builder API in dependency order: meter and key changes first (position
148
- # strings roll counts and ticks over via the meter map), then placements,
149
- # then repeat flags (a pickup-bar flag needs its bar allocated), then
150
- # comments. Validates values at the boundary so corrupted input raises
151
- # ArgumentError with path context instead of silently deserializing wrong.
152
- class HashDeserializer
153
- def initialize(hash)
154
- raise ArgumentError, "expected a Hash, got #{hash.class}" unless hash.is_a?(Hash)
155
-
156
- @hash = hash.deep_transform_keys(&:to_s)
157
- validate_schema_version
158
- end
159
-
160
- def composition
161
- @composition ||= build_base_composition.tap do |composition|
162
- apply_bar_changes(composition)
163
- build_voices(composition)
164
- apply_repeat_flags(composition)
165
- add_comments(composition)
166
- end
167
- end
168
-
169
- private
170
-
171
- attr_reader :hash
172
-
173
- def validate_schema_version
174
- version = hash["schema_version"]
175
- return if version.is_a?(Integer) && version == SCHEMA_VERSION
176
-
177
- message = "unsupported schema_version: #{version.inspect} (supported: #{SCHEMA_VERSION})"
178
- if version == 2
179
- message += "; migrate v2 \"pitches\" arrays to v3 \"sounds\" arrays " \
180
- "(pitch strings unchanged; unpitched sounds are {\"unpitched\" => name_key} objects)"
181
- end
182
- raise ArgumentError, message
183
- end
184
-
185
- def build_base_composition
186
- HeadMusic::Content::Composition.new(
187
- name: hash["name"],
188
- key_signature: parsed_key_signature(hash["key_signature"], "key_signature"),
189
- meter: parsed_meter(hash["meter"], "meter"),
190
- composer: hash["composer"],
191
- origin: hash["origin"]
192
- )
193
- end
194
-
195
- def bar_hashes
196
- @bar_hashes ||= Array(hash["bars"])
197
- end
198
-
199
- def apply_bar_changes(composition)
200
- bar_hashes.each_with_index do |bar_hash, index|
201
- number = validated_bar_number(bar_hash, index)
202
- path = "bars[#{index}]"
203
- key_signature = parsed_key_signature(bar_hash["key_signature"], path)
204
- meter = parsed_meter(bar_hash["meter"], path)
205
- composition.change_key_signature(number, key_signature) if key_signature
206
- composition.change_meter(number, meter) if meter
207
- end
208
- end
209
-
210
- def build_voices(composition)
211
- Array(hash["voices"]).each_with_index do |voice_hash, voice_index|
212
- voice = composition.add_voice(role: voice_hash["role"])
213
- Array(voice_hash["placements"]).each_with_index do |placement_hash, placement_index|
214
- path = "voices[#{voice_index}].placements[#{placement_index}]"
215
- position = parsed_position(placement_hash["position"], path)
216
- rhythmic_value = parsed_rhythmic_value(placement_hash["rhythmic_value"], path)
217
- sounds = parsed_placement_sounds(placement_hash, path)
218
- placement = voice.place(position, rhythmic_value, sounds)
219
- placement.beam_break_before = placement_hash["beam_break_before"] if placement_hash.key?("beam_break_before")
220
- end
221
- end
222
- end
223
-
224
- def apply_repeat_flags(composition)
225
- bar_hashes.each_with_index do |bar_hash, index|
226
- next unless repeat_state?(bar_hash)
227
-
228
- bar = composition.bars(validated_bar_number(bar_hash, index)).last
229
- bar.starts_repeat = true if bar_hash["starts_repeat"]
230
- bar.ends_repeat_after_num_plays = bar_hash["ends_repeat_after_num_plays"] if bar_hash["ends_repeat_after_num_plays"]
231
- bar.plays_on_passes = bar_hash["plays_on_passes"] if bar_hash["plays_on_passes"]
232
- end
233
- end
234
-
235
- def add_comments(composition)
236
- Array(hash["comments"]).each_with_index do |comment_hash, index|
237
- position = parsed_position(comment_hash["position"], "comments[#{index}]") if comment_hash["position"]
238
- composition.add_comment(comment_hash["text"], position)
239
- end
240
- end
241
-
242
- def repeat_state?(bar_hash)
243
- bar_hash["starts_repeat"] || bar_hash["ends_repeat_after_num_plays"] || bar_hash["plays_on_passes"]
244
- end
245
-
246
- def validated_bar_number(bar_hash, index)
247
- number = bar_hash["number"]
248
- unless number.is_a?(Integer) && number >= 0
249
- raise ArgumentError, "bars[#{index}]: bar number must be an Integer of at least 0, got #{number.inspect}"
250
- end
251
- number
252
- end
253
-
254
- # Position silently coerces garbage strings to "0:1:000", which would
255
- # mislocate content with no error, so the shape is validated up front.
256
- # Accepts "bar", "bar:count", or "bar:count:tick" with non-negative parts.
257
- def parsed_position(value, path)
258
- return nil if value.nil?
259
-
260
- unless value.is_a?(String) && value.match?(/\A\d+(:\d+){0,2}\z/)
261
- raise ArgumentError, "#{path}: unknown position #{value.inspect}"
262
- end
263
- value
264
- end
265
-
266
- # KeySignature.get returns a hollow object (nil tonic_spelling) for
267
- # garbage rather than nil, so presence of the tonic is the real check.
268
- def parsed_key_signature(value, path)
269
- return nil if value.nil?
270
-
271
- key_signature = begin
272
- HeadMusic::Rudiment::KeySignature.get(value)
273
- rescue
274
- nil
275
- end
276
- unless key_signature&.tonic_spelling
277
- raise ArgumentError, "#{path}: unknown key signature #{value.inspect}"
278
- end
279
- key_signature
280
- end
281
-
282
- def parsed_meter(value, path)
283
- return nil if value.nil?
284
-
285
- meter = begin
286
- HeadMusic::Rudiment::Meter.get(value)
287
- rescue
288
- nil
289
- end
290
- unless meter&.top_number&.positive? && meter.bottom_number.positive?
291
- raise ArgumentError, "#{path}: unknown meter #{value.inspect}"
292
- end
293
- meter
294
- end
295
-
296
- def parsed_rhythmic_value(value, path)
297
- rhythmic_value = HeadMusic::Rudiment::RhythmicValue.get(value)
298
- unless valid_rhythmic_value?(rhythmic_value)
299
- raise ArgumentError, "#{path}: unknown rhythmic value #{value.inspect}"
300
- end
301
- rhythmic_value
302
- end
303
-
304
- # RhythmicValue.get returns a hollow object (nil unit) for garbage rather
305
- # than nil, and a tied tail can be hollow while the head parses, so the
306
- # whole tie chain is checked.
307
- def valid_rhythmic_value?(rhythmic_value)
308
- return false unless rhythmic_value.is_a?(HeadMusic::Rudiment::RhythmicValue)
309
- return false unless rhythmic_value.unit
310
-
311
- rhythmic_value.tied_value.nil? || valid_rhythmic_value?(rhythmic_value.tied_value)
312
- end
313
-
314
- # A value that fails to parse would otherwise silently deserialize as
315
- # a rest.
316
- def parsed_pitch(value, path)
317
- pitch = HeadMusic::Rudiment::Pitch.get(value)
318
- raise ArgumentError, "#{path}: unknown pitch #{value.inspect}" unless pitch
319
-
320
- pitch
321
- end
322
-
323
- # "sounds" is an array of sound data, empty for a rest. A pitched sound
324
- # is a pitch string; an unpitched sound is a one-key
325
- # {"unpitched" => name_key} hash. A nil element is never a rest, so it
326
- # fails like any other unknown sound.
327
- def parsed_placement_sounds(placement_hash, path)
328
- values = placement_hash["sounds"]
329
- unless values.is_a?(Array)
330
- raise ArgumentError, "#{path}: sounds must be an Array, got #{values.inspect}"
331
- end
332
-
333
- values.each_with_index.map do |value, index|
334
- parsed_sound(value, "#{path}.sounds[#{index}]")
335
- end
336
- end
337
-
338
- def parsed_sound(value, path)
339
- return parsed_pitch(value, path) if value.is_a?(String)
340
- return parsed_unpitched_sound(value, path) if value.is_a?(Hash)
341
-
342
- raise ArgumentError, "#{path}: unknown sound #{value.inspect}"
343
- end
344
-
345
- # A nil name_key is the generic unpitched sound. A pitched instrument is
346
- # a valid hit surface (a knock on a violin body is unpitched), so any
347
- # catalog name or alias resolves.
348
- def parsed_unpitched_sound(value, path)
349
- unless value.keys == ["unpitched"]
350
- raise ArgumentError, "#{path}: unknown sound #{value.inspect}"
351
- end
352
-
353
- name = value["unpitched"]
354
- valid_name = name.nil? || (name.is_a?(String) && !name.empty?)
355
- sound = HeadMusic::Rudiment::UnpitchedSound.get(name) if valid_name
356
- raise ArgumentError, "#{path}: unknown instrument #{name.inspect}" unless sound
357
-
358
- sound
359
- end
360
- end
361
145
  end