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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 032e67bcfe59e2efb864f17ebce9fd7e5134c216c5274f96ef7f9cae1d45442a
4
- data.tar.gz: fa73331c0868598885695e1fffc53e76824f1498549f82ac68d2c98276a887fc
3
+ metadata.gz: ccf6929bc722709c08c2c8dc59589b86dce287ee2d698c087e75aa785140e75a
4
+ data.tar.gz: 3eb787b72cbb4a298d35cb6006aeb6901cda74b407c2a62ce9e6e98e1ea949b5
5
5
  SHA512:
6
- metadata.gz: 363dde634782a8bf79e53bd62ac376515611711d839deb4258088ec197dd6f3f5e199321c8d347c66b42756a7872511a4b680c022a1106fcb3c4b0ba88513a88
7
- data.tar.gz: 37eb0b6df32586566a21603549df3fbbdfa01858b1ec66da3af0c76d3a8a7d276cd939a987ba36990b359c6bebca20f713c137935d095f3ee2debb9130d29c8f
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
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- head_music (17.0.0)
4
+ head_music (17.1.0)
5
5
  activesupport (>= 7.0, < 10)
6
6
  humanize (~> 2.0)
7
7
  i18n (~> 1.8)
@@ -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
- component_lines(placement, component)
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
- def component_lines(placement, component)
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
- *(placement.pitched? ? pitch_lines(placement.pitch) : ["#{INDENT * 4}<rest/>"]),
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>",
@@ -1,3 +1,3 @@
1
1
  module HeadMusic
2
- VERSION = "17.0.0"
2
+ VERSION = "17.1.0"
3
3
  end
@@ -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.
@@ -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": "expand-playing-techniques",
394
- "path": "done/expand-playing-techniques.md"
393
+ "title": "Untitled",
394
+ "path": "done/superclass-for-note.md"
395
395
  },
396
396
  {
397
- "title": "instrument-variant",
398
- "path": "done/instrument-variant.md"
397
+ "title": "Untitled",
398
+ "path": "done/expand-playing-techniques.md"
399
399
  },
400
400
  {
401
- "title": "superclass-for-note",
402
- "path": "done/superclass-for-note.md"
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.0.0
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]