calliope-ts 0.0.4 → 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 (183) hide show
  1. package/README.md +69 -3
  2. package/dist/calliope/bracketing.d.ts.map +1 -1
  3. package/dist/calliope/bracketing.js +59 -5
  4. package/dist/calliope/deps.d.ts +5 -1
  5. package/dist/calliope/deps.d.ts.map +1 -1
  6. package/dist/calliope/deps.js +53 -3
  7. package/dist/calliope/engine.d.ts.map +1 -1
  8. package/dist/calliope/engine.js +6 -1
  9. package/dist/calliope/relstress.d.ts.map +1 -1
  10. package/dist/calliope/relstress.js +411 -15
  11. package/dist/calliope/syntax.d.ts +33 -0
  12. package/dist/calliope/syntax.d.ts.map +1 -1
  13. package/dist/calliope/syntax.js +199 -0
  14. package/dist/data/en_common_words.json +1 -0
  15. package/dist/display.d.ts +15 -1
  16. package/dist/display.d.ts.map +1 -1
  17. package/dist/display.js +112 -10
  18. package/dist/fabbhalle.d.ts +54 -0
  19. package/dist/fabbhalle.d.ts.map +1 -0
  20. package/dist/fabbhalle.js +404 -0
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +86 -33
  23. package/dist/phonological.d.ts.map +1 -1
  24. package/dist/phonological.js +7 -1
  25. package/dist/rewriteEn.d.ts +14 -0
  26. package/dist/rewriteEn.d.ts.map +1 -0
  27. package/dist/rewriteEn.js +392 -0
  28. package/dist/rhyme.d.ts +35 -3
  29. package/dist/rhyme.d.ts.map +1 -1
  30. package/dist/rhyme.js +176 -11
  31. package/dist/russian/accentuator.d.ts +31 -0
  32. package/dist/russian/accentuator.d.ts.map +1 -0
  33. package/dist/russian/accentuator.js +546 -0
  34. package/dist/russian/collocations.d.ts +7 -0
  35. package/dist/russian/collocations.d.ts.map +1 -0
  36. package/dist/russian/collocations.js +38 -0
  37. package/dist/russian/compounds.d.ts +6 -0
  38. package/dist/russian/compounds.d.ts.map +1 -0
  39. package/dist/russian/compounds.js +81 -0
  40. package/dist/russian/download.d.ts +23 -0
  41. package/dist/russian/download.d.ts.map +1 -0
  42. package/dist/russian/download.js +117 -0
  43. package/dist/russian/engine.d.ts +6 -0
  44. package/dist/russian/engine.d.ts.map +1 -0
  45. package/dist/russian/engine.js +1424 -0
  46. package/dist/russian/parser.d.ts +8 -0
  47. package/dist/russian/parser.d.ts.map +1 -0
  48. package/dist/russian/parser.js +92 -0
  49. package/dist/russian/paths.d.ts +55 -0
  50. package/dist/russian/paths.d.ts.map +1 -0
  51. package/dist/russian/paths.js +114 -0
  52. package/dist/russian/rewrite.d.ts +6 -0
  53. package/dist/russian/rewrite.d.ts.map +1 -0
  54. package/dist/russian/rewrite.js +400 -0
  55. package/dist/russian/rhyme.d.ts +18 -0
  56. package/dist/russian/rhyme.d.ts.map +1 -0
  57. package/dist/russian/rhyme.js +230 -0
  58. package/dist/russian/syllabifier.d.ts +14 -0
  59. package/dist/russian/syllabifier.d.ts.map +1 -0
  60. package/dist/russian/syllabifier.js +475 -0
  61. package/dist/russian/types.d.ts +94 -0
  62. package/dist/russian/types.d.ts.map +1 -0
  63. package/dist/russian/types.js +27 -0
  64. package/dist/scandroidNative/constants.d.ts +16 -0
  65. package/dist/scandroidNative/constants.d.ts.map +1 -0
  66. package/dist/scandroidNative/constants.js +60 -0
  67. package/dist/scandroidNative/deduceParameters.d.ts +16 -0
  68. package/dist/scandroidNative/deduceParameters.d.ts.map +1 -0
  69. package/dist/scandroidNative/deduceParameters.js +69 -0
  70. package/dist/scandroidNative/dictionary-data.d.ts +2 -0
  71. package/dist/scandroidNative/dictionary-data.d.ts.map +1 -0
  72. package/dist/scandroidNative/dictionary-data.js +744 -0
  73. package/dist/scandroidNative/dictionary.d.ts +12 -0
  74. package/dist/scandroidNative/dictionary.d.ts.map +1 -0
  75. package/dist/scandroidNative/dictionary.js +50 -0
  76. package/dist/scandroidNative/engine.d.ts +41 -0
  77. package/dist/scandroidNative/engine.d.ts.map +1 -0
  78. package/dist/scandroidNative/engine.js +119 -0
  79. package/dist/scandroidNative/machine.d.ts +93 -0
  80. package/dist/scandroidNative/machine.d.ts.map +1 -0
  81. package/dist/scandroidNative/machine.js +795 -0
  82. package/dist/scandroidNative/positioner.d.ts +32 -0
  83. package/dist/scandroidNative/positioner.d.ts.map +1 -0
  84. package/dist/scandroidNative/positioner.js +208 -0
  85. package/dist/scandroidNative/syllabizer.d.ts +3 -0
  86. package/dist/scandroidNative/syllabizer.d.ts.map +1 -0
  87. package/dist/scandroidNative/syllabizer.js +215 -0
  88. package/dist/scandroidNative/utilities.d.ts +37 -0
  89. package/dist/scandroidNative/utilities.d.ts.map +1 -0
  90. package/dist/scandroidNative/utilities.js +86 -0
  91. package/dist/scansion.d.ts.map +1 -1
  92. package/dist/scansion.js +118 -14
  93. package/dist/stress.d.ts +1 -0
  94. package/dist/stress.d.ts.map +1 -1
  95. package/dist/stress.js +502 -42
  96. package/dist/types.d.ts +3 -0
  97. package/dist/types.d.ts.map +1 -1
  98. package/package.json +27 -6
  99. package/tools/fetch-russian-data.mjs +30 -0
  100. package/webapp/public/app.js +2418 -0
  101. package/webapp/public/assets/GithubLogo.png +0 -0
  102. package/webapp/public/assets/NPM2.png +0 -0
  103. package/webapp/public/assets/fonts/MurenaBold-webfont.woff2 +0 -0
  104. package/webapp/public/assets/fonts/MurenaItalic-webfont.woff2 +0 -0
  105. package/webapp/public/assets/fonts/MurenaRegular-webfont.woff2 +0 -0
  106. package/webapp/public/assets/fonts/Myra_4F_Caps_Bold-webfont.woff2 +0 -0
  107. package/webapp/public/assets/fonts/SinkinSans-700Bold.otf +0 -0
  108. package/webapp/public/assets/fonts/butterflies.ttf +0 -0
  109. package/webapp/public/assets/fonts/midcase_blackline-webfont.woff2 +0 -0
  110. package/webapp/public/assets/fonts/neueral-bold-webfont.woff2 +0 -0
  111. package/webapp/public/assets/fonts/neueral-regular-webfont.woff2 +0 -0
  112. package/webapp/public/assets/fonts/popelka-webfont.woff2 +0 -0
  113. package/webapp/public/assets/fonts/vremenagrotesk-webfont.woff +0 -0
  114. package/webapp/public/assets/fonts/wicky_javick_bold-webfont.woff2 +0 -0
  115. package/webapp/public/assets/logos/Logo_Var_10_Celtic_Knot_SVG.svg +116 -0
  116. package/webapp/public/assets/logos/Logo_Var_11_Graph_SVG.svg +313 -0
  117. package/webapp/public/assets/logos/Logo_Var_12_Limacon_Knot_SVG.svg +3942 -0
  118. package/webapp/public/assets/logos/Logo_Var_13_Crescents_SVG.svg +1030 -0
  119. package/webapp/public/assets/logos/Logo_Var_1_Spiral_SVG.svg +1491 -0
  120. package/webapp/public/assets/logos/Logo_Var_2_Tunnel_SVG.svg +12 -0
  121. package/webapp/public/assets/logos/Logo_Var_3_Triskelion_SVG.svg +10 -0
  122. package/webapp/public/assets/logos/Logo_Var_4_Quinquetra_PNG.png +0 -0
  123. package/webapp/public/assets/logos/Logo_Var_5_Penrose_Pentagram_SVG.svg +37 -0
  124. package/webapp/public/assets/logos/Logo_Var_6_Triskelion2_SVG.svg +10 -0
  125. package/webapp/public/assets/logos/Logo_Var_7_SixfoldSpirals_SVG.svg +20 -0
  126. package/webapp/public/assets/logos/Logo_Var_8_Radial_Labyrinth_SVG.svg +163 -0
  127. package/webapp/public/assets/logos/Logo_Var_9_Radial_Labyrinth_2_SVG.svg +106 -0
  128. package/webapp/public/index.html +316 -0
  129. package/webapp/public/styles.css +1114 -0
  130. package/webapp/server.mjs +680 -0
  131. package/src/caesura.ts +0 -201
  132. package/src/calliope/boundaries.ts +0 -190
  133. package/src/calliope/bracketing.ts +0 -390
  134. package/src/calliope/deps.ts +0 -160
  135. package/src/calliope/engine.ts +0 -77
  136. package/src/calliope/feats.ts +0 -46
  137. package/src/calliope/names.ts +0 -44
  138. package/src/calliope/postag.ts +0 -253
  139. package/src/calliope/prosodic.ts +0 -262
  140. package/src/calliope/relstress.ts +0 -645
  141. package/src/calliope/stressrules.ts +0 -147
  142. package/src/calliope/syntax.ts +0 -218
  143. package/src/calliope/udpos.ts +0 -152
  144. package/src/calliope_src_contents.md +0 -19049
  145. package/src/clio/caesura.ts +0 -145
  146. package/src/clio/depfix.ts +0 -88
  147. package/src/clio/display.ts +0 -1042
  148. package/src/clio/engine.ts +0 -38
  149. package/src/clio/parser.ts +0 -845
  150. package/src/clio/phonological.ts +0 -849
  151. package/src/clio/phrasestress.ts +0 -108
  152. package/src/clio/pipeline.ts +0 -154
  153. package/src/clio/rhyme.ts +0 -740
  154. package/src/clio/scandroid.ts +0 -434
  155. package/src/clio/scansion.ts +0 -1130
  156. package/src/clio/semantics.ts +0 -134
  157. package/src/clio/stress.ts +0 -1731
  158. package/src/clio/tagfix.ts +0 -104
  159. package/src/depfix.ts +0 -88
  160. package/src/display.ts +0 -1138
  161. package/src/engine.ts +0 -22
  162. package/src/index.ts +0 -690
  163. package/src/parser.ts +0 -501
  164. package/src/phonological.ts +0 -917
  165. package/src/phrasestress.ts +0 -108
  166. package/src/rhyme.ts +0 -748
  167. package/src/scandroid.ts +0 -434
  168. package/src/scansion.ts +0 -1174
  169. package/src/semantics.ts +0 -134
  170. package/src/stress.ts +0 -2111
  171. package/src/tagfix.ts +0 -104
  172. package/src/types.ts +0 -265
  173. package/tests/DataForHayesLinesOnly.txt +0 -364
  174. package/tests/DataForHayesStressSymbolsRevised.txt +0 -728
  175. package/tests/basic.test.ts +0 -1102
  176. package/tests/bench-hayes.mjs +0 -72
  177. package/tests/epg64.meter.train.txt +0 -8139
  178. package/tests/litlab-sample-2016.txt +0 -1738
  179. package/tests/prosodic.meter.train.txt +0 -13192
  180. package/tests/wagner-stress.test.ts +0 -188
  181. package/tsconfig.json +0 -29
  182. package/vitest.config.d.ts +0 -2
  183. package/vitest.config.js +0 -14
