head_music 21.0.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 (112) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/release/SKILL.md +93 -0
  3. data/.github/workflows/ci.yml +5 -3
  4. data/.github/workflows/release.yml +3 -1
  5. data/CHANGELOG.md +113 -0
  6. data/CLAUDE.md +7 -5
  7. data/Gemfile.lock +1 -1
  8. data/README.md +1 -15
  9. data/Rakefile +3 -2
  10. data/bin/guide_grade_corpus.rb +0 -14
  11. data/lib/head_music/analysis/circle.rb +1 -8
  12. data/lib/head_music/analysis/diatonic_interval.rb +1 -1
  13. data/lib/head_music/analysis/interval_cycle.rb +7 -2
  14. data/lib/head_music/analysis/motion.rb +14 -11
  15. data/lib/head_music/analysis/sonority.rb +14 -30
  16. data/lib/head_music/content/cantus_firmus/example.rb +1 -10
  17. data/lib/head_music/content/cantus_firmus/source.rb +26 -13
  18. data/lib/head_music/content/credit.rb +32 -0
  19. data/lib/head_music/content/credits.rb +70 -0
  20. data/lib/head_music/content/flow/hash_deserializer.rb +27 -14
  21. data/lib/head_music/content/flow.rb +47 -28
  22. data/lib/head_music/content/layout/realization.rb +72 -0
  23. data/lib/head_music/content/layout/transposition.rb +160 -0
  24. data/lib/head_music/content/layout.rb +210 -0
  25. data/lib/head_music/content/part.rb +1 -1
  26. data/lib/head_music/content/person.rb +54 -0
  27. data/lib/head_music/content/placement.rb +4 -0
  28. data/lib/head_music/content/position.rb +9 -1
  29. data/lib/head_music/content/project.rb +52 -20
  30. data/lib/head_music/content/publication.rb +81 -0
  31. data/lib/head_music/content/role.rb +94 -0
  32. data/lib/head_music/content/score.rb +85 -0
  33. data/lib/head_music/content/voice.rb +4 -0
  34. data/lib/head_music/content/work.rb +51 -0
  35. data/lib/head_music/instruments/score_order.rb +58 -43
  36. data/lib/head_music/locales/de.yml +13 -0
  37. data/lib/head_music/locales/en.yml +34 -6
  38. data/lib/head_music/locales/en_GB.yml +2 -2
  39. data/lib/head_music/locales/es.yml +13 -0
  40. data/lib/head_music/locales/fr.yml +13 -0
  41. data/lib/head_music/locales/it.yml +13 -0
  42. data/lib/head_music/locales/ru.yml +13 -0
  43. data/lib/head_music/notation/abc/duration_writer.rb +6 -1
  44. data/lib/head_music/notation/abc/parser.rb +8 -6
  45. data/lib/head_music/notation/abc/voice_state.rb +36 -10
  46. data/lib/head_music/notation/abc/writer.rb +96 -51
  47. data/lib/head_music/notation/abc.rb +2 -1
  48. data/lib/head_music/notation/lily_pond/book_writer.rb +57 -0
  49. data/lib/head_music/notation/lily_pond/voice_stream.rb +13 -11
  50. data/lib/head_music/notation/lily_pond/voice_writer.rb +52 -42
  51. data/lib/head_music/notation/lily_pond/writer.rb +33 -24
  52. data/lib/head_music/notation/lily_pond.rb +2 -2
  53. data/lib/head_music/notation/music_xml/attributes_writer.rb +42 -13
  54. data/lib/head_music/notation/music_xml/writer.rb +34 -31
  55. data/lib/head_music/notation/music_xml.rb +7 -4
  56. data/lib/head_music/notation/render_plan.rb +77 -23
  57. data/lib/head_music/rudiment/chromatic_interval.rb +2 -0
  58. data/lib/head_music/rudiment/key_signature.rb +9 -24
  59. data/lib/head_music/rudiment/letter_name.rb +1 -1
  60. data/lib/head_music/rudiment/pitch.rb +2 -2
  61. data/lib/head_music/rudiment/pitch_class.rb +2 -0
  62. data/lib/head_music/rudiment/rhythmic_unit/parser.rb +12 -11
  63. data/lib/head_music/rudiment/spelling.rb +1 -1
  64. data/lib/head_music/style/guide_item.rb +11 -16
  65. data/lib/head_music/style/guideline/bar_span.rb +40 -0
  66. data/lib/head_music/style/guidelines/allow_fifth_species_rhythmic_values.rb +1 -1
  67. data/lib/head_music/style/guidelines/allow_whole_half_quarter_notes.rb +1 -1
  68. data/lib/head_music/style/guidelines/always_move.rb +29 -0
  69. data/lib/head_music/style/guidelines/avoid_crossing_voices.rb +3 -6
  70. data/lib/head_music/style/guidelines/contoured.rb +2 -10
  71. data/lib/head_music/style/guidelines/diatonic.rb +53 -9
  72. data/lib/head_music/style/guidelines/embellished_suspension_treatment.rb +36 -0
  73. data/lib/head_music/style/guidelines/florid_dissonance_treatment.rb +10 -34
  74. data/lib/head_music/style/guidelines/mix_species_textures.rb +84 -0
  75. data/lib/head_music/style/guidelines/note_count_per_bar.rb +6 -23
  76. data/lib/head_music/style/guidelines/prefer_long_before_short.rb +43 -0
  77. data/lib/head_music/style/guidelines/second_species_break.rb +3 -4
  78. data/lib/head_music/style/guidelines/step_out_of_unison.rb +14 -2
  79. data/lib/head_music/style/guidelines/suspension_treatment.rb +1 -3
  80. data/lib/head_music/style/guidelines/sustain_across_barlines.rb +43 -0
  81. data/lib/head_music/style/guidelines/weak_beat_dissonance_treatment.rb +1 -3
  82. data/lib/head_music/style/guides/base.rb +2 -4
  83. data/lib/head_music/style/guides/composite_guide.rb +2 -4
  84. data/lib/head_music/style/guides/configured.rb +5 -13
  85. data/lib/head_music/style/guides/fifth_species_harmony.rb +1 -1
  86. data/lib/head_music/style/guides/fifth_species_melody.rb +2 -2
  87. data/lib/head_music/style/guides/fourth_species_melody.rb +6 -1
  88. data/lib/head_music/style/guides/species_harmony.rb +5 -0
  89. data/lib/head_music/style/guides/tiered_items.rb +10 -0
  90. data/lib/head_music/time/musical_position.rb +6 -1
  91. data/lib/head_music/time/musical_time_converter.rb +11 -10
  92. data/lib/head_music/value_equality.rb +5 -0
  93. data/lib/head_music/version.rb +1 -1
  94. data/lib/head_music.rb +17 -2
  95. data/references/content-schema.md +546 -0
  96. data/references/fifth-species-counterpoint.md +29 -31
  97. data/references/wemi.md +6 -6
  98. data/user-stories/backlog/lower-the-contour-floor.md +69 -0
  99. data/user-stories/backlog/sixteenth-century-style.md +87 -0
  100. data/user-stories/done/embellish-fifth-species-suspensions.md +473 -0
  101. data/user-stories/done/extract-the-harmonic-cores.md +7 -7
  102. data/user-stories/done/fifth-species-guide-improvements.md +631 -0
  103. data/user-stories/done/grade-every-fux-fifth-species-figure.md +479 -0
  104. data/user-stories/done/identity-and-presentation.md +424 -0
  105. data/user-stories/done/improve-melodic-contour-guidelines.md +398 -0
  106. data/user-stories/done/require-the-species-rhythm.md +546 -0
  107. data/user-stories/index.html +32 -12
  108. metadata +28 -6
  109. data/lib/head_music/content/flow/v3_hash_deserializer.rb +0 -52
  110. data/lib/head_music/style/guidelines/mixed_rhythmic_values.rb +0 -19
  111. data/user-stories/backlog/identity-and-presentation.md +0 -148
  112. 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: 3adeb7e3fa4f5422977916729f285e9ebb50309409927b25b284dbbb35d2932d
