head_music 21.1.0 → 22.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/release/SKILL.md +4 -3
  3. data/.github/workflows/ci.yml +5 -3
  4. data/CHANGELOG.md +66 -0
  5. data/CLAUDE.md +7 -5
  6. data/Gemfile.lock +1 -1
  7. data/Rakefile +3 -2
  8. data/bin/guide_grade_corpus.rb +0 -14
  9. data/lib/head_music/analysis/circle.rb +1 -8
  10. data/lib/head_music/analysis/diatonic_interval.rb +1 -1
  11. data/lib/head_music/analysis/interval_cycle.rb +7 -2
  12. data/lib/head_music/analysis/motion.rb +14 -11
  13. data/lib/head_music/analysis/sonority.rb +14 -30
  14. data/lib/head_music/content/credits.rb +3 -9
  15. data/lib/head_music/content/flow/hash_deserializer.rb +3 -3
  16. data/lib/head_music/content/flow.rb +7 -7
  17. data/lib/head_music/content/layout.rb +31 -20
  18. data/lib/head_music/content/part.rb +1 -1
  19. data/lib/head_music/content/placement.rb +4 -0
  20. data/lib/head_music/content/position.rb +9 -1
  21. data/lib/head_music/content/project.rb +10 -6
  22. data/lib/head_music/content/voice.rb +4 -0
  23. data/lib/head_music/locales/en.yml +21 -6
  24. data/lib/head_music/locales/en_GB.yml +2 -2
  25. data/lib/head_music/notation/abc/duration_writer.rb +6 -1
  26. data/lib/head_music/notation/abc/parser.rb +8 -6
  27. data/lib/head_music/notation/abc/voice_state.rb +36 -10
  28. data/lib/head_music/notation/abc/writer.rb +63 -34
  29. data/lib/head_music/notation/lily_pond/voice_stream.rb +13 -11
  30. data/lib/head_music/notation/lily_pond/voice_writer.rb +22 -21
  31. data/lib/head_music/rudiment/chromatic_interval.rb +2 -0
  32. data/lib/head_music/rudiment/key_signature.rb +9 -24
  33. data/lib/head_music/rudiment/letter_name.rb +1 -1
  34. data/lib/head_music/rudiment/pitch.rb +2 -2
  35. data/lib/head_music/rudiment/pitch_class.rb +2 -0
  36. data/lib/head_music/rudiment/rhythmic_unit/parser.rb +12 -11
  37. data/lib/head_music/rudiment/spelling.rb +1 -1
  38. data/lib/head_music/style/guide_item.rb +11 -16
  39. data/lib/head_music/style/guideline/bar_span.rb +40 -0
  40. data/lib/head_music/style/guidelines/allow_fifth_species_rhythmic_values.rb +1 -1
  41. data/lib/head_music/style/guidelines/allow_whole_half_quarter_notes.rb +1 -1
  42. data/lib/head_music/style/guidelines/always_move.rb +29 -0
  43. data/lib/head_music/style/guidelines/avoid_crossing_voices.rb +3 -6
  44. data/lib/head_music/style/guidelines/diatonic.rb +53 -9
  45. data/lib/head_music/style/guidelines/embellished_suspension_treatment.rb +36 -0
  46. data/lib/head_music/style/guidelines/florid_dissonance_treatment.rb +10 -34
  47. data/lib/head_music/style/guidelines/mix_species_textures.rb +84 -0
  48. data/lib/head_music/style/guidelines/note_count_per_bar.rb +6 -23
  49. data/lib/head_music/style/guidelines/prefer_long_before_short.rb +43 -0
  50. data/lib/head_music/style/guidelines/second_species_break.rb +3 -4
  51. data/lib/head_music/style/guidelines/step_out_of_unison.rb +14 -2
  52. data/lib/head_music/style/guidelines/suspension_treatment.rb +1 -3
  53. data/lib/head_music/style/guidelines/sustain_across_barlines.rb +43 -0
  54. data/lib/head_music/style/guidelines/weak_beat_dissonance_treatment.rb +1 -3
  55. data/lib/head_music/style/guides/base.rb +2 -4
  56. data/lib/head_music/style/guides/composite_guide.rb +2 -4
  57. data/lib/head_music/style/guides/configured.rb +5 -13
  58. data/lib/head_music/style/guides/fifth_species_harmony.rb +1 -1
  59. data/lib/head_music/style/guides/fifth_species_melody.rb +2 -2
  60. data/lib/head_music/style/guides/fourth_species_melody.rb +6 -1
  61. data/lib/head_music/style/guides/species_harmony.rb +5 -0
  62. data/lib/head_music/style/guides/tiered_items.rb +10 -0
  63. data/lib/head_music/time/musical_position.rb +6 -1
  64. data/lib/head_music/time/musical_time_converter.rb +11 -10
  65. data/lib/head_music/value_equality.rb +5 -0
  66. data/lib/head_music/version.rb +1 -1
  67. data/lib/head_music.rb +7 -2
  68. data/references/content-schema.md +2 -2
  69. data/references/fifth-species-counterpoint.md +29 -31
  70. data/user-stories/backlog/lower-the-contour-floor.md +69 -0
  71. data/user-stories/backlog/sixteenth-century-style.md +87 -0
  72. data/user-stories/done/embellish-fifth-species-suspensions.md +473 -0
  73. data/user-stories/done/extract-the-harmonic-cores.md +7 -7
  74. data/user-stories/done/fifth-species-guide-improvements.md +631 -0
  75. data/user-stories/done/grade-every-fux-fifth-species-figure.md +479 -0
  76. data/user-stories/done/require-the-species-rhythm.md +546 -0
  77. data/user-stories/index.html +262 -246
  78. metadata +13 -5
  79. data/lib/head_music/content/flow/v3_hash_deserializer.rb +0 -52
  80. data/lib/head_music/style/guidelines/mixed_rhythmic_values.rb +0 -19
  81. data/user-stories/backlog/tell-the-species-apart.md +0 -136
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: eadecb642354dfc4c70cf00303a0c1c27513fdd38b13d8bc81fe9c8e7fd3214b
4
- data.tar.gz: b534a1e194b0bd6ba4dae42433e92091812e434ec0fddb03c7617e47147207ab
3
+ metadata.gz: 4f716235926455127be636b1b1c2c575a4ce7f0f4b41f7952620dfda908a8671
4
+ data.tar.gz: 378c4749d7fbcc0ac7168f086cae549126d4fd8e648e5703d4311a2a9b63f5ad
5
5
  SHA512:
6
- metadata.gz: aa22ebc311f8ebe62590e99f90f0d65d639b250626f9668520cd604e0f2fec4c1ea7af4d9b944a19585c52ea1b941906f12d2910888e0a4005a7f582936547ec
7
- data.tar.gz: fde9ba8d894229bc349c861b24a0e56108f2a718526b67465c7b8631c9401a8d243196d4f632d1ebce890726c718b4371d2110fcc4e80eef39747d7ec1e610a1
6
+ metadata.gz: 433e4a30ce9a523d69e194b37b72f3cc5499c91d6e3605c1c901ed63ad9d2c308ae9c891876eb0cb9beef288450cc59287fbabb63e0c3115a160e8d111d032ad
7
+ data.tar.gz: 925aa64aabc77e319868ab18d71964bea6ee1aae5a02477a827283b9ce01c13eb1203b1d0ff87ae361f41c41bbc02965446acc77caa1f6a1c451fe227a718806
@@ -82,9 +82,10 @@ rubygems.org/gems/head_music/trusted_publishers, with repository
82
82
 
83
83
  - `gh run watch` on the run that the tag started, or
84
84
  `gh run list --workflow=release.yml --limit 1`. Report its status. Do not
85
- assume it passed. The workflow failed on every run before 2026-09-10, first
86
- on a denied API key and then on an action version that did not exist.
87
- - `gem search -r head_music` lists the new version.
85
+ assume it passed.
86
+ - `curl -s https://rubygems.org/api/v1/versions/head_music/latest.json`
87
+ reports the new version. `gem search -r` lags behind the API by minutes,
88
+ so do not read a stale result there as a failed publish.
88
89
  - `gh release view vX.Y.Z` shows the GitHub Release with the gem attached.
89
90
 
90
91
  If the workflow fails after the tag is pushed, the fallback is
@@ -57,6 +57,8 @@ jobs:
57
57
  bundle install
58
58
 
59
59
  - name: Run tests
60
+ env:
61
+ COVERAGE: ${{ matrix.coverage && 'true' || '' }}
60
62
  run: bundle exec rspec
61
63
 
62
64
  - name: Upload coverage to Codecov
@@ -99,8 +101,8 @@ jobs:
99
101
 
100
102
  # The LilyPond specs treat the real binary as an oracle and skip when none is
101
103
  # installed, so without this job nothing proves the rendered documents
102
- # actually compile. Runs the whole suite because SimpleCov's minimum applies
103
- # to any rspec invocation.
104
+ # actually compile. Only the specs that call the binary run here; the test
105
+ # job covers the rest.
104
106
  test-lilypond:
105
107
  name: test (with the lilypond toolchain)
106
108
  runs-on: ubuntu-latest
@@ -121,7 +123,7 @@ jobs:
121
123
  bundler-cache: true
122
124
 
123
125
  - name: Run tests
124
- run: bundle exec rspec
126
+ run: bundle exec rspec spec/head_music/notation/lily_pond_round_trip_spec.rb spec/head_music/notation/lily_pond
125
127
 
126
128
  lint:
127
129
  runs-on: ubuntu-latest