@@ -0,0 +1,680 @@
1
+ #!/usr/bin/env node
2
+ // Calliope TS — web app server.
3
+ // Serves the static front-end from webapp/public and exposes the full analysis
4
+ // pipeline as structured JSON at POST /api/analyze. All the information the
5
+ // CLI's Reading and Detailed views print (plus the raw UDPipe / nounsing-pro
6
+ // substrates) is serialized here so the browser can render it interactively.
7
+
8
+ import * as http from 'node:http';
9
+ import * as fs from 'node:fs';
10
+ import * as path from 'node:path';
11
+ import { fileURLToPath } from 'node:url';
12
+
13
+ import { analyzeReadingDocument } from '../dist/index.js';
14
+ import { analyzeReadingDocumentClio } from '../dist/clio/pipeline.js';
15
+ import { isPunctuation } from '../dist/parser.js';
16
+ import { syllabifyWord, syllableVowelLengths, vowelLengthOf } from '../dist/phonological.js';
17
+ import { computeCaesurae } from '../dist/caesura.js';
18
+ import { buildFabbHalleGridsForPoem } from '../dist/fabbhalle.js';
19
+ import { computeBoundaries } from '../dist/calliope/boundaries.js';
20
+ import { summarizePoem, analyzePhonopoetics } from '../dist/rhyme.js';
21
+ import * as nounsing from 'nounsing-pro';
22
+
23
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
24
+ const PUBLIC_DIR = path.join(__dirname, 'public');
25
+ const PORT = Number(process.env.PORT || 4321);
26
+
27
+ // ─── Serialization helpers ──────────────────────────────────────────────
28
+
29
+ const normWordForm = (s) => s.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/[^a-z]/g, '');
30
+
31
+ /** Non-punctuation, syllable-bearing words across a line's results, in order. */
32
+ function collectLineWords(ln) {
33
+ const ws = [];
34
+ for (const res of ln.results) {
35
+ for (const w of res.sentence.words) {
36
+ if (!isPunctuation(w.lexicalClass) && w.syllables.length > 0) ws.push(w);
37
+ }
38
+ }
39
+ return ws;
40
+ }
41
+
42
+ /** Orthographic syllable chunks for a token, aligned to its word's syllables.
43
+ * Mirrors display.ts colourToken(): fast path when the syllabifier's chunks
44
+ * reconstruct the token exactly; otherwise walk the token char-by-char. */
45
+ function chunkToken(tokenText, word) {
46
+ const sylCount = Math.max(1, word.syllables.length);
47
+ const chunks = syllabifyWord(
48
+ tokenText, sylCount, syllableVowelLengths(word.syllables),
49
+ word.morphSuffix, word.morphPrefix,
50
+ );
51
+ if (chunks.join('') === tokenText) {
52
+ return chunks.map((c, i) => ({ text: c, si: Math.min(i, word.syllables.length - 1) }));
53
+ }
54
+ // Fallback: assign each original character to a syllable by chunk lengths.
55
+ const lens = chunks.map(c => c.length);
56
+ const out = [];
57
+ let ci = 0, consumed = 0;
58
+ const push = (ch, si) => {
59
+ const last = out[out.length - 1];
60
+ if (last && last.si === si) last.text += ch;
61
+ else out.push({ text: ch, si });
62
+ };
63
+ for (const ch of tokenText) {
64
+ if (ch === '-') { push(ch, Math.min(ci, word.syllables.length - 1)); continue; }
65
+ while (ci < lens.length - 1 && consumed >= lens[ci]) { ci++; consumed = 0; }
66
+ push(ch, Math.min(ci, word.syllables.length - 1));
67
+ consumed++;
68
+ }
69
+ return out;
70
+ }
71
+
72
+ /** Structural port of projectStressOntoLine(): the verbatim raw line split into
73
+ * gap segments (spaces, punctuation — emitted untouched) and word segments
74
+ * (chunked into syllables, each chunk pointing at its word + syllable). */
75
+ function buildSegments(rawLine, words) {
76
+ const tokenRe = /[A-Za-z\u00C0-\u024F]+(?:['’\-][A-Za-z\u00C0-\u024F]+)*/g; // accented Latin included (Milésien)
77
+ const segments = [];
78
+ let cursor = 0, wi = 0, m;
79
+ const pushGap = (text) => { if (text) segments.push({ t: 'gap', text }); };
80
+ while ((m = tokenRe.exec(rawLine)) !== null) {
81
+ const start = m.index, end = start + m[0].length;
82
+ pushGap(rawLine.slice(cursor, start));
83
+ cursor = end;
84
+ const token = m[0];
85
+ const tokNorm = normWordForm(token);
86
+ const matches = (w) => {
87
+ if (!w) return false;
88
+ const wn = normWordForm(w.word);
89
+ return wn.length > 0 && (tokNorm === wn || tokNorm.startsWith(wn));
90
+ };
91
+ let matched = -1;
92
+ if (matches(words[wi])) matched = wi;
93
+ else {
94
+ for (let k = wi; k < Math.min(words.length, wi + 4); k++) {
95
+ if (matches(words[k])) { matched = k; break; }
96
+ }
97
+ }
98
+ if (matched >= 0) {
99
+ segments.push({ t: 'word', w: matched, chunks: chunkToken(token, words[matched]) });
100
+ wi = matched + 1;
101
+ } else {
102
+ pushGap(token);
103
+ }
104
+ }
105
+ pushGap(rawLine.slice(cursor));
106
+ return segments;
107
+ }
108
+
109
+ /** Fine-grained syllable phonology derived from the ARPAbet transcription:
110
+ * onset geometry, nucleus type, rime structure, coda status. */
111
+ const ARPA_VOWEL = /^(AA|AE|AH|AO|AW|AY|EH|ER|EY|IH|IY|OW|OY|UH|UW)/;
112
+ const ARPA_DIPH = new Set(['AY', 'AW', 'OY', 'EY', 'OW']);
113
+ function sylPhonology(phonesStr) {
114
+ const toks = String(phonesStr ?? '').replace(/[()]/g, '').trim().split(/\s+/).filter(Boolean);
115
+ const vIdx = toks.findIndex(t => ARPA_VOWEL.test(t));
116
+ if (vIdx < 0) return null;
117
+ const onsetN = vIdx;
118
+ const codaN = toks.length - vIdx - 1;
119
+ const vowel = toks[vIdx].replace(/\d/g, '');
120
+ const isDiph = ARPA_DIPH.has(vowel);
121
+ let long = isDiph;
122
+ try { if (vowelLengthOf(phonesStr) === 'long') long = true; } catch { /* keep */ }
123
+ const rime = codaN === 0 ? (long ? '-VV' : '-V')
124
+ : long ? (codaN >= 2 ? '-TCC' : '-TC')
125
+ : (codaN >= 2 ? '-LCC' : '-LC');
126
+ return {
127
+ onset: onsetN === 0 ? '0' : 'C'.repeat(Math.min(3, onsetN)),
128
+ nucleus: isDiph ? 'diphthong' : 'monophthong',
129
+ vlen: long ? 'long' : 'short',
130
+ codaC: codaN,
131
+ open: codaN === 0,
132
+ rime,
133
+ };
134
+ }
135
+ const SYL_POSITION = ['final', 'penult', 'antepenult', 'preantepenult'];
136
+
137
+ function serializeWord(w, idx, firstSyl) {
138
+ const dep = w.dependency;
139
+ // Orthographic syllable chunks for display (syl.text from the dictionary is
140
+ // phone-based); group the token chunking by syllable index.
141
+ const display = w.displayWord ?? w.word;
142
+ const orth = new Array(w.syllables.length).fill('');
143
+ try {
144
+ for (const c of chunkToken(display, w)) orth[c.si] = (orth[c.si] ?? '') + c.text;
145
+ } catch { /* fall back to phone text below */ }
146
+ return {
147
+ i: idx,
148
+ text: w.displayWord ?? w.word,
149
+ norm: w.word,
150
+ pos: w.lexicalClass,
151
+ isContent: !!w.isContent,
152
+ phraseStress: w.phraseStress ?? 0,
153
+ feats: w.featsMap && Object.keys(w.featsMap).length ? w.featsMap : null,
154
+ canonicalRel: w.canonicalRel ?? null,
155
+ dep: dep ? {
156
+ rel: dep.dependentType,
157
+ isRoot: dep.governorIndex === 0 || dep.dependentType === 'root',
158
+ govWord: dep.governor?.word ?? null,
159
+ govIndex: dep.governorIndex,
160
+ } : null,
161
+ flags: {
162
+ person: !!w.isPersonName,
163
+ place: !!w.isPlaceName,
164
+ given: !!w.discourseGiven,
165
+ coordGiven: !!w.coordinateGiven,
166
+ },
167
+ morph: (w.morphPrefix || w.morphSuffix)
168
+ ? { prefix: w.morphPrefix ?? null, suffix: w.morphSuffix ?? null } : null,
169
+ firstSyl,
170
+ syls: w.syllables.map((s, si) => ({
171
+ si,
172
+ text: orth[si] || s.text,
173
+ phones: s.phones,
174
+ weight: s.weight ?? null,
175
+ lex: s.lexicalStress ?? s.stress,
176
+ boosted: s.stress,
177
+ rel: s.relativeStress ?? 'w',
178
+ extrametrical: s.extrametrical ?? null,
179
+ pos: SYL_POSITION[Math.min(3, w.syllables.length - 1 - si)],
180
+ ph: sylPhonology(s.phones),
181
+ })),
182
+ };
183
+ }
184
+
185
+ /** Map each display word to its IU.PP.CP membership in the prosodic hierarchy. */
186
+ function hierarchyMembership(ius, words) {
187
+ const loc = new Map();
188
+ ius.forEach((iu, ii) =>
189
+ iu.phonologicalPhrases.forEach((pp, pi) =>
190
+ pp.cliticGroups.forEach((cg, ci) =>
191
+ cg.tokens.forEach(t => loc.set(t, { iu: ii, pp: pi, cp: ci })))));
192
+ return words.map(w => loc.get(w) ?? null);
193
+ }
194
+
195
+ /** Serialize the hierarchy as nested word-index lists (IU → PP → CP). */
196
+ function serializeHierarchy(ius, words) {
197
+ const indexOf = new Map(words.map((w, i) => [w, i]));
198
+ return ius.map(iu => ({
199
+ pps: iu.phonologicalPhrases.map(pp => ({
200
+ cps: pp.cliticGroups.map(cg =>
201
+ cg.tokens.map(t => indexOf.get(t)).filter(i => i !== undefined)),
202
+ })),
203
+ }));
204
+ }
205
+
206
+ /** Align scansion feet to global syllable indices (display.ts Layer 6 logic). */
207
+ function serializeFeet(scansion, totalSyls) {
208
+ const isSylChar = (ch) => 'xXwWnNmMsS'.includes(ch);
209
+ const feet = [];
210
+ let sylIdx = 0;
211
+ for (const rawFoot of scansion.split('|')) {
212
+ const cells = [];
213
+ for (const ch of rawFoot) {
214
+ if (ch === '-') { cells.push({ silent: true }); continue; }
215
+ if (!isSylChar(ch)) continue;
216
+ if (sylIdx < totalSyls) cells.push({ s: sylIdx++, mark: ch.toLowerCase() });
217
+ }
218
+ feet.push({ pattern: rawFoot, cells });
219
+ }
220
+ return feet;
221
+ }
222
+
223
+ function serializeDetail(d) {
224
+ if (!d) return null;
225
+ return {
226
+ meter: d.meter,
227
+ meterName: d.meterName,
228
+ footCount: d.footCount,
229
+ scansion: d.scansion,
230
+ certainty: d.certainty,
231
+ summary: d.summary,
232
+ ranking: (d.ranking ?? []).map(r => ({ meter: r.meter, score: Number(r.score.toFixed(3)) })),
233
+ standaloneMeter: d.standaloneMeter ?? null,
234
+ consensusMeter: d.consensusMeter ?? null,
235
+ rhythmNote: d.rhythmNote ?? null,
236
+ metricalityNote: d.metricalityNote ?? null,
237
+ formNote: d.formNote ?? null,
238
+ rhyme: d.rhyme ? {
239
+ endWord: d.rhyme.endWord,
240
+ letter: d.rhyme.letter,
241
+ type: d.rhyme.type ?? null,
242
+ matchedLine: d.rhyme.matchedLine ?? null,
243
+ notation: d.rhyme.notation ?? null,
244
+ internal: (d.rhyme.internal ?? []).map(iw => ({
245
+ word: iw.word, letter: iw.letter, type: iw.type ?? null,
246
+ })),
247
+ } : null,
248
+ };
249
+ }
250
+
251
+ function serializeLine(ln) {
252
+ const res = ln.results[0];
253
+ if (!res) return { raw: ln.raw, parsed: false };
254
+
255
+ const words = collectLineWords(ln);
256
+ let firstSyl = 0;
257
+ const serWords = words.map((w, i) => {
258
+ const sw = serializeWord(w, i, firstSyl);
259
+ firstSyl += w.syllables.length;
260
+ return sw;
261
+ });
262
+ const totalSyls = firstSyl;
263
+
264
+ // Flat global syllable list (word order — the scansion domain's order).
265
+ const syllables = [];
266
+ serWords.forEach(sw => sw.syls.forEach(s => syllables.push({
267
+ w: sw.i, si: s.si, text: s.text, phones: s.phones, weight: s.weight,
268
+ lex: s.lex, rel: s.rel,
269
+ })));
270
+
271
+ const ius = res.phonologicalHierarchy;
272
+ const allWords = res.sentence.words;
273
+
274
+ let boundaries = null;
275
+ try {
276
+ const b = computeBoundaries(allWords, ius);
277
+ boundaries = {
278
+ phi: b.phi.map(x => ({ strength: x.strength, syl: x.syllableIndex })),
279
+ iota: b.iota.map(x => ({ strength: x.strength, syl: x.syllableIndex })),
280
+ };
281
+ } catch { /* clio lines may lack substrate for graded boundaries */ }
282
+
283
+ let caesurae = [];
284
+ try {
285
+ caesurae = [...computeCaesurae(allWords, ius, res.phonologicalScansion.scansion)]
286
+ .map(([after, info]) => ({ after, kind: info.kind, strength: info.strength }));
287
+ } catch { /* non-fatal */ }
288
+
289
+ const deps = serWords
290
+ .filter(sw => sw.dep)
291
+ .map(sw => {
292
+ const govIdx = sw.dep.isRoot ? -1
293
+ : serWords.findIndex(o => words[o.i].index === sw.dep.govIndex
294
+ && words[o.i] === (words[sw.i].dependency?.governor ?? null));
295
+ // Prefer object identity; fall back to name match when identity fails.
296
+ let to = govIdx;
297
+ if (to === -1 && !sw.dep.isRoot) {
298
+ to = words.findIndex(w => w === words[sw.i].dependency?.governor);
299
+ }
300
+ return { from: sw.i, to: sw.dep.isRoot ? -1 : to, rel: sw.dep.rel };
301
+ });
302
+
303
+ return {
304
+ raw: ln.raw,
305
+ parsed: true,
306
+ segments: buildSegments(ln.raw, words),
307
+ words: serWords,
308
+ syllables,
309
+ hierarchy: serializeHierarchy(ius, words),
310
+ unitOf: hierarchyMembership(ius, words),
311
+ boundaries,
312
+ caesurae,
313
+ keyStresses: (res.keyStresses ?? []).map(k => ({
314
+ unitType: k.unitType, pattern: k.pattern, weight: k.weight, positions: k.positions,
315
+ })),
316
+ feet: serializeFeet(res.phonologicalScansion.scansion, totalSyls),
317
+ detail: serializeDetail(res.phonologicalScansion),
318
+ // Charles Hartman's Scandroid (2005), run as a fully independent second
319
+ // opinion: its own dictionary, syllabifier, and algorithms, working only
320
+ // from the raw line text.
321
+ scandroid: (() => {
322
+ const sn = res.scandroidNative;
323
+ if (!sn) return null;
324
+ const serFoot = f => f ? {
325
+ algorithm: f.algorithm, scanString: f.scanString, marksString: f.marksString,
326
+ substitutions: f.substitutions, ok: f.ok, failReason: f.failReason ?? null,
327
+ } : null;
328
+ return {
329
+ metronName: sn.metronName,
330
+ lineLengthName: sn.lineLengthName,
331
+ lineFeetSet: sn.lineFeetSet,
332
+ verdict: serFoot(sn.verdict),
333
+ corralTheWeird: serFoot(sn.corralTheWeird ?? null),
334
+ maximizeTheNormal: serFoot(sn.maximizeTheNormal ?? null),
335
+ promotions: sn.promotions,
336
+ };
337
+ })(),
338
+ // fabbHalle is attached poem-level in analyze() — F&H's rule set is
339
+ // DISCOVERED by grid construction over the whole poem, not stipulated
340
+ // per line (see attachFabbHalle below).
341
+ fabbHalle: null,
342
+ deps,
343
+ };
344
+ }
345
+
346
+ /** Fabb–Halle bracketed grids (Meter in Poetry, 2008) — an independent
347
+ * second opinion whose rule set is discovered by F&H's own poem-level
348
+ * procedure (buildFabbHalleGridsForPoem) from lexical stress alone; the
349
+ * main engine's meter verdict is reported alongside for comparison but
350
+ * never consulted. */
351
+ function attachFabbHalle(resultStanzas) {
352
+ try {
353
+ const lines = resultStanzas.flatMap(st => st.lines).filter(l => l.parsed);
354
+ const allFhSyls = lines.map(line => {
355
+ const perWord = new Map();
356
+ line.syllables.forEach(s => perWord.set(s.w, (perWord.get(s.w) ?? 0) + 1));
357
+ return line.syllables.map(s => {
358
+ const poly = (perWord.get(s.w) ?? 1) > 1;
359
+ // Monosyllabic function words are clitics — no lexical stress in
360
+ // F&H's sense (they never block a neighbour's maximumhood).
361
+ const cliticMono = !poly && line.words[s.w] && !line.words[s.w].isContent;
362
+ return { text: s.text, lex: cliticMono ? 0 : (s.lex ?? 0), poly };
363
+ });
364
+ });
365
+ const poem = buildFabbHalleGridsForPoem(allFhSyls);
366
+ if (!poem) return null;
367
+ lines.forEach((line, i) => {
368
+ const fh = poem.grids[i];
369
+ const engineMeter = line.detail?.meter ?? null;
370
+ const agrees = engineMeter
371
+ ? (engineMeter.includes(poem.schema)
372
+ || (poem.schema === 'loose' && /accentual|loose|dolnik|free/i.test(engineMeter)))
373
+ : null;
374
+ line.fabbHalle = fh ? {
375
+ rule: fh.ruleLabel
376
+ + ' · poem-discovered by grid construction'
377
+ + (agrees === null ? '' : agrees ? ', agreeing with the engine' : ` (the engine read ${engineMeter})`),
378
+ rows: fh.rows,
379
+ maxima: fh.maxima, violations: fh.violations, metrical: fh.metrical,
380
+ looseFeet: fh.looseFeet ?? null,
381
+ schema: poem.schema,
382
+ } : null;
383
+ });
384
+ return poem.schema;
385
+ } catch { return null; }
386
+ }
387
+
388
+ /** Enjambment summary — port of display.ts summariseEnjambment(). */
389
+ function lineRunsOn(raw) {
390
+ const t = raw.replace(/["'’”»)\]]+$/, '').trimEnd();
391
+ if (!t) return false;
392
+ return !/[.!?;:,—–…]$/.test(t);
393
+ }
394
+ function summariseEnjambment(stanzas) {
395
+ const raws = stanzas.flatMap(st => st.lines.map(l => l.raw));
396
+ if (raws.length < 2) return null;
397
+ const interior = raws.slice(0, -1);
398
+ const enjambed = [];
399
+ interior.forEach((r, i) => { if (lineRunsOn(r)) enjambed.push(i + 1); });
400
+ const n = interior.length, k = enjambed.length;
401
+ if (k === 0) return 'end-stopped throughout';
402
+ const where = k <= 6 ? ' (lines ' + enjambed.join(', ') + ')' : '';
403
+ return k >= Math.ceil(n / 2)
404
+ ? `predominantly enjambed — ${k} of ${n} line-ends run on${where}`
405
+ : `mostly end-stopped — ${k} of ${n} line-ends enjambed${where}`;
406
+ }
407
+
408
+ function analyze(text, engine) {
409
+ const t0 = Date.now();
410
+ const stanzas = engine === 'clio'
411
+ ? analyzeReadingDocumentClio(text)
412
+ : analyzeReadingDocument(text);
413
+
414
+ const resultStanzas = stanzas.map(st => {
415
+ const firstDetail = st.lines.flatMap(l => l.results)[0]?.phonologicalScansion;
416
+ return {
417
+ formNote: firstDetail?.formNote ?? null,
418
+ lines: st.lines.map(serializeLine),
419
+ };
420
+ });
421
+ const fabbHalleMeter = attachFabbHalle(resultStanzas);
422
+
423
+ const grouped = stanzas.map(st => st.lines.flatMap(l => l.results));
424
+ let synopsis = [];
425
+ let phonopoetics = null;
426
+ try { synopsis = summarizePoem(grouped); } catch { /* non-fatal */ }
427
+ try {
428
+ const p = analyzePhonopoetics(grouped);
429
+ phonopoetics = {
430
+ endScheme: p.endScheme,
431
+ end: p.end, caesural: p.caesural, head: p.head,
432
+ alliteration: p.alliteration, acrostics: p.acrostics,
433
+ };
434
+ } catch { /* non-fatal */ }
435
+
436
+ return {
437
+ engine: engine === 'clio' ? 'clio' : 'calliope',
438
+ elapsedMs: Date.now() - t0,
439
+ stanzas: resultStanzas,
440
+ synopsis,
441
+ enjambment: summariseEnjambment(stanzas),
442
+ phonopoetics,
443
+ fabbHalleMeter,
444
+ };
445
+ }
446
+
447
+ /** The Nounsing Pro dossier for one word: deep morpho-phonological data from
448
+ * the augmented CMU lexicon. Every probe is optional — OOV words return
449
+ * whatever survives. */
450
+ function wordDossier(raw) {
451
+ const word = String(raw).toLowerCase().replace(/[^a-z'’\-]/g, '').replace(/’/g, "'");
452
+ const first = (fn) => { try { const r = fn(word); return Array.isArray(r) ? r[0] ?? null : r ?? null; } catch { return null; } };
453
+ const insetsRaw = first(nounsing.metricalInsets);
454
+ // keep only foot types actually found inside the word
455
+ const insets = insetsRaw
456
+ ? Object.fromEntries(Object.entries(insetsRaw).filter(([, v]) => v?.length))
457
+ : null;
458
+ let rhymes = [];
459
+ try { rhymes = nounsing.rhymes(word).slice(0, 18); } catch { /* OOV */ }
460
+ return {
461
+ word,
462
+ lexicon: first(nounsing.lexicon), // freq, pos, nsylls
463
+ phonemics: first(nounsing.phonemics), // phones, syllStruct, syllabification, vowelLength
464
+ scansion: first(nounsing.scansion), // contour, label ("dactylic"…), weightPattern
465
+ weights: first(nounsing.weights), // pattern[HL], details
466
+ vowels: first(nounsing.vowels),
467
+ vowelQualities: first(nounsing.vowelQualities), // diphthongs vs monophthongs
468
+ edges: first(nounsing.edges), // onset/coda geometry
469
+ onsetParse: first(nounsing.onsetParse), // CV structure, maximal onsets
470
+ morphology: first(nounsing.morphology), // prefix/suffix types, extrametrical S
471
+ suffixShift: first(nounsing.suffixShiftPotential),
472
+ extrametricals: first(nounsing.extrametricals),
473
+ rhymeProfile: first(nounsing.rhymeProfile),
474
+ codaComplexity: first(nounsing.codaComplexity),
475
+ insets, // feet hiding inside the word
476
+ rhymes, // strict perfect rhymes
477
+ };
478
+ }
479
+
480
+ // ─── HTTP plumbing ──────────────────────────────────────────────────────
481
+
482
+ const MIME = {
483
+ '.html': 'text/html; charset=utf-8',
484
+ '.css': 'text/css; charset=utf-8',
485
+ '.js': 'text/javascript; charset=utf-8',
486
+ '.mjs': 'text/javascript; charset=utf-8',
487
+ '.json': 'application/json; charset=utf-8',
488
+ '.svg': 'image/svg+xml',
489
+ '.png': 'image/png',
490
+ '.ico': 'image/x-icon',
491
+ '.woff2': 'font/woff2',
492
+ '.woff': 'font/woff',
493
+ '.ttf': 'font/ttf',
494
+ '.otf': 'font/otf',
495
+ '.txt': 'text/plain; charset=utf-8',
496
+ };
497
+
498
+ function serveStatic(req, res) {
499
+ let urlPath = decodeURIComponent(new URL(req.url, 'http://x').pathname);
500
+ if (urlPath === '/') urlPath = '/index.html';
501
+ const filePath = path.join(PUBLIC_DIR, path.normalize(urlPath));
502
+ if (!filePath.startsWith(PUBLIC_DIR)) {
503
+ res.writeHead(403); res.end('Forbidden'); return;
504
+ }
505
+ fs.readFile(filePath, (err, data) => {
506
+ if (err) { res.writeHead(404); res.end('Not found'); return; }
507
+ res.writeHead(200, { 'Content-Type': MIME[path.extname(filePath)] ?? 'application/octet-stream' });
508
+ res.end(data);
509
+ });
510
+ }
511
+
512
+ function readBody(req) {
513
+ return new Promise((resolve, reject) => {
514
+ let body = '';
515
+ req.on('data', c => {
516
+ body += c;
517
+ if (body.length > 512 * 1024) { reject(new Error('payload too large')); req.destroy(); }
518
+ });
519
+ req.on('end', () => resolve(body));
520
+ req.on('error', reject);
521
+ });
522
+ }
523
+
524
+ const server = http.createServer(async (req, res) => {
525
+ if (req.method === 'POST' && req.url === '/api/analyze') {
526
+ try {
527
+ const body = await readBody(req);
528
+ const { text, engine } = JSON.parse(body || '{}');
529
+ if (!text || typeof text !== 'string' || !text.trim()) {
530
+ res.writeHead(400, { 'Content-Type': 'application/json' });
531
+ res.end(JSON.stringify({ error: 'No text supplied.' }));
532
+ return;
533
+ }
534
+ const lineCount = text.split('\n').filter(l => l.trim()).length;
535
+ if (lineCount > 200) {
536
+ res.writeHead(400, { 'Content-Type': 'application/json' });
537
+ res.end(JSON.stringify({ error: 'Poem too long — please keep it under 200 lines per request.' }));
538
+ return;
539
+ }
540
+ // Auto-detect Russian text and route to the Russian pipeline
541
+ const cyrillic = (text.match(/[А-Яа-яЁё]/g) || []).length;
542
+ const latin = (text.match(/[A-Za-z]/g) || []).length;
543
+ if (cyrillic > latin && cyrillic > 0 && engine !== 'calliope' && engine !== 'clio') {
544
+ try {
545
+ const { analyzeRussianPoem } = await import('../dist/russian/engine.js');
546
+ const t0 = Date.now();
547
+ const ruResult = await analyzeRussianPoem(text);
548
+ res.writeHead(200, { 'Content-Type': 'application/json' });
549
+ res.end(JSON.stringify({ engine: 'russian', elapsedMs: Date.now() - t0, ...ruResult }));
550
+ return;
551
+ } catch (ruErr) {
552
+ console.error('russian auto-detect failed:', ruErr);
553
+ // Fall through to English analysis
554
+ }
555
+ }
556
+ const result = analyze(text, engine);
557
+ res.writeHead(200, { 'Content-Type': 'application/json' });
558
+ res.end(JSON.stringify(result));
559
+ } catch (err) {
560
+ console.error('analyze failed:', err);
561
+ res.writeHead(500, { 'Content-Type': 'application/json' });
562
+ res.end(JSON.stringify({ error: 'Analysis failed: ' + (err?.message ?? String(err)) }));
563
+ }
564
+ return;
565
+ }
566
+ if (req.method === 'GET' && req.url.startsWith('/api/word')) {
567
+ try {
568
+ const params = new URL(req.url, 'http://x').searchParams;
569
+ const w = params.get('w') ?? '';
570
+ if (!w.trim()) { res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'no word' })); return; }
571
+ const dossier = wordDossier(w);
572
+ const syll = Number(params.get('syll'));
573
+ if (syll >= 1 && syll <= 8) {
574
+ try { dossier.rhymesBySyll = nounsing.rhymeBySyllables(dossier.word, syll).slice(0, 60); }
575
+ catch { dossier.rhymesBySyll = []; }
576
+ }
577
+ res.writeHead(200, { 'Content-Type': 'application/json' });
578
+ res.end(JSON.stringify(dossier));
579
+ } catch (err) {
580
+ res.writeHead(500, { 'Content-Type': 'application/json' });
581
+ res.end(JSON.stringify({ error: String(err?.message ?? err) }));
582
+ }
583
+ return;
584
+ }
585
+ if (req.method === 'GET' && req.url.startsWith('/api/meter')) {
586
+ try {
587
+ const params = new URL(req.url, 'http://x').searchParams;
588
+ const pattern = (params.get('pattern') ?? '').replace(/[^012]/g, '');
589
+ if (!pattern) { res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'pattern must be digits 0/1/2, e.g. 010' })); return; }
590
+ let words = [];
591
+ try { words = nounsing.meterMatch(pattern); } catch { /* none */ }
592
+ // a stable-but-varied sample: shuffle then cap
593
+ for (let i = words.length - 1; i > 0; i--) { const j = (Math.random() * (i + 1)) | 0; [words[i], words[j]] = [words[j], words[i]]; }
594
+ res.writeHead(200, { 'Content-Type': 'application/json' });
595
+ res.end(JSON.stringify({ pattern, total: words.length, words: words.slice(0, 72) }));
596
+ } catch (err) {
597
+ res.writeHead(500, { 'Content-Type': 'application/json' });
598
+ res.end(JSON.stringify({ error: String(err?.message ?? err) }));
599
+ }
600
+ return;
601
+ }
602
+ if (req.method === 'POST' && req.url === '/api/rewrite') {
603
+ try {
604
+ const body = JSON.parse(await readBody(req) || '{}');
605
+ const text = String(body.text ?? '').slice(0, 4000);
606
+ if (!text.trim()) { res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'No text supplied.' })); return; }
607
+ const pos = Math.max(0, Math.min(3, Number(body.posPrecision ?? 1)));
608
+ const freq = Math.max(0, Math.min(4, Number(body.freqThreshold ?? 0)));
609
+ const mode = body.mode;
610
+ const fuzzy = Boolean(body.fuzzyRhyme);
611
+ const morphGround = Boolean(body.morphGround);
612
+ const registerFidelity = Math.max(0, Math.min(4, Number(body.registerFidelity ?? 0)));
613
+ const dictPos = Boolean(body.dictPos);
614
+ // Cyrillic verse transmutes through the Russian engine (stress
615
+ // dictionary + poetry-corpus frequency table standing in for Zipf).
616
+ const cyr = (text.match(/[А-Яа-яЁё]/g) || []).length;
617
+ const lat = (text.match(/[A-Za-z]/g) || []).length;
618
+ let output;
619
+ if (cyr > lat && cyr > 0) {
620
+ const { rewriteRussianText } = await import('../dist/russian/rewrite.js');
621
+ const ruMode = mode === 'stress' || mode === 'rhyme' || mode === 'phones' ? mode : 'phones';
622
+ output = await rewriteRussianText(text, ruMode, pos, freq, fuzzy);
623
+ } else {
624
+ // Scored-ranking rewriter over nounsing-pro primitives — morphemic
625
+ // and register grounding, fuzzy rhyme tiers, punctuation preserved.
626
+ const { rewriteEnglishText } = await import('../dist/rewriteEn.js');
627
+ const enMode = mode === 'stress' || mode === 'rhyme' || mode === 'phones' ? mode : 'phones';
628
+ output = rewriteEnglishText(text, enMode, pos, freq,
629
+ { fuzzyRhyme: fuzzy, morphGround, registerFidelity, dictPos });
630
+ }
631
+ res.writeHead(200, { 'Content-Type': 'application/json' });
632
+ res.end(JSON.stringify({ output }));
633
+ } catch (err) {
634
+ res.writeHead(500, { 'Content-Type': 'application/json' });
635
+ res.end(JSON.stringify({ error: String(err?.message ?? err) }));
636
+ }
637
+ return;
638
+ }
639
+ if (req.method === 'POST' && req.url === '/api/russian') {
640
+ try {
641
+ const body = await readBody(req);
642
+ const { text } = JSON.parse(body || '{}');
643
+ if (!text || typeof text !== 'string' || !text.trim()) {
644
+ res.writeHead(400, { 'Content-Type': 'application/json' });
645
+ res.end(JSON.stringify({ error: 'No text supplied.' }));
646
+ return;
647
+ }
648
+ const lineCount = text.split('\n').filter(l => l.trim()).length;
649
+ if (lineCount > 200) {
650
+ res.writeHead(400, { 'Content-Type': 'application/json' });
651
+ res.end(JSON.stringify({ error: 'Poem too long — please keep it under 200 lines per request.' }));
652
+ return;
653
+ }
654
+ const { analyzeRussianPoem } = await import('../dist/russian/engine.js');
655
+ const t0 = Date.now();
656
+ const result = await analyzeRussianPoem(text);
657
+ res.writeHead(200, { 'Content-Type': 'application/json' });
658
+ res.end(JSON.stringify({ engine: 'russian', elapsedMs: Date.now() - t0, ...result }));
659
+ } catch (err) {
660
+ console.error('russian analyze failed:', err);
661
+ res.writeHead(500, { 'Content-Type': 'application/json' });
662
+ res.end(JSON.stringify({ error: 'Russian analysis failed: ' + (err?.message ?? String(err)) }));
663
+ }
664
+ return;
665
+ }
666
+ if (req.method === 'GET' && req.url === '/api/health') {
667
+ res.writeHead(200, { 'Content-Type': 'application/json' });
668
+ res.end(JSON.stringify({ ok: true }));
669
+ return;
670
+ }
671
+ if (req.method === 'GET' || req.method === 'HEAD') { serveStatic(req, res); return; }
672
+ res.writeHead(405); res.end();
673
+ });
674
+
675
+ server.listen(PORT, () => {
676
+ console.log(`Calliope web listening on http://localhost:${PORT}`);
677
+ // Warm the UDPipe model so the first user request is fast.
678
+ try { analyze('Shall I compare thee to a summer’s day?', 'calliope'); console.log('pipeline warm'); }
679
+ catch (e) { console.error('warm-up failed:', e); }
680
+ });