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
package/dist/index.js CHANGED
@@ -12,9 +12,10 @@ import { clioEngine } from './clio/engine.js';
12
12
  import { analyzeReadingDocumentClio, renderUnifiedDisplay as clioRenderUnifiedDisplay, renderReadingView as clioRenderReadingView, renderFullLegend as clioRenderFullLegend, parseDocument as clioParseDocument, isPunctuation as clioIsPunctuation, } from './clio/pipeline.js';
13
13
  import { renderHierarchy, renderKeyStresses, } from './phonological.js';
14
14
  import { extractKeyStresses, scoreMeters, applyStanzaConsensus, applyRhythmLayer, applyMetricalityLayer } from './scansion.js';
15
- import { applyRhymeAndForm } from './rhyme.js';
16
- import { scandroidCorralWeird, scandroidMaximizeNormal, stressToMarks, } from './scandroid.js';
17
- import { renderUnifiedDisplay, renderFullLegend, renderReadingView, } from './display.js';
15
+ import { applyRhymeAndForm, isBeatTransferRhyme } from './rhyme.js';
16
+ import { scanTextNatively } from './scandroidNative/engine.js';
17
+ import { renderUnifiedDisplay, renderFullLegend, renderReadingView, fabbHalleSyllablesOf, } from './display.js';
18
+ import { buildFabbHalleGridsForPoem } from './fabbhalle.js';
18
19
  // The active prosody engine for this process: Calliope (faithful, default) or
19
20
  // Clio (the frozen legacy / alternative parse), chosen by `--clio` or the REPL
20
21
  // menu. The exported analysis functions default their `engine` parameter to
@@ -70,6 +71,51 @@ function markStanzaGivenness(docPerLine) {
70
71
  seen.add(k);
71
72
  }
72
73
  }
