tonus 0.1.8 → 0.5.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 (164) hide show
  1. package/BIBLIOGRAPHY.md +132 -108
  2. package/CHANGELOG.md +598 -1
  3. package/LICENSE +133 -29
  4. package/README.md +106 -83
  5. package/dist/data/am.js +2666 -11196
  6. package/dist/data/ams.d.ts +5 -0
  7. package/dist/data/ams.js +122 -0
  8. package/dist/data/attestation.d.ts +19 -0
  9. package/dist/data/attestation.js +15716 -0
  10. package/dist/data/attestation.json +15711 -0
  11. package/dist/data/cadentiae.d.ts +43 -0
  12. package/dist/data/cadentiae.js +174 -0
  13. package/dist/data/cal.js +48 -0
  14. package/dist/data/census.d.ts +12 -0
  15. package/dist/data/census.js +36 -0
  16. package/dist/data/commune-office.d.ts +4 -0
  17. package/dist/data/commune-office.js +2371 -0
  18. package/dist/data/commune-office.json +2365 -0
  19. package/dist/data/commune.js +181 -11
  20. package/dist/data/corpus-overlap.d.ts +17 -0
  21. package/dist/data/corpus-overlap.js +299 -5
  22. package/dist/data/cot.d.ts +5 -0
  23. package/dist/data/cot.js +172 -0
  24. package/dist/data/cse.d.ts +5 -0
  25. package/dist/data/cse.js +122 -0
  26. package/dist/data/gabc-glyphs.d.ts +45 -0
  27. package/dist/data/gabc-glyphs.js +122 -0
  28. package/dist/data/gr.js +754 -6394
  29. package/dist/data/kyriale.js +116 -116
  30. package/dist/data/la.js +799 -13419
  31. package/dist/data/lh.js +113 -3473
  32. package/dist/data/lu.js +931 -17631
  33. package/dist/data/nocturnale-romanum.js +1659 -10411
  34. package/dist/data/office-ferial.d.ts +4 -0
  35. package/dist/data/office-ferial.js +396 -0
  36. package/dist/data/office-ferial.json +391 -0
  37. package/dist/data/office-monastic.d.ts +17 -1
  38. package/dist/data/office-monastic.js +1403 -466
  39. package/dist/data/office-psalms-monastic.d.ts +13 -1
  40. package/dist/data/office-psalms-monastic.js +9 -0
  41. package/dist/data/propers.js +1 -1
  42. package/dist/data/psalms.js +22919 -5
  43. package/dist/data/psm.d.ts +5 -0
  44. package/dist/data/psm.js +122 -0
  45. package/dist/data/seasonal-respbreve.d.ts +5 -0
  46. package/dist/data/seasonal-respbreve.js +41 -0
  47. package/dist/data/seasonal-respbreve.json +35 -0
  48. package/dist/data/smufl-glyphs.d.ts +17 -0
  49. package/dist/data/smufl-glyphs.js +1546 -0
  50. package/dist/data/smufl-glyphs.json +1530 -0
  51. package/dist/engines/cal/calendar.d.ts +3 -2
  52. package/dist/engines/cal/calendar.js +105 -29
  53. package/dist/engines/cal/data/eras.d.ts +35 -0
  54. package/dist/engines/cal/data/eras.js +128 -0
  55. package/dist/engines/cal/date.js +44 -0
  56. package/dist/engines/cal/types.d.ts +15 -3
  57. package/dist/engines/cal/types.js +5 -5
  58. package/dist/engines/census/census.d.ts +7 -0
  59. package/dist/engines/census/census.js +179 -0
  60. package/dist/engines/census/types.d.ts +55 -0
  61. package/dist/engines/census/types.js +8 -0
  62. package/dist/engines/chant/attest.d.ts +39 -0
  63. package/dist/engines/chant/attest.js +90 -0
  64. package/dist/engines/chant/chant.d.ts +16 -4
  65. package/dist/engines/chant/chant.js +220 -30
  66. package/dist/engines/chant/data/compline.js +2 -1
  67. package/dist/engines/chant/data/masses.d.ts +56 -4
  68. package/dist/engines/chant/data/masses.js +305 -80
  69. package/dist/engines/chant/data/prime.js +1 -1
  70. package/dist/engines/chant/hour.js +279 -58
  71. package/dist/engines/chant/ordinary.d.ts +2 -0
  72. package/dist/engines/chant/ordinary.js +336 -56
  73. package/dist/engines/chant/propers.js +55 -5
  74. package/dist/engines/chant/psalm.d.ts +4 -4
  75. package/dist/engines/chant/psalm.js +25 -11
  76. package/dist/engines/chant/syllabify.d.ts +1 -0
  77. package/dist/engines/chant/syllabify.js +90 -17
  78. package/dist/engines/chant/types.d.ts +115 -12
  79. package/dist/engines/chant/types.js +38 -3
  80. package/dist/engines/harmonia/api.js +4 -0
  81. package/dist/engines/harmonia/data/doctrines.js +3 -1
  82. package/dist/engines/harmonia/tabula.d.ts +3 -0
  83. package/dist/engines/harmonia/tabula.js +1 -0
  84. package/dist/engines/harmonia/voice.d.ts +4 -0
  85. package/dist/engines/harmonia/voice.js +8 -4
  86. package/dist/engines/imprint.js +14 -1
  87. package/dist/engines/planet/orbital.js +4 -4
  88. package/dist/engines/planet/planet.d.ts +10 -0
  89. package/dist/engines/planet/planet.js +30 -3
  90. package/dist/engines/planet/position.js +13 -10
  91. package/dist/engines/planet/types.d.ts +1 -0
  92. package/dist/engines/score/api.d.ts +2 -13
  93. package/dist/engines/score/api.js +21 -8
  94. package/dist/engines/score/articulation.js +2 -2
  95. package/dist/engines/score/cadence.d.ts +76 -0
  96. package/dist/engines/score/cadence.js +96 -0
  97. package/dist/engines/score/emitters/accidentals.d.ts +21 -0
  98. package/dist/engines/score/emitters/accidentals.js +88 -0
  99. package/dist/engines/score/emitters/atramentum.d.ts +107 -0
  100. package/dist/engines/score/emitters/atramentum.js +239 -0
  101. package/dist/engines/score/emitters/breaking.d.ts +62 -0
  102. package/dist/engines/score/emitters/breaking.js +80 -0
  103. package/dist/engines/score/emitters/moderna.d.ts +38 -0
  104. package/dist/engines/score/emitters/moderna.js +612 -0
  105. package/dist/engines/score/emitters/svg.d.ts +143 -0
  106. package/dist/engines/score/emitters/svg.js +1328 -0
  107. package/dist/engines/score/emitters/tracks.d.ts +104 -0
  108. package/dist/engines/score/emitters/tracks.js +728 -0
  109. package/dist/engines/score/infer.d.ts +3 -3
  110. package/dist/engines/score/infer.js +2 -2
  111. package/dist/engines/score/inscriptio.d.ts +69 -0
  112. package/dist/engines/score/inscriptio.js +138 -0
  113. package/dist/engines/score/ir.d.ts +2 -2
  114. package/dist/engines/score/ir.js +59 -12
  115. package/dist/engines/score/lyric.d.ts +23 -0
  116. package/dist/engines/score/lyric.js +234 -0
  117. package/dist/engines/score/meta.d.ts +2 -2
  118. package/dist/engines/score/modulation.d.ts +12 -0
  119. package/dist/engines/score/modulation.js +49 -0
  120. package/dist/engines/score/neume.js +35 -4
  121. package/dist/engines/score/parse.js +150 -9
  122. package/dist/engines/score/phrasing.js +4 -3
  123. package/dist/engines/score/prosody.d.ts +38 -0
  124. package/dist/engines/score/prosody.js +70 -6
  125. package/dist/engines/score/tabula.d.ts +37 -5
  126. package/dist/engines/score/tabula.js +18 -0
  127. package/dist/engines/score/types.d.ts +88 -1
  128. package/dist/engines/temper/api.d.ts +4 -1
  129. package/dist/engines/temper/api.js +28 -5
  130. package/dist/engines/temper/data/guido.js +6 -2
  131. package/dist/engines/temper/data/modes.d.ts +6 -0
  132. package/dist/engines/temper/data/modes.js +42 -0
  133. package/dist/engines/temper/data/tones.d.ts +1 -1
  134. package/dist/engines/temper/data/tones.js +20 -11
  135. package/dist/engines/temper/interval.js +4 -3
  136. package/dist/engines/temper/modality.d.ts +11 -2
  137. package/dist/engines/temper/modality.js +74 -2
  138. package/dist/engines/temper/modes.d.ts +1 -1
  139. package/dist/engines/temper/pitch.d.ts +1 -1
  140. package/dist/engines/temper/pitch.js +12 -2
  141. package/dist/engines/temper/scale.d.ts +53 -0
  142. package/dist/engines/temper/scale.js +107 -8
  143. package/dist/index.d.ts +26 -8
  144. package/dist/index.js +37 -4
  145. package/docs/api/calendar.md +279 -0
  146. package/docs/api/census.md +288 -0
  147. package/docs/api/chant.md +657 -0
  148. package/docs/api/heavens.md +346 -0
  149. package/docs/api/index.md +263 -0
  150. package/docs/api/score.md +873 -0
  151. package/docs/api/tuning.md +619 -0
  152. package/package.json +11 -5
  153. package/dist/data/office-matins-roman.d.ts +0 -19
  154. package/dist/data/office-matins-roman.js +0 -4383
  155. package/dist/data/office-psalms-roman.d.ts +0 -15
  156. package/dist/data/office-psalms-roman.js +0 -28
  157. package/dist/data/office-roman.d.ts +0 -19
  158. package/dist/data/office-roman.js +0 -13792
  159. package/dist/engines/chant/matutinum.d.ts +0 -33
  160. package/dist/engines/chant/matutinum.js +0 -81
  161. package/dist/engines/score/emitters/midi.d.ts +0 -65
  162. package/dist/engines/score/emitters/midi.js +0 -162
  163. package/dist/engines/score/emitters/musicxml.d.ts +0 -18
  164. package/dist/engines/score/emitters/musicxml.js +0 -166