4
- data.tar.gz: 78f4b8b6922142b56e185cc23da551576ba3ffd375d51eb6aa34f3382e30530f
3
+ metadata.gz: 4f716235926455127be636b1b1c2c575a4ce7f0f4b41f7952620dfda908a8671
4
+ data.tar.gz: 378c4749d7fbcc0ac7168f086cae549126d4fd8e648e5703d4311a2a9b63f5ad
5
5
  SHA512:
6
- metadata.gz: 444f66354a57746f2c9acf747462bf3f2396f76b8b9c7fe16d4c2bc93020bbe178349f437d7135141e587ffb9fef09c57bdf6a9145159b47a88af908960b608b
7
- data.tar.gz: 4c38611fa5aea3b2530310dc33644ab29af9c81c523eb37e39fe94298d79c71ee982791e1278c449cd4d91c99623e21f1a2f3c9f4882e648c8b1783f6ff94a7c
6
+ metadata.gz: 433e4a30ce9a523d69e194b37b72f3cc5499c91d6e3605c1c901ed63ad9d2c308ae9c891876eb0cb9beef288450cc59287fbabb63e0c3115a160e8d111d032ad
7
+ data.tar.gz: 925aa64aabc77e319868ab18d71964bea6ee1aae5a02477a827283b9ce01c13eb1203b1d0ff87ae361f41c41bbc02965446acc77caa1f6a1c451fe227a718806
@@ -0,0 +1,93 @@
1
+ ---
2
+ name: release
3
+ description: Prepare and publish a head_music release. Verifies the previous version actually shipped, settles the bump with the user, moves Unreleased into a dated CHANGELOG section, bumps version.rb and Gemfile.lock, runs the suite, writes the release commit, and stops before tagging and publishing. Use when the user asks to release, cut a version, bump the version, or publish the gem.
4
+ ---
5
+
6
+ # Release
7
+
8
+ The version bump and the release are one transaction. The bump commit is the
9
+ release commit, and a version that is bumped but never tagged and pushed is a
10
+ defect, not an intermediate state. This skill prepares everything, then stops
11
+ before the irreversible step.
12
+
13
+ ## 1. Preflight
14
+
15
+ Run these before touching anything. Stop and report if any fails.
16
+
17
+ - On `main`, clean working tree, up to date with `origin/main`.
18
+ - The previous version shipped. Compare `lib/head_music/version.rb` with
19
+ `gem search -r -a head_music`. If the version file is already ahead of
20
+ RubyGems and its CHANGELOG section carries a date, a bump was committed but
21
+ never published. Say so. The fix is usually to fold the new entries into
22
+ that section and release it, not to bump again.
23
+ - The `## [Unreleased]` section of `CHANGELOG.md` has entries. If it is
24
+ empty, there is nothing to release.
25
+ - `bundle exec rake` passes with coverage and `bundle exec rubocop` is clean.
26
+
27
+ ## 2. Choose the bump
28
+
29
+ Read the Unreleased subsections and put the choice to the user. Do not decide
30
+ alone when a Changed or Removed subsection is present. The project's
31
+ convention so far:
32
+
33
+ - Additive only (Added, and Fixed): minor.
34
+ - Behavior changes with no signature change, such as a grading rule that
35
+ regrades existing melodies: minor, called out in the release intro.
36
+ - A removed class or method, a serialization schema bump, or a document that
37
+ needs migration: major. 21.0.0 is the model.
38
+ - Fixes only: patch.
39
+
40
+ ## 3. Prepare
41
+
42
+ - In `CHANGELOG.md`, rename `## [Unreleased]` to `## [X.Y.Z] - YYYY-MM-DD`
43
+ with today's date, and insert a fresh empty `## [Unreleased]` above it with
44
+ blank lines around both headings. Subsections stay in Keep a Changelog
45
+ order: Added, Changed, Deprecated, Removed, Fixed, Security.
46
+ - For a minor or major release, open the section with a short intro
47
+ paragraph the way 21.0.0 and 21.1.0 do: what the release is for, and a bold
48
+ sentence naming the bump and what a consumer must do to upgrade.
49
+ - Set `VERSION` in `lib/head_music/version.rb`.
50
+ - Run `bundle install` so `Gemfile.lock` records the new version.
51
+ - Run `bundle exec rake` and `bundle exec rubocop` again.
52
+ - Show the user the diff: CHANGELOG, version file, lockfile, nothing else.
53
+
54
+ ## 4. Commit and push
55
+
56
+ Only when the user asks. Subject is `Release X.Y.Z`. Add a body only when the
57
+ number needs explaining, such as why a release is major or which behavior
58
+ changed. Push `main`.
59
+
60
+ ## 5. Tag
61
+
62
+ Stop here. Do not tag without the user's explicit go in this conversation.
63
+ Pushing the tag starts the publish, and a published version cannot be
64
+ withdrawn.
65
+
66
+ ```bash
67
+ bundle exec rake release:source_control_push
68
+ ```
69
+
70
+ This creates the annotated tag `vX.Y.Z` and pushes it. It does not push the
71
+ gem. The tag push triggers `.github/workflows/release.yml`, which runs the
72
+ suite and linter, builds the gem, creates the GitHub Release, and publishes to
73
+ RubyGems through trusted publishing. Do not run plain `rake release`: its gem
74
+ push would race the workflow's, and one of them would be rejected as a
75
+ re-push.
76
+
77
+ Prerequisite, once: the workflow must be registered as a trusted publisher at
78
+ rubygems.org/gems/head_music/trusted_publishers, with repository
79
+ `roberthead/head_music` and workflow file `release.yml`.
80
+
81
+ ## 6. Verify
82
+
83
+ - `gh run watch` on the run that the tag started, or
84
+ `gh run list --workflow=release.yml --limit 1`. Report its status. Do not
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.
89
+ - `gh release view vX.Y.Z` shows the GitHub Release with the gem attached.
90
+
91
+ If the workflow fails after the tag is pushed, the fallback is
92
+ `bundle exec rake release:rubygem_push`, which needs a RubyGems one-time
93
+ password and so is the user's to run with the `!` prefix.
@@ -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
@@ -43,8 +43,10 @@ jobs:
43
43
  # short-lived credential, so there is no API key to store or rotate.
