tonus 0.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 (141) hide show
  1. package/BIBLIOGRAPHY.md +99 -0
  2. package/LICENSE +29 -0
  3. package/README.md +108 -0
  4. package/dist/data/cal.d.ts +10 -0
  5. package/dist/data/cal.js +3862 -0
  6. package/dist/data/commune.d.ts +17 -0
  7. package/dist/data/commune.js +1333 -0
  8. package/dist/data/gr.d.ts +5 -0
  9. package/dist/data/gr.js +13449 -0
  10. package/dist/data/kyriale.d.ts +11 -0
  11. package/dist/data/kyriale.js +971 -0
  12. package/dist/data/la.d.ts +5 -0
  13. package/dist/data/la.js +14229 -0
  14. package/dist/data/lh.d.ts +5 -0
  15. package/dist/data/lh.js +3619 -0
  16. package/dist/data/lu.d.ts +5 -0
  17. package/dist/data/lu.js +23779 -0
  18. package/dist/data/masses.d.ts +18 -0
  19. package/dist/data/masses.js +297 -0
  20. package/dist/data/office-roman.d.ts +19 -0
  21. package/dist/data/office-roman.js +13792 -0
  22. package/dist/data/office.d.ts +12 -0
  23. package/dist/data/office.js +13052 -0
  24. package/dist/data/propers.d.ts +13 -0
  25. package/dist/data/propers.js +7584 -0
  26. package/dist/data/psalms.d.ts +4 -0
  27. package/dist/data/psalms.js +10 -0
  28. package/dist/data/psalms.json +22918 -0
  29. package/dist/data/tones.d.ts +20 -0
  30. package/dist/data/tones.js +153 -0
  31. package/dist/data/types.d.ts +3 -0
  32. package/dist/data/types.js +2 -0
  33. package/dist/engines/cal/calendar.d.ts +21 -0
  34. package/dist/engines/cal/calendar.js +265 -0
  35. package/dist/engines/cal/date.d.ts +31 -0
  36. package/dist/engines/cal/date.js +141 -0
  37. package/dist/engines/cal/types.d.ts +66 -0
  38. package/dist/engines/cal/types.js +189 -0
  39. package/dist/engines/chant/chant.d.ts +10 -0
  40. package/dist/engines/chant/chant.js +135 -0
  41. package/dist/engines/chant/hour.d.ts +8 -0
  42. package/dist/engines/chant/hour.js +135 -0
  43. package/dist/engines/chant/intone.d.ts +8 -0
  44. package/dist/engines/chant/intone.js +84 -0
  45. package/dist/engines/chant/ordinary.d.ts +7 -0
  46. package/dist/engines/chant/ordinary.js +232 -0
  47. package/dist/engines/chant/propers.d.ts +8 -0
  48. package/dist/engines/chant/propers.js +107 -0
  49. package/dist/engines/chant/psalm.d.ts +7 -0
  50. package/dist/engines/chant/psalm.js +60 -0
  51. package/dist/engines/chant/syllabify.d.ts +20 -0
  52. package/dist/engines/chant/syllabify.js +192 -0
  53. package/dist/engines/chant/types.d.ts +76 -0
  54. package/dist/engines/chant/types.js +34 -0
  55. package/dist/engines/epoch.d.ts +2 -0
  56. package/dist/engines/epoch.js +14 -0
  57. package/dist/engines/harmonia/api.d.ts +35 -0
  58. package/dist/engines/harmonia/api.js +90 -0
  59. package/dist/engines/harmonia/aspects.d.ts +8 -0
  60. package/dist/engines/harmonia/aspects.js +15 -0
  61. package/dist/engines/harmonia/data/doctrines.d.ts +16 -0
  62. package/dist/engines/harmonia/data/doctrines.js +154 -0
  63. package/dist/engines/harmonia/data/vowels.d.ts +10 -0
  64. package/dist/engines/harmonia/data/vowels.js +21 -0
  65. package/dist/engines/harmonia/presence.d.ts +13 -0
  66. package/dist/engines/harmonia/presence.js +48 -0
  67. package/dist/engines/harmonia/tabula.d.ts +28 -0
  68. package/dist/engines/harmonia/tabula.js +32 -0
  69. package/dist/engines/harmonia/voice.d.ts +19 -0
  70. package/dist/engines/harmonia/voice.js +51 -0
  71. package/dist/engines/imprint.d.ts +30 -0
  72. package/dist/engines/imprint.js +152 -0
  73. package/dist/engines/planet/appearance.d.ts +40 -0
  74. package/dist/engines/planet/appearance.js +84 -0
  75. package/dist/engines/planet/aspects.d.ts +5 -0
  76. package/dist/engines/planet/aspects.js +41 -0
  77. package/dist/engines/planet/math.d.ts +13 -0
  78. package/dist/engines/planet/math.js +56 -0
  79. package/dist/engines/planet/orbital.d.ts +25 -0
  80. package/dist/engines/planet/orbital.js +223 -0
  81. package/dist/engines/planet/planet.d.ts +13 -0
  82. package/dist/engines/planet/planet.js +198 -0
  83. package/dist/engines/planet/position.d.ts +62 -0
  84. package/dist/engines/planet/position.js +156 -0
  85. package/dist/engines/planet/types.d.ts +61 -0
  86. package/dist/engines/planet/types.js +14 -0
  87. package/dist/engines/score/api.d.ts +54 -0
  88. package/dist/engines/score/api.js +87 -0
  89. package/dist/engines/score/articulation.d.ts +6 -0
  90. package/dist/engines/score/articulation.js +112 -0
  91. package/dist/engines/score/emitters/midi.d.ts +65 -0
  92. package/dist/engines/score/emitters/midi.js +158 -0
  93. package/dist/engines/score/emitters/musicxml.d.ts +18 -0
  94. package/dist/engines/score/emitters/musicxml.js +166 -0
  95. package/dist/engines/score/infer.d.ts +4 -0
  96. package/dist/engines/score/infer.js +77 -0
  97. package/dist/engines/score/ir.d.ts +4 -0
  98. package/dist/engines/score/ir.js +177 -0
  99. package/dist/engines/score/meta.d.ts +19 -0
  100. package/dist/engines/score/meta.js +34 -0
  101. package/dist/engines/score/neume.d.ts +3 -0
  102. package/dist/engines/score/neume.js +26 -0
  103. package/dist/engines/score/parse.d.ts +3 -0
  104. package/dist/engines/score/parse.js +359 -0
  105. package/dist/engines/score/phrasing.d.ts +24 -0
  106. package/dist/engines/score/phrasing.js +257 -0
  107. package/dist/engines/score/prosody.d.ts +35 -0
  108. package/dist/engines/score/prosody.js +109 -0
  109. package/dist/engines/score/tabula.d.ts +70 -0
  110. package/dist/engines/score/tabula.js +109 -0
  111. package/dist/engines/score/types.d.ts +159 -0
  112. package/dist/engines/score/types.js +2 -0
  113. package/dist/engines/temper/api.d.ts +60 -0
  114. package/dist/engines/temper/api.js +130 -0
  115. package/dist/engines/temper/data/constants.d.ts +27 -0
  116. package/dist/engines/temper/data/constants.js +150 -0
  117. package/dist/engines/temper/data/guido.d.ts +14 -0
  118. package/dist/engines/temper/data/guido.js +29 -0
  119. package/dist/engines/temper/data/modes.d.ts +38 -0
  120. package/dist/engines/temper/data/modes.js +158 -0
  121. package/dist/engines/temper/gabc.d.ts +5 -0
  122. package/dist/engines/temper/gabc.js +53 -0
  123. package/dist/engines/temper/gamut.d.ts +9 -0
  124. package/dist/engines/temper/gamut.js +24 -0
  125. package/dist/engines/temper/guido.d.ts +16 -0
  126. package/dist/engines/temper/guido.js +48 -0
  127. package/dist/engines/temper/interval.d.ts +15 -0
  128. package/dist/engines/temper/interval.js +31 -0
  129. package/dist/engines/temper/modes.d.ts +6 -0
  130. package/dist/engines/temper/modes.js +13 -0
  131. package/dist/engines/temper/neume.d.ts +14 -0
  132. package/dist/engines/temper/neume.js +59 -0
  133. package/dist/engines/temper/pitch.d.ts +40 -0
  134. package/dist/engines/temper/pitch.js +129 -0
  135. package/dist/engines/temper/scale.d.ts +37 -0
  136. package/dist/engines/temper/scale.js +217 -0
  137. package/dist/engines/temper/step.d.ts +23 -0
  138. package/dist/engines/temper/step.js +53 -0
  139. package/dist/index.d.ts +40 -0
  140. package/dist/index.js +27 -0
  141. package/package.json +60 -0
