tonus 0.7.0 → 0.9.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 (35) hide show
  1. package/CHANGELOG.md +213 -1
  2. package/README.md +1 -1
  3. package/dist/engines/chant/attest.js +8 -8
  4. package/dist/engines/chant/hour.js +3 -3
  5. package/dist/engines/chant/intone.js +7 -1
  6. package/dist/engines/score/api.d.ts +3 -3
  7. package/dist/engines/score/api.js +3 -3
  8. package/dist/engines/score/cadence.d.ts +10 -4
  9. package/dist/engines/score/cadence.js +10 -4
  10. package/dist/engines/score/emitters/accidentals.d.ts +9 -0
  11. package/dist/engines/score/emitters/accidentals.js +76 -13
  12. package/dist/engines/score/emitters/atramentum.js +1 -1
  13. package/dist/engines/score/emitters/breaking.d.ts +2 -2
  14. package/dist/engines/score/emitters/breaking.js +2 -2
  15. package/dist/engines/score/emitters/moderna.js +16 -6
  16. package/dist/engines/score/emitters/svg.d.ts +16 -0
  17. package/dist/engines/score/emitters/svg.js +19 -12
  18. package/dist/engines/score/emitters/tracks.js +29 -10
  19. package/dist/engines/score/ir.js +24 -4
  20. package/dist/engines/score/{prosody.d.ts → metrics.d.ts} +3 -3
  21. package/dist/engines/score/{prosody.js → metrics.js} +2 -2
  22. package/dist/engines/score/neume.d.ts +19 -0
  23. package/dist/engines/score/neume.js +35 -0
  24. package/dist/engines/score/parse.js +4 -1
  25. package/dist/engines/score/phrasing.js +1 -1
  26. package/dist/engines/score/types.d.ts +6 -0
  27. package/dist/engines/temper/data/guido.js +4 -2
  28. package/dist/engines/temper/neume.d.ts +1 -1
  29. package/dist/engines/temper/neume.js +24 -3
  30. package/dist/index.d.ts +2 -2
  31. package/docs/api/chant.md +7 -7
  32. package/docs/api/index.md +2 -2
  33. package/docs/api/score.md +159 -106
  34. package/docs/api/tuning.md +17 -9
  35. package/package.json +2 -2
@@ -492,7 +492,9 @@ export function toSvg(rows, chant, options = {}) {
492
492
  `fill="${r.noteColor}">${esc(mark.label ?? "")}</text>`);
493
493
  return 0;
494
494
  }
495
- const p = placeGlyph(mark.glyph, atX, yFor(row.staffPosition, L, r), r, "accidental", "", r.noteScale * 0.62);
495
+ // The sign sits on the line of the pitch it alters, which is not this
496
+ // row's own line where the sign was written before the figure it governs.
497
+ const p = placeGlyph(mark.glyph, atX, yFor(mark.degree ?? row.staffPosition, L, r), r, "accidental", "", r.noteScale * 0.62);
496
498
  if (!p)
497
499
  return 0;
498
500
  body.push(p.svg);
