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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: cd3edd30892b4392257993e45de98e7c27645b61480c6f478b464005f89b10a7
4
- data.tar.gz: 9755bd71aae10839f15d9a7f73cd6f2526265672c03907e2e2b9db75d6fd4bb0
3
+ metadata.gz: 032e67bcfe59e2efb864f17ebce9fd7e5134c216c5274f96ef7f9cae1d45442a
4
+ data.tar.gz: fa73331c0868598885695e1fffc53e76824f1498549f82ac68d2c98276a887fc
5
5
  SHA512:
6
- metadata.gz: 45706fe9753a6907b79378a7b5334c76fb6cc55f282fe16b77940fe0d8dbe01197c111a452e0974ff2ebe9d5536255858e81da7fb15141a8540ad2600148f817
7
- data.tar.gz: e97830d0f4d8f102ebae608701d5db6a1bdaaa905545a6b14143d2a36f7273ba0398e960b0986d2a05bf5aa704273abea31f89b08db59875ddb059e150b2753b
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
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- head_music (15.1.0)
4
+ head_music (17.0.0)
5
5
  activesupport (>= 7.0, < 10)
6
6
  humanize (~> 2.0)
7
7
  i18n (~> 1.8)
@@ -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 = 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
- (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
@@ -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, :pitch
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, pitch = nil)
14
- ensure_attributes(voice, position, rhythmic_value, pitch)
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 note?
18
- pitch
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
- !note?
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} #{pitch || "rest"} at #{position}"
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, pitch)
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
- @pitch = HeadMusic::Rudiment::Pitch.get(pitch)
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, pitch = nil)
20
- HeadMusic::Content::Placement.new(self, position, rhythmic_value, pitch).tap do |placement|
21
- insert_into_placements(placement)
22
- end
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(&:note?).sort_by(&:position)
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 << placement
151
- @placements = @placements.sort
166
+ index = @placements.index { |existing| existing > placement } || @placements.length
167
+ @placements.insert(index, placement)
152
168
  end
153
169
 
154
170
  def pitches_string