head_music 15.2.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: d2446af0205d6b751566da4a2cc132032151d5c9b41732938552d12d3f07d05e
4
- data.tar.gz: ed75fb7cea9e3a2ea50543bad21d28d0c66b8996cb5d57b4757aa9ef211eb67b
3
+ metadata.gz: 032e67bcfe59e2efb864f17ebce9fd7e5134c216c5274f96ef7f9cae1d45442a
4
+ data.tar.gz: fa73331c0868598885695e1fffc53e76824f1498549f82ac68d2c98276a887fc
5
5
  SHA512:
6
- metadata.gz: fda48134cdaea328ddcdc1e2e072ea9c94d7a354e99178428969834cab6db36882667f21f7860ba427561d7a70e40eace071a1d4e9a7228e42c6cb92f2749e01
7
- data.tar.gz: d2d1bfd02a1b1101932acd435836006e1deaa6e81102f233b9b339dfb56c01effc94691ce30063334e7c61eafffee214075c516ff0d831395a8c91b861cfffb0
6
+ metadata.gz: 363dde634782a8bf79e53bd62ac376515611711d839deb4258088ec197dd6f3f5e199321c8d347c66b42756a7872511a4b680c022a1106fcb3c4b0ba88513a88
7
+ data.tar.gz: 37eb0b6df32586566a21603549df3fbbdfa01858b1ec66da3af0c76d3a8a7d276cd939a987ba36990b359c6bebca20f713c137935d095f3ee2debb9130d29c8f
data/CHANGELOG.md CHANGED
@@ -7,6 +7,34 @@ 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
+
10
38
  ## [15.2.0] - 2026-07-16
11
39
 
12
40
  ### Added
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- head_music (15.2.0)
4
+ head_music (17.0.0)
5
5
  activesupport (>= 7.0, < 10)
6
6
  humanize (~> 2.0)
7
7
  i18n (~> 1.8)
@@ -3,7 +3,7 @@ module HeadMusic::Content; end
3
3
 
4
4
  # A composition is musical content.
5
5
  class HeadMusic::Content::Composition
6
- SCHEMA_VERSION = 1
6
+ SCHEMA_VERSION = 3
7
7
 
8
8
  attr_reader :name, :key_signature, :meter, :voices, :composer, :origin, :comments
9
9
 
@@ -143,7 +143,7 @@ class HeadMusic::Content::Composition
143
143
  end
144
144
  end
145
145
 
146
- # Rebuilds a composition from a schema v1 hash by replaying the public
146
+ # Rebuilds a composition from a schema v3 hash by replaying the public
147
147
  # builder API in dependency order: meter and key changes first (position
148
148
  # strings roll counts and ticks over via the meter map), then placements,
149
149
  # then repeat flags (a pickup-bar flag needs its bar allocated), then
@@ -174,7 +174,12 @@ class HeadMusic::Content::Composition
174
174
  version = hash["schema_version"]
175
175
  return if version.is_a?(Integer) && version == SCHEMA_VERSION
176
176
 
177
- raise ArgumentError, "unsupported schema_version: #{version.inspect} (supported: #{SCHEMA_VERSION})"
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
178
183
  end
179
184
 
180
185
  def build_base_composition
@@ -209,8 +214,8 @@ class HeadMusic::Content::Composition
209
214
  path = "voices[#{voice_index}].placements[#{placement_index}]"
210
215
  position = parsed_position(placement_hash["position"], path)
211
216
  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)
217
+ sounds = parsed_placement_sounds(placement_hash, path)
218
+ voice.place(position, rhythmic_value, sounds)
214
219
  end
215
220
  end
216
221
  end
@@ -305,15 +310,51 @@ class HeadMusic::Content::Composition
305
310
  rhythmic_value.tied_value.nil? || valid_rhythmic_value?(rhythmic_value.tied_value)
306
311
  end
307
312
 
308
- # A nil pitch is a rest; a non-nil string that fails to parse would
309
- # otherwise silently deserialize as a rest.
313
+ # A value that fails to parse would otherwise silently deserialize as
314
+ # a rest.
310
315
  def parsed_pitch(value, path)