@@ -736,9 +738,9 @@ export function toSvg(rows, chant, options = {}) {
736
738
  // GABC's `z` says "start a new line here", and it is not a hint: an
737
739
  // editor who set a chant chose where its lines end, and that choice
738
740
  // carries a reading of the piece a width cannot infer. tonus SKIPPED the
739
- // token at parse 41 Graduale chants carry one and every break was
740
- // being thrown away, which is why the automatic breaks looked arbitrary
741
- // against a printed copy.
741
+ // token at parse, so every break the Graduale chants that carry one had
742
+ // set was being thrown away, which is why the automatic breaks looked
743
+ // arbitrary against a printed copy.
742
744
  //
743
745
  // It wins over the fit test. Where it is absent the layout still decides.
744
746
  if (r.width != null && figure[0].lineBreak && prevSyllable !== -1) {
@@ -831,8 +833,9 @@ export function toSvg(rows, chant, options = {}) {
831
833
  // Break when the NEXT phrase will not fit, rather than once this one has
832
834
  // already overrun. The check was `x > width - padding`, which only fires
833
835
  // AFTER the boundary is crossed — and since a system may break only at a
834
- // divisio, the overrun was a whole phrase wide. Measured over thirty
835
- // graduals, every one of them overran a 900px request, by up to 289px.
836
+ // divisio, the overrun was a whole phrase wide. Measured over a sweep of
837
+ // graduals, every one of them overran the requested width, some by a
838
+ // wide margin.
836
839
  // That is what made a render wider than the column it was drawn for, and
837
840
  // why "sometimes bigger, sometimes smaller" varied by chant: the overrun
838
841
  // depends on where the phrases happen to fall.
@@ -984,9 +987,9 @@ export function toSvg(rows, chant, options = {}) {
984
987
  // above. But it cannot be the only one: quadrata's break test used to live
985
988
  // entirely inside `if (div && phraseEnds)`, so a system could end nowhere
986
989
  // else, and a phrase wider than the line simply ran until its next barline.
987
- // Measured over 120 graduals, a QUARTER of quadrata's lines came out under
988
- // 75% full against 6% in moderna — which breaks between syllables. That gap
989
- // was the asymmetry, not a spacing difference.
990
+ // Measured over a sweep of graduals, a QUARTER of quadrata's lines came out
991
+ // barely more than half full, against a handful in moderna — which breaks
992
+ // between syllables. That gap was the asymmetry, not a spacing difference.
990
993
  //
991
994
  // The books break mid-phrase freely; the unit is the word, never a syllable
992
995
  // mid-word (which would split a lyric) and never mid-neume. So: at a word
@@ -1217,8 +1220,9 @@ export function toSvg(rows, chant, options = {}) {
1217
1220
  // ...and a word carried to the NEXT system takes a hyphen at the line's
1218
1221
  // end, which is what the books set. The gap-centred rule above cannot
1219
1222
  // reach this case — the two syllables have no gap between them, they have
1220
- // a line break — so the hyphen was simply dropped: measured, 351 splits
1221
- // across 165 of 200 graduals rendered with nothing joining the halves.
1223
+ // a line break — so the hyphen was simply dropped: measured, the great
1224
+ // majority of a sweep of graduals rendered a split word with nothing
1225
+ // joining the halves.
1222
1226
  // "Sanc" ended a line and "tus" opened the next, reading as two words.
1223
1227
  const thisRight = ly.cx + estLyricW(ly.text) / 2;
1224
1228
  lyricSvgs.push(lyricText(thisRight + r.lyricSize * 0.42, ly.systemY, "-"));
@@ -1324,10 +1328,13 @@ export function toSvg(rows, chant, options = {}) {
1324
1328
  phraseIndex: pl.row.phraseIndex,
1325
1329
  syllableIndex: pl.row.syllableIndex,
1326
1330
  neumeGroup: pl.row.neumeGroup,
1327
- noteIndex: pl.row.neumeIndex,
1331
+ noteIndex: pl.row.noteIndex,
1332
+ neumeIndex: pl.row.neumeIndex,
1328
1333
  system: pl.system,
1329
1334
  x: Number(pl.x.toFixed(2)),
1330
1335
  y: Number(pl.y.toFixed(2)),
1336
+ inkLeft: Number(pl.inkLeft.toFixed(2)),
1337
+ inkRight: Number(pl.inkRight.toFixed(2)),
1331
1338
  systemY: Number(pl.systemY.toFixed(2)),
1332
1339
  }));
1333
1340
  return { svg, geometry };
@@ -513,7 +513,7 @@ const SHARE_FLOOR = 10;
513
513
  */
514
514
  function cadenceLabel(fam, mode) {
515
515
  // NO CATALOGUE FAMILY IS ITSELF A MEASUREMENT. CADENTIAE holds the families
516
- // above a floor of fifty corpus occurrences — 122 of them — so a close that
516
+ // above a floor of fifty corpus occurrences — 110 of them — so a close that
517
517
  // fails to join is not unknown, it is RARER than anything the catalogue
518
518
  // records. A third of inked cadences land here, and leaving them bare made
519
519
  // the rarest closes look like the ones the analysis had nothing to say
@@ -570,7 +570,7 @@ export function buildTonarium(notes, data, cfg) {
570
570
  const governing = (p) => {
571
571
  let best = null;
572
572
  for (const m of data.modulations) {
573
- if (m.confidence >= 0.4 && m.startPhrase <= p && p <= m.endPhrase &&
573
+ if (m.confidence >= CONF_FLOOR && m.startPhrase <= p && p <= m.endPhrase &&
574
574
  (!best || m.confidence > best.confidence))
575
575
  best = m;
576
576
  }
@@ -579,6 +579,18 @@ export function buildTonarium(notes, data, cfg) {
579
579
  return home != null ? { mode: home, conf: 1, kind: "home" } : null;
580
580
  };
581
581
  const systems = [...new Set(notes.map((n) => n.system))].sort((a, b) => a - b);
582
+ // WHERE EACH CADENCE LANDS. `notes` is in tabula order, so the last note
583
+ // carrying a cadenceRef is that cadence's closing note, and the system it
584
+ // sits in is the only one that may draw the closing dot and the label. A
585
+ // figure that wraps is re-inked in every system it crosses — the claim spans
586
+ // them — but it CLOSES once. Drawn per-system, a wrapped cadence printed its
587
+ // landing dot on the earlier fragment's last sample (a landing mid-figure)
588
+ // and repeated its label, so one close read as two.
589
+ const landingSystem = new Map();
590
+ for (const n of notes) {
591
+ if (n.row.cadenceRef != null)
592
+ landingSystem.set(n.row.cadenceRef, n.system);
593
+ }
582
594
  for (const s of systems) {
583
595
  const sysNotes = notes.filter((n) => n.system === s);
584
596
  const sysY = sysNotes[0].systemY;
@@ -676,17 +688,22 @@ export function buildTonarium(notes, data, cfg) {
676
688
  const samples = (samplesByPhrase.get(fig[0].row.phraseIndex) ?? [])
677
689
  .filter(([px]) => px >= x0 && px <= x1 + 2 * k);
678
690
  // Where the cadence LANDS — the closing dot, which the label centres on.
691
+ // Only the landing system draws it; an earlier fragment re-inks the
692
+ // ribbon and stops there.
693
+ const lands = landingSystem.get(ci) === s;
679
694
  let dot;
680
695
  if (samples.length >= 2) {
681
696
  const d = ribbonPath(samples, vat, vmax, 1);
682
697
  g.push(`<path d="${d}" fill="${INK}" fill-opacity="${(STRATUM.cadence * op).toFixed(2)}"/>`);
683
- const [nx, ny] = samples[samples.length - 1];
684
- dot = nx;
685
- const r = sc(1.8 * k);
686
- g.push(closes
687
- ? `<circle cx="${nx.toFixed(1)}" cy="${ny.toFixed(1)}" r="${r}" fill="${INK}" opacity="${op.toFixed(2)}"/>`
688
- : `<circle cx="${nx.toFixed(1)}" cy="${ny.toFixed(1)}" r="${r}" fill="none" stroke="${INK}" ` +
689
- `stroke-width="${sc(0.9 * k)}" opacity="${op.toFixed(2)}"/>`);
698
+ if (lands) {
699
+ const [nx, ny] = samples[samples.length - 1];
700
+ dot = nx;
701
+ const r = sc(1.8 * k);
702
+ g.push(closes
703
+ ? `<circle cx="${nx.toFixed(1)}" cy="${ny.toFixed(1)}" r="${r}" fill="${INK}" opacity="${op.toFixed(2)}"/>`
704
+ : `<circle cx="${nx.toFixed(1)}" cy="${ny.toFixed(1)}" r="${r}" fill="none" stroke="${INK}" ` +
705
+ `stroke-width="${sc(0.9 * k)}" opacity="${op.toFixed(2)}"/>`);
706
+ }
690
707
  }
691
708
  // The label: how characteristic this close is OF THIS CHANT'S MODE —
692
709
  // the family's in-mode share over its corpus share ("×2.1"). The raw
@@ -696,7 +713,7 @@ export function buildTonarium(notes, data, cfg) {
696
713
  // system's edge it clamps to the margin rather than jumping to the
697
714
  // figure's other side. A light end-ticked bracket ties it to the span.
698
715
  const lab = cadenceLabel(fam, data.mode);
699
- if (lab) {
716
+ if (lab && lands) {
700
717
  // The label sits UNDER THE CLOSING DOT, centred on it. The dot is
701
718
  // where the cadence lands — the one point the measure is about — so
702
719
  // the number belongs beneath it rather than trailing the figure at
@@ -704,6 +721,8 @@ export function buildTonarium(notes, data, cfg) {
704
721
  // figure's span, which the re-inked sparkline above already draws,
705
722
  // and two marks for one extent read as two claims.
706
723
  const estW = lab.length * 5.6 * k; // 9px mono advance, measured
724
+ // With fewer than two samples in the landing slice no dot was drawn;
725
+ // the label centres on the figure's closing ink instead of orphaning.
707
726
  const dotX = dot ?? x1;
708
727
  const left = xL;
709
728
  const right = cfg.rightFor(s) - 2 * k;
@@ -1,7 +1,7 @@
1
1
  import { toPitch } from "../temper/pitch.js";
2
2
  import { toStep } from "../temper/step.js";
3
3
  import { selectVowel } from "../chant/syllabify.js";
4
- import { classifyNeume } from "./neume.js";
4
+ import { classifyNeume, classifyFigures } from "./neume.js";
5
5
  function rawToNote(raw, scale) {
6
6
  const midi = raw.step;
7
7
  // One read of the lyric: the nucleus and the diphthong it belongs to.
@@ -65,14 +65,15 @@ function rawToNote(raw, scale) {
65
65
  const SALICUS_PROLONGATION = 1.3;
66
66
  function makeSyllable(lyric, notes) {
67
67
  const neume = classifyNeume(notes);
68
+ const neumes = classifyFigures(notes);
68
69
  if (neume.type === "salicus" && notes.length >= 2) {
69
70
  const summit = notes[notes.length - 1];
70
71
  summit.performance.duration *= SALICUS_PROLONGATION;
71
72
  }
72
73
  const runs = notes[0]?.context.runs;
73
74
  return runs
74
- ? { lyric, runs, notes, neume, melisma: notes.length }
75
- : { lyric, notes, neume, melisma: notes.length };
75
+ ? { lyric, runs, notes, neume, neumes, melisma: notes.length }
76
+ : { lyric, notes, neume, neumes, melisma: notes.length };
76
77
  }
77
78
  function partitionByIctus(annotated) {
78
79
  const groups = [];
@@ -217,8 +218,27 @@ function applyCompoundBeats(phrases) {
217
218
  for (const phrase of phrases) {
218
219
  const annotated = [];
219
220
  for (const syl of phrase.syllables) {
221
+ // A note is named by ITS OWN figure. Annotating every note of a melisma
222
+ // with the whole syllable's name told the two conventional overrides
223
+ // below (salicus → arsic, doubly-dotted clivis → thetic) that a
224
+ // three-figure syllable was one "compound" neume, so a clivis inside it
225
+ // was invisible to the rule that names it. The salicus is classified at
226
+ // syllable scope and so survives the split — see `classifyFigures`.
227
+ const byGroup = new Map();
228
+ let figure = -1;
229
+ let cursor = -1;
220
230
  for (const note of syl.notes) {
221
- annotated.push({ note, neumeType: syl.neume.type });
231
+ if (note.context.neumeGroup !== figure) {
232
+ figure = note.context.neumeGroup;
233
+ cursor++;
234
+ }
235
+ byGroup.set(note.context.neumeGroup, syl.neumes[cursor]?.type ?? syl.neume.type);
236
+ }
237
+ for (const note of syl.notes) {
238
+ annotated.push({
239
+ note,
240
+ neumeType: byGroup.get(note.context.neumeGroup) ?? syl.neume.type,
241
+ });
222
242
  }
223
243
  }
224
244
  phrase.beats = classifyCompoundBeats(annotated);
@@ -48,7 +48,7 @@ export interface Arcus {
48
48
  */
49
49
  archIndex: number;
50
50
  }
51
- export interface Prosody {
51
+ export interface Metrics {
52
52
  noteCount: number;
53
53
  syllableCount: number;
54
54
  phraseCount: number;
@@ -69,5 +69,5 @@ export interface Prosody {
69
69
  cadenceWeight: number;
70
70
  cadenceDistribution: CadenceDistribution;
71
71
  }
72
- export declare function computeProsody(phrases: Phrase[]): Prosody;
73
- //# sourceMappingURL=prosody.d.ts.map
72
+ export declare function computeMetrics(phrases: Phrase[]): Metrics;
73
+ //# sourceMappingURL=metrics.d.ts.map
@@ -1,4 +1,4 @@
1
- export function computeProsody(phrases) {
1
+ export function computeMetrics(phrases) {
2
2
  let phraseCount = 0;
3
3
  let noteCount = 0;
4
4
  let syllableCount = 0;
@@ -175,4 +175,4 @@ export function computeProsody(phrases) {
175
175
  cadenceDistribution: cadDist,
176
176
  };
177
177
  }
178
- //# sourceMappingURL=prosody.js.map
178
+ //# sourceMappingURL=metrics.js.map
@@ -1,3 +1,22 @@
1
1
  import type { Neume, Note } from "./types.js";
2
2
  export declare function classifyNeume(notes: Note[]): Neume;
3
+ /**
4
+ * The syllable's figures, each classified in its own right.
5
+ *
6
+ * GABC marks figure boundaries (`!`, `/`, `//`) and the parser records them as
7
+ * `context.neumeGroup`, but classification read the whole syllable, so a
8
+ * three-figure melisma was named once and the name was almost always
9
+ * "compound" — two of every three compounds the corpus reported. A syllable is
10
+ * not a neume; it carries neumes.
11
+ *
12
+ * THE SALICUS IS CLASSIFIED AT SYLLABLE SCOPE, DELIBERATELY, and the exception
13
+ * is the point rather than an oversight. Its rule reads the oriscus on the
14
+ * next-to-last note of an ascent (see `classifyNeume` above), and 41 of the
15
+ * corpus's 255 salici are written across a figure boundary — the oriscus in
16
+ * one figure, the summit in the next. Splitting first severed them and the
17
+ * count fell to 251, which would have narrowed Cardine's definition by
18
+ * refactoring rather than by ruling. The ascent is the neume; where the
19
+ * scribe broke the figure is a separate fact.
20
+ */
21
+ export declare function classifyFigures(notes: Note[]): Neume[];
3
22
  //# sourceMappingURL=neume.d.ts.map
@@ -56,4 +56,39 @@ export function classifyNeume(notes) {
56
56
  }
57
57
  return { type, intervals, hasQuilisma, hasLiquescent, hasStrophicus };
58
58
  }
59
+ /**
60
+ * The syllable's figures, each classified in its own right.
61
+ *
62
+ * GABC marks figure boundaries (`!`, `/`, `//`) and the parser records them as
63
+ * `context.neumeGroup`, but classification read the whole syllable, so a
64
+ * three-figure melisma was named once and the name was almost always
65
+ * "compound" — two of every three compounds the corpus reported. A syllable is
66
+ * not a neume; it carries neumes.
67
+ *
68
+ * THE SALICUS IS CLASSIFIED AT SYLLABLE SCOPE, DELIBERATELY, and the exception
69
+ * is the point rather than an oversight. Its rule reads the oriscus on the
70
+ * next-to-last note of an ascent (see `classifyNeume` above), and 41 of the
71
+ * corpus's 255 salici are written across a figure boundary — the oriscus in
72
+ * one figure, the summit in the next. Splitting first severed them and the
73
+ * count fell to 251, which would have narrowed Cardine's definition by
74
+ * refactoring rather than by ruling. The ascent is the neume; where the
75
+ * scribe broke the figure is a separate fact.
76
+ */
77
+ export function classifyFigures(notes) {
78
+ if (notes.length === 0)
79
+ return [];
80
+ const whole = classifyNeume(notes);
81
+ if (whole.type === "salicus")
82
+ return [whole];
83
+ const figures = [];
84
+ let group = -1;
85
+ for (const note of notes) {
86
+ if (note.context.neumeGroup !== group) {
87
+ figures.push([]);
88
+ group = note.context.neumeGroup;
89
+ }
90
+ figures[figures.length - 1].push(note);
91
+ }
92
+ return figures.map(classifyNeume);
93
+ }
59
94
  //# sourceMappingURL=neume.js.map
@@ -2,8 +2,11 @@ import { buildArticulation } from "./articulation.js";
2
2
  import { createLyricDecoder } from "./lyric.js";
3
3
  import { detectVowelAccent } from "../chant/syllabify.js";
4
4
  // Constants
5
+ // `oct` anchors the staff onto the gamut, which is an absolute frame: guido.ts
6
+ // fixes Γ at midi 43. At oct 3 a chant read an octave below that anchor, so
7
+ // most of the corpus sat at or under the gamut's floor.
5
8
  const DEFAULT_OPTIONS = {
6
- oct: 3,
9
+ oct: 4,
7
10
  useVowelAccent: true,
8
11
  };
9
12
  // Per-clef diatonic-step offset. A GABC clef names the staff line it sits on
@@ -163,7 +163,7 @@ const CADENCE_VELOCITY_FACTOR = 0.5;
163
163
  const CADENCE_DURATION_FACTOR = 0.6;
164
164
  // The phrasing-side reading of the divisio (bar-line) hierarchy. This is the
165
165
  // same bar hierarchy tabulated canonically in ../../../docs/api/score.md and
166
- // weighted for analysis by prosody.ts's cadenceWeight ladder — but here the
166
+ // weighted for analysis by metrics.ts's cadenceWeight ladder — but here the
167
167
  // weights differ on purpose:
168
168
  // this is a *shaping* factor, so the virgula (`) gets 0 (a breath, no cadential
169
169
  // stress), where the analytic ladder still counts it. Do not unify the three.
@@ -221,7 +221,13 @@ export interface Syllable {
221
221
  /** Styled lyric spans; present only when GABC markup styled this syllable. */
222
222
  runs?: LyricRun[];
223
223
  notes: Note[];
224
+ /** The syllable read as ONE figure. A syllable carrying several neumes
225
+ * classifies as "compound" here; `neumes` names them individually. */
224
226
  neume: Neume;
227
+ /** The syllable's figures, as GABC groups them (`!`, `/`, `//`), each
228
+ * classified in its own right. One entry for a syllable of one figure —
229
+ * then `neumes[0]` and `neume` agree. */
230
+ neumes: Neume[];
225
231
  /** Notes sung on this syllable — its melisma. 1 = syllabic, >1 = melismatic. */
226
232
  melisma: number;
227
233
  }
@@ -7,8 +7,10 @@
7
7
  // finger to cc before crossing to the middle for dd and ee. Reading it with
8
8
  // cc on the middle and dd on the ring makes the line cross three times where
9
9
  // the hand turns once. The figure that DRAWS the turn lives in the site
10
- // (orreliquum-next), and its LOCUS table must agree with the fingers named
11
- // here; the test that holds the two together lives there beside it. The two b/♮ pairs (58/59, 70/71) share a
10
+ // (orreliquum), and its LOCUS table must agree with the fingers named here;
11
+ // the test that holds the two together lives there beside it.
12
+ //
13
+ // The two b/♮ pairs (58/59, 70/71) share a
12
14
  // locus by design — one joint, two readings (fa in molle, mi in durum), the
13
15
  // tradition, not a duplication to fix. They are one distinction, named for the
14
16
  // two shapes of the letter: b rotundum (the round b) against b quadratum (the
@@ -2,7 +2,7 @@ import type { Interval } from "./interval.js";
2
2
  import type { Pitch, PitchInput } from "./pitch.js";
3
3
  import type { Scale } from "./scale.js";
4
4
  export type { Interval };
5
- export type NeumeShape = "punctum" | "pes" | "clivis" | "torculus" | "porrectus" | "scandicus" | "salicus" | "climacus" | "torculus resupinus" | "porrectus flexus" | "scandicus flexus" | "climacus resupinus" | "pes subpunctis" | "compound";
5
+ export type NeumeShape = "punctum" | "pes" | "clivis" | "torculus" | "porrectus" | "scandicus" | "salicus" | "climacus" | "torculus resupinus" | "porrectus flexus" | "scandicus flexus" | "climacus resupinus" | "pes subpunctis" | "distropha" | "tristropha" | "tristropha flexa" | "pressus" | "pressus maior" | "scandicus subpunctis" | "compound";
6
6
  export interface Neume {
7
7
  pitches: Pitch[];
8
8
  intervals: Interval[];
@@ -9,9 +9,12 @@ export function classifyShape(dirs) {
9
9
  return "punctum";
10
10
  const up = (d) => d === "up";
11
11
  const dn = (d) => d === "down";
12
+ const un = (d) => d === "unison";
12
13
  switch (n) {
13
14
  case 1:
14
- return up(dirs[0]) ? "pes" : dn(dirs[0]) ? "clivis" : "punctum";
15
+ // A unison here is two notes on one pitch — a distropha, not a punctum.
16
+ // Reporting "punctum" said a two-note figure was one note.
17
+ return up(dirs[0]) ? "pes" : dn(dirs[0]) ? "clivis" : "distropha";
15
18
  case 2: {
16
19
  const [d0, d1] = dirs;
17
20
  if (up(d0) && dn(d1))
@@ -22,6 +25,10 @@ export function classifyShape(dirs) {
22
25
  return "scandicus";
23
26
  if (dn(d0) && dn(d1))
24
27
  return "climacus";
28
+ if (un(d0) && un(d1))
29
+ return "tristropha";
30
+ if (un(d0) && dn(d1))
31
+ return "pressus";
25
32
  return "compound";
26
33
  }
27
34
  case 3: {
@@ -36,12 +43,26 @@ export function classifyShape(dirs) {
36
43
  return "climacus resupinus";
37
44
  if (up(d0) && dn(d1) && dn(d2))
38
45
  return "pes subpunctis";
46
+ if (un(d0) && un(d1) && dn(d2))
47
+ return "tristropha flexa";
48
+ if (dn(d0) && un(d1) && dn(d2))
49
+ return "pressus maior";
39
50
  return "compound";
40
51
  }
41
- default:
42
- if (up(dirs[0]) && dirs.slice(1).every((d) => dn(d)))
52
+ default: {
53
+ // The long forms: a head, then an unbroken descent. Only the head
54
+ // distinguishes them, and the descent must be total — a figure that
55
+ // turns again is a compound melisma, which at this length most are.
56
+ if (up(dirs[0]) && dirs.slice(1).every(dn))
43
57
  return "pes subpunctis";
58
+ if (up(dirs[0]) && up(dirs[1]) && dirs.slice(2).every(dn)) {
59
+ return "scandicus subpunctis";
60
+ }
61
+ if (dirs.slice(0, -1).every(un) && dn(dirs[dirs.length - 1])) {
62
+ return "tristropha flexa";
63
+ }
44
64
  return "compound";
65
+ }
45
66
  }
46
67
  }
47
68
  export function buildNeume(inputs, scala) {
package/dist/index.d.ts CHANGED
@@ -17,7 +17,7 @@ import type { Score, ScoreOpts, PondusInput, PondusOpts, AccentusInput, Accentus
17
17
  import type { InscriptioOpts, Inscriptio, NoteGeometry, FontSpec, FontSlot, FontEmbed } from "./engines/score/inscriptio.js";
18
18
  import type { ChantTabulaRow } from "./engines/score/tabula.js";
19
19
  import type { Imprint, Attractor, VowelAttractor, ModalAffinity } from "./engines/imprint.js";
20
- import type { Prosody, RhythmicProfile, NoteRange, CadenceDistribution } from "./engines/score/prosody.js";
20
+ import type { Metrics, RhythmicProfile, NoteRange, CadenceDistribution } from "./engines/score/metrics.js";
21
21
  import type { Harmony, HarmoniaOpts, VoicedBody, VoicedAspect, Frame, Author } from "./engines/harmonia/api.js";
22
22
  import type { HarmonyTabulaRow } from "./engines/harmonia/tabula.js";
23
23
  import type { PlanetVowel } from "./engines/harmonia/data/vowels.js";
@@ -58,5 +58,5 @@ export { CADENTIAE, CADENTIAE_POPULATION } from "./data/cadentiae.js";
58
58
  export type { CadentiaFamilia } from "./data/cadentiae.js";
59
59
  export { ZODIACA } from "./engines/harmonia/data/zodiac.js";
60
60
  export { CENSUS_GROUPS, CENSUS_ORDER } from "./data/census.js";
61
- export type { Feast, FeastQuery, Pascha, Season, Grade, Chant, CantusQuery, OrdinaryChant, PropriumQuery, OrdinariumQuery, OfficiumQuery, PsalmusQuery, Corpus, GenusCount, ModeCount, SharedCount, CorpusLedger, CorpusFullCount, CorpusQuery, Census, CensusQuery, CensusBy, CensusGroup, CensusGroupProfile, CensusNeighbor, Temperamentum, TemperamentumInput, TemperamentumOpts, Tuning, Pitch, PitchInput, Step, Neume, NeumeShape, Interval, ModeData, CadenceFigure, Modus, TunedNote, GamutOptions, Tonus, TonusOpts, Score, ScoreOpts, PondusInput, PondusOpts, AccentusInput, AccentusOpts, Cadence, CadenceTarget, CadenceApproach, Modulation, InscriptioOpts, Inscriptio, NoteGeometry, FontSpec, FontSlot, FontEmbed, ChantTabulaRow, Note, Performance, Phrase, Syllable, LyricRun, RestEvent, ParseError, ArsisThesis, RhythmicType, CompoundBeat, VoicedPitch, Cosmos, CosmosQuery, Body, BodyName, Aspect, Imprint, Attractor, VowelAttractor, ModalAffinity, Prosody, RhythmicProfile, NoteRange, CadenceDistribution, Harmony, HarmoniaOpts, VoicedBody, VoicedAspect, Frame, Author, HarmonyTabulaRow, PlanetVowel, };
61
+ export type { Feast, FeastQuery, Pascha, Season, Grade, Chant, CantusQuery, OrdinaryChant, PropriumQuery, OrdinariumQuery, OfficiumQuery, PsalmusQuery, Corpus, GenusCount, ModeCount, SharedCount, CorpusLedger, CorpusFullCount, CorpusQuery, Census, CensusQuery, CensusBy, CensusGroup, CensusGroupProfile, CensusNeighbor, Temperamentum, TemperamentumInput, TemperamentumOpts, Tuning, Pitch, PitchInput, Step, Neume, NeumeShape, Interval, ModeData, CadenceFigure, Modus, TunedNote, GamutOptions, Tonus, TonusOpts, Score, ScoreOpts, PondusInput, PondusOpts, AccentusInput, AccentusOpts, Cadence, CadenceTarget, CadenceApproach, Modulation, InscriptioOpts, Inscriptio, NoteGeometry, FontSpec, FontSlot, FontEmbed, ChantTabulaRow, Note, Performance, Phrase, Syllable, LyricRun, RestEvent, ParseError, ArsisThesis, RhythmicType, CompoundBeat, VoicedPitch, Cosmos, CosmosQuery, Body, BodyName, Aspect, Imprint, Attractor, VowelAttractor, ModalAffinity, Metrics, RhythmicProfile, NoteRange, CadenceDistribution, Harmony, HarmoniaOpts, VoicedBody, VoicedAspect, Frame, Author, HarmonyTabulaRow, PlanetVowel, };
62
62
  //# sourceMappingURL=index.d.ts.map
package/docs/api/chant.md CHANGED
@@ -95,9 +95,9 @@ tonus.corpus();
95
95
  ```
96
96
 
97
97
  **`count` is the number of chants** — the one to quote. `listings` is how long
98
- the shelf is, and `listings - count` is 580 extra rows, over the 683 chants
99
- printed in more than one book. The breakdowns describe the same population
100
- `count` does, so `genera` and `modes` sum to it.
98
+ the shelf is: a melody printed in several books is stored once and listed under
99
+ each, so the shelf runs longer than the repertory. The breakdowns describe the
100
+ same population `count` does, so `genera` and `modes` sum to it.
101
101
 
102
102
  ```js
103
103
  tonus.corpus("am");
@@ -313,9 +313,9 @@ for that chant. The two prefixes in the shipped corpus are `gregobase:` (1,717)
313
313
  and `nocturnale:` (470), the latter carrying the Nocturnale's own alphanumeric
314
314
  keys rather than numbers.
315
315
 
316
- Within tonus an id is exactly one chant. A melody printed in several books
317
- 683 of them are — is stored once, under the record `cantus({ id })` returns, so
318
- `id` is a stable key to a chant rather than to a printing.
316
+ Within tonus an id is exactly one chant. A melody printed in several books is
317
+ stored once, under the record `cantus({ id })` returns, so `id` is a stable key
318
+ to a chant rather than to a printing.
319
319
 
320
320
  ## The repertoire as of a date — the era view
321
321
 
@@ -601,7 +601,7 @@ phrase punctuation ([score.md](score.md#the-tabula)).
601
601
 
602
602
  Because the encoding is textual, lyrics and neumes stay aligned syllable
603
603
  by syllable, which is what lets `tonus.notatio` reconstruct syllables,
604
- neumes, and prosody without images.
604
+ neumes, and metrics without images.
605
605
 
606
606
  ### The Mass: proper and ordinary
607
607
 
package/docs/api/index.md CHANGED
@@ -4,7 +4,7 @@ The technical center of tonus: the full public API, the conventions every method
4
4
  obeys, and the error contract. The API is **fourteen methods on the `tonus`
5
5
  namespace**, no sub-namespaces.
6
6
 
7
- **[Interactive demo →](https://jeffreypierce.github.io/tonus/)**
7
+ **[Orreliquum the library at work →](https://jeffreypierce.github.io/orreliquum/)**
8
8
 
9
9
  ```js
10
10
  import tonus from "tonus";
@@ -173,7 +173,7 @@ the list resolve their pitches through the ones before.
173
173
  - [The tabula](score.md#the-tabula)
174
174
  - [Rendering — `inscriptio`](score.md#rendering) · [theme](score.md#theme--faces-and-ink) · [The analysis tracks](score.md#the-analysis-tracks)
175
175
  - [The imprint](score.md#the-imprint)
176
- - [Prosody](score.md#prosody)
176
+ - [Metrics](score.md#metrics)
177
177
  - [Cadences](score.md#cadences) · [One spine, two annotations](score.md#one-spine-two-annotations)
178
178
  - [Modulations](score.md#modulations)
179
179
  - [Theory & Context](score.md#theory--context) · [Sources](score.md#sources)