74
+ /**
75
+ * Stanza-level RHYME-FELLOW focus — the mirror image of givenness. The end-rhyme
76
+ * is the verse form's own focus device: a rhyme falls where the ear expects a
77
+ * beat, and the poet who closes a line on a pronoun has PUT the punchline there
78
+ * ("And he stoppeth one of THREE … Now wherefore stopp'st thou ME?" — the rhyme
79
+ * pair three/me IS the contrast pair). A weak-class line-final word therefore
80
+ * inherits beat-eligibility from a rhyme partner that carries one lexically.
81
+ * Guards (all must hold — none of this is a categorical pronoun rule):
82
+ * • only the LINE-FINAL word of a line is ever flagged (mid-line "me" untouched);
83
+ * • the word itself is FUNCTION-class (a content final already has its beat);
84
+ * • the partner is a CONTENT-class line-final in the SAME stanza — the beat is
85
+ * transferred from an attested template, never invented;
86
+ * • the rhyme is BEAT-BEARING (perfect/rich/family, or augmented/diminished —
87
+ * same stressed vowel ± one terminal consonant) and non-identical — looser
88
+ * slant hunches and mere repetition do not license promotion;
89
+ * • single lines and unrhymed finals (enjambment, free verse) are untouched.
90
+ * Mutates `rhymeFocal`; must run BEFORE the per-line relativisation (the
91
+ * relativiser gives a flagged MONOSYLLABLE the 'n' tier — a real, promotable
92
+ * beat, graded below the clause's content beats).
93
+ */
94
+ function markRhymeFellows(docPerLine) {
95
+ if (docPerLine.length < 2)
96
+ return; // single line → no scheme
97
+ const finals = docPerLine.map(sents => {
98
+ const ws = sents.flatMap(s => s.words)
99
+ .filter(w => /[a-z]/i.test(w.word) && !isPunctuation(w.lexicalClass));
100
+ return ws.length ? ws.reduce((a, b) => (b.absoluteIndex > a.absoluteIndex ? b : a)) : null;
101
+ });
102
+ for (let i = 0; i < finals.length; i++) {
103
+ const w = finals[i];
104
+ if (!w || w.isContent)
105
+ continue; // weak-class finals only
106
+ for (let j = 0; j < finals.length; j++) {
107
+ if (j === i)
108
+ continue;
109
+ const p = finals[j];
110
+ if (!p || !p.isContent)
111
+ continue; // template must be a content beat
112
+ if (isBeatTransferRhyme(w.word, p.word)) {
113
+ w.rhymeFocal = true;
114
+ break;
115
+ }
116
+ }
117
+ }
118
+ }
73
119
  /**
74
120
  * Scan one VERSE LINE (which may parse into several grammatical sentences).
75
121
  *
@@ -119,39 +165,11 @@ function processLine(sents, engine = activeEngine) {
119
165
  const phonoDetail = scoreMeters(keyStresses, words, ius);
120
166
  phonoDetail.all = renderHierarchy(ius, words);
121
167
  phonoDetail.keyStresses = renderKeyStresses(ius, words, keyStresses);
122
- // Scandroid – use the actual foot count from the phonological detection
123
- const stressPattern = words.flatMap((w) => w.syllables.map((s) => s.relativeStress ?? 'w'));
124
- const marks = stressToMarks(stressPattern);
125
- const actualFeet = phonoDetail.footCount > 0 ? phonoDetail.footCount : 5; // fallback only if unknown
126
- const corral = scandroidCorralWeird(marks, actualFeet);
127
- const max = scandroidMaximizeNormal(marks, actualFeet);
128
- const corralResult = corral.footlist.length
129
- ? {
130
- meter: 'iambic',
131
- scansion: corral.footlist.map(f => f.replace(/[()]/g, '')).join(' | '),
132
- certainty: 0,
133
- weightScore: 0,
134
- maxPossibleWeight: 0,
135
- algorithm: 'Scandroid Corral the Weird',
136
- }
137
- : undefined;
138
- const maxResult = max.footlist.length
139
- ? {
140
- meter: 'iambic',
141
- scansion: max.footlist.map(f => f.replace(/[()]/g, '')).join(' | '),
142
- certainty: 0,
143
- weightScore: 0,
144
- maxPossibleWeight: 0,
145
- algorithm: 'Scandroid Maximise the Normal',
146
- }
147
- : undefined;
148
168
  return {
149
169
  sentence: merged,
150
170
  phonologicalHierarchy: ius,
151
171
  keyStresses,
152
172
  phonologicalScansion: phonoDetail,
153
- scandroidCorral: corralResult,
154
- scandroidMaximise: maxResult,
155
173
  };
156
174
  }
157
175
  /**
@@ -194,6 +212,15 @@ function applyContinuityRename(results) {
194
212
  export function analyzeStanzas(text, useScandroid = true, engine = activeEngine) {
195
213
  const stanzas = text.split(/\n\s*\n/);
196
214
  const results = [];
215
+ // Scandroid deduces its Metron/line-length ONCE over the WHOLE document (up
216
+ // to a dozen sampled lines for a real multi-line poem; a per-line decision
217
+ // for a single typed line) — exactly Hartman's own "loaded document" vs
218
+ // "typed line" distinction — so this runs across stanza breaks, not per
219
+ // stanza. Independent of everything else computed below: raw text in,
220
+ // Scandroid's own verdict out.
221
+ const allRawLines = stanzas.flatMap(st => st.split('\n').filter(l => l.trim() !== ''));
222
+ const nativeResults = useScandroid ? scanTextNatively(allRawLines) : [];
223
+ let nativeIdx = 0;
197
224
  for (const stanza of stanzas) {
198
225
  const lines = stanza.split('\n').filter(l => l.trim() !== '');
199
226
  const stanzaResults = [];
@@ -202,8 +229,12 @@ export function analyzeStanzas(text, useScandroid = true, engine = activeEngine)
202
229
  // default (Calliope) relativiser consults it; Clio ignores the flag.
203
230
  const docs = lines.map(line => parseDocument(line));
204
231
  markStanzaGivenness(docs.map(d => d.sentences));
232
+ markRhymeFellows(docs.map(d => d.sentences));
205
233
  for (const doc of docs) {
206
234
  const res = processLine(doc.sentences, engine);
235
+ if (res && useScandroid)
236
+ res.scandroidNative = nativeResults[nativeIdx];
237
+ nativeIdx++;
207
238
  if (res)
208
239
  stanzaResults.push(res);
209
240
  }
@@ -253,14 +284,23 @@ export function analyzeText(text, useScandroid = true, engine = activeEngine) {
253
284
  export function analyzeReadingDocument(text, engine = activeEngine) {
254
285
  const stanzas = text.split(/\n\s*\n/);
255
286
  const out = [];
287
+ // See analyzeStanzas: Scandroid deduces Metron/line-length once over the
288
+ // whole document, spanning stanza breaks.
289
+ const allRawLines = stanzas.flatMap(st => st.split('\n').filter(l => l.trim() !== ''));
290
+ const nativeResults = scanTextNatively(allRawLines);
291
+ let nativeIdx = 0;
256
292
  for (const stanza of stanzas) {
257
293
  const rawLines = stanza.split('\n').filter(l => l.trim() !== '');
258
294
  if (rawLines.length === 0)
259
295
  continue;
260
296
  const rawDocs = rawLines.map(raw => ({ raw, doc: parseDocument(raw) }));
261
297
  markStanzaGivenness(rawDocs.map(rd => rd.doc.sentences));
298
+ markRhymeFellows(rawDocs.map(rd => rd.doc.sentences));
262
299
  const lines = rawDocs.map(({ raw, doc }) => {
263
300
  const res = processLine(doc.sentences, engine);
301
+ if (res)
302
+ res.scandroidNative = nativeResults[nativeIdx];
303
+ nativeIdx++;
264
304
  return { raw, results: res ? [res] : [] };
265
305
  });
266
306
  applyStanzaConsensus(lines.flatMap(l => l.results.map(r => r.phonologicalScansion)));
@@ -286,14 +326,27 @@ function showResults(text) {
286
326
  // Clio routes through its own frozen pipeline + renderer.
287
327
  const isClio = activeEngine.name === 'clio';
288
328
  const stanzas = (isClio ? analyzeReadingDocumentClio : analyzeReadingDocument)(text);
289
- const render = isClio ? clioRenderUnifiedDisplay : renderUnifiedDisplay;
329
+ // Fabb–Halle's rule set is discovered by its OWN poem-level grid
330
+ // construction over the whole document (lexical stress only), then each
331
+ // line is rendered under it — the engine's verdict is only reported for
332
+ // comparison. Clio's frozen renderer keeps its own behaviour.
333
+ let fhSchema = null;
334
+ if (!isClio) {
335
+ try {
336
+ const allResults = stanzas.flatMap(st => st.lines.flatMap(l => l.results));
337
+ fhSchema = buildFabbHalleGridsForPoem(allResults.map(fabbHalleSyllablesOf))?.schema ?? null;
338
+ }
339
+ catch { /* section falls back to stipulating the engine's meter */ }
340
+ }
290
341
  for (let s = 0; s < stanzas.length; s++) {
291
342
  if (stanzas.length > 1) {
292
343
  console.log('\n' + chalk.bold('═══ Stanza ' + (s + 1) + ' ═══'));
293
344
  }
294
345
  for (const ln of stanzas[s].lines) {
295
346
  for (const res of ln.results) {
296
- console.log(render(res, ln.raw));
347
+ console.log(isClio
348
+ ? clioRenderUnifiedDisplay(res, ln.raw)
349
+ : renderUnifiedDisplay(res, ln.raw, { fhSchema }));
297
350
  }
298
351
  }
299
352
  }
