head_music 15.0.0 → 15.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7c29fa7f976d16091512b8bdc9eb8befcb4766c4bb70d387a67991e9b9897a5c
4
- data.tar.gz: b43c0fed22c3ed7c79a7a0195847740ba7b3bea608a24921893a329f0c66ba7f
3
+ metadata.gz: d2446af0205d6b751566da4a2cc132032151d5c9b41732938552d12d3f07d05e
4
+ data.tar.gz: ed75fb7cea9e3a2ea50543bad21d28d0c66b8996cb5d57b4757aa9ef211eb67b
5
5
  SHA512:
6
- metadata.gz: 47d395cc1709167182e0810c7383120395e927629a2e5e5076e197bdacfd377445546a842a032fecabd9865f62e878f969fec301535758072b45db229c80e8e4
7
- data.tar.gz: '0950f0d5a6a2b830a1733d8d0a7d19eb9fcb87815e25d3e562dc6aa94bc95c16a2d7710ca31890e23d1dcc07fc64247030565741d2edf38da6cb177f7e091fb9'
6
+ metadata.gz: fda48134cdaea328ddcdc1e2e072ea9c94d7a354e99178428969834cab6db36882667f21f7860ba427561d7a70e40eace071a1d4e9a7228e42c6cb92f2749e01
7
+ data.tar.gz: d2d1bfd02a1b1101932acd435836006e1deaa6e81102f233b9b339dfb56c01effc94691ce30063334e7c61eafffee214075c516ff0d831395a8c91b861cfffb0
data/CHANGELOG.md CHANGED
@@ -7,6 +7,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [15.2.0] - 2026-07-16
11
+
12
+ ### Added
13
+
14
+ - `HeadMusic::Content::Composition#to_h` / `.from_h` — lossless, JSON-safe hash serialization of a composition (schema_version 1). The hash captures name, key signature, meter, composer, origin, voices with roles and ordered placements (tick-precise positions, rhythmic values including ties, exact pitch spellings, rests as `null`), sparse per-bar state (mid-piece key and meter changes, repeat and volta structure), and comments. `from_h` rebuilds through the public builder API and raises `ArgumentError` with path context on malformed input; unknown keys are ignored so the format can evolve additively.
15
+ - `HeadMusic::Content::Composition#to_json` / `.from_json` — thin delegates over `to_h`/`from_h`.
16
+ - `#to_h` on `Content::Voice`, `Content::Placement`, `Content::Bar`, and `Content::Comment`.
17
+
18
+ **Schema v1 is a compatibility surface**: hashes persisted by downstream apps (e.g. in a jsonb column) must keep loading. Additive optional keys are fine within version 1; any change to existing keys' shape or meaning requires a `schema_version` bump.
19
+
20
+ ### Fixed
21
+
22
+ - `RhythmicValue.get` now parses tied value strings ("half tied to eighth", including chained ties), so tied durations round-trip through `#to_s`.
23
+ - `Bar#key_signature=` and `Bar#meter=` coerce strings via `KeySignature.get` / `Meter.get`, so `change_meter(4, "6/8")` no longer stores a raw String.
24
+ - `Voice` placement ordering is now stable: notes placed at the same position (chords) keep their insertion order.
25
+ - `Composition#change_key_signature` / `#change_meter` no longer raise for a bar earlier than the first placement (e.g. a pickup bar).
26
+
27
+ ## [15.1.0] - 2026-07-07
28
+
29
+ ### Added
30
+
31
+ - `HeadMusic::Content::Composition.to_musicxml` for MusicXML export
32
+ - `HeadMusic::Content::Composition.to_abc` for ABC Notation export. ABC can round-trip to and from Composition.
33
+
10
34
  ## [15.0.0] - 2026-07-06
11
35
 
12
36
  ### Added
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- head_music (15.0.0)
4
+ head_music (15.2.0)
5
5
  activesupport (>= 7.0, < 10)
6
6
  humanize (~> 2.0)
7
7
  i18n (~> 1.8)
@@ -220,6 +220,7 @@ DEPENDENCIES
220
220
  head_music!