@@ -12,6 +12,35 @@ const FINALIS_WEIGHT = 3;
12
12
  const TENOR_WEIGHT = 2;
13
13
  const REGULAR_MOD_WEIGHT = 1;
14
14
  const CONCEDED_MOD_WEIGHT = 0.5;
15
+ // `ModeData.recitingNotes` (see data/modes.ts) carries more than the
16
+ // principal tenor for several modes — auxiliary/secondary/pseudo/rare
17
+ // reciting notes, sourced from the Gregorian Modes Degree Summary Tables.
18
+ // A first attempt at wiring this in weighted every rank into degreeWeight
19
+ // unconditionally (via the same max-of-roles `set` used below) and broke
20
+ // ../../../tests/modality.test.mjs's authentic/plagal initials-bonus case for
21
+ // modes 7/8: that test relies on an exact baseline tie between the pair, broken
22
+ // only by the initials bonus. Mode 7's new auxiliary pc 0 partly overlapped
23
+ // a pc it already carried as a conceded modulation degree (weight 0.5),
24
+ // and "same rank, same weight" silently PROMOTED that existing pc to the
25
+ // higher auxiliary weight — mode 8 had no equivalent already-weighted pc to
26
+ // promote, so the promotion alone tipped the tie before the initials bonus
27
+ // got a chance to run.
28
+ //
29
+ // The fix below is additive-only: a non-principal reciting note contributes
30
+ // weight ONLY to a pc that isn't already weighted by the finalis, the tenor,
31
+ // or a modulation degree (see the second loop after `set(data.final, ...)`).
32
+ // It never promotes an existing weight. That keeps the 7/8 tie intact — both
33
+ // modes pick up their (as it happens, shared) new auxiliary pc 11 identically
34
+ // — while still letting genuinely new degrees (e.g. mode 4's pseudo-tenor at
35
+ // pc 5, mode 5's rare recitation on its own final) contribute real signal.
36
+ // `principal` entries are skipped outright: by construction they already
37
+ // coincide with `data.tenor` or `data.final` and would always be a no-op.
38
+ const RECITING_WEIGHT = {
39
+ auxiliary: 1.2,
40
+ secondary: 0.8,
41
+ pseudo: 0.5,
42
+ rare: 0.3,
43
+ };
15
44
  // A chant's opening note is a modal signal: each mode lists its valid initials
