head_music 21.0.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 +47 -0
- data/Gemfile.lock +1 -1
- data/README.md +1 -15
- 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 +76 -0
- data/lib/head_music/content/flow/hash_deserializer.rb +26 -13
- data/lib/head_music/content/flow.rb +40 -21
- 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/person.rb +54 -0
- data/lib/head_music/content/project.rb +45 -17
- 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/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/writer.rb +38 -22
- 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_writer.rb +35 -26
- 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/style/guidelines/contoured.rb +2 -10
- data/lib/head_music/version.rb +1 -1
- data/lib/head_music.rb +10 -0
- data/references/content-schema.md +546 -0
- data/references/wemi.md +6 -6
- data/user-stories/done/identity-and-presentation.md +424 -0
- data/user-stories/done/improve-melodic-contour-guidelines.md +398 -0
- data/user-stories/index.html +246 -242
- metadata +17 -3
- data/user-stories/backlog/identity-and-presentation.md +0 -148
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,53 @@ 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
|
+
|
|
10
57
|
## [21.0.0] - 2026-09-06
|
|
11
58
|
|
|
12
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.
|
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
|
|
|
@@ -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
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
module HeadMusic
|
|
2
2
|
module Content
|
|
3
3
|
module CantusFirmus
|
|
4
|
-
# A pedagogical source of cantus firmus examples
|
|
5
|
-
#
|
|
6
|
-
class Source
|
|
4
|
+
# A pedagogical source of cantus firmus examples: a Publication with a
|
|
5
|
+
# catalog key, the same noun any flow may cite.
|
|
6
|
+
class Source < HeadMusic::Content::Publication
|
|
7
7
|
SOURCES_DATA = YAML.load_file(File.expand_path("sources.yml", __dir__)).freeze
|
|
8
8
|
|
|
9
|
-
attr_reader :key
|
|
9
|
+
attr_reader :key
|
|
10
10
|
|
|
11
11
|
class << self
|
|
12
12
|
def all
|
|
@@ -28,8 +28,6 @@ module HeadMusic
|
|
|
28
28
|
|
|
29
29
|
private
|
|
30
30
|
|
|
31
|
-
# Normalize various source name formats to the YAML key format
|
|
32
|
-
# e.g., "Fux" -> "fux", "Clendinning & Marvin" -> "clendinning_and_marvin"
|
|
33
31
|
def normalize_key(identifier)
|
|
34
32
|
identifier.to_s
|
|
35
33
|
.downcase
|
|
@@ -39,20 +37,35 @@ module HeadMusic
|
|
|
39
37
|
end
|
|
40
38
|
end
|
|
41
39
|
|
|
40
|
+
# The key is assigned before the superclass freezes the object.
|
|
42
41
|
def initialize(key:, data:)
|
|
43
42
|
@key = key.to_sym
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
43
|
+
super(
|
|
44
|
+
title: data["publication_name"],
|
|
45
|
+
edition: data["publication_edition"],
|
|
46
|
+
abbreviation: data["abbreviation"],
|
|
47
|
+
notes: data["notes"]&.strip,
|
|
48
|
+
credits: author_credits(data["author_names"])
|
|
49
|
+
)
|
|
49
50
|
end
|
|
50
51
|
|
|
51
|
-
|
|
52
|
-
|
|
52
|
+
alias_method :publication_name, :title
|
|
53
|
+
alias_method :publication_edition, :edition
|
|
54
|
+
alias_method :author_names, :authors
|
|
55
|
+
|
|
56
|
+
def to_h
|
|
57
|
+
super.merge("key" => key.to_s)
|
|
53
58
|
end
|
|
54
59
|
|
|
55
60
|
private_class_method :new
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def author_credits(names)
|
|
65
|
+
Array(names).map do |name|
|
|
66
|
+
HeadMusic::Content::Credit.new(person: HeadMusic::Content::Person.new(full_name: name), role: :author)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
56
69
|
end
|
|
57
70
|
end
|
|
58
71
|
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# A module for musical content
|
|
2
|
+
module HeadMusic::Content; end
|
|
3
|
+
|
|
4
|
+
# One person credited in one role. Level-agnostic: the Credits collection that
|
|
5
|
+
# holds it is what constrains which roles are admissible.
|
|
6
|
+
HeadMusic::Content::Credit = Data.define(:person, :role) do
|
|
7
|
+
def self.from_h(hash)
|
|
8
|
+
values = hash.transform_keys(&:to_s)
|
|
9
|
+
new(person: values["person"], role: values["role"])
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def initialize(person:, role:)
|
|
13
|
+
super(person: ensure_person(person), role: HeadMusic::Content::Role.get(role))
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def to_s
|
|
17
|
+
"#{person} (#{role})"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def to_h
|
|
21
|
+
{"role" => role.key.to_s, "person" => person.to_h}
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def ensure_person(person)
|
|
27
|
+
return person if person.is_a?(HeadMusic::Content::Person)
|
|
28
|
+
return HeadMusic::Content::Person.from_h(person) if person.is_a?(Hash)
|
|
29
|
+
|
|
30
|
+
HeadMusic::Content::Person.new(full_name: person.to_s)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# A module for musical content
|
|
2
|
+
module HeadMusic::Content; end
|
|
3
|
+
|
|
4
|
+
# The credits attaching at one level — work, project, or publication — and the
|
|
5
|
+
# single place the level constraint is enforced.
|
|
6
|
+
class HeadMusic::Content::Credits
|
|
7
|
+
include Enumerable
|
|
8
|
+
|
|
9
|
+
LEVELS = HeadMusic::Content::Role::LEVELS
|
|
10
|
+
|
|
11
|
+
def self.from_h(array, level:)
|
|
12
|
+
new(level, Array(array))
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
attr_reader :level, :credits
|
|
16
|
+
|
|
17
|
+
def initialize(level, credits = [])
|
|
18
|
+
@level = level&.to_sym
|
|
19
|
+
raise ArgumentError, "unknown credit level: #{level.inspect}" unless LEVELS.include?(@level)
|
|
20
|
+
|
|
21
|
+
@credits = Array(credits).map { |credit| ensure_credit(credit) }.freeze
|
|
22
|
+
freeze
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def each(&)
|
|
26
|
+
credits.each(&)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def add(person, role)
|
|
30
|
+
self.class.new(level, credits + [HeadMusic::Content::Credit.new(person: person, role: role)])
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def for(role)
|
|
34
|
+
wanted = HeadMusic::Content::Role.get(role)
|
|
35
|
+
credits.select { |credit| credit.role == wanted }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def names(role)
|
|
39
|
+
self.for(role).map { |credit| credit.person.full_name }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def empty?
|
|
43
|
+
credits.empty?
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def size
|
|
47
|
+
credits.size
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def to_h
|
|
51
|
+
credits.map(&:to_h)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def ==(other)
|
|
55
|
+
other.is_a?(self.class) && level == other.level && credits == other.credits
|
|
56
|
+
end
|
|
57
|
+
alias_method :eql?, :==
|
|
58
|
+
|
|
59
|
+
def hash
|
|
60
|
+
[self.class, level, credits].hash
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
def ensure_credit(credit)
|
|
66
|
+
credit = HeadMusic::Content::Credit.from_h(credit) if credit.is_a?(Hash)
|
|
67
|
+
validate_level(credit)
|
|
68
|
+
credit
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def validate_level(credit)
|
|
72
|
+
return if credit.role.level == level
|
|
73
|
+
|
|
74
|
+
raise ArgumentError, "#{credit.role.key} is a #{credit.role.level} role, not a #{level} role"
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -1,20 +1,16 @@
|
|
|
1
1
|
class HeadMusic::Content::Flow
|
|
2
|
-
# Rebuilds a flow from a schema v4 hash
|
|
3
|
-
#
|
|
4
|
-
#
|
|
5
|
-
#
|
|
6
|
-
#
|
|
7
|
-
# voice's staff assignment names a staff of its part's system; then
|
|
8
|
-
# placements; then repeat flags, which need their bar allocated; then
|
|
9
|
-
# comments.
|
|
2
|
+
# Rebuilds a flow from a schema v4 hash, in dependency order: the timeline
|
|
3
|
+
# first, because a position string rolls its counts and ticks over through the
|
|
4
|
+
# meter map; then parts, because a voice's staff assignment names a staff of
|
|
5
|
+
# its part's system; then placements; then repeat flags, which need their bar
|
|
6
|
+
# allocated.
|
|
10
7
|
class HashDeserializer < Deserializer
|
|
11
8
|
SCHEMA_VERSION = HeadMusic::Content::Flow::SCHEMA_VERSION
|
|
12
9
|
|
|
13
10
|
private
|
|
14
11
|
|
|
15
|
-
# A v3 document
|
|
16
|
-
#
|
|
17
|
-
# where to find it.
|
|
12
|
+
# A v3 document is told where 20.1.0 still reads it, rather than merely
|
|
13
|
+
# rejected, because that reader still ships.
|
|
18
14
|
def unsupported_version_message(version)
|
|
19
15
|
message = super
|
|
20
16
|
message += "; read it with Flow.from_v3_h, which is retained in 21.x and removed in 22.0.0" if version == 3
|
|
@@ -30,12 +26,29 @@ class HeadMusic::Content::Flow
|
|
|
30
26
|
end
|
|
31
27
|
|
|
32
28
|
def build(flow)
|
|
29
|
+
apply_citations(flow)
|
|
33
30
|
apply_timeline_changes(flow)
|
|
34
31
|
build_parts(flow)
|
|
35
32
|
apply_repeat_flags(flow)
|
|
36
33
|
add_comments(flow)
|
|
37
34
|
end
|
|
38
35
|
|
|
36
|
+
# Read here rather than on the shared base, so the v3 reader gains nothing.
|
|
37
|
+
# A document written before these keys existed has neither, and reads.
|
|
38
|
+
def apply_citations(flow)
|
|
39
|
+
work = hash["work"]
|
|
40
|
+
flow.work = HeadMusic::Content::Work.from_h(work) if work
|
|
41
|
+
source = hash["source"]
|
|
42
|
+
flow.source = publication_from_h(source) if source
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# A source written from the cantus firmus catalog carries its key, and
|
|
46
|
+
# reads back as the catalog entry itself rather than as a copy of its fields.
|
|
47
|
+
def publication_from_h(source_hash)
|
|
48
|
+
key = source_hash["key"]
|
|
49
|
+
(key && HeadMusic::Content::CantusFirmus::Source.get(key)) || HeadMusic::Content::Publication.from_h(source_hash)
|
|
50
|
+
end
|
|
51
|
+
|
|
39
52
|
def apply_timeline_changes(flow)
|
|
40
53
|
each_timeline_change("meter_changes") do |bar_number, change, path|
|
|
41
54
|
flow.change_meter(bar_number, values.meter(change["meter"], path))
|
|
@@ -91,8 +104,8 @@ class HeadMusic::Content::Flow
|
|
|
91
104
|
end
|
|
92
105
|
end
|
|
93
106
|
|
|
94
|
-
#
|
|
95
|
-
#
|
|
107
|
+
# Replayed as bare map entries: a crossing is one event, and the serialized
|
|
108
|
+
# form is the map.
|
|
96
109
|
def apply_staff_assignments(voice, part, voice_hash)
|
|
97
110
|
each_change(voice_hash["staff_assignments"], "staff_assignments") do |bar_number, assignment, _path|
|
|
98
111
|
staff = part.staff_system_at(bar_number).staves[assignment["staff"].to_i]
|
|
@@ -2,18 +2,19 @@
|
|
|
2
2
|
module HeadMusic::Content; end
|
|
3
3
|
|
|
4
4
|
# A flow is a continuous span of music with its own timeline: a movement, a
|
|
5
|
-
# song, a cue, or a single exercise.
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
# firmus, a scale, or a parsed snippet be content without inventing a noun for
|
|
9
|
-
# it. What is not optional is containment below: every voice is in a part, and
|
|
10
|
-
# every part is in a flow.
|
|
5
|
+
# song, a cue, or a single exercise. Its project is optional, so a flow may
|
|
6
|
+
# stand alone; containment below is not: every voice is in a part, and every
|
|
7
|
+
# part is in a flow.
|
|
11
8
|
class HeadMusic::Content::Flow
|
|
12
9
|
SCHEMA_VERSION = 4
|
|
13
10
|
|
|
14
|
-
attr_reader :name, :parts, :
|
|
11
|
+
attr_reader :name, :parts, :origin, :comments, :timeline
|
|
15
12
|
attr_accessor :project
|
|
16
13
|
|
|
14
|
+
# The catalog identity this flow is one notation of, and the publication it
|
|
15
|
+
# was taken from. Both optional: a parsed snippet cites neither.
|
|
16
|
+
attr_accessor :work, :source
|
|
17
|
+
|
|
17
18
|
delegate :meter_at, :key_signature_at, :tempo_at, to: :timeline
|
|
18
19
|
delegate :meter_changes, :key_signature_changes, :tempo_changes, :meter_change_at, :tempo_change_at, to: :timeline
|
|
19
20
|
delegate :remove_meter_change, :remove_key_signature_change, :remove_tempo_change, to: :timeline
|
|
@@ -22,8 +23,6 @@ class HeadMusic::Content::Flow
|
|
|
22
23
|
HashDeserializer.new(hash).flow
|
|
23
24
|
end
|
|
24
25
|
|
|
25
|
-
# Read a schema v3 document.
|
|
26
|
-
#
|
|
27
26
|
# Retained read-only through 21.x so that persisted v3 data can be migrated
|
|
28
27
|
# by reading and re-saving. Removed in 22.0.0.
|
|
29
28
|
def self.from_v3_h(hash)
|
|
@@ -34,21 +33,29 @@ class HeadMusic::Content::Flow
|
|
|
34
33
|
from_h(JSON.parse(json))
|
|
35
34
|
end
|
|
36
35
|
|
|
37
|
-
def initialize(
|
|
36
|
+
def initialize(
|
|
37
|
+
name: nil, key_signature: nil, meter: nil, tempo: nil,
|
|
38
|
+
composer: nil, origin: nil, comments: nil, work: nil, source: nil
|
|
39
|
+
)
|
|
38
40
|
ensure_attributes(name, key_signature, meter, tempo)
|
|
39
41
|
@composer = composer
|
|
40
42
|
@origin = origin
|
|
43
|
+
@work = ensure_work(work)
|
|
44
|
+
@source = ensure_source(source)
|
|
41
45
|
@parts = []
|
|
42
46
|
@comments = Array(comments).map { |text| HeadMusic::Content::Comment.new(self, text) }
|
|
43
47
|
end
|
|
44
48
|
|
|
45
|
-
# The
|
|
49
|
+
# The cited work's composer wins over the authored string, which stays as the
|
|
50
|
+
# fallback for the parsed documents that have a name but no work.
|
|
51
|
+
def composer
|
|
52
|
+
work&.composer || @composer
|
|
53
|
+
end
|
|
54
|
+
|
|
46
55
|
def voices
|
|
47
56
|
parts.flat_map(&:voices)
|
|
48
57
|
end
|
|
49
58
|
|
|
50
|
-
# @param player [HeadMusic::Content::Player, nil] the chair this part fills;
|
|
51
|
-
# a part with no player is simply a staff of music
|
|
52
59
|
def add_part(player: nil, instrument: nil, staff_system: nil)
|
|
53
60
|
HeadMusic::Content::Part
|
|
54
61
|
.new(flow: self, player: player, instrument: instrument, staff_system: staff_system)
|
|
@@ -66,14 +73,13 @@ class HeadMusic::Content::Flow
|
|
|
66
73
|
@comments.last
|
|
67
74
|
end
|
|
68
75
|
|
|
69
|
-
# A position in this flow, from a "bar:count:tick" code or its components.
|
|
70
76
|
def position(code_or_bar, count = nil, tick = nil, subtick = nil)
|
|
71
77
|
HeadMusic::Content::Position.new(self, code_or_bar, count, tick, subtick)
|
|
72
78
|
end
|
|
73
79
|
|
|
74
|
-
# The signature
|
|
75
|
-
#
|
|
76
|
-
#
|
|
80
|
+
# The opening signature, meter, and tempo are the timeline's, so a change at
|
|
81
|
+
# bar 1 is a change like any other rather than a rewrite of the flow's own
|
|
82
|
+
# attributes.
|
|
77
83
|
def key_signature
|
|
78
84
|
timeline.opening_key_signature_event.key_signature
|
|
79
85
|
end
|
|
@@ -160,6 +166,8 @@ class HeadMusic::Content::Flow
|
|
|
160
166
|
"name" => name,
|
|
161
167
|
"composer" => composer&.to_s,
|
|
162
168
|
"origin" => origin&.to_s,
|
|
169
|
+
"work" => work&.to_h,
|
|
170
|
+
"source" => source&.to_h,
|
|
163
171
|
"timeline" => timeline_to_h,
|
|
164
172
|
"parts" => parts.map(&:to_h),
|
|
165
173
|
"bars" => bars_to_h,
|
|
@@ -195,6 +203,18 @@ class HeadMusic::Content::Flow
|
|
|
195
203
|
(@bars || []).index { |bar| !bar.nil? }
|
|
196
204
|
end
|
|
197
205
|
|
|
206
|
+
def ensure_work(work)
|
|
207
|
+
return HeadMusic::Content::Work.from_h(work) if work.is_a?(Hash)
|
|
208
|
+
|
|
209
|
+
work
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def ensure_source(source)
|
|
213
|
+
return HeadMusic::Content::Publication.from_h(source) if source.is_a?(Hash)
|
|
214
|
+
|
|
215
|
+
source
|
|
216
|
+
end
|
|
217
|
+
|
|
198
218
|
def ensure_attributes(name, key_signature, meter, tempo)
|
|
199
219
|
@name = name || "Composition"
|
|
200
220
|
@timeline = Timeline.new(key_signature: key_signature, meter: meter, tempo: tempo)
|
|
@@ -206,10 +226,9 @@ class HeadMusic::Content::Flow
|
|
|
206
226
|
{"beat_value" => tempo.beat_value.to_s, "beats_per_minute" => tempo.beats_per_minute}
|
|
207
227
|
end
|
|
208
228
|
|
|
209
|
-
# Iterates the raw sparse array
|
|
210
|
-
# number offset
|
|
211
|
-
#
|
|
212
|
-
# and nothing else.
|
|
229
|
+
# Iterates the raw sparse array rather than the public #bars slice, which
|
|
230
|
+
# loses the number offset. Key and meter changes are the timeline's, so a bar
|
|
231
|
+
# serializes its repeat structure and nothing else.
|
|
213
232
|
def bars_to_h
|
|
214
233
|
(@bars || []).each_with_index.filter_map do |bar, number|
|
|
215
234
|
next if bar.nil?
|