221
221
  kramdown
222
222
  rake (~> 13.0)
223
+ rexml (~> 3.4)
223
224
  rspec (~> 3.0)
224
225
  rspec-its (~> 2.0)
225
226
  rubocop
data/head_music.gemspec CHANGED
@@ -37,6 +37,7 @@ Gem::Specification.new do |spec|
37
37
  spec.add_runtime_dependency "i18n", "~> 1.8"
38
38
 
39
39
  spec.add_development_dependency "rake", "~> 13.0"
40
+ spec.add_development_dependency "rexml", "~> 3.4"
40
41
  spec.add_development_dependency "rspec", "~> 3.0"
41
42
  spec.add_development_dependency "rspec-its", "~> 2.0"
42
43
  spec.add_development_dependency "bundler-audit", "~> 0.9"
@@ -5,19 +5,26 @@ module HeadMusic::Content; end
5
5
  # Encapsulates meter and key signature changes
6
6
  # and repeat structure (repeat barlines and volta brackets) as content semantics
7
7
  class HeadMusic::Content::Bar
8
- attr_reader :composition, :ends_repeat_after_num_plays, :plays_on_passes
9
- attr_accessor :key_signature, :meter
8
+ attr_reader :composition, :ends_repeat_after_num_plays, :plays_on_passes, :key_signature, :meter
10
9
  attr_writer :starts_repeat
11
10
 
12
11
  def initialize(composition, key_signature: nil, meter: nil)
13
12
  @composition = composition
14
- @key_signature = HeadMusic::Rudiment::KeySignature.get(key_signature) if key_signature
15
- @meter = HeadMusic::Rudiment::Meter.get(meter) if meter
13
+ self.key_signature = key_signature
14
+ self.meter = meter
16
15
  @starts_repeat = false
17
16
  @ends_repeat_after_num_plays = nil
18
17
  @plays_on_passes = nil
19
18
  end
20
19
 
20
+ def key_signature=(value)
21
+ @key_signature = value ? HeadMusic::Rudiment::KeySignature.get(value) : nil
22
+ end
23
+
24
+ def meter=(value)
25
+ @meter = value ? HeadMusic::Rudiment::Meter.get(value) : nil
26
+ end
27
+
21
28
  def starts_repeat?
22
29
  @starts_repeat
23
30
  end
@@ -48,6 +55,19 @@ class HeadMusic::Content::Bar
48
55
  ["Bar", key_signature, meter, repeat_summary].compact.join(" ")
49
56
  end
50
57
 
58
+ # Sparse serialization: only non-default state, so a default bar is {}.
59
+ # KeySignature serializes via #name ("F♯ minor") because #to_s ("3 sharps")
60
+ # cannot be parsed back by KeySignature.get.
61
+ def to_h
62
+ hash = {}
63
+ hash["key_signature"] = key_signature.name if key_signature
64
+ hash["meter"] = meter.to_s if meter
65
+ hash["starts_repeat"] = true if starts_repeat?
66
+ hash["ends_repeat_after_num_plays"] = ends_repeat_after_num_plays if ends_repeat?
67
+ hash["plays_on_passes"] = plays_on_passes.dup if plays_on_passes
68
+ hash
69
+ end
70
+
51
71
  private
52
72
 
53
73
  def valid_ends_repeat_after_num_plays?(value)
@@ -15,6 +15,10 @@ class HeadMusic::Content::Comment
15
15
  text
16
16
  end
17
17
 
18
+ def to_h
19
+ {"text" => text, "position" => position&.to_s}
20
+ end
21
+
18
22
  private
19
23
 
20
24
  def ensure_position(position)
@@ -3,8 +3,18 @@ module HeadMusic::Content; end
3
3
 
4
4
  # A composition is musical content.
5
5
  class HeadMusic::Content::Composition
6
+ SCHEMA_VERSION = 1
7
+
6
8
  attr_reader :name, :key_signature, :meter, :voices, :composer, :origin, :comments
7
9
 
