head_music 15.1.0 → 17.0.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 +45 -0
- data/Gemfile.lock +1 -1
- 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 +266 -3
- data/lib/head_music/content/placement.rb +114 -10
- data/lib/head_music/content/voice.rb +23 -7
- data/lib/head_music/notation/abc/body_lexer.rb +49 -7
- data/lib/head_music/notation/abc/duration_resolver.rb +7 -0
- data/lib/head_music/notation/abc/parser.rb +57 -9
- data/lib/head_music/notation/abc/writer.rb +19 -0
- data/lib/head_music/notation/music_xml/writer.rb +12 -1
- data/lib/head_music/rudiment/pitch.rb +6 -0
- data/lib/head_music/rudiment/rhythmic_value.rb +18 -1
- data/lib/head_music/rudiment/unpitched_note.rb +1 -0
- data/lib/head_music/rudiment/unpitched_sound.rb +62 -0
- data/lib/head_music/version.rb +1 -1
- data/lib/head_music.rb +3 -0
- data/user-stories/backlog/musicxml-chord-rendering.md +57 -0
- data/user-stories/done/abc-chord-input.md +190 -0
- data/user-stories/done/chord-placement-model.md +185 -0
- data/user-stories/done/composition-serialization.md +335 -0
- data/user-stories/done/sound-model.md +223 -0
- data/user-stories/index.html +27 -8
- metadata +8 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 032e67bcfe59e2efb864f17ebce9fd7e5134c216c5274f96ef7f9cae1d45442a
|
|
4
|
+
data.tar.gz: fa73331c0868598885695e1fffc53e76824f1498549f82ac68d2c98276a887fc
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 363dde634782a8bf79e53bd62ac376515611711d839deb4258088ec197dd6f3f5e199321c8d347c66b42756a7872511a4b680c022a1106fcb3c4b0ba88513a88
|
|
7
|
+
data.tar.gz: 37eb0b6df32586566a21603549df3fbbdfa01858b1ec66da3af0c76d3a8a7d276cd939a987ba36990b359c6bebca20f713c137935d095f3ee2debb9130d29c8f
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,51 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [17.0.0] - 2026-07-18
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- `HeadMusic::Rudiment::UnpitchedSound` — an unpitched sound (a drum hit, a clap, a percussive knock), backed by the instruments catalog. `.get(nil)` returns the generic instrument-less sound; `.get(name_or_alias_or_instrument)` resolves through the catalog (aliases canonicalize to the instrument's name key), and pitched instruments are valid hit surfaces — a knock on a violin body is unpitched.
|
|
15
|
+
- Placements hold sounds: `HeadMusic::Content::Placement#sounds` is the source of truth (pitched and unpitched, mixed within one placement allowed), with `#pitches` as the pitched subset. `Voice#place` accepts pitches, unpitched sounds, instruments, or mixed arrays.
|
|
16
|
+
- New placement predicates: `sounded?` (any sound), `pitched?` (any pitched sound), `pitched_note?`, and `unpitched_note?`.
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
|
|
20
|
+
- **Breaking**: serialization schema is now version 3. Placement hashes carry a `"sounds"` array instead of the `"pitches"` key — pitched sounds serialize as pitch strings (unchanged), unpitched sounds as `{"unpitched" => name_key}` objects (`null` name key for the generic sound) — and `Composition.from_h` no longer accepts schema version 2 hashes. Persisted v2 data (e.g. in a jsonb column) must be migrated by renaming each placement's `"pitches"` key to `"sounds"`; the pitch strings themselves are unchanged.
|
|
21
|
+
- **Breaking**: `Placement#note?` now means exactly one sound of any kind, so chords are no longer `note?`; `Placement#chord?` counts pitched sounds (two or more).
|
|
22
|
+
- **Breaking**: `Voice#place` raises `ArgumentError` on an unparseable value instead of quietly placing a rest.
|
|
23
|
+
- The ABC and MusicXML writers raise `RenderError` when asked to render an unpitched sound (unpitched rendering lands in a future release).
|
|
24
|
+
|
|
25
|
+
## [16.0.0] - 2026-07-17
|
|
26
|
+
|
|
27
|
+
### Added
|
|
28
|
+
|
|
29
|
+
- Chords in the content model: `HeadMusic::Content::Placement` holds a `pitches` array (empty for a rest, two or more for a chord) and derives `#pitch` as the highest pitch, so melodic analysis follows the top line. `Placement#chord?` distinguishes chords. `Voice#place` accepts a single pitch or an array of pitches; a chord is one rhythmic event.
|
|
30
|
+
- `Voice#place` merges a placement at an already-occupied position into the existing placement when the rhythmic value matches (the pitch union is duplicate-free, so re-placing a pitch is idempotent), and raises `ArgumentError` when it does not. A position within a voice holds at most one placement, enforcing structurally that simultaneous pitches with distinct durations belong in separate voices.
|
|
31
|
+
|
|
32
|
+
### Changed
|
|
33
|
+
|
|
34
|
+
- **Breaking**: serialization schema is now version 2. Placement hashes carry a `"pitches"` array instead of the singular `"pitch"` key (rests serialize as `"pitches" => []`), and `Composition.from_h` no longer accepts schema version 1 hashes.
|
|
35
|
+
- **Breaking**: `Placement#pitch` is a derived reader (highest of `pitches`, `nil` for a rest) rather than a stored attribute, and `Placement#note?` returns a boolean rather than the pitch object.
|
|
36
|
+
- The ABC and MusicXML writers raise `RenderError` when asked to render a chord placement (chord rendering lands in a future release) rather than silently emitting only the top pitch.
|
|
37
|
+
|
|
38
|
+
## [15.2.0] - 2026-07-16
|
|
39
|
+
|
|
40
|
+
### Added
|
|
41
|
+
|
|
42
|
+
- `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.
|
|
43
|
+
- `HeadMusic::Content::Composition#to_json` / `.from_json` — thin delegates over `to_h`/`from_h`.
|
|
44
|
+
- `#to_h` on `Content::Voice`, `Content::Placement`, `Content::Bar`, and `Content::Comment`.
|
|
45
|
+
|
|
46
|
+
**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.
|
|
47
|
+
|
|
48
|
+
### Fixed
|
|
49
|
+
|
|
50
|
+
- `RhythmicValue.get` now parses tied value strings ("half tied to eighth", including chained ties), so tied durations round-trip through `#to_s`.
|
|
51
|
+
- `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.
|
|
52
|
+
- `Voice` placement ordering is now stable: notes placed at the same position (chords) keep their insertion order.
|
|
53
|
+
- `Composition#change_key_signature` / `#change_meter` no longer raise for a bar earlier than the first placement (e.g. a pickup bar).
|
|
54
|
+
|
|
10
55
|
## [15.1.0] - 2026-07-07
|
|
11
56
|
|
|
12
57
|
### Added
|
data/Gemfile.lock
CHANGED
|
@@ -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 = 3
|
|
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
|
|
@@ -77,8 +88,32 @@ class HeadMusic::Content::Composition
|
|
|
77
88
|
HeadMusic::Notation::MusicXML.render(self)
|
|
78
89
|
end
|
|
79
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
|
+
|
|
80
109
|
private
|
|
81
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
|
+
|
|
82
117
|
def ensure_attributes(name, key_signature, meter)
|
|
83
118
|
@name = name || "Composition"
|
|
84
119
|
@key_signature = HeadMusic::Rudiment::KeySignature.get(key_signature) if key_signature
|
|
@@ -94,4 +129,232 @@ class HeadMusic::Content::Composition
|
|
|
94
129
|
def last_key_signature_change(bar_number)
|
|
95
130
|
bars(bar_number)[earliest_bar_number..bar_number].reverse.detect(&:key_signature)
|
|
96
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 v3 hash by replaying the public
|
|
147
|
+
# builder API in dependency order: meter and key changes first (position
|
|
148
|
+
# strings roll counts and ticks over via the meter map), then placements,
|
|
149
|
+
# then repeat flags (a pickup-bar flag needs its bar allocated), then
|
|
150
|
+
# comments. Validates values at the boundary so corrupted input raises
|
|
151
|
+
# ArgumentError with path context instead of silently deserializing wrong.
|
|
152
|
+
class HashDeserializer
|
|
153
|
+
def initialize(hash)
|
|
154
|
+
raise ArgumentError, "expected a Hash, got #{hash.class}" unless hash.is_a?(Hash)
|
|
155
|
+
|
|
156
|
+
@hash = hash.deep_transform_keys(&:to_s)
|
|
157
|
+
validate_schema_version
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def composition
|
|
161
|
+
@composition ||= build_base_composition.tap do |composition|
|
|
162
|
+
apply_bar_changes(composition)
|
|
163
|
+
build_voices(composition)
|
|
164
|
+
apply_repeat_flags(composition)
|
|
165
|
+
add_comments(composition)
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
private
|
|
170
|
+
|
|
171
|
+
attr_reader :hash
|
|
172
|
+
|
|
173
|
+
def validate_schema_version
|
|
174
|
+
version = hash["schema_version"]
|
|
175
|
+
return if version.is_a?(Integer) && version == SCHEMA_VERSION
|
|
176
|
+
|
|
177
|
+
message = "unsupported schema_version: #{version.inspect} (supported: #{SCHEMA_VERSION})"
|
|
178
|
+
if version == 2
|
|
179
|
+
message += "; migrate v2 \"pitches\" arrays to v3 \"sounds\" arrays " \
|
|
180
|
+
"(pitch strings unchanged; unpitched sounds are {\"unpitched\" => name_key} objects)"
|
|
181
|
+
end
|
|
182
|
+
raise ArgumentError, message
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def build_base_composition
|
|
186
|
+
HeadMusic::Content::Composition.new(
|
|
187
|
+
name: hash["name"],
|
|
188
|
+
key_signature: parsed_key_signature(hash["key_signature"], "key_signature"),
|
|
189
|
+
meter: parsed_meter(hash["meter"], "meter"),
|
|
190
|
+
composer: hash["composer"],
|
|
191
|
+
origin: hash["origin"]
|
|
192
|
+
)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def bar_hashes
|
|
196
|
+
@bar_hashes ||= Array(hash["bars"])
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def apply_bar_changes(composition)
|
|
200
|
+
bar_hashes.each_with_index do |bar_hash, index|
|
|
201
|
+
number = validated_bar_number(bar_hash, index)
|
|
202
|
+
path = "bars[#{index}]"
|
|
203
|
+
key_signature = parsed_key_signature(bar_hash["key_signature"], path)
|
|
204
|
+
meter = parsed_meter(bar_hash["meter"], path)
|
|
205
|
+
composition.change_key_signature(number, key_signature) if key_signature
|
|
206
|
+
composition.change_meter(number, meter) if meter
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def build_voices(composition)
|
|
211
|
+
Array(hash["voices"]).each_with_index do |voice_hash, voice_index|
|
|
212
|
+
voice = composition.add_voice(role: voice_hash["role"])
|
|
213
|
+
Array(voice_hash["placements"]).each_with_index do |placement_hash, placement_index|
|
|
214
|
+
path = "voices[#{voice_index}].placements[#{placement_index}]"
|
|
215
|
+
position = parsed_position(placement_hash["position"], path)
|
|
216
|
+
rhythmic_value = parsed_rhythmic_value(placement_hash["rhythmic_value"], path)
|
|
217
|
+
sounds = parsed_placement_sounds(placement_hash, path)
|
|
218
|
+
voice.place(position, rhythmic_value, sounds)
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def apply_repeat_flags(composition)
|
|
224
|
+
bar_hashes.each_with_index do |bar_hash, index|
|
|
225
|
+
next unless repeat_state?(bar_hash)
|
|
226
|
+
|
|
227
|
+
bar = composition.bars(validated_bar_number(bar_hash, index)).last
|
|
228
|
+
bar.starts_repeat = true if bar_hash["starts_repeat"]
|
|
229
|
+
bar.ends_repeat_after_num_plays = bar_hash["ends_repeat_after_num_plays"] if bar_hash["ends_repeat_after_num_plays"]
|
|
230
|
+
bar.plays_on_passes = bar_hash["plays_on_passes"] if bar_hash["plays_on_passes"]
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def add_comments(composition)
|
|
235
|
+
Array(hash["comments"]).each_with_index do |comment_hash, index|
|
|
236
|
+
position = parsed_position(comment_hash["position"], "comments[#{index}]") if comment_hash["position"]
|
|
237
|
+
composition.add_comment(comment_hash["text"], position)
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def repeat_state?(bar_hash)
|
|
242
|
+
bar_hash["starts_repeat"] || bar_hash["ends_repeat_after_num_plays"] || bar_hash["plays_on_passes"]
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def validated_bar_number(bar_hash, index)
|
|
246
|
+
number = bar_hash["number"]
|
|
247
|
+
unless number.is_a?(Integer) && number >= 0
|
|
248
|
+
raise ArgumentError, "bars[#{index}]: bar number must be an Integer of at least 0, got #{number.inspect}"
|
|
249
|
+
end
|
|
250
|
+
number
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
# Position silently coerces garbage strings to "0:1:000", which would
|
|
254
|
+
# mislocate content with no error, so the shape is validated up front.
|
|
255
|
+
# Accepts "bar", "bar:count", or "bar:count:tick" with non-negative parts.
|
|
256
|
+
def parsed_position(value, path)
|
|
257
|
+
return nil if value.nil?
|
|
258
|
+
|
|
259
|
+
unless value.is_a?(String) && value.match?(/\A\d+(:\d+){0,2}\z/)
|
|
260
|
+
raise ArgumentError, "#{path}: unknown position #{value.inspect}"
|
|
261
|
+
end
|
|
262
|
+
value
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
# KeySignature.get returns a hollow object (nil tonic_spelling) for
|
|
266
|
+
# garbage rather than nil, so presence of the tonic is the real check.
|
|
267
|
+
def parsed_key_signature(value, path)
|
|
268
|
+
return nil if value.nil?
|
|
269
|
+
|
|
270
|
+
key_signature = begin
|
|
271
|
+
HeadMusic::Rudiment::KeySignature.get(value)
|
|
272
|
+
rescue
|
|
273
|
+
nil
|
|
274
|
+
end
|
|
275
|
+
unless key_signature&.tonic_spelling
|
|
276
|
+
raise ArgumentError, "#{path}: unknown key signature #{value.inspect}"
|
|
277
|
+
end
|
|
278
|
+
key_signature
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def parsed_meter(value, path)
|
|
282
|
+
return nil if value.nil?
|
|
283
|
+
|
|
284
|
+
meter = begin
|
|
285
|
+
HeadMusic::Rudiment::Meter.get(value)
|
|
286
|
+
rescue
|
|
287
|
+
nil
|
|
288
|
+
end
|
|
289
|
+
unless meter&.top_number&.positive? && meter.bottom_number.positive?
|
|
290
|
+
raise ArgumentError, "#{path}: unknown meter #{value.inspect}"
|
|
291
|
+
end
|
|
292
|
+
meter
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
def parsed_rhythmic_value(value, path)
|
|
296
|
+
rhythmic_value = HeadMusic::Rudiment::RhythmicValue.get(value)
|
|
297
|
+
unless valid_rhythmic_value?(rhythmic_value)
|
|
298
|
+
raise ArgumentError, "#{path}: unknown rhythmic value #{value.inspect}"
|
|
299
|
+
end
|
|
300
|
+
rhythmic_value
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
# RhythmicValue.get returns a hollow object (nil unit) for garbage rather
|
|
304
|
+
# than nil, and a tied tail can be hollow while the head parses, so the
|
|
305
|
+
# whole tie chain is checked.
|
|
306
|
+
def valid_rhythmic_value?(rhythmic_value)
|
|
307
|
+
return false unless rhythmic_value.is_a?(HeadMusic::Rudiment::RhythmicValue)
|
|
308
|
+
return false unless rhythmic_value.unit
|
|
309
|
+
|
|
310
|
+
rhythmic_value.tied_value.nil? || valid_rhythmic_value?(rhythmic_value.tied_value)
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
# A value that fails to parse would otherwise silently deserialize as
|
|
314
|
+
# a rest.
|
|
315
|
+
def parsed_pitch(value, path)
|
|
316
|
+
pitch = HeadMusic::Rudiment::Pitch.get(value)
|
|
317
|
+
raise ArgumentError, "#{path}: unknown pitch #{value.inspect}" unless pitch
|
|
318
|
+
|
|
319
|
+
pitch
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
# "sounds" is an array of sound data, empty for a rest. A pitched sound
|
|
323
|
+
# is a pitch string; an unpitched sound is a one-key
|
|
324
|
+
# {"unpitched" => name_key} hash. A nil element is never a rest, so it
|
|
325
|
+
# fails like any other unknown sound.
|
|
326
|
+
def parsed_placement_sounds(placement_hash, path)
|
|
327
|
+
values = placement_hash["sounds"]
|
|
328
|
+
unless values.is_a?(Array)
|
|
329
|
+
raise ArgumentError, "#{path}: sounds must be an Array, got #{values.inspect}"
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
values.each_with_index.map do |value, index|
|
|
333
|
+
parsed_sound(value, "#{path}.sounds[#{index}]")
|
|
334
|
+
end
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
def parsed_sound(value, path)
|
|
338
|
+
return parsed_pitch(value, path) if value.is_a?(String)
|
|
339
|
+
return parsed_unpitched_sound(value, path) if value.is_a?(Hash)
|
|
340
|
+
|
|
341
|
+
raise ArgumentError, "#{path}: unknown sound #{value.inspect}"
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
# A nil name_key is the generic unpitched sound. A pitched instrument is
|
|
345
|
+
# a valid hit surface (a knock on a violin body is unpitched), so any
|
|
346
|
+
# catalog name or alias resolves.
|
|
347
|
+
def parsed_unpitched_sound(value, path)
|
|
348
|
+
unless value.keys == ["unpitched"]
|
|
349
|
+
raise ArgumentError, "#{path}: unknown sound #{value.inspect}"
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
name = value["unpitched"]
|
|
353
|
+
valid_name = name.nil? || (name.is_a?(String) && !name.empty?)
|
|
354
|
+
sound = HeadMusic::Rudiment::UnpitchedSound.get(name) if valid_name
|
|
355
|
+
raise ArgumentError, "#{path}: unknown instrument #{name.inspect}" unless sound
|
|
356
|
+
|
|
357
|
+
sound
|
|
358
|
+
end
|
|
359
|
+
end
|
|
97
360
|
end
|
|
@@ -1,25 +1,71 @@
|
|
|
1
1
|
# A module for musical content
|
|
2
2
|
module HeadMusic::Content; end
|
|
3
3
|
|
|
4
|
-
# A placement is a note or rest at a position within a voice in a composition
|
|
4
|
+
# A placement is a note, chord, or rest at a position within a voice in a composition
|
|
5
5
|
class HeadMusic::Content::Placement
|
|
6
6
|
include Comparable
|
|
7
7
|
|
|
8
|
-
attr_reader :voice, :position, :rhythmic_value, :
|
|
8
|
+
attr_reader :voice, :position, :rhythmic_value, :sounds
|
|
9
9
|
|
|
10
10
|
delegate :composition, to: :voice
|
|
11
11
|
delegate :spelling, to: :pitch, allow_nil: true
|
|
12
12
|
|
|
13
|
-
def initialize(voice, position, rhythmic_value,
|
|
14
|
-
ensure_attributes(voice, position, rhythmic_value,
|
|
13
|
+
def initialize(voice, position, rhythmic_value, sound_or_sounds = nil)
|
|
14
|
+
ensure_attributes(voice, position, rhythmic_value, sound_or_sounds)
|
|
15
15
|
end
|
|
16
16
|
|
|
17
|
-
def
|
|
18
|
-
|
|
17
|
+
def pitches
|
|
18
|
+
sounds.select(&:pitched?)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# The top pitch of a chord (or the only pitch of a note), which melodic
|
|
22
|
+
# analysis treats as the melody note. Returns nil for rests and
|
|
23
|
+
# unpitched-only placements; pitched? is the guard. Enharmonic ties
|
|
24
|
+
# resolve to the first-listed pitch (MRI's max keeps the earliest of
|
|
25
|
+
# equals; a spec pins the behavior).
|
|
26
|
+
def pitch
|
|
27
|
+
pitches.max
|
|
19
28
|
end
|
|
20
29
|
|
|
21
30
|
def rest?
|
|
22
|
-
|
|
31
|
+
sounds.empty?
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def sounded?
|
|
35
|
+
sounds.any?
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def note?
|
|
39
|
+
sounds.length == 1
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def pitched_note?
|
|
43
|
+
note? && pitched?
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def unpitched_note?
|
|
47
|
+
note? && !pitched?
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def chord?
|
|
51
|
+
pitches.length > 1
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def pitched?
|
|
55
|
+
sounds.any?(&:pitched?)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Voice#place merges a same-position placement into the existing one, so a
|
|
59
|
+
# position holds at most one placement. The sound union keeps the chord free
|
|
60
|
+
# of duplicates, making repeated placement of a sound idempotent.
|
|
61
|
+
def merge(other)
|
|
62
|
+
unless rhythmic_value == other.rhythmic_value
|
|
63
|
+
raise ArgumentError,
|
|
64
|
+
"cannot place a #{other.rhythmic_value} at #{position}: position occupied by a #{rhythmic_value}"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
@sounds = (sounds + other.sounds).uniq.freeze
|
|
68
|
+
self
|
|
23
69
|
end
|
|
24
70
|
|
|
25
71
|
def next_position
|
|
@@ -35,11 +81,29 @@ class HeadMusic::Content::Placement
|
|
|
35
81
|
end
|
|
36
82
|
|
|
37
83
|
def to_s
|
|
38
|
-
"#{rhythmic_value} #{
|
|
84
|
+
"#{rhythmic_value} #{sounds.any? ? sounds.map { |sound| sound_label(sound) }.join(" ") : "rest"} at #{position}"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def to_h
|
|
88
|
+
{
|
|
89
|
+
"position" => position.to_s,
|
|
90
|
+
"rhythmic_value" => rhythmic_value.to_s,
|
|
91
|
+
"sounds" => sounds.map { |sound| sound_datum(sound) }
|
|
92
|
+
}
|
|
39
93
|
end
|
|
40
94
|
|
|
41
95
|
private
|
|
42
96
|
|
|
97
|
+
# Unpitched names may be multi-word, so they are bracketed to keep the
|
|
98
|
+
# space-delimited sound list unambiguous.
|
|
99
|
+
def sound_label(sound)
|
|
100
|
+
sound.pitched? ? sound.to_s : "[#{sound}]"
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def sound_datum(sound)
|
|
104
|
+
sound.pitched? ? sound.to_s : {"unpitched" => sound.name_key&.to_s}
|
|
105
|
+
end
|
|
106
|
+
|
|
43
107
|
def starts_during?(other_placement)
|
|
44
108
|
position >= other_placement.position && position < other_placement.next_position
|
|
45
109
|
end
|
|
@@ -52,11 +116,51 @@ class HeadMusic::Content::Placement
|
|
|
52
116
|
position <= other_placement.position && next_position >= other_placement.next_position
|
|
53
117
|
end
|
|
54
118
|
|
|
55
|
-
def ensure_attributes(voice, position, rhythmic_value,
|
|
119
|
+
def ensure_attributes(voice, position, rhythmic_value, sound_or_sounds)
|
|
56
120
|
@voice = voice
|
|
57
121
|
ensure_position(position)
|
|
58
122
|
@rhythmic_value = HeadMusic::Rudiment::RhythmicValue.get(rhythmic_value)
|
|
59
|
-
@
|
|
123
|
+
@sounds = ensure_sounds(sound_or_sounds)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def ensure_sounds(sound_or_sounds)
|
|
127
|
+
return [].freeze if sound_or_sounds.nil?
|
|
128
|
+
|
|
129
|
+
values = sound_or_sounds.is_a?(Array) ? sound_or_sounds : [sound_or_sounds]
|
|
130
|
+
values.map { |value| resolve_sound(value) }.uniq.freeze
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def resolve_sound(value)
|
|
134
|
+
return value if value.is_a?(HeadMusic::Rudiment::UnpitchedSound)
|
|
135
|
+
return HeadMusic::Rudiment::UnpitchedSound.get(value) if value.is_a?(HeadMusic::Instruments::Instrument)
|
|
136
|
+
|
|
137
|
+
pitch = HeadMusic::Rudiment::Pitch.get(value)
|
|
138
|
+
return pitch if pitch
|
|
139
|
+
|
|
140
|
+
unpitched_sound(value) || raise(ArgumentError, unknown_sound_message(value))
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# A bare name resolves to a percussive hit only on an unpitched instrument;
|
|
144
|
+
# naming a pitched instrument is ambiguous, so it raises instead.
|
|
145
|
+
# UnpitchedSound.get(nil) is the generic sound, so nil is excluded here to
|
|
146
|
+
# preserve nil-as-rest at the argument level and nil-raises inside arrays.
|
|
147
|
+
def unpitched_sound(value)
|
|
148
|
+
return nil if value.nil?
|
|
149
|
+
|
|
150
|
+
sound = HeadMusic::Rudiment::UnpitchedSound.get(value)
|
|
151
|
+
return nil unless sound&.instrument
|
|
152
|
+
return nil if sound.instrument.pitched?
|
|
153
|
+
|
|
154
|
+
sound
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def unknown_sound_message(value)
|
|
158
|
+
if HeadMusic::Instruments::Instrument.get(value)&.pitched?
|
|
159
|
+
"#{value.inspect} is a pitched instrument; place a pitch such as \"D4\", " \
|
|
160
|
+
"or pass HeadMusic::Rudiment::UnpitchedSound.get(#{value.inspect}) for a percussive hit"
|
|
161
|
+
else
|
|
162
|
+
"unknown sound: #{value.inspect}"
|
|
163
|
+
end
|
|
60
164
|
end
|
|
61
165
|
|
|
62
166
|
def ensure_position(position)
|
|
@@ -16,14 +16,17 @@ class HeadMusic::Content::Voice
|
|
|
16
16
|
@placements = []
|
|
17
17
|
end
|
|
18
18
|
|
|
19
|
-
def place(position, rhythmic_value,
|
|
20
|
-
HeadMusic::Content::Placement.new(self, position, rhythmic_value,
|
|
21
|
-
|
|
22
|
-
|
|
19
|
+
def place(position, rhythmic_value, sound_or_sounds = nil)
|
|
20
|
+
placement = HeadMusic::Content::Placement.new(self, position, rhythmic_value, sound_or_sounds)
|
|
21
|
+
existing = placement_at(placement.position)
|
|
22
|
+
return existing.merge(placement) if existing
|
|
23
|
+
|
|
24
|
+
insert_into_placements(placement)
|
|
25
|
+
placement
|
|
23
26
|
end
|
|
24
27
|
|
|
25
28
|
def notes
|
|
26
|
-
@placements.select(&:
|
|
29
|
+
@placements.select(&:pitched?).sort_by(&:position)
|
|
27
30
|
end
|
|
28
31
|
|
|
29
32
|
def notes_not_in_key
|
|
@@ -140,15 +143,28 @@ class HeadMusic::Content::Voice
|
|
|
140
143
|
[role, pitches_string].join(": ")
|
|
141
144
|
end
|
|
142
145
|
|
|
146
|
+
def to_h
|
|
147
|
+
{
|
|
148
|
+
"role" => role&.to_s,
|
|
149
|
+
"placements" => placements.map(&:to_h)
|
|
150
|
+
}
|
|
151
|
+
end
|
|
152
|
+
|
|
143
153
|
private
|
|
144
154
|
|
|
145
155
|
def bar_start_position(placement)
|
|
146
156
|
HeadMusic::Content::Position.new(composition, placement.position.bar_number, 1, 0)
|
|
147
157
|
end
|
|
148
158
|
|
|
159
|
+
def placement_at(position)
|
|
160
|
+
@placements.find { |placement| placement.position == position }
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Positions are unique within a voice (place merges same-position
|
|
164
|
+
# placements), so insertion order is simply position order.
|
|
149
165
|
def insert_into_placements(placement)
|
|
150
|
-
@placements
|
|
151
|
-
@placements
|
|
166
|
+
index = @placements.index { |existing| existing > placement } || @placements.length
|
|
167
|
+
@placements.insert(index, placement)
|
|
152
168
|
end
|
|
153
169
|
|
|
154
170
|
def pitches_string
|