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.
- checksums.yaml +4 -4
- data/.claude/skills/release/SKILL.md +93 -0
- data/.github/workflows/ci.yml +5 -3
- data/.github/workflows/release.yml +3 -1
- data/CHANGELOG.md +113 -0
- data/CLAUDE.md +7 -5
- data/Gemfile.lock +1 -1
- data/README.md +1 -15
- data/Rakefile +3 -2
- data/bin/guide_grade_corpus.rb +0 -14
- data/lib/head_music/analysis/circle.rb +1 -8
- data/lib/head_music/analysis/diatonic_interval.rb +1 -1
- data/lib/head_music/analysis/interval_cycle.rb +7 -2
- data/lib/head_music/analysis/motion.rb +14 -11
- data/lib/head_music/analysis/sonority.rb +14 -30
- data/lib/head_music/content/cantus_firmus/example.rb +1 -10
- data/lib/head_music/content/cantus_firmus/source.rb +26 -13
- data/lib/head_music/content/credit.rb +32 -0
- data/lib/head_music/content/credits.rb +70 -0
- data/lib/head_music/content/flow/hash_deserializer.rb +27 -14
- data/lib/head_music/content/flow.rb +47 -28
- data/lib/head_music/content/layout/realization.rb +72 -0
- data/lib/head_music/content/layout/transposition.rb +160 -0
- data/lib/head_music/content/layout.rb +210 -0
- data/lib/head_music/content/part.rb +1 -1
- data/lib/head_music/content/person.rb +54 -0
- data/lib/head_music/content/placement.rb +4 -0
- data/lib/head_music/content/position.rb +9 -1
- data/lib/head_music/content/project.rb +52 -20
- data/lib/head_music/content/publication.rb +81 -0
- data/lib/head_music/content/role.rb +94 -0
- data/lib/head_music/content/score.rb +85 -0
- data/lib/head_music/content/voice.rb +4 -0
- data/lib/head_music/content/work.rb +51 -0
- data/lib/head_music/instruments/score_order.rb +58 -43
- data/lib/head_music/locales/de.yml +13 -0
- data/lib/head_music/locales/en.yml +34 -6
- data/lib/head_music/locales/en_GB.yml +2 -2
- data/lib/head_music/locales/es.yml +13 -0
- data/lib/head_music/locales/fr.yml +13 -0
- data/lib/head_music/locales/it.yml +13 -0
- data/lib/head_music/locales/ru.yml +13 -0
- data/lib/head_music/notation/abc/duration_writer.rb +6 -1
- data/lib/head_music/notation/abc/parser.rb +8 -6
- data/lib/head_music/notation/abc/voice_state.rb +36 -10
- data/lib/head_music/notation/abc/writer.rb +96 -51
- data/lib/head_music/notation/abc.rb +2 -1
- data/lib/head_music/notation/lily_pond/book_writer.rb +57 -0
- data/lib/head_music/notation/lily_pond/voice_stream.rb +13 -11
- data/lib/head_music/notation/lily_pond/voice_writer.rb +52 -42
- data/lib/head_music/notation/lily_pond/writer.rb +33 -24
- data/lib/head_music/notation/lily_pond.rb +2 -2
- data/lib/head_music/notation/music_xml/attributes_writer.rb +42 -13
- data/lib/head_music/notation/music_xml/writer.rb +34 -31
- data/lib/head_music/notation/music_xml.rb +7 -4
- data/lib/head_music/notation/render_plan.rb +77 -23
- data/lib/head_music/rudiment/chromatic_interval.rb +2 -0
- data/lib/head_music/rudiment/key_signature.rb +9 -24
- data/lib/head_music/rudiment/letter_name.rb +1 -1
- data/lib/head_music/rudiment/pitch.rb +2 -2
- data/lib/head_music/rudiment/pitch_class.rb +2 -0
- data/lib/head_music/rudiment/rhythmic_unit/parser.rb +12 -11
- data/lib/head_music/rudiment/spelling.rb +1 -1
- data/lib/head_music/style/guide_item.rb +11 -16
- data/lib/head_music/style/guideline/bar_span.rb +40 -0
- data/lib/head_music/style/guidelines/allow_fifth_species_rhythmic_values.rb +1 -1
- data/lib/head_music/style/guidelines/allow_whole_half_quarter_notes.rb +1 -1
- data/lib/head_music/style/guidelines/always_move.rb +29 -0
- data/lib/head_music/style/guidelines/avoid_crossing_voices.rb +3 -6
- data/lib/head_music/style/guidelines/contoured.rb +2 -10
- data/lib/head_music/style/guidelines/diatonic.rb +53 -9
- data/lib/head_music/style/guidelines/embellished_suspension_treatment.rb +36 -0
- data/lib/head_music/style/guidelines/florid_dissonance_treatment.rb +10 -34
- data/lib/head_music/style/guidelines/mix_species_textures.rb +84 -0
- data/lib/head_music/style/guidelines/note_count_per_bar.rb +6 -23
- data/lib/head_music/style/guidelines/prefer_long_before_short.rb +43 -0
- data/lib/head_music/style/guidelines/second_species_break.rb +3 -4
- data/lib/head_music/style/guidelines/step_out_of_unison.rb +14 -2
- data/lib/head_music/style/guidelines/suspension_treatment.rb +1 -3
- data/lib/head_music/style/guidelines/sustain_across_barlines.rb +43 -0
- data/lib/head_music/style/guidelines/weak_beat_dissonance_treatment.rb +1 -3
- data/lib/head_music/style/guides/base.rb +2 -4
- data/lib/head_music/style/guides/composite_guide.rb +2 -4
- data/lib/head_music/style/guides/configured.rb +5 -13
- data/lib/head_music/style/guides/fifth_species_harmony.rb +1 -1
- data/lib/head_music/style/guides/fifth_species_melody.rb +2 -2
- data/lib/head_music/style/guides/fourth_species_melody.rb +6 -1
- data/lib/head_music/style/guides/species_harmony.rb +5 -0
- data/lib/head_music/style/guides/tiered_items.rb +10 -0
- data/lib/head_music/time/musical_position.rb +6 -1
- data/lib/head_music/time/musical_time_converter.rb +11 -10
- data/lib/head_music/value_equality.rb +5 -0
- data/lib/head_music/version.rb +1 -1
- data/lib/head_music.rb +17 -2
- data/references/content-schema.md +546 -0
- data/references/fifth-species-counterpoint.md +29 -31
- data/references/wemi.md +6 -6
- data/user-stories/backlog/lower-the-contour-floor.md +69 -0
- data/user-stories/backlog/sixteenth-century-style.md +87 -0
- data/user-stories/done/embellish-fifth-species-suspensions.md +473 -0
- data/user-stories/done/extract-the-harmonic-cores.md +7 -7
- data/user-stories/done/fifth-species-guide-improvements.md +631 -0
- data/user-stories/done/grade-every-fux-fifth-species-figure.md +479 -0
- data/user-stories/done/identity-and-presentation.md +424 -0
- data/user-stories/done/improve-melodic-contour-guidelines.md +398 -0
- data/user-stories/done/require-the-species-rhythm.md +546 -0
- data/user-stories/index.html +32 -12
- metadata +28 -6
- data/lib/head_music/content/flow/v3_hash_deserializer.rb +0 -52
- data/lib/head_music/style/guidelines/mixed_rhythmic_values.rb +0 -19
- data/user-stories/backlog/identity-and-presentation.md +0 -148
- 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:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4f716235926455127be636b1b1c2c575a4ce7f0f4b41f7952620dfda908a8671
|
|
4
|
+
data.tar.gz: 378c4749d7fbcc0ac7168f086cae549126d4fd8e648e5703d4311a2a9b63f5ad
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
data/.github/workflows/ci.yml
CHANGED
|
@@ -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.
|
|
103
|
-
#
|
|
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.
|
|
13
|
+
`rake -T` lists the custom tasks.
|
|
14
14
|
|
|
15
15
|
```bash
|
|
16
|
-
bundle exec rake
|
|
17
|
-
bundle exec
|
|
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
|
-
|
|
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
data/README.md
CHANGED
|
@@ -194,21 +194,7 @@ bundle exec rake doc
|
|
|
194
194
|
|
|
195
195
|
### Releasing a New Version
|
|
196
196
|
|
|
197
|
-
|
|
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
|
-
|
|
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
|
data/bin/guide_grade_corpus.rb
CHANGED
|
@@ -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 ||=
|
|
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
|
|
@@ -39,9 +39,14 @@ class HeadMusic::Analysis::IntervalCycle
|
|
|
39
39
|
protected
|
|
40
40
|
|
|
41
41
|
def pitches_up
|
|
42
|
-
@pitches_up ||=
|
|
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 =
|
|
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
|
-
|
|
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}"
|
|
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
|
|
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
|
|
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
|
-
|
|
41
|
-
return nil unless
|
|
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
|
-
|
|
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
|
|
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 ||=
|
|
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 ||=
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|