head_music 17.0.0 → 17.1.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 +6 -0
- data/Gemfile.lock +1 -1
- data/lib/head_music/notation/music_xml/writer.rb +16 -4
- data/lib/head_music/version.rb +1 -1
- data/user-stories/done/musicxml-chord-rendering.md +191 -0
- data/user-stories/index.html +14 -14
- metadata +2 -2
- data/user-stories/backlog/musicxml-chord-rendering.md +0 -57
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ccf6929bc722709c08c2c8dc59589b86dce287ee2d698c087e75aa785140e75a
|
|
4
|
+
data.tar.gz: 3eb787b72cbb4a298d35cb6006aeb6901cda74b407c2a62ce9e6e98e1ea949b5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 5d78981006e4f26848e304e1413d5b317410142aa361229dbca4703799d144cf196ee3dbec9139a9e35e1b3d77186e0194abee001251f59db8c7e5f4e85e0eff
|
|
7
|
+
data.tar.gz: 60a2308fd83f8123437d027a7b86aa543767b7b7b9595d08036639cabbf13557cfb57f5f51de7a0b68c1c91742ad29ffb780b4a79162f02becbf610529b1eea7
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [17.1.0] - 2026-07-18
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
- The MusicXML writer renders chord placements as stacked notes rather than raising `RenderError`: a chord emits one `<note>` per pitched sound, ordered low to high, with `<chord/>` on all but the lowest note, all sharing the placement's rhythmic value. Tied chords emit a full chord stack per tie-link. The writer still raises `RenderError` for placements containing unpitched sounds (percussion rendering lands in a future release).
|
|
15
|
+
|
|
10
16
|
## [17.0.0] - 2026-07-18
|
|
11
17
|
|
|
12
18
|
### Added
|
data/Gemfile.lock
CHANGED
|
@@ -312,13 +312,21 @@ module HeadMusic::Notation::MusicXML
|
|
|
312
312
|
|
|
313
313
|
def note_lines(placement)
|
|
314
314
|
ensure_pitched_sounds(placement)
|
|
315
|
-
raise RenderError, "chords are not yet supported by the MusicXML writer" if placement.chord?
|
|
316
315
|
|
|
317
316
|
components_by_placement[placement].flat_map do |component|
|
|
318
|
-
|
|
317
|
+
note_slots(placement).each_with_index.flat_map do |pitch, index|
|
|
318
|
+
note_element_lines(placement, component, pitch: pitch, chord: index.positive?)
|
|
319
|
+
end
|
|
319
320
|
end
|
|
320
321
|
end
|
|
321
322
|
|
|
323
|
+
# A rest emits one empty slot; a sounded placement emits its pitches low to
|
|
324
|
+
# high, so the lowest note leads and the rest carry <chord/>. ensure_pitched_sounds
|
|
325
|
+
# has already rejected any unpitched sound, so pitches covers every sound here.
|
|
326
|
+
def note_slots(placement)
|
|
327
|
+
placement.rest? ? [nil] : placement.pitches.sort
|
|
328
|
+
end
|
|
329
|
+
|
|
322
330
|
def ensure_pitched_sounds(placement)
|
|
323
331
|
unpitched = placement.sounds.find { |sound| !sound.pitched? }
|
|
324
332
|
return unless unpitched
|
|
@@ -327,10 +335,14 @@ module HeadMusic::Notation::MusicXML
|
|
|
327
335
|
"percussion rendering is not yet supported"
|
|
328
336
|
end
|
|
329
337
|
|
|
330
|
-
|
|
338
|
+
# A chord note carries <chord/> as its first child, before <pitch>, marking
|
|
339
|
+
# it as sounding with the preceding note; the lead note (and every single
|
|
340
|
+
# note and rest) omits it, so this path stays byte-identical for those.
|
|
341
|
+
def note_element_lines(placement, component, pitch: nil, chord: false)
|
|
331
342
|
[
|
|
332
343
|
"#{INDENT * 3}<note>",
|
|
333
|
-
*(
|
|
344
|
+
*(chord ? ["#{INDENT * 4}<chord/>"] : []),
|
|
345
|
+
*(pitch ? pitch_lines(pitch) : ["#{INDENT * 4}<rest/>"]),
|
|
334
346
|
"#{INDENT * 4}<duration>#{component.duration}</duration>",
|
|
335
347
|
*tie_lines(placement, component),
|
|
336
348
|
"#{INDENT * 4}<type>#{component.type}</type>",
|
data/lib/head_music/version.rb
CHANGED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
metadata:
|
|
3
|
+
created_at: 2026-07-17T12:54:19-07:00
|
|
4
|
+
activated_at: 2026-07-18T20:22:35-07:00
|
|
5
|
+
planned_at: 2026-07-18T20:30:54-07:00
|
|
6
|
+
finished_at: 2026-07-18T20:44:44-07:00
|
|
7
|
+
updated_at: 2026-07-18T20:44:44-07:00
|
|
8
|
+
-->
|
|
9
|
+
|
|
10
|
+
# Story: MusicXML Chord Rendering
|
|
11
|
+
|
|
12
|
+
## Summary
|
|
13
|
+
|
|
14
|
+
AS a developer using HeadMusic
|
|
15
|
+
|
|
16
|
+
I WANT `Composition#to_musicxml` to render chord placements as stacked notes
|
|
17
|
+
|
|
18
|
+
SO THAT block chords display on one staff in MusicXML-consuming renderers (OSMD, MuseScore, etc.)
|
|
19
|
+
|
|
20
|
+
## Background
|
|
21
|
+
|
|
22
|
+
The content model represents a chord as a single `Placement` holding two or more pitched sounds (see the Chord Placement Model and Sound Model stories): one position, one rhythmic value, many sounds. `Placement#sounds` (a frozen array) is the source of truth — each sound is a `Rudiment::Pitch` or a `Rudiment::UnpitchedSound` — with `Placement#pitches` as the derived pitched subset and `chord?` true when there are two or more pitched sounds. Because a chord is one placement, `Voice#first_gap` contiguity works by construction — no special grouping is needed. The MusicXML writer, however, currently guards against chords: it raises when it encounters a placement where `chord?` is true, because it only knows how to emit the single derived `placement.pitch` (the highest pitch) and would otherwise silently render the top note alone. It never emits the `<chord/>` element, which is how MusicXML marks a note as sounding simultaneously with the previous note. (The writer's separate `RenderError` guard for placements containing unpitched sounds is out of scope here — it remains until a dedicated percussion-rendering story.)
|
|
23
|
+
|
|
24
|
+
This story is a prerequisite for BardTheory's staff-notation-view story, which renders compositions via `to_musicxml` → OSMD and requires block chords on the treble staff. The complementary input half is [ABC Chord Input](abc-chord-input.md) — that story gets chords *into* the model; this one gets them *out* to notation.
|
|
25
|
+
|
|
26
|
+
## Scope
|
|
27
|
+
|
|
28
|
+
- Replace the writer's chord guard with real rendering: a chord placement emits one `<note>` element per *pitched* sound, first note plain, each subsequent note carrying `<chord/>`, per the MusicXML 4.0 convention. All notes share the placement's rhythmic value.
|
|
29
|
+
- Emit chord notes in a deterministic order (low to high) regardless of the insertion order of the placement's `sounds`.
|
|
30
|
+
- Leave the writer's `RenderError` guard for unpitched sounds in place — any placement containing an unpitched sound still raises until a percussion-rendering story lands.
|
|
31
|
+
- Chords spanning voices are *not* merged — each voice remains its own part; only the pitched sounds of a single placement form a chord.
|
|
32
|
+
|
|
33
|
+
## Example
|
|
34
|
+
|
|
35
|
+
```ruby
|
|
36
|
+
composition = HeadMusic::Content::Composition.new(name: "Chorale", key_signature: "C major", meter: "4/4")
|
|
37
|
+
voice = composition.add_voice(role: "Treble")
|
|
38
|
+
voice.place("1:1", :half, %w[C4 E4 G4])
|
|
39
|
+
voice.place("1:3", :half, %w[D4 F4 A4])
|
|
40
|
+
|
|
41
|
+
xml = composition.to_musicxml
|
|
42
|
+
# E4 and G4 <note> elements at beat 1 each contain <chord/>; OSMD renders two stacked triads
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Acceptance Criteria
|
|
46
|
+
|
|
47
|
+
- A chord placement emits one `<note>` per pitched sound as a MusicXML chord (`<chord/>` on all but the first note), replacing the writer's raise-on-chord guard
|
|
48
|
+
- A composition mixing chords and single notes renders with correct measure durations
|
|
49
|
+
- Chord notes emit low to high
|
|
50
|
+
- The writer's `RenderError` guard for unpitched sounds is unchanged
|
|
51
|
+
- Existing single-line compositions render byte-identically to before (no regression)
|
|
52
|
+
- Output validates against the MusicXML 4.0 schema (matching the writer's existing spec approach)
|
|
53
|
+
- Rubocop and all specs pass
|
|
54
|
+
|
|
55
|
+
## Implementation Plan
|
|
56
|
+
|
|
57
|
+
### Overview
|
|
58
|
+
|
|
59
|
+
Replace the writer's raise-on-chord guard with a single parametrized note-builder that emits one `<note>` per pitched sound of a placement — the lowest note plain, each higher note carrying `<chord/>` — sorted low→high, sharing the placement's rhythmic value. The single-note and rest paths collapse to the same builder so existing output stays byte-identical. The change is confined to `lib/head_music/notation/music_xml/writer.rb` and its spec.
|
|
60
|
+
|
|
61
|
+
### Steps
|
|
62
|
+
|
|
63
|
+
1. **Remove the chord guard and restructure `note_lines`** — `lib/head_music/notation/music_xml/writer.rb:313`
|
|
64
|
+
- Delete the `raise RenderError ... "chords are not yet supported"` line (`:315`). Keep `ensure_pitched_sounds` first — it still raises for any unpitched sound, so every non-rest placement reaching the builder is fully pitched (this is what makes the unpitched-sound guard AC hold unchanged).
|
|
65
|
+
- Nest the loops **components outer, pitches inner** so each tied rhythmic segment emits a complete, contiguous chord block. This ordering is load-bearing: pitches-outer would break `<chord/>` adjacency and produce invalid MusicXML.
|
|
66
|
+
|
|
67
|
+
```ruby
|
|
68
|
+
def note_lines(placement)
|
|
69
|
+
ensure_pitched_sounds(placement)
|
|
70
|
+
|
|
71
|
+
components_by_placement[placement].flat_map do |component|
|
|
72
|
+
note_slots(placement).each_with_index.flat_map do |pitch, index|
|
|
73
|
+
note_element_lines(placement, component, pitch: pitch, chord: index.positive?)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# ensure_pitched_sounds has rejected any unpitched sound, so a sounded
|
|
79
|
+
# placement's pitches are all its sounds; a rest emits one empty slot.
|
|
80
|
+
def note_slots(placement)
|
|
81
|
+
placement.rest? ? [nil] : placement.pitches.sort
|
|
82
|
+
end
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
- `placement.pitches.sort` yields low→high (Pitch is `Comparable`); this is the explicit sort the ordering AC requires — `pitches` is otherwise insertion-ordered. `index.positive?` puts `<chord/>` on all but the lowest note. For a single-pitch placement this is a one-element array at index 0 (`chord: false`), and `pitches.sort.first == placement.pitch` for length 1, preserving today's output exactly.
|
|
86
|
+
|
|
87
|
+
2. **Parametrize the note builder (rename `component_lines` → `note_element_lines`)** — `writer.rb:330`
|
|
88
|
+
- One builder serves single note, rest, and chord — no second code path to drift.
|
|
89
|
+
|
|
90
|
+
```ruby
|
|
91
|
+
def note_element_lines(placement, component, pitch: nil, chord: false)
|
|
92
|
+
[
|
|
93
|
+
"#{INDENT * 3}<note>",
|
|
94
|
+
*(chord ? ["#{INDENT * 4}<chord/>"] : []),
|
|
95
|
+
*(pitch ? pitch_lines(pitch) : ["#{INDENT * 4}<rest/>"]),
|
|
96
|
+
"#{INDENT * 4}<duration>#{component.duration}</duration>",
|
|
97
|
+
*tie_lines(placement, component),
|
|
98
|
+
"#{INDENT * 4}<type>#{component.type}</type>",
|
|
99
|
+
*Array.new(component.dots) { "#{INDENT * 4}<dot/>" },
|
|
100
|
+
*notation_lines(placement, component),
|
|
101
|
+
"#{INDENT * 3}</note>"
|
|
102
|
+
]
|
|
103
|
+
end
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
- Byte-identical guarantees: `<chord/>` sits at `INDENT * 4`, first child after `<note>` and **before** `<pitch>` (MusicXML schema requires this order — the one correctness detail that must be right); the `chord ? [...] : []` splat contributes zero lines when false. The rest branch now keys off the passed `pitch:` arg (nil → `<rest/>`) instead of `placement.pitched?`; equivalent because `note_slots` only yields nil for a rest.
|
|
107
|
+
- `tie_lines`/`notation_lines` are unchanged and already per-component + rest-guarded, so each note in a chord (and each tied link) carries its own correctly-matched tie — no chord-specific tie work needed.
|
|
108
|
+
|
|
109
|
+
3. **Rewrite the two chord specs; add coverage** — `spec/head_music/notation/music_xml/writer_spec.rb`
|
|
110
|
+
- **Flip** the two "raises the chord render error" contexts (`:465-479` three-pitch, `:481-495` two-pitch) from raise-assertions to rendering assertions — they currently pin the removed behavior and will otherwise fail.
|
|
111
|
+
- See Testing Strategy for the exact cases.
|
|
112
|
+
|
|
113
|
+
4. **Run the checks**
|
|
114
|
+
|
|
115
|
+
```
|
|
116
|
+
bundle exec rspec spec/head_music/notation/music_xml/writer_spec.rb
|
|
117
|
+
bundle exec rubocop -a
|
|
118
|
+
bundle exec rake
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
### Design & Notation-Output Considerations
|
|
122
|
+
|
|
123
|
+
- **`<chord/>` before `<pitch>`, per subsequent note** is the single highest-risk detail — both OSMD and MuseScore mis-parse a `<chord/>` that follows `<pitch>`. The builder above places it correctly.
|
|
124
|
+
- **Iterate `placement.pitches`, never reuse `placement.pitch`** (which is `pitches.max`, the top note only). Reusing it would print the top pitch on every chord note — the most likely refactor bug.
|
|
125
|
+
- **Low→high order is cosmetic to renderers** (both sort internally) but is the right choice: deterministic, diff-stable, and the conventional bass-up reading order. It must come from the explicit `.sort`, not insertion order.
|
|
126
|
+
- **`<stem>`/`<voice>`/`<staff>` omission is fine for v1** single-voice, single-staff block chords, consistent with how single notes already render (the writer emits none today). Consumers default absent voice/staff to 1, and `<chord/>` alone binds the stack. The boundary where `<voice>` stops being optional is multi-voice-per-staff — out of scope, tracked below.
|
|
127
|
+
- **Clusters/seconds (C4+D4) and shared-letter accidentals (C4+C#4) are the renderer's job** — each note carries its own `<alter>`; OSMD/MuseScore handle notehead offset and accidental columns automatically. No writer work for v1.
|
|
128
|
+
|
|
129
|
+
### Accessibility / Semantic Fidelity
|
|
130
|
+
|
|
131
|
+
Traditional WCAG/keyboard/ARIA do not apply (this emits a string, not a UI). The surface is MusicXML semantic fidelity for Braille-music and MusicXML-reading assistive tools:
|
|
132
|
+
|
|
133
|
+
- **`<chord/>` on notes sharing a rhythmic value IS the canonical encoding** these tools recognize — a lead `<note>` followed by `<chord/>` siblings with no intervening `<backup>`/`<forward>`. No richer encoding needed.
|
|
134
|
+
- **Low→high ordering gives a predictable reading order**, matching Braille music's bass-relative interval convention. Reinforces the explicit-sort requirement.
|
|
135
|
+
- **No semantic loss**: each tone keeps full absolute `<pitch>` (step/alter/octave); sharing one `<duration>`/`<type>` is spec-required, not a loss.
|
|
136
|
+
|
|
137
|
+
### Testing Strategy
|
|
138
|
+
|
|
139
|
+
"Validates against the MusicXML 4.0 schema" is satisfied here by the repo's actual approach — there is **no** XSD/DTD tooling; do not add a Nokogiri XSD dependency. Verification is: REXML well-formedness via `parse_musicxml` (`spec/support/music_xml_helpers.rb`), XPath structural assertions, and the byte-for-byte golden-document test. (See the AC note: treat "schema validation" as these concrete facts.)
|
|
140
|
+
|
|
141
|
+
In `spec/head_music/notation/music_xml/writer_spec.rb`, using the existing helpers (`parse_musicxml`, `xpath_count`, `xpath_texts`):
|
|
142
|
+
|
|
143
|
+
- **Three-pitch chord** `voice.place("1:1", :half, %w[C4 E4 G4])`: `//note` count == 3 for the measure; `xpath_count(document, "//note/chord")` == 2; the lowest note (C4) has no `<chord/>`; all three share `<duration>`.
|
|
144
|
+
- **Insertion-order independence**: place `%w[G4 C4 E4]` and assert `xpath_texts(document, "//note/pitch/step")` == `%w[C E G]` (low→high regardless of input order).
|
|
145
|
+
- **`<chord/>` element ordering** (child-order, invisible to count-only XPath): assert against the rendered `to_s` that `<chord/>` precedes `<pitch>` — cleanest via a **golden-document fixture** with a chord measure (extend the existing golden test at `:173`, the only mechanism in the suite that catches child-ordering regressions), or a targeted string `include`.
|
|
146
|
+
- **Two-pitch boundary**: rewrite the `:481` context to assert emission, pinning `chord?`-true-at-exactly-2.
|
|
147
|
+
- **Mixed chords + single notes measure**: `place("1:1", :half, %w[C4 E4 G4]); place("1:3", :half, "D5")` — assert per-note durations and correct note count (measure-duration AC; confirms only the lead note advances the cursor).
|
|
148
|
+
- **Tied chord** (if kept in v1 — see Open Questions): a chord with a tied rhythmic value → each tie-link emits a full chord stack with `<chord/>` on the upper notes and ties on every note.
|
|
149
|
+
- **Keep green** the unpitched (`:497`) and mixed pitched+unpitched (`:513`) percussion-raise contexts, and the single-line regression contexts (`SPEED_THE_PLOUGH` at `:22`, the golden document at `:173`) — these are the no-regression / byte-identical guard.
|
|
150
|
+
|
|
151
|
+
### Risks & Open Questions
|
|
152
|
+
|
|
153
|
+
- **Tied-chord handling in v1 — recommended IN, needs sign-off.** A chord with a tied rhythmic value is reachable through the public `Voice#place` API (`components_by_placement` expands it into multiple tie-link components). The components-outer/pitches-inner nesting handles it correctly with **zero extra code**, and every pitch legitimately shares the link's tie flags. Recommendation: ship the natural handling. The alternative is an explicit `RenderError` guarded by `components_by_placement[placement].length > 1 && placement.chord?` plus a deferral note — but do **not** leave it to silently drop ties. This plan assumes full tied-chord rendering.
|
|
154
|
+
- **Byte-identical regression** is the primary technical risk; mitigated by the empty-splat for `chord: false` and by `pitches.sort.first == placement.pitch` for length-1 placements. The existing exact-structure and golden-document specs catch any drift.
|
|
155
|
+
- **Duplicate/enharmonic pitches**: `Placement` already dedups exact duplicates via `sounds.uniq`, so an exact unison cannot reach the writer — no writer-side dedup needed. Two distinct spellings at the same height (C#4 + Db4) are a legitimate diminished-second cluster and correctly emit as two notes; order between them is undefined but harmless.
|
|
156
|
+
- **Multi-voice-per-staff** is the concrete future trigger where omitting `<voice>`/`<staff>` stops being acceptable. Out of scope for v1; track for the multi-voice story.
|
|
157
|
+
|
|
158
|
+
## Review
|
|
159
|
+
|
|
160
|
+
_Reviewed 2026-07-18 at commit `0f1f090` (product-manager + code-reviewer agents). Full suite: 5842 examples, 0 failures, 99.76% line coverage; rubocop 0 offenses._
|
|
161
|
+
|
|
162
|
+
### Acceptance criteria
|
|
163
|
+
|
|
164
|
+
- ✅ **Chord emits one `<note>` per pitched sound, `<chord/>` on all but the first, guard removed** — `writer.rb` `note_lines`/`note_element_lines`; specs assert 3 notes, 2 `<chord/>`, none on `note[1]`. No raise-on-chord path remains.
|
|
165
|
+
- ✅ **Mixing chords and single notes → correct measure durations** — "measure mixing a chord and a single note" spec: steps `C E G D`, 2 `<chord/>`, durations `2 2 2 2`. Only the lead C4 and D5 advance the cursor → full 4/4 bar.
|
|
166
|
+
- ✅ **Chord notes emit low to high** — `note_slots` returns `placement.pitches.sort`; the "placed high to low" spec (`%w[G4 C4 E4]` → `%w[C E G]`) proves the sort, not coincidental input order.
|
|
167
|
+
- ✅ **Unpitched-sound guard unchanged** — `ensure_pitched_sounds` runs first and raises on the first non-pitched sound; the mixed pitched+unpitched spec confirms it still fires with the same message.
|
|
168
|
+
- ✅ **Single-line output byte-identical** — single note → one slot, `chord: false`, `<chord/>` line omitted; the golden-document test pins the full single-line document byte-for-byte.
|
|
169
|
+
- ✅ **Validates against MusicXML 4.0 schema (repo's approach)** — every chord spec parses via REXML + XPath; the golden-string "exact note markup" spec pins `<chord/>`-before-`<pitch>` child order (which count-based XPath cannot see). Caveat, story-acknowledged: no XSD/DTD validator exists in the repo; "schema-valid" is structural, not tool-verified.
|
|
170
|
+
- ✅ **Rubocop and all specs pass** — verified; tied-chord case also covered (6 notes / 4 `<chord/>`, 3 tie-starts / 3 tie-stops).
|
|
171
|
+
|
|
172
|
+
### Code review findings
|
|
173
|
+
|
|
174
|
+
**Verdict: clean — no blocking or should-fix issues.** Correctness verified on `<chord/>` placement/order, low→high sort, byte-identical single-note/rest paths, tied-chord loop nesting (index resets per tie-link), and guard interaction (no silent empty-slot path). Both new comments explain "why" and earn their place.
|
|
175
|
+
|
|
176
|
+
Nits (non-blocking, no action taken):
|
|
177
|
+
|
|
178
|
+
- `note_slots` re-sorts pitches once per tie-link (N times for an N-link tied chord). Negligible; hoisting would churn the diff.
|
|
179
|
+
- Enharmonic/duplicate pitches at the same height (C4 + B♯3) render as two chord notes — arguably correct MusicXML, untested. Add a spec only if the domain cares.
|
|
180
|
+
- Minor coverage: "correct measure durations" is proven indirectly (duration list + `<chord/>` count) rather than by an explicit sum-to-bar-length assertion. Low risk.
|
|
181
|
+
- `note_slots` naming is slightly abstract; the comment carries the meaning.
|
|
182
|
+
|
|
183
|
+
**Nothing blocks `finish`.**
|
|
184
|
+
|
|
185
|
+
## Learnings
|
|
186
|
+
|
|
187
|
+
- **Planning earned its keep on two judgment calls.** It surfaced the tied-chord decision as an explicit sign-off point rather than a silent implementation choice, and it reconciled the "validates against the MusicXML 4.0 schema" AC against the repo's *actual* verification approach (REXML well-formedness + XPath + a byte-for-byte golden document). That stopped us from adding an unneeded XSD/Nokogiri dependency to satisfy a criterion the existing tooling already covers.
|
|
188
|
+
- **The one surprise was a stale spec outside the writer's own file.** `composition_serialization_spec.rb` still pinned the old raise-on-chord behavior. The single-file spec run was green; only the full `bundle exec rake` suite caught it. Takeaway: when removing a guard or changing an error contract, grep the whole codebase for tests asserting the old behavior before assuming the change is done — don't trust a scoped run.
|
|
189
|
+
- **Tied chords cost zero extra code.** The components-outer / pitches-inner loop nesting composed cleanly with the model's existing expansion of a tied rhythmic value into per-link components; each link just emits a fresh full chord stack. Shipping the natural handling (rather than a guard-and-defer) was the low-risk call.
|
|
190
|
+
- **Child order is invisible to count-based XPath.** Asserting `<chord/>` precedes `<pitch>` needed a golden-string test; the structural XPath count/text assertions could confirm *how many* chord elements existed but never *where* they sat in the note. Reach for a string/golden assertion whenever element ordering is the property under test.
|
|
191
|
+
- **Byte-identical regression was the real risk, and it was cheap to neutralize.** The empty-when-false splat for the `<chord/>` line plus `pitches.sort.first == placement.pitch` for length-1 placements kept single-note output identical, and the pre-existing golden document guarded it — no new machinery required.
|
data/user-stories/index.html
CHANGED
|
@@ -254,10 +254,6 @@
|
|
|
254
254
|
// one-liner preserved across regenerations (see /stories board).
|
|
255
255
|
const stories = {
|
|
256
256
|
"backlog": [
|
|
257
|
-
{
|
|
258
|
-
"title": "MusicXML Chord Rendering",
|
|
259
|
-
"path": "backlog/musicxml-chord-rendering.md"
|
|
260
|
-
},
|
|
261
257
|
{
|
|
262
258
|
"title": "LilyPond Export",
|
|
263
259
|
"path": "backlog/lilypond-export.md"
|
|
@@ -266,10 +262,6 @@
|
|
|
266
262
|
"title": "LilyPond Interpreter",
|
|
267
263
|
"path": "backlog/lilypond-interpreter.md"
|
|
268
264
|
},
|
|
269
|
-
{
|
|
270
|
-
"title": "Organizing Content",
|
|
271
|
-
"path": "backlog/organizing-content.md"
|
|
272
|
-
},
|
|
273
265
|
{
|
|
274
266
|
"title": "Sixteenth-Century (Renaissance) Style Guides",
|
|
275
267
|
"path": "backlog/sixteenth-century-style.md"
|
|
@@ -277,10 +269,18 @@
|
|
|
277
269
|
{
|
|
278
270
|
"title": "Split Counterpoint Species Guides by Pedagogical Author",
|
|
279
271
|
"path": "backlog/split-counterpoint-species-by-author.md"
|
|
272
|
+
},
|
|
273
|
+
{
|
|
274
|
+
"title": "Untitled",
|
|
275
|
+
"path": "backlog/organizing-content.md"
|
|
280
276
|
}
|
|
281
277
|
],
|
|
282
278
|
"active": [],
|
|
283
279
|
"done": [
|
|
280
|
+
{
|
|
281
|
+
"title": "MusicXML Chord Rendering",
|
|
282
|
+
"path": "done/musicxml-chord-rendering.md"
|
|
283
|
+
},
|
|
284
284
|
{
|
|
285
285
|
"title": "ABC Chord Input",
|
|
286
286
|
"path": "done/abc-chord-input.md"
|
|
@@ -390,16 +390,16 @@
|
|
|
390
390
|
"path": "done/sonority-identification.md"
|
|
391
391
|
},
|
|
392
392
|
{
|
|
393
|
-
"title": "
|
|
394
|
-
"path": "done/
|
|
393
|
+
"title": "Untitled",
|
|
394
|
+
"path": "done/superclass-for-note.md"
|
|
395
395
|
},
|
|
396
396
|
{
|
|
397
|
-
"title": "
|
|
398
|
-
"path": "done/
|
|
397
|
+
"title": "Untitled",
|
|
398
|
+
"path": "done/expand-playing-techniques.md"
|
|
399
399
|
},
|
|
400
400
|
{
|
|
401
|
-
"title": "
|
|
402
|
-
"path": "done/
|
|
401
|
+
"title": "Untitled",
|
|
402
|
+
"path": "done/instrument-variant.md"
|
|
403
403
|
}
|
|
404
404
|
]
|
|
405
405
|
};
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: head_music
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 17.
|
|
4
|
+
version: 17.1.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Rob Head
|
|
@@ -417,7 +417,6 @@ files:
|
|
|
417
417
|
- user-stories/_template.md
|
|
418
418
|
- user-stories/backlog/lilypond-export.md
|
|
419
419
|
- user-stories/backlog/lilypond-interpreter.md
|
|
420
|
-
- user-stories/backlog/musicxml-chord-rendering.md
|
|
421
420
|
- user-stories/backlog/organizing-content.md
|
|
422
421
|
- user-stories/backlog/sixteenth-century-style.md
|
|
423
422
|
- user-stories/backlog/split-counterpoint-species-by-author.md
|
|
@@ -443,6 +442,7 @@ files:
|
|
|
443
442
|
- user-stories/done/move-staff-mapping-to-notation.md
|
|
444
443
|
- user-stories/done/move-staff-position-to-notation.md
|
|
445
444
|
- user-stories/done/music-xml-export.md
|
|
445
|
+
- user-stories/done/musicxml-chord-rendering.md
|
|
446
446
|
- user-stories/done/notation-module-foundation.md
|
|
447
447
|
- user-stories/done/notation-style.md
|
|
448
448
|
- user-stories/done/percussion_set.md
|
|
@@ -1,57 +0,0 @@
|
|
|
1
|
-
<!--
|
|
2
|
-
metadata:
|
|
3
|
-
created_at: 2026-07-17T12:54:19-07:00
|
|
4
|
-
activated_at:
|
|
5
|
-
planned_at:
|
|
6
|
-
finished_at:
|
|
7
|
-
updated_at: 2026-07-18T16:03:28-07:00
|
|
8
|
-
-->
|
|
9
|
-
|
|
10
|
-
# Story: MusicXML Chord Rendering
|
|
11
|
-
|
|
12
|
-
## Summary
|
|
13
|
-
|
|
14
|
-
AS a developer using HeadMusic
|
|
15
|
-
|
|
16
|
-
I WANT `Composition#to_musicxml` to render chord placements as stacked notes
|
|
17
|
-
|
|
18
|
-
SO THAT block chords display on one staff in MusicXML-consuming renderers (OSMD, MuseScore, etc.)
|
|
19
|
-
|
|
20
|
-
## Background
|
|
21
|
-
|
|
22
|
-
The content model represents a chord as a single `Placement` holding two or more pitched sounds (see the Chord Placement Model and Sound Model stories): one position, one rhythmic value, many sounds. `Placement#sounds` (a frozen array) is the source of truth — each sound is a `Rudiment::Pitch` or a `Rudiment::UnpitchedSound` — with `Placement#pitches` as the derived pitched subset and `chord?` true when there are two or more pitched sounds. Because a chord is one placement, `Voice#first_gap` contiguity works by construction — no special grouping is needed. The MusicXML writer, however, currently guards against chords: it raises when it encounters a placement where `chord?` is true, because it only knows how to emit the single derived `placement.pitch` (the highest pitch) and would otherwise silently render the top note alone. It never emits the `<chord/>` element, which is how MusicXML marks a note as sounding simultaneously with the previous note. (The writer's separate `RenderError` guard for placements containing unpitched sounds is out of scope here — it remains until a dedicated percussion-rendering story.)
|
|
23
|
-
|
|
24
|
-
This story is a prerequisite for BardTheory's staff-notation-view story, which renders compositions via `to_musicxml` → OSMD and requires block chords on the treble staff. The complementary input half is [ABC Chord Input](abc-chord-input.md) — that story gets chords *into* the model; this one gets them *out* to notation.
|
|
25
|
-
|
|
26
|
-
## Scope
|
|
27
|
-
|
|
28
|
-
- Replace the writer's chord guard with real rendering: a chord placement emits one `<note>` element per *pitched* sound, first note plain, each subsequent note carrying `<chord/>`, per the MusicXML 4.0 convention. All notes share the placement's rhythmic value.
|
|
29
|
-
- Emit chord notes in a deterministic order (low to high) regardless of the insertion order of the placement's `sounds`.
|
|
30
|
-
- Leave the writer's `RenderError` guard for unpitched sounds in place — any placement containing an unpitched sound still raises until a percussion-rendering story lands.
|
|
31
|
-
- Chords spanning voices are *not* merged — each voice remains its own part; only the pitched sounds of a single placement form a chord.
|
|
32
|
-
|
|
33
|
-
## Example
|
|
34
|
-
|
|
35
|
-
```ruby
|
|
36
|
-
composition = HeadMusic::Content::Composition.new(name: "Chorale", key_signature: "C major", meter: "4/4")
|
|
37
|
-
voice = composition.add_voice(role: "Treble")
|
|
38
|
-
voice.place("1:1", :half, %w[C4 E4 G4])
|
|
39
|
-
voice.place("1:3", :half, %w[D4 F4 A4])
|
|
40
|
-
|
|
41
|
-
xml = composition.to_musicxml
|
|
42
|
-
# E4 and G4 <note> elements at beat 1 each contain <chord/>; OSMD renders two stacked triads
|
|
43
|
-
```
|
|
44
|
-
|
|
45
|
-
## Acceptance Criteria
|
|
46
|
-
|
|
47
|
-
- A chord placement emits one `<note>` per pitched sound as a MusicXML chord (`<chord/>` on all but the first note), replacing the writer's raise-on-chord guard
|
|
48
|
-
- A composition mixing chords and single notes renders with correct measure durations
|
|
49
|
-
- Chord notes emit low to high
|
|
50
|
-
- The writer's `RenderError` guard for unpitched sounds is unchanged
|
|
51
|
-
- Existing single-line compositions render byte-identically to before (no regression)
|
|
52
|
-
- Output validates against the MusicXML 4.0 schema (matching the writer's existing spec approach)
|
|
53
|
-
- Rubocop and all specs pass
|
|
54
|
-
|
|
55
|
-
## Implementation Plan
|
|
56
|
-
|
|
57
|
-
[to be filled in by /stories plan]
|