44
44
  # Requires head_music to have this workflow registered as a trusted
45
45
  # publisher at rubygems.org/gems/head_music/trusted_publishers.
46
+ # The action publishes full version tags only, no v2 alias, so it is
47
+ # pinned to a release.
46
48
  - name: Configure RubyGems credentials
47
- uses: rubygems/configure-rubygems-credentials@v2
49
+ uses: rubygems/configure-rubygems-credentials@v2.1.0
48
50
 
49
51
  - name: Publish to RubyGems
50
52
  run: gem push *.gem
data/CHANGELOG.md CHANGED
@@ -7,6 +7,119 @@ 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
+
76
+ ## [21.1.0] - 2026-09-10
77
+
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.
79
+
80
+ **This is a minor release.** Everything below is additive: no public method changes signature or return type, `Flow#composer` is still a `String` or nil, `Flow.new`'s keywords are only extended, every writer's new option defaults to what it did before, and `Flow#to_abc`, `#to_lilypond`, and `#to_musicxml` are byte-identical for every flow that existed in 21.0.0. The serialization schema stays at 4 (see the last entry). The one behavioral change is the contour grading under Changed, which alters no signature but does regrade some melodies. A consumer upgrades by upgrading.
81
+
82
+ ### Added
83
+
84
+ - **`HeadMusic::Content::Work`, with `Person`, `Credit`, `Credits`, and `Role`.** A work is the catalog identity of a composition — a title, a catalog number, a year, and its people — independent of any one notated version of it. A `Flow` may cite one, or cite none, and flows in one project may cite different works: a sonata is one project whose four flows cite one work, a fake book is one project whose eighty flows cite eighty, and a counterpoint exercise cites none. Putting the identity on the project instead would have made the model lie in exactly the cases this gem is most used for.
85
+
86
+ **Credits are constrained by the level they attach to**, so the model cannot record a publisher as having composed the music:
87
+
88
+ | Level | Roles |
89
+ |---|---|
90
+ | `Work` | composer, songwriter, lyricist, librettist |
91
+ | `Project` | arranger, transcriber, orchestrator, reconstructor |
92
+ | `Publication` | author, editor, engraver, publisher |
93
+
94
+ `Credits.new(level)` is the single place that constraint is enforced — `credits.add(person, :arranger)` on a work raises `ArgumentError` naming both levels — and `Credit` itself is level-agnostic. `Role` is `Named` and translated like the rest of the gem's vocabulary, but unlike a rudiment getter an unrecognized identifier raises rather than minting a role: the twelve are the whole vocabulary. `Person` is one identity rather than one spelling of a name, requiring a full name, with an optional sort name and independently optional birth and death years; `Work`, `Person`, `Credit`, and `Credits` are all frozen values, so two flows citing the same work hold two equal objects and `project.works` deduplicates them.
95
+
96
+ - **`Flow#work` and `Flow#source`, and `Project#credits`.** `Flow#composer` now answers the cited work's composer and falls back to the authored string, which is what the ABC `C:` and LilyPond `composer =` readers fill with text like "Trad." or "arr. J. Smith" — not a person, and never minting a work. A work with no composer credit falls through to the string too, so a lyricist-only work still prints the name it was authored with, and `Flow#to_h` writes the derived string, so **every existing document renders exactly as it did**. `origin` stays a plain string: ABC's `O:` is geographic provenance, which nothing at the work level holds. `Project#add_credit(person, role)` records this version's people.
97
+
98
+ - **`HeadMusic::Content::Publication`**, the edition a flow cites as its `source` — the book, treatise, or score, with its own credits, distinct from the work it publishes. `CantusFirmus::Source` is now a `Publication` carrying a catalog key: `Source.get`, `.all`, `.keys`, `#publication_name`, `#publication_edition`, `#author_names`, `#abbreviation`, and `#notes` are all unchanged, and their specs pass unedited. `Example#to_flow` cites its source on the flow it builds, so the citation is a fact about the music rather than only about the catalog, and a serialized source round-trips through its key back into the catalog entry itself. `author` joins the publication-level roles because these sources are treatises, whose people are authors rather than editors.
99
+
100
+ - **`HeadMusic::Content::Layout`.** A layout selects flows and players (`nil` means all of them), renders in concert or written pitch, and titles the document with `title_override` — which changes what is displayed without touching `work.title` or `flow.name`. A selected flow that no selected player has a part in is skipped rather than rendered empty: a flute part book has two movements, not a silent third. A layout refuses a flow or player its project does not hold, so a stray selection fails when it is made rather than serializing as an absence, and `add_layout(kind: :score)` answers a `Score`, so every layout reads back as the class it was written as.
101
+
102
+ ```ruby
103
+ score = project.add_score(ensemble_type: :orchestral, concert_pitch: false)
104
+ book = project.add_layout(kind: :part, players: [flutist], title_override: "Flute")
105
+ ```
106
+
107
+ Each format renders a document of the shape that format has for a book: `#to_abc` writes a tune book, one numbered `X:` per flow separated by a blank line, which `ABC.parse_book` reads back; `#to_lilypond` writes one document with one `\header` and one `\score` per flow, each headed with its movement's own `piece`, via the new `LilyPond::BookWriter`; and because MusicXML holds one flow per document, `#to_musicxml_documents` answers one string per flow and `#to_musicxml` raises for more than one, naming the plural method. Rendering goes through a realized flow rather than through writer options, so the writers, both render plans, and both preflights know nothing about selection — and an all-flows, all-players, concert-pitch layout of one flow renders byte-identically to that flow's own output, in all three formats.
108
+
109
+ - **`HeadMusic::Content::Score`,** the layout that shows the players together. `#ordered_players` is a permutation of the layout's players in the ensemble's conventional order, and `#player_groups` splits them into the sections a score brackets. Neither is the score's own knowledge: `Instruments::ScoreOrder` already carried it per ensemble type and now exposes `#position_of(instrument)`, `#section_key_of(instrument)`, and `#group(instruments)`, sharing one section index with `#order` so ordering and grouping cannot disagree. Ties keep authored order, so two clarinets stay first and second, and a chair with an unknown instrument or none at all sorts last under a `nil` section key rather than disappearing. `ensemble_type` must be a `ScoreOrder` key or nil, which is authored order.
110
+
111
+ - **Transposition to written pitch.** A transposed layout renders each part at the pitch its player reads. The move is spelled rather than counted — the semitones decompose into a diatonic interval plus whole octaves — so a clarinet's sounding D is a written E and never an F♭, and a key signature moves by moving its tonic spelling and keeping its scale type rather than by arithmetic on fifths.
112
+
113
+ **Each part gets its own written key**, so a mixed ensemble renders as one document: a transposed score of flute, B♭ clarinet, and horn in F carries three key signatures, and a part that picks up an A clarinet at bar 9 gains a key change there that no other part sees. The written key is derived at render time by `Notation::RenderPlan`, whose `#first_measure_key` and `#measure_key_changes` now take the part; it is a rendering fact, not content, so it reaches no model field and no schema key. MusicXML gains `<transpose>` after `<clef>`, LilyPond a `\transposition` per staff, and ABC writes the written key into `K:`. Concert pitch means sounding pitch for every part, octave transposers included. A written key needing more than seven sharps or flats raises `RenderError` naming the enharmonic to write the part in instead, rather than failing midway through assembly.
114
+
115
+ - **Writer options, each defaulting to prior behavior**: `transposed:` on `ABC.render`, `LilyPond.render`, and `MusicXML.render`; `work_title:` and `movement_number:` on `MusicXML.render`, which emit `<movement-title>` and `<movement-number>` only when a document names a whole this flow is one movement of; and `arranger:` on `LilyPond.render` and `MusicXML.render`, which emit `arranger = "..."` and `<creator type="arranger">`. A `Layout` fills the last of these from `project.credits`, which is what makes the story's arranger visible on the page; `Flow#to_*` passes none of them, so its output is unchanged. ABC has no arranger field and no book title, so a multi-tune layout's title is not rendered there.
116
+
117
+ - **Optional document keys**: `"work"` and `"source"` on a flow, `"credits"` and `"layouts"` on a project, all absent-means-none. **The schema stays 4.** The rule, now written into `references/content-schema.md`: a rename or a container restructure bumps the schema version, because an old reader would read such a document *wrongly*; a new optional key does not, because the readers look up the keys they know and never enumerate the hash. A 21.0.0 reader accepts a 21.1.0 document and ignores what it has no home for — and `"composer"` still carries the derived name, so even that loss does not reach the page. Bumping to 5 would only have made 21.0.0 reject documents it reads perfectly well.
118
+
119
+ ### Changed
120
+
121
+ - **`Style::Guidelines::Contoured` judges arch and valley by the whole line.** An arch is now a melody whose trend directions are exactly ascending then descending, one rise past the reversal threshold and one fall, and a valley is the mirror. Before, an interior climax alone made an arch, so a line that rose a third, plunged a sixth, and climbed back passed arch, valley, and wave at once. Arch, valley, and wave are now mutually exclusive. Neighbor-note motion stays under the threshold, so an arch with a passing dip on the way up, or a 7-1 step at the close, still reads as an arch. **Some melodies previously graded as arch or valley now grade as wave, or as no contour at all**: in the pinned corpus, 47 of the 63 voices that passed `arch_contour_melody` and 23 of the 26 that passed `valley_contour_melody` now fail it. Nearly all of those are published cantus firmi and first-species lines that rise and fall more than once and now read as waves; a few that climb without a real reversal read as ascending only. `ascending`, `descending`, `static`, and `wave` are unchanged, as are the registered contour guides and their gates.
122
+
10
123
  ## [21.0.0] - 2026-09-06
