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 +4 -4
- data/CHANGELOG.md +24 -0
- data/Gemfile.lock +2 -1
- data/head_music.gemspec +1 -0
- data/lib/head_music/content/bar.rb +24 -4
- data/lib/head_music/content/comment.rb +4 -0
- data/lib/head_music/content/composition.rb +233 -3
- data/lib/head_music/content/placement.rb +8 -0
- data/lib/head_music/content/voice.rb +31 -2
- data/lib/head_music/notation/abc/duration_writer.rb +63 -0
- data/lib/head_music/notation/abc/key_mapper.rb +45 -0
- data/lib/head_music/notation/abc/pitch_writer.rb +72 -0
- data/lib/head_music/notation/abc/writer.rb +129 -0
- data/lib/head_music/notation/abc.rb +9 -1
- data/lib/head_music/notation/music_xml/clef_selector.rb +26 -0
- data/lib/head_music/notation/music_xml/divisions.rb +43 -0
- data/lib/head_music/notation/music_xml/duration_writer.rb +82 -0
- data/lib/head_music/notation/music_xml/key_mapper.rb +41 -0
- data/lib/head_music/notation/music_xml/pitch_writer.rb +21 -0
- data/lib/head_music/notation/music_xml/writer.rb +369 -0
- data/lib/head_music/notation/music_xml.rb +14 -0
- data/lib/head_music/notation.rb +4 -0
- data/lib/head_music/rudiment/rhythmic_value.rb +18 -1
- data/lib/head_music/version.rb +1 -1
- data/lib/head_music.rb +11 -1
- data/user-stories/backlog/lilypond-export.md +74 -0
- data/user-stories/done/abc-notation-export.md +203 -0
- data/user-stories/done/composition-serialization.md +335 -0
- data/user-stories/done/music-xml-export.md +201 -0
- data/user-stories/index.html +28 -12
- metadata +30 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d2446af0205d6b751566da4a2cc132032151d5c9b41732938552d12d3f07d05e
|
|
4
|
+
data.tar.gz: ed75fb7cea9e3a2ea50543bad21d28d0c66b8996cb5d57b4757aa9ef211eb67b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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
|
-
|
|
15
|
-
|
|
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)
|
|
@@ -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
|
-
|
|
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[
|
|
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
|
|
131
|
-
@placements
|
|
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
|