head_music 17.1.0 → 17.2.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: ccf6929bc722709c08c2c8dc59589b86dce287ee2d698c087e75aa785140e75a
4
- data.tar.gz: 3eb787b72cbb4a298d35cb6006aeb6901cda74b407c2a62ce9e6e98e1ea949b5
3
+ metadata.gz: afa3afecdb9f3baef8bc34bb645805cd7defbd206e388a2a156d1f4f80516656
4
+ data.tar.gz: b858239c696ef22b759d4a96f033588cf871b83254538c991c8fdff67565a179
5
5
  SHA512:
6
- metadata.gz: 5d78981006e4f26848e304e1413d5b317410142aa361229dbca4703799d144cf196ee3dbec9139a9e35e1b3d77186e0194abee001251f59db8c7e5f4e85e0eff
7
- data.tar.gz: 60a2308fd83f8123437d027a7b86aa543767b7b7b9595d08036639cabbf13557cfb57f5f51de7a0b68c1c91742ad29ffb780b4a79162f02becbf610529b1eea7
6
+ metadata.gz: b3c54b4663df784f5382fd992500df795cf69e985501596d592bf7ffe5c0a7887ecc81ad36e76cbebf9a0e30597607ef2db629c3e8946e128043f9f5aa012976
7
+ data.tar.gz: d5f6c002b3f4522dcdf2f3741ecacd933ea3bb2c33ee7a438ca8a649eb57b2e8d68053a6a8a0a616f17d1e3087b733dccf1d384a0ae6c27084854474c528deea
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.2.0] - 2026-07-19
11
+
12
+ ### Added
13
+
14
+ - The ABC interpreter reads explicit ties (`-`) between notes. `E3-E2` fuses into one sounding note whose rhythmic value carries the authored split (`dotted quarter tied to quarter`), overriding the resolver's greedy decomposition, and tie chains (`C2-C2-C2`) nest into a single value. A tie between notes of different pitches, a dangling tie, or a tie to a rest raises `ParseError`; a tie across a barline raises `ParseError` ("Ties across barlines are not yet supported") pending a future release.
15
+
10
16
  ## [17.1.0] - 2026-07-18
11
17
 
12
18
  ### Changed
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- head_music (17.1.0)
4
+ head_music (17.2.0)
5
5
  activesupport (>= 7.0, < 10)
6
6
  humanize (~> 2.0)
7
7
  i18n (~> 1.8)
@@ -88,7 +88,7 @@ GEM
88
88
  i18n (1.14.7)
89
89
  concurrent-ruby (~> 1.0)
90
90
  ice_nine (0.11.2)
91
- json (2.13.1)
91
+ json (2.21.1)
92
92
  kramdown (2.5.1)
93
93
  rexml (>= 3.3.9)
94
94
  language_server-protocol (3.17.0.5)
@@ -128,6 +128,7 @@ class HeadMusic::Notation::ABC::BodyLexer
128
128
  return if scan_bracket(scanner, line_number, column, tokens)
129
129
  return if scan_note(scanner, line_number, column, tokens)
130
130
  return if scan_rest(scanner, line_number, column, tokens)
131
+ return if scan_tie(scanner, line_number, column, tokens)
131
132
  return if scan_broken_rhythm(scanner, line_number, column, tokens)
132
133
  return if scan_unsupported(scanner, line_number, column, tokens)
133
134
 
@@ -277,13 +278,24 @@ class HeadMusic::Notation::ABC::BodyLexer
277
278
  true
278
279
  end
279
280
 
281
+ # A tie (a hyphen following a note or chord) joins it to the next
282
+ # note of the same pitch; the parser fuses the two into one sounding
283
+ # value. Ties inside a bracket chord still surface as unsupported via
284
+ # the chord fallback.
285
+ def scan_tie(scanner, line_number, column, tokens)
286
+ return false unless scanner.scan("-")
287
+
288
+ tokens << Token.new(type: :tie, line: line_number, column: column)
289
+ true
290
+ end
291
+
280
292
  # Recognizable ABC we deliberately don't handle: grace notes,
281
- # decorations, tuplets, slurs, ties, and special rests.
293
+ # decorations, tuplets, slurs, and special rests.
282
294
  def scan_unsupported(scanner, line_number, column, tokens)
283
295
  lexeme =
284
296
  scanner.scan(/\{[^}]*\}/) || scanner.scan(/\{[^}]*/) ||
285
297
  scanner.scan(/![^!]*!/) || scanner.scan(/![^!]*/) ||