11
124
 
12
125
  The [organizing content](https://github.com/roberthead/head_music/tree/main/user-stories/epics/organizing-content.md) epic's first story. `Content::Composition` was the document, the movement, the timeline, and the credits at once, and its `Voice` was a bare melodic line with no instrument, no staff, and no performer — a shape adequate for two-voice species counterpoint and for almost nothing else. Content is now `Project` → `Flow` → `Part` → `Voice` → `Placement`, and a voice can cross between the staves of its part.
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.0.0)
4
+ head_music (22.0.0)
5
5
  activesupport (>= 7.0, < 10)
6
6
  humanize (>= 2, < 4)
7
7
  i18n (~> 1.8)
data/README.md CHANGED
@@ -194,21 +194,7 @@ bundle exec rake doc
194
194
 
195
195
  ### Releasing a New Version
196
196
 
197
- 1. Update the version number in `lib/head_music/version.rb`
198
- 2. Commit the version change: `git commit -am "Bump version to X.Y.Z"`
199
- 3. Push to main: `git push origin main`
200
- 4. Release the gem:
201
-
202
- ```bash
203
- bundle exec rake release
204
- ```
205
-
206
- This will:
207
- - Build the gem
208
- - Create and push a git tag (e.g., `vX.Y.Z`)
209
- - Push the gem to RubyGems
210
-
211
- The git tag push also triggers a GitHub Actions workflow that creates a GitHub Release with auto-generated release notes.
197
+ The release checklist lives in [`.claude/skills/release/SKILL.md`](.claude/skills/release/SKILL.md). Run `/release` in Claude Code, or follow it by hand. In short: move the Unreleased changelog entries under a dated heading, bump `lib/head_music/version.rb`, refresh `Gemfile.lock`, commit as `Release X.Y.Z`, and then `bundle exec rake release:source_control_push` tags the release. The tag push runs the release workflow, which publishes the gem to RubyGems and creates the GitHub Release.
212
198
 