10
+ def self.from_h(hash)
11
+ HashDeserializer.new(hash).composition
12
+ end
13
+
14
+ def self.from_json(json)
15
+ from_h(JSON.parse(json))
16
+ end
17
+
8
18
  def initialize(name: nil, key_signature: nil, meter: nil, composer: nil, origin: nil, comments: nil)
9
19
  ensure_attributes(name, key_signature, meter)
10
20
  @composer = composer
@@ -35,10 +45,11 @@ class HeadMusic::Content::Composition
35
45
 
36
46
  def bars(last = latest_bar_number)
37
47
  @bars ||= []
38
- (earliest_bar_number..last).each do |bar_number|
48
+ first = [earliest_bar_number, last].min
49
+ (first..last).each do |bar_number|
39
50
  @bars[bar_number] ||= HeadMusic::Content::Bar.new(self)
40
51
  end
41
- @bars[earliest_bar_number..last]
52
+ @bars[first..last]
42
53
  end
43
54
 
44
55
  def change_key_signature(bar_number, key_signature)
@@ -50,7 +61,7 @@ class HeadMusic::Content::Composition
50
61
  end
51
62
 
52
63
  def earliest_bar_number
53
- [voices.map(&:earliest_bar_number), 1].flatten.min
64
+ [voices.map(&:earliest_bar_number), first_allocated_bar_number, 1].flatten.compact.min
54
65
  end
55
66
 
56
67
  def latest_bar_number
@@ -69,8 +80,40 @@ class HeadMusic::Content::Composition
69
80
  "#{name} — #{voices.count} #{(voices.count == 1) ? "voice" : "voices"}"
70
81
  end
71
82
 
83
+ def to_abc(**options)
84
+ HeadMusic::Notation::ABC.render(self, **options)
85
+ end
86
+
87
+ def to_musicxml
88
+ HeadMusic::Notation::MusicXML.render(self)
89
+ end
90
+
91
+ def to_h
92
+ {
93
+ "schema_version" => SCHEMA_VERSION,
94
+ "name" => name,
95
+ "key_signature" => key_signature.name,
96
+ "meter" => meter.to_s,
97
+ "composer" => composer&.to_s,
98
+ "origin" => origin&.to_s,
99
+ "voices" => voices.map(&:to_h),
100
+ "bars" => bars_to_h,
101
+ "comments" => comments.map(&:to_h)
102
+ }
103
+ end
104
+
105
+ def to_json(*_args)
106
+ to_h.to_json
107
+ end
108
+
72
109
  private
73
110
 
111
+ # Bars can be allocated below the voices' earliest bar (e.g. a key or meter
112
+ # change in a pickup bar), so the earliest bar reflects those allocations too.
113
+ def first_allocated_bar_number
114
+ (@bars || []).index { |bar| !bar.nil? }
115
+ end
116
+
74
117
  def ensure_attributes(name, key_signature, meter)
75
118
  @name = name || "Composition"
76
119
  @key_signature = HeadMusic::Rudiment::KeySignature.get(key_signature) if key_signature
@@ -86,4 +129,191 @@ class HeadMusic::Content::Composition
86
129
  def last_key_signature_change(bar_number)
87
130
  bars(bar_number)[earliest_bar_number..bar_number].reverse.detect(&:key_signature)
88
131
  end
