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.
Files changed (113) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +15 -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 -215
  15. data/lib/head_music/content/placement.rb +11 -42
  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 +65 -24
  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 +48 -159
  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 +47 -16
  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/beam_grouper.rb +108 -0
  46. data/lib/head_music/notation/music_xml/duration_writer.rb +3 -8
  47. data/lib/head_music/notation/music_xml/preflight.rb +90 -0
  48. data/lib/head_music/notation/music_xml/render_plan.rb +161 -0
  49. data/lib/head_music/notation/music_xml/writer.rb +31 -146
  50. data/lib/head_music/notation/placement_validation.rb +19 -0
  51. data/lib/head_music/rudiment/enharmonic_equivalence.rb +33 -0
  52. data/lib/head_music/rudiment/key.rb +8 -36
  53. data/lib/head_music/rudiment/key_signature/enharmonic_equivalence.rb +21 -15
  54. data/lib/head_music/rudiment/key_signature.rb +18 -19
  55. data/lib/head_music/rudiment/meter.rb +39 -6
  56. data/lib/head_music/rudiment/mode.rb +33 -65
  57. data/lib/head_music/rudiment/note.rb +18 -21
  58. data/lib/head_music/rudiment/pitch/enharmonic_equivalence.rb +4 -15
  59. data/lib/head_music/rudiment/pitch/natural_step.rb +36 -0
  60. data/lib/head_music/rudiment/pitch/parser.rb +16 -5
  61. data/lib/head_music/rudiment/pitch.rb +11 -33
  62. data/lib/head_music/rudiment/qualified_diatonic_context.rb +61 -0
  63. data/lib/head_music/rudiment/rhythmic_unit/parser.rb +16 -12
  64. data/lib/head_music/rudiment/rhythmic_unit.rb +21 -37
  65. data/lib/head_music/rudiment/rhythmic_value/parser.rb +53 -46
  66. data/lib/head_music/rudiment/spelling/enharmonic_equivalence.rb +13 -0
  67. data/lib/head_music/rudiment/spelling.rb +0 -25
  68. data/lib/head_music/rudiment/tuning/just_intonation.rb +2 -4
  69. data/lib/head_music/rudiment/tuning/meantone.rb +2 -4
  70. data/lib/head_music/rudiment/tuning/pythagorean.rb +2 -4
  71. data/lib/head_music/rudiment/tuning/tonal_centered.rb +10 -0
  72. data/lib/head_music/rudiment/unpitched_note.rb +3 -5
  73. data/lib/head_music/style/annotation/configured.rb +35 -0
  74. data/lib/head_music/style/annotation.rb +32 -44
  75. data/lib/head_music/style/guidelines/allowed_rhythmic_values_for_fifth_species.rb +0 -10
  76. data/lib/head_music/style/guidelines/contoured.rb +65 -53
  77. data/lib/head_music/style/guidelines/dissonance_figure_detection.rb +55 -16
  78. data/lib/head_music/style/guidelines/florid_dissonance_treatment.rb +0 -37
  79. data/lib/head_music/style/guidelines/large_leaps.rb +10 -2
  80. data/lib/head_music/style/guidelines/minimum_melodic_intervals.rb +3 -25
  81. data/lib/head_music/style/guidelines/minimum_notes.rb +3 -25
  82. data/lib/head_music/style/guidelines/minimum_threshold.rb +38 -0
  83. data/lib/head_music/style/guidelines/no_parallel_perfect.rb +31 -0
  84. data/lib/head_music/style/guidelines/no_parallel_perfect_on_downbeats.rb +3 -17
  85. data/lib/head_music/style/guidelines/no_parallel_perfect_with_syncopation.rb +3 -17
  86. data/lib/head_music/style/guidelines/prepare_octave_leaps.rb +10 -7
  87. data/lib/head_music/style/guidelines/singable_intervals.rb +22 -8
  88. data/lib/head_music/style/guidelines/suspension_treatment.rb +30 -12
  89. data/lib/head_music/style/guidelines/third_species_dissonance_treatment.rb +0 -7
  90. data/lib/head_music/style/guidelines/triple_meter_dissonance_treatment.rb +0 -12
  91. data/lib/head_music/style/guidelines/weak_beat_dissonance_treatment.rb +2 -42
  92. data/lib/head_music/style/guides/fifth_species_melody.rb +3 -13
  93. data/lib/head_music/style/guides/fourth_species_melody.rb +3 -13
  94. data/lib/head_music/style/guides/second_species_harmony.rb +2 -5
  95. data/lib/head_music/style/guides/second_species_melody.rb +2 -12
  96. data/lib/head_music/style/guides/species_harmony.rb +14 -0
  97. data/lib/head_music/style/guides/species_melody.rb +21 -0
  98. data/lib/head_music/style/guides/third_species_harmony.rb +2 -5
  99. data/lib/head_music/style/guides/third_species_melody.rb +3 -13
  100. data/lib/head_music/style/guides/third_species_triple_meter_harmony.rb +2 -5
  101. data/lib/head_music/style/guides/third_species_triple_meter_melody.rb +2 -12
  102. data/lib/head_music/time/conductor.rb +19 -125
  103. data/lib/head_music/time/musical_position.rb +26 -26
  104. data/lib/head_music/time/musical_time_converter.rb +104 -0
  105. data/lib/head_music/time/smpte_converter.rb +51 -0
  106. data/lib/head_music/time/smpte_timecode.rb +25 -23
  107. data/lib/head_music/time.rb +2 -0
  108. data/lib/head_music/value_equality.rb +37 -0
  109. data/lib/head_music/version.rb +1 -1
  110. data/lib/head_music.rb +25 -1
  111. data/user-stories/done/abc-notation-beaming.md +235 -0
  112. data/user-stories/index.html +7 -3
  113. 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
- # Separate known and unknown instruments
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
- # Try exact match with name_key
102
- return ordering_index[instrument.name_key.to_s] if instrument.name_key && ordering_index.key?(instrument.name_key.to_s)
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
- # Try matching by family + range category (e.g., alto_saxophone -> saxophone family)
105
- if instrument.family_key
106
- family_base = instrument.family_key.to_s
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
- # Check if this is a variant of a family (e.g., alto_saxophone)
110
- if instrument_key.include?(family_base)
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
- # Fall back to generic family instrument if listed
115
- return ordering_index[family_base] if ordering_index.key?(family_base)
116
- end
117
- end
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
- # Try normalized name (lowercase, underscored)
120
- normalized = HeadMusic::Utilities::Case.to_snake_case(instrument.name)
121
- return ordering_index[normalized] if ordering_index.key?(normalized)
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
- nil
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
@@ -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
- line_number = @start_line + index
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
- next if scanner.skip(/[ \t]+/)
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.scan(/\[[A-Za-z]:[^\]]*\]/) || scanner.scan(/\[[A-Za-z]:[^\]]*/)
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
- return chord_fallback(scanner, start_pos, line_number, column, tokens)
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
- length = scanner.scan(%r{[\d/]*})
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 do |part|
239
- first, last = part.split("-", 2)
240
- last ? (first.to_i..last.to_i).to_a : [first.to_i]
241
- end.select(&:positive?)
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
- Rational(((1 << run) - 1) << (bits - run), fraction.denominator)
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 = single_fraction(rhythmic_value)
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
- letter = match[1]
55
- value = match[2].strip
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
- unless FRACTION_PATTERN.match?(value)
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
- unless FRACTION_PATTERN.match?(value)
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