16
45
  // in rank order, most characteristic first (Rockstro's Grove ordering
17
46
  // [biblio: rockstro-grove]). Opening on a mode's primary initial boosts it more
@@ -19,11 +48,33 @@ const CONCEDED_MOD_WEIGHT = 0.5;
19
48
  // from its plagal partner, since the two share a finalis but rank the same
20
49
  // opening pitch differently.
21
50
  const INITIAL_BONUS = 0.3;
51
+ // The last note is the treatises' first determinant of mode: a chant comes to
52
+ // rest on its final. Landing on a mode's final is the single strongest signal.
53
+ const FINAL_NOTE_BONUS = 0.5;
54
+ // Tessitura — how high the melody sits above its final — is the classical
55
+ // authentic/plagal separator (an authentic mode ranges a fifth-and-more above the
56
+ // final; its plagal partner straddles it). The bonus peaks when the observed
57
+ // tessitura matches the mode's expected value and falls off linearly over
58
+ // TESSITURA_TOLERANCE.
59
+ //
60
+ // THE CALIBRATION IS PENDING RE-DERIVATION (noted 2026-08-11). These constants
61
+ // were fitted over the corpus at n≈6,666 — a count from before the accidental
62
+ // fix in `parse.ts`, which was emitting a phantom note at every accidental and
63
+ // so moved every mean pitch that used one. The SEPARATION still holds on a
64
+ // re-measure (authentic sits higher than plagal, cleanly), which is what these
65
+ // constants encode, so they are left in force rather than replaced by a worse
66
+ // number. What is not yet re-derived is where exactly the two centres sit.
67
+ const TESSITURA_AUTHENTIC = 4.0;
68
+ const TESSITURA_PLAGAL = 1.7;
69
+ const TESSITURA_TOLERANCE = 3;
70
+ const TESSITURA_WEIGHT = 1.0;
22
71
  /**
23
72
  * Rank a pitch-class distribution against the eight modes, best fit first.
24
- * `firstNotePc`, when given, applies the rank-weighted initials bonus.
73
+ * The optional signals sharpen the ranking: the opening note (initials bonus),
74
+ * the closing note (final-note bonus), and the tessitura (authentic/plagal).
25
75
  */