132
+
133
+ # Iterates the raw sparse array (not the public #bars slice, which loses the
134
+ # number offset), pairing each non-default bar with its number.
135
+ def bars_to_h
136
+ (@bars || []).each_with_index.filter_map do |bar, number|
137
+ next if bar.nil?
138
+
139
+ bar_hash = bar.to_h
140
+ next if bar_hash.empty?
141
+
142
+ {"number" => number}.merge(bar_hash)
143
+ end
144
+ end
145
+
146
+ # Rebuilds a composition from a schema v1 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
+ raise ArgumentError, "unsupported schema_version: #{version.inspect} (supported: #{SCHEMA_VERSION})"
178
+ end
179
+
180
+ def build_base_composition
181
+ HeadMusic::Content::Composition.new(
182
+ name: hash["name"],
183
+ key_signature: parsed_key_signature(hash["key_signature"], "key_signature"),
184
+ meter: parsed_meter(hash["meter"], "meter"),
185
+ composer: hash["composer"],
186
+ origin: hash["origin"]
187
+ )
188
+ end
189
+
190
+ def bar_hashes
191
+ @bar_hashes ||= Array(hash["bars"])
192
+ end
193
+
194
+ def apply_bar_changes(composition)
195
+ bar_hashes.each_with_index do |bar_hash, index|
196
+ number = validated_bar_number(bar_hash, index)
197
+ path = "bars[#{index}]"
198
+ key_signature = parsed_key_signature(bar_hash["key_signature"], path)
199
+ meter = parsed_meter(bar_hash["meter"], path)
200
+ composition.change_key_signature(number, key_signature) if key_signature
201
+ composition.change_meter(number, meter) if meter
202
+ end
203
+ end
204
+
205
+ def build_voices(composition)
206
+ Array(hash["voices"]).each_with_index do |voice_hash, voice_index|
207
+ voice = composition.add_voice(role: voice_hash["role"])
208
+ Array(voice_hash["placements"]).each_with_index do |placement_hash, placement_index|
209
+ path = "voices[#{voice_index}].placements[#{placement_index}]"
210
+ position = parsed_position(placement_hash["position"], path)
211
+ rhythmic_value = parsed_rhythmic_value(placement_hash["rhythmic_value"], path)
212
+ pitch = parsed_pitch(placement_hash["pitch"], path)
213
+ voice.place(position, rhythmic_value, pitch)
214
+ end
215
+ end
216
+ end
217
+
218
+ def apply_repeat_flags(composition)
219
+ bar_hashes.each_with_index do |bar_hash, index|
220
+ next unless repeat_state?(bar_hash)
221
+
222
+ bar = composition.bars(validated_bar_number(bar_hash, index)).last
223
+ bar.starts_repeat = true if bar_hash["starts_repeat"]
224
+ bar.ends_repeat_after_num_plays = bar_hash["ends_repeat_after_num_plays"] if bar_hash["ends_repeat_after_num_plays"]
225
+ bar.plays_on_passes = bar_hash["plays_on_passes"] if bar_hash["plays_on_passes"]
226
+ end
227
+ end
228
+
229
+ def add_comments(composition)
230
+ Array(hash["comments"]).each_with_index do |comment_hash, index|
231
+ position = parsed_position(comment_hash["position"], "comments[#{index}]") if comment_hash["position"]
232
+ composition.add_comment(comment_hash["text"], position)
233
+ end
234
+ end
235
+
236
+ def repeat_state?(bar_hash)
237
+ bar_hash["starts_repeat"] || bar_hash["ends_repeat_after_num_plays"] || bar_hash["plays_on_passes"]
238
+ end
239
+
240
+ def validated_bar_number(bar_hash, index)
241
+ number = bar_hash["number"]
242
+ unless number.is_a?(Integer) && number >= 0
243
+ raise ArgumentError, "bars[#{index}]: bar number must be an Integer of at least 0, got #{number.inspect}"
244
+ end
245
+ number
246
+ end
247
+
248
+ # Position silently coerces garbage strings to "0:1:000", which would
249
+ # mislocate content with no error, so the shape is validated up front.
250
+ # Accepts "bar", "bar:count", or "bar:count:tick" with non-negative parts.
251
+ def parsed_position(value, path)
252
+ return nil if value.nil?
253
+
254
+ unless value.is_a?(String) && value.match?(/\A\d+(:\d+){0,2}\z/)
255
+ raise ArgumentError, "#{path}: unknown position #{value.inspect}"
256
+ end
257
+ value
258
+ end
259
+
260
+ # KeySignature.get returns a hollow object (nil tonic_spelling) for
261
+ # garbage rather than nil, so presence of the tonic is the real check.
262
+ def parsed_key_signature(value, path)
263
+ return nil if value.nil?
264
+
265
+ key_signature = begin
266
+ HeadMusic::Rudiment::KeySignature.get(value)
267
+ rescue
268
+ nil
269
+ end
270
+ unless key_signature&.tonic_spelling
271
+ raise ArgumentError, "#{path}: unknown key signature #{value.inspect}"
272
+ end
273
+ key_signature
274
+ end
275
+
276
+ def parsed_meter(value, path)
277
+ return nil if value.nil?
278
+
279
+ meter = begin
280
+ HeadMusic::Rudiment::Meter.get(value)
281
+ rescue
282
+ nil
283
+ end
284
+ unless meter&.top_number&.positive? && meter.bottom_number.positive?
285
+ raise ArgumentError, "#{path}: unknown meter #{value.inspect}"
286
+ end
287
+ meter
288
+ end
289
+
290
+ def parsed_rhythmic_value(value, path)
291
+ rhythmic_value = HeadMusic::Rudiment::RhythmicValue.get(value)
292
+ unless valid_rhythmic_value?(rhythmic_value)
293
+ raise ArgumentError, "#{path}: unknown rhythmic value #{value.inspect}"
294
+ end
295
+ rhythmic_value
296
+ end
297
+
298
+ # RhythmicValue.get returns a hollow object (nil unit) for garbage rather
299
+ # than nil, and a tied tail can be hollow while the head parses, so the
300
+ # whole tie chain is checked.
301
+ def valid_rhythmic_value?(rhythmic_value)
302
+ return false unless rhythmic_value.is_a?(HeadMusic::Rudiment::RhythmicValue)
303
+ return false unless rhythmic_value.unit
304
+
305
+ rhythmic_value.tied_value.nil? || valid_rhythmic_value?(rhythmic_value.tied_value)
306
+ end
307
+
308
+ # A nil pitch is a rest; a non-nil string that fails to parse would
309
+ # otherwise silently deserialize as a rest.
310
+ def parsed_pitch(value, path)
311
+ return nil if value.nil?
312
+
313
+ pitch = HeadMusic::Rudiment::Pitch.get(value)
314
+ raise ArgumentError, "#{path}: unknown pitch #{value.inspect}" unless pitch
315
+
316
+ pitch
317
+ end
318
+ end
89
319
  end
