head_music 20.1.0 → 21.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.claude/skills/release/SKILL.md +92 -0
- data/.github/workflows/release.yml +3 -1
- data/CHANGELOG.md +147 -1
- data/CLAUDE.md +6 -48
- data/Gemfile.lock +1 -1
- data/README.md +1 -15
- data/Rakefile +7 -0
- data/bin/guide_grade_corpus.rb +14 -92
- data/bin/guide_grade_table.rb +1 -1
- data/lib/head_music/analysis/harmonic_interval.rb +1 -1
- data/lib/head_music/content/bar.rb +15 -14
- data/lib/head_music/content/cantus_firmus/example.rb +17 -1
- data/lib/head_music/content/cantus_firmus/source.rb +26 -13
- data/lib/head_music/content/comment.rb +7 -7
- data/lib/head_music/content/credit.rb +32 -0
- data/lib/head_music/content/credits.rb +76 -0
- data/lib/head_music/content/flow/deserializer.rb +98 -0
- data/lib/head_music/content/flow/hash_deserializer.rb +116 -0
- data/lib/head_music/content/{composition → flow}/schema_values.rb +68 -15
- data/lib/head_music/content/flow/staff_system_values.rb +69 -0
- data/lib/head_music/content/flow/timeline.rb +234 -0
- data/lib/head_music/content/flow/v3_hash_deserializer.rb +52 -0
- data/lib/head_music/content/flow.rb +242 -0
- 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 +199 -0
- data/lib/head_music/content/note.rb +1 -1
- data/lib/head_music/content/part.rb +122 -0
- data/lib/head_music/content/person.rb +54 -0
- data/lib/head_music/content/placement.rb +3 -3
- data/lib/head_music/content/player.rb +44 -0
- data/lib/head_music/content/position.rb +81 -39
- data/lib/head_music/content/project.rb +138 -0
- 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/staff.rb +59 -15
- data/lib/head_music/content/staff_system.rb +49 -0
- data/lib/head_music/content/voice/continuity.rb +4 -4
- data/lib/head_music/content/voice.rb +84 -10
- 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 +13 -0
- 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/book_parser.rb +4 -4
- data/lib/head_music/notation/abc/duration_resolver.rb +1 -1
- data/lib/head_music/notation/abc/duration_writer.rb +1 -1
- data/lib/head_music/notation/abc/header.rb +1 -1
- data/lib/head_music/notation/abc/parser.rb +7 -7
- data/lib/head_music/notation/abc/preflight.rb +1 -1
- data/lib/head_music/notation/abc/repeat_tagger.rb +6 -6
- data/lib/head_music/notation/abc/voice_registry.rb +4 -4
- data/lib/head_music/notation/abc/writer.rb +54 -40
- data/lib/head_music/notation/abc.rb +8 -7
- data/lib/head_music/notation/clef_selector.rb +5 -5
- data/lib/head_music/notation/lily_pond/book_writer.rb +57 -0
- data/lib/head_music/notation/lily_pond/document.rb +2 -2
- data/lib/head_music/notation/lily_pond/{composition_builder.rb → flow_builder.rb} +12 -12
- data/lib/head_music/notation/lily_pond/lexer.rb +43 -106
- data/lib/head_music/notation/lily_pond/music_reader.rb +1 -1
- data/lib/head_music/notation/lily_pond/parse_preflight.rb +1 -1
- data/lib/head_music/notation/lily_pond/parser.rb +7 -7
- data/lib/head_music/notation/lily_pond/preflight.rb +13 -13
- data/lib/head_music/notation/lily_pond/render_plan.rb +6 -4
- data/lib/head_music/notation/lily_pond/source_scanner.rb +90 -0
- data/lib/head_music/notation/lily_pond/voice_stream.rb +1 -1
- data/lib/head_music/notation/lily_pond/voice_writer.rb +129 -0
- data/lib/head_music/notation/lily_pond/writer.rb +84 -60
- data/lib/head_music/notation/lily_pond.rb +9 -9
- data/lib/head_music/notation/music_xml/attributes_writer.rb +132 -0
- data/lib/head_music/notation/music_xml/divisions.rb +9 -12
- data/lib/head_music/notation/music_xml/key_mapper.rb +12 -5
- data/lib/head_music/notation/music_xml/note_writer.rb +29 -4
- data/lib/head_music/notation/music_xml/preflight.rb +12 -12
- data/lib/head_music/notation/music_xml/render_plan.rb +19 -7
- data/lib/head_music/notation/music_xml/writer.rb +84 -96
- data/lib/head_music/notation/music_xml.rb +9 -6
- data/lib/head_music/notation/preflight_checks.rb +5 -5
- data/lib/head_music/notation/render_plan.rb +84 -27
- data/lib/head_music/rudiment/clefs.yml +0 -1
- data/lib/head_music/rudiment/key.rb +28 -0
- data/lib/head_music/rudiment/note.rb +2 -2
- data/lib/head_music/rudiment/unpitched_note.rb +1 -1
- data/lib/head_music/style/guideline/voice_context.rb +2 -2
- data/lib/head_music/style/guideline.rb +4 -4
- data/lib/head_music/style/guidelines/contoured.rb +2 -10
- data/lib/head_music/style/guidelines/first_bar_entry.rb +1 -1
- data/lib/head_music/style/guidelines/note_fills_final_bar.rb +1 -1
- data/lib/head_music/time/conductor.rb +0 -3
- data/lib/head_music/time/event_map.rb +172 -0
- data/lib/head_music/time/key_signature_event.rb +111 -0
- data/lib/head_music/time/meter_event.rb +1 -1
- data/lib/head_music/time/meter_map.rb +20 -34
- data/lib/head_music/time/musical_position.rb +33 -46
- data/lib/head_music/time/musical_time_converter.rb +4 -4
- data/lib/head_music/time/radix_carry.rb +8 -3
- data/lib/head_music/time/tempo_map.rb +20 -49
- data/lib/head_music/time.rb +7 -3
- data/lib/head_music/version.rb +1 -1
- data/lib/head_music.rb +22 -4
- data/references/content-schema.md +546 -0
- data/references/wemi.md +102 -0
- data/user-stories/backlog/ensemble-sessions.md +82 -0
- data/user-stories/done/content-architecture.md +834 -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/epics/organizing-content.md +137 -0
- data/user-stories/index.html +246 -234
- metadata +38 -8
- data/lib/head_music/content/composition/hash_deserializer.rb +0 -113
- data/lib/head_music/content/composition.rb +0 -149
- data/lib/head_music/time/event_map_support.rb +0 -29
- data/user-stories/backlog/organizing-content.md +0 -83
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: eadecb642354dfc4c70cf00303a0c1c27513fdd38b13d8bc81fe9c8e7fd3214b
|
|
4
|
+
data.tar.gz: b534a1e194b0bd6ba4dae42433e92091812e434ec0fddb03c7617e47147207ab
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: aa22ebc311f8ebe62590e99f90f0d65d639b250626f9668520cd604e0f2fec4c1ea7af4d9b944a19585c52ea1b941906f12d2910888e0a4005a7f582936547ec
|
|
7
|
+
data.tar.gz: fde9ba8d894229bc349c861b24a0e56108f2a718526b67465c7b8631c9401a8d243196d4f632d1ebce890726c718b4371d2110fcc4e80eef39747d7ec1e610a1
|
|
@@ -0,0 +1,92 @@
|
|
|
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. The workflow failed on every run before 2026-09-10, first
|
|
86
|
+
on a denied API key and then on an action version that did not exist.
|
|
87
|
+
- `gem search -r head_music` lists the new version.
|
|
88
|
+
- `gh release view vX.Y.Z` shows the GitHub Release with the gem attached.
|
|
89
|
+
|
|
90
|
+
If the workflow fails after the tag is pushed, the fallback is
|
|
91
|
+
`bundle exec rake release:rubygem_push`, which needs a RubyGems one-time
|
|
92
|
+
password and so is the user's to run with the `!` prefix.
|
|
@@ -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,151 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [21.1.0] - 2026-09-10
|
|
11
|
+
|
|
12
|
+
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.
|
|
13
|
+
|
|
14
|
+
**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.
|
|
15
|
+
|
|
16
|
+
### Added
|
|
17
|
+
|
|
18
|
+
- **`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.
|
|
19
|
+
|
|
20
|
+
**Credits are constrained by the level they attach to**, so the model cannot record a publisher as having composed the music:
|
|
21
|
+
|
|
22
|
+
| Level | Roles |
|
|
23
|
+
|---|---|
|
|
24
|
+
| `Work` | composer, songwriter, lyricist, librettist |
|
|
25
|
+
| `Project` | arranger, transcriber, orchestrator, reconstructor |
|
|
26
|
+
| `Publication` | author, editor, engraver, publisher |
|
|
27
|
+
|
|
28
|
+
`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.
|
|
29
|
+
|
|
30
|
+
- **`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.
|
|
31
|
+
|
|
32
|
+
- **`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.
|
|
33
|
+
|
|
34
|
+
- **`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.
|
|
35
|
+
|
|
36
|
+
```ruby
|
|
37
|
+
score = project.add_score(ensemble_type: :orchestral, concert_pitch: false)
|
|
38
|
+
book = project.add_layout(kind: :part, players: [flutist], title_override: "Flute")
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
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.
|
|
42
|
+
|
|
43
|
+
- **`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.
|
|
44
|
+
|
|
45
|
+
- **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.
|
|
46
|
+
|
|
47
|
+
**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.
|
|
48
|
+
|
|
49
|
+
- **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.
|
|
50
|
+
|
|
51
|
+
- **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.
|
|
52
|
+
|
|
53
|
+
### Changed
|
|
54
|
+
|
|
55
|
+
- **`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.
|
|
56
|
+
|
|
57
|
+
## [21.0.0] - 2026-09-06
|
|
58
|
+
|
|
59
|
+
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.
|
|
60
|
+
|
|
61
|
+
This is a breaking release. `Composition` is removed rather than deprecated, and the serialization schema goes to 4.
|
|
62
|
+
|
|
63
|
+
**Migrating from 20.1.0**, in the order a consumer will hit them:
|
|
64
|
+
|
|
65
|
+
1. **`HeadMusic::Content::Composition` is `HeadMusic::Content::Flow`.** The constant is gone, not aliased. `Flow.new` takes the same keyword arguments, and `#add_voice`, `#voices`, `#bars`, `#to_h`, `#to_abc`, `#to_lilypond`, and `#to_musicxml` all behave as they did, so most call sites need only the constant renamed. `#add_voice` now mints a `Part` per voice behind the scenes; every document this gem produced before still renders byte-identically.
|
|
66
|
+
|
|
67
|
+
2. **`LilyPond.parse` and `ABC.parse` return a `Flow`.** So do `ABC::BookParser#flows` (was `#compositions`) and `LilyPond::FlowBuilder` (was `CompositionBuilder`). These changed return type five days after the LilyPond reader shipped in 20.1.0; they are named individually here because a reader scanning for "Composition" will not otherwise notice that `parse` moved.
|
|
68
|
+
|
|
69
|
+
3. **Persisted schema-3 documents need one read-and-re-save.** `Flow.from_h` rejects a v3 hash with an error naming `Flow.from_v3_h`, which is retained read-only for this reason and removed in 22.0.0. The previous bumps shipped a key-rename recipe — v2 to v3 was "rename each placement's `pitches` key to `sounds`", doable in SQL against a jsonb column — but v3 to v4 restructures the container, so no equivalent recipe can be written. **20.1.0 is the last version that reads v3 directly.**
|
|
70
|
+
|
|
71
|
+
4. **`HeadMusic::Content::Staff` is a different class under the same name.** The 20.x `Content::Staff` was dead code, referenced by nothing but its own spec, and has been deleted; the constant is now the instance-layer staff described below. The new one takes only keyword arguments, so `Content::Staff.new(:bass_clef)` raises `ArgumentError` rather than quietly reading the clef as something else — but the readers changed too: `#default_clef` is `#clef` (and answers `nil` where none was authored, rather than falling back to treble), and `#instrument` is `#instruments_staff`, which references the catalog staff instead of an instrument.
|
|
72
|
+
|
|
73
|
+
5. **`Time::MusicalPosition#beat` is `#count`**, and `FIRST_BEAT` is `FIRST_COUNT`. `Meter` already distinguished the two — 6/8 has two beats and six counts — so a position was misnamed against the gem's own vocabulary.
|
|
74
|
+
|
|
75
|
+
6. **Grades do not move.** Every guide assesses every voice of the pinned corpus to the fitness it produced before the refactor began, which is asserted rather than assumed. Stored fitness from 20.x remains comparable.
|
|
76
|
+
|
|
77
|
+
### Added
|
|
78
|
+
|
|
79
|
+
- **`HeadMusic::Content::Project`, `Flow`, `Part`, and `Player`.** A `Project` is the document: players and flows. A `Flow` is a continuous span of music owning its own timeline — a movement, a song, a cue, an exercise. A `Player` is a chair in the project ("Flute 1", "Piano"); a `Part` is that chair's music within one flow. Pairing them that way is what makes "the flute plays in movements 1 and 3" expressible without a nullable join — there is simply no `Part` for that player in movement 2 — and what makes an instrument change *within* a part honest, since conceptually it is still the same player.
|
|
80
|
+
|
|
81
|
+
**Containment is total; context is optional.** A voice is always in a part, always in a flow, so `voice.part.staff_system_at(bar)` never needs a nil check. What is optional is the upward reference: `Flow#project` and `Part#player` may be absent. A flow with no project is how a chunk of music lives outside a document — a cantus firmus, a scale, a parsed snippet — and it renders to ABC, LilyPond, and MusicXML with no project at all. `Project#add_flow` adopts such a flow, minting a player for each part that has none.
|
|
82
|
+
|
|
83
|
+
- **Voices orthogonal to staves.** A voice belongs to a part and *has* a staff at any given moment, so a piano voice can start in the bass staff and cross into the treble without leaving its part:
|
|
84
|
+
|
|
85
|
+
```ruby
|
|
86
|
+
left_hand.cross_to(treble_staff, from: 5)
|
|
87
|
+
left_hand.cross_to(bass_staff, from: 9)
|
|
88
|
+
left_hand.staff_at(6) # => the treble staff
|
|
89
|
+
left_hand.staff_at(9) # => back to the bass staff
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
A crossing is one event, not a span: a left hand that rises for four bars and comes back down is two crossings, each authored where it happens, and a single cross-staff note is a crossing and, a bar later, another. There is no note-level special case, and nothing to overlap.
|
|
93
|
+
|
|
94
|
+
MusicXML renders the part as one `<score-part>` with `<staves>`, a numbered `<clef>` per staff, `<voice>` per voice separated by `<backup>`, and `<staff>` per note. LilyPond renders a `\new PianoStaff` (or `\new StaffGroup` for a bracket) with one named `\new Staff` per staff and `\change Staff` at the span boundaries. Both elements are omitted for a one-voice, one-staff part, so existing output is unchanged.
|
|
95
|
+
|
|
96
|
+
- **`HeadMusic::Content::Staff` and `StaffSystem`** — the instance layer. A content staff has a line count, a clef map, and an optional reference to an `Instruments::Staff` for percussion mapping; the catalog class already owns the position-to-instrument mappings, so the instance layer references rather than re-implements it. A `StaffSystem` is an ordered set of staves with a brace, a bracket, or neither; `StaffSystem.grand_staff` and `.single_staff` are the two shapes almost everything uses.
|
|
97
|
+
|
|
98
|
+
- **`HeadMusic::Time::EventMap`** — an ordered list of `(position, value)` events answering what is in force at a position. Everything that changes partway through a flow is this shape, and it is now written once: meter, tempo, and key signature on `Flow::Timeline`; instrument and staff system on `Part`; clef on `Staff`; staff assignment on `Voice`. Lookup is a binary search over tuples computed at insert. `#change_at` answers whether a change *starts* at a position, as distinct from what is in force there, which is what a writer needs in order to decide whether to print a signature.
|
|
99
|
+
|
|
100
|
+
- **`HeadMusic::Time::KeySignatureEvent`**, carrying a signature as fifths and, optionally, the interpretation of it. Neither derives the other. A signature underdetermines its interpretation — two sharps is D major, B minor, E dorian, or A mixolydian — and an interpretation does not fix its signature either, because the two legitimately diverge: C dorian written in cantus mollis takes the parallel minor's three flats and naturalizes the sixth.
|
|
101
|
+
|
|
102
|
+
Fifths rather than a `KeySignature` because a `KeySignature` cannot be built from a bare signature: `KeySignature.get("3 flats")` raises, so naming three flats means naming an interpretation of it, after which the stored tonic and quality are wrong whenever the interpretation disagrees. Fifths is also exactly what MusicXML stores.
|
|
103
|
+
|
|
104
|
+
- **`HeadMusic::Rudiment::Key.for_fifths(n)`** — the conventional reading of a signature that carries no interpretation of its own, for the two consumers that cannot proceed without a tonic: LilyPond's `\key`, and the `Diatonic` guideline. Signatures themselves are unbounded, since a theoretical key such as G♯ major counts each double accidental twice and reaches eight; the table stops at ±7, because past that there is no conventional major key to name. So past ±7 a `tonal_context` is required, and `change_key_signature` raises at authoring time rather than leaving every reader that needs a tonic to raise later.
|
|
105
|
+
|
|
106
|
+
- **`HeadMusic::Content::CantusFirmus::Example#to_flow(rhythmic_value:, meter:)`.** An example was a catalog datum — a pitch list with a mode and a citation — that nothing in the gem turned into music. It now realizes as a standalone flow with one part, no player, and one note per bar. Rhythm and meter are the realization's choice rather than the datum's, so they are parameters.
|
|
107
|
+
|
|
108
|
+
- **`Project#to_h` / `.from_h` / `#to_json` / `.from_json`**, and `Flow.from_v3_h`. Schema 4 round-trips players, flows, parts, voices, staff assignments, instrument changes, staff systems and their changes, clef changes, tempo and tempo changes, subtick-precise positions, and repeat structure, and round-trips a standalone flow as its own document. A tempo serializes as `{"beat_value", "beats_per_minute"}` rather than as a `"quarter = 72"` string, because `Tempo.get` reads the number by stripping non-digits and would turn 72.5 into 725.
|
|
109
|
+
- **`Flow.new(tempo:)`, `Flow#tempo`, and `Flow#change_tempo`**, alongside the meter and key signature equivalents. A tempo change allocates its bar the way a meter change does, and accepts a `Tempo`, a tempo name, or a `"quarter = 96"` string.
|
|
110
|
+
- **`Flow#remove_meter_change`, `#remove_key_signature_change`, and `#remove_tempo_change`** un-author a change, answering the removed value. 20.x cleared a change by passing `nil` to `change_*`; that now raises an `ArgumentError` naming the remover, rather than reaching a rudiment getter that would raise something unrelated.
|
|
111
|
+
|
|
112
|
+
- `Flow#position`, `Content::Position#subtick`, `Voice#assign_staff`, `Part#instrument_at` / `#staff_system_at` / `#instruments`, `Player#instruments` / `#primary_instrument` (derived from the parts, so they cannot drift from the instrument changes authored on them).
|
|
113
|
+
|
|
114
|
+
### Changed
|
|
115
|
+
|
|
116
|
+
- **Schema version 4.** `Flow#to_h` carries a `timeline` (opening meter and key signature, plus the changes to each) and `parts` (each with its instrument, instrument changes, staff system, and voices). Key and meter changes are no longer serialized per bar; a bar's own state is its repeat structure.
|
|
117
|
+
|
|
118
|
+
- **`Content::Bar` keeps only what is bar-shaped** — barlines, repeat structure, volta brackets. Its `#key_signature` and `#meter` are now derived reads of the change *authored in that bar*, and the writers `#key_signature=` and `#meter=` are gone; use `Flow#change_key_signature` and `#change_meter`. The bar had been carrying these in parallel with `Time::MeterMap` doing the same job properly and unused by `Content`.
|
|
119
|
+
|
|
120
|
+
- **`Clef.get(:tenor_clef)` is the C clef on the fourth line**, as in every theory text. It had been an alias of `vocal_tenor_clef`, the octave-down G clef of vocal scores, which won the lookup by appearing first in the catalog; that clef keeps its own name and its `tenor_g_clef` alias. `tenor_c_clef` still names the C clef too.
|
|
121
|
+
- **Meter and key signature changes take a bar number, and reject anything else.** Both are bar-aligned by definition, so `flow.change_meter("2:3", "3/4")` raises rather than rounding into a bar.
|
|
122
|
+
|
|
123
|
+
- **`Content::Position` wraps a `Time::MusicalPosition`.** It gains a subtick, and is normalized once at construction and then frozen — it is a sort key that `Voice#place` binary-searches over, and a mutable sort key is a wrong-note bug that raises nothing. `#eql?` and `#hash` are defined on the component tuple alongside `#<=>`; the flow deliberately takes no part in comparison, preserving the behavior `Voice#placement_at` has always guarded with. `#values` is removed in favor of `#to_a`, which now has four components. `#code` emits a subtick only when there is one, so the everyday `"bar:count:tick"` form is unchanged.
|
|
124
|
+
|
|
125
|
+
- **`Notation::RenderPlan#key_value` receives a key signature event** rather than a key signature, because the two formats want different things from it. `MusicXML::KeyMapper.mode` now takes a tonal context and returns `nil` where there is none, and the writer omits `<mode>` rather than inventing a major. LilyPond renders what is printed at the clef: the interpretation where it agrees with the signature, so a D dorian flow still prints `\key d \dorian`, and the signature where they diverge, so cantus mollis prints `\key c \minor`.
|
|
126
|
+
|
|
127
|
+
- **`Notation::ClefSelector` is demoted to a fallback.** When a staff has an authored clef the writers use it; the selector infers one from a voice's pitch range only for a part whose staves were never authored — an ABC import, a bare counterpoint exercise. The fallback stays in the writers rather than moving onto `Staff`, because it reads a *voice's* range and a staff has no back-reference to one.
|
|
128
|
+
|
|
129
|
+
- `Time::PPQN` is an alias of `Rudiment::Rhythm::PPQN` rather than a second literal 960.
|
|
130
|
+
|
|
131
|
+
### Removed
|
|
132
|
+
|
|
133
|
+
- `HeadMusic::Content::Composition`, and the dead `HeadMusic::Content::Staff` (see migration note 4).
|
|
134
|
+
- `HeadMusic::Time::EventMapSupport`, superseded by `Time::EventMap`.
|
|
135
|
+
- `Time::MusicalPosition#to_total_subticks` and its `#to_i` alias, `TempoMap#normalize_position`, and `TempoMap#meter=` — all of which existed to support the comparator fixed below.
|
|
136
|
+
|
|
137
|
+
### Fixed
|
|
138
|
+
|
|
139
|
+
- **`Time::MusicalPosition#normalize!` destroyed the last count of every bar.** `RadixCarry#carry` used `divmod`, which is correct for the 0-indexed tick and subtick and wrong for the 1-indexed count: in 3/4, `1:3:0:0` normalized to the invalid `2:0:0:0`, and so did `1:4:0:0` in 4/4 and `1:6:0:0` in 6/8. A 1-indexed component is now shifted into 0-indexed space and back.
|
|
140
|
+
|
|
141
|
+
- **`Time::MusicalPosition#<=>` was not a total order across a meter change.** It converted both positions to elapsed subticks through a single stored meter, assuming every prior bar had it, so a position in bar 4 of 4/4 compared *greater* than one in bar 5 of 7/8. Positions now compare their component tuple lexically, which needs no meter at all.
|
|
142
|
+
|
|
143
|
+
Both bugs were latent in 20.x only because `Time` was unused by `Content`. They are on the path every note travels now.
|
|
144
|
+
|
|
145
|
+
- **A `Content::Position` rolled across a meter change kept the origin bar's count unit.** The tick carry ran under the meter of the bar the position started in, so a quarter after `1:4:480` in 4/4 landed in a 6/8 bar spelled `2:1:480`, where the same instant is `2:2:000`; the two compared unequal, so a placement rolled into the bar and one authored there did not merge. A position is now carried again under the destination bar's meter until it lands in a bar it was carried under.
|
|
146
|
+
|
|
147
|
+
- **MusicXML `<backup>` rewound a whole measure regardless of what the preceding voice wrote.** In a multi-voice part whose earlier voice ended mid-bar, the cursor went negative -- invalid MusicXML, with no error. It now rewinds by the duration actually written. Whole-measure filler rests also carry `<voice>` and `<staff>`, where before a reader stacked them onto voice 1 of staff 1 and left a grand staff's bass staff empty.
|
|
148
|
+
|
|
149
|
+
- **Every clef the gem knows renders to LilyPond** by its LilyPond name -- alto, tenor, soprano, mezzosoprano, baritone, varbaritone, french, subbass, percussion, and the quoted octave clefs `"treble_8"` and `"treble^8"` -- where an authored clef other than bass had rendered as treble.
|
|
150
|
+
|
|
151
|
+
- **A part with no voices renders**, in both writers, as a staff of whole-measure rests under its clef, key, and time, so a tacet chair keeps its line in the score. LilyPond had dropped the part; MusicXML had raised `NoMethodError`.
|
|
152
|
+
|
|
153
|
+
- **A one-staff part holding several voices renders in LilyPond as one staff** with a `\new Voice` per voice in parallel, named for its part, as MusicXML already rendered it. It had rendered as one staff per voice.
|
|
154
|
+
|
|
10
155
|
## [20.1.0] - 2026-09-05
|
|
11
156
|
|
|
12
157
|
The other half of the LilyPond export released in 20.0.0. A document written by the writer, or by hand, now reads back into a composition, so `parse(render(composition))` reproduces the music. Nothing in 20.0.0 changed shape; a consumer upgrades by upgrading.
|
|
@@ -731,7 +876,8 @@ note = HeadMusic::Rudiment::Note.get("F#4 dotted-quarter")
|
|
|
731
876
|
|
|
732
877
|
For changes in versions prior to 0.28.0, please refer to the git history.
|
|
733
878
|
|
|
734
|
-
[Unreleased]: https://github.com/roberthead/head_music/compare/
|
|
879
|
+
[Unreleased]: https://github.com/roberthead/head_music/compare/v21.0.0...HEAD
|
|
880
|
+
[21.0.0]: https://github.com/roberthead/head_music/compare/v20.1.0...v21.0.0
|
|
735
881
|
[20.1.0]: https://github.com/roberthead/head_music/compare/v20.0.0...v20.1.0
|
|
736
882
|
[20.0.0]: https://github.com/roberthead/head_music/compare/v19.0.0...v20.0.0
|
|
737
883
|
[19.0.0]: https://github.com/roberthead/head_music/compare/v18.0.0...v19.0.0
|
data/CLAUDE.md
CHANGED
|
@@ -10,42 +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
|
-
|
|
14
|
-
# Install dependencies
|
|
15
|
-
bin/setup
|
|
16
|
-
|
|
17
|
-
# Run tests with coverage
|
|
18
|
-
bundle exec rake
|
|
19
|
-
|
|
20
|
-
# Run tests without coverage
|
|
21
|
-
bundle exec rspec
|
|
22
|
-
|
|
23
|
-
# Run a specific test file
|
|
24
|
-
bundle exec rspec spec/head_music/rudiments/pitch_spec.rb
|
|
25
|
-
|
|
26
|
-
# Run linting
|
|
27
|
-
bundle exec rubocop
|
|
28
|
-
|
|
29
|
-
# Run all validation checks (tests, linting, security)
|
|
30
|
-
bundle exec rake validate
|
|
31
|
-
|
|
32
|
-
# Open interactive console with gem loaded
|
|
33
|
-
bin/console
|
|
34
|
-
# or
|
|
35
|
-
bundle exec rake console
|
|
13
|
+
`rake -T` lists the custom tasks. Two distinctions it does not make obvious:
|
|
36
14
|
|
|
37
|
-
|
|
38
|
-
bundle exec rake
|
|
39
|
-
|
|
40
|
-
# Check documentation coverage
|
|
41
|
-
bundle exec rake doc_stats
|
|
15
|
+
```bash
|
|
16
|
+
bundle exec rake # tests WITH coverage (the default task)
|
|
17
|
+
bundle exec rspec # tests WITHOUT coverage
|
|
42
18
|
```
|
|
43
19
|
|
|
44
20
|
### Git Etiquette
|
|
45
21
|
|
|
46
22
|
**IMPORTANT: Do not make a commit unless I explicitly ask you to.** Wait for explicit instruction before running `git commit`.
|
|
47
23
|
|
|
48
|
-
When composing git commit messages, follow best-practices.
|
|
24
|
+
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.
|
|
49
25
|
|
|
50
26
|
This project uses a rebase flow and `main` as the mainline branch.
|
|
51
27
|
|
|
@@ -130,9 +106,7 @@ The codebase follows a domain-driven design with clear module boundaries:
|
|
|
130
106
|
|
|
131
107
|
### Entry Points
|
|
132
108
|
|
|
133
|
-
|
|
134
|
-
- Module loading order is important and defined in the main file
|
|
135
|
-
- Constants like GOLDEN_RATIO are defined at the top level
|
|
109
|
+
Module loading order matters and is defined in `lib/head_music.rb`.
|
|
136
110
|
|
|
137
111
|
## Important Implementation Details
|
|
138
112
|
|
|
@@ -186,22 +160,6 @@ This project deliberately deprioritizes formal documentation in favor of clear,
|
|
|
186
160
|
- Prefer delegation over inheritance
|
|
187
161
|
- Always run `bundle exec rubocop -a` after editing ruby code
|
|
188
162
|
|
|
189
|
-
## Common Development Tasks
|
|
190
|
-
|
|
191
|
-
### Adding a New Musical Concept
|
|
192
|
-
|
|
193
|
-
1. Create the class in the appropriate module
|
|
194
|
-
2. Include `HeadMusic::Named` if it needs internationalization
|
|
195
|
-
3. Add factory method `.get()` if appropriate
|
|
196
|
-
4. Create corresponding spec file
|
|
197
|
-
5. Add translations to locale files if using Named
|
|
198
|
-
|
|
199
|
-
### Modifying Existing Classes
|
|
200
|
-
|
|
201
|
-
1. Check for dependent classes that might be affected
|
|
202
|
-
2. Run tests for the specific module: `bundle exec rspec spec/head_music/[module_name]`
|
|
203
|
-
3. Ensure translations are updated if names change
|
|
204
|
-
|
|
205
163
|
## Reference Documents
|
|
206
164
|
|
|
207
165
|
Domain reference materials live in `references/`.
|
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
|
@@ -67,4 +67,11 @@ namespace :style do
|
|
|
67
67
|
File.write(path, snapshot.to_yaml)
|
|
68
68
|
puts "Wrote #{snapshot.values.sum(&:size)} strings to #{path}"
|
|
69
69
|
end
|
|
70
|
+
|
|
71
|
+
desc "Regenerate the pinned corpus grading (spec/fixtures/style/corpus_fitness.json)"
|
|
72
|
+
task :snapshot_corpus_fitness do
|
|
73
|
+
path = File.expand_path("spec/fixtures/style/corpus_fitness.json", __dir__)
|
|
74
|
+
sh "bundle exec ruby bin/guide_grade_corpus.rb #{path}"
|
|
75
|
+
puts "Wrote #{path}"
|
|
76
|
+
end
|
|
70
77
|
end
|
data/bin/guide_grade_corpus.rb
CHANGED
|
@@ -4,15 +4,15 @@
|
|
|
4
4
|
#
|
|
5
5
|
# bundle exec ruby bin/guide_grade_corpus.rb out.json
|
|
6
6
|
#
|
|
7
|
-
# Written to run
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
7
|
+
# Written to run on both sides of a grading change: the corpus and the grading
|
|
8
|
+
# live in spec/support/guide_grading.rb, and this script is only the file-writing
|
|
9
|
+
# wrapper around them. `rake style:snapshot_corpus_fitness` writes the pinned
|
|
10
|
+
# snapshot; this script exists for capturing a second one to diff against it.
|
|
11
11
|
#
|
|
12
|
-
# The invariant
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
12
|
+
# The invariant is that each column is the same measurement made again -- not
|
|
13
|
+
# that the file is never edited. When a change needs a seam this script does not
|
|
14
|
+
# yet have, the edit lands BEFORE both captures and is proven a no-op by diffing
|
|
15
|
+
# a capture from either side of it.
|
|
16
16
|
#
|
|
17
17
|
# Loading the fixture exercises means loading spec_helper, which starts
|
|
18
18
|
# SimpleCov and rewrites coverage/.last_run.json. That file is restored on the
|
|
@@ -34,91 +34,13 @@ at_exit do
|
|
|
34
34
|
end
|
|
35
35
|
|
|
36
36
|
require "head_music"
|
|
37
|
-
require "
|
|
37
|
+
require "flow_context"
|
|
38
38
|
require "spec_helper"
|
|
39
39
|
|
|
40
|
-
|
|
41
|
-
REPEATED = Array.new(8, "E4").freeze
|
|
42
|
-
CANTUS = %w[D4 F4 E4 D4 G4 F4 E4 D4].freeze
|
|
40
|
+
rows = GuideGrading.rows
|
|
43
41
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
def place(voice, pitches)
|
|
49
|
-
pitches.each_with_index { |pitch, bar| voice.place("#{bar + 1}:1", :whole, pitch) }
|
|
50
|
-
voice
|
|
51
|
-
end
|
|
52
|
-
|
|
53
|
-
# A voice alone in its composition: no companion, so the harmony guides have
|
|
54
|
-
# nothing to be set against.
|
|
55
|
-
def solo(pitches)
|
|
56
|
-
place(composition.add_voice(role: :counterpoint), pitches)
|
|
57
|
-
end
|
|
58
|
-
|
|
59
|
-
# A counterpoint voice with a companion, which may itself be empty.
|
|
60
|
-
def accompanied(pitches, companion_pitches)
|
|
61
|
-
comp = composition
|
|
62
|
-
place(comp.add_voice(role: "Cantus Firmus"), companion_pitches)
|
|
63
|
-
place(comp.add_voice(role: :counterpoint), pitches)
|
|
64
|
-
end
|
|
65
|
-
|
|
66
|
-
def corpus
|
|
67
|
-
entries = []
|
|
68
|
-
(0..8).each { |n| entries << ["solo-ascending-#{n}", solo(LADDER.first(n))] }
|
|
69
|
-
(0..8).each { |n| entries << ["solo-repeated-#{n}", solo(REPEATED.first(n))] }
|
|
70
|
-
[0, 1, 2, 4, 8].each { |n| entries << ["against-empty-#{n}", accompanied(LADDER.first(n), [])] }
|
|
71
|
-
[0, 1, 2, 4, 8].each { |n| entries << ["against-cantus-#{n}", accompanied(LADDER.first(n), CANTUS)] }
|
|
72
|
-
|
|
73
|
-
%w[
|
|
74
|
-
fux_cantus_firmus_examples clendinning_cantus_firmus_examples
|
|
75
|
-
schoenberg_cantus_firmus_examples davis_and_lybbert_cantus_firmus_examples
|
|
76
|
-
fux_cantus_firmus_examples_with_errors fux_first_species_examples
|
|
77
|
-
clendinning_first_species_examples davis_and_lybbert_first_species_examples
|
|
78
|
-
doubled_octave_examples
|
|
79
|
-
].each do |source|
|
|
80
|
-
Array(send(source)).each_with_index do |context, index|
|
|
81
|
-
context.composition.voices.each_with_index do |voice, position|
|
|
82
|
-
entries << ["#{source}-#{index}-v#{position}", voice]
|
|
83
|
-
end
|
|
84
|
-
end
|
|
85
|
-
end
|
|
86
|
-
entries
|
|
87
|
-
end
|
|
88
|
-
|
|
89
|
-
def grade(guide, voice)
|
|
90
|
-
# Through the guide, not GuideAssessment.new: a composite guide grades its
|
|
91
|
-
# members separately and refuses that constructor, and the rescue below would
|
|
92
|
-
# have recorded the refusal as a per-row error while the run still exited 0.
|
|
93
|
-
# Identical for every leaf guide -- Guides::Base.assess and Configured#assess
|
|
94
|
-
# are both GuideAssessment.new(self, voice) -- so this edit was made before
|
|
95
|
-
# either capture was taken and proven a byte-identical no-op on the before
|
|
96
|
-
# tree.
|
|
97
|
-
assessment = guide.assess(voice)
|
|
98
|
-
items = assessment.guide_item_assessments
|
|
99
|
-
{
|
|
100
|
-
fitness: assessment.fitness.round(12),
|
|
101
|
-
adherent: assessment.adherent?,
|
|
102
|
-
message_count: assessment.messages.length,
|
|
103
|
-
item_count: items.length,
|
|
104
|
-
assessable: assessment.assessable?,
|
|
105
|
-
failed_gates: items.select { |item| item.gate? && !item.adherent? }.map { |item| item.guideline.name.split("::").last }.sort
|
|
106
|
-
}
|
|
107
|
-
rescue => error
|
|
108
|
-
{fitness: nil, adherent: nil, message_count: nil, item_count: nil, assessable: nil,
|
|
109
|
-
failed_gates: [], error: error.class.name}
|
|
110
|
-
end
|
|
111
|
-
|
|
112
|
-
rows = corpus.flat_map do |label, voice|
|
|
113
|
-
HeadMusic::Style::Guide::ALL.map do |guide|
|
|
114
|
-
{
|
|
115
|
-
corpus: label,
|
|
116
|
-
notes: voice.notes.length,
|
|
117
|
-
guide: HeadMusic::Style::Guide.key_for(guide)
|
|
118
|
-
}.merge(grade(guide, voice))
|
|
119
|
-
end
|
|
120
|
-
end
|
|
121
|
-
|
|
122
|
-
File.write(ARGV.fetch(0), JSON.pretty_generate(rows))
|
|
123
|
-
warn "rows=#{rows.length} corpus=#{corpus.length} guides=#{HeadMusic::Style::Guide::ALL.length} " \
|
|
42
|
+
# One row per line: valid JSON, but a third the size of a pretty-printed dump
|
|
43
|
+
# and diffable a row at a time, which is how the snapshot is read.
|
|
44
|
+
File.write(ARGV.fetch(0), "[\n#{rows.map { |row| JSON.generate(row) }.join(",\n")}\n]\n")
|
|
45
|
+
warn "rows=#{rows.length} guides=#{HeadMusic::Style::Guide::ALL.length} " \
|
|
124
46
|
"errors=#{rows.count { |row| row[:error] }}"
|
data/bin/guide_grade_table.rb
CHANGED
|
@@ -167,7 +167,7 @@ def composite_members(capture)
|
|
|
167
167
|
end
|
|
168
168
|
|
|
169
169
|
# Only assessable rows carry information. Every solo entry and every cantus
|
|
170
|
-
# firmus fixture is a single-voice
|
|
170
|
+
# firmus fixture is a single-voice flow, so its harmony member gates out
|
|
171
171
|
# and the composite reads 0.000 by construction.
|
|
172
172
|
def composite_rows(capture)
|
|
173
173
|
members = composite_members(capture)
|
|
@@ -8,7 +8,7 @@ class HeadMusic::Analysis::HarmonicInterval
|
|
|
8
8
|
def initialize(voice1, voice2, position)
|
|
9
9
|
@voice1 = voice1
|
|
10
10
|
@voice2 = voice2
|
|
11
|
-
@position = position.is_a?(String) ? HeadMusic::Content::Position.new(voice1.
|
|
11
|
+
@position = position.is_a?(String) ? HeadMusic::Content::Position.new(voice1.flow, position) : position
|
|
12
12
|
end
|
|
13
13
|
|
|
14
14
|
def diatonic_interval
|
|
@@ -1,28 +1,30 @@
|
|
|
1
1
|
# A module for musical content
|
|
2
2
|
module HeadMusic::Content; end
|
|
3
3
|
|
|
4
|
-
# Representation of a bar in a
|
|
4
|
+
# Representation of a bar in a flow
|
|
5
5
|
# Encapsulates meter and key signature changes
|
|
6
6
|
# and repeat structure (repeat barlines and volta brackets) as content semantics
|
|
7
7
|
class HeadMusic::Content::Bar
|
|
8
|
-
attr_reader :
|
|
8
|
+
attr_reader :flow, :number, :ends_repeat_after_num_plays, :plays_on_passes
|
|
9
9
|
attr_writer :starts_repeat
|
|
10
10
|
|
|
11
|
-
def initialize(
|
|
12
|
-
@
|
|
13
|
-
|
|
14
|
-
self.meter = meter
|
|
11
|
+
def initialize(flow, number: HeadMusic::Time::MusicalPosition::DEFAULT_FIRST_BAR)
|
|
12
|
+
@flow = flow
|
|
13
|
+
@number = number
|
|
15
14
|
@starts_repeat = false
|
|
16
15
|
@ends_repeat_after_num_plays = nil
|
|
17
16
|
@plays_on_passes = nil
|
|
18
17
|
end
|
|
19
18
|
|
|
20
|
-
|
|
21
|
-
|
|
19
|
+
# The key signature and meter a bar reports are the changes authored here,
|
|
20
|
+
# read from the flow's timeline rather than stored -- nil where nothing was
|
|
21
|
+
# authored, which is what a writer reads to decide whether to print one.
|
|
22
|
+
def key_signature
|
|
23
|
+
flow.timeline.key_signature_change_at(number)&.key_signature
|
|
22
24
|
end
|
|
23
25
|
|
|
24
|
-
def meter
|
|
25
|
-
|
|
26
|
+
def meter
|
|
27
|
+
flow.timeline.meter_change_at(number)
|
|
26
28
|
end
|
|
27
29
|
|
|
28
30
|
def starts_repeat?
|
|
@@ -56,12 +58,11 @@ class HeadMusic::Content::Bar
|
|
|
56
58
|
end
|
|
57
59
|
|
|
58
60
|
# Sparse serialization: only non-default state, so a default bar is {}.
|
|
59
|
-
#
|
|
60
|
-
#
|
|
61
|
+
#
|
|
62
|
+
# Key and meter changes are not here: they belong to the flow's timeline, and
|
|
63
|
+
# a bar merely reports the ones authored in it.
|
|
61
64
|
def to_h
|
|
62
65
|
hash = {}
|
|
63
|
-
hash["key_signature"] = key_signature.name if key_signature
|
|
64
|
-
hash["meter"] = meter.to_s if meter
|
|
65
66
|
hash["starts_repeat"] = true if starts_repeat?
|
|
66
67
|
hash["ends_repeat_after_num_plays"] = ends_repeat_after_num_plays if ends_repeat?
|
|
67
68
|
hash["plays_on_passes"] = plays_on_passes.dup if plays_on_passes
|