@@ -0,0 +1,59 @@
1
+ // ---------------------------------------------------------------------------
2
+ // engines/temper/neume — neume shape classification
3
+ // ---------------------------------------------------------------------------
4
+ import { classifyInterval } from "./interval.js";
5
+ import { toPitch } from "./pitch.js";
6
+ export function classifyShape(dirs) {
7
+ const n = dirs.length;
8
+ if (n === 0)
9
+ return "punctum";
10
+ const up = (d) => d === "up";
11
+ const dn = (d) => d === "down";
12
+ switch (n) {
13
+ case 1:
14
+ return up(dirs[0]) ? "pes" : dn(dirs[0]) ? "clivis" : "punctum";
15
+ case 2: {
16
+ const [d0, d1] = dirs;
17
+ if (up(d0) && dn(d1))
18
+ return "torculus";
19
+ if (dn(d0) && up(d1))
20
+ return "porrectus";
21
+ if (up(d0) && up(d1))
22
+ return "scandicus";
23
+ if (dn(d0) && dn(d1))
24
+ return "climacus";
25
+ return "compound";
26
+ }
27
+ case 3: {
28
+ const [d0, d1, d2] = dirs;
29
+ if (up(d0) && dn(d1) && up(d2))
30
+ return "torculus resupinus";
31
+ if (dn(d0) && up(d1) && dn(d2))
32
+ return "porrectus flexus";
33
+ if (up(d0) && up(d1) && dn(d2))
34
+ return "scandicus flexus";
35
+ if (dn(d0) && dn(d1) && up(d2))
36
+ return "climacus resupinus";
37
+ if (up(d0) && dn(d1) && dn(d2))
38
+ return "pes subpunctis";
39
+ return "compound";
40
+ }
41
+ default:
42
+ if (up(dirs[0]) && dirs.slice(1).every((d) => dn(d)))
43
+ return "pes subpunctis";
44
+ return "compound";
45
+ }
46
+ }
47
+ export function buildNeume(inputs, scala) {
48
+ const pitches = inputs.map((n) => toPitch(n, scala));
49
+ const intervals = [];
50
+ for (let i = 0; i < pitches.length - 1; i++) {
51
+ intervals.push(classifyInterval(pitches[i].midi, pitches[i + 1].midi));
52
+ }
53
+ return {
54
+ pitches,
55
+ intervals,
56
+ shape: classifyShape(intervals.map((iv) => iv.direction)),
57
+ };
58
+ }
59
+ //# sourceMappingURL=neume.js.map
@@ -0,0 +1,40 @@
1
+ import type { Scale } from "./scale.js";
2
+ export interface Pitch {
3
+ midi: number;
4
+ pc: number;
5
+ oct: number;
6
+ acc: -1 | 0 | 1;
7
+ spn: string;
8
+ hz: number;
9
+ offset: number;
10
+ bend: number;
11
+ ratio: number;
12
+ }
13
+ export type PitchInput = number | string | {
14
+ midi: number;
15
+ } | {
16
+ hz: number;
17
+ } | {
18
+ spn: string;
19
+ } | {
20
+ solfege: string;
21
+ oct?: number;
22
+ } | {
23
+ solmization: string;
24
+ hexachord?: "durum" | "naturale" | "molle";
25
+ oct?: number;
26
+ } | {
27
+ gabc: string;
28
+ clef?: string;
29
+ oct?: number;
30
+ };
31
+ export interface PitchContext {
32
+ clef?: string;
33
+ mode?: number;
34
+ hexachord?: "durum" | "naturale" | "molle";
35
+ a4?: number;
36
+ }
37
+ export declare function parsePitch(input: PitchInput, ctx?: PitchContext): number;
38
+ export declare function toPitch(input: PitchInput, scale: Scale): Pitch;
39
+ export declare function scaleDegreeInMode(midi: number, mode: number): number | null;
40
+ //# sourceMappingURL=pitch.d.ts.map
@@ -0,0 +1,129 @@
1
+ // ---------------------------------------------------------------------------
2
+ // engines/temper/pitch — Pitch type, input parsing, and scale binding
3
+ // ---------------------------------------------------------------------------
4
+ import { NAME_TO_CHROMA, SOLFEGE_TO_CHROMA, SHARP_SPELLING, FLAT_SPELLING, PREFER_FLAT_PCS, GUIDO_TO_PC } from "./data/constants.js";
5
+ import { gabcToMidi } from "./gabc.js";
6
+ import { MODES } from "./modes.js";
7
+ import { midiToHz } from "./scale.js";
8
+ // ── parsePitch ──
9
+ // Resolves any input form to a raw MIDI number.
10
+ // ctx provides fallback mode/clef/hexachord for ambiguous inputs.
11
+ export function parsePitch(input, ctx = {}) {
12
+ // Tagged object forms
13
+ if (typeof input === "object" && input !== null) {
14
+ if ("midi" in input) {
15
+ return clamp(input.midi);
16
+ }
17
+ if ("hz" in input) {
18
+ return hzToMidi(input.hz, ctx.a4 ?? 440);
19
+ }
20
+ if ("spn" in input) {
21
+ return parseSpn(input.spn);
22
+ }
23
+ if ("solfege" in input) {
24
+ return parseModernSolfege(input.solfege, input.oct ?? 4);
25
+ }
26
+ if ("solmization" in input) {
27
+ const hex = input.hexachord ?? inferHexachord(ctx.mode);
28
+ return parseMedievalSolmization(input.solmization, hex, input.oct ?? 4);
29
+ }
30
+ if ("gabc" in input) {
31
+ return parseGabc(input.gabc, input.clef ?? ctx.clef ?? "c4");
32
+ }
33
+ }
34
+ // Bare number
35
+ if (typeof input === "number") {
36
+ if (Number.isInteger(input) && input >= 0 && input <= 127)
37
+ return input;
38
+ return hzToMidi(input, ctx.a4 ?? 440);
39
+ }
40
+ // Bare string — detect form
41
+ if (typeof input === "string") {
42
+ const s = input.trim();
43
+ // Scientific name: starts with A–G, optional #/b, then digits
44
+ if (/^[A-Ga-g][#b]?-?\d+$/.test(s)) {
45
+ return parseSpn(s);
46
+ }
47
+ // Modern solfège: uppercase syllable, no digits
48
+ if (/^[A-Z]+$/.test(s) && SOLFEGE_TO_CHROMA.has(s)) {
49
+ return parseModernSolfege(s, 4);
50
+ }
51
+ // GABC letter: single lowercase a–m
52
+ if (/^[a-m]$/.test(s)) {
53
+ if (!ctx.clef && !ctx.mode) {
54
+ throw new Error(`GABC input "${s}" requires clef context. Pass { gabc: "${s}", clef: "c4" } or provide ctx.clef.`);
55
+ }
56
+ return parseGabc(s, ctx.clef ?? "c4");
57
+ }
58
+ }
59
+ throw new Error(`Cannot parse pitch input: ${JSON.stringify(input)}`);
60
+ }
61
+ // ── toPitch ──
62
+ // Resolves a PitchInput through a Scale into a tuned Pitch.
63
+ // Applies the Scale's transpose and returns a Pitch with midi/pc/oct/spn
64
+ // from the transposed MIDI plus tuning-derived hz/offset/bend/ratio.
65
+ export function toPitch(input, scale) {
66
+ const rawMidi = parsePitch(input, { mode: scale.mode, a4: scale.a4 });
67
+ const { hz, offset, bend } = midiToHz(rawMidi, scale);
68
+ const midi = clamp(rawMidi + scale.transpose);
69
+ const pc = midi % 12;
70
+ const oct = Math.floor(midi / 12) - 1;
71
+ const useFlat = PREFER_FLAT_PCS.has(pc);
72
+ const sp = useFlat ? FLAT_SPELLING[pc] : SHARP_SPELLING[pc];
73
+ const accStr = sp.acc === -1 ? "b" : sp.acc === 1 ? "#" : "";
74
+ const spn = `${sp.step}${accStr}${oct}`;
75
+ const ratio = scale.ratios[pc] ?? 1;
76
+ return { midi, pc, oct, acc: sp.acc, spn, hz, offset, bend, ratio };
77
+ }
78
+ // ── scaleDegreeInMode ──
79
+ export function scaleDegreeInMode(midi, mode) {
80
+ const modeData = MODES.get(mode);
81
+ if (!modeData)
82
+ return null;
83
+ const pc = ((midi % 12) + 12) % 12;
84
+ const idx = modeData.scalePcs.indexOf(pc);
85
+ return idx === -1 ? null : idx + 1;
86
+ }
87
+ // ── Internal helpers ──
88
+ function clamp(n) {
89
+ return Math.min(127, Math.max(0, Math.round(n)));
90
+ }
91
+ function hzToMidi(hz, a4 = 440) {
92
+ return clamp(69 + 12 * Math.log2(hz / a4));
93
+ }
94
+ function parseSpn(s) {
95
+ const m = s.trim().match(/^([A-Ga-g])([#b]?)(-?\d+)$/);
96
+ if (!m)
97
+ throw new Error(`Invalid scientific pitch name: "${s}"`);
98
+ const [, letter, acc, octStr] = m;
99
+ const normalized = letter.toUpperCase() + (acc === "b" ? "b" : acc === "#" ? "#" : "");
100
+ const pc = NAME_TO_CHROMA.get(normalized);
101
+ if (pc === undefined)
102
+ throw new Error(`Unknown note name: "${normalized}"`);
103
+ return (parseInt(octStr, 10) + 1) * 12 + pc;
104
+ }
105
+ function parseModernSolfege(s, oct) {
106
+ const pc = SOLFEGE_TO_CHROMA.get(s.trim().toUpperCase());
107
+ if (pc === undefined)
108
+ throw new Error(`Unknown solfège syllable: "${s}"`);
109
+ return (oct + 1) * 12 + pc;
110
+ }
111
+ function parseMedievalSolmization(s, hexachord, oct) {
112
+ const map = GUIDO_TO_PC[hexachord];
113
+ if (!map)
114
+ throw new Error(`Unknown hexachord: "${hexachord}"`);
115
+ const pc = map[s.trim().toUpperCase()];
116
+ if (pc === undefined)
117
+ throw new Error(`Unknown solmization "${s}" in hexachord "${hexachord}"`);
118
+ return (oct + 1) * 12 + pc;
119
+ }
120
+ function parseGabc(letter, clef) {
121
+ return gabcToMidi(letter.toLowerCase(), clef);
122
+ }
123
+ function inferHexachord(mode) {
124
+ if (!mode)
125
+ return "naturale";
126
+ const modeData = MODES.get(mode);
127
+ return modeData?.hexachords[0] ?? "naturale";
128
+ }
129
+ //# sourceMappingURL=pitch.js.map
@@ -0,0 +1,37 @@
1
+ export interface Scale {
2
+ ratios: number[];
3
+ cents: number[];
4
+ mode: number;
5
+ a4: number;
6
+ comma: number;
7
+ root: number;
8
+ transpose: number;
9
+ }
10
+ export interface ScaleOpts {
11
+ mode?: number;
12
+ a4?: number;
13
+ comma?: number | string;
14
+ steps?: (number | string)[];
15
+ root?: number;
16
+ transpose?: number;
17
+ }
18
+ export interface RatioResult {
19
+ ratio: number;
20
+ cents: number;
21
+ display: string;
22
+ }
23
+ export declare function toRatio(input: string): RatioResult;
24
+ export declare function parseStep(v: number | string): number;
25
+ export interface ScalaFile {
26
+ name: string;
27
+ steps: string[];
28
+ }
29
+ export declare function parseScala(input: string): ScalaFile;
30
+ export declare function getPtolemaicRatios(tuning: string): string[] | undefined;
31
+ export declare function buildRatios(opts?: ScaleOpts): Scale;
32
+ export declare function midiToHz(midi: number, scala: Scale): {
33
+ hz: number;
34
+ offset: number;
35
+ bend: number;
36
+ };
37
+ //# sourceMappingURL=scale.d.ts.map
@@ -0,0 +1,217 @@
1
+ // ---------------------------------------------------------------------------
2
+ // engines/temper/scale — tuning ratio builder
3
+ // ---------------------------------------------------------------------------
4
+ import { MODES } from "./modes.js";
5
+ // Stern-Brocot rational approximation — finds nearest simple fraction
6
+ function approximate(value, maxDen = 1000) {
7
+ if (value === Math.round(value))
8
+ return [Math.round(value), 1];
9
+ let [a, b, c, d] = [0, 1, 1, 0];
10
+ let bestNum = 1, bestDen = 1, bestErr = Infinity;
11
+ for (let i = 0; i < 100; i++) {
12
+ const medNum = a + c;
13
+ const medDen = b + d;
14
+ if (medDen > maxDen)
15
+ break;
16
+ const med = medNum / medDen;
17
+ const err = Math.abs(value - med);
18
+ if (err < bestErr) {
19
+ bestErr = err;
20
+ bestNum = medNum;
21
+ bestDen = medDen;
22
+ }
23
+ if (err < 1e-9)
24
+ break;
25
+ if (value > med) {
26
+ a = medNum;
27
+ b = medDen;
28
+ }
29
+ else {
30
+ c = medNum;
31
+ d = medDen;
32
+ }
33
+ }
34
+ return [bestNum, bestDen];
35
+ }
36
+ export function toRatio(input) {
37
+ const r = parseStep(input);
38
+ const cents = 1200 * Math.log2(r);
39
+ const [num, den] = approximate(r);
40
+ const display = den === 1 ? `${num}:1` : `${num}:${den}`;
41
+ return { ratio: r, cents, display };
42
+ }
43
+ function foldOct(r) {
44
+ let x = r;
45
+ while (x >= 2)
46
+ x *= 0.5;
47
+ while (x < 1)
48
+ x *= 2;
49
+ return x;
50
+ }
51
+ function parseComma(comma) {
52
+ if (typeof comma === "number")
53
+ return comma;
54
+ const s = comma.trim();
55
+ const slash = s.indexOf("/");
56
+ if (slash !== -1) {
57
+ const num = parseFloat(s.slice(0, slash));
58
+ const den = parseFloat(s.slice(slash + 1));
59
+ if (!Number.isFinite(num) || !Number.isFinite(den) || den === 0)
60
+ throw new RangeError(`Invalid comma fraction: "${s}"`);
61
+ return num / den;
62
+ }
63
+ const n = parseFloat(s);
64
+ if (!Number.isFinite(n))
65
+ throw new RangeError(`Invalid comma value: "${s}"`);
66
+ return n;
67
+ }
68
+ // Parse a single pitch value using Scala convention:
69
+ // period present → cents, slash or colon → ratio, bare integer → ratio over 1
70
+ export function parseStep(v) {
71
+ if (typeof v === "number")
72
+ return 2 ** (v / 1200);
73
+ const s = v.trim().split(/\s/)[0]; // first token only (Scala allows trailing text)
74
+ const ratioMatch = s.match(/^(\d+)[/:](\d+)$/);
75
+ if (ratioMatch) {
76
+ const num = Number(ratioMatch[1]);
77
+ const den = Number(ratioMatch[2]);
78
+ if (!Number.isFinite(num) || !Number.isFinite(den) || den === 0)
79
+ throw new RangeError(`Invalid ratio: "${s}"`);
80
+ return num / den;
81
+ }
82
+ const n = parseFloat(s);
83
+ if (!Number.isFinite(n))
84
+ throw new RangeError(`Invalid step value: "${s}"`);
85
+ if (s.includes("."))
86
+ return 2 ** (n / 1200); // cents
87
+ return n; // bare integer → ratio (e.g. 2 = 2/1)
88
+ }
89
+ export function parseScala(input) {
90
+ const lines = input.split(/\r?\n/);
91
+ const nonComment = [];
92
+ for (const line of lines) {
93
+ if (line.trimStart().startsWith("!"))
94
+ continue;
95
+ nonComment.push(line);
96
+ }
97
+ const name = (nonComment[0] ?? "").trim();
98
+ const count = parseInt(nonComment[1] ?? "0", 10);
99
+ if (!Number.isFinite(count) || count < 0)
100
+ throw new RangeError(`Invalid note count in Scala file: "${nonComment[1]}"`);
101
+ const steps = [];
102
+ for (let i = 2; i < nonComment.length && steps.length < count; i++) {
103
+ const trimmed = nonComment[i].trim();
104
+ if (trimmed)
105
+ steps.push(trimmed);
106
+ }
107
+ if (steps.length !== count)
108
+ throw new RangeError(`Scala file declares ${count} pitches but only ${steps.length} found`);
109
+ return { name, steps };
110
+ }
111
+ const PURE_FIFTH = 3 / 2;
112
+ const SYNTONIC_COMMA = 81 / 80;
113
+ const FIFTH_TO_CHROM = [0, 7, 2, 9, 4, 11, 6, 1, 8, 3, 10, 5];
114
+ const PTOLEMAIC = {
115
+ "ptolemy-intense": ["1/1", "9/8", "5/4", "4/3", "3/2", "5/3", "15/8"],
116
+ "ptolemy-soft": ["1/1", "8/7", "80/63", "4/3", "3/2", "12/7", "40/21"],
117
+ "ptolemy-equable": ["1/1", "12/11", "6/5", "4/3", "3/2", "18/11", "9/5"],
118
+ };
119
+ export function getPtolemaicRatios(tuning) {
120
+ return PTOLEMAIC[tuning];
121
+ }
122
+ function buildPythagoreanRatios(commaN) {
123
+ const tf = commaN === 0
124
+ ? PURE_FIFTH
125
+ : PURE_FIFTH * Math.pow(1 / SYNTONIC_COMMA, Math.min(1, Math.max(0, commaN)));
126
+ const out = new Array(12);
127
+ for (let k = 0; k < 12; k++) {
128
+ const pc = FIFTH_TO_CHROM[k];
129
+ out[pc] = foldOct(tf ** k);
130
+ }
131
+ const root = out[0] ?? 1;
132
+ for (let i = 0; i < 12; i++)
133
+ out[i] = (out[i] ?? 1) / root;
134
+ return out;
135
+ }
136
+ // The natural (white-key) pitch classes in ascending pitch order: C D E F G A B.
137
+ // A diatonic-genus preset (Ptolemy's tetrachord divisions) describes the tuning
138
+ // of THIS fixed gamut, not a per-mode arrangement.
139
+ const NATURAL_PCS = [0, 2, 4, 5, 7, 9, 11];
140
+ // Expand 7 diatonic step ratios to 12 by filling chromatic gaps with pythagorean.
141
+ //
142
+ // The seven ratios are laid onto the fixed natural gamut (C D E F G A B) in
143
+ // pitch order — NOT onto the mode's scalePcs. A church mode is an octave
144
+ // species of this one gamut, so its interval qualities emerge from *where the
145
+ // final sits within the fixed tuning*, handled downstream by normalizeToRoot.
146
+ // (Mapping degree-per-mode instead would force major-scale qualities — e.g.
147
+ // a 5/4 major third above every final — onto every mode; see docs/tuning.md.)
148
+ function expandDiatonicSteps(diatonic) {
149
+ const base = buildPythagoreanRatios(0);
150
+ const out = base.slice();
151
+ for (let i = 0; i < 7 && i < diatonic.length; i++) {
152
+ out[NATURAL_PCS[i]] = foldOct(diatonic[i]);
153
+ }
154
+ return out;
155
+ }
156
+ function normalizeToRoot(ratios, rootPc) {
157
+ const pivot = ratios[rootPc] ?? 1;
158
+ return ratios.map((r) => r / pivot);
159
+ }
160
+ function ratiosToCents(ratios) {
161
+ return ratios.map((r) => 1200 * Math.log2(r));
162
+ }
163
+ // ── Public ──
164
+ export function buildRatios(opts = {}) {
165
+ const mode = opts.mode ?? 1;
166
+ const a4 = opts.a4 ?? 440;
167
+ const transpose = opts.transpose ?? 0;
168
+ const commaN = opts.comma != null ? parseComma(opts.comma) : 0;
169
+ const modeData = MODES.get(mode);
170
+ const finalisPc = modeData?.final ?? 0;
171
+ const rootPc = opts.root ?? finalisPc;
172
+ let ratios;
173
+ if (opts.steps != null) {
174
+ const parsed = opts.steps.map(parseStep);
175
+ if (parsed.length === 7) {
176
+ ratios = expandDiatonicSteps(parsed);
177
+ }
178
+ else if (parsed.length === 12) {
179
+ ratios = parsed;
180
+ }
181
+ else {
182
+ throw new RangeError(`steps must be 7 or 12 values, got ${parsed.length}`);
183
+ }
184
+ }
185
+ else {
186
+ ratios = buildPythagoreanRatios(commaN);
187
+ }
188
+ ratios = normalizeToRoot(ratios, rootPc);
189
+ const cents = ratiosToCents(ratios);
190
+ return { ratios, cents, mode, a4, comma: commaN, root: rootPc, transpose };
191
+ }
192
+ export function midiToHz(midi, scala) {
193
+ const m = Math.min(127, Math.max(0, Math.round(midi + scala.transpose)));
194
+ const pc = m % 12;
195
+ const oct = Math.floor(m / 12) - 1;
196
+ // Interval from A (pc=9) to target pc in cents, within the tuning.
197
+ // cents[] is root-normalized, and its octave folding differs by build path
198
+ // (the Pythagorean builder leaves below-root pcs negative; the diatonic-
199
+ // steps builder folds everything to [0,1200)). Place every pc in the
200
+ // C-register first — its height above pitch-class C within one octave —
201
+ // so the A→pc displacement is correct regardless of root or tuning.
202
+ const posC = (p) => {
203
+ const rel = (scala.cents[p] ?? 0) - (scala.cents[0] ?? 0);
204
+ return ((rel % 1200) + 1200) % 1200;
205
+ };
206
+ const centsFromA = posC(pc) - posC(9);
207
+ // A4 = midi 69, so A in this octave is midi (oct+1)*12 + 9 = m's octave A.
208
+ const octaveShift = oct - 4; // octaves above/below A4
209
+ const hz = scala.a4 * 2 ** (octaveShift + centsFromA / 1200);
210
+ const equalHz = scala.a4 * 2 ** ((m - 69) / 12);
211
+ const offset = 1200 * Math.log2(hz / equalHz);
212
+ const center = 8192;
213
+ const semis = offset / 100;
214
+ const bend = Math.max(0, Math.min(16383, Math.round(center + center * (semis / 2))));
215
+ return { hz, offset, bend };
216
+ }
217
+ //# sourceMappingURL=scale.js.map
@@ -0,0 +1,23 @@
1
+ import type { Scale } from "./scale.js";
2
+ export type Finger = "wrist" | "palm" | "thumb" | "index" | "middle" | "ring" | "pinky";
3
+ export type Region = "base" | "mid" | "tip" | "top";
4
+ export interface StepVariant {
5
+ hexachord: "durum" | "naturale" | "molle";
6
+ solmization: string;
7
+ }
8
+ export interface Step {
9
+ pc: number;
10
+ name: string;
11
+ nomen: string | null;
12
+ hexachord: "durum" | "naturale" | "molle" | null;
13
+ solmization: string | null;
14
+ variants: StepVariant[];
15
+ hand: {
16
+ finger: Finger;
17
+ region: Region;
18
+ } | null;
19
+ degree: number | null;
20
+ role: "finalis" | "tenor" | "other" | null;
21
+ }
22
+ export declare function toStep(midi: number, scala?: Scale): Step;
23
+ //# sourceMappingURL=step.d.ts.map
@@ -0,0 +1,53 @@
1
+ // ---------------------------------------------------------------------------
2
+ // engines/temper/step — Step type and modal/Guidonian annotation
3
+ // ---------------------------------------------------------------------------
4
+ import { MODES } from "./modes.js";
5
+ import { lookupGuido } from "./guido.js";
6
+ import { SHARP_SPELLING, FLAT_SPELLING, PREFER_FLAT_PCS } from "./data/constants.js";
7
+ // SPN letter fallback (e.g. "D" for D4, pc 2) for out-of-gamut pitches.
8
+ function spnLetterForPc(pc) {
9
+ const useFlat = PREFER_FLAT_PCS.has(pc);
10
+ const sp = useFlat ? FLAT_SPELLING[pc] : SHARP_SPELLING[pc];
11
+ return sp.step;
12
+ }
13
+ export function toStep(midi, scala) {
14
+ const mode = scala?.mode;
15
+ const guido = lookupGuido(midi, mode);
16
+ const pc = ((midi % 12) + 12) % 12;
17
+ let degree = null;
18
+ let role = null;
19
+ if (mode != null) {
20
+ const modeData = MODES.get(mode);
21
+ if (modeData) {
22
+ const idx = modeData.scalePcs.indexOf(pc);
23
+ degree = idx === -1 ? null : idx + 1;
24
+ if (degree != null) {
25
+ if (pc === modeData.final)
26
+ role = "finalis";
27
+ else if (pc === modeData.tenor)
28
+ role = "tenor";
29
+ else
30
+ role = "other";
31
+ }
32
+ }
33
+ }
34
+ const name = guido.name ?? spnLetterForPc(pc);
35
+ const hand = guido.hand
36
+ ? { finger: guido.hand.finger, region: guido.hand.region }
37
+ : null;
38
+ return {
39
+ pc,
40
+ name,
41
+ nomen: guido.nomen,
42
+ hexachord: guido.hexachord,
43
+ solmization: guido.solmization,
44
+ variants: guido.mutations.map((m) => ({
45
+ hexachord: m.hexachord,
46
+ solmization: m.solmization,
47
+ })),
48
+ hand,
49
+ degree,
50
+ role,
51
+ };
52
+ }
53
+ //# sourceMappingURL=step.js.map
@@ -0,0 +1,40 @@
1
+ import { getFeast, getPascha } from "./engines/cal/calendar.js";
2
+ import { getChants } from "./engines/chant/chant.js";
3
+ import { getPropers } from "./engines/chant/propers.js";
4
+ import { getOrdinary } from "./engines/chant/ordinary.js";
5
+ import { getHour } from "./engines/chant/hour.js";
6
+ import { getPsalm } from "./engines/chant/psalm.js";
7
+ import { buildTemper } from "./engines/temper/api.js";
8
+ import { buildScore } from "./engines/score/api.js";
9
+ import { getCosmos } from "./engines/planet/planet.js";
10
+ import { buildHarmonia } from "./engines/harmonia/api.js";
11
+ import type { FeastQuery, Feast, Pascha, Season, Grade } from "./engines/cal/types.js";
12
+ import type { CantusQuery, Chant, OrdinaryChant, PropriumQuery, OrdinariumQuery, OfficiumQuery, PsalmusQuery } from "./engines/chant/types.js";
13
+ import type { TemperamentumInput, Temperamentum, Tuning, TemperamentumOpts, Pitch, PitchInput, Step, Neume, NeumeShape, Interval, ModeData, GamutOptions, Tonus, TonusOpts } from "./engines/temper/api.js";
14
+ import type { Score, ScoreOpts, PondusInput, PondusOpts, AccentusInput, AccentusOpts, MidiOpts, MidiEmitResult, MidiJsonResult, MidiJsonEvent, MusicXmlOpts, MusicXmlEmitResult } from "./engines/score/api.js";
15
+ import type { ChantTabulaRow } from "./engines/score/tabula.js";
16
+ import type { Imprint, Attractor, VowelAttractor, ModalAffinity } from "./engines/imprint.js";
17
+ import type { Prosody, RhythmicProfile, NoteRange, CadenceDistribution } from "./engines/score/prosody.js";
18
+ import type { Harmony, HarmoniaOpts, VoicedBody, VoicedAspect, Frame, Author } from "./engines/harmonia/api.js";
19
+ import type { HarmonyTabulaRow } from "./engines/harmonia/tabula.js";
20
+ import type { PlanetVowel } from "./engines/harmonia/data/vowels.js";
21
+ import type { Note, Performance, Phrase, Syllable, RestEvent, ParseError, ArsisThesis } from "./engines/score/types.js";
22
+ import type { VoicedPitch } from "./engines/harmonia/voice.js";
23
+ import type { Cosmos, CosmosQuery, Body, BodyName, Aspect } from "./engines/planet/types.js";
24
+ declare const tonus: {
25
+ festum: typeof getFeast;
26
+ pascha: typeof getPascha;
27
+ cantus: typeof getChants;
28
+ proprium: typeof getPropers;
29
+ ordinarium: typeof getOrdinary;
30
+ officium: typeof getHour;
31
+ psalmus: typeof getPsalm;
32
+ temperamentum: typeof buildTemper;
33
+ notatio: typeof buildScore;
34
+ caelum: typeof getCosmos;
35
+ harmonia: typeof buildHarmonia;
36
+ };
37
+ export default tonus;
38
+ export { SEASON_LABELS, TEMPUS_NAMES, GRADE_ORDER, GRADE_NAMES, gradeOrder, compareGrade, ritusToGrade, } from "./engines/cal/types.js";
39
+ export type { Feast, FeastQuery, Pascha, Season, Grade, Chant, CantusQuery, OrdinaryChant, PropriumQuery, OrdinariumQuery, OfficiumQuery, PsalmusQuery, Temperamentum, TemperamentumInput, TemperamentumOpts, Tuning, Pitch, PitchInput, Step, Neume, NeumeShape, Interval, ModeData, GamutOptions, Tonus, TonusOpts, Score, ScoreOpts, PondusInput, PondusOpts, AccentusInput, AccentusOpts, MidiOpts, MidiEmitResult, MidiJsonResult, MidiJsonEvent, MusicXmlOpts, MusicXmlEmitResult, ChantTabulaRow, Note, Performance, Phrase, Syllable, RestEvent, ParseError, ArsisThesis, VoicedPitch, Cosmos, CosmosQuery, Body, BodyName, Aspect, Imprint, Attractor, VowelAttractor, ModalAffinity, Prosody, RhythmicProfile, NoteRange, CadenceDistribution, Harmony, HarmoniaOpts, VoicedBody, VoicedAspect, Frame, Author, HarmonyTabulaRow, PlanetVowel, };
40
+ //# sourceMappingURL=index.d.ts.map
package/dist/index.js ADDED
@@ -0,0 +1,27 @@
1
+ import { getFeast, getPascha } from "./engines/cal/calendar.js";
2
+ import { getChants } from "./engines/chant/chant.js";
3
+ import { getPropers } from "./engines/chant/propers.js";
4
+ import { getOrdinary } from "./engines/chant/ordinary.js";
5
+ import { getHour } from "./engines/chant/hour.js";
6
+ import { getPsalm } from "./engines/chant/psalm.js";
7
+ import { buildTemper } from "./engines/temper/api.js";
8
+ import { buildScore } from "./engines/score/api.js";
9
+ import { getCosmos } from "./engines/planet/planet.js";
10
+ import { buildHarmonia } from "./engines/harmonia/api.js";
11
+ const tonus = {
12
+ festum: getFeast,
13
+ pascha: getPascha,
14
+ cantus: getChants,
15
+ proprium: getPropers,
16
+ ordinarium: getOrdinary,
17
+ officium: getHour,
18
+ psalmus: getPsalm,
19
+ temperamentum: buildTemper,
20
+ notatio: buildScore,
21
+ caelum: getCosmos,
22
+ harmonia: buildHarmonia,
23
+ };
24
+ export default tonus;
25
+ // Reference maps and grade helpers (display strings live here, not on objects).
26
+ export { SEASON_LABELS, TEMPUS_NAMES, GRADE_ORDER, GRADE_NAMES, gradeOrder, compareGrade, ritusToGrade, } from "./engines/cal/types.js";
27
+ //# sourceMappingURL=index.js.map