@@ -38,6 +38,14 @@ class HeadMusic::Content::Placement
38
38
  "#{rhythmic_value} #{pitch || "rest"} at #{position}"
39
39
  end
40
40
 
41
+ def to_h
42
+ {
43
+ "position" => position.to_s,
44
+ "rhythmic_value" => rhythmic_value.to_s,
45
+ "pitch" => pitch&.to_s
46
+ }
47
+ end
48
+
41
49
  private
42
50
 
43
51
  def starts_during?(other_placement)
@@ -118,17 +118,46 @@ class HeadMusic::Content::Voice
118
118
  last_placement ? last_placement.next_position : HeadMusic::Content::Position.new(composition, 1, 1, 0)
119
119
  end
120
120
 
121
+ # Returns nil if placements are contiguous, or [expected_position, found_placement]
122
+ # for the first gap: either the first placement not starting its bar, or the
123
+ # first pair of consecutive placements where the second doesn't begin where
124
+ # the first one ends.
125
+ def first_gap
126
+ first = placements.first
127
+ return unless first
128
+
129
+ return [bar_start_position(first), first] unless first.position.count == 1 && first.position.tick.zero?
130
+
131
+ placements.each_cons(2) do |previous, current|
132
+ return [previous.next_position, current] unless current.position == previous.next_position
133
+ end
134
+ nil
135
+ end
136
+
121
137
  def to_s
122
138
  return pitches_string if role.to_s.strip == ""
123
139
 
124
140
  [role, pitches_string].join(": ")
125
141
  end
126
142
 
143
+ def to_h
144
+ {
145
+ "role" => role&.to_s,
146
+ "placements" => placements.map(&:to_h)
147
+ }
148
+ end
149
+
127
150
  private
128
151
 
152
+ def bar_start_position(placement)
153
+ HeadMusic::Content::Position.new(composition, placement.position.bar_number, 1, 0)
154
+ end
155
+
156
+ # Stable insertion: co-positioned placements (chords) keep their insertion order,
157
+ # which serialization round-trips depend on.
129
158
  def insert_into_placements(placement)
