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.
Files changed (48) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/release/SKILL.md +92 -0
  3. data/.github/workflows/release.yml +3 -1
  4. data/CHANGELOG.md +47 -0
  5. data/Gemfile.lock +1 -1
  6. data/README.md +1 -15
  7. data/lib/head_music/content/cantus_firmus/example.rb +1 -10
  8. data/lib/head_music/content/cantus_firmus/source.rb +26 -13
  9. data/lib/head_music/content/credit.rb +32 -0
  10. data/lib/head_music/content/credits.rb +76 -0
  11. data/lib/head_music/content/flow/hash_deserializer.rb +26 -13
  12. data/lib/head_music/content/flow.rb +40 -21
  13. data/lib/head_music/content/layout/realization.rb +72 -0
  14. data/lib/head_music/content/layout/transposition.rb +160 -0
  15. data/lib/head_music/content/layout.rb +199 -0
  16. data/lib/head_music/content/person.rb +54 -0
  17. data/lib/head_music/content/project.rb +45 -17
  18. data/lib/head_music/content/publication.rb +81 -0
  19. data/lib/head_music/content/role.rb +94 -0
  20. data/lib/head_music/content/score.rb +85 -0
  21. data/lib/head_music/content/work.rb +51 -0
  22. data/lib/head_music/instruments/score_order.rb +58 -43
  23. data/lib/head_music/locales/de.yml +13 -0
  24. data/lib/head_music/locales/en.yml +13 -0
  25. data/lib/head_music/locales/es.yml +13 -0
  26. data/lib/head_music/locales/fr.yml +13 -0
  27. data/lib/head_music/locales/it.yml +13 -0
  28. data/lib/head_music/locales/ru.yml +13 -0
  29. data/lib/head_music/notation/abc/writer.rb +38 -22
  30. data/lib/head_music/notation/abc.rb +2 -1
  31. data/lib/head_music/notation/lily_pond/book_writer.rb +57 -0
  32. data/lib/head_music/notation/lily_pond/voice_writer.rb +35 -26
  33. data/lib/head_music/notation/lily_pond/writer.rb +33 -24
  34. data/lib/head_music/notation/lily_pond.rb +2 -2
  35. data/lib/head_music/notation/music_xml/attributes_writer.rb +42 -13
  36. data/lib/head_music/notation/music_xml/writer.rb +34 -31
  37. data/lib/head_music/notation/music_xml.rb +7 -4
  38. data/lib/head_music/notation/render_plan.rb +77 -23
  39. data/lib/head_music/style/guidelines/contoured.rb +2 -10
  40. data/lib/head_music/version.rb +1 -1
  41. data/lib/head_music.rb +10 -0
  42. data/references/content-schema.md +546 -0
  43. data/references/wemi.md +6 -6
  44. data/user-stories/done/identity-and-presentation.md +424 -0
  45. data/user-stories/done/improve-melodic-contour-guidelines.md +398 -0
  46. data/user-stories/index.html +246 -242
  47. metadata +17 -3
  48. 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: 3adeb7e3fa4f5422977916729f285e9ebb50309409927b25b284dbbb35d2932d
4
- data.tar.gz: 78f4b8b6922142b56e185cc23da551576ba3ffd375d51eb6aa34f3382e30530f
3
+ metadata.gz: eadecb642354dfc4c70cf00303a0c1c27513fdd38b13d8bc81fe9c8e7fd3214b
4
+ data.tar.gz: b534a1e194b0bd6ba4dae42433e92091812e434ec0fddb03c7617e47147207ab
5
5
  SHA512:
6
- metadata.gz: 444f66354a57746f2c9acf747462bf3f2396f76b8b9c7fe16d4c2bc93020bbe178349f437d7135141e587ffb9fef09c57bdf6a9145159b47a88af908960b608b
7
- data.tar.gz: 4c38611fa5aea3b2530310dc33644ab29af9c81c523eb37e39fe94298d79c71ee982791e1278c449cd4d91c99623e21f1a2f3c9f4882e648c8b1783f6ff94a7c
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
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- head_music (21.0.0)
4
+ head_music (21.1.0)
5
5
  activesupport (>= 7.0, < 10)
6
6
  humanize (>= 2, < 4)
7
7
  i18n (~> 1.8)
data/README.md CHANGED
@@ -194,21 +194,7 @@ bundle exec rake doc
194
194
 
195
195
  ### Releasing a New Version
196
196
 
197
- 1. Update the version number in `lib/head_music/version.rb`
198
- 2. Commit the version change: `git commit -am "Bump version to X.Y.Z"`
199
- 3. Push to main: `git push origin main`
200
- 4. Release the gem:
201
-
202
- ```bash
203
- bundle exec rake release
204
- ```
205
-
206
- This will:
207
- - Build the gem
208
- - Create and push a git tag (e.g., `vX.Y.Z`)
209
- - Push the gem to RubyGems
210
-
211
- The git tag push also triggers a GitHub Actions workflow that creates a GitHub Release with auto-generated release notes.
197
+ The release checklist lives in [`.claude/skills/release/SKILL.md`](.claude/skills/release/SKILL.md). Run `/release` in Claude Code, or follow it by hand. In short: move the Unreleased changelog entries under a dated heading, bump `lib/head_music/version.rb`, refresh `Gemfile.lock`, commit as `Release X.Y.Z`, and then `bundle exec rake release:source_control_push` tags the release. The tag push runs the release workflow, which publishes the gem to RubyGems and creates the GitHub Release.
212
198
 
213
199
  ## Contributing
214
200
 
@@ -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
- # Sources include books and treatises on counterpoint.
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, :publication_name, :abbreviation, :publication_edition, :author_names, :notes
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
- @publication_name = data["publication_name"]
45
- @abbreviation = data["abbreviation"]
46
- @publication_edition = data["publication_edition"]
47
- @author_names = data["author_names"] || []
48
- @notes = data["notes"]&.strip
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
- def to_s
52
- publication_name
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
- # The order is the model's own dependency order: the timeline first, because
5
- # a position string rolls its counts and ticks over through the meter map;
6
- # then parts, with their instrument and staff-system changes, because a
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 names 20.1.0 rather than merely being rejected, because the
16
- # reader that understands it still ships and a caller needs to be told
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
- # Assignments are replayed as bare map entries, which is what they are: a
95
- # crossing is one event, and the serialized form is the map.
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
- # A flow may stand alone. Its project is optional, which is what lets a cantus
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, :composer, :origin, :comments, :timeline
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(name: nil, key_signature: nil, meter: nil, tempo: nil, composer: nil, origin: nil, comments: nil)
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 voices of every part, in part order.
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 and meter the flow opens in. Both are the timeline's, so a
75
- # change at bar 1 is a change like any other rather than a rewrite of the
76
- # flow's own attributes.
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 (not the public #bars slice, which loses the
210
- # number offset), pairing each non-default bar with its number. Key and meter
211
- # changes are the timeline's now, so a bar serializes its repeat structure
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?