data/CHANGELOG.md CHANGED
@@ -7,6 +7,72 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [22.0.0] - 2026-09-24
11
+
12
+ Four counterpoint stories, [Require the Species Rhythm](https://github.com/roberthead/head_music/tree/main/user-stories/done/require-the-species-rhythm.md), [Embellish Fifth Species Suspensions](https://github.com/roberthead/head_music/tree/main/user-stories/done/embellish-fifth-species-suspensions.md), [Grade Every Fux Fifth-Species Figure](https://github.com/roberthead/head_music/tree/main/user-stories/done/grade-every-fux-fifth-species-figure.md), and [Fifth Species Guide Improvements](https://github.com/roberthead/head_music/tree/main/user-stories/done/fifth-species-guide-improvements.md). Every species guide now insists on the rhythm its species teaches, fifth species accepts the decorated suspensions Fux allows and requires the mixture that defines it, and every two-voice fifth-species figure in Gradus is graded and pinned. Fux's lines are graded as evidence, not as an oracle: where one of them outruns a rule that every other source keeps, the mark stands and is recorded as his liberty. Two grades move, both in the direction the pedagogy says they should, and the count guidelines now grade the solo and cantus-firmus lines they had passed on nothing; everything else is additive, including ties across bar lines in ABC.
13
+
14
+ Grading is also faster: the pinned corpus grades in about half the time, with no grade moving for it.
15
+
16
+ **This is a major release**, because two public classes are removed. A consumer that names neither and stores no schema-3 documents upgrades by upgrading.
17
+
18
+ **Migrating from 21.1.0**, in the order a consumer will hit them:
19
+
20
+ 1. **`Style::Guidelines::MixedRhythmicValues` is gone.** A custom guide that names it raises `NameError` on load. `MixSpeciesTextures` replaces it in `FifthSpeciesMelody` and judges runs of one texture rather than counting distinct durations, so it is a successor rather than a rename.
21
+
22
+ 2. **`Content::Flow.from_v3_h` is gone, as 21.0.0 announced.** A schema-3 document must be read with head_music 21.x and saved again before upgrading; `Flow.from_h` rejects one with an error saying so. Schema-4 documents are unaffected.
23
+
24
+ 3. **Some grades move.** The rule changes under Added and Changed regrade existing lines: the fourth- and fifth-species guides most, and `Diatonic`, `StepOutOfUnison`, `AlwaysMove`, and the count guidelines in places. Stored fitness from 21.x is not comparable for the guides they touch.
25
+
26
+ 4. **`Pitch#name` and `Spelling#name` return frozen strings.** Code that modifies the returned name in place raises `FrozenError`; `dup` it first.
27
+
28
+ ### Added
29
+
30
+ - **`Style::Guidelines::MixSpeciesTextures`, strong and primary in `FifthSpeciesMelody` in place of `MixedRhythmicValues`.** Fifth species is the free mixture of the other four, and Salzer and Schachter allow "two, or at most, two-and-a-half measures of a single note value" (*Counterpoint in Composition*, pp. 101-102). Each bar from the voice's first to the one before its last is classified by the texture it shows: a note that fills the bar, halves, quarters and eighth pairs, or a ligature entered by tie. Every bar of a run of more than two bars in one texture is marked, configurable through `maximum_run:`. A bar that mixes textures is florid and an empty bar is left to the rest rules; either ends a run. It is strong although a proportion usually makes a guideline weak, because one texture running on decides whether a line is fifth species at all. All twelve Fux fifth-species counterpoints are adherent. Fux's fourth-species figure 73 falls from 0.929 to 0.827 on the `fifth_species` composite, its eight ligature bars marked in two runs on either side of an untied bar; his second- and third-species figures fall to 0.815 and 0.796, and every first-species counterpoint to between 0.59 and 0.63, and the species diagonal spec now asserts that every other species grades 0.85 or below there. In the pinned corpus, 175 rows move, all on `fifth_species_melody` and the `fifth_species` composite: every one-texture voice falls, including every cantus firmus, every first-species counterpoint, the solo and against-empty lines, and the cantus voices of the species fixtures; the triple-meter cantus counts too, since a dotted whole that fills its bar is the whole-note texture. Two three-bar solo lines rise on the melody guide and lose their one message on both guides, because two body bars cannot hold an over-long run, where the old rule marked any line with fewer than three durations. No other message or item count moves, and no harmony grade moves.
31
+
32
+ - **`Style::Guidelines::PreferLongBeforeShort`, secondary and weak in `FifthSpeciesMelody`.** A bar that opens with two quarters and closes with a longer note that does not tie forward is marked: Salzer and Schachter call that note "a static point" that "blocks the flow from the two quarters rather than channeling it into the following measure" (*Counterpoint in Composition*, p. 103), and Fux marks the shape N.B. in figure 88 bar 5 and offers figure 89 as better. A longer note that ties over the bar line is exempt, as is a bar entered by a tie. It is weak because Fux calls the alternative better rather than calling his own line wrong. Figure 88a is the only voice marked and falls from 1.000 to 0.995 on `fifth_species_melody` and to 0.997 on the `fifth_species` composite. The new item adds one to the item count of every gated row of those two guides, 250 in the pinned corpus; 176 of them rise by at most 0.005 because the secondary tier's weight is shared among one more item, and 72 move in item count only.
33
+
34
+ - **Every two-voice fifth-species figure of Gradus as a spec fixture.** Figures 83 through 88 in Mann's numbering join figure 82 in `spec/spec_helper.rb`: each of the six modes once with the counterpoint above the cantus firmus and once below, read from the scan and confirmed against the kern transcriptions in MarkGotham/species, except figure 87's upper counterpoint, which has no kern and is read from the scan alone. The kern drops the tie from bar 11 into bar 12 of figure 86a at a system break; the fixture follows the scan. `species_abc` takes an ABC key and draws the cantus firmus from `FUX_CANTUS_FIRMUS_ABC`, so a species fixture can sit on any of Fux's six cantus firmi. The pinned corpus gains 660 rows and moves none. Every new counterpoint clears both primary items of `FifthSpeciesHarmony` except figure 85b, whose suspension from bar 1 resolves as an eighth on beat 2 of bar 2 and has moved on by beat 3. No source but Fux sanctions that shape, so `EmbellishedSuspensionTreatment` keeps marking it, a spec context pins the mark, and the species diagonal spec excludes that one cell by name through a map of liberties rather than by loosening the comparison. It grades 0.916 on the `fifth_species` composite; the other eleven grade between 0.953 and 1.000.
35
+
36
+ - **`Style::Guidelines::SustainAcrossBarlines`, primary in `FourthSpeciesMelody`.** A bar is sustained when the note sounding at its downbeat began before it, so a whole note placed on beat three and a half tied to a half count the same. A quarter of the middle bars may break the syncopation, as Fux allows where no ligature will fit; past that, every bar entered without a ligature is marked. It is judged over the voice's own bars rather than the cantus firmus's, so a solo line is held to it too. `OneToOneWithTies` stays beside it: that rule bounds how many notes sound against each cantus note, and a plain first-species line passed it, which is why a first-species line graded 0.988 on the fourth-species composite.
37
+
38
+ - **`Style::Guidelines::EmbellishedSuspensionTreatment`, primary in `FifthSpeciesHarmony` in place of `SuspensionTreatment`.** Fifth species lets the space between a suspension and its resolution be decorated. The resolution is still the note sounding halfway through the bar, on its second strong beat: a descending step from the suspended pitch and consonant there, whether attacked on that beat or anticipated on beat two and held. That accepts all five shapes the fifth-species reference describes — the anticipated resolution, the anticipated resolution with an eighth-note lower neighbor, the escape tone, the consonant leap, and Fux's delayed resolution — and still marks an unprepared suspension, one left by leap, one resolving upward, one first resolving on beat four, one held through the bar, and an anticipation abandoned on beat three. What is attacked between the suspension and that beat is `FloridDissonanceTreatment`'s to judge, so a dissonant leapt-to quarter is marked once, not twice. The slot presupposes a suspension over a downbeat whose cantus note lasts to that beat; a cantus note attacked off the downbeat or gone before it, and a meter with no second strong beat, get the strict rule. Fourth species keeps `SuspensionTreatment` unchanged.
39
+
40
+ - **Ties across bar lines in ABC.** `Notation::ABC.parse` now carries a tie over a bar line into one placement whose rhythmic value keeps the authored split, where it used to raise "Ties across barlines are not yet supported"; the tie carries its accidental across the line, so `^D2-|D2` is one D-sharp. A tied note is kept pending across a volta bracket as well. The writer does the inverse: a placement that sounds across a bar line is written as one note per bar, tied, so a flow of fourth-species ligatures writes back as valid ABC and re-parses to the same placements. In-bar tie chains still collapse to one multiplier on output.
41
+
42
+ - **Fux's diminution species as spec fixtures.** `spec/spec_helper.rb` carries Mann's figures 33, 55, 73, and 82 — second, third, fourth, and fifth species above the D dorian cantus firmus — checked against the scan and against the kern transcriptions in MarkGotham/species, plus a constructed triple-meter third-species line labeled as such. They join the pinned corpus, and a new species diagonal spec asserts that every species composite and melody guide grades its own species' fixture at least as high as any other species' fixture.
43
+
44
+ ### Changed
45
+
46
+ - **`Diatonic` accepts the accidentals the modes require.** Salzer and Schachter (*Counterpoint in Composition*, pp. 9-10 and 20) name four, and the guideline now exempts exactly those: a raised seventh that steps up to the tonic anywhere in the line, which subsumes the old exemption for a sharpened penultimate note; a raised sixth that steps up to a raised seventh; the lowered fourth in lydian; and the lowered sixth in dorian or lowered third in mixolydian, approached from above and left downward. Phrygian gets no B-flat. Every Fux fifth-species counterpoint now clears `Diatonic`: figure 82's B-flat in `d c _B G`, figure 86a's mid-line F-sharp, and the five lydian B-flats of 85a and 85b. The same exemption clears the descending B-flat of Fux's fourth-species figure 73 and two of the five B-flats in his third-species figure 55; the other three are not in descent and stay marked, as do the F-sharps of the cantus firmus written with chromatic notes added. In the pinned corpus, those six counterpoints rise under the 23 guides that carry `Diatonic`, 138 rows, by at most 0.002 on a species composite, where `Diatonic` is secondary; no row falls.
47
+
48
+ - **`StepOutOfUnison` exempts the opening note and a unison tied over the bar line.** Salzer and Schachter permit a unison "if tied over or followed by stepwise motion" (*Counterpoint in Composition*, p. 106), and the opening unison belongs to the rules for beginnings (p. 40). The rule stays in every moving species, which is where they state it. Fux's figures 83 and 86b, whose opening unisons are left by leap, now clear it; the interior unisons left by leap in figures 82 (twice), 84a, 85a, and 86a keep their marks as his liberties, pinned by name in the spec. In the pinned corpus, 120 rows rise, ten voices under the twelve guides that carry the rule: those two counterpoints, the cantus firmus of figures 83, 86b, and 87a, of Fux's third-species figure, and of two first-species figures, each of which opens in unison with its counterpoint, and the two against-cantus lines. No row falls.
49
+
50
+ - **`AlwaysMove` accepts the anticipated resolution.** A quarter on beat 2 that repeats as the beat-3 resolution of a suspension held from the previous bar, the shape Salzer and Schachter name "anticipation of tone of resolution" (*Counterpoint in Composition*, p. 104), is no longer marked as a repeated note, whether or not the resolution ties forward. A repeated half, a repeated quarter with no suspension before it, and a quarter approached from below stay marked, so figure 85b keeps its mark in bar 9. Fux's figures 86a, 87 upper, 87a, and 88b lose their one `AlwaysMove` mark and rise under every guide that grades a counterpoint with it, 56 rows in the pinned corpus, each one message fewer; nothing else moves.
51
+
52
+ - **`NoteCountPerBar` judges the voice's own bars.** `OnePerBar`, `TwoPerBar`, `ThreePerBar`, and `FourPerBar` borrowed the companion voice's note list to decide which bars to inspect, so a voice with no companion passed on nothing, a bar was charged once per companion note when the companion was florid, and an empty bar's mark landed on the companion's note. They now judge the bars between the voice's own first and last notes, as `SustainAcrossBarlines` does, mark an empty bar as a span with no placements, and leave an empty voice to the minimum-notes gate. In the pinned corpus, 362 rows move, all under the first- through third-species guides: solo lines and lines against an empty companion are now marked; every cantus firmus graded on its own, whose companion voice is empty, is now marked by the second- and third-species count rules; the cantus voices of the species fixtures rise by a few thousandths where a florid companion had charged them once per note; and a counterpoint shorter than its cantus firmus is no longer charged for bars it never wrote. No harmony grade moves, and no counterpoint that runs the length of its cantus firmus moves.
53
+
54
+ - **A first-species line now grades materially lower against fourth species.** Across the ten valid Fux first-species fixtures, `fourth_species_melody` falls from 0.986 to between 0.68 and 0.71 and the `fourth_species` composite from 0.988 to between 0.80 and 0.84, while Fux's own fourth-species line grades 0.999 on the melody guide. Second, third, triple-meter, and fifth species already discounted a first-species line by a quarter through their melody primaries and are unchanged. The harmony guides deliberately do not check rhythm; `SpeciesHarmony` records that the melody guide's primary tier is where the species' rhythm is required.
55
+
56
+ - **Fux's fifth-species example now grades as fifth species.** Figure 82 rises from 0.819 to 0.937 on `fifth_species_harmony` and from 0.891 to 0.953 on the `fifth_species` composite, above his fourth-species line's 0.929 there, because its bar 9 suspension, resolved through an interposed consonant third, is no longer marked. No other corpus grade moves.
57
+
58
+ - **`FloridDissonanceTreatment`'s strings no longer promise to prepare and resolve tied suspensions.** The guideline judges each note at its attack, so a held-over suspension never reached it and the branch that claimed to handle one was unreachable; the branch is deleted and the English and British sentences now end at "on a weak beat". No grade changes.
59
+
60
+ - **Guide grading allocates about a quarter as many objects.** Positions compare field by field rather than through arrays; pitch and spelling names, a pitch's natural, a letter name's pitch class, and a diatonic interval's quality are memoized; pitch classes and chromatic intervals looked up by number skip name parsing; rhythmic unit names are indexed once rather than normalized on every parse; and `AvoidCrossingVoices` finds the predominant orientation once per assessment. Grading the pinned corpus falls from about 18.5 to 9.4 seconds, and no grade moves. `Pitch#name` and `Spelling#name` now return frozen strings.
61
+
62
+ - **`Flow`, `Voice`, `Position`, and `Placement` inspect as one line.** Each shows its class and `to_s`, as in `#<HeadMusic::Content::Placement eighth F♯4 at 2:2:240>`. The default printed the whole composition, since every placement reaches its voice and every voice its flow: two melodic note pairs from a seven-note voice inspected to two megabytes, in irb, in failure messages, and in RSpec's generated example descriptions.
63
+
64
+ ### Removed
65
+
66
+ - **`Content::Flow.from_v3_h` and `Content::Flow::V3HashDeserializer`,** as 21.0.0 announced. A schema-3 document is read with `Flow.from_v3_h` in head_music 21.x and saved again; `Flow.from_h` still rejects one with an error saying so.
67
+
68
+ - **`Style::Guidelines::MixedRhythmicValues`.** It asked only for three distinct durations and placed one mark when a line had fewer, which is why Fux's fourth-species line graded within 0.024 of his fifth-species line on the `fifth_species` composite. `MixSpeciesTextures` replaces it; no other guide used it.
69
+
70
+ ### Fixed
71
+
72
+ - **ABC repeat and volta tagging for a note that outlasts its bar.** The parser counted completed bars from where the last note started, so a note longer than its bar, or one now tied across the line, shifted every following repeat flag by a bar. Bars are counted from where the last note ends.
73
+
74
+ - **`AvoidCrossingVoices` marks the bars that leave the predominant orientation.** It deduplicated the voices' orientations before counting them, so the orientation heard first always won, and a counterpoint that opened below the cantus firmus and then stayed above it had its in-order bars marked as the crossings. The most common orientation now wins, and a tie still goes to the one heard first. No row of the pinned corpus moves: every crossing voice in it opens in its predominant orientation.
75
+
10
76
  ## [21.1.0] - 2026-09-10
11
77
 
12
78
  The [organizing content](https://github.com/roberthead/head_music/tree/main/user-stories/epics/organizing-content.md) epic's second story. 21.0.0 separated the document from the music; this separates the music from what it *is* and from how it is *shown*. A flow may now cite a `Work` — the piece, with its catalog number and its people — while a project credits whoever made *this version* of it, so Bach is credited for the work and Segovia for the arrangement. A `Layout` is a view of a project: which flows, which players, concert or written pitch, under what title. Two layouts over one project — a transposed score and a flute part book — are two documents from one body of music.
data/CLAUDE.md CHANGED
@@ -10,16 +10,18 @@ HeadMusic is a Ruby gem for Western music theory. It provides a comprehensive to
10
10
 
11
11
  ### Essential Commands
12
12
 
13
- `rake -T` lists the custom tasks. Two distinctions it does not make obvious:
13
+ `rake -T` lists the custom tasks.
14
14
 
15
15
  ```bash
16
- bundle exec rake # tests WITH coverage (the default task)
17
- bundle exec rspec # tests WITHOUT coverage
16
+ bundle exec rake validate # tests with coverage, linting, security audit, code quality
17
+ bundle exec rake # tests WITH coverage (the default task, same as `rake spec`)
18
+ bundle exec rspec # tests WITHOUT coverage, so a single file can run on its own
19
+ bundle exec rake coverage # opens the last coverage report
18
20
  ```
19
21
 
20
22
  ### Git Etiquette
21
23
 
22
- **IMPORTANT: Do not make a commit unless I explicitly ask you to.** Wait for explicit instruction before running `git commit`.
24
+ Commit when your judgment says the work is ready: a coherent, tested change, with one cause per commit where the work calls for it. You do not need to wait to be asked. Pushing, merging, and rewriting published history still wait for my go-ahead.
23
25
 
24
26
  When composing git commit messages, follow best-practices. Describe the change itself — do not narrate the assistant's process in the message body. Attribution trailers (`Co-Authored-By`, `Claude-Session`) are added when the session's attribution setting asks for them.
25
27
 
@@ -39,7 +41,7 @@ Do not use an assignment inside a condition.
39
41
 
40
42
  ### Testing
41
43
 
42
- Tests are written in RSpec and located in the `/spec` directory, mirroring the `/lib` structure. The project requires 90% code coverage minimum.
44
+ Tests are written in RSpec and located in the `/spec` directory, mirroring the `/lib` structure. The project requires 90% code coverage minimum. Coverage is opt-in: `rake spec` sets `COVERAGE=true`, and `spec_helper.rb` starts SimpleCov only then.
43
45
 
44
46
  ## Architecture
45
47
 
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- head_music (21.1.0)
4
+ head_music (22.0.0)
5
5
  activesupport (>= 7.0, < 10)
6
6
  humanize (>= 2, < 4)
7
7
  i18n (~> 1.8)
data/Rakefile CHANGED
@@ -2,7 +2,8 @@ require "bundler/gem_tasks"
2
2
  require "rspec/core/rake_task"
3
3
  require "standard/rake"
4
4
 
5
- RSpec::Core::RakeTask.new(:spec)
5
+ # The block runs when the task does, just before rspec is launched.
6
+ RSpec::Core::RakeTask.new(:spec) { ENV["COVERAGE"] = "true" }
6
7
 
7
8
  begin
8
9
  require "yard"
@@ -71,7 +72,7 @@ namespace :style do
71
72
  desc "Regenerate the pinned corpus grading (spec/fixtures/style/corpus_fitness.json)"
72
73
  task :snapshot_corpus_fitness do
73
74
  path = File.expand_path("spec/fixtures/style/corpus_fitness.json", __dir__)
74
- sh "bundle exec ruby bin/guide_grade_corpus.rb #{path}"
75
+ sh({"COVERAGE" => nil}, "bundle exec ruby bin/guide_grade_corpus.rb #{path}")
75
76
  puts "Wrote #{path}"
76
77
  end
77
78
  end
@@ -13,26 +13,12 @@
13
13
  # that the file is never edited. When a change needs a seam this script does not
14
14
  # yet have, the edit lands BEFORE both captures and is proven a no-op by diffing
15
15
  # a capture from either side of it.
16
- #
17
- # Loading the fixture exercises means loading spec_helper, which starts
18
- # SimpleCov and rewrites coverage/.last_run.json. That file is restored on the
19
- # way out, so a later `bundle exec rake` measures against the baseline it had.
20
16
 
21
17
  require "json"
22
18
 
23
19
  ROOT = File.expand_path("..", __dir__)
24
20
  $LOAD_PATH.unshift File.join(ROOT, "lib"), File.join(ROOT, "spec")
25
21
 
26
- COVERAGE_BASELINE = File.join(ROOT, "coverage", ".last_run.json")
27
- SAVED_BASELINE = File.exist?(COVERAGE_BASELINE) ? File.read(COVERAGE_BASELINE) : nil
28
- at_exit do
29
- if SAVED_BASELINE
30
- File.write(COVERAGE_BASELINE, SAVED_BASELINE)
31
- elsif File.exist?(COVERAGE_BASELINE)
32
- File.delete(COVERAGE_BASELINE)
33
- end
34
- end
35
-
36
22
  require "head_music"
37
23
  require "flow_context"
38
24
  require "spec_helper"
@@ -44,14 +44,7 @@ class HeadMusic::Analysis::Circle < HeadMusic::Analysis::IntervalCycle
44
44
  end
45
45
 
46
46
  def pitches_down
47
- @pitches_down ||= [starting_pitch].tap do |list|
48
- loop do
49
- next_pitch = folded_down(list.last)
50
- break if next_pitch.pitch_class == starting_pitch.pitch_class
51
-
52
- list << next_pitch
53
- end
54
- end
47
+ @pitches_down ||= cycle_of_pitches { |pitch| folded_down(pitch) }
55
48
  end
56
49
 
57
50
  private
@@ -89,7 +89,7 @@ class HeadMusic::Analysis::DiatonicInterval
89
89
  end
90
90
 
91
91
  def quality
92
- HeadMusic::Rudiment::Quality.get(quality_name)
92
+ @quality ||= HeadMusic::Rudiment::Quality.get(quality_name)
93
93
  end
94
94
 
95
95
  def inversion
@@ -39,9 +39,14 @@ class HeadMusic::Analysis::IntervalCycle
39
39
  protected
40
40
 
41
41
  def pitches_up
42
- @pitches_up ||= [starting_pitch].tap do |list|
42
+ @pitches_up ||= cycle_of_pitches { |pitch| folded_up(pitch) }
43
+ end
44
+
45
+ # Steps from the starting pitch until the cycle returns to its pitch class.
46
+ def cycle_of_pitches
47
+ [starting_pitch].tap do |list|
43
48
  loop do
44
- next_pitch = folded_up(list.last)
49
+ next_pitch = yield(list.last)
45
50
  break if next_pitch.pitch_class == starting_pitch.pitch_class
46
51
 
47
52
  list << next_pitch
@@ -24,20 +24,15 @@ class HeadMusic::Analysis::Motion
24
24
  end
25
25
 
26
26
  def parallel?
27
- upper_melodic_interval.moving? &&
28
- upper_melodic_interval.direction == lower_melodic_interval.direction &&
29
- upper_melodic_interval.steps == lower_melodic_interval.steps
27
+ upper_melodic_interval.moving? && same_direction? && same_steps?
30
28
  end
31
29
 
32
30
  def similar?
33
- upper_melodic_interval.direction == lower_melodic_interval.direction &&
34
- upper_melodic_interval.steps != lower_melodic_interval.steps
31
+ same_direction? && !same_steps?
35
32
  end
36
33
 
37
34
  def contrary?
38
- upper_melodic_interval.moving? &&
39
- lower_melodic_interval.moving? &&
40
- upper_melodic_interval.direction != lower_melodic_interval.direction
35
+ upper_melodic_interval.moving? && lower_melodic_interval.moving? && !same_direction?
41
36
  end
42
37
 
43
38
  def notes
@@ -51,19 +46,27 @@ class HeadMusic::Analysis::Motion
51
46
  end
52
47
 
53
48
  def to_s
54
- return "repetition of a #{second_harmonic_interval}" unless contrapuntal_motion != :repetition
49
+ return "repetition of a #{second_harmonic_interval}" if contrapuntal_motion == :repetition
55
50
 
56
51
  "#{contrapuntal_motion} motion from a #{first_harmonic_interval} to a #{second_harmonic_interval}"
57
52
  end
58
53
 
59
54
  private
60
55
 
56
+ def same_direction?
57
+ upper_melodic_interval.direction == lower_melodic_interval.direction
58
+ end
59
+
60
+ def same_steps?
61
+ upper_melodic_interval.steps == lower_melodic_interval.steps
62
+ end
63
+
61
64
  def upper_melodic_interval
62
- HeadMusic::Analysis::MelodicInterval.new(upper_notes.first, upper_notes.last)
65
+ @upper_melodic_interval ||= HeadMusic::Analysis::MelodicInterval.new(*upper_notes)
63
66
  end
64
67
 
65
68
  def lower_melodic_interval
66
- HeadMusic::Analysis::MelodicInterval.new(lower_notes.first, lower_notes.last)
69
+ @lower_melodic_interval ||= HeadMusic::Analysis::MelodicInterval.new(*lower_notes)
67
70
  end
68
71
 
69
72
  def upper_notes
@@ -37,25 +37,15 @@ class HeadMusic::Analysis::Sonority
37
37
  # @param inversion [Integer] the inversion number (default: 0 for root position)
38
38
  # @return [Sonority, nil] the sonority object, or nil if identifier not found
39
39
  def self.get(identifier, root: DEFAULT_ROOT, inversion: 0)
40
- identifier = identifier.to_sym
41
- return nil unless SONORITIES.key?(identifier)
40
+ interval_shorthands = SONORITIES[identifier.to_sym]
41
+ return nil unless interval_shorthands
42
42
 
43
43
  root_pitch = HeadMusic::Rudiment::Pitch.get(root)
44
- interval_shorthands = SONORITIES[identifier]
45
-
46
- # Build pitches: root + intervals above root
47
44
  pitches = [root_pitch] + interval_shorthands.map do |shorthand|
48
- interval = HeadMusic::Analysis::DiatonicInterval.get(shorthand)
49
- interval.above(root_pitch)
45
+ HeadMusic::Analysis::DiatonicInterval.get(shorthand).above(root_pitch)
50
46
  end
51
-
52
47
  pitch_collection = HeadMusic::Analysis::PitchCollection.new(pitches)
53
-
54
- # Apply inversions if requested
55
- inversion.times do
56
- pitch_collection = pitch_collection.invert
57
- end
58
-
48
+ inversion.times { pitch_collection = pitch_collection.invert }
59
49
  new(pitch_collection)
60
50
  end
61
51
 
@@ -83,28 +73,22 @@ class HeadMusic::Analysis::Sonority
83
73
  def identifier
84
74
  return @identifier if defined?(@identifier)
85
75
 
86
- @identifier = SONORITIES.keys.detect do |key|
87
- inversions.map do |inversion|
88
- inversion.diatonic_intervals_above_bass_pitch.map(&:shorthand)
89
- end.include?(SONORITIES[key])
90
- end
76
+ @identifier = SONORITIES.keys.detect { |key| inversion_shorthands.include?(SONORITIES[key]) }
91
77
  end
92
78
 
93
79
  def inversion
94
- @inversion ||= inversions.index do |inversion|
95
- SONORITIES[identifier] == inversion.diatonic_intervals_above_bass_pitch.map(&:shorthand)
96
- end
80
+ @inversion ||= inversion_shorthands.index(SONORITIES[identifier])
97
81
  end
98
82
 
99
83
  def inversions
100
- @inversions ||= begin
101
- inversion = reduction
102
- inversions = []
103
- inversion.pitches.length.times do |_i|
104
- inversions << inversion
105
- inversion = inversion.uninvert
106
- end
107
- inversions
84
+ @inversions ||= reduction.pitches.each_with_object([]) do |_pitch, list|
85
+ list << (list.last&.uninvert || reduction)
86
+ end
87
+ end
88
+
89
+ def inversion_shorthands
90
+ @inversion_shorthands ||= inversions.map do |inversion|
91
+ inversion.diatonic_intervals_above_bass_pitch.map(&:shorthand)
108
92
  end
109
93
  end
110
94
 
@@ -5,6 +5,7 @@ module HeadMusic::Content; end
5
5
  # single place the level constraint is enforced.
6
6
  class HeadMusic::Content::Credits
7
7
  include Enumerable
8
+ include HeadMusic::ValueEquality
8
9
 
9
10
  LEVELS = HeadMusic::Content::Role::LEVELS
10
11
 
@@ -14,6 +15,8 @@ class HeadMusic::Content::Credits
14
15
 
15
16
  attr_reader :level, :credits
16
17
 
18
+ value_equality :level, :credits
19
+
17
20
  def initialize(level, credits = [])
18
21
  @level = level&.to_sym
19
22
  raise ArgumentError, "unknown credit level: #{level.inspect}" unless LEVELS.include?(@level)
@@ -51,15 +54,6 @@ class HeadMusic::Content::Credits
51
54
  credits.map(&:to_h)
52
55
  end
53
56
 
54
- def ==(other)
55
- other.is_a?(self.class) && level == other.level && credits == other.credits
56
- end
57
- alias_method :eql?, :==
58
-
59
- def hash
60
- [self.class, level, credits].hash
61
- end
62
-
63
57
  private
64
58
 
65
59
  def ensure_credit(credit)
@@ -9,11 +9,11 @@ class HeadMusic::Content::Flow
9
9
 
10
10
  private
11
11
 
12
- # A v3 document is told where 20.1.0 still reads it, rather than merely
13
- # rejected, because that reader still ships.
12
+ # No recipe migrates v3 in place, so a v3 document is told which release
13
+ # still reads it rather than merely rejected.
14
14
  def unsupported_version_message(version)
15
15
  message = super
16
- message += "; read it with Flow.from_v3_h, which is retained in 21.x and removed in 22.0.0" if version == 3
16
+ message += "; read it with Flow.from_v3_h in head_music 21.x and save it again" if version == 3
17
17
  message
18
18
  end
19
19
 
@@ -23,12 +23,6 @@ class HeadMusic::Content::Flow
23
23
  HashDeserializer.new(hash).flow
24
24
  end
25
25
 
26
- # Retained read-only through 21.x so that persisted v3 data can be migrated
27
- # by reading and re-saving. Removed in 22.0.0.
28
- def self.from_v3_h(hash)
29
- V3HashDeserializer.new(hash).flow
30
- end
31
-
32
26
  def self.from_json(json)
33
27
  from_h(JSON.parse(json))
34
28
  end
@@ -145,7 +139,13 @@ class HeadMusic::Content::Flow
145
139
  end
146
140
 
147
141
  def to_s
148
- "#{name} — #{voices.count} #{(voices.count == 1) ? "voice" : "voices"}"
142
+ "#{name} — #{voices.count} #{"voice".pluralize(voices.count)}"
143
+ end
144
+
145
+ # The default would print the whole composition, since every placement
146
+ # reaches its voice and every voice its flow.
147
+ def inspect
148
+ "#<#{self.class.name} #{self}>"
149
149
  end
150
150
 
151
151
  def to_abc(**options)
@@ -18,13 +18,18 @@ class HeadMusic::Content::Layout
18
18
  hash = hash.transform_keys(&:to_s)
19
19
  {
20
20
  kind: (hash["kind"] || :custom).to_sym,
21
- flows: hash["flows"] && Array(hash["flows"]).map { |index| project.flows[index] },
22
- players: hash["players"] && Array(hash["players"]).map { |index| project.players[index] },
21
+ flows: members_at(hash["flows"], project.flows),
22
+ players: members_at(hash["players"], project.players),
23
23
  concert_pitch: hash.fetch("concert_pitch", true),
24
24
  title_override: hash["title_override"]
25
25
  }
26
26
  end
27
27
 
28
+ def self.members_at(indexes, collection)
29
+ indexes && Array(indexes).map { |index| collection[index] }
30
+ end
31
+ private_class_method :members_at
32
+
28
33
  def initialize(project:, kind: :custom, flows: nil, players: nil, concert_pitch: true, title_override: nil)
29
34
  ensure_known_kind!(kind)
30
35
 
@@ -68,11 +73,12 @@ class HeadMusic::Content::Layout
68
73
  Realization.new(self, flow).flow
69
74
  end
70
75
 
76
+ # A selection holds only the project's players, never nil, so a part with no
77
+ # player is kept only when nothing is selected.
78
+ #
71
79
  # @api private for Layout::Realization
72
80
  def selects?(part)
73
- return true if selected_players.nil?
74
-
75
- !part.player.nil? && selected_players.include?(part.player)
81
+ selected_players.nil? || selected_players.include?(part.player)
76
82
  end
77
83
 
78
84
  # Which of the flow's parts survive the selection, by index, in the order they
@@ -95,9 +101,9 @@ class HeadMusic::Content::Layout
95
101
  # rendered; each tune carries its own T:.
96
102
  def to_abc
97
103
  ensure_something_to_render!
98
- rendered_flows.each_with_index
99
- .map { |flow, index| HeadMusic::Notation::ABC.render(realize(flow), reference_number: index + 1, transposed: transposed?) }
100
- .join("\n")
104
+ rendered_flows.map.with_index(1) do |flow, number|
105
+ HeadMusic::Notation::ABC.render(realize(flow), reference_number: number, transposed: transposed?)
106
+ end.join("\n")
101
107
  end
102
108
 
103
109
  # A single flow renders exactly as the flow would on its own; several render
@@ -105,15 +111,16 @@ class HeadMusic::Content::Layout
105
111
  def to_lilypond
106
112
  ensure_something_to_render!
107
113
  realized = rendered_flows.map { |flow| realize(flow) }
108
- return HeadMusic::Notation::LilyPond.render(realized.first, transposed: transposed?, arranger: arranger) if realized.one?
114
+ return HeadMusic::Notation::LilyPond.render(realized.first, **rendering_options) if realized.one?
109
115
 
110
- HeadMusic::Notation::LilyPond::BookWriter.new(realized, title: title, transposed: transposed?, arranger: arranger).to_s
116
+ HeadMusic::Notation::LilyPond::BookWriter.new(realized, title: title, **rendering_options).to_s
111
117
  end
112
118
 
113
119
  def to_musicxml
114
- if rendered_flows.length > 1
120
+ count = rendered_flows.length
121
+ if count > 1
115
122
  raise HeadMusic::Notation::RenderError,
116
- "MusicXML holds one flow per document and this layout renders #{rendered_flows.length}; use #to_musicxml_documents"
123
+ "MusicXML holds one flow per document and this layout renders #{count}; use #to_musicxml_documents"
117
124
  end
118
125
 
119
126
  to_musicxml_documents.first
@@ -121,8 +128,8 @@ class HeadMusic::Content::Layout
121
128
 
122
129
  def to_musicxml_documents
123
130
  ensure_something_to_render!
124
- rendered_flows.each_with_index.map do |flow, index|
125
- HeadMusic::Notation::MusicXML.render(realize(flow), **musicxml_options(index))
131
+ rendered_flows.map.with_index(1) do |flow, number|
132
+ HeadMusic::Notation::MusicXML.render(realize(flow), **musicxml_options(number))
126
133
  end
127
134
  end
128
135
 
@@ -138,7 +145,7 @@ class HeadMusic::Content::Layout
138
145
  end
139
146
 
140
147
  def to_s
141
- "#{title} — #{kind} layout of #{rendered_flows.length} #{(rendered_flows.length == 1) ? "flow" : "flows"}"
148
+ "#{title} — #{kind} layout of #{rendered_flows.length} #{"flow".pluralize(rendered_flows.length)}"
142
149
  end
143
150
 
144
151
  private
@@ -167,10 +174,14 @@ class HeadMusic::Content::Layout
167
174
 
168
175
  # A document standing alone names only itself, which is what keeps a one-flow
169
176
  # layout byte-identical to the flow's own output.
170
- def musicxml_options(index)
171
- return {transposed: transposed?, arranger: arranger} if single_flow?
177
+ def musicxml_options(movement_number)
178
+ return rendering_options if single_flow?
179
+
180
+ rendering_options.merge(work_title: title, movement_number: movement_number)
181
+ end
172
182
 
173
- {work_title: title, movement_number: index + 1, transposed: transposed?, arranger: arranger}
183
+ def rendering_options
184
+ {transposed: transposed?, arranger: arranger}
174
185
  end
175
186
 
176
187
  # The project's arrangers, joined as the composer is: this version's credit,
@@ -187,8 +198,8 @@ class HeadMusic::Content::Layout
187
198
  end
188
199
 
189
200
  def shared_work_title
190
- works = flows.map(&:work)
191
- works.first&.title if works.uniq.length == 1
201
+ works = flows.map(&:work).uniq
202
+ works.first&.title if works.size == 1
192
203
  end
193
204
 
194
205
  # Positions, not objects: a player and a flow are identified in a document
@@ -81,7 +81,7 @@ class HeadMusic::Content::Part
81
81
  end
82
82
 
83
83
  def to_s
84
- [player&.name, "#{voices.length} #{(voices.length == 1) ? "voice" : "voices"}"].compact.join(": ")
84
+ [player&.name, "#{voices.length} #{"voice".pluralize(voices.length)}"].compact.join(": ")
85
85
  end
86
86
 
87
87
  # Sparse: a part with no instrument and no authored staves serializes as its
@@ -119,6 +119,10 @@ class HeadMusic::Content::Placement
119
119
  "#{rhythmic_value} #{sounds.any? ? sounds.map { |sound| sound_label(sound) }.join(" ") : "rest"} at #{position}"
120
120
  end
121
121
 
122
+ def inspect
123
+ "#<#{self.class.name} #{self}>"
124
+ end
125
+
122
126
  def to_h
123
127
  hash = {
124
128
  "position" => position.to_s,