130
- @placements << placement
131
- @placements = @placements.sort
159
+ index = @placements.index { |existing| existing > placement } || @placements.length
160
+ @placements.insert(index, placement)
132
161
  end
133
162
 
134
163
  def pitches_string
@@ -0,0 +1,63 @@
1
+ # Parses and renders ABC notation as HeadMusic::Content compositions
2
+ module HeadMusic::Notation::ABC
3
+ # Converts a HeadMusic::Rudiment::RhythmicValue back into the ABC note-length
4
+ # multiplier string relative to the tune's unit note length — the inverse of
5
+ # DurationResolver.
6
+ class DurationWriter
7
+ attr_reader :unit_note_length
8
+
9
+ def initialize(unit_note_length)
10
+ @unit_note_length = Rational(unit_note_length)
11
+ end
12
+
13
+ def multiplier_string(rhythmic_value)
14
+ fraction = total_fraction(rhythmic_value)
15
+ validate_fraction!(fraction, rhythmic_value)
16
+ format_multiplier(fraction / unit_note_length)
17
+ end
18
+
19
+ private
20
+
21
+ # RhythmicValue's own value methods return Floats; rebuild the fraction
22
+ # from integer parts so the multiplier arithmetic stays exact. A tied
23
+ # chain collapses to one multiplier, round-tripping tokens like "A5".
24
+ def total_fraction(rhythmic_value)
25
+ fraction = single_fraction(rhythmic_value)
26
+ tied_value = rhythmic_value.tied_value
27
+ fraction += total_fraction(tied_value) if tied_value
28
+ fraction
29
+ end
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
+ def validate_fraction!(fraction, rhythmic_value)
39
+ max_fraction = DurationResolver::MAX_FRACTION
40
+ if fraction > max_fraction
41
+ raise_error("note length exceeds #{max_fraction.to_i} whole notes", rhythmic_value)
42
+ end
43
+ return if power_of_two?(fraction.denominator)
44
+
45
+ raise_error("note length #{fraction} is not expressible in binary note values", rhythmic_value)
46
+ end
47
+
48
+ def format_multiplier(multiplier)
49
+ return "" if multiplier == 1
50
+ return multiplier.numerator.to_s if multiplier.denominator == 1
51
+
52
+ "#{multiplier.numerator}/#{multiplier.denominator}"
53
+ end
54
+
55
+ def power_of_two?(integer)
56
+ (integer & (integer - 1)).zero?
57
+ end
58
+
59
+ def raise_error(message, rhythmic_value)
60
+ raise HeadMusic::Notation::ABC::RenderError, "#{message}: #{rhythmic_value}"
61
+ end
62
+ end
63
+ end
@@ -20,6 +20,51 @@ class HeadMusic::Notation::ABC::KeyMapper
20
20
  "loc" => "locrian"
21
21
  }.freeze
22
22
 
23
+ # MODE_NAMES_BY_PREFIX is many-to-one, so rendering uses this explicit
24
+ # inverse map. Every suffix here parses back to an equal key signature
25
+ # (ionian/aeolian differ from major/minor only in name, not alterations).
26
+ ABC_SUFFIXES_BY_MODE = {
27
+ "major" => "",
28
+ "ionian" => "",
29
+ "minor" => "m",
30
+ "aeolian" => "m",
31
+ "dorian" => "dor",
32
+ "phrygian" => "phr",
33
+ "lydian" => "lyd",
34
+ "mixolydian" => "mix",
35
+ "locrian" => "loc"
36
+ }.freeze
37
+
38
+ # Returns the ABC K: field value for a key signature.
39
+ def self.abc_value(key_signature)
40
+ key_signature = HeadMusic::Rudiment::KeySignature.get(key_signature)
41
+ "#{tonic_string(key_signature)}#{mode_suffix(key_signature)}"
42
+ end
43
+
44
+ def self.tonic_string(key_signature)
45
+ spelling = key_signature.tonic_spelling
46
+ alteration = spelling.alteration
47
+ if alteration && (alteration.double_sharp? || alteration.double_flat?)
48
+ raise_render_error("Cannot render double-altered tonic #{spelling} in an ABC K: field")
49
+ end
50
+
51
+ # ABC convention uses ASCII "#"/"b" rather than the unicode signs
52
+ # that Spelling#to_s produces.
53
+ "#{spelling.letter_name}#{alteration&.ascii}"
54
+ end
55
+ private_class_method :tonic_string
56
+
57
+ def self.mode_suffix(key_signature)
58
+ ABC_SUFFIXES_BY_MODE[key_signature.scale_type.name.to_s] ||
59
+ raise_render_error("Cannot render scale type #{key_signature.scale_type} in an ABC K: field")
60
+ end
61
+ private_class_method :mode_suffix
62
+
63
+ def self.raise_render_error(message)
64
+ raise HeadMusic::Notation::ABC::RenderError, message
65
+ end
66
+ private_class_method :raise_render_error
67
+
23
68
  attr_reader :value, :line_number