@@ -1 +1 @@
1
- {"version":3,"file":"phonological.d.ts","sourceRoot":"","sources":["../src/phonological.ts"],"names":[],"mappings":"AAGA,OAAO,EACL,WAAW,EACX,OAAO,EAGP,kBAAkB,EAClB,gBAAgB,EAChB,SAAS,EAET,oBAAoB,EACrB,MAAM,YAAY,CAAC;AAIpB;;;;;;;;GAQG;AACH,wBAAgB,0BAA0B,CACxC,QAAQ,EAAE,WAAW,GACpB,gBAAgB,EAAE,CAoBpB;AA0UD,wBAAgB,eAAe,CAAC,EAAE,EAAE,gBAAgB,GAAG,OAAO,EAAE,CAM/D;AAED,wBAAgB,eAAe,CAAC,EAAE,EAAE,kBAAkB,GAAG,OAAO,EAAE,CAMjE;AA+ED;;;GAGG;AACH,wBAAgB,eAAe,CAAC,GAAG,EAAE,gBAAgB,EAAE,EAAE,KAAK,EAAE,OAAO,EAAE,GAAG,MAAM,CAGjF;AAED;;;GAGG;AACH,wBAAgB,iBAAiB,CAC/B,GAAG,EAAE,gBAAgB,EAAE,EACvB,KAAK,EAAE,OAAO,EAAE,EAChB,WAAW,EAAE,SAAS,EAAE,GACvB,MAAM,CASR;AAsBD,MAAM,MAAM,WAAW,GAAG,MAAM,GAAG,OAAO,GAAG,SAAS,CAAC;AAEvD,2FAA2F;AAC3F,wBAAgB,aAAa,CAAC,MAAM,EAAE,MAAM,GAAG,WAAW,CAQzD;AAED,mFAAmF;AACnF,wBAAgB,oBAAoB,CAClC,SAAS,EAAE;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IAAC,aAAa,CAAC,EAAE,MAAM,CAAA;CAAE,EAAE,GACvE,WAAW,EAAE,CASf;AAsDD,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,EAAE,aAAa,EAAE,MAAM,EAAE,YAAY,CAAC,EAAE,WAAW,EAAE,EAAE,WAAW,CAAC,EAAE,MAAM,EAAE,WAAW,CAAC,EAAE,MAAM,GAAG,MAAM,EAAE,CA8RrJ;AAED;;;;;GAKG;AACH,wBAAgB,qBAAqB,CAAC,KAAK,EAAE,OAAO,EAAE,GAAG,oBAAoB,EAAE,CAwB9E"}
1
+ {"version":3,"file":"phonological.d.ts","sourceRoot":"","sources":["../src/phonological.ts"],"names":[],"mappings":"AAGA,OAAO,EACL,WAAW,EACX,OAAO,EAGP,kBAAkB,EAClB,gBAAgB,EAChB,SAAS,EAET,oBAAoB,EACrB,MAAM,YAAY,CAAC;AAIpB;;;;;;;;GAQG;AACH,wBAAgB,0BAA0B,CACxC,QAAQ,EAAE,WAAW,GACpB,gBAAgB,EAAE,CAoBpB;AA0UD,wBAAgB,eAAe,CAAC,EAAE,EAAE,gBAAgB,GAAG,OAAO,EAAE,CAM/D;AAED,wBAAgB,eAAe,CAAC,EAAE,EAAE,kBAAkB,GAAG,OAAO,EAAE,CAMjE;AA+ED;;;GAGG;AACH,wBAAgB,eAAe,CAAC,GAAG,EAAE,gBAAgB,EAAE,EAAE,KAAK,EAAE,OAAO,EAAE,GAAG,MAAM,CAGjF;AAED;;;GAGG;AACH,wBAAgB,iBAAiB,CAC/B,GAAG,EAAE,gBAAgB,EAAE,EACvB,KAAK,EAAE,OAAO,EAAE,EAChB,WAAW,EAAE,SAAS,EAAE,GACvB,MAAM,CASR;AA8BD,MAAM,MAAM,WAAW,GAAG,MAAM,GAAG,OAAO,GAAG,SAAS,CAAC;AAEvD,2FAA2F;AAC3F,wBAAgB,aAAa,CAAC,MAAM,EAAE,MAAM,GAAG,WAAW,CAQzD;AAED,mFAAmF;AACnF,wBAAgB,oBAAoB,CAClC,SAAS,EAAE;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IAAC,aAAa,CAAC,EAAE,MAAM,CAAA;CAAE,EAAE,GACvE,WAAW,EAAE,CASf;AAsDD,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,EAAE,aAAa,EAAE,MAAM,EAAE,YAAY,CAAC,EAAE,WAAW,EAAE,EAAE,WAAW,CAAC,EAAE,MAAM,EAAE,WAAW,CAAC,EAAE,MAAM,GAAG,MAAM,EAAE,CA8RrJ;AAED;;;;;GAKG;AACH,wBAAgB,qBAAqB,CAAC,KAAK,EAAE,OAAO,EAAE,GAAG,oBAAoB,EAAE,CAwB9E"}
@@ -435,7 +435,13 @@ export function renderKeyStresses(ius, words, keyStresses) {
435
435
  * Respects English phonotactics: digraphs stay together, consonants go to
436
436
  * the onset of the following syllable when they form a legal cluster.
437
437
  */
438
- const VOWEL_CHARS = new Set('aeiouyAEIOUY');
438
+ // Accented Latin vowels included: an "é" read as a consonant shifts every
439
+ // chunk boundary in the word (Milésien split Milé·si·en, parking the stress
440
+ // mark on the wrong displayed syllable).
441
+ const VOWEL_CHARS = new Set('aeiouyAEIOUY'
442
+ + 'àáâãäåæèéêëìíîïòóôõöøùúûüýÿ'
443
+ + 'ÀÁÂÃÄÅÆÈÉÊËÌÍÎÏÒÓÔÕÖØÙÚÛÜÝ'
444
+ + 'āăēĕīĭōŏūŭȳœŒ');
439
445
  const CONSONANT_DIGRAPHS = new Set(['th', 'sh', 'ch', 'wh', 'ph', 'gh', 'ck', 'ng', 'nk', 'tch', 'dge', 'sc', 'sk', 'sp', 'st']);
440
446
  // ARPABET vowels, split into "free/long" (can end a syllable → favours an OPEN
441
447
  // split: e·ven, ta·ble, o·pen) and "checked/short" (needs a coda → favours a
@@ -0,0 +1,14 @@
1
+ export interface EnRewriteOptions {
2
+ /** rhyme mode: widen the pool to family and assonant rhyme tiers. */
3
+ fuzzyRhyme?: boolean;
4
+ /** derivational-suffix + CMU-morphology-class grounding. */
5
+ morphGround?: boolean;
6
+ /** 0–4: weight of the |Δzipf| register-band penalty (0 = off). */
7
+ registerFidelity?: number;
8
+ /** use dictionary POS for source words instead of the contextual tagger. */
9
+ dictPos?: boolean;
10
+ }
11
+ /** Rewrite English verse in place — spacing, punctuation, and case survive
12
+ * (nounsing's built-ins lowercase and drop punctuation). */
13
+ export declare function rewriteEnglishText(text: string, mode: 'stress' | 'rhyme' | 'phones', posPrecision?: number, freqThreshold?: number, opts?: EnRewriteOptions): string;
14
+ //# sourceMappingURL=rewriteEn.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"rewriteEn.d.ts","sourceRoot":"","sources":["../src/rewriteEn.ts"],"names":[],"mappings":"AAuLA,MAAM,WAAW,gBAAgB;IAC/B,qEAAqE;IACrE,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,4DAA4D;IAC5D,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,kEAAkE;IAClE,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,4EAA4E;IAC5E,OAAO,CAAC,EAAE,OAAO,CAAC;CACnB;AAsKD;6DAC6D;AAC7D,wBAAgB,kBAAkB,CAChC,IAAI,EAAE,MAAM,EACZ,IAAI,EAAE,QAAQ,GAAG,OAAO,GAAG,QAAQ,EACnC,YAAY,SAAI,EAChB,aAAa,SAAI,EACjB,IAAI,GAAE,gBAAqB,GAC1B,MAAM,CAmCR"}
@@ -0,0 +1,392 @@
1
+ // rewriteEn.ts — the English Transmutation Chamber, rebuilt over nounsing-pro
2
+ // primitives with the Russian side's scored-ranking architecture.
3
+ //
4
+ // stress — candidates share the source's exact stress contour;
5
+ // rhyme — candidates rhyme with the source (perfect; or, with fuzzyRhyme,
6
+ // the wider family/assonant tiers of nounsing's rhyme typology —
7
+ // many words have one or zero perfect rhyme-mates: temperate ↔
8
+ // intemperate is the whole perfect pool);
9
+ // phones — candidates share the opening two phonemes.
10
+ //
11
+ // Gates (preserved semantics from nounsing's built-in rewrites):
12
+ // posPrecision 0–3 — Penn-tag prefix match. The SOURCE word's tag comes
13
+ // from the scansion engine's CONTEXTUAL tagger by default (parseDocument:
14
+ // UDPipe-in-context with clitic re-merging and archaic forms — "frame" in
15
+ // "Could frame thy fearful symmetry" gates as VB, not the dictionary's
16
+ // NN); candidates are dictionary-tagged (they occur in no context).
17
+ // `dictPos: true` reverts the source side to dictionary tags (fast path,
18
+ // no per-line parse).
19
+ // freqThreshold — Zipf floor (≥ 1.0 enables).
20
+ //
21
+ // Grounding score (new, composed like src/russian/rewrite.ts):
22
+ // morphGround — derivational-suffix match (-ness/-tion/-ing/-ly …)
23
+ // + the augmented CMU's morphology classes (suffixType,
24
+ // prefixType, simple/complex), + same-root avoidance;
25
+ // registerFidelity — 0–4 slider: −r·|Δzipf| penalty, so a rare word swaps
26
+ // for a similarly rare word and a common one for a
27
+ // common one (register/diction banding);
28
+ // fuzzy rhyme tiers — perfect +8 > family +5 > assonant +2, so perfect
29
+ // rhymes still win wherever they exist.
30
+ //
31
+ // Unlike nounsing's built-ins (lowercased whitespace-join), this rewriter
32
+ // replaces words in place — punctuation, spacing, and capitalisation survive.
33
+ import { lexicon, morphology, phonesForWord, stresses, search, searchStresses, rhymes, familyRhyme, assonantRhyme, } from 'nounsing-pro';
34
+ import { parseDocument, isPunctuation } from './parser.js';
35
+ import { lexicon as enLexicon } from 'en-lexicon';
36
+ import { readFileSync } from 'fs';
37
+ import { fileURLToPath } from 'url';
38
+ import { dirname, join } from 'path';
39
+ /** Words attested lowercase in Webster's 2nd (web2 ∩ lexicon, de-inflected
40
+ * at build time — tools note: regenerate src/data/en_common_words.json from
41
+ * /usr/share/dict/words if the lexicon changes; tsc does NOT copy it to
42
+ * dist/data/). The CMU is ~half proper names that carry NO tag/frequency
43
+ * marker (aaron is NN at Zipf 4.2), so dictionary-attestation is the one
44
+ * signal that separates eagle/warrior/chorus from aaron/melcher/salisbury. */
45
+ let _commonWords = null;
46
+ function commonWords() {
47
+ if (_commonWords)
48
+ return _commonWords;
49
+ try {
50
+ const p = join(dirname(fileURLToPath(import.meta.url)), 'data', 'en_common_words.json');
51
+ _commonWords = new Set(JSON.parse(readFileSync(p, 'utf8')));
52
+ }
53
+ catch {
54
+ _commonWords = new Set();
55
+ }
56
+ return _commonWords;
57
+ }
58
+ // ── Grounding helpers ─────────────────────────────────────────────────
59
+ /** English derivational suffixes, longest-first. Surface match beats the
60
+ * CMU's class-level fields for register: brightness → sweetness keeps the
61
+ * deadjectival-abstract cast even when Penn tags already agree. */
62
+ const DERIV_SUFFIXES = [
63
+ 'ability', 'ibility', 'ization', 'fulness', 'ousness',
64
+ 'ation', 'ition', 'iness', 'ingly', 'ement', 'ships',
65
+ 'ness', 'tion', 'sion', 'ment', 'able', 'ible', 'ance', 'ence',
66
+ 'ship', 'hood', 'ward', 'wise', 'less', 'ful', 'ous', 'ive',
67
+ 'ity', 'ism', 'ist', 'ize', 'ise', 'ify', 'ard', 'dom',
68
+ 'ing', 'est', 'eth', 'ess', 'ly', 'ed', 'er', 'en',
69
+ ];
70
+ const _sufCache = new Map();
71
+ function derivSuffix(word) {
72
+ const hit = _sufCache.get(word);
73
+ if (hit !== undefined)
74
+ return hit;
75
+ let out = '';
76
+ for (const s of DERIV_SUFFIXES) {
77
+ if (word.length - s.length >= 3 && word.endsWith(s)) {
78
+ out = s;
79
+ break;
80
+ }
81
+ }
82
+ _sufCache.set(word, out);
83
+ return out;
84
+ }
85
+ function morphClassOf(word) {
86
+ const m = morphology(word);
87
+ if (!m || m.length === 0)
88
+ return null;
89
+ return {
90
+ suffixType: m[0].suffixType ?? 'NA',
91
+ prefixType: m[0].prefixType ?? 'NA',
92
+ morphology: m[0].morphology ?? 'NA',
93
+ };
94
+ }
95
+ function zipfOf(word) {
96
+ const lex = lexicon(word);
97
+ if (!lex || lex.length === 0)
98
+ return null;
99
+ const f = parseFloat(lex[0].freq);
100
+ return Number.isNaN(f) ? null : f;
101
+ }
102
+ function posOf(word) {
103
+ const lex = lexicon(word);
104
+ const pos = lex?.[0]?.pos;
105
+ return pos && pos !== 'NA' ? pos.toUpperCase() : null;
106
+ }
107
+ /** Candidate-side tag SET, from the FinNLP en-lexicon (112k words, full
108
+ * Penn tag distributions: burning → NN|VBG|JJ) with nounsing's single
109
+ * first-profile tag as fallback. Two wins over first-profile-only:
110
+ * a VB source no longer rejects "frame" (NN|VBP|VB), and en-lexicon
111
+ * tags proper names as NNP (aaron, jake, kyle) where the augmented CMU
112
+ * calls them NN — the name signal no other resource carries. */
113
+ const _tagSetCache = new Map();
114
+ function tagSetOf(word) {
115
+ const hit = _tagSetCache.get(word);
116
+ if (hit)
117
+ return hit;
118
+ // Own-property + type guard: the lexicon is a plain object, so bare
119
+ // indexing with "constructor" (a real CMU word!) returns the Object
120
+ // constructor function — .toUpperCase() then crashes the cast.
121
+ const entry = Object.prototype.hasOwnProperty.call(enLexicon, word)
122
+ ? enLexicon[word] : undefined;
123
+ let out;
124
+ if (typeof entry === 'string' && entry) {
125
+ out = entry.toUpperCase().split('|');
126
+ }
127
+ else {
128
+ const p = posOf(word);
129
+ out = p ? [p] : [];
130
+ }
131
+ _tagSetCache.set(word, out);
132
+ return out;
133
+ }
134
+ /** Same-root detector: English derives at both edges (night→nightly,
135
+ * more→anymore, temperate→intemperate), so containment at either edge
136
+ * flags the pair. Applied unconditionally — a same-root cast is a
137
+ * transmutation in name only, and temperate's sole perfect rhyme is
138
+ * its own negation. */
139
+ function sameRoot(a, b) {
140
+ if (Math.min(a.length, b.length) < 3)
141
+ return false;
142
+ return a.endsWith(b) || b.endsWith(a) || a.startsWith(b) || b.startsWith(a);
143
+ }
144
+ // ── Closed-class scaffolding (active at posPrecision ≥ 1) ─────────────
145
+ //
146
+ // The Russian side's quality edge is exactly this: function words are the
147
+ // grammatical scaffolding, and swapping them through the open-class pipeline
148
+ // yields "of → would've" and "the → an call". Closed-class tags are KEPT;
149
+ // pronouns and modals — where swaps stay grammatical — transmute only
150
+ // WITHIN curated sets that respect case and possession (Penn's PRP covers
151
+ // he AND him, so the tag alone can't guard case).
152
+ const KEEP_TAGS = new Set([
153
+ 'DT', 'IN', 'CC', 'TO', 'EX', 'POS', 'RP', 'PDT',
154
+ 'WDT', 'WP', 'WP$', 'WRB', 'UH', 'CD', 'LS', 'SYM', 'FW',
155
+ ]);
156
+ const PRON_NOM = ['i', 'he', 'she', 'we', 'they', 'thou', 'ye', 'you', 'it'];
157
+ const PRON_ACC = ['me', 'him', 'her', 'us', 'them', 'thee', 'you', 'it'];
158
+ const PRON_POSS = ['my', 'thy', 'his', 'her', 'its', 'our', 'their', 'your'];
159
+ const MODALS = ['can', 'could', 'may', 'might', 'must', 'shall', 'should', 'will', 'would'];
160
+ function closedClassSet(tag, lower) {
161
+ if (tag === 'MD')
162
+ return MODALS;
163
+ if (tag === 'PRP$')
164
+ return PRON_POSS;
165
+ if (tag === 'PRP') {
166
+ if (PRON_NOM.includes(lower))
167
+ return PRON_NOM;
168
+ if (PRON_ACC.includes(lower))
169
+ return PRON_ACC;
170
+ return []; // unknown pronoun form — keep it
171
+ }
172
+ return null;
173
+ }
174
+ function matchCase(original, replacement) {
175
+ if (original === original.toUpperCase() && original.length > 1)
176
+ return replacement.toUpperCase();
177
+ if (original[0] === original[0].toUpperCase()) {
178
+ return replacement[0].toUpperCase() + replacement.slice(1);
179
+ }
180
+ return replacement;
181
+ }
182
+ const MAX_SCORED = 4000; // sample cap before scoring very large pools
183
+ function pickReplacement(mode, lower, ctxTag, posPrecision, zipfFloor, opts) {
184
+ const prons = phonesForWord(lower);
185
+ if (prons.length === 0)
186
+ return null;
187
+ // Candidate pool, with rhyme-tier tags in fuzzy rhyme mode.
188
+ let pool;
189
+ if (mode === 'stress') {
190
+ const pat = stresses(prons[0]);
191
+ if (!pat)
192
+ return null;
193
+ pool = searchStresses('^' + pat + '$').map(w => ({ word: w, tier: 0 }));
194
+ }
195
+ else if (mode === 'rhyme') {
196
+ const perfect = rhymes(lower);
197
+ if (opts.fuzzyRhyme) {
198
+ const seen = new Set(perfect);
199
+ pool = perfect.map(w => ({ word: w, tier: 8 }));
200
+ for (const w of familyRhyme(lower)) {
201
+ if (!seen.has(w)) {
202
+ seen.add(w);
203
+ pool.push({ word: w, tier: 5 });
204
+ }
205
+ }
206
+ for (const w of assonantRhyme(lower)) {
207
+ if (!seen.has(w)) {
208
+ seen.add(w);
209
+ pool.push({ word: w, tier: 2 });
210
+ }
211
+ }
212
+ }
213
+ else {
214
+ pool = perfect.map(w => ({ word: w, tier: 0 }));
215
+ }
216
+ }
217
+ else {
218
+ const first2 = prons[0].split(' ').slice(0, 2).join(' ');
219
+ pool = search('^' + first2).map(w => ({ word: w, tier: 0 }));
220
+ }
221
+ if (pool.length === 0)
222
+ return null;
223
+ // Gates (same semantics as nounsing's built-in rewrites). The source tag
224
+ // prefers the contextual parse; dictionary POS is the fallback (and the
225
+ // whole story when dictPos is set).
226
+ const srcPos = ctxTag ?? posOf(lower);
227
+ const posPrefix = posPrecision > 0 && srcPos ? srcPos.slice(0, posPrecision) : null;
228
+ // Closed-class scaffolding: prepositions, determiners, conjunctions etc.
229
+ // survive untouched; pronouns and modals transmute only within their
230
+ // curated sets — the mode constraint already lives in the pool, so
231
+ // could→should/would in rhyme mode, he→she/thee/we, thy→her/our.
232
+ if (posPrecision > 0 && srcPos) {
233
+ if (KEEP_TAGS.has(srcPos))
234
+ return null;
235
+ const set = closedClassSet(srcPos, lower);
236
+ if (set) {
237
+ const members = pool.filter(c => c.word !== lower && set.includes(c.word));
238
+ if (members.length === 0)
239
+ return null;
240
+ return members[(Math.random() * members.length) | 0].word;
241
+ }
242
+ }
243
+ // Sample very large pools before the scoring pass.
244
+ if (pool.length > MAX_SCORED) {
245
+ for (let i = pool.length - 1; i > pool.length - 1 - MAX_SCORED; i--) {
246
+ const j = (Math.random() * (i + 1)) | 0;
247
+ [pool[i], pool[j]] = [pool[j], pool[i]];
248
+ }
249
+ pool = pool.slice(pool.length - MAX_SCORED);
250
+ }
251
+ const srcSuffix = opts.morphGround ? derivSuffix(lower) : '';
252
+ const srcMorph = opts.morphGround ? morphClassOf(lower) : null;
253
+ const srcZipf = (opts.registerFidelity ?? 0) > 0 ? zipfOf(lower) : null;
254
+ const rFid = opts.registerFidelity ?? 0;
255
+ const srcNsylls = mode !== 'stress' ? (lexicon(lower)?.[0]?.nsylls ?? null) : null;
256
+ const scored = [];
257
+ for (const { word: cand, tier } of pool) {
258
+ if (cand === lower)
259
+ continue;
260
+ // Letters only, two or more: kills CMU noise (a42128), tokenisation
261
+ // shards (ca), and the apostrophe entries (morris', would've, it'll)
262
+ // that read as junk in a cast.
263
+ if (!/^[a-z][a-z]+$/.test(cand))
264
+ continue;
265
+ const candZipf = zipfOf(cand);
266
+ if (zipfFloor >= 1.0 && (candZipf === null || candZipf < zipfFloor))
267
+ continue;
268
+ // Tag-set gate: the candidate passes if ANY of its readings matches the
269
+ // source tag at the requested precision (precision 3 = EXACT tag — a
270
+ // prefix slice conflated PRP with PRP$; thy→me was a case wreck).
271
+ // Proper-noun readings are struck first when the source isn't proper:
272
+ // jake is NNP-only and vanishes; frank keeps JJ|NN|VB and stays.
273
+ let primaryMatch = false;
274
+ if (posPrefix && srcPos) {
275
+ let tags = tagSetOf(cand);
276
+ if (!srcPos.startsWith('NNP')) {
277
+ tags = tags.filter(t => !t.startsWith('NNP'));
278
+ if (tags.length === 0)
279
+ continue;
280
+ }
281
+ const match = (t) => posPrecision >= 3 ? t === srcPos : t.slice(0, posPrecision) === posPrefix;
282
+ if (!tags.some(match))
283
+ continue;
284
+ // The dominant (first-listed) reading matching is worth a bonus —
285
+ // "frame" as VB is its secondary reading, "blame" its primary.
286
+ primaryMatch = match(tags[0]);
287
+ }
288
+ let s = tier;
289
+ if (primaryMatch)
290
+ s += 2;
291
+ // Attestation penalty (not a hard filter — thin pools still cast):
292
+ // unattested candidates are overwhelmingly the CMU's untagged proper
293
+ // names. Skipped for proper-noun sources, where names are the point.
294
+ if (posPrecision > 0 && (!srcPos || !srcPos.startsWith('NNP')) && commonWords().size > 0
295
+ && !commonWords().has(cand))
296
+ s -= 7;
297
+ if (opts.morphGround) {
298
+ if (srcSuffix && derivSuffix(cand) === srcSuffix)
299
+ s += 4;
300
+ if (srcMorph) {
301
+ const cm = morphClassOf(cand);
302
+ if (cm) {
303
+ if (cm.suffixType === srcMorph.suffixType)
304
+ s += 2;
305
+ if (cm.morphology === srcMorph.morphology)
306
+ s += 1;
307
+ if (cm.prefixType === srcMorph.prefixType)
308
+ s += 1;
309
+ }
310
+ }
311
+ }
312
+ if (sameRoot(lower, cand))
313
+ s -= 6;
314
+ if (srcZipf !== null && candZipf !== null) {
315
+ // Quantised to half-Zipf bands: a continuous penalty would leave a
316
+ // unique argmax and kill the dice — candidates within a band tie,
317
+ // and the random tiebreak keeps each cast fresh.
318
+ s -= rFid * (Math.round(Math.abs(srcZipf - candZipf) * 2) / 2);
319
+ }
320
+ // Rhyme/phones pools ignore length, so meter breaks worst on long
321
+ // lines (hand→understand, frame→francisco). Prefer same-syllable
322
+ // casts without forcing them — rhyme pools can be thin.
323
+ if (srcNsylls !== null) {
324
+ const cn = lexicon(cand)?.[0]?.nsylls;
325
+ if (cn)
326
+ s -= 1.5 * Math.min(Math.abs(cn - srcNsylls), 3);
327
+ }
328
+ scored.push({ word: cand, s, r: Math.random() });
329
+ }
330
+ if (scored.length === 0)
331
+ return null;
332
+ scored.sort((a, b) => b.s - a.s || a.r - b.r);
333
+ return scored[0].word;
334
+ }
335
+ const WORD_RE = /[A-Za-z][A-Za-z'’]*/g;
336
+ /** a/an agreement post-pass: articles survive (closed class), but the noun
337
+ * after them changes, so "an call" and "a hour" need re-agreement. The
338
+ * first PHONE decides (an hour, a unicorn), with a letter fallback. */
339
+ function startsWithVowelSound(word) {
340
+ const p = phonesForWord(word.toLowerCase().replace(/[’]/g, "'"))[0];
341
+ if (p)
342
+ return /^[AEIOU]/.test(p);
343
+ return /^[aeiou]/i.test(word);
344
+ }
345
+ function fixArticles(line) {
346
+ return line.replace(/\b([Aa])(n?)(\s+)([A-Za-z][A-Za-z'’]*)/g, (_m, a, _n, sp, next) => {
347
+ return (startsWithVowelSound(next) ? a + 'n' : a) + sp + next;
348
+ });
349
+ }
350
+ /** Normalised lookup key: lowercase, straight apostrophe (parseDocument
351
+ * normalises curly apostrophes before tokenising, so keys must match). */
352
+ function keyOf(s) {
353
+ return s.toLowerCase().replace(/[‘’ʼ′]/g, "'");
354
+ }
355
+ /** Rewrite English verse in place — spacing, punctuation, and case survive
356
+ * (nounsing's built-ins lowercase and drop punctuation). */
357
+ export function rewriteEnglishText(text, mode, posPrecision = 1, freqThreshold = 0, opts = {}) {
358
+ return text.split('\n').map(line => {
359
+ if (!line.trim())
360
+ return line;
361
+ // Contextual tagging (default): one parse per line; the aligner walks
362
+ // the parsed words in order, matching surface forms within a small
363
+ // window (clitic re-merging keeps contractions whole on both sides).
364
+ let ctxWords = [];
365
+ if (posPrecision > 0 && !opts.dictPos) {
366
+ try {
367
+ ctxWords = parseDocument(line).sentences
368
+ .flatMap(s => s.words)
369
+ .filter(w => !isPunctuation(w.lexicalClass))
370
+ .map(w => ({ form: keyOf(w.word), tag: w.lexicalClass.toUpperCase() }));
371
+ }
372
+ catch { /* dictionary fallback for the whole line */ }
373
+ }
374
+ let ti = 0;
375
+ const nextTag = (surface) => {
376
+ const target = keyOf(surface);
377
+ for (let k = ti; k < Math.min(ctxWords.length, ti + 6); k++) {
378
+ if (ctxWords[k].form === target) {
379
+ ti = k + 1;
380
+ return ctxWords[k].tag;
381
+ }
382
+ }
383
+ return null;
384
+ };
385
+ return fixArticles(line.replace(WORD_RE, (surface) => {
386
+ const ctxTag = nextTag(surface);
387
+ const lower = keyOf(surface);
388
+ const repl = pickReplacement(mode, lower, ctxTag, posPrecision, freqThreshold, opts);
389
+ return repl ? matchCase(surface, repl) : surface;
390
+ }));
391
+ }).join('\n');
392
+ }