311
- return nil if value.nil?
312
-
313
316
  pitch = HeadMusic::Rudiment::Pitch.get(value)
314
317
  raise ArgumentError, "#{path}: unknown pitch #{value.inspect}" unless pitch
315
318
 
316
319
  pitch
317
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
318
359
  end
319
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,19 +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}"
39
85
  end
40
86
 
41
87
  def to_h
42
88
  {
43
89
  "position" => position.to_s,
44
90
  "rhythmic_value" => rhythmic_value.to_s,
45
- "pitch" => pitch&.to_s
91
+ "sounds" => sounds.map { |sound| sound_datum(sound) }
46
92
  }
47
93
  end
48
94
 
49
95
  private
50
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
+
51
107
  def starts_during?(other_placement)
52
108
  position >= other_placement.position && position < other_placement.next_position
53
109
  end
@@ -60,11 +116,51 @@ class HeadMusic::Content::Placement
60
116
  position <= other_placement.position && next_position >= other_placement.next_position
61
117
  end
62
118
 
63
- def ensure_attributes(voice, position, rhythmic_value, pitch)
119
+ def ensure_attributes(voice, position, rhythmic_value, sound_or_sounds)
64
120
  @voice = voice
65
121
  ensure_position(position)
66
122
  @rhythmic_value = HeadMusic::Rudiment::RhythmicValue.get(rhythmic_value)
67
- @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
68
164
  end
69
165
 
70
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
@@ -153,8 +156,12 @@ class HeadMusic::Content::Voice
153
156
  HeadMusic::Content::Position.new(composition, placement.position.bar_number, 1, 0)
154
157
  end
155
158
 
156
- # Stable insertion: co-positioned placements (chords) keep their insertion order,
157
- # which serialization round-trips depend on.
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.
158
165
  def insert_into_placements(placement)
159
166
  index = @placements.index { |existing| existing > placement } || @placements.length
160
167
  @placements.insert(index, placement)
@@ -12,17 +12,23 @@ class HeadMusic::Notation::ABC::BodyLexer
12
12
  Token = Data.define(
13
13
  :type, :line, :column,
14
14
  :letter, :accidental, :octave_marks, :length,
15
- :style, :passes, :direction, :voice_id, :lexeme
15
+ :style, :passes, :direction, :voice_id, :lexeme, :notes
16
16
  ) do
17
17
  def initialize(
18
18
  type:, line:, column:,
19
19
  letter: nil, accidental: nil, octave_marks: nil, length: nil,
20
- style: nil, passes: nil, direction: nil, voice_id: nil, lexeme: nil
20
+ style: nil, passes: nil, direction: nil, voice_id: nil, lexeme: nil,
21
+ notes: nil
21
22
  )
22
23
  super
23
24
  end
24
25
  end
25
26
 
27
+ # One note inside a bracket chord. Each note may carry its own length;
28
+ # the parser enforces that they are uniform and multiplies the shared
29
+ # inner length with any outer length on the token (ABC 2.1 sec. 4.17).
30
+ ChordNote = Data.define(:accidental, :letter, :octave_marks, :length)
31
+
26
32
  # Alternatives are ordered longest-first so the scanner never takes a
27
33
  # short match when a longer bar line is present (e.g. "|]" before "|").