286
- scanner.scan(/\(\d/) || scanner.scan(/[()\-~.]/) ||
298
+ scanner.scan(/\(\d/) || scanner.scan(/[()~.]/) ||
287
299
  scanner.scan(/Z\d*/) || scanner.scan(%r{x[\d/]*})
288
300
  return false unless lexeme
289
301
 
@@ -36,15 +36,21 @@ module HeadMusic::Notation::ABC
36
36
 
37
37
  # A note or chord whose placement is deferred until we know whether
38
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)
39
+ # so bar-line accidental resets cannot corrupt them. `tied_prefix`,
40
+ # when present, is the already-built rhythmic value of everything
41
+ # tied ahead of this note; its own value is appended at flush time.
42
+ PendingNote = Data.define(:pitches, :length, :scale, :tied_prefix) do
43
+ def initialize(pitches:, length:, scale:, tied_prefix: nil)
44
+ super
45
+ end
46
+ end
41
47
 
42
48
  # Per-voice interpretation state. Accidentals, the deferred note,
43
49
  # and volta tracking are all independent between voices.
44
50
  class VoiceState
45
51
  attr_reader :voice, :pitch_builder
46
52
  attr_accessor :pending_note, :awaiting_scale, :broken_line,
47
- :active_passes, :volta_start_bar
53
+ :active_passes, :volta_start_bar, :tie_open, :tie_line
48
54
 
49
55
  def initialize(voice, pitch_builder)
50
56
  @voice = voice
@@ -148,6 +154,7 @@ module HeadMusic::Notation::ABC
148
154
  when :note then handle_note(token)
149
155
  when :chord then handle_chord(token)
150
156
  when :rest then handle_rest(token)
157
+ when :tie then handle_tie(token)
151
158
  when :broken_rhythm then handle_broken_rhythm(token)
152
159
  when :bar_line then handle_bar_line(token)
153
160
  when :volta then handle_volta(token)
@@ -207,19 +214,69 @@ module HeadMusic::Notation::ABC
207
214
  def defer_placement(state, pitches, length, inner_scale = Rational(1))
208
215
  scale = (state.awaiting_scale || Rational(1)) * inner_scale
209
216
  state.awaiting_scale = nil
217
+ return tie_onto_pending(state, pitches, length, scale) if state.tie_open
218
+
210
219
  flush_pending_note(state)
211
220
  state.pending_note = PendingNote.new(pitches: pitches, length: length, scale: scale)
212
221
  end
213
222
 
223
+ # A tie (`-`) after a note or chord fuses it to the next note of the
224
+ # same pitch. The left note stays pending; the tie is only closed once
225
+ # its right note arrives.
226
+ def handle_tie(token)
227
+ state = current_state
228
+ if state.awaiting_scale || state.pending_note.nil?
229
+ raise ParseError.new(
230
+ "A tie must follow a note", line_number: token.line, snippet: "-"
231
+ )
232
+ end
233
+ state.tie_open = true
234
+ state.tie_line = token.line
235
+ end
236
+
237
+ # Closes an open tie: the pending note becomes the new note's tied
238
+ # prefix, so the pair (and any longer chain) resolves to a single
239
+ # placement whose rhythmic value carries the author's chosen split.
240
+ def tie_onto_pending(state, pitches, length, scale)
241
+ pending = state.pending_note
242
+ ensure_tie_pitches_match(state, pending, pitches)
243
+ prefix = pending_rhythmic_value(pending)
244
+ state.tie_open = false
245
+ state.tie_line = nil
246
+ state.pending_note = PendingNote.new(
247
+ pitches: pitches, length: length, scale: scale, tied_prefix: prefix
248
+ )
249
+ end
250
+
251
+ def ensure_tie_pitches_match(state, pending, pitches)
252
+ return if pending.pitches.sort == pitches.sort
253
+
254
+ raise ParseError.new(
255
+ "A tie must connect two notes of the same pitch",
256
+ line_number: state.tie_line, snippet: "-"
257
+ )
258
+ end
259
+
214
260
  def handle_rest(token)
215
261
  ensure_not_awaiting_note(token)
216
262
  state = current_state
263
+ reject_open_tie(state, token.line, "A tie must be followed by a note")
217
264
  flush_pending_note(state)
218
265
  place(state, token.length, nil)
219
266
  end
220
267
 
268
+ # A tie left open by a non-note terminator can never close, so each
269
+ # terminator rejects it. A bar line gets its own message: an author
270
+ # tie across a barline is a real, but not-yet-supported, request.
271
+ def reject_open_tie(state, line, message)
272
+ return unless state&.tie_open
273
+
274
+ raise ParseError.new(message, line_number: line || state.tie_line, snippet: "-")
275
+ end
276
+
221
277
  def handle_broken_rhythm(token)
222
278
  state = current_state
279
+ reject_open_tie(state, token.line, "A tie must be followed by a note")
223
280
  if state.awaiting_scale || state.pending_note.nil?
224
281
  raise ParseError.new(
225
282
  "Broken rhythm must appear between two notes",
@@ -235,6 +292,7 @@ module HeadMusic::Notation::ABC
235
292
  def handle_bar_line(token)
236
293
  ensure_not_awaiting_note(token)
237
294
  state = current_state
295
+ reject_open_tie(state, token.line, "Ties across barlines are not yet supported")
238
296
  flush_pending_note(state)
239
297
  tag_completed_bar(state)
240
298
  apply_repeat_flags(state, token.style)
@@ -248,6 +306,7 @@ module HeadMusic::Notation::ABC
248
306
  raise ParseError.new("Volta has no passes", line_number: token.line)
249
307
  end
250
308
  state = current_state
309
+ reject_open_tie(state, token.line, "A tie must be followed by a note")
251
310
  flush_pending_note(state)
252
311
  state.active_passes = token.passes
253
312
  state.volta_start_bar = state.entered_bar_number
@@ -257,6 +316,7 @@ module HeadMusic::Notation::ABC
257
316
  # Guarded so a leading V: line doesn't force a default voice into existence.
258
317
  if @current_state
259
318
  ensure_not_awaiting_note(token, state: @current_state)
319
+ reject_open_tie(@current_state, token.line, "A tie must be followed by a note")
260
320
  flush_pending_note(@current_state)
261
321
  end
262
322
  @current_state = voice_state(token.voice_id)
@@ -265,6 +325,7 @@ module HeadMusic::Notation::ABC
265
325
  def finish
266
326
  @voice_states.each_value do |state|
267
327
  ensure_not_awaiting_note(nil, state: state)
328
+ reject_open_tie(state, nil, "A tie must be followed by a note")
268
329
  flush_pending_note(state)
269
330
  tag_completed_bar(state)
270
331
  end
@@ -284,7 +345,22 @@ module HeadMusic::Notation::ABC
284
345
  return unless pending
285
346
 
286
347
  state.pending_note = nil
287
- place(state, pending.length, pending.pitches, scale: pending.scale)
348
+ state.voice.place(state.voice.next_position, pending_rhythmic_value(pending), pending.pitches)
349
+ end
350
+
351
+ # A pending note's own value, with any tied prefix appended ahead of
352
+ # it so the whole tie chain renders as one sounding note.
353
+ def pending_rhythmic_value(pending)
354
+ own = duration_resolver.rhythmic_value(pending.length, scale: pending.scale)
355
+ pending.tied_prefix ? append_tied(pending.tied_prefix, own) : own
356
+ end
357
+
358
+ # Attaches `tail` at the deep end of `head`'s tied chain, rebuilding
359
+ # each link (RhythmicValue exposes no setter) so a chain like
360
+ # "half tied to eighth" gains a further "tied to quarter".
361
+ def append_tied(head, tail)
362
+ inner = head.tied_value ? append_tied(head.tied_value, tail) : tail
363
+ HeadMusic::Rudiment::RhythmicValue.new(head.unit, dots: head.dots, tied_value: inner)
288
364
  end
289
365
 
290
366
  def place(state, length, pitches, scale: Rational(1))
@@ -1,3 +1,3 @@
1
1
  module HeadMusic
2
- VERSION = "17.1.0"
2
+ VERSION = "17.2.0"
3
3
  end
@@ -0,0 +1,195 @@
1
+ <!--
2
+ metadata:
3
+ created_at: 2026-07-19T11:46:34-07:00
4
+ activated_at: 2026-07-19T11:52:42-07:00
5
+ planned_at: 2026-07-19T11:57:00-07:00
6
+ finished_at: 2026-07-19T15:56:20-07:00
7
+ updated_at: 2026-07-19T15:56:20-07:00
8
+ -->
9
+
10
+ # Story: ABC Tie Input
11
+
12
+ ## Summary
13
+
14
+ AS a developer authoring music with the ABC interpreter
15
+
16
+ I WANT to write explicit ties (`-`) between notes
17
+
18
+ SO THAT I can control how a sustained duration is engraved — including durations that span a barline — instead of accepting the interpreter's automatic decomposition
19
+
20
+ ## Background
21
+
22
+ The [ABC Notation interpreter](../done/abc-notation-interpreter.md) reads ABC text into a `HeadMusic::Content::Composition` via `HeadMusic::Notation::ABC.parse`. It handles pitches, durations, chords, broken rhythm, and voltas — but it **deliberately rejects the tie character `-`**. `BodyLexer#scan_unsupported` folds `-` into an `:unsupported` token (character class `/[()\-~.]/`, whose comment reads "Recognizable ABC we deliberately don't handle: grace notes, decorations, tuplets, slurs, **ties**, and special rests"), and `Parser#reject_unsupported_tokens` then raises `UnsupportedFeatureError`.
23
+
24
+ Ties are already *modeled* everywhere except at the input boundary:
25
+
26
+ - `HeadMusic::Rudiment::RhythmicValue` carries a `tied_value`, and its string form round-trips "half tied to eighth".
27
+ - `ABC::DurationResolver#build_rhythmic_value` already emits tied chains, decomposing any non-dotted-expressible duration by **greedily peeling the largest dotted head each pass**. So `E5` (five eighths under `L:1/8`) always becomes **half tied to eighth** (4 + 1) — the resolver never chooses 3 + 2.
28
+ - The ABC exporter's `DurationWriter` already **collapses** a tied chain back into a single multiplier ("A tied chain collapses to one multiplier, round-tripping tokens like `A5`").
29
+
30
+ The gap is authoring. Because the split is derived greedily and can't be overridden, an author cannot ask for a **dotted quarter tied to a quarter** (3 + 2) rather than **half tied to an eighth** (4 + 1), and — more fundamentally — cannot tie a note *across a barline*, which is the primary reason ties exist in ABC.
31
+
32
+ This surfaced concretely in bardtheory's "Three Blind Mice" seed. In `6/8` with `L:1/8`, measure 8 is `E5 G` — a sustained E leading to a pickup G. The author wants that E engraved as a dotted quarter tied to a quarter; the interpreter can only produce half-tied-to-eighth, and `E3-E2 G` fails to parse.
33
+
34
+ The [Notation Module epic](../epics/notation-module.md) already lists this as planned work: "Future: … ties …" and "Ties (connecting same pitches across bars)."
35
+
36
+ ## Example
37
+
38
+ ```
39
+ X:1
40
+ T:Tie examples
41
+ M:6/8
42
+ L:1/8
43
+ K:C
44
+ % Author-chosen split: dotted quarter tied to a quarter, then a pickup G
45
+ E3-E2 G |
46
+ % Tie across a barline: the C sustains from bar 1 into bar 2
47
+ C6 | C3-C3 |]
48
+ ```
49
+
50
+ ```ruby
51
+ composition = HeadMusic::Notation::ABC.parse(abc)
52
+ # The E in bar 1 is a single sounding note whose RhythmicValue is
53
+ # "dotted quarter tied to quarter" — the authored split, not 4 + 1.
54
+ # The tied C sustains across the barline as one note.
55
+ ```
56
+
57
+ ## Acceptance Criteria
58
+
59
+ - The lexer recognizes `-` immediately following a note (or chord) as a **tie token** rather than an unsupported feature; `-` in any other position still surfaces the existing clear error.
60
+ - A tie between two **same-pitch** notes parses into a single sounding note whose `RhythmicValue` is the authored head tied to the authored tail: `E3-E2` → *dotted quarter tied to quarter* (3 + 2), overriding the greedy resolver rather than re-decomposing to 4 + 1.
61
+ - Tie **chains** parse: `E2-E2-E2` yields a single note with a nested tied value spanning the whole duration.
62
+ - A tie **across a barline** joins the last note of a measure to the first note of the next measure of the same pitch, producing one sustained note that spans the bar line. **(Deferred to a follow-up — see the Implementation Plan's scope decision. v1 detects an author tie that would cross a barline and raises a clear `ParseError`, "Ties across barlines are not yet supported," rather than joining the notes.)**
63
+ - A tie between **different pitches** is not a tie in ABC (that notation is a slur). It raises a `ParseError` with line number and snippet — or is classified as an unsupported slur — but never silently produces a wrong pitch. (Pick one behavior and document it.)
64
+ - The resulting model renders correctly through `to_musicxml`: tied notes emit MusicXML `<tie>` / `<tied>` so playback and engraving treat them as one sustained pitch, not two re-articulated notes.
65
+ - Decide and document the **ABC export** behavior for authored ties: whether `DurationWriter` preserves the authored split on round-trip or continues collapsing tied chains to a single multiplier (today it collapses, so `E3-E2` would re-export as `E5`). Round-trip specs assert whichever behavior is chosen.
66
+ - Specs cover: an author-chosen intra-measure split, a tie across a barline, a multi-note tie chain, a mismatched-pitch tie (error/slur path), and a MusicXML render asserting the `<tied>` elements.
67
+
68
+ ## Notes
69
+
70
+ **Touch points (for planning, not prescriptive):**
71
+
72
+ - `ABC::BodyLexer` — remove `-` from `scan_unsupported`; add a `scan_tie` producing a `:tie` token. Keep the "unterminated / dangling tie" cases as clear `ParseError`s.
73
+ - `ABC::Parser` — when a `:tie` token joins two adjacent note placements, merge them into one placement whose `RhythmicValue` uses the authored head with the tail as its `tied_value`, bypassing `DurationResolver`'s greedy split for the tied span. Cross-barline ties are the hard part: a single note must span two measures' placement bookkeeping.
74
+ - `ABC::DurationResolver` — unchanged for un-tied durations; explicit ties should *supply* the split rather than derive it.
75
+ - `Notation::MusicXML::Writer` / `DurationWriter` — verify tied `RhythmicValue`s already emit `<tie type="start"/stop">` and `<tied>`; the greedy resolver produces tied chains today, so this path may already work and just need coverage.
76
+ - `ABC::DurationWriter` (export) — the round-trip decision above lives here.
77
+
78
+ **Scope boundaries (candidates for follow-up, not v1):**
79
+
80
+ - Ties inside or between chords (`[CEG]-[CEG]`).
81
+ - Slurs `( … )`, which share the `-`-adjacent lexer neighborhood but are a distinct musical construct.
82
+ - Preserving an authored split through ABC *export* if that proves to conflict with the exporter's canonical-form goal.
83
+
84
+ Related: [ABC Notation interpreter](../done/abc-notation-interpreter.md), [ABC Chord Input](../done/abc-chord-input.md), [ABC Notation Export](../done/abc-notation-export.md), [Notation Module epic](../epics/notation-module.md).
85
+
86
+ ## Implementation Plan
87
+
88
+ ### Scope decision
89
+
90
+ This story ships **intra-note (within-bar) author-controlled ties** and reuses the existing tie-rendering pipeline end to end. The MusicXML writer already turns a `RhythmicValue` with a `tied_value` chain into multiple `<note>` elements carrying `<tie>`/`<tied>` (`MusicXML::DurationWriter#components`, `Writer#tie_lines`/`#notation_lines`). So the only gap is the ABC **input** boundary: letting the parser build a placement whose `RhythmicValue` is the author's chosen head-plus-tail instead of the resolver's greedy split.
91
+
92
+ **Cross-barline ties are deferred.** A single placement cannot span a bar — the MusicXML writer's `ensure_notes_within_barlines` rejects that outright, and representing a tie *between two placements* would require a new concept in the Voice/Placement model plus writer changes. This story instead detects an author tie that would cross a barline (a `-` immediately before a bar line) and raises a clear, specific `ParseError` at parse time. Tracked as a follow-up in Scope Boundaries.
93
+
94
+ ### Step 1 — Lexer: emit a `:tie` token (`abc/body_lexer.rb`)
95
+
96
+ - Add `scan_tie` to the `scan_token` chain, placed **before** `scan_unsupported`: match a single `-` and push `Token.new(type: :tie, line:, column:)`.
97
+ - Remove `-` from `scan_unsupported`'s character class (`/[()\-~.]/` → `/[()~.]/`) so slurs `()`, staccato `.`, and roll `~` stay unsupported while `-` no longer does.
98
+ - `-` inside volta ranges (`VOLTA_DIGITS_PATTERN`, `[…]`/trailing-volta paths) is unaffected — those are scanned in their own branches before the note stream reaches `scan_tie`.
99
+
100
+ ### Step 2 — Parser: combine tied notes into one placement (`abc/parser.rb`)
101
+
102
+ - Extend `VoiceState` with a `tie_open` flag (+ a `tie_line` for error reporting).
103
+ - Extend `PendingNote` with a `tied_prefix:` field (default `nil`) — a pre-built `RhythmicValue` holding everything tied *before* this note's own `(length, scale)`.
104
+ - `handle_tie(token)`: require a `pending_note` and not `awaiting_scale`; otherwise raise `ParseError` ("A tie must follow a note" / broken-rhythm conflict). Set `state.tie_open = true`, record the line. Do **not** flush the pending note.
105
+ - In `handle_note`/`handle_chord`: when `state.tie_open` is set, don't defer normally. Instead:
106
+ - Validate the new pitches equal the pending pitches (set equality). Mismatch → `ParseError` "A tie must connect two notes of the same pitch" with line/snippet (this is the ABC slur case).
107
+ - Build the head chain: `head = append_tied(pending.tied_prefix, resolve(pending))` where `resolve` = `duration_resolver.rhythmic_value(pending.length, scale: pending.scale)` and `append_tied(prefix, tail)` returns `tail` when `prefix` is nil, else a copy of `prefix` whose deepest `tied_value` is `tail` (recursive rebuild via `RhythmicValue.new(unit, dots:, tied_value:)`, since `RhythmicValue` has no setters).
108
+ - Set the new `pending_note` to the incoming note with `tied_prefix: head`; clear `tie_open`. Keeping it pending preserves broken-rhythm deferral and lets `E2-E2-E2` chains accumulate.
109
+ - `flush_pending_note`/`place`: when `pending.tied_prefix` is present, place `append_tied(pending.tied_prefix, resolve(pending))` as the single placement's value; otherwise unchanged.
110
+ - Guard the non-note terminators so an open tie fails fast and specifically:
111
+ - `handle_bar_line` while `tie_open` → `ParseError` "Ties across barlines are not yet supported" (the deferred case).
112
+ - `handle_rest` / `handle_volta` / `handle_voice_change` while `tie_open` → `ParseError` "A tie must connect two notes of the same pitch" (a tie to a non-note).
113
+ - `finish` while `tie_open` → `ParseError` "Tie has no following note" (dangling tie at end of tune).
114
+ - `handle` gains a `when :tie then handle_tie(token)` branch.
115
+
116
+ ### Step 3 — Verify the render path is already correct
117
+
118
+ No writer changes expected. Confirm by test that a parsed `E3-E2` produces a placement whose `rhythmic_value.to_s == "dotted quarter tied to quarter"` and that `to_musicxml` emits `<tie type="start"/>` + `<tied type="start"/>` on the first note and the `stop` pair on the second. If a gap surfaces, fix it in `MusicXML::Writer`, but the existing greedy-resolver output already exercises this path.
119
+
120
+ ### Step 4 — Specs
121
+
122
+ - **Lexer** (`spec/.../abc/body_lexer_spec.rb`): `-` lexes as a `:tie` token; `()`, `.`, `~` still lex as `:unsupported`.
123
+ - **Parser** (`spec/.../abc/parser_spec.rb` or the interpreter spec): author split `E3-E2` → one note, `RhythmicValue` "dotted quarter tied to quarter" (assert it differs from the greedy `E5` → "half tied to eighth"); a chain `C2-C2-C2`; mismatched pitch `E3-D2` raises the same-pitch `ParseError`; cross-barline `E3-|E3` raises "not yet supported"; dangling `E3-` at end raises; tie with no preceding note (`-E3`) raises.
124
+ - **Round-trip / MusicXML** (`spec/.../music_xml_spec.rb` or the ABC interpreter → MusicXML spec): parsing the Three-Blind-Mice-style measure and rendering asserts the `<tied>` start/stop elements.
125
+ - Follow existing spec structure/naming in `spec/head_music/notation/abc/`.
126
+
127
+ ### Step 5 — Docs & housekeeping
128
+
129
+ - Update the `scan_unsupported` comment (currently lists "ties" among deliberately-unhandled constructs) to reflect that ties are now handled on input.
130
+ - If a README/CHANGELOG enumerates supported ABC features, add ties (input, within-bar).
131
+ - Run `bundle exec rspec` and `bundle exec standardrb`/`rubocop` (whichever the repo uses) to green before finishing.
132
+
133
+ ### Files touched
134
+
135
+ - `lib/head_music/notation/abc/body_lexer.rb` — `scan_tie`, unsupported char-class edit, comment.
136
+ - `lib/head_music/notation/abc/parser.rb` — `:tie` handling, `PendingNote`/`VoiceState` extension, `append_tied`, terminator guards.
137
+ - `spec/head_music/notation/abc/*` and the MusicXML spec — new coverage.
138
+ - Possibly `CHANGELOG`/README if they enumerate ABC feature support.
139
+
140
+ ### Scope Boundaries (Not in v1)
141
+
142
+ - **Cross-barline ties** (`E3-|E3`) — needs a tie-between-placements concept in the Voice/Placement model and MusicXML writer; raises a clear "not yet supported" error for now.
143
+ - **Ties on/between chords** (`[CEG]-[CEG]`) — the pitch-set-equality guard would admit identical chords, but this path is not a v1 target and gets at most light coverage; document as follow-up.
144
+ - **Slurs** (`( … )`) — distinct construct, remains unsupported.
145
+ - **ABC export of authored ties** — the exporter still collapses tied chains to a single multiplier (`E3-E2` → `E5`); preserving an authored split on export is out of scope. Export round-trip specs assert the current collapsing behavior.
146
+
147
+ ## Review
148
+
149
+ _Reviewed 2026-07-19 at commit `8323de5` (implementation in `93023bb`, committed to `main` — no `story/abc-tie-input` branch was cut). All 236 examples in the ABC lexer/parser and MusicXML writer specs pass; the full suite still needs to be run to confirm nothing else regressed._
150
+
151
+ ### Acceptance criteria
152
+
153
+ - ✅ **Lexer tie token; `-` elsewhere still errors.** `scan_tie` is inserted before `scan_unsupported` and emits a `:tie` token (`body_lexer.rb:285-290`); `-` was removed from the unsupported char class (`body_lexer.rb:298`). A leading tie is rejected in the parser ("A tie must follow a note", `parser.rb:226-235`). Note: a stray `-` with nothing else to match now yields the generic "Unexpected character" error rather than the old `UnsupportedFeatureError "-"` — arguably clearer, but a behavior change.
154
+ - ✅ **Authored split preserved, overrides greedy resolver.** `tie_onto_pending` + `append_tied` build the nested `RhythmicValue` from the authored lengths (`parser.rb:240-363`). `parser_spec.rb:295-306` asserts `E3-E2` → `"dotted quarter tied to quarter"` and contrasts it against the greedy `"half tied to eighth"`.
155
+ - ✅ **Tie chains nest into one value.** `append_tied` recurses; `C2-C2-C2` → `"quarter tied to quarter tied to quarter"` (`parser_spec.rb:308-311`).
156
+ - ❌ **Cross-barline spanning tie — consciously deferred.** Not implemented; `handle_bar_line` raises "Ties across barlines are not yet supported" (`parser.rb:294`). The deferral is clean, documented in code/CHANGELOG/commit, and tested (`parser_spec.rb:323-326`). The original criterion is not met by design — this was scoped out in the plan. _Follow-up: the story's AC text has now been annotated to record the deferral._
157
+ - ✅ **Different-pitch tie raises, never silent.** `ensure_tie_pitches_match` raises "A tie must connect two notes of the same pitch" (`parser.rb:251-258`); `E3-D2` tested (`parser_spec.rb:318-321`). Behavior (raise, not slur) is documented in the CHANGELOG.
158
+ - ✅ **MusicXML `<tie>`/`<tied>` render.** No writer change needed — reuses the existing tied-`RhythmicValue` path. `writer_spec.rb:363-380` asserts note types `quarter quarter eighth`, a dot on note 1, and `tied` start/stop counts `[1,0,0]`/`[0,1,0]`.
159
+ - ✅ **ABC export round-trip decided/documented/asserted.** Behavior is decided (collapse to one multiplier: `E3-E2` → `E5`) and documented in the story's Scope Boundaries. _Follow-up: a round-trip spec was added (`writer_spec.rb`, "with an authored tie collapsing to a single multiplier") asserting that parsing `E3-E2 G` and re-exporting yields `E5 G` — pinning the intentional asymmetry between input (preserves the authored split) and export (collapses it)._
160
+ - ✅ **Five required spec cases present** (intra-measure split, cross-barline [asserts the deferral error], chain, mismatched pitch, MusicXML `<tied>`), plus bonus error-path coverage (dangling tie, no-preceding-note, tie-to-rest, whole-bar simple-meter tie).
161
+
162
+ ### Code review findings
163
+
164
+ 1. **(Should-fix, confirmed bug) `handle_broken_rhythm` does not reject an open tie — silent wrong output.** `parser.rb:277-289`. Every other terminator calls `reject_open_tie`, but this one was overlooked; its guard checks only `awaiting_scale`/`pending_note.nil?`. Verified: `A->A` parses **without error** into a bogus "dotted eighth tied to sixteenth" (both the tie and the broken-rhythm ×3/2·×1/2 scaling applied at once) instead of raising like `A-z` or `A-|` do. Fix: add `reject_open_tie(state, token.line, "A tie must be followed by a note")` at the top of `handle_broken_rhythm`, with a spec for `A->A` / `A-<A`.
165
+ 2. **(Nice-to-have) Pitch match mixes two comparison semantics.** `parser.rb:252` — `pending.pitches.sort == pitches.sort`. `sort` orders by `Pitch#<=>` (MIDI number; enharmonics tie), then array `==` compares by `Pitch#==` (spelling). They disagree on enharmonics, so `^C-_D` is rejected as "same pitch" mismatch — defensible, but worth a one-line "why" comment.
166
+ 3. **(Nice-to-have) Bar-line tie message can mislead.** `parser.rb:294` — `E3-|]` (dangling tie at a section end) reports "Ties across barlines are not yet supported" though nothing crosses a barline. Minor; the terminator can't distinguish the two cases and the message is right for the tested `E3-|E3`.
167
+
168
+ ### Test coverage gaps
169
+
170
+ - **Tie + broken rhythm** (`A->A`) — the untested path behind finding #1.
171
+ - **Successful chord-to-chord tie** — the lexer tokenizes `[CEG]-c`, but no parser spec fuses a chord tie into one placement.
172
+ - **Open tie across a voice change** (`parser.rb:318`) and **across a volta** (`parser.rb:308`) — both `reject_open_tie` branches are unexercised.
173
+
174
+ ### Blocks `finish`?
175
+
176
+ Nothing outstanding. **Finding #1** (the `A->A` silent-miscompute) has been fixed: `handle_broken_rhythm` now calls `reject_open_tie` and `A->A`/`A-<A` raise a clear `ParseError`, with a covering spec. The two follow-up items called out under criteria 4 and 7 have also been addressed — the export round-trip spec was added and the deferred cross-barline AC was annotated. The remaining nice-to-haves (pitch-comparison comment, bar-line message wording, chord-to-chord / voice-change / volta coverage) are non-blocking.
177
+
178
+ ## Learnings
179
+
180
+ **What went well**
181
+
182
+ - The plan's central bet held: because the greedy resolver already emits tied chains, the entire tie *rendering* path (`RhythmicValue.tied_value` → MusicXML `<tie>`/`<tied>`) already existed. No writer changes were needed, so the work collapsed to the input boundary (lexer + parser) and stayed small.
183
+ - The "keep the left note pending, close the tie when its right note arrives" state machine composed cleanly with the existing broken-rhythm deferral and gave tie chains (`E2-E2-E2`) for free.
184
+
185
+ **What was surprising**
186
+
187
+ - The `reject_open_tie` guard had to be called by *every* non-note terminator, and `handle_broken_rhythm` was the one that got missed — the review caught a genuine silent miscompute (`A->A` applied both the tie and the broken-rhythm scaling with no error).
188
+ - Cross-barline ties — the primary real-world reason ties exist in ABC — had to be deferred, because a single `Placement` cannot span a bar (`ensure_notes_within_barlines`). The feature the story most wanted is the one v1 does not ship.
189
+ - The input/export asymmetry (input preserves an authored split; export collapses it to one multiplier) is intentional but was easy to overlook until a spec pinned it.
190
+
191
+ **What to do differently next time**
192
+
193
+ - When introducing a cross-cutting guard that "every terminator must call," write one spec per guard branch — the missing terminator failed silently precisely because it had no test.
194
+ - Annotate a deferred acceptance criterion in the AC text at *plan* time, not review time, so the backlog never overstates what shipped.
195
+ - Cut a `story/<name>` branch. Work went straight onto `main` (acceptable for this rebase-flow repo, but the review/finish tooling assumes a story branch and had to diff commit ranges instead).
@@ -277,6 +277,10 @@
277
277
  ],
278
278
  "active": [],
279
279
  "done": [
280
+ {
281
+ "title": "ABC Tie Input",
282
+ "path": "done/abc-tie-input.md"
283
+ },
280
284
  {
281
285
  "title": "MusicXML Chord Rendering",
282
286
  "path": "done/musicxml-chord-rendering.md"
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.1.0
4
+ version: 17.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Rob Head
@@ -423,6 +423,7 @@ files:
423
423
  - user-stories/done/abc-chord-input.md
424
424
  - user-stories/done/abc-notation-export.md
425
425
  - user-stories/done/abc-notation-interpreter.md
426
+ - user-stories/done/abc-tie-input.md
426
427
  - user-stories/done/chord-placement-model.md
427
428
  - user-stories/done/composition-serialization.md
428
429
  - user-stories/done/configurable-large-leap-recovery.md