24
69
 
25
70
  def initialize(value, line_number: nil)
@@ -0,0 +1,72 @@
1
+ # A namespace for ABC-notation parsing helpers
2
+ module HeadMusic::Notation::ABC
3
+ # Converts pitches into ABC note tokens, emitting the minimal accidental
4
+ # marks required under the key signature and ABC's bar-persistent rules.
5
+ #
6
+ # A live PitchBuilder acts as an oracle: if an unmarked token would already
7
+ # parse back to the target pitch, no accidental is written. Emitted tokens
8
+ # are fed back through the oracle so its bar state matches what a parser
9
+ # will accumulate when re-reading the output.
10
+ class PitchWriter
11
+ attr_reader :key_signature
12
+
13
+ def initialize(key_signature)
14
+ @key_signature = HeadMusic::Rudiment::KeySignature.get(key_signature)
15
+ @oracle = PitchBuilder.new(@key_signature)
16
+ end
17
+
18
+ # Returns the ABC note token for the pitch, without any duration multiplier.
19
+ def token(pitch)
20
+ pitch = HeadMusic::Rudiment::Pitch.get(pitch)
21
+ letter = letter_token(pitch)
22
+ octave_marks = octave_marks(pitch)
23
+ return "#{letter}#{octave_marks}" if oracle.pitch(letter, octave_marks) == pitch
24
+
25
+ marks = accidental_marks(pitch)
26
+ commit_and_verify(pitch, letter, octave_marks, marks)
27
+ "#{marks}#{letter}#{octave_marks}"
28
+ end
29
+
30
+ # In ABC, accidentals persist only to the end of the bar.
31
+ def start_new_bar
32
+ oracle.start_new_bar
33
+ end
34
+
35
+ private
36
+
37
+ attr_reader :oracle
38
+
39
+ def letter_token(pitch)
40
+ letter = pitch.letter_name.to_s
41
+ (pitch.register >= 5) ? letter.downcase : letter
42
+ end
43
+
44
+ def octave_marks(pitch)
45
+ if pitch.register >= 5
46
+ "'" * (pitch.register - 5)
47
+ else
48
+ "," * (4 - pitch.register)
49
+ end
50
+ end
51
+
52
+ def accidental_marks(pitch)
53
+ fragment = pitch.alteration&.ascii.to_s
54
+ accidental_marks_by_fragment.fetch(fragment) do
55
+ raise RenderError, "cannot express #{pitch} in ABC notation"
56
+ end
57
+ end
58
+
59
+ def accidental_marks_by_fragment
60
+ @accidental_marks_by_fragment ||= PitchBuilder::ACCIDENTAL_FRAGMENTS.invert
61
+ end
62
+
63
+ # Feeding the marked token through the oracle updates its bar state
64
+ # exactly as a parser reading the output would — this call is load-bearing,
65
+ # not just a verification; later unmarked notes depend on the state it writes.
66
+ def commit_and_verify(pitch, letter, octave_marks, marks)
67
+ return if oracle.pitch(letter, octave_marks, marks) == pitch
68
+
69
+ raise RenderError, "cannot express #{pitch} in ABC notation"
70
+ end
71
+ end
72
+ end