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 +4 -4
- data/CHANGELOG.md +28 -0
- data/Gemfile.lock +1 -1
- data/lib/head_music/content/composition.rb +50 -9
- data/lib/head_music/content/placement.rb +107 -11
- data/lib/head_music/content/voice.rb +14 -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/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 +1 -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/sound-model.md +223 -0
- data/user-stories/index.html +24 -8
- metadata +7 -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,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
|
@@ -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 =
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
213
|
-
voice.place(position, rhythmic_value,
|
|
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
|
|
309
|
-
#
|
|
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, :
|
|
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,19 +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}"
|
|
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
|
-
"
|
|
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,
|
|
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
|
-
@
|
|
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,
|
|
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
|
|
@@ -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
|
-
|
|
157
|
-
|
|
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(
|
|
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
|
|
38
|
-
# broken-rhythm mark follows it. The
|
|
39
|
-
# bar-line accidental resets cannot corrupt
|
|
40
|
-
PendingNote = Data.define(:
|
|
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
|
-
|
|
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(
|
|
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.
|
|
287
|
+
place(state, pending.length, pending.pitches, scale: pending.scale)
|
|
240
288
|
end
|
|
241
289
|
|
|
242
|
-
def place(state, length,
|
|
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,
|
|
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.
|
|
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
|
|