213
199
  ## Contributing
214
200
 
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
 
@@ -2,7 +2,6 @@ module HeadMusic
2
2
  module Content
3
3
  module CantusFirmus
4
4
  # Sample cantus firmus examples from various pedagogical sources.
5
- # These are traditional melodies used for teaching counterpoint.
6
5
  class Example
7
6
  EXAMPLES_DATA = YAML.load_file(File.expand_path("examples.yml", __dir__)).freeze
8
7
 
@@ -52,20 +51,12 @@ module HeadMusic
52
51
  pitches.length
53
52
  end
54
53
 
55
- # Realize the example as a standalone flow: one part, no player, one
56
- # voice, one note per bar.
57
- #
58
54
  # An example is a catalog datum -- a pitch list with a mode and a
59
55
  # citation -- not content, so rhythm and meter are the realization's
60
56
  # choice rather than the datum's, and are parameters.
61
- #
62
- # The example's tonal center and mode land on the flow's opening key
63
- # signature with no loss: the mode is carried by the tonal context, not
64
- # inferred from the signature, which is what lets an example in
65
- # E phrygian and one in D dorian share a signature of zero without
66
- # collapsing into each other.
67
57
  def to_flow(rhythmic_value: :whole, meter: "4/4")
68
58
  flow = HeadMusic::Content::Flow.new(name: to_s, key_signature: key_signature_name, meter: meter)
59
+ flow.source = source
69
60
  voice = flow.add_voice(role: "cantus firmus")
70
61
  pitches.each_with_index { |pitch, index| voice.place("#{index + 1}:1", rhythmic_value, pitch) }
71
62
  flow