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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +8 -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 -216
- data/lib/head_music/content/placement.rb +1 -41
- 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 +48 -23
- 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 +44 -183
- 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 +34 -17
- 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/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 +20 -209
- 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 +17 -13
- 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
- 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 ||=
|
|
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
|
|
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
|
|
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
|
|
206
|
-
|
|
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
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
|
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
|
|
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
|