26
- export function computeModalAffinity(pcDistribution, firstNotePc) {
76
+ export function computeModalAffinity(pcDistribution, opts = {}) {
77
+ const { firstNotePc, lastNotePc, tessitura } = opts;
27
78
  const results = [];
28
79
  for (let m = 1; m <= 8; m++) {
29
80
  const data = MODES.get(m);
@@ -41,6 +92,16 @@ export function computeModalAffinity(pcDistribution, firstNotePc) {
41
92
  set(pc % 12, REGULAR_MOD_WEIGHT);
42
93
  set(data.tenor, TENOR_WEIGHT);
43
94
  set(data.final, FINALIS_WEIGHT);
95
+ // Additive-only pass: see the comment on RECITING_WEIGHT above for why
96
+ // this fills in gaps rather than using `set` (which would promote an
97
+ // existing weight, not just add a new one).
98
+ for (const rn of data.recitingNotes) {
99
+ if (rn.rank === "principal")
100
+ continue;
101
+ const pc = rn.pc % 12;
102
+ if (!degreeWeight.has(pc))
103
+ degreeWeight.set(pc, RECITING_WEIGHT[rn.rank]);
104
+ }
44
105
  let score = 0;
45
106
  for (const [pc, w] of degreeWeight)
46
107
  score += (pcDistribution[pc] ?? 0) * w;
@@ -51,6 +112,17 @@ export function computeModalAffinity(pcDistribution, firstNotePc) {
51
112
  if (rank !== -1)
52
113
  score += (INITIAL_BONUS * (initials.length - rank)) / initials.length;
53
114
  }
115
+ // Final-note + tessitura: only apply when the chant actually rests on this
116
+ // mode's final. The tessitura then separates the mode from its authentic/
117
+ // plagal partner (which share the final but sit at different heights).
118
+ if (lastNotePc != null && lastNotePc === data.final % 12) {
119
+ score += FINAL_NOTE_BONUS;
120
+ if (tessitura != null) {
121
+ const expected = data.type === "authentic" ? TESSITURA_AUTHENTIC : TESSITURA_PLAGAL;
122
+ const fit = Math.max(0, 1 - Math.abs(tessitura - expected) / TESSITURA_TOLERANCE);
123
+ score += TESSITURA_WEIGHT * fit;
124
+ }
125
+ }
54
126
  results.push({ mode: m, alias: data.alias, score });
55
127
  }
56
128
  return results.sort((a, b) => b.score - a.score);
@@ -1,5 +1,5 @@
1
1
  import type { ModeData } from "./data/modes.js";
2
- export type { ModeProfile, ModeData, CadenceFigure } from "./data/modes.js";
2
+ export type { ModeProfile, ModeData, CadenceFigure, RecitingNote } from "./data/modes.js";
3
3
  export { MODES } from "./data/modes.js";
4
4
  /** Return ModeData for mode 1–8. Throws on unknown mode. */
5
5
  export declare function getMode(mode: number): ModeData;
@@ -35,6 +35,6 @@ export interface PitchContext {
35
35
  a4?: number;
36
36
  }
37
37
  export declare function parsePitch(input: PitchInput, ctx?: PitchContext): number;
38
- export declare function toPitch(input: PitchInput, scale: Scale): Pitch;
38
+ export declare function toPitch(input: PitchInput, scale: Scale, prefer?: "flat" | "sharp"): Pitch;
39
39
  export declare function scaleDegreeInMode(midi: number, mode: number): number | null;
40
40
  //# sourceMappingURL=pitch.d.ts.map
@@ -62,13 +62,23 @@ export function parsePitch(input, ctx = {}) {
62
62
  // Resolves a PitchInput through a Scale into a tuned Pitch.
63
63
  // Applies the Scale's transpose and returns a Pitch with midi/pc/oct/spn
64
64
  // from the transposed MIDI plus tuning-derived hz/offset/bend/ratio.
65
- export function toPitch(input, scale) {
65
+ export function toPitch(input, scale, prefer) {
66
66
  const rawMidi = parsePitch(input, { mode: scale.mode, a4: scale.a4 });
67
67
  const { hz, offset, bend } = midiToHz(rawMidi, scale);
68
68
  const midi = clamp(rawMidi + scale.transpose);
69
69
  const pc = midi % 12;
70
70
  const oct = Math.floor(midi / 12) - 1;
71
- const useFlat = PREFER_FLAT_PCS.has(pc);
71
+ // Spell as the source WROTE it when the caller knows. Deriving the spelling
72
+ // from the pitch class alone cannot: pc 1 is D-flat in a chant that wrote a
73
+ // flat and C-sharp in one that wrote a sharp, and the class is identical.
74
+ // PREFER_FLAT_PCS ({3, 8, 10}) is a reasonable default but omits pc 1 and 6,
75
+ // so a written flat landing there came back spelled — and reported in `acc` —
76
+ // as a SHARP, the opposite of what the source said.
77
+ //
78
+ // The Graduale only ever writes B-flat (pc 10, the bmolle), so the corpus
79
+ // never reached the gap; `notatio` passes the hint because a caller may set
80
+ // any GABC accidental on any degree.
81
+ const useFlat = prefer ? prefer === "flat" : PREFER_FLAT_PCS.has(pc);
72
82
  const sp = useFlat ? FLAT_SPELLING[pc] : SHARP_SPELLING[pc];
73
83
  const accStr = sp.acc === -1 ? "b" : sp.acc === 1 ? "#" : "";
74
84
  const spn = `${sp.step}${accStr}${oct}`;
@@ -20,6 +20,15 @@ export interface RatioResult {
20
20
  cents: number;
21
21
  display: string;
22
22
  }
23
+ /** Stern-Brocot rational approximation — the nearest simple fraction to a
24
+ * decimal ratio, as `[numerator, denominator]`.
25
+ *
26
+ * Exported because `toRatio` only reaches it through a STRING, and a caller
27
+ * holding a computed ratio (a gamut row's `ratio`, a string fraction derived
28
+ * from two frequencies) has a number. Without this the caller writes the
29
+ * search again — which is how the site came to carry a second copy of this
30
+ * same algorithm. */
31
+ export declare function approximate(value: number, maxDen?: number): [number, number];
23
32
  export declare function toRatio(input: string): RatioResult;
24
33
  export declare function parseStep(v: number | string): number;
25
34
  export interface ScalaFile {
@@ -28,6 +37,50 @@ export interface ScalaFile {
28
37
  }
29
38
  export declare function parseScala(input: string): ScalaFile;
30
39
  export declare function getPtolemaicRatios(tuning: string): string[] | undefined;
40
+ /**
41
+ * The pure-fifth chain's ET-cents deviation per pitch class, anchored A = 0
42
+ * (the a4-reference anchor `offset` carries under the default tuning). The
43
+ * heji/cents accidental baseline reads THIS, so the emitters and the engine
44
+ * can never disagree about the chain's spelling.
45
+ */
46
+ export declare function pythagoreanCentsByPc(): number[];
47
+ /** The wolf: what is left of the octave when the chain of fifths runs out. */
48
+ export interface Lupus {
49
+ /** Pitch class the wolf is measured UP from — the chain's last link. */
50
+ from: number;
51
+ /** Pitch class it lands on — the chain's first. */
52
+ to: number;
53
+ /** The two, spelled as the chain spells them. */
54
+ spn: [string, string];
55
+ /** Its size, tempered, in cents. */
56
+ cents: number;
57
+ /** A pure fifth, for the comparison this exists to invite. */
58
+ pure: number;
59
+ /** Signed: positive is wide of a pure fifth, negative narrow. */
60
+ fromPure: number;
61
+ }
62
+ /**
63
+ * THE WOLF, from the chain the ratios are built on.
64
+ *
65
+ * Twelve fifths do not close an octave. Stack them from E♭ and the twelfth
66
+ * lands beside the first rather than on it, and the gap it leaves has to go
67
+ * somewhere: it goes into the one interval nobody stacked, G♯ up to E♭. That
68
+ * interval is a fifth by position and not by size, which is why it howls.
69
+ *
70
+ * Under pure fifths it comes out NARROW (678.5¢, the Pythagorean comma taken
71
+ * out of it). Temper the fifths and the leftover grows: every cent taken off
72
+ * eleven fifths is given back to this one, so it passes a pure fifth and goes
73
+ * wide (737.6¢ at quarter-comma). That sign change is the thing a reader of
74
+ * the slider is watching, and it is why `fromPure` is signed.
75
+ *
76
+ * Derived from the SAME walk `buildPythagoreanRatios` does, deliberately.
77
+ * There is a closed form (1200·7 − 11·fifth, which agrees to the decimal),
78
+ * but two derivations of one number is how they drift apart.
79
+ *
80
+ * Null when no chain built the scale: a Ptolemaic genus and a custom Scala
81
+ * list are given as ratios, and have no fifths chain to leave a remainder.
82
+ */
83
+ export declare function wolfOf(commaN: number): Lupus;
31
84
  export declare function buildRatios(opts?: ScaleOpts): Scale;
32
85
  export declare function midiToHz(midi: number, scala: Scale): {
33
86
  hz: number;
@@ -20,8 +20,15 @@
20
20
  // ptolemy-harmonics, Harmonics I.15–16]. See expandDiatonicSteps for how a
21
21
  // 7-ratio genus is laid onto the fixed gamut, and why.
22
22
  import { MODES } from "./modes.js";
23
- // Stern-Brocot rational approximation — finds nearest simple fraction
24
- function approximate(value, maxDen = 1000) {
23
+ /** Stern-Brocot rational approximation — the nearest simple fraction to a
24
+ * decimal ratio, as `[numerator, denominator]`.
25
+ *
26
+ * Exported because `toRatio` only reaches it through a STRING, and a caller
27
+ * holding a computed ratio (a gamut row's `ratio`, a string fraction derived
28
+ * from two frequencies) has a number. Without this the caller writes the
29
+ * search again — which is how the site came to carry a second copy of this
30
+ * same algorithm. */
31
+ export function approximate(value, maxDen = 1000) {
25
32
  if (value === Math.round(value))
26
33
  return [Math.round(value), 1];
27
34
  let [a, b, c, d] = [0, 1, 1, 0];
@@ -128,9 +135,16 @@ export function parseScala(input) {
128
135
  }
129
136
  const PURE_FIFTH = 3 / 2;
130
137
  const SYNTONIC_COMMA = 81 / 80;
131
- // The circle of fifths, as chromatic pitch classes: C G D A E B F♯ … stacking
132
- // twelve 3/2s. buildPythagoreanRatios walks this and octave-folds each.
133
- const FIFTH_TO_CHROM = [0, 7, 2, 9, 4, 11, 6, 1, 8, 3, 10, 5];
138
+ // The chain of fifths as chromatic pitch classes, running E♭–G♯:
139
+ // E♭ B♭ F C G D A E B F♯ C♯ G♯
140
+ // The naturals sit F–B (ut–fa is a pure 4/3 — b molle exists precisely to make
141
+ // that fourth over F), b molle and E♭ take the flat side, the ficta sharps
142
+ // (F♯ C♯ G♯) the sharp side. This is the received medieval dodecachord; an
143
+ // ascending-only chain from C would spell F as E♯ (a wolf ut–fa of 521.5¢)
144
+ // and b molle as A♯. buildPythagoreanRatios walks this and octave-folds each.
145
+ // The heji/cents baseline in score/emitters/accidentals.ts derives from this
146
+ // same chain via pythagoreanCentsByPc() — keep them coupled.
147
+ const FIFTH_TO_CHROM = [3, 10, 5, 0, 7, 2, 9, 4, 11, 6, 1, 8];
134
148
  // Ptolemy's three diatonic genera [biblio: ptolemy-harmonics, Harmonics I.15–16],
135
149
  // each a tetrachord (1/1 … 4/3) doubled up a 3/2 to fill the octave:
136
150
  // intense (syntonon) — classical just intonation: pure 5/4 major, 6/5 minor.
@@ -144,6 +158,18 @@ const PTOLEMAIC = {
144
158
  export function getPtolemaicRatios(tuning) {
145
159
  return PTOLEMAIC[tuning];
146
160
  }
161
+ /**
162
+ * The pure-fifth chain's ET-cents deviation per pitch class, anchored A = 0
163
+ * (the a4-reference anchor `offset` carries under the default tuning). The
164
+ * heji/cents accidental baseline reads THIS, so the emitters and the engine
165
+ * can never disagree about the chain's spelling.
166
+ */
167
+ export function pythagoreanCentsByPc() {
168
+ const ratios = buildPythagoreanRatios(0); // C-anchored, folded to [1, 2)
169
+ const cents = ratios.map((r, pc) => 1200 * Math.log2(r) - 100 * pc);
170
+ const anchor = cents[9];
171
+ return cents.map((c) => Math.round((c - anchor) * 100) / 100);
172
+ }
147
173
  function buildPythagoreanRatios(commaN) {
148
174
  const tf = commaN === 0
149
175
  ? PURE_FIFTH
@@ -153,9 +179,13 @@ function buildPythagoreanRatios(commaN) {
153
179
  const pc = FIFTH_TO_CHROM[k];
154
180
  out[pc] = foldOct(tf ** k);
155
181
  }
182
+ // Anchor on C and fold each pitch class into the C-register [1, 2): the
183
+ // table's contract is "ascending within the C octave, then normalized to
184
+ // root". The E♭–G♯ chain reaches some pcs below C, so without this fold the
185
+ // table comes out octave-scrambled (A at 27/32, a broken ratio() matcher).
156
186
  const root = out[0] ?? 1;
157
187
  for (let i = 0; i < 12; i++)
158
- out[i] = (out[i] ?? 1) / root;
188
+ out[i] = foldOct((out[i] ?? 1) / root);
159
189
  return out;
160
190
  }
161
191
  // The natural (white-key) pitch classes in ascending pitch order: C D E F G A B.
@@ -191,7 +221,52 @@ function normalizeToRoot(ratios, rootPc) {
191
221
  function ratiosToCents(ratios) {
192
222
  return ratios.map((r) => 1200 * Math.log2(r));
193
223
  }
194
- // ── Public ──
224
+ const PURE_FIFTH_CENTS = 1200 * Math.log2(3 / 2);
225
+ // The chain's own spelling, in its own order. A wolf is named by where it
226
+ // falls in the chain, not by an enharmonic the gamut might prefer: the
227
+ // interval G♯–E♭ is the wolf, and calling either end by its other name
228
+ // ("A♭–D♯") describes a different reading of the same two frequencies.
229
+ const CHAIN_SPELLING = ["Eb", "Bb", "F", "C", "G", "D", "A", "E", "B", "F#", "C#", "G#"];
230
+ /**
231
+ * THE WOLF, from the chain the ratios are built on.
232
+ *
233
+ * Twelve fifths do not close an octave. Stack them from E♭ and the twelfth
234
+ * lands beside the first rather than on it, and the gap it leaves has to go
235
+ * somewhere: it goes into the one interval nobody stacked, G♯ up to E♭. That
236
+ * interval is a fifth by position and not by size, which is why it howls.
237
+ *
238
+ * Under pure fifths it comes out NARROW (678.5¢, the Pythagorean comma taken
239
+ * out of it). Temper the fifths and the leftover grows: every cent taken off
240
+ * eleven fifths is given back to this one, so it passes a pure fifth and goes
241
+ * wide (737.6¢ at quarter-comma). That sign change is the thing a reader of
242
+ * the slider is watching, and it is why `fromPure` is signed.
243
+ *
244
+ * Derived from the SAME walk `buildPythagoreanRatios` does, deliberately.
245
+ * There is a closed form (1200·7 − 11·fifth, which agrees to the decimal),
246
+ * but two derivations of one number is how they drift apart.
247
+ *
248
+ * Null when no chain built the scale: a Ptolemaic genus and a custom Scala
249
+ * list are given as ratios, and have no fifths chain to leave a remainder.
250
+ */
251
+ export function wolfOf(commaN) {
252
+ const ratios = buildPythagoreanRatios(commaN);
253
+ const cents = ratios.map((r) => 1200 * Math.log2(r));
254
+ const from = FIFTH_TO_CHROM[11];
255
+ const to = FIFTH_TO_CHROM[0];
256
+ // Upward and octave-folded: the chain's ends sit either side of the fold,
257
+ // so the raw difference is negative about as often as not.
258
+ let span = cents[to] - cents[from];
259
+ if (span < 0)
260
+ span += 1200;
261
+ return {
262
+ from,
263
+ to,
264
+ spn: [CHAIN_SPELLING[11], CHAIN_SPELLING[0]],
265
+ cents: span,
266
+ pure: PURE_FIFTH_CENTS,
267
+ fromPure: span - PURE_FIFTH_CENTS,
268
+ };
269
+ }
195
270
  export function buildRatios(opts = {}) {
196
271
  const mode = opts.mode ?? 1;
197
272
  const a4 = opts.a4 ?? 440;
@@ -202,7 +277,31 @@ export function buildRatios(opts = {}) {
202
277
  const rootPc = opts.root ?? finalisPc;
203
278
  let ratios;
204
279
  if (opts.steps != null) {
205
- const parsed = opts.steps.map(parseStep);
280
+ let parsed = opts.steps.map(parseStep);
281
+ // Two USER step conventions circulate, and mistaking one for the other
282
+ // mistunes silently: a DEGREE LIST begins at 1/1 (the ptolemy presets), a
283
+ // SCALA list gives the degrees above an implicit tonic and ends at 2/1
284
+ // (every .scl file). Normalize both to 1/1-first; anything else is
285
+ // ambiguous and throws rather than resolving a plausible-looking wrong
286
+ // tuning. The check applies to USER intake only — string steps. An
287
+ // all-number list is the engine's own resolved table riding back in
288
+ // (notatio carries temperamentum.cents), root-relative by construction.
289
+ const userIntake = opts.steps.some((v) => typeof v === "string");
290
+ if (userIntake) {
291
+ const EPS = 1e-9;
292
+ const firstIsUnison = Math.abs((parsed[0] ?? 0) - 1) < EPS;
293
+ const lastIsOctave = Math.abs((parsed[parsed.length - 1] ?? 0) - 2) < EPS;
294
+ if (!firstIsUnison && lastIsOctave) {
295
+ parsed = [1, ...parsed.slice(0, -1)]; // Scala convention → shift under the tonic
296
+ }
297
+ else if (firstIsUnison && lastIsOctave) {
298
+ parsed = parsed.slice(0, -1); // both stated → drop the closing octave
299
+ }
300
+ else if (!firstIsUnison) {
301
+ throw new RangeError("Custom scale must begin at 1/1 (a degree list) or end at 2/1 (Scala convention)");
302
+ }
303
+ parsed = parsed.map(foldOct);
304
+ }
206
305
  if (parsed.length === 7) {
207
306
  ratios = expandDiatonicSteps(parsed);
208
307
  }
package/dist/index.d.ts CHANGED
@@ -3,26 +3,28 @@ import { getChants, getCorpus } from "./engines/chant/chant.js";
3
3
  import { getPropers } from "./engines/chant/propers.js";
4
4
  import { getOrdinary } from "./engines/chant/ordinary.js";
5
5
  import { getHour } from "./engines/chant/hour.js";
6
- import { getMatins } from "./engines/chant/matutinum.js";
7
6
  import { getPsalm } from "./engines/chant/psalm.js";
8
7
  import { buildTemper } from "./engines/temper/api.js";
9
8
  import { buildScore } from "./engines/score/api.js";
9
+ import { inscriptio } from "./engines/score/inscriptio.js";
10
10
  import { getCosmos } from "./engines/planet/planet.js";
11
11
  import { buildHarmonia } from "./engines/harmonia/api.js";
12
+ import { getCensus } from "./engines/census/census.js";
12
13
  import type { FeastQuery, Feast, Pascha, Season, Grade } from "./engines/cal/types.js";
13
- import type { CantusQuery, Chant, OrdinaryChant, PropriumQuery, OrdinariumQuery, OfficiumQuery, PsalmusQuery, Rite, Corpus, GenusCount, ModeCount, SharedCount } from "./engines/chant/types.js";
14
- import type { Matins, Nocturn } from "./engines/chant/matutinum.js";
14
+ import type { CantusQuery, Chant, OrdinaryChant, PropriumQuery, OrdinariumQuery, OfficiumQuery, PsalmusQuery, Corpus, GenusCount, ModeCount, SharedCount, CorpusLedger, CorpusFullCount, CorpusQuery } from "./engines/chant/types.js";
15
15
  import type { TemperamentumInput, Temperamentum, Tuning, TemperamentumOpts, Pitch, PitchInput, Step, Neume, NeumeShape, Interval, ModeData, CadenceFigure, Modus, TunedNote, GamutOptions, Tonus, TonusOpts } from "./engines/temper/api.js";
16
- import type { Score, ScoreOpts, PondusInput, PondusOpts, AccentusInput, AccentusOpts, Cadence, CadenceTarget, CadenceApproach, Modulation, MidiOpts, MidiEmitResult, MidiJsonResult, MidiJsonEvent, MusicXmlOpts, MusicXmlEmitResult } from "./engines/score/api.js";
16
+ import type { Score, ScoreOpts, PondusInput, PondusOpts, AccentusInput, AccentusOpts, Cadence, CadenceTarget, CadenceApproach, Modulation } from "./engines/score/api.js";
17
+ import type { InscriptioOpts, Inscriptio, NoteGeometry, FontSpec, FontSlot, FontEmbed } from "./engines/score/inscriptio.js";
17
18
  import type { ChantTabulaRow } from "./engines/score/tabula.js";
18
19
  import type { Imprint, Attractor, VowelAttractor, ModalAffinity } from "./engines/imprint.js";
19
20
  import type { Prosody, RhythmicProfile, NoteRange, CadenceDistribution } from "./engines/score/prosody.js";
20
21
  import type { Harmony, HarmoniaOpts, VoicedBody, VoicedAspect, Frame, Author } from "./engines/harmonia/api.js";
21
22
  import type { HarmonyTabulaRow } from "./engines/harmonia/tabula.js";
22
23
  import type { PlanetVowel } from "./engines/harmonia/data/vowels.js";
23
- import type { Note, Performance, Phrase, Syllable, RestEvent, ParseError, ArsisThesis, RhythmicType, CompoundBeat } from "./engines/score/types.js";
24
+ import type { Note, Performance, Phrase, Syllable, LyricRun, RestEvent, ParseError, ArsisThesis, RhythmicType, CompoundBeat } from "./engines/score/types.js";
24
25
  import type { VoicedPitch } from "./engines/harmonia/voice.js";
25
26
  import type { Cosmos, CosmosQuery, Body, BodyName, Aspect } from "./engines/planet/types.js";
27
+ import type { Census, CensusQuery, CensusBy, CensusGroup, CensusGroupProfile, CensusNeighbor } from "./engines/census/types.js";
26
28
  declare const tonus: {
27
29
  festum: typeof getFeast;
28
30
  pascha: typeof getPascha;
@@ -31,14 +33,30 @@ declare const tonus: {
31
33
  proprium: typeof getPropers;
32
34
  ordinarium: typeof getOrdinary;
33
35
  officium: typeof getHour;
34
- matutinum: typeof getMatins;
35
36
  psalmus: typeof getPsalm;
36
37
  temperamentum: typeof buildTemper;
37
38
  notatio: typeof buildScore;
39
+ inscriptio: typeof inscriptio;
38
40
  caelum: typeof getCosmos;
39
41
  harmonia: typeof buildHarmonia;
42
+ census: typeof getCensus;
40
43
  };
41
44
  export default tonus;
42
- export { SEASON_LABELS, TEMPUS_NAMES, GRADE_ORDER, GRADE_NAMES, gradeOrder, compareGrade, ritusToGrade, } from "./engines/cal/types.js";
43
- export type { Feast, FeastQuery, Pascha, Season, Grade, Chant, CantusQuery, OrdinaryChant, PropriumQuery, OrdinariumQuery, OfficiumQuery, PsalmusQuery, Rite, Corpus, GenusCount, ModeCount, SharedCount, Matins, Nocturn, 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, MidiOpts, MidiEmitResult, MidiJsonResult, MidiJsonEvent, MusicXmlOpts, MusicXmlEmitResult, ChantTabulaRow, Note, Performance, Phrase, Syllable, 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, };
45
+ export { SEASON_LABEL, // season code → English name ("adv" → "Advent")
46
+ TEMPORA, // season code → Latin name ("adv" → "Tempus Adventus")
47
+ GRADE_ORDER, // grade code → rank, low is higher
48
+ GRADUS, } from "./engines/cal/types.js";
49
+ export { HORAE, // the eight canonical hours, Matins first — the order is content
50
+ OFFICIA, // office code → Latin genus ("an" → "Antiphona")
51
+ ORDINARIA, // ordinary code → Latin name ("kyrie" → "Kyrie eleison")
52
+ MODI, } from "./engines/chant/types.js";
53
+ export { SOURCES } from "./engines/chant/chant.js";
54
+ export { MODES } from "./engines/temper/data/modes.js";
55
+ export { TONES } from "./engines/temper/data/tones.js";
56
+ export type { PsalmTone, Differentia } from "./engines/temper/data/tones.js";
57
+ export { CADENTIAE, CADENTIAE_POPULATION } from "./data/cadentiae.js";
58
+ export type { CadentiaFamilia } from "./data/cadentiae.js";
59
+ export { SIGNS, SIGNA } from "./engines/planet/planet.js";
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, };
44
62
  //# sourceMappingURL=index.d.ts.map
package/dist/index.js CHANGED
@@ -3,12 +3,13 @@ import { getChants, getCorpus } from "./engines/chant/chant.js";
3
3
  import { getPropers } from "./engines/chant/propers.js";
4
4
  import { getOrdinary } from "./engines/chant/ordinary.js";
5
5
  import { getHour } from "./engines/chant/hour.js";
6
- import { getMatins } from "./engines/chant/matutinum.js";
7
6
  import { getPsalm } from "./engines/chant/psalm.js";
8
7
  import { buildTemper } from "./engines/temper/api.js";
9
8
  import { buildScore } from "./engines/score/api.js";
9
+ import { inscriptio } from "./engines/score/inscriptio.js";
10
10
  import { getCosmos } from "./engines/planet/planet.js";
11
11
  import { buildHarmonia } from "./engines/harmonia/api.js";
12
+ import { getCensus } from "./engines/census/census.js";
12
13
  const tonus = {
13
14
  festum: getFeast,
14
15
  pascha: getPascha,
@@ -17,14 +18,46 @@ const tonus = {
17
18
  proprium: getPropers,
18
19
  ordinarium: getOrdinary,
19
20
  officium: getHour,
20
- matutinum: getMatins,
21
21
  psalmus: getPsalm,
22
22
  temperamentum: buildTemper,
23
23
  notatio: buildScore,
24
+ inscriptio,
24
25
  caelum: getCosmos,
25
26
  harmonia: buildHarmonia,
27
+ census: getCensus,
26
28
  };
27
29
  export default tonus;
28
- // Reference maps and grade helpers (display strings live here, not on objects).
29
- export { SEASON_LABELS, TEMPUS_NAMES, GRADE_ORDER, GRADE_NAMES, gradeOrder, compareGrade, ritusToGrade, } from "./engines/cal/types.js";
30
+ // ── The appendix ──
31
+ // The export law: verbs live on the namespace; return values are plain data;
32
+ // the appendix exports canonical constant tables — nothing with a (). A
33
+ // function that earns public life earns a fifteenth Latin noun instead.
34
+ // A constant is admitted when a caller would otherwise TYPE IT OUT — a mode
35
+ // list, an hour list, the valid `by:` values. Those transcriptions drift, and a
36
+ // caller's drifted copy fails as wrong answers rather than as an error. Naming
37
+ // follows the register rule: a table of Latin values takes a Latin name
38
+ // (TEMPORA, "Tempus Adventus"), a table of codes or English keeps English
39
+ // (SEASON_LABEL, "Advent"). So the name says which one you are holding.
40
+ // cal — the liturgical year
41
+ export { SEASON_LABEL, // season code → English name ("adv" → "Advent")
42
+ TEMPORA, // season code → Latin name ("adv" → "Tempus Adventus")
43
+ GRADE_ORDER, // grade code → rank, low is higher
44
+ GRADUS, // grade code → Latin name
45
+ } from "./engines/cal/types.js";
46
+ // chant — the corpus vocabulary
47
+ export { HORAE, // the eight canonical hours, Matins first — the order is content
48
+ OFFICIA, // office code → Latin genus ("an" → "Antiphona")
49
+ ORDINARIA, // ordinary code → Latin name ("kyrie" → "Kyrie eleison")
50
+ MODI, // mode number → Latin name ("1" → "Modus I")
51
+ } from "./engines/chant/types.js";
52
+ export { SOURCES } from "./engines/chant/chant.js"; // book code → bibliographic record
53
+ // temper — modes, tones, cadences
54
+ export { MODES } from "./engines/temper/data/modes.js";
55
+ export { TONES } from "./engines/temper/data/tones.js";
56
+ export { CADENTIAE, CADENTIAE_POPULATION } from "./data/cadentiae.js";
57
+ // planet — the zodiac
58
+ export { SIGNS, SIGNA } from "./engines/planet/planet.js";
59
+ // census — the field groups and the block index. CENSUS_GROUPS keys are the
60
+ // valid `by:` values AND the `profile` keys; CENSUS_ORDER holds every censused
61
+ // id, so asking whether a chant is in the census stops needing a try/catch.
62
+ export { CENSUS_GROUPS, CENSUS_ORDER } from "./data/census.js";
30
63
  //# sourceMappingURL=index.js.map