28
34
  BAR_LINE_PATTERN = /:\|\|:|:\|:|::|:\||\|:|\|\||\|\]|\[\||\|/
@@ -31,6 +37,8 @@ class HeadMusic::Notation::ABC::BodyLexer
31
37
  NORMALIZED_BAR_STYLES = {":||:" => "::", ":|:" => "::"}.freeze
32
38
 
33
39
  NOTE_PATTERN = %r{(\^\^|\^|__|_|=)?([A-Ga-g])([',]*)([\d/]*)}
40
+ CHORD_START_PATTERN = /\[(\^\^|\^|__|_|=)?[A-Ga-g]/
41
+ CHORD_NOTE_PATTERN = %r{(\^\^|\^|__|_|=)?([A-Ga-g])([',]*)([\d/]*)}
34
42
  REST_PATTERN = %r{z([\d/]*)}
35
43
  VOLTA_DIGITS_PATTERN = /\d[\d,-]*/
36
44
 
@@ -171,15 +179,49 @@ class HeadMusic::Notation::ABC::BodyLexer
171
179
  return true
172
180
  end
173
181
 
174
- if scanner.check(/\[[A-Ga-g]/)
175
- chord = scanner.scan(/\[[^\]]*\]/) || scanner.scan(/\[[^\]]*/)
176
- tokens << unsupported_token(chord, line_number, column)
177
- return true
178
- end
182
+ return scan_chord(scanner, line_number, column, tokens) if scanner.check(CHORD_START_PATTERN)
179
183
 
180
184
  raise_unexpected_character(scanner, line_number, column)
181
185
  end
182
186
 
187
+ def scan_chord(scanner, line_number, column, tokens)
188
+ start_pos = scanner.pos
189
+ scanner.skip(/\[/)
190
+ notes = []
191
+ until scanner.skip(/\]/)
192
+ unless scanner.scan(CHORD_NOTE_PATTERN)
193
+ return chord_fallback(scanner, start_pos, line_number, column, tokens)
194
+ end
195
+ notes << ChordNote.new(
196
+ accidental: scanner[1], letter: scanner[2],
197
+ octave_marks: scanner[3], length: scanner[4]
198
+ )
199
+ end
200
+ length = scanner.scan(%r{[\d/]*})
201
+ tokens << Token.new(type: :chord, line: line_number, column: column, notes: notes, length: length)
202
+ true
203
+ end
204
+
205
+ # Non-note content inside the brackets (ties, rests, spaces,
206
+ # decorations) makes the whole group one unsupported token, matching
207
+ # how those constructs surface outside a chord.
208
+ def chord_fallback(scanner, start_pos, line_number, column, tokens)
209
+ if scanner.eos?
210
+ raise HeadMusic::Notation::ABC::ParseError.new(
211
+ 'Unterminated chord; expected "]"',
212
+ line_number: line_number, snippet: chord_snippet(scanner, start_pos)
213
+ )
214
+ end
215
+ scanner.pos = start_pos
216
+ lexeme = scanner.scan(/\[[^\]]*\]/) || scanner.scan(/\[[^\]]*/)
217
+ tokens << unsupported_token(lexeme, line_number, column)
218
+ true
219
+ end
220
+
221
+ def chord_snippet(scanner, start_pos)
222
+ scanner.string[start_pos, SNIPPET_LENGTH]
223
+ end
224
+
183
225
  def volta_token(digits, line_number, column)
184
226
  passes = volta_passes(digits)
185
227
  unless passes.uniq.length == passes.length
@@ -28,6 +28,13 @@ module HeadMusic::Notation::ABC
28
28
  build_rhythmic_value(fraction, multiplier_string)
29
29
  end
30
30
 
31
+ # The bare fraction a length string denotes (e.g. "2" -> 2, "/2" -> 1/2,
32
+ # "" -> 1), without the unit note length applied. Used to compare a
33
+ # chord's per-note lengths for uniformity.
34
+ def length_fraction(multiplier_string)
35
+ multiplier(multiplier_string)
36
+ end
37
+
31
38
  private
32
39
 
33
40
  def multiplier(multiplier_string)
@@ -34,10 +34,10 @@ module HeadMusic::Notation::ABC
34
34
 
35
35
  attr_reader :header, :duration_resolver
36
36
 
37
- # A note whose placement is deferred until we know whether a
38
- # broken-rhythm mark follows it. The pitch is computed eagerly so
39
- # bar-line accidental resets cannot corrupt it.
40
- PendingNote = Data.define(:pitch, :length, :scale)
37
+ # A note or chord whose placement is deferred until we know whether
38
+ # a broken-rhythm mark follows it. The pitches are computed eagerly
39
+ # so bar-line accidental resets cannot corrupt them.
40
+ PendingNote = Data.define(:pitches, :length, :scale)
41
41
 
42
42
  # Per-voice interpretation state. Accidentals, the deferred note,
43
43
  # and volta tracking are all independent between voices.
@@ -146,6 +146,7 @@ module HeadMusic::Notation::ABC
146
146
  def handle(token)
147
147
  case token.type
148
148
  when :note then handle_note(token)
149
+ when :chord then handle_chord(token)
149
150
  when :rest then handle_rest(token)
150
151
  when :broken_rhythm then handle_broken_rhythm(token)
151
152
  when :bar_line then handle_bar_line(token)
@@ -157,10 +158,57 @@ module HeadMusic::Notation::ABC
157
158
  def handle_note(token)
158
159
  state = current_state
159
160
  pitch = state.pitch_builder.pitch(token.letter, token.octave_marks, token.accidental)
160
- scale = state.awaiting_scale || Rational(1)
161
+ defer_placement(state, [pitch], token.length)
162
+ end
163
+
164
+ # Chord pitches resolve in bracket order, so an explicit accidental
165
+ # inside a chord persists for the rest of the bar like any other.
166
+ def handle_chord(token)
167
+ state = current_state
168
+ pitches = token.notes.map do |note|
169
+ state.pitch_builder.pitch(note.letter, note.octave_marks, note.accidental)
170
+ end
171
+ ensure_unique_chord_pitches(pitches, token)
172
+ inner_length = uniform_chord_length(token)
173
+ defer_placement(state, pitches, token.length, inner_length)
174
+ end
175
+
176
+ def ensure_unique_chord_pitches(pitches, token)
177
+ return if pitches.uniq.length == pitches.length
178
+
179
+ raise ParseError.new(
180
+ "Chord pitches must be unique",
181
+ line_number: token.line, snippet: chord_snippet(token)
182
+ )
183
+ end
184
+
185
+ # ABC 2.1 sec. 4.17 allows per-note lengths only when they agree; the
186
+ # shared inner length then multiplies with any outer length. Unequal
187
+ # lengths (whose ABC meaning is "the duration of the first note") would
188
+ # need silent reinterpretation to fit one rhythmic value, so we reject.
189
+ def uniform_chord_length(token)
190
+ fractions = token.notes.map { |note| duration_resolver.length_fraction(note.length) }
191
+ return fractions.first if fractions.uniq.length == 1
192
+
193
+ raise ParseError.new(
194
+ 'Chord notes must share one length; write it after the bracket ("[CEG]2") ' \
195
+ 'or repeat it on every note ("[C2E2G2]")',
196
+ line_number: token.line, snippet: chord_snippet(token)
197
+ )
198
+ end
199
+
200
+ def chord_snippet(token)
201
+ inner = token.notes.map do |note|
202
+ "#{note.accidental}#{note.letter}#{note.octave_marks}#{note.length}"
203
+ end.join
204
+ "[#{inner}]"
205
+ end
206
+
207
+ def defer_placement(state, pitches, length, inner_scale = Rational(1))
208
+ scale = (state.awaiting_scale || Rational(1)) * inner_scale
161
209
  state.awaiting_scale = nil
162
210
  flush_pending_note(state)
163
- state.pending_note = PendingNote.new(pitch: pitch, length: token.length, scale: scale)
211
+ state.pending_note = PendingNote.new(pitches: pitches, length: length, scale: scale)
164
212
  end
165
213
 
166
214
  def handle_rest(token)
@@ -236,12 +284,12 @@ module HeadMusic::Notation::ABC
236
284
  return unless pending
237
285
 
238
286
  state.pending_note = nil
239
- place(state, pending.length, pending.pitch, scale: pending.scale)
287
+ place(state, pending.length, pending.pitches, scale: pending.scale)
240
288
  end
241
289
 
242
- def place(state, length, pitch, scale: Rational(1))
290
+ def place(state, length, pitches, scale: Rational(1))
243
291
  rhythmic_value = duration_resolver.rhythmic_value(length, scale: scale)
244
- state.voice.place(state.voice.next_position, rhythmic_value, pitch)
292
+ state.voice.place(state.voice.next_position, rhythmic_value, pitches)
245
293
  end
246
294
 
247
295
  def apply_repeat_flags(state, style)
@@ -120,10 +120,29 @@ module HeadMusic::Notation::ABC
120
120
  end
121
121
 
122
122
  def token(placement, pitch_writer, duration_writer)
123
+ ensure_pitched_sounds(placement)
124
+
123
125
  multiplier = duration_writer.multiplier_string(placement.rhythmic_value)
124
126
  return "z#{multiplier}" if placement.rest?
127
+ return chord_token(placement, pitch_writer, multiplier) if placement.chord?
125
128
 
126
129
  "#{pitch_writer.token(placement.pitch)}#{multiplier}"
127
130
  end
131
+
132
+ def chord_token(placement, pitch_writer, multiplier)
133
+ # Pitches are emitted low-to-high, and the oracle sees them in that
134
+ # same order, so the writer's bar-accidental state cannot diverge from
135
+ # what a re-parse of the emitted brackets accumulates.
136
+ pitch_tokens = placement.pitches.sort.map { |pitch| pitch_writer.token(pitch) }
137
+ "[#{pitch_tokens.join}]#{multiplier}"
138
+ end
139
+
140
+ def ensure_pitched_sounds(placement)
141
+ unpitched = placement.sounds.find { |sound| !sound.pitched? }
142
+ return unless unpitched
143
+
144
+ raise RenderError, "cannot render unpitched sound \"#{unpitched}\" at #{placement.position}: " \
145
+ "percussion rendering is not yet supported"
146
+ end
128
147
  end
129
148
  end
@@ -311,15 +311,26 @@ module HeadMusic::Notation::MusicXML
311
311
  end
312
312
 
313
313
  def note_lines(placement)
314
+ ensure_pitched_sounds(placement)
315
+ raise RenderError, "chords are not yet supported by the MusicXML writer" if placement.chord?
316
+
314
317
  components_by_placement[placement].flat_map do |component|
315
318
  component_lines(placement, component)
316
319
  end
317
320
  end
318
321
 
322
+ def ensure_pitched_sounds(placement)
323
+ unpitched = placement.sounds.find { |sound| !sound.pitched? }
324
+ return unless unpitched
325
+
326
+ raise RenderError, "cannot render unpitched sound \"#{unpitched}\" at #{placement.position}: " \
327
+ "percussion rendering is not yet supported"
328
+ end
329
+
319
330
  def component_lines(placement, component)
320
331
  [
321
332
  "#{INDENT * 3}<note>",
322
- *(placement.note? ? pitch_lines(placement.pitch) : ["#{INDENT * 4}<rest/>"]),
333
+ *(placement.pitched? ? pitch_lines(placement.pitch) : ["#{INDENT * 4}<rest/>"]),
323
334
  "#{INDENT * 4}<duration>#{component.duration}</duration>",
324
335
  *tie_lines(placement, component),
325
336
  "#{INDENT * 4}<type>#{component.type}</type>",
@@ -56,6 +56,8 @@ class HeadMusic::Rudiment::Pitch < HeadMusic::Rudiment::Base
56
56
  end
57
57
 
58
58
  def self.from_number(number)
59
+ return nil unless number.respond_to?(:to_i)
60
+
59
61
  number_int = number.to_i
60
62
  return nil unless number == number_int
61
63
 
@@ -108,6 +110,10 @@ class HeadMusic::Rudiment::Pitch < HeadMusic::Rudiment::Base
108
110
  name
109
111
  end
110
112
 
113
+ def pitched?
114
+ true
115
+ end
116
+
111
117
  def to_i
112
118
  midi_note_number
113
119
  end
@@ -3,6 +3,7 @@ module HeadMusic::Rudiment; end
3
3
 
4
4
  # An UnpitchedNote represents a percussion note with rhythm but no specific pitch.
5
5
  # It inherits from RhythmicElement and can optionally have an instrument name.
6
+ # Intended future composition: UnpitchedNote ≈ UnpitchedSound + rhythmic value.
6
7
  class HeadMusic::Rudiment::UnpitchedNote < HeadMusic::Rudiment::RhythmicElement
7
8
  include HeadMusic::Named
8
9