head_music 17.2.0 → 17.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +15 -0
- data/Gemfile.lock +1 -1
- data/lib/head_music/analysis/chord_analysis.rb +123 -0
- data/lib/head_music/analysis/circle.rb +11 -3
- data/lib/head_music/analysis/diatonic_interval.rb +14 -11
- data/lib/head_music/analysis/dyad/chord_implication.rb +101 -0
- data/lib/head_music/analysis/dyad.rb +24 -104
- data/lib/head_music/analysis/interval_cycle.rb +9 -3
- data/lib/head_music/analysis/pitch_collection.rb +33 -107
- data/lib/head_music/analysis/sonority.rb +11 -21
- data/lib/head_music/content/composition/hash_deserializer.rb +110 -0
- data/lib/head_music/content/composition/schema_values.rb +127 -0
- data/lib/head_music/content/composition.rb +0 -215
- data/lib/head_music/content/placement.rb +11 -42
- data/lib/head_music/content/sound_resolver.rb +54 -0
- data/lib/head_music/content/voice/melodic_line.rb +32 -0
- data/lib/head_music/content/voice/melodic_note_pair.rb +33 -0
- data/lib/head_music/content/voice.rb +7 -58
- data/lib/head_music/instruments/alternate_tuning.rb +4 -6
- data/lib/head_music/instruments/catalog_lookup.rb +18 -0
- data/lib/head_music/instruments/instrument.rb +17 -117
- data/lib/head_music/instruments/instrument_catalog.rb +48 -0
- data/lib/head_music/instruments/instrument_configuration.rb +4 -6
- data/lib/head_music/instruments/instrument_family.rb +3 -8
- data/lib/head_music/instruments/instrument_name.rb +49 -0
- data/lib/head_music/instruments/score_order.rb +52 -42
- data/lib/head_music/instruments/staff_profile.rb +71 -0
- data/lib/head_music/instruments/stringing.rb +4 -6
- data/lib/head_music/instruments/stringing_course.rb +4 -6
- data/lib/head_music/instruments/variant.rb +4 -6
- data/lib/head_music/named/locale.rb +32 -0
- data/lib/head_music/named/localized_name.rb +15 -0
- data/lib/head_music/named.rb +1 -45
- data/lib/head_music/notation/abc/body_lexer.rb +65 -24
- data/lib/head_music/notation/abc/duration_resolver.rb +11 -1
- data/lib/head_music/notation/abc/duration_writer.rb +1 -8
- data/lib/head_music/notation/abc/header.rb +27 -16
- data/lib/head_music/notation/abc/parser.rb +48 -159
- data/lib/head_music/notation/abc/preflight.rb +48 -0
- data/lib/head_music/notation/abc/voice_state.rb +161 -0
- data/lib/head_music/notation/abc/writer.rb +47 -16
- data/lib/head_music/notation/dotted_duration.rb +19 -0
- data/lib/head_music/notation/instrument_notation.rb +5 -7
- data/lib/head_music/notation/music_xml/beam_grouper.rb +108 -0
- data/lib/head_music/notation/music_xml/duration_writer.rb +3 -8
- data/lib/head_music/notation/music_xml/preflight.rb +90 -0
- data/lib/head_music/notation/music_xml/render_plan.rb +161 -0
- data/lib/head_music/notation/music_xml/writer.rb +31 -146
- data/lib/head_music/notation/placement_validation.rb +19 -0
- data/lib/head_music/rudiment/enharmonic_equivalence.rb +33 -0
- data/lib/head_music/rudiment/key.rb +8 -36
- data/lib/head_music/rudiment/key_signature/enharmonic_equivalence.rb +21 -15
- data/lib/head_music/rudiment/key_signature.rb +18 -19
- data/lib/head_music/rudiment/meter.rb +39 -6
- data/lib/head_music/rudiment/mode.rb +33 -65
- data/lib/head_music/rudiment/note.rb +18 -21
- data/lib/head_music/rudiment/pitch/enharmonic_equivalence.rb +4 -15
- data/lib/head_music/rudiment/pitch/natural_step.rb +36 -0
- data/lib/head_music/rudiment/pitch/parser.rb +16 -5
- data/lib/head_music/rudiment/pitch.rb +11 -33
- data/lib/head_music/rudiment/qualified_diatonic_context.rb +61 -0
- data/lib/head_music/rudiment/rhythmic_unit/parser.rb +16 -12
- data/lib/head_music/rudiment/rhythmic_unit.rb +21 -37
- data/lib/head_music/rudiment/rhythmic_value/parser.rb +53 -46
- data/lib/head_music/rudiment/spelling/enharmonic_equivalence.rb +13 -0
- data/lib/head_music/rudiment/spelling.rb +0 -25
- data/lib/head_music/rudiment/tuning/just_intonation.rb +2 -4
- data/lib/head_music/rudiment/tuning/meantone.rb +2 -4
- data/lib/head_music/rudiment/tuning/pythagorean.rb +2 -4
- data/lib/head_music/rudiment/tuning/tonal_centered.rb +10 -0
- data/lib/head_music/rudiment/unpitched_note.rb +3 -5
- data/lib/head_music/style/annotation/configured.rb +35 -0
- data/lib/head_music/style/annotation.rb +32 -44
- data/lib/head_music/style/guidelines/allowed_rhythmic_values_for_fifth_species.rb +0 -10
- data/lib/head_music/style/guidelines/contoured.rb +65 -53
- data/lib/head_music/style/guidelines/dissonance_figure_detection.rb +55 -16
- data/lib/head_music/style/guidelines/florid_dissonance_treatment.rb +0 -37
- data/lib/head_music/style/guidelines/large_leaps.rb +10 -2
- data/lib/head_music/style/guidelines/minimum_melodic_intervals.rb +3 -25
- data/lib/head_music/style/guidelines/minimum_notes.rb +3 -25
- data/lib/head_music/style/guidelines/minimum_threshold.rb +38 -0
- data/lib/head_music/style/guidelines/no_parallel_perfect.rb +31 -0
- data/lib/head_music/style/guidelines/no_parallel_perfect_on_downbeats.rb +3 -17
- data/lib/head_music/style/guidelines/no_parallel_perfect_with_syncopation.rb +3 -17
- data/lib/head_music/style/guidelines/prepare_octave_leaps.rb +10 -7
- data/lib/head_music/style/guidelines/singable_intervals.rb +22 -8
- data/lib/head_music/style/guidelines/suspension_treatment.rb +30 -12
- data/lib/head_music/style/guidelines/third_species_dissonance_treatment.rb +0 -7
- data/lib/head_music/style/guidelines/triple_meter_dissonance_treatment.rb +0 -12
- data/lib/head_music/style/guidelines/weak_beat_dissonance_treatment.rb +2 -42
- data/lib/head_music/style/guides/fifth_species_melody.rb +3 -13
- data/lib/head_music/style/guides/fourth_species_melody.rb +3 -13
- data/lib/head_music/style/guides/second_species_harmony.rb +2 -5
- data/lib/head_music/style/guides/second_species_melody.rb +2 -12
- data/lib/head_music/style/guides/species_harmony.rb +14 -0
- data/lib/head_music/style/guides/species_melody.rb +21 -0
- data/lib/head_music/style/guides/third_species_harmony.rb +2 -5
- data/lib/head_music/style/guides/third_species_melody.rb +3 -13
- data/lib/head_music/style/guides/third_species_triple_meter_harmony.rb +2 -5
- data/lib/head_music/style/guides/third_species_triple_meter_melody.rb +2 -12
- data/lib/head_music/time/conductor.rb +19 -125
- data/lib/head_music/time/musical_position.rb +26 -26
- data/lib/head_music/time/musical_time_converter.rb +104 -0
- data/lib/head_music/time/smpte_converter.rb +51 -0
- data/lib/head_music/time/smpte_timecode.rb +25 -23
- data/lib/head_music/time.rb +2 -0
- data/lib/head_music/value_equality.rb +37 -0
- data/lib/head_music/version.rb +1 -1
- data/lib/head_music.rb +25 -1
- data/user-stories/done/abc-notation-beaming.md +235 -0
- data/user-stories/index.html +7 -3
- metadata +34 -2
|
@@ -30,30 +30,9 @@ class HeadMusic::Instruments::ScoreOrder
|
|
|
30
30
|
|
|
31
31
|
# Accepts a list of instruments and orders them according to this ensemble type's conventions
|
|
32
32
|
def order(instruments)
|
|
33
|
-
valid_inputs = instruments.compact.reject { |i| i.respond_to?(:empty?) && i.empty? }
|
|
34
|
-
instrument_objects = valid_inputs.map { |i| normalize_to_instrument(i) }.compact
|
|
35
|
-
|
|
36
|
-
# Build ordering index
|
|
37
33
|
ordering_index = build_ordering_index
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
known_instruments = []
|
|
41
|
-
unknown_instruments = []
|
|
42
|
-
|
|
43
|
-
instrument_objects.each do |instrument|
|
|
44
|
-
position_info = find_position_with_transposition(instrument, ordering_index)
|
|
45
|
-
if position_info
|
|
46
|
-
known_instruments << [instrument, position_info]
|
|
47
|
-
else
|
|
48
|
-
unknown_instruments << instrument
|
|
49
|
-
end
|
|
50
|
-
end
|
|
51
|
-
|
|
52
|
-
# Sort known instruments by position (primary) and transposition (secondary)
|
|
53
|
-
sorted_known = known_instruments.sort_by { |_, pos_info|
|
|
54
|
-
[pos_info[:position], -pos_info[:transposition]]
|
|
55
|
-
}.map(&:first)
|
|
56
|
-
sorted_known + unknown_instruments.sort_by(&:to_s)
|
|
34
|
+
known, unknown = partition_by_known_position(normalize_inputs(instruments), ordering_index)
|
|
35
|
+
sort_known(known) + unknown.sort_by(&:to_s)
|
|
57
36
|
end
|
|
58
37
|
|
|
59
38
|
private_class_method :new
|
|
@@ -68,6 +47,32 @@ class HeadMusic::Instruments::ScoreOrder
|
|
|
68
47
|
self.name = data["name"] || ensemble_type_key.to_s.tr("_", " ").capitalize
|
|
69
48
|
end
|
|
70
49
|
|
|
50
|
+
# Discards blank inputs and converts the rest to Instrument objects
|
|
51
|
+
def normalize_inputs(instruments)
|
|
52
|
+
valid_inputs = instruments.compact.reject { |i| i.respond_to?(:empty?) && i.empty? }
|
|
53
|
+
valid_inputs.map { |i| normalize_to_instrument(i) }.compact
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Splits instruments into those with a known score position and those without
|
|
57
|
+
def partition_by_known_position(instrument_objects, ordering_index)
|
|
58
|
+
known = []
|
|
59
|
+
unknown = []
|
|
60
|
+
instrument_objects.each do |instrument|
|
|
61
|
+
position_info = find_position_with_transposition(instrument, ordering_index)
|
|
62
|
+
if position_info
|
|
63
|
+
known << [instrument, position_info]
|
|
64
|
+
else
|
|
65
|
+
unknown << instrument
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
[known, unknown]
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Sorts known instruments by position (primary) and transposition (secondary)
|
|
72
|
+
def sort_known(known)
|
|
73
|
+
known.sort_by { |_, pos_info| [pos_info[:position], -pos_info[:transposition]] }.map(&:first)
|
|
74
|
+
end
|
|
75
|
+
|
|
71
76
|
def normalize_to_instrument(input)
|
|
72
77
|
# Return if already an Instrument instance
|
|
73
78
|
return input if input.is_a?(HeadMusic::Instruments::Instrument)
|
|
@@ -96,31 +101,36 @@ class HeadMusic::Instruments::ScoreOrder
|
|
|
96
101
|
index
|
|
97
102
|
end
|
|
98
103
|
|
|
99
|
-
# Finds the position of an instrument in the ordering
|
|
104
|
+
# Finds the position of an instrument in the ordering.
|
|
105
|
+
# Positions are non-negative integers, so a nil lookup safely means "absent".
|
|
100
106
|
def find_position(instrument, ordering_index)
|
|
101
|
-
|
|
102
|
-
|
|
107
|
+
position_by_name_key(instrument, ordering_index) ||
|
|
108
|
+
position_by_family(instrument, ordering_index) ||
|
|
109
|
+
position_by_normalized_name(instrument, ordering_index)
|
|
110
|
+
end
|
|
103
111
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
instrument_key = instrument.name_key.to_s
|
|
112
|
+
# Exact match on the instrument's name_key
|
|
113
|
+
def position_by_name_key(instrument, ordering_index)
|
|
114
|
+
return nil unless instrument.name_key
|
|
108
115
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
# Look for the specific variant first
|
|
112
|
-
return ordering_index[instrument_key] if ordering_index.key?(instrument_key)
|
|
116
|
+
ordering_index[instrument.name_key.to_s]
|
|
117
|
+
end
|
|
113
118
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
119
|
+
# Match a family variant (e.g., alto_saxophone -> saxophone family)
|
|
120
|
+
def position_by_family(instrument, ordering_index)
|
|
121
|
+
return nil unless instrument.family_key
|
|
122
|
+
|
|
123
|
+
family_base = instrument.family_key.to_s
|
|
124
|
+
instrument_key = instrument.name_key.to_s
|
|
125
|
+
return nil unless instrument_key.include?(family_base)
|
|
118
126
|
|
|
119
|
-
#
|
|
120
|
-
|
|
121
|
-
|
|
127
|
+
# Prefer the specific variant, then fall back to the generic family instrument
|
|
128
|
+
ordering_index[instrument_key] || ordering_index[family_base]
|
|
129
|
+
end
|
|
122
130
|
|
|
123
|
-
|
|
131
|
+
# Match the normalized (lowercase, underscored) display name
|
|
132
|
+
def position_by_normalized_name(instrument, ordering_index)
|
|
133
|
+
ordering_index[HeadMusic::Utilities::Case.to_snake_case(instrument.name)]
|
|
124
134
|
end
|
|
125
135
|
|
|
126
136
|
# Finds the position and transposition information for an instrument
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# A module for musical instruments and their properties
|
|
2
|
+
module HeadMusic::Instruments; end
|
|
3
|
+
|
|
4
|
+
# The properties an instrument derives from its default notation staves:
|
|
5
|
+
# how many staves it uses, their clefs, whether it transposes, and whether it
|
|
6
|
+
# is pitched. These are a projection of the notation layer (a StaffScheme built
|
|
7
|
+
# from the default NotationStyle), separated from the instrument's identity so
|
|
8
|
+
# Instrument can delegate to one place rather than carry the staff logic itself.
|
|
9
|
+
class HeadMusic::Instruments::StaffProfile
|
|
10
|
+
attr_reader :instrument
|
|
11
|
+
|
|
12
|
+
def initialize(instrument)
|
|
13
|
+
@instrument = instrument
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def staff_schemes
|
|
17
|
+
[default_staff_scheme]
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def default_staff_scheme
|
|
21
|
+
@default_staff_scheme ||= HeadMusic::Instruments::StaffScheme.new(
|
|
22
|
+
key: "default",
|
|
23
|
+
instrument: instrument,
|
|
24
|
+
list: default_notation_staves_data
|
|
25
|
+
)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def default_staves
|
|
29
|
+
default_staff_scheme.staves
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def default_clefs
|
|
33
|
+
default_staves&.map(&:clef) || []
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def sounding_transposition
|
|
37
|
+
default_staves&.first&.sounding_transposition || 0
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def transposing?
|
|
41
|
+
sounding_transposition != 0
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def transposing_at_the_octave?
|
|
45
|
+
transposing? && sounding_transposition % 12 == 0
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def single_staff?
|
|
49
|
+
default_staves.length == 1
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def multiple_staves?
|
|
53
|
+
default_staves.length > 1
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def pitched?
|
|
57
|
+
return false if default_clefs.compact.uniq == [HeadMusic::Rudiment::Clef.get("neutral_clef")]
|
|
58
|
+
|
|
59
|
+
default_clefs.any?
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
# The raw staff-attribute list for this instrument's default notation,
|
|
65
|
+
# resolved from the default NotationStyle. Referenced only inside a method
|
|
66
|
+
# body, because the Notation module loads after Instruments.
|
|
67
|
+
def default_notation_staves_data
|
|
68
|
+
notation = HeadMusic::Notation::NotationStyle.default.notation_for(instrument)
|
|
69
|
+
(notation&.staves || []).map(&:attributes)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -10,10 +10,14 @@ module HeadMusic::Instruments; end
|
|
|
10
10
|
# stringing = HeadMusic::Instruments::Stringing.for_instrument(guitar)
|
|
11
11
|
# stringing.courses.map(&:standard_pitch) # => [E2, A2, D3, G3, B3, E4]
|
|
12
12
|
class HeadMusic::Instruments::Stringing
|
|
13
|
+
include HeadMusic::ValueEquality
|
|
14
|
+
|
|
13
15
|
STRINGINGS = YAML.load_file(File.expand_path("stringings.yml", __dir__)).freeze
|
|
14
16
|
|
|
15
17
|
attr_reader :instrument_key, :courses
|
|
16
18
|
|
|
19
|
+
value_equality :instrument_key, :courses
|
|
20
|
+
|
|
17
21
|
class << self
|
|
18
22
|
# Find the stringing for an instrument
|
|
19
23
|
# @param instrument [HeadMusic::Instruments::Instrument, String, Symbol] The instrument
|
|
@@ -92,12 +96,6 @@ class HeadMusic::Instruments::Stringing
|
|
|
92
96
|
end
|
|
93
97
|
end
|
|
94
98
|
|
|
95
|
-
def ==(other)
|
|
96
|
-
return false unless other.is_a?(self.class)
|
|
97
|
-
|
|
98
|
-
instrument_key == other.instrument_key && courses == other.courses
|
|
99
|
-
end
|
|
100
|
-
|
|
101
99
|
def to_s
|
|
102
100
|
"#{course_count}-course stringing for #{instrument_key}"
|
|
103
101
|
end
|
|
@@ -11,8 +11,12 @@ module HeadMusic::Instruments; end
|
|
|
11
11
|
# - 12-string guitar: 6 courses, each with 2 strings (octave or unison)
|
|
12
12
|
# - Mandolin: 4 courses, each with 2 strings in unison
|
|
13
13
|
class HeadMusic::Instruments::StringingCourse
|
|
14
|
+
include HeadMusic::ValueEquality
|
|
15
|
+
|
|
14
16
|
attr_reader :standard_pitch, :course_semitones
|
|
15
17
|
|
|
18
|
+
value_equality :standard_pitch, :course_semitones
|
|
19
|
+
|
|
16
20
|
# @param standard_pitch [HeadMusic::Rudiment::Pitch, String] The pitch of the primary string
|
|
17
21
|
# @param course_semitones [Array<Integer>] Semitone offsets for additional strings in the course
|
|
18
22
|
def initialize(standard_pitch:, course_semitones: [])
|
|
@@ -38,12 +42,6 @@ class HeadMusic::Instruments::StringingCourse
|
|
|
38
42
|
course_semitones.any?
|
|
39
43
|
end
|
|
40
44
|
|
|
41
|
-
def ==(other)
|
|
42
|
-
return false unless other.is_a?(self.class)
|
|
43
|
-
|
|
44
|
-
standard_pitch == other.standard_pitch && course_semitones == other.course_semitones
|
|
45
|
-
end
|
|
46
|
-
|
|
47
45
|
def to_s
|
|
48
46
|
standard_pitch.to_s
|
|
49
47
|
end
|
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
module HeadMusic::Instruments; end
|
|
2
2
|
|
|
3
3
|
class HeadMusic::Instruments::Variant
|
|
4
|
+
include HeadMusic::ValueEquality
|
|
5
|
+
|
|
4
6
|
attr_reader :key, :attributes
|
|
5
7
|
|
|
8
|
+
value_equality :key, :attributes
|
|
9
|
+
|
|
6
10
|
def initialize(key, attributes = {})
|
|
7
11
|
@key = key.to_s.to_sym
|
|
8
12
|
@attributes = attributes
|
|
@@ -34,10 +38,4 @@ class HeadMusic::Instruments::Variant
|
|
|
34
38
|
@default_staff_scheme ||=
|
|
35
39
|
staff_schemes.find(&:default?) || staff_schemes.first
|
|
36
40
|
end
|
|
37
|
-
|
|
38
|
-
def ==(other)
|
|
39
|
-
return false unless other.is_a?(self.class)
|
|
40
|
-
|
|
41
|
-
key == other.key && attributes == other.attributes
|
|
42
|
-
end
|
|
43
41
|
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
module HeadMusic::Named
|
|
2
|
+
# Locale encapsulates a language and optional region or country.
|
|
3
|
+
class Locale
|
|
4
|
+
DEFAULT_CODE = :en_US
|
|
5
|
+
|
|
6
|
+
attr_reader :language, :region
|
|
7
|
+
|
|
8
|
+
def self.default_locale
|
|
9
|
+
get(DEFAULT_CODE)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def self.get(code)
|
|
13
|
+
@locales ||= {}
|
|
14
|
+
parts = code.to_s.split(/[_-]/)
|
|
15
|
+
language = parts[0].downcase
|
|
16
|
+
region = parts[1]&.upcase
|
|
17
|
+
key = [language, region].compact.join("_").to_sym
|
|
18
|
+
@locales[key] ||= new(language: language, region: region)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def initialize(language:, region: nil)
|
|
22
|
+
@language = language
|
|
23
|
+
@region = region
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def code
|
|
27
|
+
@code ||= [@language, @region].compact.join("_")
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private_class_method :new
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
module HeadMusic::Named
|
|
2
|
+
# A LocalizedName is the name of a rudiment in a locale.
|
|
3
|
+
class LocalizedName
|
|
4
|
+
attr_reader :locale, :name, :abbreviation
|
|
5
|
+
|
|
6
|
+
delegate :code, to: :locale, prefix: true
|
|
7
|
+
delegate :language, :region, to: :locale
|
|
8
|
+
|
|
9
|
+
def initialize(name:, locale_code: Locale::DEFAULT_CODE, abbreviation: nil)
|
|
10
|
+
@name = name
|
|
11
|
+
@locale = Locale.get(locale_code)
|
|
12
|
+
@abbreviation = abbreviation
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
data/lib/head_music/named.rb
CHANGED
|
@@ -1,52 +1,8 @@
|
|
|
1
1
|
# NameRudiment is a module to be included in classes whose instances may be identified by name.
|
|
2
|
+
# Locale and LocalizedName, the value objects it builds names from, live in their own files.
|
|
2
3
|
module HeadMusic::Named
|
|
3
4
|
delegate :to_s, to: :name
|
|
4
5
|
|
|
5
|
-
# Locale encapsulates a language and optional region or country.
|
|
6
|
-
class Locale
|
|
7
|
-
DEFAULT_CODE = :en_US
|
|
8
|
-
|
|
9
|
-
attr_reader :language, :region
|
|
10
|
-
|
|
11
|
-
def self.default_locale
|
|
12
|
-
get(DEFAULT_CODE)
|
|
13
|
-
end
|
|
14
|
-
|
|
15
|
-
def self.get(code)
|
|
16
|
-
@locales ||= {}
|
|
17
|
-
parts = code.to_s.split(/[_-]/)
|
|
18
|
-
language = parts[0].downcase
|
|
19
|
-
region = parts[1]&.upcase
|
|
20
|
-
key = [language, region].compact.join("_").to_sym
|
|
21
|
-
@locales[key] ||= new(language: language, region: region)
|
|
22
|
-
end
|
|
23
|
-
|
|
24
|
-
def initialize(language:, region: nil)
|
|
25
|
-
@language = language
|
|
26
|
-
@region = region
|
|
27
|
-
end
|
|
28
|
-
|
|
29
|
-
def code
|
|
30
|
-
@code ||= [@language, @region].compact.join("_")
|
|
31
|
-
end
|
|
32
|
-
|
|
33
|
-
private_class_method :new
|
|
34
|
-
end
|
|
35
|
-
|
|
36
|
-
# A LocalizedName is the name of a rudiment in a locale.
|
|
37
|
-
class LocalizedName
|
|
38
|
-
attr_reader :locale, :name, :abbreviation
|
|
39
|
-
|
|
40
|
-
delegate :code, to: :locale, prefix: true
|
|
41
|
-
delegate :language, :region, to: :locale
|
|
42
|
-
|
|
43
|
-
def initialize(name:, locale_code: Locale::DEFAULT_CODE, abbreviation: nil)
|
|
44
|
-
@name = name
|
|
45
|
-
@locale = Locale.get(locale_code)
|
|
46
|
-
@abbreviation = abbreviation
|
|
47
|
-
end
|
|
48
|
-
end
|
|
49
|
-
|
|
50
6
|
# Adds .get_by_name to the including class.
|
|
51
7
|
module ClassMethods
|
|
52
8
|
def get_by_name(name)
|
|
@@ -38,10 +38,20 @@ class HeadMusic::Notation::ABC::BodyLexer
|
|
|
38
38
|
|
|
39
39
|
NOTE_PATTERN = %r{(\^\^|\^|__|_|=)?([A-Ga-g])([',]*)([\d/]*)}
|
|
40
40
|
CHORD_START_PATTERN = /\[(\^\^|\^|__|_|=)?[A-Ga-g]/
|
|
41
|
+
# An inline field ("[K:...]"), tried closed before its unterminated fallback.
|
|
42
|
+
INLINE_FIELD_PATTERNS = [/\[[A-Za-z]:[^\]]*\]/, /\[[A-Za-z]:[^\]]*/].freeze
|
|
41
43
|
CHORD_NOTE_PATTERN = %r{(\^\^|\^|__|_|=)?([A-Ga-g])([',]*)([\d/]*)}
|
|
42
44
|
REST_PATTERN = %r{z([\d/]*)}
|
|
43
45
|
VOLTA_DIGITS_PATTERN = /\d[\d,-]*/
|
|
44
46
|
|
|
47
|
+
# Recognizable ABC we deliberately don't handle: grace notes ({..}),
|
|
48
|
+
# decorations (!..!), tuplets, slurs, and special rests (Z, x). Ordered
|
|
49
|
+
# so a closed form is tried before its unterminated fallback.
|
|
50
|
+
UNSUPPORTED_PATTERNS = [
|
|
51
|
+
/\{[^}]*\}/, /\{[^}]*/, /![^!]*!/, /![^!]*/,
|
|
52
|
+
/\(\d/, /[()~.]/, /Z\d*/, %r{x[\d/]*}
|
|
53
|
+
].freeze
|
|
54
|
+
|
|
45
55
|
SNIPPET_LENGTH = 20
|
|
46
56
|
|
|
47
57
|
def initialize(body_text, start_line: 1)
|
|
@@ -70,15 +80,17 @@ class HeadMusic::Notation::ABC::BodyLexer
|
|
|
70
80
|
@body.lines.map(&:chomp).each_with_index do |line_text, index|
|
|
71
81
|
break if line_text.strip.empty?
|
|
72
82
|
|
|
73
|
-
|
|
74
|
-
if !continued && line_start_token(line_text, line_number, tokens)
|
|
75
|
-
next
|
|
76
|
-
end
|
|
77
|
-
continued = scan_line(line_text, line_number, tokens)
|
|
83
|
+
continued = lex_line(line_text, @start_line + index, tokens, continued)
|
|
78
84
|
end
|
|
79
85
|
tokens
|
|
80
86
|
end
|
|
81
87
|
|
|
88
|
+
def lex_line(line_text, line_number, tokens, continued)
|
|
89
|
+
return false if !continued && line_start_token(line_text, line_number, tokens)
|
|
90
|
+
|
|
91
|
+
scan_line(line_text, line_number, tokens)
|
|
92
|
+
end
|
|
93
|
+
|
|
82
94
|
# Handles lines that are fields rather than music: V: switches voices;
|
|
83
95
|
# any other letter-colon line is a field we don't interpret in the body.
|
|
84
96
|
def line_start_token(line_text, line_number, tokens)
|
|
@@ -107,19 +119,35 @@ class HeadMusic::Notation::ABC::BodyLexer
|
|
|
107
119
|
Token.new(type: :voice_change, line: line_number, column: 1, voice_id: voice_id)
|
|
108
120
|
end
|
|
109
121
|
|
|
122
|
+
# Music tokens whose whitespace successor breaks a beam group; other
|
|
123
|
+
# tokens (bar lines, ties, etc.) already interrupt beaming on their own.
|
|
124
|
+
BEAMABLE_TOKEN_TYPES = [:note, :rest, :chord].freeze
|
|
125
|
+
|
|
110
126
|
# Returns true when the line ends with a continuation backslash.
|
|
111
127
|
def scan_line(line_text, line_number, tokens)
|
|
112
128
|
scanner = StringScanner.new(line_text)
|
|
113
129
|
until scanner.eos?
|
|
114
|
-
|
|
130
|
+
spaced = scanner.skip(/[ \t]+/)
|
|
115
131
|
break if scanner.skip(/%/)
|
|
116
132
|
return true if scanner.skip(/\\[ \t]*\z/)
|
|
133
|
+
break if scanner.eos?
|
|
117
134
|
|
|
135
|
+
tokens << beam_break_token(scanner, line_number) if spaced && beamable_predecessor?(tokens.last)
|
|
118
136
|
scan_token(scanner, line_number, tokens)
|
|
119
137
|
end
|
|
120
138
|
false
|
|
121
139
|
end
|
|
122
140
|
|
|
141
|
+
# A beam break only matters after a music token; whitespace elsewhere
|
|
142
|
+
# (leading, after a bar line) carries no beaming signal.
|
|
143
|
+
def beamable_predecessor?(token)
|
|
144
|
+
!token.nil? && BEAMABLE_TOKEN_TYPES.include?(token.type)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def beam_break_token(scanner, line_number)
|
|
148
|
+
Token.new(type: :beam_break, line: line_number, column: scanner.pos + 1)
|
|
149
|
+
end
|
|
150
|
+
|
|
123
151
|
def scan_token(scanner, line_number, tokens)
|
|
124
152
|
column = scanner.pos + 1
|
|
125
153
|
|
|
@@ -174,7 +202,7 @@ class HeadMusic::Notation::ABC::BodyLexer
|
|
|
174
202
|
return true
|
|
175
203
|
end
|
|
176
204
|
|
|
177
|
-
inline_field = scanner
|
|
205
|
+
inline_field = scan_first(scanner, INLINE_FIELD_PATTERNS)
|
|
178
206
|
if inline_field
|
|
179
207
|
tokens << unsupported_token(inline_field, line_number, column)
|
|
180
208
|
return true
|
|
@@ -188,19 +216,27 @@ class HeadMusic::Notation::ABC::BodyLexer
|
|
|
188
216
|
def scan_chord(scanner, line_number, column, tokens)
|
|
189
217
|
start_pos = scanner.pos
|
|
190
218
|
scanner.skip(/\[/)
|
|
219
|
+
notes = collect_chord_notes(scanner)
|
|
220
|
+
return chord_fallback(scanner, start_pos, line_number, column, tokens) unless notes
|
|
221
|
+
|
|
222
|
+
length = scanner.scan(%r{[\d/]*})
|
|
223
|
+
tokens << Token.new(type: :chord, line: line_number, column: column, notes: notes, length: length)
|
|
224
|
+
true
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# Collects the notes between the brackets, or nil when a non-note is hit
|
|
228
|
+
# (leaving the scanner where it stopped so the fallback can react).
|
|
229
|
+
def collect_chord_notes(scanner)
|
|
191
230
|
notes = []
|
|
192
231
|
until scanner.skip(/\]/)
|
|
193
|
-
unless scanner.scan(CHORD_NOTE_PATTERN)
|
|
194
|
-
|
|
195
|
-
end
|
|
232
|
+
return nil unless scanner.scan(CHORD_NOTE_PATTERN)
|
|
233
|
+
|
|
196
234
|
notes << ChordNote.new(
|
|
197
235
|
accidental: scanner[1], letter: scanner[2],
|
|
198
236
|
octave_marks: scanner[3], length: scanner[4]
|
|
199
237
|
)
|
|
200
238
|
end
|
|
201
|
-
|
|
202
|
-
tokens << Token.new(type: :chord, line: line_number, column: column, notes: notes, length: length)
|
|
203
|
-
true
|
|
239
|
+
notes
|
|
204
240
|
end
|
|
205
241
|
|
|
206
242
|
# Non-note content inside the brackets (ties, rests, spaces,
|
|
@@ -235,10 +271,12 @@ class HeadMusic::Notation::ABC::BodyLexer
|
|
|
235
271
|
end
|
|
236
272
|
|
|
237
273
|
def volta_passes(digits)
|
|
238
|
-
digits.split(",").flat_map
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
274
|
+
digits.split(",").flat_map { |part| expand_volta_range(part) }.select(&:positive?)
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def expand_volta_range(part)
|
|
278
|
+
first, last = part.split("-", 2)
|
|
279
|
+
last ? (first.to_i..last.to_i).to_a : [first.to_i]
|
|
242
280
|
end
|
|
243
281
|
|
|
244
282
|
def scan_note(scanner, line_number, column, tokens)
|
|
@@ -289,20 +327,23 @@ class HeadMusic::Notation::ABC::BodyLexer
|
|
|
289
327
|
true
|
|
290
328
|
end
|
|
291
329
|
|
|
292
|
-
# Recognizable ABC we deliberately don't handle: grace notes,
|
|
293
|
-
# decorations, tuplets, slurs, and special rests.
|
|
294
330
|
def scan_unsupported(scanner, line_number, column, tokens)
|
|
295
|
-
lexeme =
|
|
296
|
-
scanner.scan(/\{[^}]*\}/) || scanner.scan(/\{[^}]*/) ||
|
|
297
|
-
scanner.scan(/![^!]*!/) || scanner.scan(/![^!]*/) ||
|
|
298
|
-
scanner.scan(/\(\d/) || scanner.scan(/[()~.]/) ||
|
|
299
|
-
scanner.scan(/Z\d*/) || scanner.scan(%r{x[\d/]*})
|
|
331
|
+
lexeme = scan_first(scanner, UNSUPPORTED_PATTERNS)
|
|
300
332
|
return false unless lexeme
|
|
301
333
|
|
|
302
334
|
tokens << unsupported_token(lexeme, line_number, column)
|
|
303
335
|
true
|
|
304
336
|
end
|
|
305
337
|
|
|
338
|
+
# Returns the first pattern's match, trying each in order.
|
|
339
|
+
def scan_first(scanner, patterns)
|
|
340
|
+
patterns.each do |pattern|
|
|
341
|
+
lexeme = scanner.scan(pattern)
|
|
342
|
+
return lexeme if lexeme
|
|
343
|
+
end
|
|
344
|
+
nil
|
|
345
|
+
end
|
|
346
|
+
|
|
306
347
|
def unsupported_token(lexeme, line_number, column)
|
|
307
348
|
Token.new(type: :unsupported, line: line_number, column: column, lexeme: lexeme)
|
|
308
349
|
end
|
|
@@ -48,6 +48,10 @@ module HeadMusic::Notation::ABC
|
|
|
48
48
|
return Rational(numerator) unless slashes
|
|
49
49
|
return Rational(numerator, 2**slashes.length) unless denominator
|
|
50
50
|
|
|
51
|
+
explicit_ratio(numerator, slashes, denominator, source)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def explicit_ratio(numerator, slashes, denominator, source)
|
|
51
55
|
# An explicit denominator pairs with exactly one slash ("3/2", not "3//2").
|
|
52
56
|
raise_error("malformed note length multiplier", source) if slashes.length > 1
|
|
53
57
|
raise_error("note length denominator cannot be zero", source) if denominator.to_i.zero?
|
|
@@ -85,9 +89,15 @@ module HeadMusic::Notation::ABC
|
|
|
85
89
|
def greedy_head(fraction)
|
|
86
90
|
numerator = fraction.numerator
|
|
87
91
|
bits = numerator.bit_length
|
|
92
|
+
run = leading_set_bits(numerator, bits)
|
|
93
|
+
Rational(((1 << run) - 1) << (bits - run), fraction.denominator)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Length of the leading run of set bits, capped at four (triple-dotted).
|
|
97
|
+
def leading_set_bits(numerator, bits)
|
|
88
98
|
run = 0
|
|
89
99
|
run += 1 while run < 4 && run < bits && numerator[bits - 1 - run] == 1
|
|
90
|
-
|
|
100
|
+
run
|
|
91
101
|
end
|
|
92
102
|
|
|
93
103
|
def unit_for(unit_fraction, source)
|
|
@@ -22,19 +22,12 @@ module HeadMusic::Notation::ABC
|
|
|
22
22
|
# from integer parts so the multiplier arithmetic stays exact. A tied
|
|
23
23
|
# chain collapses to one multiplier, round-tripping tokens like "A5".
|
|
24
24
|
def total_fraction(rhythmic_value)
|
|
25
|
-
fraction =
|
|
25
|
+
fraction = HeadMusic::Notation::DottedDuration.dotted_unit_fraction(rhythmic_value)
|
|
26
26
|
tied_value = rhythmic_value.tied_value
|
|
27
27
|
fraction += total_fraction(tied_value) if tied_value
|
|
28
28
|
fraction
|
|
29
29
|
end
|
|
30
30
|
|
|
31
|
-
def single_fraction(rhythmic_value)
|
|
32
|
-
unit = rhythmic_value.unit
|
|
33
|
-
dots = rhythmic_value.dots
|
|
34
|
-
# A value with d dots spans (2^(d+1) - 1) / 2^d of its unit.
|
|
35
|
-
Rational(unit.numerator, unit.denominator) * Rational((2**(dots + 1)) - 1, 2**dots)
|
|
36
|
-
end
|
|
37
|
-
|
|
38
31
|
def validate_fraction!(fraction, rhythmic_value)
|
|
39
32
|
max_fraction = DurationResolver::MAX_FRACTION
|
|
40
33
|
if fraction > max_fraction
|
|
@@ -44,6 +44,14 @@ class HeadMusic::Notation::ABC::Header
|
|
|
44
44
|
|
|
45
45
|
# Returns true when the field is K:, which terminates the header.
|
|
46
46
|
def assign_field(stripped_line, line_number)
|
|
47
|
+
letter, value = parse_field(stripped_line, line_number)
|
|
48
|
+
return assign_key(value, line_number) if letter == "K"
|
|
49
|
+
|
|
50
|
+
store_field(letter, value, line_number, stripped_line)
|
|
51
|
+
false
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def parse_field(stripped_line, line_number)
|
|
47
55
|
match = FIELD_PATTERN.match(stripped_line)
|
|
48
56
|
unless match
|
|
49
57
|
raise HeadMusic::Notation::ABC::ParseError.new(
|
|
@@ -51,8 +59,15 @@ class HeadMusic::Notation::ABC::Header
|
|
|
51
59
|
line_number: line_number, snippet: stripped_line
|
|
52
60
|
)
|
|
53
61
|
end
|
|
54
|
-
|
|
55
|
-
|
|
62
|
+
[match[1], match[2].strip]
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def assign_key(value, line_number)
|
|
66
|
+
@key_signature = HeadMusic::Notation::ABC::KeyMapper.new(value, line_number: line_number).key_signature
|
|
67
|
+
true
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def store_field(letter, value, line_number, stripped_line)
|
|
56
71
|
case letter
|
|
57
72
|
when "X" then @reference_number = value
|
|
58
73
|
when "T" then @title = value
|
|
@@ -62,15 +77,11 @@ class HeadMusic::Notation::ABC::Header
|
|
|
62
77
|
when "M" then @meter = resolve_meter(value, line_number)
|
|
63
78
|
when "L" then @unit_note_length = resolve_unit_note_length(value, line_number)
|
|
64
79
|
when "V" then @voice_ids << value.split.first
|
|
65
|
-
when "K"
|
|
66
|
-
@key_signature = HeadMusic::Notation::ABC::KeyMapper.new(value, line_number: line_number).key_signature
|
|
67
|
-
return true
|
|
68
80
|
else
|
|
69
81
|
raise HeadMusic::Notation::ABC::UnsupportedFeatureError.new(
|
|
70
82
|
"Unsupported header field #{letter.inspect}", line_number: line_number, snippet: stripped_line
|
|
71
83
|
)
|
|
72
84
|
end
|
|
73
|
-
false
|
|
74
85
|
end
|
|
75
86
|
|
|
76
87
|
def capture_body(lines, key_line_index)
|
|
@@ -84,23 +95,23 @@ class HeadMusic::Notation::ABC::Header
|
|
|
84
95
|
return HeadMusic::Rudiment::Meter.common_time if value == "C"
|
|
85
96
|
return HeadMusic::Rudiment::Meter.cut_time if value == "C|"
|
|
86
97
|
|
|
87
|
-
|
|
88
|
-
raise HeadMusic::Notation::ABC::ParseError.new(
|
|
89
|
-
"Invalid meter #{value.inspect}", line_number: line_number, snippet: value
|
|
90
|
-
)
|
|
91
|
-
end
|
|
98
|
+
ensure_fraction!(value, "meter", line_number)
|
|
92
99
|
HeadMusic::Rudiment::Meter.get(value)
|
|
93
100
|
end
|
|
94
101
|
|
|
95
102
|
def resolve_unit_note_length(value, line_number)
|
|
96
|
-
|
|
97
|
-
raise HeadMusic::Notation::ABC::ParseError.new(
|
|
98
|
-
"Invalid unit note length #{value.inspect}", line_number: line_number, snippet: value
|
|
99
|
-
)
|
|
100
|
-
end
|
|
103
|
+
ensure_fraction!(value, "unit note length", line_number)
|
|
101
104
|
Rational(value)
|
|
102
105
|
end
|
|
103
106
|
|
|
107
|
+
def ensure_fraction!(value, description, line_number)
|
|
108
|
+
return if FRACTION_PATTERN.match?(value)
|
|
109
|
+
|
|
110
|
+
raise HeadMusic::Notation::ABC::ParseError.new(
|
|
111
|
+
"Invalid #{description} #{value.inspect}", line_number: line_number, snippet: value
|
|
112
|
+
)
|
|
113
|
+
end
|
|
114
|
+
|
|
104
115
|
def apply_defaults
|
|
105
116
|
@meter ||= HeadMusic::Rudiment::Meter.common_time
